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