From 3c17fde5eff96bc763c3c8a4e9ba6a6267e69bb3 Mon Sep 17 00:00:00 2001 From: ChaosExAnima Date: Thu, 7 May 2026 17:30:53 +0200 Subject: [PATCH] remove usage of emojify --- app/javascript/entrypoints/public.tsx | 46 +++++++--- .../mastodon/features/emoji/mode.ts | 37 +++++++++ .../mastodon/features/emoji/normalize.ts | 4 +- .../mastodon/features/emoji/render.test.ts | 12 +++ .../mastodon/features/emoji/render.ts | 83 ++++++++++++++++--- app/javascript/mastodon/utils/objects.ts | 6 ++ 6 files changed, 166 insertions(+), 22 deletions(-) create mode 100644 app/javascript/mastodon/utils/objects.ts diff --git a/app/javascript/entrypoints/public.tsx b/app/javascript/entrypoints/public.tsx index 8b67698f20e..68d6f438ecd 100644 --- a/app/javascript/entrypoints/public.tsx +++ b/app/javascript/entrypoints/public.tsx @@ -13,14 +13,17 @@ import axios from 'axios'; import { on } from 'delegated-events'; import { throttle } from 'lodash'; +import { determineEmojiMode } from '@/mastodon/features/emoji/mode'; +import { updateHtmlWithEmoji } from '@/mastodon/features/emoji/render'; +import loadKeyboardExtensions from '@/mastodon/load_keyboard_extensions'; +import { loadLocale, getLocale } from '@/mastodon/locales'; +import { loadPolyfills } from '@/mastodon/polyfills'; +import ready from '@/mastodon/ready'; +import { assetHost } from '@/mastodon/utils/config'; +import { isRecord } from '@/mastodon/utils/objects'; +import { isDarkMode } from '@/mastodon/utils/theme'; import { formatTime } from '@/mastodon/utils/time'; -import emojify from '../mastodon/features/emoji/emoji'; -import loadKeyboardExtensions from '../mastodon/load_keyboard_extensions'; -import { loadLocale, getLocale } from '../mastodon/locales'; -import { loadPolyfills } from '../mastodon/polyfills'; -import ready from '../mastodon/ready'; - import 'cocoon-js-vanilla'; const messages = defineMessages({ @@ -38,7 +41,7 @@ const messages = defineMessages({ }, }); -function loaded() { +async function loaded() { const { messages: localeData } = getLocale(); const locale = document.documentElement.lang; @@ -75,9 +78,32 @@ function loaded() { return messageFormat.format(values) as string; }; - document.querySelectorAll('.emojify').forEach((content) => { - content.innerHTML = emojify(content.innerHTML); - }); + let emojiStyle = 'auto'; + const initialStateText = + document.getElementById('initial-state')?.textContent; + if (initialStateText) { + const state = JSON.parse(initialStateText) as unknown; + if ( + isRecord(state) && + 'meta' in state && + isRecord(state.meta) && + 'emoji_style' in state.meta && + typeof state.meta.emoji_style === 'string' + ) { + emojiStyle = state.meta.emoji_style; + } + } + const emojiMode = determineEmojiMode(emojiStyle); + const darkTheme = isDarkMode(); + for (const element of document.querySelectorAll('.emojify')) { + await updateHtmlWithEmoji({ + assetHost, + element, + locale, + mode: emojiMode, + darkTheme, + }); + } document .querySelectorAll('time.formatted') diff --git a/app/javascript/mastodon/features/emoji/mode.ts b/app/javascript/mastodon/features/emoji/mode.ts index 6763fc4b50b..f7b0ea02168 100644 --- a/app/javascript/mastodon/features/emoji/mode.ts +++ b/app/javascript/mastodon/features/emoji/mode.ts @@ -34,6 +34,43 @@ export function useEmojiAppState(): EmojiAppState { }; } +export function getEmojiAppState(): EmojiAppState { + const currentLocale = toSupportedLocale(document.documentElement.lang); + + let emojiStyle = 'auto'; + const initialStateText = + document.getElementById('initial-state')?.textContent; + if (initialStateText) { + try { + const state = JSON.parse(initialStateText) as unknown; + if ( + state !== null && + typeof state === 'object' && + 'meta' in state && + state.meta !== null && + typeof state.meta === 'object' && + 'emoji_style' in state.meta && + typeof state.meta.emoji_style === 'string' + ) { + emojiStyle = state.meta.emoji_style; + } + } catch (err: unknown) { + console.warn( + 'Failed to parse initial state for emoji, defaulting to auto. Error:', + err, + ); + } + } + + return { + currentLocale, + locales: [currentLocale], + mode: determineEmojiMode(emojiStyle), + darkTheme: isDarkMode(), + assetHost, + }; +} + type Feature = Uint8ClampedArray; // See: https://github.com/nolanlawson/emoji-picker-element/blob/master/src/picker/constants.js diff --git a/app/javascript/mastodon/features/emoji/normalize.ts b/app/javascript/mastodon/features/emoji/normalize.ts index 6d2cfbb46ab..fd4f61c66d3 100644 --- a/app/javascript/mastodon/features/emoji/normalize.ts +++ b/app/javascript/mastodon/features/emoji/normalize.ts @@ -152,11 +152,11 @@ const CODES_WITH_LIGHT_BORDER = EMOJIS_WITH_LIGHT_BORDER.map(emojiToUnicodeHex); export function unicodeHexToUrl({ unicodeHex, - darkTheme, + darkTheme = true, assetHost, }: { unicodeHex: string; - darkTheme: boolean; + darkTheme?: boolean; assetHost: string; }): string { const normalizedHex = unicodeToTwemojiHex(unicodeHex); diff --git a/app/javascript/mastodon/features/emoji/render.test.ts b/app/javascript/mastodon/features/emoji/render.test.ts index dffebd1f8c5..d8986e519d2 100644 --- a/app/javascript/mastodon/features/emoji/render.test.ts +++ b/app/javascript/mastodon/features/emoji/render.test.ts @@ -137,6 +137,18 @@ describe('loadEmojiDataToState', () => { }); }); + test('converts unicode emoji code to hexcode when loading data', async () => { + const dbCall = vi + .spyOn(db, 'loadEmojiByHexcode') + .mockResolvedValue(unicodeEmojiFactory()); + const unicodeState = { + type: 'unicode', + code: '😊', + } as const satisfies EmojiStateUnicode; + await loadEmojiDataToState(unicodeState, 'en'); + expect(dbCall).toHaveBeenCalledWith('1F60A', 'en'); + }); + test('returns null for custom emoji without data', async () => { const customState = { type: 'custom', diff --git a/app/javascript/mastodon/features/emoji/render.ts b/app/javascript/mastodon/features/emoji/render.ts index 4b65f3abde5..918c187592d 100644 --- a/app/javascript/mastodon/features/emoji/render.ts +++ b/app/javascript/mastodon/features/emoji/render.ts @@ -4,7 +4,9 @@ import { EMOJI_TYPE_UNICODE, EMOJI_TYPE_CUSTOM, } from './constants'; +import { emojiToInversionClassName, unicodeHexToUrl } from './normalize'; import type { + EmojiAppState, EmojiLoadedState, EmojiMode, EmojiState, @@ -47,14 +49,14 @@ export function tokenizeText(text: string): TokenizedText { if (code.startsWith(':') && code.endsWith(':')) { // Custom emoji tokens.push({ - type: EMOJI_TYPE_CUSTOM, code, + type: EMOJI_TYPE_CUSTOM, } satisfies EmojiStateCustom); } else { // Unicode emoji tokens.push({ + code, type: EMOJI_TYPE_UNICODE, - code: code, } satisfies EmojiStateUnicode); } lastIndex = match.index + code.length; @@ -76,8 +78,8 @@ export function stringToEmojiState( ): EmojiStateUnicode | Required | null { if (isUnicodeEmoji(code)) { return { - type: EMOJI_TYPE_UNICODE, code: emojiToUnicodeHex(code), + type: EMOJI_TYPE_UNICODE, }; } @@ -95,6 +97,65 @@ export function stringToEmojiState( return null; } +export async function updateHtmlWithEmoji({ + assetHost, + darkTheme, + element, + mode, + locale, +}: { + element: Element; + locale: string; +} & Omit) { + if (mode === EMOJI_MODE_NATIVE) { + return; + } + + const tokens = tokenizeText(element.innerHTML); + const newChildren: (string | Element)[] = []; + for (const token of tokens) { + if (typeof token === 'string') { + newChildren.push(token); + continue; + } + + const state = await loadEmojiDataToState(token, locale); + // Ignore custom emoji if we encounter them. + if (!state || state.type === EMOJI_TYPE_CUSTOM) { + newChildren.push(token.code); + continue; + } + + if (!shouldRenderImage(state, mode)) { + newChildren.push(state.data.unicode); + continue; + } + + const img = document.createElement('img'); + img.src = unicodeHexToUrl({ + assetHost, + darkTheme, + unicodeHex: state.data.hexcode, + }); + img.alt = state.data.unicode; + img.title = state.data.label; + img.classList.add('emojione'); + + const inversionClass = emojiToInversionClassName(state.data.unicode); + if (inversionClass) { + img.classList.add(inversionClass); + } + + newChildren.push(img); + } + + element.innerHTML = newChildren.reduce( + (prev, curr) => + typeof curr === 'string' ? prev + curr : prev + curr.outerHTML, + '', + ); +} + /** * Loads emoji data into the given state if not already loaded. * @param state Emoji state to load data for. @@ -121,17 +182,19 @@ export async function loadEmojiDataToState( LocaleNotLoadedError, } = await import('./database'); + const code = isUnicodeEmoji(state.code) + ? emojiToUnicodeHex(state.code) + : state.code; + // First, try to load the data from IndexedDB. try { - const legacyCode = await loadLegacyShortcodesByShortcode(state.code); + const legacyCode = await loadLegacyShortcodesByShortcode(code); // This is duplicative, but that's because TS can't distinguish the state type easily. - const data = await loadEmojiByHexcode( - legacyCode?.hexcode ?? state.code, - locale, - ); + const data = await loadEmojiByHexcode(legacyCode?.hexcode ?? code, locale); if (data) { return { ...state, + code, type: EMOJI_TYPE_UNICODE, data, // TODO: Use CLDR shortcodes when the picker supports them. @@ -140,14 +203,14 @@ export async function loadEmojiDataToState( } // If not found, assume it's not an emoji and return null. - log('Could not find emoji %s for locale %s', state.code, locale); + log('Could not find emoji %s for locale %s', code, 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, + code, locale, ); const { importEmojiData } = await import('./loader'); diff --git a/app/javascript/mastodon/utils/objects.ts b/app/javascript/mastodon/utils/objects.ts new file mode 100644 index 00000000000..75199537e8b --- /dev/null +++ b/app/javascript/mastodon/utils/objects.ts @@ -0,0 +1,6 @@ +export type ValidObjectKey = string | number | symbol; +export type RecordObject = Record; + +export function isRecord(value: unknown): value is RecordObject { + return typeof value === 'object' && value !== null; +}