mirror of https://github.com/mastodon/mastodon
Emoji Component (#36293)
parent
ac50e5eebc
commit
c12b8f51c1
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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;
|
||||
@ -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}`} />;
|
||||
});
|
||||
}
|
||||
@ -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')}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@ -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