diff --git a/desktop/src/features/messages/lib/rowHeightEstimate.test.mjs b/desktop/src/features/messages/lib/rowHeightEstimate.test.mjs index dc33da4f8..4f434655d 100644 --- a/desktop/src/features/messages/lib/rowHeightEstimate.test.mjs +++ b/desktop/src/features/messages/lib/rowHeightEstimate.test.mjs @@ -36,7 +36,7 @@ test("estimateRowHeight: fenced code adds height by line", () => { msg({ body: "see:\n```\na\nb\nc\nd\ne\n```" }), ); const withoutCode = estimateRowHeight(msg({ body: "see:" })); - assert.ok(withCode > withoutCode + 80, `code ${withCode} vs ${withoutCode}`); + assert.ok(withCode > withoutCode + 100, `code ${withCode} vs ${withoutCode}`); }); test("estimateRowHeight: imeta image with dim reserves bounded media height", () => { @@ -84,21 +84,95 @@ test("estimateRowHeight: imeta dim is not double-counted with its body url", () assert.ok(both < 400, `expected single media reserve, got ${both}`); }); -test("estimateRowHeight: bare URL line adds a preview card", () => { +test("estimateRowHeight: unsupported bare URL line does not add a preview card", () => { const withUrl = estimateRowHeight(msg({ body: "https://example.com/x" })); const withoutUrl = estimateRowHeight(msg({ body: "example" })); + assert.ok(withUrl < withoutUrl + 25, `url ${withUrl} vs ${withoutUrl}`); +}); + +test("estimateRowHeight: supported GitHub URL line adds a preview card", () => { + const withUrl = estimateRowHeight( + msg({ body: "https://github.com/block/buzz/pull/1641" }), + ); + const withoutUrl = estimateRowHeight(msg({ body: "example" })); assert.ok(withUrl > withoutUrl + 50, `url ${withUrl} vs ${withoutUrl}`); }); +test("estimateRowHeight: supported Linear and Google URLs add preview cards", () => { + const base = estimateRowHeight(msg({ body: "example" })); + const linear = estimateRowHeight( + msg({ body: "https://linear.app/block/issue/BUZZ-123/fix-scroll" }), + ); + const google = estimateRowHeight( + msg({ body: "https://docs.google.com/document/d/abc123/edit" }), + ); + assert.ok(linear > base + 50, `linear ${linear} vs ${base}`); + assert.ok(google > base + 50, `google ${google} vs ${base}`); +}); + +test("estimateRowHeight: column width option changes prose wrapping", () => { + const body = "x".repeat(160); + const narrow = estimateRowHeight(msg({ body }), { columnWidthPx: 320 }); + const fallback = estimateRowHeight(msg({ body })); + assert.ok(narrow > fallback + 20, `narrow ${narrow} vs fallback ${fallback}`); +}); + +test("estimateRowHeight: markdown structures reserve extra chrome", () => { + const table = estimateRowHeight( + msg({ body: "| A | B |\n| - | - |\n| 1 | 2 |" }), + ); + const plain = estimateRowHeight(msg({ body: "A B\n- -\n1 2" })); + assert.ok(table > plain + 20, `table ${table} vs plain ${plain}`); +}); + test("timelineRowReserveStyle: message item yields containIntrinsicSize", () => { const style = timelineRowReserveStyle({ kind: "message", key: "k", entry: { message: msg({ body: "hi" }), summary: null }, + isContinuation: false, + isFollowedByContinuation: false, }); assert.match(String(style.containIntrinsicSize), /^auto \d+px$/); }); +test("timelineRowReserveStyle: summary rows add summary chrome", () => { + const item = { + kind: "message", + key: "k", + entry: { + message: msg({ body: "hi" }), + summary: { + threadHeadId: "m1", + replyCount: 3, + lastReplyAt: 1, + participants: [], + }, + }, + isContinuation: false, + isFollowedByContinuation: false, + }; + const withSummary = Number.parseInt( + String(timelineRowReserveStyle(item).containIntrinsicSize).match( + /auto (\d+)px/, + )?.[1] ?? "0", + 10, + ); + const withoutSummary = Number.parseInt( + String( + timelineRowReserveStyle({ + ...item, + entry: { ...item.entry, summary: null }, + }).containIntrinsicSize, + ).match(/auto (\d+)px/)?.[1] ?? "0", + 10, + ); + assert.ok( + withSummary > withoutSummary + 25, + `${withSummary} vs ${withoutSummary}`, + ); +}); + test("timelineRowReserveStyle: divider is short fixed height", () => { const style = timelineRowReserveStyle({ kind: "day-divider", diff --git a/desktop/src/features/messages/lib/rowHeightEstimate.ts b/desktop/src/features/messages/lib/rowHeightEstimate.ts index c979a8ad1..351538602 100644 --- a/desktop/src/features/messages/lib/rowHeightEstimate.ts +++ b/desktop/src/features/messages/lib/rowHeightEstimate.ts @@ -1,5 +1,6 @@ import type * as React from "react"; +import { extractSupportedLinkPreviews } from "@/shared/lib/linkPreview"; import { dimensionsFromDim } from "@/shared/ui/markdown/utils"; import type { TimelineItem } from "./timelineItems"; import type { TimelineMessage } from "../types"; @@ -24,16 +25,34 @@ const MEDIA_MAX_WIDTH = 384; // max-w-[min(24rem,100%)] const MEDIA_MAX_HEIGHT = 256; // max-h-64 const TEXT_LINE_HEIGHT = 20; const CODE_LINE_HEIGHT = 19; -const CHARS_PER_LINE = 64; // rough wrap width at the timeline column +const FALLBACK_CHARS_PER_LINE = 64; // rough wrap width at the timeline column +const AVERAGE_TEXT_CHAR_WIDTH = 7.2; // text-sm, biased toward common prose +const ROW_HORIZONTAL_CHROME = 64; // avatar + row gap + inline padding +const MIN_CHARS_PER_LINE = 32; +const MAX_CHARS_PER_LINE = 96; const ROW_CHROME = 26; // author/time header + denser row padding const CONTINUATION_ROW_CHROME = 8; // dense row padding only; header/avatar are hidden const MEDIA_BLOCK_MARGIN_TOP = 4; // image/video blocks use mt-1 in markdown const REACTION_ROW = 24; const PREVIEW_CARD = 70; +const CODE_BLOCK_CHROME = 18; // pre border/padding above the mono line boxes +const THREAD_SUMMARY_ROW = 38; +const FOOTER_ROW = 32; const MESSAGE_ITEM_BOTTOM_PADDING = 10; // TimelineMessageList pb-2.5 const MIN_ESTIMATE = 60; // never reserve less than the old flat floor const CONTINUATION_MIN_ESTIMATE = 34; +export type TimelineRowReserveOptions = { + /** Timeline column width measured once by the caller; absent keeps the old 64-char estimate. */ + columnWidthPx?: number; + /** Optional row footer chrome. The main channel timeline currently leaves this unset. */ + hasFooter?: boolean; +}; + +type EstimateRowHeightOptions = TimelineRowReserveOptions & { + isContinuation?: boolean; +}; + function mediaHeightFromDim(dim: string | undefined): number { const dimensions = dimensionsFromDim(dim); if (!dimensions) return MEDIA_MAX_HEIGHT; // unknown shape: reserve full box @@ -49,10 +68,27 @@ function mediaReserveHeight(dim: string | undefined): number { return MEDIA_BLOCK_MARGIN_TOP + mediaHeightFromDim(dim); } -function wrappedLineCount(text: string): number { +function charsPerLineFromColumnWidth( + columnWidthPx: number | undefined, +): number { + if (columnWidthPx == null || !Number.isFinite(columnWidthPx)) { + return FALLBACK_CHARS_PER_LINE; + } + + const textWidth = Math.max(0, columnWidthPx - ROW_HORIZONTAL_CHROME); + return Math.max( + MIN_CHARS_PER_LINE, + Math.min( + MAX_CHARS_PER_LINE, + Math.floor(textWidth / AVERAGE_TEXT_CHAR_WIDTH), + ), + ); +} + +function wrappedLineCount(text: string, charsPerLine: number): number { let lines = 0; for (const raw of text.split("\n")) { - lines += Math.max(1, Math.ceil(raw.length / CHARS_PER_LINE)); + lines += Math.max(1, Math.ceil(raw.length / charsPerLine)); } return lines; } @@ -61,19 +97,25 @@ function wrappedLineCount(text: string): number { * Strip fenced code blocks from the body, returning the prose remainder and the * total number of code lines (for separate mono line-height accounting). */ -function splitFencedCode(body: string): { prose: string; codeLines: number } { +function splitFencedCode(body: string): { + prose: string; + codeBlockCount: number; + codeLines: number; +} { const parts = body.split(/```/); // Even indices are prose, odd indices are inside a fence. let prose = ""; + let codeBlockCount = 0; let codeLines = 0; for (let i = 0; i < parts.length; i += 1) { if (i % 2 === 1) { + codeBlockCount += 1; codeLines += parts[i].split("\n").length; } else { prose += parts[i]; } } - return { prose, codeLines }; + return { prose, codeBlockCount, codeLines }; } // Image/video file extensions the markdown renderer turns into inline media. @@ -108,19 +150,73 @@ function stripMediaOnlyLines(text: string): string { .join("\n"); } +function markdownStructureExtraHeight(text: string): number { + let extra = 0; + let tableRunLines = 0; + let listRunLines = 0; + + const flushTableRun = () => { + if (tableRunLines >= 2) { + // GFM tables have cell padding/borders, so they are taller than the same + // raw markdown counted as plain 20px text lines. + extra += 14 + tableRunLines * 6; + } + tableRunLines = 0; + }; + const flushListRun = () => { + if (listRunLines >= 2) { + // The renderer applies `space-y-1` between list items. + extra += (listRunLines - 1) * 4; + } + listRunLines = 0; + }; + + for (const line of text.split("\n")) { + const trimmed = line.trim(); + const isTableLine = + /^\|.+\|$/.test(trimmed) || /\S\s+\|\s+\S/.test(trimmed); + const isListLine = /^(?:[-+*]|\d+[.)])\s+\S/.test(trimmed); + + if (isTableLine) tableRunLines += 1; + else flushTableRun(); + + if (isListLine) listRunLines += 1; + else flushListRun(); + + if (/^#{1,3}\s+\S/.test(trimmed)) extra += 8; + else if (/^#{4,6}\s+\S/.test(trimmed)) extra += 4; + + if (/^>\s?\S/.test(trimmed)) extra += 4; + if (/^(?:---+|\*\*\*+|___+)\s*$/.test(trimmed)) extra += 12; + } + + flushTableRun(); + flushListRun(); + return extra; +} + export function estimateRowHeight( message: TimelineMessage, - { isContinuation = false }: { isContinuation?: boolean } = {}, + { + columnWidthPx, + hasFooter = false, + isContinuation = false, + }: EstimateRowHeightOptions = {}, ): number { const body = message.body ?? ""; - const { prose, codeLines } = splitFencedCode(body); + const { prose, codeBlockCount, codeLines } = splitFencedCode(body); const proseForLineCount = stripMediaOnlyLines(prose); + const charsPerLine = charsPerLineFromColumnWidth(columnWidthPx); let height = isContinuation ? CONTINUATION_ROW_CHROME : ROW_CHROME; height += - wrappedLineCount(proseForLineCount.trim() === "" ? "" : proseForLineCount) * - TEXT_LINE_HEIGHT; + wrappedLineCount( + proseForLineCount.trim() === "" ? "" : proseForLineCount, + charsPerLine, + ) * TEXT_LINE_HEIGHT; height += codeLines * CODE_LINE_HEIGHT; + height += codeBlockCount * CODE_BLOCK_CHROME; + height += markdownStructureExtraHeight(proseForLineCount); const imetaUrls = new Set(); if (message.tags && message.tags.length > 0) { @@ -137,16 +233,13 @@ export function estimateRowHeight( height += mediaReserveHeight(undefined); } - // A bare non-media URL on its own line usually renders a link-preview card. - const hasPreviewUrlLine = body - .split("\n") - .some( - (line) => - /^\s*https?:\/\/\S+\s*$/.test(line) && !MEDIA_URL_RE.test(line.trim()), - ); - if (hasPreviewUrlLine) height += PREVIEW_CARD; + // Reserve only cards the renderer can actually produce. Generic bare URLs do + // not render preview cards, so keeping the old blanket reserve overestimated + // unsupported links by about one card height during first realization. + height += extractSupportedLinkPreviews(body).length * PREVIEW_CARD; if (message.reactions && message.reactions.length > 0) height += REACTION_ROW; + if (hasFooter) height += FOOTER_ROW; return Math.max( isContinuation ? CONTINUATION_MIN_ESTIMATE : MIN_ESTIMATE, @@ -166,14 +259,18 @@ const DIVIDER_HEIGHT = 32; */ export function timelineRowReserveStyle( item: TimelineItem, + opts: TimelineRowReserveOptions = {}, ): React.CSSProperties { const height = item.kind === "message" ? estimateRowHeight(item.entry.message, { + ...opts, isContinuation: item.isContinuation, - }) + (item.isFollowedByContinuation ? 0 : MESSAGE_ITEM_BOTTOM_PADDING) + }) + + (item.entry.summary ? THREAD_SUMMARY_ROW : 0) + + (item.isFollowedByContinuation ? 0 : MESSAGE_ITEM_BOTTOM_PADDING) : item.kind === "system" - ? estimateRowHeight(item.entry.message) + ? estimateRowHeight(item.entry.message, opts) : DIVIDER_HEIGHT; return { containIntrinsicSize: `auto ${height}px` }; }