Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

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