diff --git a/app/javascript/flavours/glitch/components/emoji/emoji.stories.tsx b/app/javascript/flavours/glitch/components/emoji/emoji.stories.tsx index d4f5663ae4..d390387a03 100644 --- a/app/javascript/flavours/glitch/components/emoji/emoji.stories.tsx +++ b/app/javascript/flavours/glitch/components/emoji/emoji.stories.tsx @@ -2,8 +2,6 @@ import type { ComponentProps } from 'react'; import type { Meta, StoryObj } from '@storybook/react-vite'; -import { importCustomEmojiData } from '@/flavours/glitch/features/emoji/loader'; - import { Emoji } from './index'; type EmojiProps = ComponentProps & { state: string }; @@ -38,7 +36,6 @@ const meta = { }, }, render(args) { - void importCustomEmojiData(); return ; }, } satisfies Meta; @@ -54,3 +51,9 @@ export const CustomEmoji: Story = { code: ':custom:', }, }; + +export const LegacyEmoji: Story = { + args: { + code: ':copyright:', + }, +}; diff --git a/app/javascript/flavours/glitch/components/emoji/index.tsx b/app/javascript/flavours/glitch/components/emoji/index.tsx index b950f4c042..2b596be6da 100644 --- a/app/javascript/flavours/glitch/components/emoji/index.tsx +++ b/app/javascript/flavours/glitch/components/emoji/index.tsx @@ -3,7 +3,10 @@ import { useContext, useEffect, useState } from 'react'; import classNames from 'classnames'; -import { EMOJI_TYPE_CUSTOM } from '@/flavours/glitch/features/emoji/constants'; +import { + EMOJI_TYPE_CUSTOM, + EMOJI_TYPE_UNICODE, +} from '@/flavours/glitch/features/emoji/constants'; import { useEmojiAppState } from '@/flavours/glitch/features/emoji/mode'; import { emojiToInversionClassName, @@ -47,8 +50,6 @@ export const Emoji: FC = ({ const animate = useContext(AnimateEmojiContext); - const inversionClass = emojiToInversionClassName(code); - const fallback = showFallback ? code : null; // If the code is invalid or we otherwise know it's not valid, show the fallback. @@ -56,10 +57,6 @@ export const Emoji: FC = ({ return fallback; } - if (!shouldRenderImage(state, appState.mode)) { - return code; - } - if (!isStateLoaded(state)) { if (showLoading) { return ; @@ -67,6 +64,17 @@ export const Emoji: FC = ({ return fallback; } + const inversionClass = + state.type === EMOJI_TYPE_UNICODE && + emojiToInversionClassName(state.data.unicode); + + if (!shouldRenderImage(state, appState.mode)) { + if (state.type === EMOJI_TYPE_UNICODE) { + return state.data.unicode; + } + return code; + } + if (state.type === EMOJI_TYPE_CUSTOM) { const shortcode = `:${state.code}:`; return ( diff --git a/app/javascript/flavours/glitch/features/emoji/constants.ts b/app/javascript/flavours/glitch/features/emoji/constants.ts index f770573121..e02663c9d8 100644 --- a/app/javascript/flavours/glitch/features/emoji/constants.ts +++ b/app/javascript/flavours/glitch/features/emoji/constants.ts @@ -23,6 +23,10 @@ export const EMOJI_MODE_TWEMOJI = 'twemoji'; export const EMOJI_TYPE_UNICODE = 'unicode'; export const EMOJI_TYPE_CUSTOM = 'custom'; +export const EMOJI_DB_NAME_SHORTCODES = 'shortcodes'; + +export const EMOJI_DB_SHORTCODE_TEST = '2122'; // 2122 is the trademark sign, which we know has shortcodes in all datasets. + export const EMOJIS_WITH_DARK_BORDER = [ '🎱', // 1F3B1 '🐜', // 1F41C diff --git a/app/javascript/flavours/glitch/features/emoji/database.test.ts b/app/javascript/flavours/glitch/features/emoji/database.test.ts index 0689fd7c54..5931a238ea 100644 --- a/app/javascript/flavours/glitch/features/emoji/database.test.ts +++ b/app/javascript/flavours/glitch/features/emoji/database.test.ts @@ -1,7 +1,8 @@ import { IDBFactory } from 'fake-indexeddb'; -import { unicodeEmojiFactory } from '@/testing/factories'; +import { customEmojiFactory, unicodeEmojiFactory } from '@/testing/factories'; +import { EMOJI_DB_SHORTCODE_TEST } from './constants'; import { putEmojiData, loadEmojiByHexcode, @@ -9,6 +10,11 @@ import { searchEmojisByTag, testClear, testGet, + putCustomEmojiData, + putLegacyShortcodes, + loadLegacyShortcodesByShortcode, + loadLatestEtag, + putLatestEtag, } from './database'; describe('emoji database', () => { @@ -16,6 +22,7 @@ describe('emoji database', () => { testClear(); indexedDB = new IDBFactory(); }); + describe('putEmojiData', () => { test('adds to loaded locales', async () => { const { loadedLocales } = await testGet(); @@ -33,6 +40,29 @@ describe('emoji database', () => { }); }); + describe('putCustomEmojiData', () => { + test('loads custom emoji into indexedDB', async () => { + const { db } = await testGet(); + await putCustomEmojiData([customEmojiFactory()]); + await expect(db.get('custom', 'custom')).resolves.toEqual( + customEmojiFactory(), + ); + }); + }); + + describe('putLegacyShortcodes', () => { + test('loads shortcodes into indexedDB', async () => { + const { db } = await testGet(); + await putLegacyShortcodes({ + test_hexcode: ['shortcode1', 'shortcode2'], + }); + await expect(db.get('shortcodes', 'test_hexcode')).resolves.toEqual({ + hexcode: 'test_hexcode', + shortcodes: ['shortcode1', 'shortcode2'], + }); + }); + }); + describe('loadEmojiByHexcode', () => { test('throws if the locale is not loaded', async () => { await expect(loadEmojiByHexcode('en', 'test')).rejects.toThrowError( @@ -136,4 +166,58 @@ describe('emoji database', () => { expect(actual).toHaveLength(0); }); }); + + describe('loadLegacyShortcodesByShortcode', () => { + const data = { + hexcode: 'test_hexcode', + shortcodes: ['shortcode1', 'shortcode2'], + }; + + beforeEach(async () => { + await putLegacyShortcodes({ + [data.hexcode]: data.shortcodes, + }); + }); + + test('retrieves the shortcodes', async () => { + await expect( + loadLegacyShortcodesByShortcode('shortcode1'), + ).resolves.toEqual(data); + await expect( + loadLegacyShortcodesByShortcode('shortcode2'), + ).resolves.toEqual(data); + }); + }); + + describe('loadLatestEtag', () => { + beforeEach(async () => { + await putLatestEtag('etag', 'en'); + await putEmojiData([unicodeEmojiFactory()], 'en'); + await putLatestEtag('fr-etag', 'fr'); + }); + + test('retrieves the etag for loaded locale', async () => { + await putEmojiData( + [unicodeEmojiFactory({ hexcode: EMOJI_DB_SHORTCODE_TEST })], + 'en', + ); + const etag = await loadLatestEtag('en'); + expect(etag).toBe('etag'); + }); + + test('returns null if locale has no shortcodes', async () => { + const etag = await loadLatestEtag('en'); + expect(etag).toBeNull(); + }); + + test('returns null if locale not loaded', async () => { + const etag = await loadLatestEtag('de'); + expect(etag).toBeNull(); + }); + + test('returns null if locale has no data', async () => { + const etag = await loadLatestEtag('fr'); + expect(etag).toBeNull(); + }); + }); }); diff --git a/app/javascript/flavours/glitch/features/emoji/database.ts b/app/javascript/flavours/glitch/features/emoji/database.ts index b7f8a32f76..2e8de71221 100644 --- a/app/javascript/flavours/glitch/features/emoji/database.ts +++ b/app/javascript/flavours/glitch/features/emoji/database.ts @@ -1,8 +1,9 @@ import { SUPPORTED_LOCALES } from 'emojibase'; -import type { Locale } from 'emojibase'; +import type { Locale, ShortcodesDataset } from 'emojibase'; import type { DBSchema, IDBPDatabase } from 'idb'; import { openDB } from 'idb'; +import { EMOJI_DB_SHORTCODE_TEST } from './constants'; import { toSupportedLocale, toSupportedLocaleOrCustom } from './locale'; import type { CustomEmojiData, @@ -19,6 +20,17 @@ interface EmojiDB extends LocaleTables, DBSchema { category: string; }; }; + shortcodes: { + key: string; + value: { + hexcode: string; + shortcodes: string[]; + }; + indexes: { + hexcode: string; + shortcodes: string[]; + }; + }; etags: { key: LocaleOrCustom; value: string; @@ -33,13 +45,14 @@ interface LocaleTable { label: string; order: number; tags: string[]; + shortcodes: string[]; }; } type LocaleTables = Record; type Database = IDBPDatabase; -const SCHEMA_VERSION = 1; +const SCHEMA_VERSION = 2; const loadedLocales = new Set(); @@ -52,28 +65,76 @@ const loadDB = (() => { // Actually load the DB. async function initDB() { const db = await openDB('mastodon-emoji', SCHEMA_VERSION, { - upgrade(database) { - const customTable = database.createObjectStore('custom', { - keyPath: 'shortcode', - autoIncrement: false, - }); - customTable.createIndex('category', 'category'); + upgrade(database, oldVersion, newVersion, trx) { + if (!database.objectStoreNames.contains('custom')) { + const customTable = database.createObjectStore('custom', { + keyPath: 'shortcode', + autoIncrement: false, + }); + customTable.createIndex('category', 'category'); + } - database.createObjectStore('etags'); + if (!database.objectStoreNames.contains('etags')) { + database.createObjectStore('etags'); + } for (const locale of SUPPORTED_LOCALES) { - const localeTable = database.createObjectStore(locale, { + if (!database.objectStoreNames.contains(locale)) { + const localeTable = database.createObjectStore(locale, { + keyPath: 'hexcode', + autoIncrement: false, + }); + localeTable.createIndex('group', 'group'); + localeTable.createIndex('label', 'label'); + localeTable.createIndex('order', 'order'); + localeTable.createIndex('tags', 'tags', { multiEntry: true }); + localeTable.createIndex('shortcodes', 'shortcodes', { + multiEntry: true, + }); + } + // Added in version 2. + const localeTable = trx.objectStore(locale); + if (!localeTable.indexNames.contains('shortcodes')) { + localeTable.createIndex('shortcodes', 'shortcodes', { + multiEntry: true, + }); + } + } + + if (!database.objectStoreNames.contains('shortcodes')) { + const shortcodeTable = database.createObjectStore('shortcodes', { keyPath: 'hexcode', autoIncrement: false, }); - localeTable.createIndex('group', 'group'); - localeTable.createIndex('label', 'label'); - localeTable.createIndex('order', 'order'); - localeTable.createIndex('tags', 'tags', { multiEntry: true }); + shortcodeTable.createIndex('hexcode', 'hexcode'); + shortcodeTable.createIndex('shortcodes', 'shortcodes', { + multiEntry: true, + }); } + + log( + 'Upgraded emoji database from version %d to %d', + oldVersion, + newVersion, + ); + }, + blocked(currentVersion, blockedVersion) { + log( + 'Emoji database upgrade from version %d to %d is blocked', + currentVersion, + blockedVersion, + ); + }, + blocking(currentVersion, blockedVersion) { + log( + 'Emoji database upgrade from version %d is blocking upgrade to %d', + currentVersion, + blockedVersion, + ); }, }); await syncLocales(db); + log('Loaded database version %d', db.version); return db; } @@ -107,6 +168,20 @@ export async function putCustomEmojiData(emojis: CustomEmojiData[]) { await trx.done; } +export async function putLegacyShortcodes(shortcodes: ShortcodesDataset) { + const db = await loadDB(); + const trx = db.transaction('shortcodes', 'readwrite'); + await Promise.all( + Object.entries(shortcodes).map(([hexcode, codes]) => + trx.store.put({ + hexcode, + shortcodes: Array.isArray(codes) ? codes : [codes], + }), + ), + ); + await trx.done; +} + export async function putLatestEtag(etag: string, localeString: string) { const locale = toSupportedLocaleOrCustom(localeString); const db = await loadDB(); @@ -161,6 +236,15 @@ export async function searchCustomEmojisByShortcodes(shortcodes: string[]) { return results.filter((emoji) => shortcodes.includes(emoji.shortcode)); } +export async function loadLegacyShortcodesByShortcode(shortcode: string) { + const db = await loadDB(); + return db.getFromIndex( + 'shortcodes', + 'shortcodes', + IDBKeyRange.only(shortcode), + ); +} + export async function loadLatestEtag(localeString: string) { const locale = toSupportedLocaleOrCustom(localeString); const db = await loadDB(); @@ -168,6 +252,15 @@ export async function loadLatestEtag(localeString: string) { if (!rowCount) { return null; // No data for this locale, return null even if there is an etag. } + + // Check if shortcodes exist for the given Unicode locale. + if (locale !== 'custom') { + const result = await db.get(locale, EMOJI_DB_SHORTCODE_TEST); + if (!result?.shortcodes) { + return null; + } + } + const etag = await db.get('etags', locale); return etag ?? null; } diff --git a/app/javascript/flavours/glitch/features/emoji/index.ts b/app/javascript/flavours/glitch/features/emoji/index.ts index 51baef7537..4a04f99bed 100644 --- a/app/javascript/flavours/glitch/features/emoji/index.ts +++ b/app/javascript/flavours/glitch/features/emoji/index.ts @@ -1,5 +1,9 @@ +import type { Locale } from 'emojibase'; + import { initialState } from '@/flavours/glitch/initial_state'; +import type { EMOJI_DB_NAME_SHORTCODES, EMOJI_TYPE_CUSTOM } from './constants'; +import { importLegacyShortcodes, localeToShortcodesPath } from './loader'; import { toSupportedLocale } from './locale'; import type { LocaleOrCustom } from './types'; import { emojiLogger } from './utils'; @@ -36,12 +40,8 @@ export function initializeEmoji() { log('worker ready, loading data'); clearTimeout(timeoutId); messageWorker('custom'); + messageWorker('shortcodes'); void loadEmojiLocale(userLocale); - // Load English locale as well, because people are still used to - // using it from before we supported other locales. - if (userLocale !== 'en') { - void loadEmojiLocale('en'); - } } else { log('got worker message: %s', message); } @@ -58,20 +58,23 @@ async function fallbackLoad() { if (emojis) { log('loaded %d custom emojis', emojis.length); } - await loadEmojiLocale(userLocale); - if (userLocale !== 'en') { - await loadEmojiLocale('en'); + const shortcodes = await importLegacyShortcodes(); + if (shortcodes.length) { + log('loaded %d legacy shortcodes', shortcodes.length); } + await loadEmojiLocale(userLocale); } async function loadEmojiLocale(localeString: string) { const locale = toSupportedLocale(localeString); - const { importEmojiData, localeToPath } = await import('./loader'); + const { importEmojiData, localeToEmojiPath: localeToPath } = + await import('./loader'); if (worker) { const path = await localeToPath(locale); + const shortcodesPath = await localeToShortcodesPath(locale); log('asking worker to load locale %s from %s', locale, path); - messageWorker(locale, path); + messageWorker(locale, path, shortcodesPath); } else { const emojis = await importEmojiData(locale); if (emojis) { @@ -80,9 +83,17 @@ async function loadEmojiLocale(localeString: string) { } } -function messageWorker(locale: LocaleOrCustom, path?: string) { +function messageWorker( + locale: typeof EMOJI_TYPE_CUSTOM | typeof EMOJI_DB_NAME_SHORTCODES, +): void; +function messageWorker(locale: Locale, path: string, shortcodes?: string): void; +function messageWorker( + locale: LocaleOrCustom | typeof EMOJI_DB_NAME_SHORTCODES, + path?: string, + shortcodes?: string, +) { if (!worker) { return; } - worker.postMessage({ locale, path }); + worker.postMessage({ locale, path, shortcodes }); } diff --git a/app/javascript/flavours/glitch/features/emoji/loader.ts b/app/javascript/flavours/glitch/features/emoji/loader.ts index 86c879cddc..18c04dc64d 100644 --- a/app/javascript/flavours/glitch/features/emoji/loader.ts +++ b/app/javascript/flavours/glitch/features/emoji/loader.ts @@ -1,16 +1,26 @@ import { flattenEmojiData } from 'emojibase'; -import type { CompactEmoji, FlatCompactEmoji, Locale } from 'emojibase'; +import type { + CompactEmoji, + FlatCompactEmoji, + Locale, + ShortcodesDataset, +} from 'emojibase'; import { putEmojiData, putCustomEmojiData, loadLatestEtag, putLatestEtag, + putLegacyShortcodes, } from './database'; import { toSupportedLocale, toSupportedLocaleOrCustom } from './locale'; import type { CustomEmojiData } from './types'; -export async function importEmojiData(localeString: string, path?: string) { +export async function importEmojiData( + localeString: string, + path?: string, + shortcodes: boolean | string = true, +) { const locale = toSupportedLocale(localeString); // Validate the provided path. @@ -18,14 +28,42 @@ export async function importEmojiData(localeString: string, path?: string) { throw new Error('Invalid path for emoji data'); } else { // Otherwise get the path if not provided. - path ??= await localeToPath(locale); + path ??= await localeToEmojiPath(locale); } const emojis = await fetchAndCheckEtag(locale, path); if (!emojis) { return; } - const flattenedEmojis: FlatCompactEmoji[] = flattenEmojiData(emojis); + + const shortcodesData: ShortcodesDataset[] = []; + if (shortcodes) { + if ( + typeof shortcodes === 'string' && + !/^[/a-z]*\/packs\/assets\/shortcodes\/cldr\.json$/.test(shortcodes) + ) { + throw new Error('Invalid path for shortcodes data'); + } + const shortcodesPath = + typeof shortcodes === 'string' + ? shortcodes + : await localeToShortcodesPath(locale); + const shortcodesResponse = await fetchAndCheckEtag( + locale, + shortcodesPath, + false, + ); + if (shortcodesResponse) { + shortcodesData.push(shortcodesResponse); + } else { + throw new Error(`No shortcodes data found for locale ${locale}`); + } + } + + const flattenedEmojis: FlatCompactEmoji[] = flattenEmojiData( + emojis, + shortcodesData, + ); await putEmojiData(flattenedEmojis, locale); return flattenedEmojis; } @@ -42,32 +80,77 @@ export async function importCustomEmojiData() { return emojis; } -const modules = import.meta.glob( - '../../../../../../node_modules/emojibase-data/**/compact.json', - { - query: '?url', - import: 'default', - }, -); - -export function localeToPath(locale: Locale) { - const key = `../../../../../../node_modules/emojibase-data/${locale}/compact.json`; - if (!modules[key] || typeof modules[key] !== 'function') { - throw new Error(`Unsupported locale: ${locale}`); +export async function importLegacyShortcodes() { + const { default: shortcodesPath } = + await import('emojibase-data/en/shortcodes/iamcal.json?url'); + const response = await fetch(shortcodesPath); + if (!response.ok) { + throw new Error( + `Failed to fetch legacy shortcodes data: ${response.statusText}`, + ); } - return modules[key](); + const shortcodesData = (await response.json()) as ShortcodesDataset; + await putLegacyShortcodes(shortcodesData); + return Object.keys(shortcodesData); } -export async function fetchAndCheckEtag( +const emojiModules = new Map( + Object.entries( + import.meta.glob( + '../../../../../../node_modules/emojibase-data/**/compact.json', + { + query: '?url', + import: 'default', + }, + ), + ).map(([key, loader]) => { + const match = /emojibase-data\/([^/]+)\/compact\.json$/.exec(key); + return [match?.at(1) ?? key, loader]; + }), +); + +export function localeToEmojiPath(locale: Locale) { + const path = emojiModules.get(locale); + if (!path) { + throw new Error(`Unsupported locale: ${locale}`); + } + return path(); +} + +const shortcodesModules = new Map( + Object.entries( + import.meta.glob( + '../../../../../../node_modules/emojibase-data/**/shortcodes/cldr.json', + { + query: '?url', + import: 'default', + }, + ), + ).map(([key, loader]) => { + const match = /emojibase-data\/([^/]+)\/shortcodes\/cldr\.json$/.exec(key); + return [match?.at(1) ?? key, loader]; + }), +); + +export function localeToShortcodesPath(locale: Locale) { + const path = shortcodesModules.get(locale); + if (!path) { + throw new Error(`Unsupported locale for shortcodes: ${locale}`); + } + return path(); +} + +export async function fetchAndCheckEtag( localeString: string, path: string, + checkEtag = true, ): Promise { const locale = toSupportedLocaleOrCustom(localeString); // Use location.origin as this script may be loaded from a CDN domain. const url = new URL(path, location.origin); - const oldEtag = await loadLatestEtag(locale); + const oldEtag = checkEtag ? await loadLatestEtag(locale) : null; const response = await fetch(url, { headers: { 'Content-Type': 'application/json', @@ -85,13 +168,10 @@ export async function fetchAndCheckEtag( } const data = (await response.json()) as ResultType; - if (!Array.isArray(data)) { - throw new Error(`Unexpected data format for ${locale}: expected an array`); - } // Store the ETag for future requests const etag = response.headers.get('ETag'); - if (etag) { + if (etag && checkEtag) { await putLatestEtag(etag, localeString); } diff --git a/app/javascript/flavours/glitch/features/emoji/render.test.ts b/app/javascript/flavours/glitch/features/emoji/render.test.ts index 3c96cbfb55..782148b36e 100644 --- a/app/javascript/flavours/glitch/features/emoji/render.test.ts +++ b/app/javascript/flavours/glitch/features/emoji/render.test.ts @@ -7,6 +7,7 @@ import { stringToEmojiState, tokenizeText, } from './render'; +import type { EmojiStateCustom, EmojiStateUnicode } from './types'; describe('tokenizeText', () => { test('returns an array of text to be a single token', () => { @@ -120,13 +121,24 @@ describe('loadEmojiDataToState', () => { const dbCall = vi .spyOn(db, 'loadEmojiByHexcode') .mockResolvedValue(unicodeEmojiFactory()); - const unicodeState = { type: 'unicode', code: '1F60A' } as const; + const dbLegacyCall = vi + .spyOn(db, 'loadLegacyShortcodesByShortcode') + .mockResolvedValueOnce({ + shortcodes: ['legacy_code'], + hexcode: '1F60A', + }); + const unicodeState = { + type: 'unicode', + code: '1F60A', + } as const satisfies EmojiStateUnicode; const result = await loadEmojiDataToState(unicodeState, 'en'); expect(dbCall).toHaveBeenCalledWith('1F60A', 'en'); + expect(dbLegacyCall).toHaveBeenCalledWith('1F60A'); expect(result).toEqual({ type: 'unicode', code: '1F60A', data: unicodeEmojiFactory(), + shortcode: 'legacy_code', }); }); @@ -134,7 +146,10 @@ describe('loadEmojiDataToState', () => { const dbCall = vi .spyOn(db, 'loadCustomEmojiByShortcode') .mockResolvedValueOnce(customEmojiFactory()); - const customState = { type: 'custom', code: 'smile' } as const; + const customState = { + type: 'custom', + code: 'smile', + } as const satisfies EmojiStateCustom; const result = await loadEmojiDataToState(customState, 'en'); expect(dbCall).toHaveBeenCalledWith('smile'); expect(result).toEqual({ @@ -144,16 +159,47 @@ describe('loadEmojiDataToState', () => { }); }); + test('loads unicode data using legacy shortcode', async () => { + const dbLegacyCall = vi + .spyOn(db, 'loadLegacyShortcodesByShortcode') + .mockResolvedValueOnce({ + shortcodes: ['test'], + hexcode: 'test', + }); + const dbUnicodeCall = vi + .spyOn(db, 'loadEmojiByHexcode') + .mockResolvedValue(unicodeEmojiFactory()); + const unicodeState = { + type: 'unicode', + code: 'test', + } as const satisfies EmojiStateUnicode; + const result = await loadEmojiDataToState(unicodeState, 'en'); + expect(dbLegacyCall).toHaveBeenCalledWith('test'); + expect(dbUnicodeCall).toHaveBeenCalledWith('test', 'en'); + expect(result).toEqual({ + type: 'unicode', + code: 'test', + data: unicodeEmojiFactory(), + shortcode: 'test', + }); + }); + test('returns null if unicode emoji not found in database', async () => { vi.spyOn(db, 'loadEmojiByHexcode').mockResolvedValueOnce(undefined); - const unicodeState = { type: 'unicode', code: '1F60A' } as const; + const unicodeState = { + type: 'unicode', + code: '1F60A', + } as const satisfies EmojiStateUnicode; const result = await loadEmojiDataToState(unicodeState, 'en'); expect(result).toBeNull(); }); test('returns null if custom emoji not found in database', async () => { vi.spyOn(db, 'loadCustomEmojiByShortcode').mockResolvedValueOnce(undefined); - const customState = { type: 'custom', code: 'smile' } as const; + const customState = { + type: 'custom', + code: 'smile', + } as const satisfies EmojiStateCustom; const result = await loadEmojiDataToState(customState, 'en'); expect(result).toBeNull(); }); @@ -167,7 +213,10 @@ describe('loadEmojiDataToState', () => { .spyOn(console, 'warn') .mockImplementationOnce(() => null); - const unicodeState = { type: 'unicode', code: '1F60A' } as const; + const unicodeState = { + type: 'unicode', + code: '1F60A', + } as const satisfies EmojiStateUnicode; const result = await loadEmojiDataToState(unicodeState, 'en'); expect(dbCall).toHaveBeenCalledTimes(2); diff --git a/app/javascript/flavours/glitch/features/emoji/render.ts b/app/javascript/flavours/glitch/features/emoji/render.ts index 574d5ef59b..e00525fe0a 100644 --- a/app/javascript/flavours/glitch/features/emoji/render.ts +++ b/app/javascript/flavours/glitch/features/emoji/render.ts @@ -7,6 +7,7 @@ import { import { loadCustomEmojiByShortcode, loadEmojiByHexcode, + loadLegacyShortcodesByShortcode, LocaleNotLoadedError, } from './database'; import { importEmojiData } from './loader'; @@ -116,13 +117,20 @@ export async function loadEmojiDataToState( // First, try to load the data from IndexedDB. try { + const legacyCode = await loadLegacyShortcodesByShortcode(state.code); // This is duplicative, but that's because TS can't distinguish the state type easily. - if (state.type === EMOJI_TYPE_UNICODE) { - const data = await loadEmojiByHexcode(state.code, locale); + if (state.type === EMOJI_TYPE_UNICODE || legacyCode) { + const data = await loadEmojiByHexcode( + legacyCode?.hexcode ?? state.code, + locale, + ); if (data) { return { ...state, + type: EMOJI_TYPE_UNICODE, data, + // TODO: Use CLDR shortcodes when the picker supports them. + shortcode: legacyCode?.shortcodes.at(0), }; } } else { diff --git a/app/javascript/flavours/glitch/features/emoji/types.ts b/app/javascript/flavours/glitch/features/emoji/types.ts index a940aa4d92..da4b87c23e 100644 --- a/app/javascript/flavours/glitch/features/emoji/types.ts +++ b/app/javascript/flavours/glitch/features/emoji/types.ts @@ -4,6 +4,7 @@ import type { FlatCompactEmoji, Locale } from 'emojibase'; import type { ApiCustomEmojiJSON } from '@/flavours/glitch/api_types/custom_emoji'; import type { CustomEmoji } from '@/flavours/glitch/models/custom_emoji'; +import type { RequiredExcept } from '@/flavours/glitch/utils/types'; import type { EMOJI_MODE_NATIVE, @@ -40,6 +41,7 @@ export interface EmojiStateUnicode { type: typeof EMOJI_TYPE_UNICODE; code: string; data?: UnicodeEmojiData; + shortcode?: string; } export interface EmojiStateCustom { type: typeof EMOJI_TYPE_CUSTOM; @@ -49,7 +51,7 @@ export interface EmojiStateCustom { export type EmojiState = EmojiStateUnicode | EmojiStateCustom; export type EmojiLoadedState = - | Required + | RequiredExcept | Required; export type CustomEmojiMapArg = diff --git a/app/javascript/flavours/glitch/features/emoji/worker.ts b/app/javascript/flavours/glitch/features/emoji/worker.ts index 5360484d77..2243678276 100644 --- a/app/javascript/flavours/glitch/features/emoji/worker.ts +++ b/app/javascript/flavours/glitch/features/emoji/worker.ts @@ -1,4 +1,9 @@ -import { importCustomEmojiData, importEmojiData } from './loader'; +import { EMOJI_DB_NAME_SHORTCODES, EMOJI_TYPE_CUSTOM } from './constants'; +import { + importCustomEmojiData, + importEmojiData, + importLegacyShortcodes, +} from './loader'; addEventListener('message', handleMessage); self.postMessage('ready'); // After the worker is ready, notify the main thread @@ -12,8 +17,10 @@ function handleMessage(event: MessageEvent<{ locale: string; path?: string }>) { async function loadData(locale: string, path?: string) { let importCount: number | undefined; - if (locale === 'custom') { + if (locale === EMOJI_TYPE_CUSTOM) { importCount = (await importCustomEmojiData())?.length; + } else if (locale === EMOJI_DB_NAME_SHORTCODES) { + importCount = (await importLegacyShortcodes()).length; } else if (path) { importCount = (await importEmojiData(locale, path))?.length; } else { diff --git a/app/javascript/flavours/glitch/utils/types.ts b/app/javascript/flavours/glitch/utils/types.ts index eb45881ee4..019b074813 100644 --- a/app/javascript/flavours/glitch/utils/types.ts +++ b/app/javascript/flavours/glitch/utils/types.ts @@ -15,6 +15,8 @@ export type SomeRequired = T & Required>; export type SomeOptional = Pick> & Partial>; +export type RequiredExcept = SomeOptional, K>; + export type OmitValueType = { [K in keyof T as T[K] extends V ? never : K]: T[K]; };