diff --git a/app/javascript/mastodon/components/account_bio.tsx b/app/javascript/mastodon/components/account_bio.tsx index b720b4746d..b5ff686f86 100644 --- a/app/javascript/mastodon/components/account_bio.tsx +++ b/app/javascript/mastodon/components/account_bio.tsx @@ -1,11 +1,15 @@ import { useCallback } from 'react'; +import classNames from 'classnames'; + import { useLinks } from 'mastodon/hooks/useLinks'; -import { EmojiHTML } from '../features/emoji/emoji_html'; import { useAppSelector } from '../store'; import { isModernEmojiEnabled } from '../utils/environment'; +import { AnimateEmojiProvider } from './emoji/context'; +import { EmojiHTML } from './emoji/html'; + interface AccountBioProps { className: string; accountId: string; @@ -44,13 +48,13 @@ export const AccountBio: React.FC = ({ } return ( -
-
+ ); }; diff --git a/app/javascript/mastodon/components/display_name/no-domain.tsx b/app/javascript/mastodon/components/display_name/no-domain.tsx index 3a66fe5042..bb5a093659 100644 --- a/app/javascript/mastodon/components/display_name/no-domain.tsx +++ b/app/javascript/mastodon/components/display_name/no-domain.tsx @@ -2,9 +2,10 @@ import type { ComponentPropsWithoutRef, FC } from 'react'; import classNames from 'classnames'; -import { EmojiHTML } from '@/mastodon/features/emoji/emoji_html'; import { isModernEmojiEnabled } from '@/mastodon/utils/environment'; +import { AnimateEmojiProvider } from '../emoji/context'; +import { EmojiHTML } from '../emoji/html'; import { Skeleton } from '../skeleton'; import type { DisplayNameProps } from './index'; @@ -14,9 +15,10 @@ export const DisplayNameWithoutDomain: FC< ComponentPropsWithoutRef<'span'> > = ({ account, className, children, ...props }) => { return ( - {account ? ( @@ -27,8 +29,8 @@ export const DisplayNameWithoutDomain: FC< ? account.get('display_name') : account.get('display_name_html') } - shallow as='strong' + extraEmojis={account.get('emojis')} /> ) : ( @@ -37,6 +39,6 @@ export const DisplayNameWithoutDomain: FC< )} {children} - + ); }; diff --git a/app/javascript/mastodon/components/display_name/simple.tsx b/app/javascript/mastodon/components/display_name/simple.tsx index 3190c4384b..375f4932b2 100644 --- a/app/javascript/mastodon/components/display_name/simple.tsx +++ b/app/javascript/mastodon/components/display_name/simple.tsx @@ -1,8 +1,9 @@ import type { ComponentPropsWithoutRef, FC } from 'react'; -import { EmojiHTML } from '@/mastodon/features/emoji/emoji_html'; import { isModernEmojiEnabled } from '@/mastodon/utils/environment'; +import { EmojiHTML } from '../emoji/html'; + import type { DisplayNameProps } from './index'; export const DisplayNameSimple: FC< @@ -12,12 +13,19 @@ export const DisplayNameSimple: FC< if (!account) { return null; } - const accountName = isModernEmojiEnabled() - ? account.get('display_name') - : account.get('display_name_html'); + return ( - + ); }; diff --git a/app/javascript/mastodon/components/emoji/context.tsx b/app/javascript/mastodon/components/emoji/context.tsx new file mode 100644 index 0000000000..9fda5714d9 --- /dev/null +++ b/app/javascript/mastodon/components/emoji/context.tsx @@ -0,0 +1,108 @@ +import type { MouseEventHandler, PropsWithChildren } from 'react'; +import { + createContext, + useCallback, + useContext, + useMemo, + useState, +} from 'react'; + +import classNames from 'classnames'; + +import { cleanExtraEmojis } from '@/mastodon/features/emoji/normalize'; +import { autoPlayGif } from '@/mastodon/initial_state'; +import { polymorphicForwardRef } from '@/types/polymorphic'; +import type { + CustomEmojiMapArg, + ExtraCustomEmojiMap, +} from 'mastodon/features/emoji/types'; + +// Animation context +export const AnimateEmojiContext = createContext(null); + +// Polymorphic provider component +type AnimateEmojiProviderProps = Required & { + className?: string; +}; + +export const AnimateEmojiProvider = polymorphicForwardRef< + 'div', + AnimateEmojiProviderProps +>( + ( + { + children, + as: Wrapper = 'div', + className, + onMouseEnter, + onMouseLeave, + ...props + }, + ref, + ) => { + const [animate, setAnimate] = useState(autoPlayGif ?? false); + + const handleEnter: MouseEventHandler = useCallback( + (event) => { + onMouseEnter?.(event); + if (!autoPlayGif) { + setAnimate(true); + } + }, + [onMouseEnter], + ); + const handleLeave: MouseEventHandler = useCallback( + (event) => { + onMouseLeave?.(event); + if (!autoPlayGif) { + setAnimate(false); + } + }, + [onMouseLeave], + ); + + // If there's a parent context or GIFs autoplay, we don't need handlers. + const parentContext = useContext(AnimateEmojiContext); + if (parentContext !== null || autoPlayGif === true) { + return ( + + {children} + + ); + } + + return ( + + + {children} + + + ); + }, +); +AnimateEmojiProvider.displayName = 'AnimateEmojiProvider'; + +// Handle custom emoji +export const CustomEmojiContext = createContext({}); + +export const CustomEmojiProvider = ({ + children, + emojis: rawEmojis, +}: PropsWithChildren<{ emojis?: CustomEmojiMapArg }>) => { + const emojis = useMemo(() => cleanExtraEmojis(rawEmojis) ?? {}, [rawEmojis]); + return ( + + {children} + + ); +}; diff --git a/app/javascript/mastodon/components/emoji/html.tsx b/app/javascript/mastodon/components/emoji/html.tsx new file mode 100644 index 0000000000..a6ecc869c1 --- /dev/null +++ b/app/javascript/mastodon/components/emoji/html.tsx @@ -0,0 +1,61 @@ +import { useMemo } from 'react'; +import type { ComponentPropsWithoutRef, ElementType } from 'react'; + +import classNames from 'classnames'; + +import type { CustomEmojiMapArg } from '@/mastodon/features/emoji/types'; +import { isModernEmojiEnabled } from '@/mastodon/utils/environment'; +import { htmlStringToComponents } from '@/mastodon/utils/html'; + +import { AnimateEmojiProvider, CustomEmojiProvider } from './context'; +import { textToEmojis } from './index'; + +type EmojiHTMLProps = Omit< + ComponentPropsWithoutRef, + 'dangerouslySetInnerHTML' | 'className' +> & { + htmlString: string; + extraEmojis?: CustomEmojiMapArg; + as?: Element; + className?: string; +}; + +export const ModernEmojiHTML = ({ + extraEmojis, + htmlString, + as: asProp = 'div', // Rename for syntax highlighting + shallow, + className = '', + ...props +}: EmojiHTMLProps) => { + const contents = useMemo( + () => htmlStringToComponents(htmlString, { onText: textToEmojis }), + [htmlString], + ); + + return ( + + + {contents} + + + ); +}; + +export const LegacyEmojiHTML = ( + props: EmojiHTMLProps, +) => { + const { as: asElement, htmlString, extraEmojis, className, ...rest } = props; + const Wrapper = asElement ?? 'div'; + return ( + + ); +}; + +export const EmojiHTML = isModernEmojiEnabled() + ? ModernEmojiHTML + : LegacyEmojiHTML; diff --git a/app/javascript/mastodon/components/emoji/index.tsx b/app/javascript/mastodon/components/emoji/index.tsx new file mode 100644 index 0000000000..e070eb30dd --- /dev/null +++ b/app/javascript/mastodon/components/emoji/index.tsx @@ -0,0 +1,99 @@ +import type { FC } from 'react'; +import { useContext, useEffect, useState } from 'react'; + +import { EMOJI_TYPE_CUSTOM } from '@/mastodon/features/emoji/constants'; +import { useEmojiAppState } from '@/mastodon/features/emoji/hooks'; +import { unicodeHexToUrl } from '@/mastodon/features/emoji/normalize'; +import { + isStateLoaded, + loadEmojiDataToState, + shouldRenderImage, + stringToEmojiState, + tokenizeText, +} from '@/mastodon/features/emoji/render'; + +import { AnimateEmojiContext, CustomEmojiContext } from './context'; + +interface EmojiProps { + code: string; + showFallback?: boolean; + showLoading?: boolean; +} + +export const Emoji: FC = ({ + code, + showFallback = true, + showLoading = true, +}) => { + const customEmoji = useContext(CustomEmojiContext); + + // First, set the emoji state based on the input code. + const [state, setState] = useState(() => + stringToEmojiState(code, customEmoji), + ); + + // If we don't have data, then load emoji data asynchronously. + const appState = useEmojiAppState(); + useEffect(() => { + if (state !== null) { + void loadEmojiDataToState(state, appState.currentLocale).then(setState); + } + }, [appState.currentLocale, state]); + + const animate = useContext(AnimateEmojiContext); + const fallback = showFallback ? code : null; + + // If the code is invalid or we otherwise know it's not valid, show the fallback. + if (!state) { + return fallback; + } + + if (!shouldRenderImage(state, appState.mode)) { + return code; + } + + if (!isStateLoaded(state)) { + if (showLoading) { + return ; + } + return fallback; + } + + if (state.type === EMOJI_TYPE_CUSTOM) { + const shortcode = `:${state.code}:`; + return ( + {shortcode} + ); + } + + const src = unicodeHexToUrl(state.code, appState.darkTheme); + + return ( + {state.data.unicode} + ); +}; + +/** + * Takes a text string and converts it to an array of React nodes. + * @param text The text to be tokenized and converted. + */ +export function textToEmojis(text: string) { + return tokenizeText(text).map((token, index) => { + if (typeof token === 'string') { + return token; + } + return ; + }); +} diff --git a/app/javascript/mastodon/components/status_content.jsx b/app/javascript/mastodon/components/status_content.jsx index af0059c7d6..d766793d87 100644 --- a/app/javascript/mastodon/components/status_content.jsx +++ b/app/javascript/mastodon/components/status_content.jsx @@ -13,10 +13,12 @@ import ChevronRightIcon from '@/material-icons/400-24px/chevron_right.svg?react' import { Icon } from 'mastodon/components/icon'; import { Poll } from 'mastodon/components/poll'; import { identityContextPropShape, withIdentity } from 'mastodon/identity_context'; -import { autoPlayGif, languages as preloadedLanguages } from 'mastodon/initial_state'; -import { EmojiHTML } from '../features/emoji/emoji_html'; +import { languages as preloadedLanguages } from 'mastodon/initial_state'; + import { isModernEmojiEnabled } from '../utils/environment'; +import { EmojiHTML } from './emoji/html'; + const MAX_HEIGHT = 706; // 22px * 32 (+ 2px padding at the top) /** diff --git a/app/javascript/mastodon/features/account_timeline/components/account_header.tsx b/app/javascript/mastodon/features/account_timeline/components/account_header.tsx index f58f1f4a8c..2be026c8f9 100644 --- a/app/javascript/mastodon/features/account_timeline/components/account_header.tsx +++ b/app/javascript/mastodon/features/account_timeline/components/account_header.tsx @@ -8,6 +8,7 @@ import { NavLink } from 'react-router-dom'; import { AccountBio } from '@/mastodon/components/account_bio'; import { DisplayName } from '@/mastodon/components/display_name'; +import { AnimateEmojiProvider } from '@/mastodon/components/emoji/context'; import CheckIcon from '@/material-icons/400-24px/check.svg?react'; import LockIcon from '@/material-icons/400-24px/lock.svg?react'; import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react'; @@ -777,8 +778,8 @@ export const AccountHeader: React.FC<{ )} -
@@ -967,7 +968,7 @@ export const AccountHeader: React.FC<{
)} - + {!(hideTabs || hidden) && (
diff --git a/app/javascript/mastodon/features/direct_timeline/components/conversation.jsx b/app/javascript/mastodon/features/direct_timeline/components/conversation.jsx index bb0815087b..fbe37f58a2 100644 --- a/app/javascript/mastodon/features/direct_timeline/components/conversation.jsx +++ b/app/javascript/mastodon/features/direct_timeline/components/conversation.jsx @@ -25,6 +25,7 @@ import StatusContent from 'mastodon/components/status_content'; import { Dropdown } from 'mastodon/components/dropdown_menu'; import { makeGetStatus } from 'mastodon/selectors'; import { LinkedDisplayName } from '@/mastodon/components/display_name'; +import { AnimateEmojiProvider } from '@/mastodon/components/emoji/context'; const messages = defineMessages({ more: { id: 'status.more', defaultMessage: 'More' }, @@ -136,9 +137,9 @@ export const Conversation = ({ conversation, scrollKey }) => { {unread && }
-
+ {names} }} /> -
+ { if (loadedLocales.has(locale)) { return true; diff --git a/app/javascript/mastodon/features/emoji/emoji_html.tsx b/app/javascript/mastodon/features/emoji/emoji_html.tsx deleted file mode 100644 index b4c352073c..0000000000 --- a/app/javascript/mastodon/features/emoji/emoji_html.tsx +++ /dev/null @@ -1,70 +0,0 @@ -import type { ComponentPropsWithoutRef, ElementType } from 'react'; - -import classNames from 'classnames'; - -import { isModernEmojiEnabled } from '@/mastodon/utils/environment'; - -import { useEmojify } from './hooks'; -import type { CustomEmojiMapArg } from './types'; - -type EmojiHTMLProps = Omit< - ComponentPropsWithoutRef, - 'dangerouslySetInnerHTML' | 'className' -> & { - htmlString: string; - extraEmojis?: CustomEmojiMapArg; - as?: Element; - shallow?: boolean; - className?: string; -}; - -export const ModernEmojiHTML = ({ - extraEmojis, - htmlString, - as: Wrapper = 'div', // Rename for syntax highlighting - shallow, - className = '', - ...props -}: EmojiHTMLProps) => { - const emojifiedHtml = useEmojify({ - text: htmlString, - extraEmojis, - deep: !shallow, - }); - - if (emojifiedHtml === null) { - return null; - } - - return ( - - ); -}; - -export const EmojiHTML = ( - props: EmojiHTMLProps, -) => { - if (isModernEmojiEnabled()) { - return ; - } - const { - as: asElement, - htmlString, - extraEmojis, - className, - shallow: _, - ...rest - } = props; - const Wrapper = asElement ?? 'div'; - return ( - - ); -}; diff --git a/app/javascript/mastodon/features/emoji/loader.ts b/app/javascript/mastodon/features/emoji/loader.ts index 72f57b6f6c..3196b28b9c 100644 --- a/app/javascript/mastodon/features/emoji/loader.ts +++ b/app/javascript/mastodon/features/emoji/loader.ts @@ -1,8 +1,6 @@ import { flattenEmojiData } from 'emojibase'; import type { CompactEmoji, FlatCompactEmoji } from 'emojibase'; -import type { ApiCustomEmojiJSON } from '@/mastodon/api_types/custom_emoji'; - import { putEmojiData, putCustomEmojiData, @@ -10,7 +8,7 @@ import { putLatestEtag, } from './database'; import { toSupportedLocale, toSupportedLocaleOrCustom } from './locale'; -import type { LocaleOrCustom } from './types'; +import type { CustomEmojiData, LocaleOrCustom } from './types'; import { emojiLogger } from './utils'; const log = emojiLogger('loader'); @@ -27,7 +25,7 @@ export async function importEmojiData(localeString: string) { } export async function importCustomEmojiData() { - const emojis = await fetchAndCheckEtag('custom'); + const emojis = await fetchAndCheckEtag('custom'); if (!emojis) { return; } diff --git a/app/javascript/mastodon/features/emoji/normalize.test.ts b/app/javascript/mastodon/features/emoji/normalize.test.ts index f0ea140590..b4c7669961 100644 --- a/app/javascript/mastodon/features/emoji/normalize.test.ts +++ b/app/javascript/mastodon/features/emoji/normalize.test.ts @@ -5,11 +5,8 @@ import { flattenEmojiData } from 'emojibase'; import unicodeRawEmojis from 'emojibase-data/en/data.json'; import { - twemojiHasBorder, twemojiToUnicodeInfo, unicodeToTwemojiHex, - CODES_WITH_DARK_BORDER, - CODES_WITH_LIGHT_BORDER, emojiToUnicodeHex, } from './normalize'; @@ -57,26 +54,6 @@ describe('unicodeToTwemojiHex', () => { }); }); -describe('twemojiHasBorder', () => { - test.concurrent.for( - svgFileNames - .filter((file) => file.endsWith('_border')) - .map((file) => { - const hexCode = file.replace('_border', ''); - return [ - hexCode, - CODES_WITH_LIGHT_BORDER.includes(hexCode.toUpperCase()), - CODES_WITH_DARK_BORDER.includes(hexCode.toUpperCase()), - ] as const; - }), - )('twemojiHasBorder for %s', ([hexCode, isLight, isDark], { expect }) => { - const result = twemojiHasBorder(hexCode); - expect(result).toHaveProperty('hexCode', hexCode); - expect(result).toHaveProperty('hasLightBorder', isLight); - expect(result).toHaveProperty('hasDarkBorder', isDark); - }); -}); - describe('twemojiToUnicodeInfo', () => { const unicodeCodeSet = new Set(unicodeEmojis.map((emoji) => emoji.hexcode)); diff --git a/app/javascript/mastodon/features/emoji/normalize.ts b/app/javascript/mastodon/features/emoji/normalize.ts index 959732f985..65667dfe6d 100644 --- a/app/javascript/mastodon/features/emoji/normalize.ts +++ b/app/javascript/mastodon/features/emoji/normalize.ts @@ -1,5 +1,7 @@ import { isList } from 'immutable'; +import { assetHost } from '@/mastodon/utils/config'; + import { VARIATION_SELECTOR_CODE, KEYCAP_CODE, @@ -9,11 +11,7 @@ import { EMOJIS_WITH_DARK_BORDER, EMOJIS_WITH_LIGHT_BORDER, } from './constants'; -import type { - CustomEmojiMapArg, - ExtraCustomEmojiMap, - TwemojiBorderInfo, -} from './types'; +import type { CustomEmojiMapArg, ExtraCustomEmojiMap } from './types'; // Misc codes that have special handling const SKIER_CODE = 0x26f7; @@ -67,21 +65,17 @@ export const CODES_WITH_DARK_BORDER = export const CODES_WITH_LIGHT_BORDER = EMOJIS_WITH_LIGHT_BORDER.map(emojiToUnicodeHex); -export function twemojiHasBorder(twemojiHex: string): TwemojiBorderInfo { - const normalizedHex = twemojiHex.toUpperCase(); - let hasLightBorder = false; - let hasDarkBorder = false; - if (CODES_WITH_LIGHT_BORDER.includes(normalizedHex)) { - hasLightBorder = true; +export function unicodeHexToUrl(unicodeHex: string, darkMode: boolean): string { + const normalizedHex = unicodeToTwemojiHex(unicodeHex); + let url = `${assetHost}/emoji/${normalizedHex}`; + if (darkMode && CODES_WITH_LIGHT_BORDER.includes(normalizedHex)) { + url += '_border'; } if (CODES_WITH_DARK_BORDER.includes(normalizedHex)) { - hasDarkBorder = true; + url += '_border'; } - return { - hexCode: twemojiHex, - hasLightBorder, - hasDarkBorder, - }; + url += '.svg'; + return url; } interface TwemojiSpecificEmoji { diff --git a/app/javascript/mastodon/features/emoji/render.test.ts b/app/javascript/mastodon/features/emoji/render.test.ts index e9609e15dc..108cf74750 100644 --- a/app/javascript/mastodon/features/emoji/render.test.ts +++ b/app/javascript/mastodon/features/emoji/render.test.ts @@ -1,10 +1,6 @@ import { customEmojiFactory, unicodeEmojiFactory } from '@/testing/factories'; -import { - EMOJI_MODE_NATIVE, - EMOJI_MODE_NATIVE_WITH_FLAGS, - EMOJI_MODE_TWEMOJI, -} from './constants'; +import { EMOJI_MODE_TWEMOJI } from './constants'; import * as db from './database'; import { emojifyElement, @@ -12,7 +8,7 @@ import { testCacheClear, tokenizeText, } from './render'; -import type { EmojiAppState, ExtraCustomEmojiMap } from './types'; +import type { EmojiAppState } from './types'; function mockDatabase() { return { @@ -40,18 +36,6 @@ const expectedSmileImage = '😊'; const expectedFlagImage = '🇪🇺'; -const expectedCustomEmojiImage = - ':custom:'; -const expectedRemoteCustomEmojiImage = - ':remote:'; - -const mockExtraCustom: ExtraCustomEmojiMap = { - remote: { - shortcode: 'remote', - static_url: 'remote.social/static', - url: 'remote.social/custom', - }, -}; function testAppState(state: Partial = {}) { return { @@ -86,64 +70,10 @@ describe('emojifyElement', () => { 'en', ); expect(searchCustomEmojisByShortcodes).toHaveBeenCalledExactlyOnceWith([ - 'custom', + ':custom:', ]); }); - test('emojifies custom emoji in native mode', async () => { - const { searchEmojisByHexcodes } = mockDatabase(); - const actual = await emojifyElement( - testElement(), - testAppState({ mode: EMOJI_MODE_NATIVE }), - ); - assert(actual); - expect(actual.innerHTML).toBe( - `

Hello 😊🇪🇺!

${expectedCustomEmojiImage}

`, - ); - expect(searchEmojisByHexcodes).not.toHaveBeenCalled(); - }); - - test('emojifies flag emoji in native-with-flags mode', async () => { - const { searchEmojisByHexcodes } = mockDatabase(); - const actual = await emojifyElement( - testElement(), - testAppState({ mode: EMOJI_MODE_NATIVE_WITH_FLAGS }), - ); - assert(actual); - expect(actual.innerHTML).toBe( - `

Hello 😊${expectedFlagImage}!

${expectedCustomEmojiImage}

`, - ); - expect(searchEmojisByHexcodes).toHaveBeenCalledOnce(); - }); - - test('emojifies everything in twemoji mode', async () => { - const { searchCustomEmojisByShortcodes, searchEmojisByHexcodes } = - mockDatabase(); - const actual = await emojifyElement(testElement(), testAppState()); - assert(actual); - expect(actual.innerHTML).toBe( - `

Hello ${expectedSmileImage}${expectedFlagImage}!

${expectedCustomEmojiImage}

`, - ); - expect(searchEmojisByHexcodes).toHaveBeenCalledOnce(); - expect(searchCustomEmojisByShortcodes).toHaveBeenCalledOnce(); - }); - - test('emojifies with provided custom emoji', async () => { - const { searchCustomEmojisByShortcodes, searchEmojisByHexcodes } = - mockDatabase(); - const actual = await emojifyElement( - testElement('

hi :remote:

'), - testAppState(), - mockExtraCustom, - ); - assert(actual); - expect(actual.innerHTML).toBe( - `

hi ${expectedRemoteCustomEmojiImage}

`, - ); - expect(searchEmojisByHexcodes).not.toHaveBeenCalled(); - expect(searchCustomEmojisByShortcodes).not.toHaveBeenCalled(); - }); - test('returns null when no emoji are found', async () => { mockDatabase(); const actual = await emojifyElement( @@ -165,28 +95,9 @@ describe('emojifyText', () => { const actual = await emojifyText('Hello 😊🇪🇺!', testAppState()); expect(actual).toBe(`Hello ${expectedSmileImage}${expectedFlagImage}!`); }); - - test('renders custom emojis', async () => { - mockDatabase(); - const actual = await emojifyText('Hello :custom:!', testAppState()); - expect(actual).toBe(`Hello ${expectedCustomEmojiImage}!`); - }); - - test('renders provided extra emojis', async () => { - const actual = await emojifyText( - 'remote emoji :remote:', - testAppState(), - mockExtraCustom, - ); - expect(actual).toBe(`remote emoji ${expectedRemoteCustomEmojiImage}`); - }); }); describe('tokenizeText', () => { - test('returns empty array for string with only whitespace', () => { - expect(tokenizeText(' \n')).toEqual([]); - }); - test('returns an array of text to be a single token', () => { expect(tokenizeText('Hello')).toEqual(['Hello']); }); @@ -212,7 +123,7 @@ describe('tokenizeText', () => { 'Hello ', { type: 'custom', - code: 'smile', + code: ':smile:', }, '!!', ]); @@ -223,7 +134,7 @@ describe('tokenizeText', () => { 'Hello ', { type: 'custom', - code: 'smile_123', + code: ':smile_123:', }, '!!', ]); @@ -239,7 +150,7 @@ describe('tokenizeText', () => { ' ', { type: 'custom', - code: 'smile', + code: ':smile:', }, '!!', ]); diff --git a/app/javascript/mastodon/features/emoji/render.ts b/app/javascript/mastodon/features/emoji/render.ts index 8d2299fd89..e0c8fd8dce 100644 --- a/app/javascript/mastodon/features/emoji/render.ts +++ b/app/javascript/mastodon/features/emoji/render.ts @@ -1,6 +1,5 @@ import { autoPlayGif } from '@/mastodon/initial_state'; import { createLimitedCache } from '@/mastodon/utils/cache'; -import { assetHost } from '@/mastodon/utils/config'; import * as perf from '@/mastodon/utils/performance'; import { @@ -8,38 +7,130 @@ import { EMOJI_MODE_NATIVE_WITH_FLAGS, EMOJI_TYPE_UNICODE, EMOJI_TYPE_CUSTOM, - EMOJI_STATE_MISSING, } from './constants'; import { + loadCustomEmojiByShortcode, + loadEmojiByHexcode, + LocaleNotLoadedError, searchCustomEmojisByShortcodes, searchEmojisByHexcodes, } from './database'; -import { - emojiToUnicodeHex, - twemojiHasBorder, - unicodeToTwemojiHex, -} from './normalize'; +import { importEmojiData } from './loader'; +import { emojiToUnicodeHex, unicodeHexToUrl } from './normalize'; import type { - CustomEmojiToken, EmojiAppState, EmojiLoadedState, EmojiMode, EmojiState, + EmojiStateCustom, EmojiStateMap, - EmojiToken, + EmojiStateUnicode, ExtraCustomEmojiMap, LocaleOrCustom, - UnicodeEmojiToken, } from './types'; import { anyEmojiRegex, emojiLogger, + isCustomEmoji, + isUnicodeEmoji, stringHasAnyEmoji, stringHasUnicodeFlags, } from './utils'; const log = emojiLogger('render'); +/** + * 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; +} + /** * Emojifies an element. This modifies the element in place, replacing text nodes with emojified versions. */ @@ -177,7 +268,11 @@ async function textToElementArray( if (token.type === EMOJI_TYPE_CUSTOM) { const extraEmojiData = extraEmojis[token.code]; if (extraEmojiData) { - state = { type: EMOJI_TYPE_CUSTOM, data: extraEmojiData }; + state = { + type: EMOJI_TYPE_CUSTOM, + data: extraEmojiData, + code: token.code, + }; } else { state = emojiForLocale(token.code, EMOJI_TYPE_CUSTOM); } @@ -189,7 +284,7 @@ async function textToElementArray( } // If the state is valid, create an image element. Otherwise, just append as text. - if (state && typeof state !== 'string') { + if (state && typeof state !== 'string' && isStateLoaded(state)) { const image = stateToImage(state, appState); renderedFragments.push(image); continue; @@ -202,11 +297,11 @@ async function textToElementArray( return renderedFragments; } -type TokenizedText = (string | EmojiToken)[]; +type TokenizedText = (string | EmojiState)[]; export function tokenizeText(text: string): TokenizedText { if (!text.trim()) { - return []; + return [text]; } const tokens = []; @@ -222,14 +317,14 @@ export function tokenizeText(text: string): TokenizedText { // Custom emoji tokens.push({ type: EMOJI_TYPE_CUSTOM, - code: code.slice(1, -1), // Remove the colons - } satisfies CustomEmojiToken); + code, + } satisfies EmojiStateCustom); } else { // Unicode emoji tokens.push({ type: EMOJI_TYPE_UNICODE, code: code, - } satisfies UnicodeEmojiToken); + } satisfies EmojiStateUnicode); } lastIndex = match.index + code.length; } @@ -304,13 +399,11 @@ async function loadMissingEmojiIntoCache( const emojis = await searchEmojisByHexcodes(missingEmojis, currentLocale); const cache = cacheForLocale(currentLocale); for (const emoji of emojis) { - cache.set(emoji.hexcode, { type: EMOJI_TYPE_UNICODE, data: emoji }); - } - const notFoundEmojis = missingEmojis.filter((code) => - emojis.every((emoji) => emoji.hexcode !== code), - ); - for (const code of notFoundEmojis) { - cache.set(code, EMOJI_STATE_MISSING); // Mark as missing if not found, as it's probably not a valid emoji. + cache.set(emoji.hexcode, { + type: EMOJI_TYPE_UNICODE, + data: emoji, + code: emoji.hexcode, + }); } localeCacheMap.set(currentLocale, cache); } @@ -320,19 +413,17 @@ async function loadMissingEmojiIntoCache( const emojis = await searchCustomEmojisByShortcodes(missingEmojis); const cache = cacheForLocale(EMOJI_TYPE_CUSTOM); for (const emoji of emojis) { - cache.set(emoji.shortcode, { type: EMOJI_TYPE_CUSTOM, data: emoji }); - } - const notFoundEmojis = missingEmojis.filter((code) => - emojis.every((emoji) => emoji.shortcode !== code), - ); - for (const code of notFoundEmojis) { - cache.set(code, EMOJI_STATE_MISSING); // Mark as missing if not found, as it's probably not a valid emoji. + cache.set(emoji.shortcode, { + type: EMOJI_TYPE_CUSTOM, + data: emoji, + code: emoji.shortcode, + }); } localeCacheMap.set(EMOJI_TYPE_CUSTOM, cache); } } -function shouldRenderImage(token: EmojiToken, mode: EmojiMode): boolean { +export function shouldRenderImage(token: EmojiState, mode: EmojiMode): boolean { if (token.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. @@ -354,18 +445,9 @@ function stateToImage(state: EmojiLoadedState, appState: EmojiAppState) { image.classList.add('emojione'); if (state.type === EMOJI_TYPE_UNICODE) { - const emojiInfo = twemojiHasBorder(unicodeToTwemojiHex(state.data.hexcode)); - let fileName = emojiInfo.hexCode; - if ( - (appState.darkTheme && emojiInfo.hasDarkBorder) || - (!appState.darkTheme && emojiInfo.hasLightBorder) - ) { - fileName = `${emojiInfo.hexCode}_border`; - } - image.alt = state.data.unicode; image.title = state.data.label; - image.src = `${assetHost}/emoji/${fileName}.svg`; + image.src = unicodeHexToUrl(state.data.hexcode, appState.darkTheme); } else { // Custom emoji const shortCode = `:${state.data.shortcode}:`; diff --git a/app/javascript/mastodon/features/emoji/types.ts b/app/javascript/mastodon/features/emoji/types.ts index 85bbe6d1a5..043b21361b 100644 --- a/app/javascript/mastodon/features/emoji/types.ts +++ b/app/javascript/mastodon/features/emoji/types.ts @@ -10,7 +10,6 @@ import type { EMOJI_MODE_NATIVE, EMOJI_MODE_NATIVE_WITH_FLAGS, EMOJI_MODE_TWEMOJI, - EMOJI_STATE_MISSING, EMOJI_TYPE_CUSTOM, EMOJI_TYPE_UNICODE, } from './constants'; @@ -29,45 +28,40 @@ export interface EmojiAppState { darkTheme: boolean; } -export interface UnicodeEmojiToken { - type: typeof EMOJI_TYPE_UNICODE; - code: string; -} -export interface CustomEmojiToken { - type: typeof EMOJI_TYPE_CUSTOM; - code: string; -} -export type EmojiToken = UnicodeEmojiToken | CustomEmojiToken; - export type CustomEmojiData = ApiCustomEmojiJSON; export type UnicodeEmojiData = FlatCompactEmoji; export type AnyEmojiData = CustomEmojiData | UnicodeEmojiData; -export type EmojiStateMissing = typeof EMOJI_STATE_MISSING; +type CustomEmojiRenderFields = Pick< + CustomEmojiData, + 'shortcode' | 'static_url' | 'url' +>; + export interface EmojiStateUnicode { type: typeof EMOJI_TYPE_UNICODE; - data: UnicodeEmojiData; + code: string; + data?: UnicodeEmojiData; } export interface EmojiStateCustom { type: typeof EMOJI_TYPE_CUSTOM; - data: CustomEmojiRenderFields; + code: string; + data?: CustomEmojiRenderFields; } -export type EmojiState = - | EmojiStateMissing - | EmojiStateUnicode - | EmojiStateCustom; -export type EmojiLoadedState = EmojiStateUnicode | EmojiStateCustom; +export type EmojiState = EmojiStateUnicode | EmojiStateCustom; +export type EmojiLoadedState = + | Required + | Required; export type EmojiStateMap = LimitedCache; export type CustomEmojiMapArg = | ExtraCustomEmojiMap | ImmutableList; -export type CustomEmojiRenderFields = Pick< - CustomEmojiData, - 'shortcode' | 'static_url' | 'url' + +export type ExtraCustomEmojiMap = Record< + string, + Pick >; -export type ExtraCustomEmojiMap = Record; export interface TwemojiBorderInfo { hexCode: string; diff --git a/app/javascript/mastodon/features/emoji/utils.ts b/app/javascript/mastodon/features/emoji/utils.ts index ce35919929..e811565c27 100644 --- a/app/javascript/mastodon/features/emoji/utils.ts +++ b/app/javascript/mastodon/features/emoji/utils.ts @@ -10,6 +10,13 @@ export function stringHasUnicodeEmoji(input: string): boolean { return new RegExp(EMOJI_REGEX, supportedFlags()).test(input); } +export function isUnicodeEmoji(input: string): boolean { + return ( + input.length > 0 && + new RegExp(`^(${EMOJI_REGEX})+$`, supportedFlags()).test(input) + ); +} + export function stringHasUnicodeFlags(input: string): boolean { if (supportsRegExpSets()) { return new RegExp( @@ -27,6 +34,11 @@ export function stringHasUnicodeFlags(input: string): boolean { // Constant as this is supported by all browsers. const CUSTOM_EMOJI_REGEX = /:([a-z0-9_]+):/i; + +export function isCustomEmoji(input: string): boolean { + return new RegExp(`^${CUSTOM_EMOJI_REGEX.source}$`, 'i').test(input); +} + export function stringHasCustomEmoji(input: string) { return CUSTOM_EMOJI_REGEX.test(input); } diff --git a/app/javascript/mastodon/features/notifications_v2/components/embedded_status.tsx b/app/javascript/mastodon/features/notifications_v2/components/embedded_status.tsx index a17425169b..8e5e72b6aa 100644 --- a/app/javascript/mastodon/features/notifications_v2/components/embedded_status.tsx +++ b/app/javascript/mastodon/features/notifications_v2/components/embedded_status.tsx @@ -6,6 +6,7 @@ import { useHistory } from 'react-router-dom'; import type { List as ImmutableList, RecordOf } from 'immutable'; +import { AnimateEmojiProvider } from '@/mastodon/components/emoji/context'; import BarChart4BarsIcon from '@/material-icons/400-24px/bar_chart_4_bars.svg?react'; import PhotoLibraryIcon from '@/material-icons/400-24px/photo_library.svg?react'; import { toggleStatusSpoilers } from 'mastodon/actions/statuses'; @@ -96,8 +97,8 @@ export const EmbeddedStatus: React.FC<{ statusId: string }> = ({ ).size; return ( -
= ({ )}
)} - + ); }; diff --git a/app/javascript/types/polymorphic.ts b/app/javascript/types/polymorphic.ts new file mode 100644 index 0000000000..e58aa7b75e --- /dev/null +++ b/app/javascript/types/polymorphic.ts @@ -0,0 +1,75 @@ +import { forwardRef } from 'react'; +import type { + ElementType, + ComponentPropsWithRef, + ForwardRefRenderFunction, + ReactElement, + Ref, + ForwardRefExoticComponent, +} from 'react'; + +// This complicated type file is based on the following posts: +// - https://www.tsteele.dev/posts/react-polymorphic-forwardref +// - https://www.kripod.dev/blog/behind-the-as-prop-polymorphism-done-well/ +// - https://github.com/radix-ui/primitives/blob/7101e7d6efb2bff13cc6761023ab85aeec73539e/packages/react/polymorphic/src/forwardRefWithAs.ts +// Whenever we upgrade to React 19 or later, we can remove all this because ref is a prop there. + +// Utils +interface AsProp { + as?: As; +} +type PropsOf = ComponentPropsWithRef; + +/** + * Extract the element instance type (e.g. HTMLButtonElement) from ComponentPropsWithRef: + * - For intrinsic elements, look up in JSX.IntrinsicElements + * - For components, infer from `ComponentPropsWithRef` + */ +type ElementRef = + As extends keyof React.JSX.IntrinsicElements + ? React.JSX.IntrinsicElements[As] extends { ref?: Ref } + ? Inst + : never + : ComponentPropsWithRef extends { ref?: Ref } + ? Inst + : never; + +/** + * Merge additional props with intrinsic/element props for `as`. + * Additional props win on conflicts. + */ +type PolymorphicProps< + As extends ElementType, + AdditionalProps extends object = object, +> = AdditionalProps & + AsProp & + Omit, keyof AdditionalProps | 'ref'>; + +/** + * Signature of a component created with `polymorphicForwardRef`. + */ +type PolymorphicWithRef< + DefaultAs extends ElementType, + AdditionalProps extends object = object, +> = ( + props: PolymorphicProps & { ref?: Ref> }, +) => ReactElement | null; + +/** + * The type of `polymorphicForwardRef`. + */ +type PolyRefFunction = < + DefaultAs extends ElementType, + AdditionalProps extends object = object, +>( + render: ForwardRefRenderFunction< + ElementRef, + PolymorphicProps + >, +) => PolymorphicWithRef & + ForwardRefExoticComponent>; + +/** + * Polymorphic `forwardRef` function. + */ +export const polymorphicForwardRef = forwardRef as PolyRefFunction;