empowered.house ↗

chat/thread.ex

Thread

A message thread with a root message, indented replies and reactions as small count pills.

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

<div class="h-96 rounded-xl border border-border">
  <.chat_thread animated />
</div>
default
isometric
custom thread · two replies
one reply · hover · no glow
light surface
isometric · fade out · still

Props

Prop Type Default What it does
channel string "Month-end close · Thread"
root map %{name: "Marta Nowak", time: "09:12", text: "The October bank statement is in. Six lines do n" <> ..., reactions: [%{count: 2, ...}, ...]} `%{name, time, text}` and optionally `reactions`, a list of up to four `%{glyph, count}`. A glyph is `check`, `eye`, `up` or `plus` (drawn as +N); `mine: true` lights the pill. The text wraps at 46 characters.
replies list [%{name: "Close agent", time: "09:13", text: "Matched four to Acme Tools invoices.", agent: true}, %{...}, ...] Up to four replies shaped like `root`. `agent: true` draws the avatar of an agent. The text wraps at 42 characters.
summary any nil The text in the top right corner. Left empty (`nil`), it counts the replies.
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/thread.ex
thread.ex
defmodule EHVisuals.Chat.Thread do
  @moduledoc """
  A message thread: the message that started it, the replies indented under
  it, and reactions as small count pills.
  """

  use EHVisuals.Composition,
    name: "chat/thread",
    title: "Thread",
    description:
      "A message thread with a root message, indented replies and reactions as small count pills."

  @root %{
    name: "Marta Nowak",
    time: "09:12",
    text: "The October bank statement is in. Six lines do not match the ledger yet.",
    reactions: [%{glyph: "eye", count: 2}, %{glyph: "check", count: 3, mine: true}]
  }

  @replies [
    %{
      name: "Close agent",
      time: "09:13",
      agent: true,
      text: "Matched four to Acme Tools invoices."
    },
    %{
      name: "Jan Kowalski",
      time: "09:20",
      text: "The other two are bank fees. Posting now.",
      reactions: [%{glyph: "plus", count: 2}]
    },
    %{name: "Ewa Lis", time: "09:24", text: "Then we can lock the period today."}
  ]

  attr(:channel, :string, default: "Month-end close · Thread")

  attr(:root, :map,
    default: @root,
    doc:
      "`%{name, time, text}` and optionally `reactions`, a list of up to four `%{glyph, count}`. A glyph is `check`, `eye`, `up` or `plus` (drawn as +N); `mine: true` lights the pill. The text wraps at 46 characters."
  )

  attr(:replies, :list,
    default: @replies,
    doc:
      "Up to four replies shaped like `root`. `agent: true` draws the avatar of an agent. The text wraps at 42 characters."
  )

  attr(:summary, :any,
    default: nil,
    doc: "The text in the top right corner. Left empty (`nil`), it counts the replies."
  )

  attr(:label, :string,
    default: "A message about the bank statement with three indented replies and reaction pills"
  )

  frame_attrs()

  @spec chat_thread(map()) :: Phoenix.LiveView.Rendered.t()
  def chat_thread(assigns) do
    root = block(assigns.root, 0, 44, 46)

    {replies, bottom} =
      assigns.replies
      |> Enum.take(4)
      |> Enum.with_index(1)
      |> Enum.map_reduce(root.bottom + 14, fn {reply, index}, y ->
        row = block(reply, index, y, 42)
        {row, row.bottom + 14}
      end)

    count = length(replies)

    assigns =
      assigns
      |> assign(:root, root)
      |> assign(:replies, replies)
      |> assign(:rail_end, if(count > 0, do: List.last(replies).y + 2, else: nil))
      |> assign(
        :summary,
        assigns.summary || "#{count} #{if count == 1, do: "reply", else: "replies"}"
      )
      |> assign(:height, bottom - 14 + 18)

    ~H"""
    <.frame name="chat/thread" 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">
            <text class="ehv-mono" x="20" y="28" font-size="8.5">{String.upcase(@channel)}</text>
            <text class="ehv-mono ehv-t-2" x="340" y="28" font-size="8.5" text-anchor="end">
              {String.upcase(@summary)}
            </text>
          </g>
          <line
            :if={@rail_end}
            class="ehv-line ehv-a-draw ehv-i-3"
            x1="33"
            x2="33"
            y1="78"
            y2={@rail_end}
            pathLength="1"
          />
          <g class="ehv-a-rise ehv-i-2">
            <.avatar x={33} y={58} r={13} agent={@root.agent} initials={@root.initials} />
            <.message row={@root} x={56} />
          </g>
          <g :for={row <- @replies} class={"ehv-a-rise ehv-i-#{row.index * 2 + 2}"}>
            <path class="ehv-line" d={"M33 #{row.y + 2} q0 8 8 8 h10"} />
            <.avatar x={64} y={row.y + 10} r={10} agent={row.agent} initials={row.initials} />
            <.message row={row} x={82} />
          </g>
        </g>
      </svg>
    </.frame>
    """
  end

  attr(:x, :any, required: true)
  attr(:y, :any, required: true)
  attr(:r, :any, required: true)
  attr(:agent, :boolean, required: true)
  attr(:initials, :string, required: true)

  defp avatar(assigns) do
    ~H"""
    <circle :if={!@agent} class="ehv-card-3" cx={@x} cy={@y} r={@r} />
    <text
      :if={!@agent}
      class="ehv-mono ehv-t-2"
      x={@x}
      y={@y + 2.8}
      font-size={if @r > 11, do: "8", else: "7"}
      text-anchor="middle"
    >
      {@initials}
    </text>
    <rect
      :if={@agent}
      class="ehv-card-3"
      x={@x - @r}
      y={@y - @r}
      width={@r * 2}
      height={@r * 2}
      rx={@r * 0.6}
    />
    <rect
      :if={@agent}
      class="ehv-chat-thread__core"
      x={@x - 3.5}
      y={@y - 3.5}
      width="7"
      height="7"
      rx="2"
    />
    """
  end

  attr(:row, :map, required: true)
  attr(:x, :any, required: true)

  defp message(assigns) do
    ~H"""
    <text
      class="ehv-t-strong"
      x={@x}
      y={@row.y + if(@row.index == 0, do: 10, else: 9)}
      font-size="12.5"
    >
      {@row.name}
    </text>
    <text class="ehv-mono ehv-t-4" x="340" y={@row.y + 9} font-size="8" text-anchor="end">
      {@row.time}
    </text>
    <text
      :for={{line, index} <- Enum.with_index(@row.lines)}
      class="ehv-t-2"
      x={@x}
      y={@row.text_y + index * 16}
      font-size="12"
    >
      {line}
    </text>
    <g :for={pill <- @row.pills} class="ehv-chat-thread__reaction">
      <rect
        class={["ehv-chat-thread__pill", pill.mine && "ehv-chat-thread__pill--mine"]}
        x={@x + pill.x + 0.5}
        y={@row.pills_y + 0.5}
        width={pill.w}
        height="18"
        rx="9"
      />
      <path
        :if={pill.d}
        class={["ehv-chat-thread__glyph ehv-round", pill.mine && "ehv-chat-thread__glyph--mine"]}
        d={glyph(pill.glyph, @x + pill.x + 8, @row.pills_y + 5)}
      />
      <circle
        :if={pill.glyph == "eye"}
        class={["ehv-chat-thread__pupil", pill.mine && "ehv-chat-thread__pupil--mine"]}
        cx={@x + pill.x + 12.5}
        cy={@row.pills_y + 9.5}
        r="1.4"
      />
      <text
        class={["ehv-mono", if(pill.mine, do: "ehv-t-lime", else: "ehv-t-2")]}
        x={@x + pill.x + if(pill.d, do: 21, else: 8)}
        y={@row.pills_y + 12.5}
        font-size="8"
      >
        {pill.text}
      </text>
    </g>
    """
  end

  # One message with everything it needs to be drawn: wrapped lines, where the
  # text and the reactions start, and where it ends.
  defp block(message, index, y, width) do
    lines = message.text |> wrap(width) |> Enum.take(3)
    text_y = y + if(index == 0, do: 28, else: 26)
    last = text_y + (length(lines) - 1) * 16
    pills = pills(Map.get(message, :reactions, []))

    %{
      index: index,
      y: y,
      name: message.name,
      time: Map.get(message, :time, ""),
      agent: Map.get(message, :agent, false),
      initials: initials(message.name),
      lines: lines,
      text_y: text_y,
      pills: pills,
      pills_y: last + 9,
      bottom: if(pills == [], do: last + 6, else: last + 27)
    }
  end

  # Lays the reaction pills out left to right.
  defp pills(reactions) do
    {pills, _x} =
      reactions
      |> Enum.take(4)
      |> Enum.map_reduce(0, fn reaction, x ->
        drawn = reaction.glyph in ~w(check eye up)
        text = if drawn, do: "#{reaction.count}", else: "+#{reaction.count}"
        width = Float.round(if(drawn, do: 28, else: 15) + String.length(text) * 5.5, 1)

        pill = %{
          glyph: reaction.glyph,
          d: drawn,
          text: text,
          mine: Map.get(reaction, :mine, false),
          x: x,
          w: width
        }

        {pill, x + width + 6}
      end)

    pills
  end

  # Tiny glyphs in a 9 by 9 box at {x, y}.
  defp glyph("check", x, y), do: "M#{x} #{y + 4.5} l2.8 2.8 l5.2 -5.8"
  defp glyph("up", x, y), do: "M#{x + 4.5} #{y + 8.5} v-8 m-3.5 3.5 l3.5 -3.5 l3.5 3.5"
  defp glyph("eye", x, y), do: "M#{x - 1} #{y + 4.5} q5.5 -6 11 0 q-5.5 6 -11 0 z"
  defp glyph(_other, x, y), do: "M#{x} #{y}"

  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/thread.css
thread.css
/* chat/thread */
.ehv-chat-thread { --ehv-max-w: 390px; --ehv-step: 120ms; }

.ehv-chat-thread__core { fill: var(--ehv-ink-2); }

/* Reactions: a count pill with a tiny drawn glyph. Yours is the lime one. */
.ehv-chat-thread__pill { fill: var(--ehv-canvas); stroke: var(--ehv-border-soft); stroke-width: 1; }
.ehv-chat-thread__pill--mine { fill: var(--ehv-lime-soft); stroke: var(--ehv-lime); }
.ehv-chat-thread__glyph { fill: none; stroke: var(--ehv-ink-2); stroke-width: 1.25; }
.ehv-chat-thread__glyph--mine { stroke: var(--ehv-lime); }
.ehv-chat-thread__pupil { fill: var(--ehv-ink-2); }
.ehv-chat-thread__pupil--mine { fill: var(--ehv-lime); }

.ehv--hover .ehv-chat-thread__pill { transition: stroke 200ms var(--ehv-ease); }
.ehv--hover .ehv-chat-thread__reaction:hover .ehv-chat-thread__pill { stroke: var(--ehv-lime); }