diff --git a/frontend/src/components/input/editor/emoji/emojiData.test.ts b/frontend/src/components/input/editor/emoji/emojiData.test.ts new file mode 100644 index 000000000..87e57d53b --- /dev/null +++ b/frontend/src/components/input/editor/emoji/emojiData.test.ts @@ -0,0 +1,58 @@ +import {describe, it, expect, vi, beforeEach, afterEach} from 'vitest' +import {filterEmojis, __resetEmojiCacheForTest, loadEmojis} from './emojiData' + +const fixture = [ + {shortcodes: ['grinning', 'grinning_face'], annotation: 'grinning face', tags: ['face', 'grin'], emoji: '😀'}, + {shortcodes: ['eyes'], annotation: 'eyes', tags: ['look'], emoji: '👀'}, + {shortcodes: ['eyeglasses'], annotation: 'glasses', tags: ['eye'], emoji: '👓'}, + {shortcodes: ['smile'], annotation: 'grinning face with smiling eyes', tags: ['eye', 'smile'], emoji: '😄'}, +] + +describe('emojiData', () => { + beforeEach(() => { + __resetEmojiCacheForTest() + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + ok: true, + json: async () => fixture, + })) + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('flattens multi-shortcode entries and sorts alphabetically', async () => { + const idx = await loadEmojis() + const codes = idx.map(e => e.shortcode) + expect(codes).toEqual(['eyeglasses', 'eyes', 'grinning', 'grinning_face', 'smile']) + }) + + it('returns [] for empty query', () => { + expect(filterEmojis([{shortcode: 'eyes', emoji: '👀', annotation: '', tags: []}], '')).toEqual([]) + }) + + it('prefers startsWith matches over substring matches', () => { + const loaded = [ + {shortcode: 'eyeglasses', emoji: '👓', annotation: 'glasses', tags: ['eye']}, + {shortcode: 'eyes', emoji: '👀', annotation: 'eyes', tags: []}, + {shortcode: 'smile', emoji: '😄', annotation: 'grinning face with smiling eyes', tags: ['eye']}, + ] + const result = filterEmojis(loaded, 'eye') + expect(result[0].shortcode).toBe('eyeglasses') + expect(result[1].shortcode).toBe('eyes') + expect(result[2].shortcode).toBe('smile') + }) + + it('limits results to 15', () => { + const big = Array.from({length: 100}, (_, i) => ({ + shortcode: `foo_${String(i).padStart(3, '0')}`, emoji: '✨', annotation: '', tags: [], + })) + expect(filterEmojis(big, 'foo')).toHaveLength(15) + }) + + it('caches the fetch promise across calls', async () => { + await loadEmojis() + await loadEmojis() + expect((globalThis.fetch as ReturnType).mock.calls).toHaveLength(1) + }) +}) diff --git a/frontend/src/components/input/editor/emoji/emojiData.ts b/frontend/src/components/input/editor/emoji/emojiData.ts new file mode 100644 index 000000000..3ea2c662b --- /dev/null +++ b/frontend/src/components/input/editor/emoji/emojiData.ts @@ -0,0 +1,75 @@ +export interface EmojiEntry { + emoji: string + shortcode: string + annotation: string + tags: string[] +} + +interface RawEmoji { + shortcodes: string[] + annotation: string + tags?: string[] + emoji: string +} + +const MAX_RESULTS = 15 + +let cache: Promise | null = null + +export function __resetEmojiCacheForTest() { + cache = null +} + +export function loadEmojis(): Promise { + if (cache) return cache + cache = fetch('/emojis.json') + .then(res => { + if (!res.ok) throw new Error(`emojis.json HTTP ${res.status}`) + return res.json() as Promise + }) + .then(raw => { + const flat: EmojiEntry[] = [] + for (const entry of raw) { + for (const shortcode of entry.shortcodes) { + flat.push({ + emoji: entry.emoji, + shortcode, + annotation: entry.annotation, + tags: entry.tags ?? [], + }) + } + } + flat.sort((a, b) => a.shortcode.localeCompare(b.shortcode)) + return flat + }) + .catch(err => { + cache = null + throw err + }) + return cache +} + +export function filterEmojis(index: EmojiEntry[], rawQuery: string): EmojiEntry[] { + const query = rawQuery.toLowerCase() + if (query === '') return [] + + const starts: EmojiEntry[] = [] + const contains: EmojiEntry[] = [] + + for (const entry of index) { + if (entry.shortcode.startsWith(query)) { + starts.push(entry) + continue + } + if ( + entry.shortcode.includes(query) || + entry.annotation.toLowerCase().includes(query) || + entry.tags.some(t => t.toLowerCase().includes(query)) + ) { + contains.push(entry) + } + if (starts.length >= MAX_RESULTS) break + } + + return [...starts, ...contains].slice(0, MAX_RESULTS) +}