fix(desktop): derive reads from rendered timeline

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:46:07 -07:00
parent 9562d0a4d2
commit 2ae6c7791e
10 changed files with 104 additions and 40 deletions

View File

@@ -132,7 +132,7 @@ export const ChannelPane = React.memo(function ChannelPane({
onThreadScrollTargetResolved,
onThreadPanelResizeStart,
onTargetReached,
onTimelineAtBottomChange,
onTimelineReadPresenceChange,
onToggleReaction,
onUnfollowThread,
unfollowThreadById,
@@ -629,7 +629,7 @@ export const ChannelPane = React.memo(function ChannelPane({
onSendVideoReviewComment={
activeChannel?.archivedAt ? undefined : onSendVideoReviewComment
}
onAtBottomChange={onTimelineAtBottomChange}
onReadPresenceChange={onTimelineReadPresenceChange}
onTargetReached={onTargetReached}
onToggleReaction={onToggleReaction}
targetMessageId={targetMessageId}

View File

@@ -139,7 +139,10 @@ export type ChannelPaneProps = {
} | null,
) => Promise<void>;
onTargetReached?: (messageId: string) => void;
onTimelineAtBottomChange?: (atBottom: boolean) => void;
onTimelineReadPresenceChange?: (presence: {
isAtBottom: boolean;
readAt: number | null;
}) => void;
onToggleReaction?: (
message: TimelineMessage,
emoji: string,

View File

@@ -44,7 +44,6 @@ import {
import { buildMessageComposerEditTarget } from "@/features/messages/lib/draftMentionRefs";
import { formatTimelineMessages } from "@/features/messages/lib/formatTimelineMessages";
import { DeleteMessageConfirmDialog } from "@/features/messages/ui/DeleteMessageConfirmDialog";
import { getThreadReference } from "@/features/messages/lib/threading";
import {
resolveTimelineLoadingLatch,
selectTimelineLoadingState,
@@ -78,6 +77,7 @@ import { useChannelAgentSessions } from "./useChannelAgentSessions";
import { useMessageProfiles } from "./useMessageProfiles";
import { useChannelPanelHistoryState } from "./useChannelPanelHistoryState";
import { useScopedAtBottom } from "./useScopedAtBottom";
import { useScopedTimelineReadPresence } from "./useScopedTimelineReadPresence";
import { useChannelProfilePanel } from "./useChannelProfilePanel";
import { useChannelRouteTarget } from "./useChannelRouteTarget";
import { useChannelOpenReadState } from "./useChannelOpenReadState";
@@ -165,8 +165,8 @@ export function ChannelScreen({
const mainInsetRef = useMainInsetRef();
const currentPubkey = currentIdentity?.pubkey;
const activeChannelId = activeChannel?.id ?? null;
const [timelineIsAtBottom, handleTimelineAtBottomChange] =
useScopedAtBottom(activeChannelId);
const [timelineReadPresence, handleTimelineReadPresenceChange] =
useScopedTimelineReadPresence(activeChannelId);
const isHuddleTranscript = useIsHuddleTranscript(activeChannelId);
const relaySelfPubkey = useRelaySelfQuery(activeChannel !== null).data;
const effectiveOpenThreadHeadId = useHuddleThreadIsolation({
@@ -205,23 +205,11 @@ export function ChannelScreen({
useChannelSubscription(activeChannel);
const { fetchOlder, hasOlderMessages, historyExhausted, isFetchingOlder } =
useFetchOlderMessages(activeChannel);
const latestActiveMessage = React.useMemo(() => {
const messages = messagesQuery.data;
if (!messages) return null;
for (let index = messages.length - 1; index >= 0; index -= 1) {
if (getThreadReference(messages[index].tags).parentId === null)
return messages[index];
}
return null;
}, [messagesQuery.data]);
const activeReadAt = latestActiveMessage
? new Date(latestActiveMessage.created_at * 1_000).toISOString()
: null;
useChannelOpenReadState(
activeChannelId,
activeChannel?.isMember,
activeReadAt,
timelineIsAtBottom,
timelineReadPresence.readAt,
timelineReadPresence.isAtBottom,
);
React.useEffect(() => {
if (!activeChannelId) {
@@ -938,7 +926,9 @@ export function ChannelScreen({
}
onThreadPanelResizeStart={handleThreadPanelResizeStart}
onTargetReached={handleTargetReached}
onTimelineAtBottomChange={handleTimelineAtBottomChange}
onTimelineReadPresenceChange={
handleTimelineReadPresenceChange
}
onToggleReaction={effectiveToggleReaction}
openAgentSessionChannelId={openAgentSessionChannelId}
openAgentSessionPubkey={openAgentSessionPubkey}

View File

@@ -22,7 +22,7 @@ export function getTopLevelInboxUnreadOverrideIds(
export function useChannelOpenReadState(
activeChannelId: string | null,
isChannelMember: boolean | undefined,
activeReadAt: string | null,
activeReadAt: number | null,
isAtBottom: boolean,
) {
const { feedItemState, locallyUnreadFeedItems, markChannelRead } =

View File

@@ -0,0 +1,24 @@
import * as React from "react";
export type TimelineReadPresence = {
isAtBottom: boolean;
readAt: number | null;
};
export function useScopedTimelineReadPresence(
scopeId: string | null,
): [TimelineReadPresence, (presence: TimelineReadPresence) => void] {
const [state, setState] = React.useState({
isAtBottom: false,
readAt: null as number | null,
scopeId: null as string | null,
});
const onPresenceChange = React.useCallback(
(presence: TimelineReadPresence) => setState({ ...presence, scopeId }),
[scopeId],
);
return [
state.scopeId === scopeId ? state : { isAtBottom: false, readAt: null },
onPresenceChange,
];
}

View File

@@ -41,12 +41,12 @@ test("live channel arrivals advance read state at bottom", async () => {
isAtBottom: true,
isMember: true,
markChannelRead,
readAt: "1970-01-01T00:01:40.000Z",
readAt: 100,
};
const view = await renderReadState(props);
calls.length = 0;
view.rerender({ ...props, readAt: "1970-01-01T00:02:00.000Z" });
view.rerender({ ...props, readAt: 120 });
assert.deepEqual(calls, [["channel-1", "1970-01-01T00:02:00.000Z"]]);
});
@@ -59,17 +59,17 @@ test("scrolled-away channel arrivals remain unread until the reader returns to b
isAtBottom: false,
isMember: true,
markChannelRead,
readAt: "1970-01-01T00:01:40.000Z",
readAt: 100,
};
const view = await renderReadState(props);
view.rerender({ ...props, readAt: "1970-01-01T00:02:00.000Z" });
view.rerender({ ...props, readAt: 120 });
assert.deepEqual(calls, []);
view.rerender({
...props,
isAtBottom: true,
readAt: "1970-01-01T00:02:00.000Z",
readAt: 120,
});
assert.deepEqual(calls, [["channel-1", "1970-01-01T00:02:00.000Z"]]);
});

View File

@@ -11,10 +11,13 @@ export function useVisibleChannelReadState({
isAtBottom: boolean;
isMember: boolean | undefined;
markChannelRead: (channelId: string, readAt: string | null) => void;
readAt: string | null;
readAt: number | null;
}): void {
React.useEffect(() => {
if (!channelId || isMember === false || !isAtBottom) return;
markChannelRead(channelId, readAt);
markChannelRead(
channelId,
readAt === null ? null : new Date(readAt * 1_000).toISOString(),
);
}, [channelId, isAtBottom, isMember, markChannelRead, readAt]);
}

View File

@@ -23,7 +23,10 @@ import { TimelineSkeleton, useTimelineSkeletonRows } from "./TimelineSkeleton";
import { TimelineMessageList } from "./TimelineMessageList";
import type { TimelineVirtualizerApi } from "./TimelineMessageList";
import { useAnchoredScroll } from "./useAnchoredScroll";
import { resolveTimelineReadPresence } from "./anchoredScrollPolicy";
import {
resolveTimelineReadPresence,
resolveTimelineVisibleReadAt,
} from "./anchoredScrollPolicy";
import { useLoadOlderOnScroll } from "./useLoadOlderOnScroll";
import { useBufferedTimelineMessages } from "./useBufferedTimelineMessages";
import {
@@ -116,7 +119,10 @@ type MessageTimelineProps = {
/** The current find-in-channel query string. */
searchQuery?: string;
targetMessageId?: string | null;
onAtBottomChange?: (atBottom: boolean) => void;
onReadPresenceChange?: (presence: {
isAtBottom: boolean;
readAt: number | null;
}) => void;
onTargetReached?: (messageId: string) => void;
splitThreadPanelOpen?: boolean;
/** Event id of the oldest unread top-level message at channel open, or null. */
@@ -206,7 +212,7 @@ const MessageTimelineBase = React.forwardRef<
searchMatchingMessageIds,
searchQuery,
targetMessageId = null,
onAtBottomChange,
onReadPresenceChange,
onTargetReached,
splitThreadPanelOpen = false,
firstUnreadMessageId = null,
@@ -372,18 +378,22 @@ const MessageTimelineBase = React.forwardRef<
virtualizerRenderVersion,
});
const visibleReadAt = React.useMemo(
() => resolveTimelineVisibleReadAt(renderedMessages),
[renderedMessages],
);
React.useEffect(() => {
// 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({
// Publish one atomic visibility fact. The loaded query can already contain
// buffered arrivals that are absent from renderedMessages, so consumers
// must never pair the query's newest timestamp with this timeline's tail.
onReadPresenceChange?.({
isAtBottom: resolveTimelineReadPresence({
isPhysicallyAtBottom: isAtBottom,
isSemanticallyAtBottom,
}),
);
}, [isAtBottom, isSemanticallyAtBottom, onAtBottomChange]);
readAt: visibleReadAt,
});
}, [isAtBottom, isSemanticallyAtBottom, onReadPresenceChange, visibleReadAt]);
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 resolveTimelineVisibleReadAt(
messages: readonly { createdAt: number; parentId?: string | null }[],
): number | null {
let latest: number | null = null;
for (const message of messages) {
if (message.parentId != null) continue;
latest =
latest === null ? message.createdAt : Math.max(latest, message.createdAt);
}
return latest;
}
export function resolveTimelineReadPresence({
isPhysicallyAtBottom: _isPhysicallyAtBottom,
isSemanticallyAtBottom,

View File

@@ -4,6 +4,7 @@ import test from "node:test";
import {
getPinnedCenterDrift,
resolveTimelineReadPresence,
resolveTimelineVisibleReadAt,
settleProgrammaticBottomPin,
shouldIgnorePinnedCenterScroll,
shouldSettleForSplitPanel,
@@ -24,6 +25,27 @@ function fakeContainer({ clientHeight, scrollHeight, scrollTop }) {
};
}
test("visible read frontier excludes query arrivals buffered outside the rendered timeline", () => {
assert.equal(
resolveTimelineVisibleReadAt([
{ createdAt: 100, parentId: null },
{ createdAt: 110, parentId: "thread-root" },
]),
100,
);
const loadedQueryWithBufferedArrival = [
{ createdAt: 100, parentId: null },
{ createdAt: 120, parentId: null },
];
const renderedTimeline = loadedQueryWithBufferedArrival.slice(0, 1);
assert.equal(resolveTimelineVisibleReadAt(renderedTimeline), 100);
assert.equal(
resolveTimelineVisibleReadAt(loadedQueryWithBufferedArrival),
120,
);
});
test("buffered arrivals do not become read when the shortened timeline reports its physical floor", () => {
assert.equal(
resolveTimelineReadPresence({