diff --git a/desktop/src/features/channels/ui/ChannelPane.tsx b/desktop/src/features/channels/ui/ChannelPane.tsx index e66600e407..3984d20dcf 100644 --- a/desktop/src/features/channels/ui/ChannelPane.tsx +++ b/desktop/src/features/channels/ui/ChannelPane.tsx @@ -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} diff --git a/desktop/src/features/channels/ui/ChannelPane.types.ts b/desktop/src/features/channels/ui/ChannelPane.types.ts index acb770df63..cfb25ec710 100644 --- a/desktop/src/features/channels/ui/ChannelPane.types.ts +++ b/desktop/src/features/channels/ui/ChannelPane.types.ts @@ -139,7 +139,10 @@ export type ChannelPaneProps = { } | null, ) => Promise; onTargetReached?: (messageId: string) => void; - onTimelineAtBottomChange?: (atBottom: boolean) => void; + onTimelineReadPresenceChange?: (presence: { + isAtBottom: boolean; + readAt: number | null; + }) => void; onToggleReaction?: ( message: TimelineMessage, emoji: string, diff --git a/desktop/src/features/channels/ui/ChannelScreen.tsx b/desktop/src/features/channels/ui/ChannelScreen.tsx index 5d5db71be4..0639540bfb 100644 --- a/desktop/src/features/channels/ui/ChannelScreen.tsx +++ b/desktop/src/features/channels/ui/ChannelScreen.tsx @@ -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} diff --git a/desktop/src/features/channels/ui/useChannelOpenReadState.ts b/desktop/src/features/channels/ui/useChannelOpenReadState.ts index 356b28a1e6..d6d61c6f65 100644 --- a/desktop/src/features/channels/ui/useChannelOpenReadState.ts +++ b/desktop/src/features/channels/ui/useChannelOpenReadState.ts @@ -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 } = diff --git a/desktop/src/features/channels/ui/useScopedTimelineReadPresence.ts b/desktop/src/features/channels/ui/useScopedTimelineReadPresence.ts new file mode 100644 index 0000000000..f00a181867 --- /dev/null +++ b/desktop/src/features/channels/ui/useScopedTimelineReadPresence.ts @@ -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, + ]; +} diff --git a/desktop/src/features/channels/ui/useVisibleChannelReadState.test.mjs b/desktop/src/features/channels/ui/useVisibleChannelReadState.test.mjs index d8a34a376c..b3ac1437e0 100644 --- a/desktop/src/features/channels/ui/useVisibleChannelReadState.test.mjs +++ b/desktop/src/features/channels/ui/useVisibleChannelReadState.test.mjs @@ -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"]]); }); diff --git a/desktop/src/features/channels/ui/useVisibleChannelReadState.ts b/desktop/src/features/channels/ui/useVisibleChannelReadState.ts index 2ce5a71d92..cab557b97e 100644 --- a/desktop/src/features/channels/ui/useVisibleChannelReadState.ts +++ b/desktop/src/features/channels/ui/useVisibleChannelReadState.ts @@ -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]); } diff --git a/desktop/src/features/messages/ui/MessageTimeline.tsx b/desktop/src/features/messages/ui/MessageTimeline.tsx index 2802d1e11b..c1aee72059 100644 --- a/desktop/src/features/messages/ui/MessageTimeline.tsx +++ b/desktop/src/features/messages/ui/MessageTimeline.tsx @@ -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) { diff --git a/desktop/src/features/messages/ui/anchoredScrollPolicy.ts b/desktop/src/features/messages/ui/anchoredScrollPolicy.ts index a632ae265a..dc8f6c4a4f 100644 --- a/desktop/src/features/messages/ui/anchoredScrollPolicy.ts +++ b/desktop/src/features/messages/ui/anchoredScrollPolicy.ts @@ -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, diff --git a/desktop/src/features/messages/ui/useAnchoredScroll.test.mjs b/desktop/src/features/messages/ui/useAnchoredScroll.test.mjs index 7e04eeb8e3..12f001bf08 100644 --- a/desktop/src/features/messages/ui/useAnchoredScroll.test.mjs +++ b/desktop/src/features/messages/ui/useAnchoredScroll.test.mjs @@ -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({