{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "chat-1",
  "title": "Chat 1: team chat with threads",
  "author": "Ballmac <https://ui.ballmac.com>",
  "description": "A team chat: channels and direct messages with unread counts and presence, messages grouped by person and day with a New divider, reactions, threads, @mentions that suggest people, and Retry when a message fails to send.",
  "dependencies": [
    "lucide-react"
  ],
  "registryDependencies": [
    "utils",
    "https://ui.ballmac.com/r/avatar.json",
    "https://ui.ballmac.com/r/button.json",
    "https://ui.ballmac.com/r/skeleton.json",
    "https://ui.ballmac.com/r/state-view.json"
  ],
  "files": [
    {
      "path": "registry/ballmac/components/blocks/chat-1/chat-1.tsx",
      "content": "// Ballmac UI: Chat 1. https://ui.ballmac.com/blocks/chat-1\n\"use client\"\n\nimport * as React from \"react\"\nimport { Hash, Lock, MessageSquare, MessagesSquare, SendHorizontal, SmilePlus, X } from \"lucide-react\"\n\nimport { Avatar, AvatarFallback } from \"@/components/ballmac/avatar\"\nimport { Button } from \"@/components/ballmac/button\"\nimport { Skeleton } from \"@/components/ballmac/skeleton\"\nimport { frameProps, StateView, type StateProps } from \"@/components/ballmac/state-view\"\nimport { cn } from \"@/lib/utils\"\n\ntype Chat1Person = { id: string; name: string; status?: \"online\" | \"away\" | \"offline\" }\n\ntype Chat1Channel = {\n  id: string\n  name: string\n  kind?: \"channel\" | \"dm\"\n  private?: boolean\n  topic?: string\n  unread?: number\n  /** For a direct message: the other person's id. */\n  personId?: string\n}\n\ntype Chat1Message = {\n  id: string\n  authorId: string\n  text: string\n  /** The group heading, already written for people (\"Today\"). */\n  day: string\n  /** Already written for people (\"09:41\"). */\n  time: string\n  reactions?: { emoji: string; count: number; mine?: boolean }[]\n  /** Replies in the thread, oldest first. */\n  replies?: Chat1Message[]\n  /** Set while a message you sent is on its way, or when it failed. */\n  delivery?: \"sending\" | \"failed\"\n}\n\ntype Chat1Props = Omit<React.ComponentProps<\"section\">, \"title\"> &\n  StateProps & {\n    people?: Chat1Person[]\n    /** The person using the app. */\n    meId?: string\n    channels?: Chat1Channel[]\n    /** Messages by channel id, oldest first. */\n    messages?: Record<string, Chat1Message[]>\n    defaultChannelId?: string\n    /** A message whose thread is open at first. */\n    defaultThreadId?: string\n    /** Message id before which a \"New\" divider is drawn, by channel id. */\n    firstUnread?: Record<string, string>\n    /** Called with a new message (and the thread it belongs to). Throw to mark it not sent; it can then be retried. Defaults to a short delay. */\n    onSend?: (message: { channelId: string; text: string; threadId?: string }) => void | Promise<void>\n    /** Called when a reaction is added or removed. */\n    onReact?: (reaction: { channelId: string; messageId: string; emoji: string; on: boolean }) => void\n    /** Called when the channel changes, to load its messages. */\n    onChannelChange?: (channelId: string) => void\n  }\n\nconst quick = [\"👍\", \"🎉\", \"❤️\", \"👀\", \"✅\", \"😄\"]\n\nconst samplePeople: Chat1Person[] = [\n  { id: \"me\", name: \"Jordan Lee\", status: \"online\" },\n  { id: \"priya\", name: \"Priya Shah\", status: \"online\" },\n  { id: \"alex\", name: \"Alex Kim\", status: \"away\" },\n  { id: \"sam\", name: \"Sam Okafor\", status: \"offline\" },\n]\n\nconst sampleChannels: Chat1Channel[] = [\n  { id: \"launch\", name: \"launch\", topic: \"Everything for the 2.5 release on Thursday\", unread: 3 },\n  { id: \"design\", name: \"design\", topic: \"Reviews, critiques and work in progress\" },\n  { id: \"leads\", name: \"leads\", private: true, topic: \"Team leads only\" },\n  { id: \"dm-priya\", name: \"Priya Shah\", kind: \"dm\", personId: \"priya\", unread: 1 },\n  { id: \"dm-alex\", name: \"Alex Kim\", kind: \"dm\", personId: \"alex\" },\n]\n\nconst sampleMessages: Record<string, Chat1Message[]> = {\n  launch: [\n    { id: \"m1\", authorId: \"alex\", day: \"Yesterday\", time: \"16:02\", text: \"Release notes draft is in the doc. I kept it to the five changes people asked for most.\" },\n    { id: \"m2\", authorId: \"alex\", day: \"Yesterday\", time: \"16:03\", text: \"Screenshots still need the new sidebar.\", reactions: [{ emoji: \"👀\", count: 1 }] },\n    {\n      id: \"m3\",\n      authorId: \"priya\",\n      day: \"Today\",\n      time: \"09:12\",\n      text: \"The migration ran on staging in 4 minutes with no errors. I think we’re clear for Thursday.\",\n      reactions: [{ emoji: \"🎉\", count: 3, mine: true }, { emoji: \"✅\", count: 2 }],\n      replies: [\n        { id: \"r1\", authorId: \"me\", day: \"Today\", time: \"09:20\", text: \"Great. Can you post the timing on the release checklist?\" },\n        { id: \"r2\", authorId: \"priya\", day: \"Today\", time: \"09:24\", text: \"Done, it’s under step 4.\" },\n      ],\n    },\n    { id: \"m4\", authorId: \"sam\", day: \"Today\", time: \"09:40\", text: \"Support macros are updated for the new export flow.\" },\n    { id: \"m5\", authorId: \"priya\", day: \"Today\", time: \"09:41\", text: \"@Jordan Lee can you approve the announcement email before noon?\" },\n  ],\n  design: [],\n  leads: [{ id: \"l1\", authorId: \"priya\", day: \"Monday\", time: \"11:00\", text: \"Hiring plan for Q1 is in the shared folder.\" }],\n  \"dm-priya\": [{ id: \"d1\", authorId: \"priya\", day: \"Today\", time: \"08:55\", text: \"Got a minute after standup?\" }],\n  \"dm-alex\": [],\n}\n\nconst initials = (name: string) => name.split(\" \").map((w) => w[0]).join(\"\").slice(0, 2)\n\nfunction MessagesSkeleton() {\n  return (\n    <div className=\"grid gap-5 p-4\" aria-hidden=\"true\">\n      {[0, 1, 2].map((i) => (\n        <div key={i} className=\"flex gap-3\">\n          <Skeleton className=\"size-8 rounded-full\" />\n          <div className=\"grid flex-1 gap-2\">\n            <Skeleton className=\"h-3 w-32\" />\n            <Skeleton className=\"h-3.5\" style={{ width: `${80 - i * 15}%` }} />\n          </div>\n        </div>\n      ))}\n    </div>\n  )\n}\n\n/** The message box: Enter sends, Shift+Enter adds a line, and typing @ suggests people to mention. */\nfunction Composer({ people, placeholder, onSend, label }: { people: Chat1Person[]; placeholder: string; onSend: (text: string) => void; label: string }) {\n  const id = React.useId()\n  const [text, setText] = React.useState(\"\")\n  const [active, setActive] = React.useState(0)\n  const mention = /(?:^|\\s)@([\\w]*)$/.exec(text)\n  const matches = mention ? people.filter((p) => p.name.toLowerCase().startsWith(mention[1]!.toLowerCase())).slice(0, 5) : []\n  const open = matches.length > 0\n  const insert = (p: Chat1Person) => {\n    setText((t) => t.replace(/@([\\w]*)$/, `@${p.name} `))\n    setActive(0)\n  }\n  const send = () => {\n    const value = text.trim()\n    if (!value) return\n    onSend(value)\n    setText(\"\")\n  }\n  return (\n    <div className=\"relative\">\n      {open ? (\n        <ul id={`${id}-people`} role=\"listbox\" aria-label=\"People to mention\" className=\"bg-popover absolute bottom-full start-0 z-10 mb-2 w-60 overflow-hidden rounded-lg border p-1 shadow-lg\">\n          {matches.map((p, i) => (\n            <li\n              key={p.id}\n              id={`${id}-p-${p.id}`}\n              role=\"option\"\n              aria-selected={i === active}\n              onMouseDown={(e) => {\n                e.preventDefault()\n                insert(p)\n              }}\n              className={cn(\"flex cursor-default items-center gap-2 rounded-md px-2 py-1.5 text-sm\", i === active && \"bg-accent\")}\n            >\n              <Avatar className=\"size-5\">\n                <AvatarFallback className=\"text-[9px]\">{initials(p.name)}</AvatarFallback>\n              </Avatar>\n              {p.name}\n            </li>\n          ))}\n        </ul>\n      ) : null}\n      <div className=\"bg-background focus-within:ring-ring/50 flex items-end gap-2 rounded-xl border p-2 ps-3 focus-within:ring-[3px]\">\n        <textarea\n          rows={1}\n          value={text}\n          onChange={(e) => setText(e.target.value)}\n          aria-label={label}\n          role={open ? \"combobox\" : undefined}\n          aria-expanded={open ? true : undefined}\n          aria-controls={open ? `${id}-people` : undefined}\n          aria-activedescendant={open ? `${id}-p-${matches[active]?.id}` : undefined}\n          aria-describedby={`${id}-hint`}\n          placeholder={placeholder}\n          onKeyDown={(e) => {\n            if (open) {\n              if (e.key === \"ArrowDown\" || e.key === \"ArrowUp\") {\n                e.preventDefault()\n                setActive((a) => (a + (e.key === \"ArrowDown\" ? 1 : -1) + matches.length) % matches.length)\n                return\n              }\n              if (e.key === \"Enter\" || e.key === \"Tab\") {\n                e.preventDefault()\n                insert(matches[active] ?? matches[0]!)\n                return\n              }\n            }\n            if (e.key === \"Enter\" && !e.shiftKey && !e.nativeEvent.isComposing) {\n              e.preventDefault()\n              send()\n            }\n          }}\n          className=\"placeholder:text-muted-foreground max-h-32 min-h-7 min-w-0 flex-1 resize-none bg-transparent py-1 text-sm outline-none [field-sizing:content]\"\n        />\n        <Button size=\"icon-sm\" aria-label=\"Send\" onClick={send} disabled={!text.trim()}>\n          <SendHorizontal aria-hidden=\"true\" className=\"rtl:-scale-x-100\" />\n        </Button>\n      </div>\n      <p id={`${id}-hint`} className=\"sr-only\">\n        Enter sends, Shift and Enter adds a line. Type @ to mention someone.\n      </p>\n    </div>\n  )\n}\n\nfunction Reactions({ m, onToggle }: { m: Chat1Message; onToggle: (emoji: string) => void }) {\n  if (!m.reactions?.length) return null\n  return (\n    <div className=\"mt-1.5 flex flex-wrap items-center gap-1\">\n      {m.reactions.map((r) => (\n        <button\n          key={r.emoji}\n          type=\"button\"\n          aria-pressed={!!r.mine}\n          aria-label={`${r.emoji} ${r.count}${r.mine ? \", including you\" : \"\"}`}\n          onClick={() => onToggle(r.emoji)}\n          className={cn(\"flex h-6 items-center gap-1 rounded-full border px-2 text-xs tabular-nums outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50\", r.mine ? \"border-primary/40 bg-primary/10\" : \"hover:bg-accent\")}\n        >\n          <span aria-hidden=\"true\">{r.emoji}</span>\n          <span aria-hidden=\"true\">{r.count}</span>\n        </button>\n      ))}\n    </div>\n  )\n}\n\n/** One message, or a run of messages from the same person within the same day when `compact`. */\nfunction Message({\n  m,\n  author,\n  compact,\n  meName,\n  onReact,\n  onReply,\n  onRetry,\n  inThread,\n}: {\n  m: Chat1Message\n  author?: Chat1Person\n  compact: boolean\n  meName?: string\n  onReact: (emoji: string, on: boolean) => void\n  onReply?: () => void\n  onRetry?: () => void\n  inThread?: boolean\n}) {\n  const [picking, setPicking] = React.useState(false)\n  const name = author?.name ?? \"Someone\"\n  // mentions of the reader stand out, as they do in every team chat\n  const parts = m.text.split(/(@[A-Z][\\w]*(?: [A-Z][\\w]*)?)/g)\n  return (\n    <article aria-label={`${name}, ${m.time}`} className={cn(\"group/msg hover:bg-accent/40 relative flex gap-3 rounded-lg px-3 focus-within:bg-accent/40\", compact ? \"py-0.5\" : \"pt-2 pb-0.5\")}>\n      <div className=\"w-8 shrink-0\">\n        {compact ? (\n          <span className=\"text-muted-foreground hidden pt-0.5 text-[10px] tabular-nums group-hover/msg:block\">{m.time}</span>\n        ) : (\n          <Avatar className=\"size-8\" status={author?.status}>\n            <AvatarFallback className=\"text-xs\">{initials(name)}</AvatarFallback>\n          </Avatar>\n        )}\n      </div>\n      <div className=\"min-w-0 flex-1\">\n        {!compact ? (\n          <p className=\"flex items-baseline gap-2\">\n            <span className=\"text-sm font-semibold\">{name}</span>\n            <span className=\"text-muted-foreground text-xs tabular-nums\">{m.time}</span>\n          </p>\n        ) : null}\n        <p className={cn(\"text-sm leading-relaxed break-words whitespace-pre-wrap\", m.delivery === \"sending\" && \"text-muted-foreground\")}>\n          {parts.map((p, i) =>\n            p.startsWith(\"@\") ? (\n              <span key={i} className={cn(\"rounded px-0.5 font-medium\", p.slice(1) === meName ? \"bg-chart-3/25\" : \"text-primary\")}>\n                {p}\n              </span>\n            ) : (\n              p\n            )\n          )}\n        </p>\n        {m.delivery ? (\n          <p className={cn(\"mt-0.5 text-xs\", m.delivery === \"failed\" ? \"text-destructive\" : \"text-muted-foreground\")} role={m.delivery === \"failed\" ? \"alert\" : undefined}>\n            {m.delivery === \"sending\" ? \"Sending…\" : \"Not sent.\"}{\" \"}\n            {m.delivery === \"failed\" && onRetry ? (\n              <button type=\"button\" onClick={onRetry} className=\"rounded-sm font-medium underline underline-offset-2 outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50\">\n                Retry\n              </button>\n            ) : null}\n          </p>\n        ) : null}\n        <Reactions m={m} onToggle={(e) => onReact(e, !m.reactions?.find((r) => r.emoji === e)?.mine)} />\n        {picking ? (\n          <span role=\"group\" aria-label=\"Add a reaction\" className=\"mt-1 flex w-fit gap-0.5 rounded-full border px-1\">\n            {quick.map((e) => (\n              <button\n                key={e}\n                type=\"button\"\n                aria-label={`React with ${e}`}\n                onClick={() => {\n                  setPicking(false)\n                  onReact(e, true)\n                }}\n                className=\"hover:bg-accent size-7 rounded-full text-sm outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50\"\n              >\n                {e}\n              </button>\n            ))}\n          </span>\n        ) : null}\n        {!inThread && m.replies?.length ? (\n          <button type=\"button\" onClick={onReply} className=\"text-primary mt-1 flex items-center gap-1.5 rounded-sm text-xs font-medium outline-none hover:underline focus-visible:ring-[3px] focus-visible:ring-ring/50\">\n            <MessageSquare aria-hidden=\"true\" className=\"size-3.5\" />\n            {m.replies.length === 1 ? \"1 reply\" : `${m.replies.length} replies`}\n          </button>\n        ) : null}\n      </div>\n      {!m.delivery ? (\n        <div className=\"bg-background absolute -top-3 end-3 hidden gap-0.5 rounded-md border p-0.5 shadow-sm group-focus-within/msg:flex group-hover/msg:flex\">\n          <Button variant=\"ghost\" size=\"icon-sm\" className=\"size-7\" aria-label={`Add a reaction to ${name}’s message`} aria-pressed={picking} onClick={() => setPicking((p) => !p)}>\n            <SmilePlus aria-hidden=\"true\" />\n          </Button>\n          {!inThread && onReply ? (\n            <Button variant=\"ghost\" size=\"icon-sm\" className=\"size-7\" aria-label={`Reply in thread to ${name}`} onClick={onReply}>\n              <MessageSquare aria-hidden=\"true\" />\n            </Button>\n          ) : null}\n        </div>\n      ) : null}\n    </article>\n  )\n}\n\n/**\n * A team chat: channels and direct messages with unread counts and presence, messages grouped by person and day with a New divider,\n * reactions, threads in a side panel, @mentions that suggest people, and messages that show while sending and offer Retry when they fail.\n */\nfunction Chat1({\n  people = samplePeople,\n  meId = \"me\",\n  channels = sampleChannels,\n  messages: messagesProp = sampleMessages,\n  defaultChannelId,\n  defaultThreadId,\n  firstUnread = { launch: \"m4\" },\n  onSend,\n  onReact,\n  onChannelChange,\n  state = \"ready\",\n  onRetry,\n  emptyState,\n  errorState,\n  className,\n  ...props\n}: Chat1Props) {\n  const id = React.useId()\n  const [channelId, setChannelId] = React.useState(defaultChannelId ?? channels[0]?.id ?? \"\")\n  const [store, setStore] = React.useState(messagesProp)\n  React.useEffect(() => setStore(messagesProp), [messagesProp])\n  const [read, setRead] = React.useState<Record<string, boolean>>({})\n  const [threadId, setThreadId] = React.useState<string | null>(defaultThreadId ?? null)\n  const channel = channels.find((c) => c.id === channelId)\n  const list = store[channelId] ?? []\n  const thread = threadId ? list.find((m) => m.id === threadId) : undefined\n  const person = (pid: string) => people.find((p) => p.id === pid)\n  const me = person(meId)\n  const logRef = React.useRef<HTMLDivElement>(null)\n  const localId = React.useRef(0)\n\n  React.useEffect(() => {\n    const el = logRef.current\n    if (el) el.scrollTop = el.scrollHeight\n  }, [channelId, list.length, state])\n\n  const update = (fn: (msgs: Chat1Message[]) => Chat1Message[], cid = channelId) => setStore((s) => ({ ...s, [cid]: fn(s[cid] ?? []) }))\n\n  function mapMessage(msgs: Chat1Message[], msgId: string, fn: (m: Chat1Message) => Chat1Message): Chat1Message[] {\n    return msgs.map((m) => (m.id === msgId ? fn(m) : m.replies ? { ...m, replies: mapMessage(m.replies, msgId, fn) } : m))\n  }\n\n  async function send(text: string, inThread?: string, retryId?: string) {\n    const cid = channelId\n    const mid = retryId ?? `local-${++localId.current}`\n    const draft: Chat1Message = { id: mid, authorId: meId, text, day: \"Today\", time: \"Now\", delivery: \"sending\" }\n    if (retryId) update((msgs) => mapMessage(msgs, mid, (m) => ({ ...m, delivery: \"sending\" })), cid)\n    else if (inThread) update((msgs) => msgs.map((m) => (m.id === inThread ? { ...m, replies: [...(m.replies ?? []), draft] } : m)), cid)\n    else update((msgs) => [...msgs, draft], cid)\n    try {\n      if (onSend) await onSend({ channelId: cid, text, threadId: inThread })\n      else await new Promise((r) => setTimeout(r, 500))\n      update((msgs) => mapMessage(msgs, mid, ({ delivery: _, ...m }) => m), cid)\n    } catch {\n      update((msgs) => mapMessage(msgs, mid, (m) => ({ ...m, delivery: \"failed\" })), cid)\n    }\n  }\n\n  function react(messageId: string, emoji: string, on: boolean) {\n    update((msgs) =>\n      mapMessage(msgs, messageId, (m) => {\n        const existing = m.reactions?.find((r) => r.emoji === emoji)\n        let reactions = m.reactions ?? []\n        if (existing) reactions = reactions.map((r) => (r.emoji === emoji ? { ...r, mine: on, count: r.count + (on === !!r.mine ? 0 : on ? 1 : -1) } : r)).filter((r) => r.count > 0)\n        else if (on) reactions = [...reactions, { emoji, count: 1, mine: true }]\n        return { ...m, reactions }\n      })\n    )\n    onReact?.({ channelId, messageId, emoji, on })\n  }\n\n  const renderList = (msgs: Chat1Message[], inThread?: boolean) => {\n    const out: React.ReactNode[] = []\n    msgs.forEach((m, i) => {\n      const prev = msgs[i - 1]\n      if (!inThread && m.day !== prev?.day)\n        out.push(\n          <div key={`day-${m.day}-${m.id}`} role=\"separator\" aria-label={m.day} className=\"flex items-center gap-3 px-3 py-2\">\n            <span className=\"bg-border h-px flex-1\" />\n            <span className=\"text-muted-foreground text-xs font-medium\">{m.day}</span>\n            <span className=\"bg-border h-px flex-1\" />\n          </div>\n        )\n      if (!inThread && firstUnread[channelId] === m.id && !read[channelId])\n        out.push(\n          <div key={`new-${m.id}`} role=\"separator\" aria-label=\"New messages\" className=\"flex items-center gap-3 px-3 py-1\">\n            <span className=\"bg-destructive/50 h-px flex-1\" />\n            <span className=\"text-destructive text-xs font-semibold\">New</span>\n          </div>\n        )\n      const compact = !!prev && prev.authorId === m.authorId && prev.day === m.day && firstUnread[channelId] !== m.id\n      out.push(\n        <Message\n          key={m.id}\n          m={m}\n          author={person(m.authorId)}\n          compact={compact}\n          meName={me?.name}\n          inThread={inThread}\n          onReact={(e, on) => react(m.id, e, on)}\n          onReply={() => setThreadId(m.id)}\n          onRetry={() => send(m.text, inThread ? threadId ?? undefined : undefined, m.id)}\n        />\n      )\n    })\n    return out\n  }\n\n  const chooseChannel = (cid: string) => {\n    setRead((r) => ({ ...r, [channelId]: true }))\n    setChannelId(cid)\n    setThreadId(null)\n    onChannelChange?.(cid)\n  }\n\n  const channelItem = (c: Chat1Channel) => {\n    const unread = !read[c.id] && c.id !== channelId ? c.unread : undefined\n    const p = c.personId ? person(c.personId) : undefined\n    return (\n      <li key={c.id}>\n        <button\n          type=\"button\"\n          aria-current={c.id === channelId ? \"page\" : undefined}\n          onClick={() => chooseChannel(c.id)}\n          className={cn(\n            \"flex h-8 w-full items-center gap-2 rounded-md px-2 text-start text-sm outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50\",\n            c.id === channelId ? \"bg-accent font-medium\" : \"text-muted-foreground hover:bg-accent/60 hover:text-foreground\",\n            unread && \"text-foreground font-semibold\"\n          )}\n        >\n          {c.kind === \"dm\" ? (\n            <Avatar className=\"size-6\" status={p?.status}>\n              <AvatarFallback className=\"text-[10px]\">{initials(c.name)}</AvatarFallback>\n            </Avatar>\n          ) : c.private ? (\n            <Lock aria-hidden=\"true\" className=\"size-3.5 shrink-0\" />\n          ) : (\n            <Hash aria-hidden=\"true\" className=\"size-3.5 shrink-0\" />\n          )}\n          <span className=\"min-w-0 flex-1 truncate\">{c.name}</span>\n          {c.private ? <span className=\"sr-only\">(private)</span> : null}\n          {unread ? (\n            <span className=\"bg-primary text-primary-foreground rounded-full px-1.5 text-[10px] leading-4 font-semibold tabular-nums\">\n              {unread}\n              {\" \"}<span className=\"sr-only\">unread</span>\n            </span>\n          ) : null}\n        </button>\n      </li>\n    )\n  }\n\n  return (\n    <section data-slot=\"chat-1\" data-state={state} aria-label=\"Team chat\" className={cn(\"bg-card relative mx-auto flex h-[38rem] w-full max-w-6xl overflow-hidden rounded-xl border\", className)} {...frameProps(props)}>\n      <nav aria-label=\"Channels\" className=\"bg-muted/40 hidden w-56 shrink-0 flex-col gap-4 overflow-y-auto border-e p-3 md:flex\">\n        <div>\n          <h2 className=\"text-muted-foreground px-2 pb-1 text-xs font-semibold\">Channels</h2>\n          <ul className=\"grid gap-0.5\">{channels.filter((c) => c.kind !== \"dm\").map(channelItem)}</ul>\n        </div>\n        <div>\n          <h2 className=\"text-muted-foreground px-2 pb-1 text-xs font-semibold\">Direct messages</h2>\n          <ul className=\"grid gap-0.5\">{channels.filter((c) => c.kind === \"dm\").map(channelItem)}</ul>\n        </div>\n      </nav>\n\n      <div className=\"flex min-w-0 flex-1 flex-col\">\n        <header className=\"flex items-center gap-3 border-b px-4 py-2.5\">\n          <div className=\"min-w-0 flex-1\">\n            <h2 id={`${id}-channel`} className=\"flex items-center gap-1.5 text-sm font-semibold\">\n              {channel?.kind === \"dm\" ? null : channel?.private ? <Lock aria-hidden=\"true\" className=\"size-3.5\" /> : <Hash aria-hidden=\"true\" className=\"size-3.5\" />}\n              {channel?.name}\n            </h2>\n            {channel?.topic ? <p className=\"text-muted-foreground truncate text-xs\">{channel.topic}</p> : null}\n          </div>\n          <label className=\"sr-only\" htmlFor={`${id}-switch`}>\n            Channel\n          </label>\n          <select\n            id={`${id}-switch`}\n            value={channelId}\n            onChange={(e) => chooseChannel(e.target.value)}\n            className=\"border-input bg-background h-8 rounded-md border px-2 text-sm md:hidden\"\n          >\n            {channels.map((c) => (\n              <option key={c.id} value={c.id}>\n                {c.kind === \"dm\" ? c.name : `# ${c.name}`}\n              </option>\n            ))}\n          </select>\n        </header>\n\n        <StateView\n          state={state === \"ready\" && !list.length ? \"empty\" : state}\n          onRetry={onRetry}\n          bare\n          label=\"Loading messages\"\n          loading={<MessagesSkeleton />}\n          empty={{\n            title: emptyState?.title ?? (channel?.kind === \"dm\" ? `This is the start of your conversation with ${channel.name}` : `This is the start of #${channel?.name ?? \"\"}`),\n            description: emptyState?.description ?? \"Say hello. Messages you send here are visible to everyone in it.\",\n            icon: emptyState?.icon ?? <MessagesSquare />,\n            action: emptyState?.action,\n          }}\n          error={{ title: errorState?.title ?? \"Messages could not be loaded\", ...errorState }}\n          className=\"min-h-0 flex-1\"\n        >\n          <div ref={logRef} role=\"log\" aria-labelledby={`${id}-channel`} className=\"h-full overflow-y-auto py-2\">\n            {renderList(list)}\n          </div>\n        </StateView>\n\n        <div className=\"p-3 pt-1\">\n          <Composer people={people.filter((p) => p.id !== meId)} label={`Message ${channel?.kind === \"dm\" ? channel.name : `#${channel?.name ?? \"\"}`}`} placeholder={`Message ${channel?.kind === \"dm\" ? channel.name : `#${channel?.name ?? \"\"}`}`} onSend={(t) => send(t)} />\n        </div>\n      </div>\n\n      {thread ? (\n        <aside aria-label=\"Thread\" className=\"absolute inset-0 z-20 flex flex-col border-s bg-card md:static md:w-80 md:shrink-0\">\n          <div className=\"flex items-center justify-between border-b px-4 py-2.5\">\n            <h3 className=\"text-sm font-semibold\">Thread</h3>\n            <Button variant=\"ghost\" size=\"icon-sm\" aria-label=\"Close thread\" onClick={() => setThreadId(null)}>\n              <X aria-hidden=\"true\" />\n            </Button>\n          </div>\n          <div className=\"min-h-0 flex-1 overflow-y-auto py-2\">\n            <Message m={thread} author={person(thread.authorId)} compact={false} meName={me?.name} inThread onReact={(e, on) => react(thread.id, e, on)} />\n            <div role=\"separator\" aria-label=\"Replies\" className=\"text-muted-foreground flex items-center gap-3 px-4 py-2 text-xs\">\n              {thread.replies?.length === 1 ? \"1 reply\" : `${thread.replies?.length ?? 0} replies`}\n              <span className=\"bg-border h-px flex-1\" />\n            </div>\n            {renderList(thread.replies ?? [], true)}\n          </div>\n          <div className=\"p-3 pt-1\">\n            <Composer people={people.filter((p) => p.id !== meId)} label=\"Reply in thread\" placeholder=\"Reply…\" onSend={(t) => send(t, thread.id)} />\n          </div>\n        </aside>\n      ) : null}\n    </section>\n  )\n}\n\nexport { Chat1, type Chat1Props, type Chat1Channel, type Chat1Message, type Chat1Person }\n",
      "type": "registry:component",
      "target": "@components/ballmac/blocks/chat-1/chat-1.tsx"
    }
  ],
  "meta": {
    "schema": 1,
    "tier": "free",
    "tags": [
      "chat",
      "team chat",
      "messaging",
      "channels",
      "threads",
      "reactions",
      "mentions",
      "slack",
      "realtime",
      "collaboration"
    ],
    "version": "1.0.0",
    "updated": "2026-10-17",
    "tests": true,
    "ai": {
      "summary": "A Slack-style team chat. <Chat1 people={[{ id, name, status }]} meId channels={[{ id, name, kind: 'channel' | 'dm', private, topic, unread, personId }]} messages={{ [channelId]: [{ id, authorId, text, day, time, reactions, replies }] }} firstUnread={{ [channelId]: messageId }} onSend={async ({ channelId, text, threadId }) => …} onReact onChannelChange />. Messages appear straight away while sending; throw from onSend to mark one Not sent with Retry.",
      "whenToUse": [
        "Team or community messaging inside a product",
        "Customer conversations with internal threads"
      ],
      "whenNotToUse": [
        "Talking to an AI assistant (use ai-chat-1 or ai-chat-2)",
        "Email-style threads (use mail-1)"
      ],
      "composesWith": [
        "app-shell-1",
        "settings-2",
        "agent-2"
      ],
      "a11y": [
        {
          "keys": "Enter / Shift+Enter in the composer",
          "action": "Sends / adds a line"
        },
        {
          "keys": "@ then Up / Down and Enter or Tab",
          "action": "Suggests people and inserts the mention (a combobox)"
        },
        {
          "keys": "Tab into a message",
          "action": "Shows Add a reaction and Reply in thread; reactions are toggle buttons with their count"
        },
        {
          "keys": "Messages",
          "action": "A log region named after the channel, with day and New separators"
        }
      ],
      "customization": [
        "Wire onSend to your realtime backend and pass new messages back through messages",
        "Below 768 px the sidebar becomes a channel select and threads open over the conversation"
      ]
    },
    "states": [
      {
        "id": "loading",
        "title": "Loading",
        "example": "chat-1-loading",
        "trigger": "state=\"loading\"",
        "note": "Skeleton messages while the channel loads; the sidebar and composer stay usable."
      },
      {
        "id": "empty",
        "title": "New channel",
        "example": "chat-1-empty",
        "trigger": "state=\"empty\" (or a channel with no messages)",
        "note": "\"This is the start of #channel\" with a prompt to say hello; emptyState sets the copy."
      },
      {
        "id": "error",
        "title": "Could not load",
        "example": "chat-1-error",
        "trigger": "state=\"error\"",
        "note": "An alert with Try again (onRetry). A single message that fails to send shows Not sent and Retry instead."
      }
    ],
    "examples": [
      "chat-1-demo",
      "chat-1-thread",
      "chat-1-loading",
      "chat-1-empty",
      "chat-1-error"
    ],
    "url": "https://ui.ballmac.com/components/chat-1"
  },
  "categories": [
    "blocks",
    "chat"
  ],
  "type": "registry:block"
}