empowered.house ↗

chat/bubbles.ex

Bubbles

A conversation in bubbles between a customer and the team, ending with a typing indicator.

Use it
# in the module that renders the template
use EHVisuals

<div class="h-96 rounded-xl border border-border">
  <.chat_bubbles animated />
</div>
default
isometric
custom conversation · the customer is typing
nobody typing · hover · no glow
light surface
isometric · fade out · still

Props

Prop Type Default What it does
customer string "Piotr Zielinski"
meta string "Northwind · PO-2291"
team string "Marta · Support" The name over the team's bubbles.
messages list [%{text: "PO-2291 was due Tuesday. Any news?", from: "customer"}, %{text: "It left the warehouse this morning.", from: "team"}, ...] Up to six `%{from, text}`, oldest first. `from` is `customer` or `team`. The text wraps at 36 characters, up to three lines.
typing "team" | "customer" | "none" "team" Who is typing in the last bubble.
Frame props, shared by every composition
Prop Type Default What it does
animated boolean false Turns motion on. Without it the composition renders in its final, still state.
trigger "mount" | "in-view" | "in-view-repeat" "in-view" When the entrance plays: on page load, once when scrolled into view, or every time it comes back into view.
hover boolean false Enables the hover micro-interactions the composition supports.
glow boolean true The warm incandescent halo behind the composition.
fade_out boolean false Fades the bottom edge into the surface underneath.
isometric boolean false Tilts the composition into the isometric plane of the platform stack.
theme "dark" | "light" "dark" `dark` for the site canvas, `light` for the cream reversed surface.
class string nil Extra classes for the frame.
rest global — Any other HTML attribute, set on the frame.

Source

Two files. Copy them into your app if you would rather own the code than depend on the library.

lib/eh_visuals/chat/bubbles.ex
bubbles.ex
defmodule EHVisuals.Chat.Bubbles do
  @moduledoc """
  A conversation between a customer and the team in bubbles, ending with
  somebody typing.
  """

  use EHVisuals.Composition,
    name: "chat/bubbles",
    title: "Bubbles",
    description:
      "A conversation in bubbles between a customer and the team, ending with a typing indicator."

  @messages [
    %{from: "customer", text: "PO-2291 was due Tuesday. Any news?"},
    %{from: "team", text: "It left the warehouse this morning."},
    %{from: "team", text: "The carrier moved its cut-off, so it arrives on Friday."},
    %{from: "customer", text: "Friday works. Tracking link, please?"}
  ]

  attr(:customer, :string, default: "Piotr Zielinski")
  attr(:meta, :string, default: "Northwind · PO-2291")
  attr(:team, :string, default: "Marta · Support", doc: "The name over the team's bubbles.")

  attr(:messages, :list,
    default: @messages,
    doc:
      "Up to six `%{from, text}`, oldest first. `from` is `customer` or `team`. The text wraps at 36 characters, up to three lines."
  )

  attr(:typing, :string,
    default: "team",
    values: ~w(team customer none),
    doc: "Who is typing in the last bubble."
  )

  attr(:label, :string,
    default: "A conversation between a customer and the team about a late order, the team typing"
  )

  frame_attrs()

  @spec chat_bubbles(map()) :: Phoenix.LiveView.Rendered.t()
  def chat_bubbles(assigns) do
    entries =
      assigns.messages
      |> Enum.take(6)
      |> Enum.map(&%{from: &1.from, lines: &1.text |> wrap(36) |> Enum.take(3)})

    entries =
      if assigns.typing == "none",
        do: entries,
        else: entries ++ [%{from: assigns.typing, lines: nil}]

    {rows, bottom} = layout(entries)
    first = assigns.customer |> String.split() |> List.first() || ""

    assigns =
      assigns
      |> assign(:rows, rows)
      |> assign(:initials, initials(assigns.customer))
      |> assign(:names, %{"customer" => first, "team" => assigns.team})
      |> assign(:height, bottom + 18)

    ~H"""
    <.frame name="chat/bubbles" label={@label} {frame_opts(assigns)}>
      <svg viewBox={"0 0 360 #{@height}"}>
        <g class="ehv-h-lift">
          <rect class="ehv-card ehv-a-rise" x="0.5" y="0.5" width="359" height={@height - 1} rx="12" />
          <g class="ehv-a-fade ehv-i-1">
            <circle class="ehv-card-3" cx="33" cy="28" r="13" />
            <text class="ehv-mono ehv-t-2" x="33" y="31" font-size="8" text-anchor="middle">
              {@initials}
            </text>
            <text class="ehv-t-strong" x="56" y="25.5" font-size="13">{@customer}</text>
            <text class="ehv-mono" x="56" y="39" font-size="8">{String.upcase(@meta)}</text>
            <line class="ehv-line-faint" x1="20" x2="340" y1="52.5" y2="52.5" />
          </g>
          <g :for={row <- @rows} class={"ehv-a-rise ehv-i-#{min(row.index * 2 + 2, 24)}"}>
            <text
              :if={row.named}
              class="ehv-mono ehv-t-4"
              x={if row.team, do: 340, else: 20}
              y={row.y - 6}
              font-size="7.5"
              text-anchor={if row.team, do: "end", else: "start"}
            >
              {String.upcase(Map.get(@names, row.from, ""))}
            </text>
            <path
              class={if row.team, do: "ehv-chat-bubbles__team", else: "ehv-card-3"}
              d={bubble(row.x, row.y, row.w, row.h, row.team)}
            />
            <text
              :for={{line, index} <- Enum.with_index(row.lines || [])}
              x={row.x + 12}
              y={row.y + 19 + index * 16}
              font-size="12"
            >
              {line}
            </text>
            <circle
              :for={dot <- 0..2}
              :if={row.lines == nil}
              class={[
                "ehv-chat-bubbles__dot ehv-i-#{dot}",
                row.team && "ehv-chat-bubbles__dot--team"
              ]}
              cx={row.x + 14 + dot * 9}
              cy={row.y + 14}
              r="2.5"
            />
          </g>
        </g>
      </svg>
    </.frame>
    """
  end

  # Stacks the bubbles from y = 62: close together inside a run of one sender,
  # further apart and with a name when the sender changes.
  defp layout(entries) do
    {rows, {bottom, _from}} =
      entries
      |> Enum.with_index()
      |> Enum.map_reduce({62, nil}, fn {entry, index}, {y, previous} ->
        named = entry.from != previous
        team = entry.from == "team"
        top = y + if(named, do: 16, else: 0) + if(previous && named, do: 6, else: 0)
        {w, h} = size(entry.lines)

        row = %{
          index: index,
          from: entry.from,
          team: team,
          named: named,
          lines: entry.lines,
          x: Float.round(if(team, do: 340 - w, else: 20.0), 1),
          y: top,
          w: w,
          h: h
        }

        {row, {top + h + 5, entry.from}}
      end)

    {rows, bottom - 5}
  end

  defp size(nil), do: {46.0, 28}

  defp size(lines) do
    longest = lines |> Enum.map(&String.length/1) |> Enum.max(fn -> 0 end)
    {Float.round(24 + longest * 6.0, 1), 12 + length(lines) * 16}
  end

  # A rounded bubble with one tight corner, bottom right for the team and
  # bottom left for the customer.
  defp bubble(x, y, w, h, team) do
    {right, left} = if team, do: {3, 11}, else: {11, 3}

    "M#{x + 11} #{y} H#{x + w - 11} a11 11 0 0 1 11 11 V#{y + h - right} " <>
      "a#{right} #{right} 0 0 1 -#{right} #{right} H#{x + left} " <>
      "a#{left} #{left} 0 0 1 -#{left} -#{left} V#{y + 11} a11 11 0 0 1 11 -11 z"
  end

  defp wrap(text, width) do
    lines =
      text
      |> String.split()
      |> Enum.reduce([], fn
        word, [] ->
          [word]

        word, [line | rest] ->
          if String.length(line) + 1 + String.length(word) <= width,
            do: [line <> " " <> word | rest],
            else: [word, line | rest]
      end)
      |> Enum.reverse()

    if lines == [], do: [""], else: lines
  end

  defp initials(name) do
    name |> String.split() |> Enum.take(2) |> Enum.map_join(&String.first/1) |> String.upcase()
  end
end
assets/css/chat/bubbles.css
bubbles.css
/* chat/bubbles */
.ehv-chat-bubbles { --ehv-max-w: 390px; --ehv-step: 130ms; }

.ehv-chat-bubbles__team { fill: var(--ehv-lime-soft); stroke: var(--ehv-lime); stroke-width: 1; stroke-opacity: 0.35; }

.ehv-chat-bubbles__dot { fill: var(--ehv-ink-3); transform-box: fill-box; transform-origin: center; }
.ehv-chat-bubbles__dot--team { fill: var(--ehv-lime); }

/* Typing: the three dots hop one after the other. */
@keyframes ehv-chat-bubbles-hop {
  0%, 60%, 100% { transform: translateY(0); opacity: 0.45; }
  30% { transform: translateY(-3px); opacity: 1; }
}
.ehv--animated .ehv-chat-bubbles__dot {
  animation: ehv-chat-bubbles-hop 1.2s ease-in-out infinite;
  animation-delay: calc(var(--ehv-i, 0) * 160ms);
}