From 55ec0ee3c1fc759c9bfe7249a65ee09df9d9e29c Mon Sep 17 00:00:00 2001 From: Kyle Zemek Date: Fri, 30 Oct 2026 21:03:05 -0700 Subject: [PATCH] feat(messages): keep the new-message boundary stable during a visit Co-authored-by: Codex Signed-off-by: Kyle Zemek --- docs/buzz-help.md | 5 + src/bundled/channels/ConversationTab.test.tsx | 77 +- .../channels/useConversationTabs.test.tsx | 1 + .../conversation/EmbeddedThread.test.tsx | 7 +- .../messages/ChannelTimeline.restore.test.tsx | 808 +++++++++++++++++- .../messages/ChannelTimeline.test.tsx | 1 + src/features/messages/ChannelTimeline.tsx | 300 ++++++- src/features/messages/MessageManagement.tsx | 137 ++- src/features/messages/MessageRow.test.tsx | 8 +- src/features/messages/Messages.module.css | 17 + tests/browser/fixture.mjs | 7 +- tests/browser/new-message-boundary.spec.mjs | 538 ++++++++++++ 12 files changed, 1862 insertions(+), 44 deletions(-) create mode 100644 tests/browser/new-message-boundary.spec.mjs diff --git a/docs/buzz-help.md b/docs/buzz-help.md index 285545c1c9..56f9a61714 100644 --- a/docs/buzz-help.md +++ b/docs/buzz-help.md @@ -58,6 +58,11 @@ the interface unless an available tool actually supports that action. to address a channel member. Typing a name alone does not guarantee a notification. Selecting someone outside the channel does not grant access; inviting them requires an explicit decision from someone with permission. +- **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 may be absent when + entry rows or unread evidence are incomplete, or read intent or known read + evidence changes before the boundary can be 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/ConversationTab.test.tsx b/src/bundled/channels/ConversationTab.test.tsx index ca7fe4e757..aedbd86fd7 100644 --- a/src/bundled/channels/ConversationTab.test.tsx +++ b/src/bundled/channels/ConversationTab.test.tsx @@ -87,6 +87,10 @@ vi.mock("../../features/messages/MessageComposer", () => ({ ), })); +const unreadEvidence = { + revision: () => 0, + sync: () => ({ capability: "unsupported" }), +}; const owners: ReturnType[] = []; afterEach(() => { cleanup(); @@ -141,8 +145,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; @@ -204,8 +213,12 @@ it.each(["thread", "conversation"] as const)( const leaveChannel = vi.fn(); const session = { viewer: "viewer", - channels: { list: () => snapshot, subscribeList: () => () => {} }, - unread: { enterChannel, leaveChannel }, + channels: { + window: () => ({ rows: [] }), + list: () => snapshot, + subscribeList: () => () => {}, + }, + unread: { ...unreadEvidence, enterChannel, leaveChannel }, } as unknown as RelaySession; const props = { tab: { @@ -255,8 +268,12 @@ it.each(["thread", "conversation"] as const)( const enterChannel = vi.fn(async () => {}); const session = { viewer: "viewer", - channels: { list: () => snapshot, subscribeList: () => () => {} }, - unread: { enterChannel, leaveChannel: vi.fn() }, + channels: { + window: () => ({ rows: [] }), + list: () => snapshot, + subscribeList: () => () => {}, + }, + 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", @@ -341,7 +363,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", @@ -391,7 +418,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" @@ -435,7 +467,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", @@ -480,7 +517,12 @@ it.each([false, true])( }; const snapshot = { status: "ready", channels: [channel] }; const session = { - channels: { list: () => snapshot, subscribeList: () => () => {} }, + unread: unreadEvidence, + channels: { + window: () => ({ rows: [] }), + list: () => snapshot, + subscribeList: () => () => {}, + }, } as unknown as RelaySession; render( void>(); const session = { + unread: unreadEvidence, channels: { list: () => snapshot, subscribeList: () => () => {}, @@ -675,7 +718,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; @@ -709,7 +757,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 5fe3e3413a..ef080cbcd5 100644 --- a/src/bundled/channels/useConversationTabs.test.tsx +++ b/src/bundled/channels/useConversationTabs.test.tsx @@ -1809,6 +1809,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 38d90753c3..153c89c5d8 100644 --- a/src/features/conversation/EmbeddedThread.test.tsx +++ b/src/features/conversation/EmbeddedThread.test.tsx @@ -142,6 +142,7 @@ function fixture( ], }; const unread = { + revision: () => 0, sync: () => ({ capability: "unsupported" }), snapshot: () => undefined, subscribe: () => () => {}, @@ -165,7 +166,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 8c979675ea..21c19fde57 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. @@ -23,7 +26,7 @@ const scroll = vi.hoisted(() => ({ resized: () => {}, })); vi.mock("virtua", async () => { - const { forwardRef, useImperativeHandle } = await import("react"); + const { Children, forwardRef, useImperativeHandle } = await import("react"); return { Virtualizer: forwardRef(function Virtualizer( { children, onResize }: { children: ReactNode; onResize?: () => void }, @@ -37,7 +40,13 @@ vi.mock("virtua", async () => { scrollToIndex: scroll.toIndex, scrollTo: scroll.toOffset, })); - return
    {children}
; + return ( +
    + {Children.map(children, (child) => ( +
  1. {child}
  2. + ))} +
+ ); }), }; }); @@ -209,6 +218,799 @@ 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, + 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, exact history, and unread evidence before selecting the 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.peer.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(laterUnread.id); + const rows = [own, agents, joined, firstUnread, laterUnread]; + const mounted = render(h.tree("c", rows, true)); + 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); + h.unreadIds.add(firstUnread.id); + h.forcedIds.add(firstUnread.id); + mounted.rerender(h.tree("c", rows)); + h.publish(); + expect(screen.queryByText("New messages", { exact: true })).toBeNull(); + + h.completeReadStateSnapshot(); + 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(); + 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 preceding of [own, agents, joined]) { + const earlier = row(preceding.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("does not infer the oldest unread message from a partially loaded read/unread transition", async () => { + const h = boundaryFixture(); + 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(unread.id); + const mounted = render(h.tree("c", [read, unread], true)); + await h.completeEntry(); + h.completeReadStateSnapshot(); + h.publish(); + expect(screen.queryByRole("separator", { name: "New messages" })).toBeNull(); + + const older = h.row( + message(h.peer, "c", "Older unread outside the entry window", 1), + ); + h.unreadIds.add(older.id); + mounted.rerender(h.tree("c", [older, read, unread])); + h.publish(); + expect(screen.queryByRole("separator", { name: "New messages" })).toBeNull(); + for (const row of [older, unread]) + expect(h.unread.attention("c", row.id).unread).toBe(true); + mounted.unmount(); +}); + +it("does not select a stale unread row after reading advances during history loading", 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], true)); + + 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], true)); + 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([false, true])( + "excludes arrivals before the first timeline mount (entry ready: %s)", + async (entryReady) => { + const h = boundaryFixture(); + const own = h.row(message(h.viewer, "c", "Own message at entry", 10)); + const mounted = render(h.tree("c", [own], false, true, false)); + if (entryReady) await h.completeEntry(); + + const live = h.row(message(h.peer, "c", "Arrived while loading", 1)); + h.unreadIds.add(live.id); + mounted.rerender(h.tree("c", [live, own])); + if (!entryReady) await h.completeEntry(); + h.completeReadStateSnapshot(); + h.publish(); + + expect( + screen.getByText("Arrived while loading", { exact: true }), + ).toBeTruthy(); + expect( + screen.queryByRole("separator", { name: "New messages" }), + ).toBeNull(); + expect(h.unread.attention("c", live.id).unread).toBe(true); + 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("does not show an unread boundary 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(); + + await h.failEntry(); + + expect(screen.getByRole("alert").textContent).toContain( + "Could not restore unread state.", + ); + expect(screen.queryByText("New messages", { exact: true })).toBeNull(); +}); + +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], true)); + await h.completeEntry(); + expect( + screen.queryByRole("separator", { name: "New messages" }), + ).toBeNull(); + mounted.rerender(h.tree("c", [entry], true, 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("omits a pending boundary when synchronized reading changes known entry attention without a local revision", async () => { + 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); + h.completeReadStateSnapshot(); + const revision = h.unread.revision(); + const mounted = render(h.tree("c", [first, second], true)); + await h.completeEntry(); + + 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); + 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); + mounted.unmount(); +}); + +it("captures a new boundary after leaving and re-entering the same channel", async () => { + 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])); + await h.completeEntry(); + h.completeReadStateSnapshot(); + h.publish(); + 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(screen.queryByText("New messages", { exact: true })).toBeNull(); + + mounted.rerender(h.tree("c", [first, next])); + await h.completeEntry(); + h.publish(); + 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(); +}); + +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", + }); + // Model only the virtualizer's imperative item offset. The browser case + // verifies the real measured message position through the same transition. + 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.test.tsx b/src/features/messages/ChannelTimeline.test.tsx index 45d0a609b8..97c43d311f 100644 --- a/src/features/messages/ChannelTimeline.test.tsx +++ b/src/features/messages/ChannelTimeline.test.tsx @@ -74,6 +74,7 @@ vi.mock("react", async (original) => ({ hooks.memos[index] = { deps, value: factory() }; return hooks.memos[index]?.value; }, + useContext: () => undefined, useEffect(create: () => (() => void) | undefined, deps: readonly unknown[]) { const index = hooks.effect++; const old = hooks.effects[index]; diff --git a/src/features/messages/ChannelTimeline.tsx b/src/features/messages/ChannelTimeline.tsx index 5fff4ff994..d5a578b48c 100644 --- a/src/features/messages/ChannelTimeline.tsx +++ b/src/features/messages/ChannelTimeline.tsx @@ -14,12 +14,14 @@ import { activityGroups, RECENT_ACTIVITY_COUNT } from "./activity-groups"; 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"; @@ -38,6 +40,7 @@ import type { PageNavigation } from "../navigation/service"; import { messageViewKey } from "./view-key"; import { useKnownAgentPubkeys } from "../agents/use-known"; import { JumpToLatestButton } from "./JumpToLatestButton"; +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, @@ -81,6 +84,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>; +}; +// 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, @@ -249,6 +276,229 @@ function Timeline({ ], ); const rows = useMemo(() => 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) => + ownsMessageVisit && boundaryPending + ? queries.unread.subscribe(unreadTarget, listener) + : () => {}, + [boundaryPending, ownsMessageVisit, 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) => + ownsMessageVisit && boundaryPending + ? queries.unread.subscribeSync(() => { + listener(); + setUnreadEvidenceRevision((revision) => revision + 1); + }) + : () => {}, + [boundaryPending, ownsMessageVisit, 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" + ? rows + .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 }; + if ( + baseline.revision !== undefined && + queries.unread.revision() !== baseline.revision + ) + return { ready: true }; + // Remote read merges do not advance the local intent revision. Once a + // loaded row's read evidence is known, later changes cannot redefine entry. + if ( + entryAttention.some(({ row, attention }) => { + const previous = baseline.attention.get(row.id); + return ( + previous && + attention.status !== "unknown" && + !attention.pending && + (previous.unread !== attention.unread || + previous.forced !== attention.forced) + ); + }) + ) + 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 (!rows.length) return { ready: noUnreadEvidence }; + for (const { row, attention } of entryAttention) { + if (attention.status === "unknown" || attention.pending) + return { ready: false }; + // Ineligible means no attention category, not that an incoming row is read. + if (attention.forced || attention.unread) + // Timestamp order cannot distinguish history from a backdated live arrival. + return baseline.messageIds.has(row.id) + ? { ready: true, messageId: row.id } + : { ready: true }; + } + 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) && + attention.status !== "unknown" && + !attention.pending + ) + baseline.attention.set(row.id, { + 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(), ); @@ -360,6 +610,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); @@ -614,6 +865,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((row) => row.id === reader.current.id); @@ -633,6 +886,7 @@ function Timeline({ if ( !id || (!moved && + !boundaryChanged && rows.length <= edges.current.order.length && !( heldReader.current?.id === id && @@ -642,7 +896,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. @@ -701,7 +968,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); } logical = logicalTop(); }); @@ -735,7 +1002,7 @@ function Timeline({ element.ownerDocument.addEventListener("pointerup", done); element.ownerDocument.addEventListener("pointercancel", done); return () => release(true); - }, [rows, prepend]); + }, [rows, prepend, newMessageBoundaryId]); useLayoutEffect(() => { const element = scroller.current; if (!element) return; @@ -914,9 +1181,21 @@ function Timeline({ if (anchor && row) { pendingRestore.current = undefined; restoredAnchor.current = { id: row.id, y: anchor.y }; + const message = + row.id === newMessageBoundaryId + ? scroller.current?.querySelector( + `[data-message-id="${CSS.escape(row.id)}"]`, + ) + : undefined; + const item = message?.closest("li"); + const inset = + message && item + ? message.getBoundingClientRect().top - + item.getBoundingClientRect().top + : 0; handle.current.scrollToIndex(restoreIndex, { align: "start", - offset: -anchor.y, + offset: inset - anchor.y, }); follow.current = false; } else if (follow.current) { @@ -1005,6 +1284,7 @@ function Timeline({ revealedSignal, updateJumpToLatest, revealMessageId, + newMessageBoundaryId, ]); // After positioning reads it: a load that ends without rows lands no page. useLayoutEffect(() => { @@ -1294,6 +1574,18 @@ function Timeline({ )} + {row.id === newMessageBoundaryId && ( +
+
+ +
+ )} (undefined); +type MessageVisit = Readonly<{ + session: RelaySession; + channelId: string | undefined; + token: object; + entryMessageIds: ReadonlySet; + entryReadRevision: number; + entryAttention: ReadonlyMap< + string, + Pick + >; + 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(); @@ -163,14 +192,42 @@ 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 belongs to this visit, not to that later first render. + const captureVisit = () => { + const rows = channelId ? session.channels.window(channelId).rows : []; + const entryAttention = new Map< + string, + Pick + >(); + if (channelId && session.unread.sync().completeness === "snapshot") + for (const row of rows) { + const attention = session.unread.attention(channelId, row.id); + if (attention.status !== "unknown" && !attention.pending) + entryAttention.set(row.id, { + unread: attention.unread, + forced: attention.forced, + }); + } + return { + session, + channelId, + visitChannelId, + entryMessageIds: new Set(rows.map((row) => row.id)), + 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 && @@ -178,8 +235,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. @@ -204,6 +263,20 @@ export function MessageManagement({ }), [session, channelId, operations, visit], ); + const messageVisit = useMemo( + () => ({ + session: visit.session, + channelId: visit.channelId, + token: visit, + entryMessageIds: visit.entryMessageIds, + 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; @@ -211,20 +284,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(); @@ -232,7 +323,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: { @@ -1275,6 +1279,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 1b8bc79133..96c1850bb8 100644 --- a/src/features/messages/Messages.module.css +++ b/src/features/messages/Messages.module.css @@ -3290,6 +3290,23 @@ html[data-keyboard-navigation] .rawDiff:focus-visible { margin-inline: auto; padding-block: var(--space-1); } +.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); +} .activityChevron { transform: rotate(-90deg); } diff --git a/tests/browser/fixture.mjs b/tests/browser/fixture.mjs index 699799c6b2..0731d2a6f3 100644 --- a/tests/browser/fixture.mjs +++ b/tests/browser/fixture.mjs @@ -2196,6 +2196,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 @@ -2703,6 +2707,7 @@ export const test = base.extend({ root, parent, attachmentTags = [], + createdAt, ) { const history = histories.get(`${community}/${channel}`); const event = sign( @@ -2721,7 +2726,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/new-message-boundary.spec.mjs b/tests/browser/new-message-boundary.spec.mjs new file mode 100644 index 0000000000..c7a118c4c1 --- /dev/null +++ b/tests/browser/new-message-boundary.spec.mjs @@ -0,0 +1,538 @@ +import { test as appTest } from "./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; + }); + +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 clipboardData.getData("text/html"); + } finally { + selection.removeAllRanges(); + } + }); + expect(copied).toContain( + app.histories.get("primary/alpha")[0].content.split("\n")[0], + ); + expect(copied).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); + }, + ); +});