mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
parent
32e41e78e0
commit
870b02a29b
@@ -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` };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user