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.

441 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 ? "workspace" : chosen !== "private" && !may.public ? "private" : chosen === "public" && !may.open ? "workspace" : chosen;
124 const isPrivate = visibility === "private";
125 const clean = channelName(name);
126 const submit = async () => {
127 if (!clean) return setError("Give the channel a name.");
128 setBusy(true);
129 setError(null);
130 const made = await send<{ to: string }>({ intent: "create_channel", name: clean, topic, private: isPrivate, visibility });
131 setBusy(false);
132 if (!made.ok) return setError(made.error.message);
133 setOpen(false);
134 setName("");
135 setTopic("");
136 navigate(made.value.to);
137 };
138 if (!may.any) {
139 // Shown off, not hidden, so nobody wonders where it went.
140 return (
141 <Hint label={OWNERS_ONLY_CHANNELS} disabled>
142 {variant === "icon" ? (
143 <Button type="button" aria-label="Create a channel" disabled variant="ghost" size="icon-xs" className="text-faint opacity-40">
144 <Plus size={14} />
145 </Button>
146 ) : (
147 <Button type="button" disabled variant="outline" className="opacity-50">
148 <Lock size={14} />
149 Create a channel
150 </Button>
151 )}
152 </Hint>
153 );
154 }
155 return (
156 <>
157 {variant === "icon" ? (
158 <Hint label="Create a channel">
159 <Button type="button" aria-label="Create a channel" onClick={() => setOpen(true)} variant="ghost" size="icon-xs" className="text-faint">
160 <Plus size={14} />
161 </Button>
162 </Hint>
163 ) : (
164 <Button type="button" onClick={() => setOpen(true)} variant="outline">
165 <Hash size={15} />
166 Create a channel
167 </Button>
168 )}
169 <Dialog open={open} onOpenChange={setOpen}>
170 <DialogContent>
171 <DialogHeader>
172 <DialogTitle>Create a channel</DialogTitle>
173 <DialogDescription>A place for one topic: a project, a team, a launch. Invite people once it's made.</DialogDescription>
174 </DialogHeader>
175 <form
176 className="grid gap-4"
177 onSubmit={(event) => {
178 event.preventDefault();
179 void submit();
180 }}
181 >
182 <label className="grid gap-1.5">
183 <span className="text-sm font-medium text-muted">Name</span>
184 <span className="flex h-10 items-center gap-1.5 rounded-md border border-line bg-bg px-3 focus-within:border-accent-dim">
185 {isPrivate ? <Lock size={14} className="text-faint" /> : visibility === "public" ? <Globe size={14} className="text-faint" /> : <Hash size={14} className="text-faint" />}
186 <input
187 autoFocus
188 value={name}
189 onChange={(event) => setName(event.target.value)}
190 placeholder="release-train"
191 maxLength={80}
192 autoComplete="off"
193 data-1p-ignore
194 className="min-w-0 grow bg-transparent text-sm outline-none placeholder:text-faint"
195 />
196 </span>
197 {name && clean !== name && <span className="text-xs text-faint">It will be #{clean || "…"}</span>}
198 </label>
199 <label className="grid gap-1.5">
200 <span className="text-sm font-medium text-muted">
201 Topic <span className="font-normal text-faint">(optional)</span>
202 </span>
203 <input
204 value={topic}
205 onChange={(event) => setTopic(event.target.value)}
206 placeholder="What this channel is for"
207 maxLength={250}
208 autoComplete="off"
209 data-1p-ignore
210 className="h-10 rounded-md border border-line bg-bg px-3 text-sm outline-none placeholder:text-faint focus:border-accent-dim"
211 />
212 </label>
213 <fieldset className="grid gap-2">
214 <legend className="mb-1.5 text-sm font-medium text-muted">Who can open it</legend>
215 <RadioGroup value={visibility} onValueChange={(value) => setChosen(value as ChannelVisibility)} className="grid gap-3">
216 <RadioOption
217 value="workspace"
218 icon={<Hash />}
219 label="Workspace"
220 description={may.public ? "Anyone in the workspace can find, read and join it." : "Only workspace owners can create these here."}
221 disabled={!may.public}
222 />
223 <RadioOption
224 value="private"
225 icon={<Lock />}
226 label="Private"
227 description={may.private ? "Only the people you invite can find and read it. Others can ask to join." : "Only workspace owners can create these here."}
228 disabled={!may.private}
229 />
230 <RadioOption
231 value="public"
232 icon={<Globe />}
233 label="Public"
234 description={
235 may.open
236 ? "Anyone signed in to g1t can read, join and talk in it, marked Guest. A community or support channel."
237 : "Open to anyone on g1t. Only workspace owners can make one."
238 }
239 disabled={!may.open}
240 />
241 </RadioGroup>
242 </fieldset>
243 {error && <p className="text-sm text-danger">{error}</p>}
244 <DialogFooter>
245 <Button type="button" onClick={() => setOpen(false)} variant="outline">
246 Cancel
247 </Button>
248 <Button
249 type="submit"
250 disabled={busy || !clean}
251 variant="accent"
252 >
253 {busy ? "Creating…" : "Create channel"}
254 </Button>
255 </DialogFooter>
256 </form>
257 </DialogContent>
258 </Dialog>
259 </>
260 );
261}
262
263/** Someone a new message can go to. */
264type Pickable = { key: string; name: string; handle: string; display: string; avatar: string | null };
265
266/**
267 * A new direct message: choose one person or a few, then open the
268 * conversation between you (the same one each time for the same people).
269 */
270export function NewMessageButton({ slug, variant = "icon", children }: { slug: string; variant?: "icon" | "button" | "fab"; children?: ReactNode }) {
271 const [open, setOpen] = useState(false);
272 return (
273 <>
274 {variant === "fab" ? (
275 // A phone's list: the round button above the tab bar.
276 <Button
277 type="button"
278 aria-label="New message"
279 onClick={() => setOpen(true)}
280 variant="accent" size="icon"
281 className="size-14 rounded-2xl shadow-lg shadow-black/50 transition-transform active:scale-95"
282 >
283 <SquarePen size={22} />
284 </Button>
285 ) : variant === "icon" ? (
286 <Hint label="New message">
287 <Button type="button" aria-label="New message" onClick={() => setOpen(true)} variant="ghost" size="icon-sm" className="text-faint">
288 <SquarePen size={15} />
289 </Button>
290 </Hint>
291 ) : (
292 <Button type="button" onClick={() => setOpen(true)} variant="outline">
293 <SquarePen size={15} />
294 {children ?? "New message"}
295 </Button>
296 )}
297 <Dialog open={open} onOpenChange={setOpen}>
298 <DialogContent className="max-w-md gap-0 p-0 max-sm:pb-[env(safe-area-inset-bottom)]" showClose={false}>
299 {open && <NewMessage slug={slug} onDone={() => setOpen(false)} />}
300 </DialogContent>
301 </Dialog>
302 </>
303 );
304}
305
306function NewMessage({ slug, onDone }: { slug: string; onDone: () => void }) {
307 const data = useChatData();
308 const [query, setQuery] = useState("");
309 const [chosen, setChosen] = useState<Pickable[]>([]);
310 const [active, setActive] = useState(0);
311 const [error, setError] = useState<string | null>(null);
312 const [busy, setBusy] = useState(false);
313 const input = useRef<HTMLInputElement>(null);
314 const send = useChatSend(slug);
315 const navigate = useNavigate();
316 const everyone = useMemo<Pickable[]>(() => {
317 const me = data?.me.username.toLowerCase();
318 return (data?.people ?? [])
319 .filter((person) => person.name.toLowerCase() !== me)
320 .map((person) => ({ key: `user:${person.name}`, name: person.name, handle: shownHandle(person), display: shownName(person), avatar: person.avatar }));
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 or a few.</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 <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" 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 <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 </span>
417 <span className="block truncate text-xs text-faint">@{person.handle}</span>
418 </span>
419 {index === active && <Check size={14} className="text-faint" />}
420 </li>
421 ))}
422 </ul>
423 <div className="flex items-center justify-between gap-3 border-t border-line px-5 py-3">
424 <span className="min-w-0 truncate text-sm text-danger">{error}</span>
425 <div className="flex shrink-0 gap-2">
426 <Button type="button" onClick={onDone} variant="outline">
427 Cancel
428 </Button>
429 <Button
430 type="button"
431 disabled={busy || chosen.length === 0}
432 onClick={() => void go()}
433 variant="accent"
434 >
435 {busy ? "Opening…" : "Open conversation"}
436 </Button>
437 </div>
438 </div>
439 </div>
440 );
441}