Emoji: Import and use shortcode data (#37178)

This commit is contained in:
Echo 2025-12-15 16:38:13 +01:00 committed by GitHub
parent a3fa441e0c
commit cbe1352103
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 433 additions and 71 deletions

View File

@ -11,6 +11,11 @@ import type { Preview } from '@storybook/react-vite';
import { initialize, mswLoader } from 'msw-storybook-addon'; import { initialize, mswLoader } from 'msw-storybook-addon';
import { action } from 'storybook/actions'; import { action } from 'storybook/actions';
import {
importCustomEmojiData,
importLegacyShortcodes,
importEmojiData,
} from '@/mastodon/features/emoji/loader';
import type { LocaleData } from '@/mastodon/locales'; import type { LocaleData } from '@/mastodon/locales';
import { reducerWithInitialState } from '@/mastodon/reducers'; import { reducerWithInitialState } from '@/mastodon/reducers';
import { defaultMiddleware } from '@/mastodon/store/store'; import { defaultMiddleware } from '@/mastodon/store/store';
@ -127,7 +132,12 @@ const preview: Preview = {
</MemoryRouter> </MemoryRouter>
), ),
], ],
loaders: [mswLoader], loaders: [
mswLoader,
importCustomEmojiData,
importLegacyShortcodes,
({ globals: { locale } }) => importEmojiData(locale as string),
],
parameters: { parameters: {
layout: 'centered', layout: 'centered',

View File

@ -2,8 +2,6 @@ import type { ComponentProps } from 'react';
import type { Meta, StoryObj } from '@storybook/react-vite'; import type { Meta, StoryObj } from '@storybook/react-vite';
import { importCustomEmojiData } from '@/mastodon/features/emoji/loader';
import { Emoji } from './index'; import { Emoji } from './index';
type EmojiProps = ComponentProps<typeof Emoji> & { state: string }; type EmojiProps = ComponentProps<typeof Emoji> & { state: string };
@ -38,7 +36,6 @@ const meta = {
}, },
}, },
render(args) { render(args) {
void importCustomEmojiData();
return <Emoji {...args} />; return <Emoji {...args} />;
}, },
} satisfies Meta<EmojiProps>; } satisfies Meta<EmojiProps>;
@ -54,3 +51,9 @@ export const CustomEmoji: Story = {
code: ':custom:', code: ':custom:',
}, },
}; };
export const LegacyEmoji: Story = {
args: {
code: ':copyright:',
},
};

View File

@ -3,7 +3,10 @@ import { useContext, useEffect, useState } from 'react';
import classNames from 'classnames'; import classNames from 'classnames';
import { EMOJI_TYPE_CUSTOM } from '@/mastodon/features/emoji/constants'; import {
EMOJI_TYPE_CUSTOM,
EMOJI_TYPE_UNICODE,
} from '@/mastodon/features/emoji/constants';
import { useEmojiAppState } from '@/mastodon/features/emoji/mode'; import { useEmojiAppState } from '@/mastodon/features/emoji/mode';
import { import {
emojiToInversionClassName, emojiToInversionClassName,
@ -47,8 +50,6 @@ export const Emoji: FC<EmojiProps> = ({
const animate = useContext(AnimateEmojiContext); const animate = useContext(AnimateEmojiContext);
const inversionClass = emojiToInversionClassName(code);
const fallback = showFallback ? code : null; const fallback = showFallback ? code : null;
// If the code is invalid or we otherwise know it's not valid, show the fallback. // If the code is invalid or we otherwise know it's not valid, show the fallback.
@ -56,10 +57,6 @@ export const Emoji: FC<EmojiProps> = ({
return fallback; return fallback;
} }
if (!shouldRenderImage(state, appState.mode)) {
return code;
}
if (!isStateLoaded(state)) { if (!isStateLoaded(state)) {
if (showLoading) { if (showLoading) {
return <span className='emojione emoji-loading' title={code} />; return <span className='emojione emoji-loading' title={code} />;
@ -67,6 +64,17 @@ export const Emoji: FC<EmojiProps> = ({
return fallback; 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) { if (state.type === EMOJI_TYPE_CUSTOM) {
const shortcode = `:${state.code}:`; const shortcode = `:${state.code}:`;
return ( return (

View File

@ -23,6 +23,10 @@ export const EMOJI_MODE_TWEMOJI = 'twemoji';
export const EMOJI_TYPE_UNICODE = 'unicode'; export const EMOJI_TYPE_UNICODE = 'unicode';
export const EMOJI_TYPE_CUSTOM = 'custom'; 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 = [ export const EMOJIS_WITH_DARK_BORDER = [
'🎱', // 1F3B1 '🎱', // 1F3B1
'🐜', // 1F41C '🐜', // 1F41C

View File

@ -1,7 +1,8 @@
import { IDBFactory } from 'fake-indexeddb'; import { IDBFactory } from 'fake-indexeddb';
import { unicodeEmojiFactory } from '@/testing/factories'; import { customEmojiFactory, unicodeEmojiFactory } from '@/testing/factories';
import { EMOJI_DB_SHORTCODE_TEST } from './constants';
import { import {
putEmojiData, putEmojiData,
loadEmojiByHexcode, loadEmojiByHexcode,
@ -9,6 +10,11 @@ import {
searchEmojisByTag, searchEmojisByTag,
testClear, testClear,
testGet, testGet,
putCustomEmojiData,
putLegacyShortcodes,
loadLegacyShortcodesByShortcode,
loadLatestEtag,
putLatestEtag,
} from './database'; } from './database';
describe('emoji database', () => { describe('emoji database', () => {
@ -16,6 +22,7 @@ describe('emoji database', () => {
testClear(); testClear();
indexedDB = new IDBFactory(); indexedDB = new IDBFactory();
}); });
describe('putEmojiData', () => { describe('putEmojiData', () => {
test('adds to loaded locales', async () => { test('adds to loaded locales', async () => {
const { loadedLocales } = await testGet(); 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', () => { describe('loadEmojiByHexcode', () => {
test('throws if the locale is not loaded', async () => { test('throws if the locale is not loaded', async () => {
await expect(loadEmojiByHexcode('en', 'test')).rejects.toThrowError( await expect(loadEmojiByHexcode('en', 'test')).rejects.toThrowError(
@ -136,4 +166,58 @@ describe('emoji database', () => {
expect(actual).toHaveLength(0); 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();
});
});
}); });

View File

@ -1,8 +1,9 @@
import { SUPPORTED_LOCALES } from 'emojibase'; import { SUPPORTED_LOCALES } from 'emojibase';
import type { Locale } from 'emojibase'; import type { Locale, ShortcodesDataset } from 'emojibase';
import type { DBSchema, IDBPDatabase } from 'idb'; import type { DBSchema, IDBPDatabase } from 'idb';
import { openDB } from 'idb'; import { openDB } from 'idb';
import { EMOJI_DB_SHORTCODE_TEST } from './constants';
import { toSupportedLocale, toSupportedLocaleOrCustom } from './locale'; import { toSupportedLocale, toSupportedLocaleOrCustom } from './locale';
import type { import type {
CustomEmojiData, CustomEmojiData,
@ -19,6 +20,17 @@ interface EmojiDB extends LocaleTables, DBSchema {
category: string; category: string;
}; };
}; };
shortcodes: {
key: string;
value: {
hexcode: string;
shortcodes: string[];
};
indexes: {
hexcode: string;
shortcodes: string[];
};
};
etags: { etags: {
key: LocaleOrCustom; key: LocaleOrCustom;
value: string; value: string;
@ -33,13 +45,14 @@ interface LocaleTable {
label: string; label: string;
order: number; order: number;
tags: string[]; tags: string[];
shortcodes: string[];
}; };
} }
type LocaleTables = Record<Locale, LocaleTable>; type LocaleTables = Record<Locale, LocaleTable>;
type Database = IDBPDatabase<EmojiDB>; type Database = IDBPDatabase<EmojiDB>;
const SCHEMA_VERSION = 1; const SCHEMA_VERSION = 2;
const loadedLocales = new Set<Locale>(); const loadedLocales = new Set<Locale>();
@ -52,28 +65,76 @@ const loadDB = (() => {
// Actually load the DB. // Actually load the DB.
async function initDB() { async function initDB() {
const db = await openDB<EmojiDB>('mastodon-emoji', SCHEMA_VERSION, { const db = await openDB<EmojiDB>('mastodon-emoji', SCHEMA_VERSION, {
upgrade(database) { upgrade(database, oldVersion, newVersion, trx) {
const customTable = database.createObjectStore('custom', { if (!database.objectStoreNames.contains('custom')) {
keyPath: 'shortcode', const customTable = database.createObjectStore('custom', {
autoIncrement: false, keyPath: 'shortcode',
}); autoIncrement: false,
customTable.createIndex('category', 'category'); });
customTable.createIndex('category', 'category');
}
database.createObjectStore('etags'); if (!database.objectStoreNames.contains('etags')) {
database.createObjectStore('etags');
}
for (const locale of SUPPORTED_LOCALES) { 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', keyPath: 'hexcode',
autoIncrement: false, autoIncrement: false,
}); });
localeTable.createIndex('group', 'group'); shortcodeTable.createIndex('hexcode', 'hexcode');
localeTable.createIndex('label', 'label'); shortcodeTable.createIndex('shortcodes', 'shortcodes', {
localeTable.createIndex('order', 'order'); multiEntry: true,
localeTable.createIndex('tags', 'tags', { 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); await syncLocales(db);
log('Loaded database version %d', db.version);
return db; return db;
} }
@ -107,6 +168,20 @@ export async function putCustomEmojiData(emojis: CustomEmojiData[]) {
await trx.done; 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) { export async function putLatestEtag(etag: string, localeString: string) {
const locale = toSupportedLocaleOrCustom(localeString); const locale = toSupportedLocaleOrCustom(localeString);
const db = await loadDB(); const db = await loadDB();
@ -161,6 +236,15 @@ export async function searchCustomEmojisByShortcodes(shortcodes: string[]) {
return results.filter((emoji) => shortcodes.includes(emoji.shortcode)); 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) { export async function loadLatestEtag(localeString: string) {
const locale = toSupportedLocaleOrCustom(localeString); const locale = toSupportedLocaleOrCustom(localeString);
const db = await loadDB(); const db = await loadDB();
@ -168,6 +252,15 @@ export async function loadLatestEtag(localeString: string) {
if (!rowCount) { if (!rowCount) {
return null; // No data for this locale, return null even if there is an etag. 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); const etag = await db.get('etags', locale);
return etag ?? null; return etag ?? null;
} }

View File

@ -1,5 +1,9 @@
import type { Locale } from 'emojibase';
import { initialState } from '@/mastodon/initial_state'; import { initialState } from '@/mastodon/initial_state';
import type { EMOJI_DB_NAME_SHORTCODES, EMOJI_TYPE_CUSTOM } from './constants';
import { importLegacyShortcodes, localeToShortcodesPath } from './loader';
import { toSupportedLocale } from './locale'; import { toSupportedLocale } from './locale';
import type { LocaleOrCustom } from './types'; import type { LocaleOrCustom } from './types';
import { emojiLogger } from './utils'; import { emojiLogger } from './utils';
@ -36,12 +40,8 @@ export function initializeEmoji() {
log('worker ready, loading data'); log('worker ready, loading data');
clearTimeout(timeoutId); clearTimeout(timeoutId);
messageWorker('custom'); messageWorker('custom');
messageWorker('shortcodes');
void loadEmojiLocale(userLocale); 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 { } else {
log('got worker message: %s', message); log('got worker message: %s', message);
} }
@ -58,20 +58,23 @@ async function fallbackLoad() {
if (emojis) { if (emojis) {
log('loaded %d custom emojis', emojis.length); log('loaded %d custom emojis', emojis.length);
} }
await loadEmojiLocale(userLocale); const shortcodes = await importLegacyShortcodes();
if (userLocale !== 'en') { if (shortcodes.length) {
await loadEmojiLocale('en'); log('loaded %d legacy shortcodes', shortcodes.length);
} }
await loadEmojiLocale(userLocale);
} }
async function loadEmojiLocale(localeString: string) { async function loadEmojiLocale(localeString: string) {
const locale = toSupportedLocale(localeString); const locale = toSupportedLocale(localeString);
const { importEmojiData, localeToPath } = await import('./loader'); const { importEmojiData, localeToEmojiPath: localeToPath } =
await import('./loader');
if (worker) { if (worker) {
const path = await localeToPath(locale); const path = await localeToPath(locale);
const shortcodesPath = await localeToShortcodesPath(locale);
log('asking worker to load locale %s from %s', locale, path); log('asking worker to load locale %s from %s', locale, path);
messageWorker(locale, path); messageWorker(locale, path, shortcodesPath);
} else { } else {
const emojis = await importEmojiData(locale); const emojis = await importEmojiData(locale);
if (emojis) { 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) { if (!worker) {
return; return;
} }
worker.postMessage({ locale, path }); worker.postMessage({ locale, path, shortcodes });
} }

View File

@ -1,16 +1,26 @@
import { flattenEmojiData } from 'emojibase'; import { flattenEmojiData } from 'emojibase';
import type { CompactEmoji, FlatCompactEmoji, Locale } from 'emojibase'; import type {
CompactEmoji,
FlatCompactEmoji,
Locale,
ShortcodesDataset,
} from 'emojibase';
import { import {
putEmojiData, putEmojiData,
putCustomEmojiData, putCustomEmojiData,
loadLatestEtag, loadLatestEtag,
putLatestEtag, putLatestEtag,
putLegacyShortcodes,
} from './database'; } from './database';
import { toSupportedLocale, toSupportedLocaleOrCustom } from './locale'; import { toSupportedLocale, toSupportedLocaleOrCustom } from './locale';
import type { CustomEmojiData } from './types'; 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); const locale = toSupportedLocale(localeString);
// Validate the provided path. // Validate the provided path.
@ -18,14 +28,42 @@ export async function importEmojiData(localeString: string, path?: string) {
throw new Error('Invalid path for emoji data'); throw new Error('Invalid path for emoji data');
} else { } else {
// Otherwise get the path if not provided. // Otherwise get the path if not provided.
path ??= await localeToPath(locale); path ??= await localeToEmojiPath(locale);
} }
const emojis = await fetchAndCheckEtag<CompactEmoji[]>(locale, path); const emojis = await fetchAndCheckEtag<CompactEmoji[]>(locale, path);
if (!emojis) { if (!emojis) {
return; 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<ShortcodesDataset>(
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); await putEmojiData(flattenedEmojis, locale);
return flattenedEmojis; return flattenedEmojis;
} }
@ -42,32 +80,77 @@ export async function importCustomEmojiData() {
return emojis; return emojis;
} }
const modules = import.meta.glob<string>( export async function importLegacyShortcodes() {
'../../../../../node_modules/emojibase-data/**/compact.json', const { default: shortcodesPath } =
{ await import('emojibase-data/en/shortcodes/iamcal.json?url');
query: '?url', const response = await fetch(shortcodesPath);
import: 'default', if (!response.ok) {
}, throw new Error(
); `Failed to fetch legacy shortcodes data: ${response.statusText}`,
);
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}`);
} }
return modules[key](); const shortcodesData = (await response.json()) as ShortcodesDataset;
await putLegacyShortcodes(shortcodesData);
return Object.keys(shortcodesData);
} }
export async function fetchAndCheckEtag<ResultType extends object[]>( const emojiModules = new Map(
Object.entries(
import.meta.glob<string>(
'../../../../../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<string>(
'../../../../../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<ResultType extends object[] | object>(
localeString: string, localeString: string,
path: string, path: string,
checkEtag = true,
): Promise<ResultType | null> { ): Promise<ResultType | null> {
const locale = toSupportedLocaleOrCustom(localeString); const locale = toSupportedLocaleOrCustom(localeString);
// Use location.origin as this script may be loaded from a CDN domain. // Use location.origin as this script may be loaded from a CDN domain.
const url = new URL(path, location.origin); const url = new URL(path, location.origin);
const oldEtag = await loadLatestEtag(locale); const oldEtag = checkEtag ? await loadLatestEtag(locale) : null;
const response = await fetch(url, { const response = await fetch(url, {
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
@ -85,13 +168,10 @@ export async function fetchAndCheckEtag<ResultType extends object[]>(
} }
const data = (await response.json()) as ResultType; 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 // Store the ETag for future requests
const etag = response.headers.get('ETag'); const etag = response.headers.get('ETag');
if (etag) { if (etag && checkEtag) {
await putLatestEtag(etag, localeString); await putLatestEtag(etag, localeString);
} }

View File

@ -7,6 +7,7 @@ import {
stringToEmojiState, stringToEmojiState,
tokenizeText, tokenizeText,
} from './render'; } from './render';
import type { EmojiStateCustom, EmojiStateUnicode } from './types';
describe('tokenizeText', () => { describe('tokenizeText', () => {
test('returns an array of text to be a single token', () => { test('returns an array of text to be a single token', () => {
@ -120,13 +121,24 @@ describe('loadEmojiDataToState', () => {
const dbCall = vi const dbCall = vi
.spyOn(db, 'loadEmojiByHexcode') .spyOn(db, 'loadEmojiByHexcode')
.mockResolvedValue(unicodeEmojiFactory()); .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'); const result = await loadEmojiDataToState(unicodeState, 'en');
expect(dbCall).toHaveBeenCalledWith('1F60A', 'en'); expect(dbCall).toHaveBeenCalledWith('1F60A', 'en');
expect(dbLegacyCall).toHaveBeenCalledWith('1F60A');
expect(result).toEqual({ expect(result).toEqual({
type: 'unicode', type: 'unicode',
code: '1F60A', code: '1F60A',
data: unicodeEmojiFactory(), data: unicodeEmojiFactory(),
shortcode: 'legacy_code',
}); });
}); });
@ -134,7 +146,10 @@ describe('loadEmojiDataToState', () => {
const dbCall = vi const dbCall = vi
.spyOn(db, 'loadCustomEmojiByShortcode') .spyOn(db, 'loadCustomEmojiByShortcode')
.mockResolvedValueOnce(customEmojiFactory()); .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'); const result = await loadEmojiDataToState(customState, 'en');
expect(dbCall).toHaveBeenCalledWith('smile'); expect(dbCall).toHaveBeenCalledWith('smile');
expect(result).toEqual({ 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 () => { test('returns null if unicode emoji not found in database', async () => {
vi.spyOn(db, 'loadEmojiByHexcode').mockResolvedValueOnce(undefined); 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'); const result = await loadEmojiDataToState(unicodeState, 'en');
expect(result).toBeNull(); expect(result).toBeNull();
}); });
test('returns null if custom emoji not found in database', async () => { test('returns null if custom emoji not found in database', async () => {
vi.spyOn(db, 'loadCustomEmojiByShortcode').mockResolvedValueOnce(undefined); 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'); const result = await loadEmojiDataToState(customState, 'en');
expect(result).toBeNull(); expect(result).toBeNull();
}); });
@ -167,7 +213,10 @@ describe('loadEmojiDataToState', () => {
.spyOn(console, 'warn') .spyOn(console, 'warn')
.mockImplementationOnce(() => null); .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'); const result = await loadEmojiDataToState(unicodeState, 'en');
expect(dbCall).toHaveBeenCalledTimes(2); expect(dbCall).toHaveBeenCalledTimes(2);

View File

@ -7,6 +7,7 @@ import {
import { import {
loadCustomEmojiByShortcode, loadCustomEmojiByShortcode,
loadEmojiByHexcode, loadEmojiByHexcode,
loadLegacyShortcodesByShortcode,
LocaleNotLoadedError, LocaleNotLoadedError,
} from './database'; } from './database';
import { importEmojiData } from './loader'; import { importEmojiData } from './loader';
@ -116,13 +117,20 @@ export async function loadEmojiDataToState(
// First, try to load the data from IndexedDB. // First, try to load the data from IndexedDB.
try { try {
const legacyCode = await loadLegacyShortcodesByShortcode(state.code);
// This is duplicative, but that's because TS can't distinguish the state type easily. // This is duplicative, but that's because TS can't distinguish the state type easily.
if (state.type === EMOJI_TYPE_UNICODE) { if (state.type === EMOJI_TYPE_UNICODE || legacyCode) {
const data = await loadEmojiByHexcode(state.code, locale); const data = await loadEmojiByHexcode(
legacyCode?.hexcode ?? state.code,
locale,
);
if (data) { if (data) {
return { return {
...state, ...state,
type: EMOJI_TYPE_UNICODE,
data, data,
// TODO: Use CLDR shortcodes when the picker supports them.
shortcode: legacyCode?.shortcodes.at(0),
}; };
} }
} else { } else {

View File

@ -4,6 +4,7 @@ import type { FlatCompactEmoji, Locale } from 'emojibase';
import type { ApiCustomEmojiJSON } from '@/mastodon/api_types/custom_emoji'; import type { ApiCustomEmojiJSON } from '@/mastodon/api_types/custom_emoji';
import type { CustomEmoji } from '@/mastodon/models/custom_emoji'; import type { CustomEmoji } from '@/mastodon/models/custom_emoji';
import type { RequiredExcept } from '@/mastodon/utils/types';
import type { import type {
EMOJI_MODE_NATIVE, EMOJI_MODE_NATIVE,
@ -40,6 +41,7 @@ export interface EmojiStateUnicode {
type: typeof EMOJI_TYPE_UNICODE; type: typeof EMOJI_TYPE_UNICODE;
code: string; code: string;
data?: UnicodeEmojiData; data?: UnicodeEmojiData;
shortcode?: string;
} }
export interface EmojiStateCustom { export interface EmojiStateCustom {
type: typeof EMOJI_TYPE_CUSTOM; type: typeof EMOJI_TYPE_CUSTOM;
@ -49,7 +51,7 @@ export interface EmojiStateCustom {
export type EmojiState = EmojiStateUnicode | EmojiStateCustom; export type EmojiState = EmojiStateUnicode | EmojiStateCustom;
export type EmojiLoadedState = export type EmojiLoadedState =
| Required<EmojiStateUnicode> | RequiredExcept<EmojiStateUnicode, 'shortcode'>
| Required<EmojiStateCustom>; | Required<EmojiStateCustom>;
export type CustomEmojiMapArg = export type CustomEmojiMapArg =

View File

@ -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); addEventListener('message', handleMessage);
self.postMessage('ready'); // After the worker is ready, notify the main thread 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) { async function loadData(locale: string, path?: string) {
let importCount: number | undefined; let importCount: number | undefined;
if (locale === 'custom') { if (locale === EMOJI_TYPE_CUSTOM) {
importCount = (await importCustomEmojiData())?.length; importCount = (await importCustomEmojiData())?.length;
} else if (locale === EMOJI_DB_NAME_SHORTCODES) {
importCount = (await importLegacyShortcodes()).length;
} else if (path) { } else if (path) {
importCount = (await importEmojiData(locale, path))?.length; importCount = (await importEmojiData(locale, path))?.length;
} else { } else {

View File

@ -15,6 +15,8 @@ export type SomeRequired<T, K extends keyof T> = T & Required<Pick<T, K>>;
export type SomeOptional<T, K extends keyof T> = Pick<T, Exclude<keyof T, K>> & export type SomeOptional<T, K extends keyof T> = Pick<T, Exclude<keyof T, K>> &
Partial<Pick<T, K>>; Partial<Pick<T, K>>;
export type RequiredExcept<T, K extends keyof T> = SomeOptional<Required<T>, K>;
export type OmitValueType<T, V> = { export type OmitValueType<T, V> = {
[K in keyof T as T[K] extends V ? never : K]: T[K]; [K in keyof T as T[K] extends V ? never : K]: T[K];
}; };

View File

@ -118,6 +118,7 @@ export function unicodeEmojiFactory(
hexcode: 'test', hexcode: 'test',
label: 'Test', label: 'Test',
unicode: '🧪', unicode: '🧪',
shortcodes: ['test_emoji'],
...data, ...data,
}; };
} }