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