import { EMOJI_MODE_NATIVE, EMOJI_MODE_NATIVE_WITH_FLAGS, EMOJI_TYPE_UNICODE, EMOJI_TYPE_CUSTOM, } from './constants'; import { loadCustomEmojiByShortcode, loadEmojiByHexcode, LocaleNotLoadedError, } from './database'; import { importEmojiData } from './loader'; import { emojiToUnicodeHex } from './normalize'; import type { EmojiLoadedState, EmojiMode, EmojiState, EmojiStateCustom, EmojiStateUnicode, ExtraCustomEmojiMap, } from './types'; import { anyEmojiRegex, emojiLogger, isCustomEmoji, isUnicodeEmoji, stringHasUnicodeFlags, } from './utils'; const log = emojiLogger('render'); type TokenizedText = (string | EmojiState)[]; /** * Tokenizes text into strings and emoji states. * @param text Text to tokenize. * @returns Array of strings and emoji states. */ export function tokenizeText(text: string): TokenizedText { if (!text.trim()) { return [text]; } const tokens = []; let lastIndex = 0; for (const match of text.matchAll(anyEmojiRegex())) { if (match.index > lastIndex) { tokens.push(text.slice(lastIndex, match.index)); } const code = match[0]; if (code.startsWith(':') && code.endsWith(':')) { // Custom emoji tokens.push({ type: EMOJI_TYPE_CUSTOM, code, } satisfies EmojiStateCustom); } else { // Unicode emoji tokens.push({ type: EMOJI_TYPE_UNICODE, code: code, } satisfies EmojiStateUnicode); } lastIndex = match.index + code.length; } if (lastIndex < text.length) { tokens.push(text.slice(lastIndex)); } return tokens; } /** * Parses emoji string to extract emoji state. * @param code Hex code or custom shortcode. * @param customEmoji Extra custom emojis. */ export function stringToEmojiState( code: string, customEmoji: ExtraCustomEmojiMap = {}, ): EmojiState | null { if (isUnicodeEmoji(code)) { return { type: EMOJI_TYPE_UNICODE, code: emojiToUnicodeHex(code), }; } if (isCustomEmoji(code)) { const shortCode = code.slice(1, -1); return { type: EMOJI_TYPE_CUSTOM, code: shortCode, data: customEmoji[shortCode], }; } return null; } /** * Loads emoji data into the given state if not already loaded. * @param state Emoji state to load data for. * @param locale Locale to load data for. Only for Unicode emoji. * @param retry Internal. Whether this is a retry after loading the locale. */ export async function loadEmojiDataToState( state: EmojiState, locale: string, retry = false, ): Promise { if (isStateLoaded(state)) { return state; } // First, try to load the data from IndexedDB. try { // This is duplicative, but that's because TS can't distinguish the state type easily. if (state.type === EMOJI_TYPE_UNICODE) { const data = await loadEmojiByHexcode(state.code, locale); if (data) { return { ...state, data, }; } } else { const data = await loadCustomEmojiByShortcode(state.code); if (data) { return { ...state, data, }; } } // If not found, assume it's not an emoji and return null. log( 'Could not find emoji %s of type %s for locale %s', state.code, state.type, locale, ); return null; } catch (err: unknown) { // If the locale is not loaded, load it and retry once. if (!retry && err instanceof LocaleNotLoadedError) { log( 'Error loading emoji %s for locale %s, loading locale and retrying.', state.code, locale, ); await importEmojiData(locale); // Use this from the loader file as it can be awaited. return loadEmojiDataToState(state, locale, true); } console.warn('Error loading emoji data, not retrying:', state, locale, err); return null; } } export function isStateLoaded(state: EmojiState): state is EmojiLoadedState { return !!state.data; } /** * Determines if the given token should be rendered as an image based on the emoji mode. * @param state Emoji state to parse. * @param mode Rendering mode. * @returns Whether to render as an image. */ export function shouldRenderImage(state: EmojiState, mode: EmojiMode): boolean { if (state.type === EMOJI_TYPE_UNICODE) { // If the mode is native or native with flags for non-flag emoji // we can just append the text node directly. if ( mode === EMOJI_MODE_NATIVE || (mode === EMOJI_MODE_NATIVE_WITH_FLAGS && !stringHasUnicodeFlags(state.code)) ) { return false; } } return true; }