diff --git a/src/components/messageStream/BubbleImage.tsx b/src/components/messageStream/BubbleImage.tsx index 799be0a..342bc28 100644 --- a/src/components/messageStream/BubbleImage.tsx +++ b/src/components/messageStream/BubbleImage.tsx @@ -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)} /> ); diff --git a/src/components/messageStream/bubbles/AdaptiveImageFrame.tsx b/src/components/messageStream/bubbles/AdaptiveImageFrame.tsx new file mode 100644 index 0000000..0dbc0c0 --- /dev/null +++ b/src/components/messageStream/bubbles/AdaptiveImageFrame.tsx @@ -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( + 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 ( +
+ + {children} +
+ ); +} diff --git a/src/components/messageStream/bubbles/AlbumBubble.tsx b/src/components/messageStream/bubbles/AlbumBubble.tsx index b7bd034..fe1c68d 100644 --- a/src/components/messageStream/bubbles/AlbumBubble.tsx +++ b/src/components/messageStream/bubbles/AlbumBubble.tsx @@ -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 (
-
+ {/* 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). */} +
{visible.map((att, i) => { const isLastSlot = i === MAX_VISIBLE - 1 && extra > 0; + const tile = layout?.tiles[i]; + if (!tile) return null; return (
- -
- ); + return ; } diff --git a/src/components/messageStream/bubbles/ImageWithTextBubble.tsx b/src/components/messageStream/bubbles/ImageWithTextBubble.tsx index a46c7b2..809362c 100644 --- a/src/components/messageStream/bubbles/ImageWithTextBubble.tsx +++ b/src/components/messageStream/bubbles/ImageWithTextBubble.tsx @@ -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 (
-
- - -
+ {text ? ( openLightbox([attachment], 0, text, postId)} + ariaLabel="View image" + > + + + ); +} diff --git a/src/components/messageStream/hooks/useImageRatios.ts b/src/components/messageStream/hooks/useImageRatios.ts new file mode 100644 index 0000000..e05e87f --- /dev/null +++ b/src/components/messageStream/hooks/useImageRatios.ts @@ -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; +} diff --git a/src/components/messageStream/utils/albumLayout.test.ts b/src/components/messageStream/utils/albumLayout.test.ts new file mode 100644 index 0000000..4b102cc --- /dev/null +++ b/src/components/messageStream/utils/albumLayout.test.ts @@ -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); + }); +}); diff --git a/src/components/messageStream/utils/albumLayout.ts b/src/components/messageStream/utils/albumLayout.ts new file mode 100644 index 0000000..2ea0488 --- /dev/null +++ b/src/components/messageStream/utils/albumLayout.ts @@ -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); +} diff --git a/src/constants/media.ts b/src/constants/media.ts new file mode 100644 index 0000000..8a90f65 --- /dev/null +++ b/src/constants/media.ts @@ -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]";