726 lines
23 KiB
TypeScript
726 lines
23 KiB
TypeScript
// Copyright 2025 Signal Messenger, LLC
|
|
// SPDX-License-Identifier: AGPL-3.0-only
|
|
import { z } from 'zod';
|
|
import emojiRegex from 'emoji-regex';
|
|
import { strictAssert } from '../../../util/assert';
|
|
import { parseUnknown } from '../../../util/schemas';
|
|
import type {
|
|
FunEmojiSearchIndex,
|
|
FunEmojiSearchIndexEntry,
|
|
} from '../useFunEmojiSearch';
|
|
import type { FunEmojiLocalizerIndex } from '../useFunEmojiLocalizer';
|
|
import { removeDiacritics } from '../../../util/removeDiacritics';
|
|
|
|
// 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
|
|
}
|
|
|
|
export function isValidEmojiSkinTone(value: unknown): value is EmojiSkinTone {
|
|
return (
|
|
typeof value === 'string' &&
|
|
EMOJI_SKIN_TONE_ORDER.includes(value as EmojiSkinTone)
|
|
);
|
|
}
|
|
|
|
export const EMOJI_SKIN_TONE_ORDER: ReadonlyArray<EmojiSkinTone> = [
|
|
EmojiSkinTone.None,
|
|
EmojiSkinTone.Type1,
|
|
EmojiSkinTone.Type2,
|
|
EmojiSkinTone.Type3,
|
|
EmojiSkinTone.Type4,
|
|
EmojiSkinTone.Type5,
|
|
];
|
|
|
|
/** @deprecated We should use `EmojiSkinTone` everywhere */
|
|
export const EMOJI_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 KEY_TO_EMOJI_SKIN_TONE = new Map<string, EmojiSkinTone>([
|
|
['1F3FB', EmojiSkinTone.Type1],
|
|
['1F3FC', EmojiSkinTone.Type2],
|
|
['1F3FD', EmojiSkinTone.Type3],
|
|
['1F3FE', EmojiSkinTone.Type4],
|
|
['1F3FF', EmojiSkinTone.Type5],
|
|
]);
|
|
|
|
/** @deprecated We should use `EmojiSkinTone` everywhere */
|
|
export const EMOJI_SKIN_TONE_TO_KEY: Map<EmojiSkinTone, string> = new Map([
|
|
[EmojiSkinTone.Type1, '1F3FB'],
|
|
[EmojiSkinTone.Type2, '1F3FC'],
|
|
[EmojiSkinTone.Type3, '1F3FD'],
|
|
[EmojiSkinTone.Type4, '1F3FE'],
|
|
[EmojiSkinTone.Type5, '1F3FF'],
|
|
]);
|
|
|
|
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;
|
|
valueNonqualified: EmojiVariantValue | null;
|
|
sheetX: number;
|
|
sheetY: number;
|
|
}>;
|
|
|
|
type EmojiDefaultSkinToneVariants = Record<EmojiSkinTone, EmojiVariantKey>;
|
|
|
|
export type EmojiParentData = Readonly<{
|
|
key: EmojiParentKey;
|
|
value: EmojiParentValue;
|
|
valueNonqualified: EmojiParentValue | null;
|
|
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(),
|
|
non_qualified: z.union([z.string(), z.null()]),
|
|
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(),
|
|
non_qualified: z.union([z.string(), z.null()]),
|
|
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 WOMAN = '\u{1F469}';
|
|
const MAN = '\u{1F468}';
|
|
const GIRL = '\u{1F467}';
|
|
const BOY = '\u{1F466}';
|
|
const ZWJ = '\u{200D}';
|
|
|
|
/**
|
|
* Deprecated unicode emoji should continue to be rendered when used,
|
|
* but should be hidden from emoji pickers.
|
|
*/
|
|
const UNICODE_DEPRECATED_EMOJI = new Set<EmojiParentValue>([
|
|
/**
|
|
* 2022 - Family Emoji Redesign: Gender Inclusive Variants
|
|
* https://www.unicode.org/L2/L2023/23029-family-emoji.pdf
|
|
* https://www.unicode.org/L2/L2022/22276-family-emoji-guidelines.pdf
|
|
*/
|
|
|
|
// 1 ADULT, 1 CHILD
|
|
`${WOMAN}${ZWJ}${GIRL}`,
|
|
`${WOMAN}${ZWJ}${BOY}`,
|
|
`${MAN}${ZWJ}${GIRL}`,
|
|
`${MAN}${ZWJ}${BOY}`,
|
|
// 1 ADULT, 2 CHILDREN
|
|
`${WOMAN}${ZWJ}${GIRL}${ZWJ}${GIRL}`,
|
|
`${WOMAN}${ZWJ}${GIRL}${ZWJ}${BOY}`,
|
|
`${WOMAN}${ZWJ}${BOY}${ZWJ}${BOY}`,
|
|
`${MAN}${ZWJ}${GIRL}${ZWJ}${GIRL}`,
|
|
`${MAN}${ZWJ}${GIRL}${ZWJ}${BOY}`,
|
|
`${MAN}${ZWJ}${BOY}${ZWJ}${BOY}`,
|
|
// 2 ADULTS, 1 CHILD
|
|
`${WOMAN}${ZWJ}${WOMAN}${ZWJ}${GIRL}`,
|
|
`${WOMAN}${ZWJ}${WOMAN}${ZWJ}${BOY}`,
|
|
`${MAN}${ZWJ}${WOMAN}${ZWJ}${GIRL}`,
|
|
`${MAN}${ZWJ}${WOMAN}${ZWJ}${BOY}`,
|
|
`${MAN}${ZWJ}${MAN}${ZWJ}${GIRL}`,
|
|
`${MAN}${ZWJ}${MAN}${ZWJ}${BOY}`,
|
|
// 2 ADULTS, 2 CHILDREN
|
|
`${WOMAN}${ZWJ}${WOMAN}${ZWJ}${GIRL}${ZWJ}${GIRL}`,
|
|
`${WOMAN}${ZWJ}${WOMAN}${ZWJ}${GIRL}${ZWJ}${BOY}`,
|
|
`${WOMAN}${ZWJ}${WOMAN}${ZWJ}${BOY}${ZWJ}${BOY}`,
|
|
|
|
`${MAN}${ZWJ}${WOMAN}${ZWJ}${GIRL}${ZWJ}${GIRL}`,
|
|
`${MAN}${ZWJ}${WOMAN}${ZWJ}${GIRL}${ZWJ}${BOY}`,
|
|
`${MAN}${ZWJ}${WOMAN}${ZWJ}${BOY}${ZWJ}${BOY}`,
|
|
|
|
`${MAN}${ZWJ}${MAN}${ZWJ}${GIRL}${ZWJ}${GIRL}`,
|
|
`${MAN}${ZWJ}${MAN}${ZWJ}${GIRL}${ZWJ}${BOY}`,
|
|
`${MAN}${ZWJ}${MAN}${ZWJ}${BOY}${ZWJ}${BOY}`,
|
|
] as Array<EmojiParentValue>);
|
|
|
|
const RAW_EMOJI_DATA = parseUnknown(
|
|
z.array(RawEmojiSchema),
|
|
RAW_UNTYPED_DATA
|
|
).sort((a, b) => {
|
|
return a.sort_order - b.sort_order;
|
|
});
|
|
|
|
/** @internal */
|
|
type EmojiIndex = Readonly<{
|
|
// raw data
|
|
parentByKey: Map<EmojiParentKey, EmojiParentData>;
|
|
parentKeysByName: Map<EmojiEnglishShortName, EmojiParentKey>;
|
|
parentKeysByValue: Map<EmojiParentValue, EmojiParentKey>;
|
|
parentKeysByValueNonQualified: Map<EmojiParentValue, EmojiParentKey>;
|
|
parentKeysByVariantKeys: Map<EmojiVariantKey, EmojiParentKey>;
|
|
|
|
variantByKey: Map<EmojiVariantKey, EmojiVariantData>;
|
|
variantKeysByValue: Map<EmojiVariantValue, EmojiVariantKey>;
|
|
variantKeysByValueNonQualified: Map<EmojiVariantValue, EmojiVariantKey>;
|
|
variantKeyToSkinTone: Map<EmojiVariantKey, EmojiSkinTone>;
|
|
|
|
unicodeCategories: Record<EmojiUnicodeCategory, Array<EmojiParentKey>>;
|
|
pickerCategories: Record<EmojiPickerCategory, Array<EmojiParentKey>>;
|
|
|
|
defaultEnglishSearchIndex: Array<FunEmojiSearchIndexEntry>;
|
|
defaultEnglishLocalizerIndex: {
|
|
parentKeyToLocaleShortName: Map<EmojiParentKey, string>;
|
|
localeShortNameToParentKey: Map<string, EmojiParentKey>;
|
|
};
|
|
}>;
|
|
|
|
/** @internal */
|
|
const EMOJI_INDEX: EmojiIndex = {
|
|
parentByKey: new Map(),
|
|
parentKeysByValue: new Map(),
|
|
parentKeysByValueNonQualified: new Map(),
|
|
parentKeysByName: new Map(),
|
|
parentKeysByVariantKeys: new Map(),
|
|
variantByKey: new Map(),
|
|
variantKeysByValue: new Map(),
|
|
variantKeysByValueNonQualified: new Map(),
|
|
variantKeyToSkinTone: new Map(),
|
|
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: [],
|
|
defaultEnglishLocalizerIndex: {
|
|
parentKeyToLocaleShortName: new Map(),
|
|
localeShortNameToParentKey: new Map(),
|
|
},
|
|
};
|
|
|
|
function addParent(parent: EmojiParentData, rank: number) {
|
|
const isDeprecated = UNICODE_DEPRECATED_EMOJI.has(parent.value);
|
|
|
|
EMOJI_INDEX.parentByKey.set(parent.key, parent);
|
|
EMOJI_INDEX.parentKeysByValue.set(parent.value, parent.key);
|
|
if (parent.valueNonqualified != null) {
|
|
EMOJI_INDEX.parentKeysByValue.set(parent.valueNonqualified, parent.key);
|
|
EMOJI_INDEX.parentKeysByValueNonQualified.set(
|
|
parent.valueNonqualified,
|
|
parent.key
|
|
);
|
|
}
|
|
EMOJI_INDEX.parentKeysByName.set(parent.englishShortNameDefault, parent.key);
|
|
EMOJI_INDEX.unicodeCategories[parent.unicodeCategory].push(parent.key);
|
|
if (parent.pickerCategory != null && !isDeprecated) {
|
|
EMOJI_INDEX.pickerCategories[parent.pickerCategory].push(parent.key);
|
|
}
|
|
|
|
for (const englishShortName of parent.englishShortNames) {
|
|
EMOJI_INDEX.parentKeysByName.set(englishShortName, parent.key);
|
|
}
|
|
|
|
if (!isDeprecated) {
|
|
EMOJI_INDEX.defaultEnglishSearchIndex.push({
|
|
key: parent.key,
|
|
rank,
|
|
shortName: parent.englishShortNameDefault,
|
|
shortNames: parent.englishShortNames,
|
|
emoticon: parent.emoticonDefault,
|
|
emoticons: parent.emoticons,
|
|
});
|
|
}
|
|
|
|
EMOJI_INDEX.defaultEnglishLocalizerIndex.parentKeyToLocaleShortName.set(
|
|
parent.key,
|
|
parent.englishShortNameDefault
|
|
);
|
|
EMOJI_INDEX.defaultEnglishLocalizerIndex.localeShortNameToParentKey.set(
|
|
parent.englishShortNameDefault,
|
|
parent.key
|
|
);
|
|
}
|
|
|
|
function addVariant(parentKey: EmojiParentKey, variant: EmojiVariantData) {
|
|
EMOJI_INDEX.parentKeysByVariantKeys.set(variant.key, parentKey);
|
|
EMOJI_INDEX.variantByKey.set(variant.key, variant);
|
|
EMOJI_INDEX.variantKeysByValue.set(variant.value, variant.key);
|
|
if (variant.valueNonqualified) {
|
|
EMOJI_INDEX.variantKeysByValue.set(variant.valueNonqualified, variant.key);
|
|
EMOJI_INDEX.variantKeysByValueNonQualified.set(
|
|
variant.valueNonqualified,
|
|
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),
|
|
valueNonqualified:
|
|
rawEmoji.non_qualified != null
|
|
? toEmojiVariantValue(rawEmoji.non_qualified)
|
|
: null,
|
|
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),
|
|
valueNonqualified:
|
|
rawEmoji.non_qualified != null
|
|
? toEmojiVariantValue(rawEmoji.non_qualified)
|
|
: null,
|
|
sheetX: value.sheet_x,
|
|
sheetY: value.sheet_y,
|
|
};
|
|
|
|
addVariant(parentKey, skinToneVariant);
|
|
}
|
|
|
|
const result: Partial<EmojiDefaultSkinToneVariants> = {};
|
|
for (const [key, skinTone] of KEY_TO_EMOJI_SKIN_TONE) {
|
|
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;
|
|
EMOJI_INDEX.variantKeyToSkinTone.set(variantKey, skinTone);
|
|
}
|
|
|
|
defaultSkinToneVariants = result as EmojiDefaultSkinToneVariants;
|
|
}
|
|
|
|
const parent: EmojiParentData = {
|
|
key: toEmojiParentKey(rawEmoji.unified),
|
|
value: toEmojiParentValue(rawEmoji.unified),
|
|
valueNonqualified:
|
|
rawEmoji.non_qualified != null
|
|
? toEmojiParentValue(rawEmoji.non_qualified)
|
|
: null,
|
|
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 EMOJI_INDEX.parentByKey.has(input as EmojiParentKey);
|
|
}
|
|
|
|
export function isEmojiParentValueDeprecated(input: EmojiParentValue): boolean {
|
|
return UNICODE_DEPRECATED_EMOJI.has(input);
|
|
}
|
|
|
|
export function isEmojiVariantKey(input: string): input is EmojiVariantKey {
|
|
return EMOJI_INDEX.variantByKey.has(input as EmojiVariantKey);
|
|
}
|
|
|
|
export function isEmojiParentValue(input: string): input is EmojiParentValue {
|
|
return EMOJI_INDEX.parentKeysByValue.has(input as EmojiParentValue);
|
|
}
|
|
|
|
export function isEmojiVariantValue(input: string): input is EmojiVariantValue {
|
|
return EMOJI_INDEX.variantKeysByValue.has(input as EmojiVariantValue);
|
|
}
|
|
|
|
export function isEmojiVariantValueNonQualified(
|
|
input: EmojiVariantValue
|
|
): boolean {
|
|
return EMOJI_INDEX.variantKeysByValueNonQualified.has(input);
|
|
}
|
|
|
|
/** @deprecated Prefer EmojiKey for refs, load short names from translations */
|
|
export function isEmojiEnglishShortName(
|
|
input: string
|
|
): input is EmojiEnglishShortName {
|
|
return EMOJI_INDEX.parentKeysByName.has(input as EmojiEnglishShortName);
|
|
}
|
|
|
|
export function getEmojiParentByKey(key: EmojiParentKey): EmojiParentData {
|
|
const data = EMOJI_INDEX.parentByKey.get(key);
|
|
strictAssert(data, `Missing emoji parent data for key "${key}"`);
|
|
return data;
|
|
}
|
|
|
|
export function getEmojiVariantByKey(key: EmojiVariantKey): EmojiVariantData {
|
|
const data = EMOJI_INDEX.variantByKey.get(key);
|
|
strictAssert(data, `Missing emoji variant data for key "${key}"`);
|
|
return data;
|
|
}
|
|
|
|
export function getEmojiParentKeyByValue(
|
|
value: EmojiParentValue
|
|
): EmojiParentKey {
|
|
const key = EMOJI_INDEX.parentKeysByValue.get(value);
|
|
strictAssert(key, `Missing emoji parent key for value "${value}"`);
|
|
return key;
|
|
}
|
|
|
|
export function getEmojiVariantKeyByValue(
|
|
value: EmojiVariantValue
|
|
): EmojiVariantKey {
|
|
const key = EMOJI_INDEX.variantKeysByValue.get(value);
|
|
strictAssert(key, `Missing emoji variant key for value "${value}"`);
|
|
return key;
|
|
}
|
|
|
|
export function getEmojiParentKeyByVariantKey(
|
|
key: EmojiVariantKey
|
|
): EmojiParentKey {
|
|
const parentKey = EMOJI_INDEX.parentKeysByVariantKeys.get(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 getEmojiVariantKeyByParentKeyAndSkinTone(
|
|
key: EmojiParentKey,
|
|
skinTone: EmojiSkinTone
|
|
): EmojiVariantKey {
|
|
const parent = getEmojiParentByKey(key);
|
|
const skinToneVariants = parent.defaultSkinToneVariants;
|
|
|
|
if (skinTone === EmojiSkinTone.None || skinToneVariants == null) {
|
|
return parent.defaultVariant;
|
|
}
|
|
|
|
const variantKey = skinToneVariants[skinTone];
|
|
strictAssert(variantKey, `Missing skin tone variant for ${skinTone}`);
|
|
|
|
return variantKey;
|
|
}
|
|
|
|
export function getEmojiVariantByParentKeyAndSkinTone(
|
|
key: EmojiParentKey,
|
|
skinTone: EmojiSkinTone
|
|
): EmojiVariantData {
|
|
return getEmojiVariantByKey(
|
|
getEmojiVariantKeyByParentKeyAndSkinTone(key, skinTone)
|
|
);
|
|
}
|
|
|
|
export function getEmojiSkinToneByVariantKey(
|
|
variantKey: EmojiVariantKey
|
|
): EmojiSkinTone {
|
|
return EMOJI_INDEX.variantKeyToSkinTone.get(variantKey) ?? EmojiSkinTone.None;
|
|
}
|
|
|
|
/** @deprecated */
|
|
export function getEmojiParentKeyByEnglishShortName(
|
|
englishShortName: EmojiEnglishShortName
|
|
): EmojiParentKey {
|
|
const emojiKey = EMOJI_INDEX.parentKeysByName.get(englishShortName);
|
|
strictAssert(emojiKey, `Missing emoji info for ${englishShortName}`);
|
|
return emojiKey;
|
|
}
|
|
|
|
export function getEmojiDefaultEnglishSearchIndex(): FunEmojiSearchIndex {
|
|
return EMOJI_INDEX.defaultEnglishSearchIndex;
|
|
}
|
|
|
|
export function getEmojiDefaultEnglishLocalizerIndex(): FunEmojiLocalizerIndex {
|
|
return EMOJI_INDEX.defaultEnglishLocalizerIndex;
|
|
}
|
|
|
|
/** Exported for testing */
|
|
export function* _allEmojiVariantKeys(): Iterable<EmojiVariantKey> {
|
|
yield* Object.keys(EMOJI_INDEX.variantByKey) as Array<EmojiVariantKey>;
|
|
}
|
|
|
|
export function emojiParentKeyConstant(input: string): EmojiParentKey {
|
|
strictAssert(
|
|
isEmojiParentValue(input),
|
|
`Missing emoji parent for value "${input}"`
|
|
);
|
|
return getEmojiParentKeyByValue(input);
|
|
}
|
|
|
|
export function emojiVariantConstant(input: string): EmojiVariantData {
|
|
strictAssert(
|
|
isEmojiVariantValue(input),
|
|
`Missing emoji variant for value "${input}"`
|
|
);
|
|
const key = getEmojiVariantKeyByValue(input);
|
|
return getEmojiVariantByKey(key);
|
|
}
|
|
|
|
/**
|
|
* Completions
|
|
*/
|
|
|
|
/** For displaying in the ui */
|
|
export function normalizeShortNameCompletionDisplay(shortName: string): string {
|
|
return shortName
|
|
.normalize('NFD')
|
|
.replaceAll(/[\s,]+/gi, '_')
|
|
.toLowerCase();
|
|
}
|
|
|
|
/** For matching in search utils */
|
|
export function normalizeShortNameCompletionQuery(query: string): string {
|
|
return removeDiacritics(query)
|
|
.normalize('NFD')
|
|
.replaceAll(/[\s,_-]+/gi, ' ')
|
|
.toLowerCase();
|
|
}
|
|
|
|
/**
|
|
* Emojify
|
|
*/
|
|
|
|
function isSafeEmojifyEmoji(value: string): value is EmojiVariantValue {
|
|
return isEmojiVariantValue(value) && !isEmojiVariantValueNonQualified(value);
|
|
}
|
|
|
|
export type EmojiSpan = Readonly<{
|
|
index: number;
|
|
length: number;
|
|
emoji: EmojiVariantValue;
|
|
}>;
|
|
|
|
export type EmojifyData = Readonly<{
|
|
text: string;
|
|
emojiCount: number;
|
|
isEmojiOnlyText: boolean;
|
|
}>;
|
|
|
|
export function getEmojifyData(input: string): EmojifyData {
|
|
// Fast path, and treat empty strings like they have non-emoji text
|
|
if (input === '' || input.trim() === '') {
|
|
return { text: input, emojiCount: 0, isEmojiOnlyText: false };
|
|
}
|
|
|
|
let hasEmojis = false;
|
|
let hasNonEmojis = false;
|
|
let emojiCount = 0;
|
|
|
|
const regex = emojiRegex();
|
|
|
|
let match = regex.exec(input);
|
|
let lastIndex = 0;
|
|
while (match) {
|
|
const value = match[0];
|
|
|
|
// Only consider safe emojis as matches
|
|
if (isSafeEmojifyEmoji(value)) {
|
|
const { index } = match;
|
|
hasEmojis = true;
|
|
// Track if we skipped over any text
|
|
if (index > lastIndex) {
|
|
hasNonEmojis = true;
|
|
lastIndex += index;
|
|
}
|
|
emojiCount += 1;
|
|
// Needs to be the value.length not the match.length
|
|
lastIndex = index + value.length;
|
|
}
|
|
|
|
match = regex.exec(input);
|
|
}
|
|
|
|
// Track if we had any remaining text
|
|
if (lastIndex === 0 || lastIndex < input.length) {
|
|
hasNonEmojis = true;
|
|
}
|
|
|
|
return {
|
|
text: input,
|
|
emojiCount,
|
|
isEmojiOnlyText: hasEmojis && !hasNonEmojis,
|
|
};
|
|
}
|