Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.
| Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002) | 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 | } |
This file's history is long; its oldest lines are credited to the oldest commit read.