# Channel sidebar

> The caller's channels with unread and mention counts, kept live, with dialogs to browse, join and create channels.

- Blocks: [channels](https://bettersupabase.com/blocks/channels.md), [messaging](https://bettersupabase.com/blocks/messaging.md)
- Kind: component
- Hook: `useConversations` from `better-supabase/blocks/messaging/react`
- Page: https://bettersupabase.com/recipes/channel-sidebar

## Install the blocks

```bash
npx better-supabase add channels messaging
```

## Web (Next.js)

Add the shadcn components:

```bash
npx shadcn add alert badge button card dialog empty field input label scroll-area skeleton switch
```

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

```tsx
"use client";

import type { SubmitEvent } from "react";

import { useConversations } from "better-supabase/blocks/messaging/react";
import { useAction } from "better-supabase/react";
import {
  HashIcon,
  LockIcon,
  PlusIcon,
  RotateCwIcon,
  SearchIcon,
} from "lucide-react";
import { useExtracted } from "next-intl";
import { useId, useState } from "react";
import { toast } from "sonner";

import { Alert, AlertAction, AlertTitle } from "@/components/reui/alert";
import { Badge } from "@/components/reui/badge";
import { Button } from "@/components/ui/button";
import {
  Card,
  CardAction,
  CardContent,
  CardHeader,
  CardTitle,
} from "@/components/ui/card";
import {
  Dialog,
  DialogClose,
  DialogContent,
  DialogDescription,
  DialogFooter,
  DialogHeader,
  DialogTitle,
  DialogTrigger,
} from "@/components/ui/dialog";
import {
  Empty,
  EmptyDescription,
  EmptyHeader,
  EmptyMedia,
  EmptyTitle,
} from "@/components/ui/empty";
import {
  Field,
  FieldDescription,
  FieldError,
  FieldGroup,
  FieldLabel,
} from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Skeleton } from "@/components/ui/skeleton";
import { Switch } from "@/components/ui/switch";
import { useErrorMessage } from "@/lib/use-error-message";
import { cn } from "@/lib/utils";

import type { ChannelRow } from "../chat-actions";

import { browseChannels, createChannel, joinChannel } from "../chat-actions";

export interface OpenChannel {
  readonly id: string;
  readonly name: string;
}

const KINDS = ["channel"] as const;

/**
 * The caller's channels with unread and mention counts, kept live over
 * Realtime, plus dialogs to browse the organization's channels and create one.
 */
export function ChannelSidebar({
  selected,
  onSelect,
}: {
  selected: string | null;
  onSelect: (channel: OpenChannel) => void;
}) {
  const t = useExtracted("chat");
  const errorMessage = useErrorMessage();
  const { conversations, error, refresh } = useConversations({ kinds: KINDS });

  const opened = (channel: OpenChannel) => {
    void refresh();
    onSelect(channel);
  };

  return (
    <Card size="sm" className="min-h-0">
      <CardHeader>
        <CardTitle>{t("Channels")}</CardTitle>
        <CardAction className="flex gap-1">
          <BrowseChannelsDialog onOpened={opened} />
          <CreateChannelDialog onCreated={opened} />
        </CardAction>
      </CardHeader>
      <CardContent className="flex min-h-0 flex-1 flex-col gap-3">
        {error ? (
          <Alert variant="destructive" aria-live="polite">
            <AlertTitle>{errorMessage(error)}</AlertTitle>
            <AlertAction>
              <Button
                type="button"
                size="sm"
                variant="outline"
                onClick={() => void refresh()}
              >
                <RotateCwIcon aria-hidden />
                {t("Retry")}
              </Button>
            </AlertAction>
          </Alert>
        ) : null}
        {conversations === undefined ? (
          <div
            className="flex flex-col gap-2"
            aria-busy="true"
            aria-live="polite"
          >
            <span className="sr-only">{t("Loading channels")}</span>
            <Skeleton className="h-8" />
            <Skeleton className="h-8" />
            <Skeleton className="h-8" />
          </div>
        ) : conversations.length === 0 ? (
          <Empty>
            <EmptyHeader>
              <EmptyMedia variant="icon">
                <HashIcon />
              </EmptyMedia>
              <EmptyTitle>{t("No channels yet")}</EmptyTitle>
              <EmptyDescription>
                {t("Create one, or browse the channels others made.")}
              </EmptyDescription>
            </EmptyHeader>
          </Empty>
        ) : (
          <ScrollArea className="min-h-0 flex-1">
            <ul className="flex flex-col gap-0.5" data-testid="channels">
              {conversations.map((conversation) => {
                const name = conversation.title ?? conversation.id;
                const current = selected === conversation.id;

                return (
                  <li key={conversation.id}>
                    <Button
                      type="button"
                      variant={current ? "secondary" : "ghost"}
                      size="sm"
                      className="w-full justify-start"
                      aria-current={current ? "true" : undefined}
                      onClick={() => {
                        onSelect({ id: conversation.id, name });
                      }}
                    >
                      {conversation.access === "members" ? (
                        <LockIcon aria-hidden />
                      ) : (
                        <HashIcon aria-hidden />
                      )}
                      <span
                        className={cn(
                          "truncate",
                          conversation.unread > 0 && "font-semibold",
                        )}
                      >
                        {name}
                      </span>
                      {conversation.mentions > 0 ? (
                        <Badge variant="destructive" className="ml-auto">
                          @{conversation.mentions}
                        </Badge>
                      ) : conversation.unread > 0 ? (
                        <Badge variant="secondary" className="ml-auto">
                          {conversation.unread}
                        </Badge>
                      ) : null}
                    </Button>
                  </li>
                );
              })}
            </ul>
          </ScrollArea>
        )}
      </CardContent>
    </Card>
  );
}

function BrowseChannelsDialog({
  onOpened,
}: {
  onOpened: (channel: OpenChannel) => void;
}) {
  const t = useExtracted("chat");
  const errorMessage = useErrorMessage();
  const [open, setOpen] = useState(false);
  const browse = useAction(browseChannels);
  const join = useAction(joinChannel);

  const pick = async (channel: ChannelRow) => {
    if (!channel.joined) {
      const joined = await join.run({ channelId: channel.id });

      if (!joined.ok) {
        toast.error(errorMessage(joined.error));

        return;
      }
    }

    setOpen(false);
    onOpened({ id: channel.id, name: channel.name });
  };

  const rows = browse.data;

  return (
    <Dialog
      open={open}
      onOpenChange={(next) => {
        setOpen(next);

        if (next) {
          void browse.run({});
        }
      }}
    >
      <DialogTrigger
        render={
          <Button
            size="icon-sm"
            variant="outline"
            aria-label={t("Browse channels")}
          />
        }
      >
        <SearchIcon aria-hidden />
      </DialogTrigger>
      <DialogContent>
        <DialogHeader>
          <DialogTitle>{t("Browse channels")}</DialogTitle>
          <DialogDescription>
            {t("Every public channel, and the private ones you are in.")}
          </DialogDescription>
        </DialogHeader>
        {browse.error ? (
          <Alert variant="destructive" aria-live="polite">
            <AlertTitle>{errorMessage(browse.error)}</AlertTitle>
            <AlertAction>
              <Button
                type="button"
                size="sm"
                variant="outline"
                onClick={() => void browse.run({})}
              >
                <RotateCwIcon aria-hidden />
                {t("Retry")}
              </Button>
            </AlertAction>
          </Alert>
        ) : rows === undefined ? (
          <div
            className="flex flex-col gap-2"
            aria-busy="true"
            aria-live="polite"
          >
            <span className="sr-only">{t("Loading channels")}</span>
            <Skeleton className="h-10" />
            <Skeleton className="h-10" />
          </div>
        ) : rows.length === 0 ? (
          <Empty>
            <EmptyHeader>
              <EmptyTitle>{t("No channels to join")}</EmptyTitle>
              <EmptyDescription>
                {t("Close this and create the first one.")}
              </EmptyDescription>
            </EmptyHeader>
          </Empty>
        ) : (
          <ul className="flex flex-col gap-2">
            {rows.map((channel) => (
              <li key={channel.id} className="flex items-center gap-3">
                <div className="flex min-w-0 flex-1 flex-col gap-0.5">
                  <span className="flex items-center gap-1 text-sm font-medium">
                    {channel.visibility === "private" ? (
                      <LockIcon aria-hidden className="size-3.5" />
                    ) : (
                      <HashIcon aria-hidden className="size-3.5" />
                    )}
                    <span className="truncate">{channel.name}</span>
                  </span>
                  <span className="truncate text-xs text-muted-foreground">
                    {channel.topic ??
                      t("{count, plural, one {# member} other {# members}}", {
                        count: channel.memberCount,
                      })}
                  </span>
                </div>
                <Button
                  type="button"
                  size="sm"
                  variant={channel.joined ? "outline" : "default"}
                  disabled={join.pending}
                  onClick={() => void pick(channel)}
                >
                  {channel.joined ? t("Open") : t("Join")}
                </Button>
              </li>
            ))}
          </ul>
        )}
      </DialogContent>
    </Dialog>
  );
}

function CreateChannelDialog({
  onCreated,
}: {
  onCreated: (channel: OpenChannel) => void;
}) {
  const t = useExtracted("chat");
  const errorMessage = useErrorMessage();
  const fieldId = useId();
  const [open, setOpen] = useState(false);
  const [name, setName] = useState("");
  const [topic, setTopic] = useState("");
  const [isPrivate, setPrivate] = useState(false);

  const create = useAction(createChannel, {
    onSuccess: (id) => {
      setOpen(false);
      onCreated({ id, name: name.trim().toLowerCase() });
    },
  });

  const message =
    create.error === undefined ? null : errorMessage(create.error);

  const submit = (event: SubmitEvent<HTMLFormElement>) => {
    event.preventDefault();
    void create.run({
      name,
      topic,
      visibility: isPrivate ? "private" : "public",
    });
  };

  return (
    <Dialog
      open={open}
      onOpenChange={(next) => {
        setOpen(next);
        setName("");
        setTopic("");
        setPrivate(false);
        create.reset();
      }}
    >
      <DialogTrigger
        render={<Button size="icon-sm" aria-label={t("Create a channel")} />}
      >
        <PlusIcon aria-hidden />
      </DialogTrigger>
      <DialogContent>
        <form onSubmit={submit} className="grid gap-6">
          <DialogHeader>
            <DialogTitle>{t("Create a channel")}</DialogTitle>
            <DialogDescription>
              {t("A place for one team, project or topic.")}
            </DialogDescription>
          </DialogHeader>
          <FieldGroup>
            <Field data-invalid={message ? true : undefined}>
              <FieldLabel htmlFor={`${fieldId}-name`}>{t("Name")}</FieldLabel>
              <Input
                id={`${fieldId}-name`}
                value={name}
                onChange={(event) => {
                  setName(event.target.value);
                }}
                required
                maxLength={80}
                pattern="[A-Za-z0-9][A-Za-z0-9_\-]*"
                placeholder="launch"
              />
              <FieldDescription>
                {t("Lowercase letters, digits, - and _.")}
              </FieldDescription>
              {message ? <FieldError>{message}</FieldError> : null}
            </Field>
            <Field>
              <FieldLabel htmlFor={`${fieldId}-topic`}>{t("Topic")}</FieldLabel>
              <Input
                id={`${fieldId}-topic`}
                value={topic}
                onChange={(event) => {
                  setTopic(event.target.value);
                }}
                maxLength={250}
              />
            </Field>
            <div className="flex items-center gap-2">
              <Switch
                id={`${fieldId}-private`}
                checked={isPrivate}
                onCheckedChange={setPrivate}
              />
              <Label
                htmlFor={`${fieldId}-private`}
                className="text-sm font-normal"
              >
                {t("Private: only the people you add see it")}
              </Label>
            </div>
          </FieldGroup>
          <DialogFooter>
            <DialogClose render={<Button type="button" variant="outline" />}>
              {t("Cancel")}
            </DialogClose>
            <Button type="submit" disabled={create.pending}>
              {t("Create")}
            </Button>
          </DialogFooter>
        </form>
      </DialogContent>
    </Dialog>
  );
}
```

## Prompt

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

### Web

```text
Build "Channel sidebar" in a React web app that uses shadcn/ui components. The caller's channels with unread and mention counts, kept live, with dialogs to browse, join and create channels.

It uses the better-supabase `channels` block (Channels) with `messaging`. It calls the hook `useConversations` from `better-supabase/blocks/messaging/react`.

Steps:

1. If the app doesn't have the blocks yet, add the SQL with `npx better-supabase add channels messaging` and apply the migration.
2. Add the shadcn components the recipe uses: `npx shadcn add alert badge button card dialog empty field input label scroll-area skeleton switch`.
3. The recipe needs no glue: the hook calls the block from the client.
4. Write the component. Call `useConversations` from `better-supabase/blocks/messaging/react`, starting from the recipe's snippet (`apps/examples/nextjs/src/features/chat/components/channel-sidebar.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/channel-sidebar.md
- Block docs: https://bettersupabase.com/docs/blocks/channels
```
