mirror of
https://github.com/mastodon/mastodon.git
synced 2025-10-06 00:52:42 +00:00
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
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:
parent
ac50e5eebc
commit
c12b8f51c1
|
@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
108
app/javascript/mastodon/components/emoji/context.tsx
Normal file
108
app/javascript/mastodon/components/emoji/context.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
61
app/javascript/mastodon/components/emoji/html.tsx
Normal file
61
app/javascript/mastodon/components/emoji/html.tsx
Normal 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;
|
99
app/javascript/mastodon/components/emoji/index.tsx
Normal file
99
app/javascript/mastodon/components/emoji/index.tsx
Normal 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}`} />;
|
||||||
|
});
|
||||||
|
}
|
|
@ -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)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -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'>
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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')}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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));
|
||||||
|
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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:',
|
||||||
},
|
},
|
||||||
'!!',
|
'!!',
|
||||||
]);
|
]);
|
||||||
|
|
|
@ -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}:`;
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
75
app/javascript/types/polymorphic.ts
Normal file
75
app/javascript/types/polymorphic.ts
Normal 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;
|
Loading…
Reference in New Issue
Block a user