// Emoji data + search. // // The data file is generated by `scripts/gen-emoji-data.mjs` from emojibase-data // and ships as a static asset at /emoji-data.json. import { useEffect, useState } from "react"; export interface Skin { n: string; } export interface Emoji { /** Canonical shortcode, e.g. "grinning-face". */ id: string; /** Human-readable name, e.g. "Grinning Face". */ m: string; /** Tags for keyword search. */ k: string[]; /** Skin variants. s[0] is always the default (no tone modifier); * s[1..5] correspond to Fitzpatrick tones 1-5 when the emoji supports them. */ s: Skin[]; } export interface EmojiData { /** Map of canonical id → emoji. */ emojis: { [id: string]: Emoji }; /** Alternate shortcodes (e.g. "shrug" → "person-shrugging") pointing to the canonical id. */ aliases: { [alias: string]: string }; } /** Returns the native character for a given Fitzpatrick tone (0 = default). */ export function getSkinNative(emoji: Emoji, skinTone: number = 0): string { return (emoji.s[skinTone] ?? emoji.s[0]).n; } const DATA_URL = "/emoji-data.json"; // Module-level promise cache so concurrent callers (e.g. two chat inputs // mounting at the same time) share a single fetch. The HTTP layer also caches, // but we don't want to fire two parses for the same data. let dataPromise: Promise | null = null; export function getEmojiData(): Promise { if (dataPromise) return dataPromise; dataPromise = fetch(DATA_URL) .then((r) => { if (!r.ok) throw new Error(`emoji-data fetch failed: ${r.status}`); return r.json() as Promise; }) .catch((err) => { // If the fetch fails, drop the promise so a future call can retry. dataPromise = null; throw err; }); return dataPromise; } /** React hook: returns the loaded emoji data, or null until it's ready. */ export function useEmojiData(): EmojiData | null { const [data, setData] = useState(null); useEffect(() => { let cancelled = false; getEmojiData() .then((d) => { if (!cancelled) setData(d); }) .catch((err) => { console.error("Failed to load emoji data", err); }); return () => { cancelled = true; }; }, []); return data; } const MIN_QUERY_LENGTH = 3; const MAX_RESULTS = 10; /** * Rank-matching emoji for a `:`-prefixed query. * * Ported from the React Native reference implementation in * `js/components/src/components/chat/chat-box.tsx`. Match order, all else equal: * 0. exact alias → 1. alias startsWith → 2. alias includes * 3. exact id → 4. id startsWith → 5. id includes * 6. name includes → 7. keyword includes * * Ties on matchType are broken by position of the substring within the * matched field. * * @param data the loaded emoji data, or null when not yet available * @param query the text after the last `:` (no colon), already lowercased * @param limit cap on the result list */ export function searchEmojis( data: EmojiData | null, query: string, limit: number = MAX_RESULTS, ): Emoji[] { if (!data) return []; if (query.length < MIN_QUERY_LENGTH) return []; if (query.includes(" ")) return []; // For each alias, compute a match type (0=exact, 1=prefix, 2=contains) and // the substring position. We then collapse aliases to "the best one per // emoji" so each emoji is only represented once. const aliasMatches = Object.entries(data.aliases) .map(([alias, emojiId]) => { const aliasLower = alias.toLowerCase(); if (aliasLower === query) { return { emojiId, alias, matchType: 0, index: 0 }; } if (aliasLower.startsWith(query)) { return { emojiId, alias, matchType: 1, index: 0 }; } if (aliasLower.includes(query)) { return { emojiId, alias, matchType: 2, index: aliasLower.indexOf(query), }; } return null; }) .filter((m): m is NonNullable => m !== null); // Best alias match per emoji id. const bestAliasMatch: Record< string, { matchType: number; index: number; alias: string } > = {}; for (const match of aliasMatches) { const prev = bestAliasMatch[match.emojiId]; if ( !prev || match.matchType < prev.matchType || (match.matchType === prev.matchType && match.index < prev.index) ) { bestAliasMatch[match.emojiId] = match; } } const allEmojis = Object.values(data.emojis); const seen = new Set(); const out: { emoji: Emoji; sort: number[] }[] = []; for (const emoji of allEmojis) { if (seen.has(emoji.id)) continue; const aliasMatch = bestAliasMatch[emoji.id]; if (aliasMatch) { seen.add(emoji.id); out.push({ emoji, sort: [aliasMatch.matchType, aliasMatch.index, 0], }); continue; } const idLower = emoji.id.toLowerCase(); if (idLower === query) { seen.add(emoji.id); out.push({ emoji, sort: [3, 0, 0] }); continue; } if (idLower.startsWith(query)) { seen.add(emoji.id); out.push({ emoji, sort: [4, 0, 0] }); continue; } if (idLower.includes(query)) { seen.add(emoji.id); out.push({ emoji, sort: [5, idLower.indexOf(query), 0] }); continue; } const nameLower = emoji.m.toLowerCase(); if (nameLower.includes(query)) { seen.add(emoji.id); out.push({ emoji, sort: [6, nameLower.indexOf(query), 0] }); continue; } if (emoji.k.some((kw) => kw.toLowerCase().includes(query))) { seen.add(emoji.id); out.push({ emoji, sort: [7, 0, 0] }); continue; } } out.sort((a, b) => { for (let i = 0; i < a.sort.length; i++) { if (a.sort[i] !== b.sort[i]) return a.sort[i] - b.sort[i]; } return 0; }); return out.slice(0, limit).map((e) => e.emoji); }