diff --git a/app/javascript/flavours/glitch/actions/compose.js b/app/javascript/flavours/glitch/actions/compose.js index 464ba6a0c2..2754309fbe 100644 --- a/app/javascript/flavours/glitch/actions/compose.js +++ b/app/javascript/flavours/glitch/actions/compose.js @@ -6,7 +6,6 @@ import { throttle } from 'lodash'; import api from 'flavours/glitch/api'; import { browserHistory } from 'flavours/glitch/components/router'; import { countableText } from 'flavours/glitch/features/compose/util/counter'; -import { search as emojiSearch } from 'flavours/glitch/features/emoji/emoji_mart_search_light'; import { tagHistory } from 'flavours/glitch/settings'; import { fetchCustomEmojiData } from '@/flavours/glitch/features/emoji/picker'; import { recoverHashtags } from 'flavours/glitch/utils/hashtag'; @@ -627,7 +626,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('@/flavours/glitch/features/emoji/emoji_mart_search_light'); + const results = search(token.replace(':', ''), { maxResults: 5, custom }); dispatch(readyComposeSuggestionsEmojis(token, results)); }; diff --git a/app/javascript/flavours/glitch/components/autosuggest_emoji.jsx b/app/javascript/flavours/glitch/components/autosuggest_emoji.jsx deleted file mode 100644 index 892d068b31..0000000000 --- a/app/javascript/flavours/glitch/components/autosuggest_emoji.jsx +++ /dev/null @@ -1,43 +0,0 @@ -import PropTypes from 'prop-types'; -import { PureComponent } from 'react'; - -import { assetHost } from 'flavours/glitch/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/flavours/glitch/components/autosuggest_emoji.tsx b/app/javascript/flavours/glitch/components/autosuggest_emoji.tsx new file mode 100644 index 0000000000..8ab9ce1de2 --- /dev/null +++ b/app/javascript/flavours/glitch/components/autosuggest_emoji.tsx @@ -0,0 +1,22 @@ +import type { FC } from 'react'; + +import { useCustomEmojis } from '@/flavours/glitch/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/flavours/glitch/components/autosuggest_input.jsx b/app/javascript/flavours/glitch/components/autosuggest_input.jsx index 9e342a353a..445ec601cf 100644 --- a/app/javascript/flavours/glitch/components/autosuggest_input.jsx +++ b/app/javascript/flavours/glitch/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/flavours/glitch/components/autosuggest_textarea.jsx b/app/javascript/flavours/glitch/components/autosuggest_textarea.jsx index fae078da31..9b01d300a3 100644 --- a/app/javascript/flavours/glitch/components/autosuggest_textarea.jsx +++ b/app/javascript/flavours/glitch/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/flavours/glitch/components/emoji/index.tsx b/app/javascript/flavours/glitch/components/emoji/index.tsx index b2e5e5fb66..3db6da7ed7 100644 --- a/app/javascript/flavours/glitch/components/emoji/index.tsx +++ b/app/javascript/flavours/glitch/components/emoji/index.tsx @@ -19,6 +19,7 @@ import { stringToEmojiState, tokenizeText, } from '@/flavours/glitch/features/emoji/render'; +import type { ExtraCustomEmojiMap } from '@/flavours/glitch/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/flavours/glitch/entrypoints/public.tsx b/app/javascript/flavours/glitch/entrypoints/public.tsx index 173c0075d5..33d791c815 100644 --- a/app/javascript/flavours/glitch/entrypoints/public.tsx +++ b/app/javascript/flavours/glitch/entrypoints/public.tsx @@ -13,12 +13,16 @@ import axios from 'axios'; import { on } from 'delegated-events'; import { throttle } from 'lodash'; +import { determineEmojiMode } from '@/flavours/glitch/features/emoji/mode'; +import { updateHtmlWithEmoji } from '@/flavours/glitch/features/emoji/render'; +import loadKeyboardExtensions from '@/flavours/glitch/load_keyboard_extensions'; +import { loadLocale, getLocale } from '@/flavours/glitch/locales'; +import { loadPolyfills } from '@/flavours/glitch/polyfills'; +import ready from '@/flavours/glitch/ready'; +import { assetHost } from '@/flavours/glitch/utils/config'; +import { getNestedProperty } from '@/flavours/glitch/utils/objects'; +import { isDarkMode } from '@/flavours/glitch/utils/theme'; import { formatTime } from '@/flavours/glitch/utils/time'; -import emojify from 'flavours/glitch/features/emoji/emoji'; -import loadKeyboardExtensions from 'flavours/glitch/load_keyboard_extensions'; -import { loadLocale, getLocale } from 'flavours/glitch/locales'; -import { loadPolyfills } from 'flavours/glitch/polyfills'; -import ready from 'flavours/glitch/ready'; import 'cocoon-js-vanilla'; @@ -37,7 +41,7 @@ const messages = defineMessages({ }, }); -function loaded() { +async function loaded() { const { messages: localeData } = getLocale(); const locale = document.documentElement.lang; @@ -74,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/flavours/glitch/features/emoji/emoji.js b/app/javascript/flavours/glitch/features/emoji/emoji.js deleted file mode 100644 index 09b3c89981..0000000000 --- a/app/javascript/flavours/glitch/features/emoji/emoji.js +++ /dev/null @@ -1,170 +0,0 @@ -import Trie from 'substring-trie'; - -import { getIsSystemTheme, isDarkMode } from '@/flavours/glitch/utils/theme'; -import { assetHost } from 'flavours/glitch/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/flavours/glitch/features/emoji/emoji_unicode_mapping_light.ts b/app/javascript/flavours/glitch/features/emoji/emoji_unicode_mapping_light.ts deleted file mode 100644 index ecf36e3ea8..0000000000 --- a/app/javascript/flavours/glitch/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/flavours/glitch/features/emoji/mode.ts b/app/javascript/flavours/glitch/features/emoji/mode.ts index 92e4749993..9cd45621cc 100644 --- a/app/javascript/flavours/glitch/features/emoji/mode.ts +++ b/app/javascript/flavours/glitch/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/flavours/glitch/features/emoji/normalize.ts b/app/javascript/flavours/glitch/features/emoji/normalize.ts index 0850a4bf8f..2816f6125c 100644 --- a/app/javascript/flavours/glitch/features/emoji/normalize.ts +++ b/app/javascript/flavours/glitch/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/flavours/glitch/features/emoji/render.test.ts b/app/javascript/flavours/glitch/features/emoji/render.test.ts index dffebd1f8c..a668f33d29 100644 --- a/app/javascript/flavours/glitch/features/emoji/render.test.ts +++ b/app/javascript/flavours/glitch/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/flavours/glitch/features/emoji/render.ts b/app/javascript/flavours/glitch/features/emoji/render.ts index 4b65f3abde..affa8e0f16 100644 --- a/app/javascript/flavours/glitch/features/emoji/render.ts +++ b/app/javascript/flavours/glitch/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/flavours/glitch/utils/objects.test.ts b/app/javascript/flavours/glitch/utils/objects.test.ts new file mode 100644 index 0000000000..5c08cd0561 --- /dev/null +++ b/app/javascript/flavours/glitch/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/flavours/glitch/utils/objects.ts b/app/javascript/flavours/glitch/utils/objects.ts new file mode 100644 index 0000000000..4430849c0c --- /dev/null +++ b/app/javascript/flavours/glitch/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; +}