build69
build69  /  Components  /  Chat & Collab  /  CommentThread

CommentThread

v1.0.31Updated 20 Oct 2026

Threaded discussion component — nested replies, reactions, role badges, rich-text bodies and score/sort controls for internal comment feeds.

Preview

Live · 14 stories
DefaultOpen ↗
▸Why this is recommendedcommentsthreadredditdiscussion+8

The default, out-of-the-box CommentThread.

Recommended for+14
  • ✓Pulse discussion threads
  • ✓Casebook ticket conversations
  • ✓document review comments
  • ✓community/forum pages
  • ✓a plain comment log on a leave or expense request, where each note is plain text with files attached
  • ✓internal notes on a record, with no voting
  • ✓an in-app bug reporter where a comment carries a screenshot
  • ✓image comments, where a reply can show a picture
Tags+6
commentsthreadredditdiscussionrepliesvotesmentionsreactionscommunityimagesattachmentsdefault
Features+4
  • Nested reply tree with Reddit-style collapse rails and collapsed summary rows
  • Up/down voting with optimistic local score and aria-pressed state; under Best/Top a vote re-ranks the comment at once (same live score drives the number and the order, ties oldest first, focus stays on the vote button)
  • Inline reply + edit composers built on RichTextEditor; async submit with pending state
  • Sort by Best / Top / New / Old (pinned comments always first)
  • Pinned, [deleted] and [removed by moderator] states
  • Emoji reaction chips with reacted state, counts, and an add-reaction picker
  • @mention highlighting with onMentionClick (event-delegated, DOMPurify-sanitized bodies)
  • Load more replies (async spinner) and Continue-thread past maxDepth (opens the next maxDepth levels in place unless onContinueThread is supplied)
  • Locked-thread banner that disables all composers
  • Own-comment Edit / Delete (Popconfirm) via a per-comment menu; Report for others only when onReport is supplied (no empty menu trigger is rendered)
  • Focus is handed on by every action, so the thread is usable with the keyboard alone: choosing Edit or Reply lands the caret in the composer after the existing text, Save / Cancel / Send return focus to the control that opened it, confirming Delete focuses the comment it emptied, collapse and expand move to the control that replaced the one pressed, removing the last reaction moves to the add-reaction button, and an action that opens nothing (Report, a dismissed menu) leaves focus on the trigger. Reported by ace-erp-app-phase2 as fb_e14ddbdb, where Edit left the caret on the more-actions trigger and typing went nowhere
  • Read-only mode when currentUser is omitted
  • Business-log mode: votes={false} drops the scores and the sort control, allowReplies={false} flattens the thread, showSort re-adds a New/Old picker
  • Plain-text mode (bodyFormat="text"): bodies are rendered literally as text (a body of <b>hi</b> shows those characters, never bold) with line breaks kept, and the composer is a Textarea — for an API that stores plain text and must not accept markup
  • Per-comment file attachments (name + optional host-formatted size + a download handler) and an attach control on the reply composers; picked File objects reach the host via onReply. The component stores and uploads nothing, and an attachment with no handler renders as a chip instead of a dead button
  • Attachment names are rendered as text with control and bidi-override characters stripped, so a file cannot inject markup or disguise its extension
  • Images in a body, opt-in: allowImages adds <img> to the sanitizer allow-list (src/alt/title/width/height only — no srcset, no style, no event handlers), and allowImageSrc is the host URL allow-list every surviving image is judged by. The default policy admits relative and http(s) URLs only, so javascript:, data: and scheme-relative tracking URLs never load; the component forces loading=lazy, decoding=async and referrerpolicy=no-referrer, and gives an image with no alt an empty one
  • Per-comment host slot: renderFooter(node) renders anything of your own under one comment (an attachment strip, an image the host must fetch with its own credentials, a workflow control) and is suppressed on deleted/removed comments like the body is. Every comment also carries data-slot="comment-thread-comment" and data-comment-id, so a host can target one comment without matching on its text
  • i18n: every built-in string overridable via the labels prop (CommentThreadLabels); relative/absolute timestamps and all counts follow LocaleProvider (Intl-driven, Buddhist-calendar aware, no bundled locale data)
Not recommended for−2
  • ✕Real-time chat (use Chat)
  • ✕Flat activity feeds (use Timeline)

Install

Ask your agent, or call the MCP tool directly. The resolved bundle is written into your project.

get_registry_item({ name: "CommentThread" })

Usage

import { CommentThread } from '@build69/ui';

<CommentThread defaultComments={initialTree} currentUser={me} onReply={(parentId, html) => api.addComment(parentId, html)} />

Tick variants in the left menu — each adds a tab with that variant’s real story source, the same code the preview above renders.

Props

PropTypeRequired
editorLabels

Strings for the RichTextEditor the composer renders under bodyFormat='html' — its toolbar, block-type menu and link popover. A separate bag because they are another component's words; without it a thread translated to the last key still showed an English toolbar (fb_75d1c537).

Partial<RichTextEditorLabels>—
comments

Controlled tree: id, author, body (HTML, or plain text under bodyFormat="text"), createdAt, score (optional), children, pinned, deleted, reactions, attachments, moreCount

CommentNode[]—
defaultComments

Uncontrolled mode: the thread manages its own tree; all mutations work with zero wiring

CommentNode[]—
votes

Up/down voting (default true). false removes the vote buttons, the score readout and the sort control — the plain business comment log. Best/Top then degrade to oldest-first, since there is no score to rank by

boolean—
allowReplies

Inline replies (default true). false gives a FLAT log: no Reply buttons and no reply composers below the root one

boolean—
showSort

Show the sort picker. Defaults to the value of votes; forced on with votes off, only New / Old are offered

boolean—
bodyFormat

How CommentNode.body is interpreted (default html, DOMPurify-sanitized, RichTextEditor composer). "text" renders the body LITERALLY as text (never parsed as markup, line breaks preserved) and composes with a Textarea

'html' | 'text'—
allowAttachments

Show an attach control on the reply composers (default false). Picked files arrive as the third argument of onReply; this component uploads and stores nothing

boolean—
acceptAttachments

accept for the composer file input, e.g. "application/pdf,image/png,image/jpeg". A browser hint only — the host must re-validate type and size on the server

string—
maxAttachments

Cap on files per composer submission; the attach control disables at the limit

number—
onDownloadAttachment

Fallback download handler for attachments with no onDownload of their own. With neither, an attachment renders as a plain chip (no dead button)

(commentId, attachment: CommentAttachment) => void—
allowImages

Allow <img> in an HTML body (default false). Off by default because a body is HTML written by someone else, and an image tag makes the reader browser fetch a URL that author chose. No effect under bodyFormat="text"

boolean—
allowImageSrc

The host URL allow-list, checked on every surviving <img> with the src EXACTLY as the author wrote it (never resolved against the page). Defaults to isSafeCommentImageSrc: relative and absolute http(s) only. Replace it to pin an origin, or to permit an object URL the host created. Only consulted with allowImages on

(src: string) => boolean—
renderFooter

Render host content under one comment — an attachment strip, a workflow control, an image the host has to fetch with its own credentials. Runs for every comment (return null to add nothing) and sits below the body and the built-in attachment list

(node: CommentNode) => ReactNode—
reactionPreset

Emoji offered by the add-reaction picker

string[]—
currentUser

Enables composers and own-comment edit/delete; omit for read-only

CommentUser—
sort

Controlled sort (defaultSort for uncontrolled). best and top both rank by live net score (including the vote just cast by the reader), ties oldest first; pinned always first

'best' | 'top' | 'new' | 'old'—
locked

Locked banner; disables replying

boolean—
maxDepth

Nesting depth before "Continue this thread" (default 6)

number—
onContinueThread

"Continue this thread" handler (e.g. route to a permalink). Omit to open the next maxDepth levels in place

(id) => void—
onVote

Vote changes (0 = retracted)

(id, vote: 1 | 0 | -1) => void—
onReply

New comment; parentId null = top level. body is HTML, or plain text under bodyFormat="text". attachments carries the files picked in the composer (only with allowAttachments)

(parentId: string | null, body: string, attachments?: File[]) => void | Promise<void>—
onEdit

Save an edited comment. Controlled mode: the Edit menu item renders only when supplied

(id, body) => void | Promise<void>—
onDelete

Confirmed via Popconfirm. Controlled mode: the Delete menu item renders only when supplied

(id) => void | Promise<void>—
onLoadMore

Fetch the moreCount replies of a node

(id) => void | Promise<void>—
onReact

Toggle a reaction chip

(id, emoji) => void—
onMentionClick

Click/Enter on an @mention

(username) => void—
onShare

Share action; defaults to copying the #comment-<id> link to the clipboard

(id) => void—
onReport

Report a comment by someone else. The Report menu item (and, when it is the only action, the More-actions trigger) renders only when supplied

(id) => void—
labels

Override any built-in UI string (i18n); merged over English defaults (DEFAULT_COMMENT_THREAD_LABELS)

Partial<CommentThreadLabels>—

Related