Skip to content
454 linesCodeBlameRaw

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002)1import { ImagePlus, Lock, Plus, Search, Trash2, Upload } from "lucide-react";
2import { type ChangeEvent, useEffect, useMemo, useRef, useState } from "react";
3import { useFetcher } from "react-router";
4
5import { type CustomEmoji, type EmojiList, type EmojiUpload, MAX_EMOJI_BYTES, MAX_EMOJI_SIDE } from "@g1t/contracts";
6
7import { EmojiProvider, forgetCustomEmoji } from "./context";
8import { loadEmojiData } from "./data";
9import { CustomEmojiImage } from "./render";
10import { MemberAvatar } from "../chat/marks";
11import { Button, EmptyState, ErrorText, TimeAgo, notACredential } from "../ui";
12import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "../ui/dialog";
13import { Hint } from "../ui/hint";
14import { cleanEmojiName, emojiNameProblem, standardCodes } from "../../lib/emoji";
15
16/** What the page's action answers (routes/workspace/emoji.tsx). */
17export type EmojiActionResult = { ok: true; intent: string } | { ok: false; intent: string; error: string };
18
19/** What an image may be: as the chat service checks, by its bytes. */
20const ACCEPT = "image/png,image/gif,image/webp";
21
22/**
23 * A workspace's own emoji: every one with its name, who added it and when;
24 * adding one from an image, giving one another name, and removing them.
25 * Owners also choose who may add them.
26 */
27export function EmojiSettings({ slug, list, usercontent, meId }: { slug: string; list: EmojiList; usercontent: string; meId: string }) {
28 const [query, setQuery] = useState("");
29 const [adding, setAdding] = useState(false);
30 const [aliasing, setAliasing] = useState(false);
31 const shown = useMemo(() => {
32 const q = cleanEmojiName(query);
33 return q ? list.emoji.filter((e) => e.name.includes(q) || e.alias_of?.includes(q)) : list.emoji;
34 }, [list.emoji, query]);
35 const taken = useMemo(() => new Set(list.emoji.map((e) => e.name)), [list.emoji]);
36 useEffect(() => forgetCustomEmoji(slug), [slug, list.emoji]);
37
38 return (
39 <EmojiProvider value={{ customs: list.emoji, usercontent, me: null }}>
40 <div className="space-y-8">
41 <div>
42 <p className="max-w-2xl text-sm text-muted">
43 Emoji everyone in {slug} can use in messages and reactions, as <code className="font-mono text-fg-soft">:name:</code>. Images are PNG, GIF or
44 WebP, up to {MAX_EMOJI_BYTES / 1024} KB and {MAX_EMOJI_SIDE}×{MAX_EMOJI_SIDE} pixels; square ones look best.
45 </p>
46 </div>
47 <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
48 <label className="relative block sm:w-72">
49 <span className="sr-only">Find an emoji</span>
50 <Search size={14} className="pointer-events-none absolute top-1/2 left-3 -translate-y-1/2 text-faint" />
51 <input
52 type="search"
53 value={query}
54 onChange={(event) => setQuery(event.target.value)}
55 placeholder="Find an emoji"
56 {...notACredential()}
57 className="w-full rounded-md border border-line bg-bg py-1.5 pr-3 pl-8 text-sm outline-none transition-colors placeholder:text-faint hover:border-line-strong focus:border-accent-dim"
58 />
59 </label>
60 {list.can_upload ? (
61 <div className="flex gap-2">
62 {list.emoji.length > 0 && (
63 <Button variant="quiet" onClick={() => setAliasing(true)}>
64 Add an alias
65 </Button>
66 )}
67 <Button variant="accent" onClick={() => setAdding(true)}>
68 <Plus size={15} />
69 Add emoji
70 </Button>
71 </div>
72 ) : (
73 <p className="flex items-center gap-1.5 text-xs text-muted">
74 <Lock size={13} className="shrink-0 text-faint" />
75 Only owners can add emoji in this workspace.
76 </p>
77 )}
78 </div>
79
80 {list.emoji.length === 0 ? (
81 <EmptyState title="No emoji of your own yet">
82 <ImagePlus size={20} className="mx-auto mb-2 text-faint" />
83 Add your team’s logo, a mascot or an inside joke, and use it as <code className="font-mono">:name:</code> anywhere in chat.
84 </EmptyState>
85 ) : (
86 <section aria-label="Emoji">
87 <h2 className="mb-3 text-sm font-medium text-muted">
88 {query ? "Matching" : "All emoji"} <span className="text-faint">{shown.length}</span>
89 </h2>
90 {shown.length === 0 ? (
91 <p className="rounded-xl border border-line px-4 py-8 text-center text-sm text-muted">No emoji match “{query}”.</p>
92 ) : (
93 <ul className="grid grid-cols-1 gap-2 sm:grid-cols-2 xl:grid-cols-3">
94 {shown.map((emoji) => (
95 <EmojiCard key={emoji.name} slug={slug} emoji={emoji} usercontent={usercontent} removable={list.can_manage || (emoji.created_by.kind === "user" && emoji.created_by.id === meId)} />
96 ))}
97 </ul>
98 )}
99 </section>
100 )}
101
102 {list.can_manage && <UploadSetting value={list.emoji_upload} />}
103 </div>
104 <UploadDialog open={adding} onOpenChange={setAdding} taken={taken} />
105 <AliasDialog open={aliasing} onOpenChange={setAliasing} emoji={list.emoji} taken={taken} usercontent={usercontent} />
106 </EmojiProvider>
107 );
108}
109
110function EmojiCard({ slug, emoji, usercontent, removable }: { slug: string; emoji: CustomEmoji; usercontent: string; removable: boolean }) {
111 const fetcher = useFetcher<EmojiActionResult>();
112 const [confirm, setConfirm] = useState(false);
113 const busy = fetcher.state !== "idle";
114 useEffect(() => {
115 if (fetcher.data?.ok) forgetCustomEmoji(slug);
116 }, [fetcher.data, slug]);
117 return (
118 <li className="group flex items-center gap-3 rounded-xl border border-line bg-surface px-3 py-2.5">
119 <span className="flex size-11 shrink-0 items-center justify-center rounded-lg bg-bg">
120 <CustomEmojiImage name={emoji.name} file={emoji.file} usercontent={usercontent} size={32} />
121 </span>
122 <div className="min-w-0 grow">
123 <p className="truncate font-mono text-sm text-fg">:{emoji.name}:</p>
124 <p className="flex min-w-0 items-center gap-1.5 truncate text-xs text-faint">
125 {emoji.alias_of ? (
126 <span className="truncate">
127 Alias of <span className="font-mono text-muted">:{emoji.alias_of}:</span>
128 </span>
129 ) : (
130 <>
131 <MemberAvatar member={emoji.created_by} size={14} />
132 <span className="truncate">{emoji.created_by.display_name || emoji.created_by.name}</span>
133 <span aria-hidden>·</span>
134 <TimeAgo at={emoji.created_at} />
135 </>
136 )}
137 </p>
138 {fetcher.data && !fetcher.data.ok && <p className="mt-0.5 text-xs text-danger">{fetcher.data.error}</p>}
139 </div>
140 {removable &&
141 (confirm ? (
142 <div className="flex shrink-0 items-center gap-1">
143 <button type="button" onClick={() => setConfirm(false)} className="rounded-md px-2 py-1 text-xs text-muted hover:bg-raised hover:text-fg">
144 Keep
145 </button>
146 <button
147 type="button"
148 disabled={busy}
149 onClick={() => fetcher.submit({ intent: "remove", name: emoji.name }, { method: "post" })}
150 className="rounded-md bg-danger/15 px-2 py-1 text-xs font-medium text-danger hover:bg-danger/25 disabled:opacity-60"
151 >
152 {busy ? "Removing…" : emoji.alias_of ? "Remove" : "Remove with aliases"}
153 </button>
154 </div>
155 ) : (
156 <Hint label={`Remove :${emoji.name}:`}>
157 <button
158 type="button"
159 aria-label={`Remove :${emoji.name}:`}
160 onClick={() => setConfirm(true)}
161 className="flex size-8 shrink-0 items-center justify-center rounded-md text-faint opacity-100 transition hover:bg-raised hover:text-danger sm:opacity-0 sm:group-hover:opacity-100 sm:focus-visible:opacity-100"
162 >
163 <Trash2 size={15} />
164 </button>
165 </Hint>
166 ))}
167 </li>
168 );
169}
170
171/** Owners: who may add emoji. */
172function UploadSetting({ value }: { value: EmojiUpload }) {
173 const fetcher = useFetcher<EmojiActionResult>();
174 const current = (fetcher.formData?.get("value") as EmojiUpload | null) ?? value;
175 const choices: { value: EmojiUpload; label: string; detail: string }[] = [
176 { value: "members", label: "Every member", detail: "Anyone in the workspace can add emoji and aliases." },
177 { value: "admins", label: "Owners only", detail: "Members use the workspace’s emoji; only owners add them." },
178 ];
179 return (
180 <section aria-labelledby="emoji-upload" className="rounded-xl border border-line p-4">
181 <h2 id="emoji-upload" className="text-sm font-medium text-fg">
182 Who can add emoji
183 </h2>
184 <p className="mt-1 text-xs text-muted">Whoever added an emoji, and owners, can remove it.</p>
185 <div role="radiogroup" aria-labelledby="emoji-upload" className="mt-3 grid gap-2 sm:grid-cols-2">
186 {choices.map((choice) => (
187 <button
188 key={choice.value}
189 type="button"
190 role="radio"
191 aria-checked={current === choice.value}
192 onClick={() => current !== choice.value && fetcher.submit({ intent: "setting", value: choice.value }, { method: "post" })}
193 className={`rounded-lg border px-3 py-2.5 text-left transition-colors ${
194 current === choice.value ? "border-accent/60 bg-accent/10" : "border-line hover:border-line-strong hover:bg-surface"
195 }`}
196 >
197 <span className="flex items-center gap-2 text-sm font-medium text-fg">
198 <span className={`size-3.5 rounded-full border ${current === choice.value ? "border-4 border-accent" : "border-line-strong"}`} aria-hidden />
199 {choice.label}
200 </span>
201 <span className="mt-0.5 block pl-5.5 text-xs text-muted">{choice.detail}</span>
202 </button>
203 ))}
204 </div>
205 {fetcher.data && !fetcher.data.ok && <ErrorText>{fetcher.data.error}</ErrorText>}
206 </section>
207 );
208}
209
210/** What a chosen file is, read in the browser before sending it: the chat service checks again. */
211type Chosen = { file: File; url: string; width: number; height: number; problem: string | null };
212
213async function inspect(file: File): Promise<Chosen> {
214 const url = URL.createObjectURL(file);
215 const size = await new Promise<{ width: number; height: number }>((resolve) => {
216 const image = new Image();
217 image.onload = () => resolve({ width: image.naturalWidth, height: image.naturalHeight });
218 image.onerror = () => resolve({ width: 0, height: 0 });
219 image.src = url;
220 });
221 let problem: string | null = null;
222 if (!["image/png", "image/gif", "image/webp"].includes(file.type)) problem = "Use a PNG, GIF or WebP image.";
223 else if (file.size > MAX_EMOJI_BYTES) problem = `This image is ${Math.ceil(file.size / 1024)} KB; the most is ${MAX_EMOJI_BYTES / 1024} KB.`;
224 else if (!size.width) problem = "This file couldn’t be read as an image.";
225 else if (size.width > MAX_EMOJI_SIDE || size.height > MAX_EMOJI_SIDE) problem = `This image is ${size.width}×${size.height}; the most is ${MAX_EMOJI_SIDE}×${MAX_EMOJI_SIDE}.`;
226 return { file, url, ...size, problem };
227}
228
229/** Adding an emoji: an image, previewed as messages and reactions will show it, and its name. */
230export function UploadDialog({
231 open,
232 onOpenChange,
233 taken,
234 initial,
235}: {
236 open: boolean;
237 onOpenChange: (open: boolean) => void;
238 taken: ReadonlySet<string>;
239 /** For a preview without a real file (the screenshots harness). */
240 initial?: { url: string; name: string; width: number; height: number; bytes: number };
241}) {
242 const fetcher = useFetcher<EmojiActionResult>();
243 const input = useRef<HTMLInputElement>(null);
244 const [chosen, setChosen] = useState<Chosen | null>(null);
245 const [name, setName] = useState(initial?.name ?? "");
246 const [named, setNamed] = useState(Boolean(initial));
247 const [standard, setStandard] = useState<ReadonlySet<string> | null>(null);
248 const busy = fetcher.state !== "idle";
249
250 useEffect(() => {
251 if (!open) return;
252 void loadEmojiData()
253 .then((data) => setStandard(standardCodes(data)))
254 .catch(() => {});
255 }, [open]);
256 useEffect(() => {
257 if (fetcher.state === "idle" && fetcher.data?.ok && fetcher.data.intent === "add") {
258 onOpenChange(false);
259 setChosen(null);
260 setName("");
261 setNamed(false);
262 }
263 // eslint-disable-next-line react-hooks/exhaustive-deps
264 }, [fetcher.state, fetcher.data]);
265 useEffect(() => () => {
266 if (chosen) URL.revokeObjectURL(chosen.url);
267 }, [chosen]);
268
269 const clean = cleanEmojiName(name);
270 const nameProblem = name ? emojiNameProblem(clean, taken, standard) : null;
271 const preview = chosen?.url ?? initial?.url ?? null;
272 const problem = chosen?.problem ?? null;
273 const ready = Boolean((chosen && !problem) || initial) && clean && !nameProblem && !busy;
274
275 const onFile = async (event: ChangeEvent<HTMLInputElement>) => {
276 const file = event.target.files?.[0];
277 if (!file) return;
278 const seen = await inspect(file);
279 setChosen(seen);
280 // The file's name suggests the emoji's, until a name is typed.
281 if (!named) setName(cleanEmojiName(file.name.replace(/\.[a-z0-9]+$/i, "")).replace(/[^a-z0-9_+-]/g, "").slice(0, 32));
282 };
283
284 const submit = () => {
285 if (!chosen || !ready) return;
286 const form = new FormData();
287 form.set("intent", "add");
288 form.set("name", clean);
289 form.set("file", chosen.file);
290 fetcher.submit(form, { method: "post", encType: "multipart/form-data" });
291 };
292
293 return (
294 <Dialog open={open} onOpenChange={onOpenChange}>
295 <DialogContent className="max-w-md">
296 <DialogHeader>
297 <DialogTitle>Add an emoji</DialogTitle>
298 <DialogDescription>PNG, GIF or WebP, up to {MAX_EMOJI_BYTES / 1024} KB and {MAX_EMOJI_SIDE}×{MAX_EMOJI_SIDE}. Animated GIFs play.</DialogDescription>
299 </DialogHeader>
300 <input ref={input} type="file" accept={ACCEPT} onChange={onFile} className="sr-only" tabIndex={-1} aria-hidden />
301 <button
302 type="button"
303 onClick={() => input.current?.click()}
304 className={`flex min-h-32 flex-col items-center justify-center gap-2 rounded-xl border border-dashed px-4 py-5 text-sm transition-colors ${
305 problem ? "border-danger/60 bg-danger/5" : "border-line-strong hover:border-accent/50 hover:bg-surface"
306 }`}
307 >
308 {preview ? (
309 <>
310 <img src={preview} alt="" className="size-16 object-contain" />
311 <span className="text-xs text-muted">
312 {chosen ? `${chosen.width}×${chosen.height} · ${Math.ceil(chosen.file.size / 1024)} KB` : initial ? `${initial.width}×${initial.height} · ${Math.ceil(initial.bytes / 1024)} KB` : ""} ·{" "}
313 <span className="text-accent">Choose another</span>
314 </span>
315 </>
316 ) : (
317 <>
318 <Upload size={20} className="text-faint" />
319 <span className="font-medium text-fg-soft">Choose an image</span>
320 </>
321 )}
322 </button>
323 {problem && <ErrorText>{problem}</ErrorText>}
324
325 <label className="block">
326 <span className="text-sm font-medium text-fg">Name</span>
327 <span className="mt-1.5 flex items-center rounded-md border border-line-strong bg-bg focus-within:border-accent/50">
328 <span className="pl-2.5 font-mono text-sm text-faint">:</span>
329 <input
330 value={name}
331 onChange={(event) => {
332 setName(event.target.value);
333 setNamed(true);
334 }}
335 placeholder="shipit"
336 maxLength={40}
337 {...notACredential()}
338 aria-invalid={Boolean(nameProblem)}
339 className="min-w-0 grow bg-transparent px-0.5 py-1.5 font-mono text-sm text-fg outline-none placeholder:text-faint"
340 />
341 <span className="pr-2.5 font-mono text-sm text-faint">:</span>
342 </span>
343 <span className={`mt-1 block text-xs ${nameProblem ? "text-danger" : "text-faint"}`}>
344 {nameProblem ?? "Lowercase letters, digits, -, _ and +; 2 to 32 characters."}
345 </span>
346 </label>
347
348 {preview && (
349 <div className="rounded-xl border border-line bg-bg p-3" aria-label="Preview">
350 <p className="mb-2 text-[0.6875rem] font-semibold tracking-wide text-faint uppercase">Preview</p>
351 <p className="text-[0.9375rem] text-fg-soft">
352 Shipping it today <img src={preview} alt={`:${clean || "name"}:`} className="inline-block size-[1.375em] object-contain align-[-0.3em]" />
353 </p>
354 <div className="mt-2 flex items-center gap-2">
355 <span className="flex h-6 items-center gap-1 rounded-full border border-accent/60 bg-accent/15 px-1.5 text-xs font-medium text-accent">
356 <img src={preview} alt="" className="size-[15px] object-contain" />3
357 </span>
358 <img src={preview} alt="" className="size-9 object-contain" />
359 <span className="ml-auto font-mono text-xs text-faint">:{clean || "name"}:</span>
360 </div>
361 </div>
362 )}
363 {fetcher.data && !fetcher.data.ok && fetcher.data.intent === "add" && <ErrorText>{fetcher.data.error}</ErrorText>}
364
365 <DialogFooter>
366 <Button variant="quiet" onClick={() => onOpenChange(false)}>
367 Cancel
368 </Button>
369 <Button variant="accent" onClick={submit} disabled={!ready}>
370 {busy ? "Adding…" : "Add emoji"}
371 </Button>
372 </DialogFooter>
373 </DialogContent>
374 </Dialog>
375 );
376}
377
378/** Another name for an emoji the workspace has. */
379function AliasDialog({
380 open,
381 onOpenChange,
382 emoji,
383 taken,
384 usercontent,
385}: {
386 open: boolean;
387 onOpenChange: (open: boolean) => void;
388 emoji: CustomEmoji[];
389 taken: ReadonlySet<string>;
390 usercontent: string;
391}) {
392 const fetcher = useFetcher<EmojiActionResult>();
393 const originals = emoji.filter((e) => !e.alias_of);
394 const [target, setTarget] = useState(originals[0]?.name ?? "");
395 const [name, setName] = useState("");
396 const clean = cleanEmojiName(name);
397 const problem = name ? emojiNameProblem(clean, taken, null) : null;
398 const busy = fetcher.state !== "idle";
399 useEffect(() => {
400 if (fetcher.state === "idle" && fetcher.data?.ok && fetcher.data.intent === "alias") {
401 onOpenChange(false);
402 setName("");
403 }
404 // eslint-disable-next-line react-hooks/exhaustive-deps
405 }, [fetcher.state, fetcher.data]);
406 const picked = originals.find((e) => e.name === target);
407 return (
408 <Dialog open={open} onOpenChange={onOpenChange}>
409 <DialogContent className="max-w-md">
410 <DialogHeader>
411 <DialogTitle>Add an alias</DialogTitle>
412 <DialogDescription>Another name for an emoji: both show the same image. Removing the emoji removes its aliases.</DialogDescription>
413 </DialogHeader>
414 <label className="block">
415 <span className="text-sm font-medium text-fg">Emoji</span>
416 <span className="mt-1.5 flex items-center gap-2 rounded-md border border-line-strong bg-bg px-2.5">
417 {picked && <CustomEmojiImage name={picked.name} file={picked.file} usercontent={usercontent} size={20} />}
418 <select value={target} onChange={(event) => setTarget(event.target.value)} className="min-w-0 grow bg-transparent py-1.5 font-mono text-sm text-fg outline-none">
419 {originals.map((e) => (
420 <option key={e.name} value={e.name}>
421 :{e.name}:
422 </option>
423 ))}
424 </select>
425 </span>
426 </label>
427 <label className="block">
428 <span className="text-sm font-medium text-fg">Alias</span>
429 <input
430 value={name}
431 onChange={(event) => setName(event.target.value)}
432 placeholder="ship-it"
433 {...notACredential()}
434 className="mt-1.5 w-full rounded-md border border-line-strong bg-bg px-2.5 py-1.5 font-mono text-sm text-fg outline-none placeholder:text-faint focus:border-accent/50"
435 />
436 {problem && <span className="mt-1 block text-xs text-danger">{problem}</span>}
437 </label>
438 {fetcher.data && !fetcher.data.ok && fetcher.data.intent === "alias" && <ErrorText>{fetcher.data.error}</ErrorText>}
439 <DialogFooter>
440 <Button variant="quiet" onClick={() => onOpenChange(false)}>
441 Cancel
442 </Button>
443 <Button
444 variant="accent"
445 disabled={!clean || Boolean(problem) || !target || busy}
446 onClick={() => fetcher.submit({ intent: "alias", name: clean, target }, { method: "post" })}
447 >
448 {busy ? "Adding…" : "Add alias"}
449 </Button>
450 </DialogFooter>
451 </DialogContent>
452 </Dialog>
453 );
454}

This file's history is long; its oldest lines are credited to the oldest commit read.