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.

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