From 4053f2d19db53d6105420b181d13b405b5039e62 Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Sat, 7 Nov 2026 01:27:42 +1100 Subject: [PATCH] fix(messages): say older messages can load once Buzz reconnects, instead of an inert Load older button When Buzz can't reach the relay, it shows saved conversations from a cached-only store. A busy channel's saved head keeps hasMore, so the top of the conversation offered an enabled "Load older messages" that did nothing: the cached-only store reads nothing, and its loadOlder returns before any request. In Solo chats, scrolling to the top did nothing in the same way. ChannelQueries gains `cachedOnly`, which the cached-only store publishes next to the `refer` it already withholds. When a window has more history in a cached-only session, the timeline edge shows "You can load older messages once Buzz reconnects." in place of the button, in both button mode (Channels) and scroll mode (Solo). The history limit caption still wins, and a window with all of its history saved shows nothing new. When the connected session replaces the saved one, the caption goes and the button or scroll paging returns, without remounting the timeline. No relay reads, state, props or timers are added. The help guide gains an Offline bullet that matches the caption. The copy says older messages can load, not that they will. A top-edge gesture made while offline is retained, and the connected session pages once it verifies the head. Without one, nothing pages by itself, because verification alone never pages a restored top: Channels shows the button again and Solo waits for a scroll. The wording holds with or without a retained gesture, so the caption appearing is not treated as demand and paging is unchanged. Two tests cover it. A ChannelTimeline.viewport case runs real cached-only and connected sessions through the handoff: offline, the caption shows in both modes with no button, and the connected session brings the button back without remounting the timeline. A ChannelTimeline case drives the setup() harness through the swap under a stable continuityKey, as ChannelBody passes: a top-edge gesture offline sends no older read, and the connected session sends exactly one, on the shared transport, once its head verifies. The harness gains a continuityKey option, swap() and caption(). Co-Authored-By: Claude Fable 5.1 Signed-off-by: Matt Toohey --- docs/buzz-help.md | 3 + .../messages/ChannelTimeline.test.tsx | 130 +++++++++++++++++- src/features/messages/ChannelTimeline.tsx | 2 + .../ChannelTimeline.viewport.test.tsx | 75 ++++++++++ src/features/relay/contracts.ts | 4 + src/features/relay/store.ts | 6 +- 6 files changed, 217 insertions(+), 3 deletions(-) diff --git a/docs/buzz-help.md b/docs/buzz-help.md index 690d0767b6..1f85af9ae1 100644 --- a/docs/buzz-help.md +++ b/docs/buzz-help.md @@ -254,6 +254,9 @@ revokes all of them, and a pairing unused for 30 days expires. stopped reading, or at the latest message once you had caught up. You and the person asking may have different channel access. A link alone never grants access. +- **Offline**: when Buzz can't reach a community, it shows the conversations + saved on this device. Once Buzz reconnects, you can load messages older than + those, and the top of the conversation says so. - **Finding channels**: search's **Channels** tab lists the community's open channels you can join, with their member counts; typing also matches channels you're in. Opening an unjoined channel shows a preview with **Join**. Private diff --git a/src/features/messages/ChannelTimeline.test.tsx b/src/features/messages/ChannelTimeline.test.tsx index c0b074a11d..6be063ff2d 100644 --- a/src/features/messages/ChannelTimeline.test.tsx +++ b/src/features/messages/ChannelTimeline.test.tsx @@ -129,6 +129,7 @@ function setup({ status = "ready" as ChannelWindow["status"], blocked = undefined as boolean | undefined, session = undefined as RelaySession | undefined, + continuityKey = undefined as string | undefined, historyControl = "button" as "button" | "scroll", hasMore = false, loadingOlder = false, @@ -264,7 +265,7 @@ function setup({ olderReads(); } }); - const queries = + let queries = session ?? ({ channels: { loadOlder, window: snapshot }, @@ -304,6 +305,7 @@ function setup({ historyControl, scope: "scope", queries, + continuityKey, window: session?.channels.window(channelId) ?? { channelId, status, @@ -413,6 +415,21 @@ function setup({ }>; return edge.props.children.some((child) => child?.type === Button); }, + /** The edge's plain caption text, if any (not its alert or status). */ + caption() { + const edge = section.props.children[0] as ReactElement<{ + children: ReactElement<{ role?: string; children: unknown }>[]; + }>; + return edge.props.children.find( + (child) => child?.type === "span" && !child.props.role, + )?.props.children; + }, + /** Replaces the session, as reconnecting does under a stable continuityKey. */ + swap(next: RelaySession) { + session = next; + queries = next; + render(); + }, retry() { const edge = section.props.children[0] as ReactElement<{ children: ReactElement[]; @@ -1408,6 +1425,117 @@ it.each(["held head", "live handoff", "disconnected"])( }, ); +it("offline top-edge input reads nothing, and the connected session pages once it verifies", async () => { + const relay = keypair(), + viewer = keypair(); + const event = message(viewer, "channel", "saved", 20); + const head = [ + event, + bounds(relay, "channel", "head", { + has_more: true, + next_cursor: { created_at: 20, id: event.id }, + }), + ]; + const saved = { + read: async () => [ + { channelId: "channel", savedAt: Date.now(), events: head, profiles: [] }, + ], + write: async () => {}, + retain: async () => {}, + remove: async () => {}, + clear: async () => {}, + close: () => {}, + }; + // Both sessions share one transport, so every older read is counted here. + const scripted = scriptedTransport(viewer.pubkey, relay.pubkey); + const older = () => + scripted.pending.filter((read) => read.filters[0]?.until !== undefined); + const offline = createRelaySession(scripted.transport, { + prepared: true, + cachedOnly: true, + persistence: { + ...saved, + readStartup: async () => ({ + discovery: { + savedAt: Date.now(), + relayAuthor: relay.pubkey, + events: [ + roster(relay, "channel", [viewer.pubkey]), + metadata(relay, "channel", "Channel"), + ], + }, + }), + }, + }); + let online: ReturnType | undefined; + await offline.restore(); + offline.session.channels.ensure("channel"); + await vi.waitFor(() => + expect(offline.session.channels.window("channel")).toMatchObject({ + status: "ready", + freshness: "cached", + hasMore: true, + }), + ); + const h = setup({ + session: offline.session, + continuityKey: "relay:1", + historyControl: "scroll", + initial: { offset: 0, bottom: false }, + }); + h.element.scrollTop = 0; + try { + h.gesture(); + h.render(); + expect(h.caption()).toBe( + "You can load older messages once Buzz reconnects.", + ); + await flush(); + expect(older()).toHaveLength(0); + online = createRelaySession( + { + ...scripted.transport, + subscribe: () => ({ update() {}, retry() {}, dispose() {} }), + }, + { prepared: true, persistence: saved }, + ); + const channels = online.session.channels; + channels.ensureList(); + scripted + .next() + .respond([ + roster(relay, "channel", [viewer.pubkey]), + metadata(relay, "channel", "Channel"), + ]); + await flush(); + channels.ensure("channel"); + await vi.waitFor(() => + expect(channels.window("channel").freshness).toBe("cached"), + ); + const held = scripted.next(); + // A handoff to a cached head is not verification: the gesture waits. + h.swap(online.session); + expect(h.caption()).toBeUndefined(); + expect(older()).toHaveLength(0); + held.respond(head); + await vi.waitFor(() => + expect(channels.window("channel").freshness).toBe("verified"), + ); + h.render(); + await vi.waitFor(() => expect(older()).toHaveLength(1)); + expect(older()[0]?.filters[0]).toMatchObject({ + until: 20, + before_id: event.id, + }); + h.render(); + expect(older()).toHaveLength(1); + } finally { + h.unmount(); + offline.dispose(); + online?.dispose(); + } +}); + it("cached rerenders do not reissue a blocked attempt", () => { const h = setup({ hasMore: true, freshness: "cached" }); h.element.scrollTop = 0; diff --git a/src/features/messages/ChannelTimeline.tsx b/src/features/messages/ChannelTimeline.tsx index b3f99a71d5..4b2689b4b0 100644 --- a/src/features/messages/ChannelTimeline.tsx +++ b/src/features/messages/ChannelTimeline.tsx @@ -1807,6 +1807,8 @@ function Timeline({ {window.error && {window.error}} {window.historyLimited ? ( History window limit reached + ) : window.hasMore && queries.channels.cachedOnly ? ( + You can load older messages once Buzz reconnects. ) : window.hasMore && (historyControl === "button" || window.error) ? (