mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 13:05:32 +09:00
fix(app): only animate media that has an animated variant (#1864)
This commit is contained in:
1 parent
15f4417c68
commit
39e2c89d5c
3 files changed
+65
-41
No files matched your search
+32
-30
@@ -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;
|
||||||
|
|||||||
Reference in new issue
Block a user