diff --git a/fluxer_app/src/features/channel/components/MessageAttachmentUtils.ts b/fluxer_app/src/features/channel/components/MessageAttachmentUtils.ts index 2f0869e75..f699a5385 100644 --- a/fluxer_app/src/features/channel/components/MessageAttachmentUtils.ts +++ b/fluxer_app/src/features/channel/components/MessageAttachmentUtils.ts @@ -38,3 +38,53 @@ export function splitMediaAndFileAttachments(attachments: ReadonlyArray 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}`, + }; +} diff --git a/fluxer_app/src/features/channel/components/embeds/attachments/Attachment.tsx b/fluxer_app/src/features/channel/components/embeds/attachments/Attachment.tsx index 0a61b6d6e..f03075157 100644 --- a/fluxer_app/src/features/channel/components/embeds/attachments/Attachment.tsx +++ b/fluxer_app/src/features/channel/components/embeds/attachments/Attachment.tsx @@ -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 = 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 = 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( = observer( attachment={enrichedAttachment} isPreview={isPreview} message={message} + spoilerHidden={spoilerHidden} data-flx="channel.embeds.attachments.attachment.attachment-file--2" /> , @@ -455,6 +465,7 @@ export const Attachment: FC = observer( attachment={enrichedAttachment} isPreview={isPreview} message={message} + spoilerHidden={spoilerHidden} data-flx="channel.embeds.attachments.attachment.attachment-file--3" /> , @@ -548,6 +559,7 @@ export const Attachment: FC = observer( attachment={att} isPreview={isPreview} message={message} + spoilerHidden={spoilerHidden} data-flx="channel.embeds.attachments.attachment.attachment-file--4" /> diff --git a/fluxer_app/src/features/channel/components/embeds/attachments/AttachmentGridItem.tsx b/fluxer_app/src/features/channel/components/embeds/attachments/AttachmentGridItem.tsx index 50e55377b..6bea5469b 100644 --- a/fluxer_app/src/features/channel/components/embeds/attachments/AttachmentGridItem.tsx +++ b/fluxer_app/src/features/channel/components/embeds/attachments/AttachmentGridItem.tsx @@ -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 = 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 = 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 = observer( height: targetHeight, animated: isAnimatedGif && shouldAnimateGif, }); - const {ref: visibilityRef, isNearViewport} = useNearViewport({ - disabled: !isMobile, - rememberKey: thumbnailSrc, - }); - const shouldLoadMedia = isNearViewport && !shouldBlur; + const {ref: visibilityRef, isNearViewport} = useNearViewport({rememberKey: thumbnailSrc}); + const shouldLoadMedia = isNearViewport && !shouldBlur && !spoilerHidden; const { loaded, error, - cachedOnMount, thumbHashURL, ref: mediaRef, onLoad: handleImageLoad, @@ -179,6 +182,11 @@ export const AttachmentGridItem: FC = 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 = 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 = observer( GIF )} + {attachment.filename} {shouldRenderPlaceholder && thumbHashURL && ( = 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" /> )} - string | undefined; } const LAYOUT_CONFIGS: Record = { - 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 = 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 (
= observer( = observer( = observer( = observer( = observer( = observer( = observer( = 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 = observer(
, ); } - 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(
{ + 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}); }); }); diff --git a/fluxer_app/src/features/ui/utils/DimensionUtils.ts b/fluxer_app/src/features/ui/utils/DimensionUtils.ts index 50039d487..a9dc3fe48 100644 --- a/fluxer_app/src/features/ui/utils/DimensionUtils.ts +++ b/fluxer_app/src/features/ui/utils/DimensionUtils.ts @@ -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 = { maxWidth: MEDIA_MAX_WIDTH, maxHeight: MEDIA_MAX_HEIGHT, preserve: false, - forceScale: false, aspectRatio: true, responsive: true, };