{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "message-list",
  "title": "Message list",
  "author": "Tori Bryan",
  "description": "A conversation that groups each person's messages under one heading, with a single tab stop.",
  "dependencies": [
    "lucide-react"
  ],
  "registryDependencies": [
    "https://fibo.toribryan.com/r/avatar.json",
    "https://fibo.toribryan.com/r/empty-state.json",
    "https://fibo.toribryan.com/r/separator.json",
    "utils"
  ],
  "files": [
    {
      "path": "registry/ui/message-list.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { CornerUpRightIcon } from \"lucide-react\"\n\nimport {\n  Avatar,\n  AvatarFallback,\n  AvatarImage,\n} from \"@/components/ui/avatar\"\nimport {\n  EmptyState,\n  EmptyStateTitle,\n} from \"@/components/ui/empty-state\"\nimport { Separator } from \"@/components/ui/separator\"\nimport { mergeRefs } from \"@/lib/merge-refs\"\nimport { cn } from \"@/lib/utils\"\n\ntype ChatAuthor = {\n  /** Stable id. A new group starts when it changes. */\n  id: string\n  /** Display name, shown in the group's heading. */\n  name: string\n  /** Image URL. Initials stand in without one. */\n  avatar?: string\n}\n\ntype ChatReply = {\n  /** The id of the message being replied to. */\n  id: string\n  /** Who wrote it. */\n  name: string\n  /** A short excerpt of it. */\n  text: string\n}\n\ntype ChatMessage = {\n  /** Stable id, unique in the list. */\n  id: string\n  /** Who sent it. */\n  author: ChatAuthor\n  /** When it was sent. */\n  sentAt: Date\n  /** The body. */\n  content: React.ReactNode\n  /** `system` for events like someone joining, set apart from conversation. */\n  kind?: \"message\" | \"system\"\n  /** The message was deleted; its content is replaced. */\n  deleted?: boolean\n  /** The message it answers, quoted above it. */\n  replyTo?: ChatReply\n}\n\ntype BreakContext = {\n  /** The first message of the group so far. */\n  first: ChatMessage\n  /** The window, in milliseconds. */\n  windowMs: number\n  /** Whether the window runs from the previous message or the group's first. */\n  windowFrom: \"previous\" | \"first\"\n  /** Ids of messages with a divider above them. */\n  dividers: ReadonlySet<string>\n}\n\ntype BreakRule = (\n  previous: ChatMessage,\n  message: ChatMessage,\n  context: BreakContext\n) => boolean\n\nfunction sameDay(a: Date, b: Date) {\n  return (\n    a.getFullYear() === b.getFullYear() &&\n    a.getMonth() === b.getMonth() &&\n    a.getDate() === b.getDate()\n  )\n}\n\n/*\n * Each reason a message starts a new group, checked in this order; the first\n * that holds is the one reported. Element, Zulip and Stream all break on\n * these. Keeping them as named rules lets each be tested, and lets a product\n * see why two messages didn't merge.\n */\nconst breakRules = {\n  divider: (_, message, { dividers }) => dividers.has(message.id),\n  day: (previous, message) => !sameDay(previous.sentAt, message.sentAt),\n  system: (previous, message) =>\n    previous.kind === \"system\" || message.kind === \"system\",\n  deleted: (previous, message) =>\n    Boolean(previous.deleted) || Boolean(message.deleted),\n  reply: (_, message) => Boolean(message.replyTo),\n  author: (previous, message) => previous.author.id !== message.author.id,\n  // Same person under a new name or picture: the old heading would be wrong.\n  identity: (previous, message) =>\n    previous.author.name !== message.author.name ||\n    previous.author.avatar !== message.author.avatar,\n  window: (previous, message, { first, windowMs, windowFrom }) =>\n    message.sentAt.getTime() -\n      (windowFrom === \"first\" ? first : previous).sentAt.getTime() >\n    windowMs,\n} satisfies Record<string, BreakRule>\n\ntype BreakReason = \"start\" | keyof typeof breakRules\n\ntype MessagePosition = \"single\" | \"first\" | \"middle\" | \"last\"\n\ntype GroupedMessage = {\n  message: ChatMessage\n  /** Where it sits in its group, for squaring off bubble corners. */\n  position: MessagePosition\n  /** Why it starts a group, or null when it continues one. */\n  breakReason: BreakReason | null\n  /** It's the first message on its calendar day. */\n  newDay: boolean\n}\n\ntype GroupOptions = {\n  /** Minutes a group stays open. */\n  windowMinutes?: number\n  /** Measure the window from the previous message (rolling) or the group's first (fixed). */\n  windowFrom?: \"previous\" | \"first\"\n  /** Ids of messages with a divider above them, such as the first unread. */\n  dividers?: Iterable<string>\n}\n\n/*\n * Grouping depends on each message's neighbors, so run it over the whole\n * list before virtualizing. Run on only the rendered window, the first row\n * would always look like the start of a group.\n */\nfunction groupMessages(\n  messages: readonly ChatMessage[],\n  { windowMinutes = 5, windowFrom = \"previous\", dividers }: GroupOptions = {}\n): GroupedMessage[] {\n  const rules = Object.entries(breakRules) as [\n    keyof typeof breakRules,\n    BreakRule,\n  ][]\n  const base = {\n    windowMs: windowMinutes * 60_000,\n    windowFrom,\n    dividers: new Set(dividers),\n  }\n\n  let first = messages[0]\n  const reasons = messages.map((message, i): BreakReason | null => {\n    const previous = messages[i - 1]\n    if (!previous || !first) {\n      first = message\n      return \"start\"\n    }\n    const context = { ...base, first }\n    const match = rules.find(([, rule]) => rule(previous, message, context))\n    if (!match) return null\n    first = message\n    return match[0]\n  })\n\n  return messages.map((message, i) => {\n    const starts = reasons[i] !== null\n    const ends = i === messages.length - 1 || reasons[i + 1] !== null\n    const previous = messages[i - 1]\n    return {\n      message,\n      position: starts ? (ends ? \"single\" : \"first\") : ends ? \"last\" : \"middle\",\n      breakReason: reasons[i] ?? null,\n      newDay: !previous || !sameDay(previous.sentAt, message.sentAt),\n    }\n  })\n}\n\ntype MessageListStrings = {\n  /** The accessible name of the whole list. */\n  list: string\n  /** The accessible name of each message. Screen reader users can land mid-group, so it names the author every time. */\n  label: (message: ChatMessage, time: string) => string\n  /** Shown in place of a deleted message. */\n  deleted: string\n  /** Read before a reply's quote. */\n  replyingTo: (name: string) => string\n  /** The label on the unread divider. */\n  unread: string\n  /** Shown when there are no messages yet. */\n  empty: string\n}\n\nconst defaultStrings: MessageListStrings = {\n  list: \"Messages\",\n  label: (message, time) =>\n    message.kind === \"system\"\n      ? time\n      : message.deleted\n        ? `Deleted message from ${message.author.name}, ${time}`\n        : `${message.author.name}, ${time}`,\n  deleted: \"This message was deleted.\",\n  replyingTo: (name) => `Replying to ${name}`,\n  unread: \"New\",\n  empty: \"No messages yet\",\n}\n\nconst FOCUSABLE = [\n  \"a[href]\",\n  \"area[href]\",\n  \"button\",\n  \"input\",\n  \"select\",\n  \"textarea\",\n  \"summary\",\n  \"iframe\",\n  \"audio[controls]\",\n  \"video[controls]\",\n  '[contenteditable]:not([contenteditable=\"false\"])',\n  \"[tabindex]\",\n].join(\", \")\n\n// Where a locked control's own tabindex waits (\"\" when it had none).\nconst LOCK = \"data-locked-tabindex\"\n\n/*\n * One Tab stop for the whole list: controls inside a message stay out of the\n * tab order until the message is entered. Content is the caller's markup, so\n * this works on the DOM, and runs again whenever that markup changes.\n */\nfunction syncLocks(root: HTMLElement, enteredId: string | null) {\n  for (const article of root.querySelectorAll<HTMLElement>(\n    '[data-slot=\"message\"]'\n  )) {\n    const open = article.dataset.id === enteredId\n    for (const el of article.querySelectorAll<HTMLElement>(FOCUSABLE)) {\n      const current = el.getAttribute(\"tabindex\")\n      if (open) {\n        if (!el.hasAttribute(LOCK)) continue\n        const original = el.getAttribute(LOCK)\n        el.removeAttribute(LOCK)\n        if (original) el.setAttribute(\"tabindex\", original)\n        else el.removeAttribute(\"tabindex\")\n      } else if (current !== \"-1\" || !el.hasAttribute(LOCK)) {\n        // Also catches the caller changing tabIndex on a locked control:\n        // their new value is kept for when the message opens.\n        el.setAttribute(LOCK, current ?? \"\")\n        el.setAttribute(\"tabindex\", \"-1\")\n      }\n    }\n  }\n}\n\n// The first control in a message the caller meant to be reachable.\nfunction firstControl(article: HTMLElement) {\n  return Array.from(article.querySelectorAll<HTMLElement>(FOCUSABLE)).find(\n    (el) =>\n      (el.getAttribute(LOCK) ?? el.getAttribute(\"tabindex\")) !== \"-1\" &&\n      !el.matches(\":disabled\") &&\n      el.getClientRects().length > 0\n  )\n}\n\nfunction initials(name: string) {\n  return name\n    .split(/\\s+/)\n    .filter(Boolean)\n    .slice(0, 2)\n    .map((part) => Array.from(part)[0])\n    .join(\"\")\n    .toUpperCase()\n}\n\ntype MessageListProps = Omit<React.ComponentProps<\"div\">, \"children\"> &\n  Omit<GroupOptions, \"dividers\"> & {\n    /** The conversation, oldest first. */\n    messages: ChatMessage[]\n    /** The first unread message. Freeze it when the conversation opens, so the divider stays put while people read. */\n    unreadFrom?: string\n    /** Labels for dividers above messages, keyed by message id. Each one also breaks the group. */\n    dividers?: Record<string, string>\n    /** The level of each group's heading. */\n    headingLevel?: 2 | 3 | 4 | 5 | 6\n    /** Locale for times and dates. */\n    locale?: string\n    /** Copy for screen readers and deleted messages. Swap it to translate. */\n    strings?: Partial<MessageListStrings>\n    /** Shown in place of the conversation when there are no messages. Defaults to an EmptyState with `strings.empty`; null shows nothing. */\n    empty?: React.ReactNode\n  }\n\nfunction MessageList({\n  messages,\n  windowMinutes,\n  windowFrom,\n  unreadFrom,\n  dividers = {},\n  headingLevel = 3,\n  locale = \"en\",\n  strings: stringOverrides,\n  empty,\n  className,\n  onKeyDown,\n  onFocus,\n  onBlur,\n  ref,\n  ...props\n}: MessageListProps) {\n  const strings = { ...defaultStrings, ...stringOverrides }\n  const dividerKey = [...Object.keys(dividers), unreadFrom ?? \"\"]\n    .filter(Boolean)\n    .join(\"\\u0000\")\n  const grouped = React.useMemo(\n    () =>\n      groupMessages(messages, {\n        windowMinutes,\n        windowFrom,\n        dividers: dividerKey ? dividerKey.split(\"\\u0000\") : [],\n      }),\n    [messages, windowMinutes, windowFrom, dividerKey]\n  )\n\n  const time = React.useMemo(\n    () =>\n      new Intl.DateTimeFormat(locale, { hour: \"numeric\", minute: \"2-digit\" }),\n    [locale]\n  )\n  const day = React.useMemo(\n    () => new Intl.DateTimeFormat(locale, { dateStyle: \"long\" }),\n    [locale]\n  )\n\n  const listRef = React.useRef<HTMLDivElement>(null)\n  // A caller's ref would otherwise replace the one arrow keys move by.\n  const mergedListRef = React.useMemo(() => mergeRefs(listRef, ref), [ref])\n  // The index is kept so a removed message's neighbor can take its place.\n  const [active, setActive] = React.useState<{\n    id: string\n    index: number\n  } | null>(null)\n  const [enteredId, setEnteredId] = React.useState<string | null>(null)\n  const focusWithin = React.useRef(false)\n\n  const found = messages.findIndex((m) => m.id === active?.id)\n  const current =\n    messages[\n      found !== -1\n        ? found\n        : Math.min(active?.index ?? Infinity, messages.length - 1)\n    ]?.id ?? null\n  const entered = messages.some((m) => m.id === enteredId) ? enteredId : null\n\n  React.useLayoutEffect(() => {\n    const root = listRef.current\n    if (!root) return\n    syncLocks(root, entered)\n    // The caller's content can add controls without this list re-rendering.\n    const observer = new MutationObserver(() => syncLocks(root, entered))\n    observer.observe(root, {\n      subtree: true,\n      childList: true,\n      attributes: true,\n      attributeFilter: [\"tabindex\", \"href\", \"contenteditable\", \"controls\"],\n    })\n    return () => observer.disconnect()\n  }, [entered])\n\n  /*\n   * React fires no blur when a focused element unmounts, so a deleted\n   * message would drop focus to the page. Put it on the neighbor instead.\n   */\n  React.useLayoutEffect(() => {\n    const root = listRef.current\n    if (!root || !focusWithin.current || root.contains(document.activeElement))\n      return\n    root\n      .querySelector<HTMLElement>(`[data-slot=\"message\"][tabindex=\"0\"]`)\n      ?.focus()\n  }, [messages])\n\n  function articles() {\n    return Array.from(\n      listRef.current?.querySelectorAll<HTMLElement>('[data-slot=\"message\"]') ??\n        []\n    )\n  }\n\n  function handleKeyDown(event: React.KeyboardEvent<HTMLDivElement>) {\n    onKeyDown?.(event)\n    if (event.defaultPrevented) return\n    const target = event.target as HTMLElement\n    const article = target.closest<HTMLElement>('[data-slot=\"message\"]')\n    if (!article) return\n\n    if (target !== article) {\n      if (event.key === \"Escape\") {\n        event.preventDefault()\n        setEnteredId(null)\n        article.focus()\n      }\n      return\n    }\n\n    const all = articles()\n    const index = all.indexOf(article)\n    if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) {\n      return\n    }\n    const moves: Record<string, HTMLElement | undefined> = {\n      ArrowDown: all[index + 1],\n      ArrowUp: all[index - 1],\n      Home: all[0],\n      End: all[all.length - 1],\n    }\n    if (event.key in moves) {\n      event.preventDefault()\n      moves[event.key]?.focus()\n      return\n    }\n\n    if (event.key === \"Enter\") {\n      const inner = firstControl(article)\n      if (!inner) return\n      event.preventDefault()\n      // Focusing it marks the message entered, which puts its controls back\n      // in the tab order.\n      inner.focus()\n    }\n  }\n\n  const Heading = `h${headingLevel}` as const\n\n  return (\n    <div\n      ref={mergedListRef}\n      role=\"log\"\n      aria-label={strings.list}\n      data-slot=\"message-list\"\n      className={cn(\n        \"flex flex-col [--message-gap:0.125rem] [--message-group-gap:1rem]\",\n        className\n      )}\n      onKeyDown={handleKeyDown}\n      onFocus={(event) => {\n        focusWithin.current = true\n        onFocus?.(event)\n      }}\n      onBlur={(event) => {\n        if (!event.currentTarget.contains(event.relatedTarget)) {\n          focusWithin.current = false\n        }\n        onBlur?.(event)\n      }}\n      {...props}\n    >\n      {messages.length === 0 ? (\n        empty === undefined ? (\n          <EmptyState>\n            <EmptyStateTitle>{strings.empty}</EmptyStateTitle>\n          </EmptyState>\n        ) : (\n          empty\n        )\n      ) : null}\n      {grouped.map(({ message, position, breakReason, newDay }, i) => {\n        const sent = time.format(message.sentAt)\n        const iso = message.sentAt.toISOString()\n        const starts = position === \"single\" || position === \"first\"\n        const plain = message.kind === \"system\" || message.deleted\n        const divider = dividers[message.id]\n\n        return (\n          <React.Fragment key={message.id}>\n            {newDay || divider || message.id === unreadFrom ? (\n              <MessageDivider\n                label={\n                  [newDay ? day.format(message.sentAt) : \"\", divider ?? \"\"]\n                    .filter(Boolean)\n                    .join(\" \\u00b7 \") || undefined\n                }\n                unread={message.id === unreadFrom ? strings.unread : undefined}\n              />\n            ) : null}\n            <article\n              data-slot=\"message\"\n              data-id={message.id}\n              data-position={position}\n              data-break={breakReason ?? undefined}\n              data-kind={message.kind ?? \"message\"}\n              data-deleted={message.deleted ? \"\" : undefined}\n              aria-label={strings.label(message, sent)}\n              tabIndex={message.id === current ? 0 : -1}\n              onFocus={(event) => {\n                setActive({ id: message.id, index: i })\n                if (event.target !== event.currentTarget) {\n                  setEnteredId(message.id)\n                }\n              }}\n              onBlur={(event) => {\n                if (!event.currentTarget.contains(event.relatedTarget)) {\n                  setEnteredId((id) => (id === message.id ? null : id))\n                }\n              }}\n              // A touch has no hover, and not every browser focuses what is\n              // tapped, so a tap focuses the message to show its time.\n              onPointerDown={(event) => {\n                if (\n                  event.pointerType !== \"mouse\" &&\n                  !event.currentTarget.contains(document.activeElement)\n                ) {\n                  event.currentTarget.focus({ preventScroll: true })\n                }\n              }}\n              className={cn(\n                \"group/message grid grid-cols-[2.5rem_minmax(0,1fr)] gap-x-3 rounded-md px-2 py-0.5 text-sm outline-none focus-visible:ring-[3px] focus-visible:ring-ring-subtle\",\n                starts\n                  ? \"mt-[var(--message-group-gap)] pt-1\"\n                  : \"mt-[var(--message-gap)]\",\n                \"first:mt-0\"\n              )}\n            >\n              {starts && !plain ? (\n                <Avatar size=\"lg\" className=\"row-span-3 mt-0.5\">\n                  {message.author.avatar ? (\n                    <AvatarImage src={message.author.avatar} alt=\"\" />\n                  ) : null}\n                  <AvatarFallback aria-hidden>\n                    {initials(message.author.name)}\n                  </AvatarFallback>\n                </Avatar>\n              ) : (\n                <time\n                  dateTime={iso}\n                  data-slot=\"message-time\"\n                  className=\"row-span-3 pt-0.5 text-right text-[10px] leading-5 text-muted-foreground tabular-nums opacity-0 group-focus-within/message:opacity-100 group-hover/message:opacity-100\"\n                >\n                  {sent}\n                </time>\n              )}\n\n              {message.replyTo ? (\n                <p\n                  data-slot=\"message-reply\"\n                  className=\"flex min-w-0 items-center gap-1.5 text-xs text-muted-foreground\"\n                >\n                  <CornerUpRightIcon aria-hidden className=\"size-3 shrink-0\" />\n                  <span className=\"sr-only\">\n                    {strings.replyingTo(message.replyTo.name)}:\n                  </span>\n                  <span aria-hidden className=\"font-medium text-foreground\">\n                    {message.replyTo.name}\n                  </span>\n                  <span className=\"truncate\">{message.replyTo.text}</span>\n                </p>\n              ) : null}\n\n              {starts && !plain ? (\n                <Heading\n                  data-slot=\"message-heading\"\n                  className=\"flex items-baseline gap-2 text-sm leading-5\"\n                >\n                  <span className=\"font-semibold text-foreground\">\n                    {message.author.name}\n                  </span>\n                  <time\n                    dateTime={iso}\n                    data-slot=\"message-time\"\n                    className=\"text-xs text-muted-foreground tabular-nums\"\n                  >\n                    {sent}\n                  </time>\n                </Heading>\n              ) : null}\n\n              <div\n                data-slot=\"message-content\"\n                className={cn(\n                  \"min-w-0 leading-5 break-words\",\n                  plain ? \"text-muted-foreground italic\" : \"text-foreground\"\n                )}\n              >\n                {message.deleted ? strings.deleted : message.content}\n              </div>\n            </article>\n          </React.Fragment>\n        )\n      })}\n    </div>\n  )\n}\n\n/*\n * One divider carries whatever marks a message: its date, a caller's label,\n * the unread marker. A new day that is also where unread messages begin\n * draws one line, not two.\n */\nfunction MessageDivider({\n  label,\n  unread,\n}: {\n  /** What the divider marks, such as a date, or a date and a label. */\n  label?: string\n  /** The unread marker's label. Turns the line red, with the label beside it. */\n  unread?: string\n}) {\n  return (\n    <Separator\n      data-slot=\"message-divider\"\n      data-unread={unread ? \"\" : undefined}\n      aria-label={[label, unread].filter(Boolean).join(\", \")}\n      className=\"mt-[var(--message-group-gap)] first:mt-0 data-unread:*:data-[slot=separator-line]:bg-destructive\"\n    >\n      {label}\n      {unread ? (\n        // A word as well as a color, since red alone carries nothing for\n        // people who can't tell it apart (WCAG 1.4.1).\n        <span\n          data-slot=\"message-divider-unread\"\n          className=\"rounded-sm bg-destructive px-1.5 py-0.5 text-[10px] leading-none font-semibold text-destructive-foreground not-first:ml-3\"\n        >\n          {unread}\n        </span>\n      ) : null}\n    </Separator>\n  )\n}\n\nexport {\n  MessageList,\n  groupMessages,\n  breakRules,\n  type MessageListProps,\n  type MessageListStrings,\n  type ChatMessage,\n  type ChatAuthor,\n  type ChatReply,\n  type GroupedMessage,\n  type GroupOptions,\n  type BreakReason,\n  type MessagePosition,\n}\n",
      "type": "registry:ui"
    },
    {
      "path": "registry/lib/merge-refs.ts",
      "content": "import * as React from \"react\"\n\n/**\n * One callback ref that sets every ref given, so a part can keep its own\n * ref on an element and still hand the element to a caller's `ref`. Undo\n * runs through React 19's ref cleanup, which calls a function ref with\n * null and empties an object ref.\n */\nexport function mergeRefs<T>(\n  ...refs: (React.Ref<T> | undefined)[]\n): React.RefCallback<T> {\n  return (node) => {\n    const cleanups = refs.map((ref) => {\n      if (typeof ref === \"function\") {\n        const cleanup = ref(node)\n        return typeof cleanup === \"function\" ? cleanup : () => ref(null)\n      }\n      if (ref) {\n        ref.current = node\n        return () => {\n          ref.current = null\n        }\n      }\n      return undefined\n    })\n    return () => {\n      for (const cleanup of cleanups) cleanup?.()\n    }\n  }\n}\n",
      "type": "registry:lib"
    }
  ],
  "cssVars": {
    "theme": {
      "color-destructive-foreground": "var(--destructive-foreground)",
      "color-ring-subtle": "var(--ring-subtle)",
      "background-color-destructive": "var(--destructive)"
    },
    "light": {
      "destructive-foreground": "var(--color-neutral-50)",
      "ring-subtle": "color-mix(in oklch, var(--color-neutral-400) 50%, transparent)"
    },
    "dark": {
      "destructive-foreground": "var(--color-neutral-950)",
      "ring-subtle": "color-mix(in oklch, var(--color-neutral-500) 50%, transparent)"
    }
  },
  "meta": {
    "tier": "base-components",
    "group": "Display",
    "docs": "https://fibo.toribryan.com/?path=/docs/base-components-display-message-list--docs"
  },
  "docs": "Docs and live examples: https://fibo.toribryan.com/?path=/docs/base-components-display-message-list--docs",
  "categories": [
    "base-components",
    "display"
  ],
  "type": "registry:ui"
}