fix(app): only animate media that has an animated variant (#1864)

This commit is contained in:
Hampus authored and GitHub committed 2026-08-24 12:33:53 +02:00
1 parent 15f4417c68
commit 39e2c89d5c
3 files changed
+65 -41

No files matched your search

@@ -7,10 +7,10 @@ import {
getEmojiAttribution,
} from '@app/features/emoji/components/emojis/EmojiAttributionSubtext';
import Emoji from '@app/features/emoji/state/Emoji';
import {buildCustomEmojiURL} from '@app/features/expressions/utils/CustomEmojiImageUrl';
import {getEmojiURL} from '@app/features/expressions/utils/EmojiUtils';
import UnicodeEmojis from '@app/features/expressions/utils/UnicodeEmojis';
import Guilds from '@app/features/guild/state/Guilds';
import {setUrlQueryParams} from '@app/features/messaging/utils/MessagingUrlUtils';
import {usePresenceCustomStatus} from '@app/features/presence/hooks/usePresenceCustomStatus';
import {remFromPx} from '@app/features/theme/layout/RemFromPx';
import {EmojiTooltipContent} from '@app/features/ui/emoji_tooltip_content/EmojiTooltipContent';
@@ -22,7 +22,6 @@ import {HoverFloatingTooltipTrigger} from '@app/features/ui/tooltip/HoverFloatin
import {Tooltip, type TooltipPosition} from '@app/features/ui/tooltip/Tooltip';
import {useHoverFloatingTooltip} from '@app/features/ui/tooltip/useHoverFloatingTooltip';
import {type CustomStatus, getCustomStatusText, normalizeCustomStatus} from '@app/features/user/state/CustomStatus';
import * as AvatarUtils from '@app/features/user/utils/AvatarUtils';
import {Trans} from '@lingui/react/macro';
import {PencilIcon, SmileyIcon} from '@phosphor-icons/react';
import {clsx} from 'clsx';
@@ -62,14 +61,17 @@ interface ClampedStyle extends React.CSSProperties {
const sanitizeText = (text: string): string => {
return text.replace(/[\r\n]+/g, ' ').trim();
};
const getStatusEmojiAnimatable = (status: CustomStatus): boolean => {
if (!status.emojiId) {
return false;
}
return Emoji.getEmojiById(status.emojiId)?.animated ?? status.emojiAnimated ?? false;
};
const getTooltipEmojiUrl = (status: CustomStatus, animationAllowed: boolean): string | null => {
if (status.emojiId) {
const emoji = Emoji.getEmojiById(status.emojiId);
const isAnimated = (emoji?.animated ?? status.emojiAnimated ?? false) && animationAllowed;
return setUrlQueryParams(AvatarUtils.getEmojiURL({id: status.emojiId, animated: isAnimated}), {
size: 96,
quality: 'lossless',
});
const isAnimatable = getStatusEmojiAnimatable(status);
return buildCustomEmojiURL({id: status.emojiId, animated: animationAllowed && isAnimatable});
}
if (status.emojiName) {
return getEmojiURL(status.emojiName);
@@ -104,7 +106,12 @@ const StatusEmojiWithTooltip = observer(
return '';
};
const emojiName = getEmojiDisplayName();
const animationAllowed = useShouldAnimate({kind: 'custom_status_emoji', isHovering: true});
const isAnimatable = getStatusEmojiAnimatable(status);
const animationAllowed = useShouldAnimate({
kind: 'custom_status_emoji',
isAnimated: isAnimatable,
isHovering: isAnimatable,
});
const tooltipEmojiUrl = getTooltipEmojiUrl(status, animationAllowed);
const TriggerComponent = isButton ? 'button' : 'span';
const triggerProps = isButton
@@ -167,24 +174,15 @@ const renderStatusEmoji = (
animationAllowed: boolean = true,
): EmojiRenderResult | null => {
if (status.emojiId) {
const emoji = Emoji.getEmojiById(status.emojiId);
const altText = `:${status.emojiName}:`;
const isAnimated = (emoji?.animated ?? status.emojiAnimated ?? false) && animationAllowed;
const staticUrl = setUrlQueryParams(AvatarUtils.getEmojiURL({id: status.emojiId, animated: false}), {
size: 96,
quality: 'lossless',
});
const animatedUrl = isAnimated
? setUrlQueryParams(AvatarUtils.getEmojiURL({id: status.emojiId, animated: true}), {
size: 96,
quality: 'lossless',
})
: null;
const isAnimatable = getStatusEmojiAnimatable(status);
const staticUrl = buildCustomEmojiURL({id: status.emojiId, animated: false});
const animatedUrl = isAnimatable ? buildCustomEmojiURL({id: status.emojiId, animated: true}) : null;
if (alwaysAnimate && animatedUrl) {
return {
node: (
<img
src={animatedUrl}
src={animationAllowed ? animatedUrl : staticUrl}
alt={status.emojiName ?? undefined}
draggable={false}
className={clsx(styles.statusEmoji, emojiClassName)}
@@ -208,13 +206,15 @@ const renderStatusEmoji = (
className={clsx(styles.statusEmoji, styles.staticEmoji, emojiClassName)}
data-flx="app.custom-status-display.custom-status-display.render-status-emoji.status-emoji--2"
/>
<img
src={animatedUrl}
alt={status.emojiName ?? undefined}
draggable={false}
className={clsx(styles.statusEmoji, styles.animatedEmoji, emojiClassName)}
data-flx="app.custom-status-display.custom-status-display.render-status-emoji.status-emoji--3"
/>
{animationAllowed && (
<img
src={animatedUrl}
alt={status.emojiName ?? undefined}
draggable={false}
className={clsx(styles.statusEmoji, styles.animatedEmoji, emojiClassName)}
data-flx="app.custom-status-display.custom-status-display.render-status-emoji.status-emoji--3"
/>
)}
</span>
),
altText,
@@ -285,9 +285,11 @@ export const CustomStatusDisplay = observer(
checkVertical: maxLines > 1,
measureTextRange: true,
});
const emojiAnimatable = normalized ? getStatusEmojiAnimatable(normalized) : false;
const animationAllowed = useShouldAnimate({
kind: 'custom_status_emoji',
isHovering: Boolean(animateOnParentHover) || Boolean(alwaysAnimate),
isAnimated: emojiAnimatable,
isHovering: animateOnParentHover || alwaysAnimate,
});
if (!normalized) {
if (showPlaceholder && isEditable && onEdit) {
@@ -11,6 +11,7 @@ interface UseAnimatedImageUrlOptions {
staticUrl: string | null;
animatedUrl?: string | null;
kind: ShouldAnimateKind;
isAnimated?: boolean;
isFocused?: boolean;
}
@@ -23,6 +24,8 @@ interface AnimatedImageUrlState {
type AnimatedImageLoadStatus = 'idle' | 'loading' | 'loaded' | 'failed';
const NOOP_HOVER_REF: React.RefCallback<HTMLElement> = () => {};
interface AnimatedImageLoadState {
url: string | null;
status: AnimatedImageLoadStatus;
@@ -32,13 +35,21 @@ export function useAnimatedImageUrl({
staticUrl,
animatedUrl: providedAnimatedUrl,
kind,
isAnimated,
isFocused,
}: UseAnimatedImageUrlOptions): AnimatedImageUrlState {
const animatedUrl: string | null = providedAnimatedUrl == null ? null : providedAnimatedUrl;
const [hoverRef, isHovering] = useHover();
const shouldAnimate = useShouldAnimate({kind, isHovering, isFocused});
const gifAutoPlayEnabled = kind === 'gif' && UserSettings.getGifAutoPlay();
const hasAnimatedUrl = Boolean(animatedUrl && animatedUrl !== staticUrl);
const isAnimatable = isAnimated ?? hasAnimatedUrl;
const shouldAnimate = useShouldAnimate({
kind,
isAnimated: isAnimatable,
isHovering: isAnimatable && isHovering,
isFocused: isAnimatable && isFocused,
});
const autoplaysWithoutInteraction = useShouldAnimate({kind, isAnimated: isAnimatable});
const gifAutoPlayEnabled = kind === 'gif' && UserSettings.getGifAutoPlay();
const initialAnimatedLoadState: AnimatedImageLoadState = {
url: animatedUrl,
status: animatedUrl != null && ImageCacheUtils.hasImage(animatedUrl) ? 'loaded' : 'idle',
@@ -104,7 +115,9 @@ export function useAnimatedImageUrl({
animatedUrl != null &&
animatedLoadState.url === animatedUrl &&
animatedLoadState.status === 'loaded';
const imageUrl = animatedSourceReady ? animatedUrl : staticUrl;
const showGifIndicator = kind === 'gif' && hasAnimatedUrl && !gifAutoPlayEnabled && !shouldAnimate;
return {hoverRef, imageUrl, shouldAnimate, showGifIndicator};
const animatedSourceFailed = animatedLoadState.url === animatedUrl && animatedLoadState.status === 'failed';
const skipsStaticFirstPaint = autoplaysWithoutInteraction && !animatedSourceFailed;
const imageUrl = animatedUrl != null && (animatedSourceReady || skipsStaticFirstPaint) ? animatedUrl : staticUrl;
const showGifIndicator = kind === 'gif' && isAnimatable && !gifAutoPlayEnabled && !shouldAnimate;
return {hoverRef: isAnimatable ? hoverRef : NOOP_HOVER_REF, imageUrl, shouldAnimate, showGifIndicator};
}
@@ -10,6 +10,7 @@ type AnimatedMediaPlaybackListener = () => void;
interface UseAnimatedMediaPlaybackAllowedOptions {
enabled?: boolean;
isAnimated?: boolean;
}
const listeners = new Set<AnimatedMediaPlaybackListener>();
@@ -17,8 +18,9 @@ const listeners = new Set<AnimatedMediaPlaybackListener>();
let teardownGlobalListeners: (() => void) | null = null;
let lastAllowedSnapshot = true;
const subscribeAlwaysAllowed = (): (() => void) => () => {};
const subscribeConstant = (): (() => void) => () => {};
const getAlwaysAllowed = (): boolean => true;
const getNeverAllowed = (): boolean => false;
export function getAnimatedMediaPlaybackAllowed(root?: HTMLElement): boolean {
if (typeof document === 'undefined') return true;
@@ -66,19 +68,26 @@ export function subscribeAnimatedMediaPlaybackChange(listener: AnimatedMediaPlay
export function useAnimatedMediaPlaybackAllowed({
enabled = true,
isAnimated = true,
}: UseAnimatedMediaPlaybackAllowedOptions = {}): boolean {
const getConstant = isAnimated ? getAlwaysAllowed : getNeverAllowed;
const live = enabled && isAnimated;
return useSyncExternalStore(
enabled ? subscribeAnimatedMediaPlaybackChange : subscribeAlwaysAllowed,
enabled ? getAnimatedMediaPlaybackAllowed : getAlwaysAllowed,
getAlwaysAllowed,
live ? subscribeAnimatedMediaPlaybackChange : subscribeConstant,
live ? getAnimatedMediaPlaybackAllowed : getConstant,
getConstant,
);
}
export function useAnimatedMediaVideoPlayback(
videoRef: RefObject<HTMLVideoElement | null>,
{enabled = true, shouldPlay = true}: {enabled?: boolean; shouldPlay?: boolean} = {},
{
enabled = true,
shouldPlay = true,
isAnimated = true,
}: {enabled?: boolean; shouldPlay?: boolean; isAnimated?: boolean} = {},
): boolean {
const playbackAllowed = useAnimatedMediaPlaybackAllowed();
const playbackAllowed = useAnimatedMediaPlaybackAllowed({isAnimated});
useEffect(() => {
const video = videoRef.current;
if (!video) return;