diff --git a/fluxer_app/src/features/app/components/shared/custom_status_display/CustomStatusDisplay.tsx b/fluxer_app/src/features/app/components/shared/custom_status_display/CustomStatusDisplay.tsx index e9906cd6f..6abfe8610 100644 --- a/fluxer_app/src/features/app/components/shared/custom_status_display/CustomStatusDisplay.tsx +++ b/fluxer_app/src/features/app/components/shared/custom_status_display/CustomStatusDisplay.tsx @@ -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: ( {status.emojiName - {status.emojiName + {animationAllowed && ( + {status.emojiName + )} ), 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) { diff --git a/fluxer_app/src/features/app/hooks/useAnimatedImageUrl.ts b/fluxer_app/src/features/app/hooks/useAnimatedImageUrl.ts index f344243f9..30a6b6bcd 100644 --- a/fluxer_app/src/features/app/hooks/useAnimatedImageUrl.ts +++ b/fluxer_app/src/features/app/hooks/useAnimatedImageUrl.ts @@ -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 = () => {}; + 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}; } diff --git a/fluxer_app/src/features/app/hooks/useAnimatedMediaPlayback.ts b/fluxer_app/src/features/app/hooks/useAnimatedMediaPlayback.ts index 8f4dcb551..8d4b03216 100644 --- a/fluxer_app/src/features/app/hooks/useAnimatedMediaPlayback.ts +++ b/fluxer_app/src/features/app/hooks/useAnimatedMediaPlayback.ts @@ -10,6 +10,7 @@ type AnimatedMediaPlaybackListener = () => void; interface UseAnimatedMediaPlaybackAllowedOptions { enabled?: boolean; + isAnimated?: boolean; } const listeners = new Set(); @@ -17,8 +18,9 @@ const listeners = new Set(); 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, - {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;