Skip to content
232 linesCodeBlameRaw
1/**
2 * Emoji as the site draws them: finding one by name or keyword, what a
3 * workspace's own emoji may be called, `:name:` in text and in the
4 * composer, and reactions changing as people click and as the room says.
5 * Pure, so it is tested on its own (emoji.test.ts); the components are in
6 * components/emoji.
7 *
8 * The standard emoji come from components/emoji/data.json (Emojibase, MIT,
9 * made by scripts/emoji/generate.mjs). It is loaded on first use, never in
10 * the main bundle.
11 */
12import type { ChatLiveEvent, ChatReaction, CustomEmoji, MemberProfile } from "@g1t/contracts";
13
14/** One standard emoji: the emoji, its name, shortcodes and keywords (space-separated), its group, and its five skin tones if it has them. */
15export type EmojiEntry = [emoji: string, name: string, codes: string, tags: string, group: number, skins?: string[]];
16
17export type EmojiData = { source: string; groups: string[]; emoji: EmojiEntry[] };
18
19/** What the picker and the composer offer: a standard emoji, or one of the workspace's. */
20export type EmojiPick =
21 | { kind: "unicode"; emoji: string; name: string; code: string | null; skins?: string[] }
22 | { kind: "custom"; emoji: string; name: string; file: string };
23
24/** Skin tones, as the picker offers them: none, then light to dark. */
25export const SKIN_TONES = ["✋", "✋🏻", "✋🏼", "✋🏽", "✋🏾", "✋🏿"] as const;
26
27/** A standard emoji in a skin tone (0 for none). */
28export function skinned(entry: Pick<EmojiPick & { kind: "unicode" }, "emoji" | "skins">, tone: number): string {
29 return tone > 0 && entry.skins?.[tone - 1] ? entry.skins[tone - 1]! : entry.emoji;
30}
31
32export function unicodePick(entry: EmojiEntry): EmojiPick {
33 return { kind: "unicode", emoji: entry[0], name: entry[1], code: entry[2].split(" ")[0] || null, skins: entry[5] };
34}
35
36export function customPick(emoji: Pick<CustomEmoji, "name" | "file">): EmojiPick {
37 return { kind: "custom", emoji: `:${emoji.name}:`, name: emoji.name, file: emoji.file };
38}
39
40/** Where a custom emoji's image is served: the usercontent origin, never the site. */
41export function emojiUrl(file: string, usercontent: string): string {
42 return `${usercontent}/emoji/${file}`;
43}
44
45/** How well `query` matches a set of words: lower is better, null for no match. */
46function rank(query: string, codes: string[], name: string, tags: string[]): number | null {
47 if (codes.includes(query)) return 0;
48 if (codes.some((code) => code.startsWith(query))) return 1;
49 const words = name.toLowerCase().split(/[\s:,-]+/);
50 if (name.toLowerCase() === query) return 0;
51 if (words.some((word) => word.startsWith(query))) return 2;
52 if (tags.some((tag) => tag.startsWith(query))) return 3;
53 if (codes.some((code) => code.includes(query)) || name.toLowerCase().includes(query)) return 4;
54 return null;
55}
56
57/**
58 * Emoji matching what was typed, best first: a shortcode exactly, then one
59 * starting with it, then a word of the name, then a keyword, then anywhere
60 * in a name. The workspace's own come first among equals. `:` around the
61 * query is ignored; an empty query finds nothing.
62 */
63export function searchEmoji(data: EmojiData | null, customs: readonly Pick<CustomEmoji, "name" | "file">[], typed: string, limit = 60): EmojiPick[] {
64 const query = typed.trim().toLowerCase().replace(/^:+|:+$/g, "");
65 if (!query) return [];
66 const found: { pick: EmojiPick; score: number; order: number }[] = [];
67 customs.forEach((custom, order) => {
68 const score = rank(query, [custom.name], custom.name.replace(/[-_+]/g, " "), []);
69 if (score != null) found.push({ pick: customPick(custom), score, order });
70 });
71 data?.emoji.forEach((entry, order) => {
72 const score = rank(query, entry[2] ? entry[2].split(" ") : [], entry[1], entry[3] ? entry[3].split(" ") : []);
73 if (score != null) found.push({ pick: unicodePick(entry), score: score + 0.5, order: order + customs.length });
74 });
75 return found
76 .sort((a, b) => a.score - b.score || a.order - b.order)
77 .slice(0, limit)
78 .map((f) => f.pick);
79}
80
81/** As the chat service's rule: lowercase letters, digits, `-`, `_` and `+`, 2 to 32 of them. */
82export const EMOJI_NAME = /^[a-z0-9_+-]{2,32}$/;
83
84/** A name typed for a new emoji, as it would be kept: lowercase, without colons. */
85export function cleanEmojiName(typed: string): string {
86 return typed.trim().replace(/^:+|:+$/g, "").toLowerCase().replace(/\s+/g, "_");
87}
88
89/**
90 * What is wrong with a name for a new emoji, or null when it will do:
91 * its shape, a standard emoji's shortcode, or one the workspace already has.
92 * The chat service checks again and decides.
93 */
94export function emojiNameProblem(name: string, taken: ReadonlySet<string>, standard: ReadonlySet<string> | null): string | null {
95 if (name.length < 2) return "At least 2 characters.";
96 if (name.length > 32) return "At most 32 characters.";
97 if (!EMOJI_NAME.test(name)) return "Only lowercase letters, digits, -, _ and +.";
98 if (standard?.has(name)) return `:${name}: is a standard emoji.`;
99 if (taken.has(name)) return `:${name}: is already taken.`;
100 return null;
101}
102
103/** Every standard shortcode, for checking a new name against. */
104export function standardCodes(data: EmojiData): Set<string> {
105 const codes = new Set<string>();
106 for (const entry of data.emoji) for (const code of entry[2].split(" ")) if (code) codes.add(code);
107 return codes;
108}
109
110/**
111 * A `:name` being typed just before the caret, for the composer to
112 * complete: two characters at least, after the start, a space or an
113 * opening bracket, so `https://` and `12:30` are left alone.
114 */
115export function shortcodeQuery(text: string, caret: number): { start: number; typed: string } | null {
116 const before = text.slice(0, caret);
117 const match = /(^|[\s([{])(:[a-z0-9_+-]{2,32})$/i.exec(before);
118 if (!match) return null;
119 return { start: caret - match[2]!.length, typed: match[2]!.slice(1).toLowerCase() };
120}
121
122export type EmojiPart = { t: "text"; v: string } | { t: "emoji"; name: string; file: string };
123
124/** Text with each `:name:` of a workspace emoji picked out; anything else stays text. */
125export function splitShortcodes(text: string, customs: ReadonlyMap<string, string>): EmojiPart[] {
126 if (!customs.size || !text.includes(":")) return [{ t: "text", v: text }];
127 const parts: EmojiPart[] = [];
128 let last = 0;
129 for (const match of text.matchAll(/:([a-z0-9_+-]{2,32}):/g)) {
130 const file = customs.get(match[1]!);
131 if (!file) continue;
132 if (match.index! > last) parts.push({ t: "text", v: text.slice(last, match.index) });
133 parts.push({ t: "emoji", name: match[1]!, file });
134 last = match.index! + match[0].length;
135 }
136 if (last < text.length) parts.push({ t: "text", v: text.slice(last) });
137 return parts.length ? parts : [{ t: "text", v: text }];
138}
139
140/** Whether a text is nothing but a few emoji, standard or the workspace's: shown large. */
141export function onlyEmojiOrCustom(text: string, customs: ReadonlyMap<string, string>): boolean {
142 const trimmed = text.trim();
143 if (!trimmed || trimmed.length > 64) return false;
144 const rest = trimmed.replace(/:([a-z0-9_+-]{2,32}):/g, (all, name: string) => (customs.has(name) ? "" : all)).replace(/\s+/g, "");
145 if (rest === trimmed.replace(/\s+/g, "")) return false;
146 return rest === "" || /^(?:\p{Extended_Pictographic}|\p{Emoji_Component}|\u200d|\ufe0f)+$/u.test(rest);
147}
148
149// ── Reactions ───────────────────────────────────────────────────────────
150
151const sameMember = (a: Pick<MemberProfile, "kind" | "id">, b: Pick<MemberProfile, "kind" | "id">) => a.kind === b.kind && a.id === b.id;
152
153/**
154 * A message's reactions after the viewer (`me`) reacts with `emoji` (`on`)
155 * or takes it back: what the page shows at once, before the service
156 * answers. Doing what is already done changes nothing.
157 */
158export function toggleReaction(reactions: readonly ChatReaction[], emoji: string, on: boolean, me: MemberProfile): ChatReaction[] {
159 const at = reactions.findIndex((r) => r.emoji === emoji);
160 const current = at >= 0 ? reactions[at]! : null;
161 if (on) {
162 if (current?.me) return [...reactions];
163 if (!current) return [...reactions, { emoji, count: 1, me: true, by: [me] }];
164 const next = { ...current, count: current.count + 1, me: true, by: current.by.length < 10 ? [...current.by, me] : current.by };
165 return reactions.map((r, i) => (i === at ? next : r));
166 }
167 if (!current?.me) return [...reactions];
168 if (current.count <= 1) return reactions.filter((_, i) => i !== at);
169 const next = { ...current, count: current.count - 1, me: false, by: current.by.filter((m) => !sameMember(m, me)) };
170 return reactions.map((r, i) => (i === at ? next : r));
171}
172
173type ReactionEvent = Extract<ChatLiveEvent, { type: "reaction.added" | "reaction.removed" }>;
174
175/**
176 * A message's reactions after the room says someone reacted or took one
177 * back. The viewer's own (`meId`, a user id) may already show, from
178 * `toggleReaction`: it is not counted twice.
179 */
180export function reactionsAfterEvent(reactions: readonly ChatReaction[], event: ReactionEvent, meId: string | null): ChatReaction[] {
181 const mine = event.member.kind === "user" && event.member.id === meId;
182 const on = event.type === "reaction.added";
183 const at = reactions.findIndex((r) => r.emoji === event.emoji);
184 const current = at >= 0 ? reactions[at]! : null;
185 if (mine) return toggleReaction(reactions, event.emoji, on, event.member);
186 const listed = current?.by.some((m) => sameMember(m, event.member)) ?? false;
187 if (on) {
188 if (!current) return [...reactions, { emoji: event.emoji, count: 1, me: false, by: [event.member] }];
189 if (listed) return [...reactions];
190 const next = { ...current, count: current.count + 1, by: current.by.length < 10 ? [...current.by, event.member] : current.by };
191 return reactions.map((r, i) => (i === at ? next : r));
192 }
193 if (!current) return [...reactions];
194 if (current.count <= 1) return reactions.filter((_, i) => i !== at);
195 const next = { ...current, count: current.count - 1, by: current.by.filter((m) => !sameMember(m, event.member)) };
196 return reactions.map((r, i) => (i === at ? next : r));
197}
198
199/** Applies a reaction event to the messages it is about. */
200export function applyReactionEvent<M extends { id: string; reactions?: ChatReaction[] }>(messages: readonly M[], event: ReactionEvent, meId: string | null): M[] {
201 return messages.map((m) => (m.id === event.message_id ? { ...m, reactions: reactionsAfterEvent(m.reactions ?? [], event, meId) } : m));
202}
203
204/**
205 * A message as the room sent it (where no reaction is anyone's own), with
206 * the viewer's own marks kept from what the page already showed.
207 */
208export function keepMine<M extends { id: string; reactions?: ChatReaction[] }>(shown: readonly M[], incoming: M): M {
209 const before = shown.find((m) => m.id === incoming.id)?.reactions;
210 if (!before || !incoming.reactions) return incoming;
211 const mine = new Set(before.filter((r) => r.me).map((r) => r.emoji));
212 return { ...incoming, reactions: incoming.reactions.map((r) => (mine.has(r.emoji) ? { ...r, me: true } : r)) };
213}
214
215/** Who reacted, for the hover list: up to ten names, and how many more. */
216export function reactorsLine(reaction: ChatReaction, meId: string | null): string {
217 const names = reaction.by.map((m) => (m.kind === "user" && m.id === meId ? "You" : m.display_name || m.name));
218 const more = reaction.count - names.length;
219 if (more > 0) return `${names.join(", ")} and ${more} more`;
220 if (names.length <= 1) return names[0] ?? "";
221 return `${names.slice(0, -1).join(", ")} and ${names[names.length - 1]}`;
222}
223
224// ── Recently used ───────────────────────────────────────────────────────
225
226/** How many recent emoji the picker remembers. */
227export const MAX_RECENT = 24;
228
229/** The recent list with `emoji` first, each once. */
230export function pushRecent(recent: readonly string[], emoji: string, max = MAX_RECENT): string[] {
231 return [emoji, ...recent.filter((e) => e !== emoji)].slice(0, max);
232}