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