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 2347969..13f4619 100644 --- a/src/components/messageStream/bubbles/AlbumBubble.tsx +++ b/src/components/messageStream/bubbles/AlbumBubble.tsx @@ -1,24 +1,16 @@ 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"; 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(); @@ -26,20 +18,40 @@ 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 (
-
+
{visible.map((att, i) => { const isLastSlot = i === MAX_VISIBLE - 1 && extra > 0; + const tile = layout?.tiles[i]; return (
- -
- ); + return ; } diff --git a/src/components/messageStream/bubbles/ImageWithTextBubble.tsx b/src/components/messageStream/bubbles/ImageWithTextBubble.tsx index a003fd7..1332eff 100644 --- a/src/components/messageStream/bubbles/ImageWithTextBubble.tsx +++ b/src/components/messageStream/bubbles/ImageWithTextBubble.tsx @@ -1,34 +1,17 @@ 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 { 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 ? (
{autolink(text)} diff --git a/src/components/messageStream/bubbles/SingleImageFrame.tsx b/src/components/messageStream/bubbles/SingleImageFrame.tsx new file mode 100644 index 0000000..be6a779 --- /dev/null +++ b/src/components/messageStream/bubbles/SingleImageFrame.tsx @@ -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 ( + 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..87ea9d6 --- /dev/null +++ b/src/components/messageStream/utils/albumLayout.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from "vitest"; +import { computeAlbumLayout } from "./albumLayout"; + +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 (1 row, 2 cols)", () => { + const layout = computeAlbumLayout([0.7, 0.8])!; + expect(layout.gridTemplateRows).toBe("1fr"); + expect(layout.tiles).toHaveLength(2); + // Both tiles occupy the single row, adjacent columns. + expect(layout.tiles[0]).toMatchObject({ colStart: 1, colEnd: 2 }); + expect(layout.tiles[1]).toMatchObject({ colStart: 2, colEnd: 3 }); + // Album width = sum of ratios when height = 1. + expect(layout.aspectRatio).toBeCloseTo(0.7 + 0.8, 4); + }); + + it("stacks two landscapes vertically (2 rows, 1 col)", () => { + const layout = computeAlbumLayout([1.6, 1.4])!; + expect(layout.gridTemplateColumns).toBe("1fr"); + expect(layout.tiles[0]).toMatchObject({ rowStart: 1, rowEnd: 2 }); + expect(layout.tiles[1]).toMatchObject({ rowStart: 2, rowEnd: 3 }); + expect(layout.aspectRatio).toBeCloseTo(1 / (1 / 1.6 + 1 / 1.4), 4); + }); + + it("puts a portrait primary on the left with the rest stacked right", () => { + const layout = computeAlbumLayout([0.6, 1.2, 1.3])!; + // Primary spans both right-hand rows in column 1. + expect(layout.tiles[0]).toEqual({ + colStart: 1, + colEnd: 2, + rowStart: 1, + rowEnd: 3, + }); + expect(layout.tiles[1]).toMatchObject({ colStart: 2, rowStart: 1 }); + expect(layout.tiles[2]).toMatchObject({ colStart: 2, rowStart: 2 }); + }); + + it("puts a landscape primary on top with the rest in a row below", () => { + const layout = computeAlbumLayout([1.8, 0.9, 1.1])!; + // Primary spans both bottom columns in row 1. + expect(layout.tiles[0]).toEqual({ + colStart: 1, + colEnd: 3, + rowStart: 1, + rowEnd: 2, + }); + expect(layout.tiles[1]).toMatchObject({ rowStart: 2, colStart: 1 }); + expect(layout.tiles[2]).toMatchObject({ rowStart: 2, colStart: 2 }); + }); +}); diff --git a/src/components/messageStream/utils/albumLayout.ts b/src/components/messageStream/utils/albumLayout.ts new file mode 100644 index 0000000..0ab4e5b --- /dev/null +++ b/src/components/messageStream/utils/albumLayout.ts @@ -0,0 +1,131 @@ +/** + * Telegram-style adaptive album layout. + * + * Given the aspect ratios (width / height) of the images in an album, computes + * a CSS-grid mosaic where each cell is sized close to its image's real ratio, + * so the collage looks tidy with minimal cropping. + * + * Rules (mirroring the product's request): + * - 2 images, both portrait -> side by side (1 row, 2 cols) + * - 2 images, both landscape -> stacked (2 rows, 1 col) + * - 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 = { + colStart: number; + colEnd: number; + rowStart: number; + rowEnd: number; +}; + +export type AlbumLayout = { + gridTemplateColumns: string; + gridTemplateRows: string; + /** 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 fr(values: number[]): string { + return values.map((v) => `${Number(v.toFixed(4))}fr`).join(" "); +} + +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; + return { + gridTemplateColumns: "1fr", + gridTemplateRows: fr([h0, h1]), + aspectRatio: 1 / (h0 + h1), + tiles: [ + { colStart: 1, colEnd: 2, rowStart: 1, rowEnd: 2 }, + { colStart: 1, colEnd: 2, rowStart: 2, rowEnd: 3 }, + ], + }; + } + + // Portrait or mixed: side by side, equal height, widths follow each ratio. + return { + gridTemplateColumns: fr([r0, r1]), + gridTemplateRows: "1fr", + aspectRatio: r0 + r1, + tiles: [ + { colStart: 1, colEnd: 2, rowStart: 1, rowEnd: 2 }, + { colStart: 2, colEnd: 3, rowStart: 1, rowEnd: 2 }, + ], + }; +} + +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) { + // Left column holds the primary (spans all rows). Right column is split + // into one row per secondary image; share width `wr`, heights follow ratio. + const invSum = secondary.reduce((acc, r) => acc + 1 / r, 0); + const wr = 1 / invSum; // right column width when total height = 1 + const wl = r0; // primary width when height = 1 + const tiles: AlbumTile[] = [ + { colStart: 1, colEnd: 2, rowStart: 1, rowEnd: secondary.length + 1 }, + ]; + secondary.forEach((_, i) => { + tiles.push({ colStart: 2, colEnd: 3, rowStart: i + 1, rowEnd: i + 2 }); + }); + return { + gridTemplateColumns: fr([wl, wr]), + gridTemplateRows: fr(secondary.map((r) => 1 / r)), + aspectRatio: wl + wr, + tiles, + }; + } + + // Primary on top (spans all columns). Bottom row holds the secondary images + // side by side; share height `hb`, widths follow ratio. + const sumR = secondary.reduce((acc, r) => acc + r, 0); + const ht = 1 / r0; // primary height when total width = 1 + const hb = 1 / sumR; // bottom row height when total width = 1 + const tiles: AlbumTile[] = [ + { colStart: 1, colEnd: secondary.length + 1, rowStart: 1, rowEnd: 2 }, + ]; + secondary.forEach((_, i) => { + tiles.push({ colStart: i + 1, colEnd: i + 2, rowStart: 2, rowEnd: 3 }); + }); + return { + gridTemplateColumns: fr(secondary.map((r) => r)), + gridTemplateRows: fr([ht, hb]), + aspectRatio: 1 / (ht + hb), + 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]";