fix(app): animate reaction emoji only while hovering them (#1883)

This commit is contained in:
Hampus
2026-08-24 12:37:48 +02:00
committed by GitHub
parent 19264d7f81
commit 7b646f891e
5 changed files with 12 additions and 73 deletions
@@ -78,14 +78,10 @@ const MessageReactionItem = observer(
const {i18n} = useLingui();
const [hoverRef, isHovering] = useHover();
const [prevCount, setPrevCount] = useState(reaction.count);
const [animationSyncKey, setAnimationSyncKey] = useState(0);
const [emojiInfoOpen, setEmojiInfoOpen] = useState(false);
const [selectedEmoji, setSelectedEmoji] = useState<EmojiInfoData | null>(null);
const [tooltipHovering, setTooltipHovering] = useState(false);
const isMobile = MobileLayout.isMobileLayout();
const handleTooltipAnimationSync = useCallback(() => {
setAnimationSyncKey((prev) => prev + 1);
}, []);
useEffect(() => {
if (prevCount !== reaction.count) {
setPrevCount(reaction.count);
@@ -186,6 +182,7 @@ const MessageReactionItem = observer(
<ReactionImage
src={emojiUrl}
alt={emojiName}
aria-hidden={true}
draggable={false}
className={clsx('emoji', styles.emoji, reactionShouldBlur && matureStyles.matureBlurred)}
data-flx="channel.message-reactions.message-reaction-item.emoji"
@@ -251,8 +248,6 @@ const MessageReactionItem = observer(
message={message}
reaction={reaction}
hoveredEmojiUrl={emojiUrl}
animationSyncKey={animationSyncKey}
onRequestAnimationSync={handleTooltipAnimationSync}
onTooltipHoverChange={setTooltipHovering}
data-flx="channel.message-reactions.message-reaction-item.reaction-tooltip"
>
@@ -1,6 +1,5 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {useExpressionImagePreload} from '@app/features/expressions/utils/ExpressionImageCache';
import type React from 'react';
type ReactionImageProps = Omit<React.ImgHTMLAttributes<HTMLImageElement>, 'alt' | 'src'> & {
@@ -8,15 +7,6 @@ type ReactionImageProps = Omit<React.ImgHTMLAttributes<HTMLImageElement>, 'alt'
alt: string;
};
export const ReactionImage: React.FC<ReactionImageProps> = ({
src,
alt,
decoding = 'async',
loading = 'eager',
...props
}) => {
useExpressionImagePreload(src);
return (
<img src={src} alt={alt} decoding={decoding} loading={loading} data-flx="messaging.reaction-image.img" {...props} />
);
export const ReactionImage: React.FC<ReactionImageProps> = ({src, alt, loading = 'eager', ...props}) => {
return <img src={src} alt={alt} loading={loading} data-flx="messaging.reaction-image.img" {...props} />;
};
@@ -28,40 +28,24 @@ export const ReactionTooltip = observer(
reaction,
children,
hoveredEmojiUrl,
animationSyncKey,
onRequestAnimationSync,
onTooltipHoverChange,
}: {
message: Message;
reaction: MessageReaction;
children: React.ReactElement<Record<string, unknown> & {ref?: React.Ref<HTMLElement>}>;
hoveredEmojiUrl?: string | null;
animationSyncKey?: number;
onRequestAnimationSync?: () => void;
onTooltipHoverChange?: (hovering: boolean) => void;
}) => {
const {i18n} = useLingui();
const tooltip = useHoverFloatingTooltip(500);
const prevIsOpenRef = useRef(false);
useEffect(() => {
if (tooltip.state.isOpen && !prevIsOpenRef.current) {
onRequestAnimationSync?.();
}
prevIsOpenRef.current = tooltip.state.isOpen;
}, [tooltip.state.isOpen, onRequestAnimationSync]);
useEffect(() => {
onTooltipHoverChange?.(tooltip.state.isOpen);
}, [tooltip.state.isOpen, onTooltipHoverChange]);
const {fetchStatus} = useReactionUsers(message.id, reaction.emoji);
const isLoading = fetchStatus === 'pending';
const tooltipText = getReactionTooltip(message, reaction.emoji);
const emojiIdentifier = reaction.emoji.id ?? reaction.emoji.name;
const tooltipEmojiKey = `${emojiIdentifier}-${animationSyncKey ?? 0}`;
const fallbackEmojiUrl = useEmojiURL({
emoji: reaction.emoji,
isHovering: tooltip.state.isOpen,
forceAnimate: tooltip.state.isOpen,
});
const tooltipEmojiKey = reaction.emoji.id ?? reaction.emoji.name;
const fallbackEmojiUrl = useEmojiURL({emoji: reaction.emoji});
const emojiUrl = hoveredEmojiUrl ?? fallbackEmojiUrl;
const errorRetryRef = useRef(false);
useEffect(() => {
@@ -1,22 +1,17 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import i18n from '@app/app/I18n';
import {useShouldAnimate} from '@app/features/app/hooks/useShouldAnimate';
import Channels from '@app/features/channel/state/Channels';
import type {UnicodeEmoji} from '@app/features/emoji/types/EmojiTypes';
import EmojiCatalog from '@app/features/expressions/utils/EmojiCatalog';
import * as EmojiImageUtils from '@app/features/expressions/utils/EmojiImageUtils';
import {getSkinTonedSurrogate} from '@app/features/expressions/utils/SkinToneUtils';
import type {Message} from '@app/features/messaging/models/MessagingMessage';
import MessageReactions from '@app/features/messaging/state/MessageReactions';
import * as ImageCacheUtils from '@app/features/messaging/utils/ImageCacheUtils';
import {setUrlQueryParams} from '@app/features/messaging/utils/MessagingUrlUtils';
import {getReactionKey, type ReactionEmoji} from '@app/features/messaging/utils/ReactionEmoji';
import * as AvatarSourceUtils from '@app/features/user/utils/AvatarSourceUtils';
import * as ReactionUtils from '@app/features/messaging/utils/ReactionUtils';
import * as DisplayNameUtils from '@app/features/user/utils/DisplayNameUtils';
import {getCurrentLocale} from '@app/features/user/utils/LocaleUtils';
import {msg, plural} from '@lingui/core/macro';
import {useEffect, useState} from 'react';
const REACTED_BY_DESCRIPTOR = msg({
message: '{emojiName} reacted by {reactors}',
@@ -97,10 +92,6 @@ export function getEmojiNameWithColons(emoji: ReactionEmoji): string {
return name ? `:${name}:` : surrogate;
}
function getCustomEmojiURL(id: string, animated: boolean, size: number): string {
return setUrlQueryParams(AvatarSourceUtils.getEmojiURL({id, animated}), {size, quality: 'lossless'});
}
export interface EmojiURLParams {
readonly emoji: ReactionEmoji;
readonly isHovering?: boolean;
@@ -116,26 +107,6 @@ export function useEmojiURL({
forceAnimate = false,
enabled = true,
}: EmojiURLParams): string | null {
const shouldAnimate = useShouldAnimate({
kind: 'emoji',
isHovering: isHovering || forceAnimate,
});
const staticUrl =
emoji.id == null
? EmojiImageUtils.getEmojiURL(EmojiCatalog.normalizeShortcodeToSurrogate(emoji.name))
: getCustomEmojiURL(emoji.id, false, size);
const animatedUrl = emoji.id != null && emoji.animated ? getCustomEmojiURL(emoji.id, true, size) : null;
const [loadedAnimatedUrl, setLoadedAnimatedUrl] = useState<string | null>(() =>
ImageCacheUtils.hasImage(animatedUrl) ? animatedUrl : null,
);
useEffect(() => {
if (!enabled) return;
ImageCacheUtils.pinImage(staticUrl);
}, [enabled, staticUrl]);
useEffect(() => {
if (!enabled || !shouldAnimate || !animatedUrl) return;
return ImageCacheUtils.loadImage(animatedUrl, () => setLoadedAnimatedUrl(animatedUrl));
}, [animatedUrl, enabled, shouldAnimate]);
if (!enabled) return null;
return shouldAnimate && animatedUrl === loadedAnimatedUrl ? animatedUrl : staticUrl;
const url = ReactionUtils.useEmojiURL({emoji, isHovering, size, forceAnimate});
return enabled ? url : null;
}
@@ -4,13 +4,12 @@ import i18n from '@app/app/I18n';
import {useShouldAnimate} from '@app/features/app/hooks/useShouldAnimate';
import Channels from '@app/features/channel/state/Channels';
import type {UnicodeEmoji} from '@app/features/emoji/types/EmojiTypes';
import {buildCustomEmojiURL} from '@app/features/expressions/utils/CustomEmojiImageUrl';
import * as EmojiUtils from '@app/features/expressions/utils/EmojiUtils';
import {getSkinTonedSurrogate} from '@app/features/expressions/utils/SkinToneUtils';
import UnicodeEmojis from '@app/features/expressions/utils/UnicodeEmojis';
import type {Message} from '@app/features/messaging/models/MessagingMessage';
import MessageReactions from '@app/features/messaging/state/MessageReactions';
import {setUrlQueryParams} from '@app/features/messaging/utils/MessagingUrlUtils';
import * as AvatarUtils from '@app/features/user/utils/AvatarUtils';
import {getCurrentLocale} from '@app/features/user/utils/LocaleUtils';
import * as NicknameUtils from '@app/features/user/utils/NicknameUtils';
import {msg, plural} from '@lingui/core/macro';
@@ -101,7 +100,7 @@ export function getEmojiNameWithColons(emoji: ReactionEmoji): string {
export function useEmojiURL({
emoji,
isHovering = false,
size = 128,
size,
forceAnimate = false,
}: {
emoji: ReactionEmoji;
@@ -111,11 +110,11 @@ export function useEmojiURL({
}): string | null {
const shouldAnimate = useShouldAnimate({
kind: 'emoji',
isAnimated: emoji.animated === true,
isHovering: isHovering || forceAnimate,
});
if (emoji.id == null) {
return EmojiUtils.getEmojiURL(UnicodeEmojis.normalizeEmojiNameToSurrogate(emoji.name));
}
const url = AvatarUtils.getEmojiURL({id: emoji.id, animated: shouldAnimate});
return setUrlQueryParams(url, {size, quality: 'lossless'});
return buildCustomEmojiURL({id: emoji.id, animated: emoji.animated === true && shouldAnimate, size});
}