[Glitch] Emoji Component

Port c12b8f51c1 to glitch-soc

Signed-off-by: Claire <claire.github-309c@sitedethib.com>
This commit is contained in:
Echo
2025-09-30 15:06:02 +02:00
committed by Claire
parent 752214622c
commit 7430e3c74d
20 changed files with 491 additions and 301 deletions

View File

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

View File

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

View File

@@ -1,8 +1,9 @@
import type { ComponentPropsWithoutRef, FC } from 'react';
import { EmojiHTML } from '@/flavours/glitch/features/emoji/emoji_html';
import { isModernEmojiEnabled } from '@/flavours/glitch/utils/environment';
import { EmojiHTML } from '../emoji/html';
import type { DisplayNameProps } from './index';
export const DisplayNameSimple: FC<
@@ -12,12 +13,19 @@ export const DisplayNameSimple: FC<
if (!account) {
return null;
}
const accountName = isModernEmojiEnabled()
? account.get('display_name')
: account.get('display_name_html');
return (
<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>
);
};

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 '@/flavours/glitch/features/emoji/normalize';
import { autoPlayGif } from '@/flavours/glitch/initial_state';
import { polymorphicForwardRef } from '@/types/polymorphic';
import type {
CustomEmojiMapArg,
ExtraCustomEmojiMap,
} from 'flavours/glitch/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 '@/flavours/glitch/features/emoji/types';
import { isModernEmojiEnabled } from '@/flavours/glitch/utils/environment';
import { htmlStringToComponents } from '@/flavours/glitch/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 '@/flavours/glitch/features/emoji/constants';
import { useEmojiAppState } from '@/flavours/glitch/features/emoji/hooks';
import { unicodeHexToUrl } from '@/flavours/glitch/features/emoji/normalize';
import {
isStateLoaded,
loadEmojiDataToState,
shouldRenderImage,
stringToEmojiState,
tokenizeText,
} from '@/flavours/glitch/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,11 +13,13 @@ import ChevronRightIcon from '@/material-icons/400-24px/chevron_right.svg?react'
import { Icon } from 'flavours/glitch/components/icon';
import { Poll } from 'flavours/glitch/components/poll';
import { identityContextPropShape, withIdentity } from 'flavours/glitch/identity_context';
import { autoPlayGif, languages as preloadedLanguages } from 'flavours/glitch/initial_state';
import { languages as preloadedLanguages } from 'flavours/glitch/initial_state';
import { decode as decodeIDNA } from 'flavours/glitch/utils/idna';
import { EmojiHTML } from '../features/emoji/emoji_html';
import { isModernEmojiEnabled } from '../utils/environment';
import { EmojiHTML } from './emoji/html';
const MAX_HEIGHT = 706; // 22px * 32 (+ 2px padding at the top)
const textMatchesTarget = (text, origin, host) => {