| 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 | */ |
| 12 | import 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. */ |
| 15 | export type EmojiEntry = [emoji: string, name: string, codes: string, tags: string, group: number, skins?: string[]]; |
| 16 | |
| 17 | export 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. */ |
| 20 | export 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. */ |
| 25 | export const SKIN_TONES = ["✋", "✋🏻", "✋🏼", "✋🏽", "✋🏾", "✋🏿"] as const; |
| 26 | |
| 27 | /** A standard emoji in a skin tone (0 for none). */ |
| 28 | export 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 | |
| 32 | export 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 | |
| 36 | export 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. */ |
| 41 | export 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. */ |
| 46 | function 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 | */ |
| 63 | export 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. */ |
| 82 | export 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. */ |
| 85 | export 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 | */ |
| 94 | export 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. */ |
| 104 | export 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 | */ |
| 115 | export 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 | |
| 122 | export 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. */ |
| 125 | export 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. */ |
| 141 | export 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 | |
| 151 | const 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 | */ |
| 158 | export 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 | |
| 173 | type 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 | */ |
| 180 | export 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. */ |
| 200 | export 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 | */ |
| 208 | export 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. */ |
| 216 | export 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. */ |
| 227 | export const MAX_RECENT = 24; |
| 228 | |
| 229 | /** The recent list with `emoji` first, each once. */ |
| 230 | export function pushRecent(recent: readonly string[], emoji: string, max = MAX_RECENT): string[] { |
| 231 | return [emoji, ...recent.filter((e) => e !== emoji)].slice(0, max); |
| 232 | } |