diff --git a/src/app/components/url-preview/UrlPreview.css.tsx b/src/app/components/url-preview/UrlPreview.css.tsx index 6ac55b660..7fa589d2c 100644 --- a/src/app/components/url-preview/UrlPreview.css.tsx +++ b/src/app/components/url-preview/UrlPreview.css.tsx @@ -1,5 +1,6 @@ import { keyframes, style } from '@vanilla-extract/css'; import { DefaultReset, color, config, toRem } from 'folds'; +import { GIF_PREVIEW_MAX_HEIGHT } from '../../utils/gifPreviewSize'; export const UrlPreview = style([ DefaultReset, @@ -955,12 +956,14 @@ export const PortraitSideLayout = style([ // GIF card (Giphy / Tenor) // --------------------------------------------------------------------------- +// Full card width; a GIF narrower than the card (portrait, square, small) is +// centred on the surface colour instead of being stretched and cropped. export const GifThumbnailWrapper = style([ DefaultReset, { - position: 'relative', + display: 'flex', + justifyContent: 'center', width: '100%', - maxHeight: toRem(200), overflow: 'hidden', flexShrink: 0, backgroundColor: color.Surface.Container, @@ -972,14 +975,26 @@ export const GifThumbnailWrapper = style([ }, ]); +// Wraps just the GIF so the "GIF" badge sits on the GIF's own corner. +export const GifFrame = style([ + DefaultReset, + { + position: 'relative', + display: 'block', + maxWidth: '100%', + }, +]); + +// The inline width/aspect-ratio (from og:image:width/height) sets the size; +// max-width lets a phone's narrower card shrink it, keeping the ratio. export const GifThumbnailImg = style([ DefaultReset, { - width: '100%', - maxHeight: toRem(200), - objectFit: 'cover', - objectPosition: 'center', display: 'block', + maxWidth: '100%', + maxHeight: toRem(GIF_PREVIEW_MAX_HEIGHT), + height: 'auto', + objectFit: 'contain', }, ]); diff --git a/src/app/components/url-preview/UrlPreviewCard.tsx b/src/app/components/url-preview/UrlPreviewCard.tsx index e7bd29ffc..24a6881b6 100644 --- a/src/app/components/url-preview/UrlPreviewCard.tsx +++ b/src/app/components/url-preview/UrlPreviewCard.tsx @@ -13,6 +13,7 @@ import { as, color, config, + toRem, } from 'folds'; import { ImageOverlay } from '../ImageOverlay'; import { MobileTouchTarget } from '../../styles/mobile.css'; @@ -48,6 +49,11 @@ import { tiktokOembedUrl, tiktokPlayerEmbedUrl, } from '../../utils/videoEmbed'; +import { + GIF_PREVIEW_MAX_HEIGHT, + GIF_PREVIEW_MAX_WIDTH, + gifPreviewBox, +} from '../../utils/gifPreviewSize'; const linkStyles = { color: color.Success.Main }; @@ -2277,6 +2283,9 @@ function GifCard({ }) { const title = (prev['og:title'] as string | undefined) ?? ''; const mxcImage = prev['og:image'] as string | undefined; + // Size the GIF from its own dimensions (Giphy and Tenor publish them) rather + // than forcing every GIF into one cropped band. + const box = gifPreviewBox(prev['og:image:width'], prev['og:image:height']); // A GIF card exists to show a moving GIF, so request the original rather than // a thumbnail — the thumbnail endpoint would return a frozen first frame. @@ -2284,7 +2293,15 @@ function GifCard({ const thumbSrc = mxcImage ? shouldServeGifOriginal(url, prev) ? mxcUrlToHttp(mx, mxcImage, useAuthentication) - : mxcUrlToHttp(mx, mxcImage, useAuthentication, 400, 200, 'scale', false) + : mxcUrlToHttp( + mx, + mxcImage, + useAuthentication, + GIF_PREVIEW_MAX_WIDTH * 2, + GIF_PREVIEW_MAX_HEIGHT * 2, + 'scale', + false, + ) : null; // If there's no image, fall back to a generic-style layout @@ -2316,7 +2333,9 @@ function GifCard({ return ( - {/* GIF thumbnail — full width */} + {/* The whole GIF at its own aspect ratio, centred; never cropped. With a + known size the box is reserved before it loads (no layout jump); + without one it shows at its natural size, capped by the card. */} - {title} - GIF + + {title} + GIF + {/* Footer row */} diff --git a/src/app/utils/gifPreviewSize.test.ts b/src/app/utils/gifPreviewSize.test.ts new file mode 100644 index 000000000..3fa60a494 --- /dev/null +++ b/src/app/utils/gifPreviewSize.test.ts @@ -0,0 +1,36 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { gifPreviewBox } from './gifPreviewSize'; + +test('gifPreviewBox: a landscape GIF wider than the card fills its width', () => { + assert.deepEqual(gifPreviewBox(480, 270), { width: 400, height: 225 }); + assert.deepEqual(gifPreviewBox(480, 200), { width: 400, height: 167 }); +}); + +test('gifPreviewBox: square and portrait GIFs keep their shape instead of being cropped', () => { + assert.deepEqual(gifPreviewBox(300, 300), { width: 320, height: 320 }); + assert.deepEqual(gifPreviewBox(480, 480), { width: 320, height: 320 }); + assert.deepEqual(gifPreviewBox(240, 480), { width: 160, height: 320 }); +}); + +test('gifPreviewBox: a small GIF is enlarged at most 2×', () => { + assert.deepEqual(gifPreviewBox(100, 80), { width: 200, height: 160 }); + assert.deepEqual(gifPreviewBox(150, 150), { width: 300, height: 300 }); +}); + +test('gifPreviewBox: extreme aspect ratios stay inside the box and never collapse to 0', () => { + assert.deepEqual(gifPreviewBox(2000, 50), { width: 400, height: 10 }); + assert.deepEqual(gifPreviewBox(1, 10000), { width: 1, height: 320 }); +}); + +test('gifPreviewBox: accepts numeric strings and rejects unknown sizes', () => { + assert.deepEqual(gifPreviewBox('480', '270'), { width: 400, height: 225 }); + assert.equal(gifPreviewBox(undefined, 270), undefined); + assert.equal(gifPreviewBox(480, 0), undefined); + assert.equal(gifPreviewBox('wide', 270), undefined); + assert.equal(gifPreviewBox(Number.NaN, 270), undefined); +}); + +test('gifPreviewBox: custom limits', () => { + assert.deepEqual(gifPreviewBox(480, 270, 200, 200, 1), { width: 200, height: 113 }); +}); diff --git a/src/app/utils/gifPreviewSize.ts b/src/app/utils/gifPreviewSize.ts new file mode 100644 index 000000000..34ce189ee --- /dev/null +++ b/src/app/utils/gifPreviewSize.ts @@ -0,0 +1,39 @@ +/** + * Display size for a Giphy / Tenor link-preview GIF. The card used to force + * every GIF into a full-width × 200px band with `object-fit: cover`, so square + * and portrait GIFs lost most of their height and small ones were blown up. + * Fit the GIF inside `maxWidth × maxHeight` keeping its aspect ratio, and + * enlarge a small GIF at most `maxUpscale`× so it stays sharp. + */ + +/** The GIF card's width (UrlPreview is 25rem); a phone shrinks it further via CSS. */ +export const GIF_PREVIEW_MAX_WIDTH = 400; +export const GIF_PREVIEW_MAX_HEIGHT = 320; +export const GIF_PREVIEW_MAX_UPSCALE = 2; + +export type GifPreviewBox = { width: number; height: number }; + +// og:image:width / og:image:height are numbers from Synapse, but some sites +// publish them as strings; anything else (missing, 0, NaN) means "unknown". +const dimension = (value: unknown): number | undefined => { + const n = typeof value === 'string' ? Number(value) : value; + return typeof n === 'number' && Number.isFinite(n) && n > 0 ? n : undefined; +}; + +/** The box to render the GIF in, or `undefined` when its size isn't known. */ +export const gifPreviewBox = ( + rawWidth: unknown, + rawHeight: unknown, + maxWidth = GIF_PREVIEW_MAX_WIDTH, + maxHeight = GIF_PREVIEW_MAX_HEIGHT, + maxUpscale = GIF_PREVIEW_MAX_UPSCALE, +): GifPreviewBox | undefined => { + const width = dimension(rawWidth); + const height = dimension(rawHeight); + if (!width || !height) return undefined; + const scale = Math.min(maxWidth / width, maxHeight / height, maxUpscale); + return { + width: Math.max(1, Math.round(width * scale)), + height: Math.max(1, Math.round(height * scale)), + }; +};