Chat bubbles
A conversation as bubbles: who said what, when, and whether it was read.
Thread with read receipts and times
chat-bubbles-01A direct-message thread where every line says when it was written, in UTC, and each of yours how far it got — sent, delivered or read — under the day it was written on.
import * as React from "react"
import { CheckCheckIcon, CheckIcon, MessagesSquareIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import { getInitials } from "@/lib/format"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Bubble, BubbleContent } from "@/components/ui/bubble"
import { Card, CardAction, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"
import { EmptyState } from "@/components/ui/empty-state"
import { Message, MessageContent, MessageFooter } from "@/components/ui/message"
import {
MessageScroller,
MessageScrollerButton,
MessageScrollerContent,
MessageScrollerItem,
MessageScrollerProvider,
MessageScrollerViewport,
} from "@/components/ui/message-scroller"
import { waitingThread, type ThreadLine } from "./chat-bubbles-01.data"
/** What a receipt says: how far one of the member's own lines got. */
const RECEIPT: Record<ThreadLine["status"], string> = { sent: "Sent", delivered: "Delivered", read: "Read" }
/** One tick for sent, two for delivered, two in the info tone for read; the word travels with it. */
function Receipt({ status, spoken }: { status: ThreadLine["status"]; spoken: boolean }) {
const Icon = status === "sent" ? CheckIcon : CheckCheckIcon
return (
<span data-slot="receipt" data-status={status} className="inline-flex items-center gap-1">
<Icon aria-hidden="true" className={cn("size-3.5 shrink-0", status === "read" ? "text-info" : "text-muted-foreground")} />
<span className={spoken ? undefined : "sr-only"}>{RECEIPT[status]}</span>
</span>
)
}
/**
* A direct-message thread with its receipts and its times: every line says
* when it was written, in UTC, and each of the signed-in member's own says how
* far it got — one tick sent, two delivered, two in the info tone read, with
* the word beside the last one and read out for all of them. The lines sit
* under the day they were written on, and the thread is a named log that
* opens at its newest line.
*
* A server component: it reads the thread through `./chat-bubbles-01.data`
* and draws it with the kit's Message, Bubble and MessageScroller. With no
* direct message in the store, the card says so.
*/
export function ChatBubbles01() {
const titleId = React.useId()
const thread = waitingThread()
if (!thread)
return (
<section
data-section="chat-bubbles-01"
data-slot="section"
aria-labelledby={titleId}
className="w-full bg-background p-4 text-foreground md:p-6"
>
<div className="mx-auto max-w-7xl">
<Card>
<CardHeader>
<CardTitle>
<h2 id={titleId}>Direct message</h2>
</CardTitle>
</CardHeader>
<CardContent>
<EmptyState
icon={<MessagesSquareIcon aria-hidden="true" />}
title="No conversation yet"
description="A direct message shows here, with how far each of your lines got, once one starts."
/>
</CardContent>
</Card>
</div>
</section>
)
const lastMine = [...thread.lines].reverse().find((line) => line.fromMe)?.id
const days = [...new Set(thread.lines.map((line) => line.day))]
return (
<section
data-section="chat-bubbles-01"
data-slot="section"
aria-labelledby={titleId}
className="w-full bg-background p-4 text-foreground md:p-6"
>
<div className="mx-auto max-w-7xl">
<Card>
<CardHeader>
<CardTitle>
<h2 id={titleId}>{thread.with.name}</h2>
</CardTitle>
<CardDescription>Direct message · your lines show how far they got · times in UTC</CardDescription>
<CardAction aria-hidden="true">
<Avatar>
<AvatarImage src={thread.with.face} alt="" />
<AvatarFallback className="bg-foreground text-xs text-background">{getInitials(thread.with.name)}</AvatarFallback>
</Avatar>
</CardAction>
</CardHeader>
<CardContent className="h-112 flex-none p-0">
<MessageScrollerProvider defaultScrollPosition="end">
<MessageScroller>
<MessageScrollerViewport aria-label={`Scrollable thread with ${thread.with.name}`} className="px-(--card-spacing)">
<MessageScrollerContent aria-label={`Messages with ${thread.with.name}`} className="gap-3 py-(--card-spacing)">
{days.map((day) => (
<React.Fragment key={day}>
<div role="separator" aria-label={thread.days[day]} className="flex items-center gap-3 py-1 text-xs text-muted-foreground">
<span aria-hidden="true" className="h-px flex-1 bg-border" />
{thread.days[day]}
<span aria-hidden="true" className="h-px flex-1 bg-border" />
</div>
{thread.lines
.filter((line) => line.day === day)
.map((line) => (
<MessageScrollerItem key={line.id} messageId={line.id}>
<Message align={line.fromMe ? "end" : "start"}>
<MessageContent className="gap-1">
<Bubble variant={line.fromMe ? "default" : "muted"} align={line.fromMe ? "end" : "start"}>
<BubbleContent>
<span className="sr-only">{line.fromMe ? "You" : line.author}: </span>
{line.text}
</BubbleContent>
</Bubble>
<MessageFooter className="gap-1.5 font-normal tabular-nums">
<time dateTime={line.at}>{line.time}</time>
{line.fromMe ? <Receipt status={line.status} spoken={line.id === lastMine} /> : null}
</MessageFooter>
</MessageContent>
</Message>
</MessageScrollerItem>
))}
</React.Fragment>
))}
</MessageScrollerContent>
</MessageScrollerViewport>
<MessageScrollerButton />
</MessageScroller>
</MessageScrollerProvider>
</CardContent>
<CardFooter className="flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground">
<span className="inline-flex items-center gap-1">
<CheckIcon aria-hidden="true" className="size-3.5" /> Sent
</span>
<span className="inline-flex items-center gap-1">
<CheckCheckIcon aria-hidden="true" className="size-3.5" /> Delivered
</span>
<span className="inline-flex items-center gap-1">
<CheckCheckIcon aria-hidden="true" className="size-3.5 text-info" /> Read
</span>
</CardFooter>
</Card>
</div>
</section>
)
}
export default ChatBubbles01/**
* What `chat-bubbles-01` reads: one direct-message thread from `db.chats` and
* `db.messages` — the one where the signed-in member's last word has been
* delivered and not yet read, the most recent first — with who wrote each
* line, when, and how far each of the member's own got.
*
* Times and days are written here, on the server, in UTC, and "today" is
* `REFERENCE_DATE`'s day, so the thread reads the same for every reader. The
* island takes the rows as props and never imports this module.
*/
import { avatarFor } from "@/lib/avatars"
import { MEMBERS, REFERENCE_DATE, db, type Chat, type Message } from "@/lib/sample-data"
const DAY_MS = 86_400_000
/** How many of the latest lines the thread shows. */
export const SHOWN = 12
const CLOCK = new Intl.DateTimeFormat("en-US", { hour: "numeric", minute: "2-digit", timeZone: "UTC" })
const LONG_DAY = new Intl.DateTimeFormat("en-US", { weekday: "long", month: "long", day: "numeric", timeZone: "UTC" })
export type ThreadLine = {
id: string
fromMe: boolean
author: string
text: string
/** ISO instant, for `<time dateTime>`. */
at: string
/** "9:44 AM", in UTC. */
time: string
/** `yyyy-mm-dd`, in UTC: the day divider it sits under. */
day: string
status: Message["status"]
}
export type Thread = {
id: string
/** The other member. */
with: { name: string; face: string }
lines: ThreadLine[]
/** What each day divider reads: Today, Yesterday, or the date. */
days: Record<string, string>
}
const midnight = (at: Date) => Date.UTC(at.getUTCFullYear(), at.getUTCMonth(), at.getUTCDate())
/** "Today", "Yesterday", or "Wednesday, September 2", counted back from `REFERENCE_DATE`. */
function dayLabel(day: number): string {
const back = (midnight(REFERENCE_DATE) - day) / DAY_MS
if (back === 0) return "Today"
if (back === 1) return "Yesterday"
return LONG_DAY.format(new Date(day))
}
function threadOf(chat: Chat): Message[] {
return db.messages
.all()
.filter((message) => message.chatId === chat.id)
.sort((a, b) => a.at.getTime() - b.at.getTime())
}
/**
* The direct message the member is waiting on: their last line delivered, not
* yet read — else the latest direct message. Null when the store has no
* direct message with a line in it: the section then says so rather than
* failing.
*/
export function waitingThread(): Thread | null {
const me = MEMBERS[0].id
const direct = db.chats
.all()
.filter((chat) => chat.kind === "dm")
.map((chat) => ({ chat, messages: threadOf(chat) }))
.filter(({ messages }) => messages.length > 0)
.sort((a, b) => b.chat.lastMessageAt.getTime() - a.chat.lastMessageAt.getTime())
const waiting = direct.find(({ messages }) => messages.at(-1)!.fromId === me && messages.at(-1)!.status !== "read") ?? direct[0]
if (!waiting) return null
const other = MEMBERS.find((member) => waiting.chat.memberIds.includes(member.id) && member.id !== me)
const name = (id: string) => MEMBERS.find((member) => member.id === id)?.name ?? "Former member"
const lines = waiting.messages.slice(-SHOWN).map((message) => ({
id: message.id,
fromMe: message.fromId === me,
author: name(message.fromId),
text: message.text,
at: message.at.toISOString(),
time: CLOCK.format(message.at),
day: new Date(midnight(message.at)).toISOString().slice(0, 10),
status: message.status,
}))
const days = Object.fromEntries([...new Set(lines.map((line) => line.day))].map((day) => [day, dayLabel(Date.parse(`${day}T00:00:00.000Z`))]))
return { id: waiting.chat.id, with: { name: other?.name ?? waiting.chat.name, face: avatarFor(other?.name ?? waiting.chat.name) }, lines, days }
}npx shadcn@latest add @vibra/chat-bubbles-01Reply that quotes the question and carries a file
chat-bubbles-02An exchange from a direct message: a question, and the reply that quotes it at its top — a way back to it — and carries a file, named with its kind and size.
import * as React from "react"
import { CheckCheckIcon, CheckIcon, FileSpreadsheetIcon, FileTextIcon, MessagesSquareIcon, PresentationIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import { Attachment, AttachmentContent, AttachmentDescription, AttachmentMedia, AttachmentTitle } from "@/components/ui/attachment"
import { Bubble, BubbleContent } from "@/components/ui/bubble"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { EmptyState } from "@/components/ui/empty-state"
import { Message, MessageContent, MessageFooter, MessageHeader } from "@/components/ui/message"
import {
MessageScroller,
MessageScrollerContent,
MessageScrollerItem,
MessageScrollerProvider,
MessageScrollerViewport,
} from "@/components/ui/message-scroller"
import { ReplyQuote } from "./chat-bubbles-02.client"
import { answeredWithAFile, type ExchangeLine, type SentFile } from "./chat-bubbles-02.data"
const RECEIPT: Record<ExchangeLine["status"], string> = { sent: "Sent", delivered: "Delivered", read: "Read" }
/** The icon a sent file wears, by what it is. */
function FileIcon({ kind }: { kind: SentFile["kind"] }) {
const Icon = kind === "Slides" ? PresentationIcon : kind === "Spreadsheet" ? FileSpreadsheetIcon : FileTextIcon
return <Icon aria-hidden="true" />
}
/**
* An exchange from a direct message: a question, and the reply that answers
* it with a file. The reply quotes the question at its top — the quote is a
* way back to it — and carries the file under the words, named with its kind
* and size. Each line says who wrote it and when, in UTC, and each of the
* member's own how far it got.
*
* A server component: it reads the exchange through `./chat-bubbles-02.data`
* and draws it with the kit's Message, Bubble, Attachment and MessageScroller;
* the quote's jump back is the island. When no question was answered with a
* file, the card says so.
*/
export function ChatBubbles02() {
const titleId = React.useId()
const exchange = answeredWithAFile()
if (!exchange)
return (
<section
data-section="chat-bubbles-02"
data-slot="section"
aria-labelledby={titleId}
className="w-full bg-background p-4 text-foreground md:p-6"
>
<div className="mx-auto max-w-7xl">
<Card>
<CardHeader>
<CardTitle>
<h2 id={titleId}>A question, answered with a file</h2>
</CardTitle>
</CardHeader>
<CardContent>
<EmptyState
icon={<MessagesSquareIcon aria-hidden="true" />}
title="No file sent in answer yet"
description="When someone asks for a document in a direct message and gets it, the exchange shows here."
/>
</CardContent>
</Card>
</div>
</section>
)
const quoted = new Set(exchange.lines.flatMap((line) => (line.replyTo ? [line.replyTo.id] : [])))
return (
<section
data-section="chat-bubbles-02"
data-slot="section"
aria-labelledby={titleId}
className="w-full bg-background p-4 text-foreground md:p-6"
>
<div className="mx-auto max-w-7xl">
<Card>
<CardHeader>
<CardTitle>
<h2 id={titleId}>A question, answered with a file</h2>
</CardTitle>
<CardDescription>
{exchange.with} · {exchange.day} · times in UTC
</CardDescription>
</CardHeader>
<CardContent className="h-104 flex-none p-0">
<MessageScrollerProvider defaultScrollPosition="start">
<MessageScroller>
<MessageScrollerViewport aria-label={`Scrollable messages with ${exchange.with}`} className="px-(--card-spacing)">
<MessageScrollerContent aria-label={`Messages with ${exchange.with}`} className="gap-4 py-(--card-spacing)">
{exchange.lines.map((line) => (
<MessageScrollerItem key={line.id} messageId={line.id}>
<Message align={line.fromMe ? "end" : "start"}>
<MessageContent className="gap-1.5">
<MessageHeader>{line.fromMe ? "You" : line.author}</MessageHeader>
<Bubble variant={line.fromMe ? "default" : "muted"} align={line.fromMe ? "end" : "start"}>
<BubbleContent
// The line a reply quotes can take the focus, so the quote's jump lands on it.
{...(quoted.has(line.id) ? { tabIndex: -1, "data-quoted-line": "" } : {})}
className={cn("flex flex-col gap-2", quoted.has(line.id) && "focus-ring")}
>
{line.replyTo ? <ReplyQuote targetId={line.replyTo.id} author={line.replyTo.author} text={line.replyTo.text} /> : null}
<span>{line.text}</span>
</BubbleContent>
</Bubble>
{line.attachment ? (
<Attachment className={cn("max-w-72", line.fromMe && "self-end")}>
<AttachmentMedia>
<FileIcon kind={line.attachment.kind} />
</AttachmentMedia>
<AttachmentContent>
<AttachmentTitle>{line.attachment.name}</AttachmentTitle>
<AttachmentDescription className="tabular-nums">
{line.attachment.kind} · {line.attachment.size}
</AttachmentDescription>
</AttachmentContent>
</Attachment>
) : null}
<MessageFooter className="gap-1.5 font-normal tabular-nums">
<time dateTime={line.at}>{line.time}</time>
{line.fromMe ? (
<span className="inline-flex items-center gap-1">
{line.status === "sent" ? (
<CheckIcon aria-hidden="true" className="size-3.5" />
) : (
<CheckCheckIcon aria-hidden="true" className={cn("size-3.5", line.status === "read" && "text-info")} />
)}
<span className="sr-only">{RECEIPT[line.status]}</span>
</span>
) : null}
</MessageFooter>
</MessageContent>
</Message>
</MessageScrollerItem>
))}
</MessageScrollerContent>
</MessageScrollerViewport>
</MessageScroller>
</MessageScrollerProvider>
</CardContent>
</Card>
</div>
</section>
)
}
export default ChatBubbles02"use client"
import * as React from "react"
import { CornerUpLeftIcon } from "lucide-react"
import { useReducedMotion } from "@/hooks/use-reduced-motion"
import { useMessageScroller } from "@/components/ui/message-scroller"
/**
* The quote at the top of a reply: who said what it answers, and a way back
* to it. Pressed, it scrolls the thread to the line it quotes — at once for a
* reader who asked for less motion — and puts the focus on that line, so a
* keyboard or a screen reader lands where the eye does.
*/
export function ReplyQuote({ targetId, author, text }: { targetId: string; author: string; text: string }) {
const { scrollToMessage } = useMessageScroller()
const reducedMotion = useReducedMotion()
function jump() {
scrollToMessage(targetId, { align: "center", behavior: reducedMotion ? "auto" : "smooth" })
const item = [...document.querySelectorAll<HTMLElement>("[data-message-id]")].find((entry) => entry.dataset.messageId === targetId)
item?.querySelector<HTMLElement>("[data-quoted-line]")?.focus({ preventScroll: true })
}
return (
<button
type="button"
data-slot="reply-quote"
// Named in full, the visible words included: the quote's two lines would otherwise run together.
aria-label={`Go to the message this answers: ${author}, ${text}`}
onClick={jump}
className="flex w-full min-w-0 flex-col gap-0.5 rounded-md border-s-2 border-current/50 bg-current/10 px-2 py-1 text-start text-xs focus-ring"
>
<span className="flex items-center gap-1 font-semibold">
<CornerUpLeftIcon aria-hidden="true" className="size-3 shrink-0" />
{author}
</span>
<span className="line-clamp-2 opacity-90">{text}</span>
</button>
)
}/**
* What `chat-bubbles-02` reads: an exchange from a direct message in
* `db.chats` and `db.messages` — a question, and the line that answers it by
* sending something — with the file that line carries from `db.files`.
*
* The store keeps no reply or attachment on a message, so both are rules over
* the rows it does keep: a line that says it is sending something, written
* straight after the other side asked a question, answers that question and
* carries the newest document its writer had saved by then. Times are
* written here, in UTC. The island takes the rows as props and never imports
* this module.
*/
import { formatBytes } from "@/lib/format"
import { MEMBERS, db, type File, type Message } from "@/lib/sample-data"
/** A line that says it sends something with it. */
const SENDING = /\b(sending|sent|attached|here is)\b/i
/** How many lines after the question the exchange shows. */
export const AFTER = 5
const CLOCK = new Intl.DateTimeFormat("en-US", { hour: "numeric", minute: "2-digit", timeZone: "UTC" })
const DAY = new Intl.DateTimeFormat("en-US", { weekday: "long", month: "long", day: "numeric", timeZone: "UTC" })
/** What a file is, read off its extension. */
const KINDS: Record<string, string> = { pptx: "Slides", xlsx: "Spreadsheet", docx: "Document", pdf: "PDF", md: "Markdown" }
export type SentFile = { name: string; kind: string; size: string }
export type ExchangeLine = {
id: string
fromMe: boolean
author: string
text: string
at: string
time: string
status: Message["status"]
/** The line this one answers, quoted. */
replyTo?: { id: string; author: string; text: string }
attachment?: SentFile
}
export type Exchange = {
with: string
/** "Tuesday, September 1": the day the question was asked. */
day: string
lines: ExchangeLine[]
}
/** The newest document a member had saved by a moment: what they would have sent. */
function newestDocument(ownerId: string, before: Date): File | undefined {
return db.files
.all()
.filter((file) => file.kind === "document" && file.ownerId === ownerId && file.modifiedAt.getTime() <= before.getTime())
.sort((a, b) => b.modifiedAt.getTime() - a.modifiedAt.getTime())[0]
}
/**
* The most recent exchange in a direct message where a question was answered
* with a file. Null when no thread has one: the section then says so rather
* than failing.
*/
export function answeredWithAFile(): Exchange | null {
const me = MEMBERS[0].id
const name = (id: string) => MEMBERS.find((member) => member.id === id)?.name ?? "Former member"
const threads = db.chats
.all()
.filter((chat) => chat.kind === "dm")
.sort((a, b) => b.lastMessageAt.getTime() - a.lastMessageAt.getTime())
for (const chat of threads) {
const messages = db.messages
.all()
.filter((message) => message.chatId === chat.id)
.sort((a, b) => a.at.getTime() - b.at.getTime())
const at = messages.findIndex((message, index) => {
const asked = messages[index - 1]
return index > 0 && SENDING.test(message.text) && asked.text.trim().endsWith("?") && asked.fromId !== message.fromId && newestDocument(message.fromId, message.at)
})
if (at === -1) continue
const question = messages[at - 1]
const file = newestDocument(messages[at].fromId, messages[at].at)!
const extension = file.name.split(".").pop() ?? ""
const other = chat.memberIds.find((id) => id !== me) ?? me
return {
with: name(other),
day: DAY.format(question.at),
// The exchange ends with the day it started on.
lines: messages
.slice(at - 1, at + AFTER)
.filter((message) => message.at.toISOString().slice(0, 10) === question.at.toISOString().slice(0, 10))
.map((message) => ({
id: message.id,
fromMe: message.fromId === me,
author: name(message.fromId),
text: message.text,
at: message.at.toISOString(),
time: CLOCK.format(message.at),
status: message.status,
...(message.id === messages[at].id
? {
replyTo: { id: question.id, author: name(question.fromId), text: question.text },
attachment: { name: file.name, kind: KINDS[extension] ?? "File", size: formatBytes(file.sizeBytes, 1) },
}
: {}),
})),
}
}
return null
}npx shadcn@latest add @vibra/chat-bubbles-02Group thread in runs, with faces and day dividers
chat-bubbles-03A group conversation where the lines one member wrote back to back share one name and one face, each day starts under a divider, and a stronger one marks where the unread lines begin.
import * as React from "react"
import { CheckCheckIcon, CheckIcon, MessagesSquareIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import { getInitials } from "@/lib/format"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { AvatarGroup } from "@/components/ui/avatar-group"
import { Bubble, BubbleContent } from "@/components/ui/bubble"
import { Card, CardAction, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { EmptyState } from "@/components/ui/empty-state"
import { Message, MessageAvatar, MessageContent, MessageFooter, MessageHeader } from "@/components/ui/message"
import {
MessageScroller,
MessageScrollerButton,
MessageScrollerContent,
MessageScrollerItem,
MessageScrollerProvider,
MessageScrollerViewport,
} from "@/components/ui/message-scroller"
import { busiestGroup, type Run } from "./chat-bubbles-03.data"
const RECEIPT: Record<Run["status"], string> = { sent: "Sent", delivered: "Delivered", read: "Read" }
/** A rule across the thread with a word in it: a day, or where the unread lines start. */
function Divider({ label, tone = "muted" }: { label: string; tone?: "muted" | "strong" }) {
return (
<div
role="separator"
aria-label={label}
className={cn("flex items-center gap-3 py-1 text-xs", tone === "strong" ? "font-medium text-foreground" : "text-muted-foreground")}
>
<span aria-hidden="true" className={cn("h-px flex-1", tone === "strong" ? "bg-foreground" : "bg-border")} />
{label}
<span aria-hidden="true" className={cn("h-px flex-1", tone === "strong" ? "bg-foreground" : "bg-border")} />
</div>
)
}
/**
* A group thread as runs: the lines one member wrote back to back sit under
* one name and beside one face, the time under the last of them, so a busy
* thread reads as turns rather than a wall of bubbles. Each day starts under
* a divider, and a stronger one marks where the unread lines begin. The
* member's own runs sit on the right with how far the last line got.
*
* A server component: it reads the thread through `./chat-bubbles-03.data`
* and draws it with the kit's Message, Bubble, Avatar and MessageScroller.
* With no group thread in the store, the card says so.
*/
export function ChatBubbles03() {
const titleId = React.useId()
const thread = busiestGroup()
if (!thread)
return (
<section
data-section="chat-bubbles-03"
data-slot="section"
aria-labelledby={titleId}
className="w-full bg-background p-4 text-foreground md:p-6"
>
<div className="mx-auto max-w-7xl">
<Card>
<CardHeader>
<CardTitle>
<h2 id={titleId}>Group conversation</h2>
</CardTitle>
</CardHeader>
<CardContent>
<EmptyState
icon={<MessagesSquareIcon aria-hidden="true" />}
title="No group conversation yet"
description="The group that talks most shows here, a turn at a time, once one starts."
/>
</CardContent>
</Card>
</div>
</section>
)
const days = [...new Set(thread.runs.map((run) => run.day))]
return (
<section
data-section="chat-bubbles-03"
data-slot="section"
aria-labelledby={titleId}
className="w-full bg-background p-4 text-foreground md:p-6"
>
<div className="mx-auto max-w-7xl">
<Card>
<CardHeader>
<CardTitle>
<h2 id={titleId}>{thread.name}</h2>
</CardTitle>
<CardDescription>
{thread.members.length} members{thread.unread > 0 ? ` · ${thread.unread} unread` : ""} · times in UTC
</CardDescription>
{/* The faces are a named group, and their "+N" is a button that names the rest: nothing here is hidden. */}
<CardAction>
<AvatarGroup size="sm" max={4} users={thread.members.map((member) => ({ name: member.name, src: member.face }))} />
</CardAction>
</CardHeader>
<CardContent className="h-128 flex-none p-0">
<MessageScrollerProvider defaultScrollPosition="end">
<MessageScroller>
<MessageScrollerViewport aria-label={`Scrollable thread in ${thread.name}`} className="px-(--card-spacing)">
<MessageScrollerContent aria-label={`Messages in ${thread.name}`} className="gap-4 py-(--card-spacing)">
{days.map((day) => (
<React.Fragment key={day}>
<Divider label={thread.days[day]} />
{thread.runs
.filter((run) => run.day === day)
.map((run) => (
<React.Fragment key={run.id}>
{run.firstUnread ? <Divider label={`${thread.unread} unread`} tone="strong" /> : null}
<MessageScrollerItem messageId={run.id}>
<Message align={run.fromMe ? "end" : "start"}>
{run.fromMe ? null : (
<MessageAvatar className="min-w-8">
<Avatar size="sm">
<AvatarImage src={run.face} alt="" />
<AvatarFallback className="bg-foreground text-2xs text-background">{getInitials(run.author)}</AvatarFallback>
</Avatar>
</MessageAvatar>
)}
<MessageContent className="gap-1">
<MessageHeader>{run.fromMe ? "You" : run.author}</MessageHeader>
{run.lines.map((line) => (
<Bubble key={line.id} variant={run.fromMe ? "default" : "muted"} align={run.fromMe ? "end" : "start"}>
<BubbleContent>{line.text}</BubbleContent>
</Bubble>
))}
<MessageFooter className="gap-1.5 font-normal tabular-nums">
<time dateTime={run.lines.at(-1)!.at}>{run.lines.at(-1)!.time}</time>
{run.fromMe ? (
<span className="inline-flex items-center gap-1">
{run.status === "sent" ? (
<CheckIcon aria-hidden="true" className="size-3.5" />
) : (
<CheckCheckIcon aria-hidden="true" className={cn("size-3.5", run.status === "read" && "text-info")} />
)}
<span className="sr-only">{RECEIPT[run.status]}</span>
</span>
) : null}
</MessageFooter>
</MessageContent>
</Message>
</MessageScrollerItem>
</React.Fragment>
))}
</React.Fragment>
))}
</MessageScrollerContent>
</MessageScrollerViewport>
<MessageScrollerButton />
</MessageScroller>
</MessageScrollerProvider>
</CardContent>
</Card>
</div>
</section>
)
}
export default ChatBubbles03/**
* What `chat-bubbles-03` reads: the group thread in `db.chats` and
* `db.messages` that ran over the most days, its latest lines gathered into
* runs — the lines one member wrote back to back, within a quarter of an hour
* and on one day — with each member's face, the day each run sits under and
* where the signed-in member's unread lines begin.
*
* Times and days are written here, in UTC, and "today" is `REFERENCE_DATE`'s
* day. The section is a server component and reads this module directly.
*/
import { avatarFor } from "@/lib/avatars"
import { MEMBERS, REFERENCE_DATE, db, type Message } from "@/lib/sample-data"
const DAY_MS = 86_400_000
const MINUTE_MS = 60_000
/** How many of the latest lines the thread shows. */
export const SHOWN = 16
/** Lines further apart than this start a new run, even from the same member. */
export const RUN_GAP_MINUTES = 15
const CLOCK = new Intl.DateTimeFormat("en-US", { hour: "numeric", minute: "2-digit", timeZone: "UTC" })
const LONG_DAY = new Intl.DateTimeFormat("en-US", { weekday: "long", month: "long", day: "numeric", timeZone: "UTC" })
export type RunLine = { id: string; text: string; at: string; time: string }
/** Lines one member wrote back to back. */
export type Run = {
id: string
fromMe: boolean
author: string
face: string
/** `yyyy-mm-dd`, in UTC. */
day: string
lines: RunLine[]
/** How far the run's last line got, for the member's own. */
status: Message["status"]
/** True for the run the unread lines start with. */
firstUnread: boolean
}
export type GroupThread = {
name: string
members: { name: string; face: string }[]
runs: Run[]
/** What each day divider reads: Today, Yesterday, or the date. */
days: Record<string, string>
/** How many of the lines shown are unread: never more than it shows, so the mark is always on the page. */
unread: number
}
const midnight = (at: Date) => Date.UTC(at.getUTCFullYear(), at.getUTCMonth(), at.getUTCDate())
const isoDay = (at: Date) => new Date(midnight(at)).toISOString().slice(0, 10)
function dayLabel(day: string): string {
const back = (midnight(REFERENCE_DATE) - Date.parse(`${day}T00:00:00.000Z`)) / DAY_MS
if (back === 0) return "Today"
if (back === 1) return "Yesterday"
return LONG_DAY.format(new Date(`${day}T00:00:00.000Z`))
}
/**
* The group that talked over the most days, its latest lines in runs. Null
* when the store has no group with a line in it — the section then says so
* rather than failing.
*/
export function busiestGroup(): GroupThread | null {
const me = MEMBERS[0].id
const name = (id: string) => MEMBERS.find((member) => member.id === id)?.name ?? "Former member"
const groups = db.chats
.all()
.filter((chat) => chat.kind === "group")
.map((chat) => ({
chat,
messages: db.messages
.all()
.filter((message) => message.chatId === chat.id)
.sort((a, b) => a.at.getTime() - b.at.getTime()),
}))
.filter(({ messages }) => messages.length > 0)
.sort((a, b) => new Set(b.messages.map((m) => isoDay(m.at))).size - new Set(a.messages.map((m) => isoDay(m.at))).size || b.chat.lastMessageAt.getTime() - a.chat.lastMessageAt.getTime())
const busiest = groups[0]
if (!busiest) return null
const shown = busiest.messages.slice(-SHOWN)
// More unread than shown puts the mark above the first line on the page: it is counted, and drawn, from there.
const unread = Math.min(busiest.chat.unread, shown.length)
const firstUnread = unread > 0 ? shown[shown.length - unread].id : undefined
const runs: Run[] = []
for (const message of shown) {
const last = runs.at(-1)
const previous = last?.lines.at(-1)
const joins =
last !== undefined &&
previous !== undefined &&
last.fromMe === (message.fromId === me) &&
last.author === name(message.fromId) &&
last.day === isoDay(message.at) &&
message.at.getTime() - Date.parse(previous.at) <= RUN_GAP_MINUTES * MINUTE_MS &&
message.id !== firstUnread
const line = { id: message.id, text: message.text, at: message.at.toISOString(), time: CLOCK.format(message.at) }
if (joins) {
last.lines.push(line)
last.status = message.status
continue
}
runs.push({
id: message.id,
fromMe: message.fromId === me,
author: name(message.fromId),
face: avatarFor(name(message.fromId)),
day: isoDay(message.at),
lines: [line],
status: message.status,
firstUnread: message.id === firstUnread,
})
}
return {
name: busiest.chat.name,
members: busiest.chat.memberIds.map((id) => ({ name: name(id), face: avatarFor(name(id)) })),
runs,
days: Object.fromEntries([...new Set(runs.map((run) => run.day))].map((day) => [day, dayLabel(day)])),
unread,
}
}npx shadcn@latest add @vibra/chat-bubbles-03Composer with typing state and send
chat-bubbles-04A direct message and the box that answers it: Enter sends, Shift+Enter breaks a line, the line shows as sending and settles as sent under a day of its own, and the log reads it out once, politely.
import * as React from "react"
import { MessagesSquareIcon } from "lucide-react"
import { avatarFor } from "@/lib/avatars"
import { getInitials } from "@/lib/format"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Card, CardAction, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { EmptyState } from "@/components/ui/empty-state"
import { Conversation } from "./chat-bubbles-04.client"
import { MAX_LENGTH, composeThread, sendMessage, type ComposeLine, type Result } from "./chat-bubbles-04.data"
export type ChatBubbles04Props = {
/** The other side is typing — the host's live channel says so; for a preview, true shows the typing bubble. */
typing?: boolean
}
/**
* A direct message and the box that answers it: Enter sends, Shift+Enter
* breaks a line, the line shows at once as sending and settles as sent, and
* the log reads it out once, politely. The box says what state it is in as
* the reader types. When the host passes `typing`, the thread ends on the
* other side's typing bubble.
*
* A server component. It reads the thread through `./chat-bubbles-04.data`
* and hands the island `send`, a server function defined here, which adds the
* line to the store and answers with a `Result`. With no direct message to
* answer, the card says so.
*/
export function ChatBubbles04({ typing = false }: ChatBubbles04Props) {
const titleId = React.useId()
const thread = composeThread()
async function send(chatId: string, text: string): Promise<Result<ComposeLine>> {
"use server"
return sendMessage(chatId, text)
}
return (
<section
data-section="chat-bubbles-04"
data-slot="section"
aria-labelledby={titleId}
className="w-full bg-background p-4 text-foreground md:p-6"
>
<div className="mx-auto max-w-7xl">
<Card>
<CardHeader>
<CardTitle>
<h2 id={titleId}>{thread ? thread.with : "Direct message"}</h2>
</CardTitle>
{thread ? (
<>
<CardDescription>Direct message · last active {thread.lastDay} · times in UTC</CardDescription>
<CardAction aria-hidden="true">
<Avatar>
<AvatarImage src={avatarFor(thread.with)} alt="" />
<AvatarFallback className="bg-foreground text-xs text-background">{getInitials(thread.with)}</AvatarFallback>
</Avatar>
</CardAction>
</>
) : null}
</CardHeader>
{thread ? (
<Conversation thread={thread} maxLength={MAX_LENGTH} typing={typing} onSend={send} />
) : (
<CardContent>
<EmptyState
icon={<MessagesSquareIcon aria-hidden="true" />}
title="No conversation to answer"
description="Direct messages show here, with a box to reply in, once someone writes to you."
/>
</CardContent>
)}
</Card>
</div>
</section>
)
}
export default ChatBubbles04"use client"
import * as React from "react"
import { CheckCheckIcon, CheckIcon, SendIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import { AsyncButton } from "@/components/ui/async-button"
import { Bubble, BubbleContent } from "@/components/ui/bubble"
import { Callout } from "@/components/ui/callout"
import { CardContent, CardFooter } from "@/components/ui/card"
import { Message, MessageContent, MessageFooter } from "@/components/ui/message"
import {
MessageScroller,
MessageScrollerButton,
MessageScrollerContent,
MessageScrollerItem,
MessageScrollerProvider,
MessageScrollerViewport,
} from "@/components/ui/message-scroller"
import { Textarea } from "@/components/ui/textarea"
import type { ComposeLine, ComposeThread, Result } from "./chat-bubbles-04.data"
/** Adds the member's line to the thread: the section's server action. */
export type SendLine = (chatId: string, text: string) => Promise<Result<ComposeLine>>
const RECEIPT: Record<ComposeLine["status"], string> = { sent: "Sent", delivered: "Delivered", read: "Read" }
/** The typing dots pulse one after another; the delays are the whole of the choreography. */
const DOTS = ["[animation-delay:0ms]", "[animation-delay:160ms]", "[animation-delay:320ms]"]
/**
* A thread and the box that answers it. The box says what state it is in as
* the reader types — who they are writing to while it is empty, how to send
* once there is something to send, how much room is left near the limit —
* and sends on Enter, or with the button; Shift+Enter breaks a line. A line
* being sent shows in the thread at once as "Sending", for the eye only; the
* section's server action answers, and the line that settles is what the log
* reads out — once, as sent, under a day of its own when it starts one. A
* refusal keeps the draft and says why above the box. When the host says the
* other side is typing (`typing`), the thread ends on a muted Bubble of three
* dots — still for a reader who asked for less motion — read out once as a
* new line of the log.
*/
export function Conversation({
thread,
maxLength,
typing = false,
onSend,
}: {
thread: ComposeThread
maxLength: number
/** The other side is typing — from the host's own live channel; the sample has none. */
typing?: boolean
onSend: SendLine
}) {
const id = React.useId()
const first = thread.with.split(" ")[0]
const box = React.useRef<HTMLTextAreaElement>(null)
const [lines, setLines] = React.useState(thread.lines)
const [draft, setDraft] = React.useState("")
const [outgoing, setOutgoing] = React.useState<string | null>(null)
const [refusal, setRefusal] = React.useState<string | null>(null)
const left = maxLength - draft.length
const state = !draft.trim()
? `Write to ${first}`
: left <= 100
? `${left} ${left === 1 ? "character" : "characters"} left · Enter to send`
: "Enter to send · Shift+Enter for a new line"
async function send() {
const text = draft.trim()
if (!text || outgoing !== null) return
setOutgoing(text)
setRefusal(null)
let result: Result<ComposeLine>
try {
result = await onSend(thread.chatId, text)
} catch {
result = { ok: false, error: { code: "network", message: "The message was not sent. Check the connection and try again." } }
}
setOutgoing(null)
if (result.ok) {
const sent = result.data
setLines((current) => [...current, sent])
setDraft("")
return
}
setRefusal(result.error.message)
box.current?.focus()
}
// A line that starts a day carries that day's divider, in the same item, so
// the log reads the two out as one when a line sent today is the first.
const today = thread.today
const startsDay = (index: number) => index === 0 || lines[index - 1].day !== lines[index].day
return (
<>
<CardContent className="h-104 flex-none p-0">
<MessageScrollerProvider defaultScrollPosition="end" autoScroll>
<MessageScroller>
<MessageScrollerViewport aria-label={`Scrollable messages with ${thread.with}`} className="px-(--card-spacing)">
<MessageScrollerContent aria-label={`Messages with ${thread.with}`} className="gap-3 py-(--card-spacing)">
{lines.map((line, index) => (
<MessageScrollerItem key={line.id} messageId={line.id} className="flex flex-col gap-3">
{startsDay(index) ? <DayDivider label={thread.days[line.day] ?? line.day} /> : null}
<Line line={line} />
</MessageScrollerItem>
))}
{outgoing !== null ? (
// For the eye: the log reads out the line that settles, not this one as well.
<MessageScrollerItem messageId={`${id}-outgoing`} aria-hidden="true" className="flex flex-col gap-3">
{lines.at(-1)?.day !== today ? <DayDivider label={thread.days[today] ?? today} /> : null}
<Line line={{ id: `${id}-outgoing`, fromMe: true, author: "You", text: outgoing, at: "", day: today, time: "", status: "sent" }} pending />
</MessageScrollerItem>
) : null}
{typing ? (
<MessageScrollerItem messageId={`${id}-typing`}>
<Message>
<MessageContent>
<Bubble variant="muted">
<BubbleContent data-typing="" className="flex items-center gap-1 py-3">
<span className="sr-only">{first} is typing</span>
{DOTS.map((delay) => (
<span
key={delay}
aria-hidden="true"
className={cn(
"size-1.5 animate-pulse rounded-full bg-muted-foreground motion-reduce:animate-none in-data-[motion=reduced]:animate-none",
delay
)}
/>
))}
</BubbleContent>
</Bubble>
</MessageContent>
</Message>
</MessageScrollerItem>
) : null}
</MessageScrollerContent>
</MessageScrollerViewport>
<MessageScrollerButton />
</MessageScroller>
</MessageScrollerProvider>
</CardContent>
<CardFooter className="block px-(--card-spacing) py-3">
<form
noValidate
aria-label={`Reply to ${thread.with}`}
onSubmit={(event) => {
event.preventDefault()
void send()
}}
className="flex flex-col gap-2"
>
{refusal ? (
<Callout variant="danger" role="alert" title="Not sent">
{refusal}
</Callout>
) : null}
<div className="flex items-end gap-2">
<Textarea
ref={box}
rows={2}
value={draft}
maxLength={maxLength}
aria-label={`Message ${thread.with}`}
aria-describedby={`${id}-state`}
placeholder={`Message ${first}…`}
onChange={(event) => setDraft(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter" && !event.shiftKey && !event.nativeEvent.isComposing) {
event.preventDefault()
void send()
}
}}
className="min-h-10 flex-1 resize-none bg-card"
/>
<AsyncButton
type="submit"
loading={outgoing !== null}
loadingText="Sending"
disabled={!draft.trim()}
focusableWhenDisabled
aria-disabled={!draft.trim() || undefined}
className="shrink-0"
>
<SendIcon data-icon="inline-start" aria-hidden="true" />
Send
</AsyncButton>
</div>
<p id={`${id}-state`} className="text-xs text-muted-foreground tabular-nums">
{state}
</p>
</form>
</CardFooter>
</>
)
}
/** Where a day starts: a rule either side of its name. */
function DayDivider({ label }: { label: string }) {
return (
<div role="separator" aria-label={label} className="flex items-center gap-3 py-1 text-xs text-muted-foreground">
<span aria-hidden="true" className="h-px flex-1 bg-border" />
{label}
<span aria-hidden="true" className="h-px flex-1 bg-border" />
</div>
)
}
/** One line of the thread: the member's own on the right with how far it got, the other side's on the left. */
function Line({ line, pending = false }: { line: ComposeLine; pending?: boolean }) {
return (
<Message align={line.fromMe ? "end" : "start"}>
<MessageContent className="gap-1">
<Bubble variant={line.fromMe ? "default" : "muted"} align={line.fromMe ? "end" : "start"}>
<BubbleContent className={cn("whitespace-pre-line", pending && "opacity-70")}>
<span className="sr-only">{line.fromMe ? "You" : line.author}: </span>
{line.text}
</BubbleContent>
</Bubble>
<MessageFooter className="gap-1.5 font-normal tabular-nums">
{pending ? (
<span>Sending…</span>
) : (
<>
<time dateTime={line.at}>{line.time}</time>
{line.fromMe ? (
<span className="inline-flex items-center gap-1">
{line.status === "sent" ? (
<CheckIcon aria-hidden="true" className="size-3.5" />
) : (
<CheckCheckIcon aria-hidden="true" className={cn("size-3.5", line.status === "read" && "text-info")} />
)}
<span className="sr-only">{RECEIPT[line.status]}</span>
</span>
) : null}
</>
)}
</MessageFooter>
</MessageContent>
</Message>
)
}/**
* What `chat-bubbles-04` reads and the one thing it writes: the shortest
* direct-message thread in `db.chats` and `db.messages`, with who wrote each
* line and when, and `sendMessage`, the section's server action, which adds
* the signed-in member's line to the store.
*
* Times are written here, in UTC, and a line sent now is sent at
* `REFERENCE_DATE`. The island takes the rows as props and calls the action it
* is handed; it never imports this module.
*/
import { MEMBERS, REFERENCE_DATE, db, type Message, type Result } from "@/lib/sample-data"
export type { Result }
/** The longest line the composer takes. */
export const MAX_LENGTH = 1000
const DAY_MS = 86_400_000
const CLOCK = new Intl.DateTimeFormat("en-US", { hour: "numeric", minute: "2-digit", timeZone: "UTC" })
const DAY = new Intl.DateTimeFormat("en-US", { weekday: "long", month: "long", day: "numeric", timeZone: "UTC" })
const isoDay = (at: Date) => at.toISOString().slice(0, 10)
/** A day as a divider says it: "Today", "Yesterday", else "Wednesday, September 2". */
function dayLabel(day: string): string {
const back = (Date.parse(`${isoDay(REFERENCE_DATE)}T00:00:00.000Z`) - Date.parse(`${day}T00:00:00.000Z`)) / DAY_MS
if (back === 0) return "Today"
if (back === 1) return "Yesterday"
return DAY.format(new Date(`${day}T00:00:00.000Z`))
}
export type ComposeLine = {
id: string
fromMe: boolean
author: string
text: string
at: string
/** The day it was written, `yyyy-mm-dd` in UTC: a line that starts a day sits under its divider. */
day: string
time: string
status: Message["status"]
}
export type ComposeThread = {
chatId: string
with: string
/** "Wednesday, September 2": the day the thread last moved. */
lastDay: string
lines: ComposeLine[]
/** `REFERENCE_DATE`'s day: where a line sent now goes. */
today: string
/** What each day's divider says, for every day a line is on, and today. */
days: Record<string, string>
}
function lineOf(message: Message): ComposeLine {
const me = MEMBERS[0].id
return {
id: message.id,
fromMe: message.fromId === me,
author: MEMBERS.find((member) => member.id === message.fromId)?.name ?? "Former member",
text: message.text,
at: message.at.toISOString(),
day: isoDay(message.at),
time: CLOCK.format(message.at),
status: message.status,
}
}
/**
* The shortest direct message: a thread short enough to read before
* answering. Null when the store has no direct message with a line in it —
* the section then says so rather than failing.
*/
export function composeThread(): ComposeThread | null {
const me = MEMBERS[0].id
const threads = db.chats
.all()
.filter((chat) => chat.kind === "dm")
.map((chat) => ({ chat, messages: db.messages.all().filter((message) => message.chatId === chat.id).sort((a, b) => a.at.getTime() - b.at.getTime()) }))
.filter(({ messages }) => messages.length > 0)
.sort((a, b) => a.messages.length - b.messages.length || b.chat.lastMessageAt.getTime() - a.chat.lastMessageAt.getTime())
const shortest = threads[0]
if (!shortest) return null
const other = shortest.chat.memberIds.find((id) => id !== me)
const lines = shortest.messages.map(lineOf)
return {
chatId: shortest.chat.id,
with: MEMBERS.find((member) => member.id === other)?.name ?? shortest.chat.name,
lastDay: DAY.format(shortest.chat.lastMessageAt),
lines,
today: isoDay(REFERENCE_DATE),
days: Object.fromEntries([...new Set([...lines.map((line) => line.day), isoDay(REFERENCE_DATE)])].map((day) => [day, dayLabel(day)])),
}
}
/**
* Adds the signed-in member's line to a direct message. The text is trimmed
* and must say something and fit; the thread must exist and be the member's
* own — the server looks both up rather than trusting the caller, and takes
* nothing but a string for either.
*/
export async function sendMessage(chatId: unknown, text: unknown): Promise<Result<ComposeLine>> {
const me = MEMBERS[0].id
if (typeof text !== "string") return { ok: false, error: { code: "empty", message: "Write something before sending.", field: "text" } }
const body = text.trim()
if (!body) return { ok: false, error: { code: "empty", message: "Write something before sending.", field: "text" } }
if (body.length > MAX_LENGTH)
return { ok: false, error: { code: "too_long", message: `Keep it to ${MAX_LENGTH} characters; this one has ${body.length}.`, field: "text" } }
const chat = typeof chatId === "string" ? db.chats.all().find((entry) => entry.id === chatId) : undefined
if (!chat || !chat.memberIds.includes(me)) return { ok: false, error: { code: "not_found", message: "That conversation is not one of yours." } }
const created = await db.messages.create({ chatId: chat.id, fromId: me, text: body, at: REFERENCE_DATE, status: "sent" })
if (!created.ok) return created
await db.chats.update(chat.id, { lastMessageAt: REFERENCE_DATE })
return { ok: true, data: lineOf(created.data) }
}npx shadcn@latest add @vibra/chat-bubbles-04