chat/thread.ex
Thread
A message thread with a root message, indented replies and reactions as small count pills.
# in the module that renders the template
use EHVisuals
<div class="h-96 rounded-xl border border-border">
<.chat_thread animated />
</div>
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
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
/* 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); }