{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"comment-thread","type":"registry:ui","title":"Comment thread","description":"Threaded comments with replies, reactions, mentions, inline edit, and resolve.","categories":["data"],"dependencies":["lucide-react","motion"],"registryDependencies":["https://uiarc.dev/r/arc-motion-tokens.json"],"files":[{"path":"registry/components/comment-thread/comment-thread.tsx","type":"registry:component","target":"@components/arc/comment-thread/comment-thread.tsx","content":"\"use client\";\n\nimport { forwardRef, useCallback, useEffect, useId, useImperativeHandle, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from \"react\";\nimport type { KeyboardEvent as ReactKeyboardEvent, ReactNode } from \"react\";\nimport { AnimatePresence, animate, motion, useMotionValue, useReducedMotion } from \"motion/react\";\nimport type { Transition, Variants } from \"motion/react\";\nimport { Check, ChevronRight, CornerDownRight, RotateCcw, SmilePlus, X } from \"lucide-react\";\nimport { motionTokens } from \"../lib/motion-tokens\";\nimport styles from \"./comment-thread.module.css\";\n\nexport interface CommentAuthor {\n id: string;\n name: string;\n /** Square image URL. Initials are shown without one. */\n avatar?: string;\n}\n\nexport interface CommentReaction {\n emoji: string;\n /** Ids of the people who reacted. */\n users: string[];\n}\n\nexport interface ThreadComment {\n id: string;\n author: CommentAuthor;\n /** Plain text. \"@Full Name\" mentions of known people are highlighted. */\n body: string;\n /** Display label such as \"2h\" or \"Sep 18\". */\n createdAt: string;\n edited?: boolean;\n /** A deleted comment that still has replies keeps its place as a quiet placeholder. */\n deleted?: boolean;\n reactions?: CommentReaction[];\n replies?: ThreadComment[];\n}\n\n/** What changed, for syncing to a server. `comments` on `onCommentsChange` is always the full next tree. */\nexport type CommentThreadEvent =\n | { type: \"reply\"; comment: ThreadComment; parentId: string | null }\n | { type: \"edit\"; id: string; body: string }\n | { type: \"delete\"; id: string }\n | { type: \"react\"; id: string; emoji: string; added: boolean };\n\n/** Every word the thread shows or announces. Each one has an English default; words with a count or a name are functions, so an app can apply its own plural rules. */\nexport interface CommentThreadLabels {\n /** Accessible name of the whole thread. */\n thread?: string;\n /** Header and resolved chip, such as \"3 comments\". */\n commentCount?: (count: number) => string;\n /** Resolved chip, such as \"2 people\". */\n peopleCount?: (count: number) => string;\n resolve?: string;\n /** Title of the resolved chip. */\n resolved?: string;\n reopen?: string;\n /** Shown when there are no comments. */\n empty?: string;\n reply?: string;\n edit?: string;\n delete?: string;\n cancel?: string;\n send?: string;\n /** Saves an edit. */\n save?: string;\n /** Placeholder and accessible name of the edit field. */\n editComment?: string;\n /** After the time of an edited comment. */\n edited?: string;\n deleteConfirm?: string;\n /** Placeholder text of a deleted comment that still has replies. */\n deletedText?: string;\n /** Accessible name of that placeholder. */\n deletedComment?: string;\n /** Above the composer while replying. The name inside it is highlighted. */\n replyingTo?: (name: string) => string;\n cancelReply?: string;\n showReplies?: (count: number) => string;\n hideReplies?: string;\n addReaction?: string;\n closeReactions?: string;\n /** Accessible name of the reaction picker. */\n reactions?: string;\n /** Accessible name of an emoji in the picker. */\n reactWith?: (emoji: string) => string;\n /** Accessible name of a reaction under a comment. */\n reactionCount?: (emoji: string, count: number, includesYou: boolean) => string;\n /** Accessible name of the @mention suggestions. */\n mentions?: string;\n}\n\nconst DEFAULT_LABELS: Required = {\n thread: \"Comment thread\",\n commentCount: count => `${count} ${count === 1 ? \"comment\" : \"comments\"}`,\n peopleCount: count => `${count} ${count === 1 ? \"person\" : \"people\"}`,\n resolve: \"Resolve\",\n resolved: \"Resolved\",\n reopen: \"Reopen\",\n empty: \"No comments yet. Start the conversation below.\",\n reply: \"Reply\",\n edit: \"Edit\",\n delete: \"Delete\",\n cancel: \"Cancel\",\n send: \"Send\",\n save: \"Save\",\n editComment: \"Edit comment\",\n edited: \"edited\",\n deleteConfirm: \"Delete this comment?\",\n deletedText: \"This comment was deleted\",\n deletedComment: \"Deleted comment\",\n replyingTo: name => `Replying to ${name}`,\n cancelReply: \"Cancel reply\",\n showReplies: count => `Show ${count} ${count === 1 ? \"reply\" : \"replies\"}`,\n hideReplies: \"Hide replies\",\n addReaction: \"Add reaction\",\n closeReactions: \"Close reactions\",\n reactions: \"Reactions\",\n reactWith: emoji => `React with ${emoji}`,\n reactionCount: (emoji, count, includesYou) => `${emoji} ${count}${includesYou ? \", including you\" : \"\"}`,\n mentions: \"People\",\n};\n\n/** A key passed as undefined keeps its default too. */\nconst withDefaults = (defaults: Required, given?: T): Required =>\n ({ ...defaults, ...Object.fromEntries(Object.entries(given ?? {}).filter(([, value]) => value !== undefined)) });\n\n/**\n * A threaded discussion: replies nest under their comment and collapse, reactions toggle with a count that rolls,\n * authors edit and delete their own comments in place, mentions autocomplete in the composer, and resolving folds the\n * whole thread into a single chip that reopens it. Controlled with `comments` and `resolved`, or uncontrolled.\n */\nexport interface CommentThreadProps {\n comments?: ThreadComment[];\n defaultComments?: ThreadComment[];\n onCommentsChange?: (comments: ThreadComment[], event: CommentThreadEvent) => void;\n /** The person writing. Their comments can be edited and deleted. */\n currentUser: CommentAuthor;\n /** People who can be mentioned. Defaults to everyone in the thread. */\n people?: CommentAuthor[];\n resolved?: boolean;\n defaultResolved?: boolean;\n onResolvedChange?: (resolved: boolean) => void;\n /** What the thread is about, shown in its header, such as \"Hero headline\". */\n title?: ReactNode;\n /** Emoji offered by the reaction picker. */\n reactions?: string[];\n placeholder?: string;\n /** Replies deeper than this attach to the deepest allowed parent. Defaults to 2. */\n maxDepth?: number;\n /** Label for comments written now. */\n nowLabel?: string;\n /** Words for localization. Leave out any key to keep its English default. */\n labels?: CommentThreadLabels;\n className?: string;\n}\n\ntype Bezier = [number, number, number, number];\nconst enter = [...motionTokens.ease.enter] as Bezier;\nconst standard = [...motionTokens.ease.standard] as Bezier;\nconst physical = (visualDuration: number, bounce: number): Transition => {\n const root = 2 * Math.PI / (visualDuration * 1.2);\n return { type: \"spring\", stiffness: root * root, damping: 2 * (1 - bounce) * root, mass: 1 };\n};\nconst HEIGHT = physical(.42, 0);\nconst DEFAULT_REACTIONS = [\"๐Ÿ‘\", \"โค๏ธ\", \"๐ŸŽ‰\", \"๐Ÿ‘€\", \"๐Ÿš€\", \"โœ…\"];\n\nconst subscribe = () => () => {};\nfunction useReducedFlag() {\n const hydrated = useSyncExternalStore(subscribe, () => true, () => false);\n return !!useReducedMotion() && hydrated;\n}\n\n/* Tree helpers. Every change returns a new tree so controlled parents can store it as is. */\nconst mapTree = (list: ThreadComment[], id: string, fn: (comment: ThreadComment) => ThreadComment | null): ThreadComment[] =>\n list.flatMap(comment => {\n if (comment.id === id) { const next = fn(comment); return next ? [next] : []; }\n return comment.replies?.length ? [{ ...comment, replies: mapTree(comment.replies, id, fn) }] : [comment];\n });\nconst countTree = (list: ThreadComment[]): number => list.reduce((sum, comment) => sum + (comment.deleted ? 0 : 1) + countTree(comment.replies ?? []), 0);\nconst authorsOf = (list: ThreadComment[], into = new Map()) => {\n list.forEach(comment => { if (!comment.deleted) into.set(comment.author.id, comment.author); authorsOf(comment.replies ?? [], into); });\n return into;\n};\n/** Path of ids from the root to `id`. */\nconst pathTo = (list: ThreadComment[], id: string, trail: string[] = []): string[] | null => {\n for (const comment of list) {\n if (comment.id === id) return [...trail, id];\n const found = pathTo(comment.replies ?? [], id, [...trail, comment.id]);\n if (found) return found;\n }\n return null;\n};\nconst find = (list: ThreadComment[], id: string): ThreadComment | null => {\n for (const comment of list) {\n if (comment.id === id) return comment;\n const found = find(comment.replies ?? [], id);\n if (found) return found;\n }\n return null;\n};\n/** Wraps the first `part` inside `text`, so a translated sentence keeps its highlighted name wherever the language puts it. */\nconst highlight = (text: string, part: string, className?: string): ReactNode => {\n const at = part ? text.indexOf(part) : -1;\n return at < 0 ? text : <>{text.slice(0, at)}{part}{text.slice(at + part.length)};\n};\nconst initials = (name: string) => name.split(/\\s+/).map(part => part[0]).slice(0, 2).join(\"\").toUpperCase();\nlet serial = 0;\nconst newId = () => `c-${Date.now().toString(36)}-${(serial++).toString(36)}`;\n\nfunction Avatar({ author, size = 28 }: { author: CommentAuthor; size?: number }) {\n return author.avatar\n // eslint-disable-next-line @next/next/no-img-element\n ? \"\"\n : {initials(author.name)};\n}\n\n/** Highlights \"@Full Name\" for known people. */\nfunction Body({ text, people }: { text: string; people: CommentAuthor[] }) {\n const parts = useMemo(() => {\n const names = people.map(person => person.name).sort((a, b) => b.length - a.length).map(name => name.replace(/[.*+?^${}()|[\\]\\\\]/g, \"\\\\$&\"));\n if (!names.length) return [text];\n return text.split(new RegExp(`(@(?:${names.join(\"|\")}))`, \"g\"));\n }, [people, text]);\n return

{parts.map((part, index) => index % 2 ? {part} : part)}

;\n}\n\nconst roll: Variants = {\n hidden: (dir: number) => ({ y: `${dir * 70}%`, opacity: 0 }),\n shown: { y: 0, opacity: 1 },\n gone: (dir: number) => ({ y: `${dir * -70}%`, opacity: 0 }),\n};\nconst rollReduced: Variants = { hidden: { opacity: 0 }, shown: { opacity: 1 }, gone: { opacity: 0 } };\n\n/** Rolls a count up or down in place. */\nfunction Count({ value, reduced }: { value: number; reduced: boolean }) {\n const [last, setLast] = useState(value);\n const [dir, setDir] = useState(1);\n if (value !== last) { setDir(value > last ? 1 : -1); setLast(value); }\n return \n \n {value}\n \n ;\n}\n\n/** Follows its content's height exactly, except when `morphKey` changes: then it springs, so the thread and the resolved chip morph into each other. */\nfunction AutoHeight({ children, reduced, morphKey }: { children: ReactNode; reduced: boolean; morphKey: string }) {\n const inner = useRef(null);\n const height = useMotionValue(0);\n const [measured, setMeasured] = useState(false);\n const morphing = useRef(false);\n const lastKey = useRef(morphKey);\n useLayoutEffect(() => {\n if (lastKey.current !== morphKey) { lastKey.current = morphKey; morphing.current = true; }\n }, [morphKey]);\n useLayoutEffect(() => {\n const node = inner.current;\n if (!node || typeof ResizeObserver === \"undefined\") return;\n // The border box keeps subpixel line heights, so the frame never clips a fraction of the last line.\n const observer = new ResizeObserver(([entry]) => {\n const next = entry?.borderBoxSize?.[0]?.blockSize ?? node.offsetHeight;\n if (morphing.current && !reduced) {\n morphing.current = false;\n animate(height, next, HEIGHT);\n return;\n }\n if (height.isAnimating()) { animate(height, next, HEIGHT); return; }\n height.jump(next);\n setMeasured(true);\n });\n observer.observe(node);\n return () => observer.disconnect();\n }, [height, reduced]);\n return
{children}
;\n}\n\ninterface ComposerProps {\n people: CommentAuthor[];\n initial?: string;\n placeholder: string;\n submitLabel: string;\n autoFocus?: boolean;\n focusKey?: number;\n onSubmit: (body: string) => void;\n onCancel?: () => void;\n leading?: ReactNode;\n compact?: boolean;\n cancelLabel: string;\n mentionsLabel: string;\n}\n\n/** A growing text field with @mention suggestions. โŒ˜ or Ctrl + Enter sends; Escape cancels. */\nfunction Composer({ people, initial = \"\", placeholder, submitLabel, autoFocus, focusKey, onSubmit, onCancel, leading, compact, cancelLabel, mentionsLabel }: ComposerProps) {\n const uid = useId().replace(/[^a-zA-Z0-9_-]/g, \"\");\n const reduced = useReducedFlag();\n const field = useRef(null);\n const [text, setText] = useState(initial);\n const [query, setQuery] = useState<{ start: number; term: string } | null>(null);\n const [active, setActive] = useState(0);\n const suggestions = useMemo(() => {\n if (!query) return [];\n const term = query.term.toLowerCase();\n return people.filter(person => person.name.toLowerCase().split(\" \").some(part => part.startsWith(term)) || person.name.toLowerCase().startsWith(term)).slice(0, 5);\n }, [people, query]);\n const open = !!query && suggestions.length > 0;\n\n useEffect(() => {\n if (!autoFocus && !focusKey) return;\n const node = field.current;\n if (!node) return;\n node.focus({ preventScroll: true });\n node.setSelectionRange(node.value.length, node.value.length);\n }, [autoFocus, focusKey]);\n\n const readQuery = (value: string, caret: number) => {\n const match = /(^|\\s)@([\\p{L}]*)$/u.exec(value.slice(0, caret));\n setQuery(match ? { start: caret - match[2]!.length - 1, term: match[2]! } : null);\n setActive(0);\n };\n\n const insert = (person: CommentAuthor) => {\n const node = field.current;\n if (!node || !query) return;\n const caret = node.selectionStart ?? text.length;\n const next = `${text.slice(0, query.start)}@${person.name} ${text.slice(caret)}`;\n const at = query.start + person.name.length + 2;\n setText(next);\n setQuery(null);\n requestAnimationFrame(() => { node.focus(); node.setSelectionRange(at, at); });\n };\n\n const send = () => {\n const body = text.trim();\n if (!body) return;\n onSubmit(body);\n setText(\"\");\n setQuery(null);\n };\n\n const onKeyDown = (event: ReactKeyboardEvent) => {\n if (open) {\n if (event.key === \"ArrowDown\" || event.key === \"ArrowUp\") { event.preventDefault(); setActive(index => (index + (event.key === \"ArrowDown\" ? 1 : -1) + suggestions.length) % suggestions.length); return; }\n if (event.key === \"Enter\" || event.key === \"Tab\") { event.preventDefault(); insert(suggestions[active]!); return; }\n if (event.key === \"Escape\") { event.preventDefault(); event.stopPropagation(); setQuery(null); return; }\n }\n if (event.key === \"Enter\" && (event.metaKey || event.ctrlKey)) { event.preventDefault(); send(); return; }\n if (event.key === \"Escape\" && onCancel) { event.preventDefault(); event.stopPropagation(); onCancel(); }\n };\n\n return
\n {leading}\n
\n