mirror of
https://github.com/mastodon/mastodon.git
synced 2025-09-05 17:31:12 +00:00
Emoji Rendering Efficiency (#35568)
Some checks failed
Check i18n / check-i18n (push) Waiting to run
CodeQL / Analyze (javascript) (push) Waiting to run
CodeQL / Analyze (ruby) (push) Waiting to run
Check formatting / lint (push) Waiting to run
Ruby Testing / build (production) (push) Waiting to run
Ruby Testing / build (test) (push) Waiting to run
Ruby Testing / test (.ruby-version) (push) Blocked by required conditions
Ruby Testing / test (3.2) (push) Blocked by required conditions
Ruby Testing / test (3.3) (push) Blocked by required conditions
Ruby Testing / ImageMagick tests (.ruby-version) (push) Blocked by required conditions
Ruby Testing / ImageMagick tests (3.2) (push) Blocked by required conditions
Ruby Testing / ImageMagick tests (3.3) (push) Blocked by required conditions
Ruby Testing / End to End testing (.ruby-version) (push) Blocked by required conditions
Ruby Testing / End to End testing (3.2) (push) Blocked by required conditions
Ruby Testing / End to End testing (3.3) (push) Blocked by required conditions
Ruby Testing / Elastic Search integration testing (.ruby-version, docker.elastic.co/elasticsearch/elasticsearch:7.17.13) (push) Blocked by required conditions
Ruby Testing / Elastic Search integration testing (.ruby-version, docker.elastic.co/elasticsearch/elasticsearch:8.10.2) (push) Blocked by required conditions
Ruby Testing / Elastic Search integration testing (.ruby-version, opensearchproject/opensearch:2) (push) Blocked by required conditions
Ruby Testing / Elastic Search integration testing (3.2, docker.elastic.co/elasticsearch/elasticsearch:7.17.13) (push) Blocked by required conditions
Ruby Testing / Elastic Search integration testing (3.3, docker.elastic.co/elasticsearch/elasticsearch:7.17.13) (push) Blocked by required conditions
Chromatic / Run Chromatic (push) Has been cancelled
CSS Linting / lint (push) Has been cancelled
JavaScript Linting / lint (push) Has been cancelled
JavaScript Testing / test (push) Has been cancelled
Some checks failed
Check i18n / check-i18n (push) Waiting to run
CodeQL / Analyze (javascript) (push) Waiting to run
CodeQL / Analyze (ruby) (push) Waiting to run
Check formatting / lint (push) Waiting to run
Ruby Testing / build (production) (push) Waiting to run
Ruby Testing / build (test) (push) Waiting to run
Ruby Testing / test (.ruby-version) (push) Blocked by required conditions
Ruby Testing / test (3.2) (push) Blocked by required conditions
Ruby Testing / test (3.3) (push) Blocked by required conditions
Ruby Testing / ImageMagick tests (.ruby-version) (push) Blocked by required conditions
Ruby Testing / ImageMagick tests (3.2) (push) Blocked by required conditions
Ruby Testing / ImageMagick tests (3.3) (push) Blocked by required conditions
Ruby Testing / End to End testing (.ruby-version) (push) Blocked by required conditions
Ruby Testing / End to End testing (3.2) (push) Blocked by required conditions
Ruby Testing / End to End testing (3.3) (push) Blocked by required conditions
Ruby Testing / Elastic Search integration testing (.ruby-version, docker.elastic.co/elasticsearch/elasticsearch:7.17.13) (push) Blocked by required conditions
Ruby Testing / Elastic Search integration testing (.ruby-version, docker.elastic.co/elasticsearch/elasticsearch:8.10.2) (push) Blocked by required conditions
Ruby Testing / Elastic Search integration testing (.ruby-version, opensearchproject/opensearch:2) (push) Blocked by required conditions
Ruby Testing / Elastic Search integration testing (3.2, docker.elastic.co/elasticsearch/elasticsearch:7.17.13) (push) Blocked by required conditions
Ruby Testing / Elastic Search integration testing (3.3, docker.elastic.co/elasticsearch/elasticsearch:7.17.13) (push) Blocked by required conditions
Chromatic / Run Chromatic (push) Has been cancelled
CSS Linting / lint (push) Has been cancelled
JavaScript Linting / lint (push) Has been cancelled
JavaScript Testing / test (push) Has been cancelled
This commit is contained in:
parent
0e249cba4b
commit
6bca52453a
|
@ -15,6 +15,17 @@ export const SKIN_TONE_CODES = [
|
||||||
0x1f3ff, // Dark skin tone
|
0x1f3ff, // Dark skin tone
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
// TODO: Test and create fallback for browsers that do not handle the /v flag.
|
||||||
|
export const UNICODE_EMOJI_REGEX = /\p{RGI_Emoji}/v;
|
||||||
|
// See: https://www.unicode.org/reports/tr51/#valid-emoji-tag-sequences
|
||||||
|
export const UNICODE_FLAG_EMOJI_REGEX =
|
||||||
|
/\p{RGI_Emoji_Flag_Sequence}|\p{RGI_Emoji_Tag_Sequence}/v;
|
||||||
|
export const CUSTOM_EMOJI_REGEX = /:([a-z0-9_]+):/i;
|
||||||
|
export const ANY_EMOJI_REGEX = new RegExp(
|
||||||
|
`(${UNICODE_EMOJI_REGEX.source}|${CUSTOM_EMOJI_REGEX.source})`,
|
||||||
|
'gv',
|
||||||
|
);
|
||||||
|
|
||||||
// Emoji rendering modes. A mode is what we are using to render emojis, a style is what the user has selected.
|
// Emoji rendering modes. A mode is what we are using to render emojis, a style is what the user has selected.
|
||||||
export const EMOJI_MODE_NATIVE = 'native';
|
export const EMOJI_MODE_NATIVE = 'native';
|
||||||
export const EMOJI_MODE_NATIVE_WITH_FLAGS = 'native-flags';
|
export const EMOJI_MODE_NATIVE_WITH_FLAGS = 'native-flags';
|
||||||
|
|
139
app/javascript/mastodon/features/emoji/database.test.ts
Normal file
139
app/javascript/mastodon/features/emoji/database.test.ts
Normal file
|
@ -0,0 +1,139 @@
|
||||||
|
import { IDBFactory } from 'fake-indexeddb';
|
||||||
|
|
||||||
|
import { unicodeEmojiFactory } from '@/testing/factories';
|
||||||
|
|
||||||
|
import {
|
||||||
|
putEmojiData,
|
||||||
|
loadEmojiByHexcode,
|
||||||
|
searchEmojisByHexcodes,
|
||||||
|
searchEmojisByTag,
|
||||||
|
testClear,
|
||||||
|
testGet,
|
||||||
|
} from './database';
|
||||||
|
|
||||||
|
describe('emoji database', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
testClear();
|
||||||
|
indexedDB = new IDBFactory();
|
||||||
|
});
|
||||||
|
describe('putEmojiData', () => {
|
||||||
|
test('adds to loaded locales', async () => {
|
||||||
|
const { loadedLocales } = await testGet();
|
||||||
|
expect(loadedLocales).toHaveLength(0);
|
||||||
|
await putEmojiData([], 'en');
|
||||||
|
expect(loadedLocales).toContain('en');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('loads emoji into indexedDB', async () => {
|
||||||
|
await putEmojiData([unicodeEmojiFactory()], 'en');
|
||||||
|
const { db } = await testGet();
|
||||||
|
await expect(db.get('en', 'test')).resolves.toEqual(
|
||||||
|
unicodeEmojiFactory(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('loadEmojiByHexcode', () => {
|
||||||
|
test('throws if the locale is not loaded', async () => {
|
||||||
|
await expect(loadEmojiByHexcode('en', 'test')).rejects.toThrowError(
|
||||||
|
'Locale en',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('retrieves the emoji', async () => {
|
||||||
|
await putEmojiData([unicodeEmojiFactory()], 'en');
|
||||||
|
await expect(loadEmojiByHexcode('test', 'en')).resolves.toEqual(
|
||||||
|
unicodeEmojiFactory(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns undefined if not found', async () => {
|
||||||
|
await putEmojiData([], 'en');
|
||||||
|
await expect(loadEmojiByHexcode('test', 'en')).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('searchEmojisByHexcodes', () => {
|
||||||
|
const data = [
|
||||||
|
unicodeEmojiFactory({ hexcode: 'not a number' }),
|
||||||
|
unicodeEmojiFactory({ hexcode: '1' }),
|
||||||
|
unicodeEmojiFactory({ hexcode: '2' }),
|
||||||
|
unicodeEmojiFactory({ hexcode: '3' }),
|
||||||
|
unicodeEmojiFactory({ hexcode: 'another not a number' }),
|
||||||
|
];
|
||||||
|
beforeEach(async () => {
|
||||||
|
await putEmojiData(data, 'en');
|
||||||
|
});
|
||||||
|
test('finds emoji in consecutive range', async () => {
|
||||||
|
const actual = await searchEmojisByHexcodes(['1', '2', '3'], 'en');
|
||||||
|
expect(actual).toHaveLength(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('finds emoji in split range', async () => {
|
||||||
|
const actual = await searchEmojisByHexcodes(['1', '3'], 'en');
|
||||||
|
expect(actual).toHaveLength(2);
|
||||||
|
expect(actual).toContainEqual(data.at(1));
|
||||||
|
expect(actual).toContainEqual(data.at(3));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('finds emoji with non-numeric range', async () => {
|
||||||
|
const actual = await searchEmojisByHexcodes(
|
||||||
|
['3', 'not a number', '1'],
|
||||||
|
'en',
|
||||||
|
);
|
||||||
|
expect(actual).toHaveLength(3);
|
||||||
|
expect(actual).toContainEqual(data.at(0));
|
||||||
|
expect(actual).toContainEqual(data.at(1));
|
||||||
|
expect(actual).toContainEqual(data.at(3));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('not found emoji are not returned', async () => {
|
||||||
|
const actual = await searchEmojisByHexcodes(['not found'], 'en');
|
||||||
|
expect(actual).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('only found emojis are returned', async () => {
|
||||||
|
const actual = await searchEmojisByHexcodes(
|
||||||
|
['another not a number', 'not found'],
|
||||||
|
'en',
|
||||||
|
);
|
||||||
|
expect(actual).toHaveLength(1);
|
||||||
|
expect(actual).toContainEqual(data.at(4));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('searchEmojisByTag', () => {
|
||||||
|
const data = [
|
||||||
|
unicodeEmojiFactory({ hexcode: 'test1', tags: ['test 1'] }),
|
||||||
|
unicodeEmojiFactory({
|
||||||
|
hexcode: 'test2',
|
||||||
|
tags: ['test 2', 'something else'],
|
||||||
|
}),
|
||||||
|
unicodeEmojiFactory({ hexcode: 'test3', tags: ['completely different'] }),
|
||||||
|
];
|
||||||
|
beforeEach(async () => {
|
||||||
|
await putEmojiData(data, 'en');
|
||||||
|
});
|
||||||
|
test('finds emojis with tag', async () => {
|
||||||
|
const actual = await searchEmojisByTag('test 1', 'en');
|
||||||
|
expect(actual).toHaveLength(1);
|
||||||
|
expect(actual).toContainEqual(data.at(0));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('finds emojis starting with tag', async () => {
|
||||||
|
const actual = await searchEmojisByTag('test', 'en');
|
||||||
|
expect(actual).toHaveLength(2);
|
||||||
|
expect(actual).not.toContainEqual(data.at(2));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('does not find emojis ending with tag', async () => {
|
||||||
|
const actual = await searchEmojisByTag('else', 'en');
|
||||||
|
expect(actual).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('finds nothing with invalid tag', async () => {
|
||||||
|
const actual = await searchEmojisByTag('not found', 'en');
|
||||||
|
expect(actual).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -9,6 +9,7 @@ import type {
|
||||||
UnicodeEmojiData,
|
UnicodeEmojiData,
|
||||||
LocaleOrCustom,
|
LocaleOrCustom,
|
||||||
} from './types';
|
} from './types';
|
||||||
|
import { emojiLogger } from './utils';
|
||||||
|
|
||||||
interface EmojiDB extends LocaleTables, DBSchema {
|
interface EmojiDB extends LocaleTables, DBSchema {
|
||||||
custom: {
|
custom: {
|
||||||
|
@ -36,15 +37,21 @@ interface LocaleTable {
|
||||||
}
|
}
|
||||||
type LocaleTables = Record<Locale, LocaleTable>;
|
type LocaleTables = Record<Locale, LocaleTable>;
|
||||||
|
|
||||||
|
type Database = IDBPDatabase<EmojiDB>;
|
||||||
|
|
||||||
const SCHEMA_VERSION = 1;
|
const SCHEMA_VERSION = 1;
|
||||||
|
|
||||||
let db: IDBPDatabase<EmojiDB> | null = null;
|
const loadedLocales = new Set<Locale>();
|
||||||
|
|
||||||
async function loadDB() {
|
const log = emojiLogger('database');
|
||||||
if (db) {
|
|
||||||
return db;
|
// Loads the database in a way that ensures it's only loaded once.
|
||||||
}
|
const loadDB = (() => {
|
||||||
db = await openDB<EmojiDB>('mastodon-emoji', SCHEMA_VERSION, {
|
let dbPromise: Promise<Database> | null = null;
|
||||||
|
|
||||||
|
// Actually load the DB.
|
||||||
|
async function initDB() {
|
||||||
|
const db = await openDB<EmojiDB>('mastodon-emoji', SCHEMA_VERSION, {
|
||||||
upgrade(database) {
|
upgrade(database) {
|
||||||
const customTable = database.createObjectStore('custom', {
|
const customTable = database.createObjectStore('custom', {
|
||||||
keyPath: 'shortcode',
|
keyPath: 'shortcode',
|
||||||
|
@ -66,10 +73,27 @@ async function loadDB() {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
await syncLocales(db);
|
||||||
return db;
|
return db;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Loads the database, or returns the existing promise if it hasn't resolved yet.
|
||||||
|
const loadPromise = async (): Promise<Database> => {
|
||||||
|
if (dbPromise) {
|
||||||
|
return dbPromise;
|
||||||
|
}
|
||||||
|
dbPromise = initDB();
|
||||||
|
return dbPromise;
|
||||||
|
};
|
||||||
|
// Special way to reset the database, used for unit testing.
|
||||||
|
loadPromise.reset = () => {
|
||||||
|
dbPromise = null;
|
||||||
|
};
|
||||||
|
return loadPromise;
|
||||||
|
})();
|
||||||
|
|
||||||
export async function putEmojiData(emojis: UnicodeEmojiData[], locale: Locale) {
|
export async function putEmojiData(emojis: UnicodeEmojiData[], locale: Locale) {
|
||||||
|
loadedLocales.add(locale);
|
||||||
const db = await loadDB();
|
const db = await loadDB();
|
||||||
const trx = db.transaction(locale, 'readwrite');
|
const trx = db.transaction(locale, 'readwrite');
|
||||||
await Promise.all(emojis.map((emoji) => trx.store.put(emoji)));
|
await Promise.all(emojis.map((emoji) => trx.store.put(emoji)));
|
||||||
|
@ -86,15 +110,15 @@ export async function putCustomEmojiData(emojis: CustomEmojiData[]) {
|
||||||
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();
|
||||||
return db.put('etags', etag, locale);
|
await db.put('etags', etag, locale);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function searchEmojiByHexcode(
|
export async function loadEmojiByHexcode(
|
||||||
hexcode: string,
|
hexcode: string,
|
||||||
localeString: string,
|
localeString: string,
|
||||||
) {
|
) {
|
||||||
const locale = toSupportedLocale(localeString);
|
|
||||||
const db = await loadDB();
|
const db = await loadDB();
|
||||||
|
const locale = toLoadedLocale(localeString);
|
||||||
return db.get(locale, hexcode);
|
return db.get(locale, hexcode);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -102,45 +126,39 @@ export async function searchEmojisByHexcodes(
|
||||||
hexcodes: string[],
|
hexcodes: string[],
|
||||||
localeString: string,
|
localeString: string,
|
||||||
) {
|
) {
|
||||||
const locale = toSupportedLocale(localeString);
|
|
||||||
const db = await loadDB();
|
const db = await loadDB();
|
||||||
return db.getAll(
|
const locale = toLoadedLocale(localeString);
|
||||||
|
const sortedCodes = hexcodes.toSorted();
|
||||||
|
const results = await db.getAll(
|
||||||
locale,
|
locale,
|
||||||
IDBKeyRange.bound(hexcodes[0], hexcodes[hexcodes.length - 1]),
|
IDBKeyRange.bound(sortedCodes.at(0), sortedCodes.at(-1)),
|
||||||
);
|
);
|
||||||
|
return results.filter((emoji) => hexcodes.includes(emoji.hexcode));
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function searchEmojiByTag(tag: string, localeString: string) {
|
export async function searchEmojisByTag(tag: string, localeString: string) {
|
||||||
const locale = toSupportedLocale(localeString);
|
|
||||||
const range = IDBKeyRange.only(tag.toLowerCase());
|
|
||||||
const db = await loadDB();
|
const db = await loadDB();
|
||||||
|
const locale = toLoadedLocale(localeString);
|
||||||
|
const range = IDBKeyRange.bound(
|
||||||
|
tag.toLowerCase(),
|
||||||
|
`${tag.toLowerCase()}\uffff`,
|
||||||
|
);
|
||||||
return db.getAllFromIndex(locale, 'tags', range);
|
return db.getAllFromIndex(locale, 'tags', range);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function searchCustomEmojiByShortcode(shortcode: string) {
|
export async function loadCustomEmojiByShortcode(shortcode: string) {
|
||||||
const db = await loadDB();
|
const db = await loadDB();
|
||||||
return db.get('custom', shortcode);
|
return db.get('custom', shortcode);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function searchCustomEmojisByShortcodes(shortcodes: string[]) {
|
export async function searchCustomEmojisByShortcodes(shortcodes: string[]) {
|
||||||
const db = await loadDB();
|
const db = await loadDB();
|
||||||
return db.getAll(
|
const sortedCodes = shortcodes.toSorted();
|
||||||
|
const results = await db.getAll(
|
||||||
'custom',
|
'custom',
|
||||||
IDBKeyRange.bound(shortcodes[0], shortcodes[shortcodes.length - 1]),
|
IDBKeyRange.bound(sortedCodes.at(0), sortedCodes.at(-1)),
|
||||||
);
|
);
|
||||||
}
|
return results.filter((emoji) => shortcodes.includes(emoji.shortcode));
|
||||||
|
|
||||||
export async function findMissingLocales(localeStrings: string[]) {
|
|
||||||
const locales = new Set(localeStrings.map(toSupportedLocale));
|
|
||||||
const missingLocales: Locale[] = [];
|
|
||||||
const db = await loadDB();
|
|
||||||
for (const locale of locales) {
|
|
||||||
const rowCount = await db.count(locale);
|
|
||||||
if (!rowCount) {
|
|
||||||
missingLocales.push(locale);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return missingLocales;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loadLatestEtag(localeString: string) {
|
export async function loadLatestEtag(localeString: string) {
|
||||||
|
@ -153,3 +171,51 @@ export async function loadLatestEtag(localeString: string) {
|
||||||
const etag = await db.get('etags', locale);
|
const etag = await db.get('etags', locale);
|
||||||
return etag ?? null;
|
return etag ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Private functions
|
||||||
|
|
||||||
|
async function syncLocales(db: Database) {
|
||||||
|
const locales = await Promise.all(
|
||||||
|
SUPPORTED_LOCALES.map(
|
||||||
|
async (locale) =>
|
||||||
|
[locale, await hasLocale(locale, db)] satisfies [Locale, boolean],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
for (const [locale, loaded] of locales) {
|
||||||
|
if (loaded) {
|
||||||
|
loadedLocales.add(locale);
|
||||||
|
} else {
|
||||||
|
loadedLocales.delete(locale);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
log('Loaded %d locales: %o', loadedLocales.size, loadedLocales);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toLoadedLocale(localeString: string) {
|
||||||
|
const locale = toSupportedLocale(localeString);
|
||||||
|
if (localeString !== locale) {
|
||||||
|
log(`Locale ${locale} is different from provided ${localeString}`);
|
||||||
|
}
|
||||||
|
if (!loadedLocales.has(locale)) {
|
||||||
|
throw new Error(`Locale ${locale} is not loaded in emoji database`);
|
||||||
|
}
|
||||||
|
return locale;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function hasLocale(locale: Locale, db: Database): Promise<boolean> {
|
||||||
|
if (loadedLocales.has(locale)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const rowCount = await db.count(locale);
|
||||||
|
return !!rowCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Testing helpers
|
||||||
|
export async function testGet() {
|
||||||
|
const db = await loadDB();
|
||||||
|
return { db, loadedLocales };
|
||||||
|
}
|
||||||
|
export function testClear() {
|
||||||
|
loadedLocales.clear();
|
||||||
|
loadDB.reset();
|
||||||
|
}
|
||||||
|
|
|
@ -1,81 +1,31 @@
|
||||||
import type { HTMLAttributes } from 'react';
|
import type { ComponentPropsWithoutRef, ElementType } from 'react';
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
|
||||||
|
|
||||||
import type { List as ImmutableList } from 'immutable';
|
import { useEmojify } from './hooks';
|
||||||
import { isList } from 'immutable';
|
import type { CustomEmojiMapArg } from './types';
|
||||||
|
|
||||||
import type { ApiCustomEmojiJSON } from '@/mastodon/api_types/custom_emoji';
|
type EmojiHTMLProps<Element extends ElementType = 'div'> = Omit<
|
||||||
import type { CustomEmoji } from '@/mastodon/models/custom_emoji';
|
ComponentPropsWithoutRef<Element>,
|
||||||
import { isModernEmojiEnabled } from '@/mastodon/utils/environment';
|
|
||||||
|
|
||||||
import { useEmojiAppState } from './hooks';
|
|
||||||
import { emojifyElement } from './render';
|
|
||||||
import type { ExtraCustomEmojiMap } from './types';
|
|
||||||
|
|
||||||
type EmojiHTMLProps = Omit<
|
|
||||||
HTMLAttributes<HTMLDivElement>,
|
|
||||||
'dangerouslySetInnerHTML'
|
'dangerouslySetInnerHTML'
|
||||||
> & {
|
> & {
|
||||||
htmlString: string;
|
htmlString: string;
|
||||||
extraEmojis?: ExtraCustomEmojiMap | ImmutableList<CustomEmoji>;
|
extraEmojis?: CustomEmojiMapArg;
|
||||||
|
as?: Element;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const EmojiHTML: React.FC<EmojiHTMLProps> = ({
|
export const EmojiHTML = <Element extends ElementType>({
|
||||||
htmlString,
|
|
||||||
extraEmojis,
|
extraEmojis,
|
||||||
|
htmlString,
|
||||||
|
as: asElement, // Rename for syntax highlighting
|
||||||
...props
|
...props
|
||||||
}) => {
|
}: EmojiHTMLProps<Element>) => {
|
||||||
if (isModernEmojiEnabled()) {
|
const Wrapper = asElement ?? 'div';
|
||||||
return (
|
const emojifiedHtml = useEmojify(htmlString, extraEmojis);
|
||||||
<ModernEmojiHTML
|
|
||||||
htmlString={htmlString}
|
|
||||||
extraEmojis={extraEmojis}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return <div dangerouslySetInnerHTML={{ __html: htmlString }} {...props} />;
|
|
||||||
};
|
|
||||||
|
|
||||||
const ModernEmojiHTML: React.FC<EmojiHTMLProps> = ({
|
if (emojifiedHtml === null) {
|
||||||
extraEmojis: rawEmojis,
|
|
||||||
htmlString: text,
|
|
||||||
...props
|
|
||||||
}) => {
|
|
||||||
const appState = useEmojiAppState();
|
|
||||||
const [innerHTML, setInnerHTML] = useState('');
|
|
||||||
|
|
||||||
const extraEmojis: ExtraCustomEmojiMap = useMemo(() => {
|
|
||||||
if (!rawEmojis) {
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
if (isList(rawEmojis)) {
|
|
||||||
return (
|
|
||||||
rawEmojis.toJS() as ApiCustomEmojiJSON[]
|
|
||||||
).reduce<ExtraCustomEmojiMap>(
|
|
||||||
(acc, emoji) => ({ ...acc, [emoji.shortcode]: emoji }),
|
|
||||||
{},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return rawEmojis;
|
|
||||||
}, [rawEmojis]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!text) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const cb = async () => {
|
|
||||||
const div = document.createElement('div');
|
|
||||||
div.innerHTML = text;
|
|
||||||
const ele = await emojifyElement(div, appState, extraEmojis);
|
|
||||||
setInnerHTML(ele.innerHTML);
|
|
||||||
};
|
|
||||||
void cb();
|
|
||||||
}, [text, appState, extraEmojis]);
|
|
||||||
|
|
||||||
if (!innerHTML) {
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return <div {...props} dangerouslySetInnerHTML={{ __html: innerHTML }} />;
|
return (
|
||||||
|
<Wrapper {...props} dangerouslySetInnerHTML={{ __html: emojifiedHtml }} />
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,45 +0,0 @@
|
||||||
import { useEffect, useState } from 'react';
|
|
||||||
|
|
||||||
import { useEmojiAppState } from './hooks';
|
|
||||||
import { emojifyText } from './render';
|
|
||||||
|
|
||||||
interface EmojiTextProps {
|
|
||||||
text: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const EmojiText: React.FC<EmojiTextProps> = ({ text }) => {
|
|
||||||
const appState = useEmojiAppState();
|
|
||||||
const [rendered, setRendered] = useState<(string | HTMLImageElement)[]>([]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const cb = async () => {
|
|
||||||
const rendered = await emojifyText(text, appState);
|
|
||||||
setRendered(rendered ?? []);
|
|
||||||
};
|
|
||||||
void cb();
|
|
||||||
}, [text, appState]);
|
|
||||||
|
|
||||||
if (rendered.length === 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{rendered.map((fragment, index) => {
|
|
||||||
if (typeof fragment === 'string') {
|
|
||||||
return <span key={index}>{fragment}</span>;
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<img
|
|
||||||
key={index}
|
|
||||||
draggable='false'
|
|
||||||
src={fragment.src}
|
|
||||||
alt={fragment.alt}
|
|
||||||
title={fragment.title}
|
|
||||||
className={fragment.className}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
|
@ -1,8 +1,64 @@
|
||||||
|
import { useCallback, useLayoutEffect, useMemo, useState } from 'react';
|
||||||
|
|
||||||
|
import { isList } from 'immutable';
|
||||||
|
|
||||||
|
import type { ApiCustomEmojiJSON } from '@/mastodon/api_types/custom_emoji';
|
||||||
import { useAppSelector } from '@/mastodon/store';
|
import { useAppSelector } from '@/mastodon/store';
|
||||||
|
import { isModernEmojiEnabled } from '@/mastodon/utils/environment';
|
||||||
|
|
||||||
import { toSupportedLocale } from './locale';
|
import { toSupportedLocale } from './locale';
|
||||||
import { determineEmojiMode } from './mode';
|
import { determineEmojiMode } from './mode';
|
||||||
import type { EmojiAppState } from './types';
|
import { emojifyElement } from './render';
|
||||||
|
import type {
|
||||||
|
CustomEmojiMapArg,
|
||||||
|
EmojiAppState,
|
||||||
|
ExtraCustomEmojiMap,
|
||||||
|
} from './types';
|
||||||
|
import { stringHasAnyEmoji } from './utils';
|
||||||
|
|
||||||
|
export function useEmojify(text: string, extraEmojis?: CustomEmojiMapArg) {
|
||||||
|
const [emojifiedText, setEmojifiedText] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const appState = useEmojiAppState();
|
||||||
|
const extra: ExtraCustomEmojiMap = useMemo(() => {
|
||||||
|
if (!extraEmojis) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
if (isList(extraEmojis)) {
|
||||||
|
return (
|
||||||
|
extraEmojis.toJS() as ApiCustomEmojiJSON[]
|
||||||
|
).reduce<ExtraCustomEmojiMap>(
|
||||||
|
(acc, emoji) => ({ ...acc, [emoji.shortcode]: emoji }),
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return extraEmojis;
|
||||||
|
}, [extraEmojis]);
|
||||||
|
|
||||||
|
const emojify = useCallback(
|
||||||
|
async (input: string) => {
|
||||||
|
const wrapper = document.createElement('div');
|
||||||
|
wrapper.innerHTML = input;
|
||||||
|
const result = await emojifyElement(wrapper, appState, extra);
|
||||||
|
if (result) {
|
||||||
|
setEmojifiedText(result.innerHTML);
|
||||||
|
} else {
|
||||||
|
setEmojifiedText(input);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[appState, extra],
|
||||||
|
);
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (isModernEmojiEnabled() && !!text.trim() && stringHasAnyEmoji(text)) {
|
||||||
|
void emojify(text);
|
||||||
|
} else {
|
||||||
|
// If no emoji or we don't want to render, fall back.
|
||||||
|
setEmojifiedText(text);
|
||||||
|
}
|
||||||
|
}, [emojify, text]);
|
||||||
|
|
||||||
|
return emojifiedText;
|
||||||
|
}
|
||||||
|
|
||||||
export function useEmojiAppState(): EmojiAppState {
|
export function useEmojiAppState(): EmojiAppState {
|
||||||
const locale = useAppSelector((state) =>
|
const locale = useAppSelector((state) =>
|
||||||
|
@ -12,5 +68,10 @@ export function useEmojiAppState(): EmojiAppState {
|
||||||
determineEmojiMode(state.meta.get('emoji_style') as string),
|
determineEmojiMode(state.meta.get('emoji_style') as string),
|
||||||
);
|
);
|
||||||
|
|
||||||
return { currentLocale: locale, locales: [locale], mode };
|
return {
|
||||||
|
currentLocale: locale,
|
||||||
|
locales: [locale],
|
||||||
|
mode,
|
||||||
|
darkTheme: document.body.classList.contains('theme-default'),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,12 +2,16 @@ import initialState from '@/mastodon/initial_state';
|
||||||
import { loadWorker } from '@/mastodon/utils/workers';
|
import { loadWorker } from '@/mastodon/utils/workers';
|
||||||
|
|
||||||
import { toSupportedLocale } from './locale';
|
import { toSupportedLocale } from './locale';
|
||||||
|
import { emojiLogger } from './utils';
|
||||||
|
|
||||||
const userLocale = toSupportedLocale(initialState?.meta.locale ?? 'en');
|
const userLocale = toSupportedLocale(initialState?.meta.locale ?? 'en');
|
||||||
|
|
||||||
let worker: Worker | null = null;
|
let worker: Worker | null = null;
|
||||||
|
|
||||||
export async function initializeEmoji() {
|
const log = emojiLogger('index');
|
||||||
|
|
||||||
|
export function initializeEmoji() {
|
||||||
|
log('initializing emojis');
|
||||||
if (!worker && 'Worker' in window) {
|
if (!worker && 'Worker' in window) {
|
||||||
try {
|
try {
|
||||||
worker = loadWorker(new URL('./worker', import.meta.url), {
|
worker = loadWorker(new URL('./worker', import.meta.url), {
|
||||||
|
@ -21,9 +25,16 @@ export async function initializeEmoji() {
|
||||||
if (worker) {
|
if (worker) {
|
||||||
// Assign worker to const to make TS happy inside the event listener.
|
// Assign worker to const to make TS happy inside the event listener.
|
||||||
const thisWorker = worker;
|
const thisWorker = worker;
|
||||||
|
const timeoutId = setTimeout(() => {
|
||||||
|
log('worker is not ready after timeout');
|
||||||
|
worker = null;
|
||||||
|
void fallbackLoad();
|
||||||
|
}, 500);
|
||||||
thisWorker.addEventListener('message', (event: MessageEvent<string>) => {
|
thisWorker.addEventListener('message', (event: MessageEvent<string>) => {
|
||||||
const { data: message } = event;
|
const { data: message } = event;
|
||||||
if (message === 'ready') {
|
if (message === 'ready') {
|
||||||
|
log('worker ready, loading data');
|
||||||
|
clearTimeout(timeoutId);
|
||||||
thisWorker.postMessage('custom');
|
thisWorker.postMessage('custom');
|
||||||
void loadEmojiLocale(userLocale);
|
void loadEmojiLocale(userLocale);
|
||||||
// Load English locale as well, because people are still used to
|
// Load English locale as well, because people are still used to
|
||||||
|
@ -31,16 +42,23 @@ export async function initializeEmoji() {
|
||||||
if (userLocale !== 'en') {
|
if (userLocale !== 'en') {
|
||||||
void loadEmojiLocale('en');
|
void loadEmojiLocale('en');
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
log('got worker message: %s', message);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
|
void fallbackLoad();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fallbackLoad() {
|
||||||
|
log('falling back to main thread for loading');
|
||||||
const { importCustomEmojiData } = await import('./loader');
|
const { importCustomEmojiData } = await import('./loader');
|
||||||
await importCustomEmojiData();
|
await importCustomEmojiData();
|
||||||
await loadEmojiLocale(userLocale);
|
await loadEmojiLocale(userLocale);
|
||||||
if (userLocale !== 'en') {
|
if (userLocale !== 'en') {
|
||||||
await loadEmojiLocale('en');
|
await loadEmojiLocale('en');
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loadEmojiLocale(localeString: string) {
|
export async function loadEmojiLocale(localeString: string) {
|
||||||
|
|
|
@ -2,7 +2,6 @@ import { flattenEmojiData } from 'emojibase';
|
||||||
import type { CompactEmoji, FlatCompactEmoji } from 'emojibase';
|
import type { CompactEmoji, FlatCompactEmoji } from 'emojibase';
|
||||||
|
|
||||||
import type { ApiCustomEmojiJSON } from '@/mastodon/api_types/custom_emoji';
|
import type { ApiCustomEmojiJSON } from '@/mastodon/api_types/custom_emoji';
|
||||||
import { isDevelopment } from '@/mastodon/utils/environment';
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
putEmojiData,
|
putEmojiData,
|
||||||
|
@ -12,6 +11,9 @@ import {
|
||||||
} from './database';
|
} from './database';
|
||||||
import { toSupportedLocale, toSupportedLocaleOrCustom } from './locale';
|
import { toSupportedLocale, toSupportedLocaleOrCustom } from './locale';
|
||||||
import type { LocaleOrCustom } from './types';
|
import type { LocaleOrCustom } from './types';
|
||||||
|
import { emojiLogger } from './utils';
|
||||||
|
|
||||||
|
const log = emojiLogger('loader');
|
||||||
|
|
||||||
export async function importEmojiData(localeString: string) {
|
export async function importEmojiData(localeString: string) {
|
||||||
const locale = toSupportedLocale(localeString);
|
const locale = toSupportedLocale(localeString);
|
||||||
|
@ -20,6 +22,7 @@ export async function importEmojiData(localeString: string) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const flattenedEmojis: FlatCompactEmoji[] = flattenEmojiData(emojis);
|
const flattenedEmojis: FlatCompactEmoji[] = flattenEmojiData(emojis);
|
||||||
|
log('loaded %d for %s locale', flattenedEmojis.length, locale);
|
||||||
await putEmojiData(flattenedEmojis, locale);
|
await putEmojiData(flattenedEmojis, locale);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -28,6 +31,7 @@ export async function importCustomEmojiData() {
|
||||||
if (!emojis) {
|
if (!emojis) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
log('loaded %d custom emojis', emojis.length);
|
||||||
await putCustomEmojiData(emojis);
|
await putCustomEmojiData(emojis);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -41,7 +45,9 @@ async function fetchAndCheckEtag<ResultType extends object[]>(
|
||||||
if (locale === 'custom') {
|
if (locale === 'custom') {
|
||||||
url.pathname = '/api/v1/custom_emojis';
|
url.pathname = '/api/v1/custom_emojis';
|
||||||
} else {
|
} else {
|
||||||
url.pathname = `/packs${isDevelopment() ? '-dev' : ''}/emoji/${locale}.json`;
|
// This doesn't use isDevelopment() as that module loads initial state
|
||||||
|
// which breaks workers, as they cannot access the DOM.
|
||||||
|
url.pathname = `/packs${import.meta.env.DEV ? '-dev' : ''}/emoji/${locale}.json`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const oldEtag = await loadLatestEtag(locale);
|
const oldEtag = await loadLatestEtag(locale);
|
||||||
|
|
|
@ -1,94 +1,184 @@
|
||||||
|
import { customEmojiFactory, unicodeEmojiFactory } from '@/testing/factories';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
EMOJI_MODE_NATIVE,
|
EMOJI_MODE_NATIVE,
|
||||||
EMOJI_MODE_NATIVE_WITH_FLAGS,
|
EMOJI_MODE_NATIVE_WITH_FLAGS,
|
||||||
EMOJI_MODE_TWEMOJI,
|
EMOJI_MODE_TWEMOJI,
|
||||||
} from './constants';
|
} from './constants';
|
||||||
import { emojifyElement, tokenizeText } from './render';
|
import * as db from './database';
|
||||||
import type { CustomEmojiData, UnicodeEmojiData } from './types';
|
import {
|
||||||
|
emojifyElement,
|
||||||
|
emojifyText,
|
||||||
|
testCacheClear,
|
||||||
|
tokenizeText,
|
||||||
|
} from './render';
|
||||||
|
import type { EmojiAppState, ExtraCustomEmojiMap } from './types';
|
||||||
|
|
||||||
vitest.mock('./database', () => ({
|
function mockDatabase() {
|
||||||
searchCustomEmojisByShortcodes: vitest.fn(
|
return {
|
||||||
() =>
|
searchCustomEmojisByShortcodes: vi
|
||||||
[
|
.spyOn(db, 'searchCustomEmojisByShortcodes')
|
||||||
{
|
.mockResolvedValue([customEmojiFactory()]),
|
||||||
shortcode: 'custom',
|
searchEmojisByHexcodes: vi
|
||||||
static_url: 'emoji/static',
|
.spyOn(db, 'searchEmojisByHexcodes')
|
||||||
url: 'emoji/custom',
|
.mockResolvedValue([
|
||||||
category: 'test',
|
unicodeEmojiFactory({
|
||||||
visible_in_picker: true,
|
|
||||||
},
|
|
||||||
] satisfies CustomEmojiData[],
|
|
||||||
),
|
|
||||||
searchEmojisByHexcodes: vitest.fn(
|
|
||||||
() =>
|
|
||||||
[
|
|
||||||
{
|
|
||||||
hexcode: '1F60A',
|
hexcode: '1F60A',
|
||||||
group: 0,
|
|
||||||
label: 'smiling face with smiling eyes',
|
label: 'smiling face with smiling eyes',
|
||||||
order: 0,
|
|
||||||
tags: ['smile', 'happy'],
|
|
||||||
unicode: '😊',
|
unicode: '😊',
|
||||||
},
|
}),
|
||||||
{
|
unicodeEmojiFactory({
|
||||||
hexcode: '1F1EA-1F1FA',
|
hexcode: '1F1EA-1F1FA',
|
||||||
group: 0,
|
|
||||||
label: 'flag-eu',
|
label: 'flag-eu',
|
||||||
order: 0,
|
|
||||||
tags: ['flag', 'european union'],
|
|
||||||
unicode: '🇪🇺',
|
unicode: '🇪🇺',
|
||||||
},
|
}),
|
||||||
] satisfies UnicodeEmojiData[],
|
]),
|
||||||
),
|
};
|
||||||
findMissingLocales: vitest.fn(() => []),
|
}
|
||||||
}));
|
|
||||||
|
|
||||||
describe('emojifyElement', () => {
|
const expectedSmileImage =
|
||||||
const testElement = document.createElement('div');
|
|
||||||
testElement.innerHTML = '<p>Hello 😊🇪🇺!</p><p>:custom:</p>';
|
|
||||||
|
|
||||||
const expectedSmileImage =
|
|
||||||
'<img draggable="false" class="emojione" alt="😊" title="smiling face with smiling eyes" src="/emoji/1f60a.svg">';
|
'<img draggable="false" class="emojione" alt="😊" title="smiling face with smiling eyes" src="/emoji/1f60a.svg">';
|
||||||
const expectedFlagImage =
|
const expectedFlagImage =
|
||||||
'<img draggable="false" class="emojione" alt="🇪🇺" title="flag-eu" src="/emoji/1f1ea-1f1fa.svg">';
|
'<img draggable="false" class="emojione" alt="🇪🇺" title="flag-eu" src="/emoji/1f1ea-1f1fa.svg">';
|
||||||
const expectedCustomEmojiImage =
|
const expectedCustomEmojiImage =
|
||||||
'<img draggable="false" class="emojione custom-emoji" alt=":custom:" title=":custom:" src="emoji/static" data-original="emoji/custom" data-static="emoji/static">';
|
'<img draggable="false" class="emojione custom-emoji" alt=":custom:" title=":custom:" src="emoji/custom/static" data-original="emoji/custom" data-static="emoji/custom/static">';
|
||||||
|
const expectedRemoteCustomEmojiImage =
|
||||||
|
'<img draggable="false" class="emojione custom-emoji" alt=":remote:" title=":remote:" src="remote.social/static" data-original="remote.social/custom" data-static="remote.social/static">';
|
||||||
|
|
||||||
function cloneTestElement() {
|
const mockExtraCustom: ExtraCustomEmojiMap = {
|
||||||
return testElement.cloneNode(true) as HTMLElement;
|
remote: {
|
||||||
}
|
shortcode: 'remote',
|
||||||
|
static_url: 'remote.social/static',
|
||||||
|
url: 'remote.social/custom',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
test('emojifies custom emoji in native mode', async () => {
|
function testAppState(state: Partial<EmojiAppState> = {}) {
|
||||||
const emojifiedElement = await emojifyElement(cloneTestElement(), {
|
return {
|
||||||
locales: ['en'],
|
|
||||||
mode: EMOJI_MODE_NATIVE,
|
|
||||||
currentLocale: 'en',
|
|
||||||
});
|
|
||||||
expect(emojifiedElement.innerHTML).toBe(
|
|
||||||
`<p>Hello 😊🇪🇺!</p><p>${expectedCustomEmojiImage}</p>`,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('emojifies flag emoji in native-with-flags mode', async () => {
|
|
||||||
const emojifiedElement = await emojifyElement(cloneTestElement(), {
|
|
||||||
locales: ['en'],
|
|
||||||
mode: EMOJI_MODE_NATIVE_WITH_FLAGS,
|
|
||||||
currentLocale: 'en',
|
|
||||||
});
|
|
||||||
expect(emojifiedElement.innerHTML).toBe(
|
|
||||||
`<p>Hello 😊${expectedFlagImage}!</p><p>${expectedCustomEmojiImage}</p>`,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('emojifies everything in twemoji mode', async () => {
|
|
||||||
const emojifiedElement = await emojifyElement(cloneTestElement(), {
|
|
||||||
locales: ['en'],
|
locales: ['en'],
|
||||||
mode: EMOJI_MODE_TWEMOJI,
|
mode: EMOJI_MODE_TWEMOJI,
|
||||||
currentLocale: 'en',
|
currentLocale: 'en',
|
||||||
|
darkTheme: false,
|
||||||
|
...state,
|
||||||
|
} satisfies EmojiAppState;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('emojifyElement', () => {
|
||||||
|
function testElement(text = '<p>Hello 😊🇪🇺!</p><p>:custom:</p>') {
|
||||||
|
const testElement = document.createElement('div');
|
||||||
|
testElement.innerHTML = text;
|
||||||
|
return testElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
testCacheClear();
|
||||||
|
vi.restoreAllMocks();
|
||||||
});
|
});
|
||||||
expect(emojifiedElement.innerHTML).toBe(
|
|
||||||
|
test('caches element rendering results', async () => {
|
||||||
|
const { searchCustomEmojisByShortcodes, searchEmojisByHexcodes } =
|
||||||
|
mockDatabase();
|
||||||
|
await emojifyElement(testElement(), testAppState());
|
||||||
|
await emojifyElement(testElement(), testAppState());
|
||||||
|
await emojifyElement(testElement(), testAppState());
|
||||||
|
expect(searchEmojisByHexcodes).toHaveBeenCalledExactlyOnceWith(
|
||||||
|
['1F1EA-1F1FA', '1F60A'],
|
||||||
|
'en',
|
||||||
|
);
|
||||||
|
expect(searchCustomEmojisByShortcodes).toHaveBeenCalledExactlyOnceWith([
|
||||||
|
'custom',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('emojifies custom emoji in native mode', async () => {
|
||||||
|
const { searchEmojisByHexcodes } = mockDatabase();
|
||||||
|
const actual = await emojifyElement(
|
||||||
|
testElement(),
|
||||||
|
testAppState({ mode: EMOJI_MODE_NATIVE }),
|
||||||
|
);
|
||||||
|
assert(actual);
|
||||||
|
expect(actual.innerHTML).toBe(
|
||||||
|
`<p>Hello 😊🇪🇺!</p><p>${expectedCustomEmojiImage}</p>`,
|
||||||
|
);
|
||||||
|
expect(searchEmojisByHexcodes).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('emojifies flag emoji in native-with-flags mode', async () => {
|
||||||
|
const { searchEmojisByHexcodes } = mockDatabase();
|
||||||
|
const actual = await emojifyElement(
|
||||||
|
testElement(),
|
||||||
|
testAppState({ mode: EMOJI_MODE_NATIVE_WITH_FLAGS }),
|
||||||
|
);
|
||||||
|
assert(actual);
|
||||||
|
expect(actual.innerHTML).toBe(
|
||||||
|
`<p>Hello 😊${expectedFlagImage}!</p><p>${expectedCustomEmojiImage}</p>`,
|
||||||
|
);
|
||||||
|
expect(searchEmojisByHexcodes).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('emojifies everything in twemoji mode', async () => {
|
||||||
|
const { searchCustomEmojisByShortcodes, searchEmojisByHexcodes } =
|
||||||
|
mockDatabase();
|
||||||
|
const actual = await emojifyElement(testElement(), testAppState());
|
||||||
|
assert(actual);
|
||||||
|
expect(actual.innerHTML).toBe(
|
||||||
`<p>Hello ${expectedSmileImage}${expectedFlagImage}!</p><p>${expectedCustomEmojiImage}</p>`,
|
`<p>Hello ${expectedSmileImage}${expectedFlagImage}!</p><p>${expectedCustomEmojiImage}</p>`,
|
||||||
);
|
);
|
||||||
|
expect(searchEmojisByHexcodes).toHaveBeenCalledOnce();
|
||||||
|
expect(searchCustomEmojisByShortcodes).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('emojifies with provided custom emoji', async () => {
|
||||||
|
const { searchCustomEmojisByShortcodes, searchEmojisByHexcodes } =
|
||||||
|
mockDatabase();
|
||||||
|
const actual = await emojifyElement(
|
||||||
|
testElement('<p>hi :remote:</p>'),
|
||||||
|
testAppState(),
|
||||||
|
mockExtraCustom,
|
||||||
|
);
|
||||||
|
assert(actual);
|
||||||
|
expect(actual.innerHTML).toBe(
|
||||||
|
`<p>hi ${expectedRemoteCustomEmojiImage}</p>`,
|
||||||
|
);
|
||||||
|
expect(searchEmojisByHexcodes).not.toHaveBeenCalled();
|
||||||
|
expect(searchCustomEmojisByShortcodes).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns null when no emoji are found', async () => {
|
||||||
|
mockDatabase();
|
||||||
|
const actual = await emojifyElement(
|
||||||
|
testElement('<p>here is just text :)</p>'),
|
||||||
|
testAppState(),
|
||||||
|
);
|
||||||
|
expect(actual).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('emojifyText', () => {
|
||||||
|
test('returns original input when no emoji are in string', async () => {
|
||||||
|
const actual = await emojifyText('nothing here', testAppState());
|
||||||
|
expect(actual).toBe('nothing here');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renders Unicode emojis to twemojis', async () => {
|
||||||
|
mockDatabase();
|
||||||
|
const actual = await emojifyText('Hello 😊🇪🇺!', testAppState());
|
||||||
|
expect(actual).toBe(`Hello ${expectedSmileImage}${expectedFlagImage}!`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renders custom emojis', async () => {
|
||||||
|
mockDatabase();
|
||||||
|
const actual = await emojifyText('Hello :custom:!', testAppState());
|
||||||
|
expect(actual).toBe(`Hello ${expectedCustomEmojiImage}!`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renders provided extra emojis', async () => {
|
||||||
|
const actual = await emojifyText(
|
||||||
|
'remote emoji :remote:',
|
||||||
|
testAppState(),
|
||||||
|
mockExtraCustom,
|
||||||
|
);
|
||||||
|
expect(actual).toBe(`remote emoji ${expectedRemoteCustomEmojiImage}`);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -1,8 +1,7 @@
|
||||||
import type { Locale } from 'emojibase';
|
|
||||||
import EMOJI_REGEX from 'emojibase-regex/emoji-loose';
|
|
||||||
|
|
||||||
import { autoPlayGif } from '@/mastodon/initial_state';
|
import { autoPlayGif } from '@/mastodon/initial_state';
|
||||||
|
import { createLimitedCache } from '@/mastodon/utils/cache';
|
||||||
import { assetHost } from '@/mastodon/utils/config';
|
import { assetHost } from '@/mastodon/utils/config';
|
||||||
|
import * as perf from '@/mastodon/utils/performance';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
EMOJI_MODE_NATIVE,
|
EMOJI_MODE_NATIVE,
|
||||||
|
@ -10,13 +9,12 @@ import {
|
||||||
EMOJI_TYPE_UNICODE,
|
EMOJI_TYPE_UNICODE,
|
||||||
EMOJI_TYPE_CUSTOM,
|
EMOJI_TYPE_CUSTOM,
|
||||||
EMOJI_STATE_MISSING,
|
EMOJI_STATE_MISSING,
|
||||||
|
ANY_EMOJI_REGEX,
|
||||||
} from './constants';
|
} from './constants';
|
||||||
import {
|
import {
|
||||||
findMissingLocales,
|
|
||||||
searchCustomEmojisByShortcodes,
|
searchCustomEmojisByShortcodes,
|
||||||
searchEmojisByHexcodes,
|
searchEmojisByHexcodes,
|
||||||
} from './database';
|
} from './database';
|
||||||
import { loadEmojiLocale } from './index';
|
|
||||||
import {
|
import {
|
||||||
emojiToUnicodeHex,
|
emojiToUnicodeHex,
|
||||||
twemojiHasBorder,
|
twemojiHasBorder,
|
||||||
|
@ -34,18 +32,33 @@ import type {
|
||||||
LocaleOrCustom,
|
LocaleOrCustom,
|
||||||
UnicodeEmojiToken,
|
UnicodeEmojiToken,
|
||||||
} from './types';
|
} from './types';
|
||||||
import { stringHasUnicodeFlags } from './utils';
|
import { emojiLogger, stringHasAnyEmoji, stringHasUnicodeFlags } from './utils';
|
||||||
|
|
||||||
const localeCacheMap = new Map<LocaleOrCustom, EmojiStateMap>([
|
const log = emojiLogger('render');
|
||||||
[EMOJI_TYPE_CUSTOM, new Map()],
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Emojifies an element. This modifies the element in place, replacing text nodes with emojified versions.
|
/**
|
||||||
|
* Emojifies an element. This modifies the element in place, replacing text nodes with emojified versions.
|
||||||
|
*/
|
||||||
export async function emojifyElement<Element extends HTMLElement>(
|
export async function emojifyElement<Element extends HTMLElement>(
|
||||||
element: Element,
|
element: Element,
|
||||||
appState: EmojiAppState,
|
appState: EmojiAppState,
|
||||||
extraEmojis: ExtraCustomEmojiMap = {},
|
extraEmojis: ExtraCustomEmojiMap = {},
|
||||||
): Promise<Element> {
|
): Promise<Element | null> {
|
||||||
|
const cacheKey = createCacheKey(element, appState, extraEmojis);
|
||||||
|
const cached = getCached(cacheKey);
|
||||||
|
if (cached !== undefined) {
|
||||||
|
log('Cache hit on %s', element.outerHTML);
|
||||||
|
if (cached === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
element.innerHTML = cached;
|
||||||
|
return element;
|
||||||
|
}
|
||||||
|
if (!stringHasAnyEmoji(element.innerHTML)) {
|
||||||
|
updateCache(cacheKey, null);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
perf.start('emojifyElement()');
|
||||||
const queue: (HTMLElement | Text)[] = [element];
|
const queue: (HTMLElement | Text)[] = [element];
|
||||||
while (queue.length > 0) {
|
while (queue.length > 0) {
|
||||||
const current = queue.shift();
|
const current = queue.shift();
|
||||||
|
@ -61,7 +74,7 @@ export async function emojifyElement<Element extends HTMLElement>(
|
||||||
current.textContent &&
|
current.textContent &&
|
||||||
(current instanceof Text || !current.hasChildNodes())
|
(current instanceof Text || !current.hasChildNodes())
|
||||||
) {
|
) {
|
||||||
const renderedContent = await emojifyText(
|
const renderedContent = await textToElementArray(
|
||||||
current.textContent,
|
current.textContent,
|
||||||
appState,
|
appState,
|
||||||
extraEmojis,
|
extraEmojis,
|
||||||
|
@ -70,7 +83,7 @@ export async function emojifyElement<Element extends HTMLElement>(
|
||||||
if (!(current instanceof Text)) {
|
if (!(current instanceof Text)) {
|
||||||
current.textContent = null; // Clear the text content if it's not a Text node.
|
current.textContent = null; // Clear the text content if it's not a Text node.
|
||||||
}
|
}
|
||||||
current.replaceWith(renderedToHTMLFragment(renderedContent));
|
current.replaceWith(renderedToHTML(renderedContent));
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
@ -81,6 +94,8 @@ export async function emojifyElement<Element extends HTMLElement>(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
updateCache(cacheKey, element.innerHTML);
|
||||||
|
perf.stop('emojifyElement()');
|
||||||
return element;
|
return element;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -88,7 +103,54 @@ export async function emojifyText(
|
||||||
text: string,
|
text: string,
|
||||||
appState: EmojiAppState,
|
appState: EmojiAppState,
|
||||||
extraEmojis: ExtraCustomEmojiMap = {},
|
extraEmojis: ExtraCustomEmojiMap = {},
|
||||||
|
): Promise<string | null> {
|
||||||
|
const cacheKey = createCacheKey(text, appState, extraEmojis);
|
||||||
|
const cached = getCached(cacheKey);
|
||||||
|
if (cached !== undefined) {
|
||||||
|
log('Cache hit on %s', text);
|
||||||
|
return cached ?? text;
|
||||||
|
}
|
||||||
|
if (!stringHasAnyEmoji(text)) {
|
||||||
|
updateCache(cacheKey, null);
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
const eleArray = await textToElementArray(text, appState, extraEmojis);
|
||||||
|
if (!eleArray) {
|
||||||
|
updateCache(cacheKey, null);
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
const rendered = renderedToHTML(eleArray, document.createElement('div'));
|
||||||
|
updateCache(cacheKey, rendered.innerHTML);
|
||||||
|
return rendered.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Private functions
|
||||||
|
|
||||||
|
const {
|
||||||
|
set: updateCache,
|
||||||
|
get: getCached,
|
||||||
|
clear: cacheClear,
|
||||||
|
} = createLimitedCache<string | null>({ log: log.extend('cache') });
|
||||||
|
|
||||||
|
function createCacheKey(
|
||||||
|
input: HTMLElement | string,
|
||||||
|
appState: EmojiAppState,
|
||||||
|
extraEmojis: ExtraCustomEmojiMap,
|
||||||
) {
|
) {
|
||||||
|
return JSON.stringify([
|
||||||
|
input instanceof HTMLElement ? input.outerHTML : input,
|
||||||
|
appState,
|
||||||
|
extraEmojis,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
type EmojifiedTextArray = (string | HTMLImageElement)[];
|
||||||
|
|
||||||
|
async function textToElementArray(
|
||||||
|
text: string,
|
||||||
|
appState: EmojiAppState,
|
||||||
|
extraEmojis: ExtraCustomEmojiMap = {},
|
||||||
|
): Promise<EmojifiedTextArray | null> {
|
||||||
// Exit if no text to convert.
|
// Exit if no text to convert.
|
||||||
if (!text.trim()) {
|
if (!text.trim()) {
|
||||||
return null;
|
return null;
|
||||||
|
@ -102,10 +164,9 @@ export async function emojifyText(
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get all emoji from the state map, loading any missing ones.
|
// Get all emoji from the state map, loading any missing ones.
|
||||||
await ensureLocalesAreLoaded(appState.locales);
|
await loadMissingEmojiIntoCache(tokens, appState, extraEmojis);
|
||||||
await loadMissingEmojiIntoCache(tokens, appState.locales);
|
|
||||||
|
|
||||||
const renderedFragments: (string | HTMLImageElement)[] = [];
|
const renderedFragments: EmojifiedTextArray = [];
|
||||||
for (const token of tokens) {
|
for (const token of tokens) {
|
||||||
if (typeof token !== 'string' && shouldRenderImage(token, appState.mode)) {
|
if (typeof token !== 'string' && shouldRenderImage(token, appState.mode)) {
|
||||||
let state: EmojiState | undefined;
|
let state: EmojiState | undefined;
|
||||||
|
@ -125,7 +186,7 @@ export async function emojifyText(
|
||||||
|
|
||||||
// If the state is valid, create an image element. Otherwise, just append as text.
|
// If the state is valid, create an image element. Otherwise, just append as text.
|
||||||
if (state && typeof state !== 'string') {
|
if (state && typeof state !== 'string') {
|
||||||
const image = stateToImage(state);
|
const image = stateToImage(state, appState);
|
||||||
renderedFragments.push(image);
|
renderedFragments.push(image);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
@ -137,21 +198,6 @@ export async function emojifyText(
|
||||||
return renderedFragments;
|
return renderedFragments;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Private functions
|
|
||||||
|
|
||||||
async function ensureLocalesAreLoaded(locales: Locale[]) {
|
|
||||||
const missingLocales = await findMissingLocales(locales);
|
|
||||||
for (const locale of missingLocales) {
|
|
||||||
await loadEmojiLocale(locale);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const CUSTOM_EMOJI_REGEX = /:([a-z0-9_]+):/i;
|
|
||||||
const TOKENIZE_REGEX = new RegExp(
|
|
||||||
`(${EMOJI_REGEX.source}|${CUSTOM_EMOJI_REGEX.source})`,
|
|
||||||
'g',
|
|
||||||
);
|
|
||||||
|
|
||||||
type TokenizedText = (string | EmojiToken)[];
|
type TokenizedText = (string | EmojiToken)[];
|
||||||
|
|
||||||
export function tokenizeText(text: string): TokenizedText {
|
export function tokenizeText(text: string): TokenizedText {
|
||||||
|
@ -161,7 +207,7 @@ export function tokenizeText(text: string): TokenizedText {
|
||||||
|
|
||||||
const tokens = [];
|
const tokens = [];
|
||||||
let lastIndex = 0;
|
let lastIndex = 0;
|
||||||
for (const match of text.matchAll(TOKENIZE_REGEX)) {
|
for (const match of text.matchAll(ANY_EMOJI_REGEX)) {
|
||||||
if (match.index > lastIndex) {
|
if (match.index > lastIndex) {
|
||||||
tokens.push(text.slice(lastIndex, match.index));
|
tokens.push(text.slice(lastIndex, match.index));
|
||||||
}
|
}
|
||||||
|
@ -189,8 +235,18 @@ export function tokenizeText(text: string): TokenizedText {
|
||||||
return tokens;
|
return tokens;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const localeCacheMap = new Map<LocaleOrCustom, EmojiStateMap>([
|
||||||
|
[
|
||||||
|
EMOJI_TYPE_CUSTOM,
|
||||||
|
createLimitedCache<EmojiState>({ log: log.extend('custom') }),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
function cacheForLocale(locale: LocaleOrCustom): EmojiStateMap {
|
function cacheForLocale(locale: LocaleOrCustom): EmojiStateMap {
|
||||||
return localeCacheMap.get(locale) ?? (new Map() as EmojiStateMap);
|
return (
|
||||||
|
localeCacheMap.get(locale) ??
|
||||||
|
createLimitedCache<EmojiState>({ log: log.extend(locale) })
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function emojiForLocale(
|
function emojiForLocale(
|
||||||
|
@ -203,7 +259,8 @@ function emojiForLocale(
|
||||||
|
|
||||||
async function loadMissingEmojiIntoCache(
|
async function loadMissingEmojiIntoCache(
|
||||||
tokens: TokenizedText,
|
tokens: TokenizedText,
|
||||||
locales: Locale[],
|
{ mode, currentLocale }: EmojiAppState,
|
||||||
|
extraEmojis: ExtraCustomEmojiMap,
|
||||||
) {
|
) {
|
||||||
const missingUnicodeEmoji = new Set<string>();
|
const missingUnicodeEmoji = new Set<string>();
|
||||||
const missingCustomEmoji = new Set<string>();
|
const missingCustomEmoji = new Set<string>();
|
||||||
|
@ -217,31 +274,31 @@ async function loadMissingEmojiIntoCache(
|
||||||
// If this is a custom emoji, check it separately.
|
// If this is a custom emoji, check it separately.
|
||||||
if (token.type === EMOJI_TYPE_CUSTOM) {
|
if (token.type === EMOJI_TYPE_CUSTOM) {
|
||||||
const code = token.code;
|
const code = token.code;
|
||||||
|
if (code in extraEmojis) {
|
||||||
|
continue; // We don't care about extra emoji.
|
||||||
|
}
|
||||||
const emojiState = emojiForLocale(code, EMOJI_TYPE_CUSTOM);
|
const emojiState = emojiForLocale(code, EMOJI_TYPE_CUSTOM);
|
||||||
if (!emojiState) {
|
if (!emojiState) {
|
||||||
missingCustomEmoji.add(code);
|
missingCustomEmoji.add(code);
|
||||||
}
|
}
|
||||||
// Otherwise this is a unicode emoji, so check it against all locales.
|
// Otherwise this is a unicode emoji, so check it against all locales.
|
||||||
} else {
|
} else if (shouldRenderImage(token, mode)) {
|
||||||
const code = emojiToUnicodeHex(token.code);
|
const code = emojiToUnicodeHex(token.code);
|
||||||
if (missingUnicodeEmoji.has(code)) {
|
if (missingUnicodeEmoji.has(code)) {
|
||||||
continue; // Already marked as missing.
|
continue; // Already marked as missing.
|
||||||
}
|
}
|
||||||
for (const locale of locales) {
|
const emojiState = emojiForLocale(code, currentLocale);
|
||||||
const emojiState = emojiForLocale(code, locale);
|
|
||||||
if (!emojiState) {
|
if (!emojiState) {
|
||||||
// If it's missing in one locale, we consider it missing for all.
|
// If it's missing in one locale, we consider it missing for all.
|
||||||
missingUnicodeEmoji.add(code);
|
missingUnicodeEmoji.add(code);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (missingUnicodeEmoji.size > 0) {
|
if (missingUnicodeEmoji.size > 0) {
|
||||||
const missingEmojis = Array.from(missingUnicodeEmoji).toSorted();
|
const missingEmojis = Array.from(missingUnicodeEmoji).toSorted();
|
||||||
for (const locale of locales) {
|
const emojis = await searchEmojisByHexcodes(missingEmojis, currentLocale);
|
||||||
const emojis = await searchEmojisByHexcodes(missingEmojis, locale);
|
const cache = cacheForLocale(currentLocale);
|
||||||
const cache = cacheForLocale(locale);
|
|
||||||
for (const emoji of emojis) {
|
for (const emoji of emojis) {
|
||||||
cache.set(emoji.hexcode, { type: EMOJI_TYPE_UNICODE, data: emoji });
|
cache.set(emoji.hexcode, { type: EMOJI_TYPE_UNICODE, data: emoji });
|
||||||
}
|
}
|
||||||
|
@ -251,8 +308,7 @@ async function loadMissingEmojiIntoCache(
|
||||||
for (const code of notFoundEmojis) {
|
for (const code of notFoundEmojis) {
|
||||||
cache.set(code, EMOJI_STATE_MISSING); // Mark as missing if not found, as it's probably not a valid emoji.
|
cache.set(code, EMOJI_STATE_MISSING); // Mark as missing if not found, as it's probably not a valid emoji.
|
||||||
}
|
}
|
||||||
localeCacheMap.set(locale, cache);
|
localeCacheMap.set(currentLocale, cache);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (missingCustomEmoji.size > 0) {
|
if (missingCustomEmoji.size > 0) {
|
||||||
|
@ -288,22 +344,24 @@ function shouldRenderImage(token: EmojiToken, mode: EmojiMode): boolean {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function stateToImage(state: EmojiLoadedState) {
|
function stateToImage(state: EmojiLoadedState, appState: EmojiAppState) {
|
||||||
const image = document.createElement('img');
|
const image = document.createElement('img');
|
||||||
image.draggable = false;
|
image.draggable = false;
|
||||||
image.classList.add('emojione');
|
image.classList.add('emojione');
|
||||||
|
|
||||||
if (state.type === EMOJI_TYPE_UNICODE) {
|
if (state.type === EMOJI_TYPE_UNICODE) {
|
||||||
const emojiInfo = twemojiHasBorder(unicodeToTwemojiHex(state.data.hexcode));
|
const emojiInfo = twemojiHasBorder(unicodeToTwemojiHex(state.data.hexcode));
|
||||||
if (emojiInfo.hasLightBorder) {
|
let fileName = emojiInfo.hexCode;
|
||||||
image.dataset.lightCode = `${emojiInfo.hexCode}_BORDER`;
|
if (
|
||||||
} else if (emojiInfo.hasDarkBorder) {
|
(appState.darkTheme && emojiInfo.hasDarkBorder) ||
|
||||||
image.dataset.darkCode = `${emojiInfo.hexCode}_BORDER`;
|
(!appState.darkTheme && emojiInfo.hasLightBorder)
|
||||||
|
) {
|
||||||
|
fileName = `${emojiInfo.hexCode}_border`;
|
||||||
}
|
}
|
||||||
|
|
||||||
image.alt = state.data.unicode;
|
image.alt = state.data.unicode;
|
||||||
image.title = state.data.label;
|
image.title = state.data.label;
|
||||||
image.src = `${assetHost}/emoji/${emojiInfo.hexCode}.svg`;
|
image.src = `${assetHost}/emoji/${fileName}.svg`;
|
||||||
} else {
|
} else {
|
||||||
// Custom emoji
|
// Custom emoji
|
||||||
const shortCode = `:${state.data.shortcode}:`;
|
const shortCode = `:${state.data.shortcode}:`;
|
||||||
|
@ -318,8 +376,16 @@ function stateToImage(state: EmojiLoadedState) {
|
||||||
return image;
|
return image;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderedToHTMLFragment(renderedArray: (string | HTMLImageElement)[]) {
|
function renderedToHTML(renderedArray: EmojifiedTextArray): DocumentFragment;
|
||||||
const fragment = document.createDocumentFragment();
|
function renderedToHTML<ParentType extends ParentNode>(
|
||||||
|
renderedArray: EmojifiedTextArray,
|
||||||
|
parent: ParentType,
|
||||||
|
): ParentType;
|
||||||
|
function renderedToHTML(
|
||||||
|
renderedArray: EmojifiedTextArray,
|
||||||
|
parent: ParentNode | null = null,
|
||||||
|
) {
|
||||||
|
const fragment = parent ?? document.createDocumentFragment();
|
||||||
for (const fragmentItem of renderedArray) {
|
for (const fragmentItem of renderedArray) {
|
||||||
if (typeof fragmentItem === 'string') {
|
if (typeof fragmentItem === 'string') {
|
||||||
fragment.appendChild(document.createTextNode(fragmentItem));
|
fragment.appendChild(document.createTextNode(fragmentItem));
|
||||||
|
@ -329,3 +395,9 @@ function renderedToHTMLFragment(renderedArray: (string | HTMLImageElement)[]) {
|
||||||
}
|
}
|
||||||
return fragment;
|
return fragment;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Testing helpers
|
||||||
|
export const testCacheClear = () => {
|
||||||
|
cacheClear();
|
||||||
|
localeCacheMap.clear();
|
||||||
|
};
|
||||||
|
|
|
@ -1,6 +1,10 @@
|
||||||
|
import type { List as ImmutableList } from 'immutable';
|
||||||
|
|
||||||
import type { FlatCompactEmoji, Locale } from 'emojibase';
|
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 { LimitedCache } from '@/mastodon/utils/cache';
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
EMOJI_MODE_NATIVE,
|
EMOJI_MODE_NATIVE,
|
||||||
|
@ -22,6 +26,7 @@ export interface EmojiAppState {
|
||||||
locales: Locale[];
|
locales: Locale[];
|
||||||
currentLocale: Locale;
|
currentLocale: Locale;
|
||||||
mode: EmojiMode;
|
mode: EmojiMode;
|
||||||
|
darkTheme: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UnicodeEmojiToken {
|
export interface UnicodeEmojiToken {
|
||||||
|
@ -45,7 +50,7 @@ export interface EmojiStateUnicode {
|
||||||
}
|
}
|
||||||
export interface EmojiStateCustom {
|
export interface EmojiStateCustom {
|
||||||
type: typeof EMOJI_TYPE_CUSTOM;
|
type: typeof EMOJI_TYPE_CUSTOM;
|
||||||
data: CustomEmojiData;
|
data: CustomEmojiRenderFields;
|
||||||
}
|
}
|
||||||
export type EmojiState =
|
export type EmojiState =
|
||||||
| EmojiStateMissing
|
| EmojiStateMissing
|
||||||
|
@ -53,9 +58,16 @@ export type EmojiState =
|
||||||
| EmojiStateCustom;
|
| EmojiStateCustom;
|
||||||
export type EmojiLoadedState = EmojiStateUnicode | EmojiStateCustom;
|
export type EmojiLoadedState = EmojiStateUnicode | EmojiStateCustom;
|
||||||
|
|
||||||
export type EmojiStateMap = Map<string, EmojiState>;
|
export type EmojiStateMap = LimitedCache<string, EmojiState>;
|
||||||
|
|
||||||
export type ExtraCustomEmojiMap = Record<string, ApiCustomEmojiJSON>;
|
export type CustomEmojiMapArg =
|
||||||
|
| ExtraCustomEmojiMap
|
||||||
|
| ImmutableList<CustomEmoji>;
|
||||||
|
export type CustomEmojiRenderFields = Pick<
|
||||||
|
CustomEmojiData,
|
||||||
|
'shortcode' | 'static_url' | 'url'
|
||||||
|
>;
|
||||||
|
export type ExtraCustomEmojiMap = Record<string, CustomEmojiRenderFields>;
|
||||||
|
|
||||||
export interface TwemojiBorderInfo {
|
export interface TwemojiBorderInfo {
|
||||||
hexCode: string;
|
hexCode: string;
|
||||||
|
|
|
@ -1,8 +1,14 @@
|
||||||
import { stringHasUnicodeEmoji, stringHasUnicodeFlags } from './utils';
|
import {
|
||||||
|
stringHasAnyEmoji,
|
||||||
|
stringHasCustomEmoji,
|
||||||
|
stringHasUnicodeEmoji,
|
||||||
|
stringHasUnicodeFlags,
|
||||||
|
} from './utils';
|
||||||
|
|
||||||
describe('stringHasEmoji', () => {
|
describe('stringHasUnicodeEmoji', () => {
|
||||||
test.concurrent.for([
|
test.concurrent.for([
|
||||||
['only text', false],
|
['only text', false],
|
||||||
|
['text with non-emoji symbols ™©', false],
|
||||||
['text with emoji 😀', true],
|
['text with emoji 😀', true],
|
||||||
['multiple emojis 😀😃😄', true],
|
['multiple emojis 😀😃😄', true],
|
||||||
['emoji with skin tone 👍🏽', true],
|
['emoji with skin tone 👍🏽', true],
|
||||||
|
@ -19,14 +25,14 @@ describe('stringHasEmoji', () => {
|
||||||
['emoji with enclosing keycap #️⃣', true],
|
['emoji with enclosing keycap #️⃣', true],
|
||||||
['emoji with no visible glyph \u200D', false],
|
['emoji with no visible glyph \u200D', false],
|
||||||
] as const)(
|
] as const)(
|
||||||
'stringHasEmoji has emojis in "%s": %o',
|
'stringHasUnicodeEmoji has emojis in "%s": %o',
|
||||||
([text, expected], { expect }) => {
|
([text, expected], { expect }) => {
|
||||||
expect(stringHasUnicodeEmoji(text)).toBe(expected);
|
expect(stringHasUnicodeEmoji(text)).toBe(expected);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('stringHasFlags', () => {
|
describe('stringHasUnicodeFlags', () => {
|
||||||
test.concurrent.for([
|
test.concurrent.for([
|
||||||
['EU 🇪🇺', true],
|
['EU 🇪🇺', true],
|
||||||
['Germany 🇩🇪', true],
|
['Germany 🇩🇪', true],
|
||||||
|
@ -45,3 +51,27 @@ describe('stringHasFlags', () => {
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('stringHasCustomEmoji', () => {
|
||||||
|
test('string with custom emoji returns true', () => {
|
||||||
|
expect(stringHasCustomEmoji(':custom: :test:')).toBeTruthy();
|
||||||
|
});
|
||||||
|
test('string without custom emoji returns false', () => {
|
||||||
|
expect(stringHasCustomEmoji('🏳️🌈 :🏳️🌈: text ™')).toBeFalsy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('stringHasAnyEmoji', () => {
|
||||||
|
test('string without any emoji or characters', () => {
|
||||||
|
expect(stringHasAnyEmoji('normal text. 12356?!')).toBeFalsy();
|
||||||
|
});
|
||||||
|
test('string with non-emoji characters', () => {
|
||||||
|
expect(stringHasAnyEmoji('™©')).toBeFalsy();
|
||||||
|
});
|
||||||
|
test('has unicode emoji', () => {
|
||||||
|
expect(stringHasAnyEmoji('🏳️🌈🔥🇸🇹 👩🔬')).toBeTruthy();
|
||||||
|
});
|
||||||
|
test('has custom emoji', () => {
|
||||||
|
expect(stringHasAnyEmoji(':test: :custom:')).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
@ -1,13 +1,27 @@
|
||||||
import EMOJI_REGEX from 'emojibase-regex/emoji-loose';
|
import debug from 'debug';
|
||||||
|
|
||||||
export function stringHasUnicodeEmoji(text: string): boolean {
|
import {
|
||||||
return EMOJI_REGEX.test(text);
|
CUSTOM_EMOJI_REGEX,
|
||||||
|
UNICODE_EMOJI_REGEX,
|
||||||
|
UNICODE_FLAG_EMOJI_REGEX,
|
||||||
|
} from './constants';
|
||||||
|
|
||||||
|
export function emojiLogger(segment: string) {
|
||||||
|
return debug(`emojis:${segment}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// From https://github.com/talkjs/country-flag-emoji-polyfill/blob/master/src/index.ts#L49-L50
|
export function stringHasUnicodeEmoji(input: string): boolean {
|
||||||
const EMOJIS_FLAGS_REGEX =
|
return UNICODE_EMOJI_REGEX.test(input);
|
||||||
/[\u{1F1E6}-\u{1F1FF}|\u{E0062}-\u{E0063}|\u{E0065}|\u{E0067}|\u{E006C}|\u{E006E}|\u{E0073}-\u{E0074}|\u{E0077}|\u{E007F}]+/u;
|
}
|
||||||
|
|
||||||
export function stringHasUnicodeFlags(text: string): boolean {
|
export function stringHasUnicodeFlags(input: string): boolean {
|
||||||
return EMOJIS_FLAGS_REGEX.test(text);
|
return UNICODE_FLAG_EMOJI_REGEX.test(input);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stringHasCustomEmoji(input: string) {
|
||||||
|
return CUSTOM_EMOJI_REGEX.test(input);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stringHasAnyEmoji(input: string) {
|
||||||
|
return stringHasUnicodeEmoji(input) || stringHasCustomEmoji(input);
|
||||||
}
|
}
|
||||||
|
|
|
@ -5,9 +5,14 @@ self.postMessage('ready'); // After the worker is ready, notify the main thread
|
||||||
|
|
||||||
function handleMessage(event: MessageEvent<string>) {
|
function handleMessage(event: MessageEvent<string>) {
|
||||||
const { data: locale } = event;
|
const { data: locale } = event;
|
||||||
if (locale !== 'custom') {
|
void loadData(locale);
|
||||||
void importEmojiData(locale);
|
}
|
||||||
} else {
|
|
||||||
void importCustomEmojiData();
|
async function loadData(locale: string) {
|
||||||
}
|
if (locale !== 'custom') {
|
||||||
|
await importEmojiData(locale);
|
||||||
|
} else {
|
||||||
|
await importCustomEmojiData();
|
||||||
|
}
|
||||||
|
self.postMessage(`loaded ${locale}`);
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,10 +2,10 @@ import { createRoot } from 'react-dom/client';
|
||||||
|
|
||||||
import { Globals } from '@react-spring/web';
|
import { Globals } from '@react-spring/web';
|
||||||
|
|
||||||
|
import * as perf from '@/mastodon/utils/performance';
|
||||||
import { setupBrowserNotifications } from 'mastodon/actions/notifications';
|
import { setupBrowserNotifications } from 'mastodon/actions/notifications';
|
||||||
import Mastodon from 'mastodon/containers/mastodon';
|
import Mastodon from 'mastodon/containers/mastodon';
|
||||||
import { me, reduceMotion } from 'mastodon/initial_state';
|
import { me, reduceMotion } from 'mastodon/initial_state';
|
||||||
import * as perf from 'mastodon/performance';
|
|
||||||
import ready from 'mastodon/ready';
|
import ready from 'mastodon/ready';
|
||||||
import { store } from 'mastodon/store';
|
import { store } from 'mastodon/store';
|
||||||
|
|
||||||
|
@ -35,7 +35,7 @@ function main() {
|
||||||
|
|
||||||
if (isModernEmojiEnabled()) {
|
if (isModernEmojiEnabled()) {
|
||||||
const { initializeEmoji } = await import('@/mastodon/features/emoji');
|
const { initializeEmoji } = await import('@/mastodon/features/emoji');
|
||||||
await initializeEmoji();
|
initializeEmoji();
|
||||||
}
|
}
|
||||||
|
|
||||||
const root = createRoot(mountNode);
|
const root = createRoot(mountNode);
|
||||||
|
|
78
app/javascript/mastodon/utils/__tests__/cache.test.ts
Normal file
78
app/javascript/mastodon/utils/__tests__/cache.test.ts
Normal file
|
@ -0,0 +1,78 @@
|
||||||
|
import { createLimitedCache } from '../cache';
|
||||||
|
|
||||||
|
describe('createCache', () => {
|
||||||
|
test('returns expected methods', () => {
|
||||||
|
const actual = createLimitedCache();
|
||||||
|
expect(actual).toBeTypeOf('object');
|
||||||
|
expect(actual).toHaveProperty('get');
|
||||||
|
expect(actual).toHaveProperty('has');
|
||||||
|
expect(actual).toHaveProperty('delete');
|
||||||
|
expect(actual).toHaveProperty('set');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('caches values provided to it', () => {
|
||||||
|
const cache = createLimitedCache();
|
||||||
|
cache.set('test', 'result');
|
||||||
|
expect(cache.get('test')).toBe('result');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('has returns expected values', () => {
|
||||||
|
const cache = createLimitedCache();
|
||||||
|
cache.set('test', 'result');
|
||||||
|
expect(cache.has('test')).toBeTruthy();
|
||||||
|
expect(cache.has('not found')).toBeFalsy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('updates a value if keys are the same', () => {
|
||||||
|
const cache = createLimitedCache();
|
||||||
|
cache.set('test1', 1);
|
||||||
|
cache.set('test1', 2);
|
||||||
|
expect(cache.get('test1')).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('delete removes an item', () => {
|
||||||
|
const cache = createLimitedCache();
|
||||||
|
cache.set('test', 'result');
|
||||||
|
expect(cache.has('test')).toBeTruthy();
|
||||||
|
cache.delete('test');
|
||||||
|
expect(cache.has('test')).toBeFalsy();
|
||||||
|
expect(cache.get('test')).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('removes oldest item cached if it exceeds a set size', () => {
|
||||||
|
const cache = createLimitedCache({ maxSize: 1 });
|
||||||
|
cache.set('test1', 1);
|
||||||
|
cache.set('test2', 2);
|
||||||
|
expect(cache.get('test1')).toBeUndefined();
|
||||||
|
expect(cache.get('test2')).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('retrieving a value bumps up last access', () => {
|
||||||
|
const cache = createLimitedCache({ maxSize: 2 });
|
||||||
|
cache.set('test1', 1);
|
||||||
|
cache.set('test2', 2);
|
||||||
|
expect(cache.get('test1')).toBe(1);
|
||||||
|
cache.set('test3', 3);
|
||||||
|
expect(cache.get('test1')).toBe(1);
|
||||||
|
expect(cache.get('test2')).toBeUndefined();
|
||||||
|
expect(cache.get('test3')).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('logs when cache is added to and removed', () => {
|
||||||
|
const log = vi.fn();
|
||||||
|
const cache = createLimitedCache({ maxSize: 1, log });
|
||||||
|
cache.set('test1', 1);
|
||||||
|
expect(log).toHaveBeenLastCalledWith(
|
||||||
|
'Added %s to cache, now size %d',
|
||||||
|
'test1',
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
cache.set('test2', 1);
|
||||||
|
expect(log).toHaveBeenLastCalledWith(
|
||||||
|
'Added %s and deleted %s from cache, now size %d',
|
||||||
|
'test2',
|
||||||
|
'test1',
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
60
app/javascript/mastodon/utils/cache.ts
Normal file
60
app/javascript/mastodon/utils/cache.ts
Normal file
|
@ -0,0 +1,60 @@
|
||||||
|
export interface LimitedCache<CacheKey, CacheValue> {
|
||||||
|
has: (key: CacheKey) => boolean;
|
||||||
|
get: (key: CacheKey) => CacheValue | undefined;
|
||||||
|
delete: (key: CacheKey) => void;
|
||||||
|
set: (key: CacheKey, value: CacheValue) => void;
|
||||||
|
clear: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LimitedCacheArguments {
|
||||||
|
maxSize?: number;
|
||||||
|
log?: (...args: unknown[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createLimitedCache<CacheValue, CacheKey = string>({
|
||||||
|
maxSize = 100,
|
||||||
|
log = () => null,
|
||||||
|
}: LimitedCacheArguments = {}): LimitedCache<CacheKey, CacheValue> {
|
||||||
|
const cacheMap = new Map<CacheKey, CacheValue>();
|
||||||
|
const cacheKeys = new Set<CacheKey>();
|
||||||
|
|
||||||
|
function touchKey(key: CacheKey) {
|
||||||
|
if (cacheKeys.has(key)) {
|
||||||
|
cacheKeys.delete(key);
|
||||||
|
}
|
||||||
|
cacheKeys.add(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
has: (key) => cacheMap.has(key),
|
||||||
|
get: (key) => {
|
||||||
|
if (cacheMap.has(key)) {
|
||||||
|
touchKey(key);
|
||||||
|
}
|
||||||
|
return cacheMap.get(key);
|
||||||
|
},
|
||||||
|
delete: (key) => cacheMap.delete(key) && cacheKeys.delete(key),
|
||||||
|
set: (key, value) => {
|
||||||
|
cacheMap.set(key, value);
|
||||||
|
touchKey(key);
|
||||||
|
|
||||||
|
const lastKey = cacheKeys.values().toArray().shift();
|
||||||
|
if (cacheMap.size > maxSize && lastKey) {
|
||||||
|
cacheMap.delete(lastKey);
|
||||||
|
cacheKeys.delete(lastKey);
|
||||||
|
log(
|
||||||
|
'Added %s and deleted %s from cache, now size %d',
|
||||||
|
key,
|
||||||
|
lastKey,
|
||||||
|
cacheMap.size,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
log('Added %s to cache, now size %d', key, cacheMap.size);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
clear: () => {
|
||||||
|
cacheMap.clear();
|
||||||
|
cacheKeys.clear();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
|
@ -4,15 +4,15 @@
|
||||||
|
|
||||||
import * as marky from 'marky';
|
import * as marky from 'marky';
|
||||||
|
|
||||||
import { isDevelopment } from './utils/environment';
|
import { isDevelopment } from './environment';
|
||||||
|
|
||||||
export function start(name) {
|
export function start(name: string) {
|
||||||
if (isDevelopment()) {
|
if (isDevelopment()) {
|
||||||
marky.mark(name);
|
marky.mark(name);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function stop(name) {
|
export function stop(name: string) {
|
||||||
if (isDevelopment()) {
|
if (isDevelopment()) {
|
||||||
marky.stop(name);
|
marky.stop(name);
|
||||||
}
|
}
|
|
@ -1,4 +1,8 @@
|
||||||
import type { ApiRelationshipJSON } from '@/mastodon/api_types/relationships';
|
import type { ApiRelationshipJSON } from '@/mastodon/api_types/relationships';
|
||||||
|
import type {
|
||||||
|
CustomEmojiData,
|
||||||
|
UnicodeEmojiData,
|
||||||
|
} from '@/mastodon/features/emoji/types';
|
||||||
import { createAccountFromServerJSON } from '@/mastodon/models/account';
|
import { createAccountFromServerJSON } from '@/mastodon/models/account';
|
||||||
import type { ApiAccountJSON } from 'mastodon/api_types/accounts';
|
import type { ApiAccountJSON } from 'mastodon/api_types/accounts';
|
||||||
|
|
||||||
|
@ -68,3 +72,26 @@ export const relationshipsFactory: FactoryFunction<ApiRelationshipJSON> = ({
|
||||||
showing_reblogs: true,
|
showing_reblogs: true,
|
||||||
...data,
|
...data,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export function unicodeEmojiFactory(
|
||||||
|
data: Partial<UnicodeEmojiData> = {},
|
||||||
|
): UnicodeEmojiData {
|
||||||
|
return {
|
||||||
|
hexcode: 'test',
|
||||||
|
label: 'Test',
|
||||||
|
unicode: '🧪',
|
||||||
|
...data,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function customEmojiFactory(
|
||||||
|
data: Partial<CustomEmojiData> = {},
|
||||||
|
): CustomEmojiData {
|
||||||
|
return {
|
||||||
|
shortcode: 'custom',
|
||||||
|
static_url: 'emoji/custom/static',
|
||||||
|
url: 'emoji/custom',
|
||||||
|
visible_in_picker: true,
|
||||||
|
...data,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
|
@ -406,6 +406,12 @@ export default tseslint.config([
|
||||||
globals: globals.vitest,
|
globals: globals.vitest,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
files: ['**/*.test.*'],
|
||||||
|
rules: {
|
||||||
|
'no-global-assign': 'off',
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
files: ['**/*.stories.ts', '**/*.stories.tsx', '.storybook/*'],
|
files: ['**/*.stories.ts', '**/*.stories.tsx', '.storybook/*'],
|
||||||
rules: {
|
rules: {
|
||||||
|
|
|
@ -64,11 +64,11 @@
|
||||||
"color-blend": "^4.0.0",
|
"color-blend": "^4.0.0",
|
||||||
"core-js": "^3.30.2",
|
"core-js": "^3.30.2",
|
||||||
"cross-env": "^10.0.0",
|
"cross-env": "^10.0.0",
|
||||||
|
"debug": "^4.4.1",
|
||||||
"detect-passive-events": "^2.0.3",
|
"detect-passive-events": "^2.0.3",
|
||||||
"emoji-mart": "npm:emoji-mart-lazyload@latest",
|
"emoji-mart": "npm:emoji-mart-lazyload@latest",
|
||||||
"emojibase": "^16.0.0",
|
"emojibase": "^16.0.0",
|
||||||
"emojibase-data": "^16.0.3",
|
"emojibase-data": "^16.0.3",
|
||||||
"emojibase-regex": "^16.0.0",
|
|
||||||
"escape-html": "^1.0.3",
|
"escape-html": "^1.0.3",
|
||||||
"fast-glob": "^3.3.3",
|
"fast-glob": "^3.3.3",
|
||||||
"fuzzysort": "^3.0.0",
|
"fuzzysort": "^3.0.0",
|
||||||
|
@ -137,6 +137,7 @@
|
||||||
"@storybook/react-vite": "^9.0.4",
|
"@storybook/react-vite": "^9.0.4",
|
||||||
"@testing-library/dom": "^10.2.0",
|
"@testing-library/dom": "^10.2.0",
|
||||||
"@testing-library/react": "^16.0.0",
|
"@testing-library/react": "^16.0.0",
|
||||||
|
"@types/debug": "^4",
|
||||||
"@types/emoji-mart": "3.0.14",
|
"@types/emoji-mart": "3.0.14",
|
||||||
"@types/escape-html": "^1.0.2",
|
"@types/escape-html": "^1.0.2",
|
||||||
"@types/hoist-non-react-statics": "^3.3.1",
|
"@types/hoist-non-react-statics": "^3.3.1",
|
||||||
|
@ -174,6 +175,7 @@
|
||||||
"eslint-plugin-react": "^7.37.4",
|
"eslint-plugin-react": "^7.37.4",
|
||||||
"eslint-plugin-react-hooks": "^5.2.0",
|
"eslint-plugin-react-hooks": "^5.2.0",
|
||||||
"eslint-plugin-storybook": "^9.0.4",
|
"eslint-plugin-storybook": "^9.0.4",
|
||||||
|
"fake-indexeddb": "^6.0.1",
|
||||||
"globals": "^16.0.0",
|
"globals": "^16.0.0",
|
||||||
"husky": "^9.0.11",
|
"husky": "^9.0.11",
|
||||||
"lint-staged": "^16.0.0",
|
"lint-staged": "^16.0.0",
|
||||||
|
|
|
@ -49,6 +49,7 @@ const legacyTests: TestProjectInlineConfiguration = {
|
||||||
'tmp/**',
|
'tmp/**',
|
||||||
],
|
],
|
||||||
globals: true,
|
globals: true,
|
||||||
|
setupFiles: ['fake-indexeddb/auto'],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
34
yarn.lock
34
yarn.lock
|
@ -2632,6 +2632,7 @@ __metadata:
|
||||||
"@storybook/react-vite": "npm:^9.0.4"
|
"@storybook/react-vite": "npm:^9.0.4"
|
||||||
"@testing-library/dom": "npm:^10.2.0"
|
"@testing-library/dom": "npm:^10.2.0"
|
||||||
"@testing-library/react": "npm:^16.0.0"
|
"@testing-library/react": "npm:^16.0.0"
|
||||||
|
"@types/debug": "npm:^4"
|
||||||
"@types/emoji-mart": "npm:3.0.14"
|
"@types/emoji-mart": "npm:3.0.14"
|
||||||
"@types/escape-html": "npm:^1.0.2"
|
"@types/escape-html": "npm:^1.0.2"
|
||||||
"@types/hoist-non-react-statics": "npm:^3.3.1"
|
"@types/hoist-non-react-statics": "npm:^3.3.1"
|
||||||
|
@ -2673,11 +2674,11 @@ __metadata:
|
||||||
color-blend: "npm:^4.0.0"
|
color-blend: "npm:^4.0.0"
|
||||||
core-js: "npm:^3.30.2"
|
core-js: "npm:^3.30.2"
|
||||||
cross-env: "npm:^10.0.0"
|
cross-env: "npm:^10.0.0"
|
||||||
|
debug: "npm:^4.4.1"
|
||||||
detect-passive-events: "npm:^2.0.3"
|
detect-passive-events: "npm:^2.0.3"
|
||||||
emoji-mart: "npm:emoji-mart-lazyload@latest"
|
emoji-mart: "npm:emoji-mart-lazyload@latest"
|
||||||
emojibase: "npm:^16.0.0"
|
emojibase: "npm:^16.0.0"
|
||||||
emojibase-data: "npm:^16.0.3"
|
emojibase-data: "npm:^16.0.3"
|
||||||
emojibase-regex: "npm:^16.0.0"
|
|
||||||
escape-html: "npm:^1.0.3"
|
escape-html: "npm:^1.0.3"
|
||||||
eslint: "npm:^9.23.0"
|
eslint: "npm:^9.23.0"
|
||||||
eslint-import-resolver-typescript: "npm:^4.2.5"
|
eslint-import-resolver-typescript: "npm:^4.2.5"
|
||||||
|
@ -2689,6 +2690,7 @@ __metadata:
|
||||||
eslint-plugin-react: "npm:^7.37.4"
|
eslint-plugin-react: "npm:^7.37.4"
|
||||||
eslint-plugin-react-hooks: "npm:^5.2.0"
|
eslint-plugin-react-hooks: "npm:^5.2.0"
|
||||||
eslint-plugin-storybook: "npm:^9.0.4"
|
eslint-plugin-storybook: "npm:^9.0.4"
|
||||||
|
fake-indexeddb: "npm:^6.0.1"
|
||||||
fast-glob: "npm:^3.3.3"
|
fast-glob: "npm:^3.3.3"
|
||||||
fuzzysort: "npm:^3.0.0"
|
fuzzysort: "npm:^3.0.0"
|
||||||
globals: "npm:^16.0.0"
|
globals: "npm:^16.0.0"
|
||||||
|
@ -3931,6 +3933,15 @@ __metadata:
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
|
"@types/debug@npm:^4":
|
||||||
|
version: 4.1.12
|
||||||
|
resolution: "@types/debug@npm:4.1.12"
|
||||||
|
dependencies:
|
||||||
|
"@types/ms": "npm:*"
|
||||||
|
checksum: 10c0/5dcd465edbb5a7f226e9a5efd1f399c6172407ef5840686b73e3608ce135eeca54ae8037dcd9f16bdb2768ac74925b820a8b9ecc588a58ca09eca6acabe33e2f
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
"@types/deep-eql@npm:*":
|
"@types/deep-eql@npm:*":
|
||||||
version: 4.0.2
|
version: 4.0.2
|
||||||
resolution: "@types/deep-eql@npm:4.0.2"
|
resolution: "@types/deep-eql@npm:4.0.2"
|
||||||
|
@ -4112,6 +4123,13 @@ __metadata:
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
|
"@types/ms@npm:*":
|
||||||
|
version: 2.1.0
|
||||||
|
resolution: "@types/ms@npm:2.1.0"
|
||||||
|
checksum: 10c0/5ce692ffe1549e1b827d99ef8ff71187457e0eb44adbae38fdf7b9a74bae8d20642ee963c14516db1d35fa2652e65f47680fdf679dcbde52bbfadd021f497225
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
"@types/node@npm:*, @types/node@npm:^22.0.0":
|
"@types/node@npm:*, @types/node@npm:^22.0.0":
|
||||||
version: 22.13.14
|
version: 22.13.14
|
||||||
resolution: "@types/node@npm:22.13.14"
|
resolution: "@types/node@npm:22.13.14"
|
||||||
|
@ -6599,13 +6617,6 @@ __metadata:
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"emojibase-regex@npm:^16.0.0":
|
|
||||||
version: 16.0.0
|
|
||||||
resolution: "emojibase-regex@npm:16.0.0"
|
|
||||||
checksum: 10c0/8ee5ff798e51caa581434b1cb2f9737e50195093c4efa1739df21a50a5496f80517924787d865e8cf7d6a0b4c90dbedc04bdc506dcbcc582e14cdf0bb47af0f0
|
|
||||||
languageName: node
|
|
||||||
linkType: hard
|
|
||||||
|
|
||||||
"emojibase@npm:^16.0.0":
|
"emojibase@npm:^16.0.0":
|
||||||
version: 16.0.0
|
version: 16.0.0
|
||||||
resolution: "emojibase@npm:16.0.0"
|
resolution: "emojibase@npm:16.0.0"
|
||||||
|
@ -7370,6 +7381,13 @@ __metadata:
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
|
"fake-indexeddb@npm:^6.0.1":
|
||||||
|
version: 6.0.1
|
||||||
|
resolution: "fake-indexeddb@npm:6.0.1"
|
||||||
|
checksum: 10c0/60f4ccdfd5ecb37bb98019056c688366847840cce7146e0005c5ca54823238455403b0a8803b898a11cf80f6147b1bb553457c6af427a644a6e64566cdbe42ec
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
"fast-copy@npm:^3.0.2":
|
"fast-copy@npm:^3.0.2":
|
||||||
version: 3.0.2
|
version: 3.0.2
|
||||||
resolution: "fast-copy@npm:3.0.2"
|
resolution: "fast-copy@npm:3.0.2"
|
||||||
|
|
Loading…
Reference in New Issue
Block a user