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