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