Emoji Component (#36293)
Some checks failed
Check i18n / check-i18n (push) Waiting to run
Chromatic / Run Chromatic (push) Waiting to run
CodeQL / Analyze (actions) (push) Waiting to run
CodeQL / Analyze (javascript) (push) Waiting to run
CodeQL / Analyze (ruby) (push) Waiting to run
Check formatting / lint (push) Waiting to run
JavaScript Linting / lint (push) Waiting to run
Ruby Linting / lint (push) Waiting to run
JavaScript Testing / test (push) Waiting to run
Historical data migration test / test (14-alpine) (push) Waiting to run
Historical data migration test / test (15-alpine) (push) Waiting to run
Historical data migration test / test (16-alpine) (push) Waiting to run
Historical data migration test / test (17-alpine) (push) Waiting to run
Ruby Testing / build (production) (push) Waiting to run
Ruby Testing / build (test) (push) Waiting to run
Ruby Testing / test (.ruby-version) (push) Blocked by required conditions
Ruby Testing / test (3.2) (push) Blocked by required conditions
Ruby Testing / test (3.3) (push) Blocked by required conditions
Ruby Testing / ImageMagick tests (.ruby-version) (push) Blocked by required conditions
Ruby Testing / ImageMagick tests (3.2) (push) Blocked by required conditions
Ruby Testing / ImageMagick tests (3.3) (push) Blocked by required conditions
Ruby Testing / End to End testing (.ruby-version) (push) Blocked by required conditions
Ruby Testing / End to End testing (3.2) (push) Blocked by required conditions
Ruby Testing / End to End testing (3.3) (push) Blocked by required conditions
Ruby Testing / Elastic Search integration testing (.ruby-version, docker.elastic.co/elasticsearch/elasticsearch:7.17.13) (push) Blocked by required conditions
Ruby Testing / Elastic Search integration testing (.ruby-version, docker.elastic.co/elasticsearch/elasticsearch:8.10.2) (push) Blocked by required conditions
Ruby Testing / Elastic Search integration testing (.ruby-version, opensearchproject/opensearch:2) (push) Blocked by required conditions
Ruby Testing / Elastic Search integration testing (3.2, docker.elastic.co/elasticsearch/elasticsearch:7.17.13) (push) Blocked by required conditions
Ruby Testing / Elastic Search integration testing (3.3, docker.elastic.co/elasticsearch/elasticsearch:7.17.13) (push) Blocked by required conditions
Bundler Audit / security (push) Has been cancelled
Haml Linting / lint (push) Has been cancelled

This commit is contained in:
Echo 2025-09-30 15:06:02 +02:00 committed by GitHub
parent ac50e5eebc
commit c12b8f51c1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 566 additions and 301 deletions

View File

@ -1,11 +1,15 @@
import { useCallback } from 'react'; import { useCallback } from 'react';
import classNames from 'classnames';
import { useLinks } from 'mastodon/hooks/useLinks'; import { useLinks } from 'mastodon/hooks/useLinks';
import { EmojiHTML } from '../features/emoji/emoji_html';
import { useAppSelector } from '../store'; import { useAppSelector } from '../store';
import { isModernEmojiEnabled } from '../utils/environment'; import { isModernEmojiEnabled } from '../utils/environment';
import { AnimateEmojiProvider } from './emoji/context';
import { EmojiHTML } from './emoji/html';
interface AccountBioProps { interface AccountBioProps {
className: string; className: string;
accountId: string; accountId: string;
@ -44,13 +48,13 @@ export const AccountBio: React.FC<AccountBioProps> = ({
} }
return ( return (
<div <AnimateEmojiProvider
className={`${className} translate`} className={classNames(className, 'translate')}
onClickCapture={handleClick} onClickCapture={handleClick}
ref={handleNodeChange} ref={handleNodeChange}
> >
<EmojiHTML htmlString={note} extraEmojis={extraEmojis} /> <EmojiHTML htmlString={note} extraEmojis={extraEmojis} />
</div> </AnimateEmojiProvider>
); );
}; };

View File

@ -2,9 +2,10 @@ import type { ComponentPropsWithoutRef, FC } from 'react';
import classNames from 'classnames'; import classNames from 'classnames';
import { EmojiHTML } from '@/mastodon/features/emoji/emoji_html';
import { isModernEmojiEnabled } from '@/mastodon/utils/environment'; import { isModernEmojiEnabled } from '@/mastodon/utils/environment';
import { AnimateEmojiProvider } from '../emoji/context';
import { EmojiHTML } from '../emoji/html';
import { Skeleton } from '../skeleton'; import { Skeleton } from '../skeleton';
import type { DisplayNameProps } from './index'; import type { DisplayNameProps } from './index';
@ -14,9 +15,10 @@ export const DisplayNameWithoutDomain: FC<
ComponentPropsWithoutRef<'span'> ComponentPropsWithoutRef<'span'>
> = ({ account, className, children, ...props }) => { > = ({ account, className, children, ...props }) => {
return ( return (
<span <AnimateEmojiProvider
{...props} {...props}
className={classNames('display-name animate-parent', className)} as='span'
className={classNames('display-name', className)}
> >
<bdi> <bdi>
{account ? ( {account ? (
@ -27,8 +29,8 @@ export const DisplayNameWithoutDomain: FC<
? account.get('display_name') ? account.get('display_name')
: account.get('display_name_html') : account.get('display_name_html')
} }
shallow
as='strong' as='strong'
extraEmojis={account.get('emojis')}
/> />
) : ( ) : (
<strong className='display-name__html'> <strong className='display-name__html'>
@ -37,6 +39,6 @@ export const DisplayNameWithoutDomain: FC<
)} )}
</bdi> </bdi>
{children} {children}
</span> </AnimateEmojiProvider>
); );
}; };

View File

@ -1,8 +1,9 @@
import type { ComponentPropsWithoutRef, FC } from 'react'; import type { ComponentPropsWithoutRef, FC } from 'react';
import { EmojiHTML } from '@/mastodon/features/emoji/emoji_html';
import { isModernEmojiEnabled } from '@/mastodon/utils/environment'; import { isModernEmojiEnabled } from '@/mastodon/utils/environment';
import { EmojiHTML } from '../emoji/html';
import type { DisplayNameProps } from './index'; import type { DisplayNameProps } from './index';
export const DisplayNameSimple: FC< export const DisplayNameSimple: FC<
@ -12,12 +13,19 @@ export const DisplayNameSimple: FC<
if (!account) { if (!account) {
return null; return null;
} }
const accountName = isModernEmojiEnabled()
? account.get('display_name')
: account.get('display_name_html');
return ( return (
<bdi> <bdi>
<EmojiHTML {...props} htmlString={accountName} shallow as='span' /> <EmojiHTML
{...props}
as='span'
htmlString={
isModernEmojiEnabled()
? account.get('display_name')
: account.get('display_name_html')
}
extraEmojis={account.get('emojis')}
/>
</bdi> </bdi>
); );
}; };

View File

@ -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<boolean | null>(null);
// Polymorphic provider component
type AnimateEmojiProviderProps = Required<PropsWithChildren> & {
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<HTMLDivElement> = useCallback(
(event) => {
onMouseEnter?.(event);
if (!autoPlayGif) {
setAnimate(true);
}
},
[onMouseEnter],
);
const handleLeave: MouseEventHandler<HTMLDivElement> = 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 (
<Wrapper
{...props}
className={classNames(className, 'animate-parent')}
ref={ref}
>
{children}
</Wrapper>
);
}
return (
<Wrapper
{...props}
className={classNames(className, 'animate-parent')}
onMouseEnter={handleEnter}
onMouseLeave={handleLeave}
ref={ref}
>
<AnimateEmojiContext.Provider value={animate}>
{children}
</AnimateEmojiContext.Provider>
</Wrapper>
);
},
);
AnimateEmojiProvider.displayName = 'AnimateEmojiProvider';
// Handle custom emoji
export const CustomEmojiContext = createContext<ExtraCustomEmojiMap>({});
export const CustomEmojiProvider = ({
children,
emojis: rawEmojis,
}: PropsWithChildren<{ emojis?: CustomEmojiMapArg }>) => {
const emojis = useMemo(() => cleanExtraEmojis(rawEmojis) ?? {}, [rawEmojis]);
return (
<CustomEmojiContext.Provider value={emojis}>
{children}
</CustomEmojiContext.Provider>
);
};

View File

@ -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<Element extends ElementType = 'div'> = Omit<
ComponentPropsWithoutRef<Element>,
'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<ElementType>) => {
const contents = useMemo(
() => htmlStringToComponents(htmlString, { onText: textToEmojis }),
[htmlString],
);
return (
<CustomEmojiProvider emojis={extraEmojis}>
<AnimateEmojiProvider {...props} as={asProp} className={className}>
{contents}
</AnimateEmojiProvider>
</CustomEmojiProvider>
);
};
export const LegacyEmojiHTML = <Element extends ElementType>(
props: EmojiHTMLProps<Element>,
) => {
const { as: asElement, htmlString, extraEmojis, className, ...rest } = props;
const Wrapper = asElement ?? 'div';
return (
<Wrapper
{...rest}
dangerouslySetInnerHTML={{ __html: htmlString }}
className={classNames(className, 'animate-parent')}
/>
);
};
export const EmojiHTML = isModernEmojiEnabled()
? ModernEmojiHTML
: LegacyEmojiHTML;

View File

@ -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<EmojiProps> = ({
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 <span className='emojione emoji-loading' title={code} />;
}
return fallback;
}
if (state.type === EMOJI_TYPE_CUSTOM) {
const shortcode = `:${state.code}:`;
return (
<img
src={animate ? state.data.url : state.data.static_url}
alt={shortcode}
title={shortcode}
className='emojione custom-emoji'
loading='lazy'
/>
);
}
const src = unicodeHexToUrl(state.code, appState.darkTheme);
return (
<img
src={src}
alt={state.data.unicode}
title={state.data.label}
className='emojione'
loading='lazy'
/>
);
};
/**
* 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 <Emoji code={token.code} key={`emoji-${token.code}-${index}`} />;
});
}

View File

@ -13,10 +13,12 @@ import ChevronRightIcon from '@/material-icons/400-24px/chevron_right.svg?react'
import { Icon } from 'mastodon/components/icon'; import { Icon } from 'mastodon/components/icon';
import { Poll } from 'mastodon/components/poll'; import { Poll } from 'mastodon/components/poll';
import { identityContextPropShape, withIdentity } from 'mastodon/identity_context'; import { identityContextPropShape, withIdentity } from 'mastodon/identity_context';
import { autoPlayGif, languages as preloadedLanguages } from 'mastodon/initial_state'; import { languages as preloadedLanguages } from 'mastodon/initial_state';
import { EmojiHTML } from '../features/emoji/emoji_html';
import { isModernEmojiEnabled } from '../utils/environment'; import { isModernEmojiEnabled } from '../utils/environment';
import { EmojiHTML } from './emoji/html';
const MAX_HEIGHT = 706; // 22px * 32 (+ 2px padding at the top) const MAX_HEIGHT = 706; // 22px * 32 (+ 2px padding at the top)
/** /**

View File

@ -8,6 +8,7 @@ import { NavLink } from 'react-router-dom';
import { AccountBio } from '@/mastodon/components/account_bio'; import { AccountBio } from '@/mastodon/components/account_bio';
import { DisplayName } from '@/mastodon/components/display_name'; 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 CheckIcon from '@/material-icons/400-24px/check.svg?react';
import LockIcon from '@/material-icons/400-24px/lock.svg?react'; import LockIcon from '@/material-icons/400-24px/lock.svg?react';
import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react'; import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react';
@ -777,8 +778,8 @@ export const AccountHeader: React.FC<{
<MovedNote accountId={account.id} targetAccountId={account.moved} /> <MovedNote accountId={account.id} targetAccountId={account.moved} />
)} )}
<div <AnimateEmojiProvider
className={classNames('account__header animate-parent', { className={classNames('account__header', {
inactive: !!account.moved, inactive: !!account.moved,
})} })}
> >
@ -967,7 +968,7 @@ export const AccountHeader: React.FC<{
</div> </div>
)} )}
</div> </div>
</div> </AnimateEmojiProvider>
{!(hideTabs || hidden) && ( {!(hideTabs || hidden) && (
<div className='account__section-headline'> <div className='account__section-headline'>

View File

@ -25,6 +25,7 @@ import StatusContent from 'mastodon/components/status_content';
import { Dropdown } from 'mastodon/components/dropdown_menu'; import { Dropdown } from 'mastodon/components/dropdown_menu';
import { makeGetStatus } from 'mastodon/selectors'; import { makeGetStatus } from 'mastodon/selectors';
import { LinkedDisplayName } from '@/mastodon/components/display_name'; import { LinkedDisplayName } from '@/mastodon/components/display_name';
import { AnimateEmojiProvider } from '@/mastodon/components/emoji/context';
const messages = defineMessages({ const messages = defineMessages({
more: { id: 'status.more', defaultMessage: 'More' }, more: { id: 'status.more', defaultMessage: 'More' },
@ -136,9 +137,9 @@ export const Conversation = ({ conversation, scrollKey }) => {
{unread && <span className='conversation__unread' />} <RelativeTimestamp timestamp={lastStatus.get('created_at')} /> {unread && <span className='conversation__unread' />} <RelativeTimestamp timestamp={lastStatus.get('created_at')} />
</div> </div>
<div className='conversation__content__names animate-parent'> <AnimateEmojiProvider className='conversation__content__names'>
<FormattedMessage id='conversation.with' defaultMessage='With {names}' values={{ names: <span>{names}</span> }} /> <FormattedMessage id='conversation.with' defaultMessage='With {names}' values={{ names: <span>{names}</span> }} />
</div> </AnimateEmojiProvider>
</div> </div>
<StatusContent <StatusContent

View File

@ -23,8 +23,6 @@ export const EMOJI_MODE_TWEMOJI = 'twemoji';
export const EMOJI_TYPE_UNICODE = 'unicode'; export const EMOJI_TYPE_UNICODE = 'unicode';
export const EMOJI_TYPE_CUSTOM = 'custom'; export const EMOJI_TYPE_CUSTOM = 'custom';
export const EMOJI_STATE_MISSING = 'missing';
export const EMOJIS_WITH_DARK_BORDER = [ export const EMOJIS_WITH_DARK_BORDER = [
'🎱', // 1F3B1 '🎱', // 1F3B1
'🐜', // 1F41C '🐜', // 1F41C

View File

@ -197,11 +197,18 @@ function toLoadedLocale(localeString: string) {
log(`Locale ${locale} is different from provided ${localeString}`); log(`Locale ${locale} is different from provided ${localeString}`);
} }
if (!loadedLocales.has(locale)) { if (!loadedLocales.has(locale)) {
throw new Error(`Locale ${locale} is not loaded in emoji database`); throw new LocaleNotLoadedError(locale);
} }
return locale; return locale;
} }
export class LocaleNotLoadedError extends Error {
constructor(locale: Locale) {
super(`Locale ${locale} is not loaded in emoji database`);
this.name = 'LocaleNotLoadedError';
}
}
async function hasLocale(locale: Locale, db: Database): Promise<boolean> { async function hasLocale(locale: Locale, db: Database): Promise<boolean> {
if (loadedLocales.has(locale)) { if (loadedLocales.has(locale)) {
return true; return true;

View File

@ -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<Element extends ElementType = 'div'> = Omit<
ComponentPropsWithoutRef<Element>,
'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<ElementType>) => {
const emojifiedHtml = useEmojify({
text: htmlString,
extraEmojis,
deep: !shallow,
});
if (emojifiedHtml === null) {
return null;
}
return (
<Wrapper
{...props}
className={classNames(className, 'animate-parent')}
dangerouslySetInnerHTML={{ __html: emojifiedHtml }}
/>
);
};
export const EmojiHTML = <Element extends ElementType>(
props: EmojiHTMLProps<Element>,
) => {
if (isModernEmojiEnabled()) {
return <ModernEmojiHTML {...props} />;
}
const {
as: asElement,
htmlString,
extraEmojis,
className,
shallow: _,
...rest
} = props;
const Wrapper = asElement ?? 'div';
return (
<Wrapper
{...rest}
dangerouslySetInnerHTML={{ __html: htmlString }}
className={classNames(className, 'animate-parent')}
/>
);
};

View File

@ -1,8 +1,6 @@
import { flattenEmojiData } from 'emojibase'; import { flattenEmojiData } from 'emojibase';
import type { CompactEmoji, FlatCompactEmoji } from 'emojibase'; import type { CompactEmoji, FlatCompactEmoji } from 'emojibase';
import type { ApiCustomEmojiJSON } from '@/mastodon/api_types/custom_emoji';
import { import {
putEmojiData, putEmojiData,
putCustomEmojiData, putCustomEmojiData,
@ -10,7 +8,7 @@ import {
putLatestEtag, putLatestEtag,
} from './database'; } from './database';
import { toSupportedLocale, toSupportedLocaleOrCustom } from './locale'; import { toSupportedLocale, toSupportedLocaleOrCustom } from './locale';
import type { LocaleOrCustom } from './types'; import type { CustomEmojiData, LocaleOrCustom } from './types';
import { emojiLogger } from './utils'; import { emojiLogger } from './utils';
const log = emojiLogger('loader'); const log = emojiLogger('loader');
@ -27,7 +25,7 @@ export async function importEmojiData(localeString: string) {
} }
export async function importCustomEmojiData() { export async function importCustomEmojiData() {
const emojis = await fetchAndCheckEtag<ApiCustomEmojiJSON[]>('custom'); const emojis = await fetchAndCheckEtag<CustomEmojiData[]>('custom');
if (!emojis) { if (!emojis) {
return; return;
} }

View File

@ -5,11 +5,8 @@ import { flattenEmojiData } from 'emojibase';
import unicodeRawEmojis from 'emojibase-data/en/data.json'; import unicodeRawEmojis from 'emojibase-data/en/data.json';
import { import {
twemojiHasBorder,
twemojiToUnicodeInfo, twemojiToUnicodeInfo,
unicodeToTwemojiHex, unicodeToTwemojiHex,
CODES_WITH_DARK_BORDER,
CODES_WITH_LIGHT_BORDER,
emojiToUnicodeHex, emojiToUnicodeHex,
} from './normalize'; } 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', () => { describe('twemojiToUnicodeInfo', () => {
const unicodeCodeSet = new Set(unicodeEmojis.map((emoji) => emoji.hexcode)); const unicodeCodeSet = new Set(unicodeEmojis.map((emoji) => emoji.hexcode));

View File

@ -1,5 +1,7 @@
import { isList } from 'immutable'; import { isList } from 'immutable';
import { assetHost } from '@/mastodon/utils/config';
import { import {
VARIATION_SELECTOR_CODE, VARIATION_SELECTOR_CODE,
KEYCAP_CODE, KEYCAP_CODE,
@ -9,11 +11,7 @@ import {
EMOJIS_WITH_DARK_BORDER, EMOJIS_WITH_DARK_BORDER,
EMOJIS_WITH_LIGHT_BORDER, EMOJIS_WITH_LIGHT_BORDER,
} from './constants'; } from './constants';
import type { import type { CustomEmojiMapArg, ExtraCustomEmojiMap } from './types';
CustomEmojiMapArg,
ExtraCustomEmojiMap,
TwemojiBorderInfo,
} from './types';
// Misc codes that have special handling // Misc codes that have special handling
const SKIER_CODE = 0x26f7; const SKIER_CODE = 0x26f7;
@ -67,21 +65,17 @@ export const CODES_WITH_DARK_BORDER =
export const CODES_WITH_LIGHT_BORDER = export const CODES_WITH_LIGHT_BORDER =
EMOJIS_WITH_LIGHT_BORDER.map(emojiToUnicodeHex); EMOJIS_WITH_LIGHT_BORDER.map(emojiToUnicodeHex);
export function twemojiHasBorder(twemojiHex: string): TwemojiBorderInfo { export function unicodeHexToUrl(unicodeHex: string, darkMode: boolean): string {
const normalizedHex = twemojiHex.toUpperCase(); const normalizedHex = unicodeToTwemojiHex(unicodeHex);
let hasLightBorder = false; let url = `${assetHost}/emoji/${normalizedHex}`;
let hasDarkBorder = false; if (darkMode && CODES_WITH_LIGHT_BORDER.includes(normalizedHex)) {
if (CODES_WITH_LIGHT_BORDER.includes(normalizedHex)) { url += '_border';
hasLightBorder = true;
} }
if (CODES_WITH_DARK_BORDER.includes(normalizedHex)) { if (CODES_WITH_DARK_BORDER.includes(normalizedHex)) {
hasDarkBorder = true; url += '_border';
} }
return { url += '.svg';
hexCode: twemojiHex, return url;
hasLightBorder,
hasDarkBorder,
};
} }
interface TwemojiSpecificEmoji { interface TwemojiSpecificEmoji {

View File

@ -1,10 +1,6 @@
import { customEmojiFactory, unicodeEmojiFactory } from '@/testing/factories'; import { customEmojiFactory, unicodeEmojiFactory } from '@/testing/factories';
import { import { EMOJI_MODE_TWEMOJI } from './constants';
EMOJI_MODE_NATIVE,
EMOJI_MODE_NATIVE_WITH_FLAGS,
EMOJI_MODE_TWEMOJI,
} from './constants';
import * as db from './database'; import * as db from './database';
import { import {
emojifyElement, emojifyElement,
@ -12,7 +8,7 @@ import {
testCacheClear, testCacheClear,
tokenizeText, tokenizeText,
} from './render'; } from './render';
import type { EmojiAppState, ExtraCustomEmojiMap } from './types'; import type { EmojiAppState } from './types';
function mockDatabase() { function mockDatabase() {
return { return {
@ -40,18 +36,6 @@ const expectedSmileImage =
'<img draggable="false" class="emojione" alt="😊" title="smiling face with smiling eyes" src="/emoji/1f60a.svg">'; '<img draggable="false" class="emojione" alt="😊" title="smiling face with smiling eyes" src="/emoji/1f60a.svg">';
const expectedFlagImage = const expectedFlagImage =
'<img draggable="false" class="emojione" alt="🇪🇺" title="flag-eu" src="/emoji/1f1ea-1f1fa.svg">'; '<img draggable="false" class="emojione" alt="🇪🇺" title="flag-eu" src="/emoji/1f1ea-1f1fa.svg">';
const expectedCustomEmojiImage =
'<img draggable="false" class="emojione custom-emoji" alt=":custom:" title=":custom:" src="emoji/custom/static" data-original="emoji/custom" data-static="emoji/custom/static">';
const expectedRemoteCustomEmojiImage =
'<img draggable="false" class="emojione custom-emoji" alt=":remote:" title=":remote:" src="remote.social/static" data-original="remote.social/custom" data-static="remote.social/static">';
const mockExtraCustom: ExtraCustomEmojiMap = {
remote: {
shortcode: 'remote',
static_url: 'remote.social/static',
url: 'remote.social/custom',
},
};
function testAppState(state: Partial<EmojiAppState> = {}) { function testAppState(state: Partial<EmojiAppState> = {}) {
return { return {
@ -86,64 +70,10 @@ describe('emojifyElement', () => {
'en', 'en',
); );
expect(searchCustomEmojisByShortcodes).toHaveBeenCalledExactlyOnceWith([ 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(
`<p>Hello 😊🇪🇺!</p><p>${expectedCustomEmojiImage}</p>`,
);
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(
`<p>Hello 😊${expectedFlagImage}!</p><p>${expectedCustomEmojiImage}</p>`,
);
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(
`<p>Hello ${expectedSmileImage}${expectedFlagImage}!</p><p>${expectedCustomEmojiImage}</p>`,
);
expect(searchEmojisByHexcodes).toHaveBeenCalledOnce();
expect(searchCustomEmojisByShortcodes).toHaveBeenCalledOnce();
});
test('emojifies with provided custom emoji', async () => {
const { searchCustomEmojisByShortcodes, searchEmojisByHexcodes } =
mockDatabase();
const actual = await emojifyElement(
testElement('<p>hi :remote:</p>'),
testAppState(),
mockExtraCustom,
);
assert(actual);
expect(actual.innerHTML).toBe(
`<p>hi ${expectedRemoteCustomEmojiImage}</p>`,
);
expect(searchEmojisByHexcodes).not.toHaveBeenCalled();
expect(searchCustomEmojisByShortcodes).not.toHaveBeenCalled();
});
test('returns null when no emoji are found', async () => { test('returns null when no emoji are found', async () => {
mockDatabase(); mockDatabase();
const actual = await emojifyElement( const actual = await emojifyElement(
@ -165,28 +95,9 @@ describe('emojifyText', () => {
const actual = await emojifyText('Hello 😊🇪🇺!', testAppState()); const actual = await emojifyText('Hello 😊🇪🇺!', testAppState());
expect(actual).toBe(`Hello ${expectedSmileImage}${expectedFlagImage}!`); 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', () => { 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', () => { test('returns an array of text to be a single token', () => {
expect(tokenizeText('Hello')).toEqual(['Hello']); expect(tokenizeText('Hello')).toEqual(['Hello']);
}); });
@ -212,7 +123,7 @@ describe('tokenizeText', () => {
'Hello ', 'Hello ',
{ {
type: 'custom', type: 'custom',
code: 'smile', code: ':smile:',
}, },
'!!', '!!',
]); ]);
@ -223,7 +134,7 @@ describe('tokenizeText', () => {
'Hello ', 'Hello ',
{ {
type: 'custom', type: 'custom',
code: 'smile_123', code: ':smile_123:',
}, },
'!!', '!!',
]); ]);
@ -239,7 +150,7 @@ describe('tokenizeText', () => {
' ', ' ',
{ {
type: 'custom', type: 'custom',
code: 'smile', code: ':smile:',
}, },
'!!', '!!',
]); ]);

View File

@ -1,6 +1,5 @@
import { autoPlayGif } from '@/mastodon/initial_state'; import { autoPlayGif } from '@/mastodon/initial_state';
import { createLimitedCache } from '@/mastodon/utils/cache'; import { createLimitedCache } from '@/mastodon/utils/cache';
import { assetHost } from '@/mastodon/utils/config';
import * as perf from '@/mastodon/utils/performance'; import * as perf from '@/mastodon/utils/performance';
import { import {
@ -8,38 +7,130 @@ import {
EMOJI_MODE_NATIVE_WITH_FLAGS, EMOJI_MODE_NATIVE_WITH_FLAGS,
EMOJI_TYPE_UNICODE, EMOJI_TYPE_UNICODE,
EMOJI_TYPE_CUSTOM, EMOJI_TYPE_CUSTOM,
EMOJI_STATE_MISSING,
} from './constants'; } from './constants';
import { import {
loadCustomEmojiByShortcode,
loadEmojiByHexcode,
LocaleNotLoadedError,
searchCustomEmojisByShortcodes, searchCustomEmojisByShortcodes,
searchEmojisByHexcodes, searchEmojisByHexcodes,
} from './database'; } from './database';
import { import { importEmojiData } from './loader';
emojiToUnicodeHex, import { emojiToUnicodeHex, unicodeHexToUrl } from './normalize';
twemojiHasBorder,
unicodeToTwemojiHex,
} from './normalize';
import type { import type {
CustomEmojiToken,
EmojiAppState, EmojiAppState,
EmojiLoadedState, EmojiLoadedState,
EmojiMode, EmojiMode,
EmojiState, EmojiState,
EmojiStateCustom,
EmojiStateMap, EmojiStateMap,
EmojiToken, EmojiStateUnicode,
ExtraCustomEmojiMap, ExtraCustomEmojiMap,
LocaleOrCustom, LocaleOrCustom,
UnicodeEmojiToken,
} from './types'; } from './types';
import { import {
anyEmojiRegex, anyEmojiRegex,
emojiLogger, emojiLogger,
isCustomEmoji,
isUnicodeEmoji,
stringHasAnyEmoji, stringHasAnyEmoji,
stringHasUnicodeFlags, stringHasUnicodeFlags,
} from './utils'; } from './utils';
const log = emojiLogger('render'); 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<EmojiLoadedState | null> {
if (isStateLoaded(state)) {
return state;
}
// First, try to load the data from IndexedDB.
try {
// This is duplicative, but that's because TS can't distinguish the state type easily.
if (state.type === EMOJI_TYPE_UNICODE) {
const data = await loadEmojiByHexcode(state.code, locale);
if (data) {
return {
...state,
data,
};
}
} else {
const data = await loadCustomEmojiByShortcode(state.code);
if (data) {
return {
...state,
data,
};
}
}
// If not found, assume it's not an emoji and return null.
log(
'Could not find emoji %s of type %s for locale %s',
state.code,
state.type,
locale,
);
return null;
} catch (err: unknown) {
// If the locale is not loaded, load it and retry once.
if (!retry && err instanceof LocaleNotLoadedError) {
log(
'Error loading emoji %s for locale %s, loading locale and retrying.',
state.code,
locale,
);
await importEmojiData(locale); // Use this from the loader file as it can be awaited.
return loadEmojiDataToState(state, locale, true);
}
console.warn('Error loading emoji data, not retrying:', state, locale, err);
return null;
}
}
export function isStateLoaded(state: EmojiState): state is EmojiLoadedState {
return !!state.data;
}
/** /**
* Emojifies an element. This modifies the element in place, replacing text nodes with emojified versions. * 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) { if (token.type === EMOJI_TYPE_CUSTOM) {
const extraEmojiData = extraEmojis[token.code]; const extraEmojiData = extraEmojis[token.code];
if (extraEmojiData) { if (extraEmojiData) {
state = { type: EMOJI_TYPE_CUSTOM, data: extraEmojiData }; state = {
type: EMOJI_TYPE_CUSTOM,
data: extraEmojiData,
code: token.code,
};
} else { } else {
state = emojiForLocale(token.code, EMOJI_TYPE_CUSTOM); 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 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); const image = stateToImage(state, appState);
renderedFragments.push(image); renderedFragments.push(image);
continue; continue;
@ -202,11 +297,11 @@ async function textToElementArray(
return renderedFragments; return renderedFragments;
} }
type TokenizedText = (string | EmojiToken)[]; type TokenizedText = (string | EmojiState)[];
export function tokenizeText(text: string): TokenizedText { export function tokenizeText(text: string): TokenizedText {
if (!text.trim()) { if (!text.trim()) {
return []; return [text];
} }
const tokens = []; const tokens = [];
@ -222,14 +317,14 @@ export function tokenizeText(text: string): TokenizedText {
// Custom emoji // Custom emoji
tokens.push({ tokens.push({
type: EMOJI_TYPE_CUSTOM, type: EMOJI_TYPE_CUSTOM,
code: code.slice(1, -1), // Remove the colons code,
} satisfies CustomEmojiToken); } satisfies EmojiStateCustom);
} else { } else {
// Unicode emoji // Unicode emoji
tokens.push({ tokens.push({
type: EMOJI_TYPE_UNICODE, type: EMOJI_TYPE_UNICODE,
code: code, code: code,
} satisfies UnicodeEmojiToken); } satisfies EmojiStateUnicode);
} }
lastIndex = match.index + code.length; lastIndex = match.index + code.length;
} }
@ -304,13 +399,11 @@ async function loadMissingEmojiIntoCache(
const emojis = await searchEmojisByHexcodes(missingEmojis, currentLocale); const emojis = await searchEmojisByHexcodes(missingEmojis, currentLocale);
const cache = cacheForLocale(currentLocale); const cache = cacheForLocale(currentLocale);
for (const emoji of emojis) { for (const emoji of emojis) {
cache.set(emoji.hexcode, { type: EMOJI_TYPE_UNICODE, data: emoji }); cache.set(emoji.hexcode, {
} type: EMOJI_TYPE_UNICODE,
const notFoundEmojis = missingEmojis.filter((code) => data: emoji,
emojis.every((emoji) => emoji.hexcode !== code), code: emoji.hexcode,
); });
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.
} }
localeCacheMap.set(currentLocale, cache); localeCacheMap.set(currentLocale, cache);
} }
@ -320,19 +413,17 @@ async function loadMissingEmojiIntoCache(
const emojis = await searchCustomEmojisByShortcodes(missingEmojis); const emojis = await searchCustomEmojisByShortcodes(missingEmojis);
const cache = cacheForLocale(EMOJI_TYPE_CUSTOM); const cache = cacheForLocale(EMOJI_TYPE_CUSTOM);
for (const emoji of emojis) { for (const emoji of emojis) {
cache.set(emoji.shortcode, { type: EMOJI_TYPE_CUSTOM, data: emoji }); cache.set(emoji.shortcode, {
} type: EMOJI_TYPE_CUSTOM,
const notFoundEmojis = missingEmojis.filter((code) => data: emoji,
emojis.every((emoji) => emoji.shortcode !== code), code: emoji.shortcode,
); });
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.
} }
localeCacheMap.set(EMOJI_TYPE_CUSTOM, cache); 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 (token.type === EMOJI_TYPE_UNICODE) {
// If the mode is native or native with flags for non-flag emoji // If the mode is native or native with flags for non-flag emoji
// we can just append the text node directly. // we can just append the text node directly.
@ -354,18 +445,9 @@ function stateToImage(state: EmojiLoadedState, appState: EmojiAppState) {
image.classList.add('emojione'); image.classList.add('emojione');
if (state.type === EMOJI_TYPE_UNICODE) { 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.alt = state.data.unicode;
image.title = state.data.label; image.title = state.data.label;
image.src = `${assetHost}/emoji/${fileName}.svg`; image.src = unicodeHexToUrl(state.data.hexcode, appState.darkTheme);
} else { } else {
// Custom emoji // Custom emoji
const shortCode = `:${state.data.shortcode}:`; const shortCode = `:${state.data.shortcode}:`;

View File

@ -10,7 +10,6 @@ import type {
EMOJI_MODE_NATIVE, EMOJI_MODE_NATIVE,
EMOJI_MODE_NATIVE_WITH_FLAGS, EMOJI_MODE_NATIVE_WITH_FLAGS,
EMOJI_MODE_TWEMOJI, EMOJI_MODE_TWEMOJI,
EMOJI_STATE_MISSING,
EMOJI_TYPE_CUSTOM, EMOJI_TYPE_CUSTOM,
EMOJI_TYPE_UNICODE, EMOJI_TYPE_UNICODE,
} from './constants'; } from './constants';
@ -29,45 +28,40 @@ export interface EmojiAppState {
darkTheme: boolean; 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 CustomEmojiData = ApiCustomEmojiJSON;
export type UnicodeEmojiData = FlatCompactEmoji; export type UnicodeEmojiData = FlatCompactEmoji;
export type AnyEmojiData = CustomEmojiData | UnicodeEmojiData; export type AnyEmojiData = CustomEmojiData | UnicodeEmojiData;
export type EmojiStateMissing = typeof EMOJI_STATE_MISSING; type CustomEmojiRenderFields = Pick<
CustomEmojiData,
'shortcode' | 'static_url' | 'url'
>;
export interface EmojiStateUnicode { export interface EmojiStateUnicode {
type: typeof EMOJI_TYPE_UNICODE; type: typeof EMOJI_TYPE_UNICODE;
data: UnicodeEmojiData; code: string;
data?: UnicodeEmojiData;
} }
export interface EmojiStateCustom { export interface EmojiStateCustom {
type: typeof EMOJI_TYPE_CUSTOM; type: typeof EMOJI_TYPE_CUSTOM;
data: CustomEmojiRenderFields; code: string;
data?: CustomEmojiRenderFields;
} }
export type EmojiState = export type EmojiState = EmojiStateUnicode | EmojiStateCustom;
| EmojiStateMissing export type EmojiLoadedState =
| EmojiStateUnicode | Required<EmojiStateUnicode>
| EmojiStateCustom; | Required<EmojiStateCustom>;
export type EmojiLoadedState = EmojiStateUnicode | EmojiStateCustom;
export type EmojiStateMap = LimitedCache<string, EmojiState>; export type EmojiStateMap = LimitedCache<string, EmojiState>;
export type CustomEmojiMapArg = export type CustomEmojiMapArg =
| ExtraCustomEmojiMap | ExtraCustomEmojiMap
| ImmutableList<CustomEmoji>; | ImmutableList<CustomEmoji>;
export type CustomEmojiRenderFields = Pick<
CustomEmojiData, export type ExtraCustomEmojiMap = Record<
'shortcode' | 'static_url' | 'url' string,
Pick<CustomEmojiData, 'shortcode' | 'static_url' | 'url'>
>; >;
export type ExtraCustomEmojiMap = Record<string, CustomEmojiRenderFields>;
export interface TwemojiBorderInfo { export interface TwemojiBorderInfo {
hexCode: string; hexCode: string;

View File

@ -10,6 +10,13 @@ export function stringHasUnicodeEmoji(input: string): boolean {
return new RegExp(EMOJI_REGEX, supportedFlags()).test(input); 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 { export function stringHasUnicodeFlags(input: string): boolean {
if (supportsRegExpSets()) { if (supportsRegExpSets()) {
return new RegExp( return new RegExp(
@ -27,6 +34,11 @@ export function stringHasUnicodeFlags(input: string): boolean {
// Constant as this is supported by all browsers. // Constant as this is supported by all browsers.
const CUSTOM_EMOJI_REGEX = /:([a-z0-9_]+):/i; 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) { export function stringHasCustomEmoji(input: string) {
return CUSTOM_EMOJI_REGEX.test(input); return CUSTOM_EMOJI_REGEX.test(input);
} }

View File

@ -6,6 +6,7 @@ import { useHistory } from 'react-router-dom';
import type { List as ImmutableList, RecordOf } from 'immutable'; 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 BarChart4BarsIcon from '@/material-icons/400-24px/bar_chart_4_bars.svg?react';
import PhotoLibraryIcon from '@/material-icons/400-24px/photo_library.svg?react'; import PhotoLibraryIcon from '@/material-icons/400-24px/photo_library.svg?react';
import { toggleStatusSpoilers } from 'mastodon/actions/statuses'; import { toggleStatusSpoilers } from 'mastodon/actions/statuses';
@ -96,8 +97,8 @@ export const EmbeddedStatus: React.FC<{ statusId: string }> = ({
).size; ).size;
return ( return (
<div <AnimateEmojiProvider
className='notification-group__embedded-status animate-parent' className='notification-group__embedded-status'
role='button' role='button'
tabIndex={-1} tabIndex={-1}
onMouseDown={handleMouseDown} onMouseDown={handleMouseDown}
@ -148,6 +149,6 @@ export const EmbeddedStatus: React.FC<{ statusId: string }> = ({
)} )}
</div> </div>
)} )}
</div> </AnimateEmojiProvider>
); );
}; };

View File

@ -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 extends ElementType> {
as?: As;
}
type PropsOf<As extends ElementType> = ComponentPropsWithRef<As>;
/**
* Extract the element instance type (e.g. HTMLButtonElement) from ComponentPropsWithRef<As>:
* - For intrinsic elements, look up in JSX.IntrinsicElements
* - For components, infer from `ComponentPropsWithRef`
*/
type ElementRef<As extends ElementType> =
As extends keyof React.JSX.IntrinsicElements
? React.JSX.IntrinsicElements[As] extends { ref?: Ref<infer Inst> }
? Inst
: never
: ComponentPropsWithRef<As> extends { ref?: Ref<infer Inst> }
? 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<As> &
Omit<PropsOf<As>, keyof AdditionalProps | 'ref'>;
/**
* Signature of a component created with `polymorphicForwardRef`.
*/
type PolymorphicWithRef<
DefaultAs extends ElementType,
AdditionalProps extends object = object,
> = <As extends ElementType = DefaultAs>(
props: PolymorphicProps<As, AdditionalProps> & { ref?: Ref<ElementRef<As>> },
) => ReactElement | null;
/**
* The type of `polymorphicForwardRef`.
*/
type PolyRefFunction = <
DefaultAs extends ElementType,
AdditionalProps extends object = object,
>(
render: ForwardRefRenderFunction<
ElementRef<DefaultAs>,
PolymorphicProps<DefaultAs, AdditionalProps>
>,
) => PolymorphicWithRef<DefaultAs, AdditionalProps> &
ForwardRefExoticComponent<PolymorphicProps<DefaultAs, AdditionalProps>>;
/**
* Polymorphic `forwardRef` function.
*/
export const polymorphicForwardRef = forwardRef as PolyRefFunction;