diff --git a/docs/buzz-help.md b/docs/buzz-help.md index 2ba767d9a1..87ed26a1f3 100644 --- a/docs/buzz-help.md +++ b/docs/buzz-help.md @@ -160,6 +160,13 @@ that agent in Buzz; they do not notify the external app through this connection. - **Inbox** collects DMs, ordinary responses in Solo chats, mentions of you, and replies in threads you started, replied in, or follow. Solo chats appear under All, not DMs; their nested threads stay separate. Agent-only coordination stays out. +- **New messages** marks the first known unread incoming channel message when + you enter. Once established, the divider stays in place for that visit as you + read, reveal another message, or receive new messages. It is absent for a visit + that starts with incomplete or unverified history, even if older rows load + later. It may also be absent when unread evidence is incomplete, or when read + intent or the read evidence needed to locate the boundary changes before it is + established. - **Communities** have their own channels, people, and agents. Switching communities changes the available context. A channel reopens where you stopped reading, or at the latest message once you had caught up. You and diff --git a/src/bundled/channels/ChannelsPage.solo.test.tsx b/src/bundled/channels/ChannelsPage.solo.test.tsx index 5a129f1774..c3f3c8af17 100644 --- a/src/bundled/channels/ChannelsPage.solo.test.tsx +++ b/src/bundled/channels/ChannelsPage.solo.test.tsx @@ -222,6 +222,11 @@ function fixture({ coverage: "observed" as const, freshness: "observed" as const, }; + const readSync = { + capability, + status: "reconciled" as const, + completeness: "snapshot" as const, + }; const session = { ...owner.session, profiles: { @@ -293,11 +298,7 @@ function fixture({ ? owner.session.unread : { ...owner.session.unread, - sync: () => ({ - capability, - status: "reconciled" as const, - completeness: "snapshot" as const, - }), + sync: () => readSync, snapshot: ( target: Parameters[0], ) => { diff --git a/src/bundled/channels/ConversationTab.test.tsx b/src/bundled/channels/ConversationTab.test.tsx index 3ac752d161..f97404af2f 100644 --- a/src/bundled/channels/ConversationTab.test.tsx +++ b/src/bundled/channels/ConversationTab.test.tsx @@ -84,6 +84,10 @@ vi.mock("../../features/messages/MessageComposer", () => ({ ), })); +const unreadEvidence = { + revision: () => 0, + sync: () => ({ capability: "unsupported" }), +}; const owners: ReturnType[] = []; afterEach(() => { cleanup(); @@ -138,8 +142,13 @@ it.each(["thread", "conversation"] as const)( // Only management consumes this session; independent child views above are // stubbed. These writable tabs have static eligibility/profile evidence. const session = { + unread: unreadEvidence, outbox: owner.outbox, - channels: { list: () => channels, subscribeList: () => () => {} }, + channels: { + window: () => ({ rows: [] }), + list: () => channels, + subscribeList: () => () => {}, + }, profiles: { subscribe: () => () => {} }, messages: { canRetry: () => true }, } as unknown as RelaySession; @@ -201,9 +210,13 @@ it.each(["thread", "conversation"] as const)( const leaveChannel = vi.fn(); const session = { viewer: "viewer", - channels: { list: () => snapshot, subscribeList: () => () => {} }, + channels: { + window: () => ({ rows: [] }), + list: () => snapshot, + subscribeList: () => () => {}, + }, profiles: {}, - unread: { enterChannel, leaveChannel }, + unread: { ...unreadEvidence, enterChannel, leaveChannel }, } as unknown as RelaySession; const props = { tab: { @@ -253,9 +266,13 @@ it.each(["thread", "conversation"] as const)( const enterChannel = vi.fn(async () => {}); const session = { viewer: "viewer", - channels: { list: () => snapshot, subscribeList: () => () => {} }, + channels: { + window: () => ({ rows: [] }), + list: () => snapshot, + subscribeList: () => () => {}, + }, profiles: {}, - unread: { enterChannel, leaveChannel: vi.fn() }, + unread: { ...unreadEvidence, enterChannel, leaveChannel: vi.fn() }, } as unknown as RelaySession; render( snapshot, subscribeList: () => () => {} }, + unread: unreadEvidence, + channels: { + window: () => ({ rows: [] }), + list: () => snapshot, + subscribeList: () => () => {}, + }, } as unknown as RelaySession; const tab = { id: "thread", @@ -340,7 +362,12 @@ it("a tab mounted later in its visit treats the visit's requests as restored", ( const channel = { id: "beta", name: "Beta" }; const snapshot = { status: "ready", channels: [channel] }; const session = { - channels: { list: () => snapshot, subscribeList: () => () => {} }, + unread: unreadEvidence, + channels: { + window: () => ({ rows: [] }), + list: () => snapshot, + subscribeList: () => () => {}, + }, } as unknown as RelaySession; const tab: Tab = { id: "thread", @@ -390,7 +417,12 @@ it.each([ const channel = { id: "beta", name: "Beta" }; const snapshot = { status: "ready", channels: [channel] }; const session = { - channels: { list: () => snapshot, subscribeList: () => () => {} }, + unread: unreadEvidence, + channels: { + window: () => ({ rows: [] }), + list: () => snapshot, + subscribeList: () => () => {}, + }, } as unknown as RelaySession; const tab: Tab = kind === "thread" @@ -434,7 +466,12 @@ it.each(["pointerDown", "keyDown"] as const)( const channel = { id: "beta", name: "Beta" }; const snapshot = { status: "ready", channels: [channel] }; const session = { - channels: { list: () => snapshot, subscribeList: () => () => {} }, + unread: unreadEvidence, + channels: { + window: () => ({ rows: [] }), + list: () => snapshot, + subscribeList: () => () => {}, + }, } as unknown as RelaySession; const tab: Tab = { id: "thread", @@ -482,6 +519,7 @@ function openingAt(rows: { id: string; threadRootId?: string }[]) { }; const listeners = new Set<() => void>(); const session = { + unread: unreadEvidence, channels: { list: () => snapshot, subscribeList: () => () => {}, @@ -641,7 +679,12 @@ it("lets the tab supply Solo chat identity without repeating a visible heading", const idle = () => () => {}; const profiles = new Map(); const session = { - channels: { list: () => snapshot, subscribeList: idle }, + unread: unreadEvidence, + channels: { + window: () => ({ rows: [] }), + list: () => snapshot, + subscribeList: idle, + }, profiles: { snapshot: () => profiles, subscribe: idle }, agentChoices: { snapshot: () => undefined, subscribe: idle }, } as unknown as RelaySession; @@ -675,7 +718,12 @@ it("tells the composer when its tab is shown, apart from whether showing visits" const idle = () => () => {}; const profiles = new Map(); const session = { - channels: { list: () => snapshot, subscribeList: idle }, + unread: unreadEvidence, + channels: { + window: () => ({ rows: [] }), + list: () => snapshot, + subscribeList: idle, + }, profiles: { snapshot: () => profiles, subscribe: idle }, agentChoices: { snapshot: () => undefined, subscribe: idle }, } as unknown as RelaySession; diff --git a/src/bundled/channels/useConversationTabs.test.tsx b/src/bundled/channels/useConversationTabs.test.tsx index e5fc54d73a..dace35a361 100644 --- a/src/bundled/channels/useConversationTabs.test.tsx +++ b/src/bundled/channels/useConversationTabs.test.tsx @@ -1870,6 +1870,7 @@ it("a hidden retained thread tab's rows re-render for new panels, not channel-li messages: { retry: () => {} }, media: () => undefined, unread: { + revision: () => 0, sync: () => sync, snapshot: () => undefined, subscribe: () => () => {}, diff --git a/src/features/conversation/EmbeddedThread.test.tsx b/src/features/conversation/EmbeddedThread.test.tsx index a8c6dd8cb4..cfa2fd9ea3 100644 --- a/src/features/conversation/EmbeddedThread.test.tsx +++ b/src/features/conversation/EmbeddedThread.test.tsx @@ -143,6 +143,7 @@ function fixture( ], }; const unread = { + revision: () => 0, sync: () => ({ capability: "unsupported" }), snapshot: () => undefined, subscribe: () => () => {}, @@ -166,7 +167,11 @@ function fixture( loadMore: async () => {}, dispose() {}, }), - channels: { subscribeList: () => () => {}, list: () => channels }, + channels: { + subscribeList: () => () => {}, + list: () => channels, + window: () => ({ rows: [] }), + }, profiles: { ensure: async () => {}, snapshot: () => profiles, diff --git a/src/features/messages/ChannelTimeline.restore.test.tsx b/src/features/messages/ChannelTimeline.restore.test.tsx index b46501fa0a..f5edf1a1f9 100644 --- a/src/features/messages/ChannelTimeline.restore.test.tsx +++ b/src/features/messages/ChannelTimeline.restore.test.tsx @@ -10,9 +10,12 @@ import { import { StrictMode, type ReactNode } from "react"; import { ChannelTimeline } from "./ChannelTimeline"; import { createRelaySession } from "../relay/session"; -import type { ChannelWindow } from "../relay/contracts"; +import type { ChannelMessage, ChannelWindow } from "../relay/contracts"; +import type { RelayEvent } from "../relay/events"; +import type { ReadTarget } from "../relay/read-state-model"; import { keypair, message, scriptedTransport } from "../relay/testing"; import { readView, writeView } from "../../shared/view-state"; +import { MessageManagement } from "./MessageManagement"; // Real React lifecycle; only the virtualizer's imperative layout boundary is // modeled here. Browser journeys retain the actual same-message/4px contract. @@ -24,7 +27,9 @@ const scroll = vi.hoisted(() => ({ resized: () => {}, })); vi.mock("virtua", async () => { - const { forwardRef, useImperativeHandle, useRef } = await import("react"); + const { Children, forwardRef, useImperativeHandle, useRef } = await import( + "react" + ); return { Virtualizer: forwardRef(function Virtualizer( { children, onResize }: { children: ReactNode; onResize?: () => void }, @@ -46,7 +51,9 @@ vi.mock("virtua", async () => { })); return (
    - {children} + {Children.map(children, (child) => ( +
  1. {child}
  2. + ))}
); }), @@ -245,6 +252,984 @@ function mount(bottom = false, revealOnMount = false, anchorLoaded = true) { }, }; } + +function boundaryFixture() { + const viewer = keypair(), + peer = keypair(), + relay = keypair(); + const owner = createRelaySession( + scriptedTransport(viewer.pubkey, relay.pubkey).transport, + ); + owners.push(owner); + const unreadIds = new Set(), + forcedIds = new Set(), + unknownIds = new Set(), + pendingIds = new Set(), + ineligibleIds = new Set(), + listeners = new Set<() => void>(); + const syncListeners = new Set<() => void>(); + let readRevision = owner.session.unread.revision(); + let syncSnapshot = owner.session.unread.sync(); + const entries: { + promise: Promise; + resolve: () => void; + reject: (cause: Error) => void; + settled: boolean; + }[] = []; + const enterChannel = vi.fn(() => { + let resolve!: () => void; + let reject!: (cause: Error) => void; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = () => resolvePromise(); + reject = rejectPromise; + }); + entries.push({ promise, resolve, reject, settled: false }); + return promise; + }); + const channelList = { + status: "ready" as const, + channels: ["c", "other"].map((id) => ({ + id, + name: id, + members: [viewer.pubkey], + })), + }; + const windows = new Map(); + const channels = { + ...owner.session.channels, + window: (id: string) => + windows.get(id) ?? owner.session.channels.window(id), + list: () => channelList, + subscribeList: () => () => {}, + }; + const snapshot = (channelId: string) => { + return { + target: { kind: "channel" as const, channelId }, + observedCount: unreadIds.size, + attentionCount: 0, + coverage: "observed" as const, + freshness: "observed" as const, + manual: "none" as const, + }; + }; + const snapshots = new Map([ + ["c", snapshot("c")], + ["other", snapshot("other")], + ]); + const unread = { + ...owner.session.unread, + enterChannel, + snapshot(target: ReadTarget) { + return snapshots.get(target.channelId) ?? snapshot(target.channelId); + }, + subscribe(_target: ReadTarget, listener: () => void) { + listeners.add(listener); + return () => listeners.delete(listener); + }, + sync: () => syncSnapshot, + subscribeSync(listener: () => void) { + syncListeners.add(listener); + return () => syncListeners.delete(listener); + }, + attention(_channelId: string, id: string) { + return { + status: unknownIds.has(id) + ? ("unknown" as const) + : ineligibleIds.has(id) + ? ("ineligible" as const) + : ("eligible" as const), + ...(pendingIds.has(id) ? { pending: true } : {}), + unread: unreadIds.has(id), + forced: forcedIds.has(id), + viewing: false, + }; + }, + revision: () => readRevision, + }; + const session = { ...owner.session, channels, unread }; + function row( + event: RelayEvent, + channelId = "c", + extra: Partial = {}, + ): ChannelMessage { + snapshots.set(channelId, snapshot(channelId)); + return { + id: event.id, + channelId, + authorId: event.pubkey, + content: event.content, + createdAt: event.created_at, + mentions: [], + participants: [], + attachments: [], + reactions: [], + replyCount: 0, + ...extra, + }; + } + function tree( + channelId: string, + rows: readonly ChannelMessage[], + hasMore = false, + active = true, + presented = true, + ) { + const window: ChannelWindow = { + channelId, + status: "ready", + freshness: "verified", + hasMore, + loadingOlder: false, + historyLimited: false, + error: undefined, + rows, + }; + windows.set(channelId, window); + return ( + + {presented ? ( + false} + /> + ) : ( +

Loading messages…

+ )} +
+ ); + } + function publish(channelId = "c") { + snapshots.set(channelId, snapshot(channelId)); + act(() => { + for (const listener of [...listeners]) listener(); + }); + } + function completeReadStateSnapshot() { + syncSnapshot = { + ...syncSnapshot, + status: "reconciled", + completeness: "snapshot", + }; + act(() => { + for (const listener of [...syncListeners]) listener(); + }); + } + return { + owner, + viewer, + peer, + row, + tree, + unread, + unreadIds, + forcedIds, + unknownIds, + pendingIds, + ineligibleIds, + enterChannel, + subscribers: () => ({ unread: listeners.size, sync: syncListeners.size }), + completeReadStateSnapshot, + async completeEntry() { + const entry = entries.find((candidate) => !candidate.settled); + if (!entry) throw new Error("No pending channel entry"); + entry.settled = true; + await act(async () => { + entry.resolve(); + await entry.promise; + }); + }, + async failEntry() { + const entry = entries.find((candidate) => !candidate.settled); + if (!entry) throw new Error("No pending channel entry"); + entry.settled = true; + await act(async () => { + entry.reject(new Error("Could not restore unread state.")); + await entry.promise.catch(() => {}); + }); + }, + notifyAttention() { + act(() => { + for (const listener of [...syncListeners]) listener(); + }); + }, + advanceReadRevision() { + readRevision++; + }, + publish, + }; +} + +it("waits for entry and unread evidence before selecting a verified entry boundary", async () => { + const h = boundaryFixture(); + const own = h.row(message(h.viewer, "c", "Own message", 1)); + const agents = h.row(message(h.peer, "c", "Agent coordination", 2), "c", { + audience: "agents", + }); + const joined = h.row(message(h.peer, "c", "Membership activity", 3), "c", { + membership: { + type: "member_joined", + actor: h.viewer.pubkey, + target: h.peer.pubkey, + }, + }); + const firstUnread = h.row(message(h.peer, "c", "First unread message", 4)); + const laterUnread = h.row(message(h.peer, "c", "Later unread message", 5)); + h.forcedIds.add(own.id); + h.unreadIds.add(agents.id); + h.unreadIds.add(firstUnread.id); + h.forcedIds.add(firstUnread.id); + h.unreadIds.add(laterUnread.id); + const rows = [own, agents, joined, firstUnread, laterUnread]; + const mounted = render(h.tree("c", rows)); + expect(h.enterChannel).toHaveBeenCalledExactlyOnceWith("c"); + expect(screen.queryByText("New messages", { exact: true })).toBeNull(); + await h.completeEntry(); + expect(screen.queryByText("New messages", { exact: true })).toBeNull(); + + h.unknownIds.add(firstUnread.id); + mounted.rerender(h.tree("c", rows)); + h.publish(); + expect(screen.queryByText("New messages", { exact: true })).toBeNull(); + + h.unknownIds.delete(firstUnread.id); + h.pendingIds.add(firstUnread.id); + h.notifyAttention(); + expect(screen.queryByText("New messages", { exact: true })).toBeNull(); + h.pendingIds.delete(firstUnread.id); + h.ineligibleIds.add(firstUnread.id); + h.notifyAttention(); + expect(screen.queryByText("New messages", { exact: true })).toBeNull(); + h.completeReadStateSnapshot(); + h.publish(); + const separator = screen.getByText("New messages", { exact: true }); + const first = screen.getByText("First unread message", { exact: true }); + const row = (id: string) => + document.querySelector(`[data-message-id="${id}"]`); + for (const earlier of [ + row(own.id), + screen.getByRole("button", { + name: /^Show activity history: .*: Agent coordination$/, + }), + row(joined.id), + ]) { + expect(earlier).not.toBeNull(); + expect( + (earlier?.compareDocumentPosition(separator) ?? 0) & + Node.DOCUMENT_POSITION_FOLLOWING, + ).not.toBe(0); + } + expect( + separator.compareDocumentPosition(first) & Node.DOCUMENT_POSITION_FOLLOWING, + ).not.toBe(0); + expect(screen.queryByText("Own message", { exact: true })).not.toBeNull(); +}); + +it.each([ + { remoteRead: false, presented: true }, + { remoteRead: true, presented: true }, + { remoteRead: true, presented: false }, +])( + "does not infer an entry boundary after incomplete history: %j", + async ({ remoteRead, presented }) => { + const h = boundaryFixture(); + const older = h.row( + message(h.peer, "c", "Older unread outside the entry window", 1), + ); + const read = h.row(message(h.peer, "c", "Read in loaded window", 2)); + const unread = h.row(message(h.peer, "c", "Unread in loaded window", 3)); + h.unreadIds.add(older.id); + h.unreadIds.add(unread.id); + h.completeReadStateSnapshot(); + h.publish(); + const revision = h.unread.revision(); + const mounted = render(h.tree("c", [read, unread], true, true, presented)); + await h.completeEntry(); + expect( + screen.queryByRole("separator", { name: "New messages" }), + ).toBeNull(); + + if (remoteRead) h.unreadIds.delete(older.id); + h.publish(); + mounted.rerender(h.tree("c", [older, read, unread])); + h.publish(); + expect( + screen.queryByRole("separator", { name: "New messages" }), + ).toBeNull(); + expect(h.unread.attention("c", older.id).unread).toBe(!remoteRead); + expect(h.unread.attention("c", unread.id).unread).toBe(true); + expect(h.unread.revision()).toBe(revision); + mounted.unmount(); + }, +); + +it("does not select a stale unread row after reading advances while unread evidence is pending", async () => { + const h = boundaryFixture(); + const read = h.row(message(h.peer, "c", "Already read", 1)); + const unread = h.row(message(h.peer, "c", "Unread before paging", 2)); + h.unreadIds.add(unread.id); + const mounted = render(h.tree("c", [read, unread])); + + await h.completeEntry(); + h.advanceReadRevision(); + h.completeReadStateSnapshot(); + mounted.rerender(h.tree("c", [read, unread])); + h.publish(); + + expect(screen.queryByText("New messages", { exact: true })).toBeNull(); + mounted.unmount(); +}); + +it("omits a pending boundary after a shared read revision changes without changing this channel's unread evidence", async () => { + const h = boundaryFixture(); + const unread = h.row(message(h.peer, "c", "Still unread in this channel", 1)); + h.unreadIds.add(unread.id); + h.publish(); + const target = { kind: "channel" as const, channelId: "c" }; + const entrySnapshot = h.unread.snapshot(target); + const entryAttention = h.unread.attention("c", unread.id); + const mounted = render(h.tree("c", [unread])); + await h.completeEntry(); + + h.advanceReadRevision(); + h.publish("other"); + h.completeReadStateSnapshot(); + mounted.rerender(h.tree("c", [unread])); + + expect(screen.queryByRole("separator", { name: "New messages" })).toBeNull(); + expect(h.unread.snapshot(target)).toBe(entrySnapshot); + expect(h.unread.snapshot(target).observedCount).toBe(1); + expect(h.unread.attention("c", unread.id)).toEqual(entryAttention); + expect(h.unread.attention("c", unread.id).unread).toBe(true); + mounted.unmount(); +}); + +it.each([1, 10, 11])( + "excludes a post-entry arrival at timestamp %s while snapshot evidence is pending", + async (createdAt) => { + const h = boundaryFixture(); + const read = h.row({ + ...message(h.viewer, "c", "Own message at entry", 10), + id: "0".repeat(64), + }); + const mounted = render(h.tree("c", [read])); + await h.completeEntry(); + + const live = h.row({ + ...message(h.peer, "c", "Arrived during entry", createdAt), + id: "f".repeat(64), + }); + h.unreadIds.add(live.id); + const rows = createdAt < 10 ? [live, read] : [read, live]; + mounted.rerender(h.tree("c", rows)); + h.completeReadStateSnapshot(); + h.publish(); + + expect( + screen.queryByRole("separator", { name: "New messages" }), + ).toBeNull(); + expect(h.unread.attention("c", live.id).unread).toBe(true); + mounted.unmount(); + }, +); + +it("does not adopt later-loaded unread rows as entry membership", async () => { + const h = boundaryFixture(); + const mounted = render(h.tree("c", [])); + const late = h.row(message(h.peer, "c", "First loaded after entry", 1)); + h.unreadIds.add(late.id); + mounted.rerender(h.tree("c", [late])); + await h.completeEntry(); + h.completeReadStateSnapshot(); + h.publish(); + + expect(screen.queryByRole("separator", { name: "New messages" })).toBeNull(); + expect(h.unread.attention("c", late.id).unread).toBe(true); + mounted.unmount(); +}); + +it.each([ + { entryReady: false, unreadAtEntry: false }, + { entryReady: true, unreadAtEntry: false }, + { entryReady: false, unreadAtEntry: true }, + { entryReady: true, unreadAtEntry: true }, +])( + "uses entry unread rows after a pre-mount arrival: %j", + async ({ entryReady, unreadAtEntry }) => { + const h = boundaryFixture(); + const entry = h.row( + message(unreadAtEntry ? h.peer : h.viewer, "c", "Message at entry", 10), + ); + if (unreadAtEntry) { + h.unreadIds.add(entry.id); + h.completeReadStateSnapshot(); + } + const mounted = render(h.tree("c", [entry], false, true, false)); + if (entryReady) await h.completeEntry(); + const revision = h.unread.revision(); + + const live = h.row(message(h.peer, "c", "Arrived while loading", 1)); + h.unreadIds.add(live.id); + mounted.rerender(h.tree("c", [live, entry])); + if (!entryReady) await h.completeEntry(); + h.completeReadStateSnapshot(); + h.publish(); + + expect( + screen.getByText("Arrived while loading", { exact: true }), + ).toBeTruthy(); + if (unreadAtEntry) { + const boundary = screen.getByRole("separator", { name: "New messages" }); + expect( + screen + .getByText("Arrived while loading") + .compareDocumentPosition(boundary) & Node.DOCUMENT_POSITION_FOLLOWING, + ).not.toBe(0); + expect( + boundary.compareDocumentPosition(screen.getByText("Message at entry")) & + Node.DOCUMENT_POSITION_FOLLOWING, + ).not.toBe(0); + } else { + expect( + screen.queryByRole("separator", { name: "New messages" }), + ).toBeNull(); + } + expect(h.unread.attention("c", entry.id).unread).toBe(unreadAtEntry); + expect(h.unread.attention("c", live.id).unread).toBe(true); + expect(h.unread.revision()).toBe(revision); + expect(h.enterChannel).toHaveBeenCalledTimes(1); + mounted.unmount(); + }, +); + +it("retains loaded entry membership through a delayed first timeline mount", async () => { + const h = boundaryFixture(); + const unread = h.row(message(h.peer, "c", "Unread at entry", 1)); + h.unreadIds.add(unread.id); + const mounted = render(h.tree("c", [unread], false, true, false)); + await h.completeEntry(); + h.completeReadStateSnapshot(); + h.publish(); + mounted.rerender(h.tree("c", [unread])); + + expect(screen.getByRole("separator", { name: "New messages" })).toBeTruthy(); + expect(h.unread.attention("c", unread.id).unread).toBe(true); + mounted.unmount(); +}); + +it("omits a delayed first-mount boundary after entry read intent changes", async () => { + const h = boundaryFixture(); + const unread = h.row(message(h.peer, "c", "Unread at entry", 1)); + h.unreadIds.add(unread.id); + const mounted = render(h.tree("c", [unread], false, true, false)); + h.advanceReadRevision(); + await h.completeEntry(); + h.completeReadStateSnapshot(); + h.publish(); + mounted.rerender(h.tree("c", [unread])); + + expect(screen.queryByRole("separator", { name: "New messages" })).toBeNull(); + expect(h.unread.attention("c", unread.id).unread).toBe(true); + mounted.unmount(); +}); + +it("omits a delayed first-mount boundary after synchronized entry reading changes", async () => { + const h = boundaryFixture(); + const first = h.row(message(h.peer, "c", "Unread at entry", 1)); + const later = h.row(message(h.peer, "c", "Still unread after sync", 2)); + h.unreadIds.add(first.id); + h.unreadIds.add(later.id); + h.completeReadStateSnapshot(); + const mounted = render(h.tree("c", [first, later], false, true, false)); + await h.completeEntry(); + + const entryRevision = h.unread.revision(); + h.unreadIds.delete(first.id); + h.notifyAttention(); + mounted.rerender(h.tree("c", [first, later])); + + expect(screen.queryByRole("separator", { name: "New messages" })).toBeNull(); + expect(h.unread.revision()).toBe(entryRevision); + expect(h.unread.attention("c", later.id).unread).toBe(true); + mounted.unmount(); +}); + +it("omits the boundary if entry reconciliation changes read intent", async () => { + const h = boundaryFixture(); + const unread = h.row(message(h.peer, "c", "Unread during entry", 1)); + h.unreadIds.add(unread.id); + const mounted = render(h.tree("c", [unread])); + h.advanceReadRevision(); + await h.completeEntry(); + h.completeReadStateSnapshot(); + h.publish(); + + expect(screen.queryByRole("separator", { name: "New messages" })).toBeNull(); + expect(h.unread.attention("c", unread.id).unread).toBe(true); + mounted.unmount(); +}); + +it("omits the boundary and releases listeners when channel-entry reconciliation fails", async () => { + const h = boundaryFixture(); + const unread = h.row(message(h.peer, "c", "Unread before failed entry", 1)); + h.unreadIds.add(unread.id); + render(h.tree("c", [unread])); + h.completeReadStateSnapshot(); + h.publish(); + expect(h.subscribers()).toEqual({ unread: 1, sync: 1 }); + + await h.failEntry(); + + expect(screen.getByRole("alert").textContent).toContain( + "Could not restore unread state.", + ); + expect(screen.queryByText("New messages", { exact: true })).toBeNull(); + expect(h.subscribers()).toEqual({ unread: 0, sync: 0 }); +}); + +it("keeps the first boundary stable through automatic reading and live messages", async () => { + const h = boundaryFixture(); + const read = h.row(message(h.peer, "c", "Already read", 1)); + const firstUnread = h.row(message(h.peer, "c", "Previously unread", 2)); + h.unreadIds.add(firstUnread.id); + const mounted = render(h.tree("c", [read, firstUnread])); + expect(screen.queryByText("New messages", { exact: true })).toBeNull(); + await h.completeEntry(); + expect(screen.queryByText("New messages", { exact: true })).toBeNull(); + h.completeReadStateSnapshot(); + const before = (content: string) => { + const separator = screen.getByRole("separator", { name: "New messages" }); + const target = screen.getByText(content, { exact: true }); + return !!( + separator.compareDocumentPosition(target) & + Node.DOCUMENT_POSITION_FOLLOWING + ); + }; + expect(before("Previously unread")).toBe(true); + + h.unreadIds.delete(firstUnread.id); + h.publish(); + const live = h.row(message(h.peer, "c", "Arrived during visit", 3)); + h.unreadIds.add(live.id); + mounted.rerender(h.tree("c", [read, firstUnread, live])); + expect(before("Previously unread")).toBe(true); + expect(before("Arrived during visit")).toBe(true); + + const nextVisit = h.row( + message(h.peer, "other", "Unread in another channel", 4), + "other", + ); + h.unreadIds.clear(); + h.unreadIds.add(nextVisit.id); + h.publish("other"); + mounted.rerender(h.tree("other", [nextVisit])); + await h.completeEntry(); + expect(before("Unread in another channel")).toBe(true); + mounted.unmount(); +}); + +it.each([true, false])( + "retains a completed channel boundary through a temporary timeline unmount, unread at entry=%s", + async (unreadAtEntry) => { + const h = boundaryFixture(); + const first = h.row(message(h.peer, "c", "First entry message", 1)); + if (unreadAtEntry) h.unreadIds.add(first.id); + const mounted = render(h.tree("c", [first])); + await h.completeEntry(); + h.completeReadStateSnapshot(); + expect(!!screen.queryByRole("separator", { name: "New messages" })).toBe( + unreadAtEntry, + ); + + mounted.rerender(h.tree("c", [first], false, true, false)); + expect( + screen.queryByRole("region", { name: "Channel message history" }), + ).toBeNull(); + h.unreadIds.delete(first.id); + h.advanceReadRevision(); + const live = h.row(message(h.peer, "c", "Arrived during inline reveal", 2)); + h.unreadIds.add(live.id); + h.publish(); + mounted.rerender(h.tree("c", [first, live])); + + expect(h.enterChannel).toHaveBeenCalledTimes(1); + const boundary = screen.queryByRole("separator", { name: "New messages" }); + if (unreadAtEntry) { + expect(boundary).not.toBeNull(); + expect( + (boundary?.compareDocumentPosition( + screen.getByText("First entry message"), + ) ?? 0) & Node.DOCUMENT_POSITION_FOLLOWING, + ).not.toBe(0); + } else expect(boundary).toBeNull(); + mounted.unmount(); + }, +); + +it.each(["read intent", "backdated arrival"])( + "retains the pending entry baseline through an inline-reveal unmount and %s", + async (change) => { + const h = boundaryFixture(); + const entry = h.row(message(h.peer, "c", "Present at entry", 2)); + if (change === "read intent") h.unreadIds.add(entry.id); + const mounted = render(h.tree("c", [entry])); + await h.completeEntry(); + expect( + screen.queryByRole("separator", { name: "New messages" }), + ).toBeNull(); + mounted.rerender(h.tree("c", [entry], false, true, false)); + + const rows = [entry]; + if (change === "read intent") h.advanceReadRevision(); + else { + const live = h.row(message(h.peer, "c", "Backdated after entry", 1)); + rows.unshift(live); + h.unreadIds.add(live.id); + } + h.completeReadStateSnapshot(); + h.publish(); + mounted.rerender(h.tree("c", rows)); + + expect(h.enterChannel).toHaveBeenCalledTimes(1); + expect( + screen.queryByRole("separator", { name: "New messages" }), + ).toBeNull(); + expect(rows.some((row) => h.unread.attention("c", row.id).unread)).toBe( + true, + ); + mounted.unmount(); + }, +); + +it.each([ + "entry", + "before inline reveal", + "before snapshot", + "before first mount", +] as const)( + "omits a pending boundary after synchronized reading changes attention captured at %s", + async (timing) => { + const h = boundaryFixture(); + const first = h.row(message(h.peer, "c", "Unread at entry", 1)); + const second = h.row( + message(h.peer, "c", "Still unread after remote reading", 2), + ); + h.unreadIds.add(first.id); + h.unreadIds.add(second.id); + if (timing === "entry") h.completeReadStateSnapshot(); + const revision = h.unread.revision(); + const mounted = render( + h.tree( + "c", + [first, second], + false, + true, + timing !== "before first mount", + ), + ); + if (timing !== "entry") await h.completeEntry(); + + if (timing === "before inline reveal") { + mounted.rerender(h.tree("c", [first, second], false, true, false)); + h.completeReadStateSnapshot(); + expect( + screen.queryByRole("region", { name: "Channel message history" }), + ).toBeNull(); + } + + h.unreadIds.delete(first.id); + h.notifyAttention(); + h.publish(); + expect(h.unread.revision()).toBe(revision); + expect(h.unread.attention("c", first.id).unread).toBe(false); + if (timing === "before snapshot" || timing === "before first mount") + h.completeReadStateSnapshot(); + if (timing === "entry") await h.completeEntry(); + mounted.rerender(h.tree("c", [first, second])); + + expect( + screen.queryByRole("separator", { name: "New messages" }), + ).toBeNull(); + expect(h.unread.attention("c", second.id).unread).toBe(true); + expect(h.enterChannel).toHaveBeenCalledTimes(1); + mounted.unmount(); + }, +); + +it.each( + ( + [ + ["unknown", "entry"], + ["pending", "entry"], + ["unknown", "snapshot"], + ["pending", "snapshot"], + ["unknown", "delayed mount"], + ["pending", "delayed mount"], + ] as const + ).flatMap(([attention, timing]) => + (["before", "after"] as const).map((position) => ({ + attention, + timing, + position, + })), + ), +)( + "keeps boundary evidence ordered when $attention attention at $timing resolves $position the first known unread", + async ({ attention, timing, position }) => { + const h = boundaryFixture(); + const unresolvedRow = h.row( + message( + h.peer, + "c", + "Unresolved at entry", + position === "before" ? 1 : 2, + ), + ); + const knownUnread = h.row( + message( + h.peer, + "c", + "Known unread at entry", + position === "before" ? 2 : 1, + ), + ); + const rows = + position === "before" + ? [unresolvedRow, knownUnread] + : [knownUnread, unresolvedRow]; + h.unreadIds.add(unresolvedRow.id); + h.unreadIds.add(knownUnread.id); + const unresolved = attention === "unknown" ? h.unknownIds : h.pendingIds; + unresolved.add(unresolvedRow.id); + if (timing !== "snapshot") h.completeReadStateSnapshot(); + const revision = h.unread.revision(); + const mounted = render( + h.tree("c", rows, false, true, timing !== "delayed mount"), + ); + expect(h.enterChannel).toHaveBeenCalledExactlyOnceWith("c"); + if (position === "before" || timing === "delayed mount") + await h.completeEntry(); + try { + if (timing === "snapshot") h.completeReadStateSnapshot(); + expect( + screen.queryByRole("separator", { name: "New messages" }), + ).toBeNull(); + + // A remote read arrives before the reply's root/classification lookup ends. + h.unreadIds.delete(unresolvedRow.id); + h.notifyAttention(); + unresolved.delete(unresolvedRow.id); + h.notifyAttention(); + } finally { + if (position === "after" && timing !== "delayed mount") + await h.completeEntry(); + } + mounted.rerender(h.tree("c", rows)); + h.publish(); + + expect(h.unread.revision()).toBe(revision); + expect(h.unread.attention("c", unresolvedRow.id).unread).toBe(false); + expect(h.unread.attention("c", knownUnread.id).unread).toBe(true); + if (position === "before") + expect( + screen.queryByRole("separator", { name: "New messages" }), + ).toBeNull(); + else { + const boundary = screen.getByRole("separator", { name: "New messages" }); + expect( + boundary.compareDocumentPosition( + screen.getByText("Known unread at entry"), + ) & Node.DOCUMENT_POSITION_FOLLOWING, + ).not.toBe(0); + } + expect(h.enterChannel).toHaveBeenCalledTimes(1); + mounted.unmount(); + }, +); + +it.each([false, true])( + "captures a new boundary only while active, initially active=%s", + async (initiallyActive) => { + const h = boundaryFixture(); + const first = h.row(message(h.peer, "c", "Earlier unread message", 1)); + const next = h.row(message(h.peer, "c", "Next visit unread message", 2)); + h.unreadIds.add(first.id); + const mounted = render(h.tree("c", [first, next], false, initiallyActive)); + if (!initiallyActive) { + expect(h.subscribers()).toEqual({ unread: 0, sync: 0 }); + mounted.rerender(h.tree("c", [first, next])); + } + expect(h.subscribers()).toEqual({ unread: 1, sync: 1 }); + await h.completeEntry(); + h.completeReadStateSnapshot(); + h.publish(); + expect(h.subscribers()).toEqual({ unread: 0, sync: 0 }); + expect( + screen + .getByText("New messages", { exact: true }) + .compareDocumentPosition( + screen.getByText("Earlier unread message", { exact: true }), + ) & Node.DOCUMENT_POSITION_FOLLOWING, + ).not.toBe(0); + + h.unreadIds.delete(first.id); + h.unreadIds.add(next.id); + mounted.rerender(h.tree("c", [first, next], false, false)); + expect(h.subscribers()).toEqual({ unread: 0, sync: 0 }); + expect(screen.queryByText("New messages", { exact: true })).toBeNull(); + + mounted.rerender(h.tree("c", [first, next])); + expect(h.subscribers()).toEqual({ unread: 1, sync: 1 }); + await h.completeEntry(); + h.publish(); + expect(h.subscribers()).toEqual({ unread: 0, sync: 0 }); + const separator = screen.getByText("New messages", { exact: true }); + expect( + separator.compareDocumentPosition( + screen.getByText("Next visit unread message", { exact: true }), + ) & Node.DOCUMENT_POSITION_FOLLOWING, + ).not.toBe(0); + expect(h.enterChannel).toHaveBeenCalledTimes(2); + mounted.unmount(); + expect(h.subscribers()).toEqual({ unread: 0, sync: 0 }); + }, +); + +it("does not move a completed no-boundary visit when unrelated unread arrives", async () => { + const h = boundaryFixture(); + const read = h.row(message(h.peer, "c", "Already read", 1)); + const joined = h.row(message(h.peer, "c", "Membership activity", 2), "c", { + membership: { + type: "member_joined", + actor: h.peer.pubkey, + target: h.peer.pubkey, + }, + }); + const mounted = render(h.tree("c", [read, joined])); + await h.completeEntry(); + h.completeReadStateSnapshot(); + h.publish(); + expect(screen.queryByText("New messages", { exact: true })).toBeNull(); + + const live = h.row(message(h.peer, "c", "Arrived during visit", 3)); + h.unreadIds.add(live.id); + mounted.rerender(h.tree("c", [read, joined, live])); + h.publish(); + expect(screen.queryByText("New messages", { exact: true })).toBeNull(); + mounted.unmount(); +}); + +it("finalizes an excluded unread row before a live eligible message arrives", async () => { + const h = boundaryFixture(); + const agent = h.row(message(h.peer, "c", "Agent coordination", 1), "c", { + audience: "agents", + }); + h.unreadIds.add(agent.id); + const mounted = render(h.tree("c", [agent])); + await h.completeEntry(); + h.completeReadStateSnapshot(); + h.publish(); + expect(screen.queryByText("New messages", { exact: true })).toBeNull(); + + const live = h.row(message(h.peer, "c", "Arrived during visit", 2)); + h.unreadIds.add(live.id); + mounted.rerender(h.tree("c", [agent, live])); + h.publish(); + expect(screen.queryByText("New messages", { exact: true })).toBeNull(); + mounted.unmount(); +}); + +it("does not let an earlier entry completion ready a replacement visit", async () => { + const h = boundaryFixture(); + const unread = h.row(message(h.peer, "c", "Unread after re-entry", 1)); + h.unreadIds.add(unread.id); + const mounted = render(h.tree("c", [unread])); + await h.completeReadStateSnapshot(); + h.publish(); + expect(screen.queryByText("New messages", { exact: true })).toBeNull(); + + mounted.rerender(h.tree("c", [unread], false, false)); + mounted.rerender(h.tree("c", [unread])); + expect(h.enterChannel).toHaveBeenCalledTimes(2); + await h.completeEntry(); + expect(screen.queryByText("New messages", { exact: true })).toBeNull(); + + await h.completeEntry(); + expect(screen.getByText("New messages", { exact: true })).toBeTruthy(); + mounted.unmount(); +}); +it.each([false, true])( + "preserves a restored row when the boundary appears and disappears, initially settled=%s", + async (initiallySettled) => { + const h = boundaryFixture(); + const read = h.row(message(h.peer, "c", "Earlier read message", 1)); + const unread = h.row(message(h.peer, "c", "Restored unread message", 2)); + h.unreadIds.add(unread.id); + writeView("scope", "scroll:c", { + offset: 900, + bottom: false, + anchor: { id: unread.id, y: 100 }, + }); + const mounted = render(h.tree("c", [read, unread])); + const scroller = screen.getByRole("region", { + name: "Channel message history", + }); + // This models the hold/settled permutations; new-message-boundary.spec.mjs + // covers saved-offset geometry with the real virtualizer. + scroll.itemOffset.mockReturnValue(100); + scroll.toIndex.mockImplementation((_index, { offset = 0 }) => { + scroller.scrollTop = 100 + offset; + }); + vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockImplementation( + function (this: HTMLElement) { + if (this === scroller) return new DOMRect(0, 0, 800, 600); + if (this.tagName === "LI") + return new DOMRect(0, 100 - scroller.scrollTop, 800, 80); + if (this.dataset.messageId === unread.id) { + const boundary = document.querySelector( + "[data-new-messages-boundary]", + ); + return new DOMRect( + 0, + 100 + (boundary ? 32 : 0) - scroller.scrollTop, + 800, + 80, + ); + } + return new DOMRect(0, 0, 800, 80); + }, + ); + if (initiallySettled) await frame(); + + await h.completeEntry(); + h.completeReadStateSnapshot(); + h.publish(); + expect(screen.getByText("New messages", { exact: true })).toBeTruthy(); + await frame(); + expect(scroll.toIndex).toHaveBeenLastCalledWith(1, { + align: "start", + offset: -68, + }); + expect(scroller.scrollTop).toBe(32); + + mounted.rerender(h.tree("c", [read, unread], false, false)); + expect(screen.queryByText("New messages", { exact: true })).toBeNull(); + await frame(); + expect(scroll.toIndex).toHaveBeenLastCalledWith(1, { + align: "start", + offset: -100, + }); + expect(scroller.scrollTop).toBe(0); + mounted.unmount(); + }, +); it.each([ { offset: 0, gap: 1400 }, { offset: 400, gap: 1000 }, diff --git a/src/features/messages/ChannelTimeline.tsx b/src/features/messages/ChannelTimeline.tsx index 271296b511..dcc02efecf 100644 --- a/src/features/messages/ChannelTimeline.tsx +++ b/src/features/messages/ChannelTimeline.tsx @@ -22,12 +22,14 @@ import { import { DayDivider } from "./MessageTimestamp"; import type { ConversationExtensions } from "../conversation/contracts"; import type { RelaySession } from "../relay/session"; +import type { MessageAttention } from "../relay/unread"; import { useCallback, useLayoutEffect, useMemo, useRef, useState, + useSyncExternalStore, Fragment, type KeyboardEvent, } from "react"; @@ -55,6 +57,7 @@ import { useKnownAgentPubkeys } from "../agents/use-known"; import { JumpToLatestButton } from "./JumpToLatestButton"; import { moveMessageSelection } from "./message-selection"; import { useMessageSelection } from "./use-message-selection"; +import { useMessageVisit } from "./MessageManagement"; // Native upward scrolling can start on a focused message link or button. // Inner scrollports own the key while they can move. At their top boundary, @@ -98,6 +101,30 @@ type ReadingPosition = { bottom: boolean; anchor?: { id: string; y: number }; }; +type NewMessageBoundary = { + token: object; + session: RelaySession; + scope: string; + channelId: string; + captured: boolean; + messageId?: string; +}; +type NewMessageBoundaryBaseline = { + token: object; + session: RelaySession; + scope: string; + channelId: string; + revision?: number; + messageIds: ReadonlySet; + attention: Map | null>; +}; +// Inline message seeks can unmount the timeline without ending its channel visit. +// Weak keys retain entry evidence only for the existing MessageManagement token. +const visitBoundaries = new WeakMap< + object, + { boundary: NewMessageBoundary; baseline: NewMessageBoundaryBaseline } +>(); +const unknownUnreadSync = Object.freeze({ completeness: "unknown" as const }); function positionAt( element: HTMLElement, restoredAnchor?: string, @@ -266,6 +293,238 @@ function Timeline({ () => membershipRows(visibleRows), [visibleRows], ); + const messageVisit = useMessageVisit(queries, channelId); + const ownsMessageVisit = messageVisit !== undefined && messageVisit !== null; + const [timelineVisit] = useState(() => ({})); + const visitToken = messageVisit?.token ?? timelineVisit; + const retainedBoundary = visitBoundaries.get(visitToken); + const retainedVisit = + retainedBoundary?.baseline.session === queries && + retainedBoundary.baseline.scope === scope && + retainedBoundary.baseline.channelId === channelId + ? retainedBoundary + : undefined; + const [newMessageBoundary, setNewMessageBoundary] = + useState( + () => + retainedVisit?.boundary ?? { + token: visitToken, + session: queries, + scope, + channelId, + captured: false, + }, + ); + const boundaryMatchesVisit = + newMessageBoundary.token === visitToken && + newMessageBoundary.session === queries && + newMessageBoundary.scope === scope && + newMessageBoundary.channelId === channelId; + const boundaryPending = !boundaryMatchesVisit || !newMessageBoundary.captured; + const unreadTarget = useMemo( + () => ({ kind: "channel" as const, channelId }), + [channelId], + ); + const subscribeUnread = useCallback( + (listener: () => void) => + messageVisit?.active === true && + messageVisit?.failed === false && + boundaryPending + ? queries.unread.subscribe(unreadTarget, listener) + : () => {}, + [ + boundaryPending, + messageVisit?.active, + messageVisit?.failed, + queries.unread, + unreadTarget, + ], + ); + const getUnreadSnapshot = useCallback( + () => + ownsMessageVisit ? queries.unread.snapshot(unreadTarget) : undefined, + [ownsMessageVisit, queries.unread, unreadTarget], + ); + const unreadSnapshot = useSyncExternalStore( + subscribeUnread, + getUnreadSnapshot, + getUnreadSnapshot, + ); + const [, setUnreadEvidenceRevision] = useState(0); + const subscribeUnreadSync = useCallback( + (listener: () => void) => + messageVisit?.active === true && + messageVisit?.failed === false && + boundaryPending + ? queries.unread.subscribeSync(() => { + listener(); + setUnreadEvidenceRevision((revision) => revision + 1); + }) + : () => {}, + [ + boundaryPending, + messageVisit?.active, + messageVisit?.failed, + queries.unread, + ], + ); + const getUnreadSync = useCallback( + () => (ownsMessageVisit ? queries.unread.sync() : unknownUnreadSync), + [ownsMessageVisit, queries.unread], + ); + const unreadSync = useSyncExternalStore( + subscribeUnreadSync, + getUnreadSync, + getUnreadSync, + ); + const boundaryBaseline = useRef( + retainedVisit?.baseline, + ); + if ( + !boundaryBaseline.current || + boundaryBaseline.current.token !== visitToken || + boundaryBaseline.current.session !== queries || + boundaryBaseline.current.scope !== scope || + boundaryBaseline.current.channelId !== channelId + ) + boundaryBaseline.current = retainedVisit?.baseline ?? { + token: visitToken, + session: queries, + scope, + channelId, + ...(ownsMessageVisit && messageVisit?.active + ? { revision: messageVisit.entryReadRevision } + : {}), + messageIds: messageVisit?.entryMessageIds ?? new Set(), + attention: new Map(messageVisit?.entryAttention), + }; + const baseline = boundaryBaseline.current; + const entryAttention = + boundaryPending && + ownsMessageVisit && + messageVisit?.active === true && + unreadSync.completeness === "snapshot" + ? chronologicalRows + .filter( + (row) => + !row.membership && + !row.huddle && + row.audience !== "agents" && + row.authorId !== queries.viewer, + ) + .map((row) => ({ + row, + attention: queries.unread.attention(channelId, row.id), + })) + : []; + // An unread count without a loaded, classifiable row is not an exact boundary. + const noUnreadEvidence = + unreadSnapshot?.observedCount === 0 && unreadSnapshot?.attentionCount === 0; + const boundaryCandidate = (() => { + if (!boundaryPending || !ownsMessageVisit || messageVisit?.active !== true) + return { ready: false }; + // Later paging cannot recover entry-time read evidence for unloaded rows. + if (!messageVisit.entryHistoryComplete) return { ready: true }; + if ( + baseline.revision !== undefined && + queries.unread.revision() !== baseline.revision + ) + return { ready: true }; + if ( + window.status !== "ready" || + window.freshness !== "verified" || + window.refreshing || + window.loadingOlder || + window.hasMore || + window.historyLimited || + unreadSync.completeness !== "snapshot" + ) + return { ready: false }; + if ( + unreadSnapshot?.coverage !== "observed" || + unreadSnapshot?.freshness !== "observed" + ) + return { ready: false }; + if (!chronologicalRows.length) return { ready: noUnreadEvidence }; + for (const { row, attention } of entryAttention) { + // Backdated live arrivals must neither define nor erase the entry boundary. + if (!baseline.messageIds.has(row.id)) continue; + if (attention.status === "unknown" || attention.pending) + return { ready: false }; + // Remote merges do not advance the local revision. Only this prefix can + // determine entry's boundary; later rows cannot invalidate it. + // Null preserves unresolved entry evidence instead of adopting its later state. + const previous = baseline.attention.get(row.id); + if ( + previous !== undefined && + (previous === null || + previous.unread !== attention.unread || + previous.forced !== attention.forced) + ) + return { ready: true }; + // Ineligible means no attention category, not that an incoming row is read. + if (attention.forced || attention.unread) + return { ready: true, messageId: row.id }; + } + return { ready: true }; + })(); + if (!boundaryMatchesVisit) + setNewMessageBoundary( + retainedVisit?.boundary ?? { + token: visitToken, + session: queries, + scope, + channelId, + captured: false, + }, + ); + else if ( + !newMessageBoundary.captured && + boundaryCandidate.ready && + messageVisit?.active === true && + messageVisit?.ready === true && + messageVisit?.failed === false + ) + setNewMessageBoundary({ + token: visitToken, + session: queries, + scope, + channelId, + captured: true, + ...(boundaryCandidate.messageId + ? { messageId: boundaryCandidate.messageId } + : {}), + }); + useLayoutEffect(() => { + if (!ownsMessageVisit || !boundaryMatchesVisit || !messageVisit?.active) + return; + for (const { row, attention } of entryAttention) { + if (baseline.messageIds.has(row.id) && !baseline.attention.has(row.id)) + baseline.attention.set( + row.id, + attention.status === "unknown" || attention.pending + ? null + : { unread: attention.unread, forced: attention.forced }, + ); + } + visitBoundaries.set(visitToken, { boundary: newMessageBoundary, baseline }); + }, [ + ownsMessageVisit, + boundaryMatchesVisit, + messageVisit?.active, + entryAttention, + baseline, + visitToken, + newMessageBoundary, + ]); + const newMessageBoundaryId = + boundaryMatchesVisit && + newMessageBoundary.captured && + messageVisit?.active === true && + messageVisit?.ready === true && + messageVisit?.failed === false + ? newMessageBoundary.messageId + : undefined; const [expandedActivity, setExpandedActivity] = useState>( () => new Set(), ); @@ -416,6 +675,7 @@ function Timeline({ [visibleRows, profiles, resolveName], ); const signature = `${contentSignature}:${JSON.stringify([...foldedActivity].sort())}`; + // Mount the saved boundary before restoration measures its divider inset. const keptIndices = rows.flatMap((row, index) => row.id === focusedMessageId || row.id === focusedActivityId || @@ -424,7 +684,10 @@ function Timeline({ pinnedIds.has(row.id) || disclosures .get(row.id) - ?.ids.some((id) => pinnedIds.has(id) || id === focusedMessageId) + ?.ids.some((id) => pinnedIds.has(id) || id === focusedMessageId) || + (row.id === newMessageBoundaryId && + !savedPosition.current?.bottom && + row.id === savedPosition.current?.anchor?.id) ? [index] : [], ); @@ -486,6 +749,7 @@ function Timeline({ const heldReader = useRef< { id: string; y: number; intent: number } | undefined >(undefined); + const previousBoundaryId = useRef(undefined); const upwardGesture = useRef(false); const gestureFrame = useRef(undefined); const touchY = useRef(undefined); @@ -741,6 +1005,8 @@ function Timeline({ // reader's latest row through those measurements, until reader input that // follows them ends. useLayoutEffect(() => { + const boundaryChanged = previousBoundaryId.current !== newMessageBoundaryId; + previousBoundaryId.current = newMessageBoundaryId; const element = scroller.current; const list = element?.querySelector("ol"); const index = rows.findIndex( @@ -764,6 +1030,7 @@ function Timeline({ if ( !id || (!moved && + !boundaryChanged && rows.length <= edges.current.order.length && !( heldReader.current?.id === id && @@ -773,7 +1040,20 @@ function Timeline({ return; // New rows can leave the initial offset unchanged. Hold through their // measurements, but leave ordinary prepend follow-up renders to Virtua. - correctScrollTop(element, moved); + const row = element.querySelector( + `[data-message-id="${CSS.escape(id)}"]`, + ); + // A divider changes the anchor's inset inside its virtual item before + // Virtua commits the new item height. Hold the same message through both. + const delta = + boundaryChanged && row + ? row.getBoundingClientRect().top - + element.getBoundingClientRect().top - + reader.current.y + : moved; + // WebKit truncates fractional scrollTop writes; use the nearest pixel + // when a caption's fractional line height changes the message inset. + correctScrollTop(element, boundaryChanged ? Math.round(delta) : delta); // The row's offset from the viewport, as last placed by the reader. // A live append must not choose a newly inserted row while the older // page is still measuring; keep the reader's original row and offset. @@ -872,7 +1152,7 @@ function Timeline({ // Follow the same item's delta without mixing its inner DOM offset in. if (row === undefined && next !== undefined && logical !== undefined) delta = next - logical; - correctScrollTop(element, delta); + correctScrollTop(element, boundaryChanged ? Math.round(delta) : delta); lastScrollTop = element.scrollTop; } logical = logicalTop(); @@ -909,7 +1189,7 @@ function Timeline({ element.ownerDocument.addEventListener("pointerup", done); element.ownerDocument.addEventListener("pointercancel", done); return () => release(true); - }, [rows, prepend, containedOwners]); + }, [rows, prepend, containedOwners, newMessageBoundaryId]); useLayoutEffect(() => { const element = scroller.current; if (!element) return; @@ -1094,13 +1374,14 @@ function Timeline({ y: anchor.y, }; const element = scroller.current; - const target = containedOwners.has(anchor.id) - ? element?.querySelector( - `[data-message-id="${CSS.escape(anchor.id)}"]`, - ) - : undefined; - // A contained step shares its virtual row with earlier history. - // Once mounted, include that inner offset in the saved placement. + const target = + containedOwners.has(anchor.id) || row.id === newMessageBoundaryId + ? element?.querySelector( + `[data-message-id="${CSS.escape(restoredAnchor.current.id)}"]`, + ) + : undefined; + // Contained history and an unread divider precede the saved message + // inside its virtual row. Include that inner offset only once. const innerOffset = target && element ? target.getBoundingClientRect().top - @@ -1215,6 +1496,7 @@ function Timeline({ updateJumpToLatest, revealMessageId, containedOwners, + newMessageBoundaryId, ]); // After positioning reads it: a load that ends without rows lands no page. useLayoutEffect(() => { @@ -1571,6 +1853,20 @@ function Timeline({ /> )} + {row.id === newMessageBoundaryId && ( +
+
+ +
+ )} (undefined); +type MessageVisit = Readonly<{ + session: RelaySession; + channelId: string | undefined; + token: object; + entryMessageIds: ReadonlySet; + entryHistoryComplete: boolean; + entryReadRevision: number; + entryAttention: ReadonlyMap< + string, + Pick | null + >; + active: boolean; + ready: boolean; + failed: boolean; +}>; +const MessageVisitContext = createContext(undefined); + +/** The channel visit that owns automatic reading and its entry reconciliation. */ +export function useMessageVisit( + session: RelaySession, + channelId: string, +): MessageVisit | null | undefined { + const visit = useContext(MessageVisitContext); + if (!visit) return undefined; + return visit.session === session && visit.channelId === channelId + ? visit + : null; +} + export function useMessageDeletion() { const management = useContext(Management); const editor = useMessageEditScope(); @@ -223,14 +253,52 @@ export function MessageManagement({ ? channel.id : undefined, ); - const [notice, setNotice] = useState<{ - visit: { - session: RelaySession; - channelId: string | undefined; - visitChannelId: string | undefined; + // The timeline may mount only after loading or an inline seek completes. + // Entry membership and provisional read evidence belong to this visit, not + // to that later render or the first complete read snapshot. + const captureVisit = () => { + const window = channelId ? session.channels.window(channelId) : undefined; + const rows = window?.rows ?? []; + const entryAttention = new Map< + string, + Pick | null + >(); + if (channelId) + for (const row of rows) { + const attention = session.unread.attention(channelId, row.id); + entryAttention.set( + row.id, + attention.status === "unknown" || attention.pending + ? null + : { unread: attention.unread, forced: attention.forced }, + ); + } + return { + session, + channelId, + visitChannelId, + entryMessageIds: new Set(rows.map((row) => row.id)), + entryHistoryComplete: + window?.status === "ready" && + window.freshness === "verified" && + !window.refreshing && + !window.loadingOlder && + !window.hasMore && + !window.historyLimited, + entryReadRevision: session.unread.revision(), + entryAttention, }; + }; + const [notice, setNotice] = useState<{ + visit: ReturnType; error?: string | undefined; - }>(() => ({ visit: { session, channelId, visitChannelId } })); + entryReady: boolean; + entryFailed: boolean; + }>(() => ({ + visit: captureVisit(), + entryReady: false, + entryFailed: false, + })); const { visit } = notice; const currentVisit = visit.session === session && @@ -238,8 +306,10 @@ export function MessageManagement({ visit.visitChannelId === visitChannelId; if (!currentVisit) setNotice({ - visit: { session, channelId, visitChannelId }, + visit: captureVisit(), error: undefined, + entryReady: false, + entryFailed: false, }); // Captured reporters cannot overwrite errors from a later visit, even to // the same channel. Reset during render so stale alerts never reach children. @@ -264,6 +334,21 @@ export function MessageManagement({ }), [session, channelId, operations, visit], ); + const messageVisit = useMemo( + () => ({ + session: visit.session, + channelId: visit.channelId, + token: visit, + entryMessageIds: visit.entryMessageIds, + entryHistoryComplete: visit.entryHistoryComplete, + entryReadRevision: visit.entryReadRevision, + entryAttention: visit.entryAttention, + active: !!visit.visitChannelId, + ready: notice.visit === visit && notice.entryReady, + failed: notice.visit === visit && notice.entryFailed, + }), + [visit, notice.visit, notice.entryReady, notice.entryFailed], + ); useEffect(() => { const { session, visitChannelId } = visit; let active = true; @@ -271,20 +356,38 @@ export function MessageManagement({ ? enterVisit(session, visitChannelId) : undefined; if (presence) - void presence.ready.catch((cause) => { - if (active) - setNotice((current) => - current.visit === visit - ? { - ...current, - error: - cause instanceof Error - ? cause.message - : "Could not restore unread state.", - } - : current, - ); - }); + void presence.ready.then( + () => { + if (active) + setNotice((current) => + current.visit === visit + ? { ...current, entryReady: true } + : current, + ); + }, + (cause) => { + if (active) + setNotice((current) => + current.visit === visit + ? { + ...current, + entryReady: true, + entryFailed: true, + error: + cause instanceof Error + ? cause.message + : "Could not restore unread state.", + } + : current, + ); + }, + ); + else + setNotice((current) => + current.visit === visit + ? { ...current, entryReady: true, entryFailed: false } + : current, + ); return () => { active = false; presence?.leave(); @@ -292,7 +395,9 @@ export function MessageManagement({ }, [visit]); return ( - {children} + + {children} + {currentVisit && notice.error &&

{notice.error}

} {presented && selection && available && ( snapshot, subscribeList: () => () => {} }, + channels: { + list: () => snapshot, + subscribeList: () => () => {}, + window: () => ({ rows: [row] }), + }, messages: { report: vi.fn(async () => {}) }, profiles: { subscribe: () => () => {}, event: () => undefined }, outbox: { @@ -1376,6 +1380,8 @@ it.each(["peer", "own"] as const)( snapshot: () => operations, }, unread: { + revision: () => 0, + sync: () => ({ capability: "unsupported" }), subscribe: () => () => {}, subscribeSync: () => () => {}, snapshot: () => undefined, diff --git a/src/features/messages/Messages.module.css b/src/features/messages/Messages.module.css index d19a04f92b..53ae9cde85 100644 --- a/src/features/messages/Messages.module.css +++ b/src/features/messages/Messages.module.css @@ -3281,6 +3281,23 @@ html[data-keyboard-navigation] .rawDiff:focus-visible { margin-inline: auto; padding-block: 0; } +.newMessages { + width: min(100%, var(--timeline-measure)); + display: flex; + align-items: center; + gap: var(--space-3); + margin: var(--space-2) auto; + color: var(--text-subtle); + @apply text-caption; +} +.newMessages::before, +.newMessages::after { + content: ""; + height: 1px; + min-width: 0; + flex: 1; + background: var(--border-standard); +} .activityExpansionRoot { display: contents; } diff --git a/tests/browser/fixture.mjs b/tests/browser/fixture.mjs index 414a0670d1..2a6fcf1e4b 100644 --- a/tests/browser/fixture.mjs +++ b/tests/browser/fixture.mjs @@ -2322,6 +2322,10 @@ export const test = base.extend({ : 3, ); const filter = body[0]; + if (body.length === 1 && filter.read_state_snapshot === 1) { + report.queries.push({ community, filter }); + return send(response, answer(community, filter)); + } const result = [ ...new Map( body @@ -2871,6 +2875,7 @@ export const test = base.extend({ root, parent, attachmentTags = [], + createdAt, ) { const history = histories.get(`${community}/${channel}`); const event = sign( @@ -2889,7 +2894,7 @@ export const test = base.extend({ ], content ?? `Live append ${history.length}`, own ? userKey : peerKey, - (history.at(-1)?.created_at ?? 1700000900) + 1, + createdAt ?? (history.at(-1)?.created_at ?? 1700000900) + 1, ); history.push(event); if (relay && deliver) relay.publish(community, event); diff --git a/tests/browser/history-loading.spec.mjs b/tests/browser/history-loading.spec.mjs index 10ced139b2..c9a776e59f 100644 --- a/tests/browser/history-loading.spec.mjs +++ b/tests/browser/history-loading.spec.mjs @@ -103,7 +103,17 @@ test("saved top resumes automatic history on an upward gesture after remount", a }), ).toHaveCount(0); await history(page).hover(); - await page.mouse.wheel(0, -100000); + // Establish the saved top with real input; remeasurement can consume part + // of a gesture. Each settled gesture must make progress toward the top. + while (true) { + const before = await history(page).evaluate((e) => e.scrollTop); + if (before <= 0) break; + await wheel(page, -before); + expect( + await history(page).evaluate((e) => e.scrollTop), + "saved-top setup retains upward progress", + ).toBeLessThan(before); + } await expect.poll(() => app.pending.length).toBe(1); await settle(page); await expect.poll(() => history(page).evaluate((e) => e.scrollTop)).toBe(0); diff --git a/tests/browser/new-message-boundary.spec.mjs b/tests/browser/new-message-boundary.spec.mjs new file mode 100644 index 0000000000..5373fa5462 --- /dev/null +++ b/tests/browser/new-message-boundary.spec.mjs @@ -0,0 +1,583 @@ +import { test as appTest } from "./fixture.mjs"; +import { test as sourceTest } from "./source-fixture.mjs"; +import { expect } from "./playwright-test.mjs"; +import { openChannelDetails } from "./channel-details.mjs"; +import { + anchor, + expectAnchor, + keyScroll, + open, + settle, + upper, + wheel, +} from "./timeline.mjs"; +import { + holdReadingFocus, + readJournal, + releaseReadingFocus, +} from "./reading.mjs"; + +appTest.use({ + productionBroker: true, + readState: true, + pluginFixtures: true, + historyCounts: { alpha: 20, beta: 1 }, +}); + +const history = (page) => + page.getByRole("region", { name: "Channel message history" }); +const separator = (page) => + history(page).locator("[data-new-messages-boundary]"); +const composer = (page) => + page.getByRole("textbox", { name: "Message #Alpha", exact: true }); +const messageAfter = (element) => + element.evaluate((separator) => { + let sibling = separator.nextElementSibling; + while (sibling) { + const row = sibling.matches("[data-message-id]") + ? sibling + : sibling.querySelector("[data-message-id]"); + if (row) return row.dataset.messageId; + sibling = sibling.nextElementSibling; + } + return null; + }); + +// Only a real virtualizer with a warm measured cache can omit the saved row +// without changing list height when restoration mounts it. +sourceTest( + "restores the boundary message at its saved offset with warm virtualized geometry", + async ({ page }) => { + await page.goto("/tests/fixtures/boundary-restoration.html"); + const targetId = await page.evaluate( + () => window.boundaryRestoration.boundaryId, + ); + const target = history(page).locator(`[data-message-id="${targetId}"]`); + await expect(separator(page)).toBeVisible(); + await settle(page); + await expect(target).toBeInViewport(); + const before = await anchor(page, targetId); + const height = await history(page) + .locator("ol") + .evaluate((list) => list.style.height); + expect( + await history(page).locator("[data-message-id]").count(), + ).toBeLessThan(180); + + await page + .getByRole("button", { name: "Hide timeline", exact: true }) + .click(); + await expect(history(page)).toHaveCount(0); + const saved = await page.evaluate(() => window.boundaryRestoration.saved()); + expect(saved.anchor.id).toBe(targetId); + expect(saved.bottom).toBe(false); + await page + .getByRole("button", { name: "Restore timeline", exact: true }) + .click(); + await expect(separator(page)).toBeVisible(); + await settle(page); + await expect(history(page).locator("ol")).toHaveCSS("height", height); + await expectAnchor(page, before); + }, +); + +async function reachEdge(page, direction) { + const scroller = history(page); + const distance = () => + scroller.evaluate( + (element, direction) => + direction < 0 + ? element.scrollTop + : element.scrollHeight - element.scrollTop - element.clientHeight, + direction, + ); + await scroller.hover(); + for (let gesture = 0; gesture < 8; gesture++) { + const before = await distance(); + if (before < 4) break; + await wheel(page, direction * before, scroller); + await expect + .poll(distance, { message: "wheel progresses toward timeline edge" }) + .toBeLessThan(before); + } + expect(await distance(), "timeline reaches the requested edge").toBeLessThan( + 4, + ); +} + +async function scrollBoundaryIntoView(page) { + const scroller = history(page); + await scroller.hover(); + const offset = await scroller.evaluate((element) => { + const boundary = element.querySelector("[data-new-messages-boundary]"); + return ( + boundary.getBoundingClientRect().top - element.getBoundingClientRect().top + ); + }); + const delta = offset - 32; + if (Math.abs(delta) > 4) await wheel(page, delta, scroller); + await expect(separator(page)).toBeInViewport(); + await settle(page); +} + +async function unavailableSnapshot(page, app) { + let unavailable = true; + await page.route("**/query", async (route) => { + if ( + unavailable && + route + .request() + .postDataJSON() + .some((filter) => filter.read_state_snapshot === 1) + ) { + app.report.relayFailures ??= []; + app.report.relayFailures.push(route.request().url()); + return route.fulfill({ + status: 403, + contentType: "application/json", + body: JSON.stringify({ error: "Synthetic snapshot unavailable" }), + }); + } + await route.continue(); + }); + return () => { + unavailable = false; + }; +} + +async function snapshotRetry(page) { + await openChannelDetails(page); + const settings = page.getByRole("complementary", { + name: "Channel settings", + exact: true, + }); + await expect(settings).toHaveAttribute("aria-busy", "false"); + await page.getByText("Diagnostics", { exact: true }).click(); + await page.getByText("Unread status", { exact: true }).click(); + const retry = page.getByRole("button", { + name: "Retry read sync", + exact: true, + }); + await expect(retry).toBeVisible(); + return retry; +} + +appTest( + "keeps the first unread boundary stable through reading and live appends", + async ({ page, app }, testInfo) => { + await holdReadingFocus(page); + const releaseSnapshot = await unavailableSnapshot(page, app); + await open(page, app); + await expect + .poll(() => + page.evaluate(() => { + const { status, completeness } = window.fixtureRelay + .snapshot() + .session.unread.sync(); + return { status, completeness }; + }), + ) + .toEqual({ status: "error", completeness: "unknown" }); + await expect + .poll(() => + page.evaluate(() => { + const history = window.fixtureRelay + .snapshot() + .session.channels.window("alpha"); + return { + status: history.status, + freshness: history.freshness, + refreshing: history.refreshing === true, + hasMore: history.hasMore, + }; + }), + ) + .toEqual({ + status: "ready", + freshness: "verified", + refreshing: false, + hasMore: false, + }); + await expect(separator(page)).toHaveCount(0); + + // Cold entry has no proven membership. Re-enter loaded history while + // read sync stays unavailable, so recovery can exercise divider insertion. + await page.getByRole("button", { name: /^Beta/ }).click(); + await expect( + page.getByRole("textbox", { name: "Message #Beta", exact: true }), + ).toBeVisible(); + await page.getByRole("button", { name: /^Alpha/ }).click(); + await expect(composer(page)).toBeVisible(); + await settle(page); + + // Keep the recovery control open before measuring. Holding a request + // through wheel input would race the real read-state network deadline. + const retrySnapshot = await snapshotRetry(page); + + await reachEdge(page, -1); + await settle(page); + await page.mouse.move(0, 0); + const beforePath = testInfo.outputPath("boundary-before.png"); + await history(page).screenshot({ path: beforePath }); + await testInfo.attach("boundary-before.png", { + path: beforePath, + contentType: "image/png", + }); + const boundaryId = app.histories.get("primary/alpha")[0].id; + await history(page) + .locator(`[data-message-id="${boundaryId}"] p`) + .evaluate((paragraph) => { + paragraph.style.minHeight = "640px"; + }); + await settle(page); + await history(page).hover(); + await wheel(page, 240); + await settle(page); + expect( + await history(page).evaluate((element) => element.scrollTop), + "reader is partway through the tall first row", + ).toBeGreaterThan(0); + const beforeBoundaryAnchor = await anchor(page); + const beforeBoundaryBubble = await history(page) + .locator(`[data-message-id="${boundaryId}"] [data-bubble]`) + .boundingBox(); + expect(beforeBoundaryBubble).not.toBeNull(); + await expect(separator(page)).toHaveCount(0); + await page.mouse.move(0, 0); + + releaseSnapshot(); + await retrySnapshot.click(); + await expect + .poll(() => + page.evaluate( + () => + window.fixtureRelay.snapshot().session.unread.sync().completeness, + ), + ) + .toBe("snapshot"); + await expect(separator(page)).toHaveCount(1); + await expectAnchor(page, beforeBoundaryAnchor); + const copied = await history(page).evaluate((element) => { + const selection = document.getSelection(); + const range = document.createRange(); + range.selectNodeContents(element); + selection.removeAllRanges(); + selection.addRange(range); + try { + const clipboardData = new DataTransfer(); + element.dispatchEvent( + new ClipboardEvent("copy", { + bubbles: true, + cancelable: true, + clipboardData, + }), + ); + return { + html: clipboardData.getData("text/html"), + text: clipboardData.getData("text/plain"), + }; + } finally { + selection.removeAllRanges(); + } + }); + for (const value of Object.values(copied)) { + expect(value).toContain( + app.histories.get("primary/alpha")[0].content.split("\n")[0], + ); + expect(value).not.toContain("New messages"); + } + expect(copied.html).not.toContain("
"); + const boundaryRow = history(page).locator( + `[data-message-id="${boundaryId}"] [data-layout="timeline"]`, + ); + await expect(boundaryRow).toHaveCount(1); + await expect( + boundaryRow.locator("xpath=..").locator("[data-day]"), + ).toHaveCount(1); + await expect(boundaryRow).toHaveAttribute("data-stack-next", "true"); + const boundaryPath = testInfo.outputPath("t001-boundary.png"); + await history(page).screenshot({ path: boundaryPath }); + await testInfo.attach("t001-boundary.png", { + path: boundaryPath, + contentType: "image/png", + }); + const entryMessageBounds = await history(page) + .locator(`[data-message-id="${boundaryId}"] [data-bubble]`) + .boundingBox(); + expect(entryMessageBounds).not.toBeNull(); + expect(entryMessageBounds.y).toBeCloseTo(beforeBoundaryBubble.y, 0); + expect(await messageAfter(separator(page))).toBe(boundaryId); + + // The tall row proves clipped-reader anchoring, not whole-row reading. + // Restore the normal fixture geometry before earning visible-row dwell. + await page + .getByRole("button", { name: "Close Channel settings tab", exact: true }) + .click(); + await history(page) + .locator(`[data-message-id="${boundaryId}"] p`) + .evaluate((paragraph) => paragraph.style.removeProperty("min-height")); + await settle(page); + await reachEdge(page, -1); + + await releaseReadingFocus(page); + await composer(page).focus(); + await expect(composer(page)).toBeFocused(); + await expect + .poll(() => + page.evaluate((id) => { + const { unread } = window.fixtureRelay.snapshot().session; + return unread.attention("alpha", id).viewing; + }, boundaryId), + ) + .toBe(true); + await expect + .poll(async () => + Object.hasOwn( + (await readJournal(page)).state.frontiers, + `msg:${boundaryId}`, + ), + ) + .toBe(true); + await expect(separator(page)).toHaveCount(1); + expect(await messageAfter(separator(page))).toBe(boundaryId); + + await reachEdge(page, -1); + await page.mouse.move(10, 10); + await expect( + history(page).getByRole("group", { name: "Message actions" }).first(), + ).toHaveCSS("opacity", "0"); + const afterPath = testInfo.outputPath("boundary-after.png"); + await history(page).screenshot({ path: afterPath }); + await testInfo.attach("boundary-after.png", { + path: afterPath, + contentType: "image/png", + }); + + const targetId = await messageAfter(separator(page)); + expect(targetId).toBe(boundaryId); + const alphaSidebar = page.getByRole("button", { name: /^Alpha/ }); + await alphaSidebar.focus(); + await expect(alphaSidebar).toBeFocused(); + await composer(page).focus(); + await expect(composer(page)).toBeFocused(); + await expect(separator(page)).toHaveCount(1); + expect(await messageAfter(separator(page))).toBe(boundaryId); + + await page.getByRole("button", { name: /^Beta/ }).click(); + await expect( + page.getByRole("textbox", { name: "Message #Beta", exact: true }), + ).toBeVisible(); + await page.getByRole("button", { name: /^Alpha/ }).click(); + await expect(composer(page)).toBeVisible(); + await expect(separator(page)).toBeVisible(); + const revisitBoundaryId = await page.evaluate( + (ids) => { + const { unread } = window.fixtureRelay.snapshot().session; + return ids.find((id) => { + const attention = unread.attention("alpha", id); + return ( + attention.status !== "unknown" && + !attention.pending && + (attention.forced || attention.unread) + ); + }); + }, + app.histories.get("primary/alpha").map((row) => row.id), + ); + expect(revisitBoundaryId).toBeTruthy(); + const revisitBoundary = history(page).locator( + `[data-message-id="${revisitBoundaryId}"] [data-layout="timeline"]`, + ); + await expect(revisitBoundary).toHaveAttribute( + "data-stack-previous", + "true", + ); + const actualBoundaryId = await messageAfter(separator(page)); + const actualBoundaryIndex = app.histories + .get("primary/alpha") + .findIndex((row) => row.id === actualBoundaryId); + expect(actualBoundaryId).toBe(revisitBoundaryId); + expect(actualBoundaryIndex).toBeGreaterThan(0); + await scrollBoundaryIntoView(page); + await page.mouse.move(0, 0); + await expect(separator(page)).toBeVisible(); + const labelBounds = await separator(page) + .getByText("New messages", { exact: true }) + .boundingBox(); + const wideHistoryBounds = await history(page).boundingBox(); + const messageBounds = await history(page) + .locator(`[data-message-id="${revisitBoundaryId}"] [data-bubble]`) + .boundingBox(); + expect(labelBounds).not.toBeNull(); + expect(wideHistoryBounds).not.toBeNull(); + expect(messageBounds).not.toBeNull(); + expect(labelBounds.y).toBeGreaterThanOrEqual(wideHistoryBounds.y); + expect(labelBounds.y + labelBounds.height).toBeLessThanOrEqual( + wideHistoryBounds.y + wideHistoryBounds.height, + ); + expect( + labelBounds.y + labelBounds.height, + "the boundary label clears the first unread message bubble", + ).toBeLessThanOrEqual(messageBounds.y); + const revisitPath = testInfo.outputPath("boundary-revisit-grouped.png"); + await history(page).screenshot({ path: revisitPath }); + await testInfo.attach("boundary-revisit-grouped.png", { + path: revisitPath, + contentType: "image/png", + }); + + await reachEdge(page, 1); + await upper(page); + const heldAnchor = await anchor(page); + const live = app.append( + "primary", + "alpha", + "Synthetic live follow-up", + true, + false, + ); + await expectAnchor(page, heldAnchor); + + await reachEdge(page, 1); + await expect( + history(page).locator(`[data-message-id="${live.id}"]`), + ).toBeVisible(); + await reachEdge(page, -1); + expect(await messageAfter(separator(page))).toBe(revisitBoundaryId); + + await page.evaluate(() => { + document.documentElement.dataset.colorMode = "dark"; + document.documentElement.dataset.fontSize = "larger"; + }); + await page.setViewportSize({ width: 390, height: 844 }); + await settle(page); + await scrollBoundaryIntoView(page); + await page.mouse.move(0, 0); + await expect(separator(page)).toBeVisible(); + await expect(separator(page)).toHaveText("New messages"); + const bounds = await separator(page).boundingBox(); + const viewport = await history(page).boundingBox(); + const narrowBubble = await history(page) + .locator(`[data-message-id="${revisitBoundaryId}"] [data-bubble]`) + .boundingBox(); + expect(bounds).not.toBeNull(); + expect(viewport).not.toBeNull(); + expect(narrowBubble).not.toBeNull(); + expect(bounds.x).toBeGreaterThanOrEqual(viewport.x); + expect(bounds.x + bounds.width).toBeLessThanOrEqual( + viewport.x + viewport.width, + ); + expect(bounds.y).toBeGreaterThanOrEqual(viewport.y); + expect(bounds.y + bounds.height).toBeLessThanOrEqual(narrowBubble.y); + expect(bounds.y + bounds.height).toBeLessThanOrEqual( + viewport.y + viewport.height, + ); + const narrowPath = testInfo.outputPath("boundary-narrow-dark.png"); + await history(page).screenshot({ path: narrowPath }); + await testInfo.attach("boundary-narrow-dark.png", { + path: narrowPath, + contentType: "image/png", + }); + + await page.evaluate(() => { + document.documentElement.dataset.colorMode = "light"; + delete document.documentElement.dataset.fontSize; + }); + await page.setViewportSize({ width: 1280, height: 850 }); + await settle(page); + // Reflow leaves offscreen rows estimated. Start at a known edge before + // comparing offsets so measuring those rows cannot masquerade as PageUp. + await reachEdge(page, -1); + await history(page).focus(); + await expect(history(page)).toBeFocused(); + const beforeKeyScroll = await history(page).evaluate((element) => + Math.round(element.scrollTop), + ); + await keyScroll(page, "PageDown"); + await expect + .poll(() => + history(page).evaluate((element) => Math.round(element.scrollTop)), + ) + .toBeGreaterThan(beforeKeyScroll); + await expect(separator(page)).toHaveCount(1); + }, +); + +appTest.describe("pending entry membership", () => { + appTest.use({ historyCounts: { alpha: 0, beta: 1 } }); + appTest( + "does not turn a backdated live arrival into an entry boundary", + async ({ page, app }) => { + let latest; + for (let index = 0; index < 3; index++) + latest = app.append( + "primary", + "alpha", + `Own entry message ${index}`, + false, + ); + await holdReadingFocus(page); + const releaseSnapshot = await unavailableSnapshot(page, app); + await open(page, app); + await expect + .poll(() => + page.evaluate(() => { + const { unread } = window.fixtureRelay.snapshot().session; + return { + completeness: unread.sync().completeness, + count: unread.snapshot({ kind: "channel", channelId: "alpha" }) + .observedCount, + }; + }), + ) + .toEqual({ completeness: "unknown", count: 0 }); + const retry = await snapshotRetry(page); + const late = app.append( + "primary", + "alpha", + "Backdated live message", + true, + false, + undefined, + undefined, + [], + latest.created_at - 10, + ); + await expect + .poll(() => + page.evaluate((id) => { + const { unread, channels } = window.fixtureRelay.snapshot().session; + return { + loaded: channels + .window("alpha") + .rows.some((row) => row.id === id), + unread: unread.attention("alpha", id).unread, + latest: unread.snapshot({ kind: "channel", channelId: "alpha" }) + .latestMessage?.id, + }; + }, late.id), + ) + .toEqual({ loaded: true, unread: true, latest: latest.id }); + releaseSnapshot(); + await retry.click(); + await expect + .poll(() => + page.evaluate( + () => + window.fixtureRelay.snapshot().session.unread.sync().completeness, + ), + ) + .toBe("snapshot"); + await settle(page); + await expect(separator(page)).toHaveCount(0); + expect( + await page.evaluate( + (id) => + window.fixtureRelay.snapshot().session.unread.attention("alpha", id) + .unread, + late.id, + ), + ).toBe(true); + }, + ); +}); diff --git a/tests/fixtures/boundary-restoration.html b/tests/fixtures/boundary-restoration.html new file mode 100644 index 0000000000..8e7743199b --- /dev/null +++ b/tests/fixtures/boundary-restoration.html @@ -0,0 +1,8 @@ + + + Boundary restoration regression + +
+ + + diff --git a/tests/fixtures/boundary-restoration.tsx b/tests/fixtures/boundary-restoration.tsx new file mode 100644 index 0000000000..876215d81a --- /dev/null +++ b/tests/fixtures/boundary-restoration.tsx @@ -0,0 +1,128 @@ +// Real timeline, visit ownership and Virtua; synthetic unread evidence isolates layout. +import { StrictMode, useState } from "react"; +import { createRoot } from "react-dom/client"; +import { ChannelTimeline } from "../../src/features/messages/ChannelTimeline"; +import { MessageManagement } from "../../src/features/messages/MessageManagement"; +import { createRelaySession } from "../../src/features/relay/session"; +import type { + ChannelMessage, + ChannelWindow, +} from "../../src/features/relay/contracts"; +import { keypair } from "../../src/features/relay/testing"; +import { readView, writeView } from "../../src/shared/view-state"; +import { Button } from "../../src/shared/design-system/ui/Button"; +import "../../src/shared/styles/globals.css"; + +const viewer = keypair().pubkey; +const peer = keypair().pubkey; +const owner = createRelaySession({ + viewer, + relayAuthor: keypair().pubkey, + media: () => undefined, + query: async () => [], +}); +const channelId = "boundary-restoration"; +const scope = "boundary-restoration-fixture"; +const boundaryId = "row-100"; +const rows: ChannelMessage[] = Array.from({ length: 180 }, (_, index) => ({ + id: `row-${index}`, + channelId, + authorId: peer, + createdAt: 1792152000 + index, + content: `Message ${index}. Keep this reading position through a timeline remount.`, + mentions: [], + attachments: [], + reactions: [], + replyCount: 0, + participants: [], +})); +const windowSnapshot: ChannelWindow = { + channelId, + rows, + status: "ready", + freshness: "verified", + hasMore: false, + loadingOlder: false, + error: undefined, +}; +const list = { + status: "ready" as const, + channels: [{ id: channelId, name: "Restoration", members: [viewer] }], +}; +const unreadSnapshot = { + target: { kind: "channel" as const, channelId }, + observedCount: 80, + attentionCount: 0, + coverage: "observed" as const, + freshness: "observed" as const, + manual: "none" as const, +}; +const syncSnapshot = { + ...owner.session.unread.sync(), + completeness: "snapshot" as const, +}; +const session = { + ...owner.session, + channels: { + ...owner.session.channels, + window: () => windowSnapshot, + list: () => list, + subscribeList: () => () => {}, + }, + unread: { + ...owner.session.unread, + enterChannel: async () => {}, + snapshot: () => unreadSnapshot, + sync: () => syncSnapshot, + attention: (_channel: string, id: string) => ({ + status: "eligible" as const, + unread: Number(id.slice(4)) >= 100, + forced: false, + viewing: false, + }), + }, +}; +writeView(scope, `scroll:${channelId}`, { + bottom: false, + offset: 5000, + anchor: { id: boundaryId, y: 20 }, +}); +Object.assign(window, { + boundaryRestoration: { + boundaryId, + saved: () => readView(scope, `scroll:${channelId}`, null), + }, +}); + +function Fixture() { + const [visible, setVisible] = useState(true); + return ( +
+ + + {visible && ( + false} + /> + )} + +
+ ); +} +const root = document.getElementById("root"); +if (!root) throw new Error("Missing fixture root"); +createRoot(root).render( + + + , +);