Skip to contentVibraUI
Part of the Support dashboardinstalls at /support/inbox

Support inbox

A two-pane inbox: threads with unread markers and priority on the left, the conversation, its assignee and a reply composer on the right.

Open the live page

A support thread has no entity of its own, so it is assembled rather than written out: who wrote in is a db.customers row, who owns it is the db.members row that account already belongs to, and the subjects, bodies and timings are generated once from seeded("support-inbox") against REFERENCE_DATE. Priority is a rule over the account rather than a roll — the bigger the contract, the higher the thread sits. The page is a server component; picking a thread, filtering the list and answering are one client island, and every change goes through a server action returning Result and is applied only once that action says it took, so a rejected reply keeps its text to be fixed. Timestamps are pinned to UTC and the conversation header says so, because a thread opened late in the day would otherwise be dated differently either side of Greenwich. SplitView shows one pane at a time below md, with a Back control above the conversation. Composes AppShell, PageHeader, SplitView, SegmentedControl, DataList, StatusIndicator, PriorityBadge, EmptyState, StatusBadge, NativeSelect, Textarea, AsyncButton and Avatar.

Preview

Install

npx shadcn@latest add @vibra/support-inbox

Needs the @vibra registry in your components.json — set it up once.

Source

app/support/inbox/page.tsx
import { REFERENCE_DATE } from "@/lib/sample-data"
import { AppShell } from "@/components/ui/app-shell"
import { Badge } from "@/components/ui/badge"
import { PageHeader } from "@/components/ui/page-header"

import { signOut } from "./actions"
import { InboxView } from "./components/inbox-view"
import { currentUser, shellNotifications, teammates, threads } from "./data"
import { NAV, ROUTES } from "@/lib/dashboards/support/nav"

/**
 * A support inbox. The page is a server component: it assembles the threads
 * and the people who can take them, and hands both to one client island —
 * picking a thread, filtering the list and answering are the same state.
 */
export default function InboxPage() {
  const rows = threads()
  const user = currentUser()
  const unread = rows.filter((thread) => thread.unread).length
  const open = rows.filter((thread) => thread.status === "open").length

  return (
    <AppShell
      nav={NAV}
      activeHref={ROUTES.inbox}
      user={user}
      notifications={shellNotifications()}
      now={REFERENCE_DATE}
      onSignOut={signOut}
    >
      <PageHeader
        title="Inbox"
        description="Everything customers have written in, newest first."
        badge={<Badge variant="secondary">{unread} unread</Badge>}
        meta={`${open} open of ${rows.length} threads`}
      />

      <InboxView threads={rows} teammates={teammates()} agent={{ name: user.name }} now={REFERENCE_DATE} />
    </AppShell>
  )
}
app/support/inbox/data.ts
/**
 * What this page reads. A support thread has no entity of its own, so it is
 * assembled rather than written out: who wrote in is a `db.customers` row, who
 * owns it is the `db.members` row that account already belongs to, and the
 * subjects, bodies and timings are generated once from `seeded("inbox-threads")`
 * against `REFERENCE_DATE`. Priority is a rule over the account, not a coin
 * toss — the bigger the contract, the higher it sits.
 */
import { getInitials } from "@/lib/format"
import {
  chance,
  db,
  daysAgo,
  intBetween,
  pick,
  REFERENCE_DATE,
  seeded,
  type Member,
} from "@/lib/sample-data"

export type ThreadStatus = "open" | "closed"
export type ThreadPriority = "urgent" | "high" | "normal"

export type ThreadMessage = {
  id: string
  author: string
  initials: string
  avatarUrl?: string
  /** Whether the customer wrote it, or someone on the team did. */
  fromCustomer: boolean
  at: Date
  body: string
}

export type Thread = {
  id: string
  subject: string
  customerName: string
  company: string
  email: string
  plan: string
  channel: "Email" | "Chat" | "Web form"
  status: ThreadStatus
  priority: ThreadPriority
  unread: boolean
  /** A `Member` id, or null while nobody has picked it up. */
  assigneeId: string | null
  messages: ThreadMessage[]
  lastMessageAt: Date
}

// Copy, not data: the things customers of an analytics product actually write
// in about, and the shapes a first reply takes.
const SUBJECTS = [
  "Events stopped arriving after the SDK upgrade",
  "Can we add three more seats this month?",
  "Invoice 4412 charged the old plan rate",
  "Webhook retries are firing twice",
  "How do I export a funnel to CSV?",
  "SSO login loops back to the sign-in page",
  "Dashboard totals disagree with the API",
  "Request: alert when signups drop 20%",
  "Timezone on scheduled reports is wrong",
  "Removing a teammate did not free the seat",
  "Rate limited on the events endpoint",
  "Can we get a sandbox workspace?",
]

const OPENERS = [
  "This started sometime yesterday and we have not changed anything on our side.",
  "It is blocking our weekly review, so any pointer would help.",
  "Happy to send a HAR file if that is useful.",
  "We are on the team plan, if that matters.",
  "Two people on my team see the same thing, so it is not just my browser.",
]

const REPLIES = [
  "Thanks for writing in — I can reproduce it on your workspace and I am digging into the logs now.",
  "I have passed this to the platform team; there is an open ticket and I will keep this thread updated.",
  "That is expected on the current plan, but there is a setting that changes it. Walking you through it below.",
  "Good catch. A fix is going out with tomorrow's release, and I will confirm here once it lands.",
]

const FOLLOW_UPS = [
  "Any update on this? We are seeing the same thing this morning.",
  "That worked, thank you. One follow-up while I have you.",
  "Understood. We can wait for the release.",
]

const CHANNELS: Thread["channel"][] = ["Email", "Chat", "Web form"]

const COUNT = 12

/** Rule, not a roll: what an account pays is what decides how loud its thread is. */
function priorityFor(mrrCents: number, plan: string): ThreadPriority {
  if (plan === "enterprise" || mrrCents >= 40_000) return "urgent"
  if (mrrCents >= 12_000) return "high"
  return "normal"
}

/** What a thread's draws settled: who wrote in, and every turn after, with no names attached. */
type Drawn = {
  customerId: string
  index: number
  closed: boolean
  assigned: boolean
  turns: { id: string; fromCustomer: boolean; at: Date; body: string }[]
}

let drawn: Drawn[] | undefined

/**
 * The threads' subjects, bodies and timings, drawn once from the block's seed —
 * when a page first asks, never when the module loads. Only the draws are kept:
 * the names, faces, plans and owners are read off the rows per request, so a
 * teammate renamed since signs their replies with the new name.
 */
function draws(): Drawn[] {
  if (drawn) return drawn
  const rand = seeded("inbox-threads")
  // Only accounts that are still around write in, and the newest ones first.
  const writers = db.customers
    .all()
    .filter((customer) => customer.status === "active")
    .sort((a, b) => b.lastSeenAt.getTime() - a.lastSeenAt.getTime())
    .slice(0, COUNT)

  drawn = writers.map((customer, index) => {
    const openedAt = daysAgo(intBetween(rand, 0, 9) + rand())
    const closed = chance(rand, 0.25)
    const replies = intBetween(rand, 1, 3)
    const turns: Drawn["turns"] = [
      {
        id: `${customer.id}-m1`,
        fromCustomer: true,
        at: openedAt,
        body: `${SUBJECTS[index % SUBJECTS.length]}. ${pick(rand, OPENERS)}`,
      },
    ]

    for (let turn = 0; turn < replies; turn += 1) {
      const at = new Date(openedAt.getTime() + (turn + 1) * intBetween(rand, 40, 300) * 60_000)
      if (at > REFERENCE_DATE) break
      const fromCustomer = turn % 2 === 1
      turns.push({
        id: `${customer.id}-m${turn + 2}`,
        fromCustomer,
        at,
        body: fromCustomer ? pick(rand, FOLLOW_UPS) : pick(rand, REPLIES),
      })
    }

    return { customerId: customer.id, index, closed, assigned: chance(rand, 0.75), turns }
  })
  return drawn
}

/** Every thread, newest activity first. */
export function threads(): Thread[] {
  const customers = new Map(db.customers.all().map((customer) => [customer.id, customer]))
  const members = new Map(db.members.all().map((member) => [member.id, member]))

  return draws()
    .flatMap(({ customerId, index, closed, assigned, turns }): Thread[] => {
      const customer = customers.get(customerId)
      if (!customer) return []
      const owner = members.get(customer.owner)
      const messages = turns.map(({ id, fromCustomer, at, body }): ThreadMessage => {
        const author = fromCustomer ? customer.name : (owner?.name ?? "Support")
        return {
          id,
          author,
          initials: getInitials(author),
          avatarUrl: fromCustomer ? customer.avatarUrl : owner?.avatarUrl,
          fromCustomer,
          at,
          body,
        }
      })
      const last = messages[messages.length - 1]

      return [
        {
          id: customer.id,
          subject: SUBJECTS[index % SUBJECTS.length],
          customerName: customer.name,
          company: customer.company,
          email: customer.email,
          plan: customer.plan,
          channel: CHANNELS[index % CHANNELS.length],
          status: closed ? "closed" : "open",
          priority: priorityFor(customer.mrrCents, customer.plan),
          // Nothing the team answered last is unread, and nothing closed is either.
          unread: !closed && last.fromCustomer,
          assigneeId: assigned ? customer.owner : null,
          messages,
          lastMessageAt: last.at,
        },
      ]
    })
    .sort((a, b) => b.lastMessageAt.getTime() - a.lastMessageAt.getTime())
}

export type Teammate = { id: string; name: string; initials: string }

/** Who a thread can be handed to: everyone still active in the workspace. */
export function teammates(): Teammate[] {
  return db.members
    .all()
    .filter((member) => member.status === "active")
    .map((member) => ({ id: member.id, name: member.name, initials: getInitials(member.name) }))
}

function ownerRow(): Member {
  return db.members.all().find((member) => member.role === "owner") ?? db.members.all()[0]
}

export function currentUser() {
  const owner = ownerRow()
  return { name: owner.name, email: owner.email, initials: getInitials(owner.name), avatarUrl: owner.avatarUrl }
}

export function shellNotifications() {
  return db.notifications
    .all()
    .sort((a, b) => b.at.getTime() - a.at.getTime())
    .slice(0, 6)
    .map(({ id, title, description, at, read, href }) => ({ id, title, description, at, read, href }))
}
app/support/inbox/actions.ts
"use server"

import { mockAuthAdapter } from "@/lib/auth-adapter"
import { asString, db, REFERENCE_DATE, type Result } from "@/lib/sample-data"

/**
 * What this page changes. Server actions so the page can stay a server
 * component, and `Result` so every caller reads one success-or-error shape:
 * a message pinned to a field is shown beside that field, and anything else
 * becomes a form-level alert.
 */
export async function signOut(): Promise<Result<{ signedOut: true }>> {
  await mockAuthAdapter.signOut()
  return { ok: true, data: { signedOut: true } }
}

/** The reply as it was accepted — the time is `REFERENCE_DATE`, never a clock. */
export async function sendReply(
  threadId: string,
  body: string
): Promise<Result<{ threadId: string; body: string; at: Date }>> {
  const text = asString(body).trim()
  if (!text) {
    return {
      ok: false,
      error: { code: "required", message: "Write a reply before sending.", field: "body" },
    }
  }
  return { ok: true, data: { threadId, body: text, at: REFERENCE_DATE } }
}

/** Hands a thread to someone still in the workspace, or takes it off everyone. */
export async function assignThread(
  threadId: string,
  memberId: string | null
): Promise<Result<{ threadId: string; memberId: string | null }>> {
  if (memberId && !db.members.all().some((member) => member.id === memberId)) {
    return {
      ok: false,
      error: { code: "not_found", message: "That person is no longer in the workspace." },
    }
  }
  return { ok: true, data: { threadId, memberId } }
}

/** Closes a thread, or puts it back in the queue. */
export async function setThreadStatus(
  threadId: string,
  status: "open" | "closed"
): Promise<Result<{ threadId: string; status: "open" | "closed" }>> {
  return { ok: true, data: { threadId, status } }
}
app/support/inbox/components/inbox-view.tsx
"use client"

import * as React from "react"

import { getInitials } from "@/lib/format"
import { EmptyState } from "@/components/ui/empty-state"
import { SplitView } from "@/components/ui/split-view"

import { assignThread, sendReply, setThreadStatus } from "../actions"
import { type Teammate, type Thread } from "../data"
import { ThreadConversation } from "./thread-conversation"
import { ThreadList, type ThreadFilter } from "./thread-list"

function passes(thread: Thread, filter: ThreadFilter): boolean {
  if (filter === "unread") return thread.unread
  if (filter === "closed") return thread.status === "closed"
  return thread.status === "open"
}

export type InboxViewProps = {
  threads: Thread[]
  teammates: Teammate[]
  /** Who is answering; their name goes on the replies they send. */
  agent: { name: string }
  /** The page's reference instant, which the list's "3h ago" is measured from. */
  now: Date
}

/**
 * The inbox. One island, because picking a thread, filtering the list and
 * answering are all the same piece of state; every change goes through a
 * server action and is applied only once that action says it took.
 */
export function InboxView({ threads, teammates, agent, now }: InboxViewProps) {
  const [rows, setRows] = React.useState(threads)
  const [filter, setFilter] = React.useState<ThreadFilter>("open")
  const [selectedId, setSelectedId] = React.useState<string | null>(
    () => threads.find((thread) => thread.status === "open")?.id ?? threads[0]?.id ?? null
  )
  const [showDetail, setShowDetail] = React.useState(false)
  const [replyError, setReplyError] = React.useState<string | undefined>(undefined)
  const [actionError, setActionError] = React.useState<string | undefined>(undefined)
  const [sending, setSending] = React.useState(false)

  const visible = rows.filter((thread) => passes(thread, filter))
  const selected = rows.find((thread) => thread.id === selectedId) ?? null

  function patch(id: string, change: Partial<Thread>) {
    setRows((current) =>
      current.map((thread) => (thread.id === id ? { ...thread, ...change } : thread))
    )
  }

  function select(id: string) {
    setSelectedId(id)
    setShowDetail(true)
    setReplyError(undefined)
    setActionError(undefined)
    // Opening a thread is what marks it read.
    patch(id, { unread: false })
  }

  async function assign(memberId: string | null) {
    if (!selected) return
    const result = await assignThread(selected.id, memberId)
    if (!result.ok) {
      setActionError(result.error.message)
      return
    }
    setActionError(undefined)
    patch(result.data.threadId, { assigneeId: result.data.memberId })
  }

  async function toggleStatus() {
    if (!selected) return
    const next = selected.status === "open" ? "closed" : "open"
    const result = await setThreadStatus(selected.id, next)
    if (!result.ok) {
      setActionError(result.error.message)
      return
    }
    setActionError(undefined)
    patch(result.data.threadId, { status: result.data.status, unread: false })
  }

  async function reply(body: string): Promise<boolean> {
    if (!selected) return false
    setSending(true)
    const result = await sendReply(selected.id, body)
    setSending(false)

    if (!result.ok) {
      setReplyError(result.error.message)
      return false
    }

    setReplyError(undefined)
    const message = {
      id: `${result.data.threadId}-r${selected.messages.length + 1}`,
      author: agent.name,
      initials: getInitials(agent.name),
      fromCustomer: false,
      at: result.data.at,
      body: result.data.body,
    }
    patch(result.data.threadId, {
      messages: [...selected.messages, message],
      lastMessageAt: message.at,
      unread: false,
    })
    return true
  }

  return (
    <div className="flex flex-col gap-2">
      {actionError ? (
        <p role="alert" className="text-sm text-danger">
          {actionError}
        </p>
      ) : null}

      {/* A fixed frame rather than a growing one: the two panes scroll inside
          the page, the way a mail client does, instead of the page scrolling. */}
      <div className="h-[calc(100svh-15rem)] min-h-[30rem] overflow-hidden panel">
        <SplitView
          defaultSize={34}
          minSize={26}
          maxSize={48}
          showDetail={showDetail}
          onBack={() => setShowDetail(false)}
          list={
            <ThreadList
              threads={visible}
              selectedId={selectedId}
              filter={filter}
              now={now}
              onFilterChange={setFilter}
              onSelect={select}
            />
          }
          detail={
            selected ? (
              <ThreadConversation
                thread={selected}
                teammates={teammates}
                replyError={replyError}
                sending={sending}
                onAssign={assign}
                onToggleStatus={toggleStatus}
                onReply={reply}
              />
            ) : (
              <EmptyState
                title="No thread selected"
                description="Pick a thread on the left to read it."
              />
            )
          }
        />
      </div>
    </div>
  )
}
app/support/inbox/components/thread-list.tsx
"use client"

import { DataList, DataListItem } from "@/components/ui/data-list"
import { EmptyState } from "@/components/ui/empty-state"
import { PriorityBadge } from "@/components/ui/priority-badge"
import { SegmentedControl } from "@/components/ui/segmented-control"
import { StatusIndicator } from "@/components/ui/status-indicator"

import { type Thread } from "../data"
import { since } from "./inbox-vocabulary"

// The thread's own priority words map onto the badge's; "normal" is its "medium".
const PRIORITY = { urgent: "urgent", high: "high", normal: "medium" } as const

/** Which threads the list shows. UI vocabulary, so it lives here. */
export const FILTERS = [
  { value: "open", label: "Open" },
  { value: "unread", label: "Unread" },
  { value: "closed", label: "Closed" },
]

export type ThreadFilter = "open" | "unread" | "closed"

/** The master pane: the filter, then every thread that passes it. */
export function ThreadList({
  threads,
  selectedId,
  filter,
  now,
  onFilterChange,
  onSelect,
}: {
  threads: Thread[]
  selectedId: string | null
  filter: ThreadFilter
  /** What "3h ago" is measured from: the page's reference instant. */
  now: Date
  onFilterChange: (filter: ThreadFilter) => void
  onSelect: (id: string) => void
}) {
  return (
    <section aria-label="Threads" className="flex h-full min-h-0 flex-col">
      <div className="flex shrink-0 flex-col gap-2 border-b p-2">
        <SegmentedControl
          size="sm"
          fullWidth
          aria-label="Filter threads"
          options={FILTERS}
          value={filter}
          onValueChange={(value) => onFilterChange(value as ThreadFilter)}
        />
        <p className="px-1 text-xs tabular-nums text-muted-foreground">
          {threads.length} {threads.length === 1 ? "thread" : "threads"}
        </p>
      </div>

      <div className="min-h-0 flex-1 overflow-y-auto">
        {threads.length === 0 ? (
          <EmptyState size="sm" title="Nothing here" description="No thread matches this filter." />
        ) : (
          <DataList divided>
            {threads.map((thread) => (
              <DataListItem
                key={thread.id}
                selected={thread.id === selectedId}
                onClick={() => onSelect(thread.id)}
                className="items-start py-3"
                leading={
                  thread.unread ? (
                    // Decorative: the dot repeats what the row already says in
                    // words, and the primitive's own status word ("Info") would
                    // name the tone rather than the state.
                    <StatusIndicator status="info" aria-hidden="true" className="mt-1.5" />
                  ) : (
                    <span aria-hidden="true" className="mt-1.5 size-2" />
                  )
                }
                title={
                  <span className="flex items-center gap-2">
                    {thread.unread ? <span className="sr-only">Unread.</span> : null}
                    <span className={thread.unread ? "truncate" : "truncate font-normal"}>
                      {thread.customerName}
                    </span>
                    <PriorityBadge
                      priority={PRIORITY[thread.priority]}
                      showLabel={false}
                      size="sm"
                      className="shrink-0"
                    />
                  </span>
                }
                description={thread.subject}
                meta={since(thread.lastMessageAt, now)}
              />
            ))}
          </DataList>
        )}
      </div>
    </section>
  )
}
app/support/inbox/components/thread-conversation.tsx
"use client"

import * as React from "react"
import { CheckIcon, RotateCcwIcon, SendIcon } from "lucide-react"

import { AsyncButton } from "@/components/ui/async-button"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Button } from "@/components/ui/button"
import { NativeSelect, NativeSelectOption } from "@/components/ui/native-select"
import { StatusBadge } from "@/components/ui/status-badge"
import { Textarea } from "@/components/ui/textarea"

import { type Teammate, type Thread } from "../data"
import { messageTime } from "./inbox-vocabulary"

// "closed" is not in StatusBadge's own vocabulary, so the thread states are
// named here rather than left to the lookup.
const STATUS_MAP = { open: "info", closed: "neutral" } as const

export type ThreadConversationProps = {
  thread: Thread
  teammates: Teammate[]
  replyError?: string
  sending: boolean
  onAssign: (memberId: string | null) => void
  onToggleStatus: () => void
  /** Resolves true when the reply was accepted; the draft only clears then. */
  onReply: (body: string) => Promise<boolean>
}

/** The detail pane: who wrote in, what was said, and the box that answers it. */
export function ThreadConversation({
  thread,
  teammates,
  replyError,
  sending,
  onAssign,
  onToggleStatus,
  onReply,
}: ThreadConversationProps) {
  const [draft, setDraft] = React.useState("")

  // A new thread is a new conversation: the half-written reply to the last one
  // does not belong in it.
  const [lastId, setLastId] = React.useState(thread.id)
  if (thread.id !== lastId) {
    setLastId(thread.id)
    setDraft("")
  }

  return (
    <div className="flex h-full min-h-0 flex-col">
      <header className="flex shrink-0 flex-col gap-3 border-b p-4">
        <div className="flex flex-wrap items-start justify-between gap-3">
          <div className="flex min-w-0 flex-col gap-1">
            <h2 className="text-sm leading-snug font-medium">{thread.subject}</h2>
            <p className="text-xs text-muted-foreground">
              {thread.customerName} · {thread.company} · {thread.email}
            </p>
          </div>
          <div className="flex shrink-0 items-center gap-2">
            <StatusBadge status={thread.status} map={STATUS_MAP} size="sm" />
            <Button type="button" variant="outline" size="sm" onClick={onToggleStatus}>
              {thread.status === "open" ? (
                <>
                  <CheckIcon data-icon="inline-start" aria-hidden="true" />
                  Close thread
                </>
              ) : (
                <>
                  <RotateCcwIcon data-icon="inline-start" aria-hidden="true" />
                  Reopen
                </>
              )}
            </Button>
          </div>
        </div>

        <div className="flex flex-wrap items-center gap-2">
          <label htmlFor="thread-assignee" className="text-xs text-muted-foreground">
            Assigned to
          </label>
          <NativeSelect
            id="thread-assignee"
            size="sm"
            value={thread.assigneeId ?? ""}
            onChange={(event) => onAssign(event.target.value || null)}
          >
            <NativeSelectOption value="">Unassigned</NativeSelectOption>
            {teammates.map((teammate) => (
              <NativeSelectOption key={teammate.id} value={teammate.id}>
                {teammate.name}
              </NativeSelectOption>
            ))}
          </NativeSelect>
          <span className="text-xs text-muted-foreground">
            {thread.channel} · {thread.plan} plan · times in UTC
          </span>
        </div>
      </header>

      <div className="min-h-0 flex-1 overflow-y-auto">
        <ol className="flex flex-col">
          {thread.messages.map((message) => (
            <li key={message.id} className="flex gap-3 border-b p-4 last:border-b-0">
              <Avatar className="size-7 shrink-0">
                {message.avatarUrl ? <AvatarImage src={message.avatarUrl} alt="" /> : null}
                <AvatarFallback className="text-avatar">{message.initials}</AvatarFallback>
              </Avatar>
              <div className="flex min-w-0 flex-1 flex-col gap-1">
                <p className="flex flex-wrap items-baseline gap-2 text-xs">
                  <span className="font-medium text-foreground">{message.author}</span>
                  <span className="text-muted-foreground">
                    {message.fromCustomer ? "Customer" : "Support"}
                  </span>
                  <time
                    dateTime={message.at.toISOString()}
                    className="ml-auto tabular-nums text-muted-foreground"
                  >
                    {messageTime.format(message.at)}
                  </time>
                </p>
                <p className="text-sm text-foreground/90">{message.body}</p>
              </div>
            </li>
          ))}
        </ol>
      </div>

      <form
        className="flex shrink-0 flex-col gap-2 border-t p-3"
        onSubmit={async (event) => {
          event.preventDefault()
          // The draft survives a rejected reply, so it can be fixed and sent.
          if (await onReply(draft)) setDraft("")
        }}
      >
        <Textarea
          aria-label="Reply"
          rows={3}
          value={draft}
          placeholder={`Reply to ${thread.customerName}…`}
          onChange={(event) => setDraft(event.target.value)}
          className="resize-none"
        />
        {replyError ? (
          <p role="alert" className="text-xs text-danger">
            {replyError}
          </p>
        ) : null}
        <div className="flex items-center justify-between gap-2">
          <p className="text-xs text-muted-foreground">
            Replies go to {thread.email}.
          </p>
          <AsyncButton type="submit" size="sm" loading={sending} loadingText="Sending">
            <SendIcon data-icon="inline-start" aria-hidden="true" />
            Send reply
          </AsyncButton>
        </div>
      </form>
    </div>
  )
}
app/support/inbox/components/inbox-vocabulary.ts
/**
 * How the inbox writes a moment down. Vocabulary, not data: it lives beside
 * the islands that print it, because `data.ts` reads `db` and must never reach
 * the browser. "Now" is the page's `REFERENCE_DATE`, handed down as a prop —
 * nothing here reads a clock.
 */
import { formatRelative } from "@/lib/format"

/**
 * When a message landed, pinned to UTC. Every timestamp on this page is an
 * instant, and `formatDate` has no `timeZone` to read one with — so a thread
 * opened late in the day would be dated differently either side of Greenwich,
 * and the list and the conversation could disagree about which day it was.
 */
export const messageTime = new Intl.DateTimeFormat("en-US", {
  month: "short",
  day: "numeric",
  hour: "numeric",
  minute: "2-digit",
  timeZone: "UTC",
})

const shortDay = new Intl.DateTimeFormat("en-US", {
  month: "short",
  day: "numeric",
  timeZone: "UTC",
})

/**
 * How long before `now` — and once "6d ago" stops being useful, the date
 * itself. `formatRelative` falls back to a host-zone date of its own past a
 * week, which is the one part of it that could move.
 */
export function since(at: Date, now: Date): string {
  const days = (now.getTime() - at.getTime()) / 86_400_000
  return days < 7 ? formatRelative(at, now) : shortDay.format(at)
}