# Chat 1: team chat with threads

> 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.

A shadcn/ui block from the [@ballmac](https://ui.ballmac.com/?utm_source=shadcn.exchange) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/ballmac/chat-1).

- Page, with a live preview: https://shadcn.exchange/r/ballmac/chat-1
- Screenshot: https://abzjhwgwpdpz7ua5.public.blob.vercel-storage.com/shots/ballmac/chat-1-light-86397783cbb6d308.png
- Registry item: https://ui.ballmac.com/r/chat-1.json
- Categories: blocks, chat
- Listed: 2026-10-18, source last changed 2026-10-18

## Install

```bash
npx shadcn@latest add https://shadcn.exchange/r/ballmac/chat-1.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- npm packages: `lucide-react`
- Registry items: `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

### `registry/ballmac/components/blocks/chat-1/chat-1.tsx`

```tsx
// Ballmac UI: Chat 1. https://ui.ballmac.com/blocks/chat-1
"use client"

import * as React from "react"
import { Hash, Lock, MessageSquare, MessagesSquare, SendHorizontal, SmilePlus, X } from "lucide-react"

import { Avatar, AvatarFallback } from "@/components/ballmac/avatar"
import { Button } from "@/components/ballmac/button"
import { Skeleton } from "@/components/ballmac/skeleton"
import { frameProps, StateView, type StateProps } from "@/components/ballmac/state-view"
import { cn } from "@/lib/utils"

type Chat1Person = { id: string; name: string; status?: "online" | "away" | "offline" }

type Chat1Channel = {
  id: string
  name: string
  kind?: "channel" | "dm"
  private?: boolean
  topic?: string
  unread?: number
  /** For a direct message: the other person's id. */
  personId?: string
}

type Chat1Message = {
  id: string
  authorId: string
  text: string
  /** The group heading, already written for people ("Today"). */
  day: string
  /** Already written for people ("09:41"). */
  time: string
  reactions?: { emoji: string; count: number; mine?: boolean }[]
  /** Replies in the thread, oldest first. */
  replies?: Chat1Message[]
  /** Set while a message you sent is on its way, or when it failed. */
  delivery?: "sending" | "failed"
}

type Chat1Props = Omit<React.ComponentProps<"section">, "title"> &
  StateProps & {
    people?: Chat1Person[]
    /** The person using the app. */
    meId?: string
    channels?: Chat1Channel[]
    /** Messages by channel id, oldest first. */
    messages?: Record<string, Chat1Message[]>
    defaultChannelId?: string
    /** A message whose thread is open at first. */
    defaultThreadId?: string
    /** Message id before which a "New" divider is drawn, by channel id. */
    firstUnread?: Record<string, string>
    /** 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. */
    onSend?: (message: { channelId: string; text: string; threadId?: string }) => void | Promise<void>
    /** Called when a reaction is added or removed. */
    onReact?: (reaction: { channelId: string; messageId: string; emoji: string; on: boolean }) => void
    /** Called when the channel changes, to load its messages. */
    onChannelChange?: (channelId: string) => void
  }

const quick = ["👍", "🎉", "❤️", "👀", "✅", "😄"]

const samplePeople: Chat1Person[] = [
  { id: "me", name: "Jordan Lee", status: "online" },
  { id: "priya", name: "Priya Shah", status: "online" },
  { id: "alex", name: "Alex Kim", status: "away" },
  { id: "sam", name: "Sam Okafor", status: "offline" },
]

const sampleChannels: Chat1Channel[] = [
  { id: "launch", name: "launch", topic: "Everything for the 2.5 release on Thursday", unread: 3 },
  { id: "design", name: "design", topic: "Reviews, critiques and work in progress" },
  { id: "leads", name: "leads", private: true, topic: "Team leads only" },
  { id: "dm-priya", name: "Priya Shah", kind: "dm", personId: "priya", unread: 1 },
  { id: "dm-alex", name: "Alex Kim", kind: "dm", personId: "alex" },
]

const sampleMessages: Record<string, Chat1Message[]> = {
  launch: [
    { 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." },
    { id: "m2", authorId: "alex", day: "Yesterday", time: "16:03", text: "Screenshots still need the new sidebar.", reactions: [{ emoji: "👀", count: 1 }] },
    {
      id: "m3",
      authorId: "priya",
      day: "Today",
      time: "09:12",
      text: "The migration ran on staging in 4 minutes with no errors. I think we’re clear for Thursday.",
      reactions: [{ emoji: "🎉", count: 3, mine: true }, { emoji: "✅", count: 2 }],
      replies: [
        { id: "r1", authorId: "me", day: "Today", time: "09:20", text: "Great. Can you post the timing on the release checklist?" },
        { id: "r2", authorId: "priya", day: "Today", time: "09:24", text: "Done, it’s under step 4." },
      ],
    },
    { id: "m4", authorId: "sam", day: "Today", time: "09:40", text: "Support macros are updated for the new export flow." },
    { id: "m5", authorId: "priya", day: "Today", time: "09:41", text: "@Jordan Lee can you approve the announcement email before noon?" },
  ],
  design: [],
  leads: [{ id: "l1", authorId: "priya", day: "Monday", time: "11:00", text: "Hiring plan for Q1 is in the shared folder." }],
  "dm-priya": [{ id: "d1", authorId: "priya", day: "Today", time: "08:55", text: "Got a minute after standup?" }],
  "dm-alex": [],
}

const initials = (name: string) => name.split(" ").map((w) => w[0]).join("").slice(0, 2)

function MessagesSkeleton() {
  return (
    <div className="grid gap-5 p-4" aria-hidden="true">
      {[0, 1, 2].map((i) => (
        <div key={i} className="flex gap-3">
          <Skeleton className="size-8 rounded-full" />
          <div className="grid flex-1 gap-2">
            <Skeleton className="h-3 w-32" />
            <Skeleton className="h-3.5" style={{ width: `${80 - i * 15}%` }} />
          </div>
        </div>
      ))}
    </div>
  )
}

/** The message box: Enter sends, Shift+Enter adds a line, and typing @ suggests people to mention. */
function Composer({ people, placeholder, onSend, label }: { people: Chat1Person[]; placeholder: string; onSend: (text: string) => void; label: string }) {
  const id = React.useId()
  const [text, setText] = React.useState("")
  const [active, setActive] = React.useState(0)
  const mention = /(?:^|\s)@([\w]*)$/.exec(text)
  const matches = mention ? people.filter((p) => p.name.toLowerCase().startsWith(mention[1]!.toLowerCase())).slice(0, 5) : []
  const open = matches.length > 0
  const insert = (p: Chat1Person) => {
    setText((t) => t.replace(/@([\w]*)$/, `@${p.name} `))
    setActive(0)
  }
  const send = () => {
    const value = text.trim()
    if (!value) return
    onSend(value)
    setText("")
  }
  return (
    <div className="relative">
      {open ? (
        <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">
          {matches.map((p, i) => (
            <li
              key={p.id}
              id={`${id}-p-${p.id}`}
              role="option"
              aria-selected={i === active}
              onMouseDown={(e) => {
                e.preventDefault()
                insert(p)
              }}
              className={cn("flex cursor-default items-center gap-2 rounded-md px-2 py-1.5 text-sm", i === active && "bg-accent")}
            >
              <Avatar className="size-5">
                <AvatarFallback className="text-[9px]">{initials(p.name)}</AvatarFallback>
              </Avatar>
              {p.name}
            </li>
          ))}
        </ul>
      ) : null}
      <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]">
        <textarea
          rows={1}
          value={text}
          onChange={(e) => setText(e.target.value)}
          aria-label={label}
          role={open ? "combobox" : undefined}
          aria-expanded={open ? true : undefined}
          aria-controls={open ? `${id}-people` : undefined}
          aria-activedescendant={open ? `${id}-p-${matches[active]?.id}` : undefined}
          aria-describedby={`${id}-hint`}
          placeholder={placeholder}
          onKeyDown={(e) => {
            if (open) {
              if (e.key === "ArrowDown" || e.key === "ArrowUp") {
                e.preventDefault()
                setActive((a) => (a + (e.key === "ArrowDown" ? 1 : -1) + matches.length) % matches.length)
                return
              }
              if (e.key === "Enter" || e.key === "Tab") {
                e.preventDefault()
                insert(matches[active] ?? matches[0]!)
                return
              }
            }
            if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing) {
              e.preventDefault()
              send()
            }
          }}
          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]"
        />
        <Button size="icon-sm" aria-label="Send" onClick={send} disabled={!text.trim()}>
          <SendHorizontal aria-hidden="true" className="rtl:-scale-x-100" />
        </Button>
      </div>
      <p id={`${id}-hint`} className="sr-only">
        Enter sends, Shift and Enter adds a line. Type @ to mention someone.
      </p>
    </div>
  )
}

function Reactions({ m, onToggle }: { m: Chat1Message; onToggle: (emoji: string) => void }) {
  if (!m.reactions?.length) return null
  return (
    <div className="mt-1.5 flex flex-wrap items-center gap-1">
      {m.reactions.map((r) => (
        <button
          key={r.emoji}
          type="button"
          aria-pressed={!!r.mine}
          aria-label={`${r.emoji} ${r.count}${r.mine ? ", including you" : ""}`}
          onClick={() => onToggle(r.emoji)}
          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")}
        >
          <span aria-hidden="true">{r.emoji}</span>
          <span aria-hidden="true">{r.count}</span>
        </button>
      ))}
    </div>
  )
}

/** One message, or a run of messages from the same person within the same day when `compact`. */
function Message({
  m,
  author,
  compact,
  meName,
  onReact,
  onReply,
  onRetry,
  inThread,
}: {
  m: Chat1Message
  author?: Chat1Person
  compact: boolean
  meName?: string
  onReact: (emoji: string, on: boolean) => void
  onReply?: () => void
  onRetry?: () => void
  inThread?: boolean
}) {
  const [picking, setPicking] = React.useState(false)
  const name = author?.name ?? "Someone"
  // mentions of the reader stand out, as they do in every team chat
  const parts = m.text.split(/(@[A-Z][\w]*(?: [A-Z][\w]*)?)/g)
  return (
    <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")}>
      <div className="w-8 shrink-0">
        {compact ? (
          <span className="text-muted-foreground hidden pt-0.5 text-[10px] tabular-nums group-hover/msg:block">{m.time}</span>
        ) : (
          <Avatar className="size-8" status={author?.status}>
            <AvatarFallback className="text-xs">{initials(name)}</AvatarFallback>
          </Avatar>
        )}
      </div>
      <div className="min-w-0 flex-1">
        {!compact ? (
          <p className="flex items-baseline gap-2">
            <span className="text-sm font-semibold">{name}</span>
            <span className="text-muted-foreground text-xs tabular-nums">{m.time}</span>
          </p>
        ) : null}
        <p className={cn("text-sm leading-relaxed break-words whitespace-pre-wrap", m.delivery === "sending" && "text-muted-foreground")}>
          {parts.map((p, i) =>
            p.startsWith("@") ? (
              <span key={i} className={cn("rounded px-0.5 font-medium", p.slice(1) === meName ? "bg-chart-3/25" : "text-primary")}>
                {p}
              </span>
            ) : (
              p
            )
          )}
        </p>
        {m.delivery ? (
          <p className={cn("mt-0.5 text-xs", m.delivery === "failed" ? "text-destructive" : "text-muted-foreground")} role={m.delivery === "failed" ? "alert" : undefined}>
            {m.delivery === "sending" ? "Sending…" : "Not sent."}{" "}
            {m.delivery === "failed" && onRetry ? (
              <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">
                Retry
              </button>
            ) : null}
          </p>
        ) : null}
        <Reactions m={m} onToggle={(e) => onReact(e, !m.reactions?.find((r) => r.emoji === e)?.mine)} />
        {picking ? (
          <span role="group" aria-label="Add a reaction" className="mt-1 flex w-fit gap-0.5 rounded-full border px-1">
            {quick.map((e) => (
              <button
                key={e}
                type="button"
                aria-label={`React with ${e}`}
                onClick={() => {
                  setPicking(false)
                  onReact(e, true)
                }}
                className="hover:bg-accent size-7 rounded-full text-sm outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50"
              >
                {e}
              </button>
            ))}
          </span>
        ) : null}
        {!inThread && m.replies?.length ? (
          <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">
            <MessageSquare aria-hidden="true" className="size-3.5" />
            {m.replies.length === 1 ? "1 reply" : `${m.replies.length} replies`}
          </button>
        ) : null}
      </div>
      {!m.delivery ? (
        <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">
          <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)}>
            <SmilePlus aria-hidden="true" />
          </Button>
          {!inThread && onReply ? (
            <Button variant="ghost" size="icon-sm" className="size-7" aria-label={`Reply in thread to ${name}`} onClick={onReply}>
              <MessageSquare aria-hidden="true" />
            </Button>
          ) : null}
        </div>
      ) : null}
    </article>
  )
}

/**
 * A team chat: channels and direct messages with unread counts and presence, messages grouped by person and day with a New divider,
 * reactions, threads in a side panel, @mentions that suggest people, and messages that show while sending and offer Retry when they fail.
 */
function Chat1({
  people = samplePeople,
  meId = "me",
  channels = sampleChannels,
  messages: messagesProp = sampleMessages,
  defaultChannelId,
  defaultThreadId,
  firstUnread = { launch: "m4" },
  onSend,
  onReact,
  onChannelChange,
  state = "ready",
  onRetry,
  emptyState,
  errorState,
  className,
  ...props
}: Chat1Props) {
  const id = React.useId()
  const [channelId, setChannelId] = React.useState(defaultChannelId ?? channels[0]?.id ?? "")
  const [store, setStore] = React.useState(messagesProp)
  React.useEffect(() => setStore(messagesProp), [messagesProp])
  const [read, setRead] = React.useState<Record<string, boolean>>({})
  const [threadId, setThreadId] = React.useState<string | null>(defaultThreadId ?? null)
  const channel = channels.find((c) => c.id === channelId)
  const list = store[channelId] ?? []
  const thread = threadId ? list.find((m) => m.id === threadId) : undefined
  const person = (pid: string) => people.find((p) => p.id === pid)
  const me = person(meId)
  const logRef = React.useRef<HTMLDivElement>(null)
  const localId = React.useRef(0)

  React.useEffect(() => {
    const el = logRef.current
    if (el) el.scrollTop = el.scrollHeight
  }, [channelId, list.length, state])

  const update = (fn: (msgs: Chat1Message[]) => Chat1Message[], cid = channelId) => setStore((s) => ({ ...s, [cid]: fn(s[cid] ?? []) }))

  function mapMessage(msgs: Chat1Message[], msgId: string, fn: (m: Chat1Message) => Chat1Message): Chat1Message[] {
    return msgs.map((m) => (m.id === msgId ? fn(m) : m.replies ? { ...m, replies: mapMessage(m.replies, msgId, fn) } : m))
  }

  async function send(text: string, inThread?: string, retryId?: string) {
    const cid = channelId
    const mid = retryId ?? `local-${++localId.current}`
    const draft: Chat1Message = { id: mid, authorId: meId, text, day: "Today", time: "Now", delivery: "sending" }
    if (retryId) update((msgs) => mapMessage(msgs, mid, (m) => ({ ...m, delivery: "sending" })), cid)
    else if (inThread) update((msgs) => msgs.map((m) => (m.id === inThread ? { ...m, replies: [...(m.replies ?? []), draft] } : m)), cid)
    else update((msgs) => [...msgs, draft], cid)
    try {
      if (onSend) await onSend({ channelId: cid, text, threadId: inThread })
      else await new Promise((r) => setTimeout(r, 500))
      update((msgs) => mapMessage(msgs, mid, ({ delivery: _, ...m }) => m), cid)
    } catch {
      update((msgs) => mapMessage(msgs, mid, (m) => ({ ...m, delivery: "failed" })), cid)
    }
  }

  function react(messageId: string, emoji: string, on: boolean) {
    update((msgs) =>
      mapMessage(msgs, messageId, (m) => {
        const existing = m.reactions?.find((r) => r.emoji === emoji)
        let reactions = m.reactions ?? []
        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)
        else if (on) reactions = [...reactions, { emoji, count: 1, mine: true }]
        return { ...m, reactions }
      })
    )
    onReact?.({ channelId, messageId, emoji, on })
  }

  const renderList = (msgs: Chat1Message[], inThread?: boolean) => {
    const out: React.ReactNode[] = []
    msgs.forEach((m, i) => {
      const prev = msgs[i - 1]
      if (!inThread && m.day !== prev?.day)
        out.push(
          <div key={`day-${m.day}-${m.id}`} role="separator" aria-label={m.day} className="flex items-center gap-3 px-3 py-2">
            <span className="bg-border h-px flex-1" />
            <span className="text-muted-foreground text-xs font-medium">{m.day}</span>
            <span className="bg-border h-px flex-1" />
          </div>
        )
      if (!inThread && firstUnread[channelId] === m.id && !read[channelId])
        out.push(
          <div key={`new-${m.id}`} role="separator" aria-label="New messages" className="flex items-center gap-3 px-3 py-1">
            <span className="bg-destructive/50 h-px flex-1" />
            <span className="text-destructive text-xs font-semibold">New</span>
          </div>
        )
      const compact = !!prev && prev.authorId === m.authorId && prev.day === m.day && firstUnread[channelId] !== m.id
      out.push(
        <Message
          key={m.id}
          m={m}
          author={person(m.authorId)}
          compact={compact}
          meName={me?.name}
          inThread={inThread}
          onReact={(e, on) => react(m.id, e, on)}
          onReply={() => setThreadId(m.id)}
          onRetry={() => send(m.text, inThread ? threadId ?? undefined : undefined, m.id)}
        />
      )
    })
    return out
  }

  const chooseChannel = (cid: string) => {
    setRead((r) => ({ ...r, [channelId]: true }))
    setChannelId(cid)
    setThreadId(null)
    onChannelChange?.(cid)
  }

  const channelItem = (c: Chat1Channel) => {
    const unread = !read[c.id] && c.id !== channelId ? c.unread : undefined
    const p = c.personId ? person(c.personId) : undefined
    return (
      <li key={c.id}>
        <button
          type="button"
          aria-current={c.id === channelId ? "page" : undefined}
          onClick={() => chooseChannel(c.id)}
          className={cn(
            "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",
            c.id === channelId ? "bg-accent font-medium" : "text-muted-foreground hover:bg-accent/60 hover:text-foreground",
            unread && "text-foreground font-semibold"
          )}
        >
          {c.kind === "dm" ? (
            <Avatar className="size-6" status={p?.status}>
              <AvatarFallback className="text-[10px]">{initials(c.name)}</AvatarFallback>
            </Avatar>
          ) : c.private ? (
            <Lock aria-hidden="true" className="size-3.5 shrink-0" />
          ) : (
            <Hash aria-hidden="true" className="size-3.5 shrink-0" />
          )}
          <span className="min-w-0 flex-1 truncate">{c.name}</span>
          {c.private ? <span className="sr-only">(private)</span> : null}
          {unread ? (
            <span className="bg-primary text-primary-foreground rounded-full px-1.5 text-[10px] leading-4 font-semibold tabular-nums">
              {unread}
              {" "}<span className="sr-only">unread</span>
            </span>
          ) : null}
        </button>
      </li>
    )
  }

  return (
    <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)}>
      <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">
        <div>
          <h2 className="text-muted-foreground px-2 pb-1 text-xs font-semibold">Channels</h2>
          <ul className="grid gap-0.5">{channels.filter((c) => c.kind !== "dm").map(channelItem)}</ul>
        </div>
        <div>
          <h2 className="text-muted-foreground px-2 pb-1 text-xs font-semibold">Direct messages</h2>
          <ul className="grid gap-0.5">{channels.filter((c) => c.kind === "dm").map(channelItem)}</ul>
        </div>
      </nav>

      <div className="flex min-w-0 flex-1 flex-col">
        <header className="flex items-center gap-3 border-b px-4 py-2.5">
          <div className="min-w-0 flex-1">
            <h2 id={`${id}-channel`} className="flex items-center gap-1.5 text-sm font-semibold">
              {channel?.kind === "dm" ? null : channel?.private ? <Lock aria-hidden="true" className="size-3.5" /> : <Hash aria-hidden="true" className="size-3.5" />}
              {channel?.name}
            </h2>
            {channel?.topic ? <p className="text-muted-foreground truncate text-xs">{channel.topic}</p> : null}
          </div>
          <label className="sr-only" htmlFor={`${id}-switch`}>
            Channel
          </label>
          <select
            id={`${id}-switch`}
            value={channelId}
            onChange={(e) => chooseChannel(e.target.value)}
            className="border-input bg-background h-8 rounded-md border px-2 text-sm md:hidden"
          >
            {channels.map((c) => (
              <option key={c.id} value={c.id}>
                {c.kind === "dm" ? c.name : `# ${c.name}`}
              </option>
            ))}
          </select>
        </header>

        <StateView
          state={state === "ready" && !list.length ? "empty" : state}
          onRetry={onRetry}
          bare
          label="Loading messages"
          loading={<MessagesSkeleton />}
          empty={{
            title: emptyState?.title ?? (channel?.kind === "dm" ? `This is the start of your conversation with ${channel.name}` : `This is the start of #${channel?.name ?? ""}`),
            description: emptyState?.description ?? "Say hello. Messages you send here are visible to everyone in it.",
            icon: emptyState?.icon ?? <MessagesSquare />,
            action: emptyState?.action,
          }}
          error={{ title: errorState?.title ?? "Messages could not be loaded", ...errorState }}
          className="min-h-0 flex-1"
        >
          <div ref={logRef} role="log" aria-labelledby={`${id}-channel`} className="h-full overflow-y-auto py-2">
            {renderList(list)}
          </div>
        </StateView>

        <div className="p-3 pt-1">
          <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)} />
        </div>
      </div>

      {thread ? (
        <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">
          <div className="flex items-center justify-between border-b px-4 py-2.5">
            <h3 className="text-sm font-semibold">Thread</h3>
            <Button variant="ghost" size="icon-sm" aria-label="Close thread" onClick={() => setThreadId(null)}>
              <X aria-hidden="true" />
            </Button>
          </div>
          <div className="min-h-0 flex-1 overflow-y-auto py-2">
            <Message m={thread} author={person(thread.authorId)} compact={false} meName={me?.name} inThread onReact={(e, on) => react(thread.id, e, on)} />
            <div role="separator" aria-label="Replies" className="text-muted-foreground flex items-center gap-3 px-4 py-2 text-xs">
              {thread.replies?.length === 1 ? "1 reply" : `${thread.replies?.length ?? 0} replies`}
              <span className="bg-border h-px flex-1" />
            </div>
            {renderList(thread.replies ?? [], true)}
          </div>
          <div className="p-3 pt-1">
            <Composer people={people.filter((p) => p.id !== meId)} label="Reply in thread" placeholder="Reply…" onSend={(t) => send(t, thread.id)} />
          </div>
        </aside>
      ) : null}
    </section>
  )
}

export { Chat1, type Chat1Props, type Chat1Channel, type Chat1Message, type Chat1Person }
```
