diff --git a/docs/unread.md b/docs/unread.md index a3c1570f20..c61e94530d 100644 --- a/docs/unread.md +++ b/docs/unread.md @@ -34,14 +34,42 @@ and supply evidence before their rows become observable. Reusable `ChannelTimeline` and `ThreadPanel` own the standard observation policy: focused active reading surface, visible document, settled positioning, fully -visible rows, and 750 ms dwell. Scroll/content/focus changes cancel/restart dwell. -Mounted virtualizer overscan, preload, selection, and composer focus are not -reading. Automatic observations mark **individual messages**, never a prefix that -could hide unseen siblings. Oversized rows that never fit fully are not auto-read. +visible rows, and 300 ms dwell. Scroll/content/focus changes cancel/restart dwell. Owners explicitly wake the +scheduler when positioning finishes, even if an empty history page changes no +rows or geometry. +The list and its own composer share a reading surface; focus anywhere in an open +thread panel also qualifies, including its selected owning tab header. Inactive +or hidden tab content and passive restoration do not qualify. Another pane, a +dialog, background window, preload, +and mounted virtualizer overscan do not qualify. + +Away from the bottom, observations mark individual fully visible messages. At the +physical bottom, 300 ms visibility of the newest message's bottom edge also earns +catch-up (including a message taller than the viewport): + +- `activity:` uses the newest retained verified event timestamp at dwell + completion, including replies newer than the visible top-level head. It reads + ordinary top-level backlog and quiets ordinary reply activity in the channel + aggregate. It never acknowledges + the replies themselves: direct thread/message selectors retain them. Mentions, + broadcasts, DMs, manual intent and currently known participating-thread activity + are not quieted. Missing retained participation cannot erase a reply's unread + state; later participation evidence can restore the sidebar attention dot. +- `thread-activity:` acknowledges replies through the newest reply only in + that thread. A collapsed newest reply, or one outside the bottom viewport in a + branch-ordered thread, cannot earn catch-up; visible rows still read individually. +- Both keys use verified event timestamps, never wall time. Neither enters generic + channel/thread inheritance or remote override baselines. Automatic catch-up does + not clear manual-unread intent and uses the same cancellable reading lease. + +This intentionally relaxes the old individual-row-only policy for ordinary +backlog and the thread being read, without reading unopened threads' replies. - `observedCount` is `null` when unknown or denied, never a fabricated zero. Otherwise it counts the bounded evidence currently known, excluding own messages, - auxiliary events and authorized deletions. It is **not an exact total or lower + auxiliary events and authorized deletions. The channel aggregate additionally + excludes ordinary replies quieted by its activity cutoff; direct thread and + message selectors still report those replies as unread. It is **not an exact total or lower bound**: missing markers/deletions can overcount; missing history can undercount. - `coverage` and `freshness` describe message evidence, separately from `sync()`. Evidence is capped at 4,096 events / 8 MiB. Repair queries the membership roster @@ -65,17 +93,23 @@ could hide unseen siblings. Oversized rows that never fit fully are not auto-rea - `markThrough(target, messageId)` is explicit prefix intent through verified evidence. It can mark unloaded earlier messages read; do not use it for viewport observation. A channel prefix requires a top-level message, not a reply. -- `markChannelRead(channelId)` snapshots the newest retained verified message - (including replies) when invoked, then atomically advances the channel frontier - and clears the channel's owned local manual-unread marks. It does not fetch - history, select the row, or substitute the wall clock for message evidence. - Arrivals beyond that timestamp remain unread; like other timestamp prefixes, - this also covers messages at or before the cut that arrive later. - With no message evidence, it clears only the channel's local mark and invents - no frontier. Success means local durability; publication may still be pending. -- `markAllChannelsRead()` runs `markChannelRead` one channel at a time over the - accessible listed channels that still show unread evidence or a local mark, so - an already-read community costs no writes. One failing channel does not stop +- `markChannelRead(channelId)` captures the greater of invocation-time integer + seconds and the newest retained verified message timestamp (including replies), + then atomically advances the explicit channel frontier and clears the channel's + owned local manual-unread marks. It does not fetch history or select the row. + Pre-click history arriving later stays read; events beyond the cutoff remain + unread. Same-second arrivals are covered too. An ahead-of-time device clock is + sticky because frontier merges take the maximum: correcting that clock does not + rewind the cutoff. Automatic reading never uses this clock-based action. + Empty evidence still saves the click cutoff on frontier-capable hosts; other + hosts retain local-only clearing for the empty case. Success means local + durability; publication may still be pending. +- `markAllChannelsRead()` snapshots selected channel cutoffs/evidence at sweep + invocation and reserves each channel's mutation order before newer manual + actions. It saves one channel at a time, so a slow first write cannot + acknowledge post-click activity in a later channel. It selects accessible + listed channels with retained unread evidence or a local mark, including replies + quieted in the channel aggregate, so an already-read community costs no writes. One failing channel does not stop the sweep; the first failure is rethrown afterwards. A channel whose grant is revoked before its turn is skipped, not failed; like a grant that arrives mid-sweep, it waits for the next explicit action. The community rail's @@ -87,9 +121,10 @@ could hide unseen siblings. Oversized rows that never fit fully are not auto-rea and retries pending publication. `ReadMutationResult.durability === "saved"` means the local transaction committed, not that the relay accepted it. -The sidebar separates ordinary unread from directed attention. Any unread state, -including activity that exists only in a relevant thread, strengthens the channel -label. Ordinary unread renders no row marker. DMs, mentions, broadcasts, and +The sidebar separates ordinary unread from directed attention. Ordinary backlog +or local manual intent strengthens the channel label; protected attention alone +retains its dot without bolding a caught-up channel. DMs retain unread bolding. +Ordinary unread renders no row marker. DMs, mentions, broadcasts, and relevant thread replies add one accent dot; non-DM row numerals are omitted and DM avatars are reserved for promoted offscreen cues. Thread activity reuses that dot: its hover/focus/click popover groups unread replies by canonical thread root and @@ -124,8 +159,8 @@ thread selector has observed unread replies or explicit thread-unread intent. Accessible names distinguish observed evidence, stale evidence and local-only intent; unknown/observed-zero omit the dot, not assert complete read history. Each mounted button subscribes to its own thread, without fetching thread history. -Opening/hovering a button does not acknowledge replies; the existing focused -viewport dwell in `ThreadPanel` supplies individual-message reading intent. +Hovering a button does not acknowledge replies. Opening the panel qualifies as +reading while focus remains in it; viewport/bottom dwell supplies the intent. Unread ancestry uses the same canonical marked-reference parser as thread opening and row projection (case-insensitive hex, last valid marker wins). Resolution still requires bounded, retained same-channel message evidence; references alone do not @@ -136,9 +171,10 @@ grant access or trigger a read. | Intent | Durable frontier | Local manual-unread clears | | --- | --- | --- | | Automatic visible dwell | Individual verified message | None | +| Automatic bottom dwell | Ordinary channel activity or that thread’s replies through verified evidence | None | | `markThrough(target, messageId)` | Explicit verified target prefix | That target only | -| `markChannelRead(channelId)` | Channel through newest retained verified message, including replies | Channel, retained messages, verified same-channel reply roots, and threads whose top-level root is retained | -| Channel read with no evidence | None | Channel only | +| `markChannelRead(channelId)` | Channel through max(click time, newest retained verified message) | Channel, retained messages, verified same-channel reply roots, and threads whose top-level root is retained | +| Channel read with no evidence | Click time on frontier-capable hosts; none otherwise | Channel only | | Mute/Unmute | None | None | Channel read does not clear other channels, unproven ancestry, or remote manual @@ -178,6 +214,9 @@ The local state has a 96 KiB serialized-blob budget and wire publication a 40 Ki plaintext budget. Persisted local interaction order prioritizes newly read old history as well as current traffic. Only frontier-only hints can be pruned; older messages may look unread again. No synthetic channel prefix is introduced to fit. +The automatic activity keys share these bounded-hint limits. Older clients can +preserve/republish them but do not interpret their catch-up meaning; mixed-version +sidebar behavior is not identical. No storage migration is required. Override groups, permanent clear floors, directly associated frontiers and possible inherited channel/thread frontiers are protected; capacity failure is visible, never floor truncation. Publication of any override-bearing state is deliberately diff --git a/patches/README.md b/patches/README.md index 72df1a2a46..fb4cc8afb8 100644 --- a/patches/README.md +++ b/patches/README.md @@ -64,8 +64,9 @@ between two wrap widths. The momentum-interruption predicate requires MacIntel and Apple vendor, excluding Virtua's iOS detector (including desktop-mode iPad). Chrome/Firefox, non-Mac WebKit -and iOS keep existing momentum policy. Store/layout/observer timing and imperative -smooth/instant scheduling remain stock. Scheduler-driven reveal/restore/bottom navigation is a +and iOS keep existing momentum policy. Store/layout timing and imperative smooth/instant target calculation remain stock. +The cancellation boundary below retires superseded targets. Scheduler-driven +reveal/restore/bottom navigation is a separate acceptance path, not implicitly repaired by the automatic-correction fix. Native reveal controls showed one/two transient blank interior source frames before immediate recovery, despite valid sampled DOM coverage. This remaining @@ -73,6 +74,23 @@ imperative-path flicker is not the sustained automatic-correction failure; the patch does not claim to fix it. The stale source-map directive is removed because the generated map is unpatched. +## Newer reader input cancels retained navigation + +Virtua retains imperative targets for late measurements for 150 ms. After a jump +to latest, new upward input could detach the reader, but a row measurement inside +that window replayed the old target and pulled them back down. The element driver +now invokes its existing cancellation closure on wheel, touchmove, keydown and +pointerdown, matching ChannelTimeline's reader-intent events. Capture listeners +retire the old command before a descendant handler can issue new navigation; +no input is prevented and automatic resize compensation is unchanged. Disposal +also cancels retained work and removes the listeners. + +Installed-driver tests keep the clock inside the pending command's lifetime, +including a replay already queued as a microtask, and verify a fresh navigation +still responds to measurements. The browser navigation case exercises real wheel +input and late row growth, asserting position as well as arrival count. These are +not native momentum/compositor acceptance, and do not change the limits above. + ## Automated checks ```sh diff --git a/patches/virtua@0.51.0.patch b/patches/virtua@0.51.0.patch index 788b659b8f..3317b50bde 100644 --- a/patches/virtua@0.51.0.patch +++ b/patches/virtua@0.51.0.patch @@ -1,5 +1,5 @@ diff --git a/lib/index.js b/lib/index.js -index a183cc7b..44e2d963 100644 +index a183cc7b1669aab25467840540ce3bd1c025fb47..d960e2ea2a9435296756a64fe3a3773b429ab081 100644 --- a/lib/index.js +++ b/lib/index.js @@ -6,6 +6,10 @@ import { jsx as c } from "react/jsx-runtime"; @@ -86,9 +86,16 @@ index a183cc7b..44e2d963 100644 behavior: t ? "smooth" : "instant" }); }), u = t ? "width" : "height", d = /* @__PURE__ */ new WeakMap, h = C(t => { -@@ -266,7 +310,8 @@ var {min: a, max: u, abs: d, floor: h} = Math, g = (e, t, n) => a(n, u(t, e)), v +@@ -263,10 +307,15 @@ var {min: a, max: u, abs: d, floor: h} = Math, g = (e, t, n) => a(n, u(t, e)), v + } + o.length && e.W(3, o); + }); ++ // New reader input supersedes retained imperative targets. Capture runs ++ // before a button/key handler can issue the next intentional navigation. ++ const inputEvents = ["wheel", "touchmove", "keydown", "pointerdown"]; return { D(f, u = f.parentElement) { ++ for (const type of inputEvents) u.addEventListener(type, a, { capture: true, passive: true }); h.A(n = u), t && (i = "rtl" === getComputedStyle(u).direction), o = M(e, u, t, () => T(u[s], i), (t, n, o) => { - if (o) { + if (isMacWebKit()) interruptMomentum(); @@ -96,7 +103,7 @@ index a183cc7b..44e2d963 100644 const e = u.style, t = e[c]; e[c] = "hidden", p(() => { e[c] = t; -@@ -274,7 +319,7 @@ var {min: a, max: u, abs: d, floor: h} = Math, g = (e, t, n) => a(n, u(t, e)), v +@@ -274,7 +323,7 @@ var {min: a, max: u, abs: d, floor: h} = Math, g = (e, t, n) => a(n, u(t, e)), v } const r = e.T() + t; r <= 0 || r >= e.O() + e.t() - e.o() ? u.scrollTo({ @@ -105,22 +112,21 @@ index a183cc7b..44e2d963 100644 behavior: "instant" }) : u.scrollBy({ [l]: T(t, i), -@@ -283,7 +328,7 @@ var {min: a, max: u, abs: d, floor: h} = Math, g = (e, t, n) => a(n, u(t, e)), v +@@ -283,7 +332,9 @@ var {min: a, max: u, abs: d, floor: h} = Math, g = (e, t, n) => a(n, u(t, e)), v }), r[1](!0); }, _() { - h.X(), o && o.X(), r[1](!1), r = $(); ++ a(); ++ for (const type of inputEvents) n && n.removeEventListener(type, a, true); + restoreOverflow && restoreOverflow(), h.X(), o && o.X(), r[1](!1), r = $(); }, P: (e, t) => (d.set(e, t), h.A(e), () => { d.delete(e), h.B(e); -@@ -796,5 +841,4 @@ var {min: a, max: u, abs: d, floor: h} = Math, g = (e, t, n) => a(n, u(t, e)), v - }); +@@ -797,4 +848,3 @@ var {min: a, max: u, abs: d, floor: h} = Math, g = (e, t, n) => a(n, u(t, e)), v }); - + --export { G as VList, j as Virtualizer, U as WindowVirtualizer, N as experimental_VGrid }; --//# sourceMappingURL=index.js.map -\ No newline at end of file -+export { G as VList, j as Virtualizer, U as WindowVirtualizer, N as experimental_VGrid }; + export { G as VList, j as Virtualizer, U as WindowVirtualizer, N as experimental_VGrid }; \ No newline at end of file +-//# sourceMappingURL=index.js.map diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 094a9262ef..9287ff34a8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6,7 +6,7 @@ settings: patchedDependencies: '@base-ui/react@1.8.0': 7fa81e73a79e25bc6cddeeb432a4609fbc8f1795141fbd0cfebacbfbaa02757f - virtua@0.51.0: c363436cd08be09757bc2d557a70df2a0d37072b835acb98079a9287a2f41f67 + virtua@0.51.0: b2fb84b3e136f232ee6504a63139a917e0f78473bff1bb648c33da6b5b7eec1e importers: @@ -137,7 +137,7 @@ importers: version: 4.0.1 virtua: specifier: 0.51.0 - version: 0.51.0(patch_hash=c363436cd08be09757bc2d557a70df2a0d37072b835acb98079a9287a2f41f67)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + version: 0.51.0(patch_hash=b2fb84b3e136f232ee6504a63139a917e0f78473bff1bb648c33da6b5b7eec1e)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) yaml: specifier: 2.9.1 version: 2.9.1 @@ -3776,7 +3776,7 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 - virtua@0.51.0(patch_hash=c363436cd08be09757bc2d557a70df2a0d37072b835acb98079a9287a2f41f67)(react-dom@19.2.8(react@19.2.8))(react@19.2.8): + virtua@0.51.0(patch_hash=b2fb84b3e136f232ee6504a63139a917e0f78473bff1bb648c33da6b5b7eec1e)(react-dom@19.2.8(react@19.2.8))(react@19.2.8): optionalDependencies: react: 19.2.8 react-dom: 19.2.8(react@19.2.8) diff --git a/src/bundled/channels/ChannelSidebarRow.module.css b/src/bundled/channels/ChannelSidebarRow.module.css index 7e0ab55b9e..33d578480f 100644 --- a/src/bundled/channels/ChannelSidebarRow.module.css +++ b/src/bundled/channels/ChannelSidebarRow.module.css @@ -112,7 +112,7 @@ max-width: 100%; flex: 0 1 auto; } -.row:has([data-channel-unread], [data-channel-activity]) .label { +.row:has([data-channel-ordinary="true"]) .label { color: var(--text-navigation-unread); font-weight: var(--type-weight-semibold); } diff --git a/src/bundled/channels/ChannelsPage.tsx b/src/bundled/channels/ChannelsPage.tsx index cd26c94de5..18d78dd903 100644 --- a/src/bundled/channels/ChannelsPage.tsx +++ b/src/bundled/channels/ChannelsPage.tsx @@ -580,6 +580,13 @@ function ChannelWorkspace({ showingThread?.navigation, setThread, ]); + // Retargeting the retained thread is a new opening, not a passive rerender. + const threadChannelId = showingThread?.channelId; + const threadMessageId = showingThread?.messageId; + const threadInstance = useMemo( + () => ({ queries, scope, threadChannelId, threadMessageId }), + [queries, scope, threadChannelId, threadMessageId], + ); type Opening = PanelOpening; const opened = entries.find( (entry) => panelTabId(entry) === tabState.selected, @@ -1642,6 +1649,7 @@ function ChannelWorkspace({ ? [ { id: "thread", + instance: threadInstance, label: "Thread", icon: ( 0; + // Bold tracks ordinary backlog; protected attention has its own dot. + const ordinary = + manual || + (dm ? (count ?? 0) > 0 : (count ?? 0) > (snapshot.attentionCount ?? 0)); const threadCount = activity.items?.length ?? 0; if (!unread && !threadCount && label === undefined) return null; const priority = dm || (snapshot.attentionCount ?? 0) > 0; @@ -61,12 +65,13 @@ export function UnreadBadge({ return ( <> {label !== undefined && ( - {label} + {label} )} {unread && ( ({ useReading: vi.fn() })); +vi.mock("./use-reading", () => ({ + useReading: vi.fn(), + readingPositioned: vi.fn(), +})); const hooks = vi.hoisted(() => ({ refs: [] as { current: unknown }[], states: [] as unknown[], @@ -1154,6 +1157,7 @@ it("wires the shared reading hook to its owned scroller and settled position", ( expect(useReading).toHaveBeenCalledWith({ session: expect.any(Object), channelId: "channel", + latestMessageId: expect.any(String), scroller: expect.objectContaining({ current: expect.anything() }), settled: expect.objectContaining({ current: expect.any(Boolean) }), }); diff --git a/src/features/messages/ChannelTimeline.tsx b/src/features/messages/ChannelTimeline.tsx index aa071f3523..4907822bd6 100644 --- a/src/features/messages/ChannelTimeline.tsx +++ b/src/features/messages/ChannelTimeline.tsx @@ -14,7 +14,7 @@ import { useRowProfiles } from "../relay/react"; import { geometryFor, geometrySignature } from "./geometry"; import { readView, writeView } from "../../shared/view-state"; import styles from "./Messages.module.css"; -import { useReading } from "./use-reading"; +import { readingPositioned, useReading } from "./use-reading"; import { useMessageReveal } from "./use-message-reveal"; import type { PageNavigation } from "../navigation/service"; import { messageViewKey } from "./view-key"; @@ -293,7 +293,13 @@ function Timeline({ prepare: prepareTarget, complete: completeTarget, }); - useReading({ session: queries, channelId, scroller, settled }); + useReading({ + session: queries, + channelId, + scroller, + settled, + latestMessageId: window.rows.filter((row) => !row.membership).at(-1)?.id, + }); const prepend = !!edges.current.first && edges.current.first !== rows[0]?.id && @@ -447,6 +453,7 @@ function Timeline({ recordPosition(scroller.current); if (!restore && !follow.current) { settled.current = true; + readingPositioned(scroller.current); return; } restorePosition(); @@ -481,6 +488,7 @@ function Timeline({ } } settled.current = true; + readingPositioned(scroller.current); if (scroller.current) updateJumpToLatest(scroller.current); }); return () => { diff --git a/src/features/messages/ThreadPanel.nesting.test.tsx b/src/features/messages/ThreadPanel.nesting.test.tsx index 5b14b58451..1a70a76a32 100644 --- a/src/features/messages/ThreadPanel.nesting.test.tsx +++ b/src/features/messages/ThreadPanel.nesting.test.tsx @@ -18,7 +18,10 @@ import type { MessageComposerProps } from "./MessageComposer"; import type { MessageRowProps } from "./MessageRow"; import { ThreadPanel } from "./ThreadPanel"; -vi.mock("./use-reading", () => ({ useReading: () => {} })); +vi.mock("./use-reading", () => ({ + Reading: () => null, + readingPositioned: () => {}, +})); vi.mock("../relay/react", () => { const profiles = new Map(); return { useRowProfiles: () => profiles }; diff --git a/src/features/messages/ThreadPanel.profile.test.tsx b/src/features/messages/ThreadPanel.profile.test.tsx index 1916f151a2..5566c0c676 100644 --- a/src/features/messages/ThreadPanel.profile.test.tsx +++ b/src/features/messages/ThreadPanel.profile.test.tsx @@ -21,7 +21,10 @@ import type { ThreadView } from "../relay/threads"; // Actual React effects, ThreadPanel, MessageRow, session and HTTP verification run. // jsdom observes DOM commit, NOT browser layout/paint or native interaction. vi.mock("./MessageComposer", () => ({ MessageComposer: () => null })); -vi.mock("./use-reading", () => ({ useReading: () => {} })); +vi.mock("./use-reading", () => ({ + Reading: () => null, + readingPositioned: () => {}, +})); function mount(session: RelaySession, channelId: string, messageId: string) { const container = document.createElement("div"); diff --git a/src/features/messages/ThreadPanel.test.tsx b/src/features/messages/ThreadPanel.test.tsx index 2527d51719..ff44196535 100644 --- a/src/features/messages/ThreadPanel.test.tsx +++ b/src/features/messages/ThreadPanel.test.tsx @@ -265,6 +265,13 @@ function messagesHarness( screen.queryByRole("region", { name: "Thread messages" }) ?? element; return element; }, + rerenderProps() { + mounted?.rerender( +
+ +
, + ); + }, resize(value: number) { height = value; }, @@ -421,6 +428,132 @@ it("keeps initial thread loading until available content has been positioned", ( expect(within(section).getByText("root")).toBeVisible(); expect(within(section).queryByText("Loading thread…")).toBeNull(); }); +it("keeps seeded rows usable but unread until the initial legacy walk settles", async () => { + vi.useFakeTimers(); + const h = messagesHarness(ordinaryNavigation()); + h.resize(600); + vi.mocked(document.hasFocus).mockReturnValue(true); + vi.spyOn(HTMLElement.prototype, "getClientRects").mockReturnValue([ + new DOMRect(0, 0, 500, 500), + ] as unknown as DOMRectList); + vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockReturnValue( + new DOMRect(0, 0, 500, 500), + ); + const observe = vi.fn(async () => {}); + const reading = vi.fn(() => ({ + view: vi.fn(), + observe, + catchUp: vi.fn(async () => {}), + dispose: vi.fn(), + })); + Object.assign(h.session.unread, { + sync: () => ({ capability: "frontier-sync" }), + reading, + }); + h.snapshot.status = "loading"; + h.snapshot.readKind = "refresh"; + h.render(); + h.element.focus(); + expect(h.element).not.toHaveAttribute("data-positioning"); + expect(h.element.querySelector("[data-thread-rows]")).not.toHaveAttribute( + "inert", + ); + await act(() => vi.advanceTimersByTimeAsync(300)); + expect(reading).not.toHaveBeenCalled(); + + h.snapshot.status = "ready"; + h.snapshot.readKind = undefined; + h.snapshot.direction = "forward"; + h.snapshot.canLoadMore = true; + h.snapshot.replies = [{ ...row, id: "reply", content: "reply" }]; + h.render(); + expect(h.view.loadMore).toHaveBeenCalledOnce(); + h.snapshot.status = "loading"; + h.snapshot.readKind = "older"; + h.render(); + expect(h.element).not.toHaveAttribute("data-positioning"); + expect(h.element.querySelector("[data-thread-rows]")).not.toHaveAttribute( + "inert", + ); + await act(() => vi.advanceTimersByTimeAsync(300)); + expect(reading).not.toHaveBeenCalled(); + + // The empty continuation changes neither rows nor geometry. Readiness must + // wake the existing reader, without a new focus or scroll gesture. + h.snapshot.status = "ready"; + h.snapshot.readKind = undefined; + h.snapshot.canLoadMore = false; + h.render(); + expect(h.element).not.toHaveAttribute("data-positioning"); + await act(() => vi.advanceTimersByTimeAsync(299)); + expect(observe).not.toHaveBeenCalled(); + await act(() => vi.advanceTimersByTimeAsync(1)); + expect(observe).toHaveBeenCalledExactlyOnceWith([row.id, "reply"]); +}); +it.each([false, true])( + "reads a background-completed exact thread after fresh dwell (deactivate before reveal=%s)", + async (deactivateBeforeReveal) => { + vi.useFakeTimers(); + const navigation = ordinaryNavigation(); + navigation.target.threadRootId = "different-root"; + const h = messagesHarness(navigation); + h.props.active = deactivateBeforeReveal; + h.resize(600); + vi.mocked(document.hasFocus).mockReturnValue(true); + vi.spyOn(HTMLElement.prototype, "getClientRects").mockReturnValue([ + new DOMRect(0, 0, 500, 500), + ] as unknown as DOMRectList); + vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockReturnValue( + new DOMRect(0, 0, 500, 500), + ); + const observe = vi.fn(async () => {}); + const reading = vi.fn(() => ({ + view: vi.fn(), + observe, + catchUp: vi.fn(async () => {}), + dispose: vi.fn(), + })); + Object.assign(h.session.unread, { + sync: () => ({ capability: "frontier-sync" }), + reading, + }); + h.snapshot.status = "loading"; + h.snapshot.targetStatus = "loading"; + h.render(); + // The workspace owns inactive tab inertness and focus, not ThreadPanel. + const host = screen.getByRole("application"); + host.setAttribute("inert", ""); + (document.activeElement as HTMLElement).blur(); + h.snapshot.targetStatus = "ready"; + h.snapshot.target = row; + h.render(); + h.snapshot.status = "ready"; + h.render(); + if (deactivateBeforeReveal) { + expect(navigation.complete).not.toHaveBeenCalled(); + // Props alone complete the background visit; the snapshot is unchanged. + h.props.active = false; + h.rerenderProps(); + } + expect(navigation.complete).toHaveBeenCalledExactlyOnceWith({ + status: "opened", + }); + await act(() => vi.advanceTimersByTimeAsync(300)); + expect(observe).not.toHaveBeenCalled(); + expect(HTMLElement.prototype.scrollIntoView).not.toHaveBeenCalled(); + + h.props.active = true; + h.rerenderProps(); + host.removeAttribute("inert"); + h.element.focus(); + await act(() => vi.advanceTimersByTimeAsync(299)); + expect(observe).not.toHaveBeenCalled(); + await act(() => vi.advanceTimersByTimeAsync(1)); + expect(observe).toHaveBeenCalledExactlyOnceWith([row.id]); + expect(h.element).toHaveFocus(); + expect(HTMLElement.prototype.scrollIntoView).not.toHaveBeenCalled(); + }, +); it("keeps older-page loading quiet and retry between root and replies", () => { const h = messagesHarness(); h.snapshot.direction = "older"; @@ -900,3 +1033,53 @@ it("without close, the embedded thread keeps its reader but has no close header }); expect(close).toHaveBeenCalledOnce(); }); + +it("observes a visible reply before its root resolves, then enables thread catch-up", async () => { + vi.useFakeTimers(); + const h = messagesHarness(); + const reply = { + ...row, + id: "c".repeat(64), + content: "available reply", + createdAt: 2, + }; + h.snapshot.root = undefined; + h.snapshot.replies = [reply]; + h.resize(600); + const observe = vi.fn(async () => {}); + const catchUp = vi.fn(async (_id: string, rootId?: string) => { + if (!rootId) + throw new Error("A thread reply cannot advance the channel frontier"); + }); + h.props.session = { + ...h.session, + unread: { + ...h.session.unread, + sync: () => + ({ capability: "frontier-sync" }) as ReturnType< + RelaySession["unread"]["sync"] + >, + reading: () => ({ view() {}, observe, catchUp, dispose() {} }), + }, + }; + vi.spyOn(document, "hasFocus").mockReturnValue(true); + vi.spyOn(document, "visibilityState", "get").mockReturnValue("visible"); + vi.spyOn(HTMLElement.prototype, "getClientRects").mockReturnValue([ + new DOMRect(0, 0, 500, 500), + ] as unknown as DOMRectList); + vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockReturnValue( + new DOMRect(0, 0, 500, 500), + ); + h.render(); + screen.getByRole("button", { name: "Close thread" }).focus(); + await act(() => vi.advanceTimersByTimeAsync(299)); + expect(observe).not.toHaveBeenCalled(); + await act(() => vi.advanceTimersByTimeAsync(1)); + expect(observe).toHaveBeenCalledExactlyOnceWith([reply.id]); + expect(catchUp).not.toHaveBeenCalled(); + + h.snapshot.root = row; + h.render(); + await act(() => vi.advanceTimersByTimeAsync(300)); + expect(catchUp).toHaveBeenCalledExactlyOnceWith(reply.id, row.id); +}); diff --git a/src/features/messages/ThreadPanel.tsx b/src/features/messages/ThreadPanel.tsx index 6c5f231436..da47b80a92 100644 --- a/src/features/messages/ThreadPanel.tsx +++ b/src/features/messages/ThreadPanel.tsx @@ -30,7 +30,7 @@ import { continuesMessageGroup } from "./message-grouping"; import { MessageComposer } from "./MessageComposer"; import styles from "./Messages.module.css"; import { rejectUnhandledFileDrop } from "./use-file-drop"; -import { useReading } from "./use-reading"; +import { Reading, readingPositioned } from "./use-reading"; import { useMessageReveal } from "./use-message-reveal"; import type { PageNavigation } from "../navigation/service"; import { messageViewKey } from "./view-key"; @@ -64,12 +64,19 @@ export type ThreadPanelProps = { export function ThreadPanel(props: ThreadPanelProps) { const tabbed = !!usePanelTabHost(); const { close } = props; + const viewKey = messageViewKey( + props.session, + props.scope, + props.channelId, + props.messageId, + ); return ( ); } @@ -287,6 +288,7 @@ function ThreadMessages({ const agentPubkeys = useKnownAgentPubkeys(session, profiles); const scroller = useRef(null); const positioned = useRef(false); + const readingSettled = useRef(false); const [initialPositioned, setInitialPositioned] = useState(false); const follow = useRef(true); const jumpingToLatest = useRef(false); @@ -366,6 +368,8 @@ function ThreadMessages({ // Exact lookup can finish before context. Preserve this row's reading // position through prepended history without refocusing it after opening. targetAnchor.current = selectedOffset(); + readingSettled.current = true; + readingPositioned(scroller.current); navigation?.complete({ status: "opened" }); }, [navigation, selectedOffset]); const prepareTarget = useCallback(() => { @@ -428,7 +432,6 @@ function ThreadMessages({ else if (snapshot.targetStatus === "error") navigation.complete({ status: "failed", reason: "unavailable" }); }, [navigation, rootTarget, snapshot.status, snapshot.targetStatus]); - useReading({ session, channelId, scroller, settled: positioned }); const [sent, setSent] = useState(); const [replyFocus, setReplyFocus] = useState(0); const focusReply = useCallback(() => { @@ -600,6 +603,7 @@ function ThreadMessages({ if (follow.current) element.scrollTop = element.scrollHeight; positioned.current = true; setInitialPositioned(true); + readingPositioned(element); if (jumpingToLatest.current) { setShowJumpToLatest(false); } else { @@ -627,6 +631,25 @@ function ThreadMessages({ tree, expanded, ]); + // Main's retained presentation can be usable before the initial history walk + // finishes. That is not yet automatic reading intent. Exact revealed targets + // remain individually readable; later background refreshes keep earned readiness. + // biome-ignore lint/correctness/useExhaustiveDependencies: active can complete an exact background visit in the preceding layout effect without changing the snapshot. + useLayoutEffect(() => { + if ( + !positioned.current || + readingSettled.current || + (navigation && !rootTarget && revealed.current !== navigation.signal) + ) + return; + if ( + snapshot.status === "ready" && + (snapshot.direction === "older" || !snapshot.canLoadMore) + ) { + readingSettled.current = true; + readingPositioned(scroller.current); + } + }, [active, navigation, rootTarget, revealed, snapshot]); // An own send can land in the middle of a branch, not at the list bottom. // biome-ignore lint/correctness/useExhaustiveDependencies: Retry DOM lookup after history or branch visibility changes. useLayoutEffect(() => { @@ -812,6 +835,24 @@ function ThreadMessages({ }; return ( + ( + (latest, row) => + !latest || row.createdAt > latest.createdAt ? row : latest, + undefined, + )?.id + : undefined + } + />
{ @@ -11,11 +13,22 @@ afterEach(() => { vi.useRealTimers(); vi.unstubAllGlobals(); }); +/** Registers a composer input with its scope, as `MessageComposer` does. */ +function Composer({ input }: { input: HTMLElement }) { + const scope = useMessageEditScope(); + useEffect(() => { + if (scope) scope.input.current = input; + }, [scope, input]); + return null; +} function setup({ supported = true, focused = true, settled = true, strict = false, + latestMessageId = undefined as string | undefined, + rootId = undefined as string | undefined, + focus = "element" as "element" | "composer" | "parent" | "outside" | "header", } = {}) { vi.useFakeTimers(); vi.spyOn(document, "hasFocus").mockReturnValue(focused); @@ -26,8 +39,16 @@ function setup({ const element = document.createElement("div"); element.tabIndex = 0; const outside = document.createElement("button"); - document.body.append(element, outside); - element.focus(); + // The list's own composer, and the composer of the surface it is nested in. + const composer = document.createElement("textarea"); + const parent = document.createElement("textarea"); + // A panel that owns the list: its header control is not inside the list. + const panel = document.createElement("aside"); + panel.dataset.readingSurface = ""; + const header = document.createElement("button"); + panel.append(header, element); + document.body.append(panel, composer, parent, outside); + ({ element, composer, parent, outside, header })[focus].focus(); vi.spyOn(element, "getClientRects").mockReturnValue([ new DOMRect(0, 0, 500, 500), ] as unknown as DOMRectList); @@ -63,6 +84,7 @@ function setup({ const leases: { view: ReturnType; observe: ReturnType; + catchUp: ReturnType; dispose: ReturnType; }[] = []; let observe = async () => {}; @@ -70,6 +92,7 @@ function setup({ const lease = { view: vi.fn(), observe: vi.fn(() => observe()), + catchUp: vi.fn(async () => {}), dispose: vi.fn(), }; leases.push(lease); @@ -82,11 +105,32 @@ function setup({ }, } as unknown as RelaySession; const view = renderHook(useReading, { - initialProps: { session, channelId: "room", scroller, settled: position }, + initialProps: { + session, + channelId: "room", + scroller, + settled: position, + latestMessageId, + rootId, + }, reactStrictMode: strict, + wrapper: ({ children }: { children: ReactNode }) => + createElement( + MessageEditScope, + null, + createElement(Composer, { input: parent }), + createElement( + MessageEditScope, + null, + createElement(Composer, { input: composer }), + children, + ), + ), }); return { element, + composer, + parent, outside, session, reading, @@ -107,6 +151,8 @@ function setup({ channelId, scroller, settled: position, + latestMessageId, + rootId, }), unmount: view.unmount, }; @@ -123,22 +169,108 @@ function row(id: string, top: number, bottom: number) { it("reports only fully visible settled evidence after dwell, not mounted overscan", () => { const h = setup(); expect(h.reading).toHaveBeenCalledExactlyOnceWith("room"); - vi.advanceTimersByTime(749); + vi.advanceTimersByTime(299); expect(h.leases[0]?.observe).not.toHaveBeenCalled(); vi.advanceTimersByTime(1); expect(h.leases[0]?.observe).toHaveBeenCalledExactlyOnceWith(["visible"]); }); -it.each([{ focused: false }, { settled: false }])( - "does not allocate reading from background/unsettled views: %j", +it.each([ + { focused: false }, + { settled: false }, + { focus: "parent" as const }, + { focus: "outside" as const }, +])( + "does not allocate reading from background, unsettled or other-surface views: %j", (options) => { const h = setup(options); vi.advanceTimersByTime(1000); expect(h.reading).not.toHaveBeenCalled(); }, ); -it("captures the cancellable lease before dwell and disposes it on hidden/unmount", () => { +it("the list's own composer earns dwell until the window loses focus", () => { + const h = setup({ focus: "composer" }); + vi.advanceTimersByTime(100); + window.dispatchEvent(new Event("blur")); + vi.advanceTimersByTime(1000); + expect(h.leases[0]?.observe).not.toHaveBeenCalled(); + expect(h.leases[0]?.dispose).toHaveBeenCalledOnce(); + window.dispatchEvent(new Event("focus")); + vi.advanceTimersByTime(299); + expect(h.leases[1]?.observe).not.toHaveBeenCalled(); + vi.advanceTimersByTime(1); + expect(h.leases[1]?.observe).toHaveBeenCalledExactlyOnceWith(["visible"]); +}); +it("focus anywhere in the panel that owns the list earns dwell", () => { + const h = setup({ focus: "header" }); + vi.advanceTimersByTime(300); + expect(h.leases[0]?.observe).toHaveBeenCalledExactlyOnceWith(["visible"]); +}); +it("only the owning selected tab earns dwell; inactive and restored content does not", () => { + const h = setup({ focus: "outside" }); + const pane = document.createElement("div"); + pane.setAttribute("role", "tabpanel"); + pane.setAttribute("aria-labelledby", "thread-tab"); + const tab = document.createElement("button"); + tab.id = "thread-tab"; + tab.setAttribute("role", "tab"); + tab.setAttribute("aria-selected", "true"); + const other = document.createElement("button"); + other.setAttribute("role", "tab"); + document.body.append(tab, other, pane); + const surface = h.element.closest("aside"); + expect(surface).not.toBeNull(); + if (surface) pane.append(surface); + // Remount after establishing the real tab/content ownership boundary. + h.retarget("thread-room"); + vi.advanceTimersByTime(300); + expect(h.reading).not.toHaveBeenCalled(); + other.focus(); + vi.advanceTimersByTime(300); + expect(h.reading).not.toHaveBeenCalled(); + tab.focus(); + vi.advanceTimersByTime(299); + expect(h.leases.at(-1)?.observe).not.toHaveBeenCalled(); + vi.advanceTimersByTime(1); + expect(h.leases.at(-1)?.observe).toHaveBeenCalledExactlyOnceWith(["visible"]); + h.mutation(); + const pending = h.leases.at(-1); + pane.setAttribute("inert", ""); + tab.setAttribute("aria-selected", "false"); + vi.advanceTimersByTime(300); + expect(pending?.observe).not.toHaveBeenCalled(); + expect(pending?.dispose).toHaveBeenCalledOnce(); + // Restoring visible content without moving focus back is still not reading. + other.focus(); + pane.removeAttribute("inert"); + tab.setAttribute("aria-selected", "true"); + h.mutation(); + const count = h.reading.mock.calls.length; + vi.advanceTimersByTime(300); + expect(h.reading).toHaveBeenCalledTimes(count); +}); +it("focus moving from the list to its composer keeps reading; another surface's composer cancels it", () => { const h = setup(); + vi.advanceTimersByTime(100); + h.composer.focus(); + vi.advanceTimersByTime(300); + expect(h.leases.at(-1)?.observe).toHaveBeenCalledExactlyOnceWith(["visible"]); + h.setRows([row("next", 100, 200)]); + h.mutation(); + const pending = h.leases.at(-1); + vi.advanceTimersByTime(100); + h.parent.focus(); + expect(pending?.dispose).toHaveBeenCalledOnce(); + const allocated = h.reading.mock.calls.length; + vi.advanceTimersByTime(1000); + expect(pending?.observe).not.toHaveBeenCalled(); + expect(h.reading).toHaveBeenCalledTimes(allocated); + h.composer.focus(); vi.advanceTimersByTime(300); + expect(h.leases.at(-1)?.observe).toHaveBeenCalledExactlyOnceWith(["next"]); +}); +it("captures the cancellable lease before dwell and disposes it on hidden/unmount", () => { + const h = setup(); + vi.advanceTimersByTime(100); h.setVisibility("hidden"); document.dispatchEvent(new Event("visibilitychange")); vi.advanceTimersByTime(1000); @@ -153,13 +285,13 @@ it("captures the cancellable lease before dwell and disposes it on hidden/unmoun }); it("scroll and content changes restart dwell; a row seen only at the end is not read", () => { const h = setup(); - vi.advanceTimersByTime(300); + vi.advanceTimersByTime(100); h.element.dispatchEvent(new Event("scroll")); expect(h.leases[0]?.dispose).toHaveBeenCalled(); - vi.advanceTimersByTime(300); + vi.advanceTimersByTime(100); h.setRows([row("replacement", 100, 200)]); h.mutation(); - vi.advanceTimersByTime(749); + vi.advanceTimersByTime(299); expect(h.leases[2]?.observe).not.toHaveBeenCalled(); h.setRows([row("new-at-end", 100, 200)]); vi.advanceTimersByTime(1); @@ -175,15 +307,12 @@ it("active content reflow cannot revoke dwell already queued for durability", as }), ); try { - vi.advanceTimersByTime(750); + vi.advanceTimersByTime(300); expect(h.leases[0]?.observe).toHaveBeenCalledExactlyOnceWith(["visible"]); h.mutation(); expect(h.leases[0]?.dispose).not.toHaveBeenCalled(); h.outside.focus(); - h.element.dispatchEvent( - Object.assign(new Event("focusout"), { relatedTarget: null }), - ); expect(h.leases[0]?.dispose).toHaveBeenCalledTimes(1); } finally { release?.(); @@ -194,9 +323,7 @@ it("active content reflow cannot revoke dwell already queued for durability", as it("focus leaving the reading surface cancels pending evidence", () => { const h = setup(); h.outside.focus(); - h.element.dispatchEvent( - Object.assign(new Event("focusout"), { relatedTarget: null }), - ); + expect(h.leases[0]?.dispose).toHaveBeenCalledOnce(); vi.advanceTimersByTime(1000); expect(h.leases[0]?.observe).not.toHaveBeenCalled(); }); @@ -227,7 +354,7 @@ it("membership activity cannot abort acknowledgment of a visible message below i const h = setup(); h.setRows([row("membership", 10, 50), row("conversation", 100, 200)]); h.mutation(); - vi.advanceTimersByTime(750); + vi.advanceTimersByTime(300); expect(h.leases.at(-1)?.observe).toHaveBeenCalledExactlyOnceWith([ "conversation", ]); @@ -235,7 +362,7 @@ it("membership activity cannot abort acknowledgment of a visible message below i it("releases the old channel and session on rerender and only observes the current lease", () => { const h = setup(); - vi.advanceTimersByTime(300); + vi.advanceTimersByTime(100); const oldValidity = h.leases[0]?.view.mock.calls[0]?.[1]; h.retarget("next-room"); expect(h.leases[0]?.dispose).toHaveBeenCalledOnce(); @@ -248,7 +375,7 @@ it("releases the old channel and session on rerender and only observes the curre }); expect(h.leases[1]?.dispose).toHaveBeenCalledOnce(); expect(nextReading).toHaveBeenCalledExactlyOnceWith("next-room"); - vi.advanceTimersByTime(749); + vi.advanceTimersByTime(299); for (const lease of h.leases) expect(lease.observe).not.toHaveBeenCalled(); vi.advanceTimersByTime(1); expect(h.leases[2]?.observe).toHaveBeenCalledExactlyOnceWith(["visible"]); @@ -277,5 +404,46 @@ it("StrictMode releases its probe lease and disconnects observation on unmount", }); function elementEvents(element: HTMLElement) { for (const name of ["scroll", "pointerdown", "keydown", "focusin"]) - element.dispatchEvent(new Event(name)); + element.dispatchEvent(new Event(name, { bubbles: true })); } + +it("bottom dwell catches up through the newest row, including a tall clipped row", async () => { + const h = setup({ latestMessageId: "bottom", rootId: "thread" }); + h.setRows([row("bottom", -200, 500)]); + h.mutation(); + await vi.advanceTimersByTimeAsync(299); + expect(h.leases.at(-1)?.catchUp).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(1); + expect(h.leases.at(-1)?.catchUp).toHaveBeenCalledExactlyOnceWith( + "bottom", + "thread", + ); +}); +it("a historical viewport or a bottom reached only at dwell end cannot catch up", async () => { + const h = setup({ latestMessageId: "visible" }); + vi.spyOn(h.element, "scrollHeight", "get").mockReturnValue(1000); + vi.spyOn(h.element, "clientHeight", "get").mockReturnValue(500); + h.mutation(); + await vi.advanceTimersByTimeAsync(299); + h.element.scrollTop = 500; + await vi.advanceTimersByTimeAsync(1); + expect(h.leases.at(-1)?.catchUp).not.toHaveBeenCalled(); + h.mutation(); + await vi.advanceTimersByTimeAsync(300); + expect(h.leases.at(-1)?.catchUp).toHaveBeenCalledExactlyOnceWith( + "visible", + undefined, + ); +}); + +it("settled notification starts dwell without changed rows, focus or geometry", () => { + const h = setup({ focus: "header", settled: false }); + vi.advanceTimersByTime(300); + expect(h.reading).not.toHaveBeenCalled(); + h.position.current = true; + readingPositioned(h.element); + vi.advanceTimersByTime(299); + expect(h.leases[0]?.observe).not.toHaveBeenCalled(); + vi.advanceTimersByTime(1); + expect(h.leases[0]?.observe).toHaveBeenCalledExactlyOnceWith(["visible"]); +}); diff --git a/src/features/messages/use-reading.ts b/src/features/messages/use-reading.ts index 1ba7d2420f..4fdbfce331 100644 --- a/src/features/messages/use-reading.ts +++ b/src/features/messages/use-reading.ts @@ -1,19 +1,40 @@ import { useEffect, type RefObject } from "react"; import type { RelaySession } from "../relay/session"; import type { ReadingHandle } from "../relay/unread"; +import { useMessageEditScope } from "./MessageEditScope"; -/** Consumer-owned observation: focused, visible, settled rows, never virtualizer overscan. */ +/** Wake dwell after owner-controlled positioning, even when geometry is unchanged. */ +export function readingPositioned(element: HTMLElement | null) { + element?.dispatchEvent(new Event("reading-positioned")); +} + +type Reading = { + session: RelaySession; + channelId: string; + latestMessageId?: string | undefined; + rootId?: string | undefined; + scroller: RefObject; + settled: RefObject; +}; +/** `useReading` for an owner that renders its own `MessageEditScope`. */ +export function Reading(props: Reading) { + useReading(props); + return null; +} +/** + * Consumer-owned observation: focused, visible, settled rows, never virtualizer + * overscan. Call it inside the `MessageEditScope` this message list shares with + * its composer, so focus in that composer counts and another surface's does not. + */ export function useReading({ session, channelId, + latestMessageId, + rootId, scroller, settled, -}: { - session: RelaySession; - channelId: string; - scroller: RefObject; - settled: RefObject; -}) { +}: Reading) { + const composer = useMessageEditScope()?.input; useEffect(() => { if (!scroller.current) return; const element: HTMLElement = scroller.current; @@ -21,13 +42,36 @@ export function useReading({ let timer: ReturnType | undefined; let stopped = false; const observing = new Set(); + // The message list or its own composer: typing under a conversation is + // reading it. A parent scope's composer belongs to another surface. A panel + // that owns the list opts in whole, so opening it is enough. + const surface = element.closest("[data-reading-surface]"); + // A tabbed thread's header is outside its content, but aria-labelledby + // identifies its owning tab. Never borrow focus from a sibling tab. + const tabPanel = surface?.closest('[role="tabpanel"]'); + const focusedTab = (node: EventTarget | null) => { + const label = tabPanel?.getAttribute("aria-labelledby"); + const tab = label ? document.getElementById(label) : null; + return ( + tab?.getAttribute("role") === "tab" && + tab.getAttribute("aria-selected") === "true" && + tab.contains(node as Node | null) + ); + }; + const focused = (node: EventTarget | null) => + node instanceof Node && + (element.contains(node) || + !!surface?.contains(node) || + focusedTab(node) || + !!composer?.current?.contains(node)); const active = () => !stopped && element.isConnected && + !element.closest('[inert], [hidden], [aria-hidden="true"]') && settled.current && document.visibilityState === "visible" && document.hasFocus() && - element.contains(document.activeElement) && + focused(document.activeElement) && element.getClientRects().length > 0; function cancel() { if (timer) clearTimeout(timer); @@ -58,11 +102,33 @@ export function useReading({ }) .slice(0, 128); } + function bottomId() { + if ( + !latestMessageId || + element.scrollHeight - element.clientHeight - element.scrollTop > 1 + ) + return; + const viewport = element.getBoundingClientRect(); + const row = [ + ...element.querySelectorAll("[data-message-id]"), + ].find((row) => row.dataset.messageId === latestMessageId); + const bounds = row?.getBoundingClientRect(); + return bounds && + bounds.height > 0 && + bounds.width > 0 && + bounds.bottom > Math.max(viewport.top, 0) && + bounds.bottom <= Math.min(viewport.bottom, window.innerHeight) && + bounds.left >= Math.max(viewport.left, 0) && + bounds.right <= Math.min(viewport.right, window.innerWidth) + ? latestMessageId + : undefined; + } function schedule() { cancel(); if (!active()) return; const ids = visibleIds(); - if (!ids.length) return; + const bottom = bottomId(); + if (!ids.length && !bottom) return; try { // Capture the lease BEFORE dwell: a newer manual action invalidates it. handle = session.unread.reading(channelId); @@ -79,8 +145,9 @@ export function useReading({ const visible = new Set(visibleIds()); // A row appearing only at the end of the interval has not had a dwell. const remained = ids.filter((id) => visible.has(id)); + const caughtUp = bottom && bottom === bottomId(); if ( - remained.length && + (remained.length || caughtUp) && session.unread.sync().capability === "frontier-sync" && handle ) { @@ -89,24 +156,30 @@ export function useReading({ const observed = handle; handle = undefined; observing.add(observed); - void observed - .observe(remained) + void (async () => { + if (caughtUp) await observed.catchUp(bottom, rootId); + await observed.observe(remained); + })() .catch(() => {}) .finally(() => { if (observing.delete(observed)) observed.dispose(); }); } - }, 750); + }, 300); } - for (const event of ["scroll", "pointerdown", "keydown"]) + for (const event of [ + "scroll", + "pointerdown", + "keydown", + "reading-positioned", + ]) element.addEventListener(event, schedule); - const focusin = () => schedule(); - const focusout = (event: FocusEvent) => - event.relatedTarget && element.contains(event.relatedTarget as Node) + const focus = (event: FocusEvent) => + focused(event.type === "focusin" ? event.target : event.relatedTarget) ? schedule() : stop(); - element.addEventListener("focusin", focusin); - element.addEventListener("focusout", focusout); + document.addEventListener("focusin", focus); + document.addEventListener("focusout", focus); window.addEventListener("blur", stop); window.addEventListener("focus", schedule); const visibility = () => @@ -126,13 +199,26 @@ export function useReading({ stop(); mutation.disconnect(); resize.disconnect(); - for (const event of ["scroll", "pointerdown", "keydown"]) + for (const event of [ + "scroll", + "pointerdown", + "keydown", + "reading-positioned", + ]) element.removeEventListener(event, schedule); - element.removeEventListener("focusin", focusin); - element.removeEventListener("focusout", focusout); + document.removeEventListener("focusin", focus); + document.removeEventListener("focusout", focus); window.removeEventListener("blur", stop); window.removeEventListener("focus", schedule); document.removeEventListener("visibilitychange", visibility); }; - }, [session, channelId, scroller, settled]); + }, [ + session, + channelId, + latestMessageId, + rootId, + scroller, + settled, + composer, + ]); } diff --git a/src/features/messages/virtua-compensation.test.mjs b/src/features/messages/virtua-compensation.test.mjs index 7c0cfad59e..bdef23983e 100644 --- a/src/features/messages/virtua-compensation.test.mjs +++ b/src/features/messages/virtua-compensation.test.mjs @@ -377,3 +377,64 @@ it("drops retired targets, cancels pending delivery on disposal, and can remount h.flush(); expect(h.received).toEqual([[retained], [remounted]]); }); + +// The timer stays at zero: measurement delivery is explicitly inside the +// retained command's 150ms lifetime, not after an input-settle helper expires it. +it.each(["wheel", "touchmove", "keydown", "pointerdown"])( + "%s retires a pending imperative target before late measurement", + async (type) => { + const c = setup({ platform: "Linux x86_64", offset: 0 }); + c.store.W( + 3, + Array.from({ length: 20 }, (_, index) => [index, 100]), + ); + await c.driver.V(() => c.store.u(19) + c.store.h(19) - c.store.o(), false); + expect(c.viewport.scrollTop).toBe(1500); + c.viewport.dispatchEvent(Object.assign(new Event(type), { deltaY: -500 })); + c.viewport.scrollTop = 1000; + c.viewport.dispatchEvent(new Event("scroll")); + c.calls.length = 0; + c.store.W(5, [21, false]); + c.store.W(3, [[20, 120]]); + await vi.advanceTimersByTimeAsync(0); + expect(c.viewport.scrollTop).toBe(1000); + expect(c.calls).toEqual([]); + // A fresh navigation still owns its target and corrects later measurements. + await c.driver.V(() => c.store.t() - c.store.o(), false); + c.store.W(3, [[20, 140]]); + await vi.advanceTimersByTimeAsync(0); + expect(c.viewport.scrollTop).toBe(1640); + c.driver._(); + }, +); + +it.each(["input", "dispose"])( + "%s invalidates an already queued imperative measurement replay", + async (cancel) => { + const c = setup({ platform: "Linux x86_64", offset: 0 }); + await c.driver.V(() => 1500, false); + c.calls.length = 0; + c.store.W(3, [[19, 120]]); // queue the replay, but do not run it yet + if (cancel === "input") c.viewport.dispatchEvent(new Event("wheel")); + else c.driver._(); + await vi.advanceTimersByTimeAsync(0); + expect(c.calls).toEqual([]); + if (cancel === "input") c.driver._(); + }, +); + +it("removes cancellation listeners on disposal and reattaches on remount", async () => { + const c = setup({ platform: "Linux x86_64", offset: 0 }); + const remove = vi.spyOn(c.viewport, "removeEventListener"); + c.driver._(); + for (const type of ["wheel", "touchmove", "keydown", "pointerdown"]) + expect(remove).toHaveBeenCalledWith(type, expect.any(Function), true); + c.driver.D({}, c.viewport); + await c.driver.V(() => 1500, false); + c.viewport.dispatchEvent(new Event("wheel")); + c.calls.length = 0; + c.store.W(3, [[19, 120]]); + await vi.advanceTimersByTimeAsync(0); + expect(c.calls).toEqual([]); + c.driver._(); +}); diff --git a/src/features/relay/unread.test.ts b/src/features/relay/unread.test.ts index 2554af6f9e..186419a607 100644 --- a/src/features/relay/unread.test.ts +++ b/src/features/relay/unread.test.ts @@ -27,7 +27,11 @@ import { decodeReadState, signReadState } from "../../../dev/read-state.mjs"; const owners: ReturnType[] = []; afterEach(() => { for (const owner of owners.splice(0)) owner.dispose(); + vi.restoreAllMocks(); }); +/** The device clock, in seconds, that an explicit channel read captures. */ +const clock = (seconds: number) => + vi.spyOn(Date, "now").mockReturnValue(seconds * 1000); function setup( options: ChannelStoreOptions = {}, signer = true, @@ -39,6 +43,7 @@ function setup( let journal: ReadJournal | undefined = preloaded?.(newReadJournal()); let hold: Promise | undefined; let commitHold: Promise | undefined; + let committedSignal: (() => void) | undefined; let started: (() => void) | undefined; let failNextSave = false; let failure: Error | undefined; @@ -64,6 +69,8 @@ function setup( if (commitHold) { const wait = commitHold; commitHold = undefined; + committedSignal?.(); + committedSignal = undefined; await wait; } return journal; @@ -151,10 +158,15 @@ function setup( * like a storage transaction still committing when relay events arrive. */ holdCommit() { let release = () => {}; + let signal = () => {}; + const committed = new Promise((resolve) => { + signal = resolve; + }); + committedSignal = signal; commitHold = new Promise((resolve) => { release = resolve; }); - return release; + return { committed, release }; }, }; } @@ -1422,7 +1434,7 @@ it("without a signer, own descendants do not require read frontiers to clear a f expect(h.journal()?.state.frontiers[`msg:${peer.id}`]).toBe(11); }); -it("channel read atomically clears owned marks through the latest reply, preserving other channels and later arrivals", async () => { +it("channel read atomically clears owned marks through the click, preserving other channels and later arrivals", async () => { const h = setup(); h.grant("room"); h.grant("other"); @@ -1450,15 +1462,21 @@ it("channel read atomically clears owned marks through the latest reply, preserv await unread.markUnreadLocal({ kind: "channel", channelId: "other" }); const before = h.journal(); const release = h.holdSave(); + clock(25); const pending = unread.markChannelRead("room"); - // Arrival is beyond the captured frontier while its durable transaction waits. - const later = message(h.alice, "room", "later", 21); - h.emit([later]); + // The cut is the click, not the moment its durable transaction completes. + clock(30); + h.emit([ + // Posted before the click but only loaded afterwards: read. + message(h.alice, "room", "missed", 22), + // Posted after the click while the save waits: unread. + message(h.alice, "room", "later", 26), + ]); expect(h.snapshot().manual).toBe("local-only"); release(); expect(await pending).toMatchObject({ durability: "saved", sync: "pending" }); expect(h.journal()?.revision).toBe((before?.revision ?? 0) + 1); - expect(h.journal()?.state.frontiers).toEqual({ room: 20 }); + expect(h.journal()?.state.frontiers).toEqual({ room: 25 }); expect(h.journal()?.localUnread).toEqual({ other: before?.localUnread.other, }); @@ -1490,6 +1508,8 @@ it("mark all serialises channel reads over listed channels, skips read ones and message(h.alice, "room", "one", 11), message(h.alice, "other", "two", 12), ]); + // A clock behind the evidence leaves each cut at its channel's newest message. + clock(0); const unread = h.session.unread; const results = await unread.markAllChannelsRead(); expect(results).toHaveLength(2); @@ -1529,45 +1549,68 @@ it("mark all serialises channel reads over listed channels, skips read ones and ).toBe(0); }); -it("mark all skips a channel whose grant is revoked mid-sweep instead of failing", async () => { - const h = setup(); - h.grant("room"); - h.grant("other"); - h.emit([ - message(h.alice, "room", "one", 11), - message(h.alice, "other", "two", 12), - ]); - const cut = { room: 11, other: 12 }; - const [first, second] = h.session.channels - .list() - .channels.map((channel) => channel.id) - .filter((id) => id in cut) as (keyof typeof cut)[]; - assert(first && second); - // Let the initial journal save settle so the hold lands on the sweep's write. - await flush(); - // The first channel's write is still committing when the second grant goes. - const release = h.holdCommit(); - const sweep = h.session.unread.markAllChannelsRead(); - await flush(); - expect(h.journal()?.state.frontiers).toEqual({ [first]: cut[first] }); - h.emit([roster(h.relay, second, [], 20)]); - release(); - const results = await sweep; - expect(results).toHaveLength(1); - expect(results[0]?.durability).toBe("saved"); - // The revoked channel earned neither a frontier nor a failure. - expect(h.journal()?.state.frontiers).toEqual({ [first]: cut[first] }); -}); +it.each(["revoke", "grant"] as const)( + "mark all keeps untouched channels and click-time cuts across a mid-sweep %s", + async (action) => { + const h = setup(); + const cut = { room: 11, other: 12, third: 13 }; + for (const [id, timestamp] of Object.entries(cut)) { + h.grant(id); + h.emit([message(h.alice, id, id, timestamp)]); + } + clock(20); + const [first, second, third] = h.session.channels + .list() + .channels.map((channel) => channel.id); + assert(first && second && third); + await flush(); + const held = h.holdCommit(); + const sweep = h.session.unread.markAllChannelsRead(); + // Attach before releasing the save so a regression is an assertion failure, + // not an unhandled rejection while the ordering gate is held. + const result = expect(sweep).resolves.toHaveLength( + action === "revoke" ? 2 : 3, + ); + try { + await held.committed; + expect(h.journal()?.state.frontiers).toEqual({ [first]: 20 }); + if (action === "revoke") h.emit([roster(h.relay, second, [], 30)]); + else { + h.grant("new", 30); + h.emit([message(h.alice, "new", "not selected", 31)]); + } + clock(40); + h.emit([message(h.alice, third, "after the click", 35)]); + } finally { + held.release(); + } + await result; + expect(h.journal()?.state.frontiers).toEqual({ + [first]: 20, + ...(action === "grant" ? { [second]: 20 } : {}), + [third]: 20, + }); + expect( + h.session.unread.snapshot({ kind: "channel", channelId: third }) + .observedCount, + ).toBe(1); + }, +); -it("channel read clears local intent without fabricating a frontier when no messages are known", async () => { - const h = setup(); - h.grant("room"); - await h.session.unread.markUnreadLocal(h.target); - await h.session.unread.markChannelRead("room"); - expect(h.journal()?.state.frontiers).toEqual({}); - expect(h.snapshot()).toMatchObject({ observedCount: null, manual: "none" }); - expect(h.host.sign).not.toHaveBeenCalled(); -}); +it.each([true, false])( + "empty channel read saves a click cutoff only with frontier support (%s)", + async (signer) => { + const h = setup({}, signer); + h.grant("room"); + clock(20); + await h.session.unread.markUnreadLocal(h.target); + await h.session.unread.markChannelRead("room"); + expect(h.journal()?.state.frontiers).toEqual(signer ? { room: 20 } : {}); + expect(h.snapshot()).toMatchObject({ observedCount: null, manual: "none" }); + h.emit([message(h.alice, "room", "late history", 15)]); + expect(h.snapshot().observedCount).toBe(signer ? 0 : 1); + }, +); it.each(["clearCache", "dispose", "revoke-regrant"] as const)( "channel read rejects delayed intent after %s without clearing saved marks", @@ -1611,6 +1654,7 @@ it("channel read cannot use another channel, deleted content or auxiliary events content: "", }), ]); + clock(0); await h.session.unread.markChannelRead("room"); expect(h.journal()?.state.frontiers).toEqual({ room: 11 }); await expect(h.session.unread.markChannelRead("denied")).rejects.toThrow( @@ -1711,6 +1755,7 @@ it.each(["channel", "thread", "message"] as const)( const held = h.holdSaveStarted(); const messageRead = unread.markMessageRead("room", row.id); const pending = [messageRead]; + clock(15); try { await held.started; pending.push(unread.markChannelRead("room")); @@ -1719,7 +1764,7 @@ it.each(["channel", "thread", "message"] as const)( held.release(); } await Promise.all(pending); - expect(h.journal()?.state.frontiers.room).toBe(11); + expect(h.journal()?.state.frontiers.room).toBe(15); expect(h.journal()?.localUnread[key]).toBeGreaterThan(0); expect(unread.snapshot(target).manual).toBe("local-only"); }, @@ -1758,9 +1803,14 @@ it.each(["markThrough", "clearUnreadLocal", "markChannelRead"] as const)( it.each( (["clearCache", "dispose", "revoke-regrant"] as const).flatMap((action) => - (["markUnreadLocal", "markMessageRead", "markMessageUnread"] as const).map( - (markAction) => ({ action, markAction }), - ), + ( + [ + "markUnreadLocal", + "clearUnreadLocal", + "markMessageRead", + "markMessageUnread", + ] as const + ).map((markAction) => ({ action, markAction })), ), )( "queued $markAction stays invalid after $action", @@ -1777,8 +1827,8 @@ it.each( try { await held.started; const mark = - markAction === "markUnreadLocal" - ? h.session.unread.markUnreadLocal(h.target) + markAction === "markUnreadLocal" || markAction === "clearUnreadLocal" + ? h.session.unread[markAction](h.target) : h.session.unread[markAction]("room", row.id); pending.push(expect(mark).rejects.toThrow()); if (action === "revoke-regrant") { @@ -1835,3 +1885,332 @@ it.each(["markMessageRead", "markMessageUnread"] as const)( await expect(h.session.unread[action]("room", "missing")).rejects.toThrow(); }, ); + +it("bottom catch-up preserves mentions, broadcasts, participating threads and later activity", async () => { + const h = setup(); + h.grant("room"); + const own = message(h.viewer, "room", "my thread", 11); + const reply = message(h.alice, "room", "reply to me", 12, [ + ["e", own.id, "", "reply"], + ]); + const ordinaryRoot = message(h.alice, "room", "their thread", 13); + const ordinaryReply = message(h.alice, "room", "ordinary reply", 14, [ + ["e", ordinaryRoot.id, "", "reply"], + ]); + const mention = message(h.alice, "room", "mention", 15, [ + ["p", h.viewer.pubkey], + ]); + const broadcast = message(h.alice, "room", "broadcast", 16, [ + ["broadcast", "1"], + ]); + const bottom = message(h.alice, "room", "bottom", 20); + h.emit([own, reply, ordinaryRoot, ordinaryReply, mention, broadcast, bottom]); + const lease = h.session.unread.reading("room"); + await lease.catchUp(bottom.id); + expect(h.journal()?.state.frontiers).toEqual({ "activity:room": 20 }); + expect(h.snapshot()).toMatchObject({ observedCount: 3, attentionCount: 3 }); + expect(h.session.unread.attention("room", reply.id).unread).toBe(true); + expect( + h.session.unread.snapshot({ + kind: "thread", + channelId: "room", + rootId: ordinaryRoot.id, + }).observedCount, + ).toBe(1); + h.emit([ + message(h.alice, "room", "late old ordinary", 19), + message(h.alice, "room", "new", 21), + ]); + expect(h.snapshot()).toMatchObject({ observedCount: 4, attentionCount: 3 }); + // Participation discovered later must restore protected attention, not inherit catch-up. + h.emit([ + message(h.viewer, "room", "earlier participation", 13, [ + ["e", ordinaryRoot.id, "", "reply"], + ]), + ]); + expect(h.session.unread.attention("room", ordinaryReply.id)).toMatchObject({ + category: "thread", + unread: true, + }); + lease.dispose(); +}); + +it("ordinary catch-up never clears DM attention", async () => { + const h = setup(); + h.grant("room"); + const row = message(h.alice, "room", "direct", 11); + h.emit([ + row, + signed(h.relay, { + kind: 39000, + created_at: 20, + content: "", + tags: [ + ["d", "room"], + ["name", "DM"], + ["t", "dm"], + ], + }), + ]); + const lease = h.session.unread.reading("room"); + await lease.catchUp(row.id); + expect(h.snapshot()).toMatchObject({ observedCount: 1, attentionCount: 1 }); + lease.dispose(); +}); + +it("thread bottom catch-up clears only its thread and preserves local and remote manual intent", async () => { + const h = setup(); + h.grant("room"); + const root = message(h.viewer, "room", "root", 11); + const other = message(h.viewer, "room", "other root", 12); + const reply = message(h.alice, "room", "mention", 13, [ + ["e", root.id, "", "reply"], + ["p", h.viewer.pubkey], + ]); + const sibling = message(h.alice, "room", "other reply", 14, [ + ["e", other.id, "", "reply"], + ]); + h.emit([root, other, reply, sibling]); + const thread = { + kind: "thread" as const, + channelId: "room", + rootId: root.id, + }; + await h.session.unread.markUnreadLocal(thread); + const lease = h.session.unread.reading("room"); + await lease.catchUp(reply.id, root.id); + expect(h.journal()?.state.frontiers).toEqual({ + [`thread-activity:${root.id}`]: 13, + }); + expect(h.session.unread.snapshot(thread)).toMatchObject({ + observedCount: 0, + manual: "local-only", + }); + expect(h.session.unread.attention("room", sibling.id).unread).toBe(true); + // Real encrypted marker: automatic thread catch-up must not retire a remote override. + clock(30); + const marker = await signReadState( + { + slot: "a".repeat(32), + createdAt: 30, + blob: { + v: 1, + client_id: "peer", + contexts: { + [`ov_s:thread:${root.id}`]: 1, + [`ov_c:thread:${root.id}`]: 0, + [`ov_b:thread:${root.id}`]: 0, + }, + }, + }, + h.viewer.secret, + ); + h.emit([marker]); + await flush(); + expect(h.session.unread.attention("room", reply.id).unread).toBe(true); + lease.dispose(); +}); + +it("catch-up rejects historical heads, failed storage and expired leases without saving intent", async () => { + const h = setup(); + h.grant("room"); + const old = message(h.alice, "room", "old", 11); + const bottom = message(h.alice, "room", "bottom", 20); + h.emit([old, bottom]); + const lease = h.session.unread.reading("room"); + await lease.catchUp(old.id); + expect(h.journal()?.state.frontiers ?? {}).toEqual({}); + await flush(); + h.failSave(); + await expect(lease.catchUp(bottom.id)).rejects.toThrow("disk full"); + expect(h.snapshot().observedCount).toBe(2); + const held = h.holdSaveStarted(); + const pending = lease.catchUp(bottom.id); + const rejected = expect(pending).rejects.toThrow("expired"); + await held.started; + lease.dispose(); + held.release(); + await rejected; + expect(h.journal()?.state.frontiers).toEqual({}); +}); + +it("mark all captures later channels at invocation, not after the first save", async () => { + const h = setup(); + h.grant("room"); + h.grant("other"); + h.emit([ + message(h.alice, "room", "one", 11), + message(h.alice, "other", "two", 12), + ]); + await flush(); + const ids = h.session.channels.list().channels.map((channel) => channel.id); + const second = ids[1]; + assert(second); + clock(20); + const held = h.holdSaveStarted(); + const sweep = h.session.unread.markAllChannelsRead(); + await held.started; + clock(30); + h.emit([message(h.alice, second, "after click", 21)]); + held.release(); + await sweep; + expect(h.journal()?.state.frontiers).toEqual({ room: 20, other: 20 }); + expect( + h.session.unread.snapshot({ kind: "channel", channelId: second }) + .observedCount, + ).toBe(1); +}); + +it("channel bottom quiets replies newer than the top-level head; Mark all still reads their receipts", async () => { + const h = setup(); + h.grant("room"); + const root = message(h.alice, "room", "root", 11); + const reply = message(h.alice, "room", "newer reply", 15, [ + ["e", root.id, "", "reply"], + ]); + h.emit([root, reply]); + const unread = h.session.unread; + const thread = { + kind: "thread" as const, + channelId: "room", + rootId: root.id, + }; + const lease = unread.reading("room"); + await lease.catchUp(root.id); + expect(h.journal()?.state.frontiers).toEqual({ "activity:room": 15 }); + expect(h.snapshot().observedCount).toBe(0); + expect(unread.snapshot(thread).observedCount).toBe(1); + // Same visible head, newer retained activity: another dwell can quiet it too. + const next = message(h.alice, "room", "next reply", 16, [ + ["e", root.id, "", "reply"], + ]); + h.emit([next]); + expect(h.snapshot().observedCount).toBe(1); + await lease.catchUp(root.id); + expect(h.snapshot().observedCount).toBe(0); + expect(unread.snapshot(thread).observedCount).toBe(2); + clock(20); + expect(await unread.markAllChannelsRead()).toHaveLength(1); + expect(unread.snapshot(thread).observedCount).toBe(0); + expect(await unread.markAllChannelsRead()).toEqual([]); + lease.dispose(); +}); + +it.each(["none", "grant", "revoke"])( + "Mark all preserves newer channel and thread unread intent across unrelated %s", + async (change) => { + const h = setup(); + h.grant("room"); + h.grant("other"); + const roots = [ + message(h.alice, "room", "one", 11), + message(h.alice, "other", "two", 12), + ]; + h.emit(roots); + await flush(); + const second = h.session.channels.list().channels[1]?.id; + const root = roots.find((row) => + row.tags.some(([key, id]) => key === "h" && id === second), + ); + assert(second && root); + const held = h.holdSaveStarted(); + const sweep = h.session.unread.markAllChannelsRead(); + await held.started; + const channel = { kind: "channel" as const, channelId: second }; + const thread = { + kind: "thread" as const, + channelId: second, + rootId: root.id, + }; + const markChannel = h.session.unread.markUnreadLocal(channel); + const markThread = h.session.unread.markUnreadLocal(thread); + if (change === "grant") h.grant("unrelated"); + if (change === "revoke") { + // Revoke a third, unselected channel, leaving both selected channels valid. + h.grant("unrelated"); + h.emit([roster(h.relay, "unrelated", [], 20)]); + } + held.release(); + await Promise.all([sweep, markChannel, markThread]); + expect(h.session.unread.snapshot(channel).manual).toBe("local-only"); + expect(h.session.unread.snapshot(thread).manual).toBe("local-only"); + }, +); + +it.each(["channel", "thread", "message"] as const)( + "queued %s manual clear survives an unrelated grant", + async (kind) => { + const h = setup(); + h.grant("room"); + const row = message(h.alice, "room", "root", 11); + h.emit([row]); + const target = + kind === "channel" + ? h.target + : kind === "thread" + ? { kind, channelId: "room", rootId: row.id } + : { kind, channelId: "room", messageId: row.id }; + const held = h.holdSaveStarted(); + const mark = h.session.unread.markUnreadLocal(target); + let clear: Promise | undefined; + try { + await held.started; + clear = h.session.unread.clearUnreadLocal(target); + h.grant("unrelated"); + } finally { + held.release(); + } + await Promise.all([mark, clear]); + expect(h.session.unread.snapshot(target).manual).toBe("none"); + expect(h.journal()?.localUnread).toEqual({}); + }, +); + +it.each(["channel", "thread", "message"] as const)( + "cleared %s override floors do not block reply quieting, but active overrides do", + async (target) => { + const h = setup(); + h.grant("room"); + const root = message(h.alice, "room", "root", 11); + const reply = message(h.alice, "room", "reply", 12, [ + ["e", root.id, "", "reply"], + ]); + h.emit([root, reply]); + const key = + target === "channel" + ? "room" + : target === "thread" + ? `thread:${root.id}` + : `msg:${reply.id}`; + const marker = async (set: number, clear: number) => { + clock(30 + set); + return signReadState( + { + slot: "a".repeat(32), + createdAt: 30 + set, + blob: { + v: 1, + client_id: "peer", + contexts: { + [`ov_s:${key}`]: set, + [`ov_c:${key}`]: clear, + [`ov_b:${key}`]: 0, + }, + }, + }, + h.viewer.secret, + ); + }; + h.emit([await marker(0, 1)]); + await flush(); + const lease = h.session.unread.reading("room"); + await lease.catchUp(root.id); + expect(h.snapshot().observedCount).toBe(0); + expect(h.session.unread.attention("room", reply.id).unread).toBe(true); + h.emit([await marker(2, 1)]); + await flush(); + expect(h.snapshot().observedCount).toBeGreaterThan(0); + expect(h.session.unread.attention("room", reply.id).unread).toBe(true); + lease.dispose(); + }, +); diff --git a/src/features/relay/unread.ts b/src/features/relay/unread.ts index 3f481903b9..d7bd449c5d 100644 --- a/src/features/relay/unread.ts +++ b/src/features/relay/unread.ts @@ -61,6 +61,8 @@ export type ReadingHandle = Readonly<{ view(messageIds: readonly string[], visible: () => boolean): void; /** Only message IDs actually visible to the active consumer; no caller timestamps. */ observe(messageIds: readonly string[]): Promise; + /** Bottom dwell through verified evidence; channel catch-up excludes attention. */ + catchUp(messageId: string, rootId?: string): Promise; dispose(): void; }>; export interface UnreadCapability { @@ -195,6 +197,9 @@ export function createUnread({ const messageForceKey = (channelId: string) => `message-force:${channelId}`; const visits = new Map(); const mutations = new Map>(); + // Explicit channel reads survive unrelated roster changes, but never their + // own revoke/regrant or a session/cache reset. Tokens cannot be revived. + const channelReadGenerations = new Map(); function serialize( channelId: string, operation: () => Promise, @@ -291,7 +296,8 @@ export function createUnread({ (target.kind === "thread" && rootId === target.rootId) ); } - function isUnread({ event, channelId, rootId }: Evidence, state: ReadState) { + function isUnread(entry: Evidence, state: ReadState, dm: boolean) { + const { event, channelId, rootId } = entry; if (event.pubkey === viewer) return false; const frontier = effectiveFrontier( state, @@ -310,11 +316,21 @@ export function createUnread({ state.overrides[`thread:${rootId}`], effectiveFrontier(state, `thread:${rootId}`, channelId), )); + // Channel catch-up never acknowledges thread replies: retained evidence + // cannot prove nonparticipation, especially after reload. Only ordinary + // top-level messages inherit it; reply attention/direct thread dots survive. + const ordinary = + !threadReference(event) && !priority(entry, dm) + ? state.frontiers[`activity:${channelId}`] + : undefined; + const thread = rootId + ? state.frontiers[`thread-activity:${rootId}`] + : undefined; + const caughtUp = Math.max(frontier ?? -1, ordinary ?? -1, thread ?? -1); return ( forcedMessages.get(channelId)?.has(event.id) || !!reads.localUnread(`msg:${event.id}`) || - frontier === undefined || - event.created_at > frontier || + event.created_at > caughtUp || !!forced ); } @@ -378,7 +394,7 @@ export function createUnread({ ...(kind ? { category: kind } : {}), ...(entry.mentioned ? { mentioned: true } : {}), ...(entry.rootId ? { rootId: entry.rootId } : {}), - unread: isUnread(entry, reads.state()), + unread: isUnread(entry, reads.state(), dm), forced: forcedMessages.get(channelId)?.has(messageId) ?? false, viewing, }); @@ -427,7 +443,37 @@ export function createUnread({ (event.created_at === latest.created_at && event.id < latest.id)) ) latest = event; - if (!inTarget(entry, target) || !isUnread(entry, state)) continue; + if (!inTarget(entry, target) || !isUnread(entry, state, dm)) continue; + // Quiet ordinary sidebar activity at the channel bottom without reading + // the replies themselves. Late participation can still promote their dot. + if ( + target.kind === "channel" && + entry.rootId && + !priority(entry, dm) && + !forcedMessages.get(target.channelId)?.has(event.id) && + !reads.localUnread(`msg:${event.id}`) && + !reads.localUnread(`thread:${entry.rootId}`) && + !overrideActive( + state.overrides[`msg:${event.id}`], + effectiveFrontier( + state, + `msg:${event.id}`, + target.channelId, + entry.rootId, + ), + ) && + !overrideActive( + state.overrides[`thread:${entry.rootId}`], + effectiveFrontier(state, `thread:${entry.rootId}`, target.channelId), + ) && + !overrideActive( + state.overrides[target.channelId], + effectiveFrontier(state, target.channelId), + ) && + event.created_at <= + (state.frontiers[`activity:${target.channelId}`] ?? -1) + ) + continue; count++; if (priority(entry, dm)) attention++; } @@ -476,6 +522,9 @@ export function createUnread({ }); indexEvidence(); const state = reads.state(); + const dm = + channels.list().channels.find((channel) => channel.id === channelId) + ?.channelType === "dm"; const grouped = new Map(); // Only unread thread activity is presented; most publishes have none. let presented: Map | undefined; @@ -487,7 +536,7 @@ export function createUnread({ if ( !rootId || (!mentioned && !broadcast && !participants.has(rootId)) || - !isUnread(evidence, state) + !isUnread(evidence, state, dm) ) continue; const current = grouped.get(rootId); @@ -641,6 +690,8 @@ export function createUnread({ function purge() { // A revoke/regrant must not revive a transaction accepted under the old access epoch. epoch++; + for (const channelId of channelReadGenerations.keys()) + if (!allowed(channelId)) channelReadGenerations.delete(channelId); const denied = new Set( [...known, ...forcedMessages.keys()].filter( (channel) => !allowed(channel), @@ -866,6 +917,50 @@ export function createUnread({ return event ? [event] : []; }); } + // Queued explicit reads and manual intent share the same access fence. + function channelIntentValid(channelId: string) { + const generation = channelReadGenerations.get(channelId) ?? {}; + channelReadGenerations.set(channelId, generation); + return () => + !closed && + channelReadGenerations.get(channelId) === generation && + allowed(channelId); + } + // Capture evidence and time once per click, including every channel in a sweep. + function channelReadIntent(channelId: string, clickedAt: number) { + if (closed || !allowed(channelId)) + throw new Error("Read target unavailable"); + indexEvidence(); + const rows = byChannel.get(channelId) ?? []; + // Explicit catch-up covers everything posted up to this click, including + // messages this client has not loaded: a cut at the newest retained message + // lets older activity arrive later and relight the channel. Snapshot it at + // invocation, not after a queued storage write. Frontiers merge by maximum, + // so a device clock running ahead also covers arrivals until real time + // passes it; automatic reading must never use the clock. + const cut = rows.reduce( + (newest, { event }) => Math.max(newest, event.created_at), + clickedAt, + ); + const keys = new Set([channelId, messageForceKey(channelId)]); + for (const { event, rootId } of rows) { + keys.add(`msg:${event.id}`); + if (rootId) keys.add(`thread:${rootId}`); + // A retained top-level message establishes its thread's channel even + // when that thread's replies are outside our bounded evidence window. + if (!threadReference(event)) keys.add(`thread:${event.id}`); + } + const valid = channelIntentValid(channelId); + return async () => { + const result = + rows.length || reads.snapshot().capability === "frontier-sync" + ? await reads.read(channelId, cut, valid, true, [...keys]) + : await reads.clearLocalUnread(channelId, [...keys], valid); + if (valid() && forcedMessages.delete(channelId)) + publish(new Set([channelId])); + return result; + }; + } const capability: UnreadCapability = Object.freeze({ snapshot, attention, @@ -930,6 +1025,49 @@ export function createUnread({ visible: () => valid() && visible(), }); }, + async catchUp(id: string, rootId?: string) { + if (!valid()) return; + const target = rootId + ? { kind: "thread" as const, channelId, rootId } + : { kind: "channel" as const, channelId }; + const event = requireMessage(target, id); + const key = rootId + ? `thread-activity:${rootId}` + : `activity:${channelId}`; + // A historical window is not the live bottom. Check retained evidence + // at invocation, not after a later arrival queues behind this intent. + indexEvidence(); + const rows = byChannel.get(channelId) ?? []; + const newer = rows.some( + (entry) => + entry.event.created_at > event.created_at && + (rootId + ? entry.rootId === rootId + : !threadReference(entry.event) || + entry.event.tags.some( + ([name, value]) => name === "broadcast" && value === "1", + )), + ); + if (newer) return; + // Replies can be newer than the last top-level row. Quiet that already + // retained activity too, without acknowledging the replies themselves. + const cut = rootId + ? event.created_at + : rows.reduce( + (latest, row) => Math.max(latest, row.event.created_at), + event.created_at, + ); + if ((reads.state().frontiers[key] ?? -1) >= cut) return; + await reads.read( + key, + cut, + () => + valid() && + requireMessage(target, id) === event && + (!rootId || + (reads.localUnread(`thread:${rootId}`) ?? 0) <= manualRevision), + ); + }, async observe(ids: readonly string[]) { if (!valid() || ids.length > 128) return; for (const id of ids) { @@ -1065,74 +1203,69 @@ export function createUnread({ }); }, async markChannelRead(channelId) { - if (closed || !allowed(channelId)) - throw new Error("Read target unavailable"); - indexEvidence(); - const rows = byChannel.get(channelId) ?? []; - // Snapshot the cut at invocation, not after a queued storage write. Do not - // substitute wall time or a preview timestamp for verified domain evidence. - const latest = rows.reduce( - (head, { event }) => - !head || event.created_at > head.created_at ? event : head, - undefined, - ); - const keys = new Set([channelId, messageForceKey(channelId)]); - for (const { event, rootId } of rows) { - keys.add(`msg:${event.id}`); - if (rootId) keys.add(`thread:${rootId}`); - // A retained top-level message establishes its thread's channel even - // when that thread's replies are outside our bounded evidence window. - if (!threadReference(event)) keys.add(`thread:${event.id}`); - } - const generation = epoch; - const valid = () => !closed && generation === epoch && allowed(channelId); - return serialize(channelId, async () => { - const result = latest - ? await reads.read(channelId, latest.created_at, valid, true, [ - ...keys, - ]) - : await reads.clearLocalUnread(channelId, [...keys], valid); - if (valid() && forcedMessages.delete(channelId)) - publish(new Set([channelId])); - return result; - }); + const read = channelReadIntent(channelId, Math.floor(Date.now() / 1000)); + return serialize(channelId, read); }, async markAllChannelsRead() { if (closed) throw new Error("Read target unavailable"); // Decide the sweep from the list at invocation; channels granted later wait // for the next explicit action, like arrivals after a per-channel cut. + const clickedAt = Math.floor(Date.now() / 1000); const pending = channels .list() .channels.filter((channel) => allowed(channel.id)) - .map((channel) => channel.id) - .filter((channelId) => { - const current = compute({ kind: "channel", channelId }); - return (current.observedCount ?? 0) > 0 || current.manual !== "none"; - }); + .filter((channel) => { + const current = compute({ kind: "channel", channelId: channel.id }); + // The sidebar can be quiet while unopened replies still have receipts. + // Explicit Mark all must consume retained unread intent, not styling. + return ( + current.manual !== "none" || + (byChannel.get(channel.id) ?? []).some( + (entry) => + isUnread(entry, reads.state(), channel.channelType === "dm") || + !!( + entry.rootId && reads.localUnread(`thread:${entry.rootId}`) + ) || + (!threadReference(entry.event) && + !!reads.localUnread(`thread:${entry.event.id}`)), + ) + ); + }) + .map((channel) => ({ + channelId: channel.id, + read: channelReadIntent(channel.id, clickedAt), + })); const results: ReadMutationResult[] = []; let failure: unknown; let failed = false; - for (const channelId of pending) { - // A grant revoked while earlier channels were written is no failure of - // the sweep: like a grant that arrives mid-sweep, it waits for the next - // explicit action rather than surfacing as an error. - if (!allowed(channelId)) continue; - try { - results.push(await capability.markChannelRead(channelId)); - } catch (error) { - if (!failed) failure = error; - failed = true; - } + let prior = Promise.resolve(); + for (const { channelId, read } of pending) { + // Reserve each channel's place now, before a newer manual action. Saves + // still run sequentially; a revoked channel is skipped when its turn arrives. + const before = prior; + const write = serialize(channelId, async () => { + await before; + if (allowed(channelId)) return read(); + }); + prior = write.then( + (result) => { + if (result) results.push(result); + }, + (error) => { + if (!failed) failure = error; + failed = true; + }, + ); } + await prior; if (failed) throw failure; return results; }, async markUnreadLocal(target) { const key = targetKey(target); - const generation = epoch; + const channelValid = channelIntentValid(target.channelId); const valid = () => { - if (closed || generation !== epoch || !allowed(target.channelId)) - return false; + if (!channelValid()) return false; if (target.kind !== "channel") requireMessage( target, @@ -1147,10 +1280,9 @@ export function createUnread({ }, async clearUnreadLocal(target) { const key = targetKey(target); - const generation = epoch; + const channelValid = channelIntentValid(target.channelId); const valid = () => { - if (closed || generation !== epoch || !allowed(target.channelId)) - return false; + if (!channelValid()) return false; if (target.kind !== "channel") requireMessage( target, @@ -1185,12 +1317,14 @@ export function createUnread({ }, stale() { epoch++; + channelReadGenerations.clear(); freshness = "stale"; reads.stale(); publish(); }, clear() { epoch++; + channelReadGenerations.clear(); repairAgain = false; indexed = false; events.clear(); @@ -1205,6 +1339,7 @@ export function createUnread({ dispose() { closed = true; epoch++; + channelReadGenerations.clear(); lifetime.abort(); for (const stop of [...handles]) stop(); stopRead(); diff --git a/tests/browser/message-navigation.spec.mjs b/tests/browser/message-navigation.spec.mjs index 0c00ea7bf4..84598a65cb 100644 --- a/tests/browser/message-navigation.spec.mjs +++ b/tests/browser/message-navigation.spec.mjs @@ -1,5 +1,10 @@ import { test, expect } from "./fixture.mjs"; import { open, end, settle, wheel } from "./timeline.mjs"; +import { + holdReadingFocus, + releaseReadingFocus, + readJournal, +} from "./reading.mjs"; test.use({ pluginFixtures: true, @@ -257,7 +262,23 @@ test("loaded virtual rows reveal per attempt without thread reads or live-update ), ) .toBeGreaterThan(80); + const detachedTop = await history.evaluate((element) => element.scrollTop); app.append("primary", "alpha", "Second detached arrival"); + // A row can remeasure after newer reader input (for example, media loading). + // Preserve the detached position through that measurement, not just the count + // before Virtua has delivered it. Finish native wheel movement before taking + // the exact baseline; the driver regression, not this settled browser check, + // controls cancellation inside the retained command's 150ms lifetime. + await history + .locator("[data-message-id]") + .last() + .evaluate((row) => { + row.style.paddingBottom = "24px"; + }); + await settle(page, history); + expect(await history.evaluate((element) => element.scrollTop)).toBe( + detachedTop, + ); await expect(jump).toHaveAccessibleName("1 new message"); await jump.focus(); await page.keyboard.press("Enter"); @@ -790,18 +811,26 @@ readTest( "settings blocks reading and focus in a retained thread", async ({ page, app }) => { await page.clock.install(); + await holdReadingFocus(page); await open(page, app); await page.evaluate(() => window.fixtureRelay.snapshot().session.unread.ensure(), ); - expect(await openTarget(page, target(app))).toEqual({ status: "opened" }); - await page - .getByRole("button", { - name: /^Close (?:thread|Thread tab)$/, - exact: true, - }) - .focus(); + await page.clock.pauseAt(await page.evaluate(() => Date.now() + 1000)); + await releaseReadingFocus(page); + const opening = openTarget(page, target(app)); + // Exact reveal requires real focus for two animation frames. Advance one + // frame at a time until its owner completes, without earning reading dwell. + await expect + .poll(async () => { + await page.clock.runFor(16); + return status(page); + }) + .toBe("opened"); + expect(await opening).toEqual({ status: "opened" }); + expect((await readJournal(page)).state.frontiers).toEqual({}); + expect(app.report.readPublications).toEqual([]); const before = app.report.readPublications.length; const row = page.locator( `[aria-label="Thread messages"] [data-message-id="${app.exact.target.id}"]`, @@ -827,6 +856,7 @@ readTest( .evaluate((element) => element.contains(document.activeElement)), ).toBe(false); await page.clock.runFor(10000); + expect((await readJournal(page)).state.frontiers).toEqual({}); expect(app.report.readPublications.length).toBe(before); await page .getByRole("button", { name: "Close Channel settings tab", exact: true }) diff --git a/tests/browser/navigation-mute-read.spec.mjs b/tests/browser/navigation-mute-read.spec.mjs index 1e03e4e711..4cf50093ad 100644 --- a/tests/browser/navigation-mute-read.spec.mjs +++ b/tests/browser/navigation-mute-read.spec.mjs @@ -1,6 +1,7 @@ import { openPage } from "./navigation.mjs"; import { test, expect } from "./fixture.mjs"; import { open } from "./timeline.mjs"; +import { holdReadingFocus } from "./reading.mjs"; // Browser-only boundary: real shared-menu focus/dismissal, production broker, // IndexedDB reload and app-global preference startup. Policy matrices live in Vitest. @@ -18,6 +19,8 @@ test("channel menu mute/read persist without selecting the row; failed mute rema await page.addInitScript(() => { localStorage.setItem("buzz-appearance.v1", "dark"); }); + // Sidebar actions and reload must not read the selected conversation. + await holdReadingFocus(page); await open(page, app); // Context menus make the rest of the page aria-hidden while open. const sidebar = page.getByRole("navigation", { @@ -138,8 +141,13 @@ test("channel menu mute/read persist without selecting the row; failed mute rema await expect( menu.getByRole("menuitem", { name: "Unmute", exact: true }), ).toBeVisible(); + // The explicit cut is the click, not the newest loaded message: capture the + // page clock on both sides of it rather than tolerating an arbitrary range. + const seconds = () => page.evaluate(() => Math.floor(Date.now() / 1000)); + const clickedAfter = await seconds(); await menu.getByRole("menuitem", { name: "Mark as Read" }).click(); await expect(menu).toHaveCount(0); + const clickedBefore = await seconds(); await expect(beta).toBeFocused(); await expect(badge(beta)).toHaveCount(0); await expect(badge(alpha)).toHaveAttribute( @@ -155,8 +163,8 @@ test("channel menu mute/read persist without selecting the row; failed mute rema () => app.report.readPublications.some( ({ blob }) => - blob.contexts.beta === - app.histories.get("primary/beta").at(-1).created_at, + blob.contexts.beta >= clickedAfter && + blob.contexts.beta <= clickedBefore, ), { timeout: 12000 }, ) diff --git a/tests/browser/panel-motion.spec.mjs b/tests/browser/panel-motion.spec.mjs index ffd2646e8c..a49fed7df1 100644 --- a/tests/browser/panel-motion.spec.mjs +++ b/tests/browser/panel-motion.spec.mjs @@ -289,13 +289,38 @@ for (const destination of ["Messages", "Projects"]) { exact: true, }); const dock = page.locator("[data-panel-dock]"); - await page.evaluate(() => - document.addEventListener("transitionrun", (event) => { - if (!event.target.matches("[data-panel-dock]")) return; - for (const animation of event.target.getAnimations()) animation.pause(); - event.target.dataset.motionHeld = "true"; - }), - ); + // Hold at DOM commit, not transitionrun: a delayed WebKit frame can dispatch + // that event after the short entrance has finished. getAnimations() flushes + // style, including @starting-style, before the next rendering opportunity. + await page.evaluate(() => { + new MutationObserver((records) => { + const docks = new Set(); + for (const record of records) { + if ( + record.target instanceof HTMLElement && + record.target.matches("[data-panel-dock]") + ) + docks.add(record.target); + for (const node of record.addedNodes) + if (node instanceof HTMLElement) { + if (node.matches("[data-panel-dock]")) docks.add(node); + for (const dock of node.querySelectorAll("[data-panel-dock]")) + docks.add(dock); + } + } + for (const dock of docks) { + const animations = dock.getAnimations(); + for (const animation of animations) animation.pause(); + if (animations.some((animation) => animation.playState === "paused")) + dock.dataset.motionHeld = "true"; + } + }).observe(document.body, { + childList: true, + subtree: true, + attributes: true, + attributeFilter: ["data-closing", "hidden", "style"], + }); + }); const release = () => dock.evaluateAll((elements) => { for (const element of elements) { diff --git a/tests/browser/reading.mjs b/tests/browser/reading.mjs new file mode 100644 index 0000000000..21400d1211 --- /dev/null +++ b/tests/browser/reading.mjs @@ -0,0 +1,41 @@ +// Hold real focus outside reading surfaces while a journey establishes unread +// state. Blur the target itself: the sidebar may still be inert while the search +// palette closes, so redirecting focus to a sidebar button is not reliable. +export async function holdReadingFocus(page) { + await page.addInitScript(() => { + const hold = ({ target }) => { + if ( + target instanceof HTMLElement && + (target.closest("[data-channel-timeline], [data-reading-surface]") || + target.matches('[role="textbox"][aria-label^="Message #"]')) + ) + target.blur(); + }; + document.addEventListener("focusin", hold); + window.fixtureReleaseReadingFocus = () => + document.removeEventListener("focusin", hold); + }); +} + +export async function releaseReadingFocus(page) { + await page.evaluate(() => window.fixtureReleaseReadingFocus()); +} + +// Observe the real durable result, never seed state or call an engine test hook. +export async function readJournal(page) { + return page.evaluate( + () => + new Promise((resolve, reject) => { + const request = indexedDB.open("buzz-read-state-v1", 1); + request.onerror = () => reject(request.error); + request.onsuccess = () => { + const db = request.result; + const tx = db.transaction("partitions", "readonly"); + const read = tx.objectStore("partitions").getAll(); + read.onsuccess = () => resolve(read.result[0]); + read.onerror = () => reject(read.error); + tx.oncomplete = () => db.close(); + }; + }), + ); +} diff --git a/tests/browser/sidebar-unread.spec.mjs b/tests/browser/sidebar-unread.spec.mjs index 0653835d34..ac0e51484e 100644 --- a/tests/browser/sidebar-unread.spec.mjs +++ b/tests/browser/sidebar-unread.spec.mjs @@ -1,6 +1,7 @@ import { test as base, expect } from "./fixture.mjs"; import { streamEvidence } from "./stream-evidence.mjs"; import { open } from "./timeline.mjs"; +import { holdReadingFocus, releaseReadingFocus } from "./reading.mjs"; // Preserve the failing handoff and capture original-reader delivery on Linux. const test = base.extend({ @@ -165,6 +166,8 @@ test("edge pills follow scroll and reveal the nearest unread without selection o page, app, }, info) => { + // This journey measures sidebar intent, never reading the selected channel. + await holdReadingFocus(page); // Visible rows can precede the post-establishment catch-up. Force that late // ordering, then account for its head read before measuring cue-triggered work. app.relay.holdEose("alpha"); @@ -224,6 +227,7 @@ test("edge pills follow scroll and reveal the nearest unread without selection o ); expect(transitionProperties).toEqual(["background-color", "color"]); await expect(cue(page, "above")).toHaveCount(0); + await releaseReadingFocus(page); // Keep actionable DMs below while moving only ordinary unread above: priority // is derived from the destinations on each edge, not from the whole roster. await scrollRowAbove(page, "alpha"); diff --git a/tests/browser/thread-unread.spec.mjs b/tests/browser/thread-unread.spec.mjs index f7309fc7b5..a680c48281 100644 --- a/tests/browser/thread-unread.spec.mjs +++ b/tests/browser/thread-unread.spec.mjs @@ -1,5 +1,6 @@ import { test, expect } from "./fixture.mjs"; import { open } from "./timeline.mjs"; +import { holdReadingFocus, releaseReadingFocus } from "./reading.mjs"; test.use({ productionBroker: true, @@ -16,6 +17,7 @@ test.describe("mentioned reply priority", () => { page, app, }) => { + await holdReadingFocus(page); await open(page, app); const alpha = page.locator('button[data-channel-id="alpha"]'); await expect( @@ -45,9 +47,10 @@ test("thread buttons show observed unread independently, clear only after readin page, app, }, testInfo) => { - // Reading needs a 750ms dwell (use-reading.ts). The clock runs that deadline + // Reading needs a 300ms dwell (use-reading.ts). The clock runs that deadline // exactly where the test proves that something is not reading. await page.clock.install(); + await holdReadingFocus(page); await open(page, app); const roots = app.histories .get("primary/alpha") @@ -144,6 +147,7 @@ test("thread buttons show observed unread independently, clear only after readin .first(); await item.focus(); await expect(item).toBeFocused(); + await releaseReadingFocus(page); await item.press("Enter"); await expect( page.getByRole("complementary", { name: "Thread", exact: true }), @@ -213,17 +217,15 @@ test("thread buttons show observed unread independently, clear only after readin name: "Reply to thread", exact: true, }); - await replyComposer.focus(); - await page.clock.runFor(750); - await expect(replyComposer).toBeFocused(); - await expect(first).toHaveAccessibleName(/Observed unread replies/); // Click/composer focus is not reading. - await history.focus(); - // Finish a real read of a visible sibling before checking the hidden child. - // Loaded history is not read evidence: collapsed descendants stay unread. + // Own-composer dwell acknowledges the visible reply without list focus; + // the collapsed descendant and sibling thread remain independent. const directId = await panel .locator("[data-message-id]") .filter({ hasText: "Unread reply 0" }) .getAttribute("data-message-id"); + await replyComposer.focus(); + await page.clock.runFor(300); + await expect(replyComposer).toBeFocused(); await expect .poll( () => @@ -233,6 +235,8 @@ test("thread buttons show observed unread independently, clear only after readin { timeout: 12000 }, ) .toBe(true); + await expect(replyComposer).toBeFocused(); + await expect(other).toHaveAccessibleName(/Observed unread replies/); await expect( panel.getByText("Broadcast descendant", { exact: true }), ).toHaveCount(0); @@ -306,6 +310,7 @@ test("same-thread sidebar activity replaces timeline focus return", async ({ page, app, }) => { + await holdReadingFocus(page); await open(page, app); const root = app.histories .get("primary/alpha") @@ -336,6 +341,7 @@ test("same-thread sidebar activity replaces timeline focus return", async ({ .locator(`[data-channel-timeline] [data-message-id="${root.id}"]`) .getByRole("button", { name: /^View thread:/ }); await expect(trigger).toHaveCSS("pointer-events", "auto"); + await releaseReadingFocus(page); await trigger.click(); await expect.poll(() => requested).toBe(true); const before = await page.evaluate(() => { @@ -375,3 +381,115 @@ test("same-thread sidebar activity replaces timeline focus return", async ({ release(); } }); + +// Browser-only contract: fitting rows generate no scroll/resize when an empty +// legacy continuation finishes. Opening alone must still earn reading dwell. +test("opening a fitting thread reads after unchanged-row continuation without another gesture", async ({ + page, + app, +}) => { + await page.clock.install(); + await open(page, app); + const root = app.histories + .get("primary/alpha") + .find((row) => row.content === "Thread root 1"); + const button = page + .locator(`[data-channel-timeline] [data-message-id="${root.id}"]`) + .getByRole("button", { name: /^View thread:/ }); + await expect(button).toHaveAccessibleName(/Observed unread replies/); + let release; + const gate = new Promise((resolve) => { + release = resolve; + }); + let continuation = false; + await page.route("**/query", async (route) => { + const filters = route.request().postDataJSON(); + if ( + filters.some( + (filter) => + filter.depth_limit && + filter["#e"]?.[0] === root.id && + filter.thread_cursor !== undefined, + ) + ) { + continuation = true; + await gate; + } + await route.continue(); + }); + const panel = page.getByRole("complementary", { + name: "Thread", + exact: true, + }); + const history = panel.getByRole("region", { name: "Thread messages" }); + try { + await button.click(); + await expect.poll(() => continuation).toBe(true); + await expect( + panel.getByText("Unread reply 1", { exact: true }), + ).toBeInViewport(); + await expect( + page.getByRole("tab", { name: "Thread", exact: true }), + ).toBeFocused(); + expect( + await history.evaluate( + (element) => element.scrollHeight <= element.clientHeight, + ), + ).toBe(true); + await page.clock.runFor(300); + await expect(button).toHaveAccessibleName(/Observed unread replies/); + } finally { + release(); + } + await expect(button).not.toHaveAccessibleName(/Observed unread replies/); + // An explicit retarget of the still-open panel also transfers reading focus. + const otherRoot = app.histories + .get("primary/alpha") + .find((row) => row.content === "Thread root 0"); + const other = page + .locator(`[data-channel-timeline] [data-message-id="${otherRoot.id}"]`) + .getByRole("button", { name: /^View thread:/ }); + await other.click(); + await expect(panel).toHaveCount(1); + await expect( + page.getByRole("tab", { name: "Thread", exact: true }), + ).toBeFocused(); + const reply = panel + .locator("[data-message-id]") + .filter({ hasText: "Unread reply 0" }); + await expect(reply).toBeInViewport(); + const id = await reply.getAttribute("data-message-id"); + await expect + .poll(() => + page.evaluate( + (id) => + window.fixtureRelay.snapshot().session.unread.attention("alpha", id) + .unread, + id, + ), + ) + .toBe(false); +}); + +test("channel bottom quiets ordinary replies newer than the latest top-level message without reading their threads", async ({ + page, + app, +}) => { + await open(page, app); + const alpha = page.locator('button[data-channel-id="alpha"]'); + const root = app.histories + .get("primary/alpha") + .find((row) => row.content === "Thread root 1"); + const button = page + .locator(`[data-channel-timeline] [data-message-id="${root.id}"]`) + .getByRole("button", { name: /^View thread:/ }); + await expect(button).toHaveAccessibleName(/Observed unread replies/); + await page + .getByRole("textbox", { name: "Message #Alpha", exact: true }) + .focus(); + await expect(alpha.getByText("Alpha", { exact: true })).toHaveCSS( + "font-weight", + "400", + ); + await expect(button).toHaveAccessibleName(/Observed unread replies/); +}); diff --git a/tests/browser/unread.spec.mjs b/tests/browser/unread.spec.mjs index 3a9e77d279..c2cec857b1 100644 --- a/tests/browser/unread.spec.mjs +++ b/tests/browser/unread.spec.mjs @@ -1,6 +1,11 @@ import { openPage } from "./navigation.mjs"; import { test, expect } from "./fixture.mjs"; import { open, settle } from "./timeline.mjs"; +import { + readJournal as journal, + holdReadingFocus, + releaseReadingFocus, +} from "./reading.mjs"; const alphaId = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; test.use({ @@ -16,24 +21,9 @@ const history = (page) => const alpha = (page) => page.getByRole("button", { name: /^Alpha/ }); const composer = (page) => page.getByRole("textbox", { name: "Message #Alpha", exact: true }); -// Observe the real durable result, never seed state or call an engine test hook. -async function journal(page) { - return page.evaluate( - () => - new Promise((resolve, reject) => { - const request = indexedDB.open("buzz-read-state-v1", 1); - request.onerror = () => reject(request.error); - request.onsuccess = () => { - const db = request.result; - const tx = db.transaction("partitions", "readonly"); - const read = tx.objectStore("partitions").getAll(); - read.onsuccess = () => resolve(read.result[0]); - read.onerror = () => reject(read.error); - tx.oncomplete = () => db.close(); - }; - }), - ); -} +// Focus outside the reading surface. The timeline and its own composer both +// read after dwell; a focused sidebar row selects and reads nothing. +const park = (page) => alpha(page).focus(); async function visible(page) { return history(page).evaluate((element) => { const viewport = element.getBoundingClientRect(); @@ -143,29 +133,23 @@ test("built sidebar → visible dwell → durable journal → encrypted broker p "aria-label", /^500 observed unread messages/, ); - await composer(page).focus(); + await park(page); await page.clock.pauseAt(await page.evaluate(() => Date.now() + 1000)); - await page.clock.runFor(900); // Composer focus is not reading, even past dwell. + await page.clock.runFor(900); // Sidebar focus is not reading, even past dwell. expect((await journal(page)).state.frontiers).toEqual({}); expect(app.report.readPublications).toEqual([]); const ids = await visible(page); expect(ids.length).toBeGreaterThan(0); - const before = Number( - (await alpha(page).getByRole("img").getAttribute("aria-label")).split( - " ", - )[0], - ); - await history(page).focus(); - await page.clock.runFor(300); + // Typing under the conversation is reading it: the composer shares the + // timeline's edit scope, so its focus earns the same dwell as the list's. + await composer(page).focus(); + await page.clock.runFor(299); expect((await journal(page)).state.frontiers).toEqual({}); - await page.clock.runFor(750); + await page.clock.runFor(1); await expect .poll(async () => Object.keys((await journal(page)).state.frontiers).sort()) - .toEqual(ids.map((id) => `msg:${id}`).sort()); - await expect(alpha(page).getByRole("img")).toHaveAttribute( - "aria-label", - new RegExp(`^${before - ids.length} observed unread messages`), - ); + .toEqual([`activity:${alphaId}`, ...ids.map((id) => `msg:${id}`)].sort()); + await expect(alpha(page).getByRole("img")).toHaveCount(0); await page.clock.resume(); const stored = await journal(page); expect(stored.state.frontiers[alphaId]).toBeUndefined(); @@ -186,10 +170,7 @@ test("built sidebar → visible dwell → durable journal → encrypted broker p await settle(page); expect((await journal(page)).slot).toBe(stored.slot); expect((await journal(page)).state.frontiers).toEqual(stored.state.frontiers); - await expect(alpha(page).getByRole("img")).toHaveAttribute( - "aria-label", - new RegExp(`^${before - ids.length} observed unread messages`), - ); + await expect(alpha(page).getByRole("img")).toHaveCount(0); }); test("focus cancellation and local manual-unread survive dwell/reload until explicit mark-through", async ({ @@ -236,7 +217,7 @@ test("focus cancellation and local manual-unread survive dwell/reload until expl }), ) .toEqual({ status: "reconciled", completeness: "snapshot" }); - await composer(page).focus(); + await park(page); await page.clock.pauseAt(await page.evaluate(() => Date.now() + 1000)); await history(page).focus(); const ids = await visible(page); @@ -255,8 +236,8 @@ test("focus cancellation and local manual-unread survive dwell/reload until expl ), ) .toBe(true); - await page.clock.runFor(300); - await composer(page).focus(); + await page.clock.runFor(299); + await park(page); await page.clock.runFor(900); await page.clock.resume(); await options(page); @@ -276,7 +257,7 @@ test("focus cancellation and local manual-unread survive dwell/reload until expl exact: true, }), ).toBeVisible(); - await composer(page).focus(); + await park(page); await page.clock.pauseAt(await page.evaluate(() => Date.now() + 1000)); await history(page).focus(); await page.clock.runFor(1000); @@ -319,8 +300,10 @@ test("a surviving window publishes a closed window's durable read intent", async context, app, }) => { + await page.clock.install(); + await holdReadingFocus(page); await open(page, app); - await composer(page).focus(); + await park(page); const survivor = await context.newPage(); app.watchPageErrors(survivor); survivor.on("console", (message) => { @@ -328,24 +311,41 @@ test("a surviving window publishes a closed window's durable read intent", async app.report.consoleErrors.push(message.text()); }); try { + await holdReadingFocus(survivor); await open(survivor, app); - await composer(survivor).focus(); + await park(survivor); await options(survivor); await survivor.getByText("Unread status", { exact: true }).click(); await expect( survivor.getByText(/Read sync: frontier-sync · reconciled/), ).toBeVisible(); + for (const window of [page, survivor]) + await window.evaluate(() => + window.fixtureRelay.snapshot().session.unread.ensure(), + ); + await page.clock.pauseAt(await page.evaluate(() => Date.now() + 1000)); + expect((await journal(page)).state.frontiers).toEqual({}); await page.bringToFront(); + const ids = await visible(page); + expect(ids.length).toBeGreaterThan(0); + await releaseReadingFocus(page); await history(page).focus(); + await page.clock.runFor(300); + // One dwell writes catch-up, then each visible message. A positive revision + // alone can be an intermediate durable state, not the publication baseline. await expect - .poll(async () => (await journal(page)).revision) - .toBeGreaterThan(0); + .poll(async () => + Object.keys((await journal(page)).state.frontiers).sort(), + ) + .toEqual([`activity:${alphaId}`, ...ids.map((id) => `msg:${id}`)].sort()); + await park(page); const stored = await journal(page); await expect( survivor.getByText(/Read sync: frontier-sync · pending/), ).toBeVisible(); expect(app.report.readPublications).toEqual([]); await page.close(); // Cancel the origin publisher before its normal five-second debounce. + await survivor.clock.resume(); // The controlled clock is shared by this context. await expect .poll(() => app.report.readPublications.length, { timeout: 12000 }) .toBe(1); @@ -382,7 +382,7 @@ test.describe("explicit mark-through with membership activity", () => { ); const lastChat = loaded.findLast((event) => event.kind === 9); await open(page, app); - await composer(page).focus(); + await park(page); await expect( history(page).locator("[data-membership-row]"), ).toHaveCount(1); @@ -432,3 +432,33 @@ test.describe("explicit mark-through with membership activity", () => { ); } }); + +test.describe("automatic catch-up after membership rows", () => { + test.use({ + membershipActivity: true, + historyCounts: { [alphaId]: 20, beta: 1 }, + }); + test("own-composer focus quiets a channel whose last visible row is membership activity", async ({ + page, + app, + }) => { + await open(page, app); + await expect( + history(page).locator("[data-membership-row]"), + ).toBeInViewport(); + await composer(page).focus(); + const latest = app.histories + .get(`primary/${alphaId}`) + .findLast((row) => row.kind === 9); + await expect + .poll( + async () => + (await journal(page))?.state.frontiers[`activity:${alphaId}`], + ) + .toBe(latest.created_at); + await expect(alpha(page).getByText("Alpha", { exact: true })).toHaveCSS( + "font-weight", + "400", + ); + }); +});