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