mirror of
https://github.com/block/buzz.git
synced 2026-10-27 12:40:30 +00:00
fix(desktop): single-owner anchored scroll for dynamic loading (#1115)
Signed-off-by: Tyler Longwell <tlongwell@squareup.com> Signed-off-by: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta <d8473ee32b973aa31a21a65adddcc4b69cc2a8a4dee8121ecd51926e0cddbc02@sprout-oss.stage.blox.sqprod.co> Co-authored-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@sprout-oss.stage.blox.sqprod.co> Co-authored-by: npub17jjz49l9jjmhhk7cac63j8yt9z555n9cw8vk7v5jz4vzw4ppld5qgj57cc <f4a42a97e594b77bdbd8ee35191c8b28a94a4cb871d96f32921558275421fb68@sprout-oss.stage.blox.sqprod.co> Co-authored-by: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta <d8473ee32b973aa31a21a65adddcc4b69cc2a8a4dee8121ecd51926e0cddbc02@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
@@ -50,6 +50,7 @@ export default defineConfig({
|
||||
"**/animated-avatar-screenshots.spec.ts",
|
||||
"**/reminders-screenshots.spec.ts",
|
||||
"**/virtualization-screenshots.spec.ts",
|
||||
"**/scroll-history.spec.ts",
|
||||
],
|
||||
use: {
|
||||
...devices["Desktop Chrome"],
|
||||
|
||||
23
desktop/playwright.perf.config.ts
Normal file
23
desktop/playwright.perf.config.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
|
||||
export default defineConfig({
|
||||
testDir: "./tests/e2e",
|
||||
timeout: 60_000,
|
||||
retries: 0,
|
||||
workers: 1,
|
||||
reporter: [["list"]],
|
||||
use: { baseURL: "http://127.0.0.1:4173" },
|
||||
projects: [
|
||||
{
|
||||
name: "perf",
|
||||
testMatch: ["**/*.perf.ts"],
|
||||
use: { ...devices["Desktop Chrome"] },
|
||||
},
|
||||
],
|
||||
webServer: {
|
||||
command: "python3 -m http.server 4173 -d dist",
|
||||
cwd: ".",
|
||||
reuseExistingServer: true,
|
||||
url: "http://127.0.0.1:4173",
|
||||
},
|
||||
});
|
||||
@@ -39,18 +39,46 @@ export function ChannelRouteScreen({
|
||||
return cachedTarget ? [cachedTarget] : [];
|
||||
});
|
||||
|
||||
// Reset spliced target events when the channel context changes (channel
|
||||
// switch or entering/leaving a forum post). Tied to channel identity rather
|
||||
// than the route target so clearing the `messageId` param mid-channel keeps
|
||||
// the deep-linked row in view. Seeded with the mount key so the initial
|
||||
// cache-seeded events survive first commit; only a genuine channel change
|
||||
// clears them. Declared before the fetch effect so a channel switch clears
|
||||
// stale events before the new target is fetched.
|
||||
const previousResetKeyRef = React.useRef<string>(
|
||||
`${channelId}::${selectedPostId ?? ""}`,
|
||||
);
|
||||
React.useEffect(() => {
|
||||
const resetKey = `${channelId}::${selectedPostId ?? ""}`;
|
||||
if (previousResetKeyRef.current === resetKey) return;
|
||||
previousResetKeyRef.current = resetKey;
|
||||
setTargetMessageEvents([]);
|
||||
}, [channelId, selectedPostId]);
|
||||
|
||||
React.useEffect(() => {
|
||||
let isCancelled = false;
|
||||
|
||||
// Don't wipe already-spliced target events just because the route target
|
||||
// cleared (e.g. `onTargetReached` clears the `messageId` URL param once the
|
||||
// row is centered). In a channel whose feed doesn't already contain the
|
||||
// deep-linked message, the spliced event is the only copy — dropping it on
|
||||
// param-clear blanks the timeline. Resetting on channel / forum-post change
|
||||
// is handled by the effect below; here we only fetch when there's a target.
|
||||
if ((!targetMessageId && !targetThreadRootId) || selectedPostId) {
|
||||
setTargetMessageEvents([]);
|
||||
return () => {
|
||||
isCancelled = true;
|
||||
};
|
||||
}
|
||||
|
||||
const cachedTarget = getCachedSearchHitEvent(targetMessageId);
|
||||
setTargetMessageEvents(cachedTarget ? [cachedTarget] : []);
|
||||
if (cachedTarget) {
|
||||
setTargetMessageEvents((currentEvents) =>
|
||||
currentEvents.some((event) => event.id === cachedTarget.id)
|
||||
? currentEvents
|
||||
: [...currentEvents, cachedTarget],
|
||||
);
|
||||
}
|
||||
|
||||
const eventIds = [
|
||||
targetMessageId,
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
formatDayHeading,
|
||||
formatShortMonthDayOrdinal,
|
||||
formatThreadSummaryLastReplyTime,
|
||||
startOfLocalDaySeconds,
|
||||
} from "./dateFormatters.ts";
|
||||
|
||||
function localUnixSeconds(year, monthIndex, day) {
|
||||
@@ -126,3 +127,22 @@ test("formatDayHeading includes the year for other years", () => {
|
||||
`${weekday(date)}, May 19th, ${year}`,
|
||||
);
|
||||
});
|
||||
|
||||
test("startOfLocalDaySeconds collapses a day's timestamps to one value", () => {
|
||||
const morning = new Date(2026, 5, 14, 8, 30, 15).getTime() / 1_000;
|
||||
const evening = new Date(2026, 5, 14, 23, 59, 59).getTime() / 1_000;
|
||||
const midnight = new Date(2026, 5, 14, 0, 0, 0).getTime() / 1_000;
|
||||
|
||||
assert.equal(startOfLocalDaySeconds(morning), midnight);
|
||||
assert.equal(startOfLocalDaySeconds(evening), midnight);
|
||||
});
|
||||
|
||||
test("startOfLocalDaySeconds separates adjacent calendar days", () => {
|
||||
const lateOn14 = new Date(2026, 5, 14, 23, 0, 0).getTime() / 1_000;
|
||||
const earlyOn15 = new Date(2026, 5, 15, 1, 0, 0).getTime() / 1_000;
|
||||
|
||||
assert.notEqual(
|
||||
startOfLocalDaySeconds(lateOn14),
|
||||
startOfLocalDaySeconds(earlyOn15),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -78,6 +78,18 @@ export function isSameDay(a: number, b: number): boolean {
|
||||
return isSameDayDate(new Date(a * 1_000), new Date(b * 1_000));
|
||||
}
|
||||
|
||||
/**
|
||||
* Unix-seconds timestamp of local midnight for the calendar day containing
|
||||
* `unixSeconds`. Two timestamps on the same calendar day map to the same value,
|
||||
* so it is a stable identifier for a day group that does not shift when an
|
||||
* older message is prepended into that day.
|
||||
*/
|
||||
export function startOfLocalDaySeconds(unixSeconds: number): number {
|
||||
const date = new Date(unixSeconds * 1_000);
|
||||
date.setHours(0, 0, 0, 0);
|
||||
return Math.floor(date.getTime() / 1_000);
|
||||
}
|
||||
|
||||
/** Short month + ordinal day, e.g. "May 19th". */
|
||||
export function formatShortMonthDayOrdinal(unixSeconds: number): string {
|
||||
return formatMonthDayOrdinal(
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { formatTimelineMessages } from "./formatTimelineMessages.ts";
|
||||
import {
|
||||
countTopLevelTimelineRows,
|
||||
formatTimelineMessages,
|
||||
} from "./formatTimelineMessages.ts";
|
||||
|
||||
const HEX64_A =
|
||||
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
|
||||
@@ -100,3 +103,94 @@ test("deletion target with non-hex `e` tag value is ignored", () => {
|
||||
"malformed deletion tag should not match anything",
|
||||
);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// countTopLevelTimelineRows — the unit fetch-older pages by. Must match the
|
||||
// rows `buildMainTimelineEntries` would actually render: top-level content
|
||||
// events, minus deletions, with thread replies collapsed into their parent.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function hex64(char) {
|
||||
return char.repeat(64);
|
||||
}
|
||||
|
||||
function message(id, overrides = {}) {
|
||||
return {
|
||||
id,
|
||||
pubkey: PUBKEY_A,
|
||||
kind: 9,
|
||||
created_at: 1_700_000_000,
|
||||
content: "hi",
|
||||
tags: [["h", CHANNEL_ID]],
|
||||
sig: "sig",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function reply(id, parentId, overrides = {}) {
|
||||
return message(id, {
|
||||
tags: [
|
||||
["h", CHANNEL_ID],
|
||||
["e", parentId, "", "reply"],
|
||||
],
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
test("countTopLevelTimelineRows counts top-level messages", () => {
|
||||
const events = [
|
||||
message(hex64("1")),
|
||||
message(hex64("2")),
|
||||
message(hex64("3")),
|
||||
];
|
||||
assert.equal(countTopLevelTimelineRows(events), 3);
|
||||
});
|
||||
|
||||
test("countTopLevelTimelineRows ignores collapsed thread replies", () => {
|
||||
const root = hex64("1");
|
||||
const events = [
|
||||
message(root),
|
||||
reply(hex64("2"), root),
|
||||
reply(hex64("3"), root),
|
||||
];
|
||||
// Two replies collapse into the root's summary → one visible row.
|
||||
assert.equal(countTopLevelTimelineRows(events), 1);
|
||||
});
|
||||
|
||||
test("countTopLevelTimelineRows counts broadcast replies as top-level", () => {
|
||||
const root = hex64("1");
|
||||
const broadcast = reply(hex64("2"), root, {
|
||||
tags: [
|
||||
["h", CHANNEL_ID],
|
||||
["e", root, "", "reply"],
|
||||
["broadcast", "1"],
|
||||
],
|
||||
});
|
||||
assert.equal(countTopLevelTimelineRows([message(root), broadcast]), 2);
|
||||
});
|
||||
|
||||
test("countTopLevelTimelineRows excludes deleted messages", () => {
|
||||
const target = hex64("1");
|
||||
const events = [
|
||||
message(target),
|
||||
message(hex64("2")),
|
||||
deletionEvent(9005, target, { id: hex64("9") }),
|
||||
];
|
||||
assert.equal(countTopLevelTimelineRows(events), 1);
|
||||
});
|
||||
|
||||
test("countTopLevelTimelineRows ignores non-content kinds (reactions)", () => {
|
||||
const reaction = {
|
||||
id: hex64("9"),
|
||||
pubkey: PUBKEY_B,
|
||||
kind: 7,
|
||||
created_at: 1_700_000_001,
|
||||
content: "+",
|
||||
tags: [
|
||||
["h", CHANNEL_ID],
|
||||
["e", hex64("1")],
|
||||
],
|
||||
sig: "sig",
|
||||
};
|
||||
assert.equal(countTopLevelTimelineRows([message(hex64("1")), reaction]), 1);
|
||||
});
|
||||
|
||||
@@ -9,7 +9,10 @@ import type {
|
||||
TimelineMessage,
|
||||
TimelineReaction,
|
||||
} from "@/features/messages/types";
|
||||
import { getThreadReference } from "@/features/messages/lib/threading";
|
||||
import {
|
||||
getThreadReference,
|
||||
isBroadcastReply,
|
||||
} from "@/features/messages/lib/threading";
|
||||
import {
|
||||
resolveUserLabel,
|
||||
type UserProfileLookup,
|
||||
@@ -66,6 +69,47 @@ function getDeletionTargets(tags: string[][]) {
|
||||
.map((tag) => tag[1]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Count the *visible top-level rows* a raw event window would render in the
|
||||
* main channel timeline — the same unit `buildMainTimelineEntries` produces.
|
||||
*
|
||||
* This is deliberately NOT `events.length`: thread replies collapse into their
|
||||
* parent's summary row, deleted events disappear, and non-content kinds
|
||||
* (reactions, edits, deletions) never render as their own row. A history batch
|
||||
* heavy with replies can add 100 events but only a handful of rows, which is
|
||||
* why fetch-older counts rows here, not messages, when deciding how far to page.
|
||||
*
|
||||
* Mirrors the two filters that bound the rendered list:
|
||||
* 1. `formatTimelineMessages` keeps content kinds that aren't deletion targets.
|
||||
* 2. `buildMainTimelineEntries` keeps entries that are top-level
|
||||
* (`parentId == null`) or broadcast replies.
|
||||
*/
|
||||
export function countTopLevelTimelineRows(events: RelayEvent[]): number {
|
||||
const deletedEventIds = new Set<string>();
|
||||
for (const event of events) {
|
||||
if (
|
||||
event.kind === KIND_DELETION ||
|
||||
event.kind === KIND_NIP29_DELETE_EVENT
|
||||
) {
|
||||
for (const targetId of getDeletionTargets(event.tags)) {
|
||||
deletedEventIds.add(targetId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let count = 0;
|
||||
for (const event of events) {
|
||||
if (!isTimelineContentEvent(event) || deletedEventIds.has(event.id)) {
|
||||
continue;
|
||||
}
|
||||
const { parentId } = getThreadReference(event.tags);
|
||||
if (parentId == null || isBroadcastReply(event.tags)) {
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
function getReactionTargetId(tags: string[][]) {
|
||||
for (let index = tags.length - 1; index >= 0; index -= 1) {
|
||||
const tag = tags[index];
|
||||
|
||||
@@ -16,7 +16,7 @@ export type MessageLinkInput = {
|
||||
*
|
||||
* Currently emitted into the URL but not consumed by the click handler
|
||||
* or deep-link listener — both route via `goChannel(channelId,
|
||||
* { messageId })` and let `useTimelineScrollManager` resolve the target.
|
||||
* { messageId })` and let `useAnchoredScroll` resolve the target.
|
||||
* Reserved for future "open in thread view" routing.
|
||||
*/
|
||||
threadRootId?: string | null;
|
||||
|
||||
@@ -212,6 +212,31 @@ test("buildDayGroupBoundaries: group counts always sum to message count", () =>
|
||||
assert.equal(total, messages.length);
|
||||
});
|
||||
|
||||
test("buildDayGroupBoundaries: same-day group key is stable across a prepend", () => {
|
||||
// The day section is keyed by this value; if it changes when an older
|
||||
// message lands on the same calendar day, React remounts the whole section
|
||||
// on every scroll-up prepend — the timeline flicker. The key must depend on
|
||||
// the calendar day, not the first message's exact timestamp.
|
||||
const before = buildDayGroupBoundaries([
|
||||
message({ id: "b", createdAt: dayAt(2026, 6, 14, 9) }),
|
||||
message({ id: "c", createdAt: dayAt(2026, 6, 14, 10) }),
|
||||
]);
|
||||
const afterPrepend = buildDayGroupBoundaries([
|
||||
message({ id: "a", createdAt: dayAt(2026, 6, 14, 8) }),
|
||||
message({ id: "b", createdAt: dayAt(2026, 6, 14, 9) }),
|
||||
message({ id: "c", createdAt: dayAt(2026, 6, 14, 10) }),
|
||||
]);
|
||||
assert.equal(before[0].key, afterPrepend[0].key);
|
||||
});
|
||||
|
||||
test("buildDayGroupBoundaries: distinct calendar days get distinct keys", () => {
|
||||
const groups = buildDayGroupBoundaries([
|
||||
message({ id: "a", createdAt: dayAt(2026, 6, 13, 12) }),
|
||||
message({ id: "b", createdAt: dayAt(2026, 6, 14, 12) }),
|
||||
]);
|
||||
assert.notEqual(groups[0].key, groups[1].key);
|
||||
});
|
||||
|
||||
// --- jump-to-message deep links ----------------------------------------------
|
||||
|
||||
test("resolveDeepLinkTarget: unresolved with no target", () => {
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
*/
|
||||
|
||||
import type { TimelineMessage } from "@/features/messages/types";
|
||||
import { isSameDay } from "./dateFormatters";
|
||||
import { isSameDay, startOfLocalDaySeconds } from "./dateFormatters";
|
||||
|
||||
/** Distance (px) from the bottom within which the timeline counts as "at bottom". */
|
||||
export const BOTTOM_THRESHOLD_PX = 72;
|
||||
@@ -88,7 +88,11 @@ export function selectLatestMessageAutoScrollBehavior({
|
||||
|
||||
/** A single day boundary in the timeline: where it starts and how many messages it covers. */
|
||||
export type DayGroupBoundary = {
|
||||
/** Stable key for the day section. */
|
||||
/**
|
||||
* Stable key for the day section: the local start-of-day of the messages it
|
||||
* covers, so prepending an older message into an already-rendered day reuses
|
||||
* the same key instead of remounting the whole `<section>`.
|
||||
*/
|
||||
key: string;
|
||||
/** Index into `messages` of the first message in this day. */
|
||||
startIndex: number;
|
||||
@@ -114,7 +118,7 @@ export function buildDayGroupBoundaries(
|
||||
|
||||
if (!prev || !isSameDay(prev.createdAt, message.createdAt)) {
|
||||
boundaries.push({
|
||||
key: `day-${message.createdAt}`,
|
||||
key: `day-${startOfLocalDaySeconds(message.createdAt)}`,
|
||||
startIndex: i,
|
||||
count: 1,
|
||||
headingTimestamp: message.createdAt,
|
||||
|
||||
@@ -3,6 +3,7 @@ import test from "node:test";
|
||||
|
||||
import {
|
||||
buildVideoReviewCommentsByRootId,
|
||||
buildVideoReviewCommentsForRoot,
|
||||
buildVideoReviewContextForMessage,
|
||||
hasVideoAttachment,
|
||||
} from "./videoReviewContext.ts";
|
||||
@@ -99,6 +100,64 @@ test("buildVideoReviewCommentsByRootId includes nested descendants", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("buildVideoReviewCommentsForRoot returns descendants for one root", () => {
|
||||
const video = message({
|
||||
id: "video",
|
||||
body: "",
|
||||
createdAt: 1,
|
||||
});
|
||||
const otherVideo = message({
|
||||
id: "other-video",
|
||||
body: "",
|
||||
createdAt: 2,
|
||||
});
|
||||
const firstComment = message({
|
||||
id: "first-comment",
|
||||
body: "[00:01] tighten this",
|
||||
createdAt: 4,
|
||||
parentId: "video",
|
||||
rootId: "video",
|
||||
});
|
||||
const nestedReply = message({
|
||||
id: "nested-reply",
|
||||
body: "agreed",
|
||||
createdAt: 5,
|
||||
parentId: "first-comment",
|
||||
rootId: "video",
|
||||
});
|
||||
const earlierComment = message({
|
||||
id: "earlier-comment",
|
||||
body: "[00:00] opener",
|
||||
createdAt: 3,
|
||||
parentId: "video",
|
||||
rootId: "video",
|
||||
});
|
||||
const otherComment = message({
|
||||
id: "other-comment",
|
||||
body: "different root",
|
||||
createdAt: 6,
|
||||
parentId: "other-video",
|
||||
rootId: "other-video",
|
||||
});
|
||||
|
||||
const comments = buildVideoReviewCommentsForRoot(
|
||||
[
|
||||
video,
|
||||
otherVideo,
|
||||
firstComment,
|
||||
nestedReply,
|
||||
earlierComment,
|
||||
otherComment,
|
||||
],
|
||||
"video",
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
comments.map((comment) => comment.id),
|
||||
["earlier-comment", "first-comment", "nested-reply"],
|
||||
);
|
||||
});
|
||||
|
||||
test("buildVideoReviewContextForMessage posts against the source video", async () => {
|
||||
const video = message({
|
||||
id: "video",
|
||||
|
||||
@@ -61,6 +61,38 @@ export function buildVideoReviewCommentsByRootId(
|
||||
return commentsByRootId;
|
||||
}
|
||||
|
||||
export function buildVideoReviewCommentsForRoot(
|
||||
messages: TimelineMessage[],
|
||||
rootId: string,
|
||||
): TimelineMessage[] {
|
||||
const messageById = new Map(messages.map((message) => [message.id, message]));
|
||||
const comments: TimelineMessage[] = [];
|
||||
|
||||
for (const message of messages) {
|
||||
let ancestorId = message.parentId ?? null;
|
||||
let hops = 0;
|
||||
const maxHops = messages.length + 1;
|
||||
|
||||
while (ancestorId && hops < maxHops) {
|
||||
if (ancestorId === rootId) {
|
||||
comments.push(message);
|
||||
break;
|
||||
}
|
||||
ancestorId = messageById.get(ancestorId)?.parentId ?? null;
|
||||
hops += 1;
|
||||
}
|
||||
}
|
||||
|
||||
comments.sort((left, right) => {
|
||||
if (left.createdAt !== right.createdAt) {
|
||||
return left.createdAt - right.createdAt;
|
||||
}
|
||||
return left.id.localeCompare(right.id);
|
||||
});
|
||||
|
||||
return comments;
|
||||
}
|
||||
|
||||
export function buildVideoReviewContextForMessage({
|
||||
channelId,
|
||||
channelName,
|
||||
|
||||
@@ -2,7 +2,7 @@ export function DayDivider({ label }: { label: string }) {
|
||||
return (
|
||||
<section
|
||||
aria-label={label}
|
||||
className="sticky top-[92px] z-[5] flex justify-center py-1"
|
||||
className="sticky top-[92px] z-[5] flex justify-center py-1 before:absolute before:inset-x-0 before:top-1/2 before:h-px before:-translate-y-1/2 before:bg-border/35 before:content-['']"
|
||||
data-testid="message-timeline-day-divider"
|
||||
data-day-label={label}
|
||||
>
|
||||
|
||||
@@ -31,7 +31,7 @@ import { MessageThreadSummaryRow } from "./MessageThreadSummaryRow";
|
||||
import { TypingIndicatorRow } from "./TypingIndicatorRow";
|
||||
import { UnreadDivider } from "./UnreadDivider";
|
||||
import { useComposerHeightPadding } from "./useComposerHeightPadding";
|
||||
import { useTimelineScrollManager } from "./useTimelineScrollManager";
|
||||
import { useAnchoredScroll } from "./useAnchoredScroll";
|
||||
import { selectDeferredListRenderState } from "@/features/messages/lib/timelineSnapshot";
|
||||
|
||||
type MessageThreadPanelProps = {
|
||||
@@ -306,6 +306,11 @@ export function MessageThreadPanel({
|
||||
widthPx,
|
||||
}: MessageThreadPanelProps) {
|
||||
const threadBodyRef = React.useRef<HTMLDivElement>(null);
|
||||
const threadContentRef = React.useRef<HTMLDivElement>(null);
|
||||
// Threads don't paginate older history, so this sentinel is never observed
|
||||
// (the hook's older-history effect bails without a `fetchOlder`). It exists
|
||||
// only to satisfy the hook's required ref contract.
|
||||
const threadTopSentinelRef = React.useRef<HTMLDivElement>(null);
|
||||
const threadComposerWrapperRef = React.useRef<HTMLDivElement>(null);
|
||||
const isOverlay = useIsThreadPanelOverlay();
|
||||
const isFloatingOverlay = isOverlay && !isSinglePanelView;
|
||||
@@ -338,8 +343,7 @@ export function MessageThreadPanel({
|
||||
// (`scrollIntoView`), so it must stay consistent with what's actually painted.
|
||||
// You can't scroll to a reply that hasn't committed yet. The thread pane gets
|
||||
// this no-tearing guarantee for free by routing through the same
|
||||
// `useTimelineScrollManager` (and its `timelineSnapshot` helpers) as the main
|
||||
// timeline.
|
||||
// `useAnchoredScroll` primitive as the main timeline.
|
||||
const deferredThreadReplies = React.useDeferredValue(
|
||||
threadReplies,
|
||||
EMPTY_THREAD_REPLIES,
|
||||
@@ -359,22 +363,18 @@ export function MessageThreadPanel({
|
||||
[deferredThreadReplies],
|
||||
);
|
||||
|
||||
const {
|
||||
bottomAnchorRef,
|
||||
contentRef,
|
||||
isAtBottom,
|
||||
newMessageCount,
|
||||
scrollToBottom,
|
||||
syncScrollState,
|
||||
} = useTimelineScrollManager({
|
||||
channelId: threadHeadId,
|
||||
// Wait for deferred replies to commit before scroll-init (else rows mount un-scrolled).
|
||||
isLoading: repliesRenderState === "pending",
|
||||
messages: threadMessages,
|
||||
onTargetReached: onScrollTargetResolved,
|
||||
scrollContainerRef: threadBodyRef,
|
||||
targetMessageId: scrollTargetId,
|
||||
});
|
||||
const { isAtBottom, newMessageCount, onScroll, scrollToBottom } =
|
||||
useAnchoredScroll({
|
||||
channelId: threadHeadId,
|
||||
contentRef: threadContentRef,
|
||||
// Wait for deferred replies to commit before scroll-init (else rows mount un-scrolled).
|
||||
isLoading: repliesRenderState === "pending",
|
||||
messages: threadMessages,
|
||||
onTargetReached: onScrollTargetResolved,
|
||||
scrollContainerRef: threadBodyRef,
|
||||
sentinelRef: threadTopSentinelRef,
|
||||
targetMessageId: scrollTargetId,
|
||||
});
|
||||
|
||||
if (!threadHead) {
|
||||
return null;
|
||||
@@ -388,10 +388,11 @@ export function MessageThreadPanel({
|
||||
!isSplitLayout && !isFloatingOverlay && "pt-[4.75rem]",
|
||||
)}
|
||||
data-testid="message-thread-body"
|
||||
onScroll={syncScrollState}
|
||||
onScroll={onScroll}
|
||||
ref={threadBodyRef}
|
||||
>
|
||||
<div ref={contentRef}>
|
||||
<div ref={threadContentRef}>
|
||||
<div ref={threadTopSentinelRef} aria-hidden className="h-px" />
|
||||
<div className="px-3 pb-1 pt-0" data-testid="message-thread-head">
|
||||
<div className="rounded-2xl">
|
||||
<MessageRow
|
||||
@@ -497,7 +498,6 @@ export function MessageThreadPanel({
|
||||
// rows are streaming in on the deferred commit. Paint nothing rather
|
||||
// than flashing the empty state.
|
||||
null}
|
||||
<div aria-hidden className="h-px" ref={bottomAnchorRef} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -17,8 +17,7 @@ import { UnreadPill, unreadCountLabel } from "@/shared/ui/UnreadPill";
|
||||
import { UserAvatar } from "@/shared/ui/UserAvatar";
|
||||
import { TimelineSkeleton, useTimelineSkeletonRows } from "./TimelineSkeleton";
|
||||
import { TimelineMessageList } from "./TimelineMessageList";
|
||||
import { useLoadOlderOnScroll } from "./useLoadOlderOnScroll";
|
||||
import { useTimelineScrollManager } from "./useTimelineScrollManager";
|
||||
import { useAnchoredScroll } from "./useAnchoredScroll";
|
||||
|
||||
export type MessageTimelineHandle = {
|
||||
scrollToBottomOnNextUpdate: () => void;
|
||||
@@ -161,6 +160,7 @@ const MessageTimelineBase = React.forwardRef<
|
||||
) {
|
||||
const internalScrollRef = React.useRef<HTMLDivElement>(null);
|
||||
const scrollContainerRef = externalScrollRef ?? internalScrollRef;
|
||||
const contentRef = React.useRef<HTMLDivElement>(null);
|
||||
const topSentinelRef = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
// Gate the heavy timeline render (each row runs a synchronous
|
||||
@@ -203,22 +203,24 @@ const MessageTimelineBase = React.forwardRef<
|
||||
const showMessageList = timelineBodySurface === "list";
|
||||
|
||||
const {
|
||||
bottomAnchorRef,
|
||||
contentRef,
|
||||
highlightedMessageId,
|
||||
isAtBottom,
|
||||
newMessageCount,
|
||||
restoreScrollPosition,
|
||||
onScroll,
|
||||
scrollToBottom,
|
||||
scrollToBottomOnNextUpdate,
|
||||
scrollToMessage,
|
||||
syncScrollState,
|
||||
} = useTimelineScrollManager({
|
||||
} = useAnchoredScroll({
|
||||
channelId,
|
||||
contentRef,
|
||||
fetchOlder,
|
||||
hasOlderMessages,
|
||||
isFetchingOlder,
|
||||
isLoading: showTimelineSkeleton,
|
||||
messages: deferredMessages,
|
||||
onTargetReached,
|
||||
scrollContainerRef,
|
||||
sentinelRef: topSentinelRef,
|
||||
targetMessageId,
|
||||
});
|
||||
|
||||
@@ -263,9 +265,10 @@ const MessageTimelineBase = React.forwardRef<
|
||||
}
|
||||
}, [firstUnreadMessageId, scrollToMessage]);
|
||||
|
||||
// Scroll to the active search match when it changes.
|
||||
// Scroll to the active search match when it changes. `scrollToMessage`
|
||||
// updates the scroll anchor, so the post-commit restore won't yank the
|
||||
// view back off the match.
|
||||
const prevSearchActiveRef = React.useRef<string | null>(null);
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: scrollContainerRef is a stable React ref
|
||||
React.useEffect(() => {
|
||||
if (showTimelineSkeleton) return;
|
||||
if (
|
||||
@@ -276,26 +279,8 @@ const MessageTimelineBase = React.forwardRef<
|
||||
return;
|
||||
}
|
||||
prevSearchActiveRef.current = searchActiveMessageId;
|
||||
|
||||
const container = scrollContainerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
const el = container.querySelector<HTMLElement>(
|
||||
`[data-message-id="${searchActiveMessageId}"]`,
|
||||
);
|
||||
if (el) {
|
||||
el.scrollIntoView({ block: "center", behavior: "smooth" });
|
||||
}
|
||||
}, [searchActiveMessageId, showTimelineSkeleton]);
|
||||
|
||||
useLoadOlderOnScroll({
|
||||
fetchOlder,
|
||||
hasOlderMessages,
|
||||
isLoading: showTimelineSkeleton,
|
||||
restoreScrollPosition,
|
||||
scrollContainerRef,
|
||||
sentinelRef: topSentinelRef,
|
||||
});
|
||||
scrollToMessage(searchActiveMessageId, { behavior: "smooth" });
|
||||
}, [scrollToMessage, searchActiveMessageId, showTimelineSkeleton]);
|
||||
|
||||
const timelineSkeletonRows = useTimelineSkeletonRows({
|
||||
channelId,
|
||||
@@ -328,7 +313,7 @@ const MessageTimelineBase = React.forwardRef<
|
||||
)}
|
||||
data-scroll-restoration-id={scrollRestorationId}
|
||||
data-testid="message-timeline"
|
||||
onScroll={syncScrollState}
|
||||
onScroll={onScroll}
|
||||
ref={scrollContainerRef}
|
||||
>
|
||||
<div
|
||||
@@ -359,7 +344,7 @@ const MessageTimelineBase = React.forwardRef<
|
||||
) : null}
|
||||
{activeDirectMessageIntro ? (
|
||||
<div
|
||||
className="mb-0.5 mt-auto flex w-full flex-col items-start px-3 py-2 text-left"
|
||||
className="mt-auto flex w-full flex-col items-start px-3 py-2 text-left"
|
||||
data-testid="message-dm-intro"
|
||||
>
|
||||
<DirectMessageIntroAvatarStack
|
||||
@@ -380,7 +365,7 @@ const MessageTimelineBase = React.forwardRef<
|
||||
|
||||
{activeChannelIntro ? (
|
||||
<div
|
||||
className="mb-0.5 mt-auto flex w-full max-w-2xl flex-col items-start px-3 py-2 text-left"
|
||||
className="mt-auto flex w-full max-w-2xl flex-col items-start px-3 py-2 text-left"
|
||||
data-testid="message-channel-intro"
|
||||
>
|
||||
<div
|
||||
@@ -520,8 +505,6 @@ const MessageTimelineBase = React.forwardRef<
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div aria-hidden className="h-px" ref={bottomAnchorRef} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { formatDayHeading } from "@/features/messages/lib/dateFormatters";
|
||||
import {
|
||||
formatDayHeading,
|
||||
isSameDay,
|
||||
startOfLocalDaySeconds,
|
||||
} from "@/features/messages/lib/dateFormatters";
|
||||
import {
|
||||
buildMainTimelineEntries,
|
||||
shouldRenderUnreadDivider,
|
||||
} from "@/features/messages/lib/threadPanel";
|
||||
import {
|
||||
buildVideoReviewCommentsByRootId,
|
||||
buildVideoReviewCommentsForRoot,
|
||||
buildVideoReviewContextForMessage,
|
||||
hasVideoAttachment,
|
||||
} from "@/features/messages/lib/videoReviewContext";
|
||||
import { buildDayGroupBoundaries } from "@/features/messages/lib/timelineSnapshot";
|
||||
import type { TimelineMessage } from "@/features/messages/types";
|
||||
import type { UserProfileLookup } from "@/features/profile/lib/identity";
|
||||
import type { ChannelType } from "@/shared/api/types";
|
||||
@@ -65,6 +69,76 @@ type TimelineMessageListProps = {
|
||||
threadUnreadCounts?: ReadonlyMap<string, number>;
|
||||
};
|
||||
|
||||
type TimelineDayRow = {
|
||||
key: string;
|
||||
label: string;
|
||||
type: "day";
|
||||
};
|
||||
|
||||
type TimelineUnreadRow = {
|
||||
key: string;
|
||||
type: "unread";
|
||||
};
|
||||
|
||||
type TimelineMessageRowModel = {
|
||||
key: string;
|
||||
message: TimelineMessage;
|
||||
summary: ReturnType<typeof buildMainTimelineEntries>[number]["summary"];
|
||||
type: "message";
|
||||
};
|
||||
|
||||
type TimelineRenderRow =
|
||||
| TimelineDayRow
|
||||
| TimelineUnreadRow
|
||||
| TimelineMessageRowModel;
|
||||
|
||||
function buildTimelineRenderRows({
|
||||
firstUnreadMessageId,
|
||||
messages,
|
||||
}: {
|
||||
firstUnreadMessageId: string | null;
|
||||
messages: TimelineMessage[];
|
||||
}): TimelineRenderRow[] {
|
||||
const entries = buildMainTimelineEntries(messages);
|
||||
const rows: TimelineRenderRow[] = [];
|
||||
let previousMessage: TimelineMessage | null = null;
|
||||
|
||||
for (let i = 0; i < entries.length; i++) {
|
||||
const { message, summary } = entries[i];
|
||||
const messageRenderKey = message.renderKey ?? message.id;
|
||||
|
||||
if (
|
||||
!previousMessage ||
|
||||
!isSameDay(previousMessage.createdAt, message.createdAt)
|
||||
) {
|
||||
rows.push({
|
||||
key: `day-${startOfLocalDaySeconds(message.createdAt)}`,
|
||||
label: formatDayHeading(message.createdAt),
|
||||
type: "day",
|
||||
});
|
||||
}
|
||||
|
||||
// The unread "New" divider only marks a read/unread boundary when there is
|
||||
// a message above the first unread. When the first unread is the first
|
||||
// rendered top-level entry (fresh/never-read channel), there is nothing
|
||||
// above to separate from, so it is suppressed.
|
||||
if (shouldRenderUnreadDivider(i, message.id, firstUnreadMessageId)) {
|
||||
rows.push({ key: `unread:${messageRenderKey}`, type: "unread" });
|
||||
}
|
||||
|
||||
rows.push({
|
||||
key: `msg:${messageRenderKey}`,
|
||||
message,
|
||||
summary,
|
||||
type: "message",
|
||||
});
|
||||
|
||||
previousMessage = message;
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
export const TimelineMessageList = React.memo(function TimelineMessageList({
|
||||
agentPubkeys,
|
||||
channelId,
|
||||
@@ -91,207 +165,226 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({
|
||||
threadUnreadCounts,
|
||||
unfollowThreadById,
|
||||
}: TimelineMessageListProps) {
|
||||
const entries = React.useMemo(
|
||||
() => buildMainTimelineEntries(messages),
|
||||
[messages],
|
||||
const rows = React.useMemo(
|
||||
() => buildTimelineRenderRows({ firstUnreadMessageId, messages }),
|
||||
[firstUnreadMessageId, messages],
|
||||
);
|
||||
const reviewCommentsByRootId = React.useMemo(
|
||||
() => buildVideoReviewCommentsByRootId(messages),
|
||||
[messages],
|
||||
);
|
||||
// Contexts are memoized per message id so MessageRow/Markdown memo
|
||||
// comparisons hold across unrelated timeline re-renders (typing
|
||||
// indicators, presence updates) — a fresh context object per render would
|
||||
// defeat the memo and re-render every video message on every pass.
|
||||
const videoReviewContextById = React.useMemo(() => {
|
||||
const contexts = new Map<
|
||||
string,
|
||||
NonNullable<ReturnType<typeof buildVideoReviewContextForMessage>>
|
||||
>();
|
||||
for (const message of messages) {
|
||||
const comments = reviewCommentsByRootId.get(message.id) ?? [];
|
||||
const context = buildVideoReviewContextForMessage({
|
||||
channelId,
|
||||
channelName,
|
||||
channelType,
|
||||
comments,
|
||||
isSendingVideoReviewComment,
|
||||
message,
|
||||
onSendVideoReviewComment,
|
||||
onToggleReaction,
|
||||
profiles,
|
||||
});
|
||||
if (context) {
|
||||
contexts.set(message.id, context);
|
||||
return rows.map((row) => (
|
||||
<TimelineRenderRowView
|
||||
agentPubkeys={agentPubkeys}
|
||||
allMessages={
|
||||
row.type === "message" && hasVideoAttachment(row.message)
|
||||
? messages
|
||||
: undefined
|
||||
}
|
||||
channelId={channelId}
|
||||
channelName={channelName}
|
||||
channelType={channelType}
|
||||
currentPubkey={currentPubkey}
|
||||
followThreadById={followThreadById}
|
||||
highlightedMessageId={highlightedMessageId}
|
||||
isFollowingThreadById={isFollowingThreadById}
|
||||
isSendingVideoReviewComment={isSendingVideoReviewComment}
|
||||
key={row.key}
|
||||
messageFooters={messageFooters}
|
||||
onDelete={onDelete}
|
||||
onEdit={onEdit}
|
||||
onMarkUnread={onMarkUnread}
|
||||
onReply={onReply}
|
||||
onSendVideoReviewComment={onSendVideoReviewComment}
|
||||
onToggleReaction={onToggleReaction}
|
||||
profiles={profiles}
|
||||
row={row}
|
||||
searchActiveMessageId={searchActiveMessageId}
|
||||
searchMatchingMessageIds={searchMatchingMessageIds}
|
||||
searchQuery={searchQuery}
|
||||
threadUnreadCounts={threadUnreadCounts}
|
||||
unfollowThreadById={unfollowThreadById}
|
||||
/>
|
||||
));
|
||||
});
|
||||
|
||||
type TimelineRenderRowViewProps = Omit<
|
||||
TimelineMessageListProps,
|
||||
"firstUnreadMessageId" | "messages" | "personaLookup"
|
||||
> & {
|
||||
allMessages?: TimelineMessage[];
|
||||
row: TimelineRenderRow;
|
||||
};
|
||||
|
||||
const TimelineRenderRowView = React.memo(function TimelineRenderRowView({
|
||||
agentPubkeys,
|
||||
allMessages,
|
||||
channelId,
|
||||
channelName,
|
||||
channelType,
|
||||
currentPubkey,
|
||||
followThreadById,
|
||||
highlightedMessageId = null,
|
||||
isFollowingThreadById,
|
||||
isSendingVideoReviewComment = false,
|
||||
messageFooters,
|
||||
onDelete,
|
||||
onEdit,
|
||||
onMarkUnread,
|
||||
onReply,
|
||||
onSendVideoReviewComment,
|
||||
onToggleReaction,
|
||||
profiles,
|
||||
searchActiveMessageId = null,
|
||||
searchMatchingMessageIds,
|
||||
searchQuery,
|
||||
row,
|
||||
threadUnreadCounts,
|
||||
unfollowThreadById,
|
||||
}: TimelineRenderRowViewProps) {
|
||||
const messageForContext = row.type === "message" ? row.message : null;
|
||||
const videoReviewContext = React.useMemo(() => {
|
||||
if (!allMessages || !messageForContext) {
|
||||
return undefined;
|
||||
}
|
||||
return contexts;
|
||||
|
||||
return buildVideoReviewContextForMessage({
|
||||
channelId,
|
||||
channelName,
|
||||
channelType,
|
||||
comments: buildVideoReviewCommentsForRoot(
|
||||
allMessages,
|
||||
messageForContext.id,
|
||||
),
|
||||
isSendingVideoReviewComment,
|
||||
message: messageForContext,
|
||||
onSendVideoReviewComment,
|
||||
onToggleReaction,
|
||||
profiles,
|
||||
});
|
||||
}, [
|
||||
allMessages,
|
||||
channelId,
|
||||
channelName,
|
||||
channelType,
|
||||
isSendingVideoReviewComment,
|
||||
messages,
|
||||
messageForContext,
|
||||
onSendVideoReviewComment,
|
||||
onToggleReaction,
|
||||
profiles,
|
||||
reviewCommentsByRootId,
|
||||
]);
|
||||
const dayGroups: Array<{
|
||||
key: string;
|
||||
label: string;
|
||||
elements: React.ReactNode[];
|
||||
}> = [];
|
||||
let currentDayGroup: (typeof dayGroups)[number] | null = null;
|
||||
|
||||
// Day-divider decision delegated to a pure, lib-tested helper: a new group
|
||||
// starts at index 0 and whenever a message falls on a different calendar day
|
||||
// than the one before it. We index the boundary start positions so the render
|
||||
// loop below stays a straight walk while the grouping logic lives in `lib/`.
|
||||
const dayGroupStartIndices = new Set(
|
||||
buildDayGroupBoundaries(entries.map((entry) => entry.message)).map(
|
||||
(boundary) => boundary.startIndex,
|
||||
),
|
||||
);
|
||||
|
||||
for (let i = 0; i < entries.length; i++) {
|
||||
const { message, summary } = entries[i];
|
||||
const messageRenderKey = message.renderKey ?? message.id;
|
||||
|
||||
if (dayGroupStartIndices.has(i)) {
|
||||
currentDayGroup = {
|
||||
key: `day-${message.createdAt}`,
|
||||
label: formatDayHeading(message.createdAt),
|
||||
elements: [],
|
||||
};
|
||||
dayGroups.push(currentDayGroup);
|
||||
}
|
||||
|
||||
// The unread "New" divider only marks a read/unread boundary when there is
|
||||
// a message above the first unread. When the first unread is the first
|
||||
// rendered top-level entry (fresh/never-read channel), there is nothing
|
||||
// above to separate from, so it is suppressed.
|
||||
if (shouldRenderUnreadDivider(i, message.id, firstUnreadMessageId)) {
|
||||
currentDayGroup?.elements.push(
|
||||
<UnreadDivider key={`unread-${messageRenderKey}`} />,
|
||||
);
|
||||
}
|
||||
|
||||
if (message.kind === KIND_SYSTEM_MESSAGE) {
|
||||
const footer = messageFooters?.[message.id] ?? null;
|
||||
currentDayGroup?.elements.push(
|
||||
<div key={messageRenderKey} className="flex flex-col gap-1">
|
||||
<SystemMessageRow
|
||||
message={message}
|
||||
currentPubkey={currentPubkey}
|
||||
onToggleReaction={onToggleReaction}
|
||||
profiles={profiles}
|
||||
/>
|
||||
{footer}
|
||||
</div>,
|
||||
);
|
||||
} else if (summary && onReply) {
|
||||
const footer = messageFooters?.[message.id] ?? null;
|
||||
const isHighlighted = message.id === highlightedMessageId;
|
||||
currentDayGroup?.elements.push(
|
||||
<div
|
||||
key={messageRenderKey}
|
||||
className={cn(
|
||||
"group/message relative mx-1 flex flex-col gap-0 rounded-2xl px-0 py-1 transition-colors hover:bg-muted/50 focus-within:bg-muted/50",
|
||||
isHighlighted &&
|
||||
"-mx-4 px-4 before:absolute before:-inset-y-1.5 before:inset-x-0 before:animate-[route-target-highlight-fade_2s_ease-out_forwards] before:bg-primary/10 before:content-[''] motion-reduce:before:animate-none sm:-mx-6 sm:px-6",
|
||||
)}
|
||||
>
|
||||
<MessageRow
|
||||
agentPubkeys={agentPubkeys}
|
||||
channelId={channelId}
|
||||
highlighted={false}
|
||||
hoverBackground={false}
|
||||
isFollowingThread={
|
||||
isFollowingThreadById
|
||||
? isFollowingThreadById(message.id)
|
||||
: undefined
|
||||
}
|
||||
message={message}
|
||||
onDelete={
|
||||
onDelete && currentPubkey && message.pubkey === currentPubkey
|
||||
? onDelete
|
||||
: undefined
|
||||
}
|
||||
onEdit={
|
||||
onEdit && currentPubkey && message.pubkey === currentPubkey
|
||||
? onEdit
|
||||
: undefined
|
||||
}
|
||||
onFollowThread={
|
||||
followThreadById ? () => followThreadById(message.id) : undefined
|
||||
}
|
||||
onMarkUnread={onMarkUnread}
|
||||
onToggleReaction={onToggleReaction}
|
||||
onReply={onReply}
|
||||
onUnfollowThread={
|
||||
unfollowThreadById
|
||||
? () => unfollowThreadById(message.id)
|
||||
: undefined
|
||||
}
|
||||
profiles={profiles}
|
||||
showDepthGuides={false}
|
||||
videoReviewContext={videoReviewContextById.get(message.id)}
|
||||
/>
|
||||
<MessageThreadSummaryRow
|
||||
depth={message.depth}
|
||||
message={message}
|
||||
onOpenThread={onReply}
|
||||
showDepthGuides={false}
|
||||
summary={summary}
|
||||
unreadCount={threadUnreadCounts?.get(message.id)}
|
||||
/>
|
||||
{footer}
|
||||
</div>,
|
||||
);
|
||||
} else {
|
||||
const isSearchMatch = searchMatchingMessageIds?.has(message.id) ?? false;
|
||||
const isSearchActive = message.id === searchActiveMessageId;
|
||||
const footer = messageFooters?.[message.id] ?? null;
|
||||
|
||||
currentDayGroup?.elements.push(
|
||||
<div key={messageRenderKey} className="flex flex-col gap-1">
|
||||
<MessageRow
|
||||
agentPubkeys={agentPubkeys}
|
||||
channelId={channelId}
|
||||
highlighted={message.id === highlightedMessageId || isSearchActive}
|
||||
message={message}
|
||||
onDelete={
|
||||
onDelete && currentPubkey && message.pubkey === currentPubkey
|
||||
? onDelete
|
||||
: undefined
|
||||
}
|
||||
onEdit={
|
||||
onEdit && currentPubkey && message.pubkey === currentPubkey
|
||||
? onEdit
|
||||
: undefined
|
||||
}
|
||||
onMarkUnread={onMarkUnread}
|
||||
onToggleReaction={onToggleReaction}
|
||||
onReply={onReply}
|
||||
profiles={profiles}
|
||||
searchQuery={isSearchMatch ? searchQuery : undefined}
|
||||
showDepthGuides={false}
|
||||
videoReviewContext={videoReviewContextById.get(message.id)}
|
||||
/>
|
||||
{footer}
|
||||
</div>,
|
||||
);
|
||||
}
|
||||
if (row.type === "day") {
|
||||
return <DayDivider label={row.label} />;
|
||||
}
|
||||
|
||||
return dayGroups.map((group) => (
|
||||
<section
|
||||
className="relative flex flex-col gap-2.5 before:absolute before:inset-x-0 before:top-[15px] before:h-px before:bg-border/35 before:content-['']"
|
||||
key={group.key}
|
||||
>
|
||||
<DayDivider label={group.label} />
|
||||
{group.elements}
|
||||
</section>
|
||||
));
|
||||
if (row.type === "unread") {
|
||||
return <UnreadDivider />;
|
||||
}
|
||||
|
||||
const { message, summary } = row;
|
||||
|
||||
if (message.kind === KIND_SYSTEM_MESSAGE) {
|
||||
const footer = messageFooters?.[message.id] ?? null;
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
<SystemMessageRow
|
||||
message={message}
|
||||
currentPubkey={currentPubkey}
|
||||
onToggleReaction={onToggleReaction}
|
||||
profiles={profiles}
|
||||
/>
|
||||
{footer}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (summary && onReply) {
|
||||
const footer = messageFooters?.[message.id] ?? null;
|
||||
const isHighlighted = message.id === highlightedMessageId;
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"group/message relative mx-1 flex flex-col gap-0 rounded-2xl px-0 py-1 transition-colors hover:bg-muted/50 focus-within:bg-muted/50",
|
||||
isHighlighted &&
|
||||
"-mx-4 px-4 before:absolute before:-inset-y-1.5 before:inset-x-0 before:animate-[route-target-highlight-fade_2s_ease-out_forwards] before:bg-primary/10 before:content-[''] motion-reduce:before:animate-none sm:-mx-6 sm:px-6",
|
||||
)}
|
||||
>
|
||||
<MessageRow
|
||||
agentPubkeys={agentPubkeys}
|
||||
channelId={channelId}
|
||||
highlighted={false}
|
||||
hoverBackground={false}
|
||||
isFollowingThread={
|
||||
isFollowingThreadById
|
||||
? isFollowingThreadById(message.id)
|
||||
: undefined
|
||||
}
|
||||
message={message}
|
||||
onDelete={
|
||||
onDelete && currentPubkey && message.pubkey === currentPubkey
|
||||
? onDelete
|
||||
: undefined
|
||||
}
|
||||
onEdit={
|
||||
onEdit && currentPubkey && message.pubkey === currentPubkey
|
||||
? onEdit
|
||||
: undefined
|
||||
}
|
||||
onFollowThread={
|
||||
followThreadById ? () => followThreadById(message.id) : undefined
|
||||
}
|
||||
onMarkUnread={onMarkUnread}
|
||||
onToggleReaction={onToggleReaction}
|
||||
onReply={onReply}
|
||||
onUnfollowThread={
|
||||
unfollowThreadById
|
||||
? () => unfollowThreadById(message.id)
|
||||
: undefined
|
||||
}
|
||||
profiles={profiles}
|
||||
showDepthGuides={false}
|
||||
videoReviewContext={videoReviewContext}
|
||||
/>
|
||||
<MessageThreadSummaryRow
|
||||
depth={message.depth}
|
||||
message={message}
|
||||
onOpenThread={onReply}
|
||||
showDepthGuides={false}
|
||||
summary={summary}
|
||||
unreadCount={threadUnreadCounts?.get(message.id)}
|
||||
/>
|
||||
{footer}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const isSearchMatch = searchMatchingMessageIds?.has(message.id) ?? false;
|
||||
const isSearchActive = message.id === searchActiveMessageId;
|
||||
const footer = messageFooters?.[message.id] ?? null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
<MessageRow
|
||||
agentPubkeys={agentPubkeys}
|
||||
channelId={channelId}
|
||||
highlighted={message.id === highlightedMessageId || isSearchActive}
|
||||
message={message}
|
||||
onDelete={
|
||||
onDelete && currentPubkey && message.pubkey === currentPubkey
|
||||
? onDelete
|
||||
: undefined
|
||||
}
|
||||
onEdit={
|
||||
onEdit && currentPubkey && message.pubkey === currentPubkey
|
||||
? onEdit
|
||||
: undefined
|
||||
}
|
||||
onMarkUnread={onMarkUnread}
|
||||
onToggleReaction={onToggleReaction}
|
||||
onReply={onReply}
|
||||
profiles={profiles}
|
||||
searchQuery={isSearchMatch ? searchQuery : undefined}
|
||||
showDepthGuides={false}
|
||||
videoReviewContext={videoReviewContext}
|
||||
/>
|
||||
{footer}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
626
desktop/src/features/messages/ui/useAnchoredScroll.ts
Normal file
626
desktop/src/features/messages/ui/useAnchoredScroll.ts
Normal file
@@ -0,0 +1,626 @@
|
||||
import * as React from "react";
|
||||
|
||||
import type { TimelineMessage } from "@/features/messages/types";
|
||||
|
||||
/**
|
||||
* Distance (in CSS pixels) below which we consider the scroll position
|
||||
* "at the bottom" of the message list. Tight enough that the user has to
|
||||
* actually scroll down to re-pin; permissive enough to tolerate sub-pixel
|
||||
* rounding from the layout engine.
|
||||
*/
|
||||
const AT_BOTTOM_THRESHOLD_PX = 32;
|
||||
|
||||
type AnchorState =
|
||||
| { kind: "at-bottom" }
|
||||
| { kind: "message"; messageId: string; topOffset: number };
|
||||
|
||||
type UseAnchoredScrollOptions = {
|
||||
/** Scroll container. Owned by the parent so external refs still compose. */
|
||||
scrollContainerRef: React.RefObject<HTMLDivElement | null>;
|
||||
/** Inner content element — must wrap every renderable row, including the
|
||||
* sentinel and bottom anchor. Used to schedule layout work on resize. */
|
||||
contentRef: React.RefObject<HTMLDivElement | null>;
|
||||
/** Small zero-height element near the very top of the content. When it
|
||||
* intersects the viewport (with some rootMargin) we trigger fetchOlder. */
|
||||
sentinelRef: React.RefObject<HTMLDivElement | null>;
|
||||
/** Resets when changed; lets us drop anchor + scroll state across channels. */
|
||||
channelId?: string | null;
|
||||
/** Suppresses initial scroll-to-bottom while a skeleton is showing. */
|
||||
isLoading: boolean;
|
||||
/** Source of truth for the rendered list. Used to detect new-at-bottom
|
||||
* arrivals and to seed/refresh the anchor pre-render. */
|
||||
messages: TimelineMessage[];
|
||||
/** Optional callback to fetch older history. The hook handles intersection,
|
||||
* debouncing, and post-prepend scroll restoration via the anchor. */
|
||||
fetchOlder?: () => Promise<void>;
|
||||
hasOlderMessages?: boolean;
|
||||
/** True while an older-history fetch is in flight. The fetch spinner renders
|
||||
* above the anchor, so toggling it shifts every row below it. The spinner
|
||||
* toggles on its own commit (no message change), so without this signal the
|
||||
* restoration effect — keyed on `messages` — wouldn't re-run to correct the
|
||||
* shift, leaving a visible one-frame jump. Threading it through makes the
|
||||
* anchor the single owner of every layout change above the reader's eye. */
|
||||
isFetchingOlder?: boolean;
|
||||
/** When set, scroll to and highlight this message on mount and on change. */
|
||||
targetMessageId?: string | null;
|
||||
onTargetReached?: (messageId: string) => void;
|
||||
};
|
||||
|
||||
type UseAnchoredScrollResult = {
|
||||
/** Pass through to the scroll container's `onScroll`. */
|
||||
onScroll: () => void;
|
||||
/** True when the user is within `AT_BOTTOM_THRESHOLD_PX` of the bottom. */
|
||||
isAtBottom: boolean;
|
||||
/** Number of new messages that have arrived while the user is not at the
|
||||
* bottom. Cleared when the user returns to the bottom. */
|
||||
newMessageCount: number;
|
||||
/** Message id that should pulse a highlight (target/active-search). */
|
||||
highlightedMessageId: string | null;
|
||||
/** Imperative: scroll to bottom. */
|
||||
scrollToBottom: (behavior?: ScrollBehavior) => void;
|
||||
/** Arm a one-shot scroll-to-bottom that fires on the next appended message
|
||||
* (used by the composer's send flow). */
|
||||
scrollToBottomOnNextUpdate: () => void;
|
||||
/** Imperative: scroll a specific message into view; optionally pulse it.
|
||||
* Returns true if the row was found and scrolled, false otherwise. */
|
||||
scrollToMessage: (
|
||||
messageId: string,
|
||||
options?: { highlight?: boolean; behavior?: ScrollBehavior },
|
||||
) => boolean;
|
||||
};
|
||||
|
||||
function isAtBottomNow(container: HTMLDivElement) {
|
||||
return (
|
||||
container.scrollHeight - container.clientHeight - container.scrollTop <=
|
||||
AT_BOTTOM_THRESHOLD_PX
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick an anchor for the current scroll position.
|
||||
*
|
||||
* Top-crossing walk: chronological children, top-down. The first
|
||||
* `data-message-id` row whose bottom edge has crossed below the container
|
||||
* top is the anchor — that's the row the reader's eye is on when they've
|
||||
* scrolled up through history. `topOffset` is the row's top relative to
|
||||
* the container's top and may be negative when the row straddles the edge.
|
||||
*
|
||||
* If no such row exists (e.g. nothing scrolled past the top, list shorter
|
||||
* than the viewport, etc.) the anchor is `at-bottom`.
|
||||
*
|
||||
* Algorithm credit: Sami's [13] in the buzz-bugs scroll-redesign thread,
|
||||
* supersedes the Matrix-style bottom-up walk in [7]. The top-crossing
|
||||
* choice is what keeps the row the reader is *reading* fixed under
|
||||
* in-viewport reflow (image-load, embed expansion).
|
||||
*/
|
||||
function computeAnchor(container: HTMLDivElement): AnchorState {
|
||||
if (isAtBottomNow(container)) {
|
||||
return { kind: "at-bottom" };
|
||||
}
|
||||
|
||||
const containerTop = container.getBoundingClientRect().top;
|
||||
const rows = container.querySelectorAll<HTMLElement>("[data-message-id]");
|
||||
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
const row = rows[i];
|
||||
const rect = row.getBoundingClientRect();
|
||||
if (rect.bottom > containerTop) {
|
||||
const messageId = row.dataset.messageId;
|
||||
if (messageId) {
|
||||
return {
|
||||
kind: "message",
|
||||
messageId,
|
||||
topOffset: rect.top - containerTop,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { kind: "at-bottom" };
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the rendered message id that is closest in chronological order to
|
||||
* the anchor, scanning forward in `messages`. Used as the fallback when the
|
||||
* anchor's row is gone post-render (e.g. message deleted).
|
||||
*/
|
||||
function findNearestNewerMessageId(
|
||||
container: HTMLDivElement,
|
||||
messages: TimelineMessage[],
|
||||
anchorId: string,
|
||||
): string | null {
|
||||
const anchorIndex = messages.findIndex((m) => m.id === anchorId);
|
||||
if (anchorIndex < 0) return null;
|
||||
|
||||
for (let i = anchorIndex + 1; i < messages.length; i++) {
|
||||
const candidate = messages[i];
|
||||
const el = container.querySelector(`[data-message-id="${candidate.id}"]`);
|
||||
if (el) return candidate.id;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore a message-kind anchor's on-screen offset after a layout shift.
|
||||
*
|
||||
* Finds the anchor row (or the nearest newer rendered row if the anchor
|
||||
* itself was removed), measures its current top-relative offset, and
|
||||
* `scrollBy(0, delta)` if the offset has drifted. Returns the new anchor
|
||||
* state the caller should write back:
|
||||
* - `{ kind: "message", ... }` — anchor (or its fallback) is in the DOM
|
||||
* and now sits at its previous offset.
|
||||
* - `{ kind: "at-bottom" }` — anchor and all newer rendered rows are gone;
|
||||
* caller should pin to the bottom and update at-bottom state.
|
||||
*
|
||||
* `scrollBy` is intentional over `scrollTop = ...`: relative adjustment
|
||||
* composes with the browser's own scroll anchoring and doesn't fight a
|
||||
* smooth-scroll in flight. Same rationale as the layout-effect restore.
|
||||
*
|
||||
* Used by both the post-commit layout effect (prepend / append / spinner
|
||||
* toggle / etc.) and the ResizeObserver (in-viewport reflow from image
|
||||
* decode, embed expansion, font load). Keeping them on one primitive
|
||||
* preserves the single-owner invariant of the hook.
|
||||
*/
|
||||
function restoreAnchorToMessage(
|
||||
container: HTMLDivElement,
|
||||
messages: TimelineMessage[],
|
||||
anchor: Extract<AnchorState, { kind: "message" }>,
|
||||
): AnchorState {
|
||||
let anchorEl = container.querySelector<HTMLElement>(
|
||||
`[data-message-id="${anchor.messageId}"]`,
|
||||
);
|
||||
let usedAnchor: AnchorState = anchor;
|
||||
if (!anchorEl) {
|
||||
const fallbackId = findNearestNewerMessageId(
|
||||
container,
|
||||
messages,
|
||||
anchor.messageId,
|
||||
);
|
||||
if (fallbackId) {
|
||||
anchorEl = container.querySelector<HTMLElement>(
|
||||
`[data-message-id="${fallbackId}"]`,
|
||||
);
|
||||
if (anchorEl) {
|
||||
usedAnchor = {
|
||||
kind: "message",
|
||||
messageId: fallbackId,
|
||||
topOffset: anchor.topOffset,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!anchorEl) {
|
||||
// Anchor message and all subsequent rendered messages are gone.
|
||||
container.scrollTo({ top: container.scrollHeight, behavior: "auto" });
|
||||
return { kind: "at-bottom" };
|
||||
}
|
||||
|
||||
const containerTop = container.getBoundingClientRect().top;
|
||||
const currentTop = anchorEl.getBoundingClientRect().top - containerTop;
|
||||
const delta = currentTop - usedAnchor.topOffset;
|
||||
if (Math.abs(delta) > 0.5) {
|
||||
container.scrollBy(0, delta);
|
||||
}
|
||||
return usedAnchor;
|
||||
}
|
||||
|
||||
export function useAnchoredScroll({
|
||||
scrollContainerRef,
|
||||
contentRef,
|
||||
sentinelRef,
|
||||
channelId,
|
||||
isLoading,
|
||||
messages,
|
||||
fetchOlder,
|
||||
hasOlderMessages = false,
|
||||
isFetchingOlder = false,
|
||||
targetMessageId = null,
|
||||
onTargetReached,
|
||||
}: UseAnchoredScrollOptions): UseAnchoredScrollResult {
|
||||
// Anchor lives in a ref because it must survive renders and is updated
|
||||
// both on scroll (commit-time read) and in the layout effect (post-render
|
||||
// restoration). useState would force re-renders we don't want.
|
||||
const anchorRef = React.useRef<AnchorState>({ kind: "at-bottom" });
|
||||
// Latest `messages` mirrored to a ref so the ResizeObserver effect can read
|
||||
// the current list without re-subscribing the observer on every commit
|
||||
// (which would also drop any in-flight resize callbacks). Kept fresh by a
|
||||
// layout effect below so the read is consistent with what's in the DOM.
|
||||
const messagesRef = React.useRef<TimelineMessage[]>(messages);
|
||||
const [isAtBottom, setIsAtBottom] = React.useState(true);
|
||||
const [newMessageCount, setNewMessageCount] = React.useState(0);
|
||||
const [highlightedMessageId, setHighlightedMessageId] = React.useState<
|
||||
string | null
|
||||
>(null);
|
||||
|
||||
const hasInitializedRef = React.useRef(false);
|
||||
const prevLastMessageIdRef = React.useRef<string | undefined>(undefined);
|
||||
const prevMessageCountRef = React.useRef(0);
|
||||
const fetchingOlderRef = React.useRef(false);
|
||||
const handledTargetIdRef = React.useRef<string | null>(null);
|
||||
const highlightTimeoutRef = React.useRef<number | null>(null);
|
||||
// One-shot: the consumer calls `scrollToBottomOnNextUpdate()` right before
|
||||
// it sends a message (see ChannelPane). When the user's own message then
|
||||
// appends, we snap to bottom even if they had scrolled up to read history.
|
||||
// Consumed (and cleared) by the next append in the restoration effect.
|
||||
const forceBottomOnNextAppendRef = React.useRef(false);
|
||||
|
||||
// Reset everything when the channel changes — the layout effect that runs
|
||||
// immediately after this reset is responsible for either jumping to bottom
|
||||
// or to the target message for the new channel.
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: channelId is intentionally the sole trigger — we want this effect to fire exactly when the channel changes (and on mount).
|
||||
React.useLayoutEffect(() => {
|
||||
anchorRef.current = { kind: "at-bottom" };
|
||||
setIsAtBottom(true);
|
||||
setNewMessageCount(0);
|
||||
setHighlightedMessageId(null);
|
||||
hasInitializedRef.current = false;
|
||||
prevLastMessageIdRef.current = undefined;
|
||||
prevMessageCountRef.current = 0;
|
||||
fetchingOlderRef.current = false;
|
||||
handledTargetIdRef.current = null;
|
||||
forceBottomOnNextAppendRef.current = false;
|
||||
if (highlightTimeoutRef.current !== null) {
|
||||
window.clearTimeout(highlightTimeoutRef.current);
|
||||
highlightTimeoutRef.current = null;
|
||||
}
|
||||
}, [channelId]);
|
||||
|
||||
const scrollToBottomImperative = React.useCallback(
|
||||
(behavior: ScrollBehavior = "auto") => {
|
||||
const container = scrollContainerRef.current;
|
||||
if (!container) return;
|
||||
anchorRef.current = { kind: "at-bottom" };
|
||||
container.scrollTo({ top: container.scrollHeight, behavior });
|
||||
setIsAtBottom(true);
|
||||
setNewMessageCount(0);
|
||||
},
|
||||
[scrollContainerRef],
|
||||
);
|
||||
|
||||
// Arm a one-shot: the next append snaps to bottom regardless of where the
|
||||
// user is. The consumer calls this right before sending so their own
|
||||
// outbound message pulls the view down even if they'd scrolled up.
|
||||
const scrollToBottomOnNextUpdate = React.useCallback(() => {
|
||||
forceBottomOnNextAppendRef.current = true;
|
||||
}, []);
|
||||
|
||||
const scrollToMessageImperative = React.useCallback(
|
||||
(
|
||||
messageId: string,
|
||||
options: { highlight?: boolean; behavior?: ScrollBehavior } = {},
|
||||
): boolean => {
|
||||
const container = scrollContainerRef.current;
|
||||
if (!container) return false;
|
||||
const el = container.querySelector<HTMLElement>(
|
||||
`[data-message-id="${messageId}"]`,
|
||||
);
|
||||
if (!el) return false;
|
||||
|
||||
const rect = el.getBoundingClientRect();
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
const currentTopOffset = rect.top - containerRect.top;
|
||||
const centeredTopOffset = (container.clientHeight - rect.height) / 2;
|
||||
const maxScrollTop = Math.max(
|
||||
0,
|
||||
container.scrollHeight - container.clientHeight,
|
||||
);
|
||||
const targetScrollTop = Math.min(
|
||||
maxScrollTop,
|
||||
Math.max(0, container.scrollTop + currentTopOffset - centeredTopOffset),
|
||||
);
|
||||
const targetTopOffset =
|
||||
currentTopOffset - (targetScrollTop - container.scrollTop);
|
||||
|
||||
el.scrollIntoView({
|
||||
block: "center",
|
||||
behavior: options.behavior ?? "auto",
|
||||
});
|
||||
|
||||
// `scrollIntoView({ behavior: "smooth" })` starts an async animation, so
|
||||
// measuring after the call can still return the pre-animation position.
|
||||
// Save the clamped destination offset instead; otherwise a concurrent
|
||||
// render/ResizeObserver restore can fight the smooth scroll back toward
|
||||
// where it started.
|
||||
anchorRef.current = {
|
||||
kind: "message",
|
||||
messageId,
|
||||
topOffset: targetTopOffset,
|
||||
};
|
||||
setIsAtBottom(maxScrollTop - targetScrollTop <= AT_BOTTOM_THRESHOLD_PX);
|
||||
|
||||
if (options.highlight) {
|
||||
if (highlightTimeoutRef.current !== null) {
|
||||
window.clearTimeout(highlightTimeoutRef.current);
|
||||
}
|
||||
setHighlightedMessageId(messageId);
|
||||
highlightTimeoutRef.current = window.setTimeout(() => {
|
||||
setHighlightedMessageId((current) =>
|
||||
current === messageId ? null : current,
|
||||
);
|
||||
highlightTimeoutRef.current = null;
|
||||
}, 2_000);
|
||||
}
|
||||
return true;
|
||||
},
|
||||
[scrollContainerRef],
|
||||
);
|
||||
|
||||
// Scroll handler: recompute anchor + bottom state from the current
|
||||
// scroll position. Cheap enough to run on every scroll event — a single
|
||||
// `getBoundingClientRect` walk plus rect reads.
|
||||
const onScroll = React.useCallback(() => {
|
||||
const container = scrollContainerRef.current;
|
||||
if (!container) return;
|
||||
anchorRef.current = computeAnchor(container);
|
||||
const atBottom = anchorRef.current.kind === "at-bottom";
|
||||
setIsAtBottom((prev) => (prev === atBottom ? prev : atBottom));
|
||||
if (atBottom) {
|
||||
setNewMessageCount(0);
|
||||
}
|
||||
}, [scrollContainerRef]);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Anchor restoration: after every render, if the anchor was a message,
|
||||
// realign so that message sits at the same top-relative offset it had
|
||||
// before the render. This is the single mechanism for keeping scroll
|
||||
// stable across prepends, appends, image loads, embed expansions, etc.
|
||||
// ---------------------------------------------------------------------------
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: `isFetchingOlder` is an intentional re-run trigger, not a read — the fetch spinner renders above the anchor on its own commit (with `messages` unchanged), so we re-run restoration on its toggle to correct the spinner-induced shift via the existing anchor.
|
||||
React.useLayoutEffect(() => {
|
||||
const container = scrollContainerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
// Mirror the current messages list into the ref read by the
|
||||
// ResizeObserver's restore path. Must happen before any early return so
|
||||
// a non-React layout shift sees the same array the next restoration
|
||||
// would use.
|
||||
messagesRef.current = messages;
|
||||
|
||||
// First render after a reset (channel switch or initial mount): jump
|
||||
// to the requested target message, or to the bottom by default.
|
||||
if (!hasInitializedRef.current) {
|
||||
if (isLoading) return;
|
||||
if (targetMessageId) {
|
||||
// A cold deep-link target may not be in the DOM on this first
|
||||
// commit — the route screen fetches it by id and splices it in a
|
||||
// render or two later. If centering fails now, leave the timeline at
|
||||
// its default position and let the post-mount target effect (keyed on
|
||||
// `messages`) retry once the row lands, rather than marking it handled.
|
||||
if (scrollToMessageImperative(targetMessageId, { highlight: true })) {
|
||||
handledTargetIdRef.current = targetMessageId;
|
||||
// Consumers clear the route target (`messageId` URL param) on this
|
||||
// callback. The post-mount target effect below also fires it, but
|
||||
// for a target already in the DOM on first commit that effect bails
|
||||
// (handled ref is set), so the initial path must fire it too — else
|
||||
// the param sticks and re-clicking the same deep link is a no-op.
|
||||
onTargetReached?.(targetMessageId);
|
||||
} else {
|
||||
scrollToBottomImperative("auto");
|
||||
}
|
||||
} else {
|
||||
scrollToBottomImperative("auto");
|
||||
}
|
||||
hasInitializedRef.current = true;
|
||||
prevLastMessageIdRef.current = messages[messages.length - 1]?.id;
|
||||
prevMessageCountRef.current = messages.length;
|
||||
return;
|
||||
}
|
||||
|
||||
const anchor = anchorRef.current;
|
||||
const lastMessage = messages[messages.length - 1];
|
||||
const prevLastId = prevLastMessageIdRef.current;
|
||||
const prevCount = prevMessageCountRef.current;
|
||||
const newLatestArrived =
|
||||
lastMessage !== undefined && lastMessage.id !== prevLastId;
|
||||
|
||||
// One-shot: an outbound send armed `scrollToBottomOnNextUpdate`. When the
|
||||
// resulting append lands, snap to bottom regardless of the current anchor,
|
||||
// then clear the flag. Bail before the anchored branch so the user's own
|
||||
// message pulls the view down.
|
||||
if (newLatestArrived && forceBottomOnNextAppendRef.current) {
|
||||
forceBottomOnNextAppendRef.current = false;
|
||||
container.scrollTo({ top: container.scrollHeight, behavior: "auto" });
|
||||
anchorRef.current = { kind: "at-bottom" };
|
||||
setIsAtBottom(true);
|
||||
setNewMessageCount(0);
|
||||
prevLastMessageIdRef.current = lastMessage?.id;
|
||||
prevMessageCountRef.current = messages.length;
|
||||
return;
|
||||
}
|
||||
|
||||
if (anchor.kind === "at-bottom") {
|
||||
// Stick to bottom. Use scrollTo to avoid relying on scroll anchoring.
|
||||
container.scrollTo({ top: container.scrollHeight, behavior: "auto" });
|
||||
if (newLatestArrived) setNewMessageCount(0);
|
||||
} else {
|
||||
// Anchored to a specific message. The shared helper finds it (with a
|
||||
// nearest-newer fallback if the row was removed) and corrects the
|
||||
// offset via `scrollBy`. If both the anchor and all newer rendered
|
||||
// rows are gone, it pins to the bottom and returns `at-bottom`.
|
||||
const restored = restoreAnchorToMessage(container, messages, anchor);
|
||||
anchorRef.current = restored;
|
||||
if (restored.kind === "at-bottom") {
|
||||
setIsAtBottom(true);
|
||||
}
|
||||
|
||||
if (newLatestArrived) {
|
||||
const added = Math.max(1, messages.length - prevCount);
|
||||
setNewMessageCount((current) => current + added);
|
||||
}
|
||||
}
|
||||
|
||||
prevLastMessageIdRef.current = lastMessage?.id;
|
||||
prevMessageCountRef.current = messages.length;
|
||||
}, [
|
||||
isFetchingOlder,
|
||||
isLoading,
|
||||
messages,
|
||||
onTargetReached,
|
||||
scrollContainerRef,
|
||||
scrollToBottomImperative,
|
||||
scrollToMessageImperative,
|
||||
targetMessageId,
|
||||
]);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Older-history loader. IntersectionObserver on the top sentinel; when it
|
||||
// crosses into view (with a 200px rootMargin so we preload a bit early)
|
||||
// we fire `fetchOlder`. The anchor restoration above handles the prepend
|
||||
// — we don't need to compute or apply a scrollHeight delta ourselves.
|
||||
// ---------------------------------------------------------------------------
|
||||
React.useEffect(() => {
|
||||
const sentinel = sentinelRef.current;
|
||||
const container = scrollContainerRef.current;
|
||||
if (
|
||||
!sentinel ||
|
||||
!container ||
|
||||
!fetchOlder ||
|
||||
isLoading ||
|
||||
!hasOlderMessages
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
let disposed = false;
|
||||
let observer: IntersectionObserver | null = null;
|
||||
|
||||
const start = () => {
|
||||
if (disposed) return;
|
||||
observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
if (!entry?.isIntersecting || disposed || fetchingOlderRef.current) {
|
||||
return;
|
||||
}
|
||||
fetchingOlderRef.current = true;
|
||||
observer?.disconnect();
|
||||
|
||||
// Before the fetch, capture the anchor from the current scroll
|
||||
// position. The layout effect after re-render will use it.
|
||||
anchorRef.current = computeAnchor(container);
|
||||
|
||||
void fetchOlder()
|
||||
.catch(() => {
|
||||
// Swallow; the next intersection will retry. We don't want
|
||||
// to crash the observer chain on a transient relay error.
|
||||
})
|
||||
.finally(() => {
|
||||
fetchingOlderRef.current = false;
|
||||
// Re-observe in case there's more history to load.
|
||||
start();
|
||||
});
|
||||
},
|
||||
{ root: container, rootMargin: "200px 0px 0px 0px" },
|
||||
);
|
||||
observer.observe(sentinel);
|
||||
};
|
||||
|
||||
start();
|
||||
return () => {
|
||||
disposed = true;
|
||||
observer?.disconnect();
|
||||
};
|
||||
}, [
|
||||
fetchOlder,
|
||||
hasOlderMessages,
|
||||
isLoading,
|
||||
scrollContainerRef,
|
||||
sentinelRef,
|
||||
]);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Content resize: when fonts load late, an image decodes, an embed expands,
|
||||
// or any in-viewport reflow happens that React isn't driving (so the
|
||||
// layout-effect doesn't fire), the anchor row's on-screen offset drifts.
|
||||
//
|
||||
// When stuck-to-bottom we re-pin to bottom. When anchored to a message we
|
||||
// call the same restore primitive the layout effect uses, so an in-viewport
|
||||
// reflow above the reader's eye shifts back into place. Without this,
|
||||
// anything that resizes without changing `messages` (link-card decode,
|
||||
// async embed expand, late font load, markdown that expands) silently
|
||||
// pushes the reading row around.
|
||||
// ---------------------------------------------------------------------------
|
||||
React.useEffect(() => {
|
||||
const content = contentRef.current;
|
||||
if (!content || typeof ResizeObserver === "undefined") return;
|
||||
const observer = new ResizeObserver(() => {
|
||||
const container = scrollContainerRef.current;
|
||||
if (!container) return;
|
||||
const anchor = anchorRef.current;
|
||||
if (anchor.kind === "at-bottom") {
|
||||
container.scrollTo({ top: container.scrollHeight, behavior: "auto" });
|
||||
return;
|
||||
}
|
||||
// Use the same restore primitive as the layout effect so the
|
||||
// single-owner model holds across non-React-driven layout shifts.
|
||||
const restored = restoreAnchorToMessage(
|
||||
container,
|
||||
messagesRef.current,
|
||||
anchor,
|
||||
);
|
||||
anchorRef.current = restored;
|
||||
if (restored.kind === "at-bottom") {
|
||||
setIsAtBottom(true);
|
||||
}
|
||||
});
|
||||
observer.observe(content);
|
||||
return () => observer.disconnect();
|
||||
}, [contentRef, scrollContainerRef]);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Target message handling (deep link, jump-to-reply, etc.). Distinct from
|
||||
// the initial-mount target above — this handles changes after the first
|
||||
// render.
|
||||
//
|
||||
// A deep-link target may live in older history that isn't in the DOM when
|
||||
// the route param first changes. The route screen fetches the target event
|
||||
// by id and splices it into `messages` asynchronously, so its row appears a
|
||||
// render or two later. We therefore key this effect on `messages` and bail
|
||||
// *without* marking the target handled until its row actually exists — each
|
||||
// subsequent message commit re-runs the effect and retries the centering.
|
||||
// ---------------------------------------------------------------------------
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: `messages` is an intentional trigger, not a read — the effect reads the DOM (querySelector), and we need it to re-run each time the rendered row set changes so a target spliced into older history gets centered once its row commits.
|
||||
React.useEffect(() => {
|
||||
if (!targetMessageId) {
|
||||
handledTargetIdRef.current = null;
|
||||
return;
|
||||
}
|
||||
if (handledTargetIdRef.current === targetMessageId || isLoading) return;
|
||||
if (!hasInitializedRef.current) return; // initial-mount path will handle.
|
||||
|
||||
const container = scrollContainerRef.current;
|
||||
if (!container) return;
|
||||
const el = container.querySelector<HTMLElement>(
|
||||
`[data-message-id="${targetMessageId}"]`,
|
||||
);
|
||||
if (!el) return; // Row not rendered yet; a later `messages` commit retries.
|
||||
handledTargetIdRef.current = targetMessageId;
|
||||
scrollToMessageImperative(targetMessageId, { highlight: true });
|
||||
onTargetReached?.(targetMessageId);
|
||||
}, [
|
||||
isLoading,
|
||||
messages,
|
||||
onTargetReached,
|
||||
scrollContainerRef,
|
||||
scrollToMessageImperative,
|
||||
targetMessageId,
|
||||
]);
|
||||
|
||||
React.useEffect(() => {
|
||||
return () => {
|
||||
if (highlightTimeoutRef.current !== null) {
|
||||
window.clearTimeout(highlightTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return {
|
||||
onScroll,
|
||||
isAtBottom,
|
||||
newMessageCount,
|
||||
highlightedMessageId,
|
||||
scrollToBottom: scrollToBottomImperative,
|
||||
scrollToBottomOnNextUpdate,
|
||||
scrollToMessage: scrollToMessageImperative,
|
||||
};
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
import * as React from "react";
|
||||
|
||||
type UseLoadOlderOnScrollOptions = {
|
||||
fetchOlder?: () => Promise<void>;
|
||||
hasOlderMessages: boolean;
|
||||
isLoading: boolean;
|
||||
restoreScrollPosition: (scrollTop: number) => void;
|
||||
scrollContainerRef: React.RefObject<HTMLDivElement | null>;
|
||||
sentinelRef: React.RefObject<HTMLDivElement | null>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Triggers `fetchOlder` when a sentinel element near the top of the scroll
|
||||
* container enters the viewport, then restores the scroll position so the
|
||||
* visible content doesn't jump.
|
||||
*/
|
||||
export function useLoadOlderOnScroll({
|
||||
fetchOlder,
|
||||
hasOlderMessages,
|
||||
isLoading,
|
||||
restoreScrollPosition,
|
||||
scrollContainerRef,
|
||||
sentinelRef,
|
||||
}: UseLoadOlderOnScrollOptions) {
|
||||
const restoreScrollPositionRef = React.useRef(restoreScrollPosition);
|
||||
React.useEffect(() => {
|
||||
restoreScrollPositionRef.current = restoreScrollPosition;
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
const sentinel = sentinelRef.current;
|
||||
const container = scrollContainerRef.current;
|
||||
if (
|
||||
!sentinel ||
|
||||
!container ||
|
||||
!fetchOlder ||
|
||||
isLoading ||
|
||||
!hasOlderMessages
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
let disposed = false;
|
||||
let currentObserver: IntersectionObserver | null = null;
|
||||
|
||||
const observe = () => {
|
||||
if (disposed) {
|
||||
return;
|
||||
}
|
||||
|
||||
currentObserver = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
if (!entry.isIntersecting || disposed) {
|
||||
return;
|
||||
}
|
||||
|
||||
currentObserver?.disconnect();
|
||||
|
||||
const previousHeight = container.scrollHeight;
|
||||
const previousScrollTop = container.scrollTop;
|
||||
void fetchOlder().then(() => {
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
const newHeight = container.scrollHeight;
|
||||
const delta = newHeight - previousHeight;
|
||||
if (delta > 0) {
|
||||
restoreScrollPositionRef.current(previousScrollTop + delta);
|
||||
}
|
||||
observe();
|
||||
});
|
||||
});
|
||||
});
|
||||
},
|
||||
{ root: container, rootMargin: "200px 0px 0px 0px" },
|
||||
);
|
||||
|
||||
currentObserver.observe(sentinel);
|
||||
};
|
||||
|
||||
observe();
|
||||
return () => {
|
||||
disposed = true;
|
||||
currentObserver?.disconnect();
|
||||
};
|
||||
}, [
|
||||
fetchOlder,
|
||||
hasOlderMessages,
|
||||
isLoading,
|
||||
scrollContainerRef,
|
||||
sentinelRef,
|
||||
]);
|
||||
}
|
||||
@@ -1,464 +0,0 @@
|
||||
import * as React from "react";
|
||||
|
||||
import {
|
||||
isNearBottom,
|
||||
resolveDeepLinkTarget,
|
||||
selectLatestMessageAutoScrollBehavior,
|
||||
selectLatestMessageKey,
|
||||
} from "@/features/messages/lib/timelineSnapshot";
|
||||
import type { TimelineMessage } from "@/features/messages/types";
|
||||
|
||||
type UseTimelineScrollManagerOptions = {
|
||||
channelId?: string | null;
|
||||
isLoading: boolean;
|
||||
messages: TimelineMessage[];
|
||||
onTargetReached?: (messageId: string) => void;
|
||||
scrollContainerRef: React.RefObject<HTMLDivElement | null>;
|
||||
targetMessageId?: string | null;
|
||||
};
|
||||
|
||||
type PinToBottomOptions = {
|
||||
clearNewMessageCount?: boolean;
|
||||
};
|
||||
|
||||
export function useTimelineScrollManager({
|
||||
channelId,
|
||||
isLoading,
|
||||
messages,
|
||||
onTargetReached,
|
||||
scrollContainerRef,
|
||||
targetMessageId,
|
||||
}: UseTimelineScrollManagerOptions) {
|
||||
const timelineRef = scrollContainerRef;
|
||||
const contentRef = React.useRef<HTMLDivElement>(null);
|
||||
const bottomAnchorRef = React.useRef<HTMLDivElement>(null);
|
||||
const hasInitializedRef = React.useRef(false);
|
||||
const shouldStickToBottomRef = React.useRef(true);
|
||||
const isAtBottomRef = React.useRef(true);
|
||||
const isProgrammaticBottomScrollRef = React.useRef(false);
|
||||
const previousTimelineHeightRef = React.useRef<number | null>(null);
|
||||
const previousScrollTopRef = React.useRef(0);
|
||||
const lockedScrollTopRef = React.useRef<number | null>(null);
|
||||
const previousLastMessageKeyRef = React.useRef<string | undefined>(undefined);
|
||||
const previousMessageCountRef = React.useRef(0);
|
||||
const handledTargetMessageIdRef = React.useRef<string | null>(null);
|
||||
const scrollToBottomOnNextUpdateRef = React.useRef(false);
|
||||
// Mirror isLoading into a ref so the ResizeObservers (which subscribe once)
|
||||
// can skip reacting while the skeleton is up — reacting to height churn under
|
||||
// a streaming-in list is what makes the timeline thrash on entry.
|
||||
const isLoadingRef = React.useRef(isLoading);
|
||||
isLoadingRef.current = isLoading;
|
||||
const [isAtBottom, setIsAtBottom] = React.useState(true);
|
||||
const [highlightedMessageId, setHighlightedMessageId] = React.useState<
|
||||
string | null
|
||||
>(null);
|
||||
const [newMessageCount, setNewMessageCount] = React.useState(0);
|
||||
|
||||
const resetScrollTracking = React.useCallback(() => {
|
||||
hasInitializedRef.current = false;
|
||||
shouldStickToBottomRef.current = true;
|
||||
isAtBottomRef.current = true;
|
||||
isProgrammaticBottomScrollRef.current = false;
|
||||
previousTimelineHeightRef.current = null;
|
||||
previousScrollTopRef.current = 0;
|
||||
lockedScrollTopRef.current = null;
|
||||
previousLastMessageKeyRef.current = undefined;
|
||||
previousMessageCountRef.current = 0;
|
||||
handledTargetMessageIdRef.current = null;
|
||||
scrollToBottomOnNextUpdateRef.current = false;
|
||||
setIsAtBottom(true);
|
||||
setHighlightedMessageId(null);
|
||||
setNewMessageCount(0);
|
||||
}, []);
|
||||
|
||||
const pinToBottom = React.useCallback(
|
||||
({ clearNewMessageCount = false }: PinToBottomOptions = {}) => {
|
||||
shouldStickToBottomRef.current = true;
|
||||
isAtBottomRef.current = true;
|
||||
setIsAtBottom((current) => (current ? current : true));
|
||||
|
||||
if (clearNewMessageCount) {
|
||||
setNewMessageCount(0);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const setObservedBottomState = React.useCallback((atBottom: boolean) => {
|
||||
shouldStickToBottomRef.current = atBottom;
|
||||
isAtBottomRef.current = atBottom;
|
||||
setIsAtBottom((current) => (current === atBottom ? current : atBottom));
|
||||
|
||||
if (atBottom) {
|
||||
setNewMessageCount(0);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const unpinFromBottom = React.useCallback((scrollTop: number) => {
|
||||
shouldStickToBottomRef.current = false;
|
||||
isAtBottomRef.current = false;
|
||||
isProgrammaticBottomScrollRef.current = false;
|
||||
previousScrollTopRef.current = scrollTop;
|
||||
setIsAtBottom(false);
|
||||
}, []);
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: channelId is intentionally the sole trigger — we reset all scroll state when the channel changes
|
||||
React.useLayoutEffect(() => {
|
||||
resetScrollTracking();
|
||||
}, [channelId, resetScrollTracking]);
|
||||
|
||||
const latestMessage =
|
||||
messages.length > 0 ? messages[messages.length - 1] : undefined;
|
||||
const latestMessageKey = selectLatestMessageKey(messages);
|
||||
|
||||
const scrollToBottomOnNextUpdate = React.useCallback(() => {
|
||||
scrollToBottomOnNextUpdateRef.current = true;
|
||||
}, []);
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: timelineRef is a stable React ref passed from the parent — its identity never changes
|
||||
const syncScrollState = React.useCallback(() => {
|
||||
const timeline = timelineRef.current;
|
||||
if (!timeline) {
|
||||
return;
|
||||
}
|
||||
|
||||
const scrollTop = lockedScrollTopRef.current ?? timeline.scrollTop;
|
||||
const atBottom = isNearBottom(timeline);
|
||||
const movedAwayFromBottom = scrollTop + 1 < previousScrollTopRef.current;
|
||||
|
||||
if (isProgrammaticBottomScrollRef.current) {
|
||||
previousScrollTopRef.current = scrollTop;
|
||||
|
||||
if (movedAwayFromBottom) {
|
||||
isProgrammaticBottomScrollRef.current = false;
|
||||
} else if (!atBottom) {
|
||||
pinToBottom();
|
||||
return;
|
||||
} else {
|
||||
isProgrammaticBottomScrollRef.current = false;
|
||||
pinToBottom({ clearNewMessageCount: true });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldStickToBottomRef.current && !atBottom && !movedAwayFromBottom) {
|
||||
previousScrollTopRef.current = scrollTop;
|
||||
pinToBottom({ clearNewMessageCount: true });
|
||||
return;
|
||||
}
|
||||
|
||||
previousScrollTopRef.current = scrollTop;
|
||||
setObservedBottomState(atBottom);
|
||||
}, [pinToBottom, setObservedBottomState]);
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: timelineRef is a stable React ref — its identity never changes
|
||||
const restoreScrollPosition = React.useCallback(
|
||||
(scrollTop: number) => {
|
||||
const timeline = timelineRef.current;
|
||||
|
||||
if (!timeline) {
|
||||
return;
|
||||
}
|
||||
|
||||
isProgrammaticBottomScrollRef.current = false;
|
||||
lockedScrollTopRef.current = scrollTop;
|
||||
|
||||
const restore = (remainingFrames: number) => {
|
||||
timeline.scrollTop = scrollTop;
|
||||
|
||||
if (remainingFrames > 0) {
|
||||
requestAnimationFrame(() => {
|
||||
restore(remainingFrames - 1);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
lockedScrollTopRef.current = null;
|
||||
previousScrollTopRef.current = timeline.scrollTop;
|
||||
syncScrollState();
|
||||
};
|
||||
|
||||
restore(2);
|
||||
},
|
||||
[syncScrollState],
|
||||
);
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: timelineRef is a stable React ref — its identity never changes
|
||||
const scrollToBottom = React.useCallback(
|
||||
(behavior: ScrollBehavior) => {
|
||||
const timeline = timelineRef.current;
|
||||
|
||||
if (!timeline) {
|
||||
return;
|
||||
}
|
||||
|
||||
isProgrammaticBottomScrollRef.current = true;
|
||||
|
||||
const alignToBottom = (nextBehavior: ScrollBehavior) => {
|
||||
bottomAnchorRef.current?.scrollIntoView({
|
||||
block: "end",
|
||||
behavior: nextBehavior,
|
||||
});
|
||||
timeline.scrollTo({
|
||||
top: timeline.scrollHeight,
|
||||
behavior: nextBehavior,
|
||||
});
|
||||
};
|
||||
|
||||
alignToBottom(behavior);
|
||||
lockedScrollTopRef.current = null;
|
||||
previousScrollTopRef.current = timeline.scrollTop;
|
||||
pinToBottom({ clearNewMessageCount: true });
|
||||
|
||||
if (behavior === "smooth") {
|
||||
requestAnimationFrame(() => {
|
||||
previousScrollTopRef.current = timeline.scrollTop;
|
||||
syncScrollState();
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const settleAlignment = (remainingFrames: number) => {
|
||||
requestAnimationFrame(() => {
|
||||
alignToBottom("auto");
|
||||
previousScrollTopRef.current = timeline.scrollTop;
|
||||
|
||||
if (remainingFrames > 0) {
|
||||
settleAlignment(remainingFrames - 1);
|
||||
return;
|
||||
}
|
||||
|
||||
syncScrollState();
|
||||
});
|
||||
};
|
||||
|
||||
settleAlignment(2);
|
||||
},
|
||||
[pinToBottom, syncScrollState],
|
||||
);
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: timelineRef is a stable React ref — its identity never changes
|
||||
React.useEffect(() => {
|
||||
const timeline = timelineRef.current;
|
||||
|
||||
if (!timeline || typeof ResizeObserver === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
previousTimelineHeightRef.current = timeline.clientHeight;
|
||||
previousScrollTopRef.current = timeline.scrollTop;
|
||||
|
||||
const observer = new ResizeObserver(([entry]) => {
|
||||
const previousTimelineHeight = previousTimelineHeightRef.current;
|
||||
const nextTimelineHeight = entry.contentRect.height;
|
||||
previousTimelineHeightRef.current = nextTimelineHeight;
|
||||
|
||||
// Track height while loading, but don't scroll — the init layout-effect
|
||||
// owns the first scroll once content settles.
|
||||
if (isLoadingRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
previousTimelineHeight === null ||
|
||||
Math.abs(nextTimelineHeight - previousTimelineHeight) < 1
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (shouldStickToBottomRef.current || isAtBottomRef.current) {
|
||||
scrollToBottom("auto");
|
||||
return;
|
||||
}
|
||||
|
||||
restoreScrollPosition(previousScrollTopRef.current);
|
||||
});
|
||||
|
||||
observer.observe(timeline);
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
};
|
||||
}, [restoreScrollPosition, scrollToBottom]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const content = contentRef.current;
|
||||
|
||||
if (!content || typeof ResizeObserver === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
const observer = new ResizeObserver(() => {
|
||||
if (isLoadingRef.current) {
|
||||
return;
|
||||
}
|
||||
if (shouldStickToBottomRef.current) {
|
||||
scrollToBottom("auto");
|
||||
return;
|
||||
}
|
||||
|
||||
syncScrollState();
|
||||
});
|
||||
|
||||
observer.observe(content);
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
};
|
||||
}, [scrollToBottom, syncScrollState]);
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
if (!hasInitializedRef.current) {
|
||||
if (isLoading) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (targetMessageId) {
|
||||
const timeline = timelineRef.current;
|
||||
unpinFromBottom(timeline?.scrollTop ?? 0);
|
||||
} else {
|
||||
scrollToBottom("auto");
|
||||
}
|
||||
hasInitializedRef.current = true;
|
||||
previousLastMessageKeyRef.current = latestMessageKey;
|
||||
previousMessageCountRef.current = messages.length;
|
||||
return;
|
||||
}
|
||||
|
||||
const previousLastMessageKey = previousLastMessageKeyRef.current;
|
||||
const previousMessageCount = previousMessageCountRef.current;
|
||||
const hasNewLatestMessage =
|
||||
latestMessage !== undefined &&
|
||||
latestMessageKey !== previousLastMessageKey;
|
||||
|
||||
if (!hasNewLatestMessage) {
|
||||
previousLastMessageKeyRef.current = latestMessageKey;
|
||||
previousMessageCountRef.current = messages.length;
|
||||
return;
|
||||
}
|
||||
|
||||
const shouldHonorExplicitBottomRequest =
|
||||
scrollToBottomOnNextUpdateRef.current;
|
||||
scrollToBottomOnNextUpdateRef.current = false;
|
||||
|
||||
const autoScrollBehavior = selectLatestMessageAutoScrollBehavior({
|
||||
hasExplicitBottomRequest: shouldHonorExplicitBottomRequest,
|
||||
isAtBottom: isAtBottomRef.current,
|
||||
shouldStickToBottom: shouldStickToBottomRef.current,
|
||||
targetMessageId,
|
||||
});
|
||||
|
||||
if (autoScrollBehavior) {
|
||||
scrollToBottom(autoScrollBehavior);
|
||||
} else {
|
||||
setNewMessageCount((current) => {
|
||||
const addedMessages = Math.max(
|
||||
1,
|
||||
messages.length - previousMessageCount,
|
||||
);
|
||||
return current + addedMessages;
|
||||
});
|
||||
}
|
||||
|
||||
previousLastMessageKeyRef.current = latestMessageKey;
|
||||
previousMessageCountRef.current = messages.length;
|
||||
}, [
|
||||
isLoading,
|
||||
latestMessage,
|
||||
latestMessageKey,
|
||||
messages.length,
|
||||
scrollToBottom,
|
||||
targetMessageId,
|
||||
timelineRef,
|
||||
unpinFromBottom,
|
||||
]);
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: timelineRef is a stable React ref — its identity never changes
|
||||
const scrollToMessage = React.useCallback(
|
||||
(messageId: string) => {
|
||||
const timeline = timelineRef.current;
|
||||
if (!timeline) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const targetElement = timeline.querySelector<HTMLElement>(
|
||||
`[data-message-id="${messageId}"]`,
|
||||
);
|
||||
if (!targetElement) {
|
||||
return false;
|
||||
}
|
||||
|
||||
unpinFromBottom(timeline.scrollTop);
|
||||
setHighlightedMessageId(messageId);
|
||||
setNewMessageCount(0);
|
||||
|
||||
const alignToTarget = (remainingFrames: number) => {
|
||||
targetElement.scrollIntoView({
|
||||
block: "center",
|
||||
behavior: "auto",
|
||||
});
|
||||
previousScrollTopRef.current = timeline.scrollTop;
|
||||
|
||||
if (remainingFrames > 0) {
|
||||
requestAnimationFrame(() => {
|
||||
alignToTarget(remainingFrames - 1);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
onTargetReached?.(messageId);
|
||||
};
|
||||
|
||||
alignToTarget(2);
|
||||
|
||||
window.setTimeout(() => {
|
||||
setHighlightedMessageId((current) =>
|
||||
current === messageId ? null : current,
|
||||
);
|
||||
}, 2_000);
|
||||
|
||||
return true;
|
||||
},
|
||||
[onTargetReached, unpinFromBottom],
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!targetMessageId) {
|
||||
handledTargetMessageIdRef.current = null;
|
||||
setHighlightedMessageId(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (handledTargetMessageIdRef.current === targetMessageId || isLoading) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Deep-link decision delegated to a pure, lib-tested helper: only attempt the
|
||||
// jump once the target actually exists in THIS (deferred) snapshot. If it
|
||||
// doesn't, the row hasn't committed yet — bail and let the next snapshot that
|
||||
// includes it drive the jump. This reads the same `messages` snapshot the
|
||||
// list rendered, which closes the tearing race.
|
||||
if (!resolveDeepLinkTarget(messages, targetMessageId).resolved) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!scrollToMessage(targetMessageId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
handledTargetMessageIdRef.current = targetMessageId;
|
||||
}, [isLoading, messages, scrollToMessage, targetMessageId]);
|
||||
|
||||
return {
|
||||
bottomAnchorRef,
|
||||
contentRef,
|
||||
highlightedMessageId,
|
||||
isAtBottom,
|
||||
newMessageCount,
|
||||
restoreScrollPosition,
|
||||
scrollToBottom,
|
||||
scrollToBottomOnNextUpdate,
|
||||
scrollToMessage,
|
||||
syncScrollState,
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { countTopLevelTimelineRows } from "@/features/messages/lib/formatTimelineMessages";
|
||||
import {
|
||||
channelMessagesKey,
|
||||
mergeTimelineHistoryMessages,
|
||||
@@ -10,6 +11,14 @@ import type { Channel, RelayEvent } from "@/shared/api/types";
|
||||
|
||||
const OLDER_MESSAGES_BATCH_SIZE = 100;
|
||||
|
||||
// One scroll-up should advance the timeline by a predictable, *visible* amount.
|
||||
// Because thread replies collapse into their parent and non-content events
|
||||
// never render, a single 100-message batch can add far fewer rows than that —
|
||||
// so we page in additional batches until at least this many top-level rows have
|
||||
// been added (or history runs out). Counting rows, not messages, keeps a
|
||||
// reply-heavy window from feeling like the fetch did nothing.
|
||||
const MIN_TOP_LEVEL_ROWS_PER_FETCH = 10;
|
||||
|
||||
export function useFetchOlderMessages(channel: Channel | null) {
|
||||
const queryClient = useQueryClient();
|
||||
const channelId = channel?.id ?? null;
|
||||
@@ -43,39 +52,62 @@ export function useFetchOlderMessages(channel: Channel | null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Use the oldest timestamp directly — `until` is inclusive so the relay will
|
||||
// return the boundary message again, but `sortMessages` deduplicates by id.
|
||||
// Subtracting 1 risks skipping messages that share the same second.
|
||||
const oldestTimestamp = currentMessages[0].created_at;
|
||||
isFetchingOlderRef.current = true;
|
||||
setIsFetchingOlder(true);
|
||||
|
||||
// Page in batches until the timeline has gained at least
|
||||
// MIN_TOP_LEVEL_ROWS_PER_FETCH *visible* rows or history is exhausted.
|
||||
// A single batch is fetched first; only reply-heavy windows that fall short
|
||||
// of the floor loop again. Each batch re-reads the oldest timestamp from the
|
||||
// cache so successive `until` values keep walking backward without gaps.
|
||||
const baselineRowCount = countTopLevelTimelineRows(currentMessages);
|
||||
try {
|
||||
const olderMessages = await relayClient.fetchChannelHistoryBefore(
|
||||
channelId,
|
||||
oldestTimestamp,
|
||||
OLDER_MESSAGES_BATCH_SIZE,
|
||||
);
|
||||
while (hasOlderMessagesRef.current) {
|
||||
const messagesBeforeFetch =
|
||||
queryClient.getQueryData<RelayEvent[]>(queryKey) ?? [];
|
||||
if (messagesBeforeFetch.length === 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (olderMessages.length < OLDER_MESSAGES_BATCH_SIZE) {
|
||||
hasOlderMessagesRef.current = false;
|
||||
setHasOlderMessages(false);
|
||||
}
|
||||
|
||||
if (olderMessages.length > 0) {
|
||||
queryClient.setQueryData<RelayEvent[]>(queryKey, (current = []) =>
|
||||
mergeTimelineHistoryMessages(current, olderMessages),
|
||||
// Use the oldest timestamp directly — `until` is inclusive so the relay
|
||||
// will return the boundary message again, but `sortMessages`
|
||||
// deduplicates by id. Subtracting 1 risks skipping messages that share
|
||||
// the same second.
|
||||
const oldestTimestamp = messagesBeforeFetch[0].created_at;
|
||||
const olderMessages = await relayClient.fetchChannelHistoryBefore(
|
||||
channelId,
|
||||
oldestTimestamp,
|
||||
OLDER_MESSAGES_BATCH_SIZE,
|
||||
);
|
||||
|
||||
const updatedMessages =
|
||||
queryClient.getQueryData<RelayEvent[]>(queryKey) ?? [];
|
||||
if (
|
||||
updatedMessages.length > 0 &&
|
||||
updatedMessages[0].created_at === oldestTimestamp
|
||||
) {
|
||||
if (olderMessages.length < OLDER_MESSAGES_BATCH_SIZE) {
|
||||
hasOlderMessagesRef.current = false;
|
||||
setHasOlderMessages(false);
|
||||
}
|
||||
|
||||
if (olderMessages.length > 0) {
|
||||
queryClient.setQueryData<RelayEvent[]>(queryKey, (current = []) =>
|
||||
mergeTimelineHistoryMessages(current, olderMessages),
|
||||
);
|
||||
|
||||
const updatedMessages =
|
||||
queryClient.getQueryData<RelayEvent[]>(queryKey) ?? [];
|
||||
if (
|
||||
updatedMessages.length > 0 &&
|
||||
updatedMessages[0].created_at === oldestTimestamp
|
||||
) {
|
||||
hasOlderMessagesRef.current = false;
|
||||
setHasOlderMessages(false);
|
||||
}
|
||||
}
|
||||
|
||||
const rowsGained =
|
||||
countTopLevelTimelineRows(
|
||||
queryClient.getQueryData<RelayEvent[]>(queryKey) ?? [],
|
||||
) - baselineRowCount;
|
||||
if (rowsGained >= MIN_TOP_LEVEL_ROWS_PER_FETCH) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch older messages", channelId, error);
|
||||
|
||||
@@ -144,6 +144,32 @@ function aspectRatioFromDim(dim?: string): number | undefined {
|
||||
return width / height;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a NIP-92 `dim` value ("WxH") into intrinsic pixel dimensions. Used to
|
||||
* stamp explicit `width`/`height` attributes on inline images so the browser
|
||||
* reserves aspect-ratio-correct layout space *before* the image decodes. This
|
||||
* is what keeps the timeline from jumping when a tall image loads late — the
|
||||
* row's height is known at first paint instead of growing from ~0 on load.
|
||||
*/
|
||||
function dimensionsFromDim(
|
||||
dim?: string,
|
||||
): { width: number; height: number } | undefined {
|
||||
if (!dim) return undefined;
|
||||
const match = dim.match(/^(\d+)x(\d+)$/i);
|
||||
if (!match) return undefined;
|
||||
const width = Number(match[1]);
|
||||
const height = Number(match[2]);
|
||||
if (
|
||||
!Number.isFinite(width) ||
|
||||
!Number.isFinite(height) ||
|
||||
width <= 0 ||
|
||||
height <= 0
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
return { width, height };
|
||||
}
|
||||
|
||||
function isInsideHiddenSpoiler(element: Element): boolean {
|
||||
return (
|
||||
element.closest('.buzz-spoiler[data-spoiler][data-revealed="false"]') !==
|
||||
@@ -1004,10 +1030,12 @@ function ImageZoomOverlay({
|
||||
*/
|
||||
function ImageBlock({
|
||||
alt,
|
||||
dim,
|
||||
resolvedSrc,
|
||||
src,
|
||||
}: {
|
||||
alt: string | undefined;
|
||||
dim?: string;
|
||||
resolvedSrc: string | undefined;
|
||||
src: string | undefined;
|
||||
}) {
|
||||
@@ -1093,6 +1121,12 @@ function ImageBlock({
|
||||
const closeMenu = React.useCallback(() => setMenu(null), []);
|
||||
useDismissImageContextMenu(Boolean(menu), closeMenu);
|
||||
|
||||
// Intrinsic dimensions from the NIP-92 `dim` tag, stamped as width/height
|
||||
// attributes so the browser reserves aspect-ratio space before the image
|
||||
// decodes. Without this the row grows from ~0 on load and shoves the
|
||||
// timeline — the exact reflow the anchored-scroll restore then has to fight.
|
||||
const intrinsicDimensions = dimensionsFromDim(dim);
|
||||
|
||||
const handleContextMenu = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
if (isInsideHiddenSpoiler(e.currentTarget)) return;
|
||||
@@ -1153,9 +1187,11 @@ function ImageBlock({
|
||||
alt={alt}
|
||||
className="block max-h-64 max-w-sm rounded-xl object-contain"
|
||||
data-spoiler-media-size={hiddenSpoilerMediaSize ? "" : undefined}
|
||||
height={intrinsicDimensions?.height}
|
||||
ref={imageRef}
|
||||
src={resolvedSrc}
|
||||
style={spoilerMediaStyle}
|
||||
width={intrinsicDimensions?.width}
|
||||
onLoad={(event) => updateSpoilerMediaSize(event.currentTarget)}
|
||||
/>
|
||||
</button>
|
||||
@@ -1860,9 +1896,15 @@ function createMarkdownComponents(
|
||||
</span>
|
||||
);
|
||||
}
|
||||
const entry = src ? imetaByUrl?.get(src) : undefined;
|
||||
return (
|
||||
<span data-block-media="" className="block">
|
||||
<ImageBlock alt={alt} resolvedSrc={resolvedSrc} src={src} />
|
||||
<ImageBlock
|
||||
alt={alt}
|
||||
dim={entry?.dim}
|
||||
resolvedSrc={resolvedSrc}
|
||||
src={src}
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
},
|
||||
@@ -2094,7 +2136,7 @@ function MarkdownInner({
|
||||
(link: ParsedMessageLink) => {
|
||||
// Always route through `goChannel` with `messageId` set: the channel
|
||||
// route already handles scroll-into-view + highlight via
|
||||
// `useTimelineScrollManager` + `getEventById` backfill, and works for
|
||||
// `useAnchoredScroll` + `getEventById` backfill, and works for
|
||||
// both stream-message replies and forum threads. Detecting "the thread
|
||||
// root is a forum post" up front would require an event lookup we don't
|
||||
// currently have synchronously; the brief explicitly allows skipping
|
||||
|
||||
@@ -72,6 +72,9 @@ type E2eConfig = {
|
||||
feedReadError?: string;
|
||||
canvasReadError?: string;
|
||||
openDmDelayMs?: number;
|
||||
/** Delay (ms) applied to older-history (`history-` subId) fetches so e2e
|
||||
* tests can observe the in-flight prepend window. 0/undefined = instant. */
|
||||
historyDelayMs?: number;
|
||||
profileReadDelayMs?: number;
|
||||
profileReadError?: string;
|
||||
profileUpdateError?: string;
|
||||
@@ -593,6 +596,17 @@ declare global {
|
||||
extraTags?: string[][];
|
||||
createdAt?: number;
|
||||
}) => RelayEvent;
|
||||
/** Prepend `count` synthetic older messages to a channel's mock store so
|
||||
* an older-history fetch has something to paginate. Mirrors how the real
|
||||
* relay backfills history. Returns the created events. */
|
||||
__BUZZ_E2E_PREPEND_MOCK_HISTORY__?: (input: {
|
||||
channelName: string;
|
||||
count: number;
|
||||
startIndex?: number;
|
||||
lineCount?: number;
|
||||
createdAtStart?: number;
|
||||
emit?: boolean;
|
||||
}) => RelayEvent[];
|
||||
__BUZZ_E2E_EMIT_MOCK_TYPING__?: (input: {
|
||||
channelName: string;
|
||||
pubkey?: string;
|
||||
@@ -2267,6 +2281,60 @@ function getMockMessageStore(channelId: string): RelayEvent[] {
|
||||
return seeded;
|
||||
}
|
||||
|
||||
function prependMockHistory(input: {
|
||||
channelName: string;
|
||||
count: number;
|
||||
startIndex?: number;
|
||||
lineCount?: number;
|
||||
createdAtStart?: number;
|
||||
emit?: boolean;
|
||||
}) {
|
||||
const channel = mockChannels.find(
|
||||
(candidate) => candidate.name === input.channelName,
|
||||
);
|
||||
if (!channel) {
|
||||
throw new Error(`Unknown mock channel: ${input.channelName}`);
|
||||
}
|
||||
|
||||
const store = getMockMessageStore(channel.id);
|
||||
const earliestCreatedAt = store.reduce(
|
||||
(earliest, event) => Math.min(earliest, event.created_at),
|
||||
Math.floor(Date.now() / 1000),
|
||||
);
|
||||
const createdAtStart =
|
||||
input.createdAtStart ?? earliestCreatedAt - input.count - 1;
|
||||
const startIndex = input.startIndex ?? 0;
|
||||
const lineCount = input.lineCount ?? 1;
|
||||
|
||||
const events = Array.from({ length: input.count }, (_, offset) => {
|
||||
const index = startIndex + offset;
|
||||
const body = Array.from(
|
||||
{ length: lineCount },
|
||||
(_unused, lineIndex) => `mock older ${index} line ${lineIndex + 1}`,
|
||||
).join("\n");
|
||||
|
||||
return createMockEvent(
|
||||
9,
|
||||
body,
|
||||
[["h", channel.id]],
|
||||
ALICE_PUBKEY,
|
||||
createdAtStart + offset,
|
||||
`mock-older-${channel.name}-${index}`.replace(/[^a-zA-Z0-9]/g, ""),
|
||||
);
|
||||
});
|
||||
|
||||
store.unshift(...events);
|
||||
store.sort((left, right) => left.created_at - right.created_at);
|
||||
|
||||
if (input.emit) {
|
||||
for (const event of events) {
|
||||
emitMockLiveEvent(channel.id, event);
|
||||
}
|
||||
}
|
||||
|
||||
return events;
|
||||
}
|
||||
|
||||
function emitMockHistory(
|
||||
socket: MockSocket,
|
||||
subId: string,
|
||||
@@ -2290,10 +2358,24 @@ function emitMockHistory(
|
||||
.slice(0, filter.limit ?? 50)
|
||||
.sort((left, right) => left.created_at - right.created_at);
|
||||
|
||||
for (const event of events) {
|
||||
sendWsText(socket.handler, ["EVENT", subId, event]);
|
||||
const emit = () => {
|
||||
for (const event of events) {
|
||||
sendWsText(socket.handler, ["EVENT", subId, event]);
|
||||
}
|
||||
sendWsText(socket.handler, ["EOSE", subId]);
|
||||
};
|
||||
|
||||
// Optionally pace older-history fetches so e2e tests can observe the
|
||||
// in-flight prepend window (scroll up, abandon, etc.). Scoped to
|
||||
// `history-` subscriptions — the prefix `relayClientSession` uses for
|
||||
// older-message pagination — so live/initial subscriptions stay instant.
|
||||
const delayMs = getConfig()?.mock?.historyDelayMs ?? 0;
|
||||
if (delayMs > 0 && subId.startsWith("history-")) {
|
||||
window.setTimeout(emit, delayMs);
|
||||
return;
|
||||
}
|
||||
sendWsText(socket.handler, ["EOSE", subId]);
|
||||
|
||||
emit();
|
||||
}
|
||||
|
||||
function emitMockLiveEvent(channelId: string, event: RelayEvent) {
|
||||
@@ -5628,7 +5710,7 @@ async function handleGetEvent(
|
||||
"bb22a5299220cad76ffd46190ccbeede8ab5dc260faa28b6e5a2cb31b9aff260",
|
||||
created_at: Math.floor(Date.now() / 1000) - 42 * 60,
|
||||
kind: 9,
|
||||
tags: [["e", "1c7e1c02-87bb-5e88-b2da-5a7a9432d0c9"]],
|
||||
tags: [["h", "1c7e1c02-87bb-5e88-b2da-5a7a9432d0c9"]],
|
||||
content: "Engineering shipped the desktop build.",
|
||||
sig: "mocksig".repeat(20).slice(0, 128),
|
||||
},
|
||||
@@ -6049,6 +6131,7 @@ export function maybeInstallE2eTauriMocks() {
|
||||
createdAt,
|
||||
);
|
||||
};
|
||||
window.__BUZZ_E2E_PREPEND_MOCK_HISTORY__ = prependMockHistory;
|
||||
window.__BUZZ_E2E_EMIT_MOCK_TYPING__ = ({ channelName, pubkey }) => {
|
||||
const channel = mockChannels.find(
|
||||
(candidate) => candidate.name === channelName,
|
||||
|
||||
1050
desktop/tests/e2e/scroll-history.spec.ts
Normal file
1050
desktop/tests/e2e/scroll-history.spec.ts
Normal file
File diff suppressed because it is too large
Load Diff
311
desktop/tests/e2e/scroll-smoothness.perf.ts
Normal file
311
desktop/tests/e2e/scroll-smoothness.perf.ts
Normal file
@@ -0,0 +1,311 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
import { installMockBridge } from "../helpers/bridge";
|
||||
|
||||
/**
|
||||
* Scroll-smoothness measurement harness.
|
||||
*
|
||||
* This is NOT a pass/fail correctness test — it's an instrument. It seeds a
|
||||
* busy channel, mounts the full history into the DOM, drives a synthetic
|
||||
* fast-wheel scroll up through it, and measures the *main-thread layout work*
|
||||
* that scroll triggers — the cost the headless correctness suite cannot feel
|
||||
* (it polls only the final position).
|
||||
*
|
||||
* WHY LAYOUT COST, NOT FRAME CADENCE: under Playwright's headed Chromium there
|
||||
* is no vsync throttle, so requestAnimationFrame fires far faster than a real
|
||||
* 60Hz display and "frame interval" tells you nothing about paint jank. The
|
||||
* honest, engine-agnostic signal is the cumulative layout / style-recalc time
|
||||
* Chromium spends servicing the scroll burst, read via CDP Performance metrics.
|
||||
* More live DOM rows + per-row reflow on scroll == more layout cost == the jank
|
||||
* a user feels. That is exactly the axis virtualization / content-visibility
|
||||
* attack, so it's the axis worth a baseline.
|
||||
*
|
||||
* SCOPE LIMIT (stated honestly): this measures Chromium's main-thread layout
|
||||
* cost. It does NOT measure the WKWebView compositor / stale-scrollTop race
|
||||
* Sami flagged as the dominant *feel* hazard on the shipped Tauri app — that
|
||||
* only reproduces in the real desktop shell and is Tyler's real-wheel pass.
|
||||
*
|
||||
* Run headed to watch it:
|
||||
* pnpm build && npx playwright test --config=playwright.perf.config.ts --headed
|
||||
*/
|
||||
|
||||
const SEED_ROWS = 600; // a genuinely busy channel, fully mounted
|
||||
const LINES_PER_ROW = 3;
|
||||
|
||||
type Sample = {
|
||||
rowCount: number;
|
||||
scrollSpan: number;
|
||||
frames: number;
|
||||
};
|
||||
|
||||
type Metrics = { layoutMs: number; recalcMs: number; layoutCount: number };
|
||||
|
||||
async function readMetrics(
|
||||
client: import("@playwright/test").CDPSession,
|
||||
): Promise<Metrics> {
|
||||
const { metrics } = (await client.send("Performance.getMetrics")) as {
|
||||
metrics: Array<{ name: string; value: number }>;
|
||||
};
|
||||
const m = (name: string) => metrics.find((x) => x.name === name)?.value ?? 0;
|
||||
return {
|
||||
// CDP reports durations in seconds; convert to ms.
|
||||
layoutMs: m("LayoutDuration") * 1000,
|
||||
recalcMs: m("RecalcStyleDuration") * 1000,
|
||||
layoutCount: m("LayoutCount"),
|
||||
};
|
||||
}
|
||||
|
||||
test("MEASURE: fast-wheel scroll-up layout cost on a busy un-virtualized timeline", async ({
|
||||
page,
|
||||
}) => {
|
||||
await installMockBridge(page);
|
||||
await page.goto("/");
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
typeof window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function" &&
|
||||
typeof window.__BUZZ_E2E_PREPEND_MOCK_HISTORY__ === "function",
|
||||
);
|
||||
|
||||
await page.evaluate(
|
||||
({ rows, lines }) => {
|
||||
// Emit the entire busy channel as live messages so the FULL list mounts
|
||||
// into the DOM up front — no dependence on the app's fetch-older
|
||||
// pagination (which caps per-request and is a separate axis). This is the
|
||||
// un-virtualized DOM we're here to stress.
|
||||
for (let i = 0; i < rows; i += 1) {
|
||||
const body = Array.from(
|
||||
{ length: lines },
|
||||
(_u, l) => `busy row ${i} line ${l + 1}`,
|
||||
).join("\n");
|
||||
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
|
||||
channelName: "general",
|
||||
content: body,
|
||||
});
|
||||
}
|
||||
},
|
||||
{ rows: SEED_ROWS, lines: LINES_PER_ROW },
|
||||
);
|
||||
|
||||
await page.getByTestId("channel-general").click();
|
||||
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
||||
const timeline = page.getByTestId("message-timeline");
|
||||
|
||||
// Confirm the list is mounted before measuring. Capture the actual mounted
|
||||
// count — we don't assume all SEED_ROWS render (the app may cap/window).
|
||||
await page.waitForFunction(() => {
|
||||
const el = document.querySelector(
|
||||
'[data-testid="message-timeline"]',
|
||||
) as HTMLDivElement | null;
|
||||
return !!el && el.querySelectorAll("[data-message-id]").length > 50;
|
||||
});
|
||||
await page.waitForTimeout(500); // let live emits settle
|
||||
|
||||
const client = await page.context().newCDPSession(page);
|
||||
await client.send("Performance.enable");
|
||||
|
||||
// Reset to bottom, settle, then snapshot metrics around the scroll burst.
|
||||
await timeline.evaluate((element) => {
|
||||
const el = element as HTMLDivElement;
|
||||
el.scrollTop = el.scrollHeight;
|
||||
el.dispatchEvent(new Event("scroll", { bubbles: true }));
|
||||
});
|
||||
await page.waitForTimeout(100);
|
||||
|
||||
const before = await readMetrics(client);
|
||||
|
||||
const sample = await timeline.evaluate(async (element): Promise<Sample> => {
|
||||
const el = element as HTMLDivElement;
|
||||
el.scrollTop = el.scrollHeight;
|
||||
const startTop = el.scrollTop;
|
||||
let frames = 0;
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
let elapsed = 0;
|
||||
let last = performance.now();
|
||||
const DURATION_MS = 2_000;
|
||||
const PX_PER_FRAME = 50; // brisk human flick
|
||||
|
||||
const step = (now: number) => {
|
||||
elapsed += now - last;
|
||||
last = now;
|
||||
frames += 1;
|
||||
el.dispatchEvent(
|
||||
new WheelEvent("wheel", {
|
||||
deltaY: -PX_PER_FRAME,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
}),
|
||||
);
|
||||
el.scrollTop = Math.max(0, el.scrollTop - PX_PER_FRAME);
|
||||
el.dispatchEvent(new Event("scroll", { bubbles: true }));
|
||||
if (elapsed < DURATION_MS && el.scrollTop > 0) {
|
||||
requestAnimationFrame(step);
|
||||
} else {
|
||||
resolve();
|
||||
}
|
||||
};
|
||||
requestAnimationFrame(step);
|
||||
});
|
||||
|
||||
return {
|
||||
rowCount: el.querySelectorAll("[data-message-id]").length,
|
||||
scrollSpan: startTop - el.scrollTop,
|
||||
frames,
|
||||
};
|
||||
});
|
||||
|
||||
const after = await readMetrics(client);
|
||||
await client.send("Performance.disable");
|
||||
|
||||
const layoutDelta = after.layoutMs - before.layoutMs;
|
||||
const recalcDelta = after.recalcMs - before.recalcMs;
|
||||
const layoutCountDelta = after.layoutCount - before.layoutCount;
|
||||
const perScroll = sample.frames > 0 ? sample.frames : 1;
|
||||
|
||||
/* eslint-disable no-console */
|
||||
console.log("\n=== SCROLL SMOOTHNESS BASELINE (Chromium layout cost) ===");
|
||||
console.log(`rows mounted (live DOM): ${sample.rowCount}`);
|
||||
console.log(
|
||||
`scroll span covered: ${Math.round(sample.scrollSpan)}px`,
|
||||
);
|
||||
console.log(`scroll frames driven: ${sample.frames}`);
|
||||
console.log(`layout time over burst: ${layoutDelta.toFixed(1)}ms`);
|
||||
console.log(`style-recalc time over burst: ${recalcDelta.toFixed(1)}ms`);
|
||||
console.log(`forced layouts (count delta): ${layoutCountDelta}`);
|
||||
console.log(
|
||||
`avg layout+recalc per frame: ${((layoutDelta + recalcDelta) / perScroll).toFixed(3)}ms`,
|
||||
);
|
||||
console.log("(>~8ms/frame main-thread work is where 120Hz starts to drop)");
|
||||
console.log("=========================================================\n");
|
||||
/* eslint-enable no-console */
|
||||
|
||||
// Instrument, not a gate — just confirm it exercised the full list.
|
||||
expect(sample.rowCount).toBeGreaterThan(100);
|
||||
expect(sample.scrollSpan).toBeGreaterThan(500);
|
||||
});
|
||||
|
||||
/**
|
||||
* MEASURE: prepend re-render cost — the one event-cost the team had no number
|
||||
* for. Steady-state scroll of the bounded 200-row window is compositor-cheap
|
||||
* (see the test above). The felt jank, if any, lives at the *prepend*: when
|
||||
* older rows land while you're scrolled up, the whole memoized
|
||||
* TimelineMessageList re-renders AND the anchor scrollBy fires, all on one
|
||||
* main-thread tick. If that tick exceeds the frame budget mid-wheel, the
|
||||
* compositor stalls and you feel it.
|
||||
*
|
||||
* This drives the genuine path: seed older history, scroll off-bottom, then
|
||||
* prepend a small batch via the live-event ingest (the same WS path the relay
|
||||
* uses), and measure (a) the synchronous wall-time of the tick that flushes
|
||||
* the React commit + layout, and (b) the CDP layout/recalc cost attributed to
|
||||
* it. It also records whether the anchor held (scrollTop preserved within the
|
||||
* row that was under the eye) — a non-restore here is the in-viewport-shift
|
||||
* bug, not a perf cost.
|
||||
*
|
||||
* SCOPE LIMIT: same as above — Chromium main-thread cost, not WKWebView feel.
|
||||
*/
|
||||
test("MEASURE: prepend re-render cost while scrolled up (the untested event-cost)", async ({
|
||||
page,
|
||||
}) => {
|
||||
await installMockBridge(page);
|
||||
await page.goto("/");
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
typeof window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function" &&
|
||||
typeof window.__BUZZ_E2E_PREPEND_MOCK_HISTORY__ === "function",
|
||||
);
|
||||
|
||||
// Seed the channel at roughly the real data-window cap (CHANNEL_HISTORY_LIMIT
|
||||
// = 200) so the prepend re-renders a full-size list, the worst realistic case.
|
||||
const SEED = 200;
|
||||
await page.evaluate(
|
||||
({ rows, lines }) => {
|
||||
for (let i = 0; i < rows; i += 1) {
|
||||
const body = Array.from(
|
||||
{ length: lines },
|
||||
(_u, l) => `seed row ${i} line ${l + 1}`,
|
||||
).join("\n");
|
||||
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
|
||||
channelName: "general",
|
||||
content: body,
|
||||
});
|
||||
}
|
||||
},
|
||||
{ rows: SEED, lines: LINES_PER_ROW },
|
||||
);
|
||||
|
||||
await page.getByTestId("channel-general").click();
|
||||
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
||||
const timeline = page.getByTestId("message-timeline");
|
||||
|
||||
await page.waitForFunction(() => {
|
||||
const el = document.querySelector(
|
||||
'[data-testid="message-timeline"]',
|
||||
) as HTMLDivElement | null;
|
||||
return !!el && el.querySelectorAll("[data-message-id]").length > 50;
|
||||
});
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Scroll up off the bottom so the anchor is a mid-list row, not "at-bottom"
|
||||
// (the at-bottom path corrects differently). Land roughly in the middle.
|
||||
await timeline.evaluate((element) => {
|
||||
const el = element as HTMLDivElement;
|
||||
el.scrollTop = Math.floor(el.scrollHeight * 0.4);
|
||||
el.dispatchEvent(new Event("scroll", { bubbles: true }));
|
||||
});
|
||||
await page.waitForTimeout(200);
|
||||
|
||||
const client = await page.context().newCDPSession(page);
|
||||
await client.send("Performance.enable");
|
||||
|
||||
const before = await readMetrics(client);
|
||||
const scrollTopBefore = await timeline.evaluate(
|
||||
(el) => (el as HTMLDivElement).scrollTop,
|
||||
);
|
||||
|
||||
// Prepend a small older batch through the live ingest path and time the tick
|
||||
// that flushes the resulting React commit + layout + anchor restore.
|
||||
const tickMs = await page.evaluate(async (count) => {
|
||||
const t0 = performance.now();
|
||||
window.__BUZZ_E2E_PREPEND_MOCK_HISTORY__?.({
|
||||
channelName: "general",
|
||||
count,
|
||||
lineCount: 3,
|
||||
emit: true,
|
||||
});
|
||||
// Force a layout flush so the synchronous commit + anchor scrollBy + reflow
|
||||
// are all accounted before we stop the clock. Two rAFs span the commit and
|
||||
// the subsequent paint-prep tick.
|
||||
await new Promise<void>((r) => requestAnimationFrame(() => r()));
|
||||
await new Promise<void>((r) => requestAnimationFrame(() => r()));
|
||||
return performance.now() - t0;
|
||||
}, 10);
|
||||
|
||||
const after = await readMetrics(client);
|
||||
const scrollTopAfter = await timeline.evaluate(
|
||||
(el) => (el as HTMLDivElement).scrollTop,
|
||||
);
|
||||
const rowCountAfter = await timeline.evaluate(
|
||||
(el) => (el as HTMLDivElement).querySelectorAll("[data-message-id]").length,
|
||||
);
|
||||
await client.send("Performance.disable");
|
||||
|
||||
const layoutDelta = after.layoutMs - before.layoutMs;
|
||||
const recalcDelta = after.recalcMs - before.recalcMs;
|
||||
const anchorDrift = scrollTopAfter - scrollTopBefore;
|
||||
|
||||
/* eslint-disable no-console */
|
||||
console.log("\n=== PREPEND RE-RENDER COST (10 older rows, scrolled up) ===");
|
||||
console.log(`rows after prepend (live DOM): ${rowCountAfter}`);
|
||||
console.log(`tick wall-time (commit+layout): ${tickMs.toFixed(2)}ms`);
|
||||
console.log(`layout time attributed: ${layoutDelta.toFixed(1)}ms`);
|
||||
console.log(`style-recalc time attributed: ${recalcDelta.toFixed(1)}ms`);
|
||||
console.log(
|
||||
`anchor drift (scrollTop delta): ${anchorDrift.toFixed(1)}px (0 = held)`,
|
||||
);
|
||||
console.log("(tick > ~8ms during active wheel is where 120Hz stalls)");
|
||||
console.log("===========================================================\n");
|
||||
/* eslint-enable no-console */
|
||||
|
||||
// Instrument, not a gate — just confirm the prepend actually happened.
|
||||
expect(rowCountAfter).toBeGreaterThan(50);
|
||||
});
|
||||
@@ -96,6 +96,8 @@ type MockBridgeOptions = {
|
||||
feedReadError?: string;
|
||||
canvasReadError?: string;
|
||||
openDmDelayMs?: number;
|
||||
/** Delay (ms) for older-history fetches; see e2eBridge mock config. */
|
||||
historyDelayMs?: number;
|
||||
profileReadDelayMs?: number;
|
||||
profileReadError?: string;
|
||||
profileUpdateError?: string;
|
||||
|
||||
Reference in New Issue
Block a user