mirror of
https://github.com/fluxerapp/fluxer.git
synced 2026-09-03 05:10:25 +03:00
fix(app): request attachment images at their real mosaic tile size (#1888)
This commit is contained in:
@@ -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>
|
||||
|
||||
+41
-33
@@ -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}
|
||||
|
||||
+20
-53
@@ -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
-11
@@ -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
|
||||
|
||||
@@ -78,10 +78,6 @@
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.mediaImageHidden {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.mediaBlurred {
|
||||
filter: blur(0.75rem);
|
||||
opacity: 0.15;
|
||||
|
||||
@@ -1,10 +1,52 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {describe, expect, it} from 'vitest';
|
||||
import {createCalculator} from './DimensionUtils';
|
||||
import {createCalculator, fitMediaWithinBounds, mediaAspectRatioValue} from './DimensionUtils';
|
||||
|
||||
const ATTACHMENT_CAPS = {maxWidth: 550, maxHeight: 350};
|
||||
|
||||
describe('fitMediaWithinBounds', () => {
|
||||
it('fits the maintainer 797x118 case to 550x81', () => {
|
||||
expect(fitMediaWithinBounds({width: 797, height: 118, ...ATTACHMENT_CAPS})).toEqual({width: 550, height: 81});
|
||||
});
|
||||
|
||||
it('binds on height in the second pass for a tall image', () => {
|
||||
expect(fitMediaWithinBounds({width: 400, height: 1200, ...ATTACHMENT_CAPS})).toEqual({width: 117, height: 350});
|
||||
expect(fitMediaWithinBounds({width: 1000, height: 3000, ...ATTACHMENT_CAPS})).toEqual({width: 117, height: 350});
|
||||
});
|
||||
|
||||
it('never upscales media that is already under both caps', () => {
|
||||
expect(fitMediaWithinBounds({width: 200, height: 100, ...ATTACHMENT_CAPS})).toEqual({width: 200, height: 100});
|
||||
expect(fitMediaWithinBounds({width: 43, height: 48, maxWidth: 400, maxHeight: 300})).toEqual({
|
||||
width: 43,
|
||||
height: 48,
|
||||
});
|
||||
});
|
||||
|
||||
it('rounds twice, once per pass, rather than applying a single combined scale', () => {
|
||||
const combined = (width: number, height: number, maxWidth: number, maxHeight: number) => {
|
||||
const scale = Math.min(1, maxWidth / width, maxHeight / height);
|
||||
return {width: Math.round(width * scale), height: Math.round(height * scale)};
|
||||
};
|
||||
for (const [width, height, twoPassWidth, combinedWidth] of [
|
||||
[551, 351, 550, 549],
|
||||
[800, 600, 466, 467],
|
||||
[1200, 900, 466, 467],
|
||||
] as const) {
|
||||
expect(fitMediaWithinBounds({width, height, ...ATTACHMENT_CAPS})).toEqual({
|
||||
width: twoPassWidth,
|
||||
height: 350,
|
||||
});
|
||||
expect(combined(width, height, 550, 350)).toEqual({width: combinedWidth, height: 350});
|
||||
expect(twoPassWidth).not.toBe(combinedWidth);
|
||||
}
|
||||
expect(fitMediaWithinBounds({width: 797, height: 600, ...ATTACHMENT_CAPS})).toEqual({width: 465, height: 350});
|
||||
expect(fitMediaWithinBounds({width: 550, height: 350, ...ATTACHMENT_CAPS})).toEqual({width: 550, height: 350});
|
||||
});
|
||||
});
|
||||
|
||||
describe('MediaDimensionCalculator', () => {
|
||||
it('does not upscale small portrait media', () => {
|
||||
it('caps the wrapper at the fitted width so small media is not stretched', () => {
|
||||
const calculator = createCalculator({maxWidth: 400, maxHeight: 300});
|
||||
|
||||
const {dimensions, style} = calculator.calculate({width: 43, height: 48});
|
||||
@@ -13,20 +55,38 @@ describe('MediaDimensionCalculator', () => {
|
||||
expect(style).toMatchObject({
|
||||
maxWidth: 'min(100%, 2.6875rem)',
|
||||
width: '100%',
|
||||
aspectRatio: '43/48',
|
||||
display: 'block',
|
||||
aspectRatio: 43 / 48,
|
||||
});
|
||||
});
|
||||
|
||||
it('still scales large portrait media down to the max height', () => {
|
||||
const calculator = createCalculator({maxWidth: 400, maxHeight: 300});
|
||||
it('carries the aspect ratio as a unitless number for the maintainer case', () => {
|
||||
const calculator = createCalculator(ATTACHMENT_CAPS);
|
||||
|
||||
const {dimensions, style} = calculator.calculate({width: 860, height: 960});
|
||||
const {dimensions, style} = calculator.calculate({width: 797, height: 118});
|
||||
|
||||
expect(dimensions).toEqual({width: 269, height: 300});
|
||||
expect(style).toMatchObject({
|
||||
maxWidth: 'min(100%, 16.8125rem)',
|
||||
width: '100%',
|
||||
aspectRatio: '269/300',
|
||||
});
|
||||
expect(dimensions).toEqual({width: 550, height: 81});
|
||||
expect(style.aspectRatio).toBe(550 / 81);
|
||||
expect(style.aspectRatio).toBeCloseTo(6.79012, 5);
|
||||
expect(typeof style.aspectRatio).toBe('number');
|
||||
});
|
||||
|
||||
it('scales tall media down to the height cap without an off-by-one', () => {
|
||||
const calculator = createCalculator(ATTACHMENT_CAPS);
|
||||
|
||||
const {dimensions, style} = calculator.calculate({width: 400, height: 1200});
|
||||
|
||||
expect(dimensions).toEqual({width: 117, height: 350});
|
||||
expect(style.maxHeight).toBe('21.875rem');
|
||||
expect(mediaAspectRatioValue(dimensions)).toBeCloseTo(0.334286, 6);
|
||||
});
|
||||
|
||||
it('preserves an already fitted pair and still emits the numeric aspect ratio', () => {
|
||||
const calculator = createCalculator(ATTACHMENT_CAPS);
|
||||
|
||||
const {dimensions, style} = calculator.calculate({width: 550, height: 81}, {preserve: true});
|
||||
|
||||
expect(dimensions).toEqual({width: 550, height: 81});
|
||||
expect(style).toMatchObject({width: '34.375rem', maxWidth: '100%', aspectRatio: 550 / 81});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,7 +12,6 @@ interface DimensionOptions {
|
||||
maxWidth?: number;
|
||||
maxHeight?: number;
|
||||
preserve?: boolean;
|
||||
forceScale?: boolean;
|
||||
aspectRatio?: boolean;
|
||||
responsive?: boolean;
|
||||
}
|
||||
@@ -30,7 +29,6 @@ const DEFAULT_OPTIONS: Required<DimensionOptions> = {
|
||||
maxWidth: MEDIA_MAX_WIDTH,
|
||||
maxHeight: MEDIA_MAX_HEIGHT,
|
||||
preserve: false,
|
||||
forceScale: false,
|
||||
aspectRatio: true,
|
||||
responsive: true,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user