fix(app): request attachment images at their real mosaic tile size (#1888)

This commit is contained in:
Hampus authored and GitHub committed 2026-08-24 12:39:56 +02:00
1 parent 3a064dd728
commit 188f783fae
8 files changed
+216 -128

No files matched your search

@@ -38,3 +38,53 @@ export function splitMediaAndFileAttachments(attachments: ReadonlyArray<MessageA
}
return {mediaAttachments, fileAttachments};
}
const MOSAIC_TILE_GAP = 4;
interface MosaicTileSlot {
columns: number;
span: number;
aspectWidth: number;
aspectHeight: number;
}
const MOSAIC_TILE_SLOTS: Record<number, (index: number) => MosaicTileSlot> = {
2: () => ({columns: 2, span: 1, aspectWidth: 1, aspectHeight: 1}),
3: (index) => ({columns: 3, span: index === 0 ? 2 : 1, aspectWidth: 1, aspectHeight: 1}),
4: () => ({columns: 2, span: 1, aspectWidth: 3, aspectHeight: 2}),
5: (index) =>
index < 2
? {columns: 6, span: 3, aspectWidth: 3, aspectHeight: 2}
: {columns: 6, span: 2, aspectWidth: 1, aspectHeight: 1},
6: () => ({columns: 3, span: 1, aspectWidth: 1, aspectHeight: 1}),
7: (index) =>
index === 0
? {columns: 1, span: 1, aspectWidth: 16, aspectHeight: 9}
: {columns: 3, span: 1, aspectWidth: 1, aspectHeight: 1},
8: (index) =>
index < 2
? {columns: 2, span: 1, aspectWidth: 3, aspectHeight: 2}
: {columns: 3, span: 1, aspectWidth: 1, aspectHeight: 1},
9: () => ({columns: 3, span: 1, aspectWidth: 1, aspectHeight: 1}),
10: (index) =>
index === 0
? {columns: 1, span: 1, aspectWidth: 16, aspectHeight: 9}
: {columns: 3, span: 1, aspectWidth: 1, aspectHeight: 1},
};
const FALLBACK_MOSAIC_TILE_SLOT: MosaicTileSlot = {columns: 2, span: 1, aspectWidth: 3, aspectHeight: 2};
export function getMosaicTileBox(
count: number,
index: number,
mosaicWidth: number,
): {width: number; height: number; aspectRatio: string} {
const slot = MOSAIC_TILE_SLOTS[count]?.(index) ?? FALLBACK_MOSAIC_TILE_SLOT;
const track = (mosaicWidth - (slot.columns - 1) * MOSAIC_TILE_GAP) / slot.columns;
const width = Math.max(1, track * slot.span + (slot.span - 1) * MOSAIC_TILE_GAP);
return {
width,
height: Math.max(1, (width * slot.aspectHeight) / slot.aspectWidth),
aspectRatio: `${slot.aspectWidth} / ${slot.aspectHeight}`,
};
}
@@ -18,7 +18,11 @@ import DeveloperOptions from '@app/features/devtools/state/DeveloperOptions';
import type {Message} from '@app/features/messaging/models/MessagingMessage';
import {UploadingAttachment} from '@app/features/messaging/models/UploadingAttachment';
import {getEffectiveAttachmentExpiry} from '@app/features/messaging/utils/AttachmentExpiryUtils';
import {getAttachmentMediaDimensions} from '@app/features/messaging/utils/MediaDimensionConfig';
import {
getAttachmentMediaDimensions,
isUsefulVisualMediaSize,
} from '@app/features/messaging/utils/MediaDimensionConfig';
import {resolveProxyRequestSize} from '@app/features/messaging/utils/MediaProxyRequestSize';
import {
buildAnimatedImageProxyURL,
buildMediaProxyURL,
@@ -256,20 +260,21 @@ const AttachmentMedia: FC<AttachmentMediaProps & {message?: Message; isPreview?:
maxHeight: attachmentDimensions.maxHeight,
responsive: true,
});
const {dimensions} = mediaCalculator.calculate(
{
width: attachment.width!,
height: attachment.height!,
},
{forceScale: true},
const {dimensions} = mediaCalculator.calculate({
width: attachment.width!,
height: attachment.height!,
});
const requestedSize = resolveProxyRequestSize(
dimensions.width,
dimensions.height,
attachment.width!,
attachment.height!,
);
const targetWidth = Math.round(dimensions.width * 2);
const targetHeight = Math.round(dimensions.height * 2);
const proxySrc = attachment.proxy_url ?? attachment.url ?? '';
const optimizedSrc = buildMediaProxyURL(proxySrc, {
format: resolvePreferredImageFormat(attachment.content_type),
width: targetWidth,
height: targetHeight,
width: requestedSize?.width,
height: requestedSize?.height,
animated: attachmentIsAnimated,
});
return (
@@ -291,7 +296,7 @@ const AttachmentMedia: FC<AttachmentMediaProps & {message?: Message; isPreview?:
height={dimensions.height}
placeholder={attachment.placeholder}
constrain={true}
alt={attachment.title || attachment.description}
alt={attachment.description ?? undefined}
nsfw={nsfw}
channelId={message?.channelId}
messageId={message?.id}
@@ -368,6 +373,7 @@ export const Attachment: FC<AttachmentProps> = observer(
attachment={enrichedAttachment}
isPreview={isPreview}
message={message}
spoilerHidden={spoilerHidden}
data-flx="channel.embeds.attachments.attachment.attachment-file"
/>,
),
@@ -377,7 +383,10 @@ export const Attachment: FC<AttachmentProps> = observer(
if (renderInMosaic && isMediaAttachment(att)) {
return null;
}
if (!inlineAttachmentMedia && (isImageType(att.content_type) || isVideoType(att.content_type))) {
const isVisualType = isImageType(att.content_type) || isVideoType(att.content_type);
const tooSmallToShowInline =
isVisualType && !isUsefulVisualMediaSize(att.width ?? 0, att.height ?? 0, getAttachmentMediaDimensions());
if ((!inlineAttachmentMedia || tooSmallToShowInline) && isVisualType) {
return renderWithFootnote(
wrapSpoiler(
<FocusRing
@@ -389,6 +398,7 @@ export const Attachment: FC<AttachmentProps> = observer(
attachment={enrichedAttachment}
isPreview={isPreview}
message={message}
spoilerHidden={spoilerHidden}
data-flx="channel.embeds.attachments.attachment.attachment-file--2"
/>
</FocusRing>,
@@ -455,6 +465,7 @@ export const Attachment: FC<AttachmentProps> = observer(
attachment={enrichedAttachment}
isPreview={isPreview}
message={message}
spoilerHidden={spoilerHidden}
data-flx="channel.embeds.attachments.attachment.attachment-file--3"
/>
</FocusRing>,
@@ -548,6 +559,7 @@ export const Attachment: FC<AttachmentProps> = observer(
attachment={att}
isPreview={isPreview}
message={message}
spoilerHidden={spoilerHidden}
data-flx="channel.embeds.attachments.attachment.attachment-file--4"
/>
</FocusRing>
@@ -9,6 +9,7 @@ import {MatureMediaBlurOverlay} from '@app/features/channel/components/embeds/Ma
import {getMediaButtonVisibility} from '@app/features/channel/components/embeds/media/MediaButtonUtils';
import {MediaContainer} from '@app/features/channel/components/embeds/media/MediaContainer';
import {MediaActionBottomSheet} from '@app/features/channel/components/MediaActionBottomSheet';
import {getMosaicTileBox} from '@app/features/channel/components/MessageAttachmentUtils';
import {useMaybeMessageViewContext} from '@app/features/channel/components/MessageViewContext';
import * as FavoriteMemeCommands from '@app/features/expressions/commands/FavoriteMemeCommands';
import {AddFavoriteMemeModal} from '@app/features/expressions/components/modals/AddFavoriteMemeModal';
@@ -18,11 +19,13 @@ import {isKeyboardActivationKey} from '@app/features/input/utils/KeyboardUtils';
import {useDeleteAttachment} from '@app/features/messaging/hooks/useDeleteAttachment';
import {useMatureMedia} from '@app/features/messaging/hooks/useMatureMedia';
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 {getMosaicMediaDimensions} from '@app/features/messaging/utils/MediaDimensionConfig';
import {resolveProxyRequestSize} from '@app/features/messaging/utils/MediaProxyRequestSize';
import {
buildAnimatedImageProxyURL,
buildMediaProxyURL,
@@ -31,6 +34,7 @@ import {
} from '@app/features/messaging/utils/MediaProxyUtils';
import {
attachmentsToViewerItems,
attachmentToViewerItem,
determineMediaType,
findViewerItemIndex,
} from '@app/features/messaging/utils/MediaViewerItemUtils';
@@ -43,7 +47,7 @@ import * as ContextMenuCommands from '@app/features/ui/commands/ContextMenuComma
import * as MediaViewerCommands from '@app/features/ui/commands/MediaViewerCommands';
import * as ModalCommands from '@app/features/ui/commands/ModalCommands';
import {modal} from '@app/features/ui/commands/ModalCommands';
import MobileLayout from '@app/features/ui/state/MobileLayout';
import {snapMediaProxyImageSize} from '@app/features/user/utils/AvatarUtils';
import {MessageAttachmentFlags} from '@fluxer/constants/src/ChannelConstants';
import type {MessageAttachment} from '@fluxer/schema/src/domains/message/MessageResponseSchemas';
import {msg} from '@lingui/core/macro';
@@ -109,10 +113,9 @@ export const AttachmentGridItem: FC<AttachmentGridItemProps> = observer(
const isAudio = attachmentMediaType === 'audio';
const isGifv = attachmentMediaType === 'gifv';
const isAnimatedGif = attachmentMediaType === 'gif' || isGifv;
const isMobile = MobileLayout.enabled;
const isSpoiler = (attachment.flags & MessageAttachmentFlags.IS_SPOILER) !== 0;
const nsfw = attachment.nsfw || (attachment.flags & MessageAttachmentFlags.CONTAINS_EXPLICIT_MEDIA) !== 0;
const shouldAnimateGif = useShouldAnimate({kind: 'gif'});
const shouldAnimateGif = useShouldAnimate({kind: 'gif', isAnimated: isAnimatedGif});
const {hidden: spoilerHidden, reveal: revealSpoiler} = useSpoilerState(isSpoiler, message?.channelId);
const {shouldBlur, gateReason, canReveal, reveal: revealSensitiveMedia} = useMatureMedia(nsfw, message?.channelId);
const wrapSpoiler = (node: ReactElement) =>
@@ -129,18 +132,22 @@ export const AttachmentGridItem: FC<AttachmentGridItemProps> = observer(
node
);
const mosaicDimensions = getMosaicMediaDimensions();
const maxMosaicWidth = mosaicDimensions.maxWidth;
let targetWidth = maxMosaicWidth;
let targetHeight = maxMosaicWidth;
if (
const tileIndex = mediaAttachments.findIndex((candidate) => candidate.id === attachment.id);
const tileBox = getMosaicTileBox(mediaAttachments.length, Math.max(0, tileIndex), mosaicDimensions.maxWidth);
const hasIntrinsicSize =
typeof attachment.width === 'number' &&
attachment.width > 0 &&
typeof attachment.height === 'number' &&
attachment.height > 0
) {
targetWidth = Math.min(attachment.width, maxMosaicWidth * 2);
targetHeight = Math.max(1, Math.round((targetWidth / attachment.width) * attachment.height));
}
attachment.height > 0;
const snappedTileWidth = snapMediaProxyImageSize(tileBox.width, true);
const coverWidth = hasIntrinsicSize
? Math.max(tileBox.width, (tileBox.height * attachment.width!) / attachment.height!)
: tileBox.width;
const requestedSize = hasIntrinsicSize
? resolveProxyRequestSize(coverWidth, tileBox.height, attachment.width!, attachment.height!)
: {width: snappedTileWidth, height: snappedTileWidth};
const targetWidth = requestedSize?.width;
const targetHeight = requestedSize?.height;
const proxyUrl = attachment.proxy_url ?? attachment.url ?? '';
const isBlob = proxyUrl.startsWith('blob:');
const isPlainGif = attachmentMediaType === 'gif';
@@ -159,15 +166,11 @@ export const AttachmentGridItem: FC<AttachmentGridItemProps> = observer(
height: targetHeight,
animated: isAnimatedGif && shouldAnimateGif,
});
const {ref: visibilityRef, isNearViewport} = useNearViewport<HTMLDivElement>({
disabled: !isMobile,
rememberKey: thumbnailSrc,
});
const shouldLoadMedia = isNearViewport && !shouldBlur;
const {ref: visibilityRef, isNearViewport} = useNearViewport<HTMLDivElement>({rememberKey: thumbnailSrc});
const shouldLoadMedia = isNearViewport && !shouldBlur && !spoilerHidden;
const {
loaded,
error,
cachedOnMount,
thumbHashURL,
ref: mediaRef,
onLoad: handleImageLoad,
@@ -179,6 +182,11 @@ export const AttachmentGridItem: FC<AttachmentGridItemProps> = observer(
const isFavorited = attachment.content_hash
? memes.some((meme) => meme.contentHash === attachment.content_hash)
: false;
const viewerWarmItem = useMemo(() => attachmentToViewerItem(attachment), [attachment]);
const {scheduleViewerWarm, cancelViewerWarm} = useMediaViewerHoverWarm(viewerWarmItem, {
allowAnimated: shouldAnimateGif,
enabled: !shouldBlur,
});
const handleClick = useCallback(
(event: MouseEvent | KeyboardEvent) => {
if (shouldBlur) {
@@ -355,6 +363,8 @@ export const AttachmentGridItem: FC<AttachmentGridItemProps> = observer(
tabIndex={0}
className={styles.clickableButton}
onClick={handleClick}
onMouseEnter={scheduleViewerWarm}
onMouseLeave={cancelViewerWarm}
onMouseDown={openInBrowser.onMouseDown}
onAuxClick={openInBrowser.onAuxClick}
onKeyDown={handleClick}
@@ -388,6 +398,18 @@ export const AttachmentGridItem: FC<AttachmentGridItemProps> = observer(
GIF
</div>
)}
<img
src={shouldLoadMedia ? thumbnailSrc : undefined}
ref={mediaRef}
alt={attachment.filename}
loading="eager"
draggable={false}
className={clsx(styles.mediaImage, shouldBlur && styles.mediaBlurred)}
aria-hidden={shouldBlur}
onLoad={handleImageLoad}
onError={handleImageError}
data-flx="channel.embeds.attachments.attachment-grid-item.media-image"
/>
<AnimatePresence data-flx="channel.embeds.attachments.attachment-grid-item.animate-presence">
{shouldRenderPlaceholder && thumbHashURL && (
<motion.img
@@ -397,26 +419,12 @@ export const AttachmentGridItem: FC<AttachmentGridItemProps> = observer(
transition={{duration: Accessibility.useReducedMotion ? 0 : 0.3}}
src={thumbHashURL}
alt=""
aria-hidden={true}
className={styles.placeholderImage}
data-flx="channel.embeds.attachments.attachment-grid-item.placeholder-image"
/>
)}
</AnimatePresence>
<motion.img
src={shouldLoadMedia ? thumbnailSrc : undefined}
ref={mediaRef}
alt={attachment.filename}
loading={isMobile ? 'lazy' : 'eager'}
draggable={false}
className={clsx(styles.mediaImage, shouldBlur && styles.mediaBlurred)}
aria-hidden={shouldBlur}
onLoad={handleImageLoad}
onError={handleImageError}
initial={{opacity: cachedOnMount ? 1 : 0}}
animate={{opacity: shouldRenderPlaceholder ? 0 : 1}}
transition={{duration: cachedOnMount || Accessibility.useReducedMotion ? 0 : 0.3}}
data-flx="channel.embeds.attachments.attachment-grid-item.media-image"
/>
<AltTextBadge
altText={attachment.description}
onPopoutToggle={messageViewContext?.onPopoutToggle}
@@ -4,7 +4,9 @@ import {
AttachmentGridItem,
type LayoutType,
} from '@app/features/channel/components/embeds/attachments/AttachmentGridItem';
import {getMosaicTileBox} from '@app/features/channel/components/MessageAttachmentUtils';
import type {Message} from '@app/features/messaging/models/MessagingMessage';
import {getMosaicMediaDimensions} from '@app/features/messaging/utils/MediaDimensionConfig';
import styles from '@app/features/theme/styles/AttachmentLayoutGrid.module.css';
import type {MessageAttachment} from '@fluxer/schema/src/domains/message/MessageResponseSchemas';
import {observer} from 'mobx-react-lite';
@@ -20,55 +22,18 @@ export interface AttachmentLayoutGridProps {
interface LayoutConfig {
type: LayoutType;
gridClassName: string;
getAspectRatio: (index: number) => string | undefined;
}
const LAYOUT_CONFIGS: Record<number, LayoutConfig> = {
2: {
type: 'two',
gridClassName: styles.twoImageGrid,
getAspectRatio: () => '1 / 1',
},
3: {
type: 'three',
gridClassName: styles.threeImageGrid,
getAspectRatio: (index) => (index === 0 ? undefined : '1 / 1'),
},
4: {
type: 'four',
gridClassName: styles.fourImageGrid,
getAspectRatio: () => '3 / 2',
},
5: {
type: 'five',
gridClassName: styles.fiveImageGrid,
getAspectRatio: (index) => (index < 2 ? '3 / 2' : '1 / 1'),
},
6: {
type: 'six',
gridClassName: styles.sixImageGrid,
getAspectRatio: () => '1 / 1',
},
7: {
type: 'seven',
gridClassName: styles.sevenImageContainer,
getAspectRatio: (index) => (index === 0 ? '16 / 9' : '1 / 1'),
},
8: {
type: 'eight',
gridClassName: styles.eightImageContainer,
getAspectRatio: (index) => (index < 2 ? '3 / 2' : '1 / 1'),
},
9: {
type: 'nine',
gridClassName: styles.nineImageGrid,
getAspectRatio: () => '1 / 1',
},
10: {
type: 'ten',
gridClassName: styles.tenImageContainer,
getAspectRatio: (index) => (index === 0 ? '16 / 9' : '1 / 1'),
},
2: {type: 'two', gridClassName: styles.twoImageGrid},
3: {type: 'three', gridClassName: styles.threeImageGrid},
4: {type: 'four', gridClassName: styles.fourImageGrid},
5: {type: 'five', gridClassName: styles.fiveImageGrid},
6: {type: 'six', gridClassName: styles.sixImageGrid},
7: {type: 'seven', gridClassName: styles.sevenImageContainer},
8: {type: 'eight', gridClassName: styles.eightImageContainer},
9: {type: 'nine', gridClassName: styles.nineImageGrid},
10: {type: 'ten', gridClassName: styles.tenImageContainer},
};
function getLayoutConfig(count: number): LayoutConfig {
@@ -79,6 +44,8 @@ export const AttachmentLayoutGrid: FC<AttachmentLayoutGridProps> = observer(
({attachments, message, isPreview, snapshotIndex}) => {
const count = attachments.length;
const config = getLayoutConfig(count);
const mosaicWidth = getMosaicMediaDimensions().maxWidth;
const tileAspectRatio = (index: number) => getMosaicTileBox(count, index, mosaicWidth).aspectRatio;
if (count === 7) {
return (
<div
@@ -89,7 +56,7 @@ export const AttachmentLayoutGrid: FC<AttachmentLayoutGridProps> = observer(
<AttachmentGridItem
key={attachments[0].id}
attachment={attachments[0]}
targetAspectRatio={config.getAspectRatio(0)}
targetAspectRatio={tileAspectRatio(0)}
message={message}
mediaAttachments={attachments}
isPreview={isPreview}
@@ -102,7 +69,7 @@ export const AttachmentLayoutGrid: FC<AttachmentLayoutGridProps> = observer(
<AttachmentGridItem
key={attachment.id}
attachment={attachment}
targetAspectRatio={config.getAspectRatio(index + 1)}
targetAspectRatio={tileAspectRatio(index + 1)}
message={message}
mediaAttachments={attachments}
isPreview={isPreview}
@@ -128,7 +95,7 @@ export const AttachmentLayoutGrid: FC<AttachmentLayoutGridProps> = observer(
<AttachmentGridItem
key={attachment.id}
attachment={attachment}
targetAspectRatio={config.getAspectRatio(index)}
targetAspectRatio={tileAspectRatio(index)}
message={message}
mediaAttachments={attachments}
isPreview={isPreview}
@@ -145,7 +112,7 @@ export const AttachmentLayoutGrid: FC<AttachmentLayoutGridProps> = observer(
<AttachmentGridItem
key={attachment.id}
attachment={attachment}
targetAspectRatio={config.getAspectRatio(index + 2)}
targetAspectRatio={tileAspectRatio(index + 2)}
message={message}
mediaAttachments={attachments}
isPreview={isPreview}
@@ -167,7 +134,7 @@ export const AttachmentLayoutGrid: FC<AttachmentLayoutGridProps> = observer(
<AttachmentGridItem
key={attachments[0].id}
attachment={attachments[0]}
targetAspectRatio={config.getAspectRatio(0)}
targetAspectRatio={tileAspectRatio(0)}
message={message}
mediaAttachments={attachments}
isPreview={isPreview}
@@ -180,7 +147,7 @@ export const AttachmentLayoutGrid: FC<AttachmentLayoutGridProps> = observer(
<AttachmentGridItem
key={attachment.id}
attachment={attachment}
targetAspectRatio={config.getAspectRatio(index + 1)}
targetAspectRatio={tileAspectRatio(index + 1)}
message={message}
mediaAttachments={attachments}
isPreview={isPreview}
@@ -198,7 +165,7 @@ export const AttachmentLayoutGrid: FC<AttachmentLayoutGridProps> = observer(
<AttachmentGridItem
key={attachment.id}
attachment={attachment}
targetAspectRatio={config.getAspectRatio(index)}
targetAspectRatio={tileAspectRatio(index)}
message={message}
mediaAttachments={attachments}
isPreview={isPreview}
@@ -8,6 +8,7 @@ import EmbedVideo from '@app/features/channel/components/embeds/media/EmbedVideo
import {getInlineVideoLayoutConstraints} from '@app/features/channel/components/embeds/media/VideoDimensionUtils';
import type {Message} from '@app/features/messaging/models/MessagingMessage';
import {getAttachmentMediaDimensions} from '@app/features/messaging/utils/MediaDimensionConfig';
import {resolveProxyRequestSize} from '@app/features/messaging/utils/MediaProxyRequestSize';
import {
buildAnimatedImageProxyURL,
buildMediaProxyURL,
@@ -72,13 +73,10 @@ export const AttachmentSingleMedia: FC<AttachmentSingleMediaProps> = observer(
'--attachment-media-max-height': remFromPx(attachmentDimensions.maxHeight),
'--attachment-media-max-width': remFromPx(attachmentDimensions.maxWidth),
};
const {dimensions} = standaloneMediaCalculator.calculate(
{
width: naturalWidth,
height: naturalHeight,
},
{forceScale: true},
);
const {dimensions} = standaloneMediaCalculator.calculate({
width: naturalWidth,
height: naturalHeight,
});
const safeProxy = attachment.proxy_url ?? attachment.url ?? '';
const safeUrl = attachment.url ?? '';
const commonProps = {
@@ -171,12 +169,11 @@ export const AttachmentSingleMedia: FC<AttachmentSingleMediaProps> = observer(
</div>,
);
}
const targetWidth = Math.round(dimensions.width * 2);
const targetHeight = Math.round(dimensions.height * 2);
const requestedSize = resolveProxyRequestSize(dimensions.width, dimensions.height, naturalWidth, naturalHeight);
const optimizedSrc = buildMediaProxyURL(attachment.proxy_url ?? attachment.url ?? '', {
format: resolvePreferredImageFormat(attachment.content_type),
width: targetWidth,
height: targetHeight,
width: requestedSize?.width,
height: requestedSize?.height,
});
return wrapSpoiler(
<div