signal-desktop/ts/components/fun/data/emojis.ts

641 lines
20 KiB
TypeScript
Raw Normal View History

// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import Fuse from 'fuse.js';
import { sortBy } from 'lodash';
import { useEffect, useMemo, useState } from 'react';
import { z } from 'zod';
import * as log from '../../../logging/log';
import type { LocaleEmojiListType } from '../../../types/emoji';
import type { LocalizerType } from '../../../types/I18N';
import { strictAssert } from '../../../util/assert';
import { drop } from '../../../util/drop';
import { parseUnknown } from '../../../util/schemas';
// Import emoji-datasource dynamically to avoid costly typechecking.
// eslint-disable-next-line import/no-dynamic-require, @typescript-eslint/no-var-requires
const RAW_UNTYPED_DATA: unknown = require('emoji-datasource' as string);
/**
* Types
*/
export enum EmojiUnicodeCategory {
SmileysAndEmotion = 'EmojiUnicodeCategory.SmileysAndEmotion',
PeopleAndBody = 'EmojiUnicodeCategory.PeopleAndBody',
Component = 'EmojiUnicodeCategory.Component',
AnimalsAndNature = 'EmojiUnicodeCategory.AnimalsAndNature',
FoodAndDrink = 'EmojiUnicodeCategory.FoodAndDrink',
TravelAndPlaces = 'EmojiUnicodeCategory.TravelAndPlaces',
Activities = 'EmojiUnicodeCategory.Activities',
Objects = 'EmojiUnicodeCategory.Objects',
Symbols = 'EmojiUnicodeCategory.Symbols',
Flags = 'EmojiUnicodeCategory.Flags',
}
export enum EmojiPickerCategory {
SmileysAndPeople = 'EmojiPickerCategory.SmileysAndPeople',
AnimalsAndNature = 'EmojiPickerCategory.AnimalsAndNature',
FoodAndDrink = 'EmojiPickerCategory.FoodAndDrink',
TravelAndPlaces = 'EmojiPickerCategory.TravelAndPlaces',
Activities = 'EmojiPickerCategory.Activities',
Objects = 'EmojiPickerCategory.Objects',
Symbols = 'EmojiPickerCategory.Symbols',
Flags = 'EmojiPickerCategory.Flags',
}
export enum EmojiSkinTone {
None = 'EmojiSkinTone.None',
Type1 = 'EmojiSkinTone.Type1', // 1F3FB
Type2 = 'EmojiSkinTone.Type2', // 1F3FC
Type3 = 'EmojiSkinTone.Type3', // 1F3FD
Type4 = 'EmojiSkinTone.Type4', // 1F3FE
Type5 = 'EmojiSkinTone.Type5', // 1F3FF
}
/** @deprecated We should use `EmojiSkinTone` everywhere */
export const SKIN_TONE_TO_NUMBER: Map<EmojiSkinTone, number> = new Map([
[EmojiSkinTone.None, 0],
[EmojiSkinTone.Type1, 1],
[EmojiSkinTone.Type2, 2],
[EmojiSkinTone.Type3, 3],
[EmojiSkinTone.Type4, 4],
[EmojiSkinTone.Type5, 5],
]);
/** @deprecated We should use `EmojiSkinTone` everywhere */
export const NUMBER_TO_SKIN_TONE: Map<number, EmojiSkinTone> = new Map([
[0, EmojiSkinTone.None],
[1, EmojiSkinTone.Type1],
[2, EmojiSkinTone.Type2],
[3, EmojiSkinTone.Type3],
[4, EmojiSkinTone.Type4],
[5, EmojiSkinTone.Type5],
]);
export type EmojiSkinToneVariant = Exclude<EmojiSkinTone, EmojiSkinTone.None>;
const KeyToEmojiSkinTone: Record<string, EmojiSkinToneVariant> = {
'1F3FB': EmojiSkinTone.Type1,
'1F3FC': EmojiSkinTone.Type2,
'1F3FD': EmojiSkinTone.Type3,
'1F3FE': EmojiSkinTone.Type4,
'1F3FF': EmojiSkinTone.Type5,
};
export type EmojiParentKey = string & { EmojiParentKey: never };
export type EmojiVariantKey = string & { EmojiVariantKey: never };
export type EmojiParentValue = string & { EmojiParentValue: never };
export type EmojiVariantValue = string & { EmojiVariantValue: never };
/** @deprecated Prefer EmojiKey for refs, load short names from translations */
export type EmojiEnglishShortName = string & { EmojiEnglishShortName: never };
export type EmojiVariantData = Readonly<{
key: EmojiVariantKey;
value: EmojiVariantValue;
sheetX: number;
sheetY: number;
}>;
type EmojiDefaultSkinToneVariants = Record<
EmojiSkinToneVariant,
EmojiVariantKey
>;
export type EmojiParentData = Readonly<{
key: EmojiParentKey;
value: EmojiParentValue;
unicodeCategory: EmojiUnicodeCategory;
pickerCategory: EmojiPickerCategory | null;
defaultVariant: EmojiVariantKey;
defaultSkinToneVariants: EmojiDefaultSkinToneVariants | null;
/** @deprecated Prefer EmojiKey for refs, load short names from translations */
englishShortNameDefault: EmojiEnglishShortName;
/** @deprecated Prefer EmojiKey for refs, load short names from translations */
englishShortNames: ReadonlyArray<EmojiEnglishShortName>;
emoticonDefault: string | null;
emoticons: ReadonlyArray<string>;
}>;
/**
* Schemas
*/
const RawEmojiSkinToneSchema = z.object({
unified: z.string(),
sheet_x: z.number(),
sheet_y: z.number(),
has_img_apple: z.boolean(),
});
const RawEmojiSkinToneMapSchema = z.record(z.string(), RawEmojiSkinToneSchema);
const RawEmojiSchema = z.object({
unified: z.string(),
category: z.string(),
sort_order: z.number(),
sheet_x: z.number(),
sheet_y: z.number(),
has_img_apple: z.boolean(),
short_name: z.string(),
short_names: z.array(z.string()),
text: z.nullable(z.string()),
texts: z.nullable(z.array(z.string())),
skin_variations: RawEmojiSkinToneMapSchema.optional(),
});
const RAW_UNICODE_CATEGORY_MAP: Record<string, EmojiUnicodeCategory> = {
'Smileys & Emotion': EmojiUnicodeCategory.SmileysAndEmotion,
'People & Body': EmojiUnicodeCategory.PeopleAndBody,
Component: EmojiUnicodeCategory.Component,
'Animals & Nature': EmojiUnicodeCategory.AnimalsAndNature,
'Food & Drink': EmojiUnicodeCategory.FoodAndDrink,
'Travel & Places': EmojiUnicodeCategory.TravelAndPlaces,
Activities: EmojiUnicodeCategory.Activities,
Objects: EmojiUnicodeCategory.Objects,
Symbols: EmojiUnicodeCategory.Symbols,
Flags: EmojiUnicodeCategory.Flags,
};
const RAW_PICKER_CATEGORY_MAP: Record<string, EmojiPickerCategory | null> = {
'Smileys & Emotion': EmojiPickerCategory.SmileysAndPeople, // merged
'People & Body': EmojiPickerCategory.SmileysAndPeople, // merged
Component: null, // dropped
'Animals & Nature': EmojiPickerCategory.AnimalsAndNature,
'Food & Drink': EmojiPickerCategory.FoodAndDrink,
'Travel & Places': EmojiPickerCategory.TravelAndPlaces,
Activities: EmojiPickerCategory.Activities,
Objects: EmojiPickerCategory.Objects,
Symbols: EmojiPickerCategory.Symbols,
Flags: EmojiPickerCategory.Flags,
};
/**
* Data Normalization
*/
function toEmojiUnicodeCategory(category: string): EmojiUnicodeCategory {
const result = RAW_UNICODE_CATEGORY_MAP[category];
strictAssert(result != null, `Unknown category: ${category}`);
return result;
}
function toEmojiPickerCategory(category: string): EmojiPickerCategory | null {
const result = RAW_PICKER_CATEGORY_MAP[category];
strictAssert(
typeof result !== 'undefined',
`Unknown picker category: ${category}`
);
return result;
}
function toEmojiParentKey(unified: string): EmojiParentKey {
return unified as EmojiParentKey;
}
function toEmojiVariantKey(unified: string): EmojiVariantKey {
return unified as EmojiVariantKey;
}
function encodeUnified(unified: string): string {
return unified
.split('-')
.map(char => String.fromCodePoint(Number.parseInt(char, 16)))
.join('');
}
function toEmojiParentValue(unified: string): EmojiParentValue {
return encodeUnified(unified) as EmojiParentValue;
}
function toEmojiVariantValue(unified: string): EmojiVariantValue {
return encodeUnified(unified) as EmojiVariantValue;
}
const RAW_EMOJI_DATA = parseUnknown(
z.array(RawEmojiSchema),
RAW_UNTYPED_DATA
).sort((a, b) => {
return a.sort_order - b.sort_order;
});
type EmojiSearchIndexEntry = Readonly<{
key: EmojiParentKey;
rank: number | null;
shortName: string;
shortNames: ReadonlyArray<string>;
emoticon: string | null;
emoticons: ReadonlyArray<string>;
}>;
type EmojiSearchIndex = ReadonlyArray<EmojiSearchIndexEntry>;
type EmojiIndex = Readonly<{
// raw data
parentByKey: Record<EmojiParentKey, EmojiParentData>;
parentKeysByName: Record<EmojiEnglishShortName, EmojiParentKey>;
parentKeysByValue: Record<EmojiParentValue, EmojiParentKey>;
parentKeysByVariantKeys: Record<EmojiVariantKey, EmojiParentKey>;
variantByKey: Record<EmojiVariantKey, EmojiVariantData>;
variantKeysByValue: Record<EmojiVariantValue, EmojiVariantKey>;
unicodeCategories: Record<EmojiUnicodeCategory, Array<EmojiParentKey>>;
pickerCategories: Record<EmojiPickerCategory, Array<EmojiParentKey>>;
defaultEnglishSearchIndex: Array<EmojiSearchIndexEntry>;
}>;
const EMOJI_INDEX: EmojiIndex = {
parentByKey: {},
parentKeysByValue: {},
parentKeysByName: {},
parentKeysByVariantKeys: {},
variantByKey: {},
variantKeysByValue: {},
unicodeCategories: {
[EmojiUnicodeCategory.SmileysAndEmotion]: [],
[EmojiUnicodeCategory.PeopleAndBody]: [],
[EmojiUnicodeCategory.Component]: [],
[EmojiUnicodeCategory.AnimalsAndNature]: [],
[EmojiUnicodeCategory.FoodAndDrink]: [],
[EmojiUnicodeCategory.TravelAndPlaces]: [],
[EmojiUnicodeCategory.Activities]: [],
[EmojiUnicodeCategory.Objects]: [],
[EmojiUnicodeCategory.Symbols]: [],
[EmojiUnicodeCategory.Flags]: [],
},
pickerCategories: {
[EmojiPickerCategory.SmileysAndPeople]: [],
[EmojiPickerCategory.AnimalsAndNature]: [],
[EmojiPickerCategory.FoodAndDrink]: [],
[EmojiPickerCategory.TravelAndPlaces]: [],
[EmojiPickerCategory.Activities]: [],
[EmojiPickerCategory.Objects]: [],
[EmojiPickerCategory.Symbols]: [],
[EmojiPickerCategory.Flags]: [],
},
defaultEnglishSearchIndex: [],
};
function addParent(parent: EmojiParentData, rank: number) {
EMOJI_INDEX.parentByKey[parent.key] = parent;
EMOJI_INDEX.parentKeysByValue[parent.value] = parent.key;
EMOJI_INDEX.parentKeysByName[parent.englishShortNameDefault] = parent.key;
EMOJI_INDEX.unicodeCategories[parent.unicodeCategory].push(parent.key);
if (parent.pickerCategory != null) {
EMOJI_INDEX.pickerCategories[parent.pickerCategory].push(parent.key);
}
for (const englishShortName of parent.englishShortNames) {
EMOJI_INDEX.parentKeysByName[englishShortName] = parent.key;
}
EMOJI_INDEX.defaultEnglishSearchIndex.push({
key: parent.key,
rank,
shortName: parent.englishShortNameDefault,
shortNames: parent.englishShortNames,
emoticon: parent.emoticonDefault,
emoticons: parent.emoticons,
});
}
function addVariant(parentKey: EmojiParentKey, variant: EmojiVariantData) {
EMOJI_INDEX.parentKeysByVariantKeys[variant.key] = parentKey;
EMOJI_INDEX.variantByKey[variant.key] = variant;
EMOJI_INDEX.variantKeysByValue[variant.value] = variant.key;
}
for (const rawEmoji of RAW_EMOJI_DATA) {
const parentKey = toEmojiParentKey(rawEmoji.unified);
const defaultVariant: EmojiVariantData = {
key: toEmojiVariantKey(rawEmoji.unified),
value: toEmojiVariantValue(rawEmoji.unified),
sheetX: rawEmoji.sheet_x,
sheetY: rawEmoji.sheet_y,
};
addVariant(parentKey, defaultVariant);
let defaultSkinToneVariants: EmojiDefaultSkinToneVariants | null = null;
if (rawEmoji.skin_variations != null) {
const map = new Map<string, EmojiVariantKey>();
for (const [key, value] of Object.entries(rawEmoji.skin_variations)) {
const variantKey = toEmojiVariantKey(value.unified);
map.set(key, variantKey);
const skinToneVariant: EmojiVariantData = {
key: variantKey,
value: toEmojiVariantValue(value.unified),
sheetX: value.sheet_x,
sheetY: value.sheet_y,
};
addVariant(parentKey, skinToneVariant);
}
const result: Partial<EmojiDefaultSkinToneVariants> = {};
for (const [key, skinTone] of Object.entries(KeyToEmojiSkinTone)) {
const one = map.get(key) ?? null;
const two = map.get(`${key}-${key}`) ?? null;
const variantKey = one ?? two;
if (variantKey == null) {
const keys = Object.keys(rawEmoji.skin_variations);
throw new Error(`Missing variant key ${parentKey} -> ${key} (${keys})`);
}
result[skinTone] = variantKey;
}
defaultSkinToneVariants = result as EmojiDefaultSkinToneVariants;
}
const parent: EmojiParentData = {
key: toEmojiParentKey(rawEmoji.unified),
value: toEmojiParentValue(rawEmoji.unified),
unicodeCategory: toEmojiUnicodeCategory(rawEmoji.category),
pickerCategory: toEmojiPickerCategory(rawEmoji.category),
defaultVariant: defaultVariant.key,
defaultSkinToneVariants,
englishShortNameDefault: rawEmoji.short_name as EmojiEnglishShortName,
englishShortNames: rawEmoji.short_names as Array<EmojiEnglishShortName>,
emoticonDefault: rawEmoji.text ?? null,
emoticons: rawEmoji.texts ?? [],
};
addParent(parent, rawEmoji.sort_order);
}
export function isEmojiParentKey(input: string): input is EmojiParentKey {
return Object.hasOwn(EMOJI_INDEX.parentByKey, input);
}
export function isEmojiVariantKey(input: string): input is EmojiVariantKey {
return Object.hasOwn(EMOJI_INDEX.variantByKey, input);
}
export function isEmojiParentValue(input: string): input is EmojiParentValue {
return Object.hasOwn(EMOJI_INDEX.parentKeysByValue, input);
}
export function isEmojiVariantValue(input: string): input is EmojiVariantValue {
return Object.hasOwn(EMOJI_INDEX.variantKeysByValue, input);
}
/** @deprecated Prefer EmojiKey for refs, load short names from translations */
export function isEmojiEnglishShortName(
input: string
): input is EmojiEnglishShortName {
return Object.hasOwn(EMOJI_INDEX.parentKeysByName, input);
}
export function getEmojiParentByKey(key: EmojiParentKey): EmojiParentData {
const data = EMOJI_INDEX.parentByKey[key];
strictAssert(data, `Missing emoji parent data for key "${key}"`);
return data;
}
export function getEmojiVariantByKey(key: EmojiVariantKey): EmojiVariantData {
const data = EMOJI_INDEX.variantByKey[key];
strictAssert(data, `Missing emoji variant data for key "${key}"`);
return data;
}
export function getEmojiParentKeyByValueUnsafe(input: string): EmojiParentKey {
strictAssert(
isEmojiParentValue(input),
`Missing emoji parent value for input "${input}"`
);
const key = EMOJI_INDEX.parentKeysByValue[input];
strictAssert(key, `Missing emoji parent key for input "${input}"`);
return key;
}
export function getEmojiParentKeyByValue(
value: EmojiParentValue
): EmojiParentKey {
const key = EMOJI_INDEX.parentKeysByValue[value];
strictAssert(key, `Missing emoji parent key for value "${value}"`);
return key;
}
export function getEmojiVariantKeyByValue(
value: EmojiVariantValue
): EmojiVariantKey {
const key = EMOJI_INDEX.variantKeysByValue[value];
strictAssert(key, `Missing emoji variant key for value "${value}"`);
return key;
}
export function getEmojiParentKeyByVariantKey(
key: EmojiVariantKey
): EmojiParentKey {
const parentKey = EMOJI_INDEX.parentKeysByVariantKeys[key];
strictAssert(parentKey, `Missing parent key for variant key "${key}"`);
return parentKey;
}
export function getEmojiUnicodeCategoryParentKeys(
category: EmojiUnicodeCategory
): ReadonlyArray<EmojiParentKey> {
const parents = EMOJI_INDEX.unicodeCategories[category];
strictAssert(parents, `Missing category emojis for ${category}`);
return parents;
}
export function getEmojiPickerCategoryParentKeys(
category: EmojiPickerCategory
): ReadonlyArray<EmojiParentKey> {
const parents = EMOJI_INDEX.pickerCategories[category];
strictAssert(parents, `Missing category emojis for ${category}`);
return parents;
}
/**
* Apply a skin tone (if possible) to any parent key.
*/
export function getEmojiVariantByParentKeyAndSkinTone(
key: EmojiParentKey,
skinTone: EmojiSkinTone | null
): EmojiVariantData {
const parent = getEmojiParentByKey(key);
const skinToneVariants = parent.defaultSkinToneVariants;
if (
skinTone == null ||
skinTone === EmojiSkinTone.None ||
skinToneVariants == null
) {
return getEmojiVariantByKey(parent.defaultVariant);
}
const variantKey = skinToneVariants[skinTone];
strictAssert(variantKey, `Missing skin tone variant for ${skinTone}`);
return getEmojiVariantByKey(variantKey);
}
/** @deprecated */
export function getEmojiParentKeyByEnglishShortName(
englishShortName: EmojiEnglishShortName
): EmojiParentKey {
const emojiKey = EMOJI_INDEX.parentKeysByName[englishShortName];
strictAssert(emojiKey, `Missing emoji info for ${englishShortName}`);
return emojiKey;
}
/** Exported for testing */
export function* _allEmojiVariantKeys(): Iterable<EmojiVariantKey> {
yield* Object.keys(EMOJI_INDEX.variantByKey) as Array<EmojiVariantKey>;
}
export function emojiVariantConstant(input: string): EmojiVariantData {
strictAssert(
isEmojiVariantValue(input),
`Missing emoji variant for value "${input}"`
);
const key = getEmojiVariantKeyByValue(input);
return getEmojiVariantByKey(key);
}
/**
* Search
*/
export type EmojiSearch = (
query: string,
limit?: number
) => Array<EmojiParentKey>;
function createEmojiSearchIndex(
localeEmojiList: LocaleEmojiListType
): EmojiSearchIndex {
const results: Array<EmojiSearchIndexEntry> = [];
for (const localeEmoji of localeEmojiList) {
if (!isEmojiParentValue(localeEmoji.emoji)) {
// Skipping unknown emoji, most likely apple doesn't support it
continue;
}
const parentKey = getEmojiParentKeyByValue(localeEmoji.emoji);
const emoji = getEmojiParentByKey(parentKey);
results.push({
key: parentKey,
rank: localeEmoji.rank,
shortName: localeEmoji.shortName,
shortNames: localeEmoji.tags,
emoticon: emoji.emoticonDefault,
emoticons: emoji.emoticons,
});
}
return results;
}
const FuseKeys: Array<Fuse.FuseOptionKey> = [
{ name: 'shortName', weight: 100 },
// { name: 'shortNames', weight: 1 },
{ name: 'emoticon', weight: 50 },
{ name: 'emoticons', weight: 1 },
];
const FuseFuzzyOptions: Fuse.IFuseOptions<EmojiSearchIndexEntry> = {
shouldSort: false,
threshold: 0.2,
minMatchCharLength: 1,
keys: FuseKeys,
includeScore: true,
};
const FuseExactOptions: Fuse.IFuseOptions<EmojiSearchIndexEntry> = {
shouldSort: false,
threshold: 0,
minMatchCharLength: 1,
keys: FuseKeys,
includeScore: true,
};
function createEmojiSearch(emojiSearchIndex: EmojiSearchIndex): EmojiSearch {
const fuseIndex = Fuse.createIndex(FuseKeys, emojiSearchIndex);
const fuseFuzzy = new Fuse(emojiSearchIndex, FuseFuzzyOptions, fuseIndex);
const fuseExact = new Fuse(emojiSearchIndex, FuseExactOptions, fuseIndex);
return function emojiSearch(query, limit = 200) {
// Prefer exact matches at 2 characters
const fuse = query.length < 2 ? fuseExact : fuseFuzzy;
const rawResults = fuse.search(query.substring(0, 32), {
limit: limit * 2,
});
const rankedResults = rawResults.map(result => {
const rank = result.item.rank ?? 1e9;
// Exact prefix matches in [0,1] range
if (result.item.shortName.startsWith(query)) {
return {
score: result.item.shortName.length / query.length,
item: result.item,
};
}
// Other matches in [1,], ordered by score and rank
return {
score: 1 + (result.score ?? 0) + rank / emojiSearchIndex.length,
item: result.item,
};
});
const sortedResults = sortBy(rankedResults, result => {
return result.score;
});
const truncatedResults = sortedResults.slice(0, limit);
return truncatedResults.map(result => {
return result.item.key;
});
};
}
export function useEmojiSearch(i18n: LocalizerType): EmojiSearch {
const locale = i18n.getLocale();
const [localeIndex, setLocaleIndex] = useState<EmojiSearchIndex | null>(null);
useEffect(() => {
let canceled = false;
async function run() {
try {
const list = await window.SignalContext.getLocalizedEmojiList(locale);
if (!canceled) {
const result = createEmojiSearchIndex(list);
setLocaleIndex(result);
}
} catch (error) {
log.error(`Failed to get localized emoji list for ${locale}`, error);
}
}
drop(run());
return () => {
canceled = true;
};
}, [locale]);
const searchIndex = useMemo(() => {
return localeIndex ?? EMOJI_INDEX.defaultEnglishSearchIndex;
}, [localeIndex]);
const emojiSearch = useMemo(() => {
return createEmojiSearch(searchIndex);
}, [searchIndex]);
return emojiSearch;
}