fix(app): animate message emoji only while hovering the message (#1869)

This commit is contained in:
Hampus authored and GitHub committed 2026-08-24 12:34:52 +02:00
1 parent 5b8a46d087
commit a59f3d0d0c
2 files changed
+74 -39

No files matched your search

@@ -1,15 +1,16 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {MatureEmojiWrapper} from '@app/features/app/components/shared/MatureEmojiWrapper';
import {useShouldAnimate} from '@app/features/app/hooks/useShouldAnimate';
import {requestDeleteMessage} from '@app/features/channel/components/MessageActionUtils';
import {useMaybeMessageViewContext} from '@app/features/channel/components/MessageViewContext';
import {EmojiInfoBottomSheet} from '@app/features/emoji/components/bottomsheets/EmojiInfoBottomSheet';
import {EmojiInfoContent} from '@app/features/emoji/components/emojis/EmojiInfoContent';
import type {FlatEmoji} from '@app/features/emoji/types/EmojiTypes';
import {isKeyboardActivationKey} from '@app/features/input/utils/KeyboardUtils';
import type {RendererProps} from '@app/features/messaging/components/markdown/renderers/RendererTypes';
import Messages from '@app/features/messaging/state/MessagingMessages';
import {setUrlQueryParams} from '@app/features/messaging/utils/MessagingUrlUtils';
import {getEmojiRenderData} from '@app/features/messaging/utils/markdown/EmojiDetector';
import {getEmojiRenderData, getEmojiRenderUrl} from '@app/features/messaging/utils/markdown/EmojiDetector';
import {EmojiKind} from '@app/features/messaging/utils/markdown/parser/Enums';
import type {EmojiNode} from '@app/features/messaging/utils/markdown/parser/Nodes';
import {EmojiContextMenuItems, EmojiInlineMenuItems} from '@app/features/ui/action_menu/items/EmojiContextMenuItems';
@@ -31,6 +32,8 @@ const FAILED_TO_LOAD_DESCRIPTOR = msg({
comment: 'Error message in the messaging emoji renderer.',
});
const FAILED_EMOJI_STYLE: React.CSSProperties = {opacity: 0.5};
interface EmojiBottomSheetState {
isOpen: boolean;
emoji: {id?: string; name: string; animated?: boolean} | null;
@@ -88,19 +91,25 @@ const EmojiRendererInner = observer(function EmojiRendererInner({
isOpen: false,
emoji: null,
});
const [failedUrl, setFailedUrl] = useState<string | null>(null);
const messageView = useMaybeMessageViewContext();
const isAnimatable = emojiData.isAnimatable;
const shouldAnimate = useShouldAnimate({
kind: 'emoji',
isHovering: isAnimatable && messageView?.isHovering === true,
});
const animated = isAnimatable && shouldAnimate;
const className = clsx('emoji', shouldJumboEmojis && 'jumboable');
const size = shouldJumboEmojis ? 240 : 96;
const renderedEmojiUrl = useMemo(
const emojiUrl = useMemo(
() =>
emojiData.id && emojiData.url ? setUrlQueryParams(emojiData.url, {size, quality: 'lossless'}) : emojiData.url,
[emojiData.id, emojiData.url, size],
);
const tooltipEmojiUrl = useMemo(
() =>
emojiData.id && emojiData.url
? setUrlQueryParams(emojiData.url, {size: 240, quality: 'lossless'})
: emojiData.url,
[emojiData.id, emojiData.url],
getEmojiRenderUrl({
id: emojiData.id,
surrogateUrl: emojiData.surrogateUrl,
isAnimatable: emojiData.isAnimatable,
animated,
jumbo: shouldJumboEmojis,
}),
[emojiData.id, emojiData.surrogateUrl, emojiData.isAnimatable, animated, shouldJumboEmojis],
);
const isCustomEmoji = node.kind.kind === EmojiKind.Custom;
const standardEmojiSurrogate = node.kind.kind === EmojiKind.Standard ? node.kind.raw : undefined;
@@ -147,11 +156,11 @@ const EmojiRendererInner = observer(function EmojiRendererInner({
allNamesString: `:${node.kind.name}:`,
uniqueName: node.kind.name,
surrogates: standardEmojiSurrogate,
url: standardEmojiSurrogate ? (emojiData.url ?? undefined) : undefined,
url: standardEmojiSurrogate ? (emojiData.surrogateUrl ?? undefined) : undefined,
};
}, [
emojiData.id,
emojiData.url,
emojiData.surrogateUrl,
emojiRecord,
fallbackAnimated,
fallbackGuildId,
@@ -160,13 +169,11 @@ const EmojiRendererInner = observer(function EmojiRendererInner({
]);
const getTooltipData = useCallback(() => {
const emojiForSubtext = buildEmojiForSubtext();
return {emojiUrl: tooltipEmojiUrl, emojiForSubtext};
}, [buildEmojiForSubtext, tooltipEmojiUrl]);
const handleImageError = (e: React.SyntheticEvent<HTMLImageElement>) => {
const target = e.target as HTMLImageElement;
target.style.opacity = '0.5';
target.alt = `${emojiData.name} ${i18n._(FAILED_TO_LOAD_DESCRIPTOR)}`;
};
return {emojiUrl, emojiForSubtext};
}, [buildEmojiForSubtext, emojiUrl]);
const handleImageError = useCallback((e: React.SyntheticEvent<HTMLImageElement>) => {
setFailedUrl((e.target as HTMLImageElement).getAttribute('src'));
}, []);
const handleContextMenu = useCallback(
(e: React.MouseEvent) => {
if (!isCustomEmoji || !emojiData.id) return;
@@ -220,20 +227,22 @@ const EmojiRendererInner = observer(function EmojiRendererInner({
) : (
element
);
const hasFailed = emojiUrl != null && failedUrl === emojiUrl;
const renderEmojiElement = (contextMenu: boolean, dataFlx: string) =>
renderedEmojiUrl ? (
emojiUrl ? (
<img
draggable={false}
className={className}
alt={emojiData.name}
src={renderedEmojiUrl}
alt={hasFailed ? `${emojiData.name} ${i18n._(FAILED_TO_LOAD_DESCRIPTOR)}` : emojiData.name}
src={emojiUrl}
style={hasFailed ? FAILED_EMOJI_STYLE : undefined}
data-message-id={messageId}
data-message-emoji="true"
data-emoji-id={emojiData.id}
data-animated={emojiData.isAnimated}
onError={handleImageError}
onContextMenu={contextMenu ? handleContextMenu : undefined}
loading="lazy"
loading="eager"
data-flx={dataFlx}
/>
) : (
@@ -1,43 +1,69 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {getAnimatedMediaPlaybackAllowed} from '@app/features/app/hooks/useAnimatedMediaPlayback';
import Emoji from '@app/features/emoji/state/Emoji';
import type {FlatEmoji} from '@app/features/emoji/types/EmojiTypes';
import {
buildCustomEmojiURL,
CUSTOM_EMOJI_ENLARGED_IMAGE_RUNG,
CUSTOM_EMOJI_IMAGE_RUNG,
} from '@app/features/expressions/utils/CustomEmojiImageUrl';
import * as EmojiUtils from '@app/features/expressions/utils/EmojiUtils';
import {EmojiKind} from '@app/features/messaging/utils/markdown/parser/Enums';
import type {EmojiNode} from '@app/features/messaging/utils/markdown/parser/Nodes';
import UserSettings from '@app/features/user/state/UserSettings';
import * as AvatarUtils from '@app/features/user/utils/AvatarUtils';
interface EmojiRenderData {
url: string | null;
export interface EmojiRenderData {
name: string;
isAnimated: boolean;
isAnimatable: boolean;
surrogateUrl: string | null;
id?: string;
emoji?: FlatEmoji;
}
export function getEmojiRenderData(emojiNode: EmojiNode, disableAnimatedEmoji = false): EmojiRenderData {
const {kind} = emojiNode;
const emojiName = `:${kind.name}:`;
if (kind.kind === EmojiKind.Standard) {
return {
url: EmojiUtils.getTwemojiURL(kind.codepoints),
name: emojiName,
name: `:${kind.name}:`,
isAnimated: false,
isAnimatable: false,
surrogateUrl: EmojiUtils.getTwemojiURL(kind.codepoints),
};
}
const {id} = kind;
const emoji = Emoji.getEmojiById(id);
const isAnimated = emoji?.animated ?? kind.animated;
const shouldAnimate =
isAnimated && !disableAnimatedEmoji && UserSettings.getAnimateEmoji() && getAnimatedMediaPlaybackAllowed();
const finalEmojiName = `:${emoji?.name || kind.name}:`;
return {
url: AvatarUtils.getEmojiURL({id, animated: shouldAnimate}),
name: finalEmojiName,
name: `:${emoji?.name || kind.name}:`,
isAnimated,
isAnimatable: isAnimated && !disableAnimatedEmoji,
surrogateUrl: null,
id,
emoji,
};
}
interface EmojiRenderUrlOptions {
id?: string;
surrogateUrl: string | null;
isAnimatable: boolean;
animated: boolean;
jumbo: boolean;
}
export function getEmojiRenderUrl({
id,
surrogateUrl,
isAnimatable,
animated,
jumbo,
}: EmojiRenderUrlOptions): string | null {
if (id == null) {
return surrogateUrl;
}
return buildCustomEmojiURL({
id,
animated: animated && isAnimatable,
size: jumbo ? CUSTOM_EMOJI_ENLARGED_IMAGE_RUNG : CUSTOM_EMOJI_IMAGE_RUNG,
});
}