From 50a947a722f3e817b089f3240d8852a23d62e31e Mon Sep 17 00:00:00 2001 From: Hampus Date: Mon, 24 Aug 2026 12:40:29 +0200 Subject: [PATCH] fix(app): fade image embed placeholders out instead of images in (#1891) --- .../embeds/media/EmbedImage.module.css | 1 + .../components/embeds/media/EmbedImage.tsx | 164 +++++++++++++----- 2 files changed, 118 insertions(+), 47 deletions(-) diff --git a/fluxer_app/src/features/channel/components/embeds/media/EmbedImage.module.css b/fluxer_app/src/features/channel/components/embeds/media/EmbedImage.module.css index 9f7649f58..a79ab1706 100644 --- a/fluxer_app/src/features/channel/components/embeds/media/EmbedImage.module.css +++ b/fluxer_app/src/features/channel/components/embeds/media/EmbedImage.module.css @@ -96,6 +96,7 @@ .thumbHashContainer { position: absolute; inset: 0; + pointer-events: none; } .thumbHashImage { diff --git a/fluxer_app/src/features/channel/components/embeds/media/EmbedImage.tsx b/fluxer_app/src/features/channel/components/embeds/media/EmbedImage.tsx index da27758ab..ccaa6fbe0 100644 --- a/fluxer_app/src/features/channel/components/embeds/media/EmbedImage.tsx +++ b/fluxer_app/src/features/channel/components/embeds/media/EmbedImage.tsx @@ -16,10 +16,13 @@ import {useDeleteAttachment} from '@app/features/messaging/hooks/useDeleteAttach import {useMatureMedia} from '@app/features/messaging/hooks/useMatureMedia'; import {useMediaFavorite} from '@app/features/messaging/hooks/useMediaFavorite'; import {useMediaLoading} from '@app/features/messaging/hooks/useMediaLoading'; +import {useMediaViewerHoverWarm} from '@app/features/messaging/hooks/useMediaViewerHoverWarm'; import {useNearViewport} from '@app/features/messaging/hooks/useNearViewport'; import {useOpenInBrowserOnMiddleClick} from '@app/features/messaging/hooks/useOpenInBrowserOnMiddleClick'; import type {Message} from '@app/features/messaging/models/MessagingMessage'; import {createDownloadHandler} from '@app/features/messaging/utils/FileDownloadUtils'; +import {EMBED_MAX_HEIGHT, EMBED_MAX_WIDTH} from '@app/features/messaging/utils/MediaDimensionConfig'; +import {resolveProxyRequestSize} from '@app/features/messaging/utils/MediaProxyRequestSize'; import {buildStaticGifPreviewURL, stripMediaProxyParams} from '@app/features/messaging/utils/MediaProxyUtils'; import {attachmentsToViewerItems, findViewerItemIndex} from '@app/features/messaging/utils/MediaViewerItemUtils'; import {remFromPx} from '@app/features/theme/layout/RemFromPx'; @@ -27,13 +30,13 @@ import {MediaContextMenu} from '@app/features/ui/action_menu/MediaContextMenu'; import * as ContextMenuCommands from '@app/features/ui/commands/ContextMenuCommands'; import * as MediaViewerCommands from '@app/features/ui/commands/MediaViewerCommands'; import FocusRing from '@app/features/ui/focus_ring/FocusRing'; -import MobileLayout from '@app/features/ui/state/MobileLayout'; +import type {MediaViewerItem} from '@app/features/ui/state/MediaViewer'; import {createCalculator} from '@app/features/ui/utils/DimensionUtils'; import type {MessageAttachment} from '@fluxer/schema/src/domains/message/MessageResponseSchemas'; import {msg} from '@lingui/core/macro'; import {useLingui} from '@lingui/react/macro'; import {clsx} from 'clsx'; -import {motion} from 'framer-motion'; +import {AnimatePresence, motion} from 'framer-motion'; import {observer} from 'mobx-react-lite'; import {type FC, useCallback, useMemo, useState} from 'react'; @@ -41,26 +44,52 @@ const OPEN_IMAGE_IN_FULL_VIEW_DESCRIPTOR = msg({ message: 'Open image in full view', comment: 'Button or menu action label in the channel and chat embed image. Keep it concise.', }); -const LOADING_DESCRIPTOR = msg({ - message: 'Loading: {alt}', - comment: 'Short label in the channel and chat embed image. Keep it concise. Preserve {alt}; it is inserted by code.', -}); -const LOADING_IMAGE_DESCRIPTOR = msg({ - message: 'Loading image', - comment: 'Short label in the channel and chat embed image. Keep it concise.', -}); const IMAGE_DESCRIPTOR = msg({ message: 'Image', comment: 'Short label in the channel and chat embed image. Keep it concise.', }); -const IMAGE_CONFIG = { - MAX_WIDTH: 400, -} as const; const imageCalculator = createCalculator({ - maxWidth: IMAGE_CONFIG.MAX_WIDTH, + maxWidth: EMBED_MAX_WIDTH, + maxHeight: EMBED_MAX_HEIGHT, responsive: true, }); +const NO_MEDIA_ATTACHMENTS: ReadonlyArray = Object.freeze([]); + +interface EmbedImageWarmInput { + src: string; + originalSrc: string; + naturalWidth: number; + naturalHeight: number; + contentHash?: string | null; + embedIndex?: number; + attachmentId?: string; + animated: boolean; + mediaAttachments: ReadonlyArray; +} + +export function resolveEmbedImageWarmItem(input: EmbedImageWarmInput): MediaViewerItem | null { + if (input.mediaAttachments.length > 0) { + const items = attachmentsToViewerItems(input.mediaAttachments); + return items[findViewerItemIndex(items, input.attachmentId)] ?? null; + } + if (input.src.length === 0) { + return null; + } + return { + src: input.src, + originalSrc: input.originalSrc, + naturalWidth: input.naturalWidth, + naturalHeight: input.naturalHeight, + type: 'image', + contentHash: input.contentHash, + embedIndex: input.embedIndex, + expiresAt: undefined, + expired: undefined, + animated: input.animated, + }; +} + interface ImagePreviewHandlerProps { src: string; originalSrc: string; @@ -75,6 +104,8 @@ interface ImagePreviewHandlerProps { message?: Message; animated?: boolean; mediaAttachments?: ReadonlyArray; + onViewerWarmEnter?: () => void; + onViewerWarmLeave?: () => void; children: React.ReactNode; } @@ -111,7 +142,9 @@ const ImagePreviewHandler: FC = observer( attachmentId, message, animated, - mediaAttachments = [], + mediaAttachments = NO_MEDIA_ATTACHMENTS, + onViewerWarmEnter, + onViewerWarmLeave, children, }) => { const {i18n} = useLingui(); @@ -197,6 +230,8 @@ const ImagePreviewHandler: FC = observer( onMouseDown={openInBrowser.onMouseDown} onAuxClick={openInBrowser.onAuxClick} onKeyDown={openImagePreview} + onMouseEnter={onViewerWarmEnter} + onMouseLeave={onViewerWarmLeave} data-flx="channel.embeds.media.embed-image.image-preview-handler.image-preview-handler.open-image-preview.button" > {children} @@ -227,16 +262,15 @@ export const EmbedImage: FC = observer( message, contentHash, onDelete, - mediaAttachments = [], + mediaAttachments = NO_MEDIA_ATTACHMENTS, isPreview, snapshotIndex, animated = false, }) => { const {i18n} = useLingui(); const messageViewContext = useMaybeMessageViewContext(); - const isMobile = MobileLayout.enabled; const {shouldBlur, gateReason, canReveal, reveal: revealSensitiveMedia} = useMatureMedia(nsfw, channelId); - const shouldAnimateImage = useShouldAnimate({kind: 'gif'}); + const shouldAnimateImage = useShouldAnimate({kind: 'gif', isAnimated: animated}); const {style: containerStyle, dimensions} = imageCalculator.calculate( {width, height}, { @@ -245,18 +279,48 @@ export const EmbedImage: FC = observer( aspectRatio: true, }, ); + const previewSize = useMemo( + () => resolveProxyRequestSize(dimensions.width, dimensions.height, naturalWidth, naturalHeight), + [dimensions.width, dimensions.height, naturalHeight, naturalWidth], + ); const effectiveSrc = useMemo(() => { if (!animated || shouldAnimateImage || src.startsWith('blob:')) return src; - return buildStaticGifPreviewURL(src, Math.round(dimensions.width * 2), Math.round(dimensions.height * 2)); - }, [animated, dimensions.height, dimensions.width, shouldAnimateImage, src]); + return buildStaticGifPreviewURL(src, previewSize?.width, previewSize?.height); + }, [animated, previewSize, shouldAnimateImage, src]); const staticPreviewSrc = useMemo(() => { if (!animated || src.startsWith('blob:')) return src; - return buildStaticGifPreviewURL(src, Math.round(dimensions.width * 2), Math.round(dimensions.height * 2)); - }, [animated, dimensions.height, dimensions.width, src]); - const {ref: visibilityRef, isNearViewport} = useNearViewport({ - disabled: !isMobile, - rememberKey: effectiveSrc, + return buildStaticGifPreviewURL(src, previewSize?.width, previewSize?.height); + }, [animated, previewSize, src]); + const viewerWarmItem = useMemo( + () => + resolveEmbedImageWarmItem({ + src, + originalSrc, + naturalWidth, + naturalHeight, + contentHash, + embedIndex, + attachmentId, + animated, + mediaAttachments, + }), + [ + animated, + attachmentId, + contentHash, + embedIndex, + mediaAttachments, + naturalHeight, + naturalWidth, + originalSrc, + src, + ], + ); + const {scheduleViewerWarm, cancelViewerWarm} = useMediaViewerHoverWarm(viewerWarmItem, { + allowAnimated: shouldAnimateImage, + enabled: !shouldBlur, }); + const {ref: visibilityRef, isNearViewport} = useNearViewport({rememberKey: effectiveSrc}); const shouldLoadMedia = isNearViewport && !shouldBlur; const staticImageSrc = animated && !src.startsWith('blob:') ? staticPreviewSrc : effectiveSrc; const animatedImageSource = @@ -264,7 +328,6 @@ export const EmbedImage: FC = observer( const { loaded, error, - cachedOnMount, thumbHashURL, ref: mediaRef, onLoad: handleImageLoad, @@ -398,7 +461,8 @@ export const EmbedImage: FC = observer( src={thumbHashURL} className={styles.thumbHashImage} alt="" - loading={isMobile ? 'lazy' : 'eager'} + aria-hidden={true} + loading="eager" style={{filter: 'blur(40px)'}} data-flx="channel.embeds.media.embed-image.thumb-hash-image" /> @@ -461,42 +525,48 @@ export const EmbedImage: FC = observer( message={message} mediaAttachments={mediaAttachments} animated={animated} + onViewerWarmEnter={scheduleViewerWarm} + onViewerWarmLeave={cancelViewerWarm} data-flx="channel.embeds.media.embed-image.image-preview-handler" >
- {shouldRenderPlaceholder && thumbHashURL && ( -
- {alt -
- )} - + + {shouldRenderPlaceholder && thumbHashURL && ( + + + + )} + {animatedImageSource.length > 0 && ( = observer( width={naturalWidth} height={naturalHeight} className={clsx(styles.imageElement, styles.animatedImageElement)} - loading={isMobile ? 'lazy' : 'eager'} + loading="eager" tabIndex={-1} onLoad={handleAnimatedImageLoad} onError={handleAnimatedImageError}