Refactor emoji GIF animation (#36165)
Some checks are pending
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

This commit is contained in:
Echo 2025-09-23 10:53:14 +02:00 committed by GitHub
parent 24ddf80ff7
commit 6bd90940b6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 120 additions and 193 deletions

View File

@ -14,7 +14,10 @@ export const DisplayNameWithoutDomain: FC<
ComponentPropsWithoutRef<'span'> ComponentPropsWithoutRef<'span'>
> = ({ account, className, children, ...props }) => { > = ({ account, className, children, ...props }) => {
return ( return (
<span {...props} className={classNames('display-name', className)}> <span
{...props}
className={classNames('display-name animate-parent', className)}
>
<bdi> <bdi>
{account ? ( {account ? (
<EmojiHTML <EmojiHTML

View File

@ -140,32 +140,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 () { componentDidMount () {
this._updateStatusLinks(); this._updateStatusLinks();
} }
@ -257,7 +231,13 @@ class StatusContent extends PureComponent {
if (this.props.onClick) { if (this.props.onClick) {
return ( 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'
>
<EmojiHTML <EmojiHTML
className='status__content__text status__content__text--visible translate' className='status__content__text status__content__text--visible translate'
lang={language} lang={language}
@ -274,7 +254,7 @@ class StatusContent extends PureComponent {
); );
} else { } else {
return ( return (
<div className={classNames} ref={this.setRef} onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave}> <div className={classNames} ref={this.setRef}>
<EmojiHTML <EmojiHTML
className='status__content__text status__content__text--visible translate' className='status__content__text status__content__text--visible translate'
lang={language} lang={language}

View File

@ -379,36 +379,6 @@ export const AccountHeader: React.FC<{
}); });
}, [account]); }, [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 suspended = account?.suspended;
const isRemote = account?.acct !== account?.username; const isRemote = account?.acct !== account?.username;
const remoteDomain = isRemote ? account?.acct.split('@')[1] : null; const remoteDomain = isRemote ? account?.acct.split('@')[1] : null;
@ -808,11 +778,9 @@ export const AccountHeader: React.FC<{
)} )}
<div <div
className={classNames('account__header', { className={classNames('account__header animate-parent', {
inactive: !!account.moved, inactive: !!account.moved,
})} })}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
> >
{!(suspended || hidden || account.moved) && {!(suspended || hidden || account.moved) &&
relationship?.requested_by && ( relationship?.requested_by && (

View File

@ -23,7 +23,6 @@ import { IconButton } from 'mastodon/components/icon_button';
import { RelativeTimestamp } from 'mastodon/components/relative_timestamp'; import { RelativeTimestamp } from 'mastodon/components/relative_timestamp';
import StatusContent from 'mastodon/components/status_content'; import StatusContent from 'mastodon/components/status_content';
import { Dropdown } from 'mastodon/components/dropdown_menu'; import { Dropdown } from 'mastodon/components/dropdown_menu';
import { autoPlayGif } from 'mastodon/initial_state';
import { makeGetStatus } from 'mastodon/selectors'; import { makeGetStatus } from 'mastodon/selectors';
import { LinkedDisplayName } from '@/mastodon/components/display_name'; import { LinkedDisplayName } from '@/mastodon/components/display_name';
@ -57,32 +56,6 @@ export const Conversation = ({ conversation, scrollKey }) => {
const lastStatus = useSelector(state => getStatus(state, { id: lastStatusId })); const lastStatus = useSelector(state => getStatus(state, { id: lastStatusId }));
const accounts = useSelector(state => getAccounts(state, accountIds)); 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 handleClick = useCallback(() => { const handleClick = useCallback(() => {
if (unread) { if (unread) {
dispatch(markConversationRead(id)); dispatch(markConversationRead(id));
@ -163,7 +136,7 @@ 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' 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> }} /> <FormattedMessage id='conversation.with' defaultMessage='With {names}' values={{ names: <span>{names}</span> }} />
</div> </div>
</div> </div>

View File

@ -1,4 +1,3 @@
import type { MouseEventHandler } from 'react';
import { useCallback } from 'react'; import { useCallback } from 'react';
import { FormattedMessage, defineMessages, useIntl } from 'react-intl'; import { FormattedMessage, defineMessages, useIntl } from 'react-intl';
@ -44,39 +43,6 @@ export const AccountCard: React.FC<{ accountId: string }> = ({ accountId }) => {
const account = useAppSelector((s) => getAccount(s, accountId)); const account = useAppSelector((s) => getAccount(s, accountId));
const dispatch = useAppDispatch(); 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 handleFollow = useCallback(() => { const handleFollow = useCallback(() => {
if (!account) return; if (!account) return;
@ -185,9 +151,7 @@ export const AccountCard: React.FC<{ accountId: string }> = ({ accountId }) => {
{account.get('note').length > 0 && ( {account.get('note').length > 0 && (
<div <div
className='account-card__bio translate' className='account-card__bio translate animate-parent'
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
dangerouslySetInnerHTML={{ __html: account.get('note_emojified') }} dangerouslySetInnerHTML={{ __html: account.get('note_emojified') }}
/> />
)} )}

View File

@ -1,5 +1,7 @@
import type { ComponentPropsWithoutRef, ElementType } from 'react'; import type { ComponentPropsWithoutRef, ElementType } from 'react';
import classNames from 'classnames';
import { isModernEmojiEnabled } from '@/mastodon/utils/environment'; import { isModernEmojiEnabled } from '@/mastodon/utils/environment';
import { useEmojify } from './hooks'; import { useEmojify } from './hooks';
@ -7,12 +9,13 @@ import type { CustomEmojiMapArg } from './types';
type EmojiHTMLProps<Element extends ElementType = 'div'> = Omit< type EmojiHTMLProps<Element extends ElementType = 'div'> = Omit<
ComponentPropsWithoutRef<Element>, ComponentPropsWithoutRef<Element>,
'dangerouslySetInnerHTML' 'dangerouslySetInnerHTML' | 'className'
> & { > & {
htmlString: string; htmlString: string;
extraEmojis?: CustomEmojiMapArg; extraEmojis?: CustomEmojiMapArg;
as?: Element; as?: Element;
shallow?: boolean; shallow?: boolean;
className?: string;
}; };
export const ModernEmojiHTML = ({ export const ModernEmojiHTML = ({
@ -20,6 +23,7 @@ export const ModernEmojiHTML = ({
htmlString, htmlString,
as: Wrapper = 'div', // Rename for syntax highlighting as: Wrapper = 'div', // Rename for syntax highlighting
shallow, shallow,
className = '',
...props ...props
}: EmojiHTMLProps<ElementType>) => { }: EmojiHTMLProps<ElementType>) => {
const emojifiedHtml = useEmojify({ const emojifiedHtml = useEmojify({
@ -33,7 +37,11 @@ export const ModernEmojiHTML = ({
} }
return ( return (
<Wrapper {...props} dangerouslySetInnerHTML={{ __html: emojifiedHtml }} /> <Wrapper
{...props}
className={classNames(className, 'animate-parent')}
dangerouslySetInnerHTML={{ __html: emojifiedHtml }}
/>
); );
}; };
@ -43,7 +51,13 @@ export const EmojiHTML = <Element extends ElementType>(
if (isModernEmojiEnabled()) { if (isModernEmojiEnabled()) {
return <ModernEmojiHTML {...props} />; return <ModernEmojiHTML {...props} />;
} }
const { as: asElement, htmlString, extraEmojis, ...rest } = props; const { as: asElement, htmlString, extraEmojis, className, ...rest } = props;
const Wrapper = asElement ?? 'div'; const Wrapper = asElement ?? 'div';
return <Wrapper {...rest} dangerouslySetInnerHTML={{ __html: htmlString }} />; return (
<Wrapper
{...rest}
dangerouslySetInnerHTML={{ __html: htmlString }}
className={classNames(className, 'animate-parent')}
/>
);
}; };

View File

@ -0,0 +1,61 @@
import { autoPlayGif } from '@/mastodon/initial_state';
const PARENT_MAX_DEPTH = 10;
export function handleAnimateGif(event: MouseEvent) {
// We already check this in ui/index.jsx, but just to be sure.
if (autoPlayGif) {
return;
}
const { target, type } = event;
const animate = type === 'mouseover'; // Mouse over = animate, mouse out = don't animate.
if (target instanceof HTMLImageElement) {
setAnimateGif(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 {
// Iterate up to PARENT_MAX_DEPTH levels up the DOM tree to find a parent with the class 'animate-parent'.
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++;
}
}
// Affect all animated children within the parent.
if (parent) {
const animatedChildren =
parent.querySelectorAll<HTMLImageElement>('img.custom-emoji');
for (const child of animatedChildren) {
setAnimateGif(child, animate);
}
}
}
function setAnimateGif(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

@ -111,42 +111,14 @@ 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 () { render () {
const { announcement } = this.props; const { announcement } = this.props;
return ( return (
<div <div
className='announcements__item__content translate' className='announcements__item__content translate animate-parent'
ref={this.setRef} ref={this.setRef}
dangerouslySetInnerHTML={{ __html: announcement.get('contentHtml') }} dangerouslySetInnerHTML={{ __html: announcement.get('contentHtml') }}
onMouseEnter={this.handleMouseEnter}
onMouseLeave={this.handleMouseLeave}
/> />
); );
} }
@ -238,9 +210,21 @@ class Reaction extends ImmutablePureComponent {
} }
return ( 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}> <animated.button
<span className='reactions-bar__item__emoji'><Emoji hovered={this.state.hovered} emoji={reaction.get('name')} emojiMap={this.props.emojiMap} /></span> className={classNames('reactions-bar__item', { active: reaction.get('me') })}
<span className='reactions-bar__item__count'><AnimatedNumber value={reaction.get('count')} /></span> onClick={this.handleClick}
title={`:${shortCode}:`}
style={this.props.style}
// This does not use animate-parent as this component is directly rendered by React.
onMouseEnter={this.handleMouseEnter}
onMouseLeave={this.handleMouseLeave}
>
<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> </animated.button>
); );
} }

View File

@ -76,32 +76,6 @@ export const EmbeddedStatus: React.FC<{ statusId: string }> = ({
[clickCoordinatesRef, statusId, account, history], [clickCoordinatesRef, statusId, account, history],
); );
const handleMouseEnter = useCallback<React.MouseEventHandler<HTMLDivElement>>(
({ currentTarget }) => {
const emojis =
currentTarget.querySelectorAll<HTMLImageElement>('.custom-emoji');
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 handleContentWarningClick = useCallback(() => { const handleContentWarningClick = useCallback(() => {
dispatch(toggleStatusSpoilers(statusId)); dispatch(toggleStatusSpoilers(statusId));
}, [dispatch, statusId]); }, [dispatch, statusId]);
@ -123,13 +97,11 @@ export const EmbeddedStatus: React.FC<{ statusId: string }> = ({
return ( return (
<div <div
className='notification-group__embedded-status' className='notification-group__embedded-status animate-parent'
role='button' role='button'
tabIndex={-1} tabIndex={-1}
onMouseDown={handleMouseDown} onMouseDown={handleMouseDown}
onMouseUp={handleMouseUp} onMouseUp={handleMouseUp}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
> >
<div className='notification-group__embedded-status__account'> <div className='notification-group__embedded-status__account'>
<Avatar account={account} size={16} /> <Avatar account={account} size={16} />

View File

@ -22,11 +22,12 @@ import { identityContextPropShape, withIdentity } from 'mastodon/identity_contex
import { layoutFromWindow } from 'mastodon/is_mobile'; import { layoutFromWindow } from 'mastodon/is_mobile';
import { WithRouterPropTypes } from 'mastodon/utils/react_router'; import { WithRouterPropTypes } from 'mastodon/utils/react_router';
import { handleAnimateGif } from '../emoji/handlers';
import { uploadCompose, resetCompose, changeComposeSpoilerness } from '../../actions/compose'; import { uploadCompose, resetCompose, changeComposeSpoilerness } from '../../actions/compose';
import { clearHeight } from '../../actions/height_cache'; import { clearHeight } from '../../actions/height_cache';
import { fetchServer, fetchServerTranslationLanguages } from '../../actions/server'; import { fetchServer, fetchServerTranslationLanguages } from '../../actions/server';
import { expandHomeTimeline } from '../../actions/timelines'; import { expandHomeTimeline } from '../../actions/timelines';
import initialState, { me, owner, singleUserMode, trendsEnabled, trendsAsLanding, disableHoverCards } from '../../initial_state'; import initialState, { me, owner, singleUserMode, trendsEnabled, trendsAsLanding, disableHoverCards, autoPlayGif } from '../../initial_state';
import BundleColumnError from './components/bundle_column_error'; import BundleColumnError from './components/bundle_column_error';
import { NavigationBar } from './components/navigation_bar'; import { NavigationBar } from './components/navigation_bar';
@ -379,6 +380,11 @@ class UI extends PureComponent {
window.addEventListener('beforeunload', this.handleBeforeUnload, false); window.addEventListener('beforeunload', this.handleBeforeUnload, false);
window.addEventListener('resize', this.handleResize, { passive: true }); window.addEventListener('resize', this.handleResize, { passive: true });
if (!autoPlayGif) {
window.addEventListener('mouseover', handleAnimateGif, { passive: true });
window.addEventListener('mouseout', handleAnimateGif, { passive: true });
}
document.addEventListener('dragenter', this.handleDragEnter, false); document.addEventListener('dragenter', this.handleDragEnter, false);
document.addEventListener('dragover', this.handleDragOver, false); document.addEventListener('dragover', this.handleDragOver, false);
document.addEventListener('drop', this.handleDrop, false); document.addEventListener('drop', this.handleDrop, false);
@ -404,6 +410,8 @@ class UI extends PureComponent {
window.removeEventListener('blur', this.handleWindowBlur); window.removeEventListener('blur', this.handleWindowBlur);
window.removeEventListener('beforeunload', this.handleBeforeUnload); window.removeEventListener('beforeunload', this.handleBeforeUnload);
window.removeEventListener('resize', this.handleResize); window.removeEventListener('resize', this.handleResize);
window.removeEventListener('mouseover', handleAnimateGif);
window.removeEventListener('mouseout', handleAnimateGif);
document.removeEventListener('dragenter', this.handleDragEnter); document.removeEventListener('dragenter', this.handleDragEnter);
document.removeEventListener('dragover', this.handleDragOver); document.removeEventListener('dragover', this.handleDragOver);