From 8d6406f561654697291dfb6b169fa926d21879b8 Mon Sep 17 00:00:00 2001 From: Echo Date: Fri, 8 May 2026 16:05:31 +0200 Subject: [PATCH] Remove legacy emojify function (#38965) --- app/javascript/entrypoints/public.tsx | 44 +++-- app/javascript/mastodon/actions/compose.js | 4 +- .../autosuggest_emoji-test.jsx.snap | 35 ---- .../__tests__/autosuggest_emoji-test.jsx | 29 --- .../mastodon/components/autosuggest_emoji.jsx | 43 ----- .../mastodon/components/autosuggest_emoji.tsx | 22 +++ .../mastodon/components/autosuggest_input.jsx | 2 +- .../components/autosuggest_textarea.jsx | 2 +- .../mastodon/components/emoji/index.tsx | 8 +- .../features/emoji/__tests__/emoji-test.js | 97 ---------- .../mastodon/features/emoji/emoji.js | 170 ------------------ .../emoji/emoji_unicode_mapping_light.ts | 62 ------- .../mastodon/features/emoji/mode.ts | 37 ++++ .../mastodon/features/emoji/normalize.ts | 4 +- .../mastodon/features/emoji/render.test.ts | 82 +++++++++ .../mastodon/features/emoji/render.ts | 86 +++++++-- app/javascript/mastodon/utils/objects.test.ts | 27 +++ app/javascript/mastodon/utils/objects.ts | 52 ++++++ 18 files changed, 343 insertions(+), 463 deletions(-) delete mode 100644 app/javascript/mastodon/components/__tests__/__snapshots__/autosuggest_emoji-test.jsx.snap delete mode 100644 app/javascript/mastodon/components/__tests__/autosuggest_emoji-test.jsx delete mode 100644 app/javascript/mastodon/components/autosuggest_emoji.jsx create mode 100644 app/javascript/mastodon/components/autosuggest_emoji.tsx delete mode 100644 app/javascript/mastodon/features/emoji/__tests__/emoji-test.js delete mode 100644 app/javascript/mastodon/features/emoji/emoji.js delete mode 100644 app/javascript/mastodon/features/emoji/emoji_unicode_mapping_light.ts create mode 100644 app/javascript/mastodon/utils/objects.test.ts create mode 100644 app/javascript/mastodon/utils/objects.ts diff --git a/app/javascript/entrypoints/public.tsx b/app/javascript/entrypoints/public.tsx index 8b67698f20..4e5b35b973 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 { getNestedProperty } 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,30 @@ 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 stateEmojiStyle = getNestedProperty( + JSON.parse(initialStateText) as unknown, + 'meta', + 'emoji_style', + ); + if (typeof stateEmojiStyle === 'string') { + emojiStyle = stateEmojiStyle; + } + } + 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/actions/compose.js b/app/javascript/mastodon/actions/compose.js index f43ca0039b..007e6711ee 100644 --- a/app/javascript/mastodon/actions/compose.js +++ b/app/javascript/mastodon/actions/compose.js @@ -6,7 +6,6 @@ import { throttle } from 'lodash'; import api from 'mastodon/api'; import { browserHistory } from 'mastodon/components/router'; import { countableText } from 'mastodon/features/compose/util/counter'; -import { search as emojiSearch } from 'mastodon/features/emoji/emoji_mart_search_light'; import { tagHistory } from 'mastodon/settings'; import { fetchCustomEmojiData } from '@/mastodon/features/emoji/picker'; @@ -593,7 +592,8 @@ const fetchComposeSuggestionsAccounts = throttle((dispatch, token) => { const fetchComposeSuggestionsEmojis = async (dispatch, token) => { const custom = await fetchCustomEmojiData(); - const results = emojiSearch(token.replace(':', ''), { maxResults: 5, custom }); + const { search } = await import('@/mastodon/features/emoji/emoji_mart_search_light'); + const results = search(token.replace(':', ''), { maxResults: 5, custom }); dispatch(readyComposeSuggestionsEmojis(token, results)); }; diff --git a/app/javascript/mastodon/components/__tests__/__snapshots__/autosuggest_emoji-test.jsx.snap b/app/javascript/mastodon/components/__tests__/__snapshots__/autosuggest_emoji-test.jsx.snap deleted file mode 100644 index 9502accb73..0000000000 --- a/app/javascript/mastodon/components/__tests__/__snapshots__/autosuggest_emoji-test.jsx.snap +++ /dev/null @@ -1,35 +0,0 @@ -// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html - -exports[` > renders emoji with custom url 1`] = ` -
- foobar -
- :foobar: -
-
-`; - -exports[` > renders native emoji 1`] = ` -
- ๐Ÿ’™ -
- :foobar: -
-
-`; diff --git a/app/javascript/mastodon/components/__tests__/autosuggest_emoji-test.jsx b/app/javascript/mastodon/components/__tests__/autosuggest_emoji-test.jsx deleted file mode 100644 index 2603420aec..0000000000 --- a/app/javascript/mastodon/components/__tests__/autosuggest_emoji-test.jsx +++ /dev/null @@ -1,29 +0,0 @@ -import renderer from 'react-test-renderer'; - -import AutosuggestEmoji from '../autosuggest_emoji'; - -describe('', () => { - it('renders native emoji', () => { - const emoji = { - native: '๐Ÿ’™', - colons: ':foobar:', - }; - const component = renderer.create(); - const tree = component.toJSON(); - - expect(tree).toMatchSnapshot(); - }); - - it('renders emoji with custom url', () => { - const emoji = { - custom: true, - imageUrl: 'http://example.com/emoji.png', - native: 'foobar', - colons: ':foobar:', - }; - const component = renderer.create(); - const tree = component.toJSON(); - - expect(tree).toMatchSnapshot(); - }); -}); diff --git a/app/javascript/mastodon/components/autosuggest_emoji.jsx b/app/javascript/mastodon/components/autosuggest_emoji.jsx deleted file mode 100644 index adea1d9f4b..0000000000 --- a/app/javascript/mastodon/components/autosuggest_emoji.jsx +++ /dev/null @@ -1,43 +0,0 @@ -import PropTypes from 'prop-types'; -import { PureComponent } from 'react'; - -import { assetHost } from 'mastodon/utils/config'; - -import { unicodeMapping } from '../features/emoji/emoji_unicode_mapping_light'; - -export default class AutosuggestEmoji extends PureComponent { - - static propTypes = { - emoji: PropTypes.object.isRequired, - }; - - render () { - const { emoji } = this.props; - let url; - - if (emoji.custom) { - url = emoji.imageUrl; - } else { - const mapping = unicodeMapping[emoji.native] || unicodeMapping[emoji.native.replace(/\uFE0F$/, '')]; - - if (!mapping) { - return null; - } - - url = `${assetHost}/emoji/${mapping.filename}.svg`; - } - - return ( -
- {emoji.native - -
{emoji.colons}
-
- ); - } - -} diff --git a/app/javascript/mastodon/components/autosuggest_emoji.tsx b/app/javascript/mastodon/components/autosuggest_emoji.tsx new file mode 100644 index 0000000000..42214615ef --- /dev/null +++ b/app/javascript/mastodon/components/autosuggest_emoji.tsx @@ -0,0 +1,22 @@ +import type { FC } from 'react'; + +import { useCustomEmojis } from '@/mastodon/hooks/useCustomEmojis'; + +import { Emoji } from './emoji'; + +interface LegacyEmoji { + colons: string; + custom?: boolean; + native?: string; + imageUrl?: string; +} + +export const AutosuggestEmoji: FC<{ emoji: LegacyEmoji }> = ({ emoji }) => { + const emojis = useCustomEmojis(); + return ( +
+ +
{emoji.colons}
+
+ ); +}; diff --git a/app/javascript/mastodon/components/autosuggest_input.jsx b/app/javascript/mastodon/components/autosuggest_input.jsx index 9e342a353a..445ec601cf 100644 --- a/app/javascript/mastodon/components/autosuggest_input.jsx +++ b/app/javascript/mastodon/components/autosuggest_input.jsx @@ -9,7 +9,7 @@ import Overlay from 'react-overlays/Overlay'; import AutosuggestAccountContainer from '../features/compose/containers/autosuggest_account_container'; -import AutosuggestEmoji from './autosuggest_emoji'; +import { AutosuggestEmoji } from './autosuggest_emoji'; import { AutosuggestHashtag } from './autosuggest_hashtag'; const textAtCursorMatchesToken = (str, caretPosition, searchTokens) => { diff --git a/app/javascript/mastodon/components/autosuggest_textarea.jsx b/app/javascript/mastodon/components/autosuggest_textarea.jsx index fae078da31..9b01d300a3 100644 --- a/app/javascript/mastodon/components/autosuggest_textarea.jsx +++ b/app/javascript/mastodon/components/autosuggest_textarea.jsx @@ -10,7 +10,7 @@ import Textarea from 'react-textarea-autosize'; import AutosuggestAccountContainer from '../features/compose/containers/autosuggest_account_container'; -import AutosuggestEmoji from './autosuggest_emoji'; +import { AutosuggestEmoji } from './autosuggest_emoji'; import { AutosuggestHashtag } from './autosuggest_hashtag'; const textAtCursorMatchesToken = (str, caretPosition) => { diff --git a/app/javascript/mastodon/components/emoji/index.tsx b/app/javascript/mastodon/components/emoji/index.tsx index 9b917df6ea..3989b595d7 100644 --- a/app/javascript/mastodon/components/emoji/index.tsx +++ b/app/javascript/mastodon/components/emoji/index.tsx @@ -19,6 +19,7 @@ import { stringToEmojiState, tokenizeText, } from '@/mastodon/features/emoji/render'; +import type { ExtraCustomEmojiMap } from '@/mastodon/features/emoji/types'; import { AnimateEmojiContext, CustomEmojiContext } from './context'; @@ -26,14 +27,19 @@ interface EmojiProps { code: string; showFallback?: boolean; showLoading?: boolean; + customEmoji?: ExtraCustomEmojiMap | null; } export const Emoji: FC = ({ code, showFallback = true, showLoading = true, + customEmoji: customEmojiOverride, }) => { - const customEmoji = useContext(CustomEmojiContext); + let customEmoji = useContext(CustomEmojiContext); + if (customEmojiOverride) { + customEmoji = customEmojiOverride; + } // First, set the emoji state based on the input code. const [state, setState] = useState(() => diff --git a/app/javascript/mastodon/features/emoji/__tests__/emoji-test.js b/app/javascript/mastodon/features/emoji/__tests__/emoji-test.js deleted file mode 100644 index 5d0683dace..0000000000 --- a/app/javascript/mastodon/features/emoji/__tests__/emoji-test.js +++ /dev/null @@ -1,97 +0,0 @@ -import emojify from '../emoji'; - -describe('emoji', () => { - describe('.emojify', () => { - it('ignores unknown shortcodes', () => { - expect(emojify(':foobarbazfake:')).toEqual(':foobarbazfake:'); - }); - - it('ignores shortcodes inside of tags', () => { - expect(emojify('

')).toEqual('

'); - }); - - it('works with unclosed tags', () => { - expect(emojify('hello>')).toEqual('hello>'); - expect(emojify(' { - expect(emojify('smile:')).toEqual('smile:'); - expect(emojify(':smile')).toEqual(':smile'); - }); - - it('does unicode', () => { - expect(emojify('\uD83D\uDC69\u200D\uD83D\uDC69\u200D\uD83D\uDC66\u200D\uD83D\uDC66')).toEqual( - '๐Ÿ‘ฉโ€๐Ÿ‘ฉโ€๐Ÿ‘ฆโ€๐Ÿ‘ฆ'); - expect(emojify('๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ง')).toEqual( - '๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ง'); - expect(emojify('๐Ÿ‘ฉโ€๐Ÿ‘ฉโ€๐Ÿ‘ฆ')).toEqual('๐Ÿ‘ฉโ€๐Ÿ‘ฉโ€๐Ÿ‘ฆ'); - expect(emojify('\u2757')).toEqual( - 'โ—'); - }); - - it('does multiple unicode', () => { - expect(emojify('\u2757 #\uFE0F\u20E3')).toEqual( - 'โ— #๏ธโƒฃ'); - expect(emojify('\u2757#\uFE0F\u20E3')).toEqual( - 'โ—#๏ธโƒฃ'); - expect(emojify('\u2757 #\uFE0F\u20E3 \u2757')).toEqual( - 'โ— #๏ธโƒฃ โ—'); - expect(emojify('foo \u2757 #\uFE0F\u20E3 bar')).toEqual( - 'foo โ— #๏ธโƒฃ bar'); - }); - - it('ignores unicode inside of tags', () => { - expect(emojify('

')).toEqual('

'); - }); - - it('does multiple emoji properly (issue 5188)', () => { - expect(emojify('๐Ÿ‘Œ๐ŸŒˆ๐Ÿ’•')).toEqual('๐Ÿ‘Œ๐ŸŒˆ๐Ÿ’•'); - expect(emojify('๐Ÿ‘Œ ๐ŸŒˆ ๐Ÿ’•')).toEqual('๐Ÿ‘Œ ๐ŸŒˆ ๐Ÿ’•'); - }); - - it('does an emoji that has no shortcode', () => { - expect(emojify('๐Ÿ‘โ€๐Ÿ—จ')).toEqual('๐Ÿ‘โ€๐Ÿ—จ'); - }); - - it('does an emoji whose filename is irregular', () => { - expect(emojify('โ†™๏ธ')).toEqual('โ†™๏ธ'); - }); - - it('avoid emojifying on invisible text', () => { - expect(emojify('example.com/te')) - .toEqual('example.com/te'); - expect(emojify('', { ':luigi:': { static_url: 'luigi.exe' } })) - .toEqual(''); - }); - - it('avoid emojifying on invisible text with nested tags', () => { - expect(emojify('๐Ÿ˜‡')) - .toEqual('๐Ÿ˜‡'); - expect(emojify('๐Ÿ˜‡')) - .toEqual('๐Ÿ˜‡'); - expect(emojify('๐Ÿ˜‡')) - .toEqual('๐Ÿ˜‡'); - }); - - it('does not emojify emojis with textual presentation VS15 character', () => { - expect(emojify('โœด๏ธŽ')) // This is U+2734 EIGHT POINTED BLACK STAR then U+FE0E VARIATION SELECTOR-15 - .toEqual('โœด๏ธŽ'); - }); - - it('does a simple emoji properly', () => { - expect(emojify('โ™€โ™‚')) - .toEqual('โ™€โ™‚'); - }); - - it('does an emoji containing ZWJ properly', () => { - expect(emojify('๐Ÿ’‚โ€โ™€๏ธ๐Ÿ’‚โ€โ™‚๏ธ')) - .toEqual('๐Ÿ’‚โ€โ™€๏ธ๐Ÿ’‚โ€โ™‚๏ธ'); - }); - - it('keeps ordering as expected (issue fixed by PR 20677)', () => { - expect(emojify('

๐Ÿ’• #foo test: foo.

')) - .toEqual('

๐Ÿ’• #foo test: foo.

'); - }); - }); -}); diff --git a/app/javascript/mastodon/features/emoji/emoji.js b/app/javascript/mastodon/features/emoji/emoji.js deleted file mode 100644 index 8f45c76ac7..0000000000 --- a/app/javascript/mastodon/features/emoji/emoji.js +++ /dev/null @@ -1,170 +0,0 @@ -import Trie from 'substring-trie'; - -import { getIsSystemTheme, isDarkMode } from '@/mastodon/utils/theme'; -import { assetHost } from 'mastodon/utils/config'; - -import { autoPlayGif } from '../../initial_state'; - -import { unicodeMapping } from './emoji_unicode_mapping_light'; - -const trie = new Trie(Object.keys(unicodeMapping)); - -// Convert to file names from emojis. (For different variation selector emojis) -const emojiFilenames = (emojis) => { - return emojis.map(v => unicodeMapping[v].filename); -}; - -// Emoji requiring extra borders depending on theme -const darkEmoji = emojiFilenames(['๐ŸŽฑ', '๐Ÿœ', 'โšซ', '๐Ÿ–ค', 'โฌ›', 'โ—ผ๏ธ', 'โ—พ', 'โ—ผ๏ธ', 'โœ’๏ธ', 'โ–ช๏ธ', '๐Ÿ’ฃ', '๐ŸŽณ', '๐Ÿ“ท', '๐Ÿ“ธ', 'โ™ฃ๏ธ', '๐Ÿ•ถ๏ธ', 'โœด๏ธ', '๐Ÿ”Œ', '๐Ÿ’‚โ€โ™€๏ธ', '๐Ÿ“ฝ๏ธ', '๐Ÿณ', '๐Ÿฆ', '๐Ÿ’‚', '๐Ÿ”ช', '๐Ÿ•ณ๏ธ', '๐Ÿ•น๏ธ', '๐Ÿ•‹', '๐Ÿ–Š๏ธ', '๐Ÿ–‹๏ธ', '๐Ÿ’‚โ€โ™‚๏ธ', '๐ŸŽค', '๐ŸŽ“', '๐ŸŽฅ', '๐ŸŽผ', 'โ™ ๏ธ', '๐ŸŽฉ', '๐Ÿฆƒ', '๐Ÿ“ผ', '๐Ÿ“น', '๐ŸŽฎ', '๐Ÿƒ', '๐Ÿด', '๐Ÿž', '๐Ÿ•บ', '๐Ÿ“ฑ', '๐Ÿ“ฒ', '๐Ÿšฒ', '๐Ÿชฎ', '๐Ÿฆโ€โฌ›']); -const lightEmoji = emojiFilenames(['๐Ÿ‘ฝ', 'โšพ', '๐Ÿ”', 'โ˜๏ธ', '๐Ÿ’จ', '๐Ÿ•Š๏ธ', '๐Ÿ‘€', '๐Ÿฅ', '๐Ÿ‘ป', '๐Ÿ', 'โ•', 'โ”', 'โ›ธ๏ธ', '๐ŸŒฉ๏ธ', '๐Ÿ”Š', '๐Ÿ”‡', '๐Ÿ“ƒ', '๐ŸŒง๏ธ', '๐Ÿ', '๐Ÿš', '๐Ÿ™', '๐Ÿ“', '๐Ÿ‘', '๐Ÿ’€', 'โ˜ ๏ธ', '๐ŸŒจ๏ธ', '๐Ÿ”‰', '๐Ÿ”ˆ', '๐Ÿ’ฌ', '๐Ÿ’ญ', '๐Ÿ', '๐Ÿณ๏ธ', 'โšช', 'โฌœ', 'โ—ฝ', 'โ—ป๏ธ', 'โ–ซ๏ธ', '๐Ÿชฝ', '๐Ÿชฟ']); - -/** - * @param {string} filename - * @param {"light" | "dark" } colorScheme - * @returns {string} - */ -const emojiFilename = (filename, colorScheme) => { - const borderedEmoji = colorScheme === "light" ? lightEmoji : darkEmoji; - return borderedEmoji.includes(filename) ? (filename + '_border') : filename; -}; - -const emojifyTextNode = (node, customEmojis) => { - const VS15 = 0xFE0E; - const VS16 = 0xFE0F; - - let str = node.textContent; - - const fragment = new DocumentFragment(); - let i = 0; - - for (;;) { - let unicode_emoji; - - // Skip to the next potential emoji to replace (either custom emoji or custom emoji :shortcode: - if (customEmojis === null) { - while (i < str.length && !(unicode_emoji = trie.search(str.slice(i)))) { - i += str.codePointAt(i) < 65536 ? 1 : 2; - } - } else { - while (i < str.length && str[i] !== ':' && !(unicode_emoji = trie.search(str.slice(i)))) { - i += str.codePointAt(i) < 65536 ? 1 : 2; - } - } - - // We reached the end of the string, nothing to replace - if (i === str.length) { - break; - } - - let rend, replacement = null; - if (str[i] === ':') { // Potentially the start of a custom emoji :shortcode: - rend = str.indexOf(':', i + 1) + 1; - - // no matching ending ':', skip - if (!rend) { - i++; - continue; - } - - const shortcode = str.slice(i, rend); - const custom_emoji = customEmojis[shortcode]; - - // not a recognized shortcode, skip - if (!custom_emoji) { - i++; - continue; - } - - // now got a replacee as ':shortcode:' - // if you want additional emoji handler, add statements below which set replacement and return true. - const filename = autoPlayGif ? custom_emoji.url : custom_emoji.static_url; - replacement = document.createElement('img'); - replacement.setAttribute('draggable', 'false'); - replacement.setAttribute('class', 'emojione custom-emoji'); - replacement.setAttribute('alt', shortcode); - replacement.setAttribute('title', shortcode); - replacement.setAttribute('src', filename); - replacement.setAttribute('data-original', custom_emoji.url); - replacement.setAttribute('data-static', custom_emoji.static_url); - } else { // start of an unicode emoji - rend = i + unicode_emoji.length; - - // If the matched character was followed by VS15 (for selecting text presentation), skip it. - if (str.codePointAt(rend - 1) !== VS16 && str.codePointAt(rend) === VS15) { - i = rend + 1; - continue; - } - - const { filename, shortCode } = unicodeMapping[unicode_emoji]; - const title = shortCode ? `:${shortCode}:` : ''; - - const isSystemTheme = getIsSystemTheme(); - - const theme = (isSystemTheme || !isDarkMode()) ? 'light' : 'dark'; - - const imageFilename = emojiFilename(filename, theme); - - const img = document.createElement('img'); - img.setAttribute('draggable', 'false'); - img.setAttribute('class', 'emojione'); - img.setAttribute('alt', unicode_emoji); - img.setAttribute('title', title); - img.setAttribute('src', `${assetHost}/emoji/${imageFilename}.svg`); - - if (isSystemTheme && imageFilename !== emojiFilename(filename, 'dark')) { - replacement = document.createElement('picture'); - - const source = document.createElement('source'); - source.setAttribute('media', '(prefers-color-scheme: dark)'); - source.setAttribute('srcset', `${assetHost}/emoji/${emojiFilename(filename, 'dark')}.svg`); - replacement.appendChild(source); - replacement.appendChild(img); - } else { - replacement = img; - } - } - - // Add the processed-up-to-now string and the emoji replacement - fragment.append(document.createTextNode(str.slice(0, i))); - fragment.append(replacement); - str = str.slice(rend); - i = 0; - } - - fragment.append(document.createTextNode(str)); - node.parentElement.replaceChild(fragment, node); -}; - -const emojifyNode = (node, customEmojis) => { - for (const child of Array.from(node.childNodes)) { - switch(child.nodeType) { - case Node.TEXT_NODE: - emojifyTextNode(child, customEmojis); - break; - case Node.ELEMENT_NODE: - if (!child.classList.contains('invisible')) - emojifyNode(child, customEmojis); - break; - } - } -}; - -/** - * Legacy emoji processing function. - * @param {string} str - * @param {object} customEmojis - * @returns {string} - */ -const emojify = (str, customEmojis = {}) => { - const wrapper = document.createElement('div'); - wrapper.innerHTML = str; - - if (!Object.keys(customEmojis).length) - customEmojis = null; - - emojifyNode(wrapper, customEmojis); - - return wrapper.innerHTML; -}; - -export default emojify; diff --git a/app/javascript/mastodon/features/emoji/emoji_unicode_mapping_light.ts b/app/javascript/mastodon/features/emoji/emoji_unicode_mapping_light.ts deleted file mode 100644 index ecf36e3ea8..0000000000 --- a/app/javascript/mastodon/features/emoji/emoji_unicode_mapping_light.ts +++ /dev/null @@ -1,62 +0,0 @@ -// A mapping of unicode strings to an object containing the filename -// (i.e. the svg filename) and a shortCode intended to be shown -// as a "title" attribute in an HTML element (aka tooltip). - -import emojiCompressed from 'virtual:mastodon-emoji-compressed'; -import type { - FilenameData, - ShortCodesToEmojiDataKey, -} from 'virtual:mastodon-emoji-compressed'; - -import { unicodeToFilename } from './unicode_utils'; - -type UnicodeMapping = Record< - FilenameData[number][0], - { - shortCode: ShortCodesToEmojiDataKey; - filename: FilenameData[number][number]; - } ->; - -const [ - shortCodesToEmojiData, - _skins, - _categories, - _short_names, - emojisWithoutShortCodes, -] = emojiCompressed; - -// decompress -const unicodeMapping: UnicodeMapping = {}; - -function processEmojiMapData( - emojiMapData: FilenameData[number], - shortCode?: ShortCodesToEmojiDataKey, -) { - const [native, _filename] = emojiMapData; - // filename name can be derived from unicodeToFilename - const filename = emojiMapData[1] ?? unicodeToFilename(native); - unicodeMapping[native] = { - shortCode, - filename, - }; -} - -Object.keys(shortCodesToEmojiData).forEach( - (shortCode: ShortCodesToEmojiDataKey) => { - if (shortCode === undefined) return; - - const emojiData = shortCodesToEmojiData[shortCode]; - if (!emojiData) return; - const [filenameData, _searchData] = emojiData; - filenameData.forEach((emojiMapData) => { - processEmojiMapData(emojiMapData, shortCode); - }); - }, -); - -emojisWithoutShortCodes.forEach((emojiMapData) => { - processEmojiMapData(emojiMapData); -}); - -export { unicodeMapping }; diff --git a/app/javascript/mastodon/features/emoji/mode.ts b/app/javascript/mastodon/features/emoji/mode.ts index 6763fc4b50..f7b0ea0216 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 6d2cfbb46a..fd4f61c66d 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 dffebd1f8c..a668f33d29 100644 --- a/app/javascript/mastodon/features/emoji/render.test.ts +++ b/app/javascript/mastodon/features/emoji/render.test.ts @@ -1,11 +1,13 @@ import { customEmojiFactory, unicodeEmojiFactory } from '@/testing/factories'; +import { EMOJI_MODE_TWEMOJI } from './constants'; import * as db from './database'; import * as loader from './loader'; import { loadEmojiDataToState, stringToEmojiState, tokenizeText, + updateHtmlWithEmoji, } from './render'; import type { EmojiStateCustom, EmojiStateUnicode } from './types'; @@ -107,6 +109,74 @@ describe('stringToEmojiState', () => { }); }); +describe('updateHtmlWithEmoji', () => { + const defaultOptions = { + assetHost: '', + darkTheme: false, + mode: EMOJI_MODE_TWEMOJI, + locale: 'en', + } as const; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + test('updates element text with emojis', async () => { + const element = document.createElement('div'); + element.textContent = '๐Ÿ˜Š'; + + vi.spyOn(db, 'loadLegacyShortcodesByShortcode').mockResolvedValueOnce( + undefined, + ); + vi.spyOn(db, 'loadEmojiByHexcode').mockResolvedValueOnce( + unicodeEmojiFactory(), + ); + + await updateHtmlWithEmoji({ + ...defaultOptions, + element, + }); + + const img = element.querySelector('img'); + expect(img).toBeDefined(); + }); + + test('does not update element text when mode is native', async () => { + const element = document.createElement('div'); + element.textContent = '๐Ÿ˜Š'; + + const dbShortcodeCall = vi.spyOn(db, 'loadLegacyShortcodesByShortcode'); + const dbEmojiCall = vi.spyOn(db, 'loadEmojiByHexcode'); + + await updateHtmlWithEmoji({ + ...defaultOptions, + mode: 'native', + element, + }); + + expect(dbShortcodeCall).not.toHaveBeenCalled(); + expect(dbEmojiCall).not.toHaveBeenCalled(); + expect(element.textContent).toBe('๐Ÿ˜Š'); + }); + + test('does not try to load custom emojis', async () => { + const element = document.createElement('div'); + element.textContent = ':smile:'; + + const dbShortcodeCall = vi.spyOn(db, 'loadLegacyShortcodesByShortcode'); + const dbEmojiCall = vi.spyOn(db, 'loadEmojiByHexcode'); + + await updateHtmlWithEmoji({ + ...defaultOptions, + element, + }); + + expect(dbShortcodeCall).not.toHaveBeenCalled(); + expect(dbEmojiCall).not.toHaveBeenCalled(); + expect(element.textContent).toBe(':smile:'); + }); +}); + describe('loadEmojiDataToState', () => { beforeEach(() => { vi.clearAllMocks(); @@ -137,6 +207,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 4b65f3abde..affa8e0f16 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,68 @@ export function stringToEmojiState( return null; } +/** + * Takes an element and emojifies all native emoji. + */ +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 +185,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 +206,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.test.ts b/app/javascript/mastodon/utils/objects.test.ts new file mode 100644 index 0000000000..5c08cd0561 --- /dev/null +++ b/app/javascript/mastodon/utils/objects.test.ts @@ -0,0 +1,27 @@ +/* eslint-disable @typescript-eslint/no-confusing-void-expression */ +import { getNestedProperty } from './objects'; + +describe('getNestedProperty', () => { + test('returns the value of a nested property if it exists', () => { + const obj = { a: { b: { c: 42 } } }; + expect(getNestedProperty(obj, 'a', 'b', 'c')).toBe(42); + }); + + test('returns undefined if any part of the path does not exist', () => { + const obj = { a: { b: { c: 42 } } }; + expect(getNestedProperty(obj, 'a', 'x', 'c')).toBeUndefined(); + expect(getNestedProperty(obj, 'a', 'b', 'x')).toBeUndefined(); + expect(getNestedProperty(obj, 'x', 'b', 'c')).toBeUndefined(); + }); + + test('returns undefined if the initial object is not a record', () => { + expect(getNestedProperty(null, 'a', 'b')).toBeUndefined(); + expect(getNestedProperty(42, 'a', 'b')).toBeUndefined(); + expect(getNestedProperty('string', 'a', 'b')).toBeUndefined(); + }); + + test('returns undefined if no keys are provided', () => { + const obj = { a: 1 }; + expect(getNestedProperty(obj)).toBeUndefined(); + }); +}); diff --git a/app/javascript/mastodon/utils/objects.ts b/app/javascript/mastodon/utils/objects.ts new file mode 100644 index 0000000000..4430849c0c --- /dev/null +++ b/app/javascript/mastodon/utils/objects.ts @@ -0,0 +1,52 @@ +import { isPlainObject } from '@reduxjs/toolkit'; + +export type RecordObject = Record; + +export function isRecordObject(obj: unknown): obj is RecordObject { + return isPlainObject(obj); +} + +type NestedProperty = K extends readonly [ + infer Head, + ...infer Tail, +] + ? Head extends keyof NonNullable + ? Tail extends readonly PropertyKey[] + ? NestedProperty[Head], Tail> + : NonNullable[Head] + : undefined + : T; + +export function getNestedProperty< + TObject extends RecordObject, + const TKeys extends readonly PropertyKey[], +>(object: TObject, ...keys: TKeys): NestedProperty | undefined; +export function getNestedProperty( + object: unknown, + ...keys: PropertyKey[] +): unknown; +export function getNestedProperty( + object: unknown, + ...keys: PropertyKey[] +): unknown { + if (!isRecordObject(object) || keys.length === 0) { + return undefined; + } + + const remainingKeys = [...keys]; + let currentObject: RecordObject = object; + while (remainingKeys.length > 0) { + const currentKey = remainingKeys.shift(); + if (currentKey !== undefined && currentKey in currentObject) { + const nextObject = currentObject[currentKey]; + if (isRecordObject(nextObject)) { + currentObject = nextObject; + continue; + } else if (remainingKeys.length === 0) { + return nextObject; + } + } + } + + return undefined; +}