terry-media-adaptive-trial #10

Merged
terry merged 4 commits from terry-media-adaptive-trial into main 2026-05-29 16:21:39 +00:00
10 changed files with 427 additions and 58 deletions

View File

@@ -11,6 +11,12 @@ type BubbleImageProps = {
fallbackSrc?: string | (string | undefined)[];
className?: string;
loading?: "lazy" | "eager";
/**
* Called once the active source loads, with the image's intrinsic pixel
* size. Lets callers (e.g. single-image bubbles) adopt the real aspect
* ratio without depending on backend-provided width/height.
*/
onNaturalSize?: (width: number, height: number) => void;
};
/**
@@ -24,6 +30,7 @@ export function BubbleImage({
fallbackSrc,
className,
loading,
onNaturalSize,
}: BubbleImageProps) {
// Ordered, de-duplicated list of sources to attempt in turn.
const candidates = useMemo(() => {
@@ -62,6 +69,11 @@ export function BubbleImage({
alt=""
loading={loading}
className={className}
onLoad={(e) => {
const img = e.currentTarget;
if (img.naturalWidth && img.naturalHeight)
onNaturalSize?.(img.naturalWidth, img.naturalHeight);
}}
onError={() => setAttempt((i) => i + 1)}
/>
);

View File

@@ -0,0 +1,85 @@
import { useState, type CSSProperties, type ReactNode } from "react";
import type { Attachment } from "../../../types/post";
import {
SINGLE_IMAGE_FALLBACK_HEIGHT_CLASS,
SINGLE_IMAGE_MAX_HEIGHT,
} from "../../../constants/media";
import { BubbleImage } from "../BubbleImage";
/**
* Shared frame that sizes an image to its real aspect ratio. The image always
* fills the full width (never any left/right black bars). Behaviour by
* orientation:
* - Landscape/square: height capped at SINGLE_IMAGE_MAX_HEIGHT; images taller
* than the cap are cropped top/bottom (object-cover).
* - Portrait (height > width): no height cap — the frame grows to the real
* ratio so the whole image shows, uncropped and without side bars (the
* bubble is correspondingly tall).
* Used by single-image bubbles and 2-image albums.
*
* The ratio is taken from backend width/height when present (no layout shift),
* then refined from the loaded image's intrinsic size so it works even when the
* backend omits those fields. See `constants/media.ts`.
*/
export function AdaptiveImageFrame({
attachment,
src,
fallbackSrc,
onOpen,
ariaLabel,
children,
}: {
attachment: Attachment;
/** Display source; defaults to the attachment's full url. */
src?: string;
fallbackSrc?: (string | undefined)[];
onOpen: () => void;
ariaLabel: string;
/** Overlays rendered on top of the image (download pill, "+N", etc.). */
children?: ReactNode;
}) {
const [ratio, setRatio] = useState<number | undefined>(
attachment.width && attachment.height
? attachment.width / attachment.height
: undefined,
);
const isPortrait = ratio !== undefined && ratio < 1;
let frameStyle: CSSProperties | undefined;
if (ratio !== undefined) {
frameStyle = isPortrait
? // Portrait: follow the real ratio with no cap → full image, full width,
// no crop, no side bars (the frame is tall).
{ aspectRatio: String(ratio) }
: // Landscape/square: real ratio, capped height, cropped top/bottom past it.
{ aspectRatio: String(ratio), maxHeight: SINGLE_IMAGE_MAX_HEIGHT };
}
return (
<div
className={`relative w-full overflow-hidden bg-black ${
ratio ? "" : SINGLE_IMAGE_FALLBACK_HEIGHT_CLASS
}`}
style={frameStyle}
>
<button
type="button"
onClick={onOpen}
className="block h-full w-full"
aria-label={ariaLabel}
>
<BubbleImage
src={src ?? attachment.url}
fallbackSrc={fallbackSrc}
loading="lazy"
className="h-full w-full object-cover"
onNaturalSize={(w, h) => {
if (w && h) setRatio(w / h);
}}
/>
</button>
{children}
</div>
);
}

View File

@@ -1,25 +1,17 @@
import { useI18n } from "../../../i18n";
import type { Post } from "../../../types/post";
import { ALBUM_GAP, ALBUM_MAX_HEIGHT } from "../../../constants/media";
import { AttachmentDownloadPill } from "../AttachmentDownloadPill";
import { BubbleImage } from "../BubbleImage";
import { useImageRatios } from "../hooks/useImageRatios";
import { useLightbox } from "../overlays/ImageLightbox";
import { autolink } from "../utils/autolink";
import { computeAlbumLayout } from "../utils/albumLayout";
import { postDisplayText } from "../utils/postText";
import { CollapsibleText } from "../CollapsibleText";
const MAX_VISIBLE = 4;
function albumGridClass(count: number) {
const height = "h-[230px] min-[440px]:h-[250px] md:h-[300px] lg:h-[340px]";
if (count === 2) return `${height} grid grid-cols-1 grid-rows-2`;
return `${height} grid grid-cols-2 grid-rows-2`;
}
function albumItemClass(index: number, count: number) {
if (count === 3 && index === 0) return "row-span-2";
return "";
}
export function AlbumBubble({ post }: { post: Post }) {
const { openLightbox } = useLightbox();
const { lang } = useI18n();
@@ -27,20 +19,39 @@ export function AlbumBubble({ post }: { post: Post }) {
const text = postDisplayText(post, lang);
const visible = images.slice(0, MAX_VISIBLE);
const extra = images.length - MAX_VISIBLE;
const layoutCount = Math.min(images.length, MAX_VISIBLE);
const sources = visible.map(
(att) => att.thumbnailUrl ?? att.thumbUrl ?? att.url,
);
const ratios = useImageRatios(visible, sources);
const layout = computeAlbumLayout(ratios);
return (
<div className="flex flex-col">
<div className={`${albumGridClass(layoutCount)} gap-px overflow-hidden`}>
{/* aspect-ratio sets a definite box height; tiles are absolutely
positioned by percentage so the mosaic fills it exactly (no CSS-grid
`fr` quirks, no leftover black band). */}
<div
className="relative w-full overflow-hidden bg-black"
style={{
aspectRatio: layout?.aspectRatio,
maxHeight: ALBUM_MAX_HEIGHT,
}}
>
{visible.map((att, i) => {
const isLastSlot = i === MAX_VISIBLE - 1 && extra > 0;
const tile = layout?.tiles[i];
if (!tile) return null;
return (
<div
key={att.id}
className={`relative h-full w-full overflow-hidden ${albumItemClass(
i,
layoutCount,
)}`}
className="absolute overflow-hidden"
style={{
left: `${tile.left * 100}%`,
top: `${tile.top * 100}%`,
width: `calc(${tile.width * 100}% - ${ALBUM_GAP}px)`,
height: `calc(${tile.height * 100}% - ${ALBUM_GAP}px)`,
}}
>
<button
type="button"
@@ -51,7 +62,7 @@ export function AlbumBubble({ post }: { post: Post }) {
}
>
<BubbleImage
src={att.thumbnailUrl ?? att.thumbUrl ?? att.url}
src={sources[i]}
fallbackSrc={[att.thumbUrl, att.url]}
loading="lazy"
className="h-full w-full object-cover transition duration-300 group-hover:scale-[1.03]"

View File

@@ -1,27 +1,8 @@
import type { Post } from "../../../types/post";
import { AttachmentDownloadPill } from "../AttachmentDownloadPill";
import { BubbleImage } from "../BubbleImage";
import { useLightbox } from "../overlays/ImageLightbox";
import { SingleImageFrame } from "./SingleImageFrame";
export function ImageBubble({ post }: { post: Post }) {
const { openLightbox } = useLightbox();
const att = post.attachments[0];
if (!att) return null;
return (
<div className="relative h-[180px] w-full overflow-hidden bg-black min-[440px]:h-[210px] md:h-[260px] lg:h-[300px]">
<button
type="button"
onClick={() => openLightbox([att], 0, undefined, post.id)}
className="block h-full w-full"
aria-label="View image"
>
<BubbleImage
src={att.url}
loading="lazy"
className="h-full w-full object-cover"
/>
</button>
<AttachmentDownloadPill postId={post.id} attachment={att} />
</div>
);
return <SingleImageFrame postId={post.id} attachment={att} />;
}

View File

@@ -1,35 +1,18 @@
import { useI18n } from "../../../i18n";
import type { Post } from "../../../types/post";
import { useLightbox } from "../overlays/ImageLightbox";
import { AttachmentDownloadPill } from "../AttachmentDownloadPill";
import { BubbleImage } from "../BubbleImage";
import { autolink } from "../utils/autolink";
import { postDisplayText } from "../utils/postText";
import { CollapsibleText } from "../CollapsibleText";
import { SingleImageFrame } from "./SingleImageFrame";
export function ImageWithTextBubble({ post }: { post: Post }) {
const { openLightbox } = useLightbox();
const { lang } = useI18n();
const att = post.attachments[0];
const text = postDisplayText(post, lang);
if (!att) return null;
return (
<div className="flex flex-col">
<div className="relative h-[180px] w-full overflow-hidden bg-black min-[440px]:h-[210px] md:h-[260px] lg:h-[300px]">
<button
type="button"
onClick={() => openLightbox([att], 0, text, post.id)}
className="block h-full w-full"
aria-label="View image"
>
<BubbleImage
src={att.url}
loading="lazy"
className="h-full w-full object-cover"
/>
</button>
<AttachmentDownloadPill postId={post.id} attachment={att} />
</div>
<SingleImageFrame postId={post.id} attachment={att} text={text} />
{text ? (
<CollapsibleText
wrapperClassName="px-4 pt-3"

View File

@@ -0,0 +1,29 @@
import type { Attachment } from "../../../types/post";
import { AttachmentDownloadPill } from "../AttachmentDownloadPill";
import { useLightbox } from "../overlays/ImageLightbox";
import { AdaptiveImageFrame } from "./AdaptiveImageFrame";
/**
* A single image inside a message bubble. Shown at its real aspect ratio (no
* top/bottom cropping) via {@link AdaptiveImageFrame}.
*/
export function SingleImageFrame({
postId,
attachment,
text,
}: {
postId: string;
attachment: Attachment;
text?: string;
}) {
const { openLightbox } = useLightbox();
return (
<AdaptiveImageFrame
attachment={attachment}
onOpen={() => openLightbox([attachment], 0, text, postId)}
ariaLabel="View image"
>
<AttachmentDownloadPill postId={postId} attachment={attachment} />
</AdaptiveImageFrame>
);
}

View File

@@ -0,0 +1,47 @@
import { useEffect, useState } from "react";
import type { Attachment } from "../../../types/post";
/**
* Resolves each attachment's aspect ratio (width / height). Seeds from
* backend-provided width/height when present (no flash), then refines by
* loading the image and reading its intrinsic size — so it works even when the
* backend omits dimensions. Entries are `undefined` until known.
*/
export function useImageRatios(
attachments: Attachment[],
sources: (string | undefined)[],
): (number | undefined)[] {
const seed = attachments.map((a) =>
a.width && a.height ? a.width / a.height : undefined,
);
const [ratios, setRatios] = useState<(number | undefined)[]>(seed);
const key = sources.join("|");
useEffect(() => {
let cancelled = false;
setRatios(
attachments.map((a) =>
a.width && a.height ? a.width / a.height : undefined,
),
);
sources.forEach((src, i) => {
if (!src) return;
const img = new Image();
img.onload = () => {
if (cancelled || !img.naturalWidth || !img.naturalHeight) return;
setRatios((prev) => {
const next = prev.slice();
next[i] = img.naturalWidth / img.naturalHeight;
return next;
});
};
img.src = src;
});
return () => {
cancelled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [key]);
return ratios;
}

View File

@@ -0,0 +1,59 @@
import { describe, expect, it } from "vitest";
import { computeAlbumLayout, type AlbumTile } from "./albumLayout";
/** Right/bottom edge of a tile, as fractions of the box. */
const right = (t: AlbumTile) => t.left + t.width;
const bottom = (t: AlbumTile) => t.top + t.height;
describe("computeAlbumLayout", () => {
it("returns null for fewer than 2 images", () => {
expect(computeAlbumLayout([])).toBeNull();
expect(computeAlbumLayout([1.5])).toBeNull();
});
it("places two portraits side by side, splitting the full width", () => {
const layout = computeAlbumLayout([0.7, 0.8])!;
expect(layout.aspectRatio).toBeCloseTo(0.7 + 0.8, 4);
expect(layout.tiles).toHaveLength(2);
// Full height each, adjacent columns covering the whole width.
expect(layout.tiles[0]).toMatchObject({ left: 0, top: 0, height: 1 });
expect(layout.tiles[1].top).toBe(0);
expect(layout.tiles[1].height).toBe(1);
expect(right(layout.tiles[0])).toBeCloseTo(layout.tiles[1].left, 6);
expect(right(layout.tiles[1])).toBeCloseTo(1, 6);
});
it("stacks two landscapes vertically, splitting the full height", () => {
const layout = computeAlbumLayout([1.6, 1.4])!;
expect(layout.aspectRatio).toBeCloseTo(1 / (1 / 1.6 + 1 / 1.4), 4);
expect(layout.tiles[0]).toMatchObject({ left: 0, top: 0, width: 1 });
expect(layout.tiles[1].left).toBe(0);
expect(layout.tiles[1].width).toBe(1);
expect(bottom(layout.tiles[0])).toBeCloseTo(layout.tiles[1].top, 6);
expect(bottom(layout.tiles[1])).toBeCloseTo(1, 6);
});
it("portrait primary on the left, rest stacked on the right, fully covering", () => {
const layout = computeAlbumLayout([0.6, 1.2, 1.3])!;
// Primary occupies the full-height left column.
expect(layout.tiles[0]).toMatchObject({ left: 0, top: 0, height: 1 });
// Secondary tiles share the right column and fill it top-to-bottom.
expect(layout.tiles[1].left).toBeCloseTo(layout.tiles[0].width, 6);
expect(layout.tiles[1].top).toBe(0);
expect(bottom(layout.tiles[1])).toBeCloseTo(layout.tiles[2].top, 6);
expect(bottom(layout.tiles[2])).toBeCloseTo(1, 6);
expect(right(layout.tiles[2])).toBeCloseTo(1, 6);
});
it("landscape primary on top, rest in a bottom row, fully covering", () => {
const layout = computeAlbumLayout([1.8, 0.9, 1.1])!;
// Primary occupies the full-width top row.
expect(layout.tiles[0]).toMatchObject({ left: 0, top: 0, width: 1 });
// Secondary tiles share the bottom row and fill it left-to-right.
expect(layout.tiles[1].top).toBeCloseTo(layout.tiles[0].height, 6);
expect(layout.tiles[1].left).toBe(0);
expect(right(layout.tiles[1])).toBeCloseTo(layout.tiles[2].left, 6);
expect(right(layout.tiles[2])).toBeCloseTo(1, 6);
expect(bottom(layout.tiles[2])).toBeCloseTo(1, 6);
});
});

View File

@@ -0,0 +1,123 @@
/**
* Telegram-style adaptive album layout.
*
* Given the aspect ratios (width / height) of the images in an album, computes
* an absolutely-positioned mosaic: each tile gets normalized {left, top, width,
* height} as fractions (0..1) of the album box, plus the box's overall aspect
* ratio. Absolute positioning (rather than CSS grid `fr`) keeps the layout
* robust — it only depends on the container's resolved size.
*
* Rules (mirroring the product's request):
* - 2 images, both portrait -> side by side (left/right)
* - 2 images, both landscape -> stacked (top/bottom)
* - 2 images, mixed -> side by side, proportional
* - 3+ images -> "primary + line": the first image is the big
* one; a portrait primary sits on the LEFT with the rest stacked on the
* right, a landscape primary sits on TOP with the rest in a row below.
*/
export type AlbumTile = {
/** Fractions (0..1) of the album box. */
left: number;
top: number;
width: number;
height: number;
};
export type AlbumLayout = {
/** width / height of the whole album box. */
aspectRatio: number;
tiles: AlbumTile[];
};
/**
* Keep ratios within a sane range so one extreme image can't make the whole
* mosaic absurdly tall or flat. Beyond this the cell crops (object-cover).
*/
function clampRatio(ratio: number | undefined): number {
if (!ratio || !Number.isFinite(ratio) || ratio <= 0) return 1;
return Math.min(2, Math.max(0.55, ratio));
}
function layoutTwo(ratios: number[]): AlbumLayout {
const [r0, r1] = ratios;
const bothLandscape = r0 >= 1 && r1 >= 1;
if (bothLandscape) {
// Stacked: equal width, heights follow each ratio.
const h0 = 1 / r0;
const h1 = 1 / r1;
const total = h0 + h1;
return {
aspectRatio: 1 / total,
tiles: [
{ left: 0, top: 0, width: 1, height: h0 / total },
{ left: 0, top: h0 / total, width: 1, height: h1 / total },
],
};
}
// Portrait or mixed: side by side, equal height, widths follow each ratio.
const w0 = r0 / (r0 + r1);
return {
aspectRatio: r0 + r1,
tiles: [
{ left: 0, top: 0, width: w0, height: 1 },
{ left: w0, top: 0, width: 1 - w0, height: 1 },
],
};
}
function layoutPrimaryPlusLine(ratios: number[]): AlbumLayout {
const r0 = ratios[0];
const secondary = ratios.slice(1);
const primaryOnLeft = r0 < 1; // portrait primary -> left; landscape -> top
if (primaryOnLeft) {
// Total height = 1. Left column holds the primary (full height); right
// column is split into one row per secondary image (share width `wr`).
const invSum = secondary.reduce((acc, r) => acc + 1 / r, 0);
const wrPx = 1 / invSum; // right-column width in box-height units
const total = r0 + wrPx; // box width when box height = 1 (== aspectRatio)
const wl = r0 / total;
const wr = wrPx / total;
const tiles: AlbumTile[] = [{ left: 0, top: 0, width: wl, height: 1 }];
let y = 0;
secondary.forEach((r) => {
const h = wrPx / r; // tile height as a fraction of box height (= 1)
tiles.push({ left: wl, top: y, width: wr, height: h });
y += h;
});
return { aspectRatio: total, tiles };
}
// Total width = 1. Primary on top (full width); secondary share a bottom row
// (common height `hb`, widths follow ratio).
const sumR = secondary.reduce((acc, r) => acc + r, 0);
const ht = 1 / r0;
const hb = 1 / sumR;
const total = ht + hb; // box height when width = 1
const tiles: AlbumTile[] = [
{ left: 0, top: 0, width: 1, height: ht / total },
];
let x = 0;
secondary.forEach((r) => {
const w = r / sumR;
tiles.push({ left: x, top: ht / total, width: w, height: hb / total });
x += w;
});
return { aspectRatio: 1 / total, tiles };
}
/**
* Compute the mosaic for the given image ratios (already sliced to the visible
* count, max 4). Returns null for counts this layout doesn't handle (0 or 1).
*/
export function computeAlbumLayout(
rawRatios: (number | undefined)[],
): AlbumLayout | null {
const ratios = rawRatios.map(clampRatio);
if (ratios.length < 2) return null;
if (ratios.length === 2) return layoutTwo(ratios);
return layoutPrimaryPlusLine(ratios);
}

39
src/constants/media.ts Normal file
View File

@@ -0,0 +1,39 @@
/**
* Media sizing spec (single source of truth).
*
* Goal: show images at their real aspect ratio (no top/bottom cropping) while
* keeping them bounded so an oversized upload can't take over the screen.
*
* Width is intentionally NOT touched here: a single image still fills 100% of
* the bubble, whose width stays bounded by the existing message-container
* max-widths. Only the height adapts (and is clamped).
*
* How it's applied to a single-image bubble (behaviour depends on orientation):
* - Landscape / square (width >= height): the frame fills the full width and
* follows the real ratio, capped at SINGLE_IMAGE_MAX_HEIGHT. Taller-than-cap
* images are cropped top/bottom (object-cover) — never any left/right bars.
* - Portrait (height > width): no height cap. The frame follows the real ratio
* so the whole image shows — full width, uncropped, no side bars — which
* makes the bubble tall. (Product chose "complete image" over "compact".)
* - When width/height are unknown (no backend value and not yet loaded), we
* fall back to the legacy fixed heights below so layout stays predictable.
*/
/** Height cap for landscape/square images (cropped top/bottom beyond this). */
export const SINGLE_IMAGE_MAX_HEIGHT = 260;
/**
* Telegram-style multi-image albums. Cells are sized to each image's real
* ratio; the whole mosaic is capped so a tall portrait-led album stays
* reasonable (cells crop slightly via object-cover beyond the cap).
*/
export const ALBUM_MAX_HEIGHT = 420;
/** Gap between album tiles, in px. */
export const ALBUM_GAP = 3;
/**
* Legacy responsive heights, used only when an attachment has no width/height.
* Mirrors the previous fixed-height behaviour.
*/
export const SINGLE_IMAGE_FALLBACK_HEIGHT_CLASS =
"h-[180px] min-[440px]:h-[210px] md:h-[260px] lg:h-[300px]";