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