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.

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