{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "chat-thread",
  "type": "registry:ui",
  "title": "Chat Thread",
  "description": "Grouped bubbles with tails, pinned to the latest while new messages rise in.",
  "categories": [
    "messaging"
  ],
  "dependencies": [
    "@base-ui/react",
    "@number-flow/react",
    "motion"
  ],
  "registryDependencies": [
    "https://www.stealth.pm/r/tokens.json",
    "https://www.stealth.pm/r/cn.json",
    "https://www.stealth.pm/r/icons.json",
    "https://www.stealth.pm/r/motion.json",
    "https://www.stealth.pm/r/message-status.json",
    "https://www.stealth.pm/r/reply-preview.json"
  ],
  "files": [
    {
      "path": "components/ui/chat-thread.tsx",
      "content": "\"use client\";\nimport { Avatar } from \"@base-ui/react/avatar\";\nimport NumberFlow from \"@number-flow/react\";\nimport { AnimatePresence, animate, motion, useMotionValue, useReducedMotion, useTransform, type MotionValue } from \"motion/react\";\nimport { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from \"react\";\nimport { cn } from \"@/lib/cn\";\nimport { ArrowDown } from \"@/lib/icons\";\nimport { ease, spring } from \"@/lib/motion\";\nimport { MessageStatus, type MessageStatusValue } from \"@/components/ui/message-status\";\nimport { ReplyQuote, jumpToMessage, type ReplyTarget } from \"@/components/ui/reply-preview\";\n\n/* -------------------------------------------------------------------------------------------------\n * Types\n * -----------------------------------------------------------------------------------------------*/\n\nexport type ChatAuthor = { id: string; name: string; avatar?: string };\n\nexport type ChatMessage = {\n  id: string;\n  authorId: string;\n  /** When it was sent: a Date, an ISO string or epoch milliseconds. */\n  at: Date | string | number;\n  text?: string;\n  /** Anything that isn't text: a voice note, an image, a file card. */\n  content?: React.ReactNode;\n  /** Render content without the bubble around it, for content that brings its own (a voice message). */\n  bare?: boolean;\n  /** Delivery state of your own messages. */\n  status?: MessageStatusValue;\n  replyTo?: ReplyTarget;\n  edited?: boolean;\n};\n\ntype Position = \"single\" | \"first\" | \"middle\" | \"last\";\n\nexport type ChatRow =\n  | { type: \"day\"; key: string; date: Date }\n  | { type: \"message\"; key: string; message: ChatMessage; date: Date; own: boolean; position: Position; showName: boolean; showAvatar: boolean };\n\nconst toDate = (at: ChatMessage[\"at\"]) => (at instanceof Date ? at : new Date(at));\n\n/**\n * Turns a flat list into rows: a day separator whenever the calendar day changes, and each\n * message placed in its group (same author, same day, under `windowMinutes` apart).\n */\nexport function groupMessages(\n  messages: ChatMessage[],\n  currentUserId: string,\n  { windowMinutes = 5, timeZone, showNames = false }: { windowMinutes?: number; timeZone?: string; showNames?: boolean } = {},\n) {\n  const dayKey = new Intl.DateTimeFormat(\"en-CA\", { timeZone, year: \"numeric\", month: \"2-digit\", day: \"2-digit\" });\n  const rows: ChatRow[] = [];\n  let lastDay = \"\";\n  for (let i = 0; i < messages.length; i++) {\n    const m = messages[i];\n    const date = toDate(m.at);\n    const day = dayKey.format(date);\n    if (day !== lastDay) {\n      // Keyed by the day's first message, so re-dating the thread never re-mounts a separator.\n      rows.push({ type: \"day\", key: `day-${m.id}`, date });\n      lastDay = day;\n    }\n    const joins = (a?: ChatMessage, b?: ChatMessage) =>\n      !!a &&\n      !!b &&\n      a.authorId === b.authorId &&\n      dayKey.format(toDate(a.at)) === dayKey.format(toDate(b.at)) &&\n      Math.abs(toDate(b.at).getTime() - toDate(a.at).getTime()) <= windowMinutes * 60_000;\n    const withPrev = joins(messages[i - 1], m);\n    const withNext = joins(m, messages[i + 1]);\n    const position: Position = withPrev ? (withNext ? \"middle\" : \"last\") : withNext ? \"first\" : \"single\";\n    const own = m.authorId === currentUserId;\n    rows.push({\n      type: \"message\",\n      key: m.id,\n      message: m,\n      date,\n      own,\n      position,\n      showName: showNames && !own && !withPrev,\n      showAvatar: showNames && !own && !withNext,\n    });\n  }\n  return rows;\n}\n\n/* -------------------------------------------------------------------------------------------------\n * Hydration-safe dates: the server's time zone and locale aren't the reader's, so the first\n * render uses fixed ones and the reader's take over straight after.\n * -----------------------------------------------------------------------------------------------*/\n\nconst noop = () => () => {};\nconst useHydrated = () =>\n  useSyncExternalStore(\n    noop,\n    () => true,\n    () => false,\n  );\n\nfunction useFormatters(hydrated: boolean, locale?: string, timeZone?: string) {\n  return useMemo(() => {\n    const loc = hydrated ? locale : (locale ?? \"en-US\");\n    const tz = hydrated ? timeZone : (timeZone ?? \"UTC\");\n    const time = new Intl.DateTimeFormat(loc, { hour: \"numeric\", minute: \"2-digit\", timeZone: tz });\n    const full = new Intl.DateTimeFormat(loc, { weekday: \"long\", month: \"long\", day: \"numeric\", hour: \"numeric\", minute: \"2-digit\", timeZone: tz });\n    const weekday = new Intl.DateTimeFormat(loc, { weekday: \"long\", timeZone: tz });\n    const dayMonth = new Intl.DateTimeFormat(loc, { weekday: \"short\", month: \"short\", day: \"numeric\", timeZone: tz });\n    const withYear = new Intl.DateTimeFormat(loc, { month: \"short\", day: \"numeric\", year: \"numeric\", timeZone: tz });\n    const key = new Intl.DateTimeFormat(\"en-CA\", { timeZone: tz, year: \"numeric\", month: \"2-digit\", day: \"2-digit\" });\n    const day = (date: Date) => {\n      // Relative words need \"now\", which only the reader's clock knows.\n      if (!hydrated) return withYear.format(date);\n      const now = new Date();\n      const diff = Math.round((Date.parse(key.format(now)) - Date.parse(key.format(date))) / 86_400_000);\n      if (diff === 0) return \"Today\";\n      if (diff === 1) return \"Yesterday\";\n      if (diff > 1 && diff < 7) return weekday.format(date);\n      return date.getFullYear() === now.getFullYear() ? dayMonth.format(date) : withYear.format(date);\n    };\n    return { time: (d: Date) => time.format(d), full: (d: Date) => full.format(d), day, tz };\n  }, [hydrated, locale, timeZone]);\n}\n\n/* -------------------------------------------------------------------------------------------------\n * ChatThread\n * -----------------------------------------------------------------------------------------------*/\n\nexport type ChatThreadProps = Omit<React.ComponentProps<\"div\">, \"children\"> & {\n  messages: ChatMessage[];\n  authors: ChatAuthor[];\n  /** Whose messages sit on the right in the filled bubble. */\n  currentUserId: string;\n  /** Ids of people typing right now. */\n  typing?: string[];\n  /** Reply to a message: from the hover button, the R key, or a swipe right on touch. */\n  onReply?: (message: ChatMessage) => void;\n  /** Resend a failed message. */\n  onRetry?: (message: ChatMessage) => void;\n  /** Names and avatars on other people's messages. Defaults to on when more than two people are in the thread. */\n  showNames?: boolean;\n  /** Messages closer together than this, from the same person, share a group. */\n  groupWindow?: number;\n  locale?: string;\n  timeZone?: string;\n  /** Shown when there are no messages. */\n  empty?: React.ReactNode;\n  /** Older history exists above. Scrolling near the top calls onLoadOlder. */\n  hasOlder?: boolean;\n  onLoadOlder?: () => Promise<unknown> | void;\n  /** The composer, pinned under the thread. The jump-to-latest pill sits just above it. */\n  footer?: React.ReactNode;\n  /** Names the log for screen readers: \"Conversation with Maya Chen\". */\n  label?: string;\n};\n\nconst PIN_SLOP = 32;\nconst SWIPE_REPLY = 56;\nconst SWIPE_TIMES = 64;\n\n/**\n * A conversation: bubbles grouped by sender with a tail on the last of each group, day\n * separators that stick while you scroll, times on hover (or a swipe left on touch), and new\n * messages that rise into place while the thread stays pinned to the latest.\n */\nexport function ChatThread({\n  messages,\n  authors,\n  currentUserId,\n  typing = [],\n  onReply,\n  onRetry,\n  showNames: showNamesProp,\n  groupWindow = 5,\n  locale,\n  timeZone,\n  empty,\n  hasOlder = false,\n  onLoadOlder,\n  footer,\n  label = \"Conversation\",\n  className,\n  ...rest\n}: ChatThreadProps) {\n  const reduce = !!useReducedMotion();\n  const hydrated = useHydrated();\n  const fmt = useFormatters(hydrated, locale, timeZone);\n  const people = useMemo(() => new Map(authors.map((a) => [a.id, a])), [authors]);\n  const showNames = showNamesProp ?? new Set(messages.map((m) => m.authorId)).size > 2;\n  const rows = useMemo(\n    () => groupMessages(messages, currentUserId, { windowMinutes: groupWindow, timeZone: fmt.tz, showNames }),\n    [messages, currentUserId, groupWindow, fmt.tz, showNames],\n  );\n\n  const scrollerRef = useRef<HTMLDivElement>(null);\n  const contentRef = useRef<HTMLUListElement>(null);\n  const pinned = useRef(true);\n  const [atBottom, setAtBottom] = useState(true);\n  const [seenId, setSeenId] = useState(messages[messages.length - 1]?.id);\n  const [loadingOlder, setLoadingOlder] = useState(false);\n\n  // Rows that were loaded above the history shouldn't animate in as if they just arrived.\n  // Tracked with the \"adjust state while rendering\" pattern, so no effect has to catch up.\n  const [firstId, setFirstId] = useState(messages[0]?.id);\n  const [older, setOlder] = useState<Set<string>>(() => new Set());\n  if (messages[0]?.id !== firstId) {\n    const at = messages.findIndex((m) => m.id === firstId);\n    if (at > 0) setOlder((s) => new Set([...s, ...messages.slice(0, at).map((m) => m.id)]));\n    setFirstId(messages[0]?.id);\n  }\n\n  const lastId = messages[messages.length - 1]?.id;\n  const lastIdRef = useRef(lastId);\n  useEffect(() => {\n    lastIdRef.current = lastId;\n  }, [lastId]);\n\n  const unread = useMemo(() => {\n    const from = messages.findIndex((m) => m.id === seenId);\n    return messages.slice(from + 1).filter((m) => m.authorId !== currentUserId).length;\n  }, [messages, seenId, currentUserId]);\n\n  /* ----- pinning: follow the newest while you're at the bottom, leave you alone when you're not ----- */\n  const toBottom = useCallback(\n    (smooth: boolean) => {\n      const el = scrollerRef.current;\n      if (!el) return;\n      pinned.current = true;\n      setAtBottom(true);\n      setSeenId(lastIdRef.current);\n      el.scrollTo({ top: el.scrollHeight, behavior: smooth && !reduce ? \"smooth\" : \"auto\" });\n    },\n    [reduce],\n  );\n\n  useLayoutEffect(() => {\n    const el = scrollerRef.current;\n    if (el) el.scrollTop = el.scrollHeight;\n  }, []);\n\n  useEffect(() => {\n    const el = scrollerRef.current;\n    const content = contentRef.current;\n    if (!el || !content) return;\n    // Growing rows animate their height, so this fires every frame of an arrival and the\n    // scroll follows the growth instead of jumping to its end.\n    const ro = new ResizeObserver(() => {\n      if (!pinned.current) return;\n      el.scrollTop = el.scrollHeight;\n      setSeenId(lastIdRef.current);\n    });\n    ro.observe(content);\n    return () => ro.disconnect();\n  }, []);\n\n  // Your own message always brings you down to it, wherever you were reading.\n  const lastOwn = messages[messages.length - 1]?.authorId === currentUserId ? lastId : undefined;\n  useEffect(() => {\n    if (lastOwn && !pinned.current) toBottom(true);\n  }, [lastOwn, toBottom]);\n\n  // Keep the reader's place when older messages are added above.\n  const anchor = useRef<{ first?: string; height: number }>({ first: messages[0]?.id, height: 0 });\n  useLayoutEffect(() => {\n    const el = scrollerRef.current;\n    if (!el) return;\n    const prev = anchor.current;\n    if (prev.first && prev.first !== messages[0]?.id && !pinned.current) el.scrollTop += el.scrollHeight - prev.height;\n    anchor.current = { first: messages[0]?.id, height: el.scrollHeight };\n  });\n\n  const [floatDay, setFloatDay] = useState<string | null>(null);\n  const floatTimer = useRef<number>(undefined);\n  useEffect(() => () => window.clearTimeout(floatTimer.current), []);\n\n  const handled = useRef(0);\n  const markHand = () => {\n    handled.current = performance.now();\n  };\n\n  const onScroll = () => {\n    const el = scrollerRef.current;\n    if (!el) return;\n    const bottom = el.scrollHeight - el.scrollTop - el.clientHeight <= PIN_SLOP;\n    pinned.current = bottom;\n    if (bottom !== atBottom) setAtBottom(bottom);\n    if (bottom) setSeenId(lastIdRef.current);\n    // While scrolling, the day you're reading floats at the top once its own label has scrolled away.\n    let day: string | null = null;\n    // Only for scrolling someone did: a composer growing or a message arriving also scrolls.\n    const byHand = performance.now() - handled.current < 800;\n    if (!bottom && byHand)\n      contentRef.current?.querySelectorAll<HTMLElement>(\"[data-day]\").forEach((sep) => {\n        const top = sep.offsetTop - el.scrollTop;\n        if (top + sep.offsetHeight <= 4) day = sep.dataset.day ?? null;\n        // The next day's own label is about to take over: don't show two days at once.\n        else if (top < 56) day = null;\n      });\n    window.clearTimeout(floatTimer.current);\n    setFloatDay(day);\n    if (day) floatTimer.current = window.setTimeout(() => setFloatDay(null), 1100);\n    if (hasOlder && onLoadOlder && !loadingOlder && el.scrollTop < 120) {\n      setLoadingOlder(true);\n      Promise.resolve(onLoadOlder()).finally(() => setLoadingOlder(false));\n    }\n  };\n\n  /* ----- touch: swipe a message right to reply, the thread left to see every time ----- */\n  const swipeX = useMotionValue(0);\n  const timesX = useMotionValue(0);\n  const [swipeId, setSwipeId] = useState<string | null>(null);\n  const gesture = useRef<{ x: number; y: number; id: string | null; mode: \"none\" | \"reply\" | \"times\" | \"scroll\"; pointer: number } | null>(null);\n  const rubber = (d: number, max: number) => (d <= 0 ? 0 : d < max ? d : max + (d - max) * 0.25);\n\n  const onPointerDown = (e: React.PointerEvent) => {\n    if (e.pointerType !== \"touch\") return;\n    const row = (e.target as HTMLElement).closest<HTMLElement>(\"[data-message-id]\");\n    gesture.current = { x: e.clientX, y: e.clientY, id: row?.dataset.messageId ?? null, mode: \"none\", pointer: e.pointerId };\n  };\n  const onPointerMove = (e: React.PointerEvent) => {\n    const g = gesture.current;\n    if (!g || g.pointer !== e.pointerId) return;\n    const dx = e.clientX - g.x;\n    const dy = e.clientY - g.y;\n    if (g.mode === \"none\") {\n      if (Math.abs(dy) > 10 && Math.abs(dy) > Math.abs(dx)) g.mode = \"scroll\";\n      else if (Math.abs(dx) > 10 && Math.abs(dx) > Math.abs(dy) * 1.2) {\n        g.mode = dx > 0 && g.id && onReply ? \"reply\" : dx < 0 ? \"times\" : \"scroll\";\n        if (g.mode === \"reply\") setSwipeId(g.id);\n      }\n    }\n    if (g.mode === \"reply\") swipeX.set(rubber(dx, SWIPE_REPLY + 8));\n    if (g.mode === \"times\") timesX.set(-rubber(-dx, SWIPE_TIMES));\n  };\n  const endGesture = () => {\n    const g = gesture.current;\n    gesture.current = null;\n    if (!g) return;\n    if (g.mode === \"reply\") {\n      if (swipeX.get() >= SWIPE_REPLY) {\n        const m = messages.find((x) => x.id === g.id);\n        if (m) onReply?.(m);\n      }\n      animate(swipeX, 0, reduce ? { duration: 0 } : spring.snappy).then(() => setSwipeId(null));\n    }\n    if (g.mode === \"times\") animate(timesX, 0, reduce ? { duration: 0 } : spring.soft);\n  };\n\n  /* ----- keyboard: the log is one tab stop; arrows walk the messages ----- */\n  const messageIds = useMemo(() => messages.map((m) => m.id), [messages]);\n  const focusRow = (id: string | undefined) => {\n    if (!id) return;\n    const el = contentRef.current?.querySelector<HTMLElement>(`[data-message-id=\"${CSS.escape(id)}\"]`);\n    el?.focus({ preventScroll: true });\n    el?.scrollIntoView({ block: \"nearest\" });\n  };\n  const onKeyDown = (e: React.KeyboardEvent) => {\n    const target = e.target as HTMLElement;\n    const inRow = target.matches(\"[data-message-id]\");\n    const onLog = target === scrollerRef.current;\n    if (!inRow && !onLog) return;\n    const current = inRow ? target.dataset.messageId! : null;\n    const i = current ? messageIds.indexOf(current) : messageIds.length;\n    if (e.key === \"ArrowUp\") {\n      e.preventDefault();\n      focusRow(messageIds[Math.max(0, i - 1)]);\n    } else if (e.key === \"ArrowDown\" && current) {\n      e.preventDefault();\n      if (i >= messageIds.length - 1) scrollerRef.current?.focus();\n      else focusRow(messageIds[i + 1]);\n    } else if (e.key === \"Home\") {\n      e.preventDefault();\n      focusRow(messageIds[0]);\n    } else if (e.key === \"End\") {\n      e.preventDefault();\n      focusRow(messageIds[messageIds.length - 1]);\n    } else if ((e.key === \"r\" || e.key === \"Enter\") && current && onReply && !e.metaKey && !e.ctrlKey) {\n      e.preventDefault();\n      const m = messages.find((x) => x.id === current);\n      if (m) onReply(m);\n    } else if (e.key === \"Escape\" && inRow) {\n      e.preventDefault();\n      scrollerRef.current?.focus();\n    }\n  };\n\n  const typingPeople = typing\n    .filter((id) => id !== currentUserId)\n    .map((id) => people.get(id))\n    .filter(Boolean) as ChatAuthor[];\n  const lastOwnId = [...messages].reverse().find((m) => m.authorId === currentUserId)?.id;\n\n  return (\n    <div className={cn(\"relative flex min-h-0 flex-col [--bubble:color-mix(in_oklab,var(--fg)_7%,var(--frame))]\", className)} {...rest}>\n      <div\n        ref={scrollerRef}\n        role=\"log\"\n        aria-label={label}\n        aria-live=\"polite\"\n        aria-relevant=\"additions\"\n        tabIndex={0}\n        onScroll={onScroll}\n        onWheel={markHand}\n        onTouchMove={markHand}\n        onKeyDownCapture={markHand}\n        onPointerDownCapture={markHand}\n        onKeyDown={onKeyDown}\n        onPointerDown={onPointerDown}\n        onPointerMove={onPointerMove}\n        onPointerUp={endGesture}\n        onPointerCancel={endGesture}\n        className={cn(\n          \"relative flex min-h-0 flex-1 touch-pan-y flex-col overflow-y-auto overflow-x-hidden overscroll-contain outline-none\",\n          \"[mask-image:linear-gradient(to_bottom,transparent,black_16px,black_calc(100%-10px),transparent)] focus-visible:[mask-image:none]\",\n          \"focus-visible:outline-solid focus-visible:outline-1 focus-visible:-outline-offset-1 focus-visible:outline-fg-4\",\n        )}\n      >\n        {messages.length === 0 ? (\n          <div className=\"grid min-h-full place-items-center px-6 py-10 text-center\">{empty ?? <DefaultEmpty />}</div>\n        ) : (\n          // Clipped, not hidden: the swipe-left times live past the right edge, and a clip can't be\n          // scrolled sideways by focus or scrollIntoView the way an overflow region can.\n          <div className=\"flex flex-1 flex-col overflow-x-clip\">\n            <motion.ul ref={contentRef} style={{ x: timesX }} className=\"flex flex-1 flex-col justify-end pb-3 pt-2\">\n              {hasOlder && (\n                <li className=\"flex h-10 items-center justify-center text-[11.5px] text-fg-4\" aria-live=\"off\">\n                  {loadingOlder ? \"Loading earlier messages…\" : \"\"}\n                </li>\n              )}\n              <AnimatePresence initial={false}>\n                {rows.map((row) =>\n                  row.type === \"day\" ? (\n                    <DaySeparator key={row.key} label={fmt.day(row.date)} reduce={reduce} />\n                  ) : (\n                    <MessageRow\n                      key={row.key}\n                      row={row}\n                      author={people.get(row.message.authorId)}\n                      fmt={fmt}\n                      arrived={!older.has(row.message.id)}\n                      reduce={reduce}\n                      withAvatars={showNames}\n                      onJump={(id) => jumpToMessage(id, { root: contentRef.current })}\n                      showStatusLabel={row.own && (row.message.id === lastOwnId || row.message.status === \"failed\")}\n                      onReply={onReply}\n                      onRetry={onRetry}\n                      swipe={swipeId === row.message.id ? swipeX : undefined}\n                      timesX={timesX}\n                    />\n                  ),\n                )}\n                {typingPeople.length > 0 && <TypingRow key=\"typing\" people={typingPeople} showAvatar={showNames} reduce={reduce} />}\n              </AnimatePresence>\n            </motion.ul>\n          </div>\n        )}\n      </div>\n\n      {/* The floating day: appears while you scroll through history, fades once you stop. */}\n      <div aria-hidden className=\"pointer-events-none absolute inset-x-0 top-2 z-(--z-sticky) flex justify-center\">\n        <AnimatePresence>\n          {floatDay && (\n            <motion.span\n              key=\"float\"\n              initial={reduce ? { opacity: 0 } : { opacity: 0, y: -4 }}\n              animate={{ opacity: 1, y: 0 }}\n              exit={{ opacity: 0, transition: { duration: 0.2 } }}\n              transition={{ duration: 0.16, ease: ease.out }}\n              className=\"rounded-full border border-line-2 bg-raised px-2.5 py-0.5 text-[11px] font-medium leading-4 text-fg-2 shadow-pop\"\n            >\n              {floatDay}\n            </motion.span>\n          )}\n        </AnimatePresence>\n      </div>\n\n      {/* Jump to latest: only when you've scrolled away, with a count of what you've missed. */}\n      <div className=\"pointer-events-none relative z-(--z-sticky) h-0\">\n        <AnimatePresence>\n          {!atBottom && messages.length > 0 && (\n            <motion.button\n              key=\"jump\"\n              type=\"button\"\n              onClick={() => toBottom(true)}\n              initial={reduce ? { opacity: 0 } : { opacity: 0, y: 8, scale: 0.96 }}\n              animate={{ opacity: 1, y: 0, scale: 1 }}\n              exit={reduce ? { opacity: 0, transition: { duration: 0.1 } } : { opacity: 0, y: 6, scale: 0.97, transition: { duration: 0.14 } }}\n              transition={reduce ? { duration: 0.15 } : spring.snappy}\n              aria-label={unread ? `Jump to latest, ${unread} new message${unread === 1 ? \"\" : \"s\"}` : \"Jump to latest message\"}\n              className={cn(\n                \"pointer-events-auto absolute bottom-3 left-1/2 flex h-8 -translate-x-1/2 items-center gap-1.5 rounded-full border border-line-2 bg-raised pl-2.5 pr-3 text-[12px] font-medium text-fg shadow-pop outline-none\",\n                \"transition-[background-color,border-color,scale] duration-150 hover:border-fg-4 hover:bg-hover active:scale-[0.96] active:duration-75\",\n                \"focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3\",\n              )}\n            >\n              <ArrowDown size={14} className=\"text-fg-3\" />\n              {unread > 0 ? (\n                <span className=\"flex items-center gap-1 tabular\">\n                  <NumberFlow value={unread} aria-hidden />\n                  <span>new</span>\n                </span>\n              ) : (\n                <span>Latest</span>\n              )}\n            </motion.button>\n          )}\n        </AnimatePresence>\n      </div>\n\n      {footer}\n    </div>\n  );\n}\n\n/* -------------------------------------------------------------------------------------------------\n * Rows\n * -----------------------------------------------------------------------------------------------*/\n\ntype Fmt = ReturnType<typeof useFormatters>;\n\nfunction DaySeparator({ label, reduce }: { label: string; reduce: boolean }) {\n  return (\n    <motion.li\n      data-day={label}\n      initial={reduce ? { opacity: 0 } : { opacity: 0, y: 6 }}\n      animate={{ opacity: 1, y: 0 }}\n      exit={{ opacity: 0 }}\n      transition={{ duration: 0.2, ease: ease.out }}\n      className=\"flex items-center gap-3 px-6 pb-1 pt-4\"\n    >\n      <span aria-hidden className=\"h-px flex-1 bg-line\" />\n      <span suppressHydrationWarning className=\"text-[11px] font-medium leading-4 text-fg-3\">\n        {label}\n      </span>\n      <span aria-hidden className=\"h-px flex-1 bg-line\" />\n    </motion.li>\n  );\n}\n\nconst radius: Record<Position, { own: string; other: string }> = {\n  single: { own: \"rounded-[18px] rounded-br-[4px]\", other: \"rounded-[18px] rounded-bl-[4px]\" },\n  first: { own: \"rounded-[18px] rounded-br-[6px]\", other: \"rounded-[18px] rounded-bl-[6px]\" },\n  middle: { own: \"rounded-[18px] rounded-r-[6px]\", other: \"rounded-[18px] rounded-l-[6px]\" },\n  last: { own: \"rounded-[18px] rounded-tr-[6px] rounded-br-[4px]\", other: \"rounded-[18px] rounded-tl-[6px] rounded-bl-[4px]\" },\n};\n\ntype RowProps = {\n  row: Extract<ChatRow, { type: \"message\" }>;\n  author?: ChatAuthor;\n  fmt: Fmt;\n  arrived: boolean;\n  reduce: boolean;\n  withAvatars: boolean;\n  onJump: (id: string) => void;\n  showStatusLabel: boolean;\n  onReply?: (m: ChatMessage) => void;\n  onRetry?: (m: ChatMessage) => void;\n  swipe?: MotionValue<number>;\n  timesX: MotionValue<number>;\n};\n\nfunction MessageRow({ row, author, fmt, arrived, reduce, withAvatars, onJump, showStatusLabel, onReply, onRetry, swipe, timesX }: RowProps) {\n  const { message: m, own, position, date } = row;\n  const tail = position === \"single\" || position === \"last\";\n  const spaced = position === \"single\" || position === \"first\";\n  const name = author?.name ?? \"Unknown\";\n  const time = fmt.time(date);\n  const bare = !!m.content && m.bare;\n  const failed = m.status === \"failed\";\n\n  // Arrivals rise from where they came from: yours from the composer below, theirs a shorter way.\n  const rise = own ? 28 : 14;\n  const zero = useMotionValue(0);\n  const x = swipe ?? zero;\n  const timesOpacity = useTransform(timesX, [0, -SWIPE_TIMES], [0, 1]);\n\n  return (\n    <motion.li\n      data-message-id={m.id}\n      data-own={own || undefined}\n      data-position={position}\n      tabIndex={-1}\n      initial={arrived ? { height: 0, opacity: 0 } : false}\n      animate={{ height: \"auto\", opacity: 1 }}\n      exit={{ height: 0, opacity: 0, transition: { duration: reduce ? 0 : 0.18, ease: ease.inOut } }}\n      transition={reduce ? { duration: 0.15, height: { duration: 0 } } : { height: { duration: 0.3, ease: ease.out }, opacity: { duration: 0.12 } }}\n      className={cn(\"group/row relative flex px-4 outline-none\", spaced ? \"pt-2\" : \"pt-0.5\", own ? \"justify-end\" : \"justify-start\", \"focus-visible:bg-hover\")}\n    >\n      {/* Swipe-left times: parked just past the right edge, pulled into view with the whole thread. */}\n      <motion.span aria-hidden style={{ opacity: timesOpacity }} className=\"absolute -right-14 top-1/2 w-12 -translate-y-1/2 text-left text-[10.5px] tabular text-fg-4\" suppressHydrationWarning>\n        {time}\n      </motion.span>\n\n      {/* Reply hint revealed behind a message being swiped right. */}\n      {swipe && <ReplyHint x={swipe} />}\n\n      <motion.div\n        style={{ x }}\n        initial={arrived && !reduce ? { y: rise, scale: 0.96, filter: \"blur(2px)\" } : false}\n        animate={{ y: 0, scale: 1, filter: \"blur(0px)\" }}\n        transition={{ duration: 0.34, ease: ease.out }}\n        className={cn(\"flex min-w-0 max-w-[82%] items-end gap-2 pointer-fine:max-w-[68%]\", own ? \"origin-bottom-right flex-row-reverse\" : \"origin-bottom-left\")}\n      >\n        {withAvatars && !own && <span className=\"w-7 shrink-0\">{row.showAvatar && <Face author={author} />}</span>}\n\n        <div className={cn(\"flex min-w-0 flex-col\", own ? \"items-end\" : \"items-start\")}>\n          <span\n            className=\"sr-only\"\n            suppressHydrationWarning\n          >{`${own ? \"You\" : name}, ${fmt.full(date)}${own && m.status ? `, ${m.status === \"failed\" ? \"not delivered\" : m.status}` : \"\"}:`}</span>\n          {row.showName && (\n            <span aria-hidden className=\"mb-1 px-3 text-[11.5px] font-medium leading-4 text-fg-3\">\n              {name}\n            </span>\n          )}\n\n          <div className={cn(\"relative flex min-w-0 items-center\", own && \"flex-row-reverse\")}>\n            {bare ? (\n              <div data-bubble className=\"relative min-w-0\">\n                {m.content}\n              </div>\n            ) : (\n              <div\n                data-bubble\n                className={cn(\n                  // Phones read at 15px; the pointer-sized layout drops to the 13px product scale.\n                  \"relative flex flex-col gap-1.5 px-3 py-[7px] text-[15px] leading-[21px] [overflow-wrap:anywhere] sm:text-[13px] sm:leading-5\",\n                  // A quote needs room to be read; the bubble won't shrink it below 13rem.\n                  m.replyTo ? \"min-w-52\" : \"min-w-0\",\n                  own ? \"bg-fg text-frame\" : \"bg-(--bubble) text-fg\",\n                  radius[position][own ? \"own\" : \"other\"],\n                  failed && \"opacity-70\",\n                )}\n              >\n                {m.replyTo && <ReplyQuote target={m.replyTo} onJump={onJump} className=\"-mx-1.5 mt-[-2px] w-auto\" />}\n                {m.content}\n                {m.text && (\n                  <p className=\"whitespace-pre-wrap text-pretty\">\n                    {m.text}\n                    {m.edited && <span className=\"ml-1.5 text-[11px] opacity-60\">edited</span>}\n                  </p>\n                )}\n                {tail && <Tail own={own} />}\n              </div>\n            )}\n\n            {/* Hover and focus cluster: the time, your ticks, and reply. It floats beside the bubble, so it\n                costs the bubble no width; touch has no hover, so there a swipe shows times and replies. */}\n            <div\n              className={cn(\n                \"absolute top-1/2 flex -translate-y-1/2 items-center gap-1.5 whitespace-nowrap transition-[opacity,translate] duration-150 ease-out pointer-coarse:hidden\",\n                own ? \"right-full mr-2\" : \"left-full ml-2\",\n                \"opacity-0 group-hover/row:opacity-100 group-focus-within/row:opacity-100\",\n                own ? \"translate-x-1\" : \"-translate-x-1\",\n                \"group-hover/row:translate-x-0 group-focus-within/row:translate-x-0 motion-reduce:translate-x-0\",\n                own && \"flex-row-reverse\",\n              )}\n            >\n              <span suppressHydrationWarning className=\"text-[10.5px] leading-4 text-fg-4 tabular\">\n                {time}\n              </span>\n              {own && m.status && !showStatusLabel && <MessageStatus status={m.status} onRetry={onRetry ? () => onRetry(m) : undefined} />}\n              {onReply && !failed && (\n                <button\n                  type=\"button\"\n                  tabIndex={-1}\n                  aria-label={`Reply to ${own ? \"your message\" : name}`}\n                  onClick={() => onReply(m)}\n                  className={cn(\n                    \"relative grid size-7 place-items-center rounded-full text-fg-3 outline-none\",\n                    \"transition-[background-color,color,scale] duration-150 ease-out hover:bg-hover hover:text-fg active:scale-[0.9] active:duration-75\",\n                  )}\n                >\n                  <ReplyGlyph />\n                </button>\n              )}\n            </div>\n          </div>\n\n          {showStatusLabel && m.status && (\n            <div className=\"mt-1 flex justify-end px-1\">\n              <MessageStatus status={m.status} label onRetry={onRetry ? () => onRetry(m) : undefined} />\n            </div>\n          )}\n        </div>\n      </motion.div>\n    </motion.li>\n  );\n}\n\nfunction ReplyHint({ x }: { x: MotionValue<number> }) {\n  const opacity = useTransform(x, [0, SWIPE_REPLY], [0, 1]);\n  const scale = useTransform(x, [0, SWIPE_REPLY], [0.5, 1]);\n  return (\n    <motion.span aria-hidden style={{ opacity, scale }} className=\"absolute left-3 top-1/2 grid size-7 -translate-y-1/2 place-items-center rounded-full bg-hover text-fg-2\">\n      <ReplyGlyph />\n    </motion.span>\n  );\n}\n\nfunction Tail({ own }: { own: boolean }) {\n  // Overlaps the bubble by 5px so the join never shows a seam; the bubble's own color fills it.\n  return (\n    <svg\n      aria-hidden\n      width=\"11\"\n      height=\"16\"\n      viewBox=\"0 0 11 16\"\n      className={cn(\"pointer-events-none absolute bottom-0\", own ? \"-right-[6px] text-fg\" : \"-left-[6px] -scale-x-100 text-(--bubble)\")}\n    >\n      <path d=\"M0 0H5C5 7 6.6 12.4 10.6 15.6 10.8 15.8 10.6 16 10.4 16 6.6 16 3.4 14.8 0 12.6Z\" fill=\"currentColor\" />\n    </svg>\n  );\n}\n\nfunction Face({ author }: { author?: ChatAuthor }) {\n  const initials = (author?.name ?? \"?\")\n    .split(/\\s+/)\n    .map((w) => w[0])\n    .slice(0, 2)\n    .join(\"\")\n    .toUpperCase();\n  return (\n    <Avatar.Root className=\"grid size-7 select-none place-items-center overflow-hidden rounded-full bg-hover text-[10px] font-medium text-fg-2 shadow-[inset_0_0_0_1px_var(--line)]\">\n      {author?.avatar && <Avatar.Image src={author.avatar} alt=\"\" width={28} height={28} className=\"size-full object-cover\" />}\n      <Avatar.Fallback delay={author?.avatar ? 400 : 0}>{initials}</Avatar.Fallback>\n    </Avatar.Root>\n  );\n}\n\nfunction TypingRow({ people, showAvatar, reduce }: { people: ChatAuthor[]; showAvatar: boolean; reduce: boolean }) {\n  const names = people.map((p) => p.name.split(\" \")[0]);\n  const text = names.length === 1 ? `${names[0]} is typing` : names.length === 2 ? `${names[0]} and ${names[1]} are typing` : `${names.length} people are typing`;\n  return (\n    <motion.li\n      initial={{ height: 0, opacity: 0 }}\n      animate={{ height: \"auto\", opacity: 1 }}\n      exit={{ height: 0, opacity: 0, transition: { duration: reduce ? 0 : 0.16 } }}\n      transition={reduce ? { duration: 0.15, height: { duration: 0 } } : { height: { duration: 0.26, ease: ease.out }, opacity: { duration: 0.12 } }}\n      className=\"flex px-4 pt-2\"\n    >\n      <motion.div initial={reduce ? false : { y: 10, scale: 0.9 }} animate={{ y: 0, scale: 1 }} transition={spring.soft} className=\"flex origin-bottom-left items-end gap-2\">\n        {showAvatar && (\n          <span className=\"w-7 shrink-0\">\n            <Face author={people[0]} />\n          </span>\n        )}\n        <span role=\"status\" aria-label={text} className=\"relative flex h-9 items-center gap-1 rounded-[18px] rounded-bl-[4px] bg-(--bubble) px-3.5\">\n          {[0, 1, 2].map((i) => (\n            <span key={i} className=\"size-1.5 rounded-full bg-fg-3 motion-safe:animate-dot-wave\" style={{ animationDelay: `${i * 0.16}s` }} />\n          ))}\n          <Tail own={false} />\n        </span>\n      </motion.div>\n    </motion.li>\n  );\n}\n\nfunction DefaultEmpty() {\n  return (\n    <div className=\"flex max-w-[240px] flex-col items-center gap-1\">\n      <p className=\"text-[13px] font-medium text-fg\">No messages yet</p>\n      <p className=\"text-[12.5px] leading-[18px] text-fg-3\">Messages you send and receive here will show up in order.</p>\n    </div>\n  );\n}\n\nfunction ReplyGlyph() {\n  return (\n    <svg width=\"14\" height=\"14\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" strokeWidth={1.4} strokeLinecap=\"round\" strokeLinejoin=\"round\" aria-hidden>\n      <path d=\"M6.5 4 3 7.5 6.5 11M3 7.5h6a4 4 0 0 1 4 4v1\" />\n    </svg>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/chat-thread"
}
