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