replace duplicated handler code
Some checks failed
Chromatic / Run Chromatic (push) Has been cancelled

This commit is contained in:
ChaosExAnima 2025-07-30 18:16:41 +02:00
parent dd61fed777
commit f04360c43e
No known key found for this signature in database
GPG Key ID: 8F2B333100FB6117
10 changed files with 168 additions and 200 deletions

View File

@ -61,13 +61,19 @@ export const DisplayName: FC<Props & ComponentPropsWithoutRef<'span'>> = ({
if (simple) {
return (
<bdi>
<EmojiHTML {...props} htmlString={accountName} shallow as='span' />
<EmojiHTML
{...props}
className={`animate-parent ${className}`}
htmlString={accountName}
shallow
as='span'
/>
</bdi>
);
}
return (
<span {...props} className={`display-name ${className}`}>
<span {...props} className={`display-name animate-parent ${className}`}>
<bdi>
<EmojiHTML
{...props}

View File

@ -13,8 +13,9 @@ import ChevronRightIcon from '@/material-icons/400-24px/chevron_right.svg?react'
import { Icon } from 'mastodon/components/icon';
import { Poll } from 'mastodon/components/poll';
import { identityContextPropShape, withIdentity } from 'mastodon/identity_context';
import { autoPlayGif, languages as preloadedLanguages } from 'mastodon/initial_state';
import { languages as preloadedLanguages } from 'mastodon/initial_state';
import { EmojiHTML } from '../features/emoji/emoji_html';
import { handleAnimateEnter, handleAnimateLeave } from '../features/emoji/handlers';
import { isModernEmojiEnabled } from '../utils/environment';
const MAX_HEIGHT = 706; // 22px * 32 (+ 2px padding at the top)
@ -150,32 +151,6 @@ class StatusContent extends PureComponent {
}
}
handleMouseEnter = ({ currentTarget }) => {
if (autoPlayGif) {
return;
}
const emojis = currentTarget.querySelectorAll('.custom-emoji');
for (var i = 0; i < emojis.length; i++) {
let emoji = emojis[i];
emoji.src = emoji.getAttribute('data-original');
}
};
handleMouseLeave = ({ currentTarget }) => {
if (autoPlayGif) {
return;
}
const emojis = currentTarget.querySelectorAll('.custom-emoji');
for (var i = 0; i < emojis.length; i++) {
let emoji = emojis[i];
emoji.src = emoji.getAttribute('data-static');
}
};
componentDidMount () {
this._updateStatusLinks();
}
@ -245,7 +220,7 @@ class StatusContent extends PureComponent {
const content = statusContent ?? getStatusContent(status);
const language = status.getIn(['translation', 'language']) || status.get('language');
const classNames = classnames('status__content', {
const classNames = classnames('status__content animate-parent', {
'status__content--with-action': this.props.onClick && this.props.history,
'status__content--collapsed': renderReadMore,
});
@ -267,7 +242,15 @@ class StatusContent extends PureComponent {
if (this.props.onClick) {
return (
<>
<div className={classNames} ref={this.setRef} onMouseDown={this.handleMouseDown} onMouseUp={this.handleMouseUp} key='status-content' onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave}>
<div
className={classNames}
ref={this.setRef}
onMouseDown={this.handleMouseDown}
onMouseUp={this.handleMouseUp}
key='status-content'
onMouseEnter={handleAnimateEnter}
onMouseLeave={handleAnimateLeave}
>
<EmojiHTML
className='status__content__text status__content__text--visible translate'
lang={language}
@ -284,7 +267,7 @@ class StatusContent extends PureComponent {
);
} else {
return (
<div className={classNames} ref={this.setRef} onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave}>
<div className={classNames} ref={this.setRef} onMouseEnter={handleAnimateEnter} onMouseLeave={handleAnimateLeave}>
<EmojiHTML
className='status__content__text status__content__text--visible translate'
lang={language}

View File

@ -8,6 +8,10 @@ import { NavLink } from 'react-router-dom';
import { AccountBio } from '@/mastodon/components/account_bio';
import { DisplayName } from '@/mastodon/components/display_name';
import {
handleAnimateEnter,
handleAnimateLeave,
} from '@/mastodon/features/emoji/handlers';
import CheckIcon from '@/material-icons/400-24px/check.svg?react';
import LockIcon from '@/material-icons/400-24px/lock.svg?react';
import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react';
@ -379,36 +383,6 @@ export const AccountHeader: React.FC<{
});
}, [account]);
const handleMouseEnter = useCallback(
({ currentTarget }: React.MouseEvent) => {
if (autoPlayGif) {
return;
}
currentTarget
.querySelectorAll<HTMLImageElement>('.custom-emoji')
.forEach((emoji) => {
emoji.src = emoji.getAttribute('data-original') ?? '';
});
},
[],
);
const handleMouseLeave = useCallback(
({ currentTarget }: React.MouseEvent) => {
if (autoPlayGif) {
return;
}
currentTarget
.querySelectorAll<HTMLImageElement>('.custom-emoji')
.forEach((emoji) => {
emoji.src = emoji.getAttribute('data-static') ?? '';
});
},
[],
);
const suspended = account?.suspended;
const isRemote = account?.acct !== account?.username;
const remoteDomain = isRemote ? account?.acct.split('@')[1] : null;
@ -808,11 +782,11 @@ export const AccountHeader: React.FC<{
)}
<div
className={classNames('account__header', {
className={classNames('account__header animate-parent', {
inactive: !!account.moved,
})}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
onMouseEnter={handleAnimateEnter}
onMouseLeave={handleAnimateLeave}
>
{!(suspended || hidden || account.moved) &&
relationship?.requested_by && (

View File

@ -23,9 +23,9 @@ import { IconButton } from 'mastodon/components/icon_button';
import { RelativeTimestamp } from 'mastodon/components/relative_timestamp';
import StatusContent from 'mastodon/components/status_content';
import { Dropdown } from 'mastodon/components/dropdown_menu';
import { autoPlayGif } from 'mastodon/initial_state';
import { makeGetStatus } from 'mastodon/selectors';
import { LinkedDisplayName } from '@/mastodon/components/display_name';
import { handleAnimateEnter, handleAnimateLeave } from '@/mastodon/features/emoji/handlers';
const messages = defineMessages({
more: { id: 'status.more', defaultMessage: 'More' },
@ -57,31 +57,8 @@ export const Conversation = ({ conversation, scrollKey, onMoveUp, onMoveDown })
const lastStatus = useSelector(state => getStatus(state, { id: lastStatusId }));
const accounts = useSelector(state => getAccounts(state, accountIds));
const handleMouseEnter = useCallback(({ currentTarget }) => {
if (autoPlayGif) {
return;
}
const emojis = currentTarget.querySelectorAll('.custom-emoji');
for (var i = 0; i < emojis.length; i++) {
let emoji = emojis[i];
emoji.src = emoji.getAttribute('data-original');
}
}, []);
const handleMouseLeave = useCallback(({ currentTarget }) => {
if (autoPlayGif) {
return;
}
const emojis = currentTarget.querySelectorAll('.custom-emoji');
for (var i = 0; i < emojis.length; i++) {
let emoji = emojis[i];
emoji.src = emoji.getAttribute('data-static');
}
}, []);
const handleMouseEnter = useCallback(handleAnimateEnter, []);
const handleMouseLeave = useCallback(handleAnimateLeave, []);
const handleClick = useCallback(() => {
if (unread) {
@ -173,7 +150,7 @@ export const Conversation = ({ conversation, scrollKey, onMoveUp, onMoveDown })
{unread && <span className='conversation__unread' />} <RelativeTimestamp timestamp={lastStatus.get('created_at')} />
</div>
<div className='conversation__content__names' onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave}>
<div className='conversation__content__names animate-parent' onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave}>
<FormattedMessage id='conversation.with' defaultMessage='With {names}' values={{ names: <span>{names}</span> }} />
</div>
</div>

View File

@ -1,4 +1,3 @@
import type { MouseEventHandler } from 'react';
import { useCallback } from 'react';
import { FormattedMessage, defineMessages, useIntl } from 'react-intl';
@ -6,6 +5,10 @@ import { FormattedMessage, defineMessages, useIntl } from 'react-intl';
import classNames from 'classnames';
import { Link } from 'react-router-dom';
import {
handleAnimateEnter,
handleAnimateLeave,
} from '@/mastodon/features/emoji/handlers';
import {
followAccount,
unblockAccount,
@ -44,38 +47,8 @@ export const AccountCard: React.FC<{ accountId: string }> = ({ accountId }) => {
const account = useAppSelector((s) => getAccount(s, accountId));
const dispatch = useAppDispatch();
const handleMouseEnter = useCallback<MouseEventHandler>(
({ currentTarget }) => {
if (autoPlayGif) {
return;
}
const emojis =
currentTarget.querySelectorAll<HTMLImageElement>('.custom-emoji');
emojis.forEach((emoji) => {
const original = emoji.getAttribute('data-original');
if (original) emoji.src = original;
});
},
[],
);
const handleMouseLeave = useCallback<MouseEventHandler>(
({ currentTarget }) => {
if (autoPlayGif) {
return;
}
const emojis =
currentTarget.querySelectorAll<HTMLImageElement>('.custom-emoji');
emojis.forEach((emoji) => {
const staticUrl = emoji.getAttribute('data-static');
if (staticUrl) emoji.src = staticUrl;
});
},
[],
);
const handleMouseEnter = useCallback(handleAnimateEnter, []);
const handleMouseLeave = useCallback(handleAnimateLeave, []);
const handleFollow = useCallback(() => {
if (!account) return;
@ -185,7 +158,7 @@ export const AccountCard: React.FC<{ accountId: string }> = ({ accountId }) => {
{account.get('note').length > 0 && (
<div
className='account-card__bio translate'
className='account-card__bio translate animate-parent'
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
dangerouslySetInnerHTML={{ __html: account.get('note_emojified') }}

View File

@ -5,22 +5,23 @@ import type { CustomEmojiMapArg } from './types';
type EmojiHTMLProps<Element extends ElementType = 'div'> = Omit<
ComponentPropsWithoutRef<Element>,
'dangerouslySetInnerHTML'
'dangerouslySetInnerHTML' | 'className'
> & {
htmlString: string;
extraEmojis?: CustomEmojiMapArg;
as?: Element;
shallow?: boolean;
className?: string;
};
export const EmojiHTML = <Element extends ElementType>({
export const EmojiHTML = ({
extraEmojis,
htmlString,
as: asElement, // Rename for syntax highlighting
as: Wrapper = 'div', // Rename for syntax highlighting
shallow,
className = '',
...props
}: EmojiHTMLProps<Element>) => {
const Wrapper = asElement ?? 'div';
}: EmojiHTMLProps<ElementType>) => {
const emojifiedHtml = useEmojify({
text: htmlString,
extraEmojis,
@ -32,6 +33,10 @@ export const EmojiHTML = <Element extends ElementType>({
}
return (
<Wrapper {...props} dangerouslySetInnerHTML={{ __html: emojifiedHtml }} />
<Wrapper
{...props}
className={`${className} animate-parent`}
dangerouslySetInnerHTML={{ __html: emojifiedHtml }}
/>
);
};

View File

@ -0,0 +1,78 @@
import type { MouseEventHandler } from 'react';
import { autoPlayGif } from '@/mastodon/initial_state';
import { isModernEmojiEnabled } from '@/mastodon/utils/environment';
export const handleAnimateEnter: MouseEventHandler = ({ currentTarget }) => {
if (autoPlayGif || isModernEmojiEnabled()) {
return;
}
currentTarget
.querySelectorAll<HTMLImageElement>('img.custom-emoji')
.forEach((emoji) => {
toggleAnimatedGif(emoji, true);
});
};
export const handleAnimateLeave: MouseEventHandler = ({ currentTarget }) => {
if (autoPlayGif || isModernEmojiEnabled()) {
return;
}
currentTarget
.querySelectorAll<HTMLImageElement>('img.custom-emoji')
.forEach((emoji) => {
toggleAnimatedGif(emoji, false);
});
};
const PARENT_MAX_DEPTH = 10;
export function handleAnimateGif(event: MouseEvent) {
const { target, type } = event;
const animate = type === 'mouseover';
if (target instanceof HTMLImageElement) {
toggleAnimatedGif(target, animate);
} else if (!(target instanceof HTMLElement) || target === document.body) {
return;
}
let parent: HTMLElement | null = null;
let iter = 0;
if (target.classList.contains('animate-parent')) {
parent = target;
} else {
let current: HTMLElement | null = target;
while (current) {
if (iter >= PARENT_MAX_DEPTH) {
return; // We can just exit right now.
}
current = current.parentElement;
if (current?.classList.contains('animate-parent')) {
parent = current;
break;
}
iter++;
}
}
if (parent) {
const animatedChildren =
parent.querySelectorAll<HTMLImageElement>('img.custom-emoji');
for (const child of animatedChildren) {
toggleAnimatedGif(child, animate);
}
}
}
function toggleAnimatedGif(image: HTMLImageElement, animate: boolean) {
const { classList, dataset } = image;
if (
!classList.contains('custom-emoji') ||
!dataset.static ||
!dataset.original
) {
return;
}
image.src = animate ? dataset.original : dataset.static;
}

View File

@ -1,6 +1,7 @@
import initialState from '@/mastodon/initial_state';
import initialState, { autoPlayGif } from '@/mastodon/initial_state';
import { loadWorker } from '@/mastodon/utils/workers';
import { handleAnimateGif } from './handlers';
import { toSupportedLocale } from './locale';
import { emojiLogger } from './utils';
@ -22,6 +23,11 @@ export function initializeEmoji() {
}
}
if (typeof document !== 'undefined' && !autoPlayGif) {
document.addEventListener('mouseover', handleAnimateGif, { passive: true });
document.addEventListener('mouseout', handleAnimateGif, { passive: true });
}
if (worker) {
// Assign worker to const to make TS happy inside the event listener.
const thisWorker = worker;
@ -51,16 +57,6 @@ export function initializeEmoji() {
}
}
async function fallbackLoad() {
log('falling back to main thread for loading');
const { importCustomEmojiData } = await import('./loader');
await importCustomEmojiData();
await loadEmojiLocale(userLocale);
if (userLocale !== 'en') {
await loadEmojiLocale('en');
}
}
export async function loadEmojiLocale(localeString: string) {
const locale = toSupportedLocale(localeString);
@ -71,3 +67,13 @@ export async function loadEmojiLocale(localeString: string) {
await importEmojiData(locale);
}
}
async function fallbackLoad() {
log('falling back to main thread for loading');
const { importCustomEmojiData } = await import('./loader');
await importCustomEmojiData();
await loadEmojiLocale(userLocale);
if (userLocale !== 'en') {
await loadEmojiLocale('en');
}
}

View File

@ -24,6 +24,7 @@ import { unicodeMapping } from 'mastodon/features/emoji/emoji_unicode_mapping_li
import { autoPlayGif, reduceMotion, disableSwiping, mascot } from 'mastodon/initial_state';
import { assetHost } from 'mastodon/utils/config';
import { WithRouterPropTypes } from 'mastodon/utils/react_router';
import { handleAnimateEnter, handleAnimateLeave } from '../../emoji/handlers';
const messages = defineMessages({
close: { id: 'lightbox.close', defaultMessage: 'Close' },
@ -111,42 +112,16 @@ class ContentWithRouter extends ImmutablePureComponent {
}
};
handleMouseEnter = ({ currentTarget }) => {
if (autoPlayGif) {
return;
}
const emojis = currentTarget.querySelectorAll('.custom-emoji');
for (var i = 0; i < emojis.length; i++) {
let emoji = emojis[i];
emoji.src = emoji.getAttribute('data-original');
}
};
handleMouseLeave = ({ currentTarget }) => {
if (autoPlayGif) {
return;
}
const emojis = currentTarget.querySelectorAll('.custom-emoji');
for (var i = 0; i < emojis.length; i++) {
let emoji = emojis[i];
emoji.src = emoji.getAttribute('data-static');
}
};
render () {
const { announcement } = this.props;
return (
<div
className='announcements__item__content translate'
className='announcements__item__content translate animate-parent'
ref={this.setRef}
dangerouslySetInnerHTML={{ __html: announcement.get('contentHtml') }}
onMouseEnter={this.handleMouseEnter}
onMouseLeave={this.handleMouseLeave}
onMouseEnter={handleAnimateEnter}
onMouseLeave={handleAnimateLeave}
/>
);
}
@ -238,9 +213,20 @@ class Reaction extends ImmutablePureComponent {
}
return (
<animated.button className={classNames('reactions-bar__item', { active: reaction.get('me') })} onClick={this.handleClick} onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave} title={`:${shortCode}:`} style={this.props.style}>
<span className='reactions-bar__item__emoji'><Emoji hovered={this.state.hovered} emoji={reaction.get('name')} emojiMap={this.props.emojiMap} /></span>
<span className='reactions-bar__item__count'><AnimatedNumber value={reaction.get('count')} /></span>
<animated.button
className={classNames('reactions-bar__item', { active: reaction.get('me') })}
onClick={this.handleClick}
onMouseEnter={this.handleMouseEnter}
onMouseLeave={this.handleMouseLeave}
title={`:${shortCode}:`}
style={this.props.style}
>
<span className='reactions-bar__item__emoji'>
<Emoji hovered={this.state.hovered} emoji={reaction.get('name')} emojiMap={this.props.emojiMap} />
</span>
<span className='reactions-bar__item__count'>
<AnimatedNumber value={reaction.get('count')} />
</span>
</animated.button>
);
}

View File

@ -15,6 +15,8 @@ import { DisplayName } from 'mastodon/components/display_name';
import { Icon } from 'mastodon/components/icon';
import { useAppSelector, useAppDispatch } from 'mastodon/store';
import { handleAnimateEnter, handleAnimateLeave } from '../../emoji/handlers';
import { EmbeddedStatusContent } from './embedded_status_content';
export type Mention = RecordOf<{ url: string; acct: string }>;
@ -76,31 +78,9 @@ export const EmbeddedStatus: React.FC<{ statusId: string }> = ({
[clickCoordinatesRef, statusId, account, history],
);
const handleMouseEnter = useCallback<React.MouseEventHandler<HTMLDivElement>>(
({ currentTarget }) => {
const emojis =
currentTarget.querySelectorAll<HTMLImageElement>('.custom-emoji');
const handleMouseEnter = useCallback(handleAnimateEnter, []);
for (const emoji of emojis) {
const newSrc = emoji.getAttribute('data-original');
if (newSrc) emoji.src = newSrc;
}
},
[],
);
const handleMouseLeave = useCallback<React.MouseEventHandler<HTMLDivElement>>(
({ currentTarget }) => {
const emojis =
currentTarget.querySelectorAll<HTMLImageElement>('.custom-emoji');
for (const emoji of emojis) {
const newSrc = emoji.getAttribute('data-static');
if (newSrc) emoji.src = newSrc;
}
},
[],
);
const handleMouseLeave = useCallback(handleAnimateLeave, []);
const handleContentWarningClick = useCallback(() => {
dispatch(toggleStatusSpoilers(statusId));
@ -123,7 +103,7 @@ export const EmbeddedStatus: React.FC<{ statusId: string }> = ({
return (
<div
className='notification-group__embedded-status'
className='notification-group__embedded-status animate-parent'
role='button'
tabIndex={-1}
onMouseDown={handleMouseDown}