"use client";
import * as React from "react";
import { AnimatePresence, motion, MotionConfig } from "motion/react";
import { ArrowLeft, CheckCircle2, Hash, Lock, Pin, Search, Users, X } from "lucide-react";
import { cn } from "@/lib/utils";
import { AUTO_REPLIES, CHANNELS, INCOMING, LAST_READ, ME_ID, MESSAGES, SEED_NOW, USERS, WORKSPACES } from "./data";
import { Avatar, IconButton, Kbd, PRESENCE_LABEL, expandShortcodes, mentionsHandle, time } from "./chat-ui";
import { ChannelSidebar, WorkspaceRail, dmPartner, type UnreadInfo } from "./chat-sidebar";
import { Composer, type ComposerHandle } from "./composer";
import { EmojiPicker } from "./emoji-picker";
import { MessageList, TypingLine } from "./message-list";
import { PinnedPanel, SearchPanel, ThreadPanel, type Panel } from "./side-panel";
import type { Channel, ChatMessage, ChatUser, OutgoingMessage, Presence, Workspace } from "./types";
export type { Channel, ChatMessage, ChatUser, OutgoingMessage, Workspace };
export interface TeamChatAppProps {
workspaces?: Workspace[];
users?: ChatUser[];
channels?: Channel[];
/** All messages (top-level and thread replies) across workspaces. */
initialMessages?: ChatMessage[];
/** Per-channel "last read" ISO timestamps used for unread badges. */
lastRead?: Record<string, string>;
currentUserId?: string;
initialWorkspaceId?: string;
initialChannelId?: string;
/** Reference "now" for day labels. Defaults to the seed time (demo) or the client clock (your data). */
now?: Date | string;
/** Simulate teammates typing and replying after you send. Defaults to true for the demo data. */
simulateActivity?: boolean;
onSend?: (message: OutgoingMessage) => void;
onReact?: (messageId: string, emoji: string, added: boolean) => void;
/** Called with every message after each change. */
onChange?: (messages: ChatMessage[]) => void;
className?: string;
}
type Toast = { id: number; text: string; undo?: () => void };
type PickerState = {
x: number;
y: number;
onPick: (e: string) => void;
anchor: HTMLElement;
};
let seq = 0;
const newId = (p: string) => `${p}-${Date.now().toString(36)}-${(++seq).toString(36)}`;
const byTs = (a: ChatMessage, b: ChatMessage) => time(a.ts) - time(b.ts);
const wide = (q: string) => typeof window !== "undefined" && window.matchMedia(q).matches;
export function TeamChatApp({
workspaces = WORKSPACES,
users: usersProp = USERS,
channels = CHANNELS,
initialMessages,
lastRead: lastReadProp,
currentUserId = ME_ID,
initialWorkspaceId,
initialChannelId,
now: nowProp,
simulateActivity,
onSend,
onReact,
onChange,
className,
}: TeamChatAppProps) {
const demo = !initialMessages;
const simulate = simulateActivity ?? demo;
const [messages, setMessages] = React.useState<ChatMessage[]>(initialMessages ?? MESSAGES);
const [users, setUsers] = React.useState<ChatUser[]>(usersProp);
const firstWs = initialWorkspaceId ?? channels.find((c) => c.id === initialChannelId)?.workspaceId ?? workspaces[0]?.id ?? "";
const [wsId, setWsId] = React.useState(firstWs);
const [activeByWs, setActiveByWs] = React.useState<Record<string, string>>(() => {
const map: Record<string, string> = {};
for (const w of workspaces) {
const list = channels.filter((c) => c.workspaceId === w.id);
map[w.id] = (w.id === firstWs && initialChannelId) || (demo && w.id === "ws-northwind" ? "c-design" : list[0]?.id) || "";
}
return map;
});
const [lastRead, setLastRead] = React.useState<Record<string, string>>(lastReadProp ?? (demo ? LAST_READ : {}));
const [divider, setDivider] = React.useState<{
channelId: string;
ts: string;
} | null>(null);
const [panel, setPanel] = React.useState<Panel | null>(null);
const [query, setQuery] = React.useState("");
const [scope, setScope] = React.useState<"all" | "channel">("all");
const [editingId, setEditingId] = React.useState<string | null>(null);
const [flash, setFlash] = React.useState<{ id: string; n: number } | null>(null);
const [drafts, setDrafts] = React.useState<Record<string, string>>({});
const [typing, setTyping] = React.useState<Record<string, string[]>>({});
const [mobileChat, setMobileChat] = React.useState(true);
const [picker, setPicker] = React.useState<PickerState | null>(null);
const [toasts, setToasts] = React.useState<Toast[]>([]);
const [now, setNow] = React.useState(() => new Date(nowProp ?? SEED_NOW));
const rootRef = React.useRef<HTMLDivElement>(null);
const composerRef = React.useRef<ComposerHandle>(null);
const timers = React.useRef<number[]>([]);
const mountedAt = React.useRef(0);
const replyCounter = React.useRef(0);
const channelId = activeByWs[wsId] ?? "";
const channel = channels.find((c) => c.id === channelId);
/* ------------------------------- lookups ------------------------------- */
const userMap = React.useMemo(() => new Map(users.map((u) => [u.id, u])), [users]);
const handleMap = React.useMemo(() => new Map(users.map((u) => [u.handle.toLowerCase(), u])), [users]);
const channelMap = React.useMemo(() => new Map(channels.map((c) => [c.id, c])), [channels]);
const me = userMap.get(currentUserId) ?? users[0];
const channelName = React.useCallback(
(c: Channel) => (c.kind === "dm" ? (dmPartner(c, currentUserId, userMap)?.name ?? "Direct message") : `#${c.name}`),
[currentUserId, userMap],
);
/* -------------------------------- clock -------------------------------- */
React.useEffect(() => {
mountedAt.current = Date.now();
if (!nowProp && !demo) setNow(new Date());
}, [nowProp, demo]);
const stamp = React.useCallback(() => new Date(now.getTime() + (Date.now() - (mountedAt.current || Date.now()))).toISOString(), [now]);
React.useEffect(() => {
// Small screens start on the channel list, like a native messaging app.
if (!wide("(min-width: 768px)")) setMobileChat(false);
const t = timers.current;
return () => t.forEach((id) => window.clearTimeout(id));
}, []);
const later = React.useCallback((ms: number, fn: () => void) => {
timers.current.push(window.setTimeout(fn, ms));
}, []);
/* ------------------------------ onChange ------------------------------- */
const onChangeRef = React.useRef(onChange);
React.useLayoutEffect(() => {
onChangeRef.current = onChange;
});
const firstRender = React.useRef(true);
React.useEffect(() => {
if (firstRender.current) {
firstRender.current = false;
return;
}
onChangeRef.current?.(messages);
}, [messages]);
/* ------------------------------- derived ------------------------------- */
const topLevel = React.useMemo(() => messages.filter((m) => m.channelId === channelId && !m.parentId).sort(byTs), [messages, channelId]);
const replies = React.useMemo(() => {
const map = new Map<string, ChatMessage[]>();
for (const m of messages) if (m.parentId) map.set(m.parentId, [...(map.get(m.parentId) ?? []), m]);
for (const list of map.values()) list.sort(byTs);
return map;
}, [messages]);
const visibleNow = mobileChat || wide("(min-width: 768px)");
const unread = React.useMemo(() => {
const out: Record<string, UnreadInfo> = {};
for (const c of channels) out[c.id] = { count: 0, mentions: 0 };
for (const m of messages) {
if (m.parentId || m.authorId === currentUserId) continue;
const lr = lastRead[m.channelId];
if (lr && time(m.ts) <= time(lr)) continue;
if (m.channelId === channelId && visibleNow) continue;
const u = out[m.channelId];
if (!u) continue;
u.count += 1;
if (mentionsHandle(m.text, me.handle)) u.mentions += 1;
}
return out;
}, [messages, lastRead, channels, currentUserId, channelId, me.handle, visibleNow]);
const workspaceUnread = React.useMemo(() => {
const out: Record<string, UnreadInfo> = {};
for (const w of workspaces) out[w.id] = { count: 0, mentions: 0 };
for (const c of channels) {
const u = unread[c.id];
const w = out[c.workspaceId];
if (!u || !w) continue;
w.count += u.count;
w.mentions += c.kind === "dm" ? u.count : u.mentions;
}
return out;
}, [unread, channels, workspaces]);
const typingIn = React.useMemo(
() =>
new Set(
Object.entries(typing)
.filter(([, v]) => v.length)
.map(([k]) => k),
),
[typing],
);
const typingNames = (key: string) => (typing[key] ?? []).map((id) => userMap.get(id)?.name.split(" ")[0] ?? "Someone");
const pinned = topLevel.filter((m) => m.pinned).reverse();
const q = query.trim().toLowerCase();
const results = React.useMemo(() => {
if (!q) return [];
return messages
.filter((m) => {
const c = channelMap.get(m.channelId);
if (!c || c.workspaceId !== wsId) return false;
if (scope === "channel" && m.channelId !== channelId) return false;
const author = userMap.get(m.authorId);
return m.text.toLowerCase().includes(q) || author?.name.toLowerCase().includes(q);
})
.sort((a, b) => byTs(b, a))
.slice(0, 40);
}, [q, messages, channelMap, wsId, scope, channelId, userMap]);
const members = (channel?.memberIds ?? []).map((id) => userMap.get(id)).filter((u): u is ChatUser => !!u);
const memberSet = new Set(channel?.memberIds ?? []);
const people = users.filter((u) => u.id !== currentUserId).sort((a, b) => Number(memberSet.has(b.id)) - Number(memberSet.has(a.id)));
const partner = channel?.kind === "dm" ? dmPartner(channel, currentUserId, userMap) : undefined;
const label = channel ? channelName(channel) : "";
/* ------------------------------- toasts -------------------------------- */
const toast = React.useCallback((text: string, undo?: () => void) => {
const id = ++seq;
setToasts((ts) => [...ts.slice(-2), { id, text, undo }]);
window.setTimeout(() => setToasts((ts) => ts.filter((t) => t.id !== id)), 4500);
}, []);
/* ------------------------------ navigation ----------------------------- */
const markRead = (cid: string) => {
if (!cid) return;
const latest = messages
.filter((m) => m.channelId === cid)
.reduce((acc, m) => (time(m.ts) > time(acc) ? m.ts : acc), lastRead[cid] ?? new Date(0).toISOString());
setLastRead((lr) => ({ ...lr, [cid]: latest }));
};
const openChannel = (cid: string, opts: { keepPanel?: boolean } = {}) => {
const c = channelMap.get(cid);
if (!c) return;
if (cid !== channelId) markRead(channelId);
if (c.workspaceId !== wsId) setWsId(c.workspaceId);
setActiveByWs((a) => ({ ...a, [c.workspaceId]: cid }));
setDivider({
channelId: cid,
ts: lastRead[cid] ?? new Date(0).toISOString(),
});
markRead(cid);
setEditingId(null);
setMobileChat(true);
if (!opts.keepPanel) setPanel((p) => (p?.kind === "search" ? p : null));
};
// Initial divider for the channel we open on.
React.useEffect(() => {
if (channelId) {
setDivider({
channelId,
ts: lastRead[channelId] ?? new Date(0).toISOString(),
});
markRead(channelId);
}
// eslint-disable-next-line react-hooks/exhaustive-deps -- mount only
}, []);
const switchWorkspace = (id: string) => {
if (id === wsId) return;
markRead(channelId);
setWsId(id);
setPanel(null);
setQuery("");
const cid = activeByWs[id];
if (cid) {
setDivider({
channelId: cid,
ts: lastRead[cid] ?? new Date(0).toISOString(),
});
markRead(cid);
}
if (!wide("(min-width: 768px)")) setMobileChat(false);
};
const jumpTo = (m: ChatMessage) => {
const target = m.parentId ? messages.find((x) => x.id === m.parentId) : m;
if (!target) return;
if (target.channelId !== channelId) openChannel(target.channelId, { keepPanel: true });
setFlash({ id: target.id, n: Date.now() });
if (m.parentId) setPanel({ kind: "thread", id: m.parentId });
else if (!wide("(min-width: 1280px)")) setPanel(null);
};
/* ------------------------------- actions ------------------------------- */
const simulateReply = (cid: string, parentId?: string) => {
if (!simulate) return;
const c = channelMap.get(cid);
if (!c) return;
const parent = parentId ? messages.find((m) => m.id === parentId) : undefined;
const candidates = c.memberIds.filter((id) => id !== currentUserId && userMap.get(id)?.presence !== "offline");
const responder = parent && parent.authorId !== currentUserId ? parent.authorId : candidates[replyCounter.current % Math.max(1, candidates.length)];
if (!responder) return;
const key = parentId ? `thread:${parentId}` : cid;
const text = AUTO_REPLIES[replyCounter.current % AUTO_REPLIES.length];
replyCounter.current += 1;
later(900, () =>
setTyping((t) => ({
...t,
[key]: [...new Set([...(t[key] ?? []), responder])],
})),
);
later(900 + 1600 + text.length * 25, () => {
setTyping((t) => ({
...t,
[key]: (t[key] ?? []).filter((id) => id !== responder),
}));
setMessages((ms) => [
...ms,
{
id: newId("m"),
channelId: cid,
authorId: responder,
ts: stamp(),
text,
reactions: [],
parentId,
},
]);
});
};
// One incoming message shortly after the demo opens, so the typing indicator is visible.
React.useEffect(() => {
if (!simulate || !demo) return;
const { channelId: cid, authorId, text } = INCOMING;
later(1600, () => setTyping((t) => ({ ...t, [cid]: [authorId] })));
later(4800, () => {
setTyping((t) => ({ ...t, [cid]: [] }));
setMessages((ms) => [
...ms,
{
id: newId("m"),
channelId: cid,
authorId,
ts: stamp(),
text,
reactions: [],
},
]);
});
// eslint-disable-next-line react-hooks/exhaustive-deps -- mount only
}, []);
const send = (text: string, parentId?: string, alsoToChannel = false) => {
if (!channel) return;
const body = expandShortcodes(text);
const msg: ChatMessage = {
id: newId("m"),
channelId: channel.id,
authorId: currentUserId,
ts: stamp(),
text: body,
reactions: [],
parentId,
};
const extra: ChatMessage[] = alsoToChannel && parentId ? [{ ...msg, id: newId("m"), parentId: undefined }] : [];
setMessages((ms) => [...ms, msg, ...extra]);
setDrafts((d) => ({
...d,
[parentId ? `thread:${parentId}` : channel.id]: "",
}));
setDivider(null);
onSend?.({ channelId: channel.id, text: body, parentId });
simulateReply(channel.id, parentId);
};
const react = React.useCallback(
(id: string, emoji: string) => {
let added = false;
setMessages((ms) =>
ms.map((m) => {
if (m.id !== id) return m;
const existing = m.reactions.find((r) => r.emoji === emoji);
if (!existing) {
added = true;
return {
...m,
reactions: [...m.reactions, { emoji, userIds: [currentUserId] }],
};
}
const has = existing.userIds.includes(currentUserId);
added = !has;
const userIds = has ? existing.userIds.filter((u) => u !== currentUserId) : [...existing.userIds, currentUserId];
return {
...m,
reactions: userIds.length ? m.reactions.map((r) => (r.emoji === emoji ? { ...r, userIds } : r)) : m.reactions.filter((r) => r.emoji !== emoji),
};
}),
);
queueMicrotask(() => onReact?.(id, emoji, added));
},
[currentUserId, onReact],
);
const togglePin = React.useCallback(
(id: string) => {
const m = messages.find((x) => x.id === id);
if (!m) return;
setMessages((ms) => ms.map((x) => (x.id === id ? { ...x, pinned: !x.pinned } : x)));
const c = channelMap.get(m.channelId);
toast(m.pinned ? "Message unpinned" : `Pinned to ${c ? channelName(c) : "conversation"}`);
},
[messages, channelMap, channelName, toast],
);
const saveEdit = React.useCallback(
(id: string, text: string) => {
setMessages((ms) => ms.map((m) => (m.id === id ? (m.text === text ? m : { ...m, text: expandShortcodes(text), editedAt: stamp() }) : m)));
setEditingId(null);
requestAnimationFrame(() => composerRef.current?.focus());
},
[stamp],
);
const remove = React.useCallback(
(id: string) => {
const snapshot = messages;
setMessages((ms) => ms.filter((m) => m.id !== id && m.parentId !== id));
setPanel((p) => (p?.kind === "thread" && p.id === id ? null : p));
toast("Message deleted", () => setMessages(snapshot));
},
[messages, toast],
);
const openPicker = React.useCallback((anchor: HTMLElement, onPick: (e: string) => void) => {
const root = rootRef.current?.getBoundingClientRect();
if (!root) return;
const a = anchor.getBoundingClientRect();
const W = 304;
const H = 340;
const x = Math.max(8, Math.min(a.right - root.left - W, root.width - W - 8));
let y = a.top - root.top - H - 6;
if (y < 8) y = Math.min(a.bottom - root.top + 6, root.height - H - 8);
setPicker({ x, y, onPick, anchor });
}, []);
const closePicker = React.useCallback(() => {
setPicker((p) => {
p?.anchor.focus({ preventScroll: true });
return null;
});
}, []);
const openThread = React.useCallback((id: string) => {
setPanel({ kind: "thread", id });
setEditingId(null);
}, []);
const editLast = () => {
const mine = [...topLevel].reverse().find((m) => m.authorId === currentUserId);
if (mine) setEditingId(mine.id);
};
const setPresence = (p: Presence) => setUsers((us) => us.map((u) => (u.id === currentUserId ? { ...u, presence: p } : u)));
const closePanel = () => {
setPanel(null);
requestAnimationFrame(() => composerRef.current?.focus());
};
const openSearch = () => {
setPanel({ kind: "search" });
};
/* ------------------------------ keyboard ------------------------------- */
const keyRef = React.useRef<(e: KeyboardEvent) => void>(() => {});
React.useLayoutEffect(() => {
keyRef.current = (e) => {
const target = e.target as HTMLElement;
if (!rootRef.current?.contains(target) && target !== document.body) return;
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") {
e.preventDefault();
openSearch();
return;
}
if (e.key === "Escape") {
if (picker) return closePicker();
if (editingId) return setEditingId(null);
if (panel) return closePanel();
if (!wide("(min-width: 768px)") && mobileChat) {
markRead(channelId);
return setMobileChat(false);
}
return;
}
if (e.altKey && (e.key === "ArrowDown" || e.key === "ArrowUp")) {
e.preventDefault();
const list = channels.filter((c) => c.workspaceId === wsId);
const sorted = [...list.filter((c) => c.kind === "channel"), ...list.filter((c) => c.kind === "dm")];
const i = sorted.findIndex((c) => c.id === channelId);
const next = sorted[(i + (e.key === "ArrowDown" ? 1 : -1) + sorted.length) % sorted.length];
if (next) openChannel(next.id);
return;
}
if (target.closest("input,textarea,select,[contenteditable]")) return;
if (e.key === "/" && !e.metaKey && !e.ctrlKey) {
e.preventDefault();
openSearch();
}
};
});
React.useEffect(() => {
const onKey = (e: KeyboardEvent) => keyRef.current(e);
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, []);
React.useEffect(() => {
if (!picker) return;
const onDown = (e: MouseEvent) => {
const el = e.target as HTMLElement;
if (!el.closest("[data-tc-picker]") && !picker.anchor.contains(el)) setPicker(null);
};
window.addEventListener("mousedown", onDown);
return () => window.removeEventListener("mousedown", onDown);
}, [picker]);
/* -------------------------------- render ------------------------------- */
const actions = {
onReact: react,
onOpenPicker: openPicker,
onOpenThread: openThread,
onTogglePin: togglePin,
onStartEdit: setEditingId,
onSaveEdit: saveEdit,
onCancelEdit: () => setEditingId(null),
onDelete: remove,
};
const threadParent = panel?.kind === "thread" ? messages.find((m) => m.id === panel.id) : undefined;
const threadChannel = threadParent ? channelMap.get(threadParent.channelId) : undefined;
const panelNode =
panel?.kind === "thread" && threadParent ? (
<ThreadPanel
key={threadParent.id}
parent={threadParent}
replies={replies.get(threadParent.id) ?? []}
channelLabel={threadChannel ? channelName(threadChannel) : ""}
users={userMap}
handles={handleMap}
me={me}
now={now}
typing={typingNames(`thread:${threadParent.id}`)}
editingId={editingId}
draft={drafts[`thread:${threadParent.id}`] ?? ""}
onDraft={(v) => setDrafts((d) => ({ ...d, [`thread:${threadParent.id}`]: v }))}
onReply={(t, also) => send(t, threadParent.id, also)}
people={people}
onClose={closePanel}
{...actions}
/>
) : panel?.kind === "pinned" ? (
<PinnedPanel
pinned={pinned}
channelLabel={label}
users={userMap}
handles={handleMap}
me={me}
now={now}
onClose={closePanel}
onJump={jumpTo}
onUnpin={togglePin}
/>
) : panel?.kind === "search" ? (
<SearchPanel
query={query}
onQuery={setQuery}
results={results}
channels={channelMap}
channelName={channelName}
scope={scope}
onScope={setScope}
users={userMap}
handles={handleMap}
me={me}
now={now}
onClose={closePanel}
onJump={jumpTo}
/>
) : null;
const wsChannels = channels.filter((c) => c.workspaceId === wsId);
const ws = workspaces.find((w) => w.id === wsId);
return (
<MotionConfig reducedMotion="user">
<div ref={rootRef} className={cn("relative isolate flex h-[760px] w-full overflow-hidden bg-background text-foreground antialiased", className)}>
{/* Sidebar: rail + channels */}
<div className={cn("flex h-full min-w-0 shrink-0 max-md:w-full md:w-[304px] md:border-r", mobileChat ? "max-md:hidden" : "flex")}>
<WorkspaceRail workspaces={workspaces} workspaceId={wsId} workspaceUnread={workspaceUnread} onWorkspace={switchWorkspace} />
<aside aria-label={`${ws?.name ?? "Workspace"} channels`} className="min-w-0 flex-1 bg-muted/35 dark:bg-muted/15">
<ChannelSidebar
workspaces={workspaces}
workspaceId={wsId}
workspaceUnread={workspaceUnread}
channels={wsChannels}
activeChannelId={mobileChat || wide("(min-width: 768px)") ? channelId : ""}
unread={unread}
users={userMap}
me={me}
typingIn={typingIn}
onWorkspace={switchWorkspace}
onChannel={(id) => openChannel(id)}
onPresence={setPresence}
onNewMessage={() => toast("New message — pick someone from Direct messages")}
/>
</aside>
</div>
{/* Conversation */}
<main aria-label={label} className={cn("flex min-w-0 flex-1 flex-col", !mobileChat && "max-md:hidden")}>
{channel ? (
<>
<header className="flex h-14 shrink-0 items-center gap-2 border-b px-3 md:px-4">
<IconButton
label="Back to channels"
onClick={() => {
markRead(channelId);
setMobileChat(false);
}}
className="-ml-1 md:hidden"
>
<ArrowLeft className="size-4" />
{workspaceUnread[wsId]?.count > 0 && <span className="absolute ml-5 -mt-5 size-2 rounded-full bg-rose-500" aria-hidden />}
</IconButton>
{partner ? (
<Avatar user={partner} size={28} presence />
) : channel.private ? (
<Lock className="size-4 shrink-0 text-muted-foreground" aria-hidden />
) : (
<Hash className="size-4 shrink-0 text-muted-foreground" aria-hidden />
)}
<div className="min-w-0 flex-1">
<h1 className="truncate text-[15px] font-bold leading-tight">{partner ? partner.name : channel.name}</h1>
<p className="truncate text-[12px] text-muted-foreground">
{partner
? `${PRESENCE_LABEL[partner.presence]}${partner.status ? ` · ${partner.status.emoji} ${partner.status.text}` : ""}`
: channel.topic}
</p>
</div>
{!partner && (
<button
type="button"
title={`${members.length} members`}
onClick={() => toast(`${members.length} members: ${members.map((u) => u.name.split(" ")[0]).join(", ")}`)}
className="hidden h-8 items-center gap-1.5 rounded-lg border pl-1 pr-2 text-[12px] font-medium text-muted-foreground outline-none transition hover:bg-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring lg:flex"
>
<span className="flex -space-x-1.5">
{members.slice(0, 3).map((u) => (
<Avatar key={u.id} user={u} size={20} className="rounded-md ring-2 ring-background" />
))}
</span>
{members.length}
</button>
)}
<IconButton
label={`Pinned messages (${pinned.length})`}
active={panel?.kind === "pinned"}
onClick={() => setPanel((p) => (p?.kind === "pinned" ? null : { kind: "pinned" }))}
className="relative"
>
<Pin className="size-4" />
{pinned.length > 0 && (
<span className="absolute -right-0.5 -top-0.5 grid h-4 min-w-4 place-items-center rounded-full bg-amber-500 px-1 text-[9.5px] font-bold text-white">
{pinned.length}
</span>
)}
</IconButton>
<button
type="button"
onClick={openSearch}
aria-label="Search messages"
className="hidden h-8 w-44 items-center gap-2 rounded-lg border bg-muted/50 px-2.5 text-[12.5px] text-muted-foreground outline-none transition hover:border-foreground/20 hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring lg:flex dark:bg-muted/30"
>
<Search className="size-3.5" aria-hidden />
<span className="flex-1 text-left">Search</span>
<Kbd>/</Kbd>
</button>
<IconButton label="Search messages" onClick={openSearch} className="lg:hidden">
<Search className="size-4" />
</IconButton>
</header>
<MessageList
channel={channel}
channelLabel={label}
messages={topLevel}
replies={replies}
users={userMap}
handles={handleMap}
me={me}
now={now}
dividerTs={divider?.channelId === channel.id ? divider.ts : null}
editingId={editingId}
flashId={flash?.id ?? null}
partner={partner}
{...actions}
/>
<TypingLine names={typingNames(channel.id)} />
<div className="shrink-0 px-3 pb-3 md:px-4 md:pb-4">
<Composer
ref={composerRef}
key={channel.id}
id="tc-composer"
value={drafts[channel.id] ?? ""}
onValueChange={(v) => setDrafts((d) => ({ ...d, [channel.id]: v }))}
onSend={(t) => send(t)}
people={people}
placeholder={`Message ${partner ? partner.name : `#${channel.name}`}`}
onOpenEmoji={openPicker}
onEditLast={editLast}
/>
</div>
</>
) : (
<div className="grid flex-1 place-items-center text-sm text-muted-foreground">
<span className="flex items-center gap-2">
<Users className="size-4" /> Pick a conversation
</span>
</div>
)}
</main>
{/* Side panel — inline on wide screens, a sheet elsewhere */}
<AnimatePresence>
{panelNode && (
<motion.aside
key="panel"
aria-label={panel?.kind === "thread" ? "Thread" : panel?.kind === "pinned" ? "Pinned messages" : "Search"}
initial={{ x: 40, opacity: 0 }}
animate={{ x: 0, opacity: 1 }}
exit={{ x: 40, opacity: 0 }}
transition={{ type: "spring", stiffness: 460, damping: 40 }}
className="absolute inset-y-0 right-0 z-30 w-full border-l bg-background shadow-2xl sm:w-[380px] xl:static xl:z-auto xl:w-[360px] xl:shrink-0 xl:shadow-none"
>
{panelNode}
</motion.aside>
)}
</AnimatePresence>
{/* Emoji picker layer */}
<AnimatePresence>
{picker && (
<motion.div
data-tc-picker
initial={{ opacity: 0, scale: 0.96, y: 6 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{
opacity: 0,
scale: 0.96,
y: 6,
transition: { duration: 0.1 },
}}
transition={{ type: "spring", stiffness: 600, damping: 36 }}
className="absolute z-50"
style={{ left: picker.x, top: picker.y }}
>
<EmojiPicker
onPick={(e) => {
picker.onPick(e);
setPicker(null);
}}
onClose={closePicker}
/>
</motion.div>
)}
</AnimatePresence>
{/* Toasts */}
<div aria-live="polite" className="pointer-events-none absolute inset-x-0 bottom-24 z-[60] flex flex-col items-center gap-2 px-4">
<AnimatePresence initial={false}>
{toasts.map((t) => (
<motion.div
key={t.id}
layout
initial={{ opacity: 0, y: 20, scale: 0.92 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{
opacity: 0,
y: 10,
scale: 0.95,
transition: { duration: 0.15 },
}}
transition={{ type: "spring", stiffness: 500, damping: 32 }}
role="status"
className="pointer-events-auto flex max-w-full items-center gap-2.5 rounded-xl bg-foreground py-2 pl-3 pr-1.5 text-[13px] text-background shadow-xl shadow-black/20"
>
<CheckCircle2 className="size-4 shrink-0 text-emerald-400 dark:text-emerald-600" aria-hidden />
<span className="min-w-0 truncate font-medium">{t.text}</span>
{t.undo && (
<button
type="button"
onClick={() => {
t.undo?.();
setToasts((ts) => ts.filter((x) => x.id !== t.id));
}}
className="h-7 rounded-md px-2 text-xs font-semibold underline-offset-2 outline-none hover:bg-background/10 hover:underline focus-visible:ring-2 focus-visible:ring-background/60"
>
Undo
</button>
)}
<button
type="button"
aria-label="Dismiss"
onClick={() => setToasts((ts) => ts.filter((x) => x.id !== t.id))}
className="grid size-6 place-items-center rounded-md text-background/60 outline-none hover:bg-background/10 hover:text-background focus-visible:ring-2 focus-visible:ring-background/60"
>
<X className="size-3.5" />
</button>
</motion.div>
))}
</AnimatePresence>
</div>
</div>
</MotionConfig>
);
}
export default TeamChatApp;