# Team Chat

> Channels and DMs with grouped messages, reactions that roll their counts, and threads that open in a side panel; send something and a teammate starts typing back.

A shadcn/ui block from the [@devl](https://devl.dev/?utm_source=shadcn.exchange) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/devl/team-chat).

- Page, with a live preview: https://shadcn.exchange/r/devl/team-chat
- Screenshot: https://shadcn.exchange/shot/devl/team-chat/light.png
- Registry item: https://devl.dev/r/team-chat.json
- Categories: screen, Comms
- Listed: 2026-10-28, source last changed 2026-10-28

## Install

```bash
npx shadcn@latest add https://shadcn.exchange/r/devl/team-chat.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- npm packages: `lucide-react`, `motion`
- Registry items: `utils`, `https://coss.com/ui/r/avatar.json`, `https://coss.com/ui/r/button.json`, `https://coss.com/ui/r/kbd.json`, `https://coss.com/ui/r/popover.json`, `https://coss.com/ui/r/tooltip.json`

## Files

### `registry/devl/team-chat.tsx`

```tsx
"use client";

import { AnimatePresence, LayoutGroup, MotionConfig, motion } from "motion/react";
import {
  ArrowLeftIcon,
  AtSignIcon,
  BellOffIcon,
  BoldIcon,
  BookmarkIcon,
  CornerDownLeftIcon,
  ChevronDownIcon,
  CodeIcon,
  FileTextIcon,
  HashIcon,
  ItalicIcon,
  LockIcon,
  MenuIcon,
  MessageSquareTextIcon,
  MessagesSquareIcon,
  PaperclipIcon,
  PinIcon,
  PlusIcon,
  SearchIcon,
  SendHorizontalIcon,
  SmilePlusIcon,
  UsersIcon,
  XIcon,
} from "lucide-react";
import {
  Fragment,
  type KeyboardEvent,
  type RefObject,
  useEffect,
  useLayoutEffect,
  useMemo,
  useRef,
  useState,
} from "react";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import { Kbd } from "@/components/ui/kbd";
import { Popover, PopoverPopup, PopoverTrigger } from "@/components/ui/popover";
import { Tooltip, TooltipPopup, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";

export type ChatTone = "blue" | "orange" | "teal" | "violet" | "rose" | "amber" | "emerald" | "sky";

export interface ChatPerson {
  id: string;
  name: string;
  tone: ChatTone;
  title?: string;
  presence?: "active" | "away" | "offline";
}

export interface ChatReaction {
  emoji: string;
  people: string[];
}

export interface ChatReply {
  id: string;
  author: string;
  time: string;
  text: string;
  reactions?: ChatReaction[];
  saved?: boolean;
}

export interface ChatMessage extends ChatReply {
  day: string;
  replies?: ChatReply[];
  file?: { name: string; size: string; pages?: number };
}

export interface ChatChannel {
  id: string;
  name: string;
  kind: "channel" | "dm";
  with?: string;
  topic?: string;
  private?: boolean;
  unread?: number;
  mentions?: number;
  muted?: boolean;
  members?: number;
  pinned?: string[];
  messages: ChatMessage[];
}

export interface TeamChatProps {
  workspace?: string;
  me?: string;
  people?: ChatPerson[];
  channels?: ChatChannel[];
  className?: string;
}

const avatarTone: Record<ChatTone, string> = {
  blue: "bg-blue-500/14 text-blue-700 dark:text-blue-300",
  orange: "bg-orange-500/14 text-orange-700 dark:text-orange-300",
  teal: "bg-teal-500/16 text-teal-700 dark:text-teal-300",
  violet: "bg-violet-500/16 text-violet-700 dark:text-violet-300",
  rose: "bg-rose-500/14 text-rose-700 dark:text-rose-300",
  amber: "bg-amber-500/18 text-amber-700 dark:text-amber-300",
  emerald: "bg-emerald-500/16 text-emerald-700 dark:text-emerald-300",
  sky: "bg-sky-500/16 text-sky-700 dark:text-sky-300",
};

const spring = { type: "spring", stiffness: 520, damping: 42 } as const;
const panelSpring = { type: "spring", stiffness: 420, damping: 40 } as const;
const ease = [0.23, 1, 0.32, 1] as const;

const initials = (name: string) =>
  name
    .split(" ")
    .map((p) => p[0])
    .slice(0, 2)
    .join("")
    .toUpperCase();

const defaultPeople: ChatPerson[] = [
  { id: "maya", name: "Maya Okafor", tone: "teal", title: "Head of Product", presence: "active" },
  { id: "priya", name: "Priya Raman", tone: "violet", title: "Product Designer", presence: "active" },
  { id: "jonas", name: "Jonas Weber", tone: "blue", title: "Growth", presence: "active" },
  { id: "leo", name: "Leo Marchetti", tone: "rose", title: "Mobile", presence: "away" },
  { id: "sam", name: "Sam Whitfield", tone: "amber", title: "Platform", presence: "active" },
  { id: "ana", name: "Ana Sousa", tone: "orange", title: "Research", presence: "offline" },
];

const defaultChannels: ChatChannel[] = [
  {
    id: "design",
    name: "design",
    kind: "channel",
    topic: "Crit Thursdays 2pm · onboarding v3 in review",
    members: 14,
    pinned: ["d1", "d6"],
    messages: [
      {
        id: "d0",
        day: "Yesterday",
        author: "ana",
        time: "4:48 PM",
        text: "Synthesis from the five beta teams is in the research folder. Short version: nobody reads step 4.",
        reactions: [{ emoji: "😅", people: ["priya", "leo"] }],
      },
      {
        id: "d1",
        day: "Today",
        author: "priya",
        time: "9:12 AM",
        text: "Onboarding v3 is up for crit at 2pm. Biggest change: we ask for the team name last, after people have seen their first project.",
        file: { name: "onboarding-v3.pdf", size: "2.4 MB", pages: 18 },
        reactions: [
          { emoji: "🔥", people: ["jonas", "leo", "sam"] },
          { emoji: "👀", people: ["ana", "maya"] },
        ],
        replies: [
          { id: "r1", author: "jonas", time: "9:20 AM", text: "Love that it ends on their first project instead of a form." },
          { id: "r2", author: "ana", time: "9:31 AM", text: "Can we test step 4 with the beta teams before we lock it?" },
          { id: "r3", author: "priya", time: "9:40 AM", text: "@Ana yes, booked three sessions for Tuesday.", reactions: [{ emoji: "🙌", people: ["ana"] }] },
          { id: "r4", author: "leo", time: "10:15 AM", text: "I'll bring the mobile prototype so we test both." },
        ],
      },
      {
        id: "d2",
        day: "Today",
        author: "leo",
        time: "9:24 AM",
        text: "The empty project state on step 3 is so much calmer. Can we reuse it in the mobile app?",
        reactions: [{ emoji: "👍", people: ["priya", "maya"] }],
      },
      { id: "d3", day: "Today", author: "leo", time: "9:25 AM", text: "Also, the progress dots feel small on phones. 6px is hard to read at arm's length." },
      {
        id: "d4",
        day: "Today",
        author: "jonas",
        time: "10:02 AM",
        text: "From the pricing side: if team name comes last, can we still show the plan picker before invites? I need it for the Starter experiment.",
        replies: [
          { id: "r1", author: "priya", time: "10:06 AM", text: "Yes, plan picker stays on step 2." },
          { id: "r2", author: "jonas", time: "10:07 AM", text: "Perfect, thank you." },
        ],
      },
      {
        id: "d5",
        day: "Today",
        author: "maya",
        time: "10:05 AM",
        text: "@Jonas plan picker stays where it is. Priya and I walked the whole flow yesterday.",
      },
      {
        id: "d6",
        day: "Today",
        author: "sam",
        time: "10:41 AM",
        text: "Shipped the `ease-out-quint` token to the design system. Every transition in the app is under 250ms now.",
        reactions: [
          { emoji: "🎉", people: ["priya", "leo", "jonas", "ana"] },
          { emoji: "⚡", people: ["maya"] },
        ],
      },
    ],
  },
  {
    id: "eng-platform",
    name: "eng-platform",
    kind: "channel",
    topic: "Queue, webhooks, deploys · on-call: Sam",
    members: 9,
    unread: 3,
    messages: [
      { id: "e1", day: "Today", author: "sam", time: "8:02 AM", text: "Webhook backlog is back to zero. Postmortem draft is in the doc, comments open until Monday." },
      { id: "e2", day: "Today", author: "sam", time: "8:03 AM", text: "Three follow-ups: poison-message handling, consumer lag alert at 2 minutes, and a replay tool.", saved: true },
      {
        id: "e3",
        day: "Today",
        author: "jonas",
        time: "9:50 AM",
        text: "Does the replay tool cover the Oct 21 window? Two customers asked.",
        reactions: [{ emoji: "👍", people: ["maya"] }],
      },
    ],
  },
  {
    id: "launches",
    name: "launches",
    kind: "channel",
    topic: "What's shipping and when",
    members: 32,
    mentions: 1,
    messages: [
      { id: "l1", day: "Today", author: "jonas", time: "11:02 AM", text: "@Maya can you sign off the Starter pricing copy before 3pm? Release notes go out at 4." },
    ],
  },
  {
    id: "research",
    name: "research",
    kind: "channel",
    private: true,
    members: 5,
    topic: "Interview notes and synthesis",
    messages: [{ id: "x1", day: "Yesterday", author: "ana", time: "3:10 PM", text: "Uploaded the last two interview recordings." }],
  },
  {
    id: "random",
    name: "random",
    kind: "channel",
    muted: true,
    members: 41,
    topic: "Dogs, lunch, and the office plant",
    messages: [{ id: "z1", day: "Today", author: "leo", time: "8:30 AM", text: "Someone watered the fern twice again. It's thriving out of spite." }],
  },
  {
    id: "dm-priya",
    name: "Priya Raman",
    kind: "dm",
    with: "priya",
    messages: [
      { id: "p1", day: "Today", author: "priya", time: "8:55 AM", text: "Do you have 10 minutes before crit? I want a second pair of eyes on step 4." },
    ],
  },
  {
    id: "dm-sam",
    name: "Sam Whitfield",
    kind: "dm",
    with: "sam",
    unread: 1,
    messages: [{ id: "s1", day: "Today", author: "sam", time: "10:58 AM", text: "Heads up: deploy freeze starts Thursday 6pm for the board demo." }],
  },
  {
    id: "dm-leo",
    name: "Leo Marchetti",
    kind: "dm",
    with: "leo",
    messages: [{ id: "o1", day: "Yesterday", author: "leo", time: "6:12 PM", text: "Venue options for Lisbon are in your email." }],
  },
];

const emojiChoices = ["👍", "🔥", "🎉", "👀", "🙌", "😅", "✅", "❤️"];

const autoReplies: Record<string, { author: string; text: string }> = {
  design: { author: "priya", text: "Good call. Adding it to the crit doc so we don't lose it." },
  "eng-platform": { author: "sam", text: "On it. I'll post an update here after standup." },
  launches: { author: "jonas", text: "Thanks! Locking the copy now." },
};

const fileExt = (name: string) => name.split(".").pop()?.toUpperCase() ?? "File";

function fileSize(bytes: number) {
  if (bytes < 1024) return `${bytes} B`;
  if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`;
  return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
}

function isTyping(e: KeyboardEvent) {
  const t = e.target as HTMLElement;
  return t.tagName === "INPUT" || t.tagName === "TEXTAREA" || t.isContentEditable;
}

function RichText({ text, people, me }: { text: string; people: ChatPerson[]; me: string }) {
  const parts = text.split(/(`[^`]+`|\*\*[^*]+\*\*|\b_[^_]+_\b|@\w+)/g);
  return (
    <>
      {parts.map((part, i) => {
        if (part.startsWith("`") && part.endsWith("`") && part.length > 2) {
          return (
            <code className="rounded-md border bg-muted px-1 py-px font-mono text-[0.8125rem]" key={i}>
              {part.slice(1, -1)}
            </code>
          );
        }
        if (part.startsWith("**") && part.endsWith("**") && part.length > 4) {
          return (
            <strong className="font-semibold" key={i}>
              {part.slice(2, -2)}
            </strong>
          );
        }
        if (part.startsWith("_") && part.endsWith("_") && part.length > 2) {
          return (
            <em key={i}>
              {part.slice(1, -1)}
            </em>
          );
        }
        if (part.startsWith("@")) {
          const who = people.find((p) => p.name.split(" ")[0].toLowerCase() === part.slice(1).toLowerCase());
          if (who) {
            const isMe = who.id === me;
            return (
              <span
                className={cn(
                  "rounded px-0.5 font-medium",
                  isMe ? "bg-warning/16 text-warning-foreground" : "bg-info/10 text-info-foreground",
                )}
                key={i}
              >
                {part}
              </span>
            );
          }
        }
        return <Fragment key={i}>{part}</Fragment>;
      })}
    </>
  );
}

function PersonAvatar({ person, size = "size-9", presence }: { person?: ChatPerson; size?: string; presence?: boolean }) {
  if (!person) return null;
  const tiny = size === "size-5" || size === "size-6";
  const tinyText = size === "size-5" ? "text-[9px]" : "text-[10px]";
  return (
    <span className="relative inline-flex shrink-0">
      <Avatar className={cn(size, tiny ? "rounded-md" : "rounded-lg")}>
        <AvatarFallback className={cn("font-medium", tiny ? cn("rounded-md", tinyText) : "rounded-lg text-xs", avatarTone[person.tone])}>
          {initials(person.name)}
        </AvatarFallback>
      </Avatar>
      {presence && (
        <span
          className={cn(
            "absolute -right-0.5 -bottom-0.5 size-2 rounded-full ring-2 ring-sidebar",
            person.presence === "active" ? "bg-success" : person.presence === "away" ? "bg-warning" : "border border-muted-foreground/60 bg-sidebar",
          )}
        />
      )}
    </span>
  );
}

export type ChatView = "threads" | "mentions" | "saved";

const slugify = (v: string) =>
  v
    .toLowerCase()
    .trim()
    .replace(/[^a-z0-9]+/g, "-")
    .replace(/^-+|-+$/g, "")
    .slice(0, 40);

function NavPill() {
  return (
    <motion.span
      className="absolute inset-0 rounded-lg bg-accent"
      layoutId="chat-channel-pill"
      transition={spring}
    />
  );
}

function ChannelNav({
  workspace,
  channels,
  people,
  active,
  view,
  viewCounts,
  onSelect,
  onView,
  onCreateChannel,
  onJump,
  onClose,
}: {
  workspace: string;
  channels: ChatChannel[];
  people: ChatPerson[];
  active: string;
  view: ChatView | null;
  viewCounts: Record<ChatView, number>;
  onSelect: (id: string) => void;
  onView: (v: ChatView) => void;
  onCreateChannel: (name: string) => void;
  onJump: (mode: "all" | "people") => void;
  onClose?: () => void;
}) {
  const [open, setOpen] = useState({ channel: true, dm: true });
  const [creating, setCreating] = useState(false);
  const [newName, setNewName] = useState("");
  const row =
    "group/row relative flex h-8 w-full cursor-pointer items-center gap-2 rounded-lg px-2.5 text-left outline-none focus-visible:ring-2 focus-visible:ring-ring";
  const finishCreate = () => {
    const name = slugify(newName);
    if (name) onCreateChannel(name);
    setCreating(false);
    setNewName("");
  };
  const section = (kind: "channel" | "dm", title: string) => (
    <div className="mt-4">
      <div className="flex h-7 items-center ps-1.5 pe-1">
        <button
          aria-expanded={open[kind]}
          className="flex h-7 cursor-pointer items-center gap-1 rounded-md px-1 font-medium text-muted-foreground text-xs outline-none hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
          onClick={() => setOpen((o) => ({ ...o, [kind]: !o[kind] }))}
          type="button"
        >
          {title}
          <ChevronDownIcon className={cn("size-3 transition-transform duration-200 ease-out", !open[kind] && "-rotate-90")} />
        </button>
        <Button
          aria-label={kind === "channel" ? "Create a channel" : "New direct message"}
          className="ms-auto"
          onClick={() => {
            setOpen((o) => ({ ...o, [kind]: true }));
            if (kind === "channel") setCreating(true);
            else onJump("people");
          }}
          size="icon-xs"
          variant="ghost"
        >
          <PlusIcon />
        </Button>
      </div>
      <AnimatePresence initial={false}>
        {open[kind] && (
          <motion.ul
            animate={{ height: "auto", opacity: 1 }}
            className="-mx-1 flex flex-col gap-0.5 overflow-hidden px-1"
            exit={{ height: 0, opacity: 0 }}
            initial={{ height: 0, opacity: 0 }}
            transition={{ duration: 0.2, ease }}
          >
            {channels
              .filter((c) => c.kind === kind)
              .map((c) => {
                const on = !view && c.id === active;
                const loud = !c.muted && ((c.unread ?? 0) > 0 || (c.mentions ?? 0) > 0);
                const person = c.with ? people.find((p) => p.id === c.with) : undefined;
                return (
                  <li key={c.id}>
                    <button
                      aria-current={on ? "page" : undefined}
                      className={cn(
                        row,
                        on ? "text-foreground" : loud ? "text-foreground" : "text-muted-foreground hover:text-foreground",
                        c.muted && !on && "opacity-60",
                      )}
                      onClick={() => onSelect(c.id)}
                      type="button"
                    >
                      {on ? <NavPill /> : <span className="absolute inset-0 rounded-lg transition-colors group-hover/row:bg-accent" />}
                      {person ? (
                        <span className="relative -ms-0.5">
                          <PersonAvatar person={person} presence size="size-6" />
                        </span>
                      ) : c.private ? (
                        <LockIcon className="relative size-3.5 shrink-0 opacity-70" />
                      ) : (
                        <HashIcon className="relative size-3.5 shrink-0 opacity-70" />
                      )}
                      <span className={cn("relative min-w-0 flex-1 truncate", loud ? "font-semibold" : "font-medium")}>{c.name}</span>
                      {c.muted && <BellOffIcon aria-label="Muted" className="relative size-3 shrink-0" />}
                      {c.mentions ? (
                        <span className="relative flex h-4.5 min-w-4.5 items-center justify-center rounded-full bg-destructive px-1 font-semibold text-[11px] text-white tabular-nums">
                          {c.mentions}
                        </span>
                      ) : c.unread && kind === "dm" ? (
                        <span className="relative flex h-4.5 min-w-4.5 items-center justify-center rounded-full bg-foreground px-1 font-semibold text-[11px] text-background tabular-nums">
                          {c.unread}
                        </span>
                      ) : null}
                    </button>
                  </li>
                );
              })}
            {kind === "channel" && creating && (
              <li>
                <label className="flex h-8 items-center gap-2 rounded-lg border bg-background px-2.5 shadow-xs/5 focus-within:border-ring/60 focus-within:ring-3 focus-within:ring-ring/16 dark:bg-input/32">
                  <HashIcon className="size-3.5 shrink-0 text-muted-foreground" />
                  <input
                    aria-label="New channel name"
                    autoFocus
                    className="min-w-0 flex-1 bg-transparent font-medium outline-none placeholder:font-normal placeholder:text-muted-foreground"
                    onBlur={finishCreate}
                    onChange={(e) => setNewName(e.target.value)}
                    onKeyDown={(e) => {
                      e.stopPropagation();
                      if (e.key === "Enter") finishCreate();
                      if (e.key === "Escape") {
                        setNewName("");
                        setCreating(false);
                      }
                    }}
                    placeholder="new-channel"
                    value={newName}
                  />
                  <CornerDownLeftIcon className="size-3.5 shrink-0 text-muted-foreground" />
                </label>
              </li>
            )}
          </motion.ul>
        )}
      </AnimatePresence>
    </div>
  );

  const views: { id: ChatView; label: string; icon: typeof AtSignIcon }[] = [
    { id: "threads", label: "Threads", icon: MessagesSquareIcon },
    { id: "mentions", label: "Mentions", icon: AtSignIcon },
    { id: "saved", label: "Saved", icon: BookmarkIcon },
  ];

  return (
    <div className="flex h-full min-h-0 flex-col">
      <div className="flex items-center gap-2 px-3 pt-3 pb-2">
        <span className="flex size-7 shrink-0 items-center justify-center rounded-lg bg-teal-600 font-semibold text-[11px] text-white">
          {initials(workspace)}
        </span>
        <div className="min-w-0 flex-1 leading-tight">
          <div className="truncate font-medium">{workspace}</div>
          <div className="flex items-center gap-1 text-muted-foreground text-xs">
            <span className="size-1.5 rounded-full bg-success" />
            {people.filter((p) => p.presence === "active").length} online
          </div>
        </div>
        {onClose && (
          <Button aria-label="Close channels" onClick={onClose} size="icon-sm" variant="ghost">
            <XIcon />
          </Button>
        )}
      </div>
      <div className="px-2 pb-1">
        <button
          className="flex h-8 w-full cursor-pointer items-center gap-2 rounded-lg border bg-background px-2.5 text-muted-foreground shadow-xs/5 outline-none transition-colors hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring dark:bg-input/32"
          onClick={() => onJump("all")}
          type="button"
        >
          <SearchIcon className="size-4" />
          <span className="flex-1 text-left">Jump to…</span>
          <Kbd>⌘K</Kbd>
        </button>
      </div>
      <nav aria-label="Conversations" className="min-h-0 flex-1 overflow-y-auto px-2 pb-3 [scrollbar-width:thin]">
        <LayoutGroup id="chat-nav">
          <div className="mt-2 flex flex-col gap-0.5">
            {views.map((v) => {
              const on = view === v.id;
              const n = viewCounts[v.id];
              return (
                <button
                  aria-current={on ? "page" : undefined}
                  className={cn(row, on ? "text-foreground" : "text-muted-foreground hover:text-foreground")}
                  key={v.id}
                  onClick={() => onView(v.id)}
                  type="button"
                >
                  {on ? <NavPill /> : <span className="absolute inset-0 rounded-lg transition-colors group-hover/row:bg-accent" />}
                  <v.icon className="relative size-4" />
                  <span className="relative flex-1 font-medium">{v.label}</span>
                  {n > 0 && <span className="relative text-muted-foreground text-xs tabular-nums">{n}</span>}
                </button>
              );
            })}
          </div>
          {section("channel", "Channels")}
          {section("dm", "Direct messages")}
        </LayoutGroup>
      </nav>
    </div>
  );
}

function Reactions({
  reactions,
  me,
  people,
  onToggle,
}: {
  reactions: ChatReaction[];
  me: string;
  people: ChatPerson[];
  onToggle: (emoji: string) => void;
}) {
  if (!reactions.length) return null;
  return (
    <div className="mt-1.5 flex flex-wrap gap-1">
      <AnimatePresence initial={false} mode="popLayout">
        {reactions.map((r) => {
          const mine = r.people.includes(me);
          const names = r.people.map((id) => (id === me ? "You" : (people.find((p) => p.id === id)?.name.split(" ")[0] ?? id)));
          return (
            <motion.button
              animate={{ opacity: 1, scale: 1 }}
              aria-label={`${r.emoji} ${r.people.length}, ${names.join(", ")}`}
              aria-pressed={mine}
              className={cn(
                "flex h-6 cursor-pointer items-center gap-1 rounded-full border px-2 text-xs outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring active:scale-95",
                mine ? "border-info/40 bg-info/10 text-info-foreground" : "bg-background hover:border-foreground/20 dark:bg-input/32",
              )}
              exit={{ opacity: 0, scale: 0.9 }}
              initial={{ opacity: 0, scale: 0.9 }}
              key={r.emoji}
              layout
              onClick={() => onToggle(r.emoji)}
              title={names.join(", ")}
              transition={spring}
              type="button"
            >
              <span className="text-[13px] leading-none">{r.emoji}</span>
              <span className="relative inline-flex h-4 overflow-hidden font-medium tabular-nums">
                <AnimatePresence initial={false} mode="popLayout">
                  <motion.span
                    animate={{ y: 0, opacity: 1 }}
                    exit={{ y: -10, opacity: 0 }}
                    initial={{ y: 10, opacity: 0 }}
                    key={r.people.length}
                    transition={{ duration: 0.18, ease }}
                  >
                    {r.people.length}
                  </motion.span>
                </AnimatePresence>
              </span>
            </motion.button>
          );
        })}
      </AnimatePresence>
    </div>
  );
}

function EmojiPicker({ onPick, className }: { onPick: (emoji: string) => void; className?: string }) {
  const [open, setOpen] = useState(false);
  return (
    <Popover onOpenChange={setOpen} open={open}>
      <PopoverTrigger
        aria-label="Add reaction"
        className={cn(
          "flex size-8 cursor-pointer items-center justify-center rounded-md text-muted-foreground outline-none hover:bg-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring data-popup-open:bg-accent",
          className,
        )}
      >
        <SmilePlusIcon className="size-4" />
      </PopoverTrigger>
      <PopoverPopup align="end" className="p-0" side="top" sideOffset={6}>
        <div className="grid grid-cols-4 gap-0.5 p-1">
          {emojiChoices.map((e) => (
            <button
              aria-label={`React with ${e}`}
              className="flex size-9 cursor-pointer items-center justify-center rounded-lg text-lg outline-none transition-[background-color,scale] duration-100 hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring active:scale-90"
              key={e}
              onClick={() => {
                onPick(e);
                setOpen(false);
              }}
              type="button"
            >
              {e}
            </button>
          ))}
        </div>
      </PopoverPopup>
    </Popover>
  );
}

function MessageRow({
  message,
  grouped,
  people,
  me,
  inThread,
  isNew,
  onReact,
  onSave,
  onOpenThread,
  threadOpen,
  flash,
}: {
  message: ChatMessage | ChatReply;
  grouped: boolean;
  people: ChatPerson[];
  me: string;
  inThread?: boolean;
  isNew?: boolean;
  onReact: (emoji: string) => void;
  onSave: () => void;
  onOpenThread?: () => void;
  threadOpen?: boolean;
  flash?: boolean;
}) {
  const author = people.find((p) => p.id === message.author);
  const full = message as ChatMessage;
  const replies = !inThread ? full.replies : undefined;
  const repliers = replies ? Array.from(new Set(replies.map((r) => r.author))).slice(0, 3) : [];
  return (
    <motion.div
      animate={{ opacity: 1, y: 0 }}
      className={cn(
        "group/msg relative flex gap-3 rounded-xl px-3 transition-colors duration-300 hover:bg-accent/50 focus-within:bg-accent/50",
        grouped ? "py-0.5" : "pt-2 pb-1 [&:not(:first-child)]:mt-1.5",
        threadOpen && "bg-info/6 hover:bg-info/8",
        message.saved && "bg-warning/6 hover:bg-warning/8",
        flash && "bg-warning/16 duration-0",
      )}
      data-msg={message.id}
      initial={isNew ? { opacity: 0, y: 8 } : false}
      transition={spring}
    >
      <div className="w-9 shrink-0">
        {grouped ? (
          <span className="block pt-0.5 text-right text-[10px] text-muted-foreground tabular-nums leading-5 opacity-0 group-hover/msg:opacity-100">
            {message.time.replace(/ (AM|PM)/, "")}
          </span>
        ) : (
          <PersonAvatar person={author} />
        )}
      </div>
      <div className="min-w-0 flex-1">
        {message.saved && (
          <div className="flex items-center gap-1 pb-0.5 font-medium text-warning-foreground text-xs">
            <BookmarkIcon className="size-3 fill-current" />
            Saved for later
          </div>
        )}
        {!grouped && (
          <div className="flex items-baseline gap-2">
            <span className="font-semibold">{author?.name}</span>
            <span className="text-muted-foreground text-xs tabular-nums">{message.time}</span>
          </div>
        )}
        <p className="text-pretty leading-6 [overflow-wrap:anywhere]">
          <RichText me={me} people={people} text={message.text} />
        </p>
        {full.file && (
          <div className="mt-2 flex w-full max-w-sm items-center gap-3 rounded-xl border bg-card p-2.5 shadow-xs/5">
            <span className="flex size-10 shrink-0 items-center justify-center rounded-lg bg-rose-500/12 text-rose-600 dark:text-rose-400">
              <FileTextIcon className="size-5" />
            </span>
            <span className="min-w-0 flex-1 leading-tight">
              <span className="block truncate font-medium">{full.file.name}</span>
              <span className="mt-0.5 block text-muted-foreground text-xs tabular-nums">
                {[fileExt(full.file.name), full.file.pages ? `${full.file.pages} pages` : null, full.file.size].filter(Boolean).join(" · ")}
              </span>
            </span>
          </div>
        )}
        <Reactions me={me} onToggle={onReact} people={people} reactions={message.reactions ?? []} />
        {replies && replies.length > 0 && onOpenThread && (
          <button
            className="group/thr -ms-1.5 mt-1 flex cursor-pointer items-center gap-2 rounded-lg py-1 ps-1.5 pe-2 text-left outline-none transition-colors hover:bg-background focus-visible:ring-2 focus-visible:ring-ring dark:hover:bg-accent"
            onClick={onOpenThread}
            type="button"
          >
            <span className="flex gap-0.5">
              {repliers.map((id) => {
                const p = people.find((x) => x.id === id);
                return p ? <PersonAvatar key={id} person={p} size="size-5" /> : null;
              })}
            </span>
            <span className="font-medium text-info-foreground text-xs">
              {replies.length} {replies.length === 1 ? "reply" : "replies"}
            </span>
            <span className="text-muted-foreground text-xs group-hover/thr:hidden">Last reply {replies[replies.length - 1].time}</span>
            <span className="hidden text-muted-foreground text-xs group-hover/thr:inline">View thread</span>
          </button>
        )}
      </div>

      <div className="pointer-events-none absolute -top-3 right-3 z-10 flex items-center gap-0.5 rounded-lg border bg-popover p-0.5 opacity-0 shadow-xs/5 transition-opacity duration-100 group-hover/msg:pointer-events-auto group-hover/msg:opacity-100 group-focus-within/msg:pointer-events-auto group-focus-within/msg:opacity-100">
        {["✅", "👀", "🙌"].map((e) => (
          <button
            aria-label={`React with ${e}`}
            className="flex size-8 cursor-pointer items-center justify-center rounded-md text-sm outline-none hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring"
            key={e}
            onClick={() => onReact(e)}
            type="button"
          >
            {e}
          </button>
        ))}
        <span className="mx-0.5 h-4 w-px bg-border" />
        <EmojiPicker onPick={onReact} />
        {!inThread && onOpenThread && (
          <button
            aria-label="Reply in thread"
            className="flex size-8 cursor-pointer items-center justify-center rounded-md text-muted-foreground outline-none hover:bg-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
            onClick={onOpenThread}
            type="button"
          >
            <MessageSquareTextIcon className="size-4" />
          </button>
        )}
        <button
          aria-label={message.saved ? "Remove from saved" : "Save for later"}
          aria-pressed={!!message.saved}
          className={cn(
            "flex size-8 cursor-pointer items-center justify-center rounded-md outline-none hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring",
            message.saved ? "text-warning-foreground" : "text-muted-foreground hover:text-foreground",
          )}
          onClick={onSave}
          type="button"
        >
          <BookmarkIcon className={cn("size-4", message.saved && "fill-current")} />
        </button>
      </div>
    </motion.div>
  );
}

function Composer({
  placeholder,
  value,
  onChange,
  onSend,
  inputRef,
  compact,
  file,
  onFile,
}: {
  placeholder: string;
  value: string;
  onChange: (v: string) => void;
  onSend: () => void;
  inputRef?: RefObject<HTMLTextAreaElement | null>;
  compact?: boolean;
  file?: ChatMessage["file"];
  onFile?: (file: ChatMessage["file"]) => void;
}) {
  const local = useRef<HTMLTextAreaElement>(null);
  const ref = inputRef ?? local;
  const fileInput = useRef<HTMLInputElement>(null);
  const canSend = !!value.trim() || !!file;
  const wrap = (left: string, right = left) => {
    const el = ref.current;
    if (!el) return;
    const start = el.selectionStart;
    const end = el.selectionEnd;
    onChange(value.slice(0, start) + left + value.slice(start, end) + right + value.slice(end));
    requestAnimationFrame(() => {
      el.focus();
      el.setSelectionRange(start + left.length, end + left.length);
    });
  };
  useLayoutEffect(() => {
    const el = ref.current;
    if (!el) return;
    el.style.height = "0px";
    el.style.height = `${Math.min(el.scrollHeight, 160)}px`;
  }, [value, ref]);
  const tool =
    "flex size-8 cursor-pointer items-center justify-center rounded-md text-muted-foreground outline-none hover:bg-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring";
  return (
    <div className="rounded-2xl border bg-card shadow-xs/5 transition-shadow focus-within:border-ring/60 focus-within:ring-3 focus-within:ring-ring/16">
      <textarea
        aria-label={placeholder}
        className="block w-full resize-none bg-transparent px-3.5 pt-3 pb-1 text-sm leading-6 outline-none placeholder:text-muted-foreground/70"
        onChange={(e) => onChange(e.target.value)}
        onKeyDown={(e) => {
          if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing) {
            e.preventDefault();
            onSend();
          }
          if ((e.metaKey || e.ctrlKey) && !compact) {
            const k = e.key.toLowerCase();
            if (k === "b" || k === "i" || k === "e") {
              e.preventDefault();
              wrap(k === "b" ? "**" : k === "i" ? "_" : "`");
            }
          }
        }}
        placeholder={placeholder}
        ref={ref}
        rows={1}
        value={value}
      />
      {file && (
        <div className="px-2.5 pb-1">
          <span className="inline-flex h-8 max-w-full items-center gap-2 rounded-lg border bg-background ps-2 pe-0.5 text-xs dark:bg-input/32">
            <FileTextIcon className="size-3.5 shrink-0 text-muted-foreground" />
            <span className="min-w-0 truncate font-medium">{file.name}</span>
            <span className="shrink-0 text-muted-foreground tabular-nums">{file.size}</span>
            <button
              aria-label={`Remove ${file.name}`}
              className="flex size-7 shrink-0 cursor-pointer items-center justify-center rounded-md text-muted-foreground outline-none hover:bg-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
              onClick={() => onFile?.(undefined)}
              type="button"
            >
              <XIcon className="size-3" />
            </button>
          </span>
        </div>
      )}
      <div className="flex items-center gap-0.5 px-2 pb-2">
        {!compact && (
          <>
            <button aria-label="Bold" className={tool} onClick={() => wrap("**")} title="Bold (⌘B)" type="button">
              <BoldIcon className="size-4" />
            </button>
            <button aria-label="Italic" className={tool} onClick={() => wrap("_")} title="Italic (⌘I)" type="button">
              <ItalicIcon className="size-4" />
            </button>
            <button aria-label="Code" className={cn(tool, "hidden @lg:flex")} onClick={() => wrap("`")} title="Code (⌘E)" type="button">
              <CodeIcon className="size-4" />
            </button>
            <span className="mx-1 h-4 w-px bg-border" />
          </>
        )}
        {onFile && (
          <>
            <input
              aria-hidden
              className="hidden"
              onChange={(e) => {
                const f = e.target.files?.[0];
                if (f) onFile({ name: f.name, size: fileSize(f.size) });
                e.target.value = "";
                ref.current?.focus();
              }}
              ref={fileInput}
              tabIndex={-1}
              type="file"
            />
            <button aria-label="Attach a file" className={tool} onClick={() => fileInput.current?.click()} type="button">
              <PaperclipIcon className="size-4" />
            </button>
          </>
        )}
        <button
          aria-label="Mention someone"
          className={tool}
          onClick={() => {
            onChange(`${value}${value && !value.endsWith(" ") ? " " : ""}@`);
            requestAnimationFrame(() => ref.current?.focus());
          }}
          type="button"
        >
          <AtSignIcon className="size-4" />
        </button>
        <div className="ms-auto flex items-center gap-2">
          <span className="hidden text-muted-foreground text-xs @xl:inline">
            <Kbd className="h-4 text-[10px]">⇧↵</Kbd> new line
          </span>
          <Button
            aria-label="Send message"
            className="transition-[box-shadow,scale,opacity] duration-150 ease-out active:scale-[0.97]"
            disabled={!canSend}
            onClick={onSend}
            size="icon-sm"
            variant={canSend ? "default" : "ghost"}
          >
            <SendHorizontalIcon />
          </Button>
        </div>
      </div>
    </div>
  );
}

function TypingIndicator({ name }: { name: string | null }) {
  return (
    <div aria-live="polite" className="flex h-6 items-center gap-2 px-1 text-muted-foreground text-xs">
      <AnimatePresence>
        {name && (
          <motion.span
            animate={{ opacity: 1, y: 0 }}
            className="flex items-center gap-2"
            exit={{ opacity: 0, y: 2 }}
            initial={{ opacity: 0, y: 2 }}
            transition={{ duration: 0.18, ease }}
          >
            <span aria-hidden className="flex gap-0.5">
              {[0, 1, 2].map((i) => (
                <motion.span
                  animate={{ opacity: [0.3, 1, 0.3] }}
                  className="size-1 rounded-full bg-current"
                  key={i}
                  transition={{ duration: 1, repeat: Number.POSITIVE_INFINITY, delay: i * 0.15, ease: "easeInOut" }}
                />
              ))}
            </span>
            <span>
              <span className="font-medium text-foreground">{name}</span> is typing
            </span>
          </motion.span>
        )}
      </AnimatePresence>
    </div>
  );
}

function formatClock(minutes: number) {
  const h = Math.floor(minutes / 60) % 24;
  const m = minutes % 60;
  return `${((h + 11) % 12) + 1}:${String(m).padStart(2, "0")} ${h >= 12 ? "PM" : "AM"}`;
}

type ChatHit = { channel: ChatChannel; message: ChatMessage; reply?: ChatReply };

function JumpPalette({
  mode,
  channels,
  people,
  me,
  onChannel,
  onPerson,
  onClose,
}: {
  mode: "all" | "people";
  channels: ChatChannel[];
  people: ChatPerson[];
  me: string;
  onChannel: (id: string) => void;
  onPerson: (id: string) => void;
  onClose: () => void;
}) {
  const [query, setQuery] = useState("");
  const [index, setIndex] = useState(0);
  const q = query.trim().toLowerCase();
  const items = [
    ...(mode === "all"
      ? channels.filter((c) => c.kind === "channel").map((c) => ({ key: c.id, kind: "channel" as const, label: c.name, channel: c, person: undefined }))
      : []),
    ...people
      .filter((p) => p.id !== me)
      .map((p) => ({ key: `p:${p.id}`, kind: "person" as const, label: p.name, channel: channels.find((c) => c.with === p.id), person: p })),
  ].filter((i) => !q || i.label.toLowerCase().includes(q));
  const current = Math.min(index, Math.max(0, items.length - 1));
  const pick = (i: (typeof items)[number] | undefined) => {
    if (!i) return;
    if (i.person) onPerson(i.person.id);
    else onChannel(i.key);
  };
  return (
    <div className="absolute inset-0 z-50 flex justify-center px-4 pt-16">
      <motion.div
        animate={{ opacity: 1 }}
        aria-hidden
        className="absolute inset-0 bg-black/24"
        exit={{ opacity: 0 }}
        initial={{ opacity: 0 }}
        onClick={onClose}
        transition={{ duration: 0.15 }}
      />
      <motion.div
        animate={{ opacity: 1, scale: 1, y: 0 }}
        aria-label={mode === "people" ? "New direct message" : "Jump to a conversation"}
        aria-modal="true"
        className="relative flex h-fit max-h-[420px] w-full max-w-md origin-top flex-col overflow-hidden rounded-2xl border bg-popover shadow-lg/10"
        exit={{ opacity: 0, scale: 0.98, y: -4 }}
        initial={{ opacity: 0, scale: 0.97, y: -6 }}
        onKeyDown={(e) => {
          e.stopPropagation();
          if (e.key === "Escape") onClose();
          else if (e.key === "ArrowDown") {
            e.preventDefault();
            setIndex((current + 1) % Math.max(1, items.length));
          } else if (e.key === "ArrowUp") {
            e.preventDefault();
            setIndex((current - 1 + items.length) % Math.max(1, items.length));
          } else if (e.key === "Enter") {
            e.preventDefault();
            pick(items[current]);
          }
        }}
        role="dialog"
        transition={{ duration: 0.16, ease }}
      >
        <label className="flex h-12 shrink-0 items-center gap-2.5 border-b px-4">
          <SearchIcon className="size-4 shrink-0 text-muted-foreground" />
          <input
            aria-activedescendant={items[current] ? `jump-${items[current].key}` : undefined}
            aria-controls="jump-list"
            aria-label={mode === "people" ? "Find a person" : "Find a channel or person"}
            autoFocus
            className="min-w-0 flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground"
            onChange={(e) => {
              setQuery(e.target.value);
              setIndex(0);
            }}
            placeholder={mode === "people" ? "Message someone…" : "Jump to a channel or person…"}
            role="combobox"
            aria-expanded
            value={query}
          />
          <Kbd>Esc</Kbd>
        </label>
        <div className="min-h-0 overflow-y-auto p-1.5 [scrollbar-width:thin]" id="jump-list" role="listbox">
          {items.length === 0 ? (
            <p className="px-3 py-6 text-center text-muted-foreground text-xs">Nobody or nothing called “{query}”.</p>
          ) : (
            items.map((i, n) => (
              <div
                aria-selected={n === current}
                className={cn(
                  "flex h-9 cursor-pointer items-center gap-2.5 rounded-lg px-2.5",
                  n === current ? "bg-accent text-foreground" : "text-muted-foreground",
                )}
                id={`jump-${i.key}`}
                key={i.key}
                onClick={() => pick(i)}
                onMouseMove={() => n !== current && setIndex(n)}
                role="option"
                tabIndex={-1}
              >
                {i.person ? (
                  <PersonAvatar person={i.person} presence size="size-6" />
                ) : i.channel?.private ? (
                  <LockIcon className="size-3.5 shrink-0" />
                ) : (
                  <HashIcon className="size-3.5 shrink-0" />
                )}
                <span className="min-w-0 flex-1 truncate font-medium text-foreground">{i.label}</span>
                {i.person?.title && <span className="truncate text-xs">{i.person.title}</span>}
                {!i.person && i.channel?.members ? <span className="text-xs tabular-nums">{i.channel.members} members</span> : null}
              </div>
            ))
          )}
        </div>
      </motion.div>
    </div>
  );
}

const viewMeta: Record<ChatView, { title: string; icon: typeof AtSignIcon; empty: string }> = {
  threads: { title: "Threads", icon: MessagesSquareIcon, empty: "Threads you follow show up here when someone replies." },
  mentions: { title: "Mentions", icon: AtSignIcon, empty: "When someone @mentions you, it lands here." },
  saved: { title: "Saved", icon: BookmarkIcon, empty: "Hover a message and press the bookmark to keep it here for later." },
};

function ViewList({
  view,
  hits,
  people,
  me,
  onOpen,
  onMenu,
}: {
  view: ChatView;
  hits: ChatHit[];
  people: ChatPerson[];
  me: string;
  onOpen: (hit: ChatHit) => void;
  onMenu: () => void;
}) {
  const meta = viewMeta[view];
  return (
    <section aria-label={meta.title} className="flex min-w-0 flex-1 flex-col">
      <header className="flex h-13 shrink-0 items-center gap-2 border-b px-3 @lg:px-4">
        <Button aria-label="Open channels" className="@4xl:hidden" onClick={onMenu} size="icon-sm" variant="ghost">
          <MenuIcon />
        </Button>
        <meta.icon className="size-4 text-muted-foreground" />
        <h2 className="font-semibold">{meta.title}</h2>
        <span className="text-muted-foreground text-xs tabular-nums">{hits.length}</span>
      </header>
      <div className="min-h-0 flex-1 overflow-y-auto px-1 py-2 @lg:px-2 [scrollbar-width:thin]">
        {hits.length === 0 ? (
          <div className="flex h-full flex-col items-center justify-center px-8 pb-10 text-center">
            <span className="flex size-10 items-center justify-center rounded-xl border bg-card text-muted-foreground shadow-xs/5">
              <meta.icon className="size-5" />
            </span>
            <p className="mt-3 font-medium">Nothing here yet</p>
            <p className="mt-1 max-w-64 text-pretty text-muted-foreground text-xs">{meta.empty}</p>
          </div>
        ) : (
          <ul className="flex flex-col gap-0.5">
            {hits.map((h) => {
              const m = h.reply ?? h.message;
              const author = people.find((p) => p.id === m.author);
              const replies = h.message.replies ?? [];
              return (
                <li key={`${h.channel.id}:${h.message.id}:${h.reply?.id ?? ""}`}>
                  <button
                    className="flex w-full cursor-pointer gap-3 rounded-xl px-3 py-2.5 text-left outline-none transition-colors hover:bg-accent/50 focus-visible:ring-2 focus-visible:ring-ring"
                    onClick={() => onOpen(h)}
                    type="button"
                  >
                    <PersonAvatar person={author} />
                    <span className="min-w-0 flex-1">
                      <span className="flex items-baseline gap-2">
                        <span className="truncate font-semibold">{author?.name}</span>
                        <span className="shrink-0 text-muted-foreground text-xs tabular-nums">{m.time}</span>
                        <span className="ms-auto flex shrink-0 items-center gap-1 text-muted-foreground text-xs">
                          {h.channel.kind === "dm" ? h.channel.name : `#${h.channel.name}`}
                          {h.reply && " · thread"}
                        </span>
                      </span>
                      <span className="line-clamp-2 text-pretty leading-6">
                        <RichText me={me} people={people} text={m.text} />
                      </span>
                      {view === "threads" && replies.length > 0 && (
                        <span className="mt-0.5 block font-medium text-info-foreground text-xs">
                          {replies.length} {replies.length === 1 ? "reply" : "replies"}
                          <span className="font-normal text-muted-foreground"> · last {replies[replies.length - 1].time}</span>
                        </span>
                      )}
                    </span>
                  </button>
                </li>
              );
            })}
          </ul>
        )}
      </div>
    </section>
  );
}

const headerBtn =
  "flex h-7 cursor-pointer items-center gap-1.5 rounded-lg border px-2 text-muted-foreground text-xs outline-none hover:bg-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring data-popup-open:bg-accent data-popup-open:text-foreground";

export function TeamChat({
  workspace = "Kestrel",
  me = "maya",
  people = defaultPeople,
  channels: initialChannels = defaultChannels,
  className,
}: TeamChatProps) {
  const [channels, setChannels] = useState(initialChannels);
  const [active, setActive] = useState(initialChannels[0]?.id ?? "");
  const [view, setView] = useState<ChatView | null>(null);
  const [threadId, setThreadId] = useState<string | null>(null);
  const [drawer, setDrawer] = useState(false);
  const [jump, setJump] = useState<"all" | "people" | null>(null);
  const [search, setSearch] = useState<string | null>(null);
  const [pinsOpen, setPinsOpen] = useState(false);
  const [drafts, setDrafts] = useState<Record<string, string>>({});
  const [files, setFiles] = useState<Record<string, ChatMessage["file"]>>({});
  const [typing, setTyping] = useState<{ channel: string; name: string } | null>(null);
  const [fresh, setFresh] = useState<Set<string>>(() => new Set());
  const [unreadFrom, setUnreadFrom] = useState<Record<string, string>>({});
  const [flash, setFlash] = useState<string | null>(null);
  const rootRef = useRef<HTMLDivElement>(null);
  const scrollRef = useRef<HTMLDivElement>(null);
  const threadScrollRef = useRef<HTMLDivElement>(null);
  const composerRef = useRef<HTMLTextAreaElement>(null);
  const threadComposerRef = useRef<HTMLTextAreaElement>(null);
  const searchRef = useRef<HTMLInputElement>(null);
  const timers = useRef<number[]>([]);
  const clock = useRef(11 * 60 + 14);
  const nowTime = () => formatClock(++clock.current);

  useEffect(() => () => timers.current.forEach((t) => window.clearTimeout(t)), []);

  const channel = channels.find((c) => c.id === active) ?? channels[0];
  const thread = view ? null : (channel.messages.find((m) => m.id === threadId) ?? null);
  const dmPerson = channel.with ? people.find((p) => p.id === channel.with) : undefined;
  const meName = people.find((p) => p.id === me)?.name.split(" ")[0] ?? me;

  const hits = useMemo(() => {
    const mention = new RegExp(`@${meName}\\b`, "i");
    const out: Record<ChatView, ChatHit[]> = { threads: [], mentions: [], saved: [] };
    for (const c of channels) {
      for (const m of c.messages) {
        if (m.replies?.length) out.threads.push({ channel: c, message: m });
        if (mention.test(m.text)) out.mentions.push({ channel: c, message: m });
        if (m.saved) out.saved.push({ channel: c, message: m });
        for (const r of m.replies ?? []) {
          if (mention.test(r.text)) out.mentions.push({ channel: c, message: m, reply: r });
          if (r.saved) out.saved.push({ channel: c, message: m, reply: r });
        }
      }
    }
    return out;
  }, [channels, meName]);

  const q = search?.trim().toLowerCase() ?? "";
  const shown = q ? channel.messages.filter((m) => m.text.toLowerCase().includes(q)) : channel.messages;

  const count = channel.messages.length;
  const atBottom = useRef(true);
  useLayoutEffect(() => {
    const el = scrollRef.current;
    if (el) el.scrollTop = el.scrollHeight;
    atBottom.current = true;
  }, [active, count, view, q]);
  useEffect(() => {
    const el = scrollRef.current;
    if (!el) return;
    const ro = new ResizeObserver(() => {
      if (atBottom.current) el.scrollTop = el.scrollHeight;
    });
    ro.observe(el);
    if (el.firstElementChild) ro.observe(el.firstElementChild);
    return () => ro.disconnect();
  }, [active, view]);
  useLayoutEffect(() => {
    if (!flash) return;
    const box = scrollRef.current;
    const target = box?.querySelector<HTMLElement>(`[data-msg="${flash}"]`);
    if (box && target) {
      const offset = target.getBoundingClientRect().top - box.getBoundingClientRect().top;
      box.scrollTo({ top: box.scrollTop + offset - (box.clientHeight - target.offsetHeight) / 2 });
    }
    const t = window.setTimeout(() => setFlash(null), 1400);
    return () => window.clearTimeout(t);
  }, [flash]);
  const threadCount = thread?.replies?.length ?? 0;
  const shownThread = useRef<string | null>(null);
  useLayoutEffect(() => {
    const el = threadScrollRef.current;
    if (!el) return;
    el.scrollTop = shownThread.current === threadId ? el.scrollHeight : 0;
    shownThread.current = threadId;
  }, [threadId, threadCount]);

  const updateChannel = (id: string, fn: (c: ChatChannel) => ChatChannel) =>
    setChannels((cs) => cs.map((c) => (c.id === id ? fn(c) : c)));

  const select = (id: string) => {
    const target = channels.find((c) => c.id === id);
    if (target?.unread && target.messages.length >= target.unread) {
      const first = target.messages[target.messages.length - target.unread];
      setUnreadFrom((u) => ({ ...u, [id]: first.id }));
    }
    updateChannel(id, (c) => ({ ...c, unread: 0, mentions: 0 }));
    setActive(id);
    setView(null);
    setSearch(null);
    setThreadId(null);
    setDrawer(false);
  };

  const closeJump = () => {
    setJump(null);
    requestAnimationFrame(() => composerRef.current?.focus({ preventScroll: true }));
  };

  const openDm = (personId: string) => {
    const existing = channels.find((c) => c.with === personId);
    if (existing) select(existing.id);
    else {
      const p = people.find((x) => x.id === personId);
      const id = `dm-${personId}`;
      setChannels((cs) => [...cs, { id, name: p?.name ?? personId, kind: "dm", with: personId, messages: [] }]);
      setActive(id);
      setView(null);
      setSearch(null);
      setThreadId(null);
      setDrawer(false);
    }
    closeJump();
  };

  const createChannel = (name: string) => {
    if (channels.some((c) => c.id === name)) return select(name);
    setChannels((cs) => {
      const firstDm = cs.findIndex((c) => c.kind === "dm");
      const next: ChatChannel = { id: name, name, kind: "channel", members: 1, messages: [] };
      return firstDm < 0 ? [...cs, next] : [...cs.slice(0, firstDm), next, ...cs.slice(firstDm)];
    });
    setActive(name);
    setView(null);
    setSearch(null);
    setThreadId(null);
    setDrawer(false);
    requestAnimationFrame(() => composerRef.current?.focus({ preventScroll: true }));
  };

  const toggleReaction = (reactions: ChatReaction[] | undefined, emoji: string): ChatReaction[] => {
    const list = reactions ?? [];
    const hit = list.find((r) => r.emoji === emoji);
    if (!hit) return [...list, { emoji, people: [me] }];
    const who = hit.people.includes(me) ? hit.people.filter((p) => p !== me) : [...hit.people, me];
    return who.length ? list.map((r) => (r.emoji === emoji ? { ...r, people: who } : r)) : list.filter((r) => r.emoji !== emoji);
  };

  const editMessage = (msgId: string, fn: (m: ChatMessage) => ChatMessage) =>
    updateChannel(channel.id, (c) => ({ ...c, messages: c.messages.map((m) => (m.id === msgId ? fn(m) : m)) }));

  const editReply = (msgId: string, replyId: string, fn: (r: ChatReply) => ChatReply) =>
    editMessage(msgId, (m) => ({ ...m, replies: m.replies?.map((r) => (r.id === replyId ? fn(r) : r)) }));

  const markFresh = (id: string) => setFresh((f) => new Set(f).add(id));

  const send = () => {
    const text = (drafts[channel.id] ?? "").trim();
    const file = files[channel.id];
    if (!text && !file) return;
    const id = `m${Date.now()}`;
    const chId = channel.id;
    updateChannel(chId, (c) => ({ ...c, messages: [...c.messages, { id, day: "Today", author: me, time: nowTime(), text, file }] }));
    markFresh(id);
    setDrafts((d) => ({ ...d, [chId]: "" }));
    setFiles((f) => ({ ...f, [chId]: undefined }));
    setSearch(null);
    const auto = autoReplies[chId] ?? (channel.with ? { author: channel.with, text: "Sounds good, talk soon." } : null);
    if (auto && !typing) {
      const who = people.find((p) => p.id === auto.author);
      timers.current.push(
        window.setTimeout(() => setTyping({ channel: chId, name: who?.name.split(" ")[0] ?? "Someone" }), 900),
        window.setTimeout(() => {
          const rid = `m${Date.now()}`;
          setTyping(null);
          updateChannel(chId, (c) => ({ ...c, messages: [...c.messages, { id: rid, day: "Today", author: auto.author, time: nowTime(), text: auto.text }] }));
          markFresh(rid);
        }, 2600),
      );
    }
  };

  const sendReply = () => {
    if (!thread) return;
    const key = `thread:${thread.id}`;
    const text = (drafts[key] ?? "").trim();
    if (!text) return;
    const id = `r${Date.now()}`;
    editMessage(thread.id, (m) => ({ ...m, replies: [...(m.replies ?? []), { id, author: me, time: nowTime(), text }] }));
    markFresh(id);
    setDrafts((d) => ({ ...d, [key]: "" }));
  };

  const openThread = (id: string) => {
    setThreadId(id);
    requestAnimationFrame(() => threadComposerRef.current?.focus({ preventScroll: true }));
  };

  const openHit = (h: ChatHit) => {
    select(h.channel.id);
    if (h.reply || view === "threads") openThread(h.message.id);
    else setFlash(h.message.id);
  };

  const onKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
    if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") {
      e.preventDefault();
      setDrawer(false);
      setJump("all");
      return;
    }
    if (e.key === "Escape") {
      if (drawer) setDrawer(false);
      else if (threadId) {
        setThreadId(null);
        composerRef.current?.focus({ preventScroll: true });
      }
      return;
    }
    if (e.altKey && (e.key === "ArrowDown" || e.key === "ArrowUp")) {
      e.preventDefault();
      const idx = view ? -1 : channels.findIndex((c) => c.id === active);
      const next = channels[(idx + (e.key === "ArrowDown" ? 1 : -1) + channels.length) % channels.length];
      select(next.id);
      return;
    }
    if (isTyping(e) || e.metaKey || e.ctrlKey || e.altKey || view) return;
    if (e.key.length === 1 && /\S/.test(e.key)) composerRef.current?.focus({ preventScroll: true });
  };

  const typingHere = typing?.channel === channel.id ? typing.name : null;
  const marker = q ? undefined : unreadFrom[channel.id];
  const pinned = (channel.pinned ?? []).map((id) => channel.messages.find((m) => m.id === id)).filter((m): m is ChatMessage => !!m);

  const navProps = {
    workspace,
    channels,
    people,
    active,
    view,
    viewCounts: { threads: 0, mentions: hits.mentions.length, saved: hits.saved.length },
    onSelect: select,
    onView: (v: ChatView) => {
      setView(v);
      setThreadId(null);
      setSearch(null);
      setDrawer(false);
    },
    onCreateChannel: createChannel,
    onJump: (mode: "all" | "people") => {
      setDrawer(false);
      setJump(mode);
    },
  };

  return (
    <MotionConfig reducedMotion="user">
      <TooltipProvider delay={500}>
        <div
          className={cn(
            "@container relative isolate flex h-[760px] w-full overflow-hidden bg-sidebar text-foreground text-sm outline-none",
            className,
          )}
          onKeyDown={onKeyDown}
          ref={rootRef}
          tabIndex={-1}
        >
          <aside aria-label="Channels" className="hidden w-60 shrink-0 @4xl:block">
            <ChannelNav {...navProps} />
          </aside>

          <AnimatePresence>
            {drawer && (
              <>
                <motion.div
                  animate={{ opacity: 1 }}
                  aria-hidden
                  className="absolute inset-0 z-40 bg-black/32 @4xl:hidden"
                  exit={{ opacity: 0 }}
                  initial={{ opacity: 0 }}
                  onClick={() => setDrawer(false)}
                  transition={{ duration: 0.2 }}
                />
                <motion.div
                  animate={{ x: 0 }}
                  aria-label="Channels"
                  aria-modal="true"
                  className="absolute inset-y-2 left-2 z-50 w-[264px] max-w-[calc(100%-3rem)] overflow-hidden rounded-2xl border bg-sidebar shadow-lg/10 @4xl:hidden"
                  exit={{ x: "-110%" }}
                  initial={{ x: "-110%" }}
                  role="dialog"
                  transition={panelSpring}
                >
                  <ChannelNav {...navProps} onClose={() => setDrawer(false)} />
                </motion.div>
              </>
            )}
          </AnimatePresence>

          <AnimatePresence>
            {jump && (
              <JumpPalette
                channels={channels}
                key={jump}
                me={me}
                mode={jump}
                onChannel={(id) => {
                  select(id);
                  closeJump();
                }}
                onClose={closeJump}
                onPerson={openDm}
                people={people}
              />
            )}
          </AnimatePresence>

          <main className="relative flex min-w-0 flex-1 overflow-hidden bg-background @4xl:my-2 @4xl:me-2 @4xl:rounded-2xl @4xl:border @4xl:shadow-xs/5">
            {view ? (
              <ViewList hits={hits[view]} me={me} onMenu={() => setDrawer(true)} onOpen={openHit} people={people} view={view} />
            ) : (
              <section aria-label={channel.kind === "dm" ? channel.name : `#${channel.name}`} className="flex min-w-0 flex-1 flex-col">
                <header className="flex h-13 shrink-0 items-center gap-1.5 border-b px-3 @lg:px-4">
                  <Button aria-label="Open channels" className="@4xl:hidden" onClick={() => setDrawer(true)} size="icon-sm" variant="ghost">
                    <MenuIcon />
                  </Button>
                  {dmPerson ? (
                    <PersonAvatar person={dmPerson} size="size-6" />
                  ) : channel.private ? (
                    <LockIcon className="size-4 shrink-0 text-muted-foreground" />
                  ) : (
                    <HashIcon className="size-4 shrink-0 text-muted-foreground" />
                  )}
                  <div className="ms-0.5 flex min-w-0 flex-1 items-baseline gap-2">
                    <h2 className="truncate font-semibold">{channel.name}</h2>
                    {channel.topic && <span className="hidden truncate text-muted-foreground text-xs @2xl:block">{channel.topic}</span>}
                    {dmPerson?.title && <span className="hidden truncate text-muted-foreground text-xs @2xl:block">{dmPerson.title}</span>}
                  </div>
                  {channel.members ? (
                    <Popover>
                      <PopoverTrigger aria-label={`${channel.members} members`} className={cn(headerBtn, "me-1 hidden @md:flex")}>
                        <UsersIcon className="size-3.5" />
                        <span className="tabular-nums">{channel.members}</span>
                      </PopoverTrigger>
                      <PopoverPopup align="end" className="w-64">
                        <div className="-mx-1 -my-1.5 flex flex-col gap-0.5">
                          <p className="px-1 pb-1.5 font-medium text-muted-foreground text-xs">
                            {channel.members} {channel.members === 1 ? "member" : "members"}
                          </p>
                          {(channel.members === 1 ? people.filter((p) => p.id === me) : people).map((p) => (
                            <div className="flex h-9 items-center gap-2.5 px-1" key={p.id}>
                              <PersonAvatar person={p} presence size="size-6" />
                              <span className="min-w-0 flex-1 truncate font-medium">
                                {p.name}
                                {p.id === me && <span className="font-normal text-muted-foreground"> (you)</span>}
                              </span>
                              <span className="truncate text-muted-foreground text-xs">{p.title}</span>
                            </div>
                          ))}
                          {channel.members > people.length && (
                            <p className="px-1 pt-1 text-muted-foreground text-xs tabular-nums">and {channel.members - people.length} more</p>
                          )}
                        </div>
                      </PopoverPopup>
                    </Popover>
                  ) : null}
                  <Popover onOpenChange={setPinsOpen} open={pinsOpen}>
                    <Tooltip>
                      <TooltipTrigger
                        render={<PopoverTrigger render={<Button aria-label="Pinned messages" size="icon-sm" variant="ghost" />} />}
                      >
                        <PinIcon />
                      </TooltipTrigger>
                      <TooltipPopup>Pinned</TooltipPopup>
                    </Tooltip>
                    <PopoverPopup align="end" className="w-80">
                      <div className="-mx-1 -my-1.5">
                        <p className="px-1 pb-1.5 font-medium text-muted-foreground text-xs">Pinned in {dmPerson ? "this conversation" : `#${channel.name}`}</p>
                        {pinned.length === 0 ? (
                          <p className="px-1 py-3 text-pretty text-muted-foreground text-xs">Nothing pinned yet. Pins keep decisions and links easy to find.</p>
                        ) : (
                          <div className="flex flex-col gap-0.5">
                            {pinned.map((m) => {
                              const a = people.find((p) => p.id === m.author);
                              return (
                                <button
                                  className="flex w-full cursor-pointer gap-2.5 rounded-lg p-1.5 text-left outline-none hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring"
                                  key={m.id}
                                  onClick={() => {
                                    setPinsOpen(false);
                                    setSearch(null);
                                    setFlash(m.id);
                                  }}
                                  type="button"
                                >
                                  <PersonAvatar person={a} size="size-6" />
                                  <span className="min-w-0 flex-1">
                                    <span className="flex items-baseline gap-1.5 text-xs">
                                      <span className="font-semibold">{a?.name}</span>
                                      <span className="text-muted-foreground tabular-nums">
                                        {m.day} {m.time}
                                      </span>
                                    </span>
                                    <span className="line-clamp-2 text-pretty text-xs leading-5">
                                      <RichText me={me} people={people} text={m.text} />
                                    </span>
                                  </span>
                                </button>
                              );
                            })}
                          </div>
                        )}
                      </div>
                    </PopoverPopup>
                  </Popover>
                  <Tooltip>
                    <TooltipTrigger
                      render={
                        <Button
                          aria-label="Search in channel"
                          aria-pressed={search !== null}
                          onClick={() => {
                            if (search === null) {
                              setSearch("");
                              requestAnimationFrame(() => searchRef.current?.focus());
                            } else setSearch(null);
                          }}
                          size="icon-sm"
                          variant="ghost"
                        />
                      }
                    >
                      <SearchIcon />
                    </TooltipTrigger>
                    <TooltipPopup>Search</TooltipPopup>
                  </Tooltip>
                </header>

                {search !== null && (
                  <div className="flex h-11 shrink-0 items-center gap-2 border-b px-4">
                    <SearchIcon className="size-4 shrink-0 text-muted-foreground" />
                    <input
                      aria-label={`Search ${dmPerson ? dmPerson.name : `#${channel.name}`}`}
                      className="min-w-0 flex-1 bg-transparent outline-none placeholder:text-muted-foreground"
                      onChange={(e) => setSearch(e.target.value)}
                      onKeyDown={(e) => {
                        if (e.key === "Escape") {
                          e.stopPropagation();
                          setSearch(null);
                          composerRef.current?.focus({ preventScroll: true });
                        }
                      }}
                      placeholder={`Search ${dmPerson ? dmPerson.name.split(" ")[0] : `#${channel.name}`}`}
                      ref={searchRef}
                      value={search}
                    />
                    {q && (
                      <span className="shrink-0 text-muted-foreground text-xs tabular-nums">
                        {shown.length} {shown.length === 1 ? "result" : "results"}
                      </span>
                    )}
                    <Button aria-label="Close search" onClick={() => setSearch(null)} size="icon-xs" variant="ghost">
                      <XIcon />
                    </Button>
                  </div>
                )}

                <div
                  className="min-h-0 flex-1 overflow-y-auto [scrollbar-width:thin]"
                  onScroll={(e) => {
                    const el = e.currentTarget;
                    atBottom.current = el.scrollHeight - el.scrollTop - el.clientHeight < 24;
                  }}
                  ref={scrollRef}
                >
                  <div className="flex min-h-full flex-col justify-end px-1 pt-6 pb-2 @lg:px-2">
                    {q && shown.length === 0 ? (
                      <div className="flex flex-1 flex-col items-center justify-center pb-10 text-center">
                        <span className="flex size-10 items-center justify-center rounded-xl border bg-card text-muted-foreground shadow-xs/5">
                          <SearchIcon className="size-5" />
                        </span>
                        <p className="mt-3 font-medium">No messages match</p>
                        <p className="mt-1 text-muted-foreground text-xs">Nothing in {dmPerson ? "this conversation" : `#${channel.name}`} mentions “{search?.trim()}”.</p>
                      </div>
                    ) : (
                      !q && (
                        <div className="mb-4 px-3">
                          <span className="flex size-10 items-center justify-center rounded-xl border bg-card text-muted-foreground shadow-xs/5">
                            {dmPerson ? <PersonAvatar person={dmPerson} size="size-10" /> : <HashIcon className="size-5" />}
                          </span>
                          <h3 className="mt-3 font-heading font-semibold text-base">{dmPerson ? dmPerson.name : `Welcome to #${channel.name}`}</h3>
                          <p className="mt-0.5 text-pretty text-muted-foreground text-xs">
                            {dmPerson
                              ? `This is the start of your conversation with ${dmPerson.name.split(" ")[0]}.`
                              : (channel.topic ?? "You created this channel today. Say what it's for so people know when to use it.")}
                          </p>
                        </div>
                      )
                    )}
                    {shown.map((m, i) => {
                      const prev = shown[i - 1];
                      const newDay = !prev || prev.day !== m.day;
                      const grouped = !q && !newDay && prev.author === m.author && !prev.replies?.length && marker !== m.id && !m.saved;
                      return (
                        <Fragment key={m.id}>
                          {newDay && (
                            <>
                              <div aria-hidden className="mx-3 mt-4 h-px bg-border" />
                              <div className="pointer-events-none sticky top-2 z-10 -mt-[11px] mb-3 flex justify-center" role="separator">
                                <span className="h-[22px] rounded-full border bg-background px-2.5 font-medium text-muted-foreground text-xs leading-5">
                                  {m.day}
                                </span>
                              </div>
                            </>
                          )}
                          {marker === m.id && (
                            <div className="relative my-2 flex items-center" role="separator">
                              <span className="absolute inset-x-3 h-px bg-destructive/50" />
                              <span className="relative ms-auto me-3 rounded-full bg-background ps-2 font-semibold text-[11px] text-destructive-foreground">
                                New
                              </span>
                            </div>
                          )}
                          <MessageRow
                            flash={flash === m.id}
                            grouped={grouped}
                            isNew={fresh.has(m.id)}
                            me={me}
                            message={m}
                            onOpenThread={() => openThread(m.id)}
                            onReact={(emoji) => editMessage(m.id, (x) => ({ ...x, reactions: toggleReaction(x.reactions, emoji) }))}
                            onSave={() => editMessage(m.id, (x) => ({ ...x, saved: !x.saved }))}
                            people={people}
                            threadOpen={threadId === m.id}
                          />
                        </Fragment>
                      );
                    })}
                  </div>
                </div>

                <div className="shrink-0 px-3 pb-3 @lg:px-4">
                  <TypingIndicator name={typingHere} />
                  <Composer
                    file={files[channel.id]}
                    inputRef={composerRef}
                    onChange={(v) => setDrafts((d) => ({ ...d, [channel.id]: v }))}
                    onFile={(f) => setFiles((x) => ({ ...x, [channel.id]: f }))}
                    onSend={send}
                    placeholder={dmPerson ? `Message ${dmPerson.name.split(" ")[0]}` : `Message #${channel.name}`}
                    value={drafts[channel.id] ?? ""}
                  />
                </div>
              </section>
            )}

            <AnimatePresence>
              {thread && (
                <motion.aside
                  animate={{ x: 0, opacity: 1 }}
                  aria-label="Thread"
                  className="absolute inset-0 z-30 flex flex-col border-s bg-background @5xl:static @5xl:z-auto @5xl:w-[380px] @5xl:shrink-0"
                  exit={{ x: 40, opacity: 0 }}
                  initial={{ x: 40, opacity: 0 }}
                  key="thread"
                  transition={{ duration: 0.22, ease }}
                >
                  <header className="flex h-13 shrink-0 items-center gap-2 border-b px-3">
                    <Button aria-label="Back to channel" className="@5xl:hidden" onClick={() => setThreadId(null)} size="icon-sm" variant="ghost">
                      <ArrowLeftIcon />
                    </Button>
                    <div className="min-w-0 flex-1 leading-tight">
                      <div className="font-semibold">Thread</div>
                      <div className="truncate text-muted-foreground text-xs">{dmPerson ? dmPerson.name : `#${channel.name}`}</div>
                    </div>
                    <span className="hidden text-muted-foreground text-xs @5xl:inline">
                      <Kbd>Esc</Kbd>
                    </span>
                    <Button aria-label="Close thread" className="hidden @5xl:inline-flex" onClick={() => setThreadId(null)} size="icon-sm" variant="ghost">
                      <XIcon />
                    </Button>
                  </header>
                  <div className="min-h-0 flex-1 overflow-y-auto px-1 py-3 [scrollbar-width:thin]" ref={threadScrollRef}>
                    <MessageRow
                      grouped={false}
                      inThread
                      me={me}
                      message={thread}
                      onReact={(emoji) => editMessage(thread.id, (x) => ({ ...x, reactions: toggleReaction(x.reactions, emoji) }))}
                      onSave={() => editMessage(thread.id, (x) => ({ ...x, saved: !x.saved }))}
                      people={people}
                    />
                    <div className="relative my-3 flex items-center px-3" role="separator">
                      <span className="pe-2 text-muted-foreground text-xs tabular-nums">
                        {threadCount} {threadCount === 1 ? "reply" : "replies"}
                      </span>
                      <span className="h-px flex-1 bg-border" />
                    </div>
                    {(thread.replies ?? []).map((r, i, all) => (
                      <MessageRow
                        grouped={i > 0 && all[i - 1].author === r.author && !r.saved}
                        inThread
                        isNew={fresh.has(r.id)}
                        key={r.id}
                        me={me}
                        message={r}
                        onReact={(emoji) => editReply(thread.id, r.id, (x) => ({ ...x, reactions: toggleReaction(x.reactions, emoji) }))}
                        onSave={() => editReply(thread.id, r.id, (x) => ({ ...x, saved: !x.saved }))}
                        people={people}
                      />
                    ))}
                  </div>
                  <div className="shrink-0 px-3 pt-1 pb-3">
                    <Composer
                      compact
                      inputRef={threadComposerRef}
                      onChange={(v) => setDrafts((d) => ({ ...d, [`thread:${thread.id}`]: v }))}
                      onSend={sendReply}
                      placeholder="Reply in thread"
                      value={drafts[`thread:${thread.id}`] ?? ""}
                    />
                  </div>
                </motion.aside>
              )}
            </AnimatePresence>
          </main>
        </div>
      </TooltipProvider>
    </MotionConfig>
  );
}
```
