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.
The normal view. Pick a state to see what the block shows while it loads, is empty or fails.
// 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 }Installation
$ pnpm dlx shadcn@latest add @ballmac/chat-1Install the dependencies.
$ pnpm add lucide-reactAdd the Ballmac items it builds on.
$ pnpm dlx shadcn@latest add @ballmac/avatar @ballmac/button @ballmac/skeleton @ballmac/state-viewCopy the source into your project.
components/ballmac/blocks/chat-1/chat-1.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 }Update the import paths to match your project setup.
Usage
"use client"
import { Chat1 } from "@/components/ballmac/blocks/chat-1/chat-1"
export default function Chat1Demo() {
return <Chat1 onSend={() => {}} />
}States
Pass state and the block does the rest: skeletons that match its layout, a clear empty message, an error you can retry, and announcements for screen readers.
Loading
Skeleton messages while the channel loads; the sidebar and composer stay usable.
state="loading"New channel
"This is the start of #channel" with a prompt to say hello; emptyState sets the copy.
state="empty" (or a channel with no messages)Could not load
An alert with Try again (onRetry). A single message that fails to send shows Not sent and Retry instead.
state="error"
Quality scorecard
Every applicable check passes
Measured in Chromium 153.0.8010.12 on October 19, 2026, on version 1.0.0, across 5 examples.
Passed: Accessibility scan
No serious or critical issues in light and dark, on a phone and a desktop (axe-core 4.13.0, WCAG 2.1 AA and best-practice rules).
Passed: Small screens
No sideways scrolling at 360, 390 or 1280 px wide.
Passed: Reduced motion
Every endless animation stops when the visitor asks for less motion.
Passed: Right to left
Mirrors correctly in Arabic, Hebrew, Persian and Urdu layouts (1% of elements off the mirror, limit 15%).
Passed: Clean console
No console errors or React hydration errors while loading.
Passed: Keyboard
41 tab stops, each showing a visible change when it takes focus. 4 documented keys.
Passed: Loading, empty and error
Ships loading, empty, error states, each with an example.
Passed: Automated tests
Covered by chat-1.test.tsx.
For information: Footprint
7.5 KB of source (gzip) in 1 file. Installs lucide-react.
Automated checks find a subset of problems and do not replace testing with a screen reader and a keyboard in your own app. What they miss.
Dependencies
- npm
- lucide-react
Use with AI
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. With the shadcn MCP server set up (guide), ask your agent:
Add the Ballmac UI Chat 1: team chat with threads (@ballmac/chat-1) to this project with the shadcn MCP, then use it where it fits.
Use it for
- Team or community messaging inside a product
- Customer conversations with internal threads
Not for
- Talking to an AI assistant (use ai-chat-1 or ai-chat-2)
- Email-style threads (use mail-1)
Registry JSON: https://ui.ballmac.com/r/chat-1.json
Free to use in personal and commercial projects.
Ballmac UI Pro
Need the whole screen, not one piece?
150 premium blocks (dashboards, settings, calendars, mail, shops, onboarding), a private shadcn registry and Figma tokens. Blocks that show data come with loading, empty and error states built in.
Not ready? Try three Pro blocks free
Leave your email and the blocks appear right here, ready to copy. Yours to use in any project.