Files
mastodon/app/javascript/flavours/glitch/features/emoji/render.ts
Echo 8d1e67b6b2 [Glitch] Emoji: Cleanup new code
Port 0c64e7f75e to glitch-soc

Signed-off-by: Claire <claire.github-309c@sitedethib.com>
2025-10-14 18:04:18 +02:00

187 lines
4.6 KiB
TypeScript

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<EmojiLoadedState | null> {
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;
}