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