mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
perf(desktop): virtualize message timeline to stop the cold-switch beachball
Channel switch streamed up to 200 uncontained MessageRows (each with
synchronous shiki markdown), then scrollToBottom("auto") forced a
full-document scrollHeight read-then-write reflow before paint over
every row — the macOS beachball Will reported on v0.3.25.
Windows the main timeline on @tanstack/react-virtual. The day-grouped
section tree is flattened to a typed TimelineItem[] stream plus a
messageId->itemIndex map from one walk (cannot drift), and every
DOM-querySelector scroll path (deep-link, search-jump, jump-to-unread,
scrollToBottom, load-older anchor) is re-pathed onto the index model so
windowing does not silently break jumps to off-screen rows.
Scroll convergence is split: @tanstack/react-virtual owns offset
convergence (its rAF loop re-aims getOffsetForIndex as rows mount and
measure); a pure reducer owns only staleness re-resolution and
termination — re-resolving the target's index by id each frame so a
concurrent prepend/delete cannot strand the loop on a stale index, and
terminating when the target is deleted or a 32-frame cap is hit. The
breaking math lives in lib/ under the .mjs suite.
The thread reply list stays content-visibility:auto rather than
virtualized — it is bounded, unpaginated, ungrouped, and shares the
scroll hook, so virtualizing it would force a second index re-path and a
head/prologue split for no beachball gain. Phase-2 route-chunk preload
warms the agents/channel/lazy-view chunks on idle to clear the
Agents-menu first-visit stall.
Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
This commit is contained in:
co-authored by
Will Pfleger
parent
78ac6caf19
commit
913facde71
@@ -76,6 +76,9 @@ import { relayClient } from "@/shared/api/relayClient";
|
||||
import { useIdentityQuery } from "@/shared/api/hooks";
|
||||
import { useRelayAutoHeal } from "@/shared/api/useRelayAutoHeal";
|
||||
import { useDeferredStartup } from "@/shared/hooks/useDeferredStartup";
|
||||
import { preloadAgentsScreen } from "@/app/routes/agents";
|
||||
import { preloadChannelRouteScreen } from "@/app/routes/channels.$channelId";
|
||||
import { preloadChannelViews } from "@/features/channels/ui/ChannelScreenLazyViews";
|
||||
import { joinChannel } from "@/shared/api/tauri";
|
||||
import type { Channel, RelayEvent, SearchHit } from "@/shared/api/types";
|
||||
import { ChannelNavigationProvider } from "@/shared/context/ChannelNavigationContext";
|
||||
@@ -540,6 +543,19 @@ export function AppShell() {
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Warm the lazy route chunks (channel timeline, forum, agents) once the shell
|
||||
// is idle, so the FIRST main-nav transition doesn't stall on a cold chunk
|
||||
// fetch+parse. `startupReady` is the existing idle-or-timeout gate; the chunk
|
||||
// imports dedupe, so racing an actual navigation is harmless.
|
||||
React.useEffect(() => {
|
||||
if (!startupReady) {
|
||||
return;
|
||||
}
|
||||
preloadChannelRouteScreen();
|
||||
preloadChannelViews();
|
||||
preloadAgentsScreen();
|
||||
}, [startupReady]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const numericCount =
|
||||
highPriorityUnreadChannelIds.size + homeBadgeCountExcludingHighPriority;
|
||||
|
||||
@@ -3,11 +3,21 @@ import { createFileRoute } from "@tanstack/react-router";
|
||||
|
||||
import { ViewLoadingFallback } from "@/shared/ui/ViewLoadingFallback";
|
||||
|
||||
// The chunk import is hoisted so it can be triggered eagerly (route preload)
|
||||
// as well as lazily on render — calling it twice is a no-op, the module loader
|
||||
// dedupes and caches the in-flight promise.
|
||||
const importAgentsScreen = () => import("@/features/agents/ui/AgentsScreen");
|
||||
|
||||
const AgentsScreen = React.lazy(async () => {
|
||||
const module = await import("@/features/agents/ui/AgentsScreen");
|
||||
const module = await importAgentsScreen();
|
||||
return { default: module.AgentsScreen };
|
||||
});
|
||||
|
||||
/** Warms the AgentsScreen route chunk so first navigation doesn't stall. */
|
||||
export function preloadAgentsScreen(): void {
|
||||
void importAgentsScreen();
|
||||
}
|
||||
|
||||
export const Route = createFileRoute("/agents")({
|
||||
component: AgentsRouteComponent,
|
||||
});
|
||||
|
||||
@@ -38,11 +38,20 @@ export const Route = createFileRoute("/channels/$channelId")({
|
||||
component: ChannelRouteComponent,
|
||||
});
|
||||
|
||||
// Hoisted so the chunk can be warmed eagerly (route preload) as well as loaded
|
||||
// lazily on render; the loader dedupes repeat calls.
|
||||
const importChannelRouteScreen = () => import("./ChannelRouteScreen");
|
||||
|
||||
const ChannelRouteScreen = React.lazy(async () => {
|
||||
const module = await import("./ChannelRouteScreen");
|
||||
const module = await importChannelRouteScreen();
|
||||
return { default: module.ChannelRouteScreen };
|
||||
});
|
||||
|
||||
/** Warms the ChannelRouteScreen chunk so first channel open doesn't stall. */
|
||||
export function preloadChannelRouteScreen(): void {
|
||||
void importChannelRouteScreen();
|
||||
}
|
||||
|
||||
function ChannelRouteComponent() {
|
||||
const { channelId } = Route.useParams();
|
||||
const search = Route.useSearch();
|
||||
|
||||
@@ -1,11 +1,22 @@
|
||||
import * as React from "react";
|
||||
|
||||
// Hoisted chunk imports so each view can be warmed eagerly (route preload) as
|
||||
// well as loaded lazily on render; the module loader dedupes repeat calls.
|
||||
const importChannelPane = () => import("@/features/channels/ui/ChannelPane");
|
||||
const importForumView = () => import("@/features/forum/ui/ForumView");
|
||||
|
||||
export const ChannelPane = React.lazy(async () => {
|
||||
const module = await import("@/features/channels/ui/ChannelPane");
|
||||
const module = await importChannelPane();
|
||||
return { default: module.ChannelPane };
|
||||
});
|
||||
|
||||
export const ForumView = React.lazy(async () => {
|
||||
const module = await import("@/features/forum/ui/ForumView");
|
||||
const module = await importForumView();
|
||||
return { default: module.ForumView };
|
||||
});
|
||||
|
||||
/** Warms the channel/forum view chunks so first open doesn't stall. */
|
||||
export function preloadChannelViews(): void {
|
||||
void importChannelPane();
|
||||
void importForumView();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { CONVERGENCE_FRAME_CAP, convergenceStep } from "./scrollConvergence.ts";
|
||||
|
||||
function input(overrides) {
|
||||
return {
|
||||
targetMessageId: "target",
|
||||
indexByMessageId: new Map([["target", 100]]),
|
||||
lastIssuedIndex: null,
|
||||
librarySettled: false,
|
||||
framesUsed: 0,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// --- re-aim / staleness guard ------------------------------------------------
|
||||
|
||||
test("convergenceStep: first frame aims at the resolved index, not yet done", () => {
|
||||
const step = convergenceStep(input({ lastIssuedIndex: null }));
|
||||
assert.equal(step.nextIndex, 100);
|
||||
assert.equal(step.done, false);
|
||||
assert.equal(step.converged, false);
|
||||
});
|
||||
|
||||
test("convergenceStep: re-resolves a shifted index from the map each frame", () => {
|
||||
// A prepend shifted the target from 100 to 105. The library is still chasing
|
||||
// the old index (lastIssuedIndex 100); the reducer must aim at the NEW index
|
||||
// so the adapter re-issues scrollToIndex(105). This is the staleness guard.
|
||||
const step = convergenceStep(
|
||||
input({
|
||||
indexByMessageId: new Map([["target", 105]]),
|
||||
lastIssuedIndex: 100,
|
||||
}),
|
||||
);
|
||||
assert.equal(step.nextIndex, 105);
|
||||
assert.equal(step.done, false);
|
||||
assert.equal(step.converged, false);
|
||||
});
|
||||
|
||||
test("convergenceStep: target removed mid-settle stops with converged=false", () => {
|
||||
// Target deleted from the map while the loop was chasing it. Terminate so the
|
||||
// adapter clears the highlight instead of chasing a vanished row.
|
||||
const step = convergenceStep(
|
||||
input({
|
||||
indexByMessageId: new Map(), // target gone
|
||||
lastIssuedIndex: 100,
|
||||
framesUsed: 3,
|
||||
}),
|
||||
);
|
||||
assert.equal(step.nextIndex, null);
|
||||
assert.equal(step.done, true);
|
||||
assert.equal(step.converged, false);
|
||||
});
|
||||
|
||||
// --- settle ------------------------------------------------------------------
|
||||
|
||||
test("convergenceStep: library settled while aiming at current index converges", () => {
|
||||
const step = convergenceStep(
|
||||
input({ lastIssuedIndex: 100, librarySettled: true }),
|
||||
);
|
||||
assert.equal(step.nextIndex, 100);
|
||||
assert.equal(step.done, true);
|
||||
assert.equal(step.converged, true);
|
||||
});
|
||||
|
||||
test("convergenceStep: a settle reported WHILE re-aiming is ignored", () => {
|
||||
// The index just moved (105) but the library reports settled — that settle is
|
||||
// on the OLD index (100), so it must NOT count as convergence. The reducer
|
||||
// keeps going and aims at the new index.
|
||||
const step = convergenceStep(
|
||||
input({
|
||||
indexByMessageId: new Map([["target", 105]]),
|
||||
lastIssuedIndex: 100,
|
||||
librarySettled: true,
|
||||
}),
|
||||
);
|
||||
assert.equal(step.nextIndex, 105);
|
||||
assert.equal(step.done, false);
|
||||
assert.equal(step.converged, false);
|
||||
});
|
||||
|
||||
test("convergenceStep: aiming at current but not yet settled keeps waiting", () => {
|
||||
// Library is chasing the right index but its offset hasn't stabilized. The
|
||||
// reducer returns the same index (so the adapter re-issues NOTHING — issuing
|
||||
// would reset the library's stableFrames and prevent settling) and waits.
|
||||
const step = convergenceStep(
|
||||
input({ lastIssuedIndex: 100, librarySettled: false }),
|
||||
);
|
||||
assert.equal(step.nextIndex, 100);
|
||||
assert.equal(step.done, false);
|
||||
assert.equal(step.converged, false);
|
||||
});
|
||||
|
||||
// --- frame cap ---------------------------------------------------------------
|
||||
|
||||
test("convergenceStep: terminates at the frame cap without converging", () => {
|
||||
// A row that never settles (librarySettled stays false) must still stop at the
|
||||
// cap rather than spin forever.
|
||||
const step = convergenceStep(
|
||||
input({
|
||||
lastIssuedIndex: 100,
|
||||
librarySettled: false,
|
||||
framesUsed: CONVERGENCE_FRAME_CAP - 1,
|
||||
}),
|
||||
);
|
||||
assert.equal(step.done, true);
|
||||
assert.equal(step.converged, false);
|
||||
assert.equal(step.nextIndex, 100);
|
||||
});
|
||||
|
||||
test("convergenceStep: frame cap bounds a perpetually shifting target", () => {
|
||||
// Drive the loop the way the adapter would: the target index moves every
|
||||
// frame, so the library never settles. The loop must terminate at the cap.
|
||||
let lastIssuedIndex = null;
|
||||
let framesUsed = 0;
|
||||
let done = false;
|
||||
let converged = true;
|
||||
|
||||
while (framesUsed < CONVERGENCE_FRAME_CAP + 5) {
|
||||
const movingIndex = 100 + framesUsed; // shifts every frame
|
||||
const step = convergenceStep(
|
||||
input({
|
||||
indexByMessageId: new Map([["target", movingIndex]]),
|
||||
lastIssuedIndex,
|
||||
librarySettled: false,
|
||||
framesUsed,
|
||||
}),
|
||||
);
|
||||
lastIssuedIndex = step.nextIndex;
|
||||
framesUsed += 1;
|
||||
if (step.done) {
|
||||
done = step.done;
|
||||
converged = step.converged;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
assert.equal(done, true);
|
||||
assert.equal(converged, false);
|
||||
assert.ok(framesUsed <= CONVERGENCE_FRAME_CAP);
|
||||
});
|
||||
|
||||
test("convergenceStep: converges once a re-aimed index then settles", () => {
|
||||
// Realistic flow: frame 0 aims (lastIssued null -> 100), frame 1 the library
|
||||
// is chasing 100 and reports settled -> converged.
|
||||
const aim = convergenceStep(input({ lastIssuedIndex: null }));
|
||||
assert.equal(aim.nextIndex, 100);
|
||||
assert.equal(aim.done, false);
|
||||
|
||||
const settle = convergenceStep(
|
||||
input({
|
||||
lastIssuedIndex: aim.nextIndex,
|
||||
librarySettled: true,
|
||||
framesUsed: 1,
|
||||
}),
|
||||
);
|
||||
assert.equal(settle.done, true);
|
||||
assert.equal(settle.converged, true);
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* Pure staleness + termination decision for scrolling a virtualized timeline to
|
||||
* a message that may be far off-screen.
|
||||
*
|
||||
* @tanstack/react-virtual already owns the OFFSET convergence: a single
|
||||
* `scrollToIndex(index)` captures that index in `scrollState`, and its internal
|
||||
* `reconcileScroll` rAF loop re-runs `getOffsetForIndex(index)` every frame —
|
||||
* re-aiming as off-screen rows mount and `measureElement` corrects their
|
||||
* heights — until the offset is stable (or a 5s safety valve fires). We do NOT
|
||||
* recompute offsets; duplicating `getOffsetForIndex` against the library's own
|
||||
* `measurementsCache`/`scrollMargin`/`scrollPadding` would only drift.
|
||||
*
|
||||
* What the library does NOT do: it chases the INDEX captured at call time, with
|
||||
* no concept of a message id. If the data shifts mid-settle — a prepend or a
|
||||
* delete above the target — the captured index now points at the wrong row and
|
||||
* the library happily settles on it. This reducer owns exactly that gap: each
|
||||
* frame it re-resolves the target's CURRENT index from the live map and decides
|
||||
* whether the adapter must re-aim the library, let it settle, or stop.
|
||||
*
|
||||
* Two correctness properties this enforces and the `.mjs` suite gates:
|
||||
* - The target index is re-resolved by id every frame (never frozen), so a
|
||||
* concurrent prepend/delete that shifts the target re-aims the library at the
|
||||
* new index instead of stranding it on the old one.
|
||||
* - If the target id leaves the data mid-settle (deleted), the loop terminates
|
||||
* with `converged: false` rather than chasing a vanished row to the cap.
|
||||
*/
|
||||
|
||||
/** Where a scroll target should land in the viewport. Mirrors the library's align. */
|
||||
export type ConvergenceAlign = "start" | "center" | "end";
|
||||
|
||||
export type ConvergenceInput = {
|
||||
/** Id of the message to settle on — re-resolved against the map each frame. */
|
||||
targetMessageId: string;
|
||||
/** Live message-id -> item-index map; re-read every frame (staleness guard). */
|
||||
indexByMessageId: Map<string, number>;
|
||||
/**
|
||||
* Index the library is currently chasing (the last index the adapter issued
|
||||
* via `scrollToIndex`), or `null` before the first issue. Lets the reducer
|
||||
* tell a re-aim (index moved) from a steady settle (index unchanged).
|
||||
*/
|
||||
lastIssuedIndex: number | null;
|
||||
/**
|
||||
* Whether the library reports its scroll has settled this frame
|
||||
* (`virtualizer.scrollState === null`). Only meaningful once the library is
|
||||
* chasing the CURRENT index; a settle reported while re-aiming is ignored.
|
||||
*/
|
||||
librarySettled: boolean;
|
||||
/** Frames already spent in the loop (the adapter increments per rAF). */
|
||||
framesUsed: number;
|
||||
};
|
||||
|
||||
export type ConvergenceDecision = {
|
||||
/**
|
||||
* Index the adapter should be aiming the library at, or `null` when the
|
||||
* target is gone. The adapter only re-issues `scrollToIndex` when this differs
|
||||
* from `lastIssuedIndex`, so a steady settle issues no redundant scroll (which
|
||||
* would reset the library's `stableFrames` and prevent it from ever settling).
|
||||
*/
|
||||
nextIndex: number | null;
|
||||
/** True once the loop must stop (settled, target gone, or frame cap hit). */
|
||||
done: boolean;
|
||||
/** True only when the loop stopped because the target row actually settled. */
|
||||
converged: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Hard cap on frames so a perpetually re-measuring row, or a target whose index
|
||||
* keeps shifting, can't spin the loop forever. The library has its own 5s valve;
|
||||
* this is the adapter-side bound expressed in frames for deterministic testing.
|
||||
*/
|
||||
export const CONVERGENCE_FRAME_CAP = 32;
|
||||
|
||||
/**
|
||||
* One frame of the convergence loop. Pure: given the live map and the library's
|
||||
* settle state, decides the index to aim at and whether to stop.
|
||||
*/
|
||||
export function convergenceStep(input: ConvergenceInput): ConvergenceDecision {
|
||||
const currentIndex = input.indexByMessageId.get(input.targetMessageId);
|
||||
|
||||
// Target left the data mid-settle (deleted) — stop without converging so the
|
||||
// adapter clears the highlight instead of chasing a vanished row.
|
||||
if (currentIndex === undefined) {
|
||||
return { nextIndex: null, done: true, converged: false };
|
||||
}
|
||||
|
||||
const aimingAtCurrent = input.lastIssuedIndex === currentIndex;
|
||||
|
||||
// The library only settles meaningfully once it is chasing the CURRENT index.
|
||||
// A settle reported while we are still re-aiming (index just moved) is stale.
|
||||
if (aimingAtCurrent && input.librarySettled) {
|
||||
return { nextIndex: currentIndex, done: true, converged: true };
|
||||
}
|
||||
|
||||
// Frame cap: accept the best index we have rather than spin forever on a row
|
||||
// whose height never settles or a target whose index keeps shifting.
|
||||
if (input.framesUsed + 1 >= CONVERGENCE_FRAME_CAP) {
|
||||
return { nextIndex: currentIndex, done: true, converged: false };
|
||||
}
|
||||
|
||||
// Either the index moved (adapter will re-issue scrollToIndex) or the library
|
||||
// is still settling on the current index (adapter issues nothing, just waits).
|
||||
return { nextIndex: currentIndex, done: false, converged: false };
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { KIND_SYSTEM_MESSAGE } from "@/shared/constants/kinds";
|
||||
import { buildTimelineItems, getTimelineItemKey } from "./timelineItems.ts";
|
||||
|
||||
function dayAt(year, month, day, hour = 12) {
|
||||
return Math.floor(
|
||||
new Date(year, month - 1, day, hour, 0, 0).getTime() / 1_000,
|
||||
);
|
||||
}
|
||||
|
||||
function message(overrides) {
|
||||
return {
|
||||
id: "m",
|
||||
renderKey: undefined,
|
||||
createdAt: dayAt(2026, 6, 14),
|
||||
pubkey: "author",
|
||||
parentId: null,
|
||||
rootId: null,
|
||||
depth: 0,
|
||||
kind: 9,
|
||||
tags: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// The builder takes MainTimelineEntry[] (post top-level filter); summary is
|
||||
// irrelevant to item/divider placement, so null is fine here.
|
||||
function entry(overrides) {
|
||||
return { message: message(overrides), summary: null };
|
||||
}
|
||||
|
||||
function kinds(items) {
|
||||
return items.map((item) => item.kind);
|
||||
}
|
||||
|
||||
// --- divider placement -------------------------------------------------------
|
||||
|
||||
test("buildTimelineItems: 3-day channel with unread mid-day-2 places dividers by index", () => {
|
||||
const entries = [
|
||||
entry({ id: "d1a", createdAt: dayAt(2026, 6, 12) }),
|
||||
entry({ id: "d1b", createdAt: dayAt(2026, 6, 12, 13) }),
|
||||
entry({ id: "d2a", createdAt: dayAt(2026, 6, 13) }),
|
||||
entry({ id: "d2b", createdAt: dayAt(2026, 6, 13, 13) }), // first unread
|
||||
entry({ id: "d2c", createdAt: dayAt(2026, 6, 13, 14) }),
|
||||
entry({ id: "d3a", createdAt: dayAt(2026, 6, 14) }),
|
||||
];
|
||||
|
||||
const { items } = buildTimelineItems(entries, "d2b");
|
||||
|
||||
assert.deepEqual(kinds(items), [
|
||||
"day-divider", // day 1
|
||||
"message", // d1a
|
||||
"message", // d1b
|
||||
"day-divider", // day 2
|
||||
"message", // d2a
|
||||
"unread-divider", // above d2b
|
||||
"message", // d2b
|
||||
"message", // d2c
|
||||
"day-divider", // day 3
|
||||
"message", // d3a
|
||||
]);
|
||||
});
|
||||
|
||||
test("buildTimelineItems: unread divider suppressed when first unread is the first entry", () => {
|
||||
const entries = [
|
||||
entry({ id: "a", createdAt: dayAt(2026, 6, 14) }),
|
||||
entry({ id: "b", createdAt: dayAt(2026, 6, 14, 13) }),
|
||||
];
|
||||
// firstUnread === index 0 — nothing above it, so no divider.
|
||||
const { items } = buildTimelineItems(entries, "a");
|
||||
assert.equal(items.filter((i) => i.kind === "unread-divider").length, 0);
|
||||
});
|
||||
|
||||
test("buildTimelineItems: system messages flatten to a 'system' item", () => {
|
||||
const entries = [
|
||||
entry({ id: "a", createdAt: dayAt(2026, 6, 14) }),
|
||||
entry({
|
||||
id: "sys",
|
||||
kind: KIND_SYSTEM_MESSAGE,
|
||||
createdAt: dayAt(2026, 6, 14, 13),
|
||||
}),
|
||||
];
|
||||
const { items } = buildTimelineItems(entries, null);
|
||||
assert.deepEqual(kinds(items), ["day-divider", "message", "system"]);
|
||||
});
|
||||
|
||||
test("buildTimelineItems: empty entries produce no items and an empty map", () => {
|
||||
const { items, indexByMessageId } = buildTimelineItems([], null);
|
||||
assert.equal(items.length, 0);
|
||||
assert.equal(indexByMessageId.size, 0);
|
||||
});
|
||||
|
||||
// --- index map correctness ---------------------------------------------------
|
||||
|
||||
test("buildTimelineItems: map points each message id at its flattened item index", () => {
|
||||
const entries = [
|
||||
entry({ id: "d1", createdAt: dayAt(2026, 6, 12) }),
|
||||
entry({ id: "d2", createdAt: dayAt(2026, 6, 13) }),
|
||||
];
|
||||
const { items, indexByMessageId } = buildTimelineItems(entries, null);
|
||||
|
||||
// dividers occupy indices 0 and 2; messages land at 1 and 3.
|
||||
assert.equal(indexByMessageId.get("d1"), 1);
|
||||
assert.equal(indexByMessageId.get("d2"), 3);
|
||||
assert.equal(items[1].entry.message.id, "d1");
|
||||
assert.equal(items[3].entry.message.id, "d2");
|
||||
});
|
||||
|
||||
test("buildTimelineItems: appending a new message keeps prior indices stable", () => {
|
||||
const base = [entry({ id: "a", createdAt: dayAt(2026, 6, 14) })];
|
||||
const before = buildTimelineItems(base, null).indexByMessageId;
|
||||
|
||||
const appended = [
|
||||
...base,
|
||||
entry({ id: "b", createdAt: dayAt(2026, 6, 14, 13) }),
|
||||
];
|
||||
const after = buildTimelineItems(appended, null).indexByMessageId;
|
||||
|
||||
assert.equal(after.get("a"), before.get("a"));
|
||||
assert.equal(after.get("b"), 2);
|
||||
});
|
||||
|
||||
test("buildTimelineItems: prepending an older-day message shifts later indices", () => {
|
||||
const original = [entry({ id: "b", createdAt: dayAt(2026, 6, 14) })];
|
||||
const beforeIdx = buildTimelineItems(original, null).indexByMessageId.get(
|
||||
"b",
|
||||
);
|
||||
|
||||
// Prepend a message on an earlier day → adds its own day-divider + message,
|
||||
// pushing "b" (now on a new day boundary too) further down.
|
||||
const prepended = [
|
||||
entry({ id: "a", createdAt: dayAt(2026, 6, 13) }),
|
||||
entry({ id: "b", createdAt: dayAt(2026, 6, 14) }),
|
||||
];
|
||||
const afterIdx = buildTimelineItems(prepended, null).indexByMessageId.get(
|
||||
"b",
|
||||
);
|
||||
assert.ok(afterIdx > beforeIdx);
|
||||
});
|
||||
|
||||
test("buildTimelineItems: deleting a message drops it from the map", () => {
|
||||
const entries = [
|
||||
entry({ id: "a", createdAt: dayAt(2026, 6, 14) }),
|
||||
entry({ id: "b", createdAt: dayAt(2026, 6, 14, 13) }),
|
||||
];
|
||||
const afterDelete = buildTimelineItems(
|
||||
entries.filter((e) => e.message.id !== "a"),
|
||||
null,
|
||||
).indexByMessageId;
|
||||
assert.equal(afterDelete.has("a"), false);
|
||||
assert.equal(afterDelete.get("b"), 1);
|
||||
});
|
||||
|
||||
test("getTimelineItemKey: keys are unique across the stream", () => {
|
||||
const entries = [
|
||||
entry({ id: "a", createdAt: dayAt(2026, 6, 12) }),
|
||||
entry({ id: "b", createdAt: dayAt(2026, 6, 13) }),
|
||||
];
|
||||
const { items } = buildTimelineItems(entries, "b");
|
||||
const keys = items.map(getTimelineItemKey);
|
||||
assert.equal(new Set(keys).size, keys.length);
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* Flattens the heterogeneous day-grouped timeline tree into a flat
|
||||
* discriminated-union item stream that a virtualizer can window over, and
|
||||
* builds the `messageId -> itemIndex` map every DOM-query scroll path now
|
||||
* resolves against instead of `querySelector`.
|
||||
*
|
||||
* Kept pure (no React, no DOM) so it is covered by the lib-level `*.test.mjs`
|
||||
* suite. The list and the index map are produced together from the SAME walk,
|
||||
* so they can never drift: a stale map would scroll deep-links to the wrong
|
||||
* row, the exact failure virtualization risks.
|
||||
*/
|
||||
|
||||
import {
|
||||
buildDayGroupBoundaries,
|
||||
type DayGroupBoundary,
|
||||
} from "@/features/messages/lib/timelineSnapshot";
|
||||
import { shouldRenderUnreadDivider } from "@/features/messages/lib/threadPanel";
|
||||
import type { MainTimelineEntry } from "@/features/messages/lib/threadPanel";
|
||||
import { KIND_SYSTEM_MESSAGE } from "@/shared/constants/kinds";
|
||||
|
||||
/**
|
||||
* One renderable row in the flattened timeline. Dividers carry no message and
|
||||
* never appear in the index map; the three message-bearing kinds do.
|
||||
*/
|
||||
export type TimelineItem =
|
||||
// `headingTimestamp` (not a prebaked label) so the render still resolves
|
||||
// "Today"/"Yesterday" relative to the current clock, not to build time.
|
||||
| { kind: "day-divider"; key: string; headingTimestamp: number }
|
||||
| { kind: "unread-divider"; key: string }
|
||||
| { kind: "system"; key: string; entry: MainTimelineEntry }
|
||||
| { kind: "message"; key: string; entry: MainTimelineEntry };
|
||||
|
||||
export type TimelineItemsResult = {
|
||||
items: TimelineItem[];
|
||||
/** Maps a top-level message id to its index in `items`. */
|
||||
indexByMessageId: Map<string, number>;
|
||||
};
|
||||
|
||||
/** Stable per-item key, unique across the flattened stream. */
|
||||
export function getTimelineItemKey(item: TimelineItem): string {
|
||||
return item.key;
|
||||
}
|
||||
|
||||
function entryRenderKey(entry: MainTimelineEntry): string {
|
||||
return entry.message.renderKey ?? entry.message.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Walks the (already top-level-filtered) entries once, emitting a day-divider
|
||||
* at each calendar-day boundary and an unread-divider above the first unread
|
||||
* message, then the message/system row itself. The index map records where
|
||||
* each message landed in the flat stream so scroll targets resolve in O(1)
|
||||
* without touching the DOM.
|
||||
*/
|
||||
export function buildTimelineItems(
|
||||
entries: MainTimelineEntry[],
|
||||
firstUnreadMessageId: string | null,
|
||||
): TimelineItemsResult {
|
||||
const items: TimelineItem[] = [];
|
||||
const indexByMessageId = new Map<string, number>();
|
||||
|
||||
// Index boundaries by their start position so the walk below can look up the
|
||||
// prepend-stable section key (start-of-local-day). Keying the divider by
|
||||
// start-of-day, not by the first message, keeps the day section from
|
||||
// remounting when older messages prepend into it.
|
||||
const dayBoundariesByStartIndex = new Map(
|
||||
buildDayGroupBoundaries(entries.map((entry) => entry.message)).map(
|
||||
(boundary: DayGroupBoundary) => [boundary.startIndex, boundary] as const,
|
||||
),
|
||||
);
|
||||
|
||||
for (let i = 0; i < entries.length; i++) {
|
||||
const entry = entries[i];
|
||||
const { message } = entry;
|
||||
const renderKey = entryRenderKey(entry);
|
||||
|
||||
const dayBoundary = dayBoundariesByStartIndex.get(i);
|
||||
if (dayBoundary) {
|
||||
items.push({
|
||||
kind: "day-divider",
|
||||
key: dayBoundary.key,
|
||||
headingTimestamp: message.createdAt,
|
||||
});
|
||||
}
|
||||
|
||||
if (shouldRenderUnreadDivider(i, message.id, firstUnreadMessageId)) {
|
||||
items.push({ kind: "unread-divider", key: `unread-${renderKey}` });
|
||||
}
|
||||
|
||||
const kind = message.kind === KIND_SYSTEM_MESSAGE ? "system" : "message";
|
||||
indexByMessageId.set(message.id, items.length);
|
||||
items.push({ kind, key: renderKey, entry });
|
||||
}
|
||||
|
||||
return { items, indexByMessageId };
|
||||
}
|
||||
@@ -438,7 +438,7 @@ export function MessageThreadPanel({
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col gap-1",
|
||||
"content-visibility-auto flex flex-col gap-1",
|
||||
entry.summary &&
|
||||
"group/message mx-1 rounded-2xl px-0 py-1 transition-colors hover:bg-muted/50 focus-within:bg-muted/50",
|
||||
)}
|
||||
|
||||
@@ -9,12 +9,14 @@ import { getDmParticipantPreview } from "@/features/channels/lib/dmParticipantDi
|
||||
import type { TimelineMessage } from "@/features/messages/types";
|
||||
import type { UserProfileLookup } from "@/features/profile/lib/identity";
|
||||
import type { ChannelType } from "@/shared/api/types";
|
||||
import type { TimelineItemsResult } from "@/features/messages/lib/timelineItems";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { channelChrome } from "@/shared/layout/chromeLayout";
|
||||
import { Spinner } from "@/shared/ui/spinner";
|
||||
import { TooltipProvider } from "@/shared/ui/tooltip";
|
||||
import { UnreadPill, unreadCountLabel } from "@/shared/ui/UnreadPill";
|
||||
import { UserAvatar } from "@/shared/ui/UserAvatar";
|
||||
import type { ListVirtualizer } from "@/shared/ui/VirtualizedList";
|
||||
import { TimelineSkeleton, useTimelineSkeletonRows } from "./TimelineSkeleton";
|
||||
import { TimelineMessageList } from "./TimelineMessageList";
|
||||
import { useAnchoredScroll } from "./useAnchoredScroll";
|
||||
@@ -163,6 +165,34 @@ const MessageTimelineBase = React.forwardRef<
|
||||
const contentRef = React.useRef<HTMLDivElement>(null);
|
||||
const topSentinelRef = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
// The virtualizer instance and the flattened item stream are owned by the
|
||||
// child TimelineMessageList (which mounts the VirtualizedList) and reported
|
||||
// up here so the scroll manager can resolve scroll targets by index. The
|
||||
// virtualizer reaches us via a ref (its identity is stable across renders,
|
||||
// but it arrives after first paint); the item stream + id->index map arrive
|
||||
// as state so a rebuild re-runs the scroll manager's index-model paths.
|
||||
const virtualizerRef = React.useRef<ListVirtualizer | null>(null);
|
||||
const handleVirtualizer = React.useCallback((instance: ListVirtualizer) => {
|
||||
virtualizerRef.current = instance;
|
||||
}, []);
|
||||
const getVirtualizer = React.useCallback(() => virtualizerRef.current, []);
|
||||
const [timelineItems, setTimelineItems] =
|
||||
React.useState<TimelineItemsResult | null>(null);
|
||||
const handleItems = React.useCallback((result: TimelineItemsResult) => {
|
||||
setTimelineItems(result);
|
||||
}, []);
|
||||
const virtualizerOption = React.useMemo(
|
||||
() =>
|
||||
timelineItems
|
||||
? {
|
||||
getVirtualizer,
|
||||
indexByMessageId: timelineItems.indexByMessageId,
|
||||
itemCount: timelineItems.items.length,
|
||||
}
|
||||
: null,
|
||||
[getVirtualizer, timelineItems],
|
||||
);
|
||||
|
||||
// Gate the heavy timeline render (each row runs a synchronous
|
||||
// react-markdown parse) behind React concurrency. `useDeferredValue` lets the
|
||||
// commit that rebuilds the message list yield to higher-priority work, so the
|
||||
@@ -222,6 +252,7 @@ const MessageTimelineBase = React.forwardRef<
|
||||
scrollContainerRef,
|
||||
sentinelRef: topSentinelRef,
|
||||
targetMessageId,
|
||||
virtualizer: virtualizerOption,
|
||||
});
|
||||
|
||||
React.useImperativeHandle(
|
||||
@@ -266,8 +297,9 @@ const MessageTimelineBase = React.forwardRef<
|
||||
}, [firstUnreadMessageId, scrollToMessage]);
|
||||
|
||||
// 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.
|
||||
// updates the scroll anchor (so the post-commit restore won't yank the view
|
||||
// back off the match) and, when virtualized, resolves the target through the
|
||||
// index model — the row may be windowed out of the DOM.
|
||||
const prevSearchActiveRef = React.useRef<string | null>(null);
|
||||
React.useEffect(() => {
|
||||
if (showTimelineSkeleton) return;
|
||||
@@ -282,6 +314,16 @@ const MessageTimelineBase = React.forwardRef<
|
||||
scrollToMessage(searchActiveMessageId, { behavior: "smooth" });
|
||||
}, [scrollToMessage, searchActiveMessageId, showTimelineSkeleton]);
|
||||
|
||||
useLoadOlderOnScroll({
|
||||
fetchOlder,
|
||||
hasOlderMessages,
|
||||
isLoading: showTimelineSkeleton,
|
||||
restoreScrollPosition,
|
||||
scrollContainerRef,
|
||||
sentinelRef: topSentinelRef,
|
||||
virtualizer: virtualizerOption,
|
||||
});
|
||||
|
||||
const timelineSkeletonRows = useTimelineSkeletonRows({
|
||||
channelId,
|
||||
isLoading: showTimelineSkeleton,
|
||||
@@ -501,6 +543,9 @@ const MessageTimelineBase = React.forwardRef<
|
||||
searchQuery={searchQuery}
|
||||
threadUnreadCounts={threadUnreadCounts}
|
||||
unfollowThreadById={unfollowThreadById}
|
||||
scrollContainerRef={scrollContainerRef}
|
||||
onItems={handleItems}
|
||||
onVirtualizer={handleVirtualizer}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -2,19 +2,25 @@ import * as React from "react";
|
||||
|
||||
import { formatDayHeading } from "@/features/messages/lib/dateFormatters";
|
||||
import {
|
||||
buildMainTimelineEntries,
|
||||
shouldRenderUnreadDivider,
|
||||
} from "@/features/messages/lib/threadPanel";
|
||||
buildTimelineItems,
|
||||
getTimelineItemKey,
|
||||
type TimelineItem,
|
||||
type TimelineItemsResult,
|
||||
} from "@/features/messages/lib/timelineItems";
|
||||
import { buildMainTimelineEntries } from "@/features/messages/lib/threadPanel";
|
||||
import type { MainTimelineEntry } from "@/features/messages/lib/threadPanel";
|
||||
import {
|
||||
buildVideoReviewCommentsByRootId,
|
||||
buildVideoReviewContextForMessage,
|
||||
} 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";
|
||||
import { KIND_SYSTEM_MESSAGE } from "@/shared/constants/kinds";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import {
|
||||
type ListVirtualizer,
|
||||
VirtualizedList,
|
||||
} from "@/shared/ui/VirtualizedList";
|
||||
import { DayDivider } from "./DayDivider";
|
||||
import { MessageRow } from "./MessageRow";
|
||||
import { MessageThreadSummaryRow } from "./MessageThreadSummaryRow";
|
||||
@@ -63,6 +69,13 @@ type TimelineMessageListProps = {
|
||||
searchQuery?: string;
|
||||
/** Per-thread unread counts keyed by thread root id. */
|
||||
threadUnreadCounts?: ReadonlyMap<string, number>;
|
||||
/** Caller-owned scroll container the virtualizer measures and scrolls. */
|
||||
scrollContainerRef: React.RefObject<HTMLElement | null>;
|
||||
/** Receives the flattened item stream + index map so the scroll manager can
|
||||
* resolve scroll targets by id. Called whenever the stream is rebuilt. */
|
||||
onItems?: (result: TimelineItemsResult) => void;
|
||||
/** Receives the virtualizer instance for index-model scroll paths. */
|
||||
onVirtualizer?: (virtualizer: ListVirtualizer) => void;
|
||||
};
|
||||
|
||||
export const TimelineMessageList = React.memo(function TimelineMessageList({
|
||||
@@ -90,6 +103,9 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({
|
||||
searchQuery,
|
||||
threadUnreadCounts,
|
||||
unfollowThreadById,
|
||||
scrollContainerRef,
|
||||
onItems,
|
||||
onVirtualizer,
|
||||
}: TimelineMessageListProps) {
|
||||
const entries = React.useMemo(
|
||||
() => buildMainTimelineEntries(messages),
|
||||
@@ -137,163 +153,259 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({
|
||||
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 boundaries by start position so the
|
||||
// render loop below stays a straight walk while the grouping logic — and the
|
||||
// prepend-stable section key — lives in `lib/`.
|
||||
const dayGroupBoundariesByStartIndex = new Map(
|
||||
buildDayGroupBoundaries(entries.map((entry) => entry.message)).map(
|
||||
(boundary) => [boundary.startIndex, boundary],
|
||||
),
|
||||
// The flattened item stream and its messageId -> itemIndex map are produced
|
||||
// together from ONE memo, keyed on the entries and the unread boundary (the
|
||||
// unread divider is its own item, so it shifts indices). A separate memo with
|
||||
// diverging deps would let the map go stale and scroll deep-links to the wrong
|
||||
// row — the exact failure virtualization risks.
|
||||
const itemsResult = React.useMemo(
|
||||
() => buildTimelineItems(entries, firstUnreadMessageId),
|
||||
[entries, firstUnreadMessageId],
|
||||
);
|
||||
|
||||
for (let i = 0; i < entries.length; i++) {
|
||||
const { message, summary } = entries[i];
|
||||
const messageRenderKey = message.renderKey ?? message.id;
|
||||
React.useEffect(() => {
|
||||
onItems?.(itemsResult);
|
||||
}, [itemsResult, onItems]);
|
||||
|
||||
const dayBoundary = dayGroupBoundariesByStartIndex.get(i);
|
||||
if (dayBoundary) {
|
||||
currentDayGroup = {
|
||||
key: dayBoundary.key,
|
||||
label: formatDayHeading(message.createdAt),
|
||||
elements: [],
|
||||
};
|
||||
dayGroups.push(currentDayGroup);
|
||||
}
|
||||
const renderItem = React.useCallback(
|
||||
(item: TimelineItem) => {
|
||||
switch (item.kind) {
|
||||
case "day-divider":
|
||||
// Heading is resolved at render time (not baked into the item) so
|
||||
// "Today"/"Yesterday" track the wall clock, not build time.
|
||||
return <DayDivider label={formatDayHeading(item.headingTimestamp)} />;
|
||||
case "unread-divider":
|
||||
return <UnreadDivider />;
|
||||
case "system":
|
||||
return (
|
||||
<SystemRow
|
||||
currentPubkey={currentPubkey}
|
||||
entry={item.entry}
|
||||
footer={messageFooters?.[item.entry.message.id] ?? null}
|
||||
onToggleReaction={onToggleReaction}
|
||||
profiles={profiles}
|
||||
/>
|
||||
);
|
||||
case "message":
|
||||
return (
|
||||
<MessageRowItem
|
||||
agentPubkeys={agentPubkeys}
|
||||
channelId={channelId}
|
||||
currentPubkey={currentPubkey}
|
||||
entry={item.entry}
|
||||
followThreadById={followThreadById}
|
||||
footer={messageFooters?.[item.entry.message.id] ?? null}
|
||||
highlightedMessageId={highlightedMessageId}
|
||||
isFollowingThreadById={isFollowingThreadById}
|
||||
onDelete={onDelete}
|
||||
onEdit={onEdit}
|
||||
onMarkUnread={onMarkUnread}
|
||||
onReply={onReply}
|
||||
onToggleReaction={onToggleReaction}
|
||||
profiles={profiles}
|
||||
searchActiveMessageId={searchActiveMessageId}
|
||||
searchMatchingMessageIds={searchMatchingMessageIds}
|
||||
searchQuery={searchQuery}
|
||||
threadUnreadCounts={threadUnreadCounts}
|
||||
unfollowThreadById={unfollowThreadById}
|
||||
videoReviewContext={videoReviewContextById.get(
|
||||
item.entry.message.id,
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
},
|
||||
[
|
||||
agentPubkeys,
|
||||
channelId,
|
||||
currentPubkey,
|
||||
followThreadById,
|
||||
highlightedMessageId,
|
||||
isFollowingThreadById,
|
||||
messageFooters,
|
||||
onDelete,
|
||||
onEdit,
|
||||
onMarkUnread,
|
||||
onReply,
|
||||
onToggleReaction,
|
||||
profiles,
|
||||
searchActiveMessageId,
|
||||
searchMatchingMessageIds,
|
||||
searchQuery,
|
||||
threadUnreadCounts,
|
||||
unfollowThreadById,
|
||||
videoReviewContextById,
|
||||
],
|
||||
);
|
||||
|
||||
// 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}`} />,
|
||||
);
|
||||
}
|
||||
return (
|
||||
<VirtualizedList
|
||||
getItemKey={getTimelineItemKey}
|
||||
innerClassName="flex flex-col"
|
||||
items={itemsResult.items}
|
||||
onVirtualizer={onVirtualizer}
|
||||
renderItem={renderItem}
|
||||
scrollRef={scrollContainerRef}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
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;
|
||||
function SystemRow({
|
||||
currentPubkey,
|
||||
entry,
|
||||
footer,
|
||||
onToggleReaction,
|
||||
profiles,
|
||||
}: {
|
||||
currentPubkey?: string;
|
||||
entry: MainTimelineEntry;
|
||||
footer: React.ReactNode;
|
||||
onToggleReaction?: TimelineMessageListProps["onToggleReaction"];
|
||||
profiles?: UserProfileLookup;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-1 pb-2.5">
|
||||
<SystemMessageRow
|
||||
message={entry.message}
|
||||
currentPubkey={currentPubkey}
|
||||
onToggleReaction={onToggleReaction}
|
||||
profiles={profiles}
|
||||
/>
|
||||
{footer}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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>,
|
||||
);
|
||||
}
|
||||
type MessageRowItemProps = Pick<
|
||||
TimelineMessageListProps,
|
||||
| "agentPubkeys"
|
||||
| "channelId"
|
||||
| "currentPubkey"
|
||||
| "followThreadById"
|
||||
| "highlightedMessageId"
|
||||
| "isFollowingThreadById"
|
||||
| "onDelete"
|
||||
| "onEdit"
|
||||
| "onMarkUnread"
|
||||
| "onReply"
|
||||
| "onToggleReaction"
|
||||
| "profiles"
|
||||
| "searchActiveMessageId"
|
||||
| "searchMatchingMessageIds"
|
||||
| "searchQuery"
|
||||
| "threadUnreadCounts"
|
||||
| "unfollowThreadById"
|
||||
> & {
|
||||
entry: MainTimelineEntry;
|
||||
footer: React.ReactNode;
|
||||
videoReviewContext: ReturnType<typeof buildVideoReviewContextForMessage>;
|
||||
};
|
||||
|
||||
function MessageRowItem({
|
||||
agentPubkeys,
|
||||
channelId,
|
||||
currentPubkey,
|
||||
entry,
|
||||
followThreadById,
|
||||
footer,
|
||||
highlightedMessageId,
|
||||
isFollowingThreadById,
|
||||
onDelete,
|
||||
onEdit,
|
||||
onMarkUnread,
|
||||
onReply,
|
||||
onToggleReaction,
|
||||
profiles,
|
||||
searchActiveMessageId,
|
||||
searchMatchingMessageIds,
|
||||
searchQuery,
|
||||
threadUnreadCounts,
|
||||
unfollowThreadById,
|
||||
videoReviewContext,
|
||||
}: MessageRowItemProps) {
|
||||
const { message, summary } = entry;
|
||||
const canDelete =
|
||||
onDelete && currentPubkey && message.pubkey === currentPubkey
|
||||
? onDelete
|
||||
: undefined;
|
||||
const canEdit =
|
||||
onEdit && currentPubkey && message.pubkey === currentPubkey
|
||||
? onEdit
|
||||
: undefined;
|
||||
|
||||
if (summary && onReply) {
|
||||
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 pb-2.5 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={canDelete}
|
||||
onEdit={canEdit}
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
));
|
||||
});
|
||||
const isSearchMatch = searchMatchingMessageIds?.has(message.id) ?? false;
|
||||
const isSearchActive = message.id === searchActiveMessageId;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1 pb-2.5">
|
||||
<MessageRow
|
||||
agentPubkeys={agentPubkeys}
|
||||
channelId={channelId}
|
||||
highlighted={message.id === highlightedMessageId || isSearchActive}
|
||||
message={message}
|
||||
onDelete={canDelete}
|
||||
onEdit={canEdit}
|
||||
onMarkUnread={onMarkUnread}
|
||||
onToggleReaction={onToggleReaction}
|
||||
onReply={onReply}
|
||||
profiles={profiles}
|
||||
searchQuery={isSearchMatch ? searchQuery : undefined}
|
||||
showDepthGuides={false}
|
||||
videoReviewContext={videoReviewContext}
|
||||
/>
|
||||
{footer}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
import * as React from "react";
|
||||
|
||||
import {
|
||||
type ConvergenceAlign,
|
||||
convergenceStep,
|
||||
} from "@/features/messages/lib/scrollConvergence";
|
||||
import type { ListVirtualizer } from "@/shared/ui/VirtualizedList";
|
||||
|
||||
/** Offset (px) within which the library is considered to have reached the target. */
|
||||
const SETTLE_TOLERANCE_PX = 2;
|
||||
|
||||
type ConvergentScrollOptions = {
|
||||
/** Live message-id -> item-index map, rebuilt with the flattened item stream. */
|
||||
indexByMessageId: Map<string, number>;
|
||||
/** Where the target should land in the viewport. */
|
||||
align: ConvergenceAlign;
|
||||
/** Fired on the settled frame once the target row has converged. */
|
||||
onConverged?: (messageId: string) => void;
|
||||
/** Fired when the loop stops without converging (target deleted, or frame cap). */
|
||||
onAbandoned?: (messageId: string) => void;
|
||||
};
|
||||
|
||||
type ConvergentScrollController = {
|
||||
/**
|
||||
* Begins a convergence loop toward `messageId`. Returns `true` when the id is
|
||||
* present in the data (loop started), `false` when it is absent (never
|
||||
* off-screen-false — only data-absent-false, matching the deep-link contract).
|
||||
* A new call cancels any in-flight loop.
|
||||
*/
|
||||
scrollToMessage: (messageId: string) => boolean;
|
||||
/** Cancels any in-flight convergence loop (e.g. on unmount or channel switch). */
|
||||
cancel: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Drives @tanstack/react-virtual to settle on an off-screen message by id.
|
||||
*
|
||||
* The library already converges OFFSETS: one `scrollToIndex(i)` captures index
|
||||
* `i` and its `reconcileScroll` rAF loop re-aims as rows mount and measure. But
|
||||
* it chases the INDEX captured at call time — a prepend/delete mid-settle leaves
|
||||
* it on the wrong row. This adapter closes that gap: each frame it re-resolves
|
||||
* the target's CURRENT index from the live map (the pure `convergenceStep`
|
||||
* reducer owns the decision) and re-issues `scrollToIndex` ONLY when the index
|
||||
* moved. In steady state it issues nothing, so it never resets the library's
|
||||
* internal stable-frame counter and the library settles in one frame.
|
||||
*
|
||||
* Settle detection is a trivial offset-equality check (NOT the convergence math,
|
||||
* which the library owns): the measured offset for the current index is within
|
||||
* tolerance of where the library would place it, and the offset is unchanged
|
||||
* from the prior frame.
|
||||
*/
|
||||
export function useConvergentScrollToMessage(
|
||||
getVirtualizer: () => ListVirtualizer | null,
|
||||
{
|
||||
indexByMessageId,
|
||||
align,
|
||||
onConverged,
|
||||
onAbandoned,
|
||||
}: ConvergentScrollOptions,
|
||||
): ConvergentScrollController {
|
||||
// Mirror inputs into refs so the rAF loop closure always reads live values
|
||||
// without re-subscribing the loop each render.
|
||||
const mapRef = React.useRef(indexByMessageId);
|
||||
mapRef.current = indexByMessageId;
|
||||
const alignRef = React.useRef(align);
|
||||
alignRef.current = align;
|
||||
const onConvergedRef = React.useRef(onConverged);
|
||||
onConvergedRef.current = onConverged;
|
||||
const onAbandonedRef = React.useRef(onAbandoned);
|
||||
onAbandonedRef.current = onAbandoned;
|
||||
|
||||
const rafIdRef = React.useRef<number | null>(null);
|
||||
|
||||
const cancel = React.useCallback(() => {
|
||||
if (rafIdRef.current !== null) {
|
||||
cancelAnimationFrame(rafIdRef.current);
|
||||
rafIdRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const scrollToMessage = React.useCallback(
|
||||
(messageId: string) => {
|
||||
const startIndex = mapRef.current.get(messageId);
|
||||
if (startIndex === undefined) {
|
||||
return false;
|
||||
}
|
||||
|
||||
cancel();
|
||||
|
||||
let lastIssuedIndex: number | null = null;
|
||||
let previousOffset: number | null = null;
|
||||
let framesUsed = 0;
|
||||
|
||||
const frame = () => {
|
||||
rafIdRef.current = null;
|
||||
const virtualizer = getVirtualizer();
|
||||
if (!virtualizer) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentIndex = mapRef.current.get(messageId);
|
||||
// The library has settled this frame when its offset reached the target
|
||||
// index's offset (within tolerance) and stopped moving. `currentIndex`
|
||||
// is re-read so a settle on a stale index never counts as converged.
|
||||
let librarySettled = false;
|
||||
if (currentIndex !== undefined && lastIssuedIndex === currentIndex) {
|
||||
const offset = virtualizer.scrollOffset ?? 0;
|
||||
const target = virtualizer.getOffsetForIndex(
|
||||
currentIndex,
|
||||
alignRef.current,
|
||||
);
|
||||
const reachedTarget =
|
||||
target !== undefined &&
|
||||
Math.abs(offset - target[0]) <= SETTLE_TOLERANCE_PX;
|
||||
const offsetStable =
|
||||
previousOffset !== null &&
|
||||
Math.abs(offset - previousOffset) <= SETTLE_TOLERANCE_PX;
|
||||
librarySettled = reachedTarget && offsetStable;
|
||||
previousOffset = offset;
|
||||
} else {
|
||||
previousOffset = virtualizer.scrollOffset ?? 0;
|
||||
}
|
||||
|
||||
const decision = convergenceStep({
|
||||
targetMessageId: messageId,
|
||||
indexByMessageId: mapRef.current,
|
||||
lastIssuedIndex,
|
||||
librarySettled,
|
||||
framesUsed,
|
||||
});
|
||||
|
||||
if (
|
||||
decision.nextIndex !== null &&
|
||||
decision.nextIndex !== lastIssuedIndex
|
||||
) {
|
||||
// Re-aim only when the index actually moved — re-issuing the same
|
||||
// index would reset the library's stable-frame counter forever.
|
||||
virtualizer.scrollToIndex(decision.nextIndex, {
|
||||
align: alignRef.current,
|
||||
});
|
||||
lastIssuedIndex = decision.nextIndex;
|
||||
}
|
||||
|
||||
if (decision.done) {
|
||||
if (decision.converged) {
|
||||
onConvergedRef.current?.(messageId);
|
||||
} else {
|
||||
onAbandonedRef.current?.(messageId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
framesUsed += 1;
|
||||
rafIdRef.current = requestAnimationFrame(frame);
|
||||
};
|
||||
|
||||
rafIdRef.current = requestAnimationFrame(frame);
|
||||
return true;
|
||||
},
|
||||
[cancel, getVirtualizer],
|
||||
);
|
||||
|
||||
React.useEffect(() => cancel, [cancel]);
|
||||
|
||||
return { scrollToMessage, cancel };
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
import * as React from "react";
|
||||
|
||||
import type { ListVirtualizer } from "@/shared/ui/VirtualizedList";
|
||||
|
||||
type UseLoadOlderOnScrollOptions = {
|
||||
fetchOlder?: () => Promise<void>;
|
||||
hasOlderMessages: boolean;
|
||||
isLoading: boolean;
|
||||
restoreScrollPosition: (scrollTop: number) => void;
|
||||
scrollContainerRef: React.RefObject<HTMLDivElement | null>;
|
||||
sentinelRef: React.RefObject<HTMLDivElement | null>;
|
||||
/**
|
||||
* When the timeline is virtualized, prepended rows shift every index and are
|
||||
* mounted at an estimate (80px) before they measure, so the `scrollHeight`
|
||||
* delta anchor drifts. Supplying the virtualizer switches to an index anchor:
|
||||
* we hold the first-visible item across the prepend by its NEW index.
|
||||
*/
|
||||
virtualizer?: {
|
||||
getVirtualizer: () => ListVirtualizer | null;
|
||||
itemCount: number;
|
||||
} | 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,
|
||||
virtualizer = null,
|
||||
}: UseLoadOlderOnScrollOptions) {
|
||||
const restoreScrollPositionRef = React.useRef(restoreScrollPosition);
|
||||
React.useEffect(() => {
|
||||
restoreScrollPositionRef.current = restoreScrollPosition;
|
||||
});
|
||||
// Mirror the virtualizer option into a ref so the long-lived Intersection
|
||||
// observer reads the live getter + count without re-subscribing per render.
|
||||
const virtualizerRef = React.useRef(virtualizer);
|
||||
virtualizerRef.current = virtualizer;
|
||||
|
||||
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 virt = virtualizerRef.current;
|
||||
if (virt) {
|
||||
// Index anchor: hold the first rendered item across the prepend.
|
||||
// Capture its index + the gap between its top and the viewport top
|
||||
// BEFORE the fetch; after the prepend shifts indices by N, re-aim at
|
||||
// `oldIndex + N` and restore that same intra-row gap. This is immune
|
||||
// to the estimate->measured height churn that makes a scrollHeight
|
||||
// delta drift.
|
||||
const instance = virt.getVirtualizer();
|
||||
const firstVisible = instance?.getVirtualItems()[0];
|
||||
const previousCount = virt.itemCount;
|
||||
const anchorIndex = firstVisible?.index ?? null;
|
||||
const anchorOffsetIntoRow =
|
||||
firstVisible && instance
|
||||
? (instance.scrollOffset ?? 0) - firstVisible.start
|
||||
: 0;
|
||||
|
||||
void fetchOlder().then(() => {
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
const after = virtualizerRef.current?.getVirtualizer();
|
||||
const prepended =
|
||||
(virtualizerRef.current?.itemCount ?? previousCount) -
|
||||
previousCount;
|
||||
if (after && anchorIndex !== null && prepended > 0) {
|
||||
after.scrollToIndex(anchorIndex + prepended, {
|
||||
align: "start",
|
||||
});
|
||||
// scrollToIndex aligns the row's top to the viewport top;
|
||||
// re-apply the captured gap so the view doesn't nudge by a
|
||||
// partial row.
|
||||
const target = after.getOffsetForIndex(
|
||||
anchorIndex + prepended,
|
||||
"start",
|
||||
);
|
||||
if (target !== undefined) {
|
||||
restoreScrollPositionRef.current(
|
||||
target[0] + anchorOffsetIntoRow,
|
||||
);
|
||||
}
|
||||
}
|
||||
observe();
|
||||
});
|
||||
});
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
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,
|
||||
]);
|
||||
}
|
||||
Reference in New Issue
Block a user