# Inbox with threads

> A shared inbox of conversations with status tabs, an assignee filter and the open thread beside the list.

- Block: [inbox](https://bettersupabase.com/blocks/inbox.md)
- Kind: screen
- Hook: `useInbox` from `better-supabase/blocks/inbox/react`
- Page: https://bettersupabase.com/recipes/inbox-threads

## Install the block

```bash
npx better-supabase add inbox
```

## Web (Next.js)

Add the shadcn components:

```bash
npx shadcn add badge empty select tabs
```

From `apps/examples/nextjs/src/features/inbox/components/inbox-view.tsx` (https://github.com/ScaleDockHQ/better-supabase/blob/main/apps/examples/nextjs/src/features/inbox/components/inbox-view.tsx):

```tsx
"use client";

import { useInbox } from "better-supabase/blocks/inbox/react";
import { InboxIcon } from "lucide-react";
import { useExtracted, useFormatter } from "next-intl";
import { useState } from "react";
import * as v from "valibot";

import { Badge } from "@/components/ui/badge";
import {
  Empty,
  EmptyDescription,
  EmptyHeader,
  EmptyMedia,
  EmptyTitle,
} from "@/components/ui/empty";
import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from "@/components/ui/select";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { cn } from "@/lib/utils";

import { loadConversations } from "../inbox-actions";
import { unwrap } from "../inbox-load";
import {
  AssigneeFilter,
  type ConversationRow,
  ConversationStatus,
} from "../inbox-types";
import { ConversationPanel } from "./conversation-panel";

export function InboxView({
  organizationId,
  initial,
}: {
  organizationId: string;
  initial: readonly ConversationRow[];
}) {
  const t = useExtracted("inbox");
  const [status, setStatus] = useState<ConversationStatus>("open");
  const [assignee, setAssignee] = useState<AssigneeFilter>("anyone");
  const [selected, setSelected] = useState<string | null>(null);

  const assigneeItems = [
    { value: "anyone", label: t("Anyone") },
    { value: "me", label: t("Mine") },
    { value: "unassigned", label: t("Unassigned") },
  ] satisfies readonly { value: AssigneeFilter; label: string }[];

  return (
    <div className="grid min-h-128 gap-4 lg:grid-cols-3">
      <section aria-label={t("Conversations")} className="space-y-3">
        <div className="flex flex-wrap items-center gap-2">
          <Tabs
            value={status}
            onValueChange={(value) => {
              if (!v.is(ConversationStatus, value)) {
                return;
              }

              setStatus(value);
            }}
          >
            <TabsList>
              <TabsTrigger value="open">{t("Open")}</TabsTrigger>
              <TabsTrigger value="pending">{t("Pending")}</TabsTrigger>
              <TabsTrigger value="resolved">{t("Resolved")}</TabsTrigger>
              <TabsTrigger value="all">{t("All")}</TabsTrigger>
            </TabsList>
          </Tabs>
          <Select
            value={assignee}
            items={assigneeItems}
            onValueChange={(value) => {
              if (!v.is(AssigneeFilter, value)) {
                return;
              }

              setAssignee(value);
            }}
          >
            <SelectTrigger
              size="sm"
              className="w-32"
              aria-label={t("Assignee")}
            >
              <SelectValue />
            </SelectTrigger>
            <SelectContent>
              {assigneeItems.map((item) => (
                <SelectItem key={item.value} value={item.value}>
                  {item.label}
                </SelectItem>
              ))}
            </SelectContent>
          </Select>
        </div>
        <ConversationList
          key={`${status}:${assignee}`}
          organizationId={organizationId}
          status={status}
          assignee={assignee}
          initial={status === "open" && assignee === "anyone" ? initial : []}
          selected={selected}
          onSelect={setSelected}
        />
      </section>
      {selected === null ? (
        <Empty className="border lg:col-span-2">
          <EmptyHeader>
            <EmptyTitle>{t("Pick a conversation")}</EmptyTitle>
            <EmptyDescription>
              {t("Its messages, notes and actions show here.")}
            </EmptyDescription>
          </EmptyHeader>
        </Empty>
      ) : (
        <div className="lg:col-span-2">
          <ConversationPanel key={selected} conversationId={selected} />
        </div>
      )}
    </div>
  );
}

function ConversationList({
  organizationId,
  status,
  assignee,
  initial,
  selected,
  onSelect,
}: {
  organizationId: string;
  status: ConversationStatus;
  assignee: AssigneeFilter;
  initial: readonly ConversationRow[];
  selected: string | null;
  onSelect: (id: string) => void;
}) {
  const t = useExtracted("inbox");
  const format = useFormatter();

  const inbox = useInbox({
    organizationId,
    load: async () => unwrap(await loadConversations({ status, assignee })),
  });

  const conversations = inbox.items ?? initial;

  // Staff reads send no broadcast, so an opened conversation clears its badge here.
  const [openedAt, setOpenedAt] = useState<ReadonlyMap<string, number>>(
    () => new Map(),
  );

  const unread = (conversation: ConversationRow): boolean => {
    const opened = openedAt.get(conversation.id);

    return (
      conversation.unread &&
      (opened === undefined ||
        (conversation.lastMessageAt !== null &&
          Date.parse(conversation.lastMessageAt) > opened))
    );
  };

  return conversations.length === 0 ? (
    <Empty className="border">
      <EmptyHeader>
        <EmptyMedia variant="icon">
          <InboxIcon />
        </EmptyMedia>
        <EmptyTitle>{t("No conversations")}</EmptyTitle>
        <EmptyDescription>
          {t("Open Help in the header to start one as a customer.")}
        </EmptyDescription>
      </EmptyHeader>
    </Empty>
  ) : (
    <ul className="divide-y rounded-xl border">
      {conversations.map((conversation) => (
        <li key={conversation.id}>
          <button
            type="button"
            aria-current={selected === conversation.id ? "true" : undefined}
            className={cn(
              "flex w-full flex-col gap-1 px-4 py-3 text-left text-sm hover:bg-muted/50",
              selected === conversation.id && "bg-muted",
            )}
            onClick={() => {
              setOpenedAt((current) =>
                new Map(current).set(conversation.id, Date.now()),
              );
              onSelect(conversation.id);
            }}
          >
            <span className="flex items-center gap-2">
              <span className="flex-1 truncate font-medium">
                {conversation.contactName || t("Visitor")}
              </span>
              {unread(conversation) ? <Badge>{t("New")}</Badge> : null}
            </span>
            <span className="truncate">
              {conversation.subject ?? t("No subject")}
            </span>
            <span className="flex gap-2 text-xs text-muted-foreground">
              <span className="flex-1 truncate">{conversation.preview}</span>
              {conversation.lastMessageAt ? (
                <span className="tabular-nums">
                  {format.dateTime(new Date(conversation.lastMessageAt), {
                    dateStyle: "short",
                    timeStyle: "short",
                  })}
                </span>
              ) : null}
            </span>
          </button>
        </li>
      ))}
    </ul>
  );
}
```

## Prompt

Paste this into a coding agent to build the recipe in your app.

### Web

```text
Build "Inbox with threads" in a React web app that uses shadcn/ui components. A shared inbox of conversations with status tabs, an assignee filter and the open thread beside the list.

It uses the better-supabase `inbox` block (Shared inbox). It calls the hook `useInbox` from `better-supabase/blocks/inbox/react`.

Steps:

1. If the app doesn't have the block yet, add the SQL with `npx better-supabase add inbox` and apply the migration.
2. Add the shadcn components the recipe uses: `npx shadcn add badge empty select tabs`.
3. The recipe needs no glue: the hook calls the block from the client.
4. Write the screen. Call `useInbox` from `better-supabase/blocks/inbox/react`, starting from the recipe's snippet (`apps/examples/nextjs/src/features/inbox/components/inbox-view.tsx`). Adapt it to the app's existing components, styling and data loading instead of copying it as is.
5. Check the result against the recipe and the block's docs: it typechecks, it calls the same hook, and it handles the loading and empty states the snippet handles.

Read the full code first:

- Recipe: https://bettersupabase.com/recipes/inbox-threads.md
- Block docs: https://bettersupabase.com/docs/blocks/inbox
```
