Skip to content
445 linesCodeBlameRaw
1import { Check, Hash, Lock, Plus, Search, SquarePen, X } from "lucide-react";
2import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react";
3import { useFetcher, useNavigate, useRouteLoaderData } from "react-router";
4
5import type { ChatSidebar, Result } from "@g1t/contracts";
6
7import { AgentPill } from "./marks";
8import { AgentAvatar } from "../agent-avatar";
9
10import { Avatar } from "../ui/avatar";
11import { Button } from "../ui/button";
12import { Card } from "../ui/card";
13import { Hint } from "../ui/hint";
14import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "../ui/dialog";
15import { Switch } from "../ui/switch";
16import { channelName, shownHandle, shownName } from "../../lib/chat";
17import type { ChatLayoutData } from "../../routes/workspace/chat/layout";
18import { useLiveCounts } from "../../lib/notify-client";
19import { overlayEntries, unlisted } from "../../lib/notify-store";
20
21/** Chat mode's data, while a chat page is open; undefined elsewhere. */
22export function useChatData(): ChatLayoutData | undefined {
23 return useRouteLoaderData("routes/workspace/chat/layout") as ChatLayoutData | undefined;
24}
25
26/** The sidebar's own fetcher: every component that refreshes it shares this one. */
27export const SIDEBAR_FETCHER = "chat-sidebar";
28
29/** The sidebar as last read: refreshed in place, else what the page loaded. */
30export function useChatSidebar(): { sidebar: ChatSidebar | null; refresh: () => void } {
31 const data = useChatData();
32 const fetcher = useFetcher<Result<ChatSidebar>>({ key: SIDEBAR_FETCHER });
33 const slug = data?.slug;
34 const refresh = useCallback(() => {
35 if (slug) fetcher.load(`/${slug}/-/chat/api`);
36 // eslint-disable-next-line react-hooks/exhaustive-deps
37 }, [slug]);
38 const fresh = fetcher.data?.ok ? fetcher.data.value : null;
39 const sidebar = fresh ?? data?.sidebar ?? null;
40 // A conversation read on its page reads as read everywhere at once, until
41 // the service's next answer says so itself.
42 const [read, setRead] = useState<ReadonlySet<string>>(new Set());
43 useEffect(() => {
44 const onRead = (event: Event) => setRead((now) => new Set(now).add(String((event as CustomEvent).detail)));
45 window.addEventListener("g1t:chat-read", onRead);
46 return () => window.removeEventListener("g1t:chat-read", onRead);
47 }, []);
48 // Notify: the feed socket's counts (lib/notify-client.ts) over the sidebar as read, live.
49 const live = useLiveCounts(slug);
50 useEffect(() => setRead(new Set()), [sidebar, live]);
51 const shown = useMemo(() => {
52 const counted = sidebar && live ? { ...sidebar, entries: overlayEntries(sidebar.entries, live) } : sidebar;
53 return counted && read.size > 0
54 ? { ...counted, entries: counted.entries.map((e) => (read.has(e.channel.id) ? { ...e, unread: 0, mentions: 0 } : e)) }
55 : counted;
56 }, [sidebar, read, live]);
57 // A conversation the feed counts that the sidebar does not list (a new DM): read the sidebar again.
58 const missing = sidebar ? unlisted(sidebar.entries, live).join(",") : "";
59 useEffect(() => {
60 if (missing) refresh();
61 }, [missing, refresh]);
62 return { sidebar: shown, refresh };
63}
64
65/**
66 * Sends one request to the chat's JSON route and answers with the
67 * service's result; a network failure is a failure like any other. Doing
68 * something that changes the sidebar refreshes it.
69 */
70export function useChatSend(slug: string) {
71 const fetcher = useFetcher<Result<ChatSidebar>>({ key: SIDEBAR_FETCHER });
72 return useCallback(
73 async <T = unknown,>(body: Record<string, unknown>): Promise<Result<T>> => {
74 try {
75 const response = await fetch(`/${slug}/-/chat/api`, {
76 method: "POST",
77 headers: { "content-type": "application/json" },
78 body: JSON.stringify(body),
79 });
80 const result = (await response.json()) as Result<T>;
81 if (result.ok && body.intent !== "post" && body.intent !== "read") fetcher.load(`/${slug}/-/chat/api`);
82 return result;
83 } catch {
84 return { ok: false, error: { code: "conflict", message: "Couldn't reach g1t. Check your connection and try again." } };
85 }
86 },
87 // eslint-disable-next-line react-hooks/exhaustive-deps
88 [slug],
89 );
90}
91
92/** Why someone may not make a channel: the workspace keeps it to its owners. */
93export const OWNERS_ONLY_CHANNELS = "Only workspace owners can create channels here.";
94
95/**
96 * What the viewer may make, from the workspace's chat settings (Settings,
97 * Chat). Until the sidebar arrives, everything: the chat service checks.
98 */
99export function useChannelCreation(): { public: boolean; private: boolean; any: boolean } {
100 const { sidebar } = useChatSidebar();
101 const open = sidebar?.can?.create_public_channels ?? true;
102 const closed = sidebar?.can?.create_private_channels ?? true;
103 return { public: open, private: closed, any: open || closed };
104}
105
106/**
107 * Making a channel: its name, what it is for, and whether it is private.
108 * Someone the workspace's settings do not let make one sees the button
109 * turned off, with why; someone who may make only one kind gets that kind.
110 */
111export function CreateChannelButton({ slug, variant = "icon" }: { slug: string; variant?: "icon" | "button" }) {
112 const [open, setOpen] = useState(false);
113 const [name, setName] = useState("");
114 const [topic, setTopic] = useState("");
115 const [secret, setSecret] = useState(false);
116 const [error, setError] = useState<string | null>(null);
117 const [busy, setBusy] = useState(false);
118 const send = useChatSend(slug);
119 const navigate = useNavigate();
120 const may = useChannelCreation();
121 // Only one kind allowed: that kind, and the switch says why it is fixed.
122 const isPrivate = !may.public ? true : !may.private ? false : secret;
123 const clean = channelName(name);
124 const submit = async () => {
125 if (!clean) return setError("Give the channel a name.");
126 setBusy(true);
127 setError(null);
128 const made = await send<{ to: string }>({ intent: "create_channel", name: clean, topic, private: isPrivate });
129 setBusy(false);
130 if (!made.ok) return setError(made.error.message);
131 setOpen(false);
132 setName("");
133 setTopic("");
134 navigate(made.value.to);
135 };
136 if (!may.any) {
137 // Shown off, not hidden, so nobody wonders where it went.
138 return (
139 <Hint label={OWNERS_ONLY_CHANNELS} disabled>
140 {variant === "icon" ? (
141 <Button type="button" aria-label="Create a channel" disabled variant="ghost" size="icon-xs" className="text-faint opacity-40">
142 <Plus size={14} />
143 </Button>
144 ) : (
145 <Button type="button" disabled variant="outline" className="opacity-50">
146 <Lock size={14} />
147 Create a channel
148 </Button>
149 )}
150 </Hint>
151 );
152 }
153 return (
154 <>
155 {variant === "icon" ? (
156 <Hint label="Create a channel">
157 <Button type="button" aria-label="Create a channel" onClick={() => setOpen(true)} variant="ghost" size="icon-xs" className="text-faint">
158 <Plus size={14} />
159 </Button>
160 </Hint>
161 ) : (
162 <Button type="button" onClick={() => setOpen(true)} variant="outline">
163 <Hash size={15} />
164 Create a channel
165 </Button>
166 )}
167 <Dialog open={open} onOpenChange={setOpen}>
168 <DialogContent>
169 <DialogHeader>
170 <DialogTitle>Create a channel</DialogTitle>
171 <DialogDescription>A place for one topic: a project, a team, a launch. Invite people and agents once it's made.</DialogDescription>
172 </DialogHeader>
173 <form
174 className="grid gap-4"
175 onSubmit={(event) => {
176 event.preventDefault();
177 void submit();
178 }}
179 >
180 <label className="grid gap-1.5">
181 <span className="text-sm font-medium text-muted">Name</span>
182 <span className="flex h-10 items-center gap-1.5 rounded-md border border-line bg-bg px-3 focus-within:border-accent-dim">
183 {isPrivate ? <Lock size={14} className="text-faint" /> : <Hash size={14} className="text-faint" />}
184 <input
185 autoFocus
186 value={name}
187 onChange={(event) => setName(event.target.value)}
188 placeholder="release-train"
189 maxLength={80}
190 autoComplete="off"
191 data-1p-ignore
192 className="min-w-0 grow bg-transparent text-sm outline-none placeholder:text-faint"
193 />
194 </span>
195 {name && clean !== name && <span className="text-xs text-faint">It will be #{clean || "…"}</span>}
196 </label>
197 <label className="grid gap-1.5">
198 <span className="text-sm font-medium text-muted">
199 Topic <span className="font-normal text-faint">(optional)</span>
200 </span>
201 <input
202 value={topic}
203 onChange={(event) => setTopic(event.target.value)}
204 placeholder="What this channel is for"
205 maxLength={250}
206 autoComplete="off"
207 data-1p-ignore
208 className="h-10 rounded-md border border-line bg-bg px-3 text-sm outline-none placeholder:text-faint focus:border-accent-dim"
209 />
210 </label>
211 <Card asChild tone="plain" radius="lg" className="flex items-start justify-between gap-4 bg-bg/60 p-3">
212 <label>
213 <span>
214 <span className="flex items-center gap-1.5 text-sm font-medium">
215 {isPrivate ? <Lock size={13} className="text-muted" /> : <Hash size={13} className="text-muted" />}
216 {isPrivate ? "Private" : "Public"}
217 </span>
218 <span className="mt-0.5 block text-xs text-muted">
219 {!may.public
220 ? "Only workspace owners can create public channels here, so this one is private: only people and agents you invite can find and read it."
221 : !may.private
222 ? "Only workspace owners can create private channels here, so this one is public: anyone in the workspace can find, read and join it."
223 : isPrivate
224 ? "Only people and agents you invite can find and read it."
225 : "Anyone in the workspace can find, read and join it. Turn on to keep it to the people and agents you invite."}
226 </span>
227 </span>
228 <Switch checked={isPrivate} onCheckedChange={setSecret} disabled={!may.public || !may.private} aria-label="Private" />
229 </label>
230 </Card>
231 {error && <p className="text-sm text-danger">{error}</p>}
232 <DialogFooter>
233 <Button type="button" onClick={() => setOpen(false)} variant="outline">
234 Cancel
235 </Button>
236 <Button
237 type="submit"
238 disabled={busy || !clean}
239 variant="accent"
240 >
241 {busy ? "Creating…" : "Create channel"}
242 </Button>
243 </DialogFooter>
244 </form>
245 </DialogContent>
246 </Dialog>
247 </>
248 );
249}
250
251/** Someone a new message can go to. */
252type Pickable = { key: string; kind: "user" | "agent"; name: string; handle: string; display: string; avatar: string | null; role?: string | null };
253
254/**
255 * A new direct message: choose people, agents or both, then open the
256 * conversation between you (the same one each time for the same people).
257 */
258export function NewMessageButton({ slug, variant = "icon", children }: { slug: string; variant?: "icon" | "button" | "fab"; children?: ReactNode }) {
259 const [open, setOpen] = useState(false);
260 return (
261 <>
262 {variant === "fab" ? (
263 // A phone's list: the round button above the tab bar.
264 <Button
265 type="button"
266 aria-label="New message"
267 onClick={() => setOpen(true)}
268 variant="accent" size="icon"
269 className="size-14 rounded-2xl shadow-lg shadow-black/50 transition-transform active:scale-95"
270 >
271 <SquarePen size={22} />
272 </Button>
273 ) : variant === "icon" ? (
274 <Hint label="New message">
275 <Button type="button" aria-label="New message" onClick={() => setOpen(true)} variant="ghost" size="icon-sm" className="text-faint">
276 <SquarePen size={15} />
277 </Button>
278 </Hint>
279 ) : (
280 <Button type="button" onClick={() => setOpen(true)} variant="outline">
281 <SquarePen size={15} />
282 {children ?? "New message"}
283 </Button>
284 )}
285 <Dialog open={open} onOpenChange={setOpen}>
286 <DialogContent className="max-w-md gap-0 p-0 max-sm:pb-[env(safe-area-inset-bottom)]" showClose={false}>
287 {open && <NewMessage slug={slug} onDone={() => setOpen(false)} />}
288 </DialogContent>
289 </Dialog>
290 </>
291 );
292}
293
294function NewMessage({ slug, onDone }: { slug: string; onDone: () => void }) {
295 const data = useChatData();
296 const [query, setQuery] = useState("");
297 const [chosen, setChosen] = useState<Pickable[]>([]);
298 const [active, setActive] = useState(0);
299 const [error, setError] = useState<string | null>(null);
300 const [busy, setBusy] = useState(false);
301 const input = useRef<HTMLInputElement>(null);
302 const send = useChatSend(slug);
303 const navigate = useNavigate();
304 const everyone = useMemo<Pickable[]>(() => {
305 const me = data?.me.username.toLowerCase();
306 return [
307 // A personal agent is only in its own DM, opened from the sidebar, never with others.
308 ...(data?.agents ?? []).filter((agent) => agent.scope !== "personal").map((agent) => ({
309 key: `agent:${agent.id}`,
310 kind: "agent" as const,
311 name: agent.handle,
312 handle: agent.handle,
313 display: agent.display_name,
314 avatar: agent.avatar,
315 role: agent.role,
316 })),
317 ...(data?.people ?? [])
318 .filter((person) => person.name.toLowerCase() !== me)
319 .map((person) => ({ key: `user:${person.name}`, kind: "user" as const, name: person.name, handle: shownHandle(person), display: shownName(person), avatar: person.avatar })),
320 ];
321 }, [data]);
322 const q = query.trim().toLowerCase().replace(/^@/, "");
323 const picked = new Set(chosen.map((p) => p.key));
324 const options = everyone.filter(
325 (p) => !picked.has(p.key) && (!q || p.name.toLowerCase().includes(q) || p.display.toLowerCase().includes(q)),
326 );
327 const take = (person: Pickable) => {
328 setChosen((now) => [...now, person]);
329 setQuery("");
330 setActive(0);
331 input.current?.focus();
332 };
333 const go = async () => {
334 if (chosen.length === 0) return;
335 setBusy(true);
336 setError(null);
337 const opened = await send<{ to: string }>({ intent: "dm", members: chosen.map((p) => p.key) });
338 setBusy(false);
339 if (!opened.ok) return setError(opened.error.message);
340 onDone();
341 navigate(opened.value.to);
342 };
343 return (
344 <div className="flex max-h-[min(36rem,calc(100dvh-2rem))] flex-col">
345 <div className="border-b border-line px-5 pt-5 pb-4">
346 <DialogTitle>New message</DialogTitle>
347 <DialogDescription className="mt-1">To one person, a few, or an agent. Agents answer in the conversation.</DialogDescription>
348 <div className="mt-4 flex min-h-10 flex-wrap items-center gap-1.5 rounded-md border border-line bg-bg px-2 py-1.5 focus-within:border-accent-dim">
349 <span className="pl-1 text-sm text-faint">To:</span>
350 {chosen.map((person) => (
351 <span key={person.key} className="flex h-6 items-center gap-1.5 rounded-md bg-raised pr-1 pl-1 text-[0.8125rem]">
352 {person.kind === "agent" ? <AgentAvatar agent={{ id: person.key.slice(6), handle: person.name, avatar: person.avatar }} size={16} /> : <Avatar name={person.name} image={person.avatar} size={16} />}
353 {person.display}
354 <Button
355 type="button"
356 aria-label={`Remove ${person.display}`}
357 onClick={() => setChosen((now) => now.filter((p) => p.key !== person.key))}
358 variant="ghost"
359 size="inline"
360 className="rounded p-0.5 text-faint hover:bg-line"
361 >
362 <X size={12} />
363 </Button>
364 </span>
365 ))}
366 <input
367 ref={input}
368 autoFocus
369 value={query}
370 onChange={(event) => {
371 setQuery(event.target.value);
372 setActive(0);
373 }}
374 onKeyDown={(event) => {
375 if (event.key === "ArrowDown" || event.key === "ArrowUp") {
376 event.preventDefault();
377 const count = Math.max(1, options.length);
378 setActive((index) => (index + (event.key === "ArrowDown" ? 1 : count - 1)) % count);
379 } else if (event.key === "Enter") {
380 event.preventDefault();
381 const person = options[active];
382 if (person && q) take(person);
383 else void go();
384 } else if (event.key === "Backspace" && !query && chosen.length) {
385 setChosen((now) => now.slice(0, -1));
386 }
387 }}
388 placeholder={chosen.length ? "" : "Name or @handle"}
389 aria-label="Who to message"
390 autoComplete="off"
391 data-1p-ignore
392 className="h-7 min-w-24 grow bg-transparent px-1 text-sm outline-none placeholder:text-faint"
393 />
394 </div>
395 </div>
396 <ul role="listbox" aria-label="People and agents" className="min-h-0 grow overflow-y-auto p-1.5">
397 {options.length === 0 && (
398 <li className="flex items-center gap-2 px-3 py-6 text-sm text-muted">
399 <Search size={14} />
400 {everyone.length === 0 ? "No one else is in this workspace yet." : "No one matches."}
401 </li>
402 )}
403 {options.map((person, index) => (
404 <li
405 key={person.key}
406 role="option"
407 aria-selected={index === active}
408 onMouseEnter={() => setActive(index)}
409 onClick={() => take(person)}
410 className={`flex cursor-pointer items-center gap-3 rounded-md px-3 py-2 ${index === active ? "bg-raised" : ""}`}
411 >
412 {person.kind === "agent" ? <AgentAvatar agent={{ id: person.key.slice(6), handle: person.name, avatar: person.avatar }} size={28} /> : <Avatar name={person.name} image={person.avatar} size={28} />}
413 <span className="min-w-0 grow">
414 <span className="flex items-center gap-1.5 text-sm font-medium">
415 <span className="truncate">{person.display}</span>
416 {person.kind === "agent" && <AgentPill />}
417 </span>
418 <span className="block truncate text-xs text-faint">
419 @{person.handle}
420 {person.role ? ` · ${person.role}` : ""}
421 </span>
422 </span>
423 {index === active && <Check size={14} className="text-faint" />}
424 </li>
425 ))}
426 </ul>
427 <div className="flex items-center justify-between gap-3 border-t border-line px-5 py-3">
428 <span className="min-w-0 truncate text-sm text-danger">{error}</span>
429 <div className="flex shrink-0 gap-2">
430 <Button type="button" onClick={onDone} variant="outline">
431 Cancel
432 </Button>
433 <Button
434 type="button"
435 disabled={busy || chosen.length === 0}
436 onClick={() => void go()}
437 variant="accent"
438 >
439 {busy ? "Opening…" : "Open conversation"}
440 </Button>
441 </div>
442 </div>
443 </div>
444 );
445}