T3 shrink timeline row height estimates

Co-authored-by: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta <d8473ee32b973aa31a21a65adddcc4b69cc2a8a4dee8121ecd51926e0cddbc02@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta <d8473ee32b973aa31a21a65adddcc4b69cc2a8a4dee8121ecd51926e0cddbc02@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta
2026-07-08 14:08:56 -04:00
parent 32e41e78e0
commit 870b02a29b
2 changed files with 192 additions and 21 deletions
@@ -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",
@@ -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<string>();
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` };
}