Skip to content
1,759 linesCodeBlameRaw
1import {
2 Archive,
3 ArchiveRestore,
4 ChevronLeft,
5 Copy,
6 Ellipsis,
7 Link2,
8 Pencil,
9 Trash2,
10 BellOff,
11 Hash,
12 Lock,
13 MessageSquareText,
14 PanelRight,
15 Star,
16 UserPlus,
17 Users,
18 X,
19} from "lucide-react";
20import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react";
21import { Link, useNavigate, useRouteLoaderData, useSearchParams } from "react-router";
22
23import {
24 type Channel,
25 type ChannelChange,
26 type ChannelMember,
27 type ChatLiveEvent,
28 type ChatMessage,
29 type MemberProfile,
30 type Result,
31 hasCodeAccess,
32 shownUsername,
33} from "@g1t/contracts";
34
35import { useChatData, useChatSend, useChatSidebar } from "./actions";
36import { CardBox, CardToasts, showToast } from "./card";
37import { Composer } from "./composer";
38import { type LiveState, useChatLive } from "./live";
39import { AgentPill, MemberAvatar } from "./marks";
40import { PersonStatusEmoji, PersonStatusLine, PresenceSummary, WithPresence } from "../presence";
41import { CardContext, MemberCard, type PersonCard, personCard } from "./profile-card";
42
43import { localTime } from "../../lib/time-zone";
44import { BottomSheet, SheetRow, useBack, useSwipeBack } from "../mobile";
45import { MessageText, type TextContext } from "./text";
46import { FolioUnfurls } from "../folios/unfurl";
47import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "../ui/dropdown-menu";
48import { threadLink } from "../../lib/write-up";
49import {
50 AlertDialog,
51 AlertDialogAction,
52 AlertDialogCancel,
53 AlertDialogContent,
54 AlertDialogDescription,
55 AlertDialogFooter,
56 AlertDialogHeader,
57 AlertDialogTitle,
58} from "../ui/alert-dialog";
59import { Avatar } from "../ui/avatar";
60import { Badge } from "../ui/badge";
61import { Hint } from "../ui/hint";
62import { Button } from "../ui/button";
63import { Card } from "../ui/card";
64import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover";
65import {
66 type Mentionable,
67 type ShownMessage,
68 type Typer,
69 channelPath,
70 formerAgentDm,
71 formerAgentLine,
72 mentionNames,
73 mergeMessages,
74 shownHandle,
75 shownName,
76 timeline,
77 typingLabel,
78} from "../../lib/chat";
79import { codeAccessPath } from "../../lib/workspace-nav";
80import { conversationCache } from "./conversation-cache";
81import { atBottom, useKeepAtBottom } from "./stick";
82// Reactions and the workspace's own emoji (components/emoji).
83import { EmojiProvider, useCustomEmoji } from "../emoji/context";
84import { AddReaction, QuickReactions, ReactionBar } from "../emoji/reaction-bar";
85import { useAddresses } from "../../lib/addresses";
86import { applyReactionEvent, keepMine, toggleReaction } from "../../lib/emoji";
87import { dndNow } from "../../lib/notify-client";
88import { chatMessageArrived, messageSent, viewingConversation } from "../../lib/sound-events";
89import type { ChannelData } from "../../routes/workspace/chat/channel";
90
91type Loaded = Extract<ChannelData, { unavailable: false }>;
92
93/** True once the page has hydrated: times and days are the reader's own from then on. */
94function useHydrated(): boolean {
95 const [hydrated, setHydrated] = useState(false);
96 useEffect(() => setHydrated(true), []);
97 return hydrated;
98}
99
100function clock(at: string, zone: string | undefined): string {
101 return new Date(at).toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit", timeZone: zone });
102}
103
104/** Whether the viewer uses Code in this workspace. */
105export function useCodeAccess(slug: string): boolean {
106 const root = useRouteLoaderData("root") as { user?: { workspaces?: { slug: string; code_access?: boolean }[] } | null } | undefined;
107 return hasCodeAccess(root?.user?.workspaces?.find((m) => m.slug === slug) ?? null);
108}
109
110/** The project a channel of the same name is about, for a bare `#123`. */
111function useProjectNamed(name: string | null): string | null {
112 const root = useRouteLoaderData("root") as { shell?: { repos?: { name: string }[] } } | undefined;
113 if (!name) return null;
114 return root?.shell?.repos?.some((repo) => repo.name === name) ? name : null;
115}
116
117function newClientId(): string {
118 return `tmp-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
119}
120
121/** A conversation: its header, its messages, the box to write in, and its thread and details beside it. */
122export function ChannelView({ data }: { data: Loaded }) {
123 const chatData = useChatData();
124 const slug = chatData?.slug ?? "";
125 const me = chatData?.me ?? null;
126 const send = useChatSend(slug);
127 const { sidebar, refresh } = useChatSidebar();
128 const hydrated = useHydrated();
129 const zone = hydrated ? undefined : "UTC";
130 const code = useCodeAccess(slug);
131 const project = useProjectNamed(data.channel.kind === "channel" ? data.channel.name : null);
132 const [params, setParams] = useSearchParams();
133 const threadId = params.get("thread");
134
135 const [messages, setMessages] = useState<ShownMessage[]>(() => mergeMessages([], data.messages));
136 const [older, setOlder] = useState<string | null>(data.older);
137 const [loadingOlder, setLoadingOlder] = useState(false);
138 const [thread, setThread] = useState<ShownMessage[] | null>(null);
139 // The message the open thread is under, as its page says, for when it is
140 // further back than the conversation has loaded (a long-running session's card).
141 const [threadRoot, setThreadRoot] = useState<ShownMessage | null>(null);
142 // Who is typing, and until when.
143 const [typing, setTyping] = useState<Map<string, { member: MemberProfile; until: number }>>(new Map());
144 const [joined, setJoined] = useState(data.joined);
145 const [members, setMembers] = useState<ChannelMember[]>(data.members);
146 // The channel as it is now: renamed, its topic changed, archived, live.
147 const [channel, setChannel] = useState<Channel>(data.channel);
148 const [canManage, setCanManage] = useState(data.can_manage === true);
149 const view = useMemo<Loaded>(() => ({ ...data, channel, title: channel.kind === "channel" ? `#${channel.name}` : data.title }), [data, channel]);
150 // The server's answer behind a cached draw: merged in by id.
151 useEffect(() => {
152 const onFresh = (event: Event) => {
153 const { key, value } = (event as CustomEvent<{ key: string; value: ChannelData }>).detail;
154 if (key !== window.location.pathname || value.unavailable || value.channel.id !== data.channel.id) return;
155 setMessages((now) => mergeMessages(now, value.messages));
156 setMembers(value.members);
157 setJoined(value.joined);
158 setChannel(value.channel);
159 setCanManage(value.can_manage === true);
160 };
161 window.addEventListener("g1t:chat-fresh", onFresh);
162 return () => window.removeEventListener("g1t:chat-fresh", onFresh);
163 }, [data.channel.id]);
164 // The cache follows what is on screen, so coming back shows the latest.
165 useEffect(() => {
166 const path = window.location.pathname;
167 const timer = setTimeout(
168 () => conversationCache.set(path, { ...view, messages: messages.filter((m) => !m.pending).slice(-80), members, joined, can_manage: canManage }),
169 400,
170 );
171 return () => clearTimeout(timer);
172 }, [view, messages, members, joined, canManage]);
173 // Renamed while open, here or by someone else: the address follows the name.
174 const navigate = useNavigate();
175 const nameShown = useRef(data.channel.name);
176 useEffect(() => {
177 if (channel.kind !== "channel" || channel.name === nameShown.current || !slug) return;
178 nameShown.current = channel.name;
179 navigate(`${channelPath(slug, channel)}${window.location.search}`, { replace: true, preventScrollReset: true });
180 }, [channel, slug, navigate]);
181 const [info, setInfo] = useState(false);
182 // A phone: back to the list, by the chevron or a swipe from the edge;
183 // and a long-pressed message's actions.
184 const back = useBack(`/${chatData?.slug ?? ""}/-/chat`);
185 const swipe = useSwipeBack(back);
186 const [actions, setActions] = useState<ShownMessage | null>(null);
187 // Someone's profile, opened from their card, beside the conversation.
188 const [profile, setProfile] = useState<string | null>(null);
189 // A message being edited: in its place on a computer (`inline`), in a sheet on a phone.
190 const [editing, setEditing] = useState<{ id: string; body: string; inline?: boolean } | null>(null);
191 const scroller = useRef<HTMLDivElement>(null);
192 const stick = useRef(true);
193
194 const entry = sidebar?.entries.find((e) => e.channel.id === data.channel.id);
195 const starred = entry?.starred ?? members.find((m) => m.member.kind === "user" && m.member.id === me?.id)?.starred ?? false;
196 const muted = entry?.muted ?? false;
197
198 // Sounds know which conversation is on screen: it is silent while looked at (lib/sound-events.ts).
199 useEffect(() => {
200 viewingConversation(data.channel.id);
201 return () => viewingConversation(null);
202 }, [data.channel.id]);
203 const everyoneElse = members.filter((m) => !(m.member.kind === "user" && m.member.id === me?.id)).map((m) => m.member);
204 // An old agent (agents no longer exist) is not in the conversation any more, unless it was only ever the two of you: then it is who the conversation was with.
205 const stillHere = everyoneElse.filter((m) => m.kind !== "agent");
206 const others = stillHere.length > 0 ? stillHere : everyoneElse;
207 const isDm = data.channel.kind === "dm";
208 // An old direct message with an agent alone opens read-only: a note stands where the composer was (lib/chat.ts).
209 const formerAgent = isDm ? formerAgentDm({ channel: data.channel, others }) : null;
210
211 // The details panel: open by default on a wide screen, as last left.
212 useEffect(() => {
213 try {
214 const saved = localStorage.getItem("chat-info");
215 setInfo(saved ? saved === "1" : window.matchMedia("(min-width: 1280px)").matches && !isDm);
216 } catch {
217 setInfo(false);
218 }
219 }, [isDm]);
220 const toggleInfo = () =>
221 setInfo((now) => {
222 try {
223 localStorage.setItem("chat-info", now ? "0" : "1");
224 } catch {
225 // Not kept: fine.
226 }
227 return !now;
228 });
229
230 const people = useMemo<Mentionable[]>(
231 () => (chatData?.people ?? []).filter((p) => p.name !== me?.username),
232 [chatData, me?.username],
233 );
234 const context = useMemo<TextContext>(
235 () => ({
236 slug,
237 me: me?.username ?? null,
238 // `@ana` reads as the name people know: everyone in the workspace, the viewer too.
239 names: mentionNames([...people, ...(me ? [{ kind: "user" as const, name: me.username, display_username: me.display_username, display_name: me.display_name, avatar: me.avatar }] : [])]),
240 channels: new Set((sidebar?.entries ?? []).filter((e) => e.channel.kind === "channel").map((e) => e.channel.name ?? "")),
241 project,
242 codeLink: code ? undefined : (href: string) => (/^\/[^/]+\/(?!-\/)[^/]+/.test(href) ? codeAccessPath(slug, href) : href),
243 }),
244 [slug, me, people, sidebar?.entries, project, code],
245 );
246
247 // Keeps the newest message in view while the reader is at the bottom.
248 const toBottom = useCallback((smooth = false) => {
249 const element = scroller.current;
250 if (element) element.scrollTo({ top: element.scrollHeight, behavior: smooth ? "smooth" : "auto" });
251 }, []);
252 useEffect(() => toBottom(), [toBottom]);
253 useEffect(() => {
254 if (stick.current) toBottom(true);
255 }, [messages.length, typing.size, toBottom]);
256 // And while the list or what is in it changes size: the keyboard opening on a phone, a card laying out.
257 useKeepAtBottom(scroller, stick);
258
259 // Read up to the newest message whenever it is in view.
260 const lastRead = useRef<string | null>(null);
261 const markRead = useCallback(() => {
262 if (!joined || document.visibilityState !== "visible") return;
263 const newest = [...messages].reverse().find((m) => !m.pending);
264 if (!newest || newest.id === lastRead.current) return;
265 lastRead.current = newest.id;
266 void send({ intent: "read", channel_id: data.channel.id, id: newest.id });
267 window.dispatchEvent(new CustomEvent("g1t:chat-read", { detail: data.channel.id }));
268 }, [messages, joined, send, data.channel.id]);
269 useEffect(() => {
270 const timer = setTimeout(markRead, 400);
271 document.addEventListener("visibilitychange", markRead);
272 window.addEventListener("focus", markRead);
273 return () => {
274 clearTimeout(timer);
275 document.removeEventListener("visibilitychange", markRead);
276 window.removeEventListener("focus", markRead);
277 };
278 }, [markRead]);
279
280 // The thread open beside the conversation, from `?thread=`.
281 const loadThread = useCallback(async () => {
282 if (!threadId) return setThread(null);
283 try {
284 const response = await fetch(`/${slug}/-/chat/api?channel=${encodeURIComponent(data.channel.id)}&thread=${encodeURIComponent(threadId)}`);
285 const result = (await response.json()) as Result<{ messages: ChatMessage[]; root?: ChatMessage | null }>;
286 // The page reaches back to the message the thread is under: shown above it, not among the replies.
287 setThread(result.ok ? mergeMessages([], result.value.messages.filter((m) => m.id !== threadId && !m.deleted_at)) : []);
288 const root = result.ok ? (result.value.root ?? result.value.messages.find((m) => m.id === threadId) ?? null) : null;
289 setThreadRoot(root);
290 } catch {
291 setThread([]);
292 }
293 }, [threadId, slug, data.channel.id]);
294 useEffect(() => {
295 setThread(null);
296 setThreadRoot(null);
297 void loadThread();
298 }, [loadThread]);
299
300 // Reply counts: counted here only until the service's own count for that
301 // message arrives (a `message.updated` of it), which can come before or
302 // after the reply itself; never twice for one reply.
303 const serverCounted = useRef(new Set<string>());
304 const countedReplies = useRef(new Set<string>());
305 const countReply = useCallback((root: string, reply: string, at: string) => {
306 if (serverCounted.current.has(root) || countedReplies.current.has(reply)) return;
307 countedReplies.current.add(reply);
308 setMessages((now) => now.map((m) => (m.id === root ? { ...m, reply_count: m.reply_count + 1, last_reply_at: at } : m)));
309 }, []);
310 const latest = useRef(messages);
311 latest.current = messages;
312
313 // Everything that happens while the page is open.
314 const onEvent = useCallback(
315 (event: ChatLiveEvent) => {
316 if (event.type === "message.created" || event.type === "message.updated") {
317 const message = event.message;
318 if (message.channel_id !== data.channel.id) return;
319 // A sound, when the rules say so: not for your own, not while looking at it in front.
320 if (event.type === "message.created") chatMessageArrived(message, data.channel.kind, { dnd: dndNow(), muted });
321 if (message.thread_root) {
322 if (message.thread_root === threadId) setThread((now) => (now ? mergeMessages(now, [keepMine(now, message)]) : now));
323 if (event.type === "message.created") countReply(message.thread_root, message.id, message.created_at);
324 } else {
325 if (event.type === "message.updated") serverCounted.current.add(message.id);
326 setMessages((now) => mergeMessages(now, [keepMine(now, message)]));
327 // The open thread's root, a session's card say, changes in place at its top too.
328 if (message.id === threadId) setThreadRoot((now) => (now ? keepMine([now], message) : now));
329 }
330 // Whoever just spoke is no longer typing.
331 setTyping((now) => {
332 const key = `${message.author.kind}:${message.author.id}`;
333 if (!now.has(key)) return now;
334 const next = new Map(now);
335 next.delete(key);
336 return next;
337 });
338 } else if (event.type === "message.deleted") {
339 if (event.channel_id !== data.channel.id) return;
340 const gone = (m: ShownMessage) => (m.id === event.id ? { ...m, deleted_at: new Date().toISOString() } : m);
341 setMessages((now) => now.map(gone));
342 setThread((now) => now?.map(gone) ?? now);
343 setThreadRoot((now) => (now ? gone(now) : now));
344 } else if (event.type === "reaction.added" || event.type === "reaction.removed") {
345 if (event.channel_id !== data.channel.id) return;
346 setMessages((now) => applyReactionEvent(now, event, me?.id ?? null));
347 setThread((now) => (now ? applyReactionEvent(now, event, me?.id ?? null) : now));
348 } else if (event.type === "typing") {
349 if (event.channel_id !== data.channel.id) return;
350 if (event.member.kind === "user" && event.member.id === me?.id) return;
351 setTyping((now) => new Map(now).set(`${event.member.kind}:${event.member.id}`, { member: event.member, until: new Date(event.until).getTime() }));
352 } else if (event.type === "channel.updated") {
353 // Renamed, a new topic, archived or back: everyone looking sees it at once.
354 if (event.channel.id !== data.channel.id) return;
355 setChannel(event.channel);
356 refresh();
357 }
358 },
359 [data.channel.id, data.channel.kind, muted, threadId, me?.id, countReply, refresh],
360 );
361 // Back after a drop: what was missed.
362 const onReconnect = useCallback(async () => {
363 const base = `/${slug}/-/chat/api?channel=${encodeURIComponent(data.channel.id)}`;
364 try {
365 // Everything after the newest message held, page by page, oldest first;
366 // what was deleted meanwhile comes back empty and is dropped.
367 let after = [...latest.current].reverse().find((m) => !m.pending)?.id ?? null;
368 for (let pages = 0; after && pages < 20; pages++) {
369 const response = await fetch(`${base}&after=${encodeURIComponent(after)}`);
370 const result = (await response.json()) as Result<{ messages: ChatMessage[]; newer?: string | null }>;
371 if (!result.ok) break;
372 const gone = new Set(result.value.messages.filter((m) => m.deleted_at).map((m) => m.id));
373 const kept = result.value.messages.filter((m) => !m.deleted_at && !m.thread_root);
374 setMessages((now) => mergeMessages(now.filter((m) => !gone.has(m.id)), kept));
375 after = result.value.newer ?? null;
376 }
377 // Edits and deletions to what was already on screen: the latest page again.
378 const response = await fetch(base);
379 const result = (await response.json()) as Result<{ messages: ChatMessage[] }>;
380 if (result.ok) {
381 const gone = new Set(result.value.messages.filter((m) => m.deleted_at).map((m) => m.id));
382 setMessages((now) => mergeMessages(now.filter((m) => !gone.has(m.id)), result.value.messages.filter((m) => !m.deleted_at)));
383 }
384 } catch {
385 // The next event, or the next return, catches up.
386 }
387 void loadThread();
388 refresh();
389 }, [slug, data.channel.id, loadThread, refresh]);
390 const live = useChatLive(slug, data.channel.id, onEvent, onReconnect);
391
392 // Typing shows until its time runs out.
393 useEffect(() => {
394 if (typing.size === 0) return;
395 const timer = setInterval(() => {
396 const now = Date.now();
397 setTyping((current) => {
398 const next = new Map([...current].filter(([, value]) => value.until > now));
399 return next.size === current.size ? current : next;
400 });
401 }, 1000);
402 return () => clearInterval(timer);
403 }, [typing.size]);
404
405 // Says "is typing" to the others, at most every few seconds.
406 const lastTyping = useRef(0);
407 const onTyping = useCallback(() => {
408 const now = Date.now();
409 if (now - lastTyping.current < 3000) return;
410 lastTyping.current = now;
411 live.send({ type: "typing", channel_id: data.channel.id });
412 }, [live, data.channel.id]);
413
414 const post = useCallback(
415 async (body: string, root: string | null) => {
416 if (!me) return;
417 const clientId = newClientId();
418 const author: MemberProfile = { kind: "user", id: me.id, name: me.username, display_username: me.display_username, display_name: me.display_name, avatar: me.avatar, role: null };
419 const pending: ShownMessage = {
420 id: clientId,
421 client_id: clientId,
422 pending: true,
423 channel_id: data.channel.id,
424 author,
425 kind: "text",
426 body,
427 card: null,
428 thread_root: root,
429 reply_count: 0,
430 last_reply_at: null,
431 created_at: new Date().toISOString(),
432 edited_at: null,
433 deleted_at: null,
434 };
435 const put = (list: ShownMessage[]) => mergeMessages(list, [pending]);
436 if (root) setThread((now) => put(now ?? []));
437 else {
438 stick.current = true;
439 setMessages(put);
440 }
441 messageSent({ dnd: dndNow() });
442 const saved = await send<ChatMessage>({ intent: "post", channel_id: data.channel.id, body, thread_root: root });
443 const settle = (list: ShownMessage[]) =>
444 saved.ok
445 ? mergeMessages(list, [{ ...saved.value, client_id: clientId }])
446 : list.map((m) => (m.client_id === clientId ? { ...m, failed: true, pending: true } : m));
447 if (root) {
448 setThread((now) => (now ? settle(now) : now));
449 if (saved.ok) countReply(root, saved.value.id, saved.value.created_at);
450 } else setMessages(settle);
451 },
452 [me, send, data.channel.id, countReply],
453 );
454 // ── Reactions (components/emoji): shown at once, then as the service counts them.
455 const customs = useCustomEmoji(slug);
456 const { usercontent } = useAddresses();
457 const meProfile = useMemo<MemberProfile | null>(
458 () => (me ? { kind: "user", id: me.id, name: me.username, display_username: me.display_username, display_name: me.display_name, avatar: me.avatar, role: null } : null),
459 [me],
460 );
461 const react = useCallback(
462 async (messageId: string, emoji: string, on: boolean) => {
463 if (!meProfile) return;
464 const change = (to: boolean) => (list: ShownMessage[]) =>
465 list.map((m) => (m.id === messageId ? { ...m, reactions: toggleReaction(m.reactions ?? [], emoji, to, meProfile) } : m));
466 setMessages(change(on));
467 setThread((now) => (now ? change(on)(now) : now));
468 try {
469 const response = await fetch(`/${slug}/-/chat/api`, {
470 method: "POST",
471 headers: { "content-type": "application/json" },
472 body: JSON.stringify({ intent: on ? "react" : "unreact", channel_id: data.channel.id, message_id: messageId, emoji }),
473 });
474 const result = (await response.json()) as Result<ChatMessage["reactions"]>;
475 if (!result.ok) throw new Error(result.error.message);
476 const settle = (list: ShownMessage[]) => list.map((m) => (m.id === messageId ? { ...m, reactions: result.value } : m));
477 setMessages(settle);
478 setThread((now) => (now ? settle(now) : now));
479 } catch {
480 // Not saved: back as it was.
481 setMessages(change(!on));
482 setThread((now) => (now ? change(!on)(now) : now));
483 }
484 },
485 [meProfile, slug, data.channel.id],
486 );
487 const emojiContext = useMemo(
488 () => ({ customs, usercontent, me: meProfile, react: (id: string, emoji: string, on: boolean) => void react(id, emoji, on), manageHref: `/${slug}/-/emoji` }),
489 [customs, usercontent, meProfile, react, slug],
490 );
491
492 const retry = (message: ShownMessage) => {
493 const drop = (list: ShownMessage[]) => list.filter((m) => m.client_id !== message.client_id);
494 if (message.thread_root) setThread((now) => (now ? drop(now) : now));
495 else setMessages(drop);
496 void post(message.body, message.thread_root);
497 };
498
499 const loadOlder = async () => {
500 if (!older || loadingOlder) return;
501 setLoadingOlder(true);
502 const element = scroller.current;
503 const fromBottom = element ? element.scrollHeight - element.scrollTop : 0;
504 try {
505 const response = await fetch(`/${slug}/-/chat/api?channel=${encodeURIComponent(data.channel.id)}&before=${encodeURIComponent(older)}`);
506 const result = (await response.json()) as Result<{ messages: ChatMessage[]; older: string | null }>;
507 if (result.ok) {
508 stick.current = false;
509 setMessages((now) => mergeMessages(now, result.value.messages));
510 setOlder(result.value.older);
511 requestAnimationFrame(() => {
512 if (element) element.scrollTop = element.scrollHeight - fromBottom;
513 });
514 }
515 } finally {
516 setLoadingOlder(false);
517 }
518 };
519
520 const openThread = (id: string | null) => {
521 const next = new URLSearchParams(params);
522 if (id) next.set("thread", id);
523 else next.delete("thread");
524 setParams(next, { preventScrollReset: true, replace: !!threadId && !!id });
525 };
526
527 // A thread's link, to copy and to cite.
528 const linkToThread = (root: string) => threadLink(window.location.origin, channelPath(slug, channel), root);
529 const copyThreadLink = (root: string) => {
530 const done = navigator.clipboard?.writeText(linkToThread(root));
531 if (!done) return showToast(false, "Couldn't copy the link here.");
532 done.then(
533 () => showToast(true, "Link to the thread copied."),
534 () => showToast(false, "Couldn't copy the link here."),
535 );
536 };
537 const updateChannel = useCallback(
538 async (change: ChannelChange): Promise<Result<unknown>> => {
539 const done = await send<{ channel: Channel }>({ intent: "update_channel", channel_id: data.channel.id, ...change });
540 if (done.ok) setChannel(done.value.channel);
541 return done;
542 },
543 [send, data.channel.id],
544 );
545
546 const toggleStar = async () => {
547 await send({ intent: "preferences", channel_id: data.channel.id, starred: !starred });
548 refresh();
549 };
550
551 const join = async () => {
552 const done = await send({ intent: "join", channel_id: data.channel.id });
553 if (done.ok) {
554 setJoined(true);
555 if (me) {
556 setMembers((now) => [
557 ...now,
558 {
559 channel_id: data.channel.id,
560 member: { kind: "user", id: me.id, name: me.username, display_username: me.display_username, display_name: me.display_name, avatar: me.avatar, role: null },
561 role: "member",
562 starred: false,
563 muted: false,
564 last_read_id: null,
565 joined_at: new Date().toISOString(),
566 },
567 ]);
568 }
569 }
570 };
571
572 const rows = useMemo(() => timeline(messages, new Date(), zone), [messages, zone]);
573 const mine = (message: ShownMessage) => message.author.kind === "user" && message.author.id === me?.id && !message.pending && !message.deleted_at;
574 // Editing uses the composer, started from the message's Markdown.
575 const editor = (target: { id: string; body: string }) => (
576 <Composer
577 initial={target.body}
578 placeholder="Edit message"
579 people={people}
580 autoFocus
581 compact
582 onCancel={() => setEditing(null)}
583 onSend={async (body) => {
584 setEditing(null);
585 if (body === target.body) return;
586 // Shown edited at once; put back if the service refuses.
587 const before = messages.find((m) => m.id === target.id);
588 setMessages((now) => now.map((m) => (m.id === target.id ? { ...m, body, edited_at: new Date().toISOString() } : m)));
589 const saved = await send<ChatMessage>({ intent: "edit", channel_id: data.channel.id, id: target.id, body });
590 if (saved.ok) setMessages((now) => mergeMessages(now, [saved.value]));
591 else if (before) setMessages((now) => now.map((m) => (m.id === before.id ? before : m)));
592 }}
593 />
594 );
595 const typers = [...typing.values()];
596 const name = channel.name ?? "";
597 const archived = channel.kind === "channel" && !!channel.archived_at;
598 const placeholder = isDm ? `Message ${others.map(shownName).join(", ") || "yourself"}` : `Message #${name}. @ a teammate`;
599 const rootMessage = threadId ? (messages.find((m) => m.id === threadId) ?? (threadRoot?.id === threadId ? threadRoot : null)) : null;
600 const cardContext = useMemo(
601 () => ({
602 slug,
603 onViewProfile: (username: string) => {
604 if (threadId) openThread(null);
605 setProfile(username);
606 },
607 }),
608 // eslint-disable-next-line react-hooks/exhaustive-deps
609 [slug, threadId],
610 );
611
612 return (
613 // On a phone the conversation is the whole screen, as tall as what the
614 // keyboard leaves (components/mobile.tsx), so the composer sits on it.
615 <EmojiProvider value={emojiContext}>
616 <CardContext.Provider value={cardContext}>
617 <div
618 {...swipe}
619 className="flex h-(--page-h) min-h-0 max-md:fixed max-md:inset-x-0 max-md:top-(--vv-top,0px) max-md:z-30 max-md:h-(--vv-height,100dvh) max-md:bg-bg"
620 >
621 {/* What pressing a card's action did (components/chat/card.tsx). */}
622 <CardToasts />
623 <section aria-label={data.title} className="flex min-w-0 grow flex-col">
624 <ChannelHeader
625 onBack={back}
626 data={view}
627 others={others}
628 starred={starred}
629 muted={muted}
630 memberCount={members.length}
631 live={live.state}
632 info={info}
633 onStar={() => void toggleStar()}
634 onInfo={toggleInfo}
635 />
636 <div
637 ref={scroller}
638 onScroll={(event) => {
639 const element = event.currentTarget;
640 stick.current = atBottom(element);
641 }}
642 className="min-h-0 grow overflow-y-auto overscroll-contain [scrollbar-gutter:stable] [scrollbar-width:thin]"
643 >
644 <div className="mx-auto flex min-h-full max-w-[56rem] flex-col justify-end px-2 pt-6 pb-3 sm:px-4">
645 {older ? (
646 <div className="flex justify-center pb-4">
647 <Button
648 type="button"
649 onClick={() => void loadOlder()}
650 variant="outline"
651 size="inline"
652 className="rounded-full px-3 py-1 text-xs text-muted font-normal"
653 >
654 {loadingOlder ? "Loading…" : "Load earlier messages"}
655 </Button>
656 </div>
657 ) : (
658 <ConversationStart data={view} others={others} />
659 )}
660 {rows.map((row) =>
661 row.kind === "day" ? (
662 <DayRule key={row.key} label={row.label} />
663 ) : (
664 <MessageRow
665 key={row.key}
666 message={row.message}
667 head={row.head}
668 zone={zone}
669 context={context}
670 code={code}
671 slug={slug}
672 onThread={() => openThread(row.message.id)}
673 onRetry={() => retry(row.message)}
674 onLongPress={() => setActions(row.message)}
675 onCopyLink={() => copyThreadLink(row.message.id)}
676 onEdit={mine(row.message) ? () => setEditing({ id: row.message.id, body: row.message.body, inline: true }) : undefined}
677 editor={editing?.inline && editing.id === row.message.id ? editor(editing) : undefined}
678 threadOpen={row.message.id === threadId}
679 />
680 ),
681 )}
682 </div>
683 </div>
684 <div className="shrink-0 px-2 pb-3 sm:px-4 sm:pb-4 max-md:pb-[max(0.75rem,env(safe-area-inset-bottom))] in-data-[keyboard=open]:pb-2">
685 <div className="mx-auto max-w-[56rem]">
686 <TypingLine typers={typers} />
687 {archived ? (
688 <Card className="flex flex-wrap items-center justify-between gap-3 px-4 py-3">
689 <p className="flex items-center gap-2 text-sm text-muted">
690 <Archive size={15} className="shrink-0 text-faint" />
691 <span>
692 <span className="font-medium text-fg">#{name}</span> is archived. Its history stays here to read; nobody can post in it.
693 </span>
694 </p>
695 {canManage && (
696 <Button
697 type="button"
698 onClick={() => void updateChannel({ archived: false })}
699 variant="outline"
700 className="px-3 text-fg/90 hover:bg-raised"
701 >
702 <ArchiveRestore size={15} />
703 Unarchive
704 </Button>
705 )}
706 </Card>
707 ) : formerAgent ? (
708 <Card className="flex items-start gap-2 px-4 py-3 text-sm text-muted">
709 <Archive size={15} className="mt-0.5 shrink-0 text-faint" />
710 <span>{formerAgentLine(formerAgent)}</span>
711 </Card>
712 ) : joined ? (
713 <Composer draftKey={data.channel.id} placeholder={placeholder} people={people} onSend={(body) => void post(body, null)} onTyping={onTyping} autoFocus />
714 ) : (
715 <Card className="flex flex-wrap items-center justify-between gap-3 px-4 py-3">
716 <p className="text-sm text-muted">
717 You're reading <span className="font-medium text-fg">#{name}</span>. Join to post and get its notifications.
718 </p>
719 <Button
720 type="button"
721 onClick={() => void join()}
722 variant="accent"
723 >
724 Join channel
725 </Button>
726 </Card>
727 )}
728 </div>
729 </div>
730 </section>
731 {threadId ? (
732 <SidePanel
733 title="Thread"
734 subtitle={isDm ? undefined : `#${name}`}
735 onClose={() => openThread(null)}
736 menu={<ThreadMenu onCopyLink={() => copyThreadLink(threadId)} />}
737 >
738 <ThreadPanel
739 root={rootMessage}
740 replies={thread}
741 zone={zone}
742 context={context}
743 code={code}
744 slug={slug}
745 people={people}
746 joined={joined && !archived}
747 onSend={(body) => void post(body, threadId)}
748 onRetry={retry}
749 onTyping={onTyping}
750 draftKey={`${data.channel.id}:${threadId}`}
751 />
752 </SidePanel>
753 ) : profile ? (
754 <SidePanel title="Profile" onClose={() => setProfile(null)}>
755 <ProfilePanel slug={slug} username={profile} messages={messages} zone={zone} context={context} />
756 </SidePanel>
757 ) : (
758 info && (
759 <SidePanel title={isDm ? "Details" : "About this channel"} onClose={toggleInfo}>
760 <InfoPanel
761 data={view}
762 members={members}
763 slug={slug}
764 people={people}
765 onAdded={(m) => setMembers((now) => [...now, m])}
766 joined={joined}
767 canManage={canManage}
768 onUpdate={updateChannel}
769 />
770 </SidePanel>
771 )
772 )}
773 <MessageActions
774 message={actions}
775 mine={actions != null && actions.author.kind === "user" && actions.author.id === me?.id}
776 onClose={() => setActions(null)}
777 onThread={(id) => openThread(id)}
778 onCopyLink={(message) => copyThreadLink(message.thread_root ?? message.id)}
779 onEdit={(message) => setEditing({ id: message.id, body: message.body })}
780 onDelete={async (message) => {
781 // Gone at once; back if the service refuses.
782 setMessages((now) => now.map((m) => (m.id === message.id ? { ...m, deleted_at: new Date().toISOString() } : m)));
783 const done = await send({ intent: "remove", channel_id: data.channel.id, id: message.id });
784 if (!done.ok) setMessages((now) => now.map((m) => (m.id === message.id ? { ...m, deleted_at: null } : m)));
785 }}
786 />
787 <BottomSheet open={editing != null && !editing.inline} onOpenChange={(open) => !open && setEditing(null)} title="Edit message">
788 {editing && !editing.inline && (
789 <div className="space-y-3 px-1 pt-1">
790 <h2 className="px-2 text-base font-semibold">Edit message</h2>
791 {editor(editing)}
792 </div>
793 )}
794 </BottomSheet>
795 </div>
796 </CardContext.Provider>
797 </EmojiProvider>
798 );
799}
800
801/** The top of a conversation: what it is, and its controls. */
802function ChannelHeader({
803 onBack,
804 data,
805 others,
806 starred,
807 muted,
808 memberCount,
809 live,
810 info,
811 onStar,
812 onInfo,
813}: {
814 /** A phone: back to the list of conversations. */
815 onBack: () => void;
816 data: Loaded;
817 others: MemberProfile[];
818 starred: boolean;
819 muted: boolean;
820 memberCount: number;
821 live: LiveState;
822 info: boolean;
823 onStar: () => void;
824 onInfo: () => void;
825}) {
826 const channel = data.channel;
827 const agentDm = channel.kind === "dm" && others.length === 1 && others[0]!.kind === "agent" ? others[0]! : null;
828 return (
829 <header data-page-head className="flex h-14 shrink-0 items-center gap-3 border-b border-line px-4 sm:px-5 max-md:h-[calc(3.5rem+env(safe-area-inset-top))] max-md:gap-2 max-md:pt-[env(safe-area-inset-top)] max-md:pl-1.5">
830 <Button variant="ghost" size="icon-bar" onClick={onBack} aria-label="Back to conversations" className="rounded-full md:hidden">
831 <ChevronLeft size={22} />
832 </Button>
833 <div className="flex min-w-0 grow items-center gap-2.5">
834 {channel.kind === "channel" ? (
835 <>
836 <h1 className="flex min-w-0 shrink-0 items-center gap-1 text-[0.9375rem] font-semibold">
837 {channel.private ? (
838 <Hint label="Private channel: only its members can find and read it">
839 <span className="flex">
840 <Lock size={15} className="text-faint" aria-label="Private channel" />
841 </span>
842 </Hint>
843 ) : (
844 <Hash size={16} className="text-faint" aria-label="Public channel" />
845 )}
846 <span className="truncate">{channel.name}</span>
847 </h1>
848 <StarButton starred={starred} onClick={onStar} />
849 {muted && (
850 <Hint label="Muted: no unread count or notifications">
851 <span className="text-faint">
852 <BellOff size={14} aria-label="Muted" />
853 </span>
854 </Hint>
855 )}
856 {channel.archived_at && (
857 <Badge tone="neutral" className="shrink-0">
858 <Archive size={11} />
859 Archived
860 </Badge>
861 )}
862 {channel.topic && (
863 <>
864 <span aria-hidden="true" className="hidden h-4 w-px shrink-0 bg-line sm:block" />
865 <p className="hidden min-w-0 truncate text-sm text-muted sm:block">{channel.topic}</p>
866 </>
867 )}
868 </>
869 ) : (
870 <>
871 <span className="flex shrink-0 -space-x-1.5">
872 {others.slice(0, 3).map((member) => (
873 <span key={`${member.kind}:${member.id}`} className="rounded-full ring-2 ring-bg">
874 <MemberAvatar member={member} size={26} presence={others.length === 1} />
875 </span>
876 ))}
877 </span>
878 <div className="min-w-0">
879 <h1 className="flex min-w-0 items-center gap-1.5 text-[0.9375rem] leading-tight font-semibold">
880 <span className="truncate">{others.map(shownName).join(", ") || "Just you"}</span>
881 {agentDm && <AgentPill deleted={agentDm.deleted === true} />}
882 </h1>
883 {/* One person: their status under their name, as their card has it. */}
884 {!agentDm && others.length === 1 && others[0]!.kind === "user" && <PersonStatusLine person={{ id: others[0]!.id, username: others[0]!.name }} className="truncate text-xs leading-tight text-muted" />}
885 </div>
886 <StarButton starred={starred} onClick={onStar} />
887 </>
888 )}
889 </div>
890 {live !== "open" && (
891 <span role="status" className="hidden items-center gap-1.5 text-xs text-faint sm:flex">
892 <span className="size-1.5 animate-pulse rounded-full bg-warn" />
893 {live === "reconnecting" ? "Reconnecting…" : "Connecting…"}
894 </span>
895 )}
896 {channel.kind === "channel" && (
897 <Hint label={info ? "Hide details" : "Members and details"}>
898 <Button variant="outline" size="sm" onClick={onInfo} aria-pressed={info} aria-label="Members and details" className="px-2 text-xs text-muted tabular-nums aria-pressed:border-line-strong aria-pressed:bg-raised aria-pressed:text-fg max-md:h-11">
899 <UsersStack count={memberCount} />
900 </Button>
901 </Hint>
902 )}
903 {channel.kind === "dm" && (
904 <Hint label={info ? "Hide details" : "Details"}>
905 <Button variant="ghost" size="icon-bar" onClick={onInfo} aria-pressed={info} aria-label="Details">
906 <PanelRight size={16} />
907 </Button>
908 </Hint>
909 )}
910 </header>
911 );
912}
913
914function UsersStack({ count }: { count: number }) {
915 return (
916 <>
917 <Users size={14} />
918 {count}
919 </>
920 );
921}
922
923function StarButton({ starred, onClick }: { starred: boolean; onClick: () => void }) {
924 return (
925 <Hint label={starred ? "Unstar" : "Star: keep it at the top of the sidebar"}>
926 <Button
927 variant="ghost"
928 size="icon-sm"
929 aria-label={starred ? "Unstar" : "Star"}
930 aria-pressed={starred}
931 onClick={onClick}
932 className="text-faint aria-pressed:bg-transparent aria-pressed:text-warn aria-pressed:hover:bg-raised max-md:size-10"
933 >
934 <Star size={15} fill={starred ? "currentColor" : "none"} />
935 </Button>
936 </Hint>
937 );
938}
939
940/** How a conversation begins, above its first message. */
941function ConversationStart({ data, others }: { data: Loaded; others: MemberProfile[] }) {
942 const channel = data.channel;
943 if (channel.kind === "dm") {
944 const agent = others.length === 1 && others[0]!.kind === "agent" ? others[0]! : null;
945 return (
946 <div className="px-2 pb-6 sm:px-3">
947 <div className="flex -space-x-2">
948 {others.slice(0, 4).map((m) => (
949 <span key={m.id} className="rounded-full ring-4 ring-bg">
950 <MemberAvatar member={m} size={56} />
951 </span>
952 ))}
953 </div>
954 <h2 className="mt-3 flex items-center gap-2 text-xl font-semibold tracking-tight">
955 {others.map(shownName).join(", ") || "Just you"}
956 {agent && <AgentPill deleted={agent.deleted === true} />}
957 </h2>
958 <p className="mt-1 max-w-xl text-sm text-muted">{agent ? formerAgentLine(agent) : "This is the start of your conversation."}</p>
959 </div>
960 );
961 }
962 return (
963 <div className="px-2 pb-6 sm:px-3">
964 <span className="flex size-12 items-center justify-center rounded-xl bg-raised text-muted">
965 {channel.private ? <Lock size={22} /> : <Hash size={24} />}
966 </span>
967 <h2 className="mt-3 text-xl font-semibold tracking-tight">Welcome to #{channel.name}</h2>
968 <p className="mt-1 max-w-xl text-sm text-muted">{channel.topic ?? "This is the very beginning of the channel."}</p>
969 </div>
970 );
971}
972
973function DayRule({ label }: { label: string }) {
974 return (
975 <div role="separator" aria-label={label} className="relative my-3 flex items-center justify-center">
976 <span aria-hidden="true" className="absolute inset-x-2 top-1/2 h-px bg-line" />
977 <Badge className="relative bg-bg px-3 py-0.5" suppressHydrationWarning>
978 {label}
979 </Badge>
980 </div>
981 );
982}
983
984/** One message: with its author's name and avatar when it starts a group. */
985/**
986 * Someone's profile beside the conversation, opened from their card: who
987 * they are, their teams and local time, and what they said here lately.
988 * The conversation stays where it was.
989 */
990function ProfilePanel({
991 slug,
992 username,
993 messages,
994 zone,
995 context,
996}: {
997 slug: string;
998 username: string;
999 messages: ShownMessage[];
1000 zone: string | undefined;
1001 context: TextContext;
1002}) {
1003 const [card, setCard] = useState<PersonCard | null | undefined>(undefined);
1004 useEffect(() => {
1005 let live = true;
1006 setCard(undefined);
1007 void personCard(slug, username).then((value) => {
1008 if (live) setCard(value);
1009 });
1010 return () => {
1011 live = false;
1012 };
1013 }, [slug, username]);
1014 const theirs = messages
1015 .filter((m) => m.author.kind === "user" && m.author.name === username && !m.deleted_at && m.body)
1016 .slice(-5)
1017 .reverse();
1018 const time = card ? localTime(card.timezone, Date.now()) : null;
1019 return (
1020 <div className="min-h-0 grow overflow-y-auto p-5 [scrollbar-width:thin]">
1021 <WithPresence person={{ username }} size={88}>
1022 <Avatar name={username} image={card?.avatar ?? null} size={88} />
1023 </WithPresence>
1024 <h3 className="mt-3 text-xl font-semibold tracking-tight">{card?.name?.trim() || (card ? shownUsername(card) : username)}</h3>
1025 <p className="font-mono text-sm text-muted">
1026 @{card ? shownUsername(card) : username}
1027 {card?.pronouns ? <span className="font-sans"> · {card.pronouns}</span> : null}
1028 </p>
1029 <PresenceSummary person={{ username }} className="mt-3 text-sm text-muted" />
1030 {card?.bio && <p className="mt-3 text-sm leading-relaxed text-fg-soft">{card.bio}</p>}
1031 <dl className="mt-4 space-y-2 text-sm">
1032 {card && card.teams.length > 0 && (
1033 <div className="flex gap-3">
1034 <dt className="w-20 shrink-0 text-faint">Teams</dt>
1035 <dd className="text-fg-soft">{card.teams.map((team) => team.name).join(", ")}</dd>
1036 </div>
1037 )}
1038 {time && (
1039 <div className="flex gap-3">
1040 <dt className="w-20 shrink-0 text-faint">Local time</dt>
1041 <dd className="text-fg-soft" suppressHydrationWarning>
1042 {time}
1043 </dd>
1044 </div>
1045 )}
1046 {card?.location && (
1047 <div className="flex gap-3">
1048 <dt className="w-20 shrink-0 text-faint">Location</dt>
1049 <dd className="text-fg-soft">{card.location}</dd>
1050 </div>
1051 )}
1052 {card && card.workspaces.length > 0 && (
1053 <div className="flex gap-3">
1054 <dt className="w-20 shrink-0 text-faint">Workspaces</dt>
1055 <dd className="text-fg-soft">{card.workspaces.map((w) => w.name).join(", ")}</dd>
1056 </div>
1057 )}
1058 </dl>
1059 <h4 className="mt-6 mb-2 text-xs font-medium text-faint">Lately in this conversation</h4>
1060 {theirs.length === 0 ? (
1061 <p className="text-sm text-muted">Nothing here yet.</p>
1062 ) : (
1063 <ul className="space-y-3">
1064 {theirs.map((m) => (
1065 <Card asChild key={m.id} radius="lg" className="px-3 py-2.5">
1066 <li>
1067 <p className="mb-1 text-[0.6875rem] text-faint" suppressHydrationWarning>
1068 {clock(m.created_at, zone)}
1069 </p>
1070 <div className="line-clamp-4 text-sm">
1071 <MessageText body={m.body} context={context} />
1072 </div>
1073 </li>
1074 </Card>
1075 ))}
1076 </ul>
1077 )}
1078 <Link to={`/u/${username}`} className="mt-6 inline-block text-sm text-accent hover:underline">
1079 Open their full profile
1080 </Link>
1081 </div>
1082 );
1083}
1084
1085
1086/** A long press (half a second, without moving) on a touch screen. */
1087function useLongPress(onLongPress?: () => void) {
1088 const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
1089 const cancel = () => {
1090 if (timer.current) clearTimeout(timer.current);
1091 timer.current = null;
1092 };
1093 if (!onLongPress) return {};
1094 return {
1095 onTouchStart: () => {
1096 cancel();
1097 timer.current = setTimeout(() => {
1098 timer.current = null;
1099 if (typeof navigator !== "undefined" && "vibrate" in navigator) navigator.vibrate?.(8);
1100 onLongPress();
1101 }, 480);
1102 },
1103 onTouchMove: cancel,
1104 onTouchEnd: cancel,
1105 onTouchCancel: cancel,
1106 onContextMenu: (event: React.MouseEvent) => {
1107 // A phone's own long-press menu (copy, look up) would cover ours.
1108 if (window.matchMedia("(pointer: coarse)").matches) event.preventDefault();
1109 },
1110 };
1111}
1112
1113/** A long-pressed message's actions, in a sheet from the bottom: reply, copy, and for your own, edit and delete. */
1114function MessageActions({
1115 message,
1116 mine,
1117 onClose,
1118 onThread,
1119 onCopyLink,
1120 onEdit,
1121 onDelete,
1122}: {
1123 message: ShownMessage | null;
1124 mine: boolean;
1125 onClose: () => void;
1126 onThread: (id: string) => void;
1127 onCopyLink: (message: ShownMessage) => void;
1128 onEdit: (message: ShownMessage) => void;
1129 onDelete: (message: ShownMessage) => void;
1130}) {
1131 const [confirm, setConfirm] = useState(false);
1132 useEffect(() => setConfirm(false), [message]);
1133 const act = (run: () => void) => () => {
1134 run();
1135 onClose();
1136 };
1137 return (
1138 <BottomSheet open={message != null} onOpenChange={(open) => !open && onClose()} title="Message actions">
1139 {message && (
1140 <>
1141 <p className="mt-1 mb-2 line-clamp-2 px-3 text-sm text-muted">
1142 <span className="font-medium text-fg-soft">{shownName(message.author)}:</span> {message.body || message.card?.title}
1143 </p>
1144 {!message.pending && <QuickReactions messageId={message.id} onDone={onClose} />}
1145 {!message.thread_root && (
1146 <SheetRow icon={<MessageSquareText />} onClick={act(() => onThread(message.id))}>
1147 Reply in thread
1148 </SheetRow>
1149 )}
1150 <SheetRow icon={<Copy />} onClick={act(() => void navigator.clipboard?.writeText(message.body || message.card?.title || ""))}>
1151 Copy text
1152 </SheetRow>
1153 {!message.pending && (
1154 <SheetRow icon={<Link2 />} onClick={act(() => onCopyLink(message))}>
1155 Copy link to thread
1156 </SheetRow>
1157 )}
1158 {mine && message.kind === "text" && (
1159 <SheetRow icon={<Pencil />} onClick={act(() => onEdit(message))}>
1160 Edit
1161 </SheetRow>
1162 )}
1163 {mine &&
1164 (confirm ? (
1165 <SheetRow icon={<Trash2 className="text-danger" />} onClick={act(() => onDelete(message))}>
1166 <span className="text-danger">Delete for everyone</span>
1167 </SheetRow>
1168 ) : (
1169 <SheetRow icon={<Trash2 />} onClick={() => setConfirm(true)}>
1170 Delete
1171 </SheetRow>
1172 ))}
1173 </>
1174 )}
1175 </BottomSheet>
1176 );
1177}
1178
1179function MessageRow({
1180 message,
1181 head,
1182 zone,
1183 context,
1184 code,
1185 slug,
1186 onThread,
1187 onRetry,
1188 onLongPress,
1189 onCopyLink,
1190 threadOpen,
1191 inThread,
1192 onEdit,
1193 editor,
1194}: {
1195 /** Your own message: "Edit message" in the "⋯", and the composer in its place while editing. */
1196 onEdit?: () => void;
1197 editor?: ReactNode;
1198 /** A phone: a long press opens the message's actions. */
1199 onLongPress?: () => void;
1200 /** The hover toolbar's "⋯": copy the link to its thread. */
1201 onCopyLink?: () => void;
1202 message: ShownMessage;
1203 head: boolean;
1204 zone: string | undefined;
1205 context: TextContext;
1206 code: boolean;
1207 slug: string;
1208 onThread?: () => void;
1209 onRetry: () => void;
1210 threadOpen?: boolean;
1211 inThread?: boolean;
1212}) {
1213 const author = message.author;
1214 const time = clock(message.created_at, zone);
1215 const press = useLongPress(onLongPress);
1216 return (
1217 <article
1218 {...press}
1219 aria-label={`${shownName(author)}, ${time}`}
1220 className={`group/message relative flex gap-3 rounded-lg px-2 transition-colors hover:bg-surface/70 sm:px-3 max-md:min-h-11 max-md:[-webkit-touch-callout:none] max-md:active:bg-surface/70 ${head ? "mt-3 pt-1 pb-1" : "py-0.5"} ${
1221 threadOpen ? "bg-accent/[0.06] hover:bg-accent/[0.08]" : ""
1222 }`}
1223 >
1224 <div className="w-9 shrink-0">
1225 {head ? (
1226 <MemberCard member={author} className="mt-0.5 block rounded-full" label={`${shownName(author)}'s card`}>
1227 <MemberAvatar member={author} size={36} />
1228 </MemberCard>
1229 ) : (
1230 <time
1231 dateTime={message.created_at}
1232 suppressHydrationWarning
1233 className="mt-1 block text-right text-[0.625rem] leading-5 text-faint tabular-nums opacity-0 group-hover/message:opacity-100"
1234 >
1235 {time.replace(/\s?[AP]M$/, "")}
1236 </time>
1237 )}
1238 </div>
1239 <div className="min-w-0 grow">
1240 {head && (
1241 <div className="flex items-baseline gap-2">
1242 <MemberCard member={author} className="truncate text-[0.9375rem] font-semibold text-fg hover:underline">
1243 {shownName(author)}
1244 </MemberCard>
1245 {author.kind === "user" && <PersonStatusEmoji person={{ id: author.id, username: author.name }} size={14} className="self-center" />}
1246 {author.kind === "agent" && <AgentPill className="self-center" deleted={author.deleted === true} />}
1247 <time dateTime={message.created_at} suppressHydrationWarning className="shrink-0 text-xs text-faint tabular-nums">
1248 {time}
1249 </time>
1250 </div>
1251 )}
1252 <div className={message.pending && !message.failed ? "opacity-60" : undefined}>
1253 {editor ? <div className="py-1">{editor}</div> : message.body && <MessageText body={message.body} context={context} />}
1254 {!editor && message.body && !message.pending && <FolioUnfurls slug={slug} body={message.body} />}
1255 {message.card && (
1256 <CardBox card={message.card} slug={slug} code={code} />
1257 )}
1258 {message.edited_at && <span className="text-[0.6875rem] text-faint"> (edited)</span>}
1259 </div>
1260 {!message.pending && !message.deleted_at && <ReactionBar messageId={message.id} reactions={message.reactions} />}
1261 {message.failed && (
1262 <p className="mt-0.5 text-xs text-danger">
1263 Not sent.{" "}
1264 <Button type="button" onClick={onRetry} variant="link" size="inline" className="text-inherit underline underline-offset-2 hover:text-fg">
1265 Try again
1266 </Button>
1267 </p>
1268 )}
1269 {!inThread && message.reply_count > 0 && onThread && (
1270 <Button
1271 type="button"
1272 onClick={onThread}
1273 variant="ghost"
1274 size="inline"
1275 className="mt-1 -ml-1 flex gap-2 px-1 py-0.5 text-[0.8125rem] text-accent"
1276 >
1277 <MessageSquareText size={14} />
1278 {message.reply_count} {message.reply_count === 1 ? "reply" : "replies"}
1279 {message.last_reply_at && (
1280 <span className="font-normal text-faint" suppressHydrationWarning>
1281 Last reply {clock(message.last_reply_at, zone)}
1282 </span>
1283 )}
1284 </Button>
1285 )}
1286 </div>
1287 {!inThread && onThread && !message.pending && (
1288 <Card radius="lg" className="absolute -top-3 right-3 hidden p-0.5 shadow-lg shadow-black/30 group-hover/message:flex group-focus-within/message:flex has-[[data-state=open]]:flex">
1289 <AddReaction messageId={message.id} />
1290 <Hint label="Reply in thread">
1291 <Button
1292 type="button"
1293 aria-label="Reply in thread"
1294 onClick={onThread}
1295 variant="ghost"
1296 size="icon-xs"
1297 >
1298 <MessageSquareText size={15} />
1299 </Button>
1300 </Hint>
1301 {onCopyLink && <MoreActions onCopyLink={onCopyLink} onEdit={onEdit} />}
1302 </Card>
1303 )}
1304 </article>
1305 );
1306}
1307
1308/** A message's "⋯" in its hover toolbar. */
1309function MoreActions({ onCopyLink, onEdit }: { onCopyLink: () => void; onEdit?: () => void }) {
1310 return (
1311 <DropdownMenu>
1312 <Hint label="More actions">
1313 <DropdownMenuTrigger
1314 aria-label="More actions"
1315 className="flex size-7 items-center justify-center rounded-md text-muted outline-none hover:bg-raised hover:text-fg focus-visible:ring-2 focus-visible:ring-accent/50"
1316 >
1317 <Ellipsis size={15} />
1318 </DropdownMenuTrigger>
1319 </Hint>
1320 <DropdownMenuContent align="end">
1321 {onEdit && (
1322 <DropdownMenuItem onSelect={onEdit}>
1323 <Pencil />
1324 Edit message
1325 </DropdownMenuItem>
1326 )}
1327 <ThreadMenuItems onCopyLink={onCopyLink} />
1328 </DropdownMenuContent>
1329 </DropdownMenu>
1330 );
1331}
1332
1333function ThreadMenuItems({ onCopyLink }: { onCopyLink: () => void }) {
1334 return (
1335 <DropdownMenuItem onSelect={onCopyLink}>
1336 <Link2 />
1337 Copy link to thread
1338 </DropdownMenuItem>
1339 );
1340}
1341
1342/** The thread panel's "⋯", in its header. */
1343function ThreadMenu({ onCopyLink }: { onCopyLink: () => void }) {
1344 return (
1345 <DropdownMenu>
1346 <Hint label="Thread actions">
1347 <DropdownMenuTrigger asChild>
1348 <Button variant="ghost" size="icon-lg" aria-label="Thread actions" className="rounded-full active:bg-raised lg:size-7 lg:rounded-md">
1349 <Ellipsis size={16} />
1350 </Button>
1351 </DropdownMenuTrigger>
1352 </Hint>
1353 <DropdownMenuContent align="end">
1354 <ThreadMenuItems onCopyLink={onCopyLink} />
1355 </DropdownMenuContent>
1356 </DropdownMenu>
1357 );
1358}
1359
1360/** "Ana is typing…", for whoever is (`typingLabel`). */
1361function TypingLine({ typers }: { typers: Typer[] }) {
1362 const label = typingLabel(typers);
1363 return (
1364 <div aria-live="polite" className="flex h-6 items-center gap-1.5 px-1 text-xs text-muted">
1365 {label && (
1366 <>
1367 <span aria-hidden="true" className="flex gap-0.5">
1368 {[0, 1, 2].map((i) => (
1369 <span key={i} className="size-1 animate-bounce rounded-full bg-accent" style={{ animationDelay: `${i * 140}ms` }} />
1370 ))}
1371 </span>
1372 <span>
1373 <span className="font-medium text-fg-soft">{label.who}</span> {label.doing}…
1374 </span>
1375 </>
1376 )}
1377 </div>
1378 );
1379}
1380
1381/**
1382 * A panel beside the conversation: a thread, or the details. On a phone it
1383 * is a screen of its own, pushed over the conversation, as tall as what
1384 * the keyboard leaves.
1385 */
1386function SidePanel({ title, subtitle, onClose, menu, children }: { title: string; subtitle?: string; onClose: () => void; menu?: ReactNode; children: ReactNode }) {
1387 const swipe = useSwipeBack(onClose);
1388 return (
1389 <aside
1390 {...swipe}
1391 aria-label={title}
1392 className="fixed inset-0 z-40 flex flex-col bg-bg lg:static lg:z-auto lg:w-[22rem] lg:shrink-0 lg:border-l lg:border-line xl:w-[24rem] max-md:top-(--vv-top,0px) max-md:bottom-auto max-md:h-(--vv-height,100dvh) max-md:pb-[env(safe-area-inset-bottom)]"
1393 >
1394 <div className="flex h-14 shrink-0 items-center gap-2 border-b border-line px-4 max-md:h-[calc(3.5rem+env(safe-area-inset-top))] max-md:pt-[env(safe-area-inset-top)] max-md:pl-1.5">
1395 <Button type="button" onClick={onClose} aria-label="Back" variant="ghost" size="icon-lg" className="rounded-full active:bg-raised lg:hidden">
1396 <ChevronLeft size={22} />
1397 </Button>
1398 <h2 className="text-[0.9375rem] font-semibold">{title}</h2>
1399 {subtitle && <span className="truncate text-sm text-faint">{subtitle}</span>}
1400 <div className="ml-auto flex shrink-0 items-center gap-1">
1401 {menu}
1402 <Button type="button" onClick={onClose} aria-label="Close" variant="ghost" size="inline" className="hidden p-1 text-faint lg:block">
1403 <X size={16} />
1404 </Button>
1405 </div>
1406 </div>
1407 <div className="flex min-h-0 grow flex-col">{children}</div>
1408 </aside>
1409 );
1410}
1411
1412function ThreadPanel({
1413 root,
1414 replies,
1415 zone,
1416 context,
1417 code,
1418 slug,
1419 people,
1420 joined,
1421 onSend,
1422 onRetry,
1423 onTyping,
1424 draftKey,
1425}: {
1426 root: ShownMessage | null;
1427 replies: ShownMessage[] | null;
1428 zone: string | undefined;
1429 context: TextContext;
1430 code: boolean;
1431 slug: string;
1432 people: Mentionable[];
1433 joined: boolean;
1434 onSend: (body: string) => void;
1435 onRetry: (message: ShownMessage) => void;
1436 /** Says "is typing" to the others in the channel, as the main composer does. */
1437 onTyping: () => void;
1438 draftKey: string;
1439}) {
1440 const rows = useMemo(() => timeline(replies ?? [], new Date(), zone).filter((row) => row.kind === "message"), [replies, zone]);
1441 // The newest reply stays in view as replies arrive and as the keyboard opens, unless the reader scrolled up.
1442 const scroller = useRef<HTMLDivElement>(null);
1443 const stick = useRef(true);
1444 useKeepAtBottom(scroller, stick);
1445 return (
1446 <>
1447 <div
1448 ref={scroller}
1449 onScroll={(event) => {
1450 stick.current = atBottom(event.currentTarget);
1451 }}
1452 className="min-h-0 grow overflow-y-auto overscroll-contain px-1 py-3 [scrollbar-width:thin]"
1453 >
1454 {/* One box for what scrolls, so its growing is seen (./stick.ts). */}
1455 <div>
1456 {root ? (
1457 <MessageRow message={root} head zone={zone} context={context} code={code} slug={slug} onRetry={() => onRetry(root)} inThread />
1458 ) : (
1459 replies != null && <p className="px-4 py-2 text-sm text-faint">The message this thread is under is further back.</p>
1460 )}
1461 <div className="my-2 flex items-center gap-3 px-4 text-xs text-faint">
1462 {replies == null ? "Loading replies…" : `${replies.filter((r) => !r.deleted_at).length} ${replies.length === 1 ? "reply" : "replies"}`}
1463 <span className="h-px grow bg-line" />
1464 </div>
1465 {rows.map((row) =>
1466 row.kind === "message" ? (
1467 <MessageRow
1468 key={row.key}
1469 message={row.message}
1470 head={row.head}
1471 zone={zone}
1472 context={context}
1473 code={code}
1474 slug={slug}
1475 onRetry={() => onRetry(row.message)}
1476 inThread
1477 />
1478 ) : null,
1479 )}
1480 </div>
1481 </div>
1482 {joined && (
1483 <div className="shrink-0 p-3">
1484 <Composer draftKey={draftKey} placeholder="Reply…" people={people} onSend={onSend} onTyping={onTyping} compact autoFocus />
1485 </div>
1486 )}
1487 </>
1488 );
1489}
1490
1491/**
1492 * A channel's name, topic and visibility, and changing them: any member
1493 * edits the topic; renaming and archiving are for whoever the workspace's
1494 * chat settings allow (`canManage`). #general keeps its name and stays.
1495 */
1496function ChannelAbout({
1497 channel,
1498 joined,
1499 canManage,
1500 onUpdate,
1501}: {
1502 channel: Channel;
1503 joined: boolean;
1504 canManage: boolean;
1505 onUpdate: (change: ChannelChange) => Promise<Result<unknown>>;
1506}) {
1507 const [editing, setEditing] = useState<"name" | "topic" | null>(null);
1508 const [value, setValue] = useState("");
1509 const [error, setError] = useState<string | null>(null);
1510 const [busy, setBusy] = useState(false);
1511 const [confirm, setConfirm] = useState(false);
1512 const general = channel.name === "general";
1513 const archived = !!channel.archived_at;
1514 const mayRename = canManage && !general && !archived;
1515 const mayTopic = joined && !archived;
1516 const save = async (change: ChannelChange) => {
1517 setBusy(true);
1518 setError(null);
1519 const done = await onUpdate(change);
1520 setBusy(false);
1521 if (!done.ok) return setError(done.error.message);
1522 setEditing(null);
1523 setConfirm(false);
1524 };
1525 const start = (field: "name" | "topic") => {
1526 setEditing(field);
1527 setValue(field === "name" ? (channel.name ?? "") : (channel.topic ?? ""));
1528 setError(null);
1529 };
1530 const edit = (field: "name" | "topic") => (
1531 <form
1532 className="mt-1.5 space-y-2"
1533 onSubmit={(event) => {
1534 event.preventDefault();
1535 void save(field === "name" ? { name: value } : { topic: value });
1536 }}
1537 >
1538 <span className="flex h-9 items-center gap-1.5 rounded-md border border-line bg-bg px-2.5 focus-within:border-accent-dim">
1539 {field === "name" && (channel.private ? <Lock size={13} className="text-faint" /> : <Hash size={13} className="text-faint" />)}
1540 <input
1541 autoFocus
1542 value={value}
1543 onChange={(event) => setValue(event.target.value)}
1544 onKeyDown={(event) => event.key === "Escape" && setEditing(null)}
1545 maxLength={field === "name" ? 80 : 250}
1546 placeholder={field === "name" ? "channel-name" : "What this channel is for"}
1547 aria-label={field === "name" ? "Channel name" : "Topic"}
1548 autoComplete="off"
1549 data-1p-ignore
1550 className="min-w-0 grow bg-transparent text-sm outline-none placeholder:text-faint"
1551 />
1552 </span>
1553 <div className="flex justify-end gap-1.5">
1554 <Button type="button" onClick={() => setEditing(null)} variant="ghost" size="xs" className="px-2.5 font-normal">
1555 Cancel
1556 </Button>
1557 <Button type="submit" disabled={busy} variant="accent" size="xs" className="px-2.5">
1558 {busy ? "Saving…" : "Save"}
1559 </Button>
1560 </div>
1561 </form>
1562 );
1563 const editButton = (field: "name" | "topic", label: string) => (
1564 <Button
1565 type="button"
1566 onClick={() => start(field)}
1567 aria-label={label}
1568 variant="ghost"
1569 size="inline"
1570 className="rounded px-1.5 py-0.5 text-xs opacity-0 transition-opacity group-hover/about:opacity-100 focus-visible:opacity-100 [@media(hover:none)]:opacity-100 font-normal"
1571 >
1572 Edit
1573 </Button>
1574 );
1575 return (
1576 <>
1577 <Card asChild className="group/about p-3.5">
1578 <section>
1579 <div className="flex items-center justify-between">
1580 <h3 className="text-xs font-medium text-faint">Name</h3>
1581 {mayRename && editing !== "name" && editButton("name", "Rename channel")}
1582 </div>
1583 {editing === "name" ? edit("name") : <p className="mt-1 text-sm text-fg-soft">#{channel.name}</p>}
1584 <div className="mt-3 flex items-center justify-between">
1585 <h3 className="text-xs font-medium text-faint">Topic</h3>
1586 {mayTopic && editing !== "topic" && editButton("topic", "Edit topic")}
1587 </div>
1588 {editing === "topic" ? edit("topic") : <p className="mt-1 text-sm text-fg-soft">{channel.topic || "No topic yet."}</p>}
1589 <h3 className="mt-3 text-xs font-medium text-faint">Visibility</h3>
1590 <p className="mt-1 flex items-center gap-1.5 text-sm text-fg-soft">
1591 {channel.private ? <Lock size={13} /> : <Hash size={13} />}
1592 {channel.private ? "Private: only its members can find and read it" : "Public: anyone in the workspace can read and join"}
1593 </p>
1594 {error && <p className="mt-2 text-xs text-danger">{error}</p>}
1595 </section>
1596 </Card>
1597 {canManage && !general && (
1598 <button
1599 type="button"
1600 onClick={() => (archived ? void save({ archived: false }) : setConfirm(true))}
1601 disabled={busy}
1602 className="mt-2 flex h-8 w-full items-center justify-center gap-2 rounded-md text-[0.8125rem] text-muted transition-colors hover:bg-raised hover:text-fg disabled:opacity-50"
1603 >
1604 {archived ? <ArchiveRestore size={14} /> : <Archive size={14} />}
1605 {archived ? "Unarchive channel" : "Archive channel"}
1606 </button>
1607 )}
1608 <AlertDialog open={confirm} onOpenChange={setConfirm}>
1609 <AlertDialogContent>
1610 <AlertDialogHeader>
1611 <AlertDialogTitle>Archive #{channel.name}?</AlertDialogTitle>
1612 <AlertDialogDescription>
1613 It leaves everyone&apos;s sidebar and nobody can post in it. Its history stays readable, and it can be unarchived from Browse
1614 channels, Archived.
1615 </AlertDialogDescription>
1616 </AlertDialogHeader>
1617 <AlertDialogFooter>
1618 <AlertDialogCancel>Cancel</AlertDialogCancel>
1619 <AlertDialogAction
1620 onClick={(event) => {
1621 event.preventDefault();
1622 void save({ archived: true });
1623 }}
1624 disabled={busy}
1625 >
1626 {busy ? "Archiving…" : "Archive"}
1627 </AlertDialogAction>
1628 </AlertDialogFooter>
1629 </AlertDialogContent>
1630 </AlertDialog>
1631 </>
1632 );
1633}
1634
1635function InfoPanel({
1636 data,
1637 members,
1638 slug,
1639 people,
1640 onAdded,
1641 joined,
1642 canManage,
1643 onUpdate,
1644}: {
1645 data: Loaded;
1646 members: ChannelMember[];
1647 slug: string;
1648 people: Mentionable[];
1649 onAdded: (member: ChannelMember) => void;
1650 joined: boolean;
1651 /** Whether the viewer may rename, archive and unarchive it. */
1652 canManage: boolean;
1653 onUpdate: (change: ChannelChange) => Promise<Result<unknown>>;
1654}) {
1655 const channel = data.channel;
1656 const humans = members.filter((m) => m.member.kind === "user");
1657 return (
1658 <div className="min-h-0 grow overflow-y-auto p-4 [scrollbar-width:thin]">
1659 {channel.kind === "channel" && <ChannelAbout channel={channel} joined={joined} canManage={canManage} onUpdate={onUpdate} />}
1660 <section className="mt-5">
1661 <div className="mb-1 flex items-center justify-between px-1">
1662 <h3 className="text-xs font-medium text-faint">People · {humans.length}</h3>
1663 {joined && channel.kind === "channel" && <AddMember slug={slug} channelId={channel.id} people={people} members={members} onAdded={onAdded} />}
1664 </div>
1665 <ul className="space-y-px">
1666 {humans.map(({ member, role }) => (
1667 <li key={member.id}>
1668 <MemberCard member={member} className="flex w-full items-center gap-2.5 rounded-md px-1 py-1.5 transition-colors hover:bg-raised/60 max-md:min-h-11">
1669 <MemberAvatar member={member} size={26} presence />
1670 <span className="min-w-0 grow truncate text-sm">
1671 {shownName(member)}
1672 {shownName(member) !== shownHandle(member) && <span className="ml-1.5 text-faint">@{shownHandle(member)}</span>}
1673 <PersonStatusEmoji person={{ id: member.id, username: member.name }} size={13} className="ml-1.5 align-[-2px]" inert />
1674 </span>
1675 {role === "owner" && <span className="text-[0.6875rem] text-faint">Owner</span>}
1676 </MemberCard>
1677 </li>
1678 ))}
1679 </ul>
1680 </section>
1681 </div>
1682 );
1683}
1684
1685/** Adding a person to the channel. An invite lets them read; it never grants write. */
1686function AddMember({
1687 slug,
1688 channelId,
1689 people,
1690 members,
1691 onAdded,
1692}: {
1693 slug: string;
1694 channelId: string;
1695 people: Mentionable[];
1696 members: ChannelMember[];
1697 onAdded: (member: ChannelMember) => void;
1698}) {
1699 const [open, setOpen] = useState(false);
1700 const [query, setQuery] = useState("");
1701 const [error, setError] = useState<string | null>(null);
1702 const send = useChatSend(slug);
1703 const inside = new Set(members.map((m) => `${m.member.kind}:${m.member.name.toLowerCase()}`));
1704 const q = query.trim().toLowerCase().replace(/^@/, "");
1705 const options = people
1706 .filter((p) => !inside.has(`${p.kind}:${p.name.toLowerCase()}`))
1707 .filter((p) => !q || p.name.toLowerCase().includes(q) || p.display_name.toLowerCase().includes(q))
1708 .slice(0, 8);
1709 const add = async (person: Mentionable) => {
1710 setError(null);
1711 const done = await send({ intent: "invite", channel_id: channelId, member: `user:${person.name}` });
1712 if (!done.ok) return setError(done.error.message);
1713 onAdded({
1714 channel_id: channelId,
1715 member: { kind: "user", id: person.name, name: person.name, display_username: person.display_username ?? null, display_name: person.display_name, avatar: person.avatar, role: null },
1716 role: "member",
1717 starred: false,
1718 muted: false,
1719 last_read_id: null,
1720 joined_at: new Date().toISOString(),
1721 });
1722 setOpen(false);
1723 };
1724 return (
1725 <Popover open={open} onOpenChange={setOpen}>
1726 <PopoverTrigger asChild>
1727 <Button type="button" variant="ghost" size="inline" className="flex gap-1 px-1.5 py-0.5 text-xs font-normal">
1728 <UserPlus size={13} />
1729 Add
1730 </Button>
1731 </PopoverTrigger>
1732 <PopoverContent align="end" className="w-72 p-1.5">
1733 <input
1734 autoFocus
1735 value={query}
1736 onChange={(event) => setQuery(event.target.value)}
1737 placeholder="Add a person"
1738 aria-label="Add a person"
1739 autoComplete="off"
1740 data-1p-ignore
1741 className="mb-1 h-8 w-full rounded-md border border-line bg-bg px-2.5 text-sm outline-none placeholder:text-faint focus:border-accent-dim"
1742 />
1743 {options.length === 0 && <p className="px-2 py-3 text-xs text-faint">Everyone who matches is already here.</p>}
1744 {options.map((person) => (
1745 <button
1746 key={`${person.kind}:${person.name}`}
1747 type="button"
1748 onClick={() => void add(person)}
1749 className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm hover:bg-line"
1750 >
1751 <MemberAvatar member={person} size={20} />
1752 <span className="min-w-0 grow truncate">{person.display_name}</span>
1753 </button>
1754 ))}
1755 {error && <p className="px-2 pt-1 text-xs text-danger">{error}</p>}
1756 </PopoverContent>
1757 </Popover>
1758 );
1759}