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