{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "comment-thread",
  "title": "Comment Thread",
  "description": "Threaded review comments with replies, @mentions, edit and delete, reactions and resolve.",
  "dependencies": [
    "lucide-react"
  ],
  "registryDependencies": [
    "utils",
    "avatar",
    "button",
    "@drift-ui/mention-textarea",
    "@drift-ui/use-controllable-state"
  ],
  "files": [
    {
      "path": "registry/default/ui/comment-thread.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport {\n  CircleCheckIcon,\n  PencilIcon,\n  ReplyIcon,\n  SmilePlusIcon,\n  Trash2Icon,\n} from \"lucide-react\"\n\nimport { useControllableState } from \"@/registry/default/hooks/use-controllable-state\"\nimport {\n  addComment,\n  buildThread,\n  countVisible,\n  deleteComment,\n  describeReactors,\n  editComment,\n  extractMentions,\n  formatAbsoluteTime,\n  formatRelativeTime,\n  hasReacted,\n  initialsOf,\n  parseBody,\n  QUICK_REACTIONS,\n  toDate,\n  toggleReaction,\n  type Comment,\n  type CommentAuthor,\n  type ReactionOption,\n} from \"@/registry/default/lib/comment-thread.ts\"\nimport type { MentionOption } from \"@/registry/default/lib/mention\"\nimport { cn } from \"@/registry/default/lib/utils\"\nimport {\n  Avatar,\n  AvatarFallback,\n  AvatarImage,\n} from \"@/registry/default/ui/avatar\"\nimport { Button } from \"@/registry/default/ui/button\"\nimport { MentionTextarea } from \"@/registry/default/ui/mention-textarea\"\n\ntype MaybePromise = void | Promise<void>\n\nfunction createId() {\n  return (\n    globalThis.crypto?.randomUUID?.() ??\n    `comment-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`\n  )\n}\n\nfunction useNow(override: Date | undefined) {\n  const [now, setNow] = React.useState(() => new Date())\n  React.useEffect(() => {\n    if (override) return\n    const interval = setInterval(() => setNow(new Date()), 60_000)\n    return () => clearInterval(interval)\n  }, [override])\n  return override ?? now\n}\n\nfunction useLatest<T>(value: T) {\n  const ref = React.useRef(value)\n  React.useLayoutEffect(() => {\n    ref.current = value\n  })\n  return ref\n}\n\nfunction pluralize(count: number, word: string) {\n  return `${count} ${word}${count === 1 ? \"\" : \"s\"}`\n}\n\nfunction CommentAvatar({\n  author,\n  size,\n}: {\n  author: CommentAuthor\n  size: \"default\" | \"sm\"\n}) {\n  return (\n    <Avatar className={size === \"sm\" ? \"size-6\" : \"size-8\"}>\n      {author.avatarUrl && <AvatarImage src={author.avatarUrl} alt=\"\" />}\n      <AvatarFallback\n        className={cn(\"font-medium\", size === \"sm\" ? \"text-[10px]\" : \"text-xs\")}\n      >\n        {initialsOf(author.name)}\n      </AvatarFallback>\n    </Avatar>\n  )\n}\n\nfunction CommentComposer({\n  label,\n  placeholder,\n  submitLabel,\n  defaultValue = \"\",\n  suggestions,\n  failureMessage,\n  autoFocus,\n  focusKey,\n  actions,\n  author,\n  avatarSize = \"default\",\n  onSubmit,\n  onCancel,\n}: {\n  label: string\n  placeholder: string\n  submitLabel: string\n  defaultValue?: string\n  suggestions: MentionOption[]\n  failureMessage: string\n  autoFocus?: boolean\n  focusKey: string\n  actions?: React.ReactNode\n  author?: CommentAuthor\n  avatarSize?: \"default\" | \"sm\"\n  onSubmit: (body: string) => MaybePromise\n  onCancel?: () => void\n}) {\n  const errorId = React.useId()\n  const wrapperRef = React.useRef<HTMLDivElement>(null)\n  const [draft, setDraft] = React.useState(defaultValue)\n  const [hasFailed, setHasFailed] = React.useState(false)\n  const [isPending, startTransition] = React.useTransition()\n  const isBlank = draft.trim() === \"\"\n\n  React.useEffect(() => {\n    if (!autoFocus) return\n    const textarea = wrapperRef.current?.querySelector(\"textarea\")\n    if (!textarea) return\n    textarea.focus()\n    textarea.setSelectionRange(textarea.value.length, textarea.value.length)\n  }, [autoFocus])\n\n  function submit() {\n    if (isBlank || isPending) return\n    const body = draft.trim()\n    setHasFailed(false)\n    startTransition(async () => {\n      try {\n        await onSubmit(body)\n        setDraft(\"\")\n      } catch {\n        setHasFailed(true)\n      }\n    })\n  }\n\n  function handleKeyDown(event: React.KeyboardEvent<HTMLTextAreaElement>) {\n    if (event.nativeEvent.isComposing) return\n    if (event.currentTarget.hasAttribute(\"aria-controls\")) return\n    if (event.key === \"Enter\" && (event.metaKey || event.ctrlKey)) {\n      event.preventDefault()\n      submit()\n    } else if (event.key === \"Escape\" && onCancel && !isPending) {\n      event.preventDefault()\n      onCancel()\n    }\n  }\n\n  return (\n    <div\n      ref={wrapperRef}\n      data-slot=\"comment-thread-composer\"\n      className={cn(\n        \"grid gap-x-3\",\n        author ? \"grid-cols-[auto_minmax(0,1fr)]\" : \"grid-cols-1\"\n      )}\n    >\n      {author && (\n        <div aria-hidden>\n          <CommentAvatar author={author} size={avatarSize} />\n        </div>\n      )}\n      <div className=\"flex min-w-0 flex-col gap-2\">\n        <MentionTextarea\n          data-focus-key={focusKey}\n          aria-label={label}\n          aria-invalid={hasFailed || undefined}\n          aria-describedby={hasFailed ? errorId : undefined}\n          aria-keyshortcuts=\"Control+Enter Meta+Enter\"\n          placeholder={placeholder}\n          value={draft}\n          onValueChange={setDraft}\n          readOnly={isPending}\n          triggers={suggestions.length > 0 ? { \"@\": suggestions } : {}}\n          onKeyDown={handleKeyDown}\n          className=\"min-h-20 bg-background aria-invalid:border-destructive aria-invalid:ring-destructive/20\"\n        />\n        {hasFailed && (\n          <p id={errorId} role=\"alert\" className=\"text-sm text-destructive\">\n            {failureMessage}\n          </p>\n        )}\n        <div className=\"flex flex-wrap items-center gap-2\">\n          {actions}\n          <div className=\"ml-auto flex items-center gap-2\">\n            {onCancel && (\n              <Button\n                type=\"button\"\n                variant=\"ghost\"\n                size=\"sm\"\n                disabled={isPending}\n                onClick={onCancel}\n              >\n                Cancel\n              </Button>\n            )}\n            <Button\n              type=\"button\"\n              size=\"sm\"\n              disabled={isBlank || isPending}\n              aria-busy={isPending || undefined}\n              onClick={submit}\n            >\n              {submitLabel}\n            </Button>\n          </div>\n        </div>\n      </div>\n    </div>\n  )\n}\n\nfunction ReactionPicker({\n  id,\n  commentId,\n  options,\n  reacted,\n  onPick,\n  onClose,\n  onDismiss,\n}: {\n  id: string\n  commentId: string\n  options: ReactionOption[]\n  reacted: Set<string>\n  onPick: (emoji: string) => void\n  onClose: () => void\n  onDismiss: () => void\n}) {\n  const groupRef = React.useRef<HTMLDivElement>(null)\n  const [activeIndex, setActiveIndex] = React.useState(0)\n  const latestDismiss = useLatest(onDismiss)\n  const toggleSelector = `[data-focus-key=\"react:${commentId}\"]`\n\n  const isInside = React.useCallback(\n    (target: EventTarget | null) => {\n      if (!(target instanceof Node)) return false\n      if (groupRef.current?.contains(target)) return true\n      return (\n        target instanceof Element && target.closest(toggleSelector) !== null\n      )\n    },\n    [toggleSelector]\n  )\n\n  React.useEffect(() => {\n    groupRef.current?.querySelector(\"button\")?.focus()\n  }, [])\n\n  React.useEffect(() => {\n    function handlePointerDown(event: PointerEvent) {\n      if (!isInside(event.target)) latestDismiss.current()\n    }\n    document.addEventListener(\"pointerdown\", handlePointerDown)\n    return () => document.removeEventListener(\"pointerdown\", handlePointerDown)\n  }, [isInside, latestDismiss])\n\n  function handleKeyDown(event: React.KeyboardEvent<HTMLDivElement>) {\n    const buttons = [\n      ...(groupRef.current?.querySelectorAll(\"button\") ?? []),\n    ] as HTMLButtonElement[]\n    const index = buttons.indexOf(event.target as HTMLButtonElement)\n    const moves: Record<string, number> = {\n      ArrowRight: index + 1,\n      ArrowLeft: index - 1,\n      Home: 0,\n      End: buttons.length - 1,\n    }\n    if (event.key === \"Escape\") {\n      event.preventDefault()\n      onClose()\n    } else if (event.key in moves && index !== -1) {\n      event.preventDefault()\n      const next = (moves[event.key] + buttons.length) % buttons.length\n      buttons[next]?.focus()\n    }\n  }\n\n  return (\n    <div\n      ref={groupRef}\n      id={id}\n      role=\"group\"\n      aria-label=\"Pick a reaction\"\n      data-slot=\"comment-thread-reaction-picker\"\n      data-comment-id={commentId}\n      onKeyDown={handleKeyDown}\n      onBlur={(event) => {\n        if (event.relatedTarget && !isInside(event.relatedTarget)) onDismiss()\n      }}\n      className=\"absolute right-0 bottom-full z-20 mb-1 flex w-max origin-bottom-right gap-0.5 rounded-lg border bg-popover p-1 text-popover-foreground shadow-md motion-safe:animate-in motion-safe:fade-in-0 motion-safe:zoom-in-95 motion-safe:slide-in-from-bottom-1 pointer-coarse:grid pointer-coarse:grid-cols-4 pointer-coarse:gap-2\"\n    >\n      {options.map((option, index) => (\n        <button\n          key={option.emoji}\n          type=\"button\"\n          tabIndex={index === activeIndex ? 0 : -1}\n          onFocus={() => setActiveIndex(index)}\n          aria-label={option.label}\n          aria-pressed={reacted.has(option.emoji)}\n          title={option.label}\n          onClick={() => onPick(option.emoji)}\n          className=\"flex size-8 items-center justify-center rounded-md text-base outline-none hover:bg-accent focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-pressed:bg-accent pointer-coarse:size-11\"\n        >\n          {option.emoji}\n        </button>\n      ))}\n    </div>\n  )\n}\n\nfunction CommentBody({\n  body,\n  suggestions,\n}: {\n  body: string\n  suggestions: MentionOption[]\n}) {\n  return (\n    <p\n      data-slot=\"comment-thread-body\"\n      className=\"text-sm leading-relaxed wrap-break-word whitespace-pre-wrap\"\n    >\n      {parseBody(body, suggestions).map((segment, index) =>\n        segment.type === \"mention\" ? (\n          <span\n            key={index}\n            data-slot=\"comment-thread-mention\"\n            title={segment.option.label}\n            className=\"rounded-sm bg-primary/10 px-0.5 font-medium text-primary\"\n          >\n            {segment.text}\n          </span>\n        ) : (\n          <React.Fragment key={index}>{segment.text}</React.Fragment>\n        )\n      )}\n    </p>\n  )\n}\n\nconst actionButtonClassName =\n  \"size-7 text-muted-foreground hover:text-foreground pointer-coarse:size-11\"\n\nfunction CommentThread({\n  comments,\n  defaultComments = [],\n  onCommentsChange,\n  currentUser,\n  mentionSuggestions = [],\n  reactions = QUICK_REACTIONS,\n  resolved,\n  defaultResolved = false,\n  onResolvedChange,\n  resolvedBy,\n  onReply,\n  onEdit,\n  onDelete,\n  onReactionToggle,\n  now: nowOverride,\n  locale,\n  className,\n  \"aria-label\": ariaLabel = \"Comment thread\",\n  ref,\n  ...props\n}: Omit<React.ComponentProps<\"section\">, \"children\"> & {\n  comments?: Comment[]\n  defaultComments?: Comment[]\n  onCommentsChange?: (comments: Comment[]) => void\n  currentUser?: CommentAuthor\n  mentionSuggestions?: MentionOption[]\n  reactions?: ReactionOption[]\n  resolved?: boolean\n  defaultResolved?: boolean\n  onResolvedChange?: (resolved: boolean) => void\n  resolvedBy?: CommentAuthor\n  onReply?: (\n    parentId: string | null,\n    body: string,\n    mentions: string[]\n  ) => MaybePromise\n  onEdit?: (id: string, body: string, mentions: string[]) => MaybePromise\n  onDelete?: (id: string) => MaybePromise\n  onReactionToggle?: (id: string, emoji: string) => void\n  now?: Date\n  locale?: string\n}) {\n  const id = React.useId()\n  const rootRef = React.useRef<HTMLElement>(null)\n  const { value: list, setValue: setList } = useControllableState({\n    value: comments,\n    defaultValue: defaultComments,\n    onChange: onCommentsChange,\n  })\n  const latestListRef = useLatest(list)\n  const { value: isResolved, setValue: setIsResolved } = useControllableState({\n    value: resolved,\n    defaultValue: defaultResolved,\n    onChange: onResolvedChange,\n  })\n  const [resolver, setResolver] = React.useState<CommentAuthor>()\n  const [isExpanded, setIsExpanded] = React.useState(false)\n  const [editingId, setEditingId] = React.useState<string | null>(null)\n  const [reply, setReply] = React.useState<{\n    rootId: string\n    originId: string\n    prefill: string\n  } | null>(null)\n  const [confirmingId, setConfirmingId] = React.useState<string | null>(null)\n  const [deleteFailedId, setDeleteFailedId] = React.useState<string | null>(\n    null\n  )\n  const [pickerId, setPickerId] = React.useState<string | null>(null)\n  const latestEditingIdRef = useLatest(editingId)\n  const latestReplyRef = useLatest(reply)\n  const pendingFocusRef = React.useRef<string[] | null>(null)\n  const pendingRepliesRef = React.useRef(new Map<string, number>())\n  const [announcement, setAnnouncement] = React.useState(\"\")\n  const [isDeleting, startDeleting] = React.useTransition()\n  const now = useNow(nowOverride)\n\n  const nodes = buildThread(list)\n  const visibleCount = countVisible(nodes)\n  const order = nodes.flatMap((node) => [\n    node.comment.id,\n    ...node.replies.map((comment) => comment.id),\n  ])\n  const names = new Map<string, string>()\n  for (const comment of list) names.set(comment.author.id, comment.author.name)\n  if (currentUser) names.set(currentUser.id, currentUser.name)\n  const reactionLabels = new Map(\n    reactions.map((option) => [option.emoji, option.label])\n  )\n  const showBody = !isResolved || isExpanded\n  const canWrite = currentUser !== undefined\n  const resolvedByAuthor = resolvedBy ?? resolver\n\n  React.useEffect(() => {\n    const keys = pendingFocusRef.current\n    if (!keys) return\n    pendingFocusRef.current = null\n    const targets = [\n      ...(rootRef.current?.querySelectorAll<HTMLElement>(\"[data-focus-key]\") ??\n        []),\n    ]\n    for (const key of keys) {\n      const target = targets.find((element) => element.dataset.focusKey === key)\n      if (target) {\n        target.focus()\n        return\n      }\n    }\n  })\n\n  function focusAfterRender(keys: string[]) {\n    pendingFocusRef.current = keys\n  }\n\n  function isOwn(comment: Comment) {\n    return currentUser !== undefined && comment.author.id === currentUser.id\n  }\n\n  const setRootRef = React.useCallback(\n    (node: HTMLElement | null) => {\n      rootRef.current = node\n      if (typeof ref === \"function\") return ref(node)\n      if (ref) ref.current = node\n    },\n    [ref]\n  )\n\n  function commitList(update: (current: Comment[]) => Comment[]) {\n    const next = update(latestListRef.current)\n    latestListRef.current = next\n    setList(next)\n  }\n\n  async function postComment(parentId: string | null, body: string) {\n    if (!currentUser) return\n    const pending = pendingRepliesRef.current\n    if (parentId) pending.set(parentId, (pending.get(parentId) ?? 0) + 1)\n    try {\n      await onReply?.(parentId, body, extractMentions(body, mentionSuggestions))\n    } finally {\n      if (parentId) {\n        const left = (pending.get(parentId) ?? 1) - 1\n        if (left > 0) pending.set(parentId, left)\n        else pending.delete(parentId)\n      }\n    }\n    commitList((current) =>\n      addComment(current, {\n        id: createId(),\n        parentId,\n        author: currentUser,\n        body,\n        createdAt: new Date(),\n      })\n    )\n    setAnnouncement(parentId ? \"Reply posted\" : \"Comment posted\")\n  }\n\n  function startReply(comment: Comment, rootId: string) {\n    const option =\n      comment.id !== rootId && !isOwn(comment)\n        ? mentionSuggestions.find((item) => item.value === comment.author.id)\n        : undefined\n    setReply({\n      rootId,\n      originId: comment.id,\n      prefill: option ? `@${option.value} ` : \"\",\n    })\n  }\n\n  function closeReply(originId: string) {\n    if (latestReplyRef.current?.originId !== originId) return\n    focusAfterRender([`reply:${originId}`])\n    setReply(null)\n  }\n\n  async function saveEdit(comment: Comment, body: string) {\n    if (!isOwn(comment)) return\n    if (body !== comment.body.trim()) {\n      await onEdit?.(\n        comment.id,\n        body,\n        extractMentions(body, mentionSuggestions)\n      )\n      commitList((current) =>\n        editComment(current, comment.id, body, new Date())\n      )\n      setAnnouncement(\"Comment updated\")\n    }\n    if (latestEditingIdRef.current !== comment.id) return\n    setEditingId(null)\n    focusAfterRender([`edit:${comment.id}`])\n  }\n\n  function cancelEdit(comment: Comment) {\n    setEditingId(null)\n    focusAfterRender([`edit:${comment.id}`])\n  }\n\n  function confirmDelete(comment: Comment) {\n    if (!isOwn(comment) || isDeleting) return\n    const index = order.indexOf(comment.id)\n    const fallbacks = [\n      `comment:${comment.id}`,\n      `comment:${order[index + 1]}`,\n      `comment:${order[index - 1]}`,\n      \"composer\",\n    ]\n    setDeleteFailedId(null)\n    startDeleting(async () => {\n      try {\n        await onDelete?.(comment.id)\n        commitList((current) =>\n          deleteComment(current, comment.id, {\n            keepPlaceholder: pendingRepliesRef.current.has(comment.id),\n          })\n        )\n        setConfirmingId(null)\n        setAnnouncement(\"Comment deleted\")\n        focusAfterRender(fallbacks)\n      } catch {\n        setDeleteFailedId(comment.id)\n      }\n    })\n  }\n\n  function react(comment: Comment, emoji: string) {\n    if (!currentUser || comment.deleted) return\n    commitList((current) =>\n      toggleReaction(current, comment.id, emoji, currentUser.id)\n    )\n    onReactionToggle?.(comment.id, emoji)\n  }\n\n  function setThreadResolved(next: boolean) {\n    if (!canWrite) return\n    setIsResolved(next)\n    setResolver(next ? currentUser : undefined)\n    setIsExpanded(false)\n    setEditingId(null)\n    setReply(null)\n    setConfirmingId(null)\n    setPickerId(null)\n    setAnnouncement(next ? \"Thread resolved\" : \"Thread reopened\")\n    focusAfterRender(next ? [\"toggle-thread\"] : [\"resolve\"])\n  }\n\n  function renderComment(comment: Comment, rootId: string, isReply: boolean) {\n    const nameId = `${id}-${comment.id}-name`\n    const timeId = `${id}-${comment.id}-time`\n    const pickerDomId = `${id}-${comment.id}-reactions`\n    const createdAt = toDate(comment.createdAt)\n    const editedAt = toDate(comment.editedAt)\n    const isEditing = editingId === comment.id\n    const isConfirming = confirmingId === comment.id\n    const isPickerOpen = pickerId === comment.id\n    const own = isOwn(comment)\n    const reacted = new Set(\n      (comment.reactions ?? [])\n        .filter((reaction) => hasReacted(reaction, currentUser?.id))\n        .map((reaction) => reaction.emoji)\n    )\n\n    if (comment.deleted) {\n      return (\n        <article\n          data-slot=\"comment-thread-comment\"\n          data-deleted=\"\"\n          data-focus-key={`comment:${comment.id}`}\n          tabIndex={-1}\n          aria-label=\"Deleted comment\"\n          className=\"grid grid-cols-[auto_1fr] items-center gap-x-3 rounded-md outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50\"\n        >\n          <span\n            aria-hidden\n            className={cn(\n              \"rounded-full border border-dashed\",\n              isReply ? \"size-6\" : \"size-8\"\n            )}\n          />\n          <p className=\"text-sm text-muted-foreground italic\">\n            This comment was deleted.\n          </p>\n        </article>\n      )\n    }\n\n    return (\n      <article\n        data-slot=\"comment-thread-comment\"\n        data-focus-key={`comment:${comment.id}`}\n        tabIndex={-1}\n        aria-labelledby={createdAt ? `${nameId} ${timeId}` : nameId}\n        className=\"group/comment grid grid-cols-[auto_1fr] gap-x-3 rounded-md outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50\"\n      >\n        <CommentAvatar\n          author={comment.author}\n          size={isReply ? \"sm\" : \"default\"}\n        />\n        <div className=\"flex min-w-0 flex-col gap-1.5\">\n          <header className=\"flex min-h-7 items-center gap-2 pointer-coarse:flex-wrap\">\n            <div className=\"flex min-w-0 flex-1 flex-wrap items-baseline gap-x-2 gap-y-0.5 pointer-coarse:min-w-40\">\n              <span id={nameId} className=\"text-sm font-medium\">\n                {comment.author.name}\n              </span>\n              {createdAt && (\n                <time\n                  id={timeId}\n                  dateTime={createdAt.toISOString()}\n                  title={formatAbsoluteTime(createdAt, locale)}\n                  suppressHydrationWarning\n                  className=\"text-xs text-muted-foreground\"\n                >\n                  {formatRelativeTime(createdAt, now, locale)}\n                </time>\n              )}\n              {editedAt && (\n                <span\n                  title={`Edited ${formatAbsoluteTime(editedAt, locale)}`}\n                  suppressHydrationWarning\n                  className=\"text-xs text-muted-foreground\"\n                >\n                  (edited)\n                </span>\n              )}\n            </div>\n            {canWrite && !isEditing && !isConfirming && (\n              <div\n                role=\"group\"\n                aria-label=\"Comment actions\"\n                data-open={isPickerOpen || undefined}\n                className=\"relative flex shrink-0 items-center opacity-0 transition-opacity group-focus-within/comment:opacity-100 group-hover/comment:opacity-100 data-open:opacity-100 pointer-coarse:ml-auto pointer-coarse:gap-2 pointer-coarse:opacity-100\"\n              >\n                <Button\n                  type=\"button\"\n                  variant=\"ghost\"\n                  size=\"icon\"\n                  data-focus-key={`react:${comment.id}`}\n                  aria-label=\"Add reaction\"\n                  aria-expanded={isPickerOpen}\n                  aria-controls={isPickerOpen ? pickerDomId : undefined}\n                  title=\"Add reaction\"\n                  onClick={() => setPickerId(isPickerOpen ? null : comment.id)}\n                  className={actionButtonClassName}\n                >\n                  <SmilePlusIcon />\n                </Button>\n                {isPickerOpen && (\n                  <ReactionPicker\n                    id={pickerDomId}\n                    commentId={comment.id}\n                    options={reactions}\n                    reacted={reacted}\n                    onPick={(emoji) => {\n                      react(comment, emoji)\n                      setPickerId(null)\n                      focusAfterRender([`react:${comment.id}`])\n                    }}\n                    onClose={() => {\n                      setPickerId(null)\n                      focusAfterRender([`react:${comment.id}`])\n                    }}\n                    onDismiss={() => setPickerId(null)}\n                  />\n                )}\n                <Button\n                  type=\"button\"\n                  variant=\"ghost\"\n                  size=\"icon\"\n                  data-focus-key={`reply:${comment.id}`}\n                  aria-label={`Reply to ${comment.author.name}`}\n                  title=\"Reply\"\n                  onClick={() => startReply(comment, rootId)}\n                  className={actionButtonClassName}\n                >\n                  <ReplyIcon />\n                </Button>\n                {own && (\n                  <>\n                    <Button\n                      type=\"button\"\n                      variant=\"ghost\"\n                      size=\"icon\"\n                      data-focus-key={`edit:${comment.id}`}\n                      aria-label=\"Edit comment\"\n                      title=\"Edit\"\n                      onClick={() => {\n                        setEditingId(comment.id)\n                        setPickerId(null)\n                      }}\n                      className={actionButtonClassName}\n                    >\n                      <PencilIcon />\n                    </Button>\n                    <Button\n                      type=\"button\"\n                      variant=\"ghost\"\n                      size=\"icon\"\n                      data-focus-key={`delete:${comment.id}`}\n                      aria-label=\"Delete comment\"\n                      title=\"Delete\"\n                      onClick={() => {\n                        setConfirmingId(comment.id)\n                        setDeleteFailedId(null)\n                        setPickerId(null)\n                        focusAfterRender([`cancel-delete:${comment.id}`])\n                      }}\n                      className={cn(\n                        actionButtonClassName,\n                        \"hover:text-destructive\"\n                      )}\n                    >\n                      <Trash2Icon />\n                    </Button>\n                  </>\n                )}\n              </div>\n            )}\n          </header>\n          {isEditing ? (\n            <CommentComposer\n              label=\"Edit comment\"\n              placeholder=\"Edit your comment\"\n              submitLabel=\"Save\"\n              defaultValue={comment.body}\n              suggestions={mentionSuggestions}\n              failureMessage=\"Couldn't save your changes. Try again.\"\n              focusKey={`editor:${comment.id}`}\n              autoFocus\n              onSubmit={(body) => saveEdit(comment, body)}\n              onCancel={() => cancelEdit(comment)}\n            />\n          ) : (\n            <CommentBody body={comment.body} suggestions={mentionSuggestions} />\n          )}\n          {isConfirming && (\n            <div\n              role=\"group\"\n              aria-label=\"Confirm delete\"\n              onKeyDown={(event) => {\n                if (event.key !== \"Escape\" || isDeleting) return\n                event.preventDefault()\n                setConfirmingId(null)\n                focusAfterRender([`delete:${comment.id}`])\n              }}\n              className=\"flex flex-wrap items-center gap-2 rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2\"\n            >\n              <p className=\"mr-auto text-sm\">Delete this comment?</p>\n              <Button\n                type=\"button\"\n                variant=\"ghost\"\n                size=\"sm\"\n                data-focus-key={`cancel-delete:${comment.id}`}\n                disabled={isDeleting}\n                onClick={() => {\n                  setConfirmingId(null)\n                  focusAfterRender([`delete:${comment.id}`])\n                }}\n              >\n                Cancel\n              </Button>\n              <Button\n                type=\"button\"\n                variant=\"destructive\"\n                size=\"sm\"\n                disabled={isDeleting}\n                aria-busy={isDeleting || undefined}\n                onClick={() => confirmDelete(comment)}\n              >\n                Delete\n              </Button>\n              {deleteFailedId === comment.id && (\n                <p role=\"alert\" className=\"w-full text-sm text-destructive\">\n                  Couldn&apos;t delete this comment. Try again.\n                </p>\n              )}\n            </div>\n          )}\n          {(comment.reactions?.length ?? 0) > 0 && (\n            <ul\n              aria-label=\"Reactions\"\n              data-slot=\"comment-thread-reactions\"\n              className=\"flex flex-wrap gap-1.5 pointer-coarse:gap-2\"\n            >\n              {comment.reactions!.map((reaction) => {\n                const label =\n                  reactionLabels.get(reaction.emoji) ?? reaction.emoji\n                const who = describeReactors(\n                  reaction.userIds,\n                  names,\n                  currentUser?.id,\n                  locale\n                )\n                return (\n                  <li key={reaction.emoji} className=\"group/reaction relative\">\n                    <button\n                      type=\"button\"\n                      disabled={!canWrite}\n                      aria-pressed={reacted.has(reaction.emoji)}\n                      aria-label={`${label}: ${who}`}\n                      suppressHydrationWarning\n                      onClick={() => react(comment, reaction.emoji)}\n                      className=\"inline-flex h-6 items-center gap-1 rounded-full border bg-background px-2 text-xs tabular-nums outline-none hover:bg-accent focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-default disabled:hover:bg-background aria-pressed:border-primary/40 aria-pressed:bg-primary/10 aria-pressed:text-primary pointer-coarse:h-9 pointer-coarse:px-3\"\n                    >\n                      <span aria-hidden>{reaction.emoji}</span>\n                      <span aria-hidden>{reaction.userIds.length}</span>\n                    </button>\n                    <span\n                      aria-hidden\n                      data-slot=\"comment-thread-reaction-tooltip\"\n                      suppressHydrationWarning\n                      className=\"pointer-events-none invisible absolute bottom-full left-0 z-20 mb-1.5 flex w-max max-w-56 flex-col gap-0.5 rounded-md border bg-popover px-2.5 py-1.5 text-xs text-popover-foreground opacity-0 shadow-md transition-[opacity,visibility] duration-150 group-hover/reaction:visible group-hover/reaction:opacity-100 group-hover/reaction:delay-300 group-has-focus-visible/reaction:visible group-has-focus-visible/reaction:opacity-100 group-has-focus-visible/reaction:delay-0\"\n                    >\n                      <span className=\"font-medium\">\n                        {reaction.emoji} {label}\n                      </span>\n                      <span className=\"text-muted-foreground\">{who}</span>\n                    </span>\n                  </li>\n                )\n              })}\n            </ul>\n          )}\n        </div>\n      </article>\n    )\n  }\n\n  const resolveButton =\n    canWrite && !isResolved && visibleCount > 0 ? (\n      <Button\n        type=\"button\"\n        variant=\"outline\"\n        size=\"sm\"\n        data-focus-key=\"resolve\"\n        onClick={() => setThreadResolved(true)}\n      >\n        <CircleCheckIcon />\n        Resolve thread\n      </Button>\n    ) : null\n\n  return (\n    <section\n      ref={setRootRef}\n      data-slot=\"comment-thread\"\n      data-resolved={isResolved || undefined}\n      aria-label={ariaLabel}\n      className={cn(\n        \"flex w-full flex-col gap-4 rounded-lg border bg-card p-4 text-card-foreground\",\n        className\n      )}\n      {...props}\n    >\n      {isResolved && (\n        <div\n          data-slot=\"comment-thread-resolved\"\n          className=\"flex flex-wrap items-center gap-x-3 gap-y-2\"\n        >\n          <CircleCheckIcon\n            aria-hidden\n            className=\"size-4 shrink-0 text-primary\"\n          />\n          <p className=\"mr-auto text-sm\">\n            <span className=\"font-medium\">Resolved</span>\n            {resolvedByAuthor && <> by {resolvedByAuthor.name}</>}\n            <span className=\"text-muted-foreground\">\n              {\" · \"}\n              {pluralize(visibleCount, \"comment\")}\n            </span>\n          </p>\n          <Button\n            type=\"button\"\n            variant=\"ghost\"\n            size=\"sm\"\n            data-focus-key=\"toggle-thread\"\n            aria-expanded={isExpanded}\n            aria-controls={`${id}-body`}\n            onClick={() => setIsExpanded(!isExpanded)}\n          >\n            {isExpanded ? \"Hide thread\" : \"Show thread\"}\n          </Button>\n          {canWrite && (\n            <Button\n              type=\"button\"\n              variant=\"outline\"\n              size=\"sm\"\n              onClick={() => setThreadResolved(false)}\n            >\n              Reopen\n            </Button>\n          )}\n        </div>\n      )}\n      <div id={`${id}-body`} hidden={!showBody} className=\"flex flex-col gap-4\">\n        {nodes.length > 0 ? (\n          <ol aria-label=\"Comments\" className=\"flex flex-col gap-5\">\n            {nodes.map((node) => (\n              <li key={node.comment.id} className=\"flex flex-col gap-3\">\n                {renderComment(node.comment, node.comment.id, false)}\n                {(node.replies.length > 0 ||\n                  reply?.rootId === node.comment.id) && (\n                  <div className=\"ml-4 flex flex-col gap-3 border-l pl-5\">\n                    {node.replies.length > 0 && (\n                      <ol\n                        aria-label={\n                          node.comment.deleted\n                            ? \"Replies\"\n                            : `Replies to ${node.comment.author.name}`\n                        }\n                        className=\"flex flex-col gap-3\"\n                      >\n                        {node.replies.map((comment) => (\n                          <li key={comment.id}>\n                            {renderComment(comment, node.comment.id, true)}\n                          </li>\n                        ))}\n                      </ol>\n                    )}\n                    {reply?.rootId === node.comment.id && currentUser && (\n                      <CommentComposer\n                        key={reply.originId}\n                        label=\"Reply\"\n                        placeholder=\"Write a reply\"\n                        submitLabel=\"Reply\"\n                        defaultValue={reply.prefill}\n                        suggestions={mentionSuggestions}\n                        failureMessage=\"Couldn't post your reply. Try again.\"\n                        author={currentUser}\n                        avatarSize=\"sm\"\n                        focusKey={`reply-composer:${node.comment.id}`}\n                        autoFocus\n                        onSubmit={async (body) => {\n                          await postComment(node.comment.id, body)\n                          closeReply(reply.originId)\n                        }}\n                        onCancel={() => closeReply(reply.originId)}\n                      />\n                    )}\n                  </div>\n                )}\n              </li>\n            ))}\n          </ol>\n        ) : (\n          <p className=\"text-sm text-muted-foreground\">\n            {canWrite\n              ? \"No comments yet. Start the thread below.\"\n              : \"No comments yet.\"}\n          </p>\n        )}\n        {canWrite && (\n          <CommentComposer\n            label=\"Comment\"\n            placeholder=\"Leave a comment\"\n            submitLabel=\"Comment\"\n            suggestions={mentionSuggestions}\n            failureMessage=\"Couldn't post your comment. Try again.\"\n            author={currentUser}\n            focusKey=\"composer\"\n            actions={resolveButton}\n            onSubmit={(body) => postComment(null, body)}\n          />\n        )}\n      </div>\n      <p role=\"status\" className=\"sr-only\">\n        {announcement}\n      </p>\n    </section>\n  )\n}\n\nexport { CommentThread }\n",
      "type": "registry:ui"
    },
    {
      "path": "registry/default/lib/comment-thread.ts",
      "content": "import type { MentionOption } from \"@/registry/default/lib/mention\"\n\nexport type CommentAuthor = {\n  id: string\n  name: string\n  avatarUrl?: string\n}\n\nexport type CommentReaction = {\n  emoji: string\n  userIds: string[]\n}\n\nexport type Comment = {\n  id: string\n  parentId?: string | null\n  author: CommentAuthor\n  body: string\n  createdAt: Date | string\n  editedAt?: Date | string\n  deleted?: boolean\n  reactions?: CommentReaction[]\n}\n\nexport type CommentNode = {\n  comment: Comment\n  replies: Comment[]\n}\n\nexport type ReactionOption = {\n  emoji: string\n  label: string\n}\n\nexport type BodySegment =\n  | { type: \"text\"; text: string }\n  | { type: \"mention\"; text: string; option: MentionOption }\n\nexport const QUICK_REACTIONS: ReactionOption[] = [\n  { emoji: \"👍\", label: \"Thumbs up\" },\n  { emoji: \"👎\", label: \"Thumbs down\" },\n  { emoji: \"😄\", label: \"Laugh\" },\n  { emoji: \"🎉\", label: \"Hooray\" },\n  { emoji: \"😕\", label: \"Confused\" },\n  { emoji: \"❤️\", label: \"Heart\" },\n  { emoji: \"🚀\", label: \"Rocket\" },\n  { emoji: \"👀\", label: \"Eyes\" },\n]\n\nexport function toDate(value: Date | string | undefined | null) {\n  if (value === undefined || value === null || value === \"\") return null\n  const date = value instanceof Date ? value : new Date(value)\n  return Number.isNaN(date.getTime()) ? null : date\n}\n\nfunction rootIdOf(comment: Comment, byId: Map<string, Comment>) {\n  const visited = new Set<string>([comment.id])\n  let current = comment\n  while (current.parentId) {\n    const parent = byId.get(current.parentId)\n    if (!parent) break\n    if (visited.has(parent.id)) return comment.id\n    visited.add(parent.id)\n    current = parent\n  }\n  return current.id\n}\n\nexport function hasLiveDescendant(comments: Comment[], id: string) {\n  const pending = [id]\n  const visited = new Set<string>()\n  while (pending.length > 0) {\n    const parentId = pending.pop()!\n    if (visited.has(parentId)) continue\n    visited.add(parentId)\n    for (const comment of comments) {\n      if (comment.parentId !== parentId || visited.has(comment.id)) continue\n      if (!comment.deleted) return true\n      pending.push(comment.id)\n    }\n  }\n  return false\n}\n\nexport function buildThread(comments: Comment[]): CommentNode[] {\n  const byId = new Map(comments.map((comment) => [comment.id, comment]))\n  const nodes = new Map<string, CommentNode>()\n\n  for (const comment of comments) {\n    const rootId = rootIdOf(comment, byId)\n    if (rootId === comment.id) {\n      nodes.set(comment.id, {\n        comment,\n        replies: nodes.get(comment.id)?.replies ?? [],\n      })\n      continue\n    }\n    if (comment.deleted && !hasLiveDescendant(comments, comment.id)) continue\n    const root = nodes.get(rootId)\n    if (root) root.replies.push(comment)\n    else nodes.set(rootId, { comment: byId.get(rootId)!, replies: [comment] })\n  }\n\n  const ordered = comments\n    .filter((comment) => nodes.has(comment.id))\n    .map((comment) => nodes.get(comment.id)!)\n  return ordered.filter(\n    (node) => !node.comment.deleted || node.replies.length > 0\n  )\n}\n\nexport function countVisible(nodes: CommentNode[]) {\n  return nodes.reduce(\n    (total, node) =>\n      total +\n      (node.comment.deleted ? 0 : 1) +\n      node.replies.filter((reply) => !reply.deleted).length,\n    0\n  )\n}\n\nfunction escapeRegExp(text: string) {\n  return text.replace(/[.*+?^${}()|[\\]\\\\]/g, \"\\\\$&\")\n}\n\nfunction mentionPattern(options: MentionOption[]) {\n  const values = [...new Set(options.map((option) => option.value))]\n    .filter(Boolean)\n    .sort((a, b) => b.length - a.length)\n    .map(escapeRegExp)\n  if (values.length === 0) return null\n  return new RegExp(\n    `(^|[\\\\s(\\\\[])@(${values.join(\"|\")})(?=$|[\\\\s.,!?;:)\\\\]'\"])`,\n    \"g\"\n  )\n}\n\nexport function parseBody(\n  body: string,\n  options: MentionOption[] = []\n): BodySegment[] {\n  const pattern = mentionPattern(options)\n  if (!pattern) return body ? [{ type: \"text\", text: body }] : []\n\n  const byValue = new Map(options.map((option) => [option.value, option]))\n  const segments: BodySegment[] = []\n  let cursor = 0\n\n  for (const match of body.matchAll(pattern)) {\n    const start = match.index + match[1].length\n    if (start > cursor) {\n      segments.push({ type: \"text\", text: body.slice(cursor, start) })\n    }\n    segments.push({\n      type: \"mention\",\n      text: `@${match[2]}`,\n      option: byValue.get(match[2])!,\n    })\n    cursor = start + match[2].length + 1\n  }\n\n  if (cursor < body.length) {\n    segments.push({ type: \"text\", text: body.slice(cursor) })\n  }\n  return segments\n}\n\nexport function extractMentions(body: string, options: MentionOption[] = []) {\n  const values = parseBody(body, options).flatMap((segment) =>\n    segment.type === \"mention\" ? [segment.option.value] : []\n  )\n  return [...new Set(values)]\n}\n\nexport function addComment(comments: Comment[], comment: Comment) {\n  return [...comments, comment]\n}\n\nexport function editComment(\n  comments: Comment[],\n  id: string,\n  body: string,\n  editedAt: Date\n) {\n  return comments.map((comment) =>\n    comment.id === id ? { ...comment, body, editedAt } : comment\n  )\n}\n\nexport function deleteComment(\n  comments: Comment[],\n  id: string,\n  { keepPlaceholder = false }: { keepPlaceholder?: boolean } = {}\n): Comment[] {\n  const target = comments.find((comment) => comment.id === id)\n  if (!target) return comments\n\n  if (keepPlaceholder || hasLiveDescendant(comments, id)) {\n    return comments.map((comment) =>\n      comment.id === id\n        ? { ...comment, body: \"\", deleted: true, reactions: [] }\n        : comment\n    )\n  }\n\n  let remaining = comments.filter((comment) => comment.id !== id)\n  const visited = new Set([id])\n  let parentId = target.parentId\n  while (parentId && !visited.has(parentId)) {\n    visited.add(parentId)\n    const parent = remaining.find((comment) => comment.id === parentId)\n    if (!parent?.deleted || hasLiveDescendant(remaining, parent.id)) break\n    remaining = remaining.filter((comment) => comment.id !== parent.id)\n    parentId = parent.parentId\n  }\n  return remaining\n}\n\nexport function toggleReaction(\n  comments: Comment[],\n  id: string,\n  emoji: string,\n  userId: string\n) {\n  return comments.map((comment) => {\n    if (comment.id !== id) return comment\n    const reactions = comment.reactions ?? []\n    const existing = reactions.find((reaction) => reaction.emoji === emoji)\n    if (!existing) {\n      return {\n        ...comment,\n        reactions: [...reactions, { emoji, userIds: [userId] }],\n      }\n    }\n    const userIds = existing.userIds.includes(userId)\n      ? existing.userIds.filter((user) => user !== userId)\n      : [...existing.userIds, userId]\n    return {\n      ...comment,\n      reactions: reactions.flatMap((reaction) =>\n        reaction !== existing\n          ? [reaction]\n          : userIds.length > 0\n            ? [{ ...reaction, userIds }]\n            : []\n      ),\n    }\n  })\n}\n\nexport function hasReacted(\n  reaction: CommentReaction,\n  userId: string | undefined\n) {\n  return userId !== undefined && reaction.userIds.includes(userId)\n}\n\nexport function describeReactors(\n  userIds: string[],\n  names: Map<string, string>,\n  currentUserId?: string,\n  locale?: string\n) {\n  const unique = [...new Set(userIds)]\n  const named: string[] = []\n  let others = 0\n  if (currentUserId && unique.includes(currentUserId)) named.push(\"You\")\n  for (const id of unique) {\n    if (id === currentUserId) continue\n    const name = names.get(id)\n    if (name && named.length < 3) named.push(name)\n    else others += 1\n  }\n  if (others > 0) named.push(others === 1 ? \"1 other\" : `${others} others`)\n  return new Intl.ListFormat(locale, { type: \"conjunction\" }).format(named)\n}\n\nexport function initialsOf(name: string) {\n  const words = name.trim().split(/\\s+/).filter(Boolean)\n  if (words.length === 0) return \"?\"\n  const letters =\n    words.length === 1\n      ? [...words[0]].slice(0, 2)\n      : [[...words[0]][0], [...words[words.length - 1]][0]]\n  return letters.join(\"\").toUpperCase()\n}\n\nconst RELATIVE_UNITS: [Intl.RelativeTimeFormatUnit, number][] = [\n  [\"year\", 365 * 24 * 60 * 60],\n  [\"month\", 30 * 24 * 60 * 60],\n  [\"week\", 7 * 24 * 60 * 60],\n  [\"day\", 24 * 60 * 60],\n  [\"hour\", 60 * 60],\n  [\"minute\", 60],\n]\n\nexport function formatRelativeTime(date: Date, now: Date, locale?: string) {\n  const seconds = (date.getTime() - now.getTime()) / 1000\n  const format = new Intl.RelativeTimeFormat(locale, { numeric: \"auto\" })\n  for (const [unit, size] of RELATIVE_UNITS) {\n    if (Math.abs(seconds) >= size) {\n      return format.format(Math.trunc(seconds / size), unit)\n    }\n  }\n  return format.format(0, \"second\")\n}\n\nexport function formatAbsoluteTime(date: Date, locale?: string) {\n  return new Intl.DateTimeFormat(locale, {\n    dateStyle: \"medium\",\n    timeStyle: \"short\",\n  }).format(date)\n}\n",
      "type": "registry:lib"
    }
  ],
  "type": "registry:ui"
}