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.

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