mirror of
https://github.com/block/buzz.git
synced 2026-10-30 22:11:36 +00:00
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:
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 } =
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
@@ -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"]]);
|
||||
});
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user