{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "chat-thread",
  "type": "registry:ui",
  "title": "Chat thread",
  "description": "A chat thread with grouped messages, reactions, read receipts, typing, and a composer with attachments.",
  "categories": [
    "data"
  ],
  "dependencies": [
    "lucide-react",
    "motion"
  ],
  "registryDependencies": [
    "https://irisui.dev/r/foundation.json",
    "https://irisui.dev/r/motion-tokens.json",
    "https://irisui.dev/r/utils.json"
  ],
  "files": [
    {
      "path": "src/registry/components/chat-thread/chat-thread.tsx",
      "content": "\"use client\";\n\nimport {\n  forwardRef,\n  useCallback,\n  useEffect,\n  useId,\n  useImperativeHandle,\n  useLayoutEffect,\n  useMemo,\n  useRef,\n  useState,\n  useSyncExternalStore,\n} from \"react\";\nimport type {\n  ClipboardEvent as ReactClipboardEvent,\n  DragEvent as ReactDragEvent,\n  KeyboardEvent as ReactKeyboardEvent,\n  ReactNode,\n  RefObject,\n} from \"react\";\nimport { AnimatePresence, animate, motion, useMotionValue, useReducedMotion } from \"motion/react\";\nimport type { Transition } from \"motion/react\";\nimport { ArrowDown, ArrowUp, FileText, Paperclip, RotateCw, SmilePlus, X } from \"lucide-react\";\nimport { motionTokens } from \"../lib/motion-tokens\";\nimport { cn } from \"../lib/utils\";\n\nexport interface ChatParticipant {\n  id: string;\n  name: string;\n  /** Portrait URL. Initials show when it is missing. */\n  avatar?: string;\n}\n\nexport interface ChatAttachment {\n  id: string;\n  name: string;\n  kind: \"image\" | \"file\";\n  url?: string;\n  /** Bytes. */\n  size?: number;\n  /** Intrinsic image size, used to reserve space before the image loads. */\n  width?: number;\n  height?: number;\n  alt?: string;\n}\n\nexport interface ChatReaction {\n  emoji: string;\n  count: number;\n  /** The current user reacted with this emoji. */\n  mine?: boolean;\n}\n\nexport type ChatMessageStatus = \"sending\" | \"sent\" | \"delivered\" | \"read\" | \"failed\";\n\nexport interface ChatMessage {\n  id: string;\n  authorId: string;\n  text?: string;\n  createdAt: Date | string | number;\n  attachments?: ChatAttachment[];\n  reactions?: ChatReaction[];\n  /** Delivery state of the current user's own messages. */\n  status?: ChatMessageStatus;\n}\n\nexport interface ChatDraft {\n  text: string;\n  files: File[];\n}\n\n/** Words the composer shows or announces. Each has an English default. */\nexport interface ChatComposerLabels {\n  /** Accessible name of the send button. */\n  send?: string;\n  /** Accessible name of the attach button. */\n  attach?: string;\n  /** Accessible name of the list of files waiting to send. */\n  attachments?: string;\n  /** Accessible name of the button that takes a file back out. */\n  removeFile?: (name: string) => string;\n}\n\n/** Every word the thread shows or announces. Each has an English default; words with a count or names are functions, so an app can apply its own plural rules. */\nexport interface ChatThreadLabels extends ChatComposerLabels {\n  sending?: string;\n  sent?: string;\n  delivered?: string;\n  read?: string;\n  failed?: string;\n  retry?: string;\n  /** Accessible name of the read receipts under a message. */\n  readBy?: (names: string[]) => string;\n  /** Day separators for the last two days. Older days are formatted by `locale`. Defaults come from Intl for `locale`. */\n  today?: string;\n  yesterday?: string;\n  /** Screen reader announcement of who is typing. */\n  typing?: (names: string[]) => string;\n  /** The pill when new messages land out of view. The count inside it rolls. */\n  newMessages?: (count: number) => string;\n  jumpToLatest?: string;\n  /** Name shown for a message whose author is not in `participants`. */\n  unknownAuthor?: string;\n  addReaction?: string;\n  /** Accessible name of the reaction picker and of the reactions under a message. */\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 message. */\n  reactionCount?: (emoji: string, count: number, includesYou: boolean) => string;\n}\n\nconst COMPOSER_LABELS: Required<ChatComposerLabels> = {\n  send: \"Send\",\n  attach: \"Attach files\",\n  attachments: \"Attachments\",\n  removeFile: (name) => `Remove ${name}`,\n};\n\nconst firstName = (name: string) => name.split(\" \")[0];\nconst THREAD_LABELS: Omit<Required<ChatThreadLabels>, \"today\" | \"yesterday\"> = {\n  ...COMPOSER_LABELS,\n  sending: \"Sending\",\n  sent: \"Sent\",\n  delivered: \"Delivered\",\n  read: \"Read\",\n  failed: \"Not delivered\",\n  retry: \"Retry\",\n  readBy: (names) => `Read by ${names.join(\", \")}`,\n  typing: (names) =>\n    names.length === 1\n      ? `${firstName(names[0]!)} is typing`\n      : names.length === 2\n        ? `${firstName(names[0]!)} and ${firstName(names[1]!)} are typing`\n        : \"Several people are typing\",\n  newMessages: (count) => `${count} new ${count === 1 ? \"message\" : \"messages\"}`,\n  jumpToLatest: \"Jump to latest\",\n  unknownAuthor: \"Unknown\",\n  addReaction: \"Add reaction\",\n  reactions: \"Reactions\",\n  reactWith: (emoji) => `React with ${emoji}`,\n  reactionCount: (emoji, count, includesYou) => `${emoji} ${count}${includesYou ? \", including you\" : \"\"}`,\n};\n\n/** A key passed as undefined keeps its default too. */\nconst withDefaults = <T extends object, D extends object>(defaults: D, given?: T): D => ({\n  ...defaults,\n  ...Object.fromEntries(Object.entries(given ?? {}).filter(([, value]) => value !== undefined)),\n});\n\n/** \"Today\" and \"Yesterday\" in the thread's language, from Intl, with a capital first letter. */\nfunction relativeDay(locale: string, offset: 0 | -1) {\n  const word = new Intl.RelativeTimeFormat(locale, { numeric: \"auto\" }).format(offset, \"day\");\n  return word.charAt(0).toLocaleUpperCase(locale) + word.slice(1);\n}\n\nexport interface ChatThreadHandle {\n  scrollToBottom: (smooth?: boolean) => void;\n  focusComposer: () => void;\n}\n\nexport interface ChatThreadProps {\n  participants: ChatParticipant[];\n  currentUserId: string;\n  /** Controlled messages, oldest first. Leave it out to let the thread keep its own list. */\n  messages?: ChatMessage[];\n  defaultMessages?: ChatMessage[];\n  /** Participant ids currently typing. */\n  typing?: string[];\n  /** The last message each participant has read, by participant id. Their avatar sits under that message and glides as it moves. */\n  readBy?: Record<string, string>;\n  /** Called with the composer's text and files. Uncontrolled threads also append the message themselves. */\n  onSend?: (draft: ChatDraft) => void | Promise<void>;\n  onReact?: (messageId: string, emoji: string) => void;\n  onRetry?: (messageId: string) => void;\n  /** Emoji offered by the reaction picker. */\n  reactions?: string[];\n  placeholder?: string;\n  /** Show the composer. Defaults to true. */\n  composer?: boolean;\n  allowAttachments?: boolean;\n  /** File types the attach button offers, as for `<input accept>`. */\n  accept?: string;\n  /** Messages from one person closer together than this, in ms, share a group. Defaults to five minutes. */\n  groupWindow?: number;\n  locale?: string;\n  /** Accessible name of the message log. */\n  label?: string;\n  /** Words for localization. Leave out any key to keep its English default. */\n  labels?: ChatThreadLabels;\n  className?: string;\n}\n\ntype Row =\n  | { type: \"day\"; key: string; label: string }\n  | {\n      type: \"message\";\n      key: string;\n      message: ChatMessage;\n      author: ChatParticipant;\n      mine: boolean;\n      first: boolean;\n      last: boolean;\n      index: number;\n    };\n\nconst { spring, duration, ease, blur } = motionTokens;\nconst enter = [...ease.enter] as [number, number, number, number];\nconst standard = [...ease.standard] as [number, number, number, number];\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 SETTLE = physical(0.44, 0),\n  GROW = physical(spring.smooth.visualDuration, 0),\n  POP = physical(0.3, 0),\n  ARRIVE = physical(0.46, 0);\nconst DEFAULT_REACTIONS = [\"👍\", \"❤️\", \"😂\", \"🎉\", \"👀\", \"🙏\"];\nconst STICK_DISTANCE = 48,\n  FAR_DISTANCE = 180;\n\n/** A message row; the first of a group, or the first after a day separator, sits further from what came before. */\nconst rowClass = \"group/row relative flex items-end gap-2 [[data-day]+&]:mt-2.5\";\nconst stackClass = \"flex max-w-[min(78%,34rem)] min-w-0 flex-col gap-1 max-[420px]:max-w-[84%]\";\nconst lineClass = \"relative flex max-w-full items-center gap-1.5\";\n/** The author's face sits in the gutter, level with the bottom of their last bubble rather than the reactions under it. */\nconst faceClass = \"absolute bottom-0 -left-9 flex\";\n/** Bubbles in a group share a soft edge on the author's side, so a run of messages reads as one voice. Incoming bubbles sit on\n *  the raised surface in dark mode, where the hover fill is too close to the thread behind them. */\nconst bubbleClass = (mine: boolean, first: boolean, last: boolean) =>\n  cn(\n    \"max-w-full rounded-[18px] text-(length:--text-sm) wrap-anywhere whitespace-pre-wrap\",\n    mine ? \"bg-accent text-on-accent\" : \"bg-hover text-ink dark:bg-surface\",\n    !first && (mine ? \"rounded-tr-[6px]\" : \"rounded-tl-[6px]\"),\n    !last && (mine ? \"rounded-br-[6px]\" : \"rounded-bl-[6px]\"),\n  );\n\nconst subscribe = () => () => {};\nfunction useHydrated() {\n  return useSyncExternalStore(\n    subscribe,\n    () => true,\n    () => false,\n  );\n}\n\nconst toDate = (value: ChatMessage[\"createdAt\"]) => (value instanceof Date ? value : new Date(value));\nconst dayKey = (date: Date) => `${date.getFullYear()}-${date.getMonth()}-${date.getDate()}`;\n\nfunction dayLabel(date: Date, locale: string, words: { today: string; yesterday: string }) {\n  const today = new Date();\n  const start = (value: Date) => new Date(value.getFullYear(), value.getMonth(), value.getDate()).getTime();\n  const days = Math.round((start(today) - start(date)) / 86_400_000);\n  if (days === 0) return words.today;\n  if (days === 1) return words.yesterday;\n  if (days > 1 && days < 7) return date.toLocaleDateString(locale, { weekday: \"long\" });\n  return date.toLocaleDateString(locale, {\n    weekday: \"short\",\n    month: \"short\",\n    day: \"numeric\",\n    year: date.getFullYear() === today.getFullYear() ? undefined : \"numeric\",\n  });\n}\n\n/** Puts `node` where `value` appears in a translated sentence, so a rolling count sits wherever the language puts the number. */\nfunction withCount(text: string, value: number, node: ReactNode) {\n  const digits = String(value),\n    at = text.indexOf(digits);\n  return at < 0 ? (\n    text\n  ) : (\n    <>\n      {text.slice(0, at)}\n      {node}\n      {text.slice(at + digits.length)}\n    </>\n  );\n}\n\nexport function formatBytes(bytes = 0) {\n  if (bytes < 1024) return `${bytes} B`;\n  if (bytes < 1024 ** 2) return `${Math.round(bytes / 1024)} KB`;\n  return `${(bytes / 1024 ** 2).toFixed(1)} MB`;\n}\n\nfunction initials(name: string) {\n  return name\n    .split(/\\s+/)\n    .filter(Boolean)\n    .slice(0, 2)\n    .map((part) => part[0]?.toUpperCase())\n    .join(\"\");\n}\n\nfunction Avatar({ person, size }: { person: ChatParticipant; size: number }) {\n  return (\n    <span\n      className=\"inline-grid flex-none place-items-center overflow-hidden rounded-full bg-hover font-medium text-ink-2\"\n      style={{ width: size, height: size, fontSize: size * 0.38 }}\n      aria-hidden=\"true\"\n    >\n      {person.avatar ? (\n        // eslint-disable-next-line @next/next/no-img-element -- registry components stay framework agnostic\n        <img className=\"size-full object-cover\" src={person.avatar} alt=\"\" width={size} height={size} loading=\"lazy\" />\n      ) : (\n        initials(person.name)\n      )}\n    </span>\n  );\n}\n\n/** A count that rolls up or down when it changes. */\nfunction Count({ value, reduced }: { value: number; reduced: boolean }) {\n  const [last, setLast] = useState({ value, direction: 1 });\n  if (last.value !== value) setLast({ value, direction: value > last.value ? 1 : -1 });\n  const direction = last.value !== value ? (value > last.value ? 1 : -1) : last.direction;\n  return (\n    <span className=\"inline-grid overflow-hidden tabular-nums [&>span]:[grid-area:1/1]\">\n      <AnimatePresence initial={false} mode=\"popLayout\" custom={direction}>\n        <motion.span\n          key={value}\n          custom={direction}\n          variants={{\n            in: (dir: number) => ({ opacity: 0, y: reduced ? 0 : dir * 8 }),\n            rest: { opacity: 1, y: 0 },\n            out: (dir: number) => ({ opacity: 0, y: reduced ? 0 : dir * -8 }),\n          }}\n          initial=\"in\"\n          animate=\"rest\"\n          exit=\"out\"\n          transition={reduced ? { duration: duration.fast } : spring.snappy}\n        >\n          {value}\n        </motion.span>\n      </AnimatePresence>\n    </span>\n  );\n}\n\n/** Three dots that breathe in sequence. Reduced motion shows them still. */\nfunction TypingDots() {\n  const dot =\n    \"size-1.5 rounded-full bg-ink-3 animate-chat-thread-breathe motion-reduce:animate-none motion-reduce:opacity-60\";\n  return (\n    <span className=\"inline-flex gap-1\" aria-hidden=\"true\">\n      <i className={dot} />\n      <i className={dot} style={{ animationDelay: \".16s\" }} />\n      <i className={dot} style={{ animationDelay: \".32s\" }} />\n    </span>\n  );\n}\n\nfunction useAutoHeight(mirror: RefObject<HTMLElement | null>, maxHeight: number, reduced: boolean) {\n  const height = useMotionValue<number | \"auto\">(\"auto\");\n  const measured = useRef(false);\n  useLayoutEffect(() => {\n    const node = mirror.current;\n    if (!node) return;\n    const fit = () => {\n      const target = Math.min(maxHeight, node.offsetHeight);\n      if (!measured.current || reduced) {\n        height.jump(target);\n        measured.current = true;\n        return;\n      }\n      animate(height, target, GROW);\n    };\n    fit();\n    if (typeof ResizeObserver === \"undefined\") return;\n    const observer = new ResizeObserver(fit);\n    observer.observe(node);\n    return () => observer.disconnect();\n  }, [height, maxHeight, mirror, reduced]);\n  return height;\n}\n\ntype Pending = { id: string; file: File; url?: string };\n/** The textarea and its hidden sizing mirror share one text box, so the mirror measures exactly what the field shows. */\nconst fieldText =\n  \"m-0 box-border w-full border-0 pt-[9px] pr-1.5 pb-[9px] pl-4 text-(length:--text-base) leading-[22px] tracking-body wrap-break-word whitespace-pre-wrap [word-break:break-word]\";\n\nexport interface ChatComposerProps {\n  onSend: (draft: ChatDraft) => void;\n  placeholder?: string;\n  allowAttachments?: boolean;\n  accept?: string;\n  disabled?: boolean;\n  /** Words for localization. Leave out any key to keep its English default. */\n  labels?: ChatComposerLabels;\n  className?: string;\n}\n\nexport interface ChatComposerHandle {\n  focus: () => void;\n}\n\n/** The message box on its own: autosizing text, attachments by button, paste, or drop, and a send button that morphs when there is something to send. */\nexport const ChatComposer = forwardRef<ChatComposerHandle, ChatComposerProps>(function ChatComposer(\n  { onSend, placeholder = \"Message\", allowAttachments = true, accept, disabled, labels, className },\n  ref,\n) {\n  const t = withDefaults(COMPOSER_LABELS, labels);\n  const reduced = !!useReducedMotion();\n  const [text, setText] = useState(\"\");\n  const [files, setFiles] = useState<Pending[]>([]);\n  const [dragging, setDragging] = useState(false);\n  const [sent, setSent] = useState(0);\n  const areaRef = useRef<HTMLTextAreaElement>(null);\n  const pickerRef = useRef<HTMLInputElement>(null);\n  const mirrorRef = useRef<HTMLDivElement>(null);\n  const height = useAutoHeight(mirrorRef, 6 * 22 + 18, reduced);\n  const ready = !disabled && (text.trim().length > 0 || files.length > 0);\n  const counter = useRef(0);\n\n  useImperativeHandle(ref, () => ({ focus: () => areaRef.current?.focus() }), []);\n\n  const filesRef = useRef(files);\n  useEffect(() => {\n    filesRef.current = files;\n  }, [files]);\n  useEffect(() => () => filesRef.current.forEach((entry) => entry.url && URL.revokeObjectURL(entry.url)), []);\n\n  const add = (list: FileList | File[] | null) => {\n    if (!allowAttachments || !list) return;\n    const next = Array.from(list)\n      .slice(0, 10)\n      .map((file) => ({\n        id: `file-${++counter.current}`,\n        file,\n        url: file.type.startsWith(\"image/\") ? URL.createObjectURL(file) : undefined,\n      }));\n    if (next.length) setFiles((current) => [...current, ...next]);\n  };\n  const remove = (id: string) =>\n    setFiles((current) =>\n      current.filter((entry) => {\n        if (entry.id === id && entry.url) URL.revokeObjectURL(entry.url);\n        return entry.id !== id;\n      }),\n    );\n\n  const send = () => {\n    if (!ready) return;\n    onSend({ text: text.trim(), files: files.map((entry) => entry.file) });\n    files.forEach((entry) => entry.url && URL.revokeObjectURL(entry.url));\n    setFiles([]);\n    setText(\"\");\n    setSent((count) => count + 1);\n    areaRef.current?.focus();\n  };\n\n  const onKeyDown = (event: ReactKeyboardEvent<HTMLTextAreaElement>) => {\n    if (event.key === \"Enter\" && !event.shiftKey && !event.nativeEvent.isComposing) {\n      event.preventDefault();\n      send();\n    }\n  };\n  const onPaste = (event: ReactClipboardEvent<HTMLTextAreaElement>) => {\n    if (!allowAttachments || !event.clipboardData.files.length) return;\n    event.preventDefault();\n    add(event.clipboardData.files);\n  };\n  const onDrop = (event: ReactDragEvent<HTMLDivElement>) => {\n    if (!allowAttachments) return;\n    event.preventDefault();\n    setDragging(false);\n    add(event.dataTransfer.files);\n  };\n\n  const tray = reduced ? { opacity: 0 } : { opacity: 0, scale: 0.9, filter: `blur(${blur.subtle}px)` };\n\n  return (\n    <div\n      className={cn(\n        \"grid border-t border-line-soft px-3 pt-2.5 pb-3 transition-[background-color] duration-fast ease-standard\",\n        dragging && \"bg-accent-tint\",\n        className,\n      )}\n      data-dragging={dragging || undefined}\n      onDragOver={(event) => {\n        if (allowAttachments && event.dataTransfer.types.includes(\"Files\")) {\n          event.preventDefault();\n          setDragging(true);\n        }\n      }}\n      onDragLeave={(event) => {\n        if (!event.currentTarget.contains(event.relatedTarget as Node)) setDragging(false);\n      }}\n      onDrop={onDrop}\n    >\n      <AnimatePresence initial={false}>\n        {files.length > 0 && (\n          <motion.div\n            key=\"tray\"\n            className=\"overflow-hidden\"\n            initial={{ height: 0, opacity: 0 }}\n            animate={{ height: \"auto\", opacity: 1 }}\n            exit={{ height: 0, opacity: 0 }}\n            transition={reduced ? { duration: 0 } : { height: GROW, opacity: { duration: duration.fast } }}\n          >\n            <ul\n              className=\"m-0 flex list-none gap-2 overflow-x-auto pt-2 pr-2 pb-2.5 pl-1 [scrollbar-width:none]\"\n              aria-label={t.attachments}\n            >\n              <AnimatePresence initial={false} mode=\"popLayout\">\n                {files.map((entry) => (\n                  <motion.li\n                    key={entry.id}\n                    className=\"relative flex-none\"\n                    layout={!reduced}\n                    initial={tray}\n                    animate={{ opacity: 1, scale: 1, filter: \"blur(0px)\" }}\n                    exit={{ ...tray, transition: { duration: duration.exit, ease: standard } }}\n                    transition={POP}\n                  >\n                    {entry.url ? (\n                      // eslint-disable-next-line @next/next/no-img-element -- local object URL preview\n                      <img className=\"block size-14 rounded-[12px] object-cover\" src={entry.url} alt=\"\" />\n                    ) : (\n                      <span className=\"box-border grid h-14 w-[168px] grid-cols-[auto_minmax(0,1fr)] content-center gap-x-2 rounded-[12px] border border-line bg-surface px-3 [&>svg]:row-span-2 [&>svg]:self-center [&>svg]:text-ink-2\">\n                        <FileText size={16} strokeWidth={1.75} aria-hidden=\"true\" />\n                        <span className=\"truncate text-(length:--text-xs) font-medium\">{entry.file.name}</span>\n                        <span className=\"text-(length:--text-xs) text-ink-3 tabular-nums\">\n                          {formatBytes(entry.file.size)}\n                        </span>\n                      </span>\n                    )}\n                    <button\n                      type=\"button\"\n                      className=\"absolute -top-1.5 -right-1.5 grid size-5 cursor-pointer place-items-center rounded-button border-2 border-surface bg-ink p-0 text-page\"\n                      aria-label={t.removeFile(entry.file.name)}\n                      onClick={() => remove(entry.id)}\n                    >\n                      <X size={12} strokeWidth={2.25} />\n                    </button>\n                  </motion.li>\n                ))}\n              </AnimatePresence>\n            </ul>\n          </motion.div>\n        )}\n      </AnimatePresence>\n      <div className=\"flex items-end gap-1.5\">\n        {allowAttachments && (\n          <>\n            <button\n              type=\"button\"\n              className=\"mb-px grid size-10 flex-none cursor-pointer place-items-center rounded-button border-0 bg-transparent p-0 text-ink-2 [-webkit-tap-highlight-color:transparent] [transition:background-color_var(--duration-fast)_var(--ease-standard),color_var(--duration-fast)_var(--ease-standard),transform_var(--duration-instant)_var(--ease-standard)] hover:bg-hover hover:text-ink active:[transform:scale(.94)]\"\n              aria-label={t.attach}\n              disabled={disabled}\n              onClick={() => pickerRef.current?.click()}\n            >\n              <Paperclip size={18} strokeWidth={1.75} />\n            </button>\n            <input\n              ref={pickerRef}\n              type=\"file\"\n              multiple\n              accept={accept}\n              hidden\n              onChange={(event) => {\n                add(event.target.files);\n                event.target.value = \"\";\n              }}\n            />\n          </>\n        )}\n        {/* The field and the send button share one rounded box; the button rides the bottom edge as the text grows. */}\n        <div\n          className={cn(\n            \"relative flex min-w-0 flex-1 items-end rounded-[21px] border bg-surface transition-[border-color,background-color] duration-fast ease-standard\",\n            dragging ? \"border-dashed border-accent\" : \"border-line focus-within:border-line-strong\",\n          )}\n        >\n          <motion.div className=\"relative min-w-0 flex-1 overflow-hidden rounded-[20px]\" style={{ height }}>\n            <div\n              ref={mirrorRef}\n              className={cn(fieldText, \"pointer-events-none invisible absolute top-0 left-0\")}\n              aria-hidden=\"true\"\n            >\n              {text}\n              {\"\\u200b\"}\n            </div>\n            <textarea\n              ref={areaRef}\n              className={cn(\n                fieldText,\n                \"absolute inset-0 block h-full resize-none overflow-y-auto bg-transparent text-ink outline-none [scrollbar-width:none] placeholder:text-ink-3\",\n              )}\n              value={text}\n              rows={1}\n              placeholder={placeholder}\n              disabled={disabled}\n              aria-label={placeholder}\n              onChange={(event) => setText(event.target.value)}\n              onKeyDown={onKeyDown}\n              onPaste={onPaste}\n            />\n          </motion.div>\n          <button\n            type=\"button\"\n            className={cn(\n              \"relative mr-1 mb-1 grid size-8 flex-none place-items-center overflow-hidden rounded-button border-0 p-0 [-webkit-tap-highlight-color:transparent] [transition:background-color_var(--duration-standard)_var(--ease-standard),color_var(--duration-standard)_var(--ease-standard),transform_var(--duration-instant)_var(--ease-standard)]\",\n              ready\n                ? \"cursor-pointer bg-accent text-on-accent active:[transform:scale(.92)]\"\n                : \"cursor-default bg-transparent text-ink-3\",\n            )}\n            data-ready={ready || undefined}\n            aria-label={t.send}\n            aria-disabled={!ready}\n            onClick={send}\n          >\n            <AnimatePresence initial={false} mode=\"popLayout\">\n              <motion.span\n                key={sent}\n                className=\"grid place-items-center [grid-area:1/1]\"\n                initial={reduced ? { opacity: 0 } : { opacity: 0, y: 14 }}\n                animate={{ opacity: 1, y: 0 }}\n                exit={\n                  reduced\n                    ? { opacity: 0 }\n                    : { opacity: 0, y: -18, transition: { duration: duration.fast, ease: standard } }\n                }\n                transition={reduced ? { duration: duration.fast } : spring.snappy}\n              >\n                <ArrowUp size={16} strokeWidth={2.25} />\n              </motion.span>\n            </AnimatePresence>\n          </button>\n        </div>\n      </div>\n    </div>\n  );\n});\n\n/**\n * A chat or support thread: grouped messages with day separators, reactions, read receipts that glide to the latest read message,\n * a typing indicator, and a composer. The list stays pinned to the newest message and offers a pill when new messages land out of view.\n */\nexport const ChatThread = forwardRef<ChatThreadHandle, ChatThreadProps>(function ChatThread(\n  {\n    participants,\n    currentUserId,\n    messages: messagesProp,\n    defaultMessages,\n    typing = [],\n    readBy,\n    onSend,\n    onReact,\n    onRetry,\n    reactions = DEFAULT_REACTIONS,\n    placeholder = \"Message\",\n    composer = true,\n    allowAttachments = true,\n    accept,\n    groupWindow = 5 * 60_000,\n    locale = \"en-US\",\n    label = \"Conversation\",\n    labels,\n    className,\n  },\n  ref,\n) {\n  const t = useMemo(\n    () => withDefaults({ ...THREAD_LABELS, today: relativeDay(locale, 0), yesterday: relativeDay(locale, -1) }, labels),\n    [labels, locale],\n  );\n  const reduced = !!useReducedMotion();\n  const hydrated = useHydrated();\n  const uid = useId();\n  const [inner, setInner] = useState<ChatMessage[]>(defaultMessages ?? []);\n  const messages = messagesProp ?? inner;\n  const controlled = messagesProp !== undefined;\n  const people = useMemo(() => new Map(participants.map((entry) => [entry.id, entry])), [participants]);\n  const composerRef = useRef<ChatComposerHandle>(null);\n\n  const rows = useMemo<Row[]>(() => {\n    const out: Row[] = [];\n    let lastDay = \"\";\n    messages.forEach((message, index) => {\n      const date = toDate(message.createdAt);\n      const key = dayKey(date);\n      if (key !== lastDay) {\n        out.push({ type: \"day\", key: `day-${key}`, label: hydrated ? dayLabel(date, locale, t) : \"\" });\n        lastDay = key;\n      }\n      const joins = (other: ChatMessage | undefined) =>\n        !!other &&\n        other.authorId === message.authorId &&\n        dayKey(toDate(other.createdAt)) === key &&\n        Math.abs(toDate(other.createdAt).getTime() - date.getTime()) <= groupWindow;\n      out.push({\n        type: \"message\",\n        key: message.id,\n        message,\n        index,\n        author: people.get(message.authorId) ?? { id: message.authorId, name: t.unknownAuthor },\n        mine: message.authorId === currentUserId,\n        first: !joins(messages[index - 1]),\n        last: !joins(messages[index + 1]),\n      });\n    });\n    return out;\n  }, [currentUserId, groupWindow, hydrated, locale, messages, people, t]);\n\n  const receipts = useMemo(() => {\n    const map = new Map<string, ChatParticipant[]>();\n    for (const [personId, messageId] of Object.entries(readBy ?? {})) {\n      const who = people.get(personId);\n      const read = messages.find((message) => message.id === messageId);\n      // A reader's avatar under their own message says nothing new, so it stays hidden until they read past it.\n      if (!who || personId === currentUserId || !read || read.authorId === personId) continue;\n      map.set(messageId, [...(map.get(messageId) ?? []), who]);\n    }\n    return map;\n  }, [currentUserId, messages, people, readBy]);\n  const readIndex = useMemo(() => {\n    let furthest = -1;\n    receipts.forEach((_, messageId) => {\n      furthest = Math.max(\n        furthest,\n        messages.findIndex((message) => message.id === messageId),\n      );\n    });\n    return furthest;\n  }, [messages, receipts]);\n  const lastMine = useMemo(() => {\n    for (let index = messages.length - 1; index >= 0; index--)\n      if (messages[index]!.authorId === currentUserId) return index;\n    return -1;\n  }, [currentUserId, messages]);\n\n  const time = useMemo(() => new Intl.DateTimeFormat(locale, { hour: \"numeric\", minute: \"2-digit\" }), [locale]);\n\n  /* Pinning. While the reader is at the bottom, every growth of the content keeps the newest message in view:\n     the scroll jumps to the end before paint and the content springs up from where it was, so nothing snaps. */\n  const scrollRef = useRef<HTMLDivElement>(null);\n  const contentRef = useRef<HTMLDivElement>(null);\n  const y = useMotionValue(0);\n  const stick = useRef(true);\n  const lastHeight = useRef(0);\n  const reducedRef = useRef(reduced);\n  useEffect(() => {\n    reducedRef.current = reduced;\n  }, [reduced]);\n  const [atBottom, setAtBottom] = useState(true);\n  const [far, setFar] = useState(false);\n  const [seen, setSeen] = useState(() => messages.length);\n\n  useLayoutEffect(() => {\n    const scroller = scrollRef.current,\n      content = contentRef.current;\n    if (!scroller || !content) return;\n    lastHeight.current = content.offsetHeight;\n    scroller.scrollTop = scroller.scrollHeight;\n    if (typeof ResizeObserver === \"undefined\") return;\n    const observer = new ResizeObserver(() => {\n      const height = content.offsetHeight,\n        delta = height - lastHeight.current;\n      lastHeight.current = height;\n      if (!stick.current) return;\n      scroller.scrollTop = scroller.scrollHeight;\n      if (delta > 0 && delta < scroller.clientHeight && !reducedRef.current) {\n        y.jump(y.get() + delta);\n        animate(y, 0, SETTLE);\n      }\n    });\n    observer.observe(content);\n    return () => observer.disconnect();\n  }, [y]);\n\n  // Your own message always brings the thread back to the bottom.\n  const known = useRef(new Set(messages.map((message) => message.id)));\n  useLayoutEffect(() => {\n    const fresh = messages.filter((message) => !known.current.has(message.id));\n    fresh.forEach((message) => known.current.add(message.id));\n    if (fresh.some((message) => message.authorId === currentUserId)) stick.current = true;\n  }, [currentUserId, messages]);\n\n  const onScroll = () => {\n    const scroller = scrollRef.current;\n    if (!scroller) return;\n    const distance = scroller.scrollHeight - scroller.scrollTop - scroller.clientHeight;\n    stick.current = distance < STICK_DISTANCE;\n    setAtBottom(stick.current);\n    setFar(distance > FAR_DISTANCE);\n    if (stick.current) setSeen(messages.length);\n  };\n\n  const scrollToBottom = useCallback((smooth = true) => {\n    const scroller = scrollRef.current;\n    if (!scroller) return;\n    stick.current = true;\n    scroller.scrollTo({ top: scroller.scrollHeight, behavior: smooth && !reducedRef.current ? \"smooth\" : \"auto\" });\n  }, []);\n\n  useImperativeHandle(ref, () => ({ scrollToBottom, focusComposer: () => composerRef.current?.focus() }), [\n    scrollToBottom,\n  ]);\n\n  const unread = atBottom ? 0 : messages.slice(seen).filter((message) => message.authorId !== currentUserId).length;\n  const pill = !atBottom && (unread > 0 || far);\n\n  /* Reactions and the picker. */\n  const [picker, setPicker] = useState<string | null>(null);\n  const react = (messageId: string, emoji: string) => {\n    setPicker(null);\n    onReact?.(messageId, emoji);\n    if (controlled) return;\n    setInner((current) =>\n      current.map((message) => {\n        if (message.id !== messageId) return message;\n        const list = [...(message.reactions ?? [])];\n        const at = list.findIndex((entry) => entry.emoji === emoji);\n        const found = list[at];\n        if (!found) list.push({ emoji, count: 1, mine: true });\n        else if (found.mine) list[at] = { ...found, count: found.count - 1, mine: false };\n        else list[at] = { ...found, count: found.count + 1, mine: true };\n        return { ...message, reactions: list.filter((entry) => entry.count > 0) };\n      }),\n    );\n  };\n\n  const counter = useRef(0);\n  const send = (draft: ChatDraft) => {\n    stick.current = true;\n    const result = onSend?.(draft);\n    if (controlled) return result;\n    const attachments: ChatAttachment[] = draft.files.map((file, index) => ({\n      id: `local-file-${++counter.current}-${index}`,\n      name: file.name,\n      size: file.size,\n      kind: file.type.startsWith(\"image/\") ? \"image\" : \"file\",\n      url: URL.createObjectURL(file),\n    }));\n    setInner((current) => [\n      ...current,\n      {\n        id: `local-${Date.now()}-${++counter.current}`,\n        authorId: currentUserId,\n        text: draft.text || undefined,\n        attachments,\n        createdAt: new Date(),\n        status: \"sent\",\n      },\n    ]);\n  };\n\n  const typers = typing\n    .map((id) => people.get(id))\n    .filter((entry): entry is ChatParticipant => !!entry && entry.id !== currentUserId);\n  const typingLabel = typers.length ? t.typing(typers.map((entry) => entry.name)) : \"\";\n\n  const arrive = (mine: boolean) =>\n    reduced ? { opacity: 0 } : { opacity: 0, scale: 0.92, y: 14, x: mine ? 8 : -8, filter: `blur(${blur.subtle}px)` };\n\n  return (\n    <div\n      className={cn(\n        \"flex h-full min-h-0 flex-col font-body text-(length:--text-sm) leading-body tracking-body text-ink\",\n        className,\n      )}\n    >\n      <div className=\"relative flex min-h-0 flex-1 flex-col\">\n        <div\n          ref={scrollRef}\n          className=\"min-h-0 flex-1 overflow-x-hidden overflow-y-auto overscroll-contain [scrollbar-gutter:stable] [scrollbar-width:thin]\"\n          onScroll={onScroll}\n          data-chat-scroller=\"\"\n        >\n          {/* The content springs up from where it was when the thread grows; the clip keeps that travel out of the scroll range. */}\n          <div className=\"overflow-clip\">\n            <motion.div\n              ref={contentRef}\n              className=\"px-4 pt-3 pb-3.5 will-change-transform max-[420px]:px-3\"\n              style={{ y }}\n            >\n              <ol\n                className=\"m-0 flex list-none flex-col p-0\"\n                role=\"log\"\n                aria-label={label}\n                aria-live=\"polite\"\n                aria-relevant=\"additions\"\n              >\n                <AnimatePresence initial={false}>\n                  {rows.map((row) => {\n                    // Before hydration the label is empty; a no-break space keeps the separator's height.\n                    if (row.type === \"day\")\n                      return (\n                        <li\n                          key={row.key}\n                          className=\"flex items-center gap-3 px-1 pt-5 pb-1 text-(length:--text-xs) font-medium text-ink-3 before:h-px before:flex-1 before:bg-line-soft before:content-[''] after:h-px after:flex-1 after:bg-line-soft after:content-[''] first:pt-1\"\n                          data-day=\"\"\n                        >\n                          <span suppressHydrationWarning>{row.label || \"\\u00a0\"}</span>\n                        </li>\n                      );\n                    const { message, author, mine, first, last, index } = row;\n                    const date = toDate(message.createdAt);\n                    const seenBy = receipts.get(message.id) ?? [];\n                    const showStatus = index === lastMine && mine;\n                    const status =\n                      message.status === \"failed\"\n                        ? \"failed\"\n                        : message.status === \"sending\"\n                          ? \"sending\"\n                          : readIndex >= index\n                            ? \"read\"\n                            : (message.status ?? \"sent\");\n                    const images = message.attachments?.filter((entry) => entry.kind === \"image\" && entry.url) ?? [];\n                    const files = message.attachments?.filter((entry) => entry.kind !== \"image\" || !entry.url) ?? [];\n                    return (\n                      <motion.li\n                        key={row.key}\n                        className={cn(rowClass, first ? \"mt-3.5\" : \"mt-0.5\", mine && \"justify-end\")}\n                        data-chat-row=\"\"\n                        data-mine={mine || undefined}\n                        data-first={first || undefined}\n                        data-last={last || undefined}\n                        data-failed={message.status === \"failed\" || undefined}\n                        initial={arrive(mine)}\n                        animate={{ opacity: 1, scale: 1, y: 0, x: 0, filter: \"blur(0px)\" }}\n                        exit={{ opacity: 0, transition: { duration: duration.exit } }}\n                        transition={\n                          reduced\n                            ? { duration: duration.fast }\n                            : {\n                                ...ARRIVE,\n                                opacity: { duration: duration.standard, ease: enter },\n                                filter: { duration: duration.standard, ease: enter },\n                              }\n                        }\n                        style={{ transformOrigin: mine ? \"100% 100%\" : \"0 100%\" }}\n                      >\n                        {!mine && <div className=\"w-7 flex-none self-end\" />}\n                        <div className={cn(stackClass, mine ? \"items-end\" : \"items-start\")}>\n                          {first && (\n                            <p className=\"m-0 mb-0.5 flex items-baseline gap-1.5 px-3 text-(length:--text-xs) text-ink-3 tabular-nums\">\n                              {!mine && <span className=\"font-medium text-ink-2\">{author.name}</span>}\n                              <time dateTime={date.toISOString()} suppressHydrationWarning>\n                                {hydrated ? time.format(date) : \"\"}\n                              </time>\n                            </p>\n                          )}\n                          <div className={cn(lineClass, mine && \"flex-row-reverse\")}>\n                            {!mine && last && (\n                              <span className={faceClass}>\n                                <Avatar person={author} size={28} />\n                              </span>\n                            )}\n                            <div className={cn(\"flex min-w-0 flex-col gap-1\", mine ? \"items-end\" : \"items-start\")}>\n                              {images.length > 0 && (\n                                <div\n                                  className={cn(\n                                    \"grid w-[min(240px,100%)] gap-[3px]\",\n                                    images.length === 1 ? \"grid-cols-1\" : \"grid-cols-2 overflow-hidden rounded-[16px]\",\n                                  )}\n                                  data-count={Math.min(images.length, 4)}\n                                >\n                                  {images.slice(0, 4).map((image, at) => (\n                                    <a\n                                      key={image.id}\n                                      className={cn(\n                                        \"relative block overflow-hidden bg-hover\",\n                                        images.length === 1 ? \"aspect-[4/3] rounded-[16px]\" : \"rounded-[6px]\",\n                                        images.length > 1 &&\n                                          (Math.min(images.length, 4) === 3 && at === 0\n                                            ? \"col-span-2 aspect-[2/1]\"\n                                            : \"aspect-square\"),\n                                      )}\n                                      href={image.url}\n                                      target=\"_blank\"\n                                      rel=\"noreferrer\"\n                                      style={\n                                        images.length === 1 && image.width && image.height\n                                          ? { aspectRatio: `${image.width} / ${image.height}` }\n                                          : undefined\n                                      }\n                                    >\n                                      {/* eslint-disable-next-line @next/next/no-img-element -- registry components stay framework agnostic */}\n                                      <img\n                                        className=\"block size-full object-cover\"\n                                        src={image.url}\n                                        alt={image.alt ?? image.name}\n                                        loading=\"lazy\"\n                                      />\n                                      {at === 3 && images.length > 4 && (\n                                        <span className=\"absolute inset-0 grid place-items-center bg-[oklch(0%_0_0/.45)] text-(length:--text-lg) font-medium text-white\">\n                                          +{images.length - 4}\n                                        </span>\n                                      )}\n                                    </a>\n                                  ))}\n                                </div>\n                              )}\n                              {files.map((file) => (\n                                <a\n                                  key={file.id}\n                                  className=\"flex max-w-full items-center gap-2.5 rounded-[16px] border border-line bg-surface py-[9px] pr-3.5 pl-[11px] text-ink no-underline transition-[background-color] duration-fast ease-standard hover:bg-hover [&>svg]:flex-none [&>svg]:text-ink-2\"\n                                  href={file.url}\n                                  download={file.name}\n                                  target=\"_blank\"\n                                  rel=\"noreferrer\"\n                                >\n                                  <FileText size={18} strokeWidth={1.75} aria-hidden=\"true\" />\n                                  <span className=\"grid min-w-0\">\n                                    <span className=\"truncate font-medium\">{file.name}</span>\n                                    {file.size !== undefined && (\n                                      <span className=\"text-(length:--text-xs) text-ink-3 tabular-nums\">\n                                        {formatBytes(file.size)}\n                                      </span>\n                                    )}\n                                  </span>\n                                </a>\n                              ))}\n                              {message.text && (\n                                <div\n                                  className={cn(\n                                    bubbleClass(mine, first, last),\n                                    \"pt-2 pr-[13px] pb-[9px] pl-[13px]\",\n                                    message.status === \"failed\" && \"opacity-60\",\n                                  )}\n                                  title={\n                                    hydrated\n                                      ? date.toLocaleString(locale, { dateStyle: \"medium\", timeStyle: \"short\" })\n                                      : undefined\n                                  }\n                                >\n                                  {message.text}\n                                </div>\n                              )}\n                            </div>\n                            <ReactionPicker\n                              open={picker === message.id}\n                              mine={mine}\n                              reactions={reactions}\n                              reduced={reduced}\n                              labels={t}\n                              id={`${uid}-picker-${message.id}`}\n                              onToggle={() => setPicker((current) => (current === message.id ? null : message.id))}\n                              onClose={() => setPicker(null)}\n                              onPick={(emoji) => react(message.id, emoji)}\n                            />\n                          </div>\n                          <AnimatePresence initial={false}>\n                            {!!message.reactions?.length && (\n                              <motion.div\n                                key=\"reactions\"\n                                className={cn(\"-mx-1 -my-0.5 overflow-hidden py-0.5\", mine ? \"pr-2 pl-1\" : \"pr-1 pl-2\")}\n                                initial={{ height: 0, opacity: 0 }}\n                                animate={{ height: \"auto\", opacity: 1 }}\n                                exit={{ height: 0, opacity: 0 }}\n                                transition={\n                                  reduced ? { duration: 0 } : { height: GROW, opacity: { duration: duration.fast } }\n                                }\n                              >\n                                <ul\n                                  className={cn(\"m-0 flex list-none flex-wrap gap-1 p-0\", mine && \"justify-end\")}\n                                  aria-label={t.reactions}\n                                >\n                                  <AnimatePresence initial={false} mode=\"popLayout\">\n                                    {message.reactions.map((reaction) => (\n                                      <motion.li\n                                        key={reaction.emoji}\n                                        layout={!reduced}\n                                        initial={reduced ? { opacity: 0 } : { opacity: 0, scale: 0.6 }}\n                                        animate={{ opacity: 1, scale: 1 }}\n                                        exit={reduced ? { opacity: 0 } : { opacity: 0, scale: 0.6 }}\n                                        transition={POP}\n                                      >\n                                        <button\n                                          type=\"button\"\n                                          className={cn(\n                                            \"inline-flex h-[26px] cursor-pointer items-center gap-[5px] rounded-button border pr-[9px] pl-[7px] text-(length:--text-xs) leading-none font-medium tabular-nums [-webkit-tap-highlight-color:transparent] [transition:background-color_var(--duration-fast)_var(--ease-standard),border-color_var(--duration-fast)_var(--ease-standard),color_var(--duration-fast)_var(--ease-standard),transform_var(--duration-instant)_var(--ease-standard)] hover:border-line-strong active:[transform:scale(.95)] [&>span:first-child]:text-[14px]\",\n                                            reaction.mine\n                                              ? \"border-[color-mix(in_oklab,var(--accent)_28%,transparent)] bg-[color-mix(in_oklab,var(--accent)_8%,var(--surface))] text-ink\"\n                                              : \"border-line-soft bg-surface text-ink-2\",\n                                          )}\n                                          aria-pressed={!!reaction.mine}\n                                          aria-label={t.reactionCount(reaction.emoji, reaction.count, !!reaction.mine)}\n                                          onClick={() => react(message.id, reaction.emoji)}\n                                        >\n                                          <span aria-hidden=\"true\">{reaction.emoji}</span>\n                                          <Count value={reaction.count} reduced={reduced} />\n                                        </button>\n                                      </motion.li>\n                                    ))}\n                                  </AnimatePresence>\n                                </ul>\n                              </motion.div>\n                            )}\n                          </AnimatePresence>\n                          {(showStatus || seenBy.length > 0) && (\n                            <div className=\"flex min-h-4 items-center gap-1.5 px-1.5 pt-px pb-0 text-(length:--text-xs) text-ink-3\">\n                              {showStatus && (\n                                <span\n                                  className={cn(status === \"failed\" && \"inline-flex items-center gap-2 text-red\")}\n                                  data-status={status}\n                                >\n                                  {status === \"failed\" ? (\n                                    <>\n                                      {t.failed}\n                                      {onRetry && (\n                                        <button\n                                          type=\"button\"\n                                          className=\"inline-flex cursor-pointer items-center gap-1 rounded-button border border-line bg-surface px-2 py-0.5 leading-4 font-medium text-ink [-webkit-tap-highlight-color:transparent] transition-[background-color,border-color] duration-fast ease-standard hover:border-line-strong hover:bg-hover [&>svg]:text-ink-2\"\n                                          onClick={() => onRetry(message.id)}\n                                        >\n                                          <RotateCw size={12} strokeWidth={2} aria-hidden=\"true\" />\n                                          {t.retry}\n                                        </button>\n                                      )}\n                                    </>\n                                  ) : (\n                                    t[status]\n                                  )}\n                                </span>\n                              )}\n                              {seenBy.length > 0 && (\n                                <span className=\"inline-flex\" aria-label={t.readBy(seenBy.map((entry) => entry.name))}>\n                                  {seenBy.map((entry) => (\n                                    <motion.span\n                                      key={entry.id}\n                                      layoutId={reduced ? undefined : `${uid}-receipt-${entry.id}`}\n                                      className=\"inline-flex rounded-full shadow-[0_0_0_2px_var(--surface)] not-first:-ml-1\"\n                                      transition={SETTLE}\n                                    >\n                                      <Avatar person={entry} size={16} />\n                                    </motion.span>\n                                  ))}\n                                </span>\n                              )}\n                            </div>\n                          )}\n                        </div>\n                      </motion.li>\n                    );\n                  })}\n                  {typers.length > 0 && (\n                    <motion.li\n                      key=\"typing\"\n                      className={cn(rowClass, \"mt-3.5\")}\n                      data-first\n                      data-last\n                      aria-hidden=\"true\"\n                      initial={reduced ? { opacity: 0 } : { opacity: 0, scale: 0.9, y: 8 }}\n                      animate={{ opacity: 1, scale: 1, y: 0 }}\n                      exit={\n                        reduced\n                          ? { opacity: 0 }\n                          : { opacity: 0, scale: 0.9, transition: { duration: duration.exit, ease: standard } }\n                      }\n                      transition={reduced ? { duration: duration.fast } : spring.snappy}\n                      style={{ transformOrigin: \"0 100%\" }}\n                    >\n                      <div className=\"w-7 flex-none self-end\" />\n                      <div className={cn(stackClass, \"items-start\")}>\n                        <div className={lineClass}>\n                          <span className={faceClass}>\n                            <Avatar person={typers[0]!} size={28} />\n                          </span>\n                          <div\n                            className={cn(\n                              bubbleClass(false, true, true),\n                              \"box-border grid h-9 place-items-center px-3.5\",\n                            )}\n                          >\n                            <TypingDots />\n                          </div>\n                        </div>\n                      </div>\n                    </motion.li>\n                  )}\n                </AnimatePresence>\n              </ol>\n            </motion.div>\n          </div>\n        </div>\n        <span className=\"sr-only\" role=\"status\">\n          {typingLabel}\n        </span>\n        <AnimatePresence>\n          {pill && (\n            <motion.button\n              key=\"pill\"\n              type=\"button\"\n              className=\"absolute bottom-3 left-1/2 z-6 inline-flex h-8 -translate-x-1/2 cursor-pointer items-center gap-1.5 rounded-button border border-line bg-surface pr-3.5 pl-[11px] text-(length:--text-xs) font-medium whitespace-nowrap text-ink shadow-floating [-webkit-tap-highlight-color:transparent] active:scale-97\"\n              onClick={() => scrollToBottom()}\n              layout={!reduced}\n              style={{ borderRadius: 16 }}\n              initial={reduced ? { opacity: 0 } : { opacity: 0, y: 12, scale: 0.94 }}\n              animate={{ opacity: 1, y: 0, scale: 1 }}\n              exit={\n                reduced\n                  ? { opacity: 0 }\n                  : { opacity: 0, y: 12, scale: 0.94, transition: { duration: duration.exit, ease: standard } }\n              }\n              transition={\n                reduced ? { duration: duration.fast } : { ...spring.morph, opacity: { duration: duration.fast } }\n              }\n            >\n              {/* Icon and label hold their own size while the pill's width morphs, so the text never stretches. */}\n              <motion.span\n                className=\"inline-grid place-items-center\"\n                layout={reduced ? false : \"position\"}\n                transition={spring.morph}\n              >\n                <ArrowDown size={14} strokeWidth={2} aria-hidden=\"true\" />\n              </motion.span>\n              <AnimatePresence initial={false} mode=\"popLayout\">\n                <motion.span\n                  key={unread > 0 ? \"new\" : \"latest\"}\n                  className=\"inline-flex gap-[.3em]\"\n                  layout={reduced ? false : \"position\"}\n                  initial={{ opacity: 0, filter: `blur(${blur.subtle}px)` }}\n                  animate={{ opacity: 1, filter: \"blur(0px)\" }}\n                  exit={{ opacity: 0, filter: `blur(${blur.subtle}px)` }}\n                  transition={{ duration: duration.fast }}\n                >\n                  {unread > 0\n                    ? withCount(t.newMessages(unread), unread, <Count value={unread} reduced={reduced} />)\n                    : t.jumpToLatest}\n                </motion.span>\n              </AnimatePresence>\n            </motion.button>\n          )}\n        </AnimatePresence>\n      </div>\n      {composer && (\n        <ChatComposer\n          ref={composerRef}\n          onSend={send}\n          placeholder={placeholder}\n          allowAttachments={allowAttachments}\n          accept={accept}\n          labels={t}\n        />\n      )}\n    </div>\n  );\n});\n\nfunction ReactionPicker({\n  open,\n  mine,\n  reactions,\n  reduced,\n  labels,\n  id,\n  onToggle,\n  onClose,\n  onPick,\n}: {\n  open: boolean;\n  mine: boolean;\n  reactions: string[];\n  reduced: boolean;\n  labels: Pick<Required<ChatThreadLabels>, \"addReaction\" | \"reactions\" | \"reactWith\">;\n  id: string;\n  onToggle: () => void;\n  onClose: () => void;\n  onPick: (emoji: string) => void;\n}) {\n  const wrapRef = useRef<HTMLDivElement>(null);\n  const triggerRef = useRef<HTMLButtonElement>(null);\n  const [active, setActive] = useState(0);\n  const [below, setBelow] = useState(false);\n  /** The picker opens above the bubble unless that would run past the top of the thread. */\n  const toggle = () => {\n    const wrap = wrapRef.current,\n      scroller = wrap?.closest(\"[data-chat-scroller]\");\n    if (!open && wrap && scroller)\n      setBelow(wrap.getBoundingClientRect().top - scroller.getBoundingClientRect().top < 56);\n    onToggle();\n  };\n\n  const closeRef = useRef(onClose);\n  useEffect(() => {\n    closeRef.current = onClose;\n  }, [onClose]);\n  useEffect(() => {\n    if (!open) return;\n    const down = (event: PointerEvent) => {\n      if (!wrapRef.current?.contains(event.target as Node)) closeRef.current();\n    };\n    document.addEventListener(\"pointerdown\", down);\n    const frame = requestAnimationFrame(() =>\n      wrapRef.current?.querySelector<HTMLElement>(\"[data-emoji-index='0']\")?.focus({ preventScroll: true }),\n    );\n    return () => {\n      document.removeEventListener(\"pointerdown\", down);\n      cancelAnimationFrame(frame);\n    };\n  }, [open]);\n\n  const onKeyDown = (event: ReactKeyboardEvent<HTMLDivElement>) => {\n    if (event.key === \"Escape\") {\n      event.preventDefault();\n      event.stopPropagation();\n      onClose();\n      triggerRef.current?.focus();\n      return;\n    }\n    const step = event.key === \"ArrowRight\" ? 1 : event.key === \"ArrowLeft\" ? -1 : 0;\n    if (!step) return;\n    event.preventDefault();\n    const next = (active + step + reactions.length) % reactions.length;\n    setActive(next);\n    wrapRef.current?.querySelector<HTMLElement>(`[data-emoji-index='${next}']`)?.focus();\n  };\n\n  // The picker positions against the whole line, so it opens over the bubble and stays inside the thread.\n  return (\n    <div ref={wrapRef} className=\"static flex-none\" data-open={open || undefined} data-below={below || undefined}>\n      {/* The reaction button waits beside the bubble and shows on hover or keyboard focus. */}\n      <button\n        ref={triggerRef}\n        type=\"button\"\n        className={cn(\n          \"grid size-7 cursor-pointer place-items-center rounded-button border-0 p-0 [-webkit-tap-highlight-color:transparent] transition-[opacity,background-color,color] duration-fast ease-standard hover:bg-hover hover:text-ink\",\n          open\n            ? \"bg-hover text-ink opacity-100\"\n            : \"bg-transparent text-ink-3 opacity-0 group-focus-within/row:opacity-100 group-hover/row:opacity-100 [@media(hover:none)]:[[data-chat-row]:not(:focus-within)_&]:opacity-70\",\n        )}\n        aria-label={labels.addReaction}\n        aria-expanded={open}\n        aria-controls={open ? id : undefined}\n        onClick={toggle}\n      >\n        <SmilePlus size={16} strokeWidth={1.75} />\n      </button>\n      <AnimatePresence>\n        {open && (\n          <motion.div\n            id={id}\n            role=\"group\"\n            aria-label={labels.reactions}\n            className={cn(\n              \"absolute z-5 flex gap-0.5 rounded-full border border-line bg-surface p-1 shadow-floating\",\n              below ? \"top-[calc(100%_+_6px)]\" : \"bottom-[calc(100%_+_6px)]\",\n              mine ? \"-left-1\" : \"-right-1\",\n              below\n                ? mine\n                  ? \"origin-[18px_0]\"\n                  : \"origin-[calc(100%_-_18px)_0]\"\n                : mine\n                  ? \"origin-[18px_100%]\"\n                  : \"origin-[calc(100%_-_18px)_100%]\",\n            )}\n            data-mine={mine || undefined}\n            onKeyDown={onKeyDown}\n            initial={reduced ? { opacity: 0 } : { opacity: 0, scale: 0.85, y: below ? -6 : 6 }}\n            animate={{ opacity: 1, scale: 1, y: 0 }}\n            exit={\n              reduced\n                ? { opacity: 0 }\n                : { opacity: 0, scale: 0.9, y: below ? -4 : 4, transition: { duration: duration.exit, ease: standard } }\n            }\n            transition={reduced ? { duration: duration.fast } : POP}\n          >\n            {reactions.map((emoji, index) => (\n              <motion.button\n                key={emoji}\n                type=\"button\"\n                className=\"grid size-[34px] cursor-pointer place-items-center rounded-button border-0 bg-transparent p-0 text-[19px] leading-none [-webkit-tap-highlight-color:transparent] [transition:background-color_var(--duration-fast)_var(--ease-standard),transform_var(--duration-instant)_var(--ease-standard)] hover:bg-hover hover:[transform:scale(1.12)] focus-visible:bg-hover active:[transform:scale(.9)]\"\n                aria-label={labels.reactWith(emoji)}\n                data-emoji-index={index}\n                tabIndex={index === active ? 0 : -1}\n                initial={reduced ? false : { opacity: 0, y: 6 }}\n                animate={{ opacity: 1, y: 0 }}\n                transition={{ ...POP, delay: reduced ? 0 : index * 0.025 }}\n                onFocus={() => setActive(index)}\n                onClick={() => onPick(emoji)}\n              >\n                {emoji}\n              </motion.button>\n            ))}\n          </motion.div>\n        )}\n      </AnimatePresence>\n    </div>\n  );\n}\n\nexport default ChatThread;\n",
      "type": "registry:component",
      "target": "@components/iris/chat-thread/chat-thread.tsx"
    }
  ],
  "docs": "Docs and live preview: https://irisui.dev/components/chat-thread",
  "meta": {
    "kind": "component",
    "group": "Data display",
    "export": "ChatThread",
    "tags": [
      "data",
      "new"
    ],
    "docs": "https://irisui.dev/components/chat-thread"
  }
}
