a comment section: replies nested and folded, voted or liked, answered inline
npx shadcn add @ag/comment-threadAlready installed? Preview an update
npx shadcn add @ag/comment-thread --dry-runYour installed source stays unchanged until you update it. How updates work
- New: a comment section. Comments nested under what they answer, each with its face, name, time (its permalink), words in markdown or your own rendered content (a forum's HTML), a vote or likes, and reply, copy link and a menu of the rest. A comment folds by a press on its thread line, which becomes a plus where the line was, the head staying where it was. Replies are written inline in the editor and sent with ⌘↩. Keys: j and k between comments, p the parent, Enter folds, r replies, a and z vote.
- The parts are the API (
Comments,Comment,CommentHead,CommentBody,CommentActions,CommentReplies,CommentComposerand the rest);CommentThreadis the preset: flat comments in, sortedold,new,toporhot, at mostpagereplies a level then "N more", pastmaxDepth"continue this thread". lib/comment-thread-corebuilds the tree without React: a post quoting several earlier posts is drawn under the latest and links the others ("also replying to"), a quoted post outside the list is linked, never invented.
also called comments, discussion, replies
shelf social & links
uses avatar, button, copy, editor, key-router, markdown, material, menu, person, quote, reactions, relative-time, tooltip, vote
the demo above, verbatim
"use client"
import { Flag, Quote } from "lucide-react"
import * as React from "react"
import { Sample } from "@/app/samples"
import type { ThreadSort } from "@/registry/base-nova/lib/comment-thread-core"
import type { Person } from "@/registry/base-nova/lib/person"
import {
Comment,
CommentActions,
CommentAuthor,
CommentBody,
type CommentData,
CommentFace,
CommentHead,
CommentLink,
CommentReplies,
Comments,
CommentThread,
CommentTime,
} from "@/registry/base-nova/ui/comment-thread"
import { MaterialProvider } from "@/registry/base-nova/ui/material"
import { Panel } from "@/registry/base-nova/ui/panel"
import {
ToggleGroup,
ToggleGroupItem,
} from "@/registry/base-nova/ui/toggle-group"
import { Vote, type VoteValue } from "@/registry/base-nova/ui/vote"
// #region people
// Defined once (lib/person). Those with no picture wear their initial.
const PEOPLE: Record<string, Person> = {
twain: { name: "Mark Twain", avatar: "/mark-twain.png" },
franklin: { name: "Benjamin Franklin", avatar: "/benjamin-franklin.png" },
confucius: { name: "Confucius", avatar: "/confucius.png" },
adrian: { name: "adrian", avatar: "/adriangalilea.jpg" },
aldrin: { name: "Buzz Aldrin", avatar: "/apollo-11/aldrin.png" },
lark: { name: "lark" },
wren: { name: "wren" },
tern: { name: "tern" },
ibis: { name: "ibis" },
kite: { name: "kite" },
}
// #endregion
// #region time
/** On the hour, so the server's render and the browser's agree on every instant. */
const NOW = Math.floor(Date.now() / 3_600_000) * 3_600_000
const ago = (minutes: number) => NOW - minutes * 60_000
// #endregion
// #region thread
/** A Reddit-shaped thread: one parent each, scores voted, markdown words. Oldest
* first, as an API lists them. */
const THREAD: CommentData[] = [
{
id: "1",
from: "twain",
at: ago(600),
score: 412,
markdown:
"The secret of getting ahead is getting started. The secret of getting started is breaking your complex overwhelming tasks into small manageable tasks, and then starting on the first one.",
},
{
id: "2",
from: "lark",
at: ago(590),
score: 88,
markdown:
"Does that hold for a comment section too? It feels like the one thing every site rebuilds from scratch.",
},
{
id: "3",
from: "franklin",
at: ago(580),
score: 57,
parents: ["1"],
markdown: "Well done is better than well said.",
},
{
id: "4",
from: "adrian",
at: ago(570),
score: 131,
parents: ["2"],
markdown: [
"It does. The parts are already here, which is the point of composing it:",
"",
"- the face is `avatar`, the time `relative-time`",
"- the words `markdown`, the standing `vote` or `reactions`",
"- the reply box `editor`, the keys `key-router`",
"",
"Nothing in it draws its own button.",
].join("\n"),
},
{
id: "5",
from: "wren",
at: ago(560),
score: 24,
parents: ["4"],
markdown:
"And the tree? Building one from flat rows is where mine always went wrong.",
},
{
id: "6",
from: "adrian",
at: ago(550),
score: 40,
parents: ["5"],
markdown: [
"A lib, no React in it:",
"",
"```ts",
'const thread = buildThread(posts, { sort: "top" })',
"thread.roots // sorted, each with its replies and their count",
"```",
"",
"Posts come oldest first, so a parent is always known before its replies.",
].join("\n"),
},
{
id: "7",
from: "tern",
at: ago(540),
score: 12,
parents: ["6"],
markdown: "What happens with a cycle?",
},
{
id: "8",
from: "adrian",
at: ago(530),
score: 19,
parents: ["7"],
markdown:
"It can't be written: a reply that names a parent listed after it is refused, with the reason.",
},
{
id: "9",
from: "tern",
at: ago(520),
score: 9,
parents: ["8"],
markdown: "So deep threads stay honest. How deep does it draw?",
},
{
id: "10",
from: "ibis",
at: ago(510),
score: 6,
parents: ["9"],
markdown: "This deep, apparently. Then it offers to continue the thread.",
},
{
id: "11",
from: "kite",
at: ago(500),
score: 4,
parents: ["10"],
markdown: "Hello from below the fold.",
},
{
id: "12",
from: "confucius",
at: ago(490),
score: 77,
parents: ["1"],
markdown:
"It does not matter how slowly you go as long as you do not stop.",
},
{
id: "13",
from: "aldrin",
at: ago(300),
score: 230,
markdown: [
"Long one, sorry. Every comment system I've used fails the same three ways:",
"",
"1. **Folding moves things.** You collapse a thread and the line you were reading jumps.",
"2. **Deep threads squeeze.** Ten levels in, the text is a column of three words.",
"3. **Long threads are slow.** Four thousand replies, all in the DOM, on a phone.",
"",
"> Here the head stays put when it folds, past a depth the thread continues on its own, and past a page of replies the rest wait behind a button.",
"",
"Which is the whole list.",
].join("\n"),
},
...Array.from({ length: 9 }, (_, i) => ({
id: `13.${i + 1}`,
from: ["lark", "wren", "tern", "ibis", "kite"][i % 5] as string,
at: ago(290 - i * 20),
score: [41, 3, 17, 0, -4, 8, 22, 1, 5][i] as number,
parents: ["13"],
markdown: [
"Number two is the one that gets me.",
"+1, especially on a phone.",
"Folding without the jump is underrated.",
"Does it virtualize?",
"Hot take: threads deeper than five are a smell.",
"Collapsed by default past a page is the right call.",
"The `j` and `k` keys are a nice touch.",
"What about quotes of several posts?",
"Bookmarking this.",
][i] as string,
})),
{
id: "14",
from: "lark",
at: ago(200),
score: 2,
parents: ["13.4"],
markdown: "No: what is not drawn is what was folded, paged or cut.",
},
{
id: "15",
from: "franklin",
at: ago(120),
score: 15,
parents: ["13.8"],
markdown: "See the forum below: a post quoting three earlier posts.",
},
{
id: "16",
from: "kite",
at: ago(45),
score: 3,
markdown: "Short and sweet.",
},
{
id: "17",
from: "wren",
at: ago(12),
score: 1,
markdown: "Folded by default, and its replies with it.",
},
{
id: "18",
from: "tern",
at: ago(8),
score: 0,
parents: ["17"],
markdown: "You can't see me until it opens.",
},
]
// #endregion
// #region votes
/** Your votes, held here; a score moves with yours (vote's optimistic rule). */
function useVotes(comments: readonly CommentData[]) {
const [votes, setVotes] = React.useState<Record<string, VoteValue>>({})
const voted = React.useMemo(
() =>
comments.map((c) => {
const vote = votes[c.id] ?? 0
return vote ? { ...c, vote, score: (c.score ?? 0) + vote } : c
}),
[comments, votes],
)
const onVote = (id: string, value: VoteValue) =>
setVotes((all) => ({ ...all, [id]: value }))
return [voted, onVote] as const
}
// #endregion
// #region reply
/** A stand-in server: a reply takes a moment, and one that says "fail" is refused. */
function useReplies(start: readonly CommentData[]) {
const [comments, setComments] = React.useState(start)
const onReply = (parent: string | null, markdown: string) =>
new Promise<void>((done, refuse) =>
setTimeout(() => {
if (/fail/i.test(markdown))
return refuse(new Error("the server said no"))
setComments((all) => [
...all,
{
id: `new-${all.length}`,
from: "adrian",
at: Date.now(),
score: 1,
vote: 1,
parents: parent ? [parent] : undefined,
markdown,
},
])
done()
}, 600),
)
return [comments, onReply] as const
}
// #endregion
// #region reddit
function Reddit() {
const [sort, setSort] = React.useState<ThreadSort>("top")
const [comments, onReply] = useReplies(THREAD)
const [voted, onVote] = useVotes(comments)
return (
<div className="flex flex-col gap-4">
<ToggleGroup
size="sm"
aria-label="sort comments"
value={sort}
onValueChange={(v) => setSort(v as ThreadSort)}
className="w-fit"
>
<ToggleGroupItem value="top">top</ToggleGroupItem>
<ToggleGroupItem value="hot">hot</ToggleGroupItem>
<ToggleGroupItem value="new">new</ToggleGroupItem>
<ToggleGroupItem value="old">old</ToggleGroupItem>
</ToggleGroup>
<CommentThread
comments={voted}
people={PEOPLE}
sort={sort}
now={NOW}
domId={(id) => `reddit-${id}`}
onVote={onVote}
onReply={onReply}
menu={() => [{ icon: <Flag />, label: "report", onSelect: () => {} }]}
page={5}
defaultCollapsed={["17"]}
/>
</div>
)
}
// #endregion
// #region forum
// A forum's replies, as a reader of one has them: numbered posts, each quoting any
// earlier posts it answers (several, sometimes), likes counted with the first few
// names, and words that are the forum's HTML, rendered to React already.
const FORUM_PEOPLE: Record<string, Person> = {
...PEOPLE,
op: { name: "nightjar" },
}
/** A quoted post, the way the forum's own HTML draws one. */
const Quoted = ({ who, children }: { who: string; children: string }) => (
<blockquote>
<p>
<strong>{who} wrote:</strong> {children}
</p>
</blockquote>
)
const FORUM: CommentData[] = [
{
id: "201",
from: "op",
at: ago(4000),
likes: { count: 31, mine: false, who: ["lark", "wren", "tern", "ibis"] },
body: (
<p>
Moving the old reader to a reply tree. Posts quote each other freely, so
one post can answer three. How should that draw?
</p>
),
},
{
id: "202",
from: "lark",
at: ago(3900),
parents: ["201"],
likes: { count: 4, mine: false, who: ["nightjar", "wren"] },
body: (
<p>Under the latest one it quotes. That is the exchange it continues.</p>
),
},
{
id: "203",
from: "wren",
at: ago(3800),
parents: ["201"],
likes: { count: 2, mine: true, who: ["adrian", "tern"] },
body: (
<p>
And link the others, so nothing is lost. Reddit never had to solve this.
</p>
),
},
{
id: "204",
from: "tern",
at: ago(3700),
parents: ["202", "203"],
likes: { count: 9, mine: false, who: ["lark", "wren", "ibis"] },
body: (
<>
<Quoted who="lark">Under the latest one it quotes.</Quoted>
<Quoted who="wren">And link the others, so nothing is lost.</Quoted>
<p>Both, then. Drawn under wren's, with a link back to lark's.</p>
</>
),
},
{
id: "205",
from: "ibis",
at: ago(3600),
parents: ["212", "204"],
likes: { count: 0, mine: false },
body: (
<>
<Quoted who="kite">the old reader is fine as it is</Quoted>
<p>
That was on the previous page. Its link goes there, since it is not in
this thread.
</p>
</>
),
},
{
id: "206",
from: "kite",
at: ago(3500),
parents: ["201", "202", "204"],
likes: {
count: 14,
mine: false,
who: ["nightjar", "lark", "tern", "ibis", "wren"],
},
body: (
<>
<Quoted who="tern">Both, then.</Quoted>
<p>
Quoting the first post adds nothing here: it is right above. lark's is
elsewhere, so it gets a link.
</p>
</>
),
},
{
id: "207",
from: "op",
at: ago(3000),
likes: { count: 3, mine: false, who: ["kite"] },
body: (
<p>
Thanks all. <a href="#forum-201">Back to the top</a>.
</p>
),
},
]
function Forum() {
const [posts, setPosts] = React.useState(FORUM)
const onLike = (id: string) =>
setPosts((all) =>
all.map((p) =>
p.id === id && p.likes
? {
...p,
likes: {
...p.likes,
mine: !p.likes.mine,
count: p.likes.count + (p.likes.mine ? -1 : 1),
who: p.likes.mine
? p.likes.who?.filter((w) => w !== "adrian")
: ["adrian", ...(p.likes.who ?? [])],
},
}
: p,
),
)
return (
<CommentThread
label="replies"
comments={posts}
people={FORUM_PEOPLE}
domId={(id) => `forum-${id}`}
href={(id) =>
posts.some((p) => p.id === id) ? `#forum-${id}` : `?page=11#post-${id}`
}
onLike={onLike}
like="👍"
menu={() => [{ icon: <Quote />, label: "quote", onSelect: () => {} }]}
/>
)
}
// #endregion
// #region small
const SMALL: CommentData[] = [
{
id: "a",
from: "confucius",
at: ago(90),
score: 64,
markdown: "Real knowledge is to know the extent of one's ignorance.",
},
{
id: "b",
from: "lark",
at: ago(60),
score: 12,
parents: ["a"],
markdown: "Which is why I read the comments.",
},
{
id: "c",
from: "twain",
at: ago(30),
score: 30,
parents: ["b"],
markdown: "A brave choice.",
},
]
function Small({ prefix }: { prefix: string }) {
const [voted, onVote] = useVotes(SMALL)
return (
<CommentThread
comments={voted}
people={PEOPLE}
domId={(id) => `${prefix}-${id}`}
onVote={onVote}
/>
)
}
// #endregion
// #region parts
/** The parts by hand: a comment with a badge by the name and a vote beside it, its
* one reply, no reply box. What the preset composes, laid out yourself. */
function Parts() {
const [vote, setVote] = React.useState<VoteValue>(0)
const op = PEOPLE.franklin as Person
const reply = PEOPLE.wren as Person
return (
<Comments label="one comment">
<Comment
id="parts-1"
author={op.name}
face={<CommentFace person={op} />}
replies={1}
>
<CommentHead>
<CommentAuthor person={op}>
<span className="rounded bg-current/8 px-1 font-medium text-[10px] uppercase tracking-wide">
op
</span>
</CommentAuthor>
<CommentTime at={ago(15)} href="#parts-1" />
</CommentHead>
<CommentBody markdown="An investment in knowledge pays the best interest." />
<CommentActions>
<Vote
compact
size="sm"
orientation="horizontal"
value={vote}
score={7 + vote}
onValueChange={setVote}
aria-label="vote on Franklin's comment"
/>
<CommentLink href="#parts-1" />
</CommentActions>
<CommentReplies>
<Comment
id="parts-2"
author={reply.name}
face={<CommentFace person={reply} />}
>
<CommentHead>
<CommentAuthor person={reply} />
<CommentTime at={ago(5)} />
</CommentHead>
<CommentBody>
<p>
Rendered already: <em>this body is React</em>, worn in
markdown's look.
</p>
</CommentBody>
</Comment>
</CommentReplies>
</Comment>
</Comments>
)
}
// #endregion
export default function CommentThreadDemo() {
return (
<div className="space-y-8">
<Sample
name="glance"
glance="large"
title="a short thread"
with="people time votes small"
>
<div className="w-[26rem] max-w-full">
<Small prefix="glance" />
</div>
</Sample>
<Sample
name="reddit"
title="votes, sorts, replies"
description="thirty comments, eight levels deep: past six it continues the thread, past five replies the rest wait behind a button, one folded from the start. sort it, vote, fold by the line, reply (a reply saying fail is refused). keys: j, k, p, enter, r, a, z."
with="people time thread votes reply reddit"
>
<Reddit />
</Sample>
<Sample
name="forum"
title="a forum: quotes of several"
description="a post quoting several earlier posts is drawn under the latest and links the others, unless one is right above it; a quote of a post on another page links there. likes are a count and the first few names, the words the forum's own HTML."
with="people time forum"
>
<Forum />
</Sample>
<Sample
name="materials"
title="plain and glass"
with="people time votes small"
>
<div className="grid gap-4 lg:grid-cols-2">
<Panel className="p-5">
<Small prefix="plain" />
</Panel>
<div className="rounded-xl bg-[url(/lightbox/lake.jpg)] bg-cover bg-center p-4">
<MaterialProvider material="glass" scheme="dark">
<Panel className="p-5">
<Small prefix="glass" />
</Panel>
</MaterialProvider>
</div>
</div>
</Sample>
<Sample name="parts" title="the parts by hand" with="people time parts">
<Parts />
</Sample>
</div>
)
}the source, every file an install writes
"use client"
import {
ArrowLeft,
ChevronRight,
CornerDownRight,
Ellipsis,
Minus,
Plus,
Reply,
} from "lucide-react"
import * as React from "react"
import { cn } from "@/lib/utils"
import {
buildThread,
nodeOf,
pathTo,
type Thread,
type ThreadNode,
type ThreadPost,
type ThreadSort,
} from "@/registry/base-nova/lib/comment-thread-core"
import type { Person } from "@/registry/base-nova/lib/person"
import { Avatar } from "@/registry/base-nova/ui/avatar"
import { Button } from "@/registry/base-nova/ui/button"
import { Copy } from "@/registry/base-nova/ui/copy"
import { Editor } from "@/registry/base-nova/ui/editor"
import { useEscape, useKeys } from "@/registry/base-nova/ui/key-router"
import { MARKDOWN, Markdown } from "@/registry/base-nova/ui/markdown"
import {
type Material,
MaterialProvider,
type Scheme,
useMaterial,
useSurfaceScheme,
} from "@/registry/base-nova/ui/material"
import { schemeOf } from "@/registry/base-nova/ui/material-glass"
import { Menu, type MenuEntry } from "@/registry/base-nova/ui/menu"
import { QuoteMarkMask } from "@/registry/base-nova/ui/quote-mark"
import { Reactions } from "@/registry/base-nova/ui/reactions"
import { RelativeTime } from "@/registry/base-nova/ui/relative-time"
import { Tip } from "@/registry/base-nova/ui/tooltip"
import { Vote, type VoteValue } from "@/registry/base-nova/ui/vote"
// A COMMENT SECTION (Reddit, Hacker News, a forum's replies): comments nested under
// what they answer, each with its author, when, its words, its standing and what to do
// with it. Composed, not drawn: the face is `avatar`, the time `relative-time`, the
// words `markdown` (or the consumer's own rendered content, in markdown's look), the
// standing `vote` or `reactions` (a forum's likes, with who gave them), the verbs
// `button`, `copy` and `menu`, the reply box `editor`, the keys `key-router`.
//
// THE PARTS ARE THE API, shadcn's way:
//
// <Comments label="comments">
// <Comment id="c1" author="ana" face={<CommentFace person={ana} />} replies={2}>
// <CommentHead>
// <CommentAuthor person={ana} />
// <CommentTime at={c.at} href="#c1" />
// </CommentHead>
// <CommentBody markdown={c.text} /> or <CommentBody>{html}</CommentBody>
// <CommentActions>
// <Vote compact size="sm" orientation="horizontal" … />
// <CommentReply onClick={…} />
// <CommentLink href="#c1" />
// </CommentActions>
// <CommentReplies>…<Comment>…</CommentReplies>
// </Comment>
// </Comments>
//
// `CommentThread` is the preset over them: flat comments in (lib/comment-thread-core
// builds the tree, quotes of several posts included), people, a sort, and handlers.
//
// COLLAPSE is the comment's own: a press on its thread line (under the face, Reddit's),
// which folded is a plus where the line was. Collapsed, the head stays exactly where it
// was and says how many replies it hides; everything else (the words, the verbs, the
// replies) is not rendered at all, so a long thread folded is a short DOM. A part
// reads the state from its comment, so a consumer's composition folds the same way.
//
// THE DOM STAYS BOUNDED in the preset: `page` replies a level are drawn, then "N more
// replies"; past `maxDepth` levels a comment offers "continue this thread", which
// draws that comment alone as the thread (or goes to `continueHref`). Nothing is
// virtualized: a thread is read in the page's own scroll, and what is not drawn is
// what was folded, paged or cut.
//
// KEYS (Reddit Enhancement Suite's, which a reader of threads already knows), on the
// key router, taken while focus or the pointer is in the section: j and k the next
// and previous comment, p its parent, Enter folds or opens it, r replies, a and z vote
// up and down (a like, where it is likes). Each key presses the comment's own control,
// so a key does exactly what the hand would and a composition without a reply button
// has no r. The comment's menu (⋯) lists them.
//
// A comment is an `article` named by its head; replies are a `group` named for whose
// they are; the toggle says `aria-expanded`. Focus on a comment rings its head, never
// the whole subtree under it.
/** The words' voice: the meta line small and quiet, the name at full ink. */
const META = "text-[12px] text-current/55"
const NAME = "font-medium text-[13px] text-current/90"
// ── the section ──────────────────────────────────────────────────────────────────
/** Every comment in the section, in reading order: what j and k walk. */
const COMMENT = "[data-slot=comment]"
/** The part of `comment` that is its own, never a reply's below it. */
function ownPart(comment: Element, selector: string): HTMLElement | null {
for (const el of comment.querySelectorAll<HTMLElement>(selector))
if (el.closest(COMMENT) === comment) return el
return null
}
/** Focus a comment (its article), keeping it in view. */
export function focusComment(el: HTMLElement | null) {
if (!el) return
el.focus({ preventScroll: true })
el.scrollIntoView({ block: "nearest" })
}
export interface CommentsProps
extends Omit<React.ComponentProps<"section">, "aria-label"> {
/** The section's name: "comments", "replies to this post". */
label?: string
material?: Material
/** The colour scheme it stands in (material): default the nearest provider's. */
scheme?: Scheme
}
/** The section every comment stands in: its name, its material, its keys. */
export function Comments({
label = "comments",
material,
scheme,
className,
children,
ref,
...props
}: CommentsProps) {
const m = useMaterial(material)
const [s, here] = useSurfaceScheme<HTMLElement>(scheme, ref)
const root = React.useRef<HTMLElement | null>(null)
const attach = React.useCallback(
(el: HTMLElement | null) => {
root.current = el
here(el)
},
[here],
)
useKeys(
(event) => {
const section = root.current
if (!section || event.metaKey || event.ctrlKey || event.altKey)
return false
// The section's keys while the hand is in it: focus, or the pointer over it.
const active = document.activeElement
if (!section.contains(active) && !section.matches(":hover")) return false
const all = [...section.querySelectorAll<HTMLElement>(COMMENT)]
const near = active?.closest<HTMLElement>(COMMENT) ?? null
const current = near && section.contains(near) ? near : null
const press = (selector: string) => {
const control = current && ownPart(current, selector)
if (!control) return false
control.click()
return true
}
switch (event.key) {
case "j":
case "k": {
if (all.length === 0) return false
const at = current ? all.indexOf(current) : -1
const to =
event.key === "j"
? Math.min(all.length - 1, at + 1)
: Math.max(0, at === -1 ? 0 : at - 1)
focusComment(all[to] ?? null)
return true
}
case "p": {
const parent =
current?.parentElement?.closest<HTMLElement>(COMMENT) ?? null
if (!parent || !section.contains(parent)) return false
focusComment(parent)
return true
}
// Enter on the comment itself; on one of its controls it is the control's.
case "Enter":
return active === current && press("[data-slot=comment-toggle]")
case "r":
return press("[data-slot=comment-reply]")
case "a":
return press(
"[data-slot=vote-up], [data-slot=reactions-pill]:is(button)",
)
case "z":
return press("[data-slot=vote-down]")
default:
return false
}
},
{ root },
)
return (
<MaterialProvider material={m} scheme={s}>
<section
ref={attach}
aria-label={label}
data-slot="comments"
data-material={m}
className={cn("flex flex-col gap-5", schemeOf(s), className)}
{...props}
>
<QuoteMarkMask />
{children}
</section>
</MaterialProvider>
)
}
// ── a comment ────────────────────────────────────────────────────────────────────
interface CommentState {
id: string
author: string
replies: number
collapsed: boolean
setCollapsed: (collapsed: boolean) => void
headId: string
}
const CommentContext = React.createContext<CommentState | null>(null)
/** The comment a part stands in; null outside one (a composer for a new comment). */
export function useComment(): CommentState | null {
return React.useContext(CommentContext)
}
function useOwnComment(part: string): CommentState {
const c = React.useContext(CommentContext)
if (!c) throw new Error(`${part}: it is a part of a <Comment>`)
return c
}
export interface CommentProps
extends Omit<React.ComponentProps<"article">, "id"> {
/** The comment's own id: its element's is `domId` (default the same). */
id: string
/** The element's id, what a permalink's fragment names. */
domId?: string
/** Its author's name, for what the controls say ("collapse ana's comment"). */
author: string
/** In the gutter, above the thread line: a `CommentFace`. */
face?: React.ReactNode
/** Every reply under it, at any depth: what a fold says it hides. */
replies?: number
collapsed?: boolean
defaultCollapsed?: boolean
onCollapsedChange?: (collapsed: boolean) => void
}
/** One comment: the face and its thread line in the gutter, the parts beside them. */
export function Comment({
id,
domId = id,
author,
face,
replies = 0,
collapsed: controlled,
defaultCollapsed = false,
onCollapsedChange,
className,
children,
...props
}: CommentProps) {
if (!Number.isInteger(replies) || replies < 0)
throw new Error(`Comment ${id}: replies is a count, got ${replies}`)
const [own, setOwn] = React.useState(defaultCollapsed)
const collapsed = controlled ?? own
const headId = `${React.useId()}-head`
const state = React.useMemo<CommentState>(
() => ({
id,
author,
replies,
collapsed,
headId,
setCollapsed: (next) => {
if (controlled === undefined) setOwn(next)
onCollapsedChange?.(next)
},
}),
[id, author, replies, collapsed, headId, controlled, onCollapsedChange],
)
return (
<CommentContext.Provider value={state}>
<article
id={domId}
tabIndex={-1}
aria-labelledby={headId}
data-slot="comment"
data-collapsed={collapsed || undefined}
className={cn(
// The ring is the head's and the line lights, never a ring round the subtree.
"group/comment grid grid-cols-[1.5rem_minmax(0,1fr)] gap-x-2.5 outline-none",
"[&:focus-visible>[data-slot=comment-content]>[data-slot=comment-head]]:focus-ring",
"[&:focus-visible>[data-slot=comment-gutter]>*>[data-slot=comment-toggle]]:text-current/45 [&:focus-visible>[data-slot=comment-gutter]>[data-slot=comment-toggle]]:text-current/45",
className,
)}
{...props}
>
<div
data-slot="comment-gutter"
className="flex min-h-7 flex-col items-center pt-0.5"
>
{face}
<CommentLine />
</div>
<div data-slot="comment-content" className="min-w-0">
{children}
</div>
{collapsed && <CommentUnfold />}
</article>
</CommentContext.Provider>
)
}
/** THE FOLD IS THE THREAD LINE (Adrian, 2026-10-29: no minus, no mark while open).
* Open, the line runs from under the face to the last reply, and a press anywhere on
* it folds the comment (Reddit's). Folded, there is no line and no gap where it was:
* the comment is its head, which ends in a small dim plus and how many replies it
* hides (`CommentUnfold`). Bespoke, as a tree row's marks are: a button's box would
* break the line. */
function CommentLine() {
const c = useOwnComment("CommentLine")
if (c.collapsed) return null
return (
<Tip content="collapse" keys="enter" side="left">
<button
type="button"
aria-expanded
aria-label={`collapse ${c.author}'s comment`}
data-slot="comment-toggle"
onClick={() => c.setCollapsed(true)}
className="group/line relative mt-1 w-full flex-1 origin-top cursor-pointer text-current/14 transition-[color,scale] duration-200 ease-out hover:text-current/45 motion-reduce:transition-none starting:scale-y-0"
>
<span
aria-hidden
className="absolute inset-y-0 left-1/2 w-px -translate-x-1/2 rounded-full bg-current group-hover/line:w-0.5"
/>
</button>
</Tip>
)
}
/** Folded: under the face, where the line was, the line shrunk into a small dim plus
* (its upright the line drawn in, its crossbar growing out), and right beside it what
* the fold hides. One press, the whole row, opens it; the line grows back down. */
function CommentUnfold() {
const c = useOwnComment("CommentUnfold")
const what = c.replies === 1 ? "1 reply" : `${c.replies} replies`
return (
<button
type="button"
aria-expanded={false}
aria-label={`expand ${c.author}'s comment${c.replies > 0 ? `, ${what} hidden` : ""}`}
data-slot="comment-toggle"
onClick={() => c.setCollapsed(false)}
className={cn(
"group/unfold col-span-2 grid w-fit cursor-pointer grid-cols-[1.5rem_auto] items-center gap-x-2.5 text-current/40 transition-colors hover:text-current/80",
META,
"text-current/40",
)}
>
<span aria-hidden className="relative mx-auto size-3">
<span className="absolute inset-y-0 left-1/2 w-px origin-top -translate-x-1/2 rounded-full bg-current transition-transform duration-200 ease-out motion-reduce:transition-none starting:scale-y-[4]" />
<span className="absolute top-1/2 left-0 h-px w-full -translate-y-1/2 rounded-full bg-current transition-transform duration-200 ease-out motion-reduce:transition-none starting:scale-x-0" />
</span>
{c.replies > 0 && (
<span data-slot="comment-hidden" className="text-left">
{what} hidden
</span>
)}
</button>
)
}
/** A person's face in the gutter: their picture, or their initial (avatar). The name
* stands beside it, so it is decorative. */
export function CommentFace({
person,
className,
}: {
person: Person
className?: string
}) {
return (
<Avatar
src={person.avatar}
alt=""
name={person.name}
size="sm"
className={cn("shrink-0", className)}
/>
)
}
/** The comment's first line: who and when. Folded, it is all that is drawn, and says
* at its end how many replies the fold hides; nothing on it moves. */
export function CommentHead({
className,
children,
...props
}: React.ComponentProps<"header">) {
const c = useOwnComment("CommentHead")
return (
<header
id={c.headId}
data-slot="comment-head"
className={cn(
"flex min-h-7 flex-wrap items-center gap-x-1.5 rounded-md",
className,
)}
{...props}
>
{children}
</header>
)
}
/** Who wrote it: the name at full ink, and what stands beside it (`children`: a
* badge, "OP", a flair). */
export function CommentAuthor({
person,
className,
children,
}: {
person: Person
className?: string
children?: React.ReactNode
}) {
return (
<span
data-slot="comment-author"
className={cn("inline-flex items-center gap-1.5", className)}
>
<span className={NAME}>{person.name}</span>
{children}
</span>
)
}
/** When, said from now; with `href` it is the comment's permalink, as on every
* forum. */
export function CommentTime({
at,
href,
className,
}: {
at: number
href?: string
className?: string
}) {
const time = <RelativeTime at={at} />
return (
<span data-slot="comment-time" className={cn(META, className)}>
<span aria-hidden>· </span>
{href ? (
<a
href={href}
className="rounded-sm underline-offset-2 hover:text-current/80 hover:underline"
>
{time}
</a>
) : (
time
)}
</span>
)
}
/** One more post a comment answers: what a link to it says and where it goes. */
export interface CommentLinkTo {
id: string
label: string
href: string
}
/** The other posts it answers, where it quotes several and is drawn under one
* (lib/comment-thread-core's rule): "also replying to ana, ben". A press follows the
* link, or `onFollow` (a thread revealing a comment folded or paged away). */
export function CommentAlso({
to,
onFollow,
className,
}: {
to: readonly CommentLinkTo[]
onFollow?: (id: string) => void
className?: string
}) {
const c = useOwnComment("CommentAlso")
if (c.collapsed || to.length === 0) return null
return (
<p
data-slot="comment-also"
className={cn("flex flex-wrap items-center gap-x-1", META, className)}
>
<CornerDownRight aria-hidden className="size-3 text-current/45" />
also replying to
{to.map((t, i) => (
<React.Fragment key={t.id}>
<a
href={t.href}
onClick={
onFollow
? (event) => {
event.preventDefault()
onFollow(t.id)
}
: undefined
}
className="rounded-sm text-current/75 underline decoration-current/25 underline-offset-2 hover:text-current hover:decoration-current/60"
>
{t.label}
</a>
{i < to.length - 1 && <span aria-hidden>,</span>}
</React.Fragment>
))}
</p>
)
}
/** The words: markdown text (drawn by `markdown`, nothing embedded from another
* host), or content the consumer rendered already (a forum's HTML as React), worn
* in markdown's look so both read the same. One or the other. */
export function CommentBody({
markdown,
children,
className,
}: {
markdown?: string
children?: React.ReactNode
className?: string
}) {
const c = useOwnComment("CommentBody")
const given = (markdown !== undefined ? 1 : 0) + (children != null ? 1 : 0)
if (given !== 1)
throw new Error(
`CommentBody ${c.id}: markdown or children, one of them (${given} given)`,
)
if (c.collapsed) return null
return (
<div data-slot="comment-body" className={cn("mt-0.5", className)}>
{markdown !== undefined ? (
<Markdown
embeds={keepLinks}
components={{ blockquote: CommentQuote }}
className={BODY}
>
{markdown}
</Markdown>
) : (
<div className={cn(MARKDOWN, BODY)}>{children}</div>
)}
</div>
)
}
/** Text anyone wrote loads nothing from another host: every link stays a link. */
const keepLinks = () => null
/** A comment's words: smaller than a page's, and a QUOTE IS THE CLASSIC INDENTED ONE
* (Adrian, 2026-10-29: markdown's quote, the serif on a surface with its mark, "def
* not this" in a comment): a hairline at the left, the quoted words a step dimmer, in
* the comment's own voice. Written on markdown's own blockquote rule, the same
* variant, so these win over it (tailwind-merge keeps the later of two of a kind)
* for markdown's quotes and for a forum's own `<blockquote>` alike. */
const BODY = [
"text-[14px] leading-[1.6]",
"[&_blockquote:not([data-slot=quote-body])]:my-[0.75em] [&_blockquote:not([data-slot=quote-body])]:rounded-none [&_blockquote:not([data-slot=quote-body])]:border-l-2 [&_blockquote:not([data-slot=quote-body])]:border-current/20 [&_blockquote:not([data-slot=quote-body])]:bg-transparent [&_blockquote:not([data-slot=quote-body])]:py-0 [&_blockquote:not([data-slot=quote-body])]:pr-0 [&_blockquote:not([data-slot=quote-body])]:pl-[0.85em]",
"[&_blockquote:not([data-slot=quote-body])]:font-[family-name:inherit] [&_blockquote:not([data-slot=quote-body])]:text-[1em] [&_blockquote:not([data-slot=quote-body])]:text-current/60 [&_blockquote:not([data-slot=quote-body])]:before:content-none",
].join(" ")
/** A quote in a comment's markdown: the words and, when its last line names them,
* who said it. Drawn plain; the look is the body's (`BODY`). */
export function CommentQuote({
author,
children,
}: {
author?: { name: string; href?: string | null } | null
children?: React.ReactNode
}) {
return (
<blockquote data-slot="comment-quote">
{children}
{author && (
<p className="text-[0.85em] text-current/50">
{author.href ? <a href={author.href}>{author.name}</a> : author.name}
</p>
)}
</blockquote>
)
}
/** What to do with it, under the words: the standing (`Vote`, `Reactions`), reply,
* link, more. */
export function CommentActions({
className,
...props
}: React.ComponentProps<"div">) {
const c = useOwnComment("CommentActions")
if (c.collapsed) return null
return (
<div
data-slot="comment-actions"
className={cn(
"-ml-1.5 mt-1 flex flex-wrap items-center gap-1",
className,
)}
{...props}
/>
)
}
/** Reply: opens the box under the comment (`CommentComposer`), pressed while open. */
export function CommentReply({
open = false,
onClick,
}: {
open?: boolean
onClick: () => void
}) {
const c = useOwnComment("CommentReply")
return (
<Tip content={`reply to ${c.author}`} keys="r">
<Button
variant="ghost"
size="sm"
icon={<Reply />}
pressed={open}
aria-label={`reply to ${c.author}`}
data-slot="comment-reply"
onClick={onClick}
className="text-current/60 hover:text-current"
>
reply
</Button>
</Tip>
)
}
/** Copies the comment's permalink, made whole from where the page is. */
export function CommentLink({ href }: { href: string }) {
return (
<Copy
value={() => new URL(href, window.location.href).href}
tip="copy a link to this comment"
className="text-current/60 hover:text-current"
/>
)
}
/** More verbs, under ⋯ (menu): yours (`entries`), and below them the comment's own,
* each with its key. */
export function CommentMenu({
entries = [],
onReply,
onParent,
}: {
entries?: readonly MenuEntry[]
/** Given, "reply" is listed (r). */
onReply?: () => void
/** Given, "go to the parent" is listed (p). */
onParent?: () => void
}) {
const c = useOwnComment("CommentMenu")
const [anchor, setAnchor] = React.useState<Element | null>(null)
const button = React.useRef<HTMLButtonElement>(null)
const own: MenuEntry[] = [
...(onReply
? [{ icon: <Reply />, label: "reply", hint: "r", onSelect: onReply }]
: []),
...(onParent
? [
{
icon: <CornerDownRight className="-scale-y-100" />,
label: "go to the parent",
hint: "p",
onSelect: onParent,
},
]
: []),
{
icon: <Minus />,
label: "collapse",
hint: "enter",
onSelect: () => c.setCollapsed(true),
},
]
return (
<>
<Button
ref={button}
variant="ghost"
size="icon-sm"
aria-label={`more on ${c.author}'s comment`}
aria-haspopup="menu"
aria-expanded={anchor !== null}
data-slot="comment-menu"
onClick={(event) => setAnchor(event.currentTarget)}
className="text-current/60 hover:text-current"
>
<Ellipsis aria-hidden />
</Button>
<Menu
entries={entries.length ? [...entries, "separator", ...own] : own}
anchor={anchor}
onClose={() => setAnchor(null)}
returnTo={() => button.current}
/>
</>
)
}
/** The replies under a comment, a group named for whose they are. */
export function CommentReplies({
className,
...props
}: React.ComponentProps<"div">) {
const c = useOwnComment("CommentReplies")
if (c.collapsed) return null
return (
// biome-ignore lint/a11y/useSemanticElements: a group of articles, not a fieldset of fields
<div
role="group"
aria-label={`replies to ${c.author}`}
data-slot="comment-replies"
className={cn("mt-3 flex flex-col gap-4", className)}
{...props}
/>
)
}
/** What is not drawn yet: "N more replies" (`more`), or "continue this thread" past
* the depth a thread is drawn to (`continue`), a link when given `href`. */
export function CommentMore({
kind = "more",
count,
href,
onClick,
className,
}: {
kind?: "more" | "continue"
/** How many it would show. */
count: number
href?: string
onClick?: () => void
className?: string
}) {
if (!Number.isInteger(count) || count < 1)
throw new Error(`CommentMore: count is at least 1, got ${count}`)
const replies = count === 1 ? "1 reply" : `${count} replies`
const words =
kind === "more"
? `${count} more ${count === 1 ? "reply" : "replies"}`
: `continue this thread (${replies})`
return (
<Button
variant="ghost"
size="sm"
data-slot="comment-more"
data-kind={kind}
icon={kind === "more" ? <Plus /> : undefined}
iconEnd={kind === "continue" ? <ChevronRight /> : undefined}
// biome-ignore lint/a11y/useAnchorContent: the button's words are the link's content
render={href ? <a href={href} /> : undefined}
onClick={onClick}
className={cn(
"-ml-1.5 w-fit text-current/65 hover:text-current",
className,
)}
>
{words}
</Button>
)
}
/** Once the editor has read what was typed into the page: it takes in input on its
* own update, a frame after the keys at most, so two frames are past it. */
const afterEditor = () =>
new Promise<void>((done) =>
requestAnimationFrame(() => requestAnimationFrame(() => done())),
)
/** The reply box, opened inline: the editor as a field, written in markdown, sent
* with ⌘↩ or its button, put away with Esc or cancel. While it sends, nothing more
* can be typed into it (what is sent is what was written); refused, the text stays
* and says why. */
export function CommentComposer({
label,
placeholder = "write a reply",
submit = "reply",
onSubmit,
onClose,
className,
}: {
/** The field's name: "reply to ana". */
label: string
placeholder?: string
/** The send button's word. */
submit?: string
/** The markdown written; a promise returned holds the box until it settles, a
* rejection keeps the text and says why. */
onSubmit: (markdown: string) => unknown
/** Cancelled, or sent. */
onClose: () => void
className?: string
}) {
const c = useComment()
const [text, setText] = React.useState("")
const [sending, setSending] = React.useState(false)
const [refused, setRefused] = React.useState<string | null>(null)
const form = React.useRef<HTMLFormElement>(null)
// What is sent is what the editor holds at the press, read from these, never from a
// render that has not caught up with the last keystroke; one send at a time.
const words = React.useRef("")
const busy = React.useRef(false)
const empty = text.trim() === ""
useEscape(onClose, { active: !sending && !c?.collapsed, root: form })
if (c?.collapsed) return null
const write = (value: string) => {
words.current = value
setText(value)
}
const send = async () => {
const sent = words.current
if (sent.trim() === "" || busy.current) return
busy.current = true
setSending(true)
setRefused(null)
try {
await onSubmit(sent)
onClose()
} catch (cause) {
busy.current = false
setRefused(cause instanceof Error ? cause.message : String(cause))
setSending(false)
// Back to the words, to change them or send again.
requestAnimationFrame(() =>
form.current?.querySelector<HTMLElement>("[role=textbox]")?.focus(),
)
}
}
return (
<form
ref={form}
aria-label={label}
data-slot="comment-composer"
className={cn("mt-2 flex flex-col gap-2", className)}
onSubmit={(event) => {
event.preventDefault()
void send()
}}
// ⌘↩ (⌃↩) sends, as every comment box does. Taken from the editor's own keys
// (prevented), yet let through to it, and the send waits for the editor's next
// update: it reads what was typed into the page a frame later (WebKit later than
// Chromium), and a send at the key itself went without the last characters.
onKeyDownCapture={(event) => {
if (event.key !== "Enter" || !(event.metaKey || event.ctrlKey)) return
event.preventDefault()
void afterEditor().then(send)
}}
>
<div inert={sending}>
<Editor
variant="field"
format="markdown"
images={false}
rows={3}
autoFocus
label={label}
placeholder={placeholder}
onChange={write}
/>
</div>
<div className="flex flex-wrap items-center justify-end gap-2">
{refused && (
<p role="alert" className="mr-auto text-[12px] text-tone-bad">
not sent: {refused}
</p>
)}
<Button variant="ghost" size="sm" onClick={onClose} disabled={sending}>
cancel
</Button>
<Tip content={submit} keys="mod+enter">
<Button
type="submit"
size="sm"
loading={sending}
disabled={empty}
data-slot="comment-send"
>
{submit}
</Button>
</Tip>
</div>
</form>
)
}
// ── the preset ───────────────────────────────────────────────────────────────────
/** One comment as data: a post of lib/comment-thread-core, who wrote it, its words, and
* its standing as a vote or as likes. */
export interface CommentData extends Omit<ThreadPost, "score"> {
/** A key into `people`. */
from: string
/** Markdown text, drawn by markdown. */
markdown?: string
/** Or content rendered already (a forum's HTML as React), worn in markdown's look. */
body?: React.ReactNode
/** A score that is voted up and down (Reddit): drawn by `vote`. */
score?: number
/** Yours, with `score`: 1, -1, 0. */
vote?: VoteValue
/** Or likes (a forum): how many, whether yours is among them, who gave them, as
* many names as are known. Drawn by `reactions`, sorted by the count. */
likes?: { count: number; mine: boolean; who?: readonly string[] }
}
/** A comment as the tree holds it: its standing, whichever it is, as the sort's
* score. */
type Post = CommentData & { score?: number }
export interface CommentThreadProps {
/** Every comment, oldest first: a parent before its replies (lib/comment-thread-core). */
comments: readonly CommentData[]
/** Everyone who wrote, by the key a comment names them with. */
people: Record<string, Person>
/** Each level's order: `old` (as given, a forum's), `new`, `top`, `hot`. */
sort?: ThreadSort
/** The instant `hot` ages comments from. */
now?: number
/** A comment's element id; its permalink's fragment. Default `comment-<id>`. */
domId?: (id: string) => string
/** A comment's permalink, any id including those outside the thread (a quoted post
* on another page). Default `#<domId>`. */
href?: (id: string) => string
/** Where "continue this thread" goes. Absent, the thread draws that comment alone
* in place, with the way back. */
continueHref?: (id: string) => string
onVote?: (id: string, value: VoteValue) => void
onLike?: (id: string) => void
/** A reply written (`parent` null: a new comment); a promise holds the box. Absent,
* nothing offers to reply. */
onReply?: (parent: string | null, markdown: string) => unknown
/** Your verbs for a comment, in its ⋯ menu (report, quote, edit). */
menu?: (comment: CommentData) => readonly MenuEntry[]
/** Levels drawn before "continue this thread". */
maxDepth?: number
/** Comments drawn a level (the top included) before "N more". */
page?: number
/** Comments folded when the thread is first drawn. */
defaultCollapsed?: readonly string[]
/** A like's glyph. */
like?: string
label?: string
material?: Material
scheme?: Scheme
className?: string
}
/** What a view shows: which comments are folded, how many of each level's replies
* are drawn (`""` the top), the comment drawn as the whole thread (continued), the
* open reply box, and the comment to take focus once drawn. */
interface View {
folded: ReadonlySet<string>
shown: Readonly<Record<string, number>>
alone: string | null
replying: { to: string | null } | null
focus: string | null
}
/** A discussion from data: the tree built (a comment quoting several drawn under the
* latest, the rest linked), each comment composed of the parts, the DOM bounded by
* `page` and `maxDepth`. */
export function CommentThread({
comments,
people,
sort = "old",
now,
domId = (id) => `comment-${id}`,
href,
continueHref,
onVote,
onLike,
onReply,
menu,
maxDepth = 6,
page = 8,
defaultCollapsed = [],
like = "❤️",
label = "comments",
material,
scheme,
className,
}: CommentThreadProps) {
if (!Number.isInteger(maxDepth) || maxDepth < 1)
throw new Error(`CommentThread: maxDepth is at least 1, got ${maxDepth}`)
if (!(page >= 1))
throw new Error(`CommentThread: page is at least 1, got ${page}`)
const thread = React.useMemo(() => {
const posts: Post[] = comments.map((c) => {
if (!people[c.from])
throw new Error(`CommentThread: "${c.from}" (${c.id}) is not in people`)
if (c.likes && c.score !== undefined)
throw new Error(`CommentThread: ${c.id} has a score and likes; one`)
if (c.vote !== undefined && c.score === undefined)
throw new Error(`CommentThread: ${c.id} has your vote and no score`)
return c.likes ? { ...c, score: c.likes.count } : c
})
return buildThread(posts, { sort, now })
}, [comments, people, sort, now])
const linkOf = href ?? ((id: string) => `#${domId(id)}`)
const [view, setView] = React.useState<View>(() => ({
folded: new Set(defaultCollapsed),
shown: {},
alone: null,
replying: null,
focus: null,
}))
// A comment asked for (a link followed, a thread left, a box put away) takes focus
// once it is drawn.
React.useEffect(() => {
if (!view.focus) return
focusComment(document.getElementById(domId(view.focus)))
setView((v) => ({ ...v, focus: null }))
}, [view.focus, domId])
const base = view.alone ? nodeOf(thread, view.alone).depth : 0
const reveal = (id: string) =>
setView((v) => revealed(thread, v, id, page, maxDepth))
const draw = (node: ThreadNode<Post>): React.ReactNode => {
const c = node.post
const person = people[c.from] as Person
const depth = node.depth - base
const shown = view.shown[node.id] ?? page
const replying = view.replying?.to === node.id
const fold = (folded: boolean) =>
setView((v) => {
const next = new Set(v.folded)
if (folded) next.add(node.id)
else next.delete(node.id)
return { ...v, folded: next }
})
const reply = onReply
? () =>
setView((v) => ({
...v,
replying: replying ? null : { to: node.id },
}))
: undefined
const also = [
...node.also.map((id) => ({
id,
label: (people[nodeOf(thread, id).post.from] as Person).name,
href: linkOf(id),
})),
...node.outside.map((id) => ({ id, label: `#${id}`, href: linkOf(id) })),
]
return (
<Comment
key={node.id}
id={node.id}
domId={domId(node.id)}
author={person.name}
face={<CommentFace person={person} />}
replies={node.replies}
collapsed={view.folded.has(node.id)}
onCollapsedChange={fold}
>
<CommentHead>
<CommentAuthor person={person} />
<CommentTime at={c.at} href={linkOf(node.id)} />
</CommentHead>
<CommentAlso
to={also}
onFollow={(id) => (thread.byId.has(id) ? reveal(id) : undefined)}
/>
{c.markdown !== undefined ? (
<CommentBody markdown={c.markdown} />
) : (
<CommentBody>{c.body}</CommentBody>
)}
<CommentActions>
{c.likes ? (
<Reactions
aria-label={`likes on ${person.name}'s comment`}
reactions={[{ emoji: like, label: "like", ...c.likes }]}
onToggle={onLike ? () => onLike(node.id) : undefined}
/>
) : (
c.score !== undefined && (
<Vote
compact
size="sm"
orientation="horizontal"
aria-label={`vote on ${person.name}'s comment`}
value={c.vote ?? 0}
score={c.score}
readOnly={!onVote}
onValueChange={(v) => onVote?.(node.id, v)}
/>
)
)}
{reply && <CommentReply open={replying} onClick={reply} />}
<CommentLink href={linkOf(node.id)} />
<CommentMenu
entries={menu?.(c) ?? []}
onReply={reply}
onParent={
node.parent !== null && depth > 0
? () => setView((v) => ({ ...v, focus: node.parent }))
: undefined
}
/>
</CommentActions>
{replying && onReply && (
<CommentComposer
label={`reply to ${person.name}`}
onSubmit={(markdown) => onReply(node.id, markdown)}
onClose={() =>
setView((v) => ({ ...v, replying: null, focus: node.id }))
}
/>
)}
{node.children.length > 0 && (
<CommentReplies>
{depth >= maxDepth - 1 ? (
<CommentMore
kind="continue"
count={node.replies}
href={continueHref?.(node.id)}
onClick={
continueHref
? undefined
: () => setView((v) => ({ ...v, alone: node.id }))
}
/>
) : (
<>
{node.children.slice(0, shown).map(draw)}
{node.children.length > shown && (
<CommentMore
count={Math.min(page, node.children.length - shown)}
onClick={() => setView((v) => more(v, node.id, page))}
/>
)}
</>
)}
</CommentReplies>
)}
</Comment>
)
}
const top = view.alone ? [nodeOf(thread, view.alone)] : thread.roots
const topShown = view.alone ? 1 : (view.shown[""] ?? page)
const writingNew = view.replying !== null && view.replying.to === null
return (
<Comments
label={label}
material={material}
scheme={scheme}
className={className}
data-sort={thread.sort}
>
{view.alone && (
<div
data-slot="comment-thread-alone"
className="flex items-center gap-2"
>
<Button
variant="ghost"
size="sm"
icon={<ArrowLeft />}
onClick={() =>
setView((v) => ({ ...v, alone: null, focus: v.alone }))
}
className="-ml-1.5 text-current/70 hover:text-current"
>
every comment
</Button>
<span className={META}>one thread, continued</span>
</div>
)}
{onReply && !view.alone && (
<div data-slot="comment-thread-new">
{writingNew ? (
<CommentComposer
label="a new comment"
placeholder="add a comment"
submit="comment"
onSubmit={(markdown) => onReply(null, markdown)}
onClose={() => setView((v) => ({ ...v, replying: null }))}
/>
) : (
<Button
variant="outline"
size="sm"
icon={<Plus />}
onClick={() => setView((v) => ({ ...v, replying: { to: null } }))}
>
add a comment
</Button>
)}
</div>
)}
{top.length === 0 && <p className={META}>no comments yet</p>}
{top.slice(0, topShown).map(draw)}
{top.length > topShown && (
<CommentMore
count={Math.min(page, top.length - topShown)}
onClick={() => setView((v) => more(v, "", page))}
/>
)}
</Comments>
)
}
/** One more page of a level's replies (`""` the top). */
function more(v: View, level: string, page: number): View {
return {
...v,
shown: { ...v.shown, [level]: (v.shown[level] ?? page) + page },
}
}
/** The view that shows comment `id` and focuses it: every comment above it unfolded,
* every level's replies drawn past its place, and, when it sits deeper than the
* thread draws, the thread continued from the ancestor that brings it within reach. */
function revealed(
thread: Thread<Post>,
v: View,
id: string,
page: number,
maxDepth: number,
): View {
const path = pathTo(thread, id)
const node = nodeOf(thread, id)
const folded = new Set(v.folded)
const shown = { ...v.shown }
for (const step of path) {
const level = step.parent ?? ""
shown[level] = Math.max(shown[level] ?? page, step.index + 1)
if (step.parent !== null) folded.delete(step.parent)
}
// Drawn from `alone`, if it is above the comment and close enough; else from the
// ancestor maxDepth - 1 levels up, or the top.
const within = (from: string | null) =>
from === null
? node.depth <= maxDepth - 1
: path.some((s) => s.parent === from) &&
node.depth - nodeOf(thread, from).depth <= maxDepth - 1
const ancestorAt = (depth: number) => {
let at = node
while (at.depth > depth) at = nodeOf(thread, at.parent as string)
return at.id
}
const keep = v.alone !== null && (v.alone === id || within(v.alone))
const alone = keep
? v.alone
: within(null)
? null
: ancestorAt(node.depth - (maxDepth - 1))
return { ...v, folded, shown, alone, focus: id }
}// A DISCUSSION'S SHAPE, framework-free: flat posts in, the tree a comment section draws
// out. A server can build it, a test can read it, ui/comment-thread draws it.
//
// const thread = buildThread(posts, { sort: "top", now: Date.now() })
// thread.roots the top-level comments, sorted
// thread.byId.get(id) any comment: its replies, its depth, what else it answers
// pathTo(thread, id) where it sits, for revealing it inside collapsed or paged
// replies
//
// A POST NAMES ITS PARENTS, any number of them. Reddit and Hacker News give one; a
// forum post that quotes three earlier posts answers all three, so a discussion is a
// graph (a DAG), not a tree. A tree view still draws each post once, so:
//
// THE RULE: a post is drawn under its LATEST parent, the one that comes last in the
// posts' order (the most recent post it answers). Every other parent it names is
// listed in `also`, for a "also replying to" link, except a parent that is already
// an ancestor of where it is drawn (answering #3 and #7 where #7 answers #3: #3 is
// right above it, a link would point at what the reader just read). A parent that
// is not among the posts (deleted, on another page of the forum) is in `outside`,
// never invented; a post whose parents are all outside is drawn at the top.
//
// Why the latest: it is the exchange the post continues, it is the deepest of the
// places it could go when the parents are one chain, and under it the post is read
// after everything it answers.
//
// POSTS COME OLDEST FIRST, the order a forum or an API lists them in: every parent
// present comes before the post that names it. That is asserted, and it is what makes
// a cycle impossible to write. `at` (ms since the epoch) is when it was written and
// `score` its standing (votes, likes), both read only by the sort.
//
// THE SORT is the consumer's choice, applied to every set of siblings: `old` (the
// posts' order, a forum's), `new`, `top` (score, then older first) and `hot`
// (Hacker News's gravity: score over age, so a fresh good reply climbs past an old
// one; it reads `now`).
export type ThreadSort = "old" | "new" | "top" | "hot"
export interface ThreadPost {
readonly id: string
/** The posts it answers or quotes, in the post's own order. None: top level. */
readonly parents?: readonly string[]
/** When it was written, ms since the epoch. */
readonly at: number
/** Its standing (a vote score, a like count), for `top` and `hot`. Default 0. */
readonly score?: number
}
export interface ThreadNode<P extends ThreadPost> {
readonly post: P
readonly id: string
/** The parent it is drawn under; null at the top. */
readonly parent: string | null
/** The other parents it answers that are in the thread and not above it. */
readonly also: readonly string[]
/** The parents it names that are not in the thread. */
readonly outside: readonly string[]
/** 0 at the top. */
readonly depth: number
/** Its direct replies, sorted. */
readonly children: readonly ThreadNode<P>[]
/** Every reply under it, at any depth. */
readonly replies: number
}
export interface Thread<P extends ThreadPost> {
readonly roots: readonly ThreadNode<P>[]
readonly byId: ReadonlyMap<string, ThreadNode<P>>
readonly sort: ThreadSort
}
/** Where a post is drawn and what else it answers, decided before the tree is built. */
type Placement = {
parent: string | null
also: readonly string[]
outside: readonly string[]
}
/** Hacker News's gravity: how fast age outweighs score. */
const GRAVITY = 1.8
const HOUR = 3_600_000
/** A post's heat at `now`: its score over its age in hours (plus two, so the first
* hours do not divide by nothing), raised to the gravity. */
export function heat(post: ThreadPost, now: number): number {
const hours = Math.max(0, now - post.at) / HOUR
return (post.score ?? 0) / (hours + 2) ** GRAVITY
}
/** Builds the tree (see the header for the rule). `now` is read by `hot` alone, and
* required there: a sort that read the clock itself would order a server's render and
* the browser's differently. */
export function buildThread<P extends ThreadPost>(
posts: readonly P[],
{ sort = "old", now }: { sort?: ThreadSort; now?: number } = {},
): Thread<P> {
if (sort === "hot" && now === undefined)
throw new Error("comment-thread: the hot sort reads `now`; pass it")
const all = new Set(posts.map((p) => p.id))
const order = new Map<string, number>()
posts.forEach((p, i) => {
if (!p.id) throw new Error(`comment-thread: post ${i} has no id`)
if (order.has(p.id))
throw new Error(`comment-thread: two posts are "${p.id}"`)
if (!Number.isFinite(p.at))
throw new Error(`comment-thread: "${p.id}" has no instant (at ${p.at})`)
if (p.score !== undefined && !Number.isFinite(p.score))
throw new Error(`comment-thread: "${p.id}" scores ${p.score}`)
const parents = p.parents ?? []
if (new Set(parents).size !== parents.length)
throw new Error(`comment-thread: "${p.id}" names a parent twice`)
for (const parent of parents) {
if (parent === p.id)
throw new Error(`comment-thread: "${p.id}" answers itself`)
// Absent from what came before: either outside the thread, or listed after
// the post that answers it, which is the out-of-order list the header forbids.
if (!order.has(parent) && all.has(parent))
throw new Error(
`comment-thread: "${p.id}" answers "${parent}", which comes after it; posts come oldest first`,
)
}
order.set(p.id, i)
})
// Where each post is drawn, decided in order: a parent's place is known before
// any post that answers it.
const placed = new Map<string, string | null>()
const ancestors = (id: string | null): Set<string> => {
const out = new Set<string>()
for (let at = id; at !== null; at = placed.get(at) ?? null) out.add(at)
return out
}
const facts = new Map<string, Placement>()
const kids = new Map<string | null, P[]>()
for (const p of posts) {
const parents = p.parents ?? []
const present = parents.filter((id) => order.has(id))
const outside = parents.filter((id) => !order.has(id))
const parent =
present.length === 0
? null
: present.reduce((a, b) =>
(order.get(a) as number) > (order.get(b) as number) ? a : b,
)
const above = ancestors(parent)
const also = present.filter((id) => id !== parent && !above.has(id))
placed.set(p.id, parent)
facts.set(p.id, { parent, also, outside })
// Local to the build: each list is gathered here once and never seen half-made.
const siblings = kids.get(parent)
if (siblings) siblings.push(p)
else kids.set(parent, [p])
}
const rank = ranker(sort, order, now ?? 0)
const byId = new Map<string, ThreadNode<P>>()
const make = (post: P, depth: number): ThreadNode<P> => {
const f = facts.get(post.id) as Placement
const children = (kids.get(post.id) ?? [])
.toSorted(rank)
.map((c) => make(c, depth + 1))
const node: ThreadNode<P> = {
post,
id: post.id,
parent: f.parent,
also: f.also,
outside: f.outside,
depth,
children,
replies: children.reduce((n, c) => n + 1 + c.replies, 0),
}
byId.set(post.id, node)
return node
}
const roots = (kids.get(null) ?? []).toSorted(rank).map((p) => make(p, 0))
if (byId.size !== posts.length)
throw new Error(
`comment-thread: ${posts.length} posts drew ${byId.size} comments`,
)
return { roots, byId, sort }
}
/** Siblings' order under each sort; the posts' own order breaks every tie, so the
* same posts always come out the same way. */
function ranker(
sort: ThreadSort,
order: ReadonlyMap<string, number>,
now: number,
): (a: ThreadPost, b: ThreadPost) => number {
const place = (p: ThreadPost) => order.get(p.id) as number
switch (sort) {
case "old":
return (a, b) => place(a) - place(b)
case "new":
return (a, b) => b.at - a.at || place(b) - place(a)
case "top":
return (a, b) =>
(b.score ?? 0) - (a.score ?? 0) || a.at - b.at || place(a) - place(b)
case "hot":
return (a, b) => heat(b, now) - heat(a, now) || place(b) - place(a)
}
}
/** One step down to a comment: whose child it is (null: the top level) and its place
* among those siblings, as sorted. */
export interface ThreadStep {
readonly parent: string | null
readonly index: number
}
/** The steps from the top down to `id`, the last being its own place: what a view
* opens (each parent expanded, each list of replies shown past that place) to show
* a comment hidden inside collapsed or paged replies. */
export function pathTo<P extends ThreadPost>(
thread: Thread<P>,
id: string,
): ThreadStep[] {
const node = thread.byId.get(id)
if (!node) throw new Error(`comment-thread: no comment "${id}"`)
const up: ThreadStep[] = []
for (let at: ThreadNode<P> | undefined = node; at; ) {
const parent: string | null = at.parent
const siblings =
parent === null ? thread.roots : nodeOf(thread, parent).children
const index = siblings.indexOf(at)
if (index < 0)
throw new Error(`comment-thread: "${at.id}" is not among its siblings`)
up.push({ parent, index })
at = parent === null ? undefined : nodeOf(thread, parent)
}
return up.reverse()
}
/** The comment `id`, which must be in the thread. */
export function nodeOf<P extends ThreadPost>(
thread: Thread<P>,
id: string,
): ThreadNode<P> {
const node = thread.byId.get(id)
if (!node) throw new Error(`comment-thread: no comment "${id}"`)
return node
}