Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions docs/buzz-help.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
130 changes: 129 additions & 1 deletion src/features/messages/ChannelTimeline.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -264,7 +265,7 @@ function setup({
olderReads();
}
});
const queries =
let queries =
session ??
({
channels: { loadOlder, window: snapshot },
Expand Down Expand Up @@ -304,6 +305,7 @@ function setup({
historyControl,
scope: "scope",
queries,
continuityKey,
window: session?.channels.window(channelId) ?? {
channelId,
status,
Expand Down Expand Up @@ -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[];
Expand Down Expand Up @@ -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<typeof createRelaySession> | 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;
Expand Down
2 changes: 2 additions & 0 deletions src/features/messages/ChannelTimeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1807,6 +1807,8 @@ function Timeline({
{window.error && <span role="alert">{window.error}</span>}
{window.historyLimited ? (
<span>History window limit reached</span>
) : window.hasMore && queries.channels.cachedOnly ? (
<span>You can load older messages once Buzz reconnects.</span>
) : window.hasMore && (historyControl === "button" || window.error) ? (
<Button
type="button"
Expand Down
75 changes: 75 additions & 0 deletions src/features/messages/ChannelTimeline.viewport.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -848,6 +848,81 @@ it("hides the owner's starting agents only in a complete personal chat", () => {
}
});

it("offline, says older messages can be loaded once Buzz reconnects instead of offering an inert Load older", () => {
vi.spyOn(HTMLElement.prototype, "clientWidth", "get").mockReturnValue(800);
vi.spyOn(HTMLElement.prototype, "clientHeight", "get").mockReturnValue(600);
vi.stubGlobal(
"ResizeObserver",
class {
observe() {}
disconnect() {}
},
);
const viewer = keypair(),
relay = keypair();
// The relay service's saved-conversations session and the connected
// session that replaces it share one identity and scope.
const transport = {
viewer: viewer.pubkey,
relayAuthor: relay.pubkey,
media: () => undefined,
query: async () => [],
};
const offline = createRelaySession(transport, { cachedOnly: true });
const online = createRelaySession(transport);
// One saved page of a longer history.
const window: ChannelWindow = {
channelId: "alpha",
rows: foldMessages("alpha", relay.pubkey, [
message(viewer, "alpha", "Saved message", 100),
]),
status: "ready",
hasMore: true,
loadingOlder: false,
freshness: "cached",
error: undefined,
};
const timeline = (
owner: typeof offline,
historyControl: "button" | "scroll" = "button",
) => (
<ChannelTimeline
channelId="alpha"
scope="offline-history"
continuityKey="relay:1"
queries={owner.session}
window={window}
historyControl={historyControl}
onOpenLink={() => false}
/>
);
const waiting = "You can load older messages once Buzz reconnects.";
try {
const view = render(timeline(offline));
expect(screen.getByText(waiting)).toBeVisible();
expect(screen.queryByRole("button", { name: /older messages/ })).toBeNull();
// Solo chats page on scroll, which reads nothing offline either.
view.rerender(timeline(offline, "scroll"));
expect(screen.getByText(waiting)).toBeVisible();
// The connected session replaces it without remounting the timeline.
const history = screen.getByRole("region", {
name: "Channel message history",
});
view.rerender(timeline(online));
expect(
screen.getByRole("region", { name: "Channel message history" }),
).toBe(history);
expect(
screen.getByRole("button", { name: "Load older messages" }),
).toBeEnabled();
expect(screen.queryByText(waiting)).toBeNull();
view.unmount();
} finally {
offline.dispose();
online.dispose();
}
});

it.each([0, 600])(
"prepend compensation requires a measured virtualizer viewport (%s)",
(size) => {
Expand Down
4 changes: 4 additions & 0 deletions src/features/relay/contracts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -331,6 +331,10 @@ export interface ChannelQueries {
* withheld answers) and when it ends. Absent when no lookup can run, as in
* a cached-only store. */
refer?(channelId: string): () => void;
/** A cached-only store: saved conversations while Buzz can't reach the
* relay. It reads nothing, so `loadOlder` adds no rows, until a connected
* session replaces it. */
readonly cachedOnly?: true;
/** Exact re-read of one already-listed channel's roster, merged into the
* ready list. `resolve` admits channels the list lacks; this confirms a
* membership change on one it already carries, without a full rediscovery.
Expand Down
6 changes: 4 additions & 2 deletions src/features/relay/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3314,8 +3314,10 @@ export function createChannelStore(
subscribeDirectory: (listener: Listener) =>
subscribe(directoryListeners, listener),
describe: describeReference,
// A cached-only store never looks a reference up, so it offers no lookup.
...(options.cachedOnly ? {} : { refer: referChannel }),
// A cached-only store reads nothing: it says so, and offers no lookup.
...(options.cachedOnly
? { cachedOnly: true as const }
: { refer: referChannel }),
refreshRoster,
subscribeList: (listener: Listener) => subscribe(listListeners, listener),
window: (channelId: string) =>
Expand Down
Loading