fix(app): honour motion settings in composer autocomplete previews (#1912)

This commit is contained in:
Hampus authored and GitHub committed 2026-08-24 12:44:19 +02:00
1 parent 0670380360
commit 7d02b7959f
5 files changed
+143 -100

No files matched your search

@@ -1,10 +1,10 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {useAnimatedMediaVideoPlayback} from '@app/features/app/hooks/useAnimatedMediaPlayback';
import {useShouldAnimate} from '@app/features/app/hooks/useShouldAnimate';
import {type AutocompleteOption, isEmoji, isMeme, isSticker} from '@app/features/channel/components/Autocomplete';
import styles from '@app/features/channel/components/AutocompleteEmoji.module.css';
import {AutocompleteItem} from '@app/features/channel/components/AutocompleteItem';
import {AutocompleteMemePreview} from '@app/features/channel/components/AutocompleteMemePreview';
import * as EmojiPickerCommands from '@app/features/emoji/commands/EmojiPickerCommands';
import {useStickerAnimation} from '@app/features/emoji/hooks/useStickerAnimation';
import type {FlatEmoji} from '@app/features/emoji/types/EmojiTypes';
@@ -16,13 +16,12 @@ import {
MEDIA_DESCRIPTOR,
STICKERS_DESCRIPTOR,
} from '@app/features/i18n/utils/CommonMessageDescriptors';
import {getEmojiRenderUrl} from '@app/features/messaging/utils/markdown/EmojiDetector';
import * as AvatarUtils from '@app/features/user/utils/AvatarUtils';
import {msg} from '@lingui/core/macro';
import {useLingui} from '@lingui/react/macro';
import {MusicNoteIcon} from '@phosphor-icons/react';
import {observer} from 'mobx-react-lite';
import type React from 'react';
import {useRef} from 'react';
const DEFAULT_EMOJI_DESCRIPTOR = msg({
message: 'Default emoji',
@@ -34,53 +33,53 @@ const SectionHeading = observer(({children}: {children: React.ReactNode}) => (
</div>
));
const AutocompleteEmojiIcon = observer(({emoji}: {emoji: FlatEmoji}) => {
const shouldAnimate = useShouldAnimate({kind: 'emoji'});
const isAnimatable = Boolean(emoji.animated);
const shouldAnimate = useShouldAnimate({
kind: 'emoji',
isAnimated: isAnimatable,
});
const {url: fallbackDisplayUrl} = getEmojiDisplayData(emoji);
const displayUrl = emoji.id
? AvatarUtils.getEmojiURL({id: emoji.id, animated: Boolean(emoji.animated) && shouldAnimate})
: (fallbackDisplayUrl ?? '');
const displayUrl =
getEmojiRenderUrl({
id: emoji.id,
surrogateUrl: fallbackDisplayUrl ?? null,
isAnimatable,
animated: shouldAnimate,
jumbo: false,
}) ?? '';
return (
<img
draggable={false}
className={styles.emojiIcon}
src={displayUrl}
alt={emoji.name}
aria-hidden={true}
data-flx="channel.autocomplete-emoji.emoji-icon"
/>
);
});
const AutocompleteStickerIcon = observer(
({sticker, isInteracting}: {sticker: GuildSticker; isInteracting: boolean}) => {
const {shouldAnimate} = useStickerAnimation({isInteracting});
const {shouldAnimate} = useStickerAnimation({isInteracting, isAnimated: sticker.animated});
return (
<div className={styles.stickerIconWrapper} data-flx="channel.autocomplete-emoji.sticker-icon-wrapper">
<img
draggable={false}
className={styles.stickerIcon}
src={AvatarUtils.getStickerURL({id: sticker.id, animated: shouldAnimate, size: 320})}
src={AvatarUtils.getStickerURL({
id: sticker.id,
animated: shouldAnimate,
isAnimatable: sticker.animated,
size: 320,
})}
alt={sticker.name}
aria-hidden={true}
data-flx="channel.autocomplete-emoji.sticker-icon"
/>
</div>
);
},
);
const AutocompleteMemeVideo = ({src}: {src: string}) => {
const videoRef = useRef<HTMLVideoElement>(null);
const playbackAllowed = useAnimatedMediaVideoPlayback(videoRef);
return (
<video
ref={videoRef}
src={src}
className={styles.memeVideo}
muted
autoPlay={playbackAllowed}
loop
playsInline
data-flx="channel.autocomplete-emoji.meme-video"
/>
);
};
export const AutocompleteEmoji = observer(
({
onSelect,
@@ -214,31 +213,10 @@ export const AutocompleteEmoji = observer(
description={option.meme.tags.length > 0 ? option.meme.tags.join(', ') : undefined}
icon={
<div className={styles.memeIconWrapper} data-flx="channel.autocomplete-emoji.meme-icon-wrapper">
{option.meme.contentType.startsWith('video/') || option.meme.contentType.includes('gif') ? (
<AutocompleteMemeVideo
src={option.meme.url}
data-flx="channel.autocomplete-emoji.autocomplete-meme-video"
/>
) : option.meme.contentType.startsWith('audio/') ? (
<div
className={styles.audioIconWrapper}
data-flx="channel.autocomplete-emoji.audio-icon-wrapper"
>
<MusicNoteIcon
className={styles.audioIcon}
weight="fill"
data-flx="channel.autocomplete-emoji.audio-icon"
/>
</div>
) : (
<img
draggable={false}
className={styles.memeIcon}
src={option.meme.url}
alt={option.meme.name}
data-flx="channel.autocomplete-emoji.meme-icon"
/>
)}
<AutocompleteMemePreview
meme={option.meme}
data-flx="channel.autocomplete-emoji.autocomplete-meme-preview"
/>
</div>
}
isKeyboardSelected={currentIndex === keyboardFocusIndex}
@@ -1,10 +1,12 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {useAnimatedMediaVideoPlayback} from '@app/features/app/hooks/useAnimatedMediaPlayback';
import {useShouldAnimate} from '@app/features/app/hooks/useShouldAnimate';
import {type AutocompleteOption, isGif} from '@app/features/channel/components/Autocomplete';
import styles from '@app/features/channel/components/AutocompleteGif.module.css';
import * as KlipyUtils from '@app/features/expressions/utils/KlipyUtils';
import {GIFS_DESCRIPTOR} from '@app/features/i18n/utils/CommonMessageDescriptors';
import {buildStaticGifPreviewURL} from '@app/features/messaging/utils/MediaProxyUtils';
import {Scroller, type ScrollerHandle} from '@app/features/ui/components/Scroller';
import {msg} from '@lingui/core/macro';
import {useLingui} from '@lingui/react/macro';
@@ -16,22 +18,56 @@ const NO_GIFS_FOUND_DESCRIPTOR = msg({
message: 'No GIFs match',
comment: 'Empty-state text in the channel and chat autocomplete gif.',
});
const AutocompleteGifVideo = ({src}: {src: string}) => {
const PLAYABLE_PREVIEW_EXTENSION_PATTERN = /\.(mp4|webm|mov|m4v)(?:$|\?)/iu;
function isPlayablePreviewSource(value: string): boolean {
try {
return PLAYABLE_PREVIEW_EXTENSION_PATTERN.test(new URL(value).pathname);
} catch {
return PLAYABLE_PREVIEW_EXTENSION_PATTERN.test(value);
}
}
const AutocompleteGifVideo = ({src, motionAllowed}: {src: string; motionAllowed: boolean}) => {
const videoRef = useRef<HTMLVideoElement>(null);
const playbackAllowed = useAnimatedMediaVideoPlayback(videoRef);
const playbackAllowed = useAnimatedMediaVideoPlayback(videoRef, {shouldPlay: motionAllowed});
return (
<video
ref={videoRef}
src={src}
className={styles.gifVideo}
muted
autoPlay={playbackAllowed}
autoPlay={motionAllowed && playbackAllowed}
loop
playsInline
preload={motionAllowed ? 'auto' : 'metadata'}
src={src}
data-flx="channel.autocomplete-gif.gif-video"
/>
);
};
const AutocompleteGifPreview = observer(({src, isActive}: {src: string; isActive: boolean}) => {
const motionAllowed = useShouldAnimate({kind: 'gif', isHovering: isActive});
if (src.length === 0) {
return null;
}
if (isPlayablePreviewSource(src)) {
return (
<AutocompleteGifVideo
src={src}
motionAllowed={motionAllowed}
data-flx="channel.autocomplete-gif.autocomplete-gif-video"
/>
);
}
return (
<img
draggable={false}
src={motionAllowed ? src : buildStaticGifPreviewURL(src)}
alt=""
className={styles.gifVideo}
data-flx="channel.autocomplete-gif.gif-image"
/>
);
});
export const AutocompleteGif = observer(
({
onSelect,
@@ -117,9 +153,10 @@ export const AutocompleteGif = observer(
data-flx="channel.autocomplete-gif.gif-button.select"
>
<div className={styles.gifVideoWrapper} data-flx="channel.autocomplete-gif.gif-video-wrapper">
<AutocompleteGifVideo
<AutocompleteGifPreview
src={gif.proxy_src}
data-flx="channel.autocomplete-gif.autocomplete-gif-video"
isActive={isActive}
data-flx="channel.autocomplete-gif.autocomplete-gif-preview"
/>
</div>
</button>
@@ -1,30 +1,12 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {useAnimatedMediaVideoPlayback} from '@app/features/app/hooks/useAnimatedMediaPlayback';
import {type AutocompleteOption, isMeme} from '@app/features/channel/components/Autocomplete';
import styles from '@app/features/channel/components/AutocompleteEmoji.module.css';
import {AutocompleteItem} from '@app/features/channel/components/AutocompleteItem';
import {MusicNoteIcon} from '@phosphor-icons/react';
import {AutocompleteMemePreview} from '@app/features/channel/components/AutocompleteMemePreview';
import {observer} from 'mobx-react-lite';
import type React from 'react';
import {useRef} from 'react';
const AutocompleteMemeVideo = ({src}: {src: string}) => {
const videoRef = useRef<HTMLVideoElement>(null);
const playbackAllowed = useAnimatedMediaVideoPlayback(videoRef);
return (
<video
ref={videoRef}
src={src}
className={styles.memeVideo}
muted
autoPlay={playbackAllowed}
loop
playsInline
data-flx="channel.autocomplete-meme.meme-video"
/>
);
};
export const AutocompleteMeme = observer(
({
onSelect,
@@ -54,28 +36,10 @@ export const AutocompleteMeme = observer(
description={option.meme.tags.length > 0 ? option.meme.tags.join(', ') : undefined}
icon={
<div className={styles.memeIconWrapper} data-flx="channel.autocomplete-meme.meme-icon-wrapper">
{option.meme.contentType.startsWith('video/') || option.meme.contentType.includes('gif') ? (
<AutocompleteMemeVideo
src={option.meme.url}
data-flx="channel.autocomplete-meme.autocomplete-meme-video"
/>
) : option.meme.contentType.startsWith('audio/') ? (
<div className={styles.audioIconWrapper} data-flx="channel.autocomplete-meme.audio-icon-wrapper">
<MusicNoteIcon
className={styles.audioIcon}
weight="fill"
data-flx="channel.autocomplete-meme.audio-icon"
/>
</div>
) : (
<img
draggable={false}
className={styles.memeIcon}
src={option.meme.url}
alt={option.meme.name}
data-flx="channel.autocomplete-meme.meme-icon"
/>
)}
<AutocompleteMemePreview
meme={option.meme}
data-flx="channel.autocomplete-meme.autocomplete-meme-preview"
/>
</div>
}
isKeyboardSelected={index === keyboardFocusIndex}
@@ -0,0 +1,62 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {useAnimatedMediaVideoPlayback} from '@app/features/app/hooks/useAnimatedMediaPlayback';
import {useShouldAnimate} from '@app/features/app/hooks/useShouldAnimate';
import styles from '@app/features/channel/components/AutocompleteEmoji.module.css';
import type {FavoriteMeme} from '@app/features/expressions/models/FavoriteMeme';
import {buildStaticGifPreviewURL} from '@app/features/messaging/utils/MediaProxyUtils';
import {MusicNoteIcon} from '@phosphor-icons/react';
import {observer} from 'mobx-react-lite';
import {useRef} from 'react';
const AutocompleteMemeVideo = ({src}: {src: string}) => {
const videoRef = useRef<HTMLVideoElement>(null);
const playbackAllowed = useAnimatedMediaVideoPlayback(videoRef);
return (
<video
ref={videoRef}
className={styles.memeVideo}
muted
autoPlay={playbackAllowed}
loop
playsInline
preload="auto"
src={src}
data-flx="channel.autocomplete-meme-preview.meme-video"
/>
);
};
export const AutocompleteMemePreview = observer(({meme}: {meme: FavoriteMeme}) => {
const contentType = meme.contentType.toLowerCase();
const isAudioMeme = contentType.startsWith('audio/');
const isVideoMeme = contentType.startsWith('video/');
const isAnimatedImageMeme = !isVideoMeme && !isAudioMeme && contentType.includes('gif');
const motionAllowed = useShouldAnimate({kind: 'gif', isAnimated: isVideoMeme || isAnimatedImageMeme});
if (isAudioMeme) {
return (
<div className={styles.audioIconWrapper} data-flx="channel.autocomplete-meme-preview.audio-icon-wrapper">
<MusicNoteIcon
className={styles.audioIcon}
weight="fill"
data-flx="channel.autocomplete-meme-preview.audio-icon"
/>
</div>
);
}
if (isVideoMeme && motionAllowed) {
return (
<AutocompleteMemeVideo src={meme.url} data-flx="channel.autocomplete-meme-preview.autocomplete-meme-video" />
);
}
const needsStillFrame = (isVideoMeme || isAnimatedImageMeme) && !motionAllowed;
return (
<img
draggable={false}
className={styles.memeIcon}
src={needsStillFrame ? buildStaticGifPreviewURL(meme.url) : meme.url}
alt={meme.name}
aria-hidden={true}
data-flx="channel.autocomplete-meme-preview.meme-icon"
/>
);
});
@@ -10,15 +10,16 @@ import {observer} from 'mobx-react-lite';
import type React from 'react';
const AutocompleteStickerIcon = observer(
({id, name, isInteracting}: {id: string; name: string; isInteracting: boolean}) => {
const {shouldAnimate} = useStickerAnimation({isInteracting});
({id, name, animated, isInteracting}: {id: string; name: string; animated: boolean; isInteracting: boolean}) => {
const {shouldAnimate} = useStickerAnimation({isInteracting, isAnimated: animated});
return (
<div className={styles.stickerIconWrapper} data-flx="channel.autocomplete-sticker.sticker-icon-wrapper">
<img
draggable={false}
className={styles.stickerIcon}
src={AvatarUtils.getStickerURL({id, animated: shouldAnimate, size: 320})}
src={AvatarUtils.getStickerURL({id, animated: shouldAnimate, isAnimatable: animated, size: 320})}
alt={name}
aria-hidden={true}
data-flx="channel.autocomplete-sticker.sticker-icon"
/>
</div>
@@ -58,6 +59,7 @@ export const AutocompleteSticker = observer(
<AutocompleteStickerIcon
id={option.sticker.id}
name={option.sticker.name}
animated={option.sticker.animated}
isInteracting={index === keyboardFocusIndex || index === hoverIndex}
data-flx="channel.autocomplete-sticker.autocomplete-sticker-icon"
/>