fix(desktop): preserve buffered channel unread

Co-authored-by: Morgan Martin <morgan@block.xyz>
Signed-off-by: Morgan Martin <morgan@block.xyz>
This commit is contained in:
Rivet
2026-08-23 17:11:32 -07:00
parent f9bb9cbc99
commit 9562d0a4d2
3 changed files with 42 additions and 2 deletions

View File

@@ -23,6 +23,7 @@ import { TimelineSkeleton, useTimelineSkeletonRows } from "./TimelineSkeleton";
import { TimelineMessageList } from "./TimelineMessageList";
import type { TimelineVirtualizerApi } from "./TimelineMessageList";
import { useAnchoredScroll } from "./useAnchoredScroll";
import { resolveTimelineReadPresence } from "./anchoredScrollPolicy";
import { useLoadOlderOnScroll } from "./useLoadOlderOnScroll";
import { useBufferedTimelineMessages } from "./useBufferedTimelineMessages";
import {
@@ -372,8 +373,17 @@ const MessageTimelineBase = React.forwardRef<
});
React.useEffect(() => {
onAtBottomChange?.(isAtBottom);
}, [isAtBottom, onAtBottomChange]);
// Read presence follows the semantic tail, not the physical virtualizer
// floor. While live arrivals are buffered, freezing the rendered tail can
// make the shortened DOM report physically at-bottom even though the
// reader is still scrolled away from the new messages.
onAtBottomChange?.(
resolveTimelineReadPresence({
isPhysicallyAtBottom: isAtBottom,
isSemanticallyAtBottom,
}),
);
}, [isAtBottom, isSemanticallyAtBottom, onAtBottomChange]);
const hasConfirmedVirtualizerBottomRef = React.useRef(false);
const bottomConfirmationChannelRef = React.useRef(channelId);
if (bottomConfirmationChannelRef.current !== channelId) {

View File

@@ -42,6 +42,18 @@ export function settleProgrammaticBottomPin(
);
}
export function resolveTimelineReadPresence({
isPhysicallyAtBottom: _isPhysicallyAtBottom,
isSemanticallyAtBottom,
}: {
isPhysicallyAtBottom: boolean;
isSemanticallyAtBottom: boolean;
}): boolean {
// A buffered timeline can shorten the rendered model enough that the
// virtualizer reports its physical floor while newer messages remain hidden.
return isSemanticallyAtBottom;
}
export function shouldSettleForSplitPanel({
isAtBottom,
splitPanelOpen,

View File

@@ -3,6 +3,7 @@ import test from "node:test";
import {
getPinnedCenterDrift,
resolveTimelineReadPresence,
settleProgrammaticBottomPin,
shouldIgnorePinnedCenterScroll,
shouldSettleForSplitPanel,
@@ -23,6 +24,23 @@ function fakeContainer({ clientHeight, scrollHeight, scrollTop }) {
};
}
test("buffered arrivals do not become read when the shortened timeline reports its physical floor", () => {
assert.equal(
resolveTimelineReadPresence({
isPhysicallyAtBottom: true,
isSemanticallyAtBottom: false,
}),
false,
);
assert.equal(
resolveTimelineReadPresence({
isPhysicallyAtBottom: true,
isSemanticallyAtBottom: true,
}),
true,
);
});
test("split panel settles only an already-bottomed timeline", () => {
assert.equal(
shouldSettleForSplitPanel({ isAtBottom: true, splitPanelOpen: true }),