Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

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