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