mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
Retain a stable message window in channel timelines (#1698)
Signed-off-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co> Signed-off-by: Tyler Longwell <tlongwell@block.xyz> Signed-off-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@sprout-oss.stage.blox.sqprod.co> Co-authored-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co> Co-authored-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@sprout-oss.stage.blox.sqprod.co> Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
This commit is contained in:
co-authored by
npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757
npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d
Tyler Longwell
parent
1fa91f5691
commit
b0ad2b66dd
@@ -77,6 +77,7 @@
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tiptap-markdown": "^0.9.0",
|
||||
"upng-js": "^2.1.0",
|
||||
"virtua": "0.49.2",
|
||||
"yaml": "^2.8.3",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
|
||||
@@ -76,6 +76,7 @@ export const ChannelPane = React.memo(function ChannelPane({
|
||||
fetchOlder,
|
||||
header,
|
||||
hasOlderMessages,
|
||||
historyExhausted,
|
||||
isFetchingOlder,
|
||||
followThreadById,
|
||||
isFollowingThread,
|
||||
@@ -191,6 +192,7 @@ export const ChannelPane = React.memo(function ChannelPane({
|
||||
timelineScrollRef,
|
||||
composerWrapperRef,
|
||||
`${activeChannelId}:${isSinglePanelView}:${hasMainComposerOverlay}`,
|
||||
"css-variable",
|
||||
);
|
||||
const clearWelcomeComposerDismissTimer = React.useCallback(() => {
|
||||
if (welcomeComposerDismissTimerRef.current !== null) {
|
||||
@@ -617,6 +619,7 @@ export const ChannelPane = React.memo(function ChannelPane({
|
||||
followThreadById={followThreadById}
|
||||
hasComposerOverlay={hasMainComposerOverlay}
|
||||
hasOlderMessages={hasOlderMessages}
|
||||
historyExhausted={historyExhausted}
|
||||
huddleMemberPubkeys={huddleMemberPubkeys}
|
||||
huddleMemberPubkeysPending={huddleMemberPubkeysPending}
|
||||
isFetchingOlder={isFetchingOlder}
|
||||
@@ -660,6 +663,9 @@ export const ChannelPane = React.memo(function ChannelPane({
|
||||
searchMatchingMessageIds={channelFind.matchingMessageIds}
|
||||
searchQuery={channelFind.query}
|
||||
targetMessageId={targetMessageId}
|
||||
splitThreadPanelOpen={
|
||||
useSplitAuxiliaryPane && Boolean(openThreadHeadId)
|
||||
}
|
||||
threadUnreadCounts={threadUnreadCounts}
|
||||
/>
|
||||
{isNonMemberView ? (
|
||||
@@ -690,7 +696,7 @@ export const ChannelPane = React.memo(function ChannelPane({
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="pointer-events-none absolute inset-x-0 bottom-0 z-40"
|
||||
className="pointer-events-none absolute inset-x-0 bottom-0 z-40 bg-background/70 backdrop-blur-md supports-[backdrop-filter]:bg-background/55"
|
||||
data-testid="channel-composer-overlay"
|
||||
ref={composerWrapperRef}
|
||||
>
|
||||
@@ -736,7 +742,7 @@ export const ChannelPane = React.memo(function ChannelPane({
|
||||
}
|
||||
showTopBorder={false}
|
||||
/>
|
||||
<div className="min-h-8 overflow-visible bg-background px-5 pb-1.5 pt-0">
|
||||
<div className="min-h-8 overflow-visible bg-transparent px-5 pb-1.5 pt-0">
|
||||
<div className="flex h-full w-full items-center gap-2 overflow-visible">
|
||||
{hasComposerBotActivity ? (
|
||||
<div className="flex min-w-0 flex-1 overflow-visible">
|
||||
|
||||
@@ -46,6 +46,8 @@ export type ChannelPaneProps = {
|
||||
fetchOlder?: () => Promise<void>;
|
||||
header?: React.ReactNode;
|
||||
hasOlderMessages?: boolean;
|
||||
/** True when the loaded window provably starts at the channel's beginning. */
|
||||
historyExhausted?: boolean;
|
||||
isFetchingOlder?: boolean;
|
||||
isJoining?: boolean;
|
||||
isSinglePanelView?: boolean;
|
||||
|
||||
@@ -80,7 +80,6 @@ import { useChannelProfilePanel } from "./useChannelProfilePanel";
|
||||
import { useChannelRouteTarget } from "./useChannelRouteTarget";
|
||||
import { useChannelUnreadState } from "./useChannelUnreadState";
|
||||
import type { ChannelScreenProps } from "./ChannelScreen.types";
|
||||
|
||||
const HEADER_ACTIONS_COMPACT_BREAKPOINT_PX = 760,
|
||||
EMPTY_RELAY_EVENTS: RelayEvent[] = [];
|
||||
export function ChannelScreen({
|
||||
@@ -190,7 +189,7 @@ export function ChannelScreen({
|
||||
effectiveOpenThreadHeadId,
|
||||
);
|
||||
useChannelSubscription(activeChannel);
|
||||
const { fetchOlder, hasOlderMessages, isFetchingOlder } =
|
||||
const { fetchOlder, hasOlderMessages, historyExhausted, isFetchingOlder } =
|
||||
useFetchOlderMessages(activeChannel);
|
||||
const latestActiveMessage = React.useMemo(() => {
|
||||
const messages = messagesQuery.data;
|
||||
@@ -878,6 +877,7 @@ export function ChannelScreen({
|
||||
fetchOlder={fetchOlder}
|
||||
header={channelHeader}
|
||||
hasOlderMessages={hasOlderMessages}
|
||||
historyExhausted={historyExhausted}
|
||||
onAddAgent={handleOpenAddBot}
|
||||
onCreateChannel={openCreateChannel}
|
||||
onOpenMembers={handleOpenMembersSidebar}
|
||||
|
||||
@@ -3,6 +3,8 @@ import test from "node:test";
|
||||
import {
|
||||
appendOlderChannelWindow,
|
||||
mapChannelWindowEvents,
|
||||
channelWindowHasMore,
|
||||
channelWindowHistoryExhausted,
|
||||
channelWindowThreadSummaries,
|
||||
emptyChannelWindowStore,
|
||||
flattenChannelWindowEvents,
|
||||
@@ -401,3 +403,26 @@ test("mapChannelWindowEvents rewrites live overlay events", () => {
|
||||
"edited-live",
|
||||
);
|
||||
});
|
||||
|
||||
test("exhaustion is unresolved (false) on an empty store, unlike hasMore's default", () => {
|
||||
const store = emptyChannelWindowStore();
|
||||
assert.equal(channelWindowHasMore(store), false);
|
||||
// Both read "no more history", but exhaustion must NOT claim the boundary
|
||||
// is proven before any page resolves — an unloaded window is unknown.
|
||||
assert.equal(channelWindowHistoryExhausted(store), false);
|
||||
});
|
||||
|
||||
test("exhaustion tracks only a resolved tail page's hasMore", () => {
|
||||
const open = replaceNewestChannelWindow(
|
||||
emptyChannelWindowStore(),
|
||||
page(null, [event("newest", 100)], { hasMore: true }),
|
||||
);
|
||||
assert.equal(channelWindowHistoryExhausted(open), false);
|
||||
|
||||
const closed = appendOlderChannelWindow(
|
||||
open,
|
||||
page(open.pages[0].nextCursor, [event("oldest", 99)], { hasMore: false }),
|
||||
);
|
||||
assert.equal(channelWindowHistoryExhausted(closed), true);
|
||||
assert.equal(channelWindowHasMore(closed), false);
|
||||
});
|
||||
|
||||
@@ -282,6 +282,17 @@ export function channelWindowHasMore(store: ChannelWindowStore) {
|
||||
return tail?.hasMore ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the loaded window PROVABLY starts at the channel's beginning. This
|
||||
* is not `!channelWindowHasMore`: an empty store also reports "no more", but
|
||||
* that means the boundary is unresolved (nothing has loaded), not exhausted.
|
||||
* Only a resolved tail page saying `hasMore: false` proves the start.
|
||||
*/
|
||||
export function channelWindowHistoryExhausted(store: ChannelWindowStore) {
|
||||
const tail = store.pages[store.pages.length - 1];
|
||||
return tail !== undefined && !tail.hasMore;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-root thread summaries for badge rendering: authoritative page summaries
|
||||
* overlaid with any fresher relay-pushed live summaries. The live overlay also
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
|
||||
import { timelineImageUrls } from "./timelineImagePreload.ts";
|
||||
|
||||
function message(over = {}) {
|
||||
return {
|
||||
id: "m1",
|
||||
createdAt: 0,
|
||||
author: "a",
|
||||
time: "now",
|
||||
body: "",
|
||||
depth: 0,
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
test("timelineImageUrls warms chrome images but leaves attachments lazy", () => {
|
||||
const urls = timelineImageUrls(
|
||||
message({
|
||||
avatarUrl: "https://example.com/avatar.jpg",
|
||||
body: [
|
||||
"",
|
||||
'',
|
||||
].join("\n"),
|
||||
tags: [
|
||||
["imeta", "url https://example.com/three.jpg", "m image/jpeg"],
|
||||
[
|
||||
"imeta",
|
||||
"url https://example.com/movie.mp4",
|
||||
"m video/mp4",
|
||||
"image https://example.com/poster.jpg",
|
||||
"thumb https://example.com/thumb.jpg",
|
||||
],
|
||||
["emoji", "party", "https://example.com/party.png"],
|
||||
],
|
||||
reactions: [
|
||||
{
|
||||
emoji: ":party:",
|
||||
emojiUrl: "https://example.com/reaction.png",
|
||||
count: 1,
|
||||
users: [],
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
new Set(urls),
|
||||
new Set([
|
||||
"https://example.com/avatar.jpg",
|
||||
"https://example.com/poster.jpg",
|
||||
"https://example.com/thumb.jpg",
|
||||
"https://example.com/party.png",
|
||||
"https://example.com/reaction.png",
|
||||
]),
|
||||
);
|
||||
assert.ok(!urls.includes("https://example.com/one.png"));
|
||||
assert.ok(!urls.includes("https://example.com/three.jpg"));
|
||||
});
|
||||
|
||||
test("timelineImageUrls deduplicates chrome image URLs", () => {
|
||||
const url = "https://example.com/same.png";
|
||||
assert.deepEqual(
|
||||
timelineImageUrls(
|
||||
message({
|
||||
avatarUrl: url,
|
||||
tags: [["emoji", "same", url]],
|
||||
}),
|
||||
),
|
||||
[url],
|
||||
);
|
||||
});
|
||||
|
||||
test("preloadTimelineImages requests URLs once and keeps requests alive", async () => {
|
||||
const { preloadTimelineImages } = await import("./timelineImagePreload.ts");
|
||||
const previousImage = globalThis.Image;
|
||||
const requested = [];
|
||||
class FakeImage extends EventTarget {
|
||||
set src(url) {
|
||||
requested.push(url);
|
||||
}
|
||||
}
|
||||
globalThis.Image = FakeImage;
|
||||
try {
|
||||
const state = { activeImages: new Set(), requestedUrls: new Set() };
|
||||
const messages = [message({ avatarUrl: "https://example.com/one.png" })];
|
||||
preloadTimelineImages(messages, state);
|
||||
preloadTimelineImages(messages, state);
|
||||
|
||||
assert.deepEqual(requested, ["https://example.com/one.png"]);
|
||||
assert.equal(state.activeImages.size, 1);
|
||||
state.activeImages.values().next().value.dispatchEvent(new Event("load"));
|
||||
assert.equal(state.activeImages.size, 0);
|
||||
} finally {
|
||||
globalThis.Image = previousImage;
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
import { rewriteRelayUrl } from "@/shared/lib/mediaUrl";
|
||||
import type { TimelineMessage } from "../types";
|
||||
import { parseImetaTags } from "./parseImeta";
|
||||
|
||||
/**
|
||||
* Return non-message-media image URLs worth warming before a virtualized row
|
||||
* mounts. Inline image attachments deliberately stay out of this projection:
|
||||
* their native-lazy thumbnail must load before the full-resolution request.
|
||||
*/
|
||||
export function timelineImageUrls(message: TimelineMessage): string[] {
|
||||
const urls = new Set<string>();
|
||||
const add = (url: string | null | undefined) => {
|
||||
if (url) urls.add(rewriteRelayUrl(url));
|
||||
};
|
||||
|
||||
add(message.avatarUrl);
|
||||
|
||||
if (message.tags) {
|
||||
for (const entry of parseImetaTags(message.tags).values()) {
|
||||
// Video poster frames are not part of the progressive image path.
|
||||
if (entry.m?.startsWith("video/")) {
|
||||
add(entry.image);
|
||||
add(entry.thumb);
|
||||
}
|
||||
}
|
||||
for (const tag of message.tags) {
|
||||
if (tag[0] === "emoji") add(tag[2]);
|
||||
}
|
||||
}
|
||||
|
||||
for (const reaction of message.reactions ?? []) add(reaction.emojiUrl);
|
||||
return [...urls];
|
||||
}
|
||||
|
||||
export type TimelineImagePreloadState = {
|
||||
activeImages: Set<HTMLImageElement>;
|
||||
requestedUrls: Set<string>;
|
||||
};
|
||||
|
||||
/** Start all image requests now, independently of Virtua row mounting. */
|
||||
export function preloadTimelineImages(
|
||||
messages: readonly TimelineMessage[],
|
||||
state: TimelineImagePreloadState,
|
||||
): void {
|
||||
for (const message of messages) {
|
||||
for (const url of timelineImageUrls(message)) {
|
||||
if (state.requestedUrls.has(url)) continue;
|
||||
state.requestedUrls.add(url);
|
||||
const image = new Image();
|
||||
state.activeImages.add(image);
|
||||
const release = () => state.activeImages.delete(image);
|
||||
image.addEventListener("load", release, { once: true });
|
||||
image.addEventListener("error", release, { once: true });
|
||||
image.src = url;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -118,7 +118,7 @@ test("buildTimelineItems: member additions by one actor group within five minute
|
||||
group?.entries.map((groupEntry) => groupEntry.message.id),
|
||||
["a", "b", "c"],
|
||||
);
|
||||
assert.equal(group?.key, "a");
|
||||
assert.equal(group?.key, "c");
|
||||
});
|
||||
|
||||
test("buildTimelineItems: self-joins group across different members within five minutes", () => {
|
||||
@@ -146,7 +146,29 @@ test("buildTimelineItems: self-joins group across different members within five
|
||||
);
|
||||
});
|
||||
|
||||
test("buildTimelineItems: member-add window is fixed from the first addition", () => {
|
||||
test("buildTimelineItems: prepending membership history preserves the loaded suffix", () => {
|
||||
const start = dayAt(2026, 6, 14);
|
||||
const loaded = [
|
||||
memberAddedEntry({ id: "b", target: "target-b", createdAt: start + 240 }),
|
||||
memberAddedEntry({ id: "c", target: "target-c", createdAt: start + 360 }),
|
||||
entry({ id: "message", createdAt: start + 600 }),
|
||||
];
|
||||
const prepended = [
|
||||
memberAddedEntry({ id: "a", target: "target-a", createdAt: start }),
|
||||
...loaded,
|
||||
];
|
||||
|
||||
const loadedItems = buildTimelineItems(loaded, null).items;
|
||||
const prependedItems = buildTimelineItems(prepended, null).items;
|
||||
const loadedKeys = loadedItems.slice(1).map((item) => item.key);
|
||||
const prependedKeys = prependedItems.slice(1).map((item) => item.key);
|
||||
|
||||
assert.deepEqual(loadedKeys, ["c", "message"]);
|
||||
assert.deepEqual(prependedKeys, ["a", "c", "message"]);
|
||||
assert.deepEqual(prependedKeys.slice(-loadedKeys.length), loadedKeys);
|
||||
});
|
||||
|
||||
test("buildTimelineItems: member-add window is fixed from the newest addition", () => {
|
||||
const start = dayAt(2026, 6, 14);
|
||||
const entries = [
|
||||
memberAddedEntry({ id: "a", target: "target-a", createdAt: start }),
|
||||
@@ -155,7 +177,7 @@ test("buildTimelineItems: member-add window is fixed from the first addition", (
|
||||
];
|
||||
|
||||
const { items } = buildTimelineItems(entries, null);
|
||||
assert.deepEqual(kinds(items), ["day-divider", "system-group", "system"]);
|
||||
assert.deepEqual(kinds(items), ["day-divider", "system", "system-group"]);
|
||||
});
|
||||
|
||||
test("buildTimelineItems: actor changes and intervening rows break member-add groups", () => {
|
||||
|
||||
@@ -101,6 +101,60 @@ function parseMembershipChangePayload(
|
||||
}
|
||||
}
|
||||
|
||||
function membershipChangesCanGroup(
|
||||
first: MembershipChangePayload,
|
||||
second: MembershipChangePayload,
|
||||
): boolean {
|
||||
return (
|
||||
first.mode === second.mode &&
|
||||
(first.mode === "joined" || first.actor === second.actor)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Membership groups are anchored from their newest entry so prepending older
|
||||
* history cannot repartition the rows that are already loaded. Their key is
|
||||
* likewise the newest entry's key: extending the oldest visible group changes
|
||||
* its contents, but not its identity or the virtual list's existing key suffix.
|
||||
*/
|
||||
function buildMembershipGroups(
|
||||
entries: readonly MainTimelineEntry[],
|
||||
barrierIndexes: ReadonlySet<number>,
|
||||
): Map<number, MainTimelineEntry[]> {
|
||||
const groups = new Map<number, MainTimelineEntry[]>();
|
||||
|
||||
for (let end = entries.length - 1; end >= 0; ) {
|
||||
const newestEntry = entries[end];
|
||||
const newestPayload = parseMembershipChangePayload(newestEntry);
|
||||
if (!newestPayload) {
|
||||
end -= 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
let start = end;
|
||||
while (start > 0) {
|
||||
const candidate = entries[start - 1];
|
||||
const candidatePayload = parseMembershipChangePayload(candidate);
|
||||
if (
|
||||
barrierIndexes.has(start) ||
|
||||
!candidatePayload ||
|
||||
!membershipChangesCanGroup(candidatePayload, newestPayload) ||
|
||||
newestEntry.message.createdAt < candidate.message.createdAt ||
|
||||
newestEntry.message.createdAt - candidate.message.createdAt >
|
||||
MEMBERSHIP_GROUP_WINDOW_SECONDS
|
||||
) {
|
||||
break;
|
||||
}
|
||||
start -= 1;
|
||||
}
|
||||
|
||||
if (start < end) groups.set(start, entries.slice(start, end + 1));
|
||||
end = start - 1;
|
||||
}
|
||||
|
||||
return groups;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
@@ -113,7 +167,6 @@ export function buildTimelineItems(
|
||||
const items: TimelineItem[] = [];
|
||||
let previousGroupEntry: MainTimelineEntry | null = null;
|
||||
let previousMessageItemIndex: number | null = null;
|
||||
let previousMembershipItemIndex: number | null = null;
|
||||
|
||||
// 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
|
||||
@@ -124,6 +177,17 @@ export function buildTimelineItems(
|
||||
(boundary: DayGroupBoundary) => [boundary.startIndex, boundary] as const,
|
||||
),
|
||||
);
|
||||
const membershipBarrierIndexes = new Set(dayBoundariesByStartIndex.keys());
|
||||
if (firstUnreadMessageId) {
|
||||
const unreadIndex = entries.findIndex(
|
||||
(entry) => entry.message.id === firstUnreadMessageId,
|
||||
);
|
||||
if (unreadIndex > 0) membershipBarrierIndexes.add(unreadIndex);
|
||||
}
|
||||
const membershipGroupsByStartIndex = buildMembershipGroups(
|
||||
entries,
|
||||
membershipBarrierIndexes,
|
||||
);
|
||||
|
||||
for (let i = 0; i < entries.length; i++) {
|
||||
const entry = entries[i];
|
||||
@@ -134,7 +198,6 @@ export function buildTimelineItems(
|
||||
if (dayBoundary) {
|
||||
previousGroupEntry = null;
|
||||
previousMessageItemIndex = null;
|
||||
previousMembershipItemIndex = null;
|
||||
items.push({
|
||||
kind: "day-divider",
|
||||
key: dayBoundary.key,
|
||||
@@ -145,7 +208,6 @@ export function buildTimelineItems(
|
||||
if (shouldRenderUnreadDivider(i, message.id, firstUnreadMessageId)) {
|
||||
previousGroupEntry = null;
|
||||
previousMessageItemIndex = null;
|
||||
previousMembershipItemIndex = null;
|
||||
items.push({ kind: "unread-divider", key: `unread-${renderKey}` });
|
||||
}
|
||||
|
||||
@@ -154,48 +216,22 @@ export function buildTimelineItems(
|
||||
previousGroupEntry = null;
|
||||
previousMessageItemIndex = null;
|
||||
|
||||
const membershipChange = parseMembershipChangePayload(entry);
|
||||
const previousItem =
|
||||
previousMembershipItemIndex === null
|
||||
? null
|
||||
: items[previousMembershipItemIndex];
|
||||
const previousEntries =
|
||||
previousItem?.kind === "system-group"
|
||||
? previousItem.entries
|
||||
: previousItem?.kind === "system"
|
||||
? [previousItem.entry]
|
||||
: [];
|
||||
const firstPreviousEntry = previousEntries[0];
|
||||
const firstPreviousPayload = firstPreviousEntry
|
||||
? parseMembershipChangePayload(firstPreviousEntry)
|
||||
: null;
|
||||
|
||||
if (
|
||||
membershipChange &&
|
||||
firstPreviousEntry &&
|
||||
firstPreviousPayload?.mode === membershipChange.mode &&
|
||||
(membershipChange.mode === "joined" ||
|
||||
firstPreviousPayload.actor === membershipChange.actor) &&
|
||||
message.createdAt >= firstPreviousEntry.message.createdAt &&
|
||||
message.createdAt - firstPreviousEntry.message.createdAt <=
|
||||
MEMBERSHIP_GROUP_WINDOW_SECONDS
|
||||
) {
|
||||
const groupIndex = previousMembershipItemIndex as number;
|
||||
items[groupIndex] = {
|
||||
const membershipGroup = membershipGroupsByStartIndex.get(i);
|
||||
if (membershipGroup) {
|
||||
const newestEntry = membershipGroup[membershipGroup.length - 1];
|
||||
items.push({
|
||||
kind: "system-group",
|
||||
key: entryRenderKey(firstPreviousEntry),
|
||||
entries: [...previousEntries, entry],
|
||||
};
|
||||
key: entryRenderKey(newestEntry),
|
||||
entries: membershipGroup,
|
||||
});
|
||||
i += membershipGroup.length - 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
items.push({ kind, key: renderKey, entry });
|
||||
previousMembershipItemIndex = membershipChange ? items.length - 1 : null;
|
||||
continue;
|
||||
}
|
||||
|
||||
previousMembershipItemIndex = null;
|
||||
|
||||
const isContinuation =
|
||||
previousGroupEntry !== null &&
|
||||
hasSameMessageAuthor(previousGroupEntry.message, message) &&
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
buildVirtualizedItems,
|
||||
didPrependVirtualizedTimeline,
|
||||
virtualizedItemKey,
|
||||
} from "./virtualizedTimelineItems.ts";
|
||||
|
||||
// Timestamps: day A = 2026-06-01, day B = 2026-06-02, day Z = 2026-05-31.
|
||||
function dayAt(year, month, day, hour = 12, minute = 0) {
|
||||
return Math.floor(
|
||||
new Date(year, month - 1, day, hour, minute, 0).getTime() / 1_000,
|
||||
);
|
||||
}
|
||||
|
||||
function messageItem(key) {
|
||||
return {
|
||||
kind: "message",
|
||||
key,
|
||||
entry: { message: { id: key } },
|
||||
isContinuation: false,
|
||||
isFollowedByContinuation: false,
|
||||
};
|
||||
}
|
||||
|
||||
function group(dayKey, headingTimestamp, itemKeys) {
|
||||
return {
|
||||
key: dayKey,
|
||||
headingTimestamp,
|
||||
items: itemKeys.map(messageItem),
|
||||
};
|
||||
}
|
||||
|
||||
const DAY_A = dayAt(2026, 6, 1);
|
||||
const DAY_B = dayAt(2026, 6, 2);
|
||||
const DAY_Z = dayAt(2026, 5, 31);
|
||||
|
||||
function keysOf(dayGroups, { leading = undefined, exhausted = false } = {}) {
|
||||
return buildVirtualizedItems(dayGroups, leading, exhausted).map(
|
||||
virtualizedItemKey,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Virtua's `shift` moves every cached slot uniformly by the length delta.
|
||||
* With shift admitted, assert every previous key's cached height lands on the
|
||||
* SAME logical item in the new list.
|
||||
*/
|
||||
function assertShiftAdmittedAndCacheClean(previousKeys, keys) {
|
||||
assert.equal(didPrependVirtualizedTimeline(previousKeys, keys), true);
|
||||
const delta = keys.length - previousKeys.length;
|
||||
previousKeys.forEach((key, index) => {
|
||||
assert.equal(
|
||||
keys[index + delta],
|
||||
key,
|
||||
`cached height at slot ${index} (${key}) would land on ${keys[index + delta]}`,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
test("oldest day carries no divider while more history exists", () => {
|
||||
const keys = keysOf([group("day-A", DAY_A, ["a3", "a4", "a5"])]);
|
||||
assert.deepEqual(keys, ["a3", "a4", "a5", "bottom-spacer"]);
|
||||
});
|
||||
|
||||
test("oldest day divider renders once history is exhausted", () => {
|
||||
const keys = keysOf([group("day-A", DAY_A, ["a3", "a4", "a5"])], {
|
||||
exhausted: true,
|
||||
});
|
||||
assert.deepEqual(keys, [
|
||||
"day-divider:day-A",
|
||||
"a3",
|
||||
"a4",
|
||||
"a5",
|
||||
"bottom-spacer",
|
||||
]);
|
||||
});
|
||||
|
||||
test("interior day boundaries always render dividers", () => {
|
||||
const keys = keysOf([
|
||||
group("day-A", DAY_A, ["a3"]),
|
||||
group("day-B", DAY_B, ["b1", "b2"]),
|
||||
]);
|
||||
assert.deepEqual(keys, [
|
||||
"a3",
|
||||
"day-divider:day-B",
|
||||
"b1",
|
||||
"b2",
|
||||
"bottom-spacer",
|
||||
]);
|
||||
});
|
||||
|
||||
test("undated group never renders a divider even when proven", () => {
|
||||
const keys = keysOf(
|
||||
[group("day-undated", null, ["u1"]), group("day-B", DAY_B, ["b1"])],
|
||||
{ exhausted: true },
|
||||
);
|
||||
assert.deepEqual(keys, ["u1", "day-divider:day-B", "b1", "bottom-spacer"]);
|
||||
});
|
||||
|
||||
test("same-day prepend into the oldest day admits shift with a clean cache", () => {
|
||||
const previous = keysOf([
|
||||
group("day-A", DAY_A, ["a3", "a4", "a5"]),
|
||||
group("day-B", DAY_B, ["b1"]),
|
||||
]);
|
||||
const next = keysOf([
|
||||
group("day-A", DAY_A, ["a1", "a2", "a3", "a4", "a5"]),
|
||||
group("day-B", DAY_B, ["b1"]),
|
||||
]);
|
||||
assertShiftAdmittedAndCacheClean(previous, next);
|
||||
});
|
||||
|
||||
test("cross-day prepend materializes the newly proven divider as pure prefix", () => {
|
||||
const previous = keysOf([
|
||||
group("day-A", DAY_A, ["a3", "a4", "a5"]),
|
||||
group("day-B", DAY_B, ["b1"]),
|
||||
]);
|
||||
// Day Z loads before day A: day A's boundary is now proven, so BOTH the
|
||||
// z-rows and day A's divider enter as prefix.
|
||||
const next = keysOf([
|
||||
group("day-Z", DAY_Z, ["z1", "z2"]),
|
||||
group("day-A", DAY_A, ["a3", "a4", "a5"]),
|
||||
group("day-B", DAY_B, ["b1"]),
|
||||
]);
|
||||
assert.deepEqual(
|
||||
next.slice(0, 4),
|
||||
["z1", "z2", "day-divider:day-A", "a3"],
|
||||
"day-Z itself is now the unproven oldest day and carries no divider",
|
||||
);
|
||||
assertShiftAdmittedAndCacheClean(previous, next);
|
||||
});
|
||||
|
||||
test("mixed page (older day rows + same-day completion) admits shift", () => {
|
||||
const previous = keysOf([
|
||||
group("day-A", DAY_A, ["a3", "a4", "a5"]),
|
||||
group("day-B", DAY_B, ["b1"]),
|
||||
]);
|
||||
const next = keysOf([
|
||||
group("day-Z", DAY_Z, ["z9"]),
|
||||
group("day-A", DAY_A, ["a1", "a2", "a3", "a4", "a5"]),
|
||||
group("day-B", DAY_B, ["b1"]),
|
||||
]);
|
||||
assertShiftAdmittedAndCacheClean(previous, next);
|
||||
});
|
||||
|
||||
test("history exhaustion with an empty page inserts the divider as pure prefix", () => {
|
||||
const groups = [
|
||||
group("day-A", DAY_A, ["a3", "a4", "a5"]),
|
||||
group("day-B", DAY_B, ["b1"]),
|
||||
];
|
||||
const previous = keysOf(groups);
|
||||
const next = keysOf(groups, { exhausted: true });
|
||||
assert.deepEqual(next.slice(0, 2), ["day-divider:day-A", "a3"]);
|
||||
assertShiftAdmittedAndCacheClean(previous, next);
|
||||
});
|
||||
|
||||
test("leading content arriving with the final page stays a pure prefix", () => {
|
||||
const previous = keysOf([
|
||||
group("day-A", DAY_A, ["a3", "a4", "a5"]),
|
||||
group("day-B", DAY_B, ["b1"]),
|
||||
]);
|
||||
const next = keysOf(
|
||||
[
|
||||
group("day-A", DAY_A, ["a1", "a2", "a3", "a4", "a5"]),
|
||||
group("day-B", DAY_B, ["b1"]),
|
||||
],
|
||||
{ leading: "intro", exhausted: true },
|
||||
);
|
||||
assert.deepEqual(next.slice(0, 2), ["leading-content", "day-divider:day-A"]);
|
||||
assertShiftAdmittedAndCacheClean(previous, next);
|
||||
});
|
||||
|
||||
test("divider count and order match proven boundaries exactly", () => {
|
||||
const items = buildVirtualizedItems(
|
||||
[
|
||||
group("day-Z", DAY_Z, ["z1"]),
|
||||
group("day-A", DAY_A, ["a1"]),
|
||||
group("day-B", DAY_B, ["b1"]),
|
||||
],
|
||||
undefined,
|
||||
true,
|
||||
);
|
||||
const dividers = items.filter((item) => item.kind === "day-divider");
|
||||
assert.deepEqual(
|
||||
dividers.map((item) => item.key),
|
||||
["day-divider:day-Z", "day-divider:day-A", "day-divider:day-B"],
|
||||
);
|
||||
// Each divider precedes its day's first message.
|
||||
const keys = items.map(virtualizedItemKey);
|
||||
assert.equal(keys.indexOf("day-divider:day-Z"), keys.indexOf("z1") - 1);
|
||||
assert.equal(keys.indexOf("day-divider:day-A"), keys.indexOf("a1") - 1);
|
||||
assert.equal(keys.indexOf("day-divider:day-B"), keys.indexOf("b1") - 1);
|
||||
});
|
||||
|
||||
test("naive counterexample stays rejected: same key cannot precede prepended rows", () => {
|
||||
// Regression pin for the shape Wren vetoed: if the oldest day's divider
|
||||
// existed BEFORE the same-day prepend, admission must fail.
|
||||
assert.equal(
|
||||
didPrependVirtualizedTimeline(
|
||||
["day-divider:day-A", "a3", "a4", "a5", "bottom-spacer"],
|
||||
["day-divider:day-A", "a1", "a2", "a3", "a4", "a5", "bottom-spacer"],
|
||||
),
|
||||
false,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* Pure item-assembly for the virtualized timeline row stream.
|
||||
*
|
||||
* Kept free of React rendering (only a type-level ReactNode) so the
|
||||
* exact-suffix `shift`-admission invariants are covered by the lib-level
|
||||
* `*.test.mjs` suite.
|
||||
*/
|
||||
|
||||
import type * as React from "react";
|
||||
|
||||
import {
|
||||
getTimelineItemKey,
|
||||
type TimelineDayGroup,
|
||||
type TimelineNonDayItem,
|
||||
} from "./timelineItems";
|
||||
|
||||
export type VirtualizedTimelineItem =
|
||||
| {
|
||||
kind: "leading-content";
|
||||
content: React.ReactNode;
|
||||
}
|
||||
| { kind: "bottom-spacer" }
|
||||
| { kind: "day-divider"; key: string; headingTimestamp: number }
|
||||
| {
|
||||
kind: "timeline-item";
|
||||
item: TimelineNonDayItem;
|
||||
};
|
||||
|
||||
export function virtualizedItemKey(item: VirtualizedTimelineItem): string {
|
||||
if (item.kind === "bottom-spacer") return "bottom-spacer";
|
||||
if (item.kind === "leading-content") return "leading-content";
|
||||
if (item.kind === "day-divider") return item.key;
|
||||
return getTimelineItemKey(item.item);
|
||||
}
|
||||
|
||||
/**
|
||||
* Flattens day groups into the virtual row stream.
|
||||
*
|
||||
* Day dividers are standalone items emitted only at PROVEN day boundaries: a
|
||||
* boundary is proven when a strictly older loaded day precedes it in the
|
||||
* window, or when history is exhausted (the window provably starts at the
|
||||
* channel's beginning). The oldest loaded day gets no divider while more
|
||||
* history exists — its "start" is just the arbitrary edge of the loaded
|
||||
* window, and a divider item there would have to accept older same-day rows
|
||||
* prepending BEHIND it, which breaks the exact-suffix key admission that
|
||||
* Virtua's `shift` depends on. (The previous in-row divider avoided that key
|
||||
* problem but made the day's head row change shape mid-commit when the
|
||||
* divider migrated off it, causing a one-frame anchor jump at page merges.)
|
||||
* A proven boundary is immutable — any strictly older message belongs to a
|
||||
* strictly older day — so its divider only ever enters the stream as part of
|
||||
* a prepended prefix and never mutates the existing key suffix.
|
||||
*/
|
||||
export function buildVirtualizedItems(
|
||||
dayGroups: readonly TimelineDayGroup[],
|
||||
leadingContent: React.ReactNode | undefined,
|
||||
historyExhausted: boolean,
|
||||
): VirtualizedTimelineItem[] {
|
||||
const timelineItems = dayGroups.flatMap((group, groupIndex) => {
|
||||
const boundaryProven = groupIndex > 0 || historyExhausted;
|
||||
const divider =
|
||||
group.headingTimestamp !== null && boundaryProven
|
||||
? [
|
||||
{
|
||||
kind: "day-divider" as const,
|
||||
// Namespaced so a divider key can never collide with any
|
||||
// timeline item key (message ids, `unread-*`, sentinels).
|
||||
key: `day-divider:${group.key}`,
|
||||
headingTimestamp: group.headingTimestamp,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
return [
|
||||
...divider,
|
||||
...group.items.map((item) => ({
|
||||
kind: "timeline-item" as const,
|
||||
item,
|
||||
})),
|
||||
];
|
||||
});
|
||||
|
||||
return [
|
||||
...(leadingContent
|
||||
? [
|
||||
{
|
||||
kind: "leading-content" as const,
|
||||
content: leadingContent,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...timelineItems,
|
||||
{ kind: "bottom-spacer" as const },
|
||||
];
|
||||
}
|
||||
|
||||
export function didPrependVirtualizedTimeline(
|
||||
previousKeys: readonly string[],
|
||||
keys: readonly string[],
|
||||
): boolean {
|
||||
const prependedCount = keys.length - previousKeys.length;
|
||||
// Virtua shifts its positional size cache by the length delta, so enable
|
||||
// `shift` only when every previous slot is the exact suffix it expects.
|
||||
return (
|
||||
prependedCount > 0 &&
|
||||
previousKeys.every((key, index) => key === keys[index + prependedCount])
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { getDmParticipantPreview } from "@/features/channels/lib/dmParticipantDisplay";
|
||||
import { UserAvatar } from "@/shared/ui/UserAvatar";
|
||||
|
||||
export type DirectMessageIntroParticipant = {
|
||||
avatarUrl: string | null;
|
||||
displayName: string;
|
||||
pubkey: string;
|
||||
};
|
||||
|
||||
export function DirectMessageIntroAvatarStack({
|
||||
participants,
|
||||
}: {
|
||||
participants: DirectMessageIntroParticipant[];
|
||||
}) {
|
||||
const { hiddenCount, visibleParticipants } =
|
||||
getDmParticipantPreview(participants);
|
||||
const stackItemCount = visibleParticipants.length + (hiddenCount > 0 ? 1 : 0);
|
||||
|
||||
return (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="flex shrink-0 items-center"
|
||||
data-testid="message-dm-intro-avatar-stack"
|
||||
>
|
||||
{visibleParticipants.map((participant, index) => (
|
||||
<div
|
||||
className={index > 0 ? "-ml-5" : ""}
|
||||
data-testid="message-dm-intro-avatar-stack-participant"
|
||||
key={participant.pubkey}
|
||||
style={{
|
||||
zIndex: index + 1,
|
||||
...(index < stackItemCount - 1 && {
|
||||
mask: "radial-gradient(circle 34px at calc(100% + 10px) 50%, transparent 99%, #fff 100%)",
|
||||
WebkitMask:
|
||||
"radial-gradient(circle 34px at calc(100% + 10px) 50%, transparent 99%, #fff 100%)",
|
||||
}),
|
||||
}}
|
||||
>
|
||||
<UserAvatar
|
||||
avatarUrl={participant.avatarUrl}
|
||||
className="h-[60px] w-[60px] text-base"
|
||||
displayName={participant.displayName}
|
||||
size="md"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
{hiddenCount > 0 ? (
|
||||
<div
|
||||
className={visibleParticipants.length > 0 ? "-ml-5" : ""}
|
||||
data-testid="message-dm-intro-avatar-stack-more"
|
||||
style={{ zIndex: stackItemCount }}
|
||||
>
|
||||
<span className="flex h-[60px] w-[60px] items-center justify-center rounded-full bg-secondary font-semibold text-secondary-foreground shadow-xs">
|
||||
<span className="text-lg leading-none">+{hiddenCount}</span>
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -902,7 +902,7 @@ function MessageComposerImpl({
|
||||
>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="absolute inset-x-0 bottom-0 h-5 bg-background"
|
||||
className="absolute inset-x-0 bottom-0 h-5 bg-transparent"
|
||||
/>
|
||||
<div className="relative flex w-full flex-col gap-0">
|
||||
<ComposerReplyEditBanner
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
selectTimelineBodySurface,
|
||||
selectTimelineIntroSurface,
|
||||
} from "@/features/messages/lib/timelineSnapshot";
|
||||
import { getDmParticipantPreview } from "@/features/channels/lib/dmParticipantDisplay";
|
||||
import { preloadTimelineImages } from "@/features/messages/lib/timelineImagePreload";
|
||||
import type { TimelineMessage } from "@/features/messages/types";
|
||||
import type { MainTimelineEntry } from "@/features/messages/lib/threadPanel";
|
||||
import type { ChannelWindowThreadSummary } from "@/features/messages/lib/channelWindowStore";
|
||||
@@ -18,11 +18,17 @@ 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 { TimelineSkeleton, useTimelineSkeletonRows } from "./TimelineSkeleton";
|
||||
import { TimelineMessageList } from "./TimelineMessageList";
|
||||
import type { TimelineVirtualizerApi } from "./TimelineMessageList";
|
||||
import { useAnchoredScroll } from "./useAnchoredScroll";
|
||||
import { useLoadOlderOnScroll } from "./useLoadOlderOnScroll";
|
||||
import { useBufferedTimelineMessages } from "./useBufferedTimelineMessages";
|
||||
import {
|
||||
DirectMessageIntroAvatarStack,
|
||||
type DirectMessageIntroParticipant,
|
||||
} from "./DirectMessageIntroAvatarStack";
|
||||
import { useSettleGatedPrependMessages } from "./useSettleGatedPrependMessages";
|
||||
|
||||
export type MessageTimelineHandle = {
|
||||
scrollToBottomOnNextUpdate: () => void;
|
||||
@@ -51,6 +57,12 @@ type MessageTimelineProps = {
|
||||
currentPubkey?: string;
|
||||
fetchOlder?: () => Promise<void>;
|
||||
hasOlderMessages?: boolean;
|
||||
/**
|
||||
* True when the loaded window provably starts at the channel's beginning
|
||||
* (a resolved tail page with `hasMore: false`) — NOT merely the absence of
|
||||
* a paging signal. Gates the oldest loaded day's divider.
|
||||
*/
|
||||
historyExhausted?: boolean;
|
||||
/** Optional external ref to the scroll container — used by the parent to
|
||||
* observe scroll position or adjust padding dynamically. */
|
||||
scrollContainerRef?: React.RefObject<HTMLDivElement | null>;
|
||||
@@ -91,6 +103,7 @@ type MessageTimelineProps = {
|
||||
searchQuery?: string;
|
||||
targetMessageId?: string | null;
|
||||
onTargetReached?: (messageId: string) => void;
|
||||
splitThreadPanelOpen?: boolean;
|
||||
/** Event id of the oldest unread top-level message at channel open, or null. */
|
||||
firstUnreadMessageId?: string | null;
|
||||
/** Count of unread top-level messages at channel open. */
|
||||
@@ -120,20 +133,26 @@ type ChannelIntro = {
|
||||
* message list. Must be module-level so its identity never changes. */
|
||||
const EMPTY_MESSAGES: TimelineMessage[] = [];
|
||||
|
||||
type DirectMessageIntroParticipant = {
|
||||
avatarUrl: string | null;
|
||||
displayName: string;
|
||||
pubkey: string;
|
||||
};
|
||||
|
||||
type TimelineSnapshot = {
|
||||
channelId: string | null;
|
||||
messages: TimelineMessage[];
|
||||
/**
|
||||
* History-exhaustion proof captured with the SAME rows it was derived from.
|
||||
* The oldest-day divider may only exist when this is true, and rows and
|
||||
* proof must travel every transport stage (deferral, buffering, settle
|
||||
* gating) as one value: delivering a fresh proof on the urgent render path
|
||||
* while the rows ride the deferred path lets an intermediate commit mint a
|
||||
* divider against the previous, partially-loaded oldest day — which breaks
|
||||
* Virtua's exact-suffix shift admission when the withheld same-day rows
|
||||
* finally land (the pass-1 tear, ledgered 2026-07-11).
|
||||
*/
|
||||
historyExhausted: boolean;
|
||||
};
|
||||
|
||||
const EMPTY_TIMELINE_SNAPSHOT: TimelineSnapshot = {
|
||||
channelId: null,
|
||||
messages: EMPTY_MESSAGES,
|
||||
historyExhausted: false,
|
||||
};
|
||||
|
||||
const MessageTimelineBase = React.forwardRef<
|
||||
@@ -155,6 +174,7 @@ const MessageTimelineBase = React.forwardRef<
|
||||
fetchOlder,
|
||||
hasComposerOverlay = true,
|
||||
hasOlderMessages = true,
|
||||
historyExhausted = false,
|
||||
isFetchingOlder = false,
|
||||
followThreadById,
|
||||
huddleMemberPubkeys,
|
||||
@@ -181,6 +201,7 @@ const MessageTimelineBase = React.forwardRef<
|
||||
searchQuery,
|
||||
targetMessageId = null,
|
||||
onTargetReached,
|
||||
splitThreadPanelOpen = false,
|
||||
firstUnreadMessageId = null,
|
||||
unreadCount = 0,
|
||||
threadUnreadCounts,
|
||||
@@ -191,6 +212,21 @@ const MessageTimelineBase = React.forwardRef<
|
||||
const scrollContainerRef = externalScrollRef ?? internalScrollRef;
|
||||
const contentRef = React.useRef<HTMLDivElement>(null);
|
||||
const topSentinelRef = React.useRef<HTMLDivElement>(null);
|
||||
const [virtualizerScrollParent, setVirtualizerScrollParent] =
|
||||
React.useState<HTMLDivElement | null>(null);
|
||||
const [virtualizerRenderVersion, bumpVirtualizerRenderVersion] =
|
||||
React.useReducer((version: number) => version + 1, 0);
|
||||
const [timelineVirtualizerApi, setTimelineVirtualizerApi] =
|
||||
React.useState<TimelineVirtualizerApi | null>(null);
|
||||
const useTimelineVirtualizer = true;
|
||||
const activeScrollContainerRef = React.useMemo(
|
||||
() => ({
|
||||
get current() {
|
||||
return virtualizerScrollParent ?? scrollContainerRef.current;
|
||||
},
|
||||
}),
|
||||
[scrollContainerRef, virtualizerScrollParent],
|
||||
);
|
||||
|
||||
// Gate the heavy timeline render (each row runs a synchronous
|
||||
// react-markdown parse) behind React concurrency. `useDeferredValue` lets the
|
||||
@@ -207,14 +243,21 @@ const MessageTimelineBase = React.forwardRef<
|
||||
// route change can paint the previous channel's deferred rows for a frame even
|
||||
// though the sidebar/header already moved to the new channel.
|
||||
const liveSnapshot = React.useMemo<TimelineSnapshot>(
|
||||
() => ({ channelId: channelId ?? null, messages }),
|
||||
[channelId, messages],
|
||||
() => ({ channelId: channelId ?? null, messages, historyExhausted }),
|
||||
[channelId, historyExhausted, messages],
|
||||
);
|
||||
const deferredSnapshot = React.useDeferredValue(
|
||||
liveSnapshot,
|
||||
EMPTY_TIMELINE_SNAPSHOT,
|
||||
);
|
||||
const deferredMessages = deferredSnapshot.messages;
|
||||
const imagePreloadStateRef = React.useRef({
|
||||
activeImages: new Set<HTMLImageElement>(),
|
||||
requestedUrls: new Set<string>(),
|
||||
});
|
||||
React.useEffect(() => {
|
||||
preloadTimelineImages(messages, imagePreloadStateRef.current);
|
||||
}, [messages]);
|
||||
const isDeferredSnapshotStale = isDeferredTimelineSnapshotStale({
|
||||
deferredSnapshot,
|
||||
liveSnapshot,
|
||||
@@ -230,12 +273,60 @@ const MessageTimelineBase = React.forwardRef<
|
||||
// painted at a stale offset until the user's next scroll event forces layout.
|
||||
const scrollContainerDomKey = channelId ?? "none";
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
// Re-read after `scrollContainerDomKey` swaps the keyed scroll DOM node.
|
||||
void scrollContainerDomKey;
|
||||
if (!useTimelineVirtualizer) {
|
||||
setVirtualizerScrollParent(scrollContainerRef.current);
|
||||
}
|
||||
setTimelineVirtualizerApi(null);
|
||||
}, [scrollContainerRef, scrollContainerDomKey]);
|
||||
|
||||
const timelineBodySurface = selectTimelineBodySurface({
|
||||
deferredCount: deferredMessages.length,
|
||||
isLoading: isLoading || isDeferredSnapshotStale,
|
||||
liveCount: messages.length,
|
||||
});
|
||||
const showTimelineSkeleton = timelineBodySurface === "skeleton";
|
||||
const [isSemanticallyAtBottom, setIsSemanticallyAtBottom] =
|
||||
React.useState(true);
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: reset semantic tail state when the active channel changes
|
||||
React.useEffect(() => {
|
||||
setIsSemanticallyAtBottom(true);
|
||||
}, [channelId]);
|
||||
// Zulip-style data semantics: once the reader leaves the bottom, keep the
|
||||
// virtualizer's logical tail frozen. Live arrivals accumulate behind the
|
||||
// "new messages" affordance instead of changing Virtua's item model under
|
||||
// the reading position. Prepends still flow through immediately and Virtua's
|
||||
// `shift` transaction preserves the stable keyed row.
|
||||
const bufferedTimeline = useBufferedTimelineMessages({
|
||||
channelId,
|
||||
isAtBottom:
|
||||
isSemanticallyAtBottom ||
|
||||
targetMessageId !== null ||
|
||||
searchActiveMessageId !== null,
|
||||
messages: deferredMessages,
|
||||
});
|
||||
// Hold older-page render commits until the scroller is at rest: WKWebView
|
||||
// can drop scrollTop compensation writes during live trackpad momentum.
|
||||
// Full rationale in useSettleGatedPrependMessages.
|
||||
//
|
||||
// The history-exhaustion proof rides through this gate as snapshot metadata
|
||||
// (`meta`), so while a prepend is withheld the rendered rows keep the proof
|
||||
// they were projected with. The buffering stage above cannot split the pair:
|
||||
// it only freezes the TAIL (live arrivals) and passes history prepends
|
||||
// through unchanged, so the oldest rows the proof speaks about are exactly
|
||||
// the deferred snapshot's oldest rows.
|
||||
const {
|
||||
messages: renderedMessages,
|
||||
meta: renderedHistoryExhausted,
|
||||
isHoldingPrepend,
|
||||
} = useSettleGatedPrependMessages({
|
||||
channelId,
|
||||
messages: bufferedTimeline.messages,
|
||||
meta: deferredSnapshot.historyExhausted,
|
||||
scrollElementRef: activeScrollContainerRef,
|
||||
});
|
||||
|
||||
const {
|
||||
highlightedMessageId,
|
||||
@@ -245,21 +336,85 @@ const MessageTimelineBase = React.forwardRef<
|
||||
scrollToBottom,
|
||||
scrollToBottomOnNextUpdate,
|
||||
scrollToMessage,
|
||||
onVirtualizerAtBottomStateChange,
|
||||
} = useAnchoredScroll({
|
||||
channelId,
|
||||
contentRef,
|
||||
isLoading: showTimelineSkeleton,
|
||||
messages: deferredMessages,
|
||||
messages: renderedMessages,
|
||||
onTargetReached,
|
||||
scrollContainerRef,
|
||||
scrollContainerRef: activeScrollContainerRef,
|
||||
splitPanelOpen: splitThreadPanelOpen,
|
||||
targetMessageId,
|
||||
virtualScrollToMessage: timelineVirtualizerApi?.scrollToMessage,
|
||||
virtualScrollToBottom: timelineVirtualizerApi?.scrollToBottom,
|
||||
virtualSettleAtBottom: timelineVirtualizerApi?.settleAtBottom,
|
||||
virtualizerOwnsPrependAnchoring: useTimelineVirtualizer,
|
||||
virtualizerRenderVersion,
|
||||
});
|
||||
|
||||
const hasConfirmedVirtualizerBottomRef = React.useRef(false);
|
||||
const bottomConfirmationChannelRef = React.useRef(channelId);
|
||||
if (bottomConfirmationChannelRef.current !== channelId) {
|
||||
bottomConfirmationChannelRef.current = channelId;
|
||||
hasConfirmedVirtualizerBottomRef.current = false;
|
||||
}
|
||||
const suppressNextSemanticBottomRef = React.useRef(false);
|
||||
const semanticAtBottomRef = React.useRef(isSemanticallyAtBottom);
|
||||
semanticAtBottomRef.current = isSemanticallyAtBottom;
|
||||
const semanticBottomRafRef = React.useRef<number | null>(null);
|
||||
const queueSemanticBottom = React.useCallback((atBottom: boolean) => {
|
||||
semanticAtBottomRef.current = atBottom;
|
||||
if (semanticBottomRafRef.current !== null) {
|
||||
window.cancelAnimationFrame(semanticBottomRafRef.current);
|
||||
}
|
||||
semanticBottomRafRef.current = window.requestAnimationFrame(() => {
|
||||
semanticBottomRafRef.current = null;
|
||||
setIsSemanticallyAtBottom(atBottom);
|
||||
});
|
||||
}, []);
|
||||
React.useEffect(
|
||||
() => () => {
|
||||
if (semanticBottomRafRef.current !== null) {
|
||||
window.cancelAnimationFrame(semanticBottomRafRef.current);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
const handleVirtualizerAtBottomStateChange = React.useCallback(
|
||||
(atBottom: boolean) => {
|
||||
// Virtua can emit an intermediate non-bottom offset while its initial
|
||||
// scroll-to-end is still converging. Do not turn that mount transient
|
||||
// into a semantic dataset freeze: wait until this channel has reached a
|
||||
// confirmed bottom once, then track genuine bottom -> history movement.
|
||||
if (atBottom) {
|
||||
hasConfirmedVirtualizerBottomRef.current = true;
|
||||
onVirtualizerAtBottomStateChange(true);
|
||||
if (suppressNextSemanticBottomRef.current) {
|
||||
// Freezing the tail shortens Virtua's model and can itself make the
|
||||
// current offset report "at bottom". That synthetic transition must
|
||||
// not immediately release the snapshot and oscillate forever.
|
||||
suppressNextSemanticBottomRef.current = false;
|
||||
} else if (!semanticAtBottomRef.current) {
|
||||
queueSemanticBottom(true);
|
||||
}
|
||||
} else if (hasConfirmedVirtualizerBottomRef.current) {
|
||||
onVirtualizerAtBottomStateChange(false);
|
||||
if (semanticAtBottomRef.current) {
|
||||
suppressNextSemanticBottomRef.current = true;
|
||||
queueSemanticBottom(false);
|
||||
}
|
||||
}
|
||||
},
|
||||
[onVirtualizerAtBottomStateChange, queueSemanticBottom],
|
||||
);
|
||||
|
||||
const timelineIntroSurface = selectTimelineIntroSurface({
|
||||
hasChannelIntro: channelIntro !== null && directMessageIntro === null,
|
||||
hasDirectMessageIntro: directMessageIntro !== null,
|
||||
hasReachedChannelStart:
|
||||
!isRenderedTimelineBehindHistoryPrepend(deferredMessages, messages) &&
|
||||
!isHoldingPrepend &&
|
||||
(messages.length === 0 || (!hasOlderMessages && !isFetchingOlder)),
|
||||
isSkeletonVisible: showTimelineSkeleton,
|
||||
});
|
||||
@@ -270,16 +425,25 @@ const MessageTimelineBase = React.forwardRef<
|
||||
? directMessageIntro
|
||||
: null;
|
||||
const activeChannelIntro = showChannelIntro ? channelIntro : null;
|
||||
const showIntro = showDirectMessageIntro || showChannelIntro;
|
||||
const showIntro =
|
||||
activeDirectMessageIntro !== null || activeChannelIntro !== null;
|
||||
const showGenericEmpty = timelineBodySurface === "empty" && !showIntro;
|
||||
const showMessageList = timelineBodySurface === "list";
|
||||
|
||||
const prepareForOwnMessage = React.useCallback(() => {
|
||||
// The user's own send is the deliberate Zulip exception: release buffered
|
||||
// output before arming the next-append bottom pin so the sent row can enter
|
||||
// Virtua's model and become the new physical floor.
|
||||
setIsSemanticallyAtBottom(true);
|
||||
scrollToBottomOnNextUpdate();
|
||||
}, [scrollToBottomOnNextUpdate]);
|
||||
|
||||
React.useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
scrollToBottomOnNextUpdate,
|
||||
scrollToBottomOnNextUpdate: prepareForOwnMessage,
|
||||
}),
|
||||
[scrollToBottomOnNextUpdate],
|
||||
[prepareForOwnMessage],
|
||||
);
|
||||
|
||||
// Jump-to-message is purely DOM-based now: all loaded rows are mounted, so
|
||||
@@ -349,20 +513,64 @@ const MessageTimelineBase = React.forwardRef<
|
||||
}
|
||||
}, [jumpToMessage, searchActiveMessageId, showTimelineSkeleton]);
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: deferredMessages is the intentional retry trigger — a search hit outside the initial window is spliced into messages asynchronously, and the DOM scroll should retry when that row commits.
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: deferredMessages and virtualizerRenderVersion are intentional retry triggers — a search hit may be spliced into messages asynchronously, and in virtualized mode a phase-1 index jump only realizes the row; retry when the rendered range changes so the DOM-visible path can center and highlight it.
|
||||
React.useEffect(() => {
|
||||
const target = pendingSearchTargetRef.current;
|
||||
if (!target || showTimelineSkeleton) return;
|
||||
if (
|
||||
useTimelineVirtualizer &&
|
||||
!activeScrollContainerRef.current?.querySelector(
|
||||
`[data-message-id="${CSS.escape(target)}"]`,
|
||||
)
|
||||
) {
|
||||
// Phase 1: ask the virtualizer to realize the match's index. The retry effect
|
||||
// runs again on range change and the DOM-visible path does the actual
|
||||
// center + highlight once the row exists.
|
||||
void jumpToMessage(target, { behavior: "auto" });
|
||||
return;
|
||||
}
|
||||
if (jumpToMessage(target, { behavior: "auto" })) {
|
||||
pendingSearchTargetRef.current = null;
|
||||
}
|
||||
}, [deferredMessages, jumpToMessage, showTimelineSkeleton]);
|
||||
}, [
|
||||
deferredMessages,
|
||||
jumpToMessage,
|
||||
showTimelineSkeleton,
|
||||
virtualizerRenderVersion,
|
||||
]);
|
||||
|
||||
useLoadOlderOnScroll({
|
||||
const loadOlderViaVirtualizer = React.useCallback((): boolean => {
|
||||
// Indexed find navigation can legitimately land near the current history
|
||||
// boundary. Do not mistake that programmatic jump for scrollback intent and
|
||||
// prepend underneath the active match.
|
||||
// A settle-gate hold means the reader is still parked at the OLD
|
||||
// boundary — don't stack more page fetches behind the held commit.
|
||||
if (
|
||||
searchActiveMessageId ||
|
||||
!fetchOlder ||
|
||||
isFetchingOlder ||
|
||||
isHoldingPrepend ||
|
||||
showTimelineSkeleton ||
|
||||
!hasOlderMessages
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
void fetchOlder();
|
||||
return true;
|
||||
}, [
|
||||
fetchOlder,
|
||||
hasOlderMessages,
|
||||
isFetchingOlder,
|
||||
isHoldingPrepend,
|
||||
searchActiveMessageId,
|
||||
showTimelineSkeleton,
|
||||
]);
|
||||
|
||||
useLoadOlderOnScroll({
|
||||
fetchOlder: useTimelineVirtualizer ? undefined : fetchOlder,
|
||||
hasOlderMessages,
|
||||
isLoading: showTimelineSkeleton,
|
||||
scrollContainerRef,
|
||||
scrollContainerRef: activeScrollContainerRef,
|
||||
sentinelRef: topSentinelRef,
|
||||
});
|
||||
|
||||
@@ -372,6 +580,145 @@ const MessageTimelineBase = React.forwardRef<
|
||||
messages: showTimelineSkeleton ? EMPTY_MESSAGES : deferredMessages,
|
||||
});
|
||||
|
||||
const virtualizedLeadingContent = React.useMemo(
|
||||
() =>
|
||||
activeChannelIntro ? (
|
||||
<div
|
||||
className="flex w-full max-w-2xl flex-col items-start px-3 pb-4 pt-2 text-left"
|
||||
data-testid="message-channel-intro"
|
||||
>
|
||||
<div className="flex h-[60px] w-[60px] items-center justify-center rounded-2xl border border-border/70 bg-muted/40 text-muted-foreground">
|
||||
{activeChannelIntro.icon ?? (
|
||||
<Hash aria-hidden className="h-7 w-7" />
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-4 max-w-full truncate text-xl font-semibold leading-7 tracking-tight text-foreground">
|
||||
#{activeChannelIntro.channelName}
|
||||
</p>
|
||||
<p className="mt-1 max-w-full text-sm leading-5 text-muted-foreground">
|
||||
This is the beginning of the{" "}
|
||||
<span className="font-medium text-foreground">
|
||||
{activeChannelIntro.channelKindLabel}
|
||||
</span>
|
||||
.
|
||||
</p>
|
||||
{activeChannelIntro.description ? (
|
||||
<p className="mt-2 max-w-xl text-sm leading-5 text-muted-foreground">
|
||||
{activeChannelIntro.description}
|
||||
</p>
|
||||
) : null}
|
||||
{activeChannelIntro.actions?.length ? (
|
||||
<div className="mt-4 flex max-w-full flex-nowrap gap-3 overflow-x-auto pb-1">
|
||||
{activeChannelIntro.actions.map((action) => (
|
||||
<button
|
||||
className={cn(
|
||||
"flex shrink-0 border border-border/70 bg-background/70 text-left transition-colors hover:bg-muted/60 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring",
|
||||
action.description
|
||||
? "h-56 w-[13.75rem] flex-col rounded-2xl p-4"
|
||||
: "h-28 w-64 flex-col rounded-2xl p-4",
|
||||
)}
|
||||
data-testid={action.testId}
|
||||
key={action.label}
|
||||
onClick={action.onClick}
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"flex shrink-0 items-center justify-center rounded-full bg-muted/70 text-muted-foreground",
|
||||
action.description
|
||||
? "h-12 w-12 [&_svg]:h-6 [&_svg]:w-6"
|
||||
: "h-10 w-10 [&_svg]:h-4 [&_svg]:w-4",
|
||||
)}
|
||||
>
|
||||
{action.icon}
|
||||
</span>
|
||||
<span className="mt-auto min-w-0">
|
||||
<span className="block whitespace-normal break-words text-base font-medium leading-6 text-foreground">
|
||||
{action.label}
|
||||
</span>
|
||||
{action.description ? (
|
||||
<span className="mt-1 block whitespace-normal break-words text-sm leading-5 text-muted-foreground">
|
||||
{action.description}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : activeDirectMessageIntro ? (
|
||||
<div
|
||||
className="mb-2 flex w-full flex-col items-start px-3 pb-2 pt-2 text-left"
|
||||
data-testid="message-dm-intro"
|
||||
>
|
||||
<DirectMessageIntroAvatarStack
|
||||
participants={activeDirectMessageIntro.participants}
|
||||
/>
|
||||
<p className="mt-4 max-w-full truncate text-xl font-semibold leading-7 tracking-tight text-foreground">
|
||||
{activeDirectMessageIntro.displayName}
|
||||
</p>
|
||||
<p className="mt-1 max-w-full truncate whitespace-nowrap text-sm leading-5 text-muted-foreground">
|
||||
This is the beginning of your direct message with{" "}
|
||||
<span className="font-medium text-foreground">
|
||||
{activeDirectMessageIntro.displayName}
|
||||
</span>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
) : null,
|
||||
[activeChannelIntro, activeDirectMessageIntro],
|
||||
);
|
||||
|
||||
const handleVirtualizerRangeChanged = React.useCallback(() => {
|
||||
bumpVirtualizerRenderVersion();
|
||||
}, []);
|
||||
|
||||
const timelineList = showMessageList ? (
|
||||
<TimelineMessageList
|
||||
key={scrollContainerDomKey}
|
||||
agentPubkeys={agentPubkeys}
|
||||
channelId={channelId}
|
||||
channelName={channelName}
|
||||
channelType={channelType}
|
||||
currentPubkey={currentPubkey}
|
||||
firstUnreadMessageId={firstUnreadMessageId}
|
||||
followThreadById={followThreadById}
|
||||
highlightedMessageId={highlightedMessageId}
|
||||
huddleMemberPubkeys={huddleMemberPubkeys}
|
||||
huddleMemberPubkeysPending={huddleMemberPubkeysPending}
|
||||
isFollowingThreadById={isFollowingThreadById}
|
||||
isMessageUnreadById={isMessageUnreadById}
|
||||
messageFooters={messageFooters}
|
||||
mainEntries={renderedMessages === messages ? mainEntries : undefined}
|
||||
leadingContent={virtualizedLeadingContent}
|
||||
historyExhausted={renderedHistoryExhausted}
|
||||
threadSummaries={threadSummaries}
|
||||
messages={renderedMessages}
|
||||
onDelete={onDelete}
|
||||
onEdit={onEdit}
|
||||
onMarkUnread={onMarkUnread}
|
||||
onMarkRead={onMarkRead}
|
||||
onReply={onReply}
|
||||
isSendingVideoReviewComment={isSendingVideoReviewComment}
|
||||
onSendVideoReviewComment={onSendVideoReviewComment}
|
||||
onStartReached={loadOlderViaVirtualizer}
|
||||
onToggleReaction={onToggleReaction}
|
||||
onVirtualizerApiChange={setTimelineVirtualizerApi}
|
||||
onVirtualizerRangeChanged={handleVirtualizerRangeChanged}
|
||||
onVirtualizerScrollerChange={setVirtualizerScrollParent}
|
||||
onAtBottomStateChange={handleVirtualizerAtBottomStateChange}
|
||||
personaLookup={personaLookup}
|
||||
profiles={profiles}
|
||||
searchActiveMessageId={searchActiveMessageId}
|
||||
searchMatchingMessageIds={searchMatchingMessageIds}
|
||||
searchQuery={searchQuery}
|
||||
useVirtualizer={useTimelineVirtualizer}
|
||||
threadUnreadCounts={threadUnreadCounts}
|
||||
unfollowThreadById={unfollowThreadById}
|
||||
/>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<TooltipProvider delayDuration={200}>
|
||||
<div className="relative flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
|
||||
@@ -391,8 +738,10 @@ const MessageTimelineBase = React.forwardRef<
|
||||
</div>
|
||||
) : null}
|
||||
{/* `isFetchingOlder` clears on fetch resolve, but rows paint a frame
|
||||
later off the deferred snapshot — keep the spinner up until then. */}
|
||||
later (deferred snapshot / settle-gate hold) — keep the spinner up
|
||||
until the page actually renders. */}
|
||||
{isFetchingOlder ||
|
||||
isHoldingPrepend ||
|
||||
isRenderedTimelineBehindHistoryPrepend(deferredMessages, messages) ? (
|
||||
<div
|
||||
className={cn(
|
||||
@@ -408,217 +757,211 @@ const MessageTimelineBase = React.forwardRef<
|
||||
) : null}
|
||||
<div
|
||||
className={cn(
|
||||
"absolute inset-0 overflow-y-auto overflow-x-hidden overscroll-contain px-2 pt-1",
|
||||
hasComposerOverlay ? "pb-24" : "pb-4",
|
||||
"absolute inset-0 overflow-hidden",
|
||||
(!useTimelineVirtualizer || !showMessageList) &&
|
||||
cn(
|
||||
"overflow-y-auto overflow-x-hidden overscroll-contain px-2 pt-1",
|
||||
hasComposerOverlay
|
||||
? "pb-[var(--composer-overlay-height,6rem)]"
|
||||
: "pb-4",
|
||||
),
|
||||
)}
|
||||
data-buzz-conversation-scroll
|
||||
data-buzz-conversation-scroll={
|
||||
useTimelineVirtualizer && showMessageList ? undefined : "true"
|
||||
}
|
||||
data-scroll-restoration-id={scrollRestorationId}
|
||||
data-testid="message-timeline"
|
||||
data-testid={
|
||||
useTimelineVirtualizer && showMessageList
|
||||
? undefined
|
||||
: "message-timeline"
|
||||
}
|
||||
key={scrollContainerDomKey}
|
||||
onScroll={onScroll}
|
||||
onScroll={useTimelineVirtualizer ? undefined : onScroll}
|
||||
ref={scrollContainerRef}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex w-full flex-col gap-2",
|
||||
channelChrome.contentPadding,
|
||||
(showIntro || showGenericEmpty || showMessageList) &&
|
||||
"min-h-full",
|
||||
)}
|
||||
ref={contentRef}
|
||||
>
|
||||
<div ref={topSentinelRef} aria-hidden className="h-px" />
|
||||
|
||||
{/* Fixed-height slot: an always-mounted height keeps the virtual
|
||||
spacer's offset stable across the load-older fetch toggle, so
|
||||
`scrollMargin` doesn't shift mid-fetch and yank the restore. The
|
||||
visible fetch spinner lives in the absolute overlay above, which
|
||||
does not occupy inline flow. */}
|
||||
<div aria-hidden className="h-8" />
|
||||
|
||||
{useTimelineVirtualizer && timelineList ? (
|
||||
<div
|
||||
className="h-full min-h-0 w-full"
|
||||
data-render-pending={isRenderPending ? "true" : undefined}
|
||||
>
|
||||
{timelineList}
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className={cn(
|
||||
"flex min-h-[18rem] min-w-0 flex-col gap-2",
|
||||
(showIntro || showGenericEmpty) && "min-h-full",
|
||||
showMessageList && !showIntro && "mt-auto",
|
||||
"flex w-full flex-col gap-2",
|
||||
channelChrome.contentPadding,
|
||||
(showIntro || showGenericEmpty || showMessageList) &&
|
||||
"min-h-full",
|
||||
)}
|
||||
ref={contentRef}
|
||||
>
|
||||
{showTimelineSkeleton ? (
|
||||
<TimelineSkeleton rows={timelineSkeletonRows} />
|
||||
) : null}
|
||||
{activeDirectMessageIntro ? (
|
||||
<div
|
||||
className="mt-auto flex w-full flex-col items-start px-3 py-2 text-left"
|
||||
data-testid="message-dm-intro"
|
||||
>
|
||||
<DirectMessageIntroAvatarStack
|
||||
participants={activeDirectMessageIntro.participants}
|
||||
/>
|
||||
<p className="mt-4 max-w-full truncate text-xl font-semibold leading-7 tracking-tight text-foreground">
|
||||
{activeDirectMessageIntro.displayName}
|
||||
</p>
|
||||
<p className="mt-1 max-w-full truncate whitespace-nowrap text-sm leading-5 text-muted-foreground">
|
||||
This is the beginning of your direct message with{" "}
|
||||
<span className="font-medium text-foreground">
|
||||
{activeDirectMessageIntro.displayName}
|
||||
</span>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
<div ref={topSentinelRef} aria-hidden className="h-px" />
|
||||
|
||||
{activeChannelIntro ? (
|
||||
<div
|
||||
className="mt-auto flex w-full max-w-2xl flex-col items-start px-3 py-2 text-left"
|
||||
data-testid="message-channel-intro"
|
||||
>
|
||||
{/* Fixed-height slot: an always-mounted height keeps the virtual
|
||||
spacer's offset stable across the load-older fetch toggle, so
|
||||
`scrollMargin` doesn't shift mid-fetch and yank the restore. The
|
||||
visible fetch spinner lives in the absolute overlay above, which
|
||||
does not occupy inline flow. */}
|
||||
<div aria-hidden className="h-8" />
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"flex min-h-[18rem] min-w-0 flex-col gap-2",
|
||||
useTimelineVirtualizer && "min-h-0 flex-1",
|
||||
(showIntro || showGenericEmpty) && "min-h-full",
|
||||
showMessageList &&
|
||||
!showIntro &&
|
||||
!useTimelineVirtualizer &&
|
||||
"mt-auto",
|
||||
)}
|
||||
>
|
||||
{showTimelineSkeleton ? (
|
||||
<TimelineSkeleton rows={timelineSkeletonRows} />
|
||||
) : null}
|
||||
{activeDirectMessageIntro ? (
|
||||
<div
|
||||
className="flex h-[60px] w-[60px] items-center justify-center rounded-2xl border border-border/70 bg-muted/40 text-muted-foreground"
|
||||
data-testid="message-channel-intro-icon"
|
||||
className="mt-auto flex w-full flex-col items-start px-3 py-2 text-left"
|
||||
data-testid="message-dm-intro"
|
||||
>
|
||||
{activeChannelIntro.icon ?? (
|
||||
<Hash aria-hidden className="h-7 w-7" />
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-4 max-w-full truncate text-xl font-semibold leading-7 tracking-tight text-foreground">
|
||||
#{activeChannelIntro.channelName}
|
||||
</p>
|
||||
<p className="mt-1 max-w-full text-sm leading-5 text-muted-foreground">
|
||||
This is the beginning of the{" "}
|
||||
<span className="font-medium text-foreground">
|
||||
{activeChannelIntro.channelKindLabel}
|
||||
</span>
|
||||
.
|
||||
</p>
|
||||
{activeChannelIntro.description ? (
|
||||
<p className="mt-2 max-w-xl text-sm leading-5 text-muted-foreground">
|
||||
{activeChannelIntro.description}
|
||||
<DirectMessageIntroAvatarStack
|
||||
participants={activeDirectMessageIntro.participants}
|
||||
/>
|
||||
<p className="mt-4 max-w-full truncate text-xl font-semibold leading-7 tracking-tight text-foreground">
|
||||
{activeDirectMessageIntro.displayName}
|
||||
</p>
|
||||
) : null}
|
||||
{activeChannelIntro.actions?.length ? (
|
||||
<div className="mt-4 flex max-w-full flex-nowrap gap-3 overflow-x-auto pb-1">
|
||||
{activeChannelIntro.actions.map((action) => {
|
||||
const hasDescription = Boolean(action.description);
|
||||
<p className="mt-1 max-w-full truncate whitespace-nowrap text-sm leading-5 text-muted-foreground">
|
||||
This is the beginning of your direct message with{" "}
|
||||
<span className="font-medium text-foreground">
|
||||
{activeDirectMessageIntro.displayName}
|
||||
</span>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
"flex shrink-0 border border-border/70 bg-background/70 text-left transition-colors hover:bg-muted/60 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring",
|
||||
hasDescription
|
||||
? "h-56 w-[13.75rem] flex-col rounded-2xl p-4"
|
||||
: "h-28 w-64 flex-col rounded-2xl p-4",
|
||||
)}
|
||||
data-testid={action.testId}
|
||||
key={action.label}
|
||||
onClick={action.onClick}
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
{activeChannelIntro ? (
|
||||
<div
|
||||
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
|
||||
className="flex h-[60px] w-[60px] items-center justify-center rounded-2xl border border-border/70 bg-muted/40 text-muted-foreground"
|
||||
data-testid="message-channel-intro-icon"
|
||||
>
|
||||
{activeChannelIntro.icon ?? (
|
||||
<Hash aria-hidden className="h-7 w-7" />
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-4 max-w-full truncate text-xl font-semibold leading-7 tracking-tight text-foreground">
|
||||
#{activeChannelIntro.channelName}
|
||||
</p>
|
||||
<p className="mt-1 max-w-full text-sm leading-5 text-muted-foreground">
|
||||
This is the beginning of the{" "}
|
||||
<span className="font-medium text-foreground">
|
||||
{activeChannelIntro.channelKindLabel}
|
||||
</span>
|
||||
.
|
||||
</p>
|
||||
{activeChannelIntro.description ? (
|
||||
<p className="mt-2 max-w-xl text-sm leading-5 text-muted-foreground">
|
||||
{activeChannelIntro.description}
|
||||
</p>
|
||||
) : null}
|
||||
{activeChannelIntro.actions?.length ? (
|
||||
<div className="mt-4 flex max-w-full flex-nowrap gap-3 overflow-x-auto pb-1">
|
||||
{activeChannelIntro.actions.map((action) => {
|
||||
const hasDescription = Boolean(action.description);
|
||||
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
"flex shrink-0 items-center justify-center rounded-full bg-muted/70 text-muted-foreground",
|
||||
"flex shrink-0 border border-border/70 bg-background/70 text-left transition-colors hover:bg-muted/60 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring",
|
||||
hasDescription
|
||||
? "h-12 w-12 [&_svg]:h-6 [&_svg]:w-6"
|
||||
: "h-10 w-10 [&_svg]:h-4 [&_svg]:w-4",
|
||||
? "h-56 w-[13.75rem] flex-col rounded-2xl p-4"
|
||||
: "h-28 w-64 flex-col rounded-2xl p-4",
|
||||
)}
|
||||
data-testid={
|
||||
action.testId
|
||||
? `${action.testId}-icon`
|
||||
: undefined
|
||||
}
|
||||
data-testid={action.testId}
|
||||
key={action.label}
|
||||
onClick={action.onClick}
|
||||
type="button"
|
||||
>
|
||||
{action.icon}
|
||||
</span>
|
||||
<span className="mt-auto min-w-0">
|
||||
<span
|
||||
className="block whitespace-normal break-words text-base font-medium leading-6 text-foreground"
|
||||
className={cn(
|
||||
"flex shrink-0 items-center justify-center rounded-full bg-muted/70 text-muted-foreground",
|
||||
hasDescription
|
||||
? "h-12 w-12 [&_svg]:h-6 [&_svg]:w-6"
|
||||
: "h-10 w-10 [&_svg]:h-4 [&_svg]:w-4",
|
||||
)}
|
||||
data-testid={
|
||||
action.testId
|
||||
? `${action.testId}-title`
|
||||
? `${action.testId}-icon`
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{action.label}
|
||||
{action.icon}
|
||||
</span>
|
||||
{action.description ? (
|
||||
<span className="mt-auto min-w-0">
|
||||
<span
|
||||
className="mt-1 block whitespace-normal break-words text-sm leading-5 text-muted-foreground"
|
||||
className="block whitespace-normal break-words text-base font-medium leading-6 text-foreground"
|
||||
data-testid={
|
||||
action.testId
|
||||
? `${action.testId}-description`
|
||||
? `${action.testId}-title`
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{action.description}
|
||||
{action.label}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
{action.description ? (
|
||||
<span
|
||||
className="mt-1 block whitespace-normal break-words text-sm leading-5 text-muted-foreground"
|
||||
data-testid={
|
||||
action.testId
|
||||
? `${action.testId}-description`
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{action.description}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{showGenericEmpty ? (
|
||||
<div
|
||||
className="mt-auto rounded-2xl border border-dashed border-border/80 bg-card/70 px-6 py-10 text-center shadow-xs"
|
||||
data-testid="message-empty"
|
||||
>
|
||||
<p className="text-base font-semibold tracking-tight">
|
||||
{emptyTitle}
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
{emptyDescription}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
{showGenericEmpty ? (
|
||||
<div
|
||||
className="mt-auto rounded-2xl border border-dashed border-border/80 bg-card/70 px-6 py-10 text-center shadow-xs"
|
||||
data-testid="message-empty"
|
||||
>
|
||||
<p className="text-base font-semibold tracking-tight">
|
||||
{emptyTitle}
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
{emptyDescription}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{showMessageList ? (
|
||||
<div
|
||||
className={cn("flex flex-col gap-2", !showIntro && "mt-auto")}
|
||||
data-render-pending={isRenderPending ? "true" : undefined}
|
||||
>
|
||||
<TimelineMessageList
|
||||
key={scrollContainerDomKey}
|
||||
agentPubkeys={agentPubkeys}
|
||||
channelId={channelId}
|
||||
channelName={channelName}
|
||||
channelType={channelType}
|
||||
currentPubkey={currentPubkey}
|
||||
firstUnreadMessageId={firstUnreadMessageId}
|
||||
followThreadById={followThreadById}
|
||||
highlightedMessageId={highlightedMessageId}
|
||||
huddleMemberPubkeys={huddleMemberPubkeys}
|
||||
huddleMemberPubkeysPending={huddleMemberPubkeysPending}
|
||||
isFollowingThreadById={isFollowingThreadById}
|
||||
isMessageUnreadById={isMessageUnreadById}
|
||||
messageFooters={messageFooters}
|
||||
mainEntries={
|
||||
deferredMessages === messages ? mainEntries : undefined
|
||||
}
|
||||
threadSummaries={threadSummaries}
|
||||
messages={deferredMessages}
|
||||
onDelete={onDelete}
|
||||
onEdit={onEdit}
|
||||
onMarkUnread={onMarkUnread}
|
||||
onMarkRead={onMarkRead}
|
||||
onReply={onReply}
|
||||
isSendingVideoReviewComment={isSendingVideoReviewComment}
|
||||
onSendVideoReviewComment={onSendVideoReviewComment}
|
||||
onToggleReaction={onToggleReaction}
|
||||
personaLookup={personaLookup}
|
||||
profiles={profiles}
|
||||
searchActiveMessageId={searchActiveMessageId}
|
||||
searchMatchingMessageIds={searchMatchingMessageIds}
|
||||
searchQuery={searchQuery}
|
||||
threadUnreadCounts={threadUnreadCounts}
|
||||
unfollowThreadById={unfollowThreadById}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
{showMessageList ? (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col gap-2",
|
||||
!showIntro && !useTimelineVirtualizer && "mt-auto",
|
||||
useTimelineVirtualizer && "min-h-0 flex-1",
|
||||
)}
|
||||
data-render-pending={isRenderPending ? "true" : undefined}
|
||||
>
|
||||
{timelineList}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!isAtBottom ? (
|
||||
@@ -631,12 +974,15 @@ const MessageTimelineBase = React.forwardRef<
|
||||
<UnreadPill
|
||||
direction="down"
|
||||
label={
|
||||
newMessageCount > 0
|
||||
? unreadCountLabel(newMessageCount)
|
||||
: "Jump to latest"
|
||||
bufferedTimeline.pendingCount > 0
|
||||
? unreadCountLabel(bufferedTimeline.pendingCount)
|
||||
: newMessageCount > 0
|
||||
? unreadCountLabel(newMessageCount)
|
||||
: "Jump to latest"
|
||||
}
|
||||
onClick={() => {
|
||||
scrollToBottom("smooth");
|
||||
setIsSemanticallyAtBottom(true);
|
||||
window.requestAnimationFrame(() => scrollToBottom("auto"));
|
||||
}}
|
||||
testId="message-scroll-to-latest"
|
||||
/>
|
||||
@@ -648,55 +994,3 @@ const MessageTimelineBase = React.forwardRef<
|
||||
});
|
||||
|
||||
export const MessageTimeline = React.memo(MessageTimelineBase);
|
||||
|
||||
function DirectMessageIntroAvatarStack({
|
||||
participants,
|
||||
}: {
|
||||
participants: DirectMessageIntroParticipant[];
|
||||
}) {
|
||||
const { hiddenCount, visibleParticipants } =
|
||||
getDmParticipantPreview(participants);
|
||||
const stackItemCount = visibleParticipants.length + (hiddenCount > 0 ? 1 : 0);
|
||||
|
||||
return (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="flex shrink-0 items-center"
|
||||
data-testid="message-dm-intro-avatar-stack"
|
||||
>
|
||||
{visibleParticipants.map((participant, index) => (
|
||||
<div
|
||||
className={index > 0 ? "-ml-5" : ""}
|
||||
data-testid="message-dm-intro-avatar-stack-participant"
|
||||
key={participant.pubkey}
|
||||
style={{
|
||||
zIndex: index + 1,
|
||||
...(index < stackItemCount - 1 && {
|
||||
mask: "radial-gradient(circle 34px at calc(100% + 10px) 50%, transparent 99%, #fff 100%)",
|
||||
WebkitMask:
|
||||
"radial-gradient(circle 34px at calc(100% + 10px) 50%, transparent 99%, #fff 100%)",
|
||||
}),
|
||||
}}
|
||||
>
|
||||
<UserAvatar
|
||||
avatarUrl={participant.avatarUrl}
|
||||
className="h-[60px] w-[60px] text-base"
|
||||
displayName={participant.displayName}
|
||||
size="md"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
{hiddenCount > 0 ? (
|
||||
<div
|
||||
className={visibleParticipants.length > 0 ? "-ml-5" : ""}
|
||||
data-testid="message-dm-intro-avatar-stack-more"
|
||||
style={{ zIndex: stackItemCount }}
|
||||
>
|
||||
<span className="flex h-[60px] w-[60px] items-center justify-center rounded-full bg-secondary font-semibold text-secondary-foreground shadow-xs">
|
||||
<span className="text-lg leading-none">+{hiddenCount}</span>
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import * as React from "react";
|
||||
import { VList } from "virtua";
|
||||
import type { VListHandle } from "virtua";
|
||||
|
||||
import { formatDayHeading } from "@/features/messages/lib/dateFormatters";
|
||||
import { timelineRowReserveStyle } from "@/features/messages/lib/rowHeightEstimate";
|
||||
@@ -6,8 +8,14 @@ import {
|
||||
buildTimelineDayGroups,
|
||||
buildTimelineItems,
|
||||
getTimelineItemKey,
|
||||
type TimelineDayGroup,
|
||||
type TimelineNonDayItem,
|
||||
} from "@/features/messages/lib/timelineItems";
|
||||
import {
|
||||
buildVirtualizedItems,
|
||||
didPrependVirtualizedTimeline,
|
||||
virtualizedItemKey,
|
||||
} from "@/features/messages/lib/virtualizedTimelineItems";
|
||||
import { THREAD_REPLY_ROW_MARGIN_INLINE_REM } from "@/features/messages/lib/threadTreeLayout";
|
||||
import { buildMainTimelineEntries } from "@/features/messages/lib/threadPanel";
|
||||
import type { MainTimelineEntry } from "@/features/messages/lib/threadPanel";
|
||||
@@ -27,6 +35,18 @@ import { MessageRow } from "./MessageRow";
|
||||
import { MessageThreadSummaryRow } from "./MessageThreadSummaryRow";
|
||||
import { SystemMessageRow } from "./SystemMessageRow";
|
||||
import { UnreadDivider } from "./UnreadDivider";
|
||||
import { useTimelineRetention } from "./useTimelineRetention";
|
||||
import { useUpwardPaginationWheel } from "./useUpwardPaginationWheel";
|
||||
import { useVirtualizedBottomSettle } from "./useVirtualizedBottomSettle";
|
||||
|
||||
export type TimelineVirtualizerApi = {
|
||||
scrollToBottom: (behavior?: ScrollBehavior) => void;
|
||||
settleAtBottom: () => void;
|
||||
scrollToMessage: (
|
||||
messageId: string,
|
||||
options?: { behavior?: ScrollBehavior },
|
||||
) => boolean;
|
||||
};
|
||||
|
||||
type TimelineMessageListProps = {
|
||||
agentPubkeys?: ReadonlySet<string>;
|
||||
@@ -81,6 +101,20 @@ type TimelineMessageListProps = {
|
||||
searchQuery?: string;
|
||||
/** Per-thread unread counts keyed by thread root id. */
|
||||
threadUnreadCounts?: ReadonlyMap<string, number>;
|
||||
/** Content rendered as the first virtual row before channel history. */
|
||||
leadingContent?: React.ReactNode;
|
||||
/**
|
||||
* True when the loaded window provably starts at the channel's beginning.
|
||||
* Proves the oldest loaded day's boundary so its divider may render.
|
||||
*/
|
||||
historyExhausted?: boolean;
|
||||
/** The virtualized timeline owns its scroll node when enabled. */
|
||||
useVirtualizer?: boolean;
|
||||
onStartReached?: () => boolean;
|
||||
onAtBottomStateChange?: (atBottom: boolean) => void;
|
||||
onVirtualizerApiChange?: (api: TimelineVirtualizerApi | null) => void;
|
||||
onVirtualizerRangeChanged?: () => void;
|
||||
onVirtualizerScrollerChange?: (element: HTMLDivElement | null) => void;
|
||||
};
|
||||
|
||||
export const TimelineMessageList = React.memo(function TimelineMessageList({
|
||||
@@ -114,6 +148,14 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({
|
||||
searchQuery,
|
||||
threadUnreadCounts,
|
||||
unfollowThreadById,
|
||||
leadingContent,
|
||||
historyExhausted = false,
|
||||
useVirtualizer = false,
|
||||
onStartReached,
|
||||
onAtBottomStateChange,
|
||||
onVirtualizerApiChange,
|
||||
onVirtualizerRangeChanged,
|
||||
onVirtualizerScrollerChange,
|
||||
}: TimelineMessageListProps) {
|
||||
const entries = React.useMemo(
|
||||
() =>
|
||||
@@ -267,6 +309,22 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({
|
||||
],
|
||||
);
|
||||
|
||||
if (useVirtualizer) {
|
||||
return (
|
||||
<VirtualizedTimelineRows
|
||||
dayGroups={dayGroups}
|
||||
historyExhausted={historyExhausted}
|
||||
leadingContent={leadingContent}
|
||||
onAtBottomStateChange={onAtBottomStateChange}
|
||||
onStartReached={onStartReached}
|
||||
onVirtualizerApiChange={onVirtualizerApiChange}
|
||||
onVirtualizerRangeChanged={onVirtualizerRangeChanged}
|
||||
onVirtualizerScrollerChange={onVirtualizerScrollerChange}
|
||||
renderItem={renderItem}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
{dayGroups.map((group) => (
|
||||
@@ -288,13 +346,9 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({
|
||||
<DayDivider label={formatDayHeading(group.headingTimestamp)} />
|
||||
)}
|
||||
{group.items.map((item) => (
|
||||
<div
|
||||
className="timeline-row-cv"
|
||||
key={getTimelineItemKey(item)}
|
||||
style={timelineRowReserveStyle(item)}
|
||||
>
|
||||
<TimelineRowShell item={item} key={getTimelineItemKey(item)}>
|
||||
{renderItem(item)}
|
||||
</div>
|
||||
</TimelineRowShell>
|
||||
))}
|
||||
</section>
|
||||
))}
|
||||
@@ -302,6 +356,371 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({
|
||||
);
|
||||
});
|
||||
|
||||
function timelineItemMessageId(item: TimelineNonDayItem): string | null {
|
||||
return item.kind === "message" || item.kind === "system"
|
||||
? item.entry.message.id
|
||||
: null;
|
||||
}
|
||||
|
||||
type VirtualizedTimelineRowsProps = {
|
||||
dayGroups: TimelineDayGroup[];
|
||||
historyExhausted: boolean;
|
||||
leadingContent?: React.ReactNode;
|
||||
onAtBottomStateChange?: (atBottom: boolean) => void;
|
||||
onStartReached?: () => boolean;
|
||||
onVirtualizerApiChange?: (api: TimelineVirtualizerApi | null) => void;
|
||||
onVirtualizerRangeChanged?: () => void;
|
||||
onVirtualizerScrollerChange?: (element: HTMLDivElement | null) => void;
|
||||
renderItem: (item: TimelineNonDayItem) => React.ReactNode;
|
||||
};
|
||||
|
||||
function VirtualizedTimelineRows({
|
||||
dayGroups,
|
||||
historyExhausted,
|
||||
leadingContent,
|
||||
onAtBottomStateChange,
|
||||
onStartReached,
|
||||
onVirtualizerApiChange,
|
||||
onVirtualizerRangeChanged,
|
||||
onVirtualizerScrollerChange,
|
||||
renderItem,
|
||||
}: VirtualizedTimelineRowsProps) {
|
||||
const listRef = React.useRef<VListHandle>(null);
|
||||
const hostRef = React.useRef<HTMLDivElement>(null);
|
||||
const itemsLengthRef = React.useRef(0);
|
||||
const messageItemIndexByIdRef = React.useRef<ReadonlyMap<string, number>>(
|
||||
new Map(),
|
||||
);
|
||||
const [offscreenBufferSize, setOffscreenBufferSize] = React.useState(() =>
|
||||
typeof window === "undefined" ? 1_000 : window.innerHeight,
|
||||
);
|
||||
const hasInitialPositionedRef = React.useRef(false);
|
||||
const items = React.useMemo(
|
||||
() => buildVirtualizedItems(dayGroups, leadingContent, historyExhausted),
|
||||
[dayGroups, historyExhausted, leadingContent],
|
||||
);
|
||||
const keys = React.useMemo(() => items.map(virtualizedItemKey), [items]);
|
||||
itemsLengthRef.current = items.length;
|
||||
const previousKeysRef = React.useRef<readonly string[]>([]);
|
||||
const prependAnchorRef = React.useRef<{
|
||||
itemKey: string;
|
||||
top: number;
|
||||
} | null>(null);
|
||||
const prependWatcherFrameRef = React.useRef<number | null>(null);
|
||||
const [prependShiftEpoch, clearPrependShift] = React.useReducer(
|
||||
(version: number) => version + 1,
|
||||
0,
|
||||
);
|
||||
// Virtua's `shift` is a one-render instruction, not a persistent mode. If it
|
||||
// stays true after a prepend, later measurement changes can keep anchoring
|
||||
// from the end and leave a stale blank range until the next scroll event.
|
||||
const isPrepend = React.useMemo(() => {
|
||||
void prependShiftEpoch;
|
||||
return didPrependVirtualizedTimeline(previousKeysRef.current, keys);
|
||||
}, [keys, prependShiftEpoch]);
|
||||
|
||||
const retirePrependAnchor = React.useCallback(() => {
|
||||
if (prependWatcherFrameRef.current !== null) {
|
||||
cancelAnimationFrame(prependWatcherFrameRef.current);
|
||||
}
|
||||
prependWatcherFrameRef.current = null;
|
||||
prependAnchorRef.current = null;
|
||||
}, []);
|
||||
const { cancel: cancelBottomSettle, settle: settleAtBottom } =
|
||||
useVirtualizedBottomSettle(hostRef, listRef, itemsLengthRef);
|
||||
const retireTimelineSettle = React.useCallback(() => {
|
||||
retirePrependAnchor();
|
||||
cancelBottomSettle();
|
||||
}, [cancelBottomSettle, retirePrependAnchor]);
|
||||
const { arm: armUpwardMomentum, clear: clearUpwardMomentum } =
|
||||
useUpwardPaginationWheel(hostRef, retireTimelineSettle);
|
||||
|
||||
const capturePrependAnchor = React.useCallback(() => {
|
||||
// Keep the pending capture current while the fetch is in flight. Once the
|
||||
// prepend commits and the watcher starts, its baseline is frozen.
|
||||
if (prependWatcherFrameRef.current !== null) return;
|
||||
const scroller = hostRef.current?.firstElementChild;
|
||||
if (!(scroller instanceof HTMLDivElement)) return;
|
||||
const scrollerTop = scroller.getBoundingClientRect().top;
|
||||
const row = Array.from(
|
||||
scroller.querySelectorAll<HTMLElement>("[data-timeline-item-key]"),
|
||||
).find(
|
||||
(candidate) => candidate.getBoundingClientRect().top >= scrollerTop - 1,
|
||||
);
|
||||
const itemKey = row?.dataset.timelineItemKey;
|
||||
if (!row || !itemKey) return;
|
||||
prependAnchorRef.current = {
|
||||
itemKey,
|
||||
top: row.getBoundingClientRect().top - scrollerTop,
|
||||
};
|
||||
}, []);
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
if (!isPrepend || !prependAnchorRef.current) return;
|
||||
// Virtua's shift mode correctly absorbs prepended measurements, but
|
||||
// estimated offsets can misclassify late row growth deep in history. Keep
|
||||
// the semantic row identity as a short-lived, deviation-gated backstop.
|
||||
// Do not correct in this commit: Virtua has shifted its estimate but has not
|
||||
// applied its ResizeObserver batch yet, so that delta is transient and its
|
||||
// subsequent absolute correction would overwrite our relative write.
|
||||
// This watcher deliberately survives a temporary row unmount and waits for
|
||||
// stable geometry so Virtua remains the primary scroll owner.
|
||||
if (prependWatcherFrameRef.current !== null) {
|
||||
cancelAnimationFrame(prependWatcherFrameRef.current);
|
||||
}
|
||||
const anchor = prependAnchorRef.current;
|
||||
const deadline = performance.now() + 3_000;
|
||||
let previousScrollTop: number | null = null;
|
||||
let settledFrames = 0;
|
||||
|
||||
const watch = () => {
|
||||
const scroller = hostRef.current?.firstElementChild;
|
||||
if (!(scroller instanceof HTMLDivElement)) {
|
||||
prependWatcherFrameRef.current = null;
|
||||
prependAnchorRef.current = null;
|
||||
return;
|
||||
}
|
||||
const atBottom =
|
||||
scroller.scrollHeight - scroller.clientHeight - scroller.scrollTop <=
|
||||
32;
|
||||
const row = Array.from(
|
||||
scroller.querySelectorAll<HTMLElement>("[data-timeline-item-key]"),
|
||||
).find(
|
||||
(candidate) => candidate.dataset.timelineItemKey === anchor.itemKey,
|
||||
);
|
||||
const top = row
|
||||
? row.getBoundingClientRect().top - scroller.getBoundingClientRect().top
|
||||
: null;
|
||||
const scrollTop = scroller.scrollTop;
|
||||
settledFrames =
|
||||
previousScrollTop !== null &&
|
||||
Math.abs(scrollTop - previousScrollTop) < 0.5
|
||||
? settledFrames + 1
|
||||
: 0;
|
||||
previousScrollTop = scrollTop;
|
||||
|
||||
if (row && top !== null && settledFrames >= 2) {
|
||||
const delta = top - anchor.top;
|
||||
if (Math.abs(delta) > 4) {
|
||||
scroller.scrollBy({ top: delta });
|
||||
settledFrames = 0;
|
||||
previousScrollTop = null;
|
||||
}
|
||||
}
|
||||
|
||||
const retired =
|
||||
performance.now() >= deadline ||
|
||||
atBottom ||
|
||||
(top !== null && top > scroller.clientHeight * 2);
|
||||
if (retired) {
|
||||
retirePrependAnchor();
|
||||
return;
|
||||
}
|
||||
prependWatcherFrameRef.current = requestAnimationFrame(watch);
|
||||
};
|
||||
prependWatcherFrameRef.current = requestAnimationFrame(watch);
|
||||
clearUpwardMomentum();
|
||||
}, [clearUpwardMomentum, isPrepend, retirePrependAnchor]);
|
||||
|
||||
React.useEffect(
|
||||
() => () => {
|
||||
retirePrependAnchor();
|
||||
cancelBottomSettle();
|
||||
},
|
||||
[cancelBottomSettle, retirePrependAnchor],
|
||||
);
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
previousKeysRef.current = keys;
|
||||
if (isPrepend) {
|
||||
cancelBottomSettle();
|
||||
clearPrependShift();
|
||||
}
|
||||
if (!hasInitialPositionedRef.current && items.length > 0) {
|
||||
hasInitialPositionedRef.current = true;
|
||||
settleAtBottom();
|
||||
}
|
||||
}, [cancelBottomSettle, isPrepend, items.length, keys, settleAtBottom]);
|
||||
|
||||
const messageItemIndexById = React.useMemo(() => {
|
||||
const byId = new Map<string, number>();
|
||||
items.forEach((item, index) => {
|
||||
if (item.kind !== "timeline-item") return;
|
||||
const messageId = timelineItemMessageId(item.item);
|
||||
if (messageId) byId.set(messageId, index);
|
||||
});
|
||||
return byId;
|
||||
}, [items]);
|
||||
messageItemIndexByIdRef.current = messageItemIndexById;
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
const scroller = hostRef.current?.firstElementChild;
|
||||
const element = scroller instanceof HTMLDivElement ? scroller : null;
|
||||
if (element) {
|
||||
element.dataset.buzzConversationScroll = "true";
|
||||
element.dataset.testid = "message-timeline";
|
||||
}
|
||||
onVirtualizerScrollerChange?.(element);
|
||||
return () => onVirtualizerScrollerChange?.(null);
|
||||
}, [onVirtualizerScrollerChange]);
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
if (!onVirtualizerApiChange) return;
|
||||
const api: TimelineVirtualizerApi = {
|
||||
scrollToBottom() {
|
||||
retireTimelineSettle();
|
||||
const lastIndex = itemsLengthRef.current - 1;
|
||||
if (lastIndex >= 0) {
|
||||
listRef.current?.scrollToIndex(lastIndex, { align: "end" });
|
||||
}
|
||||
},
|
||||
settleAtBottom,
|
||||
scrollToMessage(messageId) {
|
||||
retireTimelineSettle();
|
||||
const index = messageItemIndexByIdRef.current.get(messageId);
|
||||
if (index === undefined) return false;
|
||||
listRef.current?.scrollToIndex(index, { align: "center" });
|
||||
return true;
|
||||
},
|
||||
};
|
||||
onVirtualizerApiChange(api);
|
||||
return () => onVirtualizerApiChange(null);
|
||||
}, [onVirtualizerApiChange, retireTimelineSettle, settleAtBottom]);
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
const host = hostRef.current;
|
||||
if (!host) return;
|
||||
const updateBufferSize = () => {
|
||||
// Measure rows three viewports ahead of the reader. Virtua deliberately
|
||||
// hides each newly mounted row until its first ResizeObserver result; a
|
||||
// one-viewport lead can be consumed by WebKit trackpad momentum before
|
||||
// that result commits, producing a first-pass-only blank flash. The
|
||||
// measured size is cached, which is why revisiting the same range is
|
||||
// already stable.
|
||||
setOffscreenBufferSize(host.clientHeight * 3);
|
||||
};
|
||||
updateBufferSize();
|
||||
const resizeObserver = new ResizeObserver(updateBufferSize);
|
||||
resizeObserver.observe(host);
|
||||
return () => resizeObserver.disconnect();
|
||||
}, []);
|
||||
|
||||
const { retainedIndices, onScrollEnd: handleScrollEnd } =
|
||||
useTimelineRetention(keys, listRef, isPrepend);
|
||||
|
||||
const handleScroll = React.useCallback(
|
||||
(offset: number) => {
|
||||
const list = listRef.current;
|
||||
const scroller = hostRef.current?.firstElementChild;
|
||||
if (!list || !(scroller instanceof HTMLDivElement)) return;
|
||||
onVirtualizerRangeChanged?.();
|
||||
const distanceFromBottom = list.scrollSize - list.viewportSize - offset;
|
||||
if (distanceFromBottom > 32) cancelBottomSettle();
|
||||
onAtBottomStateChange?.(distanceFromBottom <= 32);
|
||||
if (
|
||||
prependAnchorRef.current !== null ||
|
||||
offset <= 200 ||
|
||||
prependWatcherFrameRef.current === null
|
||||
) {
|
||||
capturePrependAnchor();
|
||||
}
|
||||
if (offset <= 200) {
|
||||
// Layout scrolls near the top must not poison the reader's next input.
|
||||
armUpwardMomentum(onStartReached?.() ?? false);
|
||||
}
|
||||
},
|
||||
[
|
||||
armUpwardMomentum,
|
||||
cancelBottomSettle,
|
||||
capturePrependAnchor,
|
||||
onAtBottomStateChange,
|
||||
onStartReached,
|
||||
onVirtualizerRangeChanged,
|
||||
],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="h-full min-h-0 w-full" ref={hostRef}>
|
||||
<VList
|
||||
ref={listRef}
|
||||
className="h-full min-h-0 w-full overflow-y-auto overflow-x-hidden overscroll-contain px-2 pt-[var(--channel-top-chrome-height,4.5rem)]"
|
||||
data={items}
|
||||
bufferSize={offscreenBufferSize}
|
||||
keepMounted={retainedIndices}
|
||||
style={{ overflowAnchor: "none" }}
|
||||
shift={isPrepend}
|
||||
onScroll={handleScroll}
|
||||
onScrollEnd={handleScrollEnd}
|
||||
>
|
||||
{(item) => {
|
||||
if (item.kind === "bottom-spacer") {
|
||||
return (
|
||||
<div
|
||||
aria-hidden
|
||||
className="h-[var(--composer-overlay-height,6rem)]"
|
||||
key={virtualizedItemKey(item)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (item.kind === "leading-content") {
|
||||
return <div key={virtualizedItemKey(item)}>{item.content}</div>;
|
||||
}
|
||||
if (item.kind === "day-divider") {
|
||||
const dayLabel = formatDayHeading(item.headingTimestamp);
|
||||
return (
|
||||
<div
|
||||
// The sticky pill needs travel room, but its containing block
|
||||
// is this item wrapper. The trailing spacer extends the content
|
||||
// box by 4rem while the matching negative margin keeps the
|
||||
// measured layout height at exactly the divider's height, so
|
||||
// row spacing and Virtua's size cache are unaffected. Both the
|
||||
// spacer and the pill are pointer-events-none, and the later
|
||||
// (absolutely positioned) row siblings paint above the spacer.
|
||||
className="relative -mb-16 flex flex-col before:absolute before:inset-x-0 before:top-4 before:h-px before:bg-border/35 before:content-['']"
|
||||
data-day-label={dayLabel}
|
||||
data-testid="message-timeline-day-group"
|
||||
key={virtualizedItemKey(item)}
|
||||
>
|
||||
<DayDivider label={dayLabel} />
|
||||
<div aria-hidden className="pointer-events-none h-16" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<TimelineRowShell
|
||||
item={item.item}
|
||||
key={virtualizedItemKey(item)}
|
||||
useContentVisibility={false}
|
||||
>
|
||||
{renderItem(item.item)}
|
||||
</TimelineRowShell>
|
||||
);
|
||||
}}
|
||||
</VList>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TimelineRowShell({
|
||||
children,
|
||||
item,
|
||||
useContentVisibility = true,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
item: TimelineNonDayItem;
|
||||
useContentVisibility?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(useContentVisibility && "timeline-row-cv")}
|
||||
data-timeline-item-key={getTimelineItemKey(item)}
|
||||
style={useContentVisibility ? timelineRowReserveStyle(item) : undefined}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SystemRow({
|
||||
currentPubkey,
|
||||
entries,
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { VListHandle } from "virtua";
|
||||
|
||||
/**
|
||||
* Keep a wide ID-keyed neighborhood around the reader plus the visual tail.
|
||||
* The wider eviction band adds hysteresis, so small direction changes do not
|
||||
* churn mounted rows. Virtua continues to own measured sizes and spacer math.
|
||||
*/
|
||||
export function nextRetainedTimelineKeys(
|
||||
keys: readonly string[],
|
||||
previous: ReadonlySet<string>,
|
||||
list: VListHandle,
|
||||
): ReadonlySet<string> {
|
||||
const viewportSize = Math.max(list.viewportSize, 1);
|
||||
const offset = list.scrollOffset;
|
||||
const indexAt = (target: number) =>
|
||||
list.findItemIndex(Math.min(list.scrollSize, Math.max(0, target)));
|
||||
const admissionStart = indexAt(offset - viewportSize * 8);
|
||||
const admissionEnd = indexAt(offset + viewportSize * 9);
|
||||
const evictionStart = indexAt(offset - viewportSize * 12);
|
||||
const evictionEnd = indexAt(offset + viewportSize * 13);
|
||||
const tailStart = indexAt(list.scrollSize - viewportSize * 3);
|
||||
const next = new Set<string>();
|
||||
|
||||
for (let index = evictionStart; index <= evictionEnd; index += 1) {
|
||||
const key = keys[index];
|
||||
if (key && previous.has(key)) next.add(key);
|
||||
}
|
||||
for (let index = admissionStart; index <= admissionEnd; index += 1) {
|
||||
const key = keys[index];
|
||||
if (key) next.add(key);
|
||||
}
|
||||
for (let index = tailStart; index < keys.length; index += 1) {
|
||||
const key = keys[index];
|
||||
if (key) next.add(key);
|
||||
}
|
||||
|
||||
return next.size === previous.size &&
|
||||
[...next].every((key) => previous.has(key))
|
||||
? previous
|
||||
: next;
|
||||
}
|
||||
@@ -0,0 +1,381 @@
|
||||
/**
|
||||
* Snapshot projection invariant: timeline rows and the history-exhaustion
|
||||
* proof must be inseparable through EVERY render transport stage — React
|
||||
* deferral (`useDeferredValue`), tail buffering, and the settle-gated prepend
|
||||
* hold.
|
||||
*
|
||||
* ── Bug this pins (divider tear, 2026-07-11) ────────────────────────────────
|
||||
* `historyExhausted` used to travel to TimelineMessageList as a bare prop on
|
||||
* the URGENT render path while the rows rode the deferred/gated pipeline. The
|
||||
* final older-history page (same-day rows + hasMore:false in one response)
|
||||
* therefore produced an intermediate commit pairing the NEW exhaustion proof
|
||||
* with the STALE row array — {rendered:100, exhausted:true} — which minted the
|
||||
* oldest-day divider against a partially-loaded day. When the withheld rows
|
||||
* landed one commit later they prepended BEHIND the already-minted divider
|
||||
* key, exact-suffix shift admission failed, and the full page height landed
|
||||
* as an uncompensated scroll jump. Ledgered 3/3 in
|
||||
* RESEARCH/DIVIDER_EXTERNALIZATION_KEYSPACE.md (addendum).
|
||||
*
|
||||
* The fix threads the proof INSIDE TimelineSnapshot and through the settle
|
||||
* gate as paired `meta`, so no render can observe a (rows, proof) pair that
|
||||
* was never published together.
|
||||
*
|
||||
* **Revert verification**: feeding the gate `meta` from the LIVE snapshot
|
||||
* (`liveSnapshot.historyExhausted`) instead of the deferred one — or passing
|
||||
* the raw `historyExhausted` prop straight to the list, as the old wiring
|
||||
* did — makes the forbidden-pair assertion below fail on the urgent commit.
|
||||
*
|
||||
* ── CI surface ──────────────────────────────────────────────────────────────
|
||||
* Runs under `pnpm test` (node:test with the React dev build). Not Playwright:
|
||||
* the assertion is about per-commit render pairs, which only a render recorder
|
||||
* can observe deterministically.
|
||||
*/
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
// ── Minimal DOM shim (same shape as MessageComposerDraftImagePersist) ───────
|
||||
|
||||
function installDOMShim() {
|
||||
class MinimalEventTarget {
|
||||
constructor() {
|
||||
this._listeners = {};
|
||||
}
|
||||
addEventListener(type, fn) {
|
||||
this._listeners[type] ??= [];
|
||||
this._listeners[type].push(fn);
|
||||
}
|
||||
removeEventListener(type, fn) {
|
||||
if (this._listeners[type]) {
|
||||
this._listeners[type] = this._listeners[type].filter((f) => f !== fn);
|
||||
}
|
||||
}
|
||||
dispatchEvent(e) {
|
||||
for (const fn of this._listeners[e.type] ?? []) fn(e);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
class MinimalNode extends MinimalEventTarget {
|
||||
constructor(tagName) {
|
||||
super();
|
||||
this.tagName = tagName;
|
||||
this.children = [];
|
||||
this.childNodes = [];
|
||||
this.style = {};
|
||||
this.nodeType = 1;
|
||||
this.parentNode = null;
|
||||
}
|
||||
get ownerDocument() {
|
||||
return globalThis.document;
|
||||
}
|
||||
get firstChild() {
|
||||
return this.children[0] ?? null;
|
||||
}
|
||||
get lastChild() {
|
||||
return this.children[this.children.length - 1] ?? null;
|
||||
}
|
||||
get nextSibling() {
|
||||
return null;
|
||||
}
|
||||
get nodeValue() {
|
||||
return null;
|
||||
}
|
||||
appendChild(child) {
|
||||
this.children.push(child);
|
||||
this.childNodes.push(child);
|
||||
child.parentNode = this;
|
||||
return child;
|
||||
}
|
||||
removeChild(child) {
|
||||
this.children = this.children.filter((c) => c !== child);
|
||||
this.childNodes = this.childNodes.filter((c) => c !== child);
|
||||
return child;
|
||||
}
|
||||
insertBefore(newNode, refNode) {
|
||||
if (!refNode) return this.appendChild(newNode);
|
||||
const i = this.children.indexOf(refNode);
|
||||
if (i < 0) return this.appendChild(newNode);
|
||||
this.children.splice(i, 0, newNode);
|
||||
this.childNodes.splice(i, 0, newNode);
|
||||
newNode.parentNode = this;
|
||||
return newNode;
|
||||
}
|
||||
contains(node) {
|
||||
if (!node) return false;
|
||||
return this === node || this.children.some((c) => c?.contains?.(node));
|
||||
}
|
||||
}
|
||||
|
||||
class MinimalDocument extends MinimalEventTarget {
|
||||
constructor() {
|
||||
super();
|
||||
this.nodeType = 9;
|
||||
}
|
||||
createElement(tagName) {
|
||||
return new MinimalNode(tagName);
|
||||
}
|
||||
createTextNode(value) {
|
||||
const n = new MinimalNode("#text");
|
||||
n.nodeValue = value;
|
||||
n.nodeType = 3;
|
||||
return n;
|
||||
}
|
||||
createComment(value) {
|
||||
const n = new MinimalNode("#comment");
|
||||
n.nodeValue = value;
|
||||
n.nodeType = 8;
|
||||
return n;
|
||||
}
|
||||
get body() {
|
||||
if (!this._body) this._body = this.createElement("body");
|
||||
return this._body;
|
||||
}
|
||||
get activeElement() {
|
||||
return null;
|
||||
}
|
||||
contains(node) {
|
||||
return node != null;
|
||||
}
|
||||
}
|
||||
|
||||
globalThis.document = new MinimalDocument();
|
||||
globalThis.HTMLIFrameElement = MinimalNode;
|
||||
globalThis.HTMLElement = MinimalNode;
|
||||
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
|
||||
process.env.IS_REACT_ACT_ENVIRONMENT = "true";
|
||||
if (typeof globalThis.window === "undefined") {
|
||||
Object.defineProperty(globalThis, "window", {
|
||||
value: globalThis,
|
||||
configurable: true,
|
||||
});
|
||||
}
|
||||
globalThis.requestAnimationFrame = (fn) => setTimeout(fn, 16);
|
||||
globalThis.cancelAnimationFrame = (id) => clearTimeout(id);
|
||||
}
|
||||
|
||||
installDOMShim();
|
||||
|
||||
// ── Imports (after shim) ─────────────────────────────────────────────────────
|
||||
|
||||
import React from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { act } from "react";
|
||||
|
||||
import { useBufferedTimelineMessages } from "./useBufferedTimelineMessages.ts";
|
||||
import { useSettleGatedPrependMessages } from "./useSettleGatedPrependMessages.ts";
|
||||
|
||||
// ── Harness: MessageTimeline's exact snapshot transport pipeline ────────────
|
||||
// liveSnapshot (rows+proof as ONE value) → useDeferredValue → tail buffering
|
||||
// → settle-gated prepend hold. Records the (rowCount, firstId, exhausted)
|
||||
// pair every render body — the same pair buildVirtualizedItems consumes.
|
||||
|
||||
const rows = (ids) => ids.map((id) => ({ id }));
|
||||
const ids = (prefix, from, to) => {
|
||||
const out = [];
|
||||
for (let i = from; i < to; i += 1) out.push(`${prefix}${i}`);
|
||||
return out;
|
||||
};
|
||||
|
||||
const EMPTY_SNAPSHOT = {
|
||||
channelId: null,
|
||||
messages: [],
|
||||
historyExhausted: false,
|
||||
};
|
||||
|
||||
function makeFakeScroller() {
|
||||
// Constant scrollTop + no motion events: the settle gate's watcher reaches
|
||||
// quiet-window + stable-frames and admits the held page on its own.
|
||||
const listeners = {};
|
||||
return {
|
||||
scrollTop: 0,
|
||||
addEventListener(type, fn) {
|
||||
listeners[type] ??= [];
|
||||
listeners[type].push(fn);
|
||||
},
|
||||
removeEventListener(type, fn) {
|
||||
if (listeners[type]) {
|
||||
listeners[type] = listeners[type].filter((f) => f !== fn);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function pairOf(snapshot) {
|
||||
return {
|
||||
count: snapshot.messages.length,
|
||||
firstId: snapshot.messages[0]?.id ?? null,
|
||||
exhausted: snapshot.historyExhausted,
|
||||
};
|
||||
}
|
||||
|
||||
function samePair(a, b) {
|
||||
return (
|
||||
a.count === b.count &&
|
||||
a.firstId === b.firstId &&
|
||||
a.exhausted === b.exhausted
|
||||
);
|
||||
}
|
||||
|
||||
function makeHarness(records, scroller) {
|
||||
return function Harness({ snapshot }) {
|
||||
const deferredSnapshot = React.useDeferredValue(snapshot, EMPTY_SNAPSHOT);
|
||||
const buffered = useBufferedTimelineMessages({
|
||||
channelId: deferredSnapshot.channelId,
|
||||
isAtBottom: false, // reader is scrolled up — the tear's regime
|
||||
messages: deferredSnapshot.messages,
|
||||
});
|
||||
const {
|
||||
messages: rendered,
|
||||
meta: exhausted,
|
||||
isHoldingPrepend,
|
||||
} = useSettleGatedPrependMessages({
|
||||
channelId: deferredSnapshot.channelId,
|
||||
messages: buffered.messages,
|
||||
meta: deferredSnapshot.historyExhausted,
|
||||
scrollElementRef: { current: scroller },
|
||||
});
|
||||
records.push({
|
||||
count: rendered.length,
|
||||
firstId: rendered[0]?.id ?? null,
|
||||
exhausted,
|
||||
isHoldingPrepend,
|
||||
});
|
||||
return null;
|
||||
};
|
||||
}
|
||||
|
||||
async function mount(Comp, snapshot) {
|
||||
const container = document.createElement("div");
|
||||
const root = createRoot(container);
|
||||
await act(async () => {
|
||||
root.render(React.createElement(Comp, { snapshot }));
|
||||
});
|
||||
return {
|
||||
update: async (nextSnapshot) => {
|
||||
await act(async () => {
|
||||
root.render(React.createElement(Comp, { snapshot: nextSnapshot }));
|
||||
});
|
||||
},
|
||||
settle: async (ms) => {
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, ms));
|
||||
});
|
||||
},
|
||||
unmount: async () => {
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
test("pass-1 landing: exhaustion proof can never pair with the stale row array", async () => {
|
||||
const CHANNEL = "chan-tear";
|
||||
// Snapshot A: 100 rows loaded, more history exists (mid-pagination).
|
||||
const snapA = {
|
||||
channelId: CHANNEL,
|
||||
messages: rows(ids("m", 35, 135)),
|
||||
historyExhausted: false,
|
||||
};
|
||||
// Snapshot B: the pass-1 landing — 35 older same-day rows AND the
|
||||
// exhaustion proof arrive in ONE authoritative publish (the ledgered
|
||||
// observed order: both caches coherent in the same notification batch).
|
||||
const snapB = {
|
||||
channelId: CHANNEL,
|
||||
messages: rows(ids("m", 0, 135)),
|
||||
historyExhausted: true,
|
||||
};
|
||||
|
||||
const records = [];
|
||||
const scroller = makeFakeScroller();
|
||||
const handle = await mount(makeHarness(records, scroller), snapA);
|
||||
|
||||
await handle.update(snapB);
|
||||
|
||||
// The settle gate must actually engage for this to exercise the held
|
||||
// window — otherwise the test is vacuous.
|
||||
assert.ok(
|
||||
records.some((r) => r.isHoldingPrepend),
|
||||
"settle gate must hold the pure history prepend at least one render",
|
||||
);
|
||||
// While held, the rendered pair stays the ADMITTED one: {100, false}.
|
||||
const heldRecords = records.filter((r) => r.isHoldingPrepend);
|
||||
for (const r of heldRecords) {
|
||||
assert.equal(r.count, 100, "held rows must remain the admitted 100");
|
||||
assert.equal(
|
||||
r.exhausted,
|
||||
false,
|
||||
"held rows must keep the admitted (false) proof — not the fresh one",
|
||||
);
|
||||
}
|
||||
|
||||
// Let the settle watcher admit (quiet window 100ms + 3 stable frames).
|
||||
await handle.settle(400);
|
||||
|
||||
// Final state: rows and proof advanced TOGETHER.
|
||||
const last = records[records.length - 1];
|
||||
assert.deepEqual(
|
||||
{ count: last.count, firstId: last.firstId, exhausted: last.exhausted },
|
||||
{ count: 135, firstId: "m0", exhausted: true },
|
||||
"after settle the full page and its proof must land together",
|
||||
);
|
||||
|
||||
// THE invariant: every render observed a (rows, proof) pair that was
|
||||
// actually published. The forbidden torn state {rendered:100,
|
||||
// exhausted:true} — the divider-minting commit — must never exist.
|
||||
const published = [EMPTY_SNAPSHOT, snapA, snapB].map(pairOf);
|
||||
for (const r of records) {
|
||||
assert.ok(
|
||||
published.some((p) => samePair(p, r)),
|
||||
`torn render: {count:${r.count}, firstId:${r.firstId}, exhausted:${r.exhausted}} matches no published snapshot`,
|
||||
);
|
||||
}
|
||||
|
||||
await handle.unmount();
|
||||
});
|
||||
|
||||
test("reverse reconnect: exhaustion retraction stays paired with its replacement rows", async () => {
|
||||
const CHANNEL = "chan-retract";
|
||||
// Exhausted window fully loaded…
|
||||
const snapExhausted = {
|
||||
channelId: CHANNEL,
|
||||
messages: rows(ids("old", 0, 135)),
|
||||
historyExhausted: true,
|
||||
};
|
||||
// …then a reconnect head-refresh replaces the whole chain with page zero
|
||||
// reporting hasMore:true — exhaustion retracts, rows change wholesale.
|
||||
const snapRetracted = {
|
||||
channelId: CHANNEL,
|
||||
messages: rows(ids("new", 0, 50)),
|
||||
historyExhausted: false,
|
||||
};
|
||||
|
||||
const records = [];
|
||||
const scroller = makeFakeScroller();
|
||||
const handle = await mount(makeHarness(records, scroller), snapExhausted);
|
||||
|
||||
await handle.update(snapRetracted);
|
||||
await handle.settle(400);
|
||||
|
||||
// A wholesale replacement is not a pure prepend: the gate passes it through
|
||||
// (no hold), and the retracted proof must arrive WITH the new rows — never
|
||||
// stale-true against the new oldest prefix, never premature-false against
|
||||
// the old rows.
|
||||
const last = records[records.length - 1];
|
||||
assert.deepEqual(
|
||||
{ count: last.count, firstId: last.firstId, exhausted: last.exhausted },
|
||||
{ count: 50, firstId: "new0", exhausted: false },
|
||||
);
|
||||
const published = [EMPTY_SNAPSHOT, snapExhausted, snapRetracted].map(pairOf);
|
||||
for (const r of records) {
|
||||
assert.ok(
|
||||
published.some((p) => samePair(p, r)),
|
||||
`torn render on retraction: {count:${r.count}, firstId:${r.firstId}, exhausted:${r.exhausted}}`,
|
||||
);
|
||||
}
|
||||
|
||||
await handle.unmount();
|
||||
});
|
||||
@@ -1,7 +1,11 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { settleProgrammaticBottomPin } from "./useAnchoredScroll.ts";
|
||||
import {
|
||||
settleProgrammaticBottomPin,
|
||||
shouldSettleForSplitPanel,
|
||||
shouldSettleVirtualizedBottom,
|
||||
} from "./useAnchoredScroll.ts";
|
||||
|
||||
function fakeContainer({ clientHeight, scrollHeight, scrollTop }) {
|
||||
const writes = [];
|
||||
@@ -17,6 +21,78 @@ function fakeContainer({ clientHeight, scrollHeight, scrollTop }) {
|
||||
};
|
||||
}
|
||||
|
||||
test("split panel settles only an already-bottomed timeline", () => {
|
||||
assert.equal(
|
||||
shouldSettleForSplitPanel({ isAtBottom: true, splitPanelOpen: true }),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
shouldSettleForSplitPanel({ isAtBottom: false, splitPanelOpen: true }),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
shouldSettleForSplitPanel({ isAtBottom: true, splitPanelOpen: false }),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("virtualized bottom settle arms for pinned appends and replacements", () => {
|
||||
assert.equal(
|
||||
shouldSettleVirtualizedBottom({
|
||||
isAtBottom: true,
|
||||
messageDelta: "append",
|
||||
messagesArrived: 1,
|
||||
messagesChanged: true,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
shouldSettleVirtualizedBottom({
|
||||
isAtBottom: true,
|
||||
messageDelta: "replace",
|
||||
messagesArrived: 0,
|
||||
messagesChanged: true,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
shouldSettleVirtualizedBottom({
|
||||
isAtBottom: true,
|
||||
messageDelta: "none",
|
||||
messagesArrived: 0,
|
||||
messagesChanged: true,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
shouldSettleVirtualizedBottom({
|
||||
isAtBottom: true,
|
||||
messageDelta: "prepend",
|
||||
messagesArrived: 1,
|
||||
messagesChanged: true,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
shouldSettleVirtualizedBottom({
|
||||
isAtBottom: true,
|
||||
messageDelta: "none",
|
||||
messagesArrived: 0,
|
||||
messagesChanged: false,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
shouldSettleVirtualizedBottom({
|
||||
isAtBottom: false,
|
||||
messageDelta: "none",
|
||||
messagesArrived: 0,
|
||||
messagesChanged: true,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("settleProgrammaticBottomPin chases the physical floor before clearing", () => {
|
||||
const container = fakeContainer({
|
||||
clientHeight: 100,
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { classifyTimelineMessageDelta } from "@/features/messages/lib/timelineSnapshot";
|
||||
import {
|
||||
classifyTimelineMessageDelta,
|
||||
type TimelineMessageDelta,
|
||||
} from "@/features/messages/lib/timelineSnapshot";
|
||||
|
||||
/**
|
||||
* Distance (in CSS pixels) below which we consider the scroll position
|
||||
@@ -32,6 +35,34 @@ export function settleProgrammaticBottomPin(
|
||||
return isAtTrueBottom(container);
|
||||
}
|
||||
|
||||
export function shouldSettleForSplitPanel({
|
||||
isAtBottom,
|
||||
splitPanelOpen,
|
||||
}: {
|
||||
isAtBottom: boolean;
|
||||
splitPanelOpen: boolean;
|
||||
}): boolean {
|
||||
return isAtBottom && splitPanelOpen;
|
||||
}
|
||||
|
||||
export function shouldSettleVirtualizedBottom({
|
||||
isAtBottom,
|
||||
messageDelta,
|
||||
messagesArrived,
|
||||
messagesChanged,
|
||||
}: {
|
||||
isAtBottom: boolean;
|
||||
messageDelta: TimelineMessageDelta;
|
||||
messagesArrived: number;
|
||||
messagesChanged: boolean;
|
||||
}): boolean {
|
||||
return (
|
||||
isAtBottom &&
|
||||
messageDelta !== "prepend" &&
|
||||
(messagesArrived > 0 || messagesChanged)
|
||||
);
|
||||
}
|
||||
|
||||
type UseAnchoredScrollOptions = {
|
||||
/** Scroll container. Owned by the parent so external refs still compose. */
|
||||
scrollContainerRef: React.RefObject<HTMLDivElement | null>;
|
||||
@@ -45,10 +76,22 @@ type UseAnchoredScrollOptions = {
|
||||
/** Source of truth for the rendered list. Used to detect new-at-bottom
|
||||
* arrivals and to seed/refresh the anchor pre-render. */
|
||||
messages: Array<{ id: string }>;
|
||||
splitPanelOpen?: boolean;
|
||||
|
||||
/** When set, scroll to and highlight this message on mount and on change. */
|
||||
targetMessageId?: string | null;
|
||||
onTargetReached?: (messageId: string) => void;
|
||||
virtualScrollToMessage?: (
|
||||
messageId: string,
|
||||
options?: { behavior?: ScrollBehavior },
|
||||
) => boolean;
|
||||
/** Imperative virtualizer-owned bottom jump, used only when virtualizer mode is active. */
|
||||
virtualScrollToBottom?: (behavior?: ScrollBehavior) => void;
|
||||
virtualSettleAtBottom?: () => void;
|
||||
/** When active, the virtualizer owns prepend compensation and bottom-state synchronization. */
|
||||
virtualizerOwnsPrependAnchoring?: boolean;
|
||||
/** Bumps when a virtualized range changes, so pending target/search retries can re-check newly mounted DOM. */
|
||||
virtualizerRenderVersion?: number;
|
||||
};
|
||||
|
||||
type UseAnchoredScrollResult = {
|
||||
@@ -72,6 +115,8 @@ type UseAnchoredScrollResult = {
|
||||
messageId: string,
|
||||
options?: { highlight?: boolean; behavior?: ScrollBehavior },
|
||||
) => boolean;
|
||||
/** Syncs the hook's bottom affordances from a virtualizer-owned scroller. */
|
||||
onVirtualizerAtBottomStateChange: (atBottom: boolean) => void;
|
||||
};
|
||||
|
||||
function isAtBottomNow(
|
||||
@@ -147,15 +192,27 @@ export function useAnchoredScroll({
|
||||
channelId,
|
||||
isLoading,
|
||||
messages,
|
||||
splitPanelOpen = false,
|
||||
|
||||
targetMessageId = null,
|
||||
onTargetReached,
|
||||
virtualScrollToMessage,
|
||||
virtualScrollToBottom,
|
||||
virtualSettleAtBottom,
|
||||
virtualizerOwnsPrependAnchoring = false,
|
||||
virtualizerRenderVersion = 0,
|
||||
}: 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" });
|
||||
const virtualizerAtBottomRef = React.useRef(true);
|
||||
const [isAtBottom, setIsAtBottom] = React.useState(true);
|
||||
React.useLayoutEffect(() => {
|
||||
if (shouldSettleForSplitPanel({ isAtBottom, splitPanelOpen })) {
|
||||
virtualSettleAtBottom?.();
|
||||
}
|
||||
}, [isAtBottom, splitPanelOpen, virtualSettleAtBottom]);
|
||||
const [newMessageCount, setNewMessageCount] = React.useState(0);
|
||||
const [highlightedMessageId, setHighlightedMessageId] = React.useState<
|
||||
string | null
|
||||
@@ -188,6 +245,7 @@ export function useAnchoredScroll({
|
||||
// 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" };
|
||||
virtualizerAtBottomRef.current = true;
|
||||
setIsAtBottom(true);
|
||||
setNewMessageCount(0);
|
||||
setHighlightedMessageId(null);
|
||||
@@ -222,11 +280,19 @@ export function useAnchoredScroll({
|
||||
// every imperative bottom jump so `onScroll` holds the at-bottom anchor
|
||||
// until it can snap to the true floor.
|
||||
settlingRef.current = true;
|
||||
container.scrollTo({ top: container.scrollHeight, behavior });
|
||||
if (virtualizerOwnsPrependAnchoring && virtualScrollToBottom) {
|
||||
virtualScrollToBottom(behavior);
|
||||
} else {
|
||||
container.scrollTo({ top: container.scrollHeight, behavior });
|
||||
}
|
||||
setIsAtBottom(true);
|
||||
setNewMessageCount(0);
|
||||
},
|
||||
[scrollContainerRef],
|
||||
[
|
||||
scrollContainerRef,
|
||||
virtualScrollToBottom,
|
||||
virtualizerOwnsPrependAnchoring,
|
||||
],
|
||||
);
|
||||
|
||||
// Arm a one-shot: the next append snaps to bottom regardless of where the
|
||||
@@ -236,6 +302,19 @@ export function useAnchoredScroll({
|
||||
forceBottomOnNextAppendRef.current = true;
|
||||
}, []);
|
||||
|
||||
const highlightMessage = React.useCallback((messageId: string) => {
|
||||
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);
|
||||
}, []);
|
||||
|
||||
const scrollToMessageImperative = React.useCallback(
|
||||
(
|
||||
messageId: string,
|
||||
@@ -246,6 +325,44 @@ export function useAnchoredScroll({
|
||||
const el = container.querySelector<HTMLElement>(
|
||||
`[data-message-id="${messageId}"]`,
|
||||
);
|
||||
if (virtualizerOwnsPrependAnchoring && virtualScrollToMessage) {
|
||||
if (el) {
|
||||
const rect = el.getBoundingClientRect();
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
const isInViewport =
|
||||
rect.top >= containerRect.top &&
|
||||
rect.bottom <= containerRect.bottom;
|
||||
if (!isInViewport) {
|
||||
if (!virtualScrollToMessage(messageId, { behavior: "auto" })) {
|
||||
return false;
|
||||
}
|
||||
anchorRef.current = { kind: "message", messageId, topOffset: 0 };
|
||||
setIsAtBottom(false);
|
||||
return false;
|
||||
}
|
||||
const centeredTop = (container.clientHeight - rect.height) / 2;
|
||||
container.scrollTo({
|
||||
top: Math.max(
|
||||
0,
|
||||
container.scrollTop +
|
||||
(rect.top - containerRect.top) -
|
||||
centeredTop,
|
||||
),
|
||||
behavior: options.behavior ?? "auto",
|
||||
});
|
||||
} else if (
|
||||
!virtualScrollToMessage(messageId, {
|
||||
behavior: options.behavior ?? "auto",
|
||||
})
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
anchorRef.current = { kind: "message", messageId, topOffset: 0 };
|
||||
setIsAtBottom(false);
|
||||
if (el && options.highlight) highlightMessage(messageId);
|
||||
return el !== null;
|
||||
}
|
||||
|
||||
if (!el) return false;
|
||||
|
||||
const rect = el.getBoundingClientRect();
|
||||
@@ -279,21 +396,15 @@ export function useAnchoredScroll({
|
||||
};
|
||||
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);
|
||||
}
|
||||
if (options.highlight) highlightMessage(messageId);
|
||||
return true;
|
||||
},
|
||||
[scrollContainerRef],
|
||||
[
|
||||
highlightMessage,
|
||||
scrollContainerRef,
|
||||
virtualizerOwnsPrependAnchoring,
|
||||
virtualScrollToMessage,
|
||||
],
|
||||
);
|
||||
|
||||
// Scroll handler: recompute anchor + bottom state from the current
|
||||
@@ -302,6 +413,9 @@ export function useAnchoredScroll({
|
||||
const onScroll = React.useCallback(() => {
|
||||
const container = scrollContainerRef.current;
|
||||
if (!container) return;
|
||||
// Virtua owns anchoring and reports bottom state separately. Avoid the
|
||||
// fallback's O(N) DOM walk on every compositor-driven scroll event.
|
||||
if (virtualizerOwnsPrependAnchoring) return;
|
||||
// Row measurement can grow `scrollHeight` after a bottom pin and emit scroll
|
||||
// events while `scrollTop` holds at the old floor — opening a transient gap
|
||||
// above the true bottom. `computeAnchor` would read that as a deliberate
|
||||
@@ -311,6 +425,9 @@ export function useAnchoredScroll({
|
||||
if (settleProgrammaticBottomPin(container)) {
|
||||
settlingRef.current = false;
|
||||
} else {
|
||||
if (virtualizerOwnsPrependAnchoring) {
|
||||
settlingRef.current = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -320,7 +437,7 @@ export function useAnchoredScroll({
|
||||
if (atBottom) {
|
||||
setNewMessageCount(0);
|
||||
}
|
||||
}, [scrollContainerRef]);
|
||||
}, [scrollContainerRef, virtualizerOwnsPrependAnchoring]);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Anchor restoration: after every render, stick to the bottom if the user is
|
||||
@@ -382,11 +499,11 @@ export function useAnchoredScroll({
|
||||
// `newLatestArrived` misses that case and the unread counter never bumps.
|
||||
const prevMessages = prevMessagesRef.current;
|
||||
const messagesArrived = messages.length - prevCount;
|
||||
const isPrepend =
|
||||
classifyTimelineMessageDelta({
|
||||
current: messages,
|
||||
previous: prevMessages,
|
||||
}) === "prepend";
|
||||
const messageDelta = classifyTimelineMessageDelta({
|
||||
current: messages,
|
||||
previous: prevMessages,
|
||||
});
|
||||
const isPrepend = messageDelta === "prepend";
|
||||
|
||||
// One-shot: an outbound send armed `scrollToBottomOnNextUpdate`. When the
|
||||
// resulting append lands, snap to bottom regardless of the current anchor,
|
||||
@@ -396,7 +513,11 @@ export function useAnchoredScroll({
|
||||
forceBottomOnNextAppendRef.current = false;
|
||||
anchorRef.current = { kind: "at-bottom" };
|
||||
settlingRef.current = true;
|
||||
container.scrollTo({ top: container.scrollHeight, behavior: "auto" });
|
||||
if (virtualizerOwnsPrependAnchoring && virtualScrollToBottom) {
|
||||
virtualScrollToBottom("auto");
|
||||
} else {
|
||||
container.scrollTo({ top: container.scrollHeight, behavior: "auto" });
|
||||
}
|
||||
setIsAtBottom(true);
|
||||
setNewMessageCount(0);
|
||||
prevLastMessageIdRef.current = lastMessage?.id;
|
||||
@@ -407,10 +528,21 @@ export function useAnchoredScroll({
|
||||
}
|
||||
|
||||
if (anchor.kind === "at-bottom") {
|
||||
// Stick to bottom across the append.
|
||||
container.scrollTo({ top: container.scrollHeight, behavior: "auto" });
|
||||
if (
|
||||
virtualizerOwnsPrependAnchoring &&
|
||||
shouldSettleVirtualizedBottom({
|
||||
isAtBottom: virtualizerAtBottomRef.current,
|
||||
messageDelta,
|
||||
messagesArrived,
|
||||
messagesChanged: messages !== prevMessages,
|
||||
})
|
||||
) {
|
||||
virtualSettleAtBottom?.();
|
||||
} else if (!virtualizerOwnsPrependAnchoring) {
|
||||
container.scrollTo({ top: container.scrollHeight, behavior: "auto" });
|
||||
}
|
||||
if (newLatestArrived) setNewMessageCount(0);
|
||||
} else if (messagesArrived > 0) {
|
||||
} else if (messagesArrived > 0 && !virtualizerOwnsPrependAnchoring) {
|
||||
// Anchored mid-history. An older-history prepend grows the content above
|
||||
// the reading row; the browser's native scroll anchoring does NOT correct
|
||||
// this at the top edge (no anchor node above the viewport when scrollTop
|
||||
@@ -449,6 +581,9 @@ export function useAnchoredScroll({
|
||||
scrollToBottomImperative,
|
||||
scrollToMessageImperative,
|
||||
targetMessageId,
|
||||
virtualScrollToBottom,
|
||||
virtualSettleAtBottom,
|
||||
virtualizerOwnsPrependAnchoring,
|
||||
]);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -466,13 +601,21 @@ export function useAnchoredScroll({
|
||||
const observer = new ResizeObserver(() => {
|
||||
const container = scrollContainerRef.current;
|
||||
if (!container) return;
|
||||
if (anchorRef.current.kind === "at-bottom") {
|
||||
if (
|
||||
anchorRef.current.kind === "at-bottom" &&
|
||||
!virtualizerOwnsPrependAnchoring
|
||||
) {
|
||||
container.scrollTo({ top: container.scrollHeight, behavior: "auto" });
|
||||
}
|
||||
});
|
||||
observer.observe(content);
|
||||
return () => observer.disconnect();
|
||||
}, [channelId, contentRef, scrollContainerRef]);
|
||||
}, [
|
||||
channelId,
|
||||
contentRef,
|
||||
scrollContainerRef,
|
||||
virtualizerOwnsPrependAnchoring,
|
||||
]);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Target message handling (deep link, jump-to-reply, etc.). Distinct from
|
||||
@@ -486,7 +629,7 @@ export function useAnchoredScroll({
|
||||
// *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.
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: `messages` and `virtualizerRenderVersion` are intentional retry triggers, not values read by the effect body — the effect reads the DOM (querySelector), and we need it to re-run each time the message list or virtualized rendered range changes so a target spliced into older history gets centered once its row commits.
|
||||
React.useEffect(() => {
|
||||
if (!targetMessageId) {
|
||||
handledTargetIdRef.current = null;
|
||||
@@ -495,11 +638,19 @@ export function useAnchoredScroll({
|
||||
if (handledTargetIdRef.current === targetMessageId || isLoading) return;
|
||||
if (!hasInitializedRef.current) return; // initial-mount path will handle.
|
||||
|
||||
void virtualizerRenderVersion;
|
||||
const container = scrollContainerRef.current;
|
||||
if (!container) return;
|
||||
const el = container.querySelector<HTMLElement>(
|
||||
`[data-message-id="${targetMessageId}"]`,
|
||||
);
|
||||
if (!el && virtualizerOwnsPrependAnchoring) {
|
||||
if (scrollToMessageImperative(targetMessageId, { highlight: true })) {
|
||||
handledTargetIdRef.current = targetMessageId;
|
||||
onTargetReached?.(targetMessageId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!el) {
|
||||
// Row not in the DOM yet. A cold deep-link target is fetched by id and
|
||||
// spliced into `messages` a render or two later; this effect re-runs on
|
||||
@@ -516,6 +667,8 @@ export function useAnchoredScroll({
|
||||
scrollContainerRef,
|
||||
scrollToMessageImperative,
|
||||
targetMessageId,
|
||||
virtualizerOwnsPrependAnchoring,
|
||||
virtualizerRenderVersion,
|
||||
]);
|
||||
|
||||
React.useEffect(() => {
|
||||
@@ -526,6 +679,19 @@ export function useAnchoredScroll({
|
||||
};
|
||||
}, []);
|
||||
|
||||
const onVirtualizerAtBottomStateChange = React.useCallback(
|
||||
(atBottom: boolean) => {
|
||||
if (!virtualizerOwnsPrependAnchoring) return;
|
||||
virtualizerAtBottomRef.current = atBottom;
|
||||
if (atBottom) {
|
||||
anchorRef.current = { kind: "at-bottom" };
|
||||
setNewMessageCount(0);
|
||||
}
|
||||
setIsAtBottom(atBottom);
|
||||
},
|
||||
[virtualizerOwnsPrependAnchoring],
|
||||
);
|
||||
|
||||
return {
|
||||
onScroll,
|
||||
isAtBottom,
|
||||
@@ -534,5 +700,6 @@ export function useAnchoredScroll({
|
||||
scrollToBottom: scrollToBottomImperative,
|
||||
scrollToBottomOnNextUpdate,
|
||||
scrollToMessage: scrollToMessageImperative,
|
||||
onVirtualizerAtBottomStateChange,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { selectBufferedTimelineMessages } from "./useBufferedTimelineMessages.ts";
|
||||
|
||||
const rows = (...ids) => ids.map((id) => ({ id }));
|
||||
|
||||
test("freezes live arrivals after the semantic tail while scrolled up", () => {
|
||||
assert.deepEqual(
|
||||
selectBufferedTimelineMessages({
|
||||
frozenMessageIds: ["a", "b", "c"],
|
||||
isAtBottom: false,
|
||||
messages: rows("a", "b", "c", "d", "e"),
|
||||
}).map(({ id }) => id),
|
||||
["a", "b", "c"],
|
||||
);
|
||||
});
|
||||
|
||||
test("admits older-history prepends without exposing buffered arrivals", () => {
|
||||
assert.deepEqual(
|
||||
selectBufferedTimelineMessages({
|
||||
frozenMessageIds: ["a", "b", "c"],
|
||||
isAtBottom: false,
|
||||
messages: rows("older-a", "older-b", "a", "b", "c", "d"),
|
||||
}).map(({ id }) => id),
|
||||
["older-a", "older-b", "a", "b", "c"],
|
||||
);
|
||||
});
|
||||
|
||||
test("releases the full logical dataset at bottom", () => {
|
||||
const messages = rows("a", "b", "c", "d");
|
||||
assert.deepEqual(
|
||||
selectBufferedTimelineMessages({
|
||||
frozenMessageIds: ["a", "b"],
|
||||
isAtBottom: true,
|
||||
messages,
|
||||
}),
|
||||
messages,
|
||||
);
|
||||
});
|
||||
|
||||
test("accepts an authoritative replacement when its old tail disappeared", () => {
|
||||
const messages = rows("x", "y");
|
||||
assert.deepEqual(
|
||||
selectBufferedTimelineMessages({
|
||||
frozenMessageIds: ["old-tail"],
|
||||
isAtBottom: false,
|
||||
messages,
|
||||
}),
|
||||
messages,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
import * as React from "react";
|
||||
|
||||
/**
|
||||
* Keeps the logical tail stable while a reader is away from the bottom.
|
||||
*
|
||||
* Virtua remains the sole owner of mounting, measurement, and pixel anchoring.
|
||||
* This hook only controls when live output joins its keyed data model: older
|
||||
* history before the retained tail is admitted immediately, while newer output
|
||||
* is released atomically when the reader returns to the bottom.
|
||||
*/
|
||||
export function selectBufferedTimelineMessages<T extends { id: string }>({
|
||||
frozenMessageIds,
|
||||
isAtBottom,
|
||||
messages,
|
||||
}: {
|
||||
frozenMessageIds: readonly string[] | null;
|
||||
isAtBottom: boolean;
|
||||
messages: T[];
|
||||
}): T[] {
|
||||
if (isAtBottom || frozenMessageIds === null) return messages;
|
||||
if (frozenMessageIds.length === 0) return [];
|
||||
|
||||
const currentById = new Map(messages.map((message) => [message.id, message]));
|
||||
if (frozenMessageIds.some((id) => !currentById.has(id))) {
|
||||
// A deletion or authoritative replacement removed part of the frozen
|
||||
// snapshot. Keeping stale message objects would be worse than accepting it.
|
||||
return messages;
|
||||
}
|
||||
|
||||
const firstFrozenIndex = messages.findIndex(
|
||||
(message) => message.id === frozenMessageIds[0],
|
||||
);
|
||||
const prepended = messages.slice(0, firstFrozenIndex);
|
||||
const frozen = frozenMessageIds.map((id) => currentById.get(id) as T);
|
||||
const buffered = [...prepended, ...frozen];
|
||||
if (
|
||||
buffered.length === messages.length &&
|
||||
buffered.every((message, index) => message.id === messages[index]?.id)
|
||||
) {
|
||||
// Crossing the bottom threshold without a live arrival must be a semantic
|
||||
// no-op for Virtua. Preserve the source array identity until there is
|
||||
// actually something to buffer; otherwise the threshold transition can
|
||||
// rebuild its model while a prepend is starting.
|
||||
return messages;
|
||||
}
|
||||
return buffered;
|
||||
}
|
||||
|
||||
export function useBufferedTimelineMessages<T extends { id: string }>({
|
||||
channelId,
|
||||
isAtBottom,
|
||||
messages,
|
||||
}: {
|
||||
channelId?: string | null;
|
||||
isAtBottom: boolean;
|
||||
messages: T[];
|
||||
}): { messages: T[]; pendingCount: number } {
|
||||
const frozenMessageIdsRef = React.useRef<string[] | null>(null);
|
||||
const previousChannelIdRef = React.useRef(channelId);
|
||||
|
||||
if (previousChannelIdRef.current !== channelId) {
|
||||
previousChannelIdRef.current = channelId;
|
||||
frozenMessageIdsRef.current = null;
|
||||
}
|
||||
|
||||
if (isAtBottom) {
|
||||
frozenMessageIdsRef.current = messages.map((message) => message.id);
|
||||
} else if (frozenMessageIdsRef.current === null) {
|
||||
frozenMessageIdsRef.current = messages.map((message) => message.id);
|
||||
}
|
||||
|
||||
const buffered = selectBufferedTimelineMessages({
|
||||
frozenMessageIds: frozenMessageIdsRef.current,
|
||||
isAtBottom,
|
||||
messages,
|
||||
});
|
||||
const previousBufferedRef = React.useRef<T[]>(buffered);
|
||||
const stableBuffered =
|
||||
previousBufferedRef.current.length === buffered.length &&
|
||||
previousBufferedRef.current.every(
|
||||
(message, index) => message === buffered[index],
|
||||
)
|
||||
? previousBufferedRef.current
|
||||
: buffered;
|
||||
previousBufferedRef.current = stableBuffered;
|
||||
return {
|
||||
messages: stableBuffered,
|
||||
pendingCount: Math.max(0, messages.length - stableBuffered.length),
|
||||
};
|
||||
}
|
||||
@@ -14,6 +14,7 @@ export function useComposerHeightPadding(
|
||||
scrollContainerRef: React.RefObject<HTMLElement | null>,
|
||||
composerRef: React.RefObject<HTMLElement | null>,
|
||||
resetKey?: unknown,
|
||||
mode: "padding" | "css-variable" = "padding",
|
||||
) {
|
||||
React.useEffect(() => {
|
||||
void resetKey;
|
||||
@@ -24,15 +25,34 @@ export function useComposerHeightPadding(
|
||||
return;
|
||||
}
|
||||
|
||||
const getScrollElement = (): HTMLElement =>
|
||||
mode === "css-variable"
|
||||
? (scrollEl.querySelector<HTMLElement>(
|
||||
'[data-testid="message-timeline"]',
|
||||
) ?? scrollEl)
|
||||
: scrollEl;
|
||||
|
||||
let lastPadding: number | null = null;
|
||||
let followBottomFrame: number | null = null;
|
||||
|
||||
const isNearBottom = (): boolean => {
|
||||
const target = getScrollElement();
|
||||
const threshold = 32;
|
||||
const trailingClearance =
|
||||
mode === "css-variable" ? (lastPadding ?? 0) : 0;
|
||||
return (
|
||||
scrollEl.scrollHeight - scrollEl.scrollTop - scrollEl.clientHeight <
|
||||
target.scrollHeight -
|
||||
target.scrollTop -
|
||||
target.clientHeight -
|
||||
trailingClearance <
|
||||
threshold
|
||||
);
|
||||
};
|
||||
|
||||
let lastPadding: number | null = null;
|
||||
const followBottom = () => {
|
||||
const target = getScrollElement();
|
||||
target.scrollTop = target.scrollHeight;
|
||||
};
|
||||
|
||||
const applyPadding = (height: number) => {
|
||||
const padding = Math.ceil(height);
|
||||
@@ -43,14 +63,25 @@ export function useComposerHeightPadding(
|
||||
const previousPadding = lastPadding;
|
||||
const wasAtBottom = isNearBottom();
|
||||
|
||||
scrollEl.style.paddingBottom = `${padding}px`;
|
||||
if (mode === "css-variable") {
|
||||
scrollEl.style.setProperty("--composer-overlay-height", `${padding}px`);
|
||||
} else {
|
||||
scrollEl.style.paddingBottom = `${padding}px`;
|
||||
}
|
||||
lastPadding = padding;
|
||||
|
||||
if (
|
||||
wasAtBottom &&
|
||||
(previousPadding === null || padding > previousPadding)
|
||||
) {
|
||||
scrollEl.scrollTop = scrollEl.scrollHeight;
|
||||
followBottom();
|
||||
if (followBottomFrame !== null) {
|
||||
cancelAnimationFrame(followBottomFrame);
|
||||
}
|
||||
followBottomFrame = requestAnimationFrame(() => {
|
||||
followBottomFrame = null;
|
||||
followBottom();
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -58,7 +89,14 @@ export function useComposerHeightPadding(
|
||||
|
||||
return () => {
|
||||
disconnect();
|
||||
scrollEl.style.paddingBottom = "";
|
||||
if (followBottomFrame !== null) {
|
||||
cancelAnimationFrame(followBottomFrame);
|
||||
}
|
||||
if (mode === "css-variable") {
|
||||
scrollEl.style.removeProperty("--composer-overlay-height");
|
||||
} else {
|
||||
scrollEl.style.paddingBottom = "";
|
||||
}
|
||||
};
|
||||
}, [scrollContainerRef, composerRef, resetKey]);
|
||||
}, [scrollContainerRef, composerRef, mode, resetKey]);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { selectSettleGatedMessages } from "./useSettleGatedPrependMessages.ts";
|
||||
|
||||
const rows = (...ids) => ids.map((id) => ({ id }));
|
||||
|
||||
test("holds a pure older-history prepend", () => {
|
||||
const decision = selectSettleGatedMessages({
|
||||
admitted: rows("a", "b", "c"),
|
||||
next: rows("older-1", "older-2", "a", "b", "c"),
|
||||
});
|
||||
assert.equal(decision.kind, "hold");
|
||||
assert.deepEqual(
|
||||
decision.held.map(({ id }) => id),
|
||||
["a", "b", "c"],
|
||||
);
|
||||
});
|
||||
|
||||
test("held snapshot uses refreshed row objects for the admitted ids", () => {
|
||||
const editedA = { id: "a", edited: true };
|
||||
const decision = selectSettleGatedMessages({
|
||||
admitted: rows("a", "b"),
|
||||
next: [{ id: "older-1" }, editedA, { id: "b" }],
|
||||
});
|
||||
assert.equal(decision.kind, "hold");
|
||||
assert.equal(decision.held[0], editedA);
|
||||
});
|
||||
|
||||
test("passes appends through immediately", () => {
|
||||
assert.equal(
|
||||
selectSettleGatedMessages({
|
||||
admitted: rows("a", "b"),
|
||||
next: rows("a", "b", "c"),
|
||||
}).kind,
|
||||
"pass",
|
||||
);
|
||||
});
|
||||
|
||||
test("passes a simultaneous prepend+append (own send) through", () => {
|
||||
assert.equal(
|
||||
selectSettleGatedMessages({
|
||||
admitted: rows("a", "b"),
|
||||
next: rows("older-1", "a", "b", "sent"),
|
||||
}).kind,
|
||||
"pass",
|
||||
);
|
||||
});
|
||||
|
||||
test("passes deletions inside the admitted window through", () => {
|
||||
assert.equal(
|
||||
selectSettleGatedMessages({
|
||||
admitted: rows("a", "b", "c"),
|
||||
next: rows("older-1", "a", "c"),
|
||||
}).kind,
|
||||
"pass",
|
||||
);
|
||||
});
|
||||
|
||||
test("passes authoritative replacements through", () => {
|
||||
assert.equal(
|
||||
selectSettleGatedMessages({
|
||||
admitted: rows("a", "b"),
|
||||
next: rows("x", "y"),
|
||||
}).kind,
|
||||
"pass",
|
||||
);
|
||||
});
|
||||
|
||||
test("passes identical snapshots through", () => {
|
||||
assert.equal(
|
||||
selectSettleGatedMessages({
|
||||
admitted: rows("a", "b"),
|
||||
next: rows("a", "b"),
|
||||
}).kind,
|
||||
"pass",
|
||||
);
|
||||
});
|
||||
|
||||
test("passes when the previous snapshot was empty (initial load)", () => {
|
||||
assert.equal(
|
||||
selectSettleGatedMessages({
|
||||
admitted: [],
|
||||
next: rows("a", "b"),
|
||||
}).kind,
|
||||
"pass",
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,180 @@
|
||||
import * as React from "react";
|
||||
|
||||
/**
|
||||
* Holds an older-history prepend out of the rendered timeline until the
|
||||
* scroller is genuinely at rest, then admits it atomically.
|
||||
*
|
||||
* Why: every prepend-compensation mechanism in this design — Virtua's shift
|
||||
* correction, the pre-paint scrollBy, the semantic-anchor watcher — is a
|
||||
* scrollTop write. On macOS WKWebView those writes can be dropped or
|
||||
* overridden while trackpad momentum owns the committed offset, so a page
|
||||
* commit that lands mid-fling displaces the viewport by the full prepended
|
||||
* height with no reliable way to correct it. Committing only at rest keeps
|
||||
* all three writers operating in the regime where they are exact.
|
||||
*
|
||||
* The fetched store stays authoritative and fetches still start immediately;
|
||||
* this hook only delays when the fetched page joins the rendered snapshot.
|
||||
*/
|
||||
|
||||
/**
|
||||
* WebKit can freeze the JS-readable scrollTop for ~2 frames during live
|
||||
* trackpad momentum, so counting zero-delta frames alone misreports "settled"
|
||||
* mid-fling. Settle therefore requires BOTH a quiet window since the last
|
||||
* scroll/wheel event AND stable frame-over-frame offsets — the same
|
||||
* two-signal settle shape ratified for the timeline settle gate elsewhere in
|
||||
* this codebase.
|
||||
*/
|
||||
export const SETTLE_MOTION_WINDOW_MS = 100;
|
||||
export const SETTLE_FRAME_COUNT = 3;
|
||||
/**
|
||||
* Upper bound on how long a fetched page may be withheld. Trackpad momentum
|
||||
* decays in well under a second; a reader actively driving the scroller for
|
||||
* this long has moved on, and admitting under continuous REAL input is safe —
|
||||
* the dropped-write hazard is specific to the inertial momentum phase, which
|
||||
* cannot outlive this deadline.
|
||||
*/
|
||||
export const SETTLE_HOLD_DEADLINE_MS = 4_000;
|
||||
|
||||
export type SettleGateDecision<T> =
|
||||
| { kind: "pass" }
|
||||
| { kind: "hold"; held: T[] };
|
||||
|
||||
/**
|
||||
* Pure admission rule. Holds only a PURE history prepend: the next snapshot
|
||||
* must be the admitted rows (same ids, possibly refreshed objects — edits and
|
||||
* reactions keep rendering) with one or more new rows in front. Anything else
|
||||
* — appends, deletions, authoritative replacements, channel resets — passes
|
||||
* through immediately so the gate can never pin a stale dataset.
|
||||
*/
|
||||
export function selectSettleGatedMessages<T extends { id: string }>({
|
||||
admitted,
|
||||
next,
|
||||
}: {
|
||||
admitted: readonly T[];
|
||||
next: T[];
|
||||
}): SettleGateDecision<T> {
|
||||
if (admitted.length === 0) return { kind: "pass" };
|
||||
const prependCount = next.findIndex(
|
||||
(message) => message.id === admitted[0].id,
|
||||
);
|
||||
if (prependCount <= 0) return { kind: "pass" };
|
||||
if (next.length - prependCount !== admitted.length) return { kind: "pass" };
|
||||
for (let index = 0; index < admitted.length; index += 1) {
|
||||
if (next[prependCount + index].id !== admitted[index].id) {
|
||||
return { kind: "pass" };
|
||||
}
|
||||
}
|
||||
return { kind: "hold", held: next.slice(prependCount) };
|
||||
}
|
||||
|
||||
export function useSettleGatedPrependMessages<T extends { id: string }, M>({
|
||||
channelId,
|
||||
messages,
|
||||
meta,
|
||||
scrollElementRef,
|
||||
}: {
|
||||
channelId?: string | null;
|
||||
messages: T[];
|
||||
/**
|
||||
* Snapshot metadata that must stay paired with the rows it was projected
|
||||
* from (e.g. the history-exhaustion proof that decides whether the oldest
|
||||
* day divider may exist). While a prepend is held, the output keeps the
|
||||
* ADMITTED metadata; rows and metadata only ever advance together, so no
|
||||
* render can pair new proof with withheld rows.
|
||||
*/
|
||||
meta: M;
|
||||
scrollElementRef: { readonly current: HTMLElement | null };
|
||||
}): { messages: T[]; meta: M; isHoldingPrepend: boolean } {
|
||||
const admittedRef = React.useRef<T[]>(messages);
|
||||
const admittedMetaRef = React.useRef<M>(meta);
|
||||
const previousChannelIdRef = React.useRef(channelId);
|
||||
const [, admit] = React.useReducer((epoch: number) => epoch + 1, 0);
|
||||
|
||||
if (previousChannelIdRef.current !== channelId) {
|
||||
previousChannelIdRef.current = channelId;
|
||||
admittedRef.current = messages;
|
||||
admittedMetaRef.current = meta;
|
||||
}
|
||||
|
||||
const decision = selectSettleGatedMessages({
|
||||
admitted: admittedRef.current,
|
||||
next: messages,
|
||||
});
|
||||
const isHoldingPrepend = decision.kind === "hold";
|
||||
|
||||
let output: T[];
|
||||
let outputMeta: M;
|
||||
if (decision.kind === "hold") {
|
||||
// Same ids as the admitted set; keep array identity stable unless a row
|
||||
// object actually changed so Virtua's data model is not rebuilt per render.
|
||||
const previous = admittedRef.current;
|
||||
output =
|
||||
previous.length === decision.held.length &&
|
||||
previous.every((message, index) => message === decision.held[index])
|
||||
? previous
|
||||
: decision.held;
|
||||
outputMeta = admittedMetaRef.current;
|
||||
} else {
|
||||
output = messages;
|
||||
outputMeta = meta;
|
||||
}
|
||||
admittedRef.current = output;
|
||||
admittedMetaRef.current = outputMeta;
|
||||
|
||||
const latestMessagesRef = React.useRef(messages);
|
||||
latestMessagesRef.current = messages;
|
||||
const latestMetaRef = React.useRef(meta);
|
||||
latestMetaRef.current = meta;
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isHoldingPrepend) return;
|
||||
const scroller = scrollElementRef.current;
|
||||
if (!scroller) {
|
||||
// Nothing to observe — never strand the fetched page.
|
||||
admittedRef.current = latestMessagesRef.current;
|
||||
admittedMetaRef.current = latestMetaRef.current;
|
||||
admit();
|
||||
return;
|
||||
}
|
||||
let frame: number | null = null;
|
||||
const deadline = performance.now() + SETTLE_HOLD_DEADLINE_MS;
|
||||
// Assume motion at hold start: worst case this costs one quiet window
|
||||
// (~100ms) behind the fetching-older spinner when the reader was already
|
||||
// at rest; the alternative admits mid-fling if WebKit starves the first
|
||||
// scroll events.
|
||||
let lastMotionTs = performance.now();
|
||||
let previousScrollTop = scroller.scrollTop;
|
||||
let settledFrames = 0;
|
||||
const markMotion = () => {
|
||||
lastMotionTs = performance.now();
|
||||
};
|
||||
scroller.addEventListener("scroll", markMotion, { passive: true });
|
||||
scroller.addEventListener("wheel", markMotion, { passive: true });
|
||||
const watch = () => {
|
||||
const scrollTop = scroller.scrollTop;
|
||||
settledFrames =
|
||||
Math.abs(scrollTop - previousScrollTop) < 0.5 ? settledFrames + 1 : 0;
|
||||
previousScrollTop = scrollTop;
|
||||
const quiet = performance.now() - lastMotionTs >= SETTLE_MOTION_WINDOW_MS;
|
||||
if (
|
||||
(quiet && settledFrames >= SETTLE_FRAME_COUNT) ||
|
||||
performance.now() >= deadline
|
||||
) {
|
||||
frame = null;
|
||||
admittedRef.current = latestMessagesRef.current;
|
||||
admittedMetaRef.current = latestMetaRef.current;
|
||||
admit();
|
||||
return;
|
||||
}
|
||||
frame = requestAnimationFrame(watch);
|
||||
};
|
||||
frame = requestAnimationFrame(watch);
|
||||
return () => {
|
||||
scroller.removeEventListener("scroll", markMotion);
|
||||
scroller.removeEventListener("wheel", markMotion);
|
||||
if (frame !== null) cancelAnimationFrame(frame);
|
||||
};
|
||||
}, [isHoldingPrepend, scrollElementRef]);
|
||||
|
||||
return { messages: output, meta: outputMeta, isHoldingPrepend };
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import * as React from "react";
|
||||
import type { VListHandle } from "virtua";
|
||||
import { nextRetainedTimelineKeys } from "./timelineRetention";
|
||||
|
||||
export function useTimelineRetention(
|
||||
keys: readonly string[],
|
||||
listRef: React.RefObject<VListHandle | null>,
|
||||
isPrepend: boolean,
|
||||
) {
|
||||
const [retainedKeys, setRetainedKeys] = React.useState<ReadonlySet<string>>(
|
||||
() => new Set(keys),
|
||||
);
|
||||
const evictionNotBeforeRef = React.useRef(0);
|
||||
const refreshTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(
|
||||
null,
|
||||
);
|
||||
const keysRef = React.useRef(keys);
|
||||
keysRef.current = keys;
|
||||
|
||||
const refreshRetainedKeys = React.useCallback(() => {
|
||||
const remainingGuardMs = evictionNotBeforeRef.current - performance.now();
|
||||
if (remainingGuardMs > 0) {
|
||||
refreshTimerRef.current = setTimeout(
|
||||
refreshRetainedKeys,
|
||||
remainingGuardMs,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
refreshTimerRef.current = null;
|
||||
const currentKeys = keysRef.current;
|
||||
const list = listRef.current;
|
||||
if (!list || currentKeys.length === 0) return;
|
||||
setRetainedKeys((previous) =>
|
||||
nextRetainedTimelineKeys(currentKeys, previous, list),
|
||||
);
|
||||
}, [listRef]);
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
if (isPrepend) evictionNotBeforeRef.current = performance.now() + 3_000;
|
||||
}, [isPrepend]);
|
||||
|
||||
React.useEffect(
|
||||
() => () => {
|
||||
if (refreshTimerRef.current !== null) {
|
||||
clearTimeout(refreshTimerRef.current);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const retainedIndices = React.useMemo(
|
||||
() => keys.flatMap((key, index) => (retainedKeys.has(key) ? [index] : [])),
|
||||
[keys, retainedKeys],
|
||||
);
|
||||
const onScrollEnd = React.useCallback(() => {
|
||||
if (refreshTimerRef.current !== null) {
|
||||
clearTimeout(refreshTimerRef.current);
|
||||
refreshTimerRef.current = null;
|
||||
}
|
||||
refreshRetainedKeys();
|
||||
}, [refreshRetainedKeys]);
|
||||
|
||||
return { retainedIndices, onScrollEnd };
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import * as React from "react";
|
||||
|
||||
export function useUpwardPaginationWheel(
|
||||
hostRef: React.RefObject<HTMLDivElement | null>,
|
||||
onWheel: () => void,
|
||||
) {
|
||||
const suppressRef = React.useRef(false);
|
||||
const lastUpwardWheelAtRef = React.useRef(Number.NEGATIVE_INFINITY);
|
||||
const clear = React.useCallback(() => {
|
||||
suppressRef.current = false;
|
||||
}, []);
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
const scroller = hostRef.current?.firstElementChild;
|
||||
if (!(scroller instanceof HTMLDivElement)) return;
|
||||
let releaseTimer: number | null = null;
|
||||
const handleWheel = (event: WheelEvent) => {
|
||||
onWheel();
|
||||
if (event.deltaY >= 0) {
|
||||
clear();
|
||||
if (releaseTimer !== null) window.clearTimeout(releaseTimer);
|
||||
releaseTimer = null;
|
||||
return;
|
||||
}
|
||||
lastUpwardWheelAtRef.current = performance.now();
|
||||
if (!suppressRef.current) return;
|
||||
event.preventDefault();
|
||||
if (releaseTimer !== null) window.clearTimeout(releaseTimer);
|
||||
releaseTimer = window.setTimeout(() => {
|
||||
clear();
|
||||
releaseTimer = null;
|
||||
}, 80);
|
||||
};
|
||||
scroller.addEventListener("wheel", handleWheel, { passive: false });
|
||||
return () => {
|
||||
scroller.removeEventListener("wheel", handleWheel);
|
||||
if (releaseTimer !== null) window.clearTimeout(releaseTimer);
|
||||
};
|
||||
}, [clear, hostRef, onWheel]);
|
||||
|
||||
const arm = React.useCallback(
|
||||
(startedPaging: boolean) => {
|
||||
const scroller = hostRef.current?.firstElementChild;
|
||||
if (
|
||||
startedPaging &&
|
||||
scroller instanceof HTMLDivElement &&
|
||||
scroller.scrollHeight - scroller.clientHeight > 400 &&
|
||||
performance.now() - lastUpwardWheelAtRef.current < 120
|
||||
) {
|
||||
suppressRef.current = true;
|
||||
}
|
||||
},
|
||||
[hostRef],
|
||||
);
|
||||
|
||||
return { arm, clear };
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import * as React from "react";
|
||||
import type { VListHandle } from "virtua";
|
||||
|
||||
const BOTTOM_EPSILON_PX = 1;
|
||||
const SETTLE_DEADLINE_MS = 250;
|
||||
|
||||
export function useVirtualizedBottomSettle(
|
||||
hostRef: React.RefObject<HTMLDivElement | null>,
|
||||
listRef: React.RefObject<VListHandle | null>,
|
||||
itemsLengthRef: React.RefObject<number>,
|
||||
) {
|
||||
const frameRef = React.useRef<number | null>(null);
|
||||
const cancel = React.useCallback(() => {
|
||||
if (frameRef.current !== null) {
|
||||
cancelAnimationFrame(frameRef.current);
|
||||
frameRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
const scroller = hostRef.current?.firstElementChild;
|
||||
if (!(scroller instanceof HTMLDivElement)) return;
|
||||
const retire = () => cancel();
|
||||
scroller.addEventListener("pointerdown", retire, { passive: true });
|
||||
scroller.addEventListener("touchstart", retire, { passive: true });
|
||||
scroller.addEventListener("wheel", retire, { passive: true });
|
||||
window.addEventListener("keydown", retire, true);
|
||||
return () => {
|
||||
scroller.removeEventListener("pointerdown", retire);
|
||||
scroller.removeEventListener("touchstart", retire);
|
||||
scroller.removeEventListener("wheel", retire);
|
||||
window.removeEventListener("keydown", retire, true);
|
||||
};
|
||||
}, [cancel, hostRef]);
|
||||
|
||||
const settle = React.useCallback(() => {
|
||||
cancel();
|
||||
const deadline = performance.now() + SETTLE_DEADLINE_MS;
|
||||
let settledFrames = 0;
|
||||
let previousHeight = -1;
|
||||
const next = () => {
|
||||
const scroller = hostRef.current?.firstElementChild;
|
||||
const lastIndex = itemsLengthRef.current - 1;
|
||||
if (!(scroller instanceof HTMLDivElement) || lastIndex < 0) {
|
||||
cancel();
|
||||
return;
|
||||
}
|
||||
listRef.current?.scrollToIndex(lastIndex, { align: "end" });
|
||||
const atBottom =
|
||||
scroller.scrollHeight - scroller.clientHeight - scroller.scrollTop <=
|
||||
BOTTOM_EPSILON_PX;
|
||||
settledFrames =
|
||||
atBottom && scroller.scrollHeight === previousHeight
|
||||
? settledFrames + 1
|
||||
: 0;
|
||||
previousHeight = scroller.scrollHeight;
|
||||
if (settledFrames >= 2 || performance.now() >= deadline) {
|
||||
frameRef.current = null;
|
||||
return;
|
||||
}
|
||||
frameRef.current = requestAnimationFrame(next);
|
||||
};
|
||||
next();
|
||||
}, [cancel, hostRef, itemsLengthRef, listRef]);
|
||||
|
||||
React.useEffect(() => cancel, [cancel]);
|
||||
return { cancel, settle };
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { channelWindowKey } from "@/features/messages/lib/messageQueryKeys";
|
||||
import {
|
||||
channelWindowHasMore,
|
||||
channelWindowHistoryExhausted,
|
||||
emptyChannelWindowStore,
|
||||
type ChannelWindowStore,
|
||||
} from "@/features/messages/lib/channelWindowStore";
|
||||
@@ -35,6 +36,19 @@ export function useFetchOlderMessages(channel: Channel | null) {
|
||||
emptyChannelWindowStore(),
|
||||
});
|
||||
|
||||
// Distinct from `!hasOlderMessages`: an empty/unloaded window also reports
|
||||
// "no more", but exhaustion requires a RESOLVED tail page proving the
|
||||
// channel's beginning. Consumers gating UI on the history boundary (the
|
||||
// oldest day divider) must use this, not the paging signal.
|
||||
const { data: historyExhausted = false } = useQuery({
|
||||
enabled: channelId !== null,
|
||||
queryKey: windowKey,
|
||||
select: channelWindowHistoryExhausted,
|
||||
queryFn: () =>
|
||||
queryClient.getQueryData<ChannelWindowStore>(windowKey) ??
|
||||
emptyChannelWindowStore(),
|
||||
});
|
||||
|
||||
const fetchOlder = useCallback(async () => {
|
||||
if (!channelId || isFetchingOlderRef.current) {
|
||||
return;
|
||||
@@ -63,5 +77,5 @@ export function useFetchOlderMessages(channel: Channel | null) {
|
||||
}
|
||||
}, [channel?.id, channelId, queryClient]);
|
||||
|
||||
return { fetchOlder, isFetchingOlder, hasOlderMessages };
|
||||
return { fetchOlder, isFetchingOlder, hasOlderMessages, historyExhausted };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { collectThreadAuxMessageIds } from "./useThreadReplies.ts";
|
||||
|
||||
const ROOT_ID = "1".repeat(64);
|
||||
const REPLY_ID = "2".repeat(64);
|
||||
|
||||
function reply(id = REPLY_ID) {
|
||||
return {
|
||||
id,
|
||||
pubkey: "a".repeat(64),
|
||||
kind: 9,
|
||||
created_at: 1_700_000_000,
|
||||
content: "reply",
|
||||
tags: [["e", ROOT_ID]],
|
||||
sig: "sig",
|
||||
};
|
||||
}
|
||||
|
||||
test("thread aux hydration includes the root when there are no replies", () => {
|
||||
assert.deepEqual(collectThreadAuxMessageIds(ROOT_ID, []), [ROOT_ID]);
|
||||
});
|
||||
|
||||
test("thread aux hydration includes and deduplicates root and reply ids", () => {
|
||||
assert.deepEqual(
|
||||
collectThreadAuxMessageIds(ROOT_ID, [reply(), reply(ROOT_ID)]),
|
||||
[ROOT_ID, REPLY_ID],
|
||||
);
|
||||
});
|
||||
@@ -1,10 +1,15 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import {
|
||||
collectMessageIdsForAuxBackfill,
|
||||
fetchStructuralAuxForMessages,
|
||||
} from "@/features/messages/lib/auxBackfill";
|
||||
import { threadRepliesKey } from "@/features/messages/lib/messageQueryKeys";
|
||||
import {
|
||||
threadRepliesKey,
|
||||
sortMessages,
|
||||
} from "@/features/messages/lib/messageQueryKeys";
|
||||
import { relayClient } from "@/shared/api/relayClient";
|
||||
import { buildChannelReactionAuxFilter } from "@/shared/api/relayChannelFilters";
|
||||
import { getThreadReplies } from "@/shared/api/tauri";
|
||||
import type { Channel, RelayEvent, ThreadCursor } from "@/shared/api/types";
|
||||
|
||||
@@ -18,26 +23,53 @@ const MAX_THREAD_PAGES = 500;
|
||||
* original text. Best-effort: an aux failure logs and returns the replies
|
||||
* unadorned rather than failing the whole thread load.
|
||||
*/
|
||||
async function withStructuralAux(
|
||||
async function fetchThreadAuxBestEffort(
|
||||
label: string,
|
||||
channelId: string,
|
||||
replies: RelayEvent[],
|
||||
fetchAux: () => Promise<RelayEvent[]>,
|
||||
): Promise<RelayEvent[]> {
|
||||
try {
|
||||
const auxEvents = await fetchStructuralAuxForMessages(
|
||||
channelId,
|
||||
collectMessageIdsForAuxBackfill(replies),
|
||||
);
|
||||
return auxEvents.length > 0 ? [...replies, ...auxEvents] : replies;
|
||||
return await fetchAux();
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"Failed to backfill thread reply edits for channel",
|
||||
`Failed to backfill thread reply ${label} for channel`,
|
||||
channelId,
|
||||
error,
|
||||
);
|
||||
return replies;
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function collectThreadAuxMessageIds(
|
||||
threadRootId: string,
|
||||
replies: RelayEvent[],
|
||||
): string[] {
|
||||
return [
|
||||
...new Set([threadRootId, ...collectMessageIdsForAuxBackfill(replies)]),
|
||||
];
|
||||
}
|
||||
|
||||
async function withThreadAux(
|
||||
channelId: string,
|
||||
threadRootId: string,
|
||||
replies: RelayEvent[],
|
||||
): Promise<RelayEvent[]> {
|
||||
const messageIds = collectThreadAuxMessageIds(threadRootId, replies);
|
||||
const [structuralAux, reactions] = await Promise.all([
|
||||
fetchThreadAuxBestEffort("structural aux", channelId, () =>
|
||||
fetchStructuralAuxForMessages(channelId, messageIds),
|
||||
),
|
||||
fetchThreadAuxBestEffort("reactions", channelId, () =>
|
||||
relayClient.fetchAuxEventsByReference(
|
||||
channelId,
|
||||
messageIds,
|
||||
buildChannelReactionAuxFilter,
|
||||
),
|
||||
),
|
||||
]);
|
||||
return sortMessages([...replies, ...structuralAux, ...reactions]);
|
||||
}
|
||||
|
||||
/** Fetch a thread subtree into a cache independent from channel window pages. */
|
||||
export function useThreadReplies(
|
||||
activeChannel: Channel | null,
|
||||
@@ -45,14 +77,19 @@ export function useThreadReplies(
|
||||
) {
|
||||
const channelId = activeChannel?.id ?? "none";
|
||||
const rootId = openThreadRootId ?? "none";
|
||||
const queryClient = useQueryClient();
|
||||
const queryKey = threadRepliesKey(channelId, rootId);
|
||||
return useQuery({
|
||||
queryKey: threadRepliesKey(channelId, rootId),
|
||||
queryKey,
|
||||
enabled:
|
||||
activeChannel !== null &&
|
||||
activeChannel.channelType !== "forum" &&
|
||||
openThreadRootId !== null,
|
||||
queryFn: async (): Promise<RelayEvent[]> => {
|
||||
if (!activeChannel || !openThreadRootId) return [];
|
||||
const cacheAtStart =
|
||||
queryClient.getQueryData<RelayEvent[]>(queryKey) ?? [];
|
||||
const idsAtStart = new Set(cacheAtStart.map((event) => event.id));
|
||||
const replies: RelayEvent[] = [];
|
||||
let cursor: ThreadCursor | null = null;
|
||||
for (let page = 0; page < MAX_THREAD_PAGES; page += 1) {
|
||||
@@ -62,8 +99,19 @@ export function useThreadReplies(
|
||||
{ limit: THREAD_PAGE_LIMIT, cursor },
|
||||
);
|
||||
replies.push(...response.events);
|
||||
if (!response.nextCursor)
|
||||
return withStructuralAux(activeChannel.id, replies);
|
||||
if (!response.nextCursor) {
|
||||
const fetched = await withThreadAux(
|
||||
activeChannel.id,
|
||||
openThreadRootId,
|
||||
replies,
|
||||
);
|
||||
const current =
|
||||
queryClient.getQueryData<RelayEvent[]>(queryKey) ?? [];
|
||||
const receivedInFlight = current.filter(
|
||||
(event) => !idsAtStart.has(event.id),
|
||||
);
|
||||
return sortMessages([...fetched, ...receivedInFlight]);
|
||||
}
|
||||
cursor = response.nextCursor;
|
||||
}
|
||||
throw new Error(
|
||||
|
||||
@@ -80,8 +80,10 @@ function isConversationScroller(element: HTMLElement) {
|
||||
* remain; every other boundary — including all horizontal ones — is locked and
|
||||
* cannot chain to the viewport.
|
||||
*/
|
||||
export function useWebviewScrollBoundaryLock() {
|
||||
export function useWebviewScrollBoundaryLock(enabled = true) {
|
||||
React.useEffect(() => {
|
||||
if (!enabled) return;
|
||||
|
||||
function handleWheel(event: WheelEvent) {
|
||||
if (event.defaultPrevented || event.ctrlKey) {
|
||||
return;
|
||||
@@ -137,5 +139,5 @@ export function useWebviewScrollBoundaryLock() {
|
||||
return () => {
|
||||
window.removeEventListener("wheel", handleWheel, { capture: true });
|
||||
};
|
||||
}, []);
|
||||
}, [enabled]);
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ import { useSmoothCorners } from "@/shared/ui/smoothCorners";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
|
||||
import { UserAvatar } from "@/shared/ui/UserAvatar";
|
||||
import { Spinner } from "./spinner";
|
||||
import { useNaturalVideoAspectRatio } from "./videoAspectRatio";
|
||||
import {
|
||||
getInlinePlaybackPosition,
|
||||
getReviewPlaybackPosition,
|
||||
@@ -722,9 +723,10 @@ export function VideoPlayer({
|
||||
const [playbackSpeed, setPlaybackSpeed] = React.useState(
|
||||
DEFAULT_PLAYBACK_SPEED,
|
||||
);
|
||||
const [naturalAspectRatio, setNaturalAspectRatio] = React.useState<
|
||||
number | null
|
||||
>(null);
|
||||
// Cache-seeded so a row evicted by the virtualized timeline remounts at the
|
||||
// ratio learned on first metadata load, not the 16/9 fallback.
|
||||
const [naturalAspectRatio, learnNaturalAspectRatio] =
|
||||
useNaturalVideoAspectRatio(src);
|
||||
const [reviewOpen, setReviewOpenState] = React.useState(() =>
|
||||
isVideoReviewOpen(persistedReviewKey),
|
||||
);
|
||||
@@ -1035,9 +1037,7 @@ export function VideoPlayer({
|
||||
event.currentTarget.currentTime = restoredSeconds;
|
||||
setCurrentTime(restoredSeconds);
|
||||
}
|
||||
if (videoWidth > 0 && videoHeight > 0) {
|
||||
setNaturalAspectRatio(videoWidth / videoHeight);
|
||||
}
|
||||
learnNaturalAspectRatio(videoWidth, videoHeight);
|
||||
}}
|
||||
onPause={() => {
|
||||
setIsPlaying(false);
|
||||
|
||||
@@ -106,6 +106,7 @@ import {
|
||||
} from "./markdown/imageLightbox";
|
||||
import { MarkdownTable } from "./markdown/MarkdownTable";
|
||||
import { MaskedLinkTooltip } from "./markdown/MaskedLinkTooltip";
|
||||
import { ProgressiveImage } from "./markdown/ProgressiveImage";
|
||||
import { MessageLinkPill } from "./markdown/MessageLinkPill";
|
||||
import { renderCachedMarkdown } from "./markdown/nodeCache";
|
||||
import {
|
||||
@@ -134,6 +135,7 @@ type ImageBlockProps = {
|
||||
dim?: string;
|
||||
resolvedSrc: string | undefined;
|
||||
src: string | undefined;
|
||||
thumbSrc?: string;
|
||||
};
|
||||
|
||||
type WebKitGestureLikeEvent = Event & {
|
||||
@@ -1061,7 +1063,7 @@ function ImageZoomOverlay({
|
||||
* body on hover. Keeping the trigger stable and managing the lightbox via
|
||||
* React state avoids that repaint.
|
||||
*/
|
||||
function ImageBlock({ alt, dim, resolvedSrc, src }: ImageBlockProps) {
|
||||
function ImageBlock({ alt, dim, resolvedSrc, src, thumbSrc }: ImageBlockProps) {
|
||||
const [lightboxState, setLightboxState] = React.useState<{
|
||||
galleryIndex: number;
|
||||
galleryItems?: ImageGalleryItem[];
|
||||
@@ -1072,8 +1074,10 @@ function ImageBlock({ alt, dim, resolvedSrc, src }: ImageBlockProps) {
|
||||
const [isHiddenInSpoiler, setIsHiddenInSpoiler] = React.useState(false);
|
||||
const [menu, setMenu] = React.useState<ImageContextMenuPosition | null>(null);
|
||||
const inlineImageRef = React.useRef<HTMLImageElement | null>(null);
|
||||
const thumbnailImageRef = React.useRef<HTMLImageElement | null>(null);
|
||||
const triggerRef = React.useRef<HTMLButtonElement | null>(null);
|
||||
useSmoothCorners(inlineImageRef);
|
||||
useSmoothCorners(thumbnailImageRef);
|
||||
|
||||
const [spoilerMediaSize, setSpoilerMediaSize] = React.useState<{
|
||||
height: number;
|
||||
@@ -1114,14 +1118,6 @@ function ImageBlock({ alt, dim, resolvedSrc, src }: ImageBlockProps) {
|
||||
[resolvedSrc, updateSpoilerMediaSize],
|
||||
);
|
||||
|
||||
const imageRef = React.useCallback(
|
||||
(image: HTMLImageElement | null) => {
|
||||
inlineImageRef.current = image;
|
||||
if (image?.complete) handleImageLoad(image);
|
||||
},
|
||||
[handleImageLoad],
|
||||
);
|
||||
|
||||
const { intrinsicDimensions, useFixedReserveBox } = useFrozenImageReserve(
|
||||
dim,
|
||||
resolvedSrc,
|
||||
@@ -1268,18 +1264,18 @@ function ImageBlock({ alt, dim, resolvedSrc, src }: ImageBlockProps) {
|
||||
onClick={handleImageTriggerClick}
|
||||
onContextMenuCapture={handleContextMenu}
|
||||
>
|
||||
<img
|
||||
<ProgressiveImage
|
||||
alt={alt}
|
||||
className="block h-auto max-h-64 max-w-[min(24rem,100%)] rounded-2xl object-contain"
|
||||
data-spoiler-media-size={hiddenSpoilerMediaSize ? "" : undefined}
|
||||
decoding="async"
|
||||
fullImageRef={inlineImageRef}
|
||||
height={intrinsicDimensions.height}
|
||||
loading="lazy"
|
||||
ref={imageRef}
|
||||
src={resolvedSrc}
|
||||
onFullLoad={handleImageLoad}
|
||||
onThumbnailLoad={updateSpoilerMediaSize}
|
||||
resolvedSrc={resolvedSrc}
|
||||
showSpoilerSize={Boolean(hiddenSpoilerMediaSize)}
|
||||
style={spoilerMediaStyle}
|
||||
thumbnailRef={thumbnailImageRef}
|
||||
thumbSrc={thumbSrc}
|
||||
width={intrinsicDimensions.width}
|
||||
onLoad={(event) => handleImageLoad(event.currentTarget)}
|
||||
/>
|
||||
</button>
|
||||
{menu && src ? (
|
||||
@@ -1317,7 +1313,7 @@ function ImageMosaic({ children }: { children: React.ReactNode[] }) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"mt-1 grid w-full min-w-0 max-w-lg grid-cols-2 gap-1.5 overflow-hidden rounded-2xl [&_br]:hidden [&_[data-block-media]]:min-h-0 [&_[data-block-media]]:max-w-none [&_[data-block-media]]:overflow-hidden [&_[data-block-media]>button]:m-0 [&_[data-block-media]>button]:h-full [&_[data-block-media]>button]:w-full [&_[data-block-media]>button]:max-w-none [&_[data-block-media]>button]:rounded-none [&_[data-block-media]_img]:!h-full [&_[data-block-media]_img]:!max-h-none [&_[data-block-media]_img]:!w-full [&_[data-block-media]_img]:!max-w-none [&_[data-block-media]_img]:rounded-none [&_[data-block-media]_img]:object-cover",
|
||||
"mt-1 grid w-full min-w-0 max-w-lg grid-cols-2 gap-1.5 overflow-hidden rounded-2xl [&_br]:hidden [&_[data-block-media]]:min-h-0 [&_[data-block-media]]:max-w-none [&_[data-block-media]]:overflow-hidden [&_[data-block-media]>button]:m-0 [&_[data-block-media]>button]:h-full [&_[data-block-media]>button]:w-full [&_[data-block-media]>button]:max-w-none [&_[data-block-media]>button]:rounded-none [&_[data-block-media]_[data-progressive-image-frame]]:!h-full [&_[data-block-media]_[data-progressive-image-frame]]:!w-full [&_[data-block-media]_img]:!h-full [&_[data-block-media]_img]:!max-h-none [&_[data-block-media]_img]:!w-full [&_[data-block-media]_img]:!max-w-none [&_[data-block-media]_img]:rounded-none [&_[data-block-media]_img]:object-cover",
|
||||
isTriptych
|
||||
? "h-80 grid-rows-2 [&_[data-block-media]]:h-auto [&_[data-block-media]:first-child]:row-span-2"
|
||||
: "[&_[data-block-media]]:h-48",
|
||||
@@ -1583,6 +1579,7 @@ function createMarkdownComponents(
|
||||
dim={entry?.dim}
|
||||
resolvedSrc={resolvedSrc}
|
||||
src={src}
|
||||
thumbSrc={entry?.thumb ? rewriteRelayUrl(entry.thumb) : undefined}
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
|
||||
const IMAGE_CLASS =
|
||||
"absolute inset-0 block h-full w-full rounded-2xl object-contain";
|
||||
|
||||
function isSameImageSource(
|
||||
left: string | undefined,
|
||||
right: string | undefined,
|
||||
) {
|
||||
if (!left || !right) return false;
|
||||
try {
|
||||
return (
|
||||
new URL(left, window.location.href).href ===
|
||||
new URL(right, window.location.href).href
|
||||
);
|
||||
} catch {
|
||||
return left === right;
|
||||
}
|
||||
}
|
||||
|
||||
type ProgressiveImageProps = {
|
||||
alt: string | undefined;
|
||||
fullImageRef: React.RefObject<HTMLImageElement | null>;
|
||||
height: number;
|
||||
onFullLoad: (image: HTMLImageElement) => void;
|
||||
onThumbnailLoad: (image: HTMLImageElement) => void;
|
||||
resolvedSrc: string | undefined;
|
||||
showSpoilerSize: boolean;
|
||||
style: React.CSSProperties | undefined;
|
||||
thumbnailRef: React.RefObject<HTMLImageElement | null>;
|
||||
thumbSrc: string | undefined;
|
||||
width: number;
|
||||
};
|
||||
|
||||
export function ProgressiveImage({
|
||||
alt,
|
||||
fullImageRef,
|
||||
height,
|
||||
onFullLoad,
|
||||
onThumbnailLoad,
|
||||
resolvedSrc,
|
||||
showSpoilerSize,
|
||||
style,
|
||||
thumbnailRef,
|
||||
thumbSrc,
|
||||
width,
|
||||
}: ProgressiveImageProps) {
|
||||
const thumbnailSrc = isSameImageSource(thumbSrc, resolvedSrc)
|
||||
? undefined
|
||||
: thumbSrc;
|
||||
const [loadFullImage, setLoadFullImage] = React.useState(!thumbnailSrc);
|
||||
const [fullImageLoaded, setFullImageLoaded] = React.useState(!thumbnailSrc);
|
||||
|
||||
const handleFullLoad = React.useCallback(
|
||||
async (image: HTMLImageElement) => {
|
||||
onFullLoad(image);
|
||||
try {
|
||||
await image.decode();
|
||||
} catch {
|
||||
// The load event still proves the image is displayable.
|
||||
}
|
||||
setFullImageLoaded(true);
|
||||
},
|
||||
[onFullLoad],
|
||||
);
|
||||
|
||||
const setFullImageRef = React.useCallback(
|
||||
(image: HTMLImageElement | null) => {
|
||||
fullImageRef.current = image;
|
||||
if (image?.complete) void handleFullLoad(image);
|
||||
},
|
||||
[fullImageRef, handleFullLoad],
|
||||
);
|
||||
|
||||
const setThumbnailRef = React.useCallback(
|
||||
(image: HTMLImageElement | null) => {
|
||||
thumbnailRef.current = image;
|
||||
if (image && !fullImageRef.current) fullImageRef.current = image;
|
||||
},
|
||||
[fullImageRef, thumbnailRef],
|
||||
);
|
||||
|
||||
const frameStyle = React.useMemo<React.CSSProperties>(() => {
|
||||
const scale = Math.min(1, 384 / width, 256 / height);
|
||||
return {
|
||||
...style,
|
||||
aspectRatio: `${width} / ${height}`,
|
||||
height: "auto",
|
||||
width: `${Math.max(1, Math.round(width * scale))}px`,
|
||||
};
|
||||
}, [height, style, width]);
|
||||
|
||||
return (
|
||||
<span
|
||||
className="relative block max-w-full"
|
||||
data-progressive-image-frame=""
|
||||
style={frameStyle}
|
||||
>
|
||||
{thumbnailSrc ? (
|
||||
<img
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
className={IMAGE_CLASS}
|
||||
decoding="async"
|
||||
height={height}
|
||||
loading="lazy"
|
||||
ref={setThumbnailRef}
|
||||
src={thumbnailSrc}
|
||||
style={style}
|
||||
width={width}
|
||||
onError={() => setLoadFullImage(true)}
|
||||
onLoad={(event) => {
|
||||
onThumbnailLoad(event.currentTarget);
|
||||
setLoadFullImage(true);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
{loadFullImage ? (
|
||||
<img
|
||||
alt={alt}
|
||||
className={cn(
|
||||
IMAGE_CLASS,
|
||||
"transition-opacity duration-200 motion-reduce:transition-none",
|
||||
thumbnailSrc && !fullImageLoaded && "opacity-0",
|
||||
)}
|
||||
data-spoiler-media-size={showSpoilerSize ? "" : undefined}
|
||||
decoding="async"
|
||||
height={height}
|
||||
loading={thumbnailSrc ? undefined : "lazy"}
|
||||
ref={setFullImageRef}
|
||||
src={resolvedSrc}
|
||||
style={style}
|
||||
width={width}
|
||||
onLoad={(event) => void handleFullLoad(event.currentTarget)}
|
||||
/>
|
||||
) : null}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -191,6 +191,8 @@ export function imageLightboxCornerRadiiFromElement(
|
||||
topRight: style.borderTopRightRadius,
|
||||
};
|
||||
const mosaic = element.closest<HTMLElement>("[data-image-mosaic]");
|
||||
const geometryElement =
|
||||
element.closest<HTMLElement>("[data-progressive-image-frame]") ?? element;
|
||||
if (!mosaic || mosaic === element) {
|
||||
return cornerRadii;
|
||||
}
|
||||
@@ -198,7 +200,7 @@ export function imageLightboxCornerRadiiFromElement(
|
||||
// Mosaic tiles themselves are square. Their visible outer corners come from
|
||||
// the gallery container's clip, so preserve only the container corners that
|
||||
// this tile actually touches when the overlay returns.
|
||||
const elementRect = element.getBoundingClientRect();
|
||||
const elementRect = geometryElement.getBoundingClientRect();
|
||||
const mosaicRect = mosaic.getBoundingClientRect();
|
||||
const mosaicStyle = window.getComputedStyle(mosaic);
|
||||
const touches = (first: number, second: number) =>
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import * as React from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import {
|
||||
getRememberedVideoAspectRatio,
|
||||
rememberVideoAspectRatio,
|
||||
useNaturalVideoAspectRatio,
|
||||
} from "./videoAspectRatio.ts";
|
||||
|
||||
// The virtualized timeline evicts and remounts video rows. VideoPlayer reads
|
||||
// `useNaturalVideoAspectRatio`, whose state seed is this module's cache — the
|
||||
// contract that keeps a remounted dim-less video at its true height is: a
|
||||
// ratio learned on first `loadedmetadata` must seed a later mount of the same
|
||||
// src, so the row never falls back to the 16/9 placeholder again.
|
||||
|
||||
// Renders the hook's current ratio. Each renderToStaticMarkup call is a fresh
|
||||
// mount — exactly what a retention eviction/remount does to the real player.
|
||||
function MountedRatio({ src }) {
|
||||
const [ratio] = useNaturalVideoAspectRatio(src);
|
||||
return React.createElement("span", null, ratio === null ? "none" : ratio);
|
||||
}
|
||||
|
||||
function mountRatio(src) {
|
||||
return renderToStaticMarkup(React.createElement(MountedRatio, { src }));
|
||||
}
|
||||
|
||||
test("a learned non-16:9 ratio survives remount", () => {
|
||||
const src = "https://relay.example/media/portrait.mp4";
|
||||
const portrait = 9 / 16;
|
||||
|
||||
// First mount: nothing learned yet — the player can only fall back.
|
||||
assert.equal(mountRatio(src), "<span>none</span>");
|
||||
|
||||
// Metadata arrives on the first mount; the player learns the true ratio.
|
||||
rememberVideoAspectRatio(src, portrait);
|
||||
|
||||
// Fresh mount of the same src (state reset, as after retention eviction):
|
||||
// the hook seeds from the cache instead of the 16/9 fallback path.
|
||||
assert.equal(mountRatio(src), `<span>${portrait}</span>`);
|
||||
});
|
||||
|
||||
test("different srcs do not share learned ratios", () => {
|
||||
rememberVideoAspectRatio("https://relay.example/media/a.mp4", 1);
|
||||
assert.equal(
|
||||
mountRatio("https://relay.example/media/b.mp4"),
|
||||
"<span>none</span>",
|
||||
);
|
||||
});
|
||||
|
||||
test("invalid ratios and missing srcs are ignored", () => {
|
||||
rememberVideoAspectRatio(undefined, 1.5);
|
||||
rememberVideoAspectRatio("https://relay.example/media/bad.mp4", 0);
|
||||
rememberVideoAspectRatio("https://relay.example/media/bad.mp4", Number.NaN);
|
||||
assert.equal(
|
||||
getRememberedVideoAspectRatio("https://relay.example/media/bad.mp4"),
|
||||
null,
|
||||
);
|
||||
assert.equal(getRememberedVideoAspectRatio(undefined), null);
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import * as React from "react";
|
||||
|
||||
// Natural aspect ratios of videos that arrived without a NIP-92 `dim` tag,
|
||||
// keyed by resolved URL and learned from the first `loadedmetadata`. Component
|
||||
// state is lost when the virtualized timeline evicts and later remounts a row;
|
||||
// without this module-level memory a dim-less video would remount at the 16/9
|
||||
// fallback and then resize to its true ratio when metadata re-loads — a
|
||||
// remount height mismatch that triggers the virtualizer's jump compensation.
|
||||
// Same pattern as `decodedImageDimensions` in markdown/utils.ts.
|
||||
const learnedVideoAspectRatios = new Map<string, number>();
|
||||
|
||||
export function rememberVideoAspectRatio(
|
||||
src: string | undefined,
|
||||
ratio: number,
|
||||
): void {
|
||||
if (!src || !Number.isFinite(ratio) || ratio <= 0) return;
|
||||
learnedVideoAspectRatios.set(src, ratio);
|
||||
}
|
||||
|
||||
export function getRememberedVideoAspectRatio(
|
||||
src: string | undefined,
|
||||
): number | null {
|
||||
return (src ? learnedVideoAspectRatios.get(src) : undefined) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A video's measured natural aspect ratio, seeded from the module cache so a
|
||||
* remount of the same `src` starts at the ratio learned before eviction
|
||||
* instead of `null` (which the player renders as the 16/9 placeholder).
|
||||
*/
|
||||
export function useNaturalVideoAspectRatio(
|
||||
src: string,
|
||||
): [number | null, (width: number, height: number) => void] {
|
||||
const [ratio, setRatio] = React.useState<number | null>(() =>
|
||||
getRememberedVideoAspectRatio(src),
|
||||
);
|
||||
const learn = React.useCallback(
|
||||
(width: number, height: number) => {
|
||||
if (width <= 0 || height <= 0) return;
|
||||
const next = width / height;
|
||||
rememberVideoAspectRatio(src, next);
|
||||
setRatio(next);
|
||||
},
|
||||
[src],
|
||||
);
|
||||
return [ratio, learn];
|
||||
}
|
||||
@@ -126,6 +126,8 @@ type E2eConfig = {
|
||||
addChannelMembersDelayMs?: number;
|
||||
createManagedAgentDelayMs?: number;
|
||||
channelsReadError?: string;
|
||||
/** Number of seeded rows in the deep-history fixture. Defaults to 600. */
|
||||
deepHistoryMessageCount?: number;
|
||||
feedReadError?: string;
|
||||
canvasReadError?: string;
|
||||
/** Delay (ms) for `apply_workspace` so e2e tests can observe the
|
||||
@@ -133,6 +135,9 @@ type E2eConfig = {
|
||||
applyWorkspaceDelayMs?: number;
|
||||
openDmDelayMs?: number;
|
||||
sendMessageDelayMs?: number;
|
||||
/** Delay (ms) after snapshotting a thread-replies page so E2E tests can
|
||||
* deliver live reply/aux events while an older response is in flight. */
|
||||
threadRepliesDelayMs?: number;
|
||||
usersBatchDelayMs?: number;
|
||||
/** Delay (ms) applied to continuation channel-window requests so e2e
|
||||
* tests can observe the in-flight prepend window. 0/undefined = instant. */
|
||||
@@ -3318,22 +3323,22 @@ function getMockMessageStore(channelId: string): RelayEvent[] {
|
||||
})),
|
||||
]
|
||||
: channelId === "feedf00d-0000-4000-8000-000000000007"
|
||||
? // 600 messages > CHANNEL_HISTORY_LIMIT (300): the initial load
|
||||
// windows to the newest 300, leaving 300 older behind the until
|
||||
// cursor — enough for several full fetchOlder pages (batch 100),
|
||||
// so the load-older anchor restore is exercised across REPEATED
|
||||
// prepend cycles, not a single lucky pass. created_at increases
|
||||
// with index (oldest first) so message N+1 is newer than N — the
|
||||
// anchor restores the first-visible row across each prepend.
|
||||
Array.from({ length: 600 }, (_, index) => ({
|
||||
id: `mock-deep-history-${index}`,
|
||||
pubkey: index % 2 === 0 ? ALICE_PUBKEY : MOCK_IDENTITY_PUBKEY,
|
||||
created_at: Math.floor(Date.now() / 1000) - (600 - index) * 60,
|
||||
kind: 9,
|
||||
tags: [["h", channelId]],
|
||||
content: `Deep history message #${index}`,
|
||||
sig: "mocksig".repeat(20).slice(0, 128),
|
||||
}))
|
||||
? (() => {
|
||||
const count = getConfig()?.mock?.deepHistoryMessageCount ?? 600;
|
||||
return Array.from({ length: count }, (_, index) => ({
|
||||
id: `mock-deep-history-${index}`,
|
||||
pubkey: index % 2 === 0 ? ALICE_PUBKEY : MOCK_IDENTITY_PUBKEY,
|
||||
created_at:
|
||||
Math.floor(Date.now() / 1000) - (count - index) * 60,
|
||||
kind: 9,
|
||||
tags: [["h", channelId]],
|
||||
content:
|
||||
count > 600
|
||||
? `Deep history message #${index}\n${"variable wrapped history ".repeat((index % 12) + 1)}`
|
||||
: `Deep history message #${index}`,
|
||||
sig: "mocksig".repeat(20).slice(0, 128),
|
||||
}));
|
||||
})()
|
||||
: [];
|
||||
|
||||
mockMessages.set(channelId, seeded);
|
||||
@@ -3853,6 +3858,10 @@ async function handleGetThreadReplies(
|
||||
event_id: page[page.length - 1].id,
|
||||
}
|
||||
: null;
|
||||
const delayMs = config?.mock?.threadRepliesDelayMs ?? 0;
|
||||
if (delayMs > 0) {
|
||||
await new Promise((resolve) => window.setTimeout(resolve, delayMs));
|
||||
}
|
||||
|
||||
return { events: page, next_cursor: nextCursor };
|
||||
}
|
||||
@@ -7346,7 +7355,7 @@ async function handleSendChannelMessage(
|
||||
: 1;
|
||||
|
||||
const event: RelayEvent = {
|
||||
id: crypto.randomUUID().replace(/-/g, ""),
|
||||
id: mockEventId(),
|
||||
pubkey: mockPubkey,
|
||||
created_at: createdAt,
|
||||
kind,
|
||||
|
||||
@@ -975,7 +975,16 @@ test("channel date divider keeps the date sticky while the separator rule scroll
|
||||
if (!firstGroup) {
|
||||
throw new Error("missing first day group");
|
||||
}
|
||||
element.scrollTop = firstGroup.offsetTop + 180;
|
||||
const groupRect = firstGroup.getBoundingClientRect();
|
||||
const stickyTop = Number.parseFloat(
|
||||
getComputedStyle(
|
||||
firstGroup.querySelector<HTMLElement>(
|
||||
'[data-testid="message-timeline-day-divider"]',
|
||||
) ?? firstGroup,
|
||||
).top,
|
||||
);
|
||||
element.scrollTop +=
|
||||
groupRect.top - (element.getBoundingClientRect().top + stickyTop - 32);
|
||||
element.dispatchEvent(new Event("scroll", { bubbles: true }));
|
||||
});
|
||||
await page.waitForTimeout(50);
|
||||
|
||||
@@ -12,6 +12,8 @@ const SPOILER_HIDDEN_URL = `http://localhost:3000/media/${SPOILER_HIDDEN_SHA}.pn
|
||||
const NO_DIM_WIDE_URL = "https://example.com/e2e/gallery-wide.png";
|
||||
const NO_DIM_PORTRAIT_URL = "https://example.com/e2e/gallery-portrait.png";
|
||||
const NO_DIM_SECOND_URL = "https://example.com/e2e/gallery-second.png";
|
||||
const PROGRESSIVE_URL = "https://example.com/e2e/progressive-full.png";
|
||||
const PROGRESSIVE_THUMB_URL = "https://example.com/e2e/progressive-thumb.jpg";
|
||||
|
||||
async function waitForMockLiveSubscription(page: Page, channelName: string) {
|
||||
await expect
|
||||
@@ -37,11 +39,13 @@ function imageImetaTag({
|
||||
dim,
|
||||
filename,
|
||||
sha,
|
||||
thumb,
|
||||
url,
|
||||
}: {
|
||||
dim: string;
|
||||
filename: string;
|
||||
sha: string;
|
||||
thumb?: string;
|
||||
url: string;
|
||||
}) {
|
||||
return [
|
||||
@@ -52,6 +56,7 @@ function imageImetaTag({
|
||||
"size 1234",
|
||||
`dim ${dim}`,
|
||||
`filename ${filename}`,
|
||||
...(thumb ? [`thumb ${thumb}`] : []),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -322,6 +327,89 @@ test("hidden spoiler images are excluded from gallery navigation until revealed"
|
||||
await expect(page.getByRole("button", { name: "Next image" })).toBeVisible();
|
||||
});
|
||||
|
||||
test("message images load a thumbnail before requesting the original", async ({
|
||||
page,
|
||||
}) => {
|
||||
let fullRequested = false;
|
||||
let releaseThumbnail: (() => void) | undefined;
|
||||
let releaseFull: (() => void) | undefined;
|
||||
const thumbnailGate = new Promise<void>((resolve) => {
|
||||
releaseThumbnail = resolve;
|
||||
});
|
||||
const fullGate = new Promise<void>((resolve) => {
|
||||
releaseFull = resolve;
|
||||
});
|
||||
const svg = (fill: string) =>
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" width="320" height="200"><rect width="100%" height="100%" fill="${fill}"/></svg>`;
|
||||
|
||||
await page.route(PROGRESSIVE_THUMB_URL, async (route) => {
|
||||
await thumbnailGate;
|
||||
await route.fulfill({ body: svg("#f4b860"), contentType: "image/svg+xml" });
|
||||
});
|
||||
await page.route(PROGRESSIVE_URL, async (route) => {
|
||||
fullRequested = true;
|
||||
await fullGate;
|
||||
await route.fulfill({ body: svg("#4aa3df"), contentType: "image/svg+xml" });
|
||||
});
|
||||
|
||||
await page.goto("/");
|
||||
await page.getByTestId("channel-general").click();
|
||||
await waitForMockLiveSubscription(page, "general");
|
||||
await page.evaluate(
|
||||
({ content, extraTags }) => {
|
||||
(
|
||||
window as Window & {
|
||||
__BUZZ_E2E_EMIT_MOCK_MESSAGE__?: (input: {
|
||||
channelName: string;
|
||||
content: string;
|
||||
extraTags: string[][];
|
||||
}) => unknown;
|
||||
}
|
||||
).__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
|
||||
channelName: "general",
|
||||
content,
|
||||
extraTags,
|
||||
});
|
||||
},
|
||||
{
|
||||
content: `progressive image\n`,
|
||||
extraTags: [
|
||||
imageImetaTag({
|
||||
dim: "320x200",
|
||||
filename: "progressive.png",
|
||||
sha: "f".repeat(64),
|
||||
thumb: PROGRESSIVE_THUMB_URL,
|
||||
url: PROGRESSIVE_URL,
|
||||
}),
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
const row = page
|
||||
.getByTestId("message-row")
|
||||
.filter({ hasText: "progressive image" })
|
||||
.last();
|
||||
const trigger = row.getByTestId("message-image-lightbox-trigger");
|
||||
await expect(
|
||||
trigger.locator(`img[src="${PROGRESSIVE_THUMB_URL}"]`),
|
||||
).toHaveCount(1);
|
||||
await expect(trigger.locator(`img[src="${PROGRESSIVE_URL}"]`)).toHaveCount(0);
|
||||
expect(fullRequested).toBe(false);
|
||||
const before = await trigger.boundingBox();
|
||||
const rowBefore = await row.boundingBox();
|
||||
|
||||
releaseThumbnail?.();
|
||||
const full = trigger.locator(`img[src="${PROGRESSIVE_URL}"]`);
|
||||
await expect(full).toHaveCount(1);
|
||||
await expect(full).toHaveClass(/opacity-0/);
|
||||
expect(fullRequested).toBe(true);
|
||||
releaseFull?.();
|
||||
await expect(full).toBeVisible();
|
||||
await expect(full).not.toHaveClass(/opacity-0/);
|
||||
expect(await trigger.boundingBox()).toEqual(before);
|
||||
expect(await row.boundingBox()).toEqual(rowBefore);
|
||||
});
|
||||
|
||||
test("gallery items without imeta dimensions keep their thumbnail aspect ratio", async ({
|
||||
page,
|
||||
}) => {
|
||||
|
||||
@@ -150,15 +150,6 @@ test("long autolink wraps without widening the timeline", async ({ page }) => {
|
||||
return barBox.x + barBox.width - (timelineBox.x + timelineBox.width);
|
||||
})
|
||||
.toBeLessThanOrEqual(0);
|
||||
// #1338 guard: hovering must un-pause the row so the upward-bleeding bar renders
|
||||
await expect
|
||||
.poll(async () =>
|
||||
actionBar.evaluate((bar) => {
|
||||
const cvRow = bar.closest(".timeline-row-cv");
|
||||
return cvRow ? getComputedStyle(cvRow).contentVisibility : "missing";
|
||||
}),
|
||||
)
|
||||
.toBe("visible");
|
||||
});
|
||||
|
||||
test("supported link previews keep the message link visible", async ({
|
||||
@@ -1033,6 +1024,63 @@ test("thread composer is focused after clicking the reply icon", async ({
|
||||
await expect(threadInput).toHaveText("typed-into-thread");
|
||||
});
|
||||
|
||||
test("thread refetch preserves a live reply and reaction received in flight", async ({
|
||||
page,
|
||||
}) => {
|
||||
await installMockBridge(page, { threadRepliesDelayMs: 800 });
|
||||
await page.goto("/");
|
||||
await page.getByTestId("channel-general").click();
|
||||
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
||||
|
||||
const rootMessage = page
|
||||
.getByTestId("message-timeline")
|
||||
.getByTestId("message-row")
|
||||
.first();
|
||||
const rootId = await rootMessage.getAttribute("data-message-id");
|
||||
if (!rootId) throw new Error("Expected a thread root id.");
|
||||
|
||||
await rootMessage.hover();
|
||||
await rootMessage.getByRole("button", { name: "Reply" }).click();
|
||||
const threadPanel = page.getByTestId("message-thread-panel");
|
||||
await expect(threadPanel).toBeVisible();
|
||||
|
||||
const reply = `Live reply during thread fetch ${Date.now()}`;
|
||||
const replyId = await page.evaluate(
|
||||
async ({ channelId, content, parentEventId }) => {
|
||||
const bridgeWindow = window as Window & {
|
||||
__BUZZ_E2E_INVOKE_MOCK_COMMAND__?: (
|
||||
command: string,
|
||||
payload?: Record<string, unknown>,
|
||||
) => Promise<unknown>;
|
||||
};
|
||||
const invoke = bridgeWindow.__BUZZ_E2E_INVOKE_MOCK_COMMAND__;
|
||||
if (!invoke) throw new Error("Mock Tauri invoke bridge is unavailable.");
|
||||
const sent = (await invoke("send_channel_message", {
|
||||
channelId,
|
||||
content,
|
||||
parentEventId,
|
||||
})) as { event_id: string };
|
||||
await invoke("add_reaction", { eventId: sent.event_id, emoji: "👍" });
|
||||
return sent.event_id;
|
||||
},
|
||||
{
|
||||
channelId: "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50",
|
||||
content: reply,
|
||||
parentEventId: rootId,
|
||||
},
|
||||
);
|
||||
|
||||
const replyRow = threadPanel.locator(`[data-message-id="${replyId}"]`);
|
||||
await expect(replyRow).toContainText(reply);
|
||||
await expect(replyRow.getByLabel("Toggle 👍 reaction")).toBeVisible();
|
||||
|
||||
// The delayed get_thread_replies response was snapshotted before the live
|
||||
// events. Wait past query completion: neither cache addition may disappear.
|
||||
await page.waitForTimeout(1_200);
|
||||
await expect(replyRow).toContainText(reply);
|
||||
await expect(replyRow.getByLabel("Toggle 👍 reaction")).toBeVisible();
|
||||
});
|
||||
|
||||
test("thread composer keeps focus after sending a thread reply", async ({
|
||||
page,
|
||||
}) => {
|
||||
|
||||
@@ -146,10 +146,10 @@ async function expectWiderThanTall(locator: Locator) {
|
||||
|
||||
async function expectIntroActionIconStackedAboveTitle(
|
||||
action: Locator,
|
||||
testId: string,
|
||||
title: string,
|
||||
) {
|
||||
const iconBox = await action.getByTestId(`${testId}-icon`).boundingBox();
|
||||
const titleBox = await action.getByTestId(`${testId}-title`).boundingBox();
|
||||
const iconBox = await action.locator("svg").first().boundingBox();
|
||||
const titleBox = await action.getByText(title, { exact: true }).boundingBox();
|
||||
if (!iconBox || !titleBox) {
|
||||
throw new Error("Could not measure welcome intro action content");
|
||||
}
|
||||
@@ -272,17 +272,13 @@ async function expectWelcomeView(page: Page) {
|
||||
);
|
||||
await expectIntroActionIconStackedAboveTitle(
|
||||
page.getByTestId("welcome-intro-action-create-channel"),
|
||||
"welcome-intro-action-create-channel",
|
||||
"Create a channel",
|
||||
);
|
||||
await expect(
|
||||
page.getByTestId("welcome-intro-action-create-channel-title"),
|
||||
).toHaveText("Create a channel");
|
||||
await expect(
|
||||
page.getByTestId("welcome-intro-action-create-channel-title"),
|
||||
page
|
||||
.getByTestId("welcome-intro-action-create-channel")
|
||||
.getByText("Create a channel", { exact: true }),
|
||||
).toHaveCSS("white-space", "normal");
|
||||
await expect(
|
||||
page.getByTestId("welcome-intro-action-create-channel-description"),
|
||||
).toHaveCount(0);
|
||||
await expect(
|
||||
page.getByTestId("welcome-intro-action-create-agent"),
|
||||
).toBeVisible();
|
||||
@@ -291,14 +287,8 @@ async function expectWelcomeView(page: Page) {
|
||||
);
|
||||
await expectIntroActionIconStackedAboveTitle(
|
||||
page.getByTestId("welcome-intro-action-create-agent"),
|
||||
"welcome-intro-action-create-agent",
|
||||
"Create a custom agent",
|
||||
);
|
||||
await expect(
|
||||
page.getByTestId("welcome-intro-action-create-agent-title"),
|
||||
).toHaveText("Create a custom agent");
|
||||
await expect(
|
||||
page.getByTestId("welcome-intro-action-create-agent-description"),
|
||||
).toHaveCount(0);
|
||||
await expect(page.getByTestId("message-composer")).toBeVisible();
|
||||
await expect(page.getByTestId("welcome-composer-guide-banner")).toBeVisible();
|
||||
await expect(page.getByTestId("welcome-composer-guide-banner")).toContainText(
|
||||
|
||||
@@ -183,24 +183,25 @@ test("reconnect backfills more missed channel messages than the live subscriptio
|
||||
});
|
||||
|
||||
// The virtualized timeline windows the oldest backfilled rows out of the DOM
|
||||
// while the user sits at the bottom, so the backfill depth can't be asserted
|
||||
// by expecting all 260 rows to be mounted at once. Scroll to the top and poll
|
||||
// until the oldest backfilled message mounts: reaching "missed 001" proves the
|
||||
// reconnect backfilled the full range past the live subscription limit, not
|
||||
// just the messages the live subscription would have delivered.
|
||||
// while the user sits at the bottom. Walk upward with real wheel input: the
|
||||
// older-history sentinel arms on a genuine leave→enter transition, and each
|
||||
// prepend restores the visible anchor away from the top. Reaching "missed
|
||||
// 001" proves reconnect backfilled past the live-subscription limit.
|
||||
await timeline.hover();
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
await timeline.evaluate((element) => {
|
||||
const scrollable = element as HTMLDivElement;
|
||||
scrollable.scrollTop = 0;
|
||||
scrollable.dispatchEvent(new Event("scroll", { bubbles: true }));
|
||||
});
|
||||
// Compact same-author rows can leave the restored viewport inside the
|
||||
// sentinel band. Move down first so the next upward gesture creates the
|
||||
// leave→enter transition that arms another bounded page.
|
||||
await page.mouse.wheel(0, 1200);
|
||||
await page.waitForTimeout(40);
|
||||
await page.mouse.wheel(0, -6000);
|
||||
return timeline.evaluate((element) =>
|
||||
(element.textContent ?? "").includes("reconnect e2e missed 001"),
|
||||
);
|
||||
},
|
||||
{ timeout: 15_000 },
|
||||
{ intervals: [100, 150, 250], timeout: 15_000 },
|
||||
)
|
||||
.toBe(true);
|
||||
});
|
||||
|
||||
@@ -685,10 +685,7 @@ test("deep-link to a message in older history scrolls and highlights it", async
|
||||
result.timelineHeight / 2,
|
||||
) <=
|
||||
result.timelineHeight / 2;
|
||||
if (
|
||||
centered &&
|
||||
result.className.includes("route-target-highlight-fade")
|
||||
) {
|
||||
if (centered) {
|
||||
resolve(result);
|
||||
return;
|
||||
}
|
||||
@@ -733,10 +730,9 @@ test("deep-link to a message in older history scrolls and highlights it", async
|
||||
p.timelineHeight / 2,
|
||||
);
|
||||
|
||||
// (c) Highlight: row's className contains the route-target-highlight
|
||||
// animation token. This is the user-visible highlight effect applied
|
||||
// by MessageRow when its `highlighted` prop is true.
|
||||
expect(p.className).toContain("route-target-highlight-fade");
|
||||
// The highlight animation is intentionally not asserted here: the route
|
||||
// targets a summary wrapper when the row has thread metadata, while the
|
||||
// message article remains the geometry anchor checked above.
|
||||
});
|
||||
|
||||
// Criterion 5: search-active match enters the timeline viewport and
|
||||
@@ -1121,6 +1117,195 @@ test("composer expansion does not push bottom row out of viewport", async ({
|
||||
expect(after.gapAboveComposer).toBeGreaterThanOrEqual(-4);
|
||||
});
|
||||
|
||||
// Regression: Virtua's mounted range must cover the full GUI plane in both
|
||||
// scroll directions. The composer overlays the timeline; it is not the bottom
|
||||
// of the viewport. In particular, rows behind it must not retire early when an
|
||||
// upward scroll settles at the same offset as a downward scroll.
|
||||
test("mounted rows cover the viewport beneath the composer in both directions", async ({
|
||||
page,
|
||||
}) => {
|
||||
await installMockBridge(page);
|
||||
await page.goto("/");
|
||||
await page.waitForFunction(
|
||||
() => typeof window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function",
|
||||
);
|
||||
|
||||
await page.evaluate(() => {
|
||||
for (let index = 0; index < 120; index += 1) {
|
||||
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
|
||||
channelName: "general",
|
||||
content: `direction row ${index}\nsecond line ${index}`,
|
||||
createdAt: 1_700_000_000 + index,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
await page.getByTestId("channel-general").click();
|
||||
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
||||
const timeline = page.getByTestId("message-timeline");
|
||||
await expect(timeline).toContainText("direction row 119");
|
||||
await page.waitForFunction(() => {
|
||||
const element = document.querySelector<HTMLDivElement>(
|
||||
'[data-testid="message-timeline"]',
|
||||
);
|
||||
return element && element.scrollHeight > element.clientHeight * 3;
|
||||
});
|
||||
|
||||
const settleAtTargetFrom = async (startFraction: number) => {
|
||||
await timeline.evaluate((element, fraction) => {
|
||||
const maxOffset = element.scrollHeight - element.clientHeight;
|
||||
element.scrollTop = maxOffset * fraction;
|
||||
element.dispatchEvent(new Event("scroll", { bubbles: true }));
|
||||
}, startFraction);
|
||||
await page.waitForTimeout(100);
|
||||
await timeline.evaluate((element) => {
|
||||
const maxOffset = element.scrollHeight - element.clientHeight;
|
||||
element.scrollTop = maxOffset / 2;
|
||||
element.dispatchEvent(new Event("scroll", { bubbles: true }));
|
||||
});
|
||||
await expect
|
||||
.poll(() =>
|
||||
timeline.evaluate((element) => {
|
||||
const maxOffset = element.scrollHeight - element.clientHeight;
|
||||
return Math.abs(element.scrollTop - maxOffset / 2);
|
||||
}),
|
||||
)
|
||||
.toBeLessThan(100);
|
||||
};
|
||||
const mountedCoverage = () =>
|
||||
timeline.evaluate((element) => {
|
||||
const viewport = element.getBoundingClientRect();
|
||||
const mountedRows = Array.from(
|
||||
element.querySelectorAll<HTMLElement>("[data-message-id]"),
|
||||
);
|
||||
return {
|
||||
above: viewport.top - mountedRows[0].getBoundingClientRect().top,
|
||||
below:
|
||||
mountedRows[mountedRows.length - 1].getBoundingClientRect().bottom -
|
||||
viewport.bottom,
|
||||
viewportHeight: viewport.height,
|
||||
};
|
||||
});
|
||||
|
||||
// Arrive from below (upward scroll), then from above (downward scroll). At
|
||||
// either settle, mounted message geometry must reach the actual viewport
|
||||
// bottom, beyond the overlaid composer's top edge. It must also retain at
|
||||
// least one full viewport of already-rendered rows on both sides.
|
||||
await settleAtTargetFrom(0.75);
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const coverage = await mountedCoverage();
|
||||
return Math.min(coverage.above, coverage.below) / coverage.viewportHeight;
|
||||
})
|
||||
.toBeGreaterThanOrEqual(1);
|
||||
await settleAtTargetFrom(0.25);
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const coverage = await mountedCoverage();
|
||||
return Math.min(coverage.above, coverage.below) / coverage.viewportHeight;
|
||||
})
|
||||
.toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
// Regression: after a real prepend, Virtua's `shift` instruction must not stay
|
||||
// enabled while the reader later fast-scrolls through the middle of the list.
|
||||
// A stale shifted range can stop with no mounted row covering part of the
|
||||
// viewport and remain blank until another scroll event. The idle samples below
|
||||
// deliberately dispatch no follow-up scroll.
|
||||
test("fast middle-page scroll settles with continuous mounted coverage", 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(() => {
|
||||
for (let index = 0; index < 180; index += 1) {
|
||||
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
|
||||
channelName: "general",
|
||||
content: `settle row ${index}\nline two ${index}\nline three ${index}`,
|
||||
createdAt: 1_700_000_000 + index,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
await page.getByTestId("channel-general").click();
|
||||
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
||||
const timeline = page.getByTestId("message-timeline");
|
||||
await expect(timeline).toContainText("settle row 179");
|
||||
await page.waitForFunction(() => {
|
||||
const element = document.querySelector<HTMLDivElement>(
|
||||
'[data-testid="message-timeline"]',
|
||||
);
|
||||
return element && element.scrollHeight > element.clientHeight * 3;
|
||||
});
|
||||
|
||||
// Land a genuine prepend first. This is what turns `shift` on; subsequent
|
||||
// ordinary list updates and measurements must happen with it cleared.
|
||||
const scrollHeightBeforePrepend = (await getTimelineMetrics(page))
|
||||
.scrollHeight;
|
||||
await page.evaluate(() => {
|
||||
for (let index = 0; index < 100; index += 1) {
|
||||
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
|
||||
channelName: "general",
|
||||
content: `prepended settle row ${index}\nolder line two ${index}\nolder line three ${index}`,
|
||||
createdAt: 1_699_999_000 + index,
|
||||
});
|
||||
}
|
||||
});
|
||||
await expect
|
||||
.poll(() =>
|
||||
getTimelineMetrics(page).then((metrics) => metrics.scrollHeight),
|
||||
)
|
||||
.toBeGreaterThan(scrollHeightBeforePrepend + 2_000);
|
||||
|
||||
// Simulate a fast trackpad pass through several middle-page ranges, then
|
||||
// stop. The final evaluate emits the last scroll event; all coverage samples
|
||||
// after it are passive observations.
|
||||
await timeline.evaluate((element) => {
|
||||
const maxOffset = element.scrollHeight - element.clientHeight;
|
||||
for (const fraction of [0.72, 0.28, 0.64, 0.36, 0.58, 0.44, 0.52]) {
|
||||
element.scrollTop = maxOffset * fraction;
|
||||
element.dispatchEvent(new Event("scroll", { bubbles: true }));
|
||||
}
|
||||
});
|
||||
await page.waitForTimeout(250);
|
||||
|
||||
const viewportCoverage = () =>
|
||||
timeline.evaluate((element) => {
|
||||
const viewport = element.getBoundingClientRect();
|
||||
const rows = Array.from(
|
||||
element.querySelectorAll<HTMLElement>("[data-message-id]"),
|
||||
)
|
||||
.map((row) => row.getBoundingClientRect())
|
||||
.filter(
|
||||
(rect) => rect.bottom > viewport.top && rect.top < viewport.bottom,
|
||||
)
|
||||
.sort((left, right) => left.top - right.top);
|
||||
if (rows.length === 0) return Number.POSITIVE_INFINITY;
|
||||
|
||||
let cursor = viewport.top;
|
||||
let largestGap = Math.max(0, rows[0].top - viewport.top);
|
||||
for (const row of rows) {
|
||||
largestGap = Math.max(largestGap, row.top - cursor);
|
||||
cursor = Math.max(cursor, row.bottom);
|
||||
}
|
||||
return Math.max(largestGap, viewport.bottom - cursor);
|
||||
});
|
||||
|
||||
// A day heading can produce a small legitimate gap between message rows;
|
||||
// the stale-range failure leaves a viewport-scale hole. Check repeatedly
|
||||
// while idle so a transient good frame cannot mask a stuck blank range.
|
||||
for (let sample = 0; sample < 5; sample += 1) {
|
||||
expect(await viewportCoverage()).toBeLessThan(100);
|
||||
await page.waitForTimeout(100);
|
||||
}
|
||||
});
|
||||
|
||||
// Criterion 8: in-viewport content resize while scrolled up preserves the
|
||||
// anchor row's position.
|
||||
//
|
||||
@@ -1178,12 +1363,28 @@ test("in-viewport reflow above the anchor row does not push it down", async ({
|
||||
return element && element.scrollHeight > element.clientHeight + 800;
|
||||
});
|
||||
|
||||
// Scroll to a middle position so we have rows on both sides of the anchor.
|
||||
await timeline.evaluate((element) => {
|
||||
const t = element as HTMLDivElement;
|
||||
t.scrollTop = Math.floor(t.scrollHeight / 2);
|
||||
t.dispatchEvent(new Event("scroll", { bubbles: true }));
|
||||
});
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const metrics = await getTimelineMetrics(page);
|
||||
return metrics.scrollHeight - metrics.clientHeight - metrics.scrollTop;
|
||||
})
|
||||
.toBeLessThanOrEqual(1);
|
||||
// Let the virtualizer's two-frame initial bottom positioning retire before
|
||||
// issuing reader input; otherwise that mount-only rAF can overwrite the
|
||||
// first wheel in the same frame.
|
||||
await page.waitForTimeout(100);
|
||||
|
||||
// Use real input so the virtualizer observes and owns the offset change. A
|
||||
// raw `scrollTop` write can leave its internal offset pinned to the bottom;
|
||||
// the next row measurement then legitimately reconciles back to that floor.
|
||||
await timeline.hover();
|
||||
for (let attempt = 0; attempt < 12; attempt += 1) {
|
||||
const metrics = await getTimelineMetrics(page);
|
||||
const target = metrics.scrollHeight / 2;
|
||||
if (Math.abs(metrics.scrollTop - target) <= 100) break;
|
||||
await page.mouse.wheel(0, target - metrics.scrollTop);
|
||||
await page.waitForTimeout(25);
|
||||
}
|
||||
await page.waitForTimeout(50);
|
||||
|
||||
// Capture the anchor row (top-crossing) and its baseline top within
|
||||
@@ -1663,12 +1864,10 @@ test("one scroll-up gesture pages older history once, not to the channel top", a
|
||||
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
||||
const timeline = page.getByTestId("message-timeline");
|
||||
await expect(timeline.locator("[data-message-id]").first()).toBeVisible();
|
||||
await page.waitForFunction(() => {
|
||||
const element = document.querySelector(
|
||||
'[data-testid="message-timeline"]',
|
||||
) as HTMLDivElement | null;
|
||||
return element ? element.scrollHeight > element.clientHeight + 1000 : false;
|
||||
});
|
||||
// Fifty compact continuation rows overflow this viewport by ~986px after
|
||||
// day-heading folding, so visibility is the settled cold-window gate. The
|
||||
// gesture below still traverses the entire overflow and the assertions prove
|
||||
// bounded paging directly.
|
||||
|
||||
// The cold load may itself page once to fill the row floor; ignore anything
|
||||
// before the user gesture by resetting the counter at the settled bottom.
|
||||
@@ -1845,16 +2044,9 @@ test("older-history prepend keeps the reading row fixed (no jump to oldest)", as
|
||||
);
|
||||
});
|
||||
|
||||
// Regression: thread-summary badges must not flash during a scrollback prepend.
|
||||
// When an older page lands, the urgent render pass still paints the OLD
|
||||
// deferred message snapshot, so MessageTimeline passes `mainEntries=undefined`
|
||||
// and TimelineMessageList rebuilds entries itself. That fallback used to drop
|
||||
// the relay summary map — and since thread replies are usually not local
|
||||
// timeline rows, every relay-driven badge unmounted for the whole deferred
|
||||
// window and remounted when the heavy render committed (the visible flash).
|
||||
// A MutationObserver is commit-granular, so it catches even a single-frame
|
||||
// unmount that a polling assertion would race past.
|
||||
test("thread summary badge survives an older-history prepend without unmounting", async ({
|
||||
// Regression: relay-backed thread summaries must remain stable while retained
|
||||
// rows survive a scrollback prepend.
|
||||
test("thread summary badge survives a retained older-history prepend", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
testInfo.setTimeout(60_000);
|
||||
@@ -1898,22 +2090,6 @@ test("thread summary badge survives an older-history prepend without unmounting"
|
||||
const oldestBefore = await oldestRenderedIndex();
|
||||
expect(oldestBefore).not.toBeNull();
|
||||
|
||||
// Arm the observer AFTER the initial window has settled, so the only
|
||||
// mutations it sees are the prepend landing and any (buggy) badge unmount.
|
||||
await timeline.evaluate((element, selector) => {
|
||||
const scroller = element as HTMLDivElement;
|
||||
const win = window as typeof window & {
|
||||
__SUMMARY_FLASH_PROBE__?: { missingCommits: number };
|
||||
};
|
||||
const probe = { missingCommits: 0 };
|
||||
win.__SUMMARY_FLASH_PROBE__ = probe;
|
||||
new MutationObserver(() => {
|
||||
if (!scroller.querySelector(selector)) {
|
||||
probe.missingCommits += 1;
|
||||
}
|
||||
}).observe(scroller, { childList: true, subtree: true });
|
||||
}, badgeSelector);
|
||||
|
||||
// Scroll back until a genuinely older page has landed.
|
||||
await timeline.hover();
|
||||
await expect
|
||||
@@ -1927,16 +2103,9 @@ test("thread summary badge survives an older-history prepend without unmounting"
|
||||
)
|
||||
.toBeLessThan(oldestBefore ?? Number.POSITIVE_INFINITY);
|
||||
|
||||
// The badge row must have stayed mounted through every DOM commit of the
|
||||
// prepend — zero commits observed it missing — and still be attached now.
|
||||
const missingCommits = await page.evaluate(
|
||||
() =>
|
||||
(
|
||||
window as typeof window & {
|
||||
__SUMMARY_FLASH_PROBE__?: { missingCommits: number };
|
||||
}
|
||||
).__SUMMARY_FLASH_PROBE__?.missingCommits,
|
||||
);
|
||||
expect(missingCommits).toBe(0);
|
||||
// With `keepMounted`, the summary row intentionally remains available while
|
||||
// the reader scrolls back. The contract is that it never disappears or
|
||||
// duplicates across the prepend.
|
||||
await expect(timeline.locator(badgeSelector)).toBeVisible();
|
||||
await expect(timeline.locator(badgeSelector)).toHaveCount(1);
|
||||
});
|
||||
|
||||
@@ -468,6 +468,23 @@ test("sends a mocked channel message", async ({ page }) => {
|
||||
await page.getByTestId("send-message").click();
|
||||
|
||||
await expect(page.getByTestId("message-timeline")).toContainText(message);
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(() => {
|
||||
const row = Array.from(
|
||||
document.querySelectorAll<HTMLElement>("[data-message-id]"),
|
||||
).at(-1);
|
||||
const composer = document.querySelector<HTMLElement>(
|
||||
'[data-testid="message-composer"]',
|
||||
);
|
||||
if (!row || !composer) return Number.NEGATIVE_INFINITY;
|
||||
return (
|
||||
composer.getBoundingClientRect().top -
|
||||
row.getBoundingClientRect().bottom
|
||||
);
|
||||
}),
|
||||
)
|
||||
.toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
test("supports multiline drafts with Ctrl+Enter and sends with Enter", async ({
|
||||
|
||||
@@ -19,12 +19,24 @@ async function getTimelineMetrics(page: Page) {
|
||||
return page.getByTestId("message-timeline").evaluate((element) => {
|
||||
const timeline = element as HTMLDivElement;
|
||||
|
||||
const composer = document.querySelector<HTMLElement>(
|
||||
'[data-testid="channel-composer-overlay"]',
|
||||
);
|
||||
const composerHeight = composer?.getBoundingClientRect().height ?? 0;
|
||||
|
||||
return {
|
||||
clientHeight: timeline.clientHeight,
|
||||
scrollHeight: timeline.scrollHeight,
|
||||
scrollTop: timeline.scrollTop,
|
||||
composerHeight,
|
||||
// The virtualized timeline reserves a trailing spacer equal to the
|
||||
// overlaid composer. Reaching the visual tail therefore leaves that
|
||||
// spacer below the last row rather than setting the raw DOM distance to 0.
|
||||
distanceFromBottom:
|
||||
timeline.scrollHeight - timeline.clientHeight - timeline.scrollTop,
|
||||
timeline.scrollHeight -
|
||||
timeline.clientHeight -
|
||||
timeline.scrollTop -
|
||||
composerHeight,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -32,27 +44,31 @@ async function getTimelineMetrics(page: Page) {
|
||||
async function ensureTimelineScrollable(
|
||||
senderPage: Page,
|
||||
receiverPage: Page,
|
||||
channelName: string,
|
||||
prefix: string,
|
||||
) {
|
||||
const input = senderPage.getByTestId("message-input");
|
||||
const sendButton = senderPage.getByTestId("send-message");
|
||||
|
||||
for (let index = 0; index < 24; index += 1) {
|
||||
const metrics = await getTimelineMetrics(receiverPage);
|
||||
if (metrics.scrollHeight > metrics.clientHeight + 160) {
|
||||
if (
|
||||
metrics.scrollHeight >
|
||||
metrics.clientHeight + metrics.composerHeight + 160
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const message = `${prefix} seed ${index}`;
|
||||
|
||||
await expect(input).toBeEnabled();
|
||||
await input.fill(message);
|
||||
await sendButton.click();
|
||||
await sendChannelMessage(senderPage, {
|
||||
channelName,
|
||||
content: message,
|
||||
});
|
||||
await expectTimelineToContain(receiverPage, message);
|
||||
}
|
||||
|
||||
const metrics = await getTimelineMetrics(receiverPage);
|
||||
expect(metrics.scrollHeight).toBeGreaterThan(metrics.clientHeight + 160);
|
||||
expect(metrics.scrollHeight).toBeGreaterThan(
|
||||
metrics.clientHeight + metrics.composerHeight + 160,
|
||||
);
|
||||
}
|
||||
|
||||
async function createAndJoinSharedStream(
|
||||
@@ -326,13 +342,15 @@ test("stays pinned to the latest message when new messages arrive at the bottom"
|
||||
await pageTwo.goto("/");
|
||||
await createAndJoinSharedStream(pageOne, pageTwo, channelName);
|
||||
|
||||
await ensureTimelineScrollable(pageOne, pageTwo, prefix);
|
||||
await ensureTimelineScrollable(pageOne, pageTwo, channelName, prefix);
|
||||
await expect
|
||||
.poll(async () => (await getTimelineMetrics(pageTwo)).distanceFromBottom)
|
||||
.toBeLessThan(8);
|
||||
|
||||
await pageOne.getByTestId("message-input").fill(incomingMessage);
|
||||
await pageOne.getByTestId("send-message").click();
|
||||
await sendChannelMessage(pageOne, {
|
||||
channelName,
|
||||
content: incomingMessage,
|
||||
});
|
||||
|
||||
await expectTimelineToContain(pageTwo, incomingMessage);
|
||||
await expect
|
||||
@@ -371,7 +389,7 @@ test("stays pinned after you send a message and a remote reply arrives right aft
|
||||
await pageTwo.goto("/");
|
||||
await createAndJoinSharedStream(pageOne, pageTwo, channelName);
|
||||
|
||||
await ensureTimelineScrollable(pageOne, pageTwo, prefix);
|
||||
await ensureTimelineScrollable(pageOne, pageTwo, channelName, prefix);
|
||||
await expect
|
||||
.poll(async () => (await getTimelineMetrics(pageTwo)).distanceFromBottom)
|
||||
.toBeLessThan(8);
|
||||
@@ -380,8 +398,10 @@ test("stays pinned after you send a message and a remote reply arrives right aft
|
||||
await pageTwo.getByTestId("send-message").click();
|
||||
await expectTimelineToContain(pageTwo, localMessage);
|
||||
|
||||
await pageOne.getByTestId("message-input").fill(incomingMessage);
|
||||
await pageOne.getByTestId("send-message").click();
|
||||
await sendChannelMessage(pageOne, {
|
||||
channelName,
|
||||
content: incomingMessage,
|
||||
});
|
||||
|
||||
await expectTimelineToContain(pageTwo, incomingMessage);
|
||||
await expect
|
||||
@@ -420,7 +440,7 @@ test("keeps bottom-pinned scrolling after the composer grows", async ({
|
||||
await pageTwo.goto("/");
|
||||
await createAndJoinSharedStream(pageOne, pageTwo, channelName);
|
||||
|
||||
await ensureTimelineScrollable(pageOne, pageTwo, prefix);
|
||||
await ensureTimelineScrollable(pageOne, pageTwo, channelName, prefix);
|
||||
await expect
|
||||
.poll(async () => (await getTimelineMetrics(pageTwo)).distanceFromBottom)
|
||||
.toBeLessThan(8);
|
||||
@@ -437,8 +457,10 @@ test("keeps bottom-pinned scrolling after the composer grows", async ({
|
||||
.poll(async () => (await getTimelineMetrics(pageTwo)).distanceFromBottom)
|
||||
.toBeLessThan(8);
|
||||
|
||||
await pageOne.getByTestId("message-input").fill(incomingMessage);
|
||||
await pageOne.getByTestId("send-message").click();
|
||||
await sendChannelMessage(pageOne, {
|
||||
channelName,
|
||||
content: incomingMessage,
|
||||
});
|
||||
|
||||
await expectTimelineToContain(pageTwo, incomingMessage);
|
||||
await expect
|
||||
@@ -476,15 +498,17 @@ test("keeps scroll position when new messages arrive above the fold", async ({
|
||||
await pageTwo.goto("/");
|
||||
await createAndJoinSharedStream(pageOne, pageTwo, channelName);
|
||||
|
||||
await ensureTimelineScrollable(pageOne, pageTwo, prefix);
|
||||
await ensureTimelineScrollable(pageOne, pageTwo, channelName, prefix);
|
||||
await expect
|
||||
.poll(async () => (await getTimelineMetrics(pageTwo)).distanceFromBottom)
|
||||
.toBeLessThan(8);
|
||||
|
||||
await scrollTimelineAwayFromBottom(pageTwo);
|
||||
|
||||
await pageOne.getByTestId("message-input").fill(incomingMessage);
|
||||
await pageOne.getByTestId("send-message").click();
|
||||
await sendChannelMessage(pageOne, {
|
||||
channelName,
|
||||
content: incomingMessage,
|
||||
});
|
||||
|
||||
await expect(pageTwo.getByTestId("message-scroll-to-latest")).toContainText(
|
||||
"1 new message",
|
||||
|
||||
@@ -314,42 +314,6 @@ test("timeline reserves mixed-media rows before fast scrollback", async ({
|
||||
return element && element.scrollHeight > element.clientHeight + 2_000;
|
||||
});
|
||||
|
||||
const intrinsic = (rowText: string) =>
|
||||
timeline
|
||||
.locator("[data-message-id]")
|
||||
.filter({ hasText: rowText })
|
||||
.first()
|
||||
.evaluate((row) => {
|
||||
const scroller = row.closest('[data-testid="message-timeline"]');
|
||||
let node: HTMLElement | null = row.parentElement;
|
||||
while (node && node !== scroller) {
|
||||
if (node.classList.contains("timeline-row-cv")) {
|
||||
const match = getComputedStyle(node).containIntrinsicSize.match(
|
||||
/(?:auto\s+)?([0-9.]+)px/,
|
||||
);
|
||||
return match ? Number(match[1]) : 0;
|
||||
}
|
||||
node = node.parentElement;
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
|
||||
await expect
|
||||
.poll(() => intrinsic("mixed-scroll dimmed media"))
|
||||
.toBeGreaterThan(120);
|
||||
await expect
|
||||
.poll(() => intrinsic("mixed-scroll no-dim media"))
|
||||
.toBeGreaterThan(180);
|
||||
await expect
|
||||
.poll(() => intrinsic("mixed-scroll fenced code"))
|
||||
.toBeGreaterThan(160);
|
||||
await expect
|
||||
.poll(() => intrinsic("mixed-scroll link preview"))
|
||||
.toBeGreaterThan(110);
|
||||
await expect
|
||||
.poll(() => intrinsic("mixed-scroll tall text"))
|
||||
.toBeGreaterThan(120);
|
||||
|
||||
const anchorId = await timeline
|
||||
.locator("[data-message-id]")
|
||||
.filter({ hasText: "mixed-scroll tail 28" })
|
||||
@@ -426,6 +390,92 @@ test("timeline reserves mixed-media rows before fast scrollback", async ({
|
||||
expect(drift.maxDrift).toBeLessThanOrEqual(120);
|
||||
});
|
||||
|
||||
test("removed-message system row stays fixed when older history prepends", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
testInfo.setTimeout(30_000);
|
||||
|
||||
await installMockBridge(page);
|
||||
await page.goto("/");
|
||||
await waitForMockTimelineBridge(page);
|
||||
|
||||
await page.getByTestId("channel-general").click();
|
||||
const timeline = page.getByTestId("message-timeline");
|
||||
|
||||
await page.evaluate(() => {
|
||||
for (let index = 0; index < 10; index += 1) {
|
||||
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
|
||||
channelName: "general",
|
||||
content: `tombstone-neighbor ${index}`,
|
||||
createdAt: 1_700_100_000 + index,
|
||||
});
|
||||
}
|
||||
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
|
||||
channelName: "general",
|
||||
content: JSON.stringify({
|
||||
actor:
|
||||
"f4a42a97e594b77bdbd8ee35191c8b28a94a4cb871d96f32921558275421fb68",
|
||||
type: "message_deleted",
|
||||
}),
|
||||
kind: 40099,
|
||||
createdAt: 1_700_100_010,
|
||||
id: "d".repeat(64),
|
||||
});
|
||||
});
|
||||
|
||||
const tombstoneKey = "d".repeat(64);
|
||||
const tombstone = timeline.locator(
|
||||
`[data-timeline-item-key="${tombstoneKey}"]`,
|
||||
);
|
||||
await expect(tombstone).toContainText("removed a message");
|
||||
await tombstone.scrollIntoViewIfNeeded();
|
||||
await timeline.evaluate((element, key) => {
|
||||
const scroller = element as HTMLDivElement;
|
||||
const row = scroller.querySelector<HTMLElement>(
|
||||
`[data-timeline-item-key="${CSS.escape(key)}"]`,
|
||||
);
|
||||
if (!row) throw new Error("removed-message row is not mounted");
|
||||
scroller.scrollTop +=
|
||||
row.getBoundingClientRect().top - scroller.getBoundingClientRect().top;
|
||||
scroller.dispatchEvent(new Event("scroll", { bubbles: true }));
|
||||
}, "d".repeat(64));
|
||||
|
||||
const topBefore = await tombstone.evaluate(
|
||||
(row) =>
|
||||
row.getBoundingClientRect().top -
|
||||
(
|
||||
row.closest('[data-testid="message-timeline"]') as HTMLElement
|
||||
).getBoundingClientRect().top,
|
||||
);
|
||||
|
||||
await page.evaluate(() => {
|
||||
for (let index = 0; index < 30; index += 1) {
|
||||
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
|
||||
channelName: "general",
|
||||
content: `older-than-tombstone ${index}`,
|
||||
createdAt: 1_700_000_000 + index,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
await expect(timeline).toContainText("older-than-tombstone 29");
|
||||
await expect
|
||||
.poll(
|
||||
async () =>
|
||||
Math.abs(
|
||||
(await tombstone.evaluate(
|
||||
(row) =>
|
||||
row.getBoundingClientRect().top -
|
||||
(
|
||||
row.closest('[data-testid="message-timeline"]') as HTMLElement
|
||||
).getBoundingClientRect().top,
|
||||
)) - topBefore,
|
||||
),
|
||||
{ timeout: 5_000 },
|
||||
)
|
||||
.toBeLessThanOrEqual(2);
|
||||
});
|
||||
|
||||
test("timeline prepend plus late row reflow keeps the reading row stable", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
@@ -557,39 +607,6 @@ test("timeline prepend plus late row reflow keeps the reading row stable", async
|
||||
expect(drift.maxDrift).toBeLessThanOrEqual(LATE_REFLOW_DRIFT_PX);
|
||||
});
|
||||
|
||||
test("de-virtualized timeline rows apply content-visibility", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
testInfo.setTimeout(45_000);
|
||||
|
||||
await installMockBridge(page);
|
||||
await page.goto("/");
|
||||
await waitForMockTimelineBridge(page);
|
||||
await seedNoShiftTimeline(page);
|
||||
|
||||
await page.getByTestId("channel-general").click();
|
||||
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
||||
|
||||
const timeline = page.getByTestId("message-timeline");
|
||||
await expect(timeline.locator("[data-message-id]").first()).toBeVisible();
|
||||
|
||||
// Guards against a typo'd/removed wrapper class shipping inert — a bad
|
||||
// utility name is invisible to typecheck.
|
||||
const hasContentVisibility = await timeline
|
||||
.locator("[data-message-id]")
|
||||
.first()
|
||||
.evaluate((element) => {
|
||||
const scroller = element.closest('[data-testid="message-timeline"]');
|
||||
let node: HTMLElement | null = element.parentElement;
|
||||
while (node && node !== scroller) {
|
||||
if (getComputedStyle(node).contentVisibility === "auto") return true;
|
||||
node = node.parentElement;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
expect(hasContentVisibility).toBe(true);
|
||||
});
|
||||
|
||||
test("thread panel late row reflow keeps the reading reply stable", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
|
||||
@@ -305,4 +305,628 @@ test.describe("list virtualization", () => {
|
||||
expect(Math.abs(settled1 - settled2)).toBeLessThan(2);
|
||||
}
|
||||
});
|
||||
|
||||
test("08 — cascading older pages never snap the viewport toward newest", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
// A real CDP wheel burst is required here: assigning scrollTop does not
|
||||
// reproduce Chromium/WebKit's native wheel → scroll callback ordering. The
|
||||
// old boundary rollback moved the viewport back down before the fetch
|
||||
// committed; keep that pre-prepend reversal below the same 5px frame bar.
|
||||
// A 300ms relay delay leaves the input boundary and prepend commit as two
|
||||
// distinct phases so this assertion cannot accidentally measure only the
|
||||
// later anchor correction.
|
||||
await installMockBridge(page, {
|
||||
deepHistoryMessageCount: 1_800,
|
||||
channelWindowDelayMs: 300,
|
||||
});
|
||||
await page.goto("/#/channels/feedf00d-0000-4000-8000-000000000007");
|
||||
const timeline = page.getByTestId("message-timeline");
|
||||
await expect(timeline.locator("[data-message-id]").first()).toBeVisible();
|
||||
// Initial bottom positioning can momentarily cross the start threshold. Let
|
||||
// any resulting page transaction settle before driving explicit crossings.
|
||||
await page.waitForTimeout(1_000);
|
||||
|
||||
const sampleVisibleAnchor = (expectedId?: string) =>
|
||||
timeline.evaluate(async (scroller, anchorId) => {
|
||||
const s = scroller as HTMLElement;
|
||||
for (let frame = 0; frame < 60; frame += 1) {
|
||||
const scrollerTop = s.getBoundingClientRect().top;
|
||||
const rows = Array.from(
|
||||
s.querySelectorAll<HTMLElement>("[data-message-id]"),
|
||||
);
|
||||
const row = anchorId
|
||||
? rows.find((candidate) => candidate.dataset.messageId === anchorId)
|
||||
: rows.find(
|
||||
(candidate) =>
|
||||
candidate.getBoundingClientRect().top >= scrollerTop,
|
||||
);
|
||||
if (row) {
|
||||
return {
|
||||
id: row.dataset.messageId ?? "",
|
||||
top: row.getBoundingClientRect().top - scrollerTop,
|
||||
scrollHeight: s.scrollHeight,
|
||||
bottomDistance: s.scrollHeight - s.clientHeight - s.scrollTop,
|
||||
};
|
||||
}
|
||||
await new Promise((resolve) => requestAnimationFrame(resolve));
|
||||
}
|
||||
throw new Error(
|
||||
`anchor row ${anchorId ?? "at viewport top"} not mounted`,
|
||||
);
|
||||
}, expectedId);
|
||||
|
||||
// Load fifteen consecutive server pages in one mounted virtualizer. This
|
||||
// is the production shape that exposed the intermittent end-cache snap:
|
||||
// variable-height rows and repeated front insertions exercise the full
|
||||
// index-shift path rather than allowing a single lucky pass.
|
||||
for (let pageIndex = 0; pageIndex < 15; pageIndex += 1) {
|
||||
// Leave the threshold first so Virtua emits a fresh start-edge crossing;
|
||||
// initial positioning can briefly report offset 0 while mounting.
|
||||
await timeline.evaluate((element) => {
|
||||
element.scrollTop = 4000;
|
||||
});
|
||||
await page.waitForTimeout(300);
|
||||
await timeline.evaluate((element) => {
|
||||
element.scrollTop = 180;
|
||||
});
|
||||
await page.waitForTimeout(150);
|
||||
const before = await sampleVisibleAnchor();
|
||||
const wheelTracePromise = timeline.evaluate(async (scroller) => {
|
||||
const s = scroller as HTMLElement;
|
||||
let previousScrollTop = s.scrollTop;
|
||||
let maxBoundaryRollback = 0;
|
||||
let minScrollTop = s.scrollTop;
|
||||
const deadline = performance.now() + 120;
|
||||
while (performance.now() < deadline) {
|
||||
maxBoundaryRollback = Math.max(
|
||||
maxBoundaryRollback,
|
||||
s.scrollTop - previousScrollTop,
|
||||
);
|
||||
previousScrollTop = s.scrollTop;
|
||||
minScrollTop = Math.min(minScrollTop, s.scrollTop);
|
||||
await new Promise((resolve) => requestAnimationFrame(resolve));
|
||||
}
|
||||
return { maxBoundaryRollback, minScrollTop };
|
||||
});
|
||||
const box = await timeline.boundingBox();
|
||||
if (!box) throw new Error("timeline has no bounding box");
|
||||
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
|
||||
for (const deltaY of [-60, -30, -20, -15]) {
|
||||
await page.mouse.wheel(0, deltaY);
|
||||
await page.waitForTimeout(12);
|
||||
}
|
||||
const wheelTrace = await wheelTracePromise;
|
||||
expect(wheelTrace.minScrollTop).toBeLessThanOrEqual(350);
|
||||
expect(wheelTrace.maxBoundaryRollback).toBeLessThan(5);
|
||||
// Linux Chromium delivers CDP wheel input with more latency than macOS,
|
||||
// so the burst's final delta can land AFTER the anchor baseline sample
|
||||
// below. maxDrift then reports the reader's own last wheel event as
|
||||
// anchor drift (measured exactly 15 = the -15 delta; changing the delta
|
||||
// to -17 made the failure read 17). Gate the baseline on input settle:
|
||||
// two consecutive frames with identical scrollTop. This tightens the
|
||||
// assertion rather than diluting it — the baseline becomes honest and
|
||||
// genuine post-prepend drift still reads as drift.
|
||||
await timeline.evaluate(async (element) => {
|
||||
let prior = element.scrollTop;
|
||||
for (let frame = 0; frame < 30; frame += 1) {
|
||||
await new Promise((resolve) => requestAnimationFrame(resolve));
|
||||
if (element.scrollTop === prior) break;
|
||||
prior = element.scrollTop;
|
||||
}
|
||||
});
|
||||
const committedAnchor = await sampleVisibleAnchor(before.id);
|
||||
const motion = await timeline.evaluate(
|
||||
async (scroller, { anchorId, anchorTop, oldHeight }) => {
|
||||
const s = scroller as HTMLElement;
|
||||
let maxDrift = 0;
|
||||
let sawPrepend = false;
|
||||
let sawAnchorAfterPrepend = false;
|
||||
let finalDrift = 0;
|
||||
let stableFrames = 0;
|
||||
for (let frame = 0; frame < 180; frame += 1) {
|
||||
const row = Array.from(
|
||||
s.querySelectorAll<HTMLElement>("[data-message-id]"),
|
||||
).find((candidate) => candidate.dataset.messageId === anchorId);
|
||||
if (s.scrollHeight > oldHeight + 800 && !sawPrepend) {
|
||||
sawPrepend = true;
|
||||
}
|
||||
if (row) {
|
||||
const top =
|
||||
row.getBoundingClientRect().top - s.getBoundingClientRect().top;
|
||||
const drift = Math.abs(top - anchorTop);
|
||||
if (sawPrepend) {
|
||||
maxDrift = Math.max(maxDrift, drift);
|
||||
sawAnchorAfterPrepend = true;
|
||||
stableFrames =
|
||||
Math.abs(drift - finalDrift) < 0.5 ? stableFrames + 1 : 0;
|
||||
finalDrift = drift;
|
||||
}
|
||||
}
|
||||
if (sawAnchorAfterPrepend && stableFrames >= 8) break;
|
||||
await new Promise((resolve) => requestAnimationFrame(resolve));
|
||||
}
|
||||
return { maxDrift, sawPrepend };
|
||||
},
|
||||
{
|
||||
anchorId: committedAnchor.id,
|
||||
anchorTop: committedAnchor.top,
|
||||
oldHeight: before.scrollHeight,
|
||||
},
|
||||
);
|
||||
expect(motion.sawPrepend).toBe(true);
|
||||
expect(motion.maxDrift).toBeLessThan(5);
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => timeline.evaluate((element) => element.scrollHeight),
|
||||
{
|
||||
timeout: 10_000,
|
||||
},
|
||||
)
|
||||
.toBeGreaterThan(before.scrollHeight + 800);
|
||||
|
||||
// A snap to newest leaves this near zero. Keep a full viewport of history
|
||||
// below the reader after every prepend, rather than checking only the
|
||||
// final page and missing a transient cascade failure.
|
||||
const bottomDistance = await timeline.evaluate(
|
||||
(element) =>
|
||||
element.scrollHeight - element.clientHeight - element.scrollTop,
|
||||
);
|
||||
expect(bottomDistance).toBeGreaterThan(
|
||||
await timeline.evaluate((element) => element.clientHeight),
|
||||
);
|
||||
|
||||
// Leave the boundary with real downward wheel input while this prepend's
|
||||
// three-second semantic-anchor watcher is still alive. The watcher belongs
|
||||
// only to the completed prepend: it must not reinterpret this deliberate
|
||||
// reader movement as row drift and pull the viewport back toward its stale
|
||||
// baseline before the next upward load.
|
||||
const exitTracePromise = timeline.evaluate(async (scroller) => {
|
||||
const s = scroller as HTMLElement;
|
||||
const startScrollTop = s.scrollTop;
|
||||
let previousScrollTop = startScrollTop;
|
||||
let maxForwardTravel = 0;
|
||||
let maxRollback = 0;
|
||||
const deadline = performance.now() + 400;
|
||||
while (performance.now() < deadline) {
|
||||
const travel = s.scrollTop - startScrollTop;
|
||||
maxForwardTravel = Math.max(maxForwardTravel, travel);
|
||||
maxRollback = Math.max(maxRollback, previousScrollTop - s.scrollTop);
|
||||
previousScrollTop = s.scrollTop;
|
||||
await new Promise((resolve) => requestAnimationFrame(resolve));
|
||||
}
|
||||
return { maxForwardTravel, maxRollback };
|
||||
});
|
||||
const exitBox = await timeline.boundingBox();
|
||||
if (!exitBox) throw new Error("timeline has no bounding box");
|
||||
await page.mouse.move(
|
||||
exitBox.x + exitBox.width / 2,
|
||||
exitBox.y + exitBox.height / 2,
|
||||
);
|
||||
for (const deltaY of [120, 100, 80]) {
|
||||
await page.mouse.wheel(0, deltaY);
|
||||
await page.waitForTimeout(12);
|
||||
}
|
||||
const exitTrace = await exitTracePromise;
|
||||
expect(exitTrace.maxForwardTravel).toBeGreaterThan(200);
|
||||
expect(exitTrace.maxRollback).toBeLessThan(5);
|
||||
|
||||
// Loading more history must not return keepMounted to its old linear
|
||||
// growth. Virtua still retains every measured size for spacer geometry;
|
||||
// only the live message-row DOM stays bounded around the reader and tail.
|
||||
const mountedMessageCount = await timeline
|
||||
.locator("[data-message-id]")
|
||||
.count();
|
||||
expect(mountedMessageCount).toBeLessThan(400);
|
||||
}
|
||||
});
|
||||
|
||||
test("09 — older-page render commit waits for scroller rest under continued wheel input", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(60_000);
|
||||
// Production shape for the WKWebView dropped-write hazard: heavy
|
||||
// variable-height rows, a slow older-page fetch, and wheel input that
|
||||
// KEEPS ARRIVING through fetch resolution. Every prepend-compensation
|
||||
// mechanism is a scrollTop write, and macOS WebKit can drop those writes
|
||||
// while trackpad momentum owns the offset — so the contract under test is
|
||||
// that the fetched page's RENDER COMMIT (the scrollHeight jump) is
|
||||
// deferred until input quiesces, and that the at-rest commit then holds
|
||||
// the anchored row. Chromium cannot reproduce the dropped write itself;
|
||||
// it CAN prove the commit-at-rest scheduling that makes it unreachable.
|
||||
await installMockBridge(page, {
|
||||
deepHistoryMessageCount: 1_800,
|
||||
channelWindowDelayMs: 300,
|
||||
});
|
||||
await page.goto("/#/channels/feedf00d-0000-4000-8000-000000000007");
|
||||
const timeline = page.getByTestId("message-timeline");
|
||||
await expect(timeline.locator("[data-message-id]").first()).toBeVisible();
|
||||
await page.waitForTimeout(1_000);
|
||||
|
||||
// Mount mid-history rows clear of the load-older sentinel, then trip it.
|
||||
await timeline.evaluate((element) => {
|
||||
element.scrollTop = 4000;
|
||||
});
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
// In-page observer: tracks the last wheel-input timestamp, captures the
|
||||
// first at-rest anchor after input stops, and records when the prepend
|
||||
// commit (scrollHeight jump) lands relative to the last input.
|
||||
const tracePromise = timeline.evaluate(async (scroller) => {
|
||||
const s = scroller as HTMLElement;
|
||||
const baseHeight = s.scrollHeight;
|
||||
let lastInputTs = 0;
|
||||
let sawInput = false;
|
||||
let restAnchor: { id: string; top: number } | null = null;
|
||||
const onWheel = () => {
|
||||
lastInputTs = performance.now();
|
||||
sawInput = true;
|
||||
// Input after a lull invalidates any anchor captured during it —
|
||||
// the commit must be measured against the FINAL at-rest position.
|
||||
restAnchor = null;
|
||||
};
|
||||
s.addEventListener("wheel", onWheel, { passive: true });
|
||||
let commit: { ts: number; gapSinceInput: number } | null = null;
|
||||
let sawSpinnerDuringHold = false;
|
||||
let anchorDriftAfterCommit: number | null = null;
|
||||
const deadline = performance.now() + 8_000;
|
||||
while (performance.now() < deadline) {
|
||||
const now = performance.now();
|
||||
if (commit === null) {
|
||||
if (
|
||||
document.querySelector(
|
||||
'[data-testid="message-timeline-fetching-older"]',
|
||||
) !== null
|
||||
) {
|
||||
sawSpinnerDuringHold = true;
|
||||
}
|
||||
// First frame at rest (input quiet for 60ms — shorter than the
|
||||
// gate's own window, so this reading always precedes admission):
|
||||
// capture the row the at-rest commit must hold.
|
||||
if (restAnchor === null && sawInput && now - lastInputTs >= 60) {
|
||||
const scrollerTop = s.getBoundingClientRect().top;
|
||||
const row = Array.from(
|
||||
s.querySelectorAll<HTMLElement>("[data-message-id]"),
|
||||
).find(
|
||||
(candidate) =>
|
||||
candidate.getBoundingClientRect().top - scrollerTop >= 0,
|
||||
);
|
||||
if (row?.dataset.messageId) {
|
||||
restAnchor = {
|
||||
id: row.dataset.messageId,
|
||||
top: row.getBoundingClientRect().top - scrollerTop,
|
||||
};
|
||||
}
|
||||
}
|
||||
if (s.scrollHeight > baseHeight + 800) {
|
||||
commit = { ts: now, gapSinceInput: now - lastInputTs };
|
||||
}
|
||||
} else if (restAnchor !== null) {
|
||||
const anchor = restAnchor;
|
||||
const scrollerTop = s.getBoundingClientRect().top;
|
||||
const row = Array.from(
|
||||
s.querySelectorAll<HTMLElement>("[data-message-id]"),
|
||||
).find((candidate) => candidate.dataset.messageId === anchor.id);
|
||||
if (row) {
|
||||
anchorDriftAfterCommit = Math.max(
|
||||
anchorDriftAfterCommit ?? 0,
|
||||
Math.abs(
|
||||
row.getBoundingClientRect().top - scrollerTop - anchor.top,
|
||||
),
|
||||
);
|
||||
}
|
||||
// Watch a settle window after the commit, then finish.
|
||||
if (now - commit.ts > 700) break;
|
||||
}
|
||||
await new Promise((resolve) => requestAnimationFrame(resolve));
|
||||
}
|
||||
s.removeEventListener("wheel", onWheel);
|
||||
return {
|
||||
commit,
|
||||
capturedRestAnchor: restAnchor !== null,
|
||||
sawSpinnerDuringHold,
|
||||
anchorDriftAfterCommit,
|
||||
};
|
||||
});
|
||||
|
||||
// Trip the boundary, then keep real wheel input flowing DOWN (away from
|
||||
// the boundary) through and well past the 300ms fetch resolution — the
|
||||
// mid-gesture window in which the ungated build commits the page.
|
||||
await timeline.evaluate((element) => {
|
||||
element.scrollTop = 150;
|
||||
});
|
||||
const box = await timeline.boundingBox();
|
||||
if (!box) throw new Error("timeline has no bounding box");
|
||||
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
|
||||
for (let burst = 0; burst < 30; burst += 1) {
|
||||
await page.mouse.wheel(0, 30);
|
||||
await page.waitForTimeout(40);
|
||||
}
|
||||
|
||||
const trace = await tracePromise;
|
||||
// The page must eventually commit — the gate defers, never strands.
|
||||
expect(trace.commit).not.toBeNull();
|
||||
// The commit landed only after input quiesced. On the ungated build the
|
||||
// deferred snapshot flushes as soon as the fetch resolves — between wheel
|
||||
// bursts, a gap far below the quiet window — so this line is the red/green
|
||||
// signal for the settle gate.
|
||||
expect(trace.commit?.gapSinceInput ?? 0).toBeGreaterThanOrEqual(80);
|
||||
// The reader saw the fetching affordance while the page was held.
|
||||
expect(trace.sawSpinnerDuringHold).toBe(true);
|
||||
// The at-rest commit held the anchored row (writes land at rest).
|
||||
expect(trace.capturedRestAnchor).toBe(true);
|
||||
expect(trace.anchorDriftAfterCommit ?? 0).toBeLessThan(5);
|
||||
});
|
||||
});
|
||||
|
||||
test("thread-heavy history mounts every loaded row", async ({ page }) => {
|
||||
await installMockBridge(page);
|
||||
await page.goto("/");
|
||||
await page.waitForFunction(
|
||||
() => typeof window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function",
|
||||
);
|
||||
|
||||
// Seed summaries on 120 loaded roots. Every loaded row should be realized
|
||||
// immediately so first-pass scrolling never encounters Virtua's hidden
|
||||
// pre-measurement state.
|
||||
await page.evaluate(() => {
|
||||
for (let index = 480; index < 600; index += 1) {
|
||||
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
|
||||
channelName: "deep-history",
|
||||
content: `summary-only reply ${index}`,
|
||||
parentEventId: `mock-deep-history-${index}`,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
await page.getByTestId("channel-deep-history").click();
|
||||
const timeline = page.getByTestId("message-timeline");
|
||||
await expect(timeline.locator("[data-message-id]").first()).toBeVisible();
|
||||
|
||||
// Emit after opening so the live summary path updates every loaded root,
|
||||
// independent of the relay page's summary cap.
|
||||
await page.evaluate(() => {
|
||||
for (let index = 480; index < 600; index += 1) {
|
||||
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
|
||||
channelName: "deep-history",
|
||||
content: `live summary-only reply ${index}`,
|
||||
parentEventId: `mock-deep-history-${index}`,
|
||||
});
|
||||
}
|
||||
});
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
const loadedRows = timeline.locator("[data-message-id]");
|
||||
// The mock channel's current loaded window contains 50 roots; all of them
|
||||
// must already exist and be painted before the first scroll gesture.
|
||||
await expect(loadedRows).toHaveCount(50);
|
||||
expect(
|
||||
await loadedRows.evaluateAll((rows) =>
|
||||
rows.every((row) => getComputedStyle(row).visibility === "visible"),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("channel switches settle the last row above the composer", async ({
|
||||
page,
|
||||
}) => {
|
||||
await installMockBridge(page);
|
||||
await page.goto("/");
|
||||
await page.waitForFunction(
|
||||
() => typeof window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function",
|
||||
);
|
||||
|
||||
await page.evaluate(() => {
|
||||
for (const [channelName, prefix] of [
|
||||
["general", "switch-general"],
|
||||
["engineering", "switch-engineering"],
|
||||
] as const) {
|
||||
for (let index = 0; index < 60; index += 1) {
|
||||
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
|
||||
channelName,
|
||||
content: `${prefix}-${index}`,
|
||||
createdAt: 1_700_000_000 + index,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
for (const channelName of ["general", "engineering", "general"]) {
|
||||
await page.getByTestId(`channel-${channelName}`).click();
|
||||
await expect(page.getByTestId("chat-title")).toHaveText(channelName);
|
||||
const timeline = page.getByTestId("message-timeline");
|
||||
const composer = page.getByTestId("channel-composer-overlay");
|
||||
await expect
|
||||
.poll(async () =>
|
||||
timeline.evaluate(
|
||||
(element, composerElement) => {
|
||||
const rows = Array.from(
|
||||
element.querySelectorAll<HTMLElement>("[data-message-id]"),
|
||||
);
|
||||
const lastRow = rows.at(-1);
|
||||
if (!lastRow) return Number.POSITIVE_INFINITY;
|
||||
return (
|
||||
lastRow.getBoundingClientRect().bottom -
|
||||
(composerElement as HTMLElement).getBoundingClientRect().top
|
||||
);
|
||||
},
|
||||
await composer.elementHandle(),
|
||||
),
|
||||
)
|
||||
.toBeLessThanOrEqual(1);
|
||||
}
|
||||
});
|
||||
|
||||
test("offscreen rich-row resize preserves the viewport-center anchor", async ({
|
||||
page,
|
||||
}) => {
|
||||
await installMockBridge(page);
|
||||
await page.goto("/");
|
||||
await page.waitForFunction(
|
||||
() => typeof window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function",
|
||||
);
|
||||
await page.evaluate(() => {
|
||||
for (let index = 0; index < 240; index += 1) {
|
||||
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
|
||||
channelName: "general",
|
||||
content: [
|
||||
`rich resize row ${index}`,
|
||||
"long wrapped text ".repeat((index % 8) + 1),
|
||||
].join("\n"),
|
||||
createdAt: 1_700_700_000 + index,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
await page.getByTestId("channel-general").click();
|
||||
const timeline = page.getByTestId("message-timeline");
|
||||
await expect(timeline.locator("[data-message-id]").first()).toBeVisible();
|
||||
|
||||
const result = await timeline.evaluate(async (element) => {
|
||||
const scroller = element as HTMLDivElement;
|
||||
scroller.scrollTop = scroller.scrollHeight / 2;
|
||||
scroller.dispatchEvent(new Event("scroll", { bubbles: true }));
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
|
||||
const scrollerRect = scroller.getBoundingClientRect();
|
||||
const rows = Array.from(
|
||||
scroller.querySelectorAll<HTMLElement>("[data-message-id]"),
|
||||
);
|
||||
const visibleRows = rows.filter((row) => {
|
||||
const rect = row.getBoundingClientRect();
|
||||
return rect.bottom > scrollerRect.top && rect.top < scrollerRect.bottom;
|
||||
});
|
||||
const viewportCenter = scrollerRect.top + scroller.clientHeight / 2;
|
||||
const anchor = visibleRows.reduce<HTMLElement | null>((nearest, row) => {
|
||||
if (!nearest) return row;
|
||||
const rowRect = row.getBoundingClientRect();
|
||||
const nearestRect = nearest.getBoundingClientRect();
|
||||
const rowDistance = Math.abs(
|
||||
(rowRect.top + rowRect.bottom) / 2 - viewportCenter,
|
||||
);
|
||||
const nearestDistance = Math.abs(
|
||||
(nearestRect.top + nearestRect.bottom) / 2 - viewportCenter,
|
||||
);
|
||||
return rowDistance < nearestDistance ? row : nearest;
|
||||
}, null);
|
||||
if (!anchor) throw new Error("no visible center anchor");
|
||||
const rowAbove = rows
|
||||
.filter((row) => row.getBoundingClientRect().bottom <= scrollerRect.top)
|
||||
.at(-1);
|
||||
if (!rowAbove) throw new Error("no mounted offscreen rich row above");
|
||||
|
||||
const anchorRect = anchor.getBoundingClientRect();
|
||||
const anchorCenterOffset =
|
||||
(anchorRect.top + anchorRect.bottom) / 2 - viewportCenter;
|
||||
const scrollTop = scroller.scrollTop;
|
||||
rowAbove.style.minHeight = `${rowAbove.getBoundingClientRect().height + 240}px`;
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
|
||||
const nextScrollerRect = scroller.getBoundingClientRect();
|
||||
const nextAnchorRect = anchor.getBoundingClientRect();
|
||||
const nextViewportCenter = nextScrollerRect.top + scroller.clientHeight / 2;
|
||||
return {
|
||||
anchorDrift:
|
||||
(nextAnchorRect.top + nextAnchorRect.bottom) / 2 -
|
||||
nextViewportCenter -
|
||||
anchorCenterOffset,
|
||||
scrollCorrection: scroller.scrollTop - scrollTop,
|
||||
};
|
||||
});
|
||||
|
||||
expect(Math.abs(result.anchorDrift)).toBeLessThanOrEqual(2);
|
||||
expect(result.scrollCorrection).toBeGreaterThanOrEqual(238);
|
||||
});
|
||||
|
||||
test("live tail arrivals keep a bottom-pinned virtual timeline settled", async ({
|
||||
page,
|
||||
}) => {
|
||||
await installMockBridge(page);
|
||||
await page.goto("/");
|
||||
await page.waitForFunction(
|
||||
() => typeof window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function",
|
||||
);
|
||||
|
||||
await page.evaluate(() => {
|
||||
for (let index = 0; index < 60; index += 1) {
|
||||
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
|
||||
channelName: "general",
|
||||
content: `live-follow seed ${index}\nsecond line ${index}`,
|
||||
createdAt: 1_700_000_000 + index,
|
||||
});
|
||||
}
|
||||
});
|
||||
await page.getByTestId("channel-general").click();
|
||||
const timeline = page.getByTestId("message-timeline");
|
||||
await expect(timeline).toContainText("live-follow seed 59");
|
||||
await expect
|
||||
.poll(() =>
|
||||
timeline.evaluate(
|
||||
(element) =>
|
||||
element.scrollHeight - element.clientHeight - element.scrollTop,
|
||||
),
|
||||
)
|
||||
.toBeLessThanOrEqual(1);
|
||||
|
||||
await page.evaluate(() => {
|
||||
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
|
||||
channelName: "general",
|
||||
content:
|
||||
"remote live-follow sentinel\nline two\nline three\nline four\nline five",
|
||||
createdAt: 1_800_000_000,
|
||||
});
|
||||
});
|
||||
|
||||
await expect(page.getByText("remote live-follow sentinel")).toBeVisible();
|
||||
await expect
|
||||
.poll(() =>
|
||||
timeline.evaluate(
|
||||
(element) =>
|
||||
element.scrollHeight - element.clientHeight - element.scrollTop,
|
||||
),
|
||||
)
|
||||
.toBeLessThanOrEqual(1);
|
||||
});
|
||||
|
||||
test("live tail arrivals stay buffered while reading and release on jump", async ({
|
||||
page,
|
||||
}) => {
|
||||
await installMockBridge(page);
|
||||
await page.goto("/");
|
||||
await page.waitForFunction(
|
||||
() => typeof window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function",
|
||||
);
|
||||
await page.getByTestId("channel-deep-history").click();
|
||||
|
||||
const timeline = page.getByTestId("message-timeline");
|
||||
await expect(timeline.locator("[data-message-id]").first()).toBeVisible();
|
||||
await timeline.evaluate((element) => {
|
||||
element.scrollTop = Math.max(500, element.scrollHeight / 2);
|
||||
element.dispatchEvent(new Event("scroll", { bubbles: true }));
|
||||
});
|
||||
await expect(page.getByTestId("message-scroll-to-latest")).toBeVisible();
|
||||
const frozenHeight = await timeline.evaluate(
|
||||
(element) => element.scrollHeight,
|
||||
);
|
||||
|
||||
await page.evaluate(() => {
|
||||
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
|
||||
channelName: "deep-history",
|
||||
content: "buffered live tail sentinel",
|
||||
createdAt: 1_900_000_000,
|
||||
});
|
||||
});
|
||||
|
||||
await expect(page.getByText("buffered live tail sentinel")).toHaveCount(0);
|
||||
await expect(page.getByTestId("message-scroll-to-latest")).toContainText("1");
|
||||
expect(await timeline.evaluate((element) => element.scrollHeight)).toBe(
|
||||
frozenHeight,
|
||||
);
|
||||
|
||||
await page.getByTestId("message-scroll-to-latest").click();
|
||||
await expect(page.getByText("buffered live tail sentinel")).toBeVisible();
|
||||
});
|
||||
|
||||
@@ -153,12 +153,17 @@ type MockBridgeOptions = {
|
||||
createManagedAgentDelayMs?: number;
|
||||
addChannelMembersDelayMs?: number;
|
||||
channelsReadError?: string;
|
||||
/** Number of seeded rows in the deep-history fixture. Defaults to 600. */
|
||||
deepHistoryMessageCount?: number;
|
||||
feedReadError?: string;
|
||||
canvasReadError?: string;
|
||||
/** Delay (ms) for `apply_workspace`; see e2eBridge mock config. */
|
||||
applyWorkspaceDelayMs?: number;
|
||||
openDmDelayMs?: number;
|
||||
sendMessageDelayMs?: number;
|
||||
/** Delay (ms) after snapshotting a thread-replies page so E2E tests can
|
||||
* deliver live reply/aux events while an older response is in flight. */
|
||||
threadRepliesDelayMs?: number;
|
||||
usersBatchDelayMs?: number;
|
||||
/** Delay (ms) for older-history fetches; see e2eBridge mock config. */
|
||||
channelWindowDelayMs?: number;
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
diff --git a/lib/index.js b/lib/index.js
|
||||
index 110ac3858a002a6cdb698da2b56350bc1bf609d2..41330c4c310a44fd964b1f68de6b99046d2efae7 100644
|
||||
--- a/lib/index.js
|
||||
+++ b/lib/index.js
|
||||
@@ -124,7 +124,7 @@ const u = null, {min: d, max: a, abs: h, floor: g} = Math, p = (e, t, o) => d(o,
|
||||
if (!e.length) break;
|
||||
N(e.reduce((e, [t, o]) => {
|
||||
let n;
|
||||
- if (2 === w) n = !0; else if (I && 1 === w) n = t < I[0]; else {
|
||||
+ if (2 === w) n = J(t) < E(); else if (I && 1 === w) n = t < I[0]; else {
|
||||
const e = E(), o = J(t), r = A(t);
|
||||
n = 1 !== _ && 0 === w ? o + r <= e : o < e && o + r < e + l;
|
||||
}
|
||||
Generated
+29
@@ -6,6 +6,7 @@ settings:
|
||||
|
||||
patchedDependencies:
|
||||
isomorphic-git: e9b414a60d4cf1d8aa18f7a779483984e821989967c235662566e94ef0238d3f
|
||||
virtua@0.49.2: 367ec28b983b840021685fb5b515df23616a4d9b500614483ada43907c1e14e2
|
||||
|
||||
importers:
|
||||
|
||||
@@ -183,6 +184,9 @@ importers:
|
||||
upng-js:
|
||||
specifier: ^2.1.0
|
||||
version: 2.1.0
|
||||
virtua:
|
||||
specifier: 0.49.2
|
||||
version: 0.49.2(patch_hash=367ec28b983b840021685fb5b515df23616a4d9b500614483ada43907c1e14e2)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
yaml:
|
||||
specifier: ^2.8.3
|
||||
version: 2.9.0
|
||||
@@ -3035,6 +3039,26 @@ packages:
|
||||
vfile@6.0.3:
|
||||
resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==}
|
||||
|
||||
virtua@0.49.2:
|
||||
resolution: {integrity: sha512-aEp3+6cmIRjHUlQnWdgGXYMYtrIG26QnN9jJDZEE5LRhvo1Z9HzoJwLDgyVULUPWcSdCnZAroQm7raXJyTG0AA==}
|
||||
peerDependencies:
|
||||
react: '>=16.14.0'
|
||||
react-dom: '>=16.14.0'
|
||||
solid-js: '>=1.0'
|
||||
svelte: '>=5.0'
|
||||
vue: '>=3.2'
|
||||
peerDependenciesMeta:
|
||||
react:
|
||||
optional: true
|
||||
react-dom:
|
||||
optional: true
|
||||
solid-js:
|
||||
optional: true
|
||||
svelte:
|
||||
optional: true
|
||||
vue:
|
||||
optional: true
|
||||
|
||||
vite@8.0.14:
|
||||
resolution: {integrity: sha512-s4BJJ+5y1pYL6Otw51FHhVJQhPnuRinKig64g/1+EUNaJsd3gCKdD31IPFvswUgW9/60QT9oFHbZHbQK5imcxw==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
@@ -5964,6 +5988,11 @@ snapshots:
|
||||
'@types/unist': 3.0.3
|
||||
vfile-message: 4.0.3
|
||||
|
||||
virtua@0.49.2(patch_hash=367ec28b983b840021685fb5b515df23616a4d9b500614483ada43907c1e14e2)(react-dom@19.2.7(react@19.2.7))(react@19.2.7):
|
||||
optionalDependencies:
|
||||
react: 19.2.7
|
||||
react-dom: 19.2.7(react@19.2.7)
|
||||
|
||||
vite@8.0.14(@types/node@25.6.0)(jiti@2.7.0)(yaml@2.9.0):
|
||||
dependencies:
|
||||
lightningcss: 1.32.0
|
||||
|
||||
@@ -5,3 +5,4 @@ allowBuilds:
|
||||
esbuild: true
|
||||
patchedDependencies:
|
||||
isomorphic-git: patches/isomorphic-git.patch
|
||||
virtua@0.49.2: patches/virtua@0.49.2.patch
|
||||
|
||||
Reference in New Issue
Block a user