{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "conversation-list",
  "type": "registry:ui",
  "title": "Conversation List",
  "description": "Chat inbox where rows spring to the top, counts roll and swipes commit.",
  "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/motion.json",
    "https://www.stealth.pm/r/use-controllable-state.json"
  ],
  "files": [
    {
      "path": "components/ui/conversation-list.tsx",
      "content": "\"use client\";\nimport { Avatar } from \"@base-ui/react/avatar\";\nimport { ContextMenu } from \"@base-ui/react/context-menu\";\nimport { Tooltip } from \"@base-ui/react/tooltip\";\nimport NumberFlow from \"@number-flow/react\";\nimport {\n  AnimatePresence,\n  LayoutGroup,\n  animate,\n  motion,\n  useDragControls,\n  useIsPresent,\n  useMotionValue,\n  useReducedMotion,\n  useTransform,\n  type MotionValue,\n} from \"motion/react\";\nimport { useEffect, useId, useMemo, useRef, useState, useSyncExternalStore } from \"react\";\nimport { cn } from \"@/lib/cn\";\nimport { ease, spring } from \"@/lib/motion\";\nimport { useControllableState } from \"@/lib/use-controllable-state\";\n\n/* -------------------------------------------------------------------------------------------------\n * Data\n * -----------------------------------------------------------------------------------------------*/\n\nexport type Conversation = {\n  id: string;\n  /** The person or the group. Never truncated before the preview is. */\n  name: string;\n  /** Image URL. Initials on a neutral tile stand in until it loads, or when it fails. */\n  avatar?: string;\n  /** Draws a presence dot cut into the avatar. */\n  online?: boolean;\n  /** The last message, as plain text. */\n  preview?: string;\n  /** Who sent the last message: \"You\", or a name in a group. Shown as a prefix. */\n  sender?: string;\n  /** A Date or timestamp is formatted relative to now and kept fresh. A string is shown as is. */\n  time?: Date | number | string;\n  /** Unread messages. Rolls when it changes; 99+ past 99. */\n  unread?: number;\n  /** Marked unread by hand: a dot instead of a count. */\n  markedUnread?: boolean;\n  /** Someone mentioned you: an @ sits beside the count. */\n  mention?: boolean;\n  /** Replaces the preview while someone types. A string names who (\"Priya\"), for groups. */\n  typing?: boolean | string;\n  /** Your unsent text. Shown in place of the preview, marked as a draft. */\n  draft?: string;\n  pinned?: boolean;\n  muted?: boolean;\n};\n\nconst isUnread = (c: Conversation) => (c.unread ?? 0) > 0 || !!c.markedUnread;\n\n/* -------------------------------------------------------------------------------------------------\n * Time\n * -----------------------------------------------------------------------------------------------*/\n\n// One shared clock for every list on the page, ticking every 30s so \"Now\" becomes \"9:41\"\n// on its own. The server has no idea what \"now\" is for the reader, so it renders times\n// only after hydration (strings are shown straight away).\nlet clockNow = 0;\nlet clockTimer: number | undefined;\nconst clockListeners = new Set<() => void>();\nfunction subscribeClock(cb: () => void) {\n  clockListeners.add(cb);\n  if (clockTimer === undefined) {\n    clockTimer = window.setInterval(() => {\n      clockNow = Date.now();\n      clockListeners.forEach((l) => l());\n    }, 30_000);\n  }\n  return () => {\n    clockListeners.delete(cb);\n    if (!clockListeners.size) {\n      window.clearInterval(clockTimer);\n      clockTimer = undefined;\n    }\n  };\n}\nconst readClock = () => clockNow || (clockNow = Date.now());\nconst readServerClock = () => 0;\n\nconst capitalize = (s: string) => s.charAt(0).toLocaleUpperCase() + s.slice(1);\nconst dayStart = (d: Date) => new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();\n\n/** Inbox-style time: Now, 9:41, Yesterday, Mon, 12 Sep, 12 Sep 2025. */\nexport function formatConversationTime(date: Date, now: number | Date = Date.now(), locale?: string) {\n  const n = new Date(now);\n  if (Math.abs(n.getTime() - date.getTime()) < 60_000) return capitalize(new Intl.RelativeTimeFormat(locale, { numeric: \"auto\" }).format(0, \"second\"));\n  const days = Math.round((dayStart(n) - dayStart(date)) / 86_400_000);\n  if (days <= 0) return new Intl.DateTimeFormat(locale, { hour: \"numeric\", minute: \"2-digit\" }).format(date);\n  if (days === 1) return capitalize(new Intl.RelativeTimeFormat(locale, { numeric: \"auto\" }).format(-1, \"day\"));\n  if (days < 7) return new Intl.DateTimeFormat(locale, { weekday: \"short\" }).format(date);\n  if (date.getFullYear() === n.getFullYear()) return new Intl.DateTimeFormat(locale, { day: \"numeric\", month: \"short\" }).format(date);\n  return new Intl.DateTimeFormat(locale, { day: \"numeric\", month: \"short\", year: \"numeric\" }).format(date);\n}\n\n/* -------------------------------------------------------------------------------------------------\n * Icons (same 16px grid and 1.4 stroke as the shared set)\n * -----------------------------------------------------------------------------------------------*/\n\nconst svg = { width: 16, height: 16, viewBox: \"0 0 16 16\", fill: \"none\", stroke: \"currentColor\", strokeWidth: 1.4, strokeLinecap: \"round\" as const, strokeLinejoin: \"round\" as const, \"aria-hidden\": true, focusable: false };\n\nfunction ArchiveIcon() {\n  return (\n    <svg {...svg}>\n      <rect x=\"2.25\" y=\"3\" width=\"11.5\" height=\"3\" rx=\".75\" />\n      <path d=\"M3.25 6v6.25c0 .4.35.75.75.75h8c.4 0 .75-.35.75-.75V6M6.5 8.75h3\" />\n    </svg>\n  );\n}\nfunction PinIcon({ off }: { off?: boolean }) {\n  return (\n    <svg {...svg}>\n      <path d=\"M6 2.75h4M6.75 2.75v3.5L4.5 8.75v1h7v-1L9.25 6.25v-3.5M8 9.75v3.5\" />\n      {off && <path d=\"m2.75 2.75 10.5 10.5\" />}\n    </svg>\n  );\n}\nfunction BellOffIcon({ off = true }: { off?: boolean }) {\n  return (\n    <svg {...svg}>\n      <path d=\"M4 11V7a4 4 0 0 1 8 0v4l1.25 1.25H2.75zM6.5 13.75a1.6 1.6 0 0 0 3 0\" />\n      {off && <path d=\"m2.5 2.5 11 11\" />}\n    </svg>\n  );\n}\nfunction UnreadIcon({ read }: { read: boolean }) {\n  return read ? (\n    <svg {...svg}>\n      <path d=\"M2.75 4.25c0-.8.7-1.5 1.5-1.5h7.5c.8 0 1.5.7 1.5 1.5v5.5c0 .8-.7 1.5-1.5 1.5H7.5L4.75 13.5v-2.25h-.5c-.8 0-1.5-.7-1.5-1.5z\" />\n      <path d=\"m5.75 7 1.5 1.5 3-3\" />\n    </svg>\n  ) : (\n    <svg {...svg}>\n      <path d=\"M9.25 2.75h-5c-.8 0-1.5.7-1.5 1.5v5.5c0 .8.7 1.5 1.5 1.5h.5v2.25l2.75-2.25h4.25c.8 0 1.5-.7 1.5-1.5v-3\" />\n      <circle cx=\"12.25\" cy=\"3.75\" r=\"1.5\" fill=\"currentColor\" stroke=\"none\" />\n    </svg>\n  );\n}\n\n/* -------------------------------------------------------------------------------------------------\n * Actions\n * -----------------------------------------------------------------------------------------------*/\n\ntype ActionKey = \"read\" | \"pin\" | \"mute\" | \"archive\";\ntype Action = { key: ActionKey; label: string; short: string; shortcut: string; icon: React.ReactNode; run: () => void };\n\ntype Handlers = {\n  onArchive?: (id: string) => void;\n  onPinnedChange?: (id: string, pinned: boolean) => void;\n  onReadChange?: (id: string, read: boolean) => void;\n  onMutedChange?: (id: string, muted: boolean) => void;\n};\n\nfunction actionsFor(c: Conversation, h: Handlers): Partial<Record<ActionKey, Action>> {\n  const unread = isUnread(c);\n  return {\n    read: h.onReadChange && {\n      key: \"read\",\n      label: unread ? \"Mark as read\" : \"Mark as unread\",\n      short: unread ? \"Read\" : \"Unread\",\n      shortcut: \"U\",\n      icon: <UnreadIcon read={unread} />,\n      run: () => h.onReadChange?.(c.id, unread),\n    },\n    pin: h.onPinnedChange && {\n      key: \"pin\",\n      label: c.pinned ? \"Unpin\" : \"Pin\",\n      short: c.pinned ? \"Unpin\" : \"Pin\",\n      shortcut: \"P\",\n      icon: <PinIcon off={c.pinned} />,\n      run: () => h.onPinnedChange?.(c.id, !c.pinned),\n    },\n    mute: h.onMutedChange && {\n      key: \"mute\",\n      label: c.muted ? \"Unmute\" : \"Mute\",\n      short: c.muted ? \"Unmute\" : \"Mute\",\n      shortcut: \"M\",\n      icon: <BellOffIcon off={!c.muted} />,\n      run: () => h.onMutedChange?.(c.id, !c.muted),\n    },\n    archive: h.onArchive && { key: \"archive\", label: \"Archive\", short: \"Archive\", shortcut: \"E\", icon: <ArchiveIcon />, run: () => h.onArchive?.(c.id) },\n  };\n}\n\nconst SHORTCUTS: Record<string, ActionKey> = { u: \"read\", p: \"pin\", m: \"mute\", e: \"archive\" };\n\n/* -------------------------------------------------------------------------------------------------\n * ConversationList\n * -----------------------------------------------------------------------------------------------*/\n\nexport type ConversationListProps = Omit<React.ComponentProps<\"div\">, \"defaultValue\" | \"onChange\"> &\n  Handlers & {\n    conversations: Conversation[];\n    /** The open conversation's id. */\n    value?: string | null;\n    defaultValue?: string | null;\n    onValueChange?: (id: string) => void;\n    /** Shows skeleton rows shaped like the real ones. */\n    loading?: boolean;\n    /** Shown when there are no conversations and nothing is loading. */\n    empty?: React.ReactNode;\n    /** Section titles and the list's accessible name. */\n    labels?: { list?: string; pinned?: string; all?: string };\n    /** Replace the default time format. Receives the date and the current time. */\n    formatTime?: (date: Date, now: number) => string;\n  };\n\n/**\n * An inbox of chats. Rows spring into their new place when a message arrives or a chat is\n * pinned, one highlight glides after the pointer, previews swap to a typing line, counts\n * roll, and every hover action has a swipe, a shortcut and a context-menu equivalent.\n */\nexport function ConversationList({\n  conversations,\n  value: valueProp,\n  defaultValue = null,\n  onValueChange,\n  onArchive,\n  onPinnedChange,\n  onReadChange,\n  onMutedChange,\n  loading = false,\n  empty,\n  labels,\n  formatTime = formatConversationTime,\n  className,\n  ...rest\n}: ConversationListProps) {\n  const reduce = !!useReducedMotion();\n  const uid = useId();\n  const root = useRef<HTMLDivElement>(null);\n  const now = useSyncExternalStore(subscribeClock, readClock, readServerClock);\n  const [value, setValue] = useControllableState<string | null>({ value: valueProp, defaultValue, onChange: (id) => id && onValueChange?.(id) });\n  const [active, setActive] = useState<string | null>(null);\n  const [openId, setOpenId] = useState<string | null>(null);\n  const [hover, setHover] = useState<{ top: number; height: number } | null>(null);\n  const refocus = useRef<string | null>(null);\n\n  const handlers: Handlers = { onArchive, onPinnedChange, onReadChange, onMutedChange };\n  const pinned = conversations.filter((c) => c.pinned);\n  const others = conversations.filter((c) => !c.pinned);\n  const ids = useMemo(() => new Set(conversations.map((c) => c.id)), [conversations]);\n\n  // Rows that just arrived fade in; rows that only moved (pinned, bumped) glide instead.\n  const idKey = conversations.map((c) => c.id).join(\"\\u0000\");\n  const [known, setKnown] = useState(() => ({ key: idKey, ids, fresh: new Set<string>() }));\n  if (known.key !== idKey) setKnown({ key: idKey, ids, fresh: new Set([...ids].filter((id) => !known.ids.has(id))) });\n\n  // One tab stop for the whole list: the row last focused, else the open one, else the first.\n  const tabbable = (active && ids.has(active) && active) || (value && ids.has(value) && value) || [...pinned, ...others][0]?.id;\n\n  // A row that moved between sections is a new element: put focus back on it.\n  useEffect(() => {\n    const id = refocus.current;\n    if (!id) return;\n    refocus.current = null;\n    root.current?.querySelector<HTMLElement>(`[data-row=\"${CSS.escape(id)}\"]:not([data-exiting]) [data-row-main]`)?.focus({ preventScroll: false });\n  }, [conversations]);\n\n  const mains = () => Array.from(root.current?.querySelectorAll<HTMLElement>(\"[data-row]:not([data-exiting]) [data-row-main]\") ?? []);\n\n  const run = (c: Conversation, key: ActionKey, from: HTMLElement | null) => {\n    const action = actionsFor(c, handlers)[key];\n    if (!action) return;\n    if (from) {\n      if (key === \"archive\") {\n        // Hand focus to the neighbour before the row leaves, so it never falls to the page.\n        const list = mains();\n        const i = list.findIndex((el) => el.closest(\"[data-row]\") === from.closest(\"[data-row]\"));\n        (list[i + 1] ?? list[i - 1])?.focus();\n      } else if (key === \"pin\") refocus.current = c.id;\n    }\n    setOpenId(null);\n    action.run();\n  };\n\n  const onKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {\n    const target = e.target as HTMLElement;\n    const row = target.closest<HTMLElement>(\"[data-row]\");\n    if (!row || e.defaultPrevented || e.altKey || e.ctrlKey || e.metaKey) return;\n    const list = mains();\n    const main = row.querySelector<HTMLElement>(\"[data-row-main]\");\n    const i = list.indexOf(main!);\n    const acts = Array.from(row.querySelectorAll<HTMLElement>(\"[data-row-action]\"));\n    const a = acts.indexOf(target);\n    // A key the list uses is consumed here, so page-level single-key shortcuts don't also fire.\n    const consume = () => {\n      e.preventDefault();\n      e.stopPropagation();\n    };\n    const move = (el?: HTMLElement | null) => {\n      if (!el) return;\n      consume();\n      el.focus();\n    };\n    switch (e.key) {\n      case \"ArrowDown\":\n      case \"j\":\n        return move(list[i + 1]);\n      case \"ArrowUp\":\n      case \"k\":\n        return move(list[i - 1]);\n      case \"Home\":\n        return move(list[0]);\n      case \"End\":\n        return move(list[list.length - 1]);\n      case \"ArrowRight\":\n        return move(a < 0 ? acts[0] : acts[a + 1]);\n      case \"ArrowLeft\":\n        return a >= 0 ? move(a === 0 ? main : acts[a - 1]) : undefined;\n      case \"Escape\":\n        return a >= 0 ? move(main) : undefined;\n    }\n    const key = SHORTCUTS[e.key.toLowerCase()];\n    if (key && !e.shiftKey) {\n      const c = conversations.find((x) => x.id === row.dataset.row);\n      if (c && actionsFor(c, handlers)[key]) {\n        consume();\n        run(c, key, main);\n      }\n    }\n  };\n\n  // The hover highlight is one element for the whole list, measured against the row under\n  // the pointer and sprung there, so it reads as one thing gliding rather than rows blinking.\n  const track = (e: React.PointerEvent) => {\n    if (e.pointerType !== \"mouse\") return;\n    const row = (e.target as HTMLElement).closest<HTMLElement>(\"[data-row-surface]\");\n    const box = root.current?.getBoundingClientRect();\n    if (!row || !box) return setHover(null);\n    const r = row.getBoundingClientRect();\n    const top = r.top - box.top;\n    if (!hover || hover.top !== top || hover.height !== r.height) setHover({ top, height: r.height });\n  };\n\n  const renderRows = (items: Conversation[]) => (\n    <AnimatePresence mode=\"popLayout\" initial={false} custom={ids}>\n      {items.map((c) => (\n        <Row\n          key={c.id}\n          c={c}\n          reduce={reduce}\n          fresh={known.fresh.has(c.id)}\n          now={now}\n          formatTime={formatTime}\n          selected={value === c.id}\n          tabbable={tabbable === c.id}\n          open={openId === c.id}\n          actions={actionsFor(c, handlers)}\n          onOpenChange={(open) => setOpenId(open ? c.id : null)}\n          onSelect={() => {\n            setOpenId(null);\n            setValue(c.id);\n          }}\n          onFocusRow={() => setActive(c.id)}\n          onRun={(key, from) => run(c, key, from)}\n        />\n      ))}\n    </AnimatePresence>\n  );\n\n  const header = (id: string, text: string) => (\n    <motion.div\n      key={id}\n      id={`${uid}-${id}`}\n      layout={reduce ? false : \"position\"}\n      initial={reduce ? { opacity: 0 } : { opacity: 0, y: -4 }}\n      animate={{ opacity: 1, y: 0 }}\n      exit={{ opacity: 0, transition: { duration: 0.1 } }}\n      transition={{ duration: 0.2, ease: ease.out, layout: reduce ? { duration: 0 } : spring.soft }}\n      className=\"px-3 pb-1.5 pt-3 font-mono text-2xs uppercase tracking-[0.08em] text-fg-3 first:pt-1\"\n    >\n      {text}\n    </motion.div>\n  );\n\n  let body: React.ReactNode;\n  if (loading && !conversations.length) {\n    body = (\n      <div aria-busy=\"true\" className=\"flex flex-col gap-0.5\">\n        <span className=\"sr-only\" role=\"status\">Loading conversations</span>\n        {[62, 44, 70, 52, 38].map((w, i) => (\n          <div key={i} aria-hidden className=\"flex h-[60px] items-center gap-3 px-3\">\n            <div className=\"size-10 shrink-0 animate-pulse-soft rounded-full bg-fg/[0.07]\" />\n            <div className=\"flex min-w-0 flex-1 flex-col gap-2\">\n              <div className=\"flex items-center justify-between gap-6\">\n                <div className=\"h-2.5 animate-pulse-soft rounded-full bg-fg/[0.08]\" style={{ width: `${w - 18}%` }} />\n                <div className=\"h-2 w-8 animate-pulse-soft rounded-full bg-fg/[0.06]\" />\n              </div>\n              <div className=\"h-2 animate-pulse-soft rounded-full bg-fg/[0.06]\" style={{ width: `${w + 16}%` }} />\n            </div>\n          </div>\n        ))}\n      </div>\n    );\n  } else if (!conversations.length) {\n    body = empty ?? (\n      <div className=\"flex flex-col items-center gap-1 px-6 py-12 text-center\">\n        <p className=\"text-[13px] font-medium text-fg\">No conversations yet</p>\n        <p className=\"text-[12.5px] text-fg-3\">New messages will show up here.</p>\n      </div>\n    );\n  } else {\n    body = (\n      <LayoutGroup id={uid}>\n        <motion.div\n          aria-hidden\n          className=\"pointer-events-none absolute inset-x-0 top-0 rounded-lg bg-fg/[0.035]\"\n          initial={false}\n          animate={hover ? { opacity: 1, y: hover.top, height: hover.height } : { opacity: 0 }}\n          transition={reduce ? { duration: 0 } : { ...spring.follow, opacity: { duration: hover ? 0.12 : 0.15 } }}\n        />\n        <AnimatePresence initial={false}>\n          {pinned.length > 0 && others.length > 0 && header(\"pinned\", labels?.pinned ?? \"Pinned\")}\n        </AnimatePresence>\n        {pinned.length > 0 && (\n          <ul aria-labelledby={others.length ? `${uid}-pinned` : undefined} aria-label={others.length ? undefined : labels?.pinned ?? \"Pinned\"} className=\"relative flex flex-col gap-0.5\">\n            {renderRows(pinned)}\n          </ul>\n        )}\n        <AnimatePresence initial={false}>{pinned.length > 0 && others.length > 0 && header(\"all\", labels?.all ?? \"Messages\")}</AnimatePresence>\n        {others.length > 0 && (\n          <ul aria-labelledby={pinned.length ? `${uid}-all` : undefined} aria-label={pinned.length ? undefined : labels?.all ?? \"Messages\"} className=\"relative flex flex-col gap-0.5\">\n            {renderRows(others)}\n          </ul>\n        )}\n      </LayoutGroup>\n    );\n  }\n\n  return (\n    <Tooltip.Provider delay={500} closeDelay={0}>\n      <div\n        ref={root}\n        role=\"group\"\n        aria-label={labels?.list ?? \"Conversations\"}\n        data-loading={loading || undefined}\n        onKeyDown={onKeyDown}\n        onPointerMove={track}\n        onPointerLeave={() => setHover(null)}\n        onPointerDownCapture={(e) => {\n          // Touching anywhere else closes a row that was swiped open.\n          if (openId && !(e.target as HTMLElement).closest(`[data-row=\"${CSS.escape(openId)}\"]`)) setOpenId(null);\n        }}\n        className={cn(\"relative isolate flex flex-col\", className)}\n        {...rest}\n      >\n        {body}\n      </div>\n    </Tooltip.Provider>\n  );\n}\n\n/* -------------------------------------------------------------------------------------------------\n * Row\n * -----------------------------------------------------------------------------------------------*/\n\nconst SWIPE_W = 72;\n\ntype RowProps = {\n  c: Conversation;\n  reduce: boolean;\n  fresh: boolean;\n  now: number;\n  formatTime: (date: Date, now: number) => string;\n  selected: boolean;\n  tabbable: boolean;\n  open: boolean;\n  actions: Partial<Record<ActionKey, Action>>;\n  onOpenChange: (open: boolean) => void;\n  onSelect: () => void;\n  onFocusRow: () => void;\n  onRun: (key: ActionKey, from: HTMLElement | null) => void;\n};\n\nfunction Row({ c, reduce, fresh, now, formatTime, selected, tabbable, open, actions, onOpenChange, onSelect, onFocusRow, onRun }: RowProps) {\n  const present = useIsPresent();\n  const x = useMotionValue(0);\n  const controls = useDragControls();\n  const surface = useRef<HTMLDivElement>(null);\n  const main = useRef<HTMLButtonElement>(null);\n  const dragged = useRef(false);\n  const [armed, setArmed] = useState<\"start\" | \"end\" | null>(null);\n\n  // Swipe right for read state (and pin); swipe left for mute and archive. The action on the\n  // outer edge is the one a long swipe commits.\n  const start = [actions.read, actions.pin].filter(Boolean) as Action[];\n  const end = [actions.mute, actions.archive].filter(Boolean) as Action[];\n  const hover = [actions.read, actions.archive].filter(Boolean) as Action[];\n  const menu = [actions.read, actions.pin, actions.mute].filter(Boolean) as Action[];\n\n  const settle = (to: number) => animate(x, to, reduce ? { duration: 0.15, ease: ease.out } : spring.snappy);\n\n  // Another row opened, or the list asked everything to close.\n  useEffect(() => {\n    if (!open && x.get() !== 0) settle(0);\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [open]);\n\n  const commit = async (action: Action, side: \"start\" | \"end\") => {\n    const w = surface.current?.offsetWidth ?? 320;\n    if (action.key === \"archive\") {\n      await animate(x, side === \"end\" ? -w - 24 : w + 24, { duration: reduce ? 0.1 : 0.2, ease: ease.in });\n      onRun(action.key, null);\n    } else {\n      onRun(action.key, null);\n      settle(0);\n    }\n  };\n\n  const release = (vx: number) => {\n    const v = x.get();\n    const startW = start.length * SWIPE_W;\n    const endW = end.length * SWIPE_W;\n    const wasArmed = armed;\n    setArmed(null);\n    if (wasArmed === \"start\" && start[0]) return commit(start[0], \"start\");\n    if (wasArmed === \"end\" && end.at(-1)) return commit(end.at(-1)!, \"end\");\n    // A flick counts as much as a drag: fast and in the right direction opens the tray.\n    if (endW && (v < -endW / 2 || (v < -12 && vx < -500))) {\n      onOpenChange(true);\n      return settle(-endW);\n    }\n    if (startW && (v > startW / 2 || (v > 12 && vx > 500))) {\n      onOpenChange(true);\n      return settle(startW);\n    }\n    onOpenChange(false);\n    settle(0);\n  };\n\n  const unread = isUnread(c);\n  // Muted chats still count, but never shout: no bright sender, preview or time.\n  const loud = unread && !c.muted;\n  const typing = !!c.typing;\n  const count = c.unread ?? 0;\n  const timeText =\n    typeof c.time === \"string\" ? c.time : c.time != null && now ? formatTime(new Date(c.time), now) : \"\";\n  const timeISO = c.time != null && typeof c.time !== \"string\" ? new Date(c.time).toISOString() : undefined;\n  const timeTitle =\n    c.time != null && typeof c.time !== \"string\" && now ? new Intl.DateTimeFormat(undefined, { dateStyle: \"full\", timeStyle: \"short\" }).format(new Date(c.time)) : undefined;\n  const typingText = typeof c.typing === \"string\" ? `${c.typing} is typing` : \"typing\";\n\n  const spoken = [\n    c.name,\n    count > 0 ? `${count} unread${c.mention ? \", mentions you\" : \"\"}` : c.markedUnread ? \"unread\" : \"\",\n    c.pinned ? \"pinned\" : \"\",\n    c.muted ? \"muted\" : \"\",\n    c.online ? \"online\" : \"\",\n  ]\n    .filter(Boolean)\n    .join(\", \");\n  const said = typing ? `${typingText}…` : c.draft ? `Draft: ${c.draft}` : c.preview ? `${c.sender ? `${c.sender}: ` : \"\"}${c.preview}` : \"\";\n  const label = `${spoken}. ${said}${timeText ? `. ${timeText}` : \"\"}`;\n\n  const previewKey = typing ? \"typing\" : c.draft ? `d:${c.draft}` : `p:${c.sender ?? \"\"}:${c.preview ?? \"\"}`;\n  const swap = {\n    initial: reduce ? { opacity: 0 } : { opacity: 0, y: 5, filter: \"blur(2px)\" },\n    animate: { opacity: 1, y: 0, filter: \"blur(0px)\" },\n    exit: reduce ? { opacity: 0, transition: { duration: 0.08 } } : { opacity: 0, y: -5, filter: \"blur(2px)\", transition: { duration: 0.12, ease: ease.in } },\n    transition: { duration: reduce ? 0.12 : 0.2, ease: ease.out },\n  };\n\n  return (\n    <motion.li\n      layout={reduce ? false : \"position\"}\n      layoutId={reduce ? undefined : c.id}\n      data-row={c.id}\n      data-exiting={present ? undefined : \"\"}\n      inert={!present}\n      custom={c.id}\n      initial={fresh ? (reduce ? { opacity: 0 } : { opacity: 0, y: -6, scale: 0.985 }) : false}\n      animate={{ opacity: 1, y: 0, scale: 1 }}\n      exit=\"exit\"\n      variants={{\n        // Still in the list means it only moved to the other section: vanish, the new copy glides.\n        exit: (ids: Set<string>) =>\n          ids?.has?.(c.id)\n            ? { opacity: 0, transition: { duration: 0 } }\n            : reduce\n              ? { opacity: 0, transition: { duration: 0.12 } }\n              : { opacity: 0, x: -12, scale: 0.98, transition: { duration: 0.18, ease: ease.in } },\n      }}\n      transition={{ duration: 0.24, ease: ease.out, layout: spring.soft }}\n      className=\"relative\"\n    >\n      <ContextMenu.Root>\n        <ContextMenu.Trigger\n          render={<div />}\n          ref={surface}\n          data-row-surface=\"\"\n          className=\"relative overflow-hidden rounded-lg [-webkit-touch-callout:none]\"\n        >\n          {/* Swipe trays sit behind the row and are clipped to exactly what the row uncovers. */}\n          {start.length > 0 && <Tray side=\"start\" actions={start} x={x} armed={armed === \"start\"} reduce={reduce} onPress={(a) => (a === start[0] ? commit(a, \"start\") : (onRun(a.key, null), settle(0)))} />}\n          {end.length > 0 && <Tray side=\"end\" actions={end} x={x} armed={armed === \"end\"} reduce={reduce} onPress={(a) => (a === end.at(-1) ? commit(a, \"end\") : (onRun(a.key, null), settle(0)))} />}\n\n          <motion.div\n            drag=\"x\"\n            dragControls={controls}\n            dragListener={false}\n            dragDirectionLock\n            dragMomentum={false}\n            dragConstraints={{ left: end.length ? -2000 : 0, right: start.length ? 2000 : 0 }}\n            dragElastic={{ left: end.length ? 1 : 0.08, right: start.length ? 1 : 0.08 }}\n            onPointerDown={(e) => {\n              dragged.current = false;\n              if (e.pointerType !== \"mouse\") controls.start(e);\n            }}\n            onDragStart={() => {\n              dragged.current = true;\n              onOpenChange(true);\n            }}\n            onDrag={() => {\n              const w = surface.current?.offsetWidth ?? 320;\n              const v = x.get();\n              const next = start.length && v > Math.max(w * 0.55, start.length * SWIPE_W + 40) ? \"start\" : end.length && v < -Math.max(w * 0.55, end.length * SWIPE_W + 40) ? \"end\" : null;\n              if (next !== armed) setArmed(next);\n            }}\n            onDragEnd={(_, info) => release(info.velocity.x)}\n            style={{ x, touchAction: \"pan-y\" }}\n            className=\"group/row relative\"\n            data-selected={selected || undefined}\n          >\n            <button\n              ref={main}\n              type=\"button\"\n              data-row-main=\"\"\n              aria-label={label}\n              aria-current={selected ? \"true\" : undefined}\n              aria-keyshortcuts={[...start, ...end].map((a) => a.shortcut).join(\" \") || undefined}\n              tabIndex={tabbable ? 0 : -1}\n              onFocus={onFocusRow}\n              onKeyDown={(e) => {\n                if (e.key === \"ContextMenu\" || (e.shiftKey && e.key === \"F10\")) {\n                  e.preventDefault();\n                  const r = e.currentTarget.getBoundingClientRect();\n                  e.currentTarget.dispatchEvent(new MouseEvent(\"contextmenu\", { bubbles: true, cancelable: true, clientX: r.left + 56, clientY: r.bottom - 6 }));\n                }\n              }}\n              onClick={() => {\n                if (dragged.current) return;\n                // Tapping a row that is swiped open closes it instead of opening the chat.\n                if (Math.abs(x.get()) > 2) {\n                  onOpenChange(false);\n                  return;\n                }\n                onSelect();\n              }}\n              className={cn(\n                \"flex w-full select-none items-center gap-3 rounded-lg px-3 py-2.5 text-left\",\n                \"outline-none focus-visible:outline-solid focus-visible:outline-1 focus-visible:-outline-offset-1 focus-visible:outline-fg-3\",\n                \"transition-[background-color] duration-150 active:bg-fg/[0.06] active:duration-75\",\n                selected && \"bg-fg/[0.075] active:bg-fg/[0.1]\",\n              )}\n            >\n              <RowAvatar c={c} />\n\n              <span className=\"flex min-w-0 flex-1 flex-col gap-0.5\">\n                <span className=\"flex min-w-0 items-center gap-1.5\">\n                  <span className=\"truncate text-[13px] font-medium leading-[18px] tracking-[-0.005em] text-fg\">{c.name}</span>\n                  {c.muted && (\n                    <span className=\"shrink-0 text-fg-4 [&_svg]:size-3\" aria-hidden>\n                      <BellOffIcon />\n                    </span>\n                  )}\n                </span>\n                <span className=\"relative grid h-[18px] min-w-0 overflow-hidden\">\n                  <AnimatePresence initial={false}>\n                    <motion.span key={previewKey} {...swap} className=\"col-start-1 row-start-1 flex min-w-0 items-center gap-1 text-[12.5px] leading-[18px]\">\n                      {typing ? (\n                        <span className=\"flex min-w-0 items-center gap-1.5 text-fg-2\">\n                          <span className=\"truncate\">{typingText}</span>\n                          <TypingDots />\n                        </span>\n                      ) : c.draft ? (\n                        <span className=\"truncate text-fg-3\">\n                          <span className=\"font-medium text-fg-2\">Draft </span>\n                          {c.draft}\n                        </span>\n                      ) : (\n                        <span className={cn(\"truncate transition-colors duration-200\", loud ? \"text-fg-2\" : \"text-fg-3\")}>\n                          {c.sender && <span className={cn(\"transition-colors duration-200\", loud ? \"text-fg\" : \"text-fg-2\")}>{c.sender}: </span>}\n                          {c.preview}\n                        </span>\n                      )}\n                    </motion.span>\n                  </AnimatePresence>\n                </span>\n              </span>\n\n              {/* Fixed width, so the hover actions take exactly this space and nothing reflows. */}\n              <span\n                className={cn(\n                  \"flex min-w-15 shrink-0 flex-col items-end gap-0.5 self-stretch py-px\",\n                  \"transition-[opacity] duration-150\",\n                  hover.length > 0 && \"pointer-fine:group-hover/row:opacity-0 group-has-[[data-row-action]:focus-visible]/row:opacity-0\",\n                )}\n              >\n                <time\n                  dateTime={timeISO}\n                  title={timeTitle}\n                  className={cn(\"h-[18px] whitespace-nowrap text-[11.5px] leading-[18px] tabular transition-colors duration-200\", loud ? \"text-fg\" : \"text-fg-3\")}\n                >\n                  {timeText}\n                </time>\n                <span className=\"flex h-[18px] items-center gap-1\">\n                  <AnimatePresence initial={false}>\n                    {c.pinned && !unread && (\n                      <motion.span key=\"pin\" className=\"text-fg-4 [&_svg]:size-3\" aria-hidden initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0, transition: { duration: 0.1 } }}>\n                        <PinIcon />\n                      </motion.span>\n                    )}\n                  </AnimatePresence>\n                  <Badge count={count} marked={!!c.markedUnread} mention={!!c.mention} muted={!!c.muted} reduce={reduce} />\n                </span>\n              </span>\n            </button>\n\n            {hover.length > 0 && (\n              <div\n                className={cn(\n                  \"absolute right-2 top-1/2 flex -translate-y-1/2 items-center gap-1\",\n                  \"pointer-events-none translate-x-1 opacity-0 transition-[opacity,translate] duration-150 ease-out-expo\",\n                  \"pointer-fine:group-hover/row:pointer-events-auto pointer-fine:group-hover/row:translate-x-0 pointer-fine:group-hover/row:opacity-100\",\n                  \"group-has-[[data-row-action]:focus-visible]/row:pointer-events-auto group-has-[[data-row-action]:focus-visible]/row:translate-x-0 group-has-[[data-row-action]:focus-visible]/row:opacity-100\",\n                  \"pointer-coarse:hidden motion-reduce:translate-x-0\",\n                )}\n              >\n                {hover.map((a) => (\n                  <HoverAction key={a.key} action={a} onPress={(el) => onRun(a.key, el)} />\n                ))}\n              </div>\n            )}\n          </motion.div>\n        </ContextMenu.Trigger>\n\n        <ContextMenu.Portal>\n          <ContextMenu.Positioner collisionPadding={8} className=\"z-(--z-dropdown) outline-none\">\n            <ContextMenu.Popup\n              className={cn(\n                \"min-w-48 rounded-xl border border-line-2 bg-raised p-1 text-fg shadow-pop outline-none\",\n                \"origin-(--transform-origin) transition-[opacity,scale] duration-150 ease-out-expo\",\n                \"data-starting-style:scale-96 data-starting-style:opacity-0 data-ending-style:scale-98 data-ending-style:opacity-0 data-ending-style:duration-100\",\n                \"motion-reduce:scale-100\",\n              )}\n            >\n              {menu.map((a) => (\n                <MenuItem key={a.key} action={a} onClick={() => onRun(a.key, main.current)} />\n              ))}\n              {actions.archive && (\n                <>\n                  {menu.length > 0 && <ContextMenu.Separator className=\"-mx-1 my-1 h-px bg-line\" />}\n                  <MenuItem action={actions.archive} onClick={() => onRun(\"archive\", main.current)} />\n                </>\n              )}\n            </ContextMenu.Popup>\n          </ContextMenu.Positioner>\n        </ContextMenu.Portal>\n      </ContextMenu.Root>\n    </motion.li>\n  );\n}\n\n/* -------------------------------------------------------------------------------------------------\n * Parts\n * -----------------------------------------------------------------------------------------------*/\n\nfunction initials(name: string) {\n  const parts = name.trim().split(/\\s+/).filter((p) => /[\\p{L}\\p{N}]/u.test(p));\n  return ((parts[0]?.[0] ?? \"\") + (parts.length > 1 ? parts[parts.length - 1][0] : \"\")).toUpperCase() || \"?\";\n}\n\nfunction RowAvatar({ c }: { c: Conversation }) {\n  return (\n    <span className=\"relative size-10 shrink-0\">\n      <Avatar.Root\n        className={cn(\n          \"flex size-10 select-none items-center justify-center overflow-hidden rounded-full bg-fg/[0.08] text-[13px] font-medium text-fg-2\",\n          // The presence dot is cut out of the avatar rather than ringed in a surface color,\n          // so it sits right on any background: selected, hovered, light or dark.\n          \"transition-[mask-size] duration-200\",\n          c.online && \"[mask-image:radial-gradient(circle_at_calc(100%-5px)_calc(100%-5px),transparent_6.5px,var(--fg)_7px)]\",\n        )}\n      >\n        {c.avatar && <Avatar.Image src={c.avatar} alt=\"\" width={40} height={40} className=\"size-full object-cover\" />}\n        <Avatar.Fallback delay={c.avatar ? 400 : 0} className=\"tracking-[0.01em]\">\n          {initials(c.name)}\n        </Avatar.Fallback>\n      </Avatar.Root>\n      <AnimatePresence initial={false}>\n        {c.online && (\n          <motion.span\n            key=\"online\"\n            aria-hidden\n            className=\"absolute bottom-0 right-0 size-2.5 rounded-full bg-success\"\n            initial={{ opacity: 0, scale: 0.4 }}\n            animate={{ opacity: 1, scale: 1 }}\n            exit={{ opacity: 0, scale: 0.4, transition: { duration: 0.12 } }}\n            transition={spring.pop}\n          />\n        )}\n      </AnimatePresence>\n    </span>\n  );\n}\n\nfunction TypingDots() {\n  return (\n    <span aria-hidden className=\"flex shrink-0 items-center gap-[3px] pt-px\">\n      {[0, 0.15, 0.3].map((d) => (\n        <span key={d} className=\"size-[3px] animate-dot-wave rounded-full bg-current\" style={{ animationDelay: `${d}s` }} />\n      ))}\n    </span>\n  );\n}\n\nfunction Badge({ count, marked, mention, muted, reduce }: { count: number; marked: boolean; mention: boolean; muted: boolean; reduce: boolean }) {\n  const show = count > 0 ? \"count\" : marked ? \"dot\" : null;\n  const enter = reduce ? { opacity: 0 } : { opacity: 0, scale: 0.5 };\n  const tone = muted ? \"bg-fg/[0.14] text-fg-2\" : \"bg-fg text-frame\";\n  return (\n    <span className=\"flex items-center gap-1\" aria-hidden>\n      <AnimatePresence initial={false}>\n        {mention && count > 0 && (\n          <motion.span\n            key=\"at\"\n            initial={enter}\n            animate={{ opacity: 1, scale: 1 }}\n            exit={{ ...enter, transition: { duration: 0.12 } }}\n            transition={reduce ? { duration: 0.12 } : spring.pop}\n            className={cn(\"grid size-[18px] place-items-center rounded-full text-[11px] font-medium leading-none\", tone)}\n          >\n            @\n          </motion.span>\n        )}\n      </AnimatePresence>\n      <AnimatePresence initial={false} mode=\"popLayout\">\n        {show === \"count\" && (\n          <motion.span\n            key=\"count\"\n            initial={enter}\n            animate={{ opacity: 1, scale: 1 }}\n            exit={{ ...enter, transition: { duration: 0.12 } }}\n            transition={reduce ? { duration: 0.12 } : spring.pop}\n            className={cn(\"flex h-[18px] min-w-[18px] items-center justify-center rounded-full px-[5px] text-[11px] font-medium leading-none tabular transition-colors duration-200\", tone)}\n          >\n            <NumberFlow value={Math.min(count, 99)} suffix={count > 99 ? \"+\" : undefined} animated={!reduce} />\n          </motion.span>\n        )}\n        {show === \"dot\" && (\n          <motion.span\n            key=\"dot\"\n            initial={enter}\n            animate={{ opacity: 1, scale: 1 }}\n            exit={{ ...enter, transition: { duration: 0.12 } }}\n            transition={reduce ? { duration: 0.12 } : spring.pop}\n            className={cn(\"mr-[5px] size-2 rounded-full\", muted ? \"bg-fg-3\" : \"bg-fg\")}\n          />\n        )}\n      </AnimatePresence>\n    </span>\n  );\n}\n\nfunction HoverAction({ action, onPress }: { action: Action; onPress: (el: HTMLElement) => void }) {\n  return (\n    <Tooltip.Root>\n      <Tooltip.Trigger\n        render={<button type=\"button\" />}\n        tabIndex={-1}\n        data-row-action=\"\"\n        aria-label={action.label}\n        aria-keyshortcuts={action.shortcut}\n        onClick={(e) => onPress(e.currentTarget)}\n        className={cn(\n          \"grid size-7 place-items-center rounded-md text-fg-3\",\n          \"outline-none focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-1 focus-visible:outline-fg-3\",\n          \"transition-[background-color,color,scale] duration-150 hover:bg-fg/[0.07] hover:text-fg active:scale-[0.92] active:duration-75\",\n        )}\n      >\n        {action.icon}\n      </Tooltip.Trigger>\n      <Tooltip.Portal>\n        <Tooltip.Positioner side=\"top\" sideOffset={6} collisionPadding={8} className=\"z-(--z-tooltip)\">\n          <Tooltip.Popup\n            className={cn(\n              \"flex h-[26px] items-center gap-2 rounded-lg border border-line-2 bg-raised pl-2 pr-1 text-[12px] text-fg shadow-pop\",\n              \"origin-(--transform-origin) transition-[opacity,scale] duration-150 ease-out-expo\",\n              \"data-starting-style:scale-96 data-starting-style:opacity-0 data-ending-style:opacity-0 data-ending-style:duration-100 data-instant:transition-none\",\n            )}\n          >\n            {action.label}\n            <kbd className=\"inline-flex h-[18px] min-w-[18px] items-center justify-center rounded-[5px] border border-line-2 bg-frame px-1 font-mono text-[10.5px] leading-none text-fg-2\">\n              {action.shortcut}\n            </kbd>\n          </Tooltip.Popup>\n        </Tooltip.Positioner>\n      </Tooltip.Portal>\n    </Tooltip.Root>\n  );\n}\n\nfunction MenuItem({ action, onClick }: { action: Action; onClick: () => void }) {\n  return (\n    <ContextMenu.Item\n      onClick={onClick}\n      className={cn(\n        \"flex h-8 cursor-default select-none items-center gap-2.5 rounded-lg px-2 text-[13px] outline-none pointer-coarse:h-10\",\n        \"data-highlighted:bg-fg/[0.06] [&_svg]:text-fg-3 data-highlighted:[&_svg]:text-fg-2\",\n      )}\n    >\n      {action.icon}\n      <span className=\"flex-1\">{action.label}</span>\n      <kbd className=\"font-sans text-[12px] text-fg-3 pointer-coarse:hidden\">{action.shortcut}</kbd>\n    </ContextMenu.Item>\n  );\n}\n\n/** The actions a swipe uncovers. Each button stretches with the drag; past the commit point the edge action takes the whole tray. */\nfunction Tray({ side, actions, x, armed, reduce, onPress }: { side: \"start\" | \"end\"; actions: Action[]; x: MotionValue<number>; armed: boolean; reduce: boolean; onPress: (a: Action) => void }) {\n  const width = useTransform(x, (v) => Math.max(0, side === \"start\" ? v : -v));\n  const edge = side === \"start\" ? actions[0] : actions[actions.length - 1];\n  const tone: Record<ActionKey, string> = {\n    read: \"bg-fg-2 text-frame\",\n    pin: \"bg-fg/[0.14] text-fg\",\n    mute: \"bg-fg/[0.14] text-fg\",\n    archive: \"bg-fg text-frame\",\n  };\n  return (\n    <motion.div aria-hidden className={cn(\"absolute inset-y-0 flex overflow-hidden\", side === \"start\" ? \"left-0\" : \"right-0\")} style={{ width }}>\n      {actions.map((a) => {\n        const isEdge = a === edge;\n        const collapsed = armed && !isEdge;\n        return (\n          <button\n            key={a.key}\n            type=\"button\"\n            tabIndex={-1}\n            onClick={() => onPress(a)}\n            className={cn(\n              \"relative flex min-w-0 basis-0 flex-col justify-center gap-1 overflow-hidden text-[11px] font-medium\",\n              \"transition-[flex-grow] duration-200 ease-out-expo\",\n              collapsed ? \"grow-0\" : \"grow\",\n              tone[a.key],\n              // Past the commit point the icon hugs the row, so it looks pulled along by the finger.\n              !(armed && isEdge) ? \"items-center\" : side === \"start\" ? \"items-end pr-6\" : \"items-start pl-6\",\n            )}\n          >\n            <motion.span\n              className=\"flex flex-col items-center gap-1\"\n              animate={{ scale: armed && isEdge && !reduce ? 1.12 : 1 }}\n              transition={spring.pop}\n            >\n              {a.icon}\n              <span className=\"whitespace-nowrap\">{a.short}</span>\n            </motion.span>\n          </button>\n        );\n      })}\n    </motion.div>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/conversation-list"
}
