From 46fde674c35cf07c9060767d1ab58e1fa87a46a5 Mon Sep 17 00:00:00 2001
From: mtoohey
Date: Mon, 9 Nov 2026 15:20:11 +0000
Subject: [PATCH 1/2] fix(errors): say what a failed history read or connection
means, not the relay's words
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Add userCopy(error, context) in features/relay. It classifies a failure
by the typed errors Buzz already produces (ReadError kind and status,
ApiPaused/ApiCapacity, UploadError, PublishRejected/PublishNotSent,
fetch's network TypeErrors, DOMException names) and returns { text,
retry, kind, details }. text is the site's own sentence plus a plain
next step and never carries an exception name, HTTP status, URL or raw
relay or native string; those stay in details. ErrorDetails renders
details under a collapsed "Details" disclosure.
The relay layer keeps failures typed instead of stringified:
- The service snapshot stores a RelayFailure ({ kind, details }) and
channel windows and thread snapshots keep a WindowFailure. History
parse failures are ReadError("invalid-response").
- The window store records errorSource "older" only when loadOlder
failed, so a head or catch-up failure no longer blames older history.
- PublishNotSent carries the relay's wait for a quota hint or a held
lane, so busy copy can say how long.
- Copy and retry share one set of fetch network messages (including
WebKit's), and a body cut off after its headers is an unavailable read.
- An answer Buzz can't use is not retried at once. The service asks
again in the background after 30 s, 1, 2 and every 5 minutes, keeping
the failure on screen meanwhile.
History reads: ChannelBody, ChannelTimeline, NewMessage, ThreadPanel,
MediaReviewViewer and DraftPreview map the failure with historyFailure():
"Messages didn’t load." with "Try again" for an outage or 5xx; "Buzz is
busy. Try again in a few seconds." (or "in about N minutes/hours") when
paused; "Check for updates" and no Try again for a 4xx or invalid
answer; "You don’t have access to this conversation." for a refusal with
no known reason. The older edge says "Older messages didn’t load.".
Connection failures: Workflows and Notes show the Messages
ConnectionFailed card: "Can’t connect", "Check your connection. We’ll
keep trying." and "Try again".
An answer Buzz can't use reads "Buzz couldn’t connect to this
community." with "Check for updates"; a refusal shows the community
refusal title with no retry. Bestie, Pinned Bestie, Activity, its
preview and Reminders say the same through connectionFailureText and
connectionFailureAction. App's page failure cards put the primary
action last and drop Try again for an unusable connection.
Co-Authored-By: Claude Fable 5.1
Signed-off-by: mtoohey
---
src/app/App.tsx | 28 +-
src/app/entity-navigation.test.tsx | 53 ++-
src/app/services.test.ts | 4 +-
src/app/shell/PageSearch.test.tsx | 2 +-
src/app/shell/PrimarySidebar.test.tsx | 17 +-
src/app/shell/PrimarySidebar.tsx | 4 +-
src/bundled/bestie/PinnedBestie.test.tsx | 51 ++-
src/bundled/bestie/PinnedBestie.tsx | 20 +-
src/bundled/bestie/index.test.tsx | 32 ++
src/bundled/bestie/index.tsx | 20 +-
src/bundled/bestie/testing.ts | 10 +-
src/bundled/channels/ChannelBody.test.tsx | 115 ++++++
src/bundled/channels/ChannelBody.tsx | 30 +-
src/bundled/channels/ChannelsPage.tsx | 47 +--
src/bundled/inbox/InboxPage.test.tsx | 36 +-
src/bundled/inbox/InboxPage.tsx | 18 +-
src/bundled/inbox/InboxPreview.tsx | 23 +-
src/bundled/relay-notes/NotesPage.test.tsx | 86 ++++
src/bundled/relay-notes/NotesPage.tsx | 31 +-
src/bundled/reminders/RemindersPage.test.tsx | 55 +++
src/bundled/reminders/RemindersPage.tsx | 22 +-
src/bundled/workflows/WorkflowsPage.test.tsx | 97 ++++-
src/bundled/workflows/WorkflowsPage.tsx | 49 ++-
src/bundled/workflows/index.tsx | 4 +-
src/features/agents/inventory.test.ts | 2 +-
src/features/agents/service-testing.ts | 7 +-
.../NewMessage.recovery.test.tsx | 45 +-
src/features/direct-messages/NewMessage.tsx | 33 +-
.../drafts-sent/DraftPreview.test.tsx | 60 ++-
src/features/drafts-sent/DraftPreview.tsx | 30 +-
src/features/huddle/discussion.ts | 8 +-
.../messages/ChannelTimeline.test.tsx | 104 ++++-
src/features/messages/ChannelTimeline.tsx | 26 +-
.../MediaReviewViewer.comments.test.tsx | 69 +++-
src/features/messages/MediaReviewViewer.tsx | 64 ++-
src/features/messages/ThreadPanel.test.tsx | 51 ++-
src/features/messages/ThreadPanel.tsx | 37 +-
.../messages/attachment-panes.test.tsx | 2 +-
src/features/messages/history-failure.test.ts | 87 ++++
src/features/messages/history-failure.ts | 40 ++
.../messages/use-media-read-recovery.ts | 4 +-
.../messages/use-message-opening.test.ts | 2 +-
src/features/relay/ConnectionFailed.test.tsx | 147 +++++++
src/features/relay/ConnectionFailed.tsx | 120 ++++++
src/features/relay/channel-lifecycle.test.ts | 6 +-
src/features/relay/channel-opening.test.ts | 11 +-
src/features/relay/contracts.ts | 7 +-
src/features/relay/errors.ts | 16 +-
src/features/relay/events.ts | 6 +-
src/features/relay/live-session.test.ts | 13 +-
src/features/relay/outbox.ts | 12 +-
src/features/relay/read-retry.test.ts | 4 +-
src/features/relay/service.test.ts | 216 +++++++++-
src/features/relay/service.ts | 386 ++++++++++--------
src/features/relay/session-read-retry.test.ts | 6 +-
src/features/relay/signed-admission.test.ts | 44 ++
src/features/relay/startup-service.test.ts | 2 +-
src/features/relay/store.test.ts | 20 +-
src/features/relay/store.ts | 41 +-
src/features/relay/thread-profile-fixture.ts | 2 +-
src/features/relay/thread-window.test.ts | 8 +-
src/features/relay/threads.test.ts | 10 +-
src/features/relay/threads.ts | 5 +-
src/features/relay/transport.test.ts | 43 ++
src/features/relay/transport.ts | 50 ++-
src/features/relay/user-copy.test.ts | 340 +++++++++++++++
src/features/relay/user-copy.ts | 257 ++++++++++++
src/features/relay/window-failure.ts | 14 +
src/features/relay/window.ts | 32 +-
src/shared/ErrorDetails.test.tsx | 24 ++
src/shared/ErrorDetails.tsx | 21 +
tests/browser/history-loading.spec.mjs | 6 +-
tests/browser/live-status.spec.mjs | 6 +-
tests/browser/new-message.spec.mjs | 9 +-
74 files changed, 2992 insertions(+), 417 deletions(-)
create mode 100644 src/bundled/channels/ChannelBody.test.tsx
create mode 100644 src/features/messages/history-failure.test.ts
create mode 100644 src/features/messages/history-failure.ts
create mode 100644 src/features/relay/ConnectionFailed.test.tsx
create mode 100644 src/features/relay/ConnectionFailed.tsx
create mode 100644 src/features/relay/user-copy.test.ts
create mode 100644 src/features/relay/user-copy.ts
create mode 100644 src/features/relay/window-failure.ts
create mode 100644 src/shared/ErrorDetails.test.tsx
create mode 100644 src/shared/ErrorDetails.tsx
diff --git a/src/app/App.tsx b/src/app/App.tsx
index e681d678d5..14021570a7 100644
--- a/src/app/App.tsx
+++ b/src/app/App.tsx
@@ -62,6 +62,7 @@ import { PanelLaunchers } from "./shell/PanelLaunchers";
import { ShellBackdrops } from "../features/backdrops/ShellBackdrops";
import { PanelCard } from "../features/panels/PanelCard";
import { communityDestination } from "../features/communities/destination";
+import { UNUSABLE_CONNECTION } from "../features/relay/ConnectionFailed";
import { CommunityLanding } from "../features/communities/CommunityLanding";
import { LegacyCommunityImport } from "../features/communities/LegacyCommunityImport";
import {
@@ -433,6 +434,9 @@ function ConnectedAppContent({ services }: { services: AppServices }) {
route.reconnecting &&
!route.state.ingress &&
(failureReason === "timeout" || failureReason === "unavailable");
+ // An answer this Buzz can't use comes back the same on Try again.
+ const unusableConnection =
+ connection.status === "error" && connection.error?.kind === "unusable";
const retryButton = (variant: "primary" | "quiet") => (
}
title="Can’t connect"
description={
- retriedEntry === route.state.entry.id
- ? keepTrying
- ? "Still can’t connect. We’ll keep trying."
- : "Still can’t connect. Check your connection, then try again."
- : keepTrying
- ? "Check your connection. We’ll keep trying."
- : "Check your connection, then try again."
+ unusableConnection
+ ? UNUSABLE_CONNECTION
+ : retriedEntry === route.state.entry.id
+ ? keepTrying
+ ? "Still can’t connect. We’ll keep trying."
+ : "Still can’t connect. Check your connection, then try again."
+ : keepTrying
+ ? "Check your connection. We’ll keep trying."
+ : "Check your connection, then try again."
}
action={
<>
- {retryButton("primary")}
{messagesButton("quiet")}
+ {!unusableConnection && retryButton("primary")}
>
}
/>
@@ -538,6 +544,7 @@ function ConnectedAppContent({ services }: { services: AppServices }) {
description="Turn it on in Manage plugins to open this page."
action={
<>
+ {messagesButton("quiet")}
Manage plugins
- {messagesButton("quiet")}
>
}
/>
@@ -558,8 +564,8 @@ function ConnectedAppContent({ services }: { services: AppServices }) {
description="It may have been removed, or you may need to update Buzz."
action={
<>
- {messagesButton("primary")}
{retryButton(leaveForMessages ? "quiet" : "primary")}
+ {messagesButton("primary")}
>
}
/>
@@ -570,8 +576,8 @@ function ConnectedAppContent({ services }: { services: AppServices }) {
description="It didn’t load this time. Try again in a moment."
action={
<>
- {retryButton("primary")}
{messagesButton("quiet")}
+ {retryButton("primary")}
>
}
/>
diff --git a/src/app/entity-navigation.test.tsx b/src/app/entity-navigation.test.tsx
index 71ecc95f46..f4023750e0 100644
--- a/src/app/entity-navigation.test.tsx
+++ b/src/app/entity-navigation.test.tsx
@@ -11,7 +11,7 @@ import {
within,
} from "@testing-library/react";
import userEvent from "@testing-library/user-event";
-import { afterEach, beforeEach, expect, it, vi } from "vitest";
+import { afterEach, assert, beforeEach, expect, it, vi } from "vitest";
import { finalizeEvent, getPublicKey } from "nostr-tools";
import { App } from "./App";
import { createServices, type AppServices } from "./services";
@@ -502,7 +502,8 @@ function bindCommunityLinks(
return { current, ping: () => ping() };
}
-function offlineMessagesFixture() {
+/** Messages opened while the session read answers `status`, until `online`. */
+function offlineMessagesFixture(status = 503) {
vi.stubGlobal(
"ResizeObserver",
class {
@@ -527,7 +528,7 @@ function offlineMessagesFixture() {
if (url.endsWith("/identity")) return Response.json({ viewer });
if (url.endsWith("/session"))
return offline
- ? Response.json({ error: "offline" }, { status: 503 })
+ ? Response.json({ error: "offline" }, { status })
: Response.json({ viewer, relayAuthor: viewer, relayUrl: origin });
if (url.endsWith("/query")) return Response.json([]);
return Response.json({});
@@ -603,6 +604,42 @@ it("reopens a visit that timed out while connecting once the connection is live"
vi.useRealTimers();
}
});
+it("says only that Buzz couldn't connect, with no Try again, when the session answer can't be used", async () => {
+ vi.useFakeTimers({ shouldAdvanceTime: true });
+ try {
+ // An edge answering 400 during a relay deploy.
+ const { current, online } = offlineMessagesFixture(400);
+ await waitFor(() =>
+ expect(current.relay.snapshot()).toMatchObject({
+ status: "error",
+ error: { kind: "unusable" },
+ }),
+ );
+ await act(() => vi.advanceTimersByTimeAsync(15_000));
+ const heading = await screen.findByRole("heading", {
+ name: "Can’t connect",
+ });
+ const card = heading.closest('[role="alert"]');
+ assert.exists(card);
+ expect(
+ within(card).getByText("Buzz couldn’t connect to this community."),
+ ).toBeVisible();
+ // Trying again gets the same answer, so the card doesn't ask for it.
+ expect(
+ within(card).queryByRole("button", { name: "Try again" }),
+ ).toBeNull();
+ expect(card).not.toHaveTextContent(/try again|check your connection/i);
+ // The relay still asks again by itself, and the visit reopens.
+ online();
+ await act(() => vi.advanceTimersByTimeAsync(31_000));
+ await waitFor(() =>
+ expect(current.navigation.snapshot().status).toBe("opened"),
+ );
+ expect(screen.queryByText(pageFailure)).toBeNull();
+ } finally {
+ vi.useRealTimers();
+ }
+});
it("does not reopen a failed visit after the user has left it", async () => {
vi.useFakeTimers({ shouldAdvanceTime: true });
try {
@@ -783,11 +820,11 @@ it("keeps a failed message read in place until its own Retry succeeds", async ()
document.querySelector('[data-channel-timeline="alpha"]');
while (!timeline()) {
await userEvent.click(
- await screen.findByRole("button", { name: "Retry messages" }),
+ await screen.findByRole("button", { name: "Try again" }),
);
await waitFor(() =>
expect(
- timeline() ?? screen.queryByRole("button", { name: "Retry messages" }),
+ timeline() ?? screen.queryByRole("button", { name: "Try again" }),
).not.toBeNull(),
);
}
@@ -798,7 +835,7 @@ it("keeps a failed message read in place until its own Retry succeeds", async ()
.session.channels.window("alpha")
.rows.map((row) => row.content),
).toEqual(["Back again"]);
- expect(screen.queryByRole("button", { name: "Retry messages" })).toBeNull();
+ expect(screen.queryByRole("button", { name: "Try again" })).toBeNull();
expect(current.navigation.snapshot().attempt.signal.aborted).toBe(false);
expect(failures).toBe(0);
expect(current.relay.snapshot().generation).toBe(generation);
@@ -1298,7 +1335,7 @@ async function activityTurnedOff() {
within(card)
.getAllByRole("button")
.map((button) => button.textContent),
- ).toEqual(["Manage plugins", "Go to Messages"]);
+ ).toEqual(["Go to Messages", "Manage plugins"]);
return card;
}
@@ -1392,7 +1429,7 @@ it("turning a plugin back on over its failed page offers Try again, which opens
within(card)
.getAllByRole("button")
.map((button) => button.textContent),
- ).toEqual(["Try again", "Go to Messages"]);
+ ).toEqual(["Go to Messages", "Try again"]);
expect(
screen.queryByRole("heading", { name: "Activity is turned off" }),
).not.toBeInTheDocument();
diff --git a/src/app/services.test.ts b/src/app/services.test.ts
index fb82854b08..8db9530d1a 100644
--- a/src/app/services.test.ts
+++ b/src/app/services.test.ts
@@ -446,7 +446,7 @@ it("restores the selected native community and recovers discovery failure withou
await vi.advanceTimersByTimeAsync(0);
expect(services.relay.snapshot()).toMatchObject({
status: "error",
- error: expect.stringContaining("Discovery offline"),
+ error: { details: expect.stringContaining("Discovery offline") },
});
unavailable = false;
services.relay.retry();
@@ -508,7 +508,7 @@ it("seeds a packaged native community after onboarding and preserves the user's
});
expect(services.relay.snapshot()).toMatchObject({
status: "error",
- error: expect.stringContaining("Discovery offline"),
+ error: { details: expect.stringContaining("Discovery offline") },
});
unavailable = false;
services.relay.retry();
diff --git a/src/app/shell/PageSearch.test.tsx b/src/app/shell/PageSearch.test.tsx
index a27ee9b6bd..f8dbde1e39 100644
--- a/src/app/shell/PageSearch.test.tsx
+++ b/src/app/shell/PageSearch.test.tsx
@@ -1034,7 +1034,7 @@ it("finds and opens saved conversations while the community is unreachable or st
);
let connection: RelaySnapshot = {
status: "error",
- error: "Relay broker session is malformed",
+ error: { kind: "connection", details: "Relay broker session is malformed" },
cached: true,
generation: 1,
scope: "s",
diff --git a/src/app/shell/PrimarySidebar.test.tsx b/src/app/shell/PrimarySidebar.test.tsx
index e3e314e54e..8860c8a9c5 100644
--- a/src/app/shell/PrimarySidebar.test.tsx
+++ b/src/app/shell/PrimarySidebar.test.tsx
@@ -1080,7 +1080,10 @@ it("moves a refusal's recovery from the sidebar to its one notice", async () =>
);
const sidebar = () => screen.getByRole("complementary", { name: "Sidebar" });
// A connection failure with native's sign-in error asks for sign-in here.
- f.connection({ status: "error", error: "Federated sign-in is required" });
+ f.connection({
+ status: "error",
+ error: { kind: "refused", details: "Federated sign-in is required" },
+ });
expect(
within(sidebar()).getByRole("button", {
name: "Sign in with builderlab.example",
@@ -1125,7 +1128,7 @@ it("makes Try again a person's retry, which asks the issuer again", async () =>
const sidebar = () => screen.getByRole("complementary", { name: "Sidebar" });
f.connection({
status: "error",
- error: "grant-refused",
+ error: { kind: "unknown", details: "grant-refused" },
origin: "wss://relay.example",
});
// So its issuer-refusal advice is that Try again, not a restart.
@@ -1143,7 +1146,10 @@ it("shows Try again beside a settled failure, and Connecting while the connectio
const f = fixture(true);
const retry = vi.spyOn(f.relay, "tryAgain");
const sidebar = () => screen.getByRole("complementary", { name: "Sidebar" });
- const error = "ReadError: Relay read failed (502)";
+ const error = {
+ kind: "connection",
+ details: "ReadError: Relay read failed (502)",
+ } as const;
f.connection({ status: "error", error });
expect(within(sidebar()).getByText("Couldn’t load channels.")).toBeVisible();
expect(within(sidebar()).queryByText(/ReadError/)).toBeNull();
@@ -1155,7 +1161,10 @@ it("shows Try again beside a settled failure, and Connecting while the connectio
// every page; the sidebar neither repeats it nor offers Try again.
f.connection({
status: "error",
- error: "ReadError: Relay read failed (403)",
+ error: {
+ kind: "refused",
+ details: "ReadError: Relay read failed (403)",
+ },
denied: true,
});
const notice = await screen.findByRole("dialog", {
diff --git a/src/app/shell/PrimarySidebar.tsx b/src/app/shell/PrimarySidebar.tsx
index f1b6168915..1d3123513c 100644
--- a/src/app/shell/PrimarySidebar.tsx
+++ b/src/app/shell/PrimarySidebar.tsx
@@ -273,7 +273,7 @@ function ScopedSidebar({
cached={!!connection.cached}
layoutReady={filter === "team" || soloReady}
preserveSidebar={preserveSidebar}
- connectionError={connection.error}
+ connectionError={connection.error?.details}
render={({
sidebar,
dmPreviews,
@@ -426,7 +426,7 @@ function ScopedSidebar({
props.federatedIdentity,
connection.origin,
)}
- error={connection.error}
+ error={connection.error?.details}
retry={props.relay.tryAgain}
relay={connection.origin}
memberships={props.memberships}
diff --git a/src/bundled/bestie/PinnedBestie.test.tsx b/src/bundled/bestie/PinnedBestie.test.tsx
index 47066e4301..f52f1c7a10 100644
--- a/src/bundled/bestie/PinnedBestie.test.tsx
+++ b/src/bundled/bestie/PinnedBestie.test.tsx
@@ -858,15 +858,62 @@ it("keeps the saved Bestie visible through reconnect and offers connection recov
screen.queryByRole("button", { name: "Set up Bestie" }),
).not.toBeInTheDocument();
act(() =>
- f.updateRelay({ status: "error", error: "Community temporarily offline" }),
+ f.updateRelay({
+ status: "error",
+ error: {
+ kind: "connection",
+ details: "ReadError: Relay read failed (502)",
+ },
+ }),
);
- expect(screen.getByText("Community temporarily offline")).toBeVisible();
+ expect(
+ screen.getByText("Check your connection, then try again."),
+ ).toBeVisible();
+ expect(screen.queryByText(/ReadError|502/)).not.toBeInTheDocument();
await userEvent.click(
screen.getByRole("button", { name: "Retry connection" }),
);
expect(f.retryConnection).toHaveBeenCalledOnce();
});
+it("offers Check for updates, not Retry connection, for an answer Buzz can't use", async () => {
+ const f = await fixture({ existing: true });
+ f.choose(pollen);
+ const open = vi.fn(async () => ({ status: "opened" }) as const);
+ render(
+ f.element({
+ renderedNavigator: Object.create(f.navigator, { open: { value: open } }),
+ }),
+ );
+ await screen.findByRole("textbox", { name: "Message Pollen" });
+ act(() =>
+ f.updateRelay({
+ status: "error",
+ error: {
+ kind: "unusable",
+ details: "ReadError: Relay response is not an event array",
+ },
+ }),
+ );
+ expect(
+ screen.getByText("Buzz couldn’t connect to this community."),
+ ).toBeVisible();
+ expect(screen.queryByText(/try again/i)).not.toBeInTheDocument();
+ // Reconnecting gets the same answer; an update may not.
+ expect(
+ screen.queryByRole("button", { name: "Retry connection" }),
+ ).not.toBeInTheDocument();
+ await userEvent.click(
+ screen.getByRole("button", { name: "Check for updates" }),
+ );
+ expect(open).toHaveBeenCalledExactlyOnceWith({
+ version: 1,
+ kind: "settings",
+ section: "updates",
+ });
+ expect(f.retryConnection).not.toHaveBeenCalled();
+});
+
it("recovers a saved agent missing from a partial inventory through refresh", async () => {
const f = await fixture({ localFailed: true });
f.choose(pollen);
diff --git a/src/bundled/bestie/PinnedBestie.tsx b/src/bundled/bestie/PinnedBestie.tsx
index 551912d713..1c1627e81f 100644
--- a/src/bundled/bestie/PinnedBestie.tsx
+++ b/src/bundled/bestie/PinnedBestie.tsx
@@ -10,6 +10,11 @@ import { useAgentChoices } from "../../features/agents/use-choices";
import { useChannelList, useRelayConnection } from "../../features/relay/react";
import type { ChannelSummary } from "../../features/relay/contracts";
import type { RelayData } from "../../features/relay/service";
+import {
+ connectionFailureAction,
+ connectionFailureText,
+ openUpdatesWith,
+} from "../../features/relay/ConnectionFailed";
import type { RelaySession } from "../../features/relay/session";
import { useSavedAgent } from "../../features/solo/default-agent";
import { RobotIcon } from "../../shared/design-system/icons";
@@ -91,14 +96,15 @@ export function PinnedBestie({
description={
connection.status === "connecting"
? "Your bestie conversation opens once this community is connected."
- : (connection.error ??
- "Choose a community to chat with your bestie.")
- }
- action={
- connection.status === "error" ? (
- Retry connection
- ) : undefined
+ : connection.error
+ ? connectionFailureText(connection)
+ : "Choose a community to chat with your bestie."
}
+ action={connectionFailureAction({
+ connection,
+ relay,
+ openUpdates: openUpdatesWith(navigator),
+ })}
/>
);
} else if (channel && saved) {
diff --git a/src/bundled/bestie/index.test.tsx b/src/bundled/bestie/index.test.tsx
index 410168fe55..3a94665078 100644
--- a/src/bundled/bestie/index.test.tsx
+++ b/src/bundled/bestie/index.test.tsx
@@ -41,6 +41,38 @@ const renderPage = (h: Awaited>) => {
return render( );
};
+it("offers Check for updates, not Retry connection, for an answer Buzz can't use", async () => {
+ const h = await bestieFixture(native.invoke);
+ act(() =>
+ h.updateRelay({
+ status: "error",
+ generation: 2,
+ error: {
+ kind: "unusable",
+ details: "ReadError: Relay response is not an event array",
+ },
+ }),
+ );
+ renderPage(h);
+ expect(
+ await screen.findByText("Buzz couldn’t connect to this community."),
+ ).toBeVisible();
+ expect(screen.queryByText(/try again/i)).not.toBeInTheDocument();
+ // Reconnecting gets the same answer; an update may not.
+ expect(
+ screen.queryByRole("button", { name: "Retry connection" }),
+ ).not.toBeInTheDocument();
+ await userEvent.click(
+ screen.getByRole("button", { name: "Check for updates" }),
+ );
+ expect(h.open).toHaveBeenCalledExactlyOnceWith({
+ version: 1,
+ kind: "settings",
+ section: "updates",
+ });
+ expect(h.retryConnection).not.toHaveBeenCalled();
+});
+
it("waits for the account choice before showing the chooser", async () => {
const choice = deferred();
const h = await bestieFixture(native.invoke, {
diff --git a/src/bundled/bestie/index.tsx b/src/bundled/bestie/index.tsx
index f8c371ee37..436d6ea0e5 100644
--- a/src/bundled/bestie/index.tsx
+++ b/src/bundled/bestie/index.tsx
@@ -7,6 +7,11 @@ import type { ConversationExtensions } from "../../features/conversation/contrac
import type { ConversationHostServices } from "../../features/conversation/ConversationHost";
import type { Agents } from "../../features/agents/service";
import type { RelayData } from "../../features/relay/service";
+import {
+ connectionFailureAction,
+ connectionFailureText,
+ openUpdatesWith,
+} from "../../features/relay/ConnectionFailed";
import { useRelayConnection } from "../../features/relay/react";
import { BestieIcon } from "../../shared/design-system/icons";
import { Button } from "../../shared/design-system/ui/Button";
@@ -128,14 +133,15 @@ function BestieDestination({
description={
connection.status === "connecting"
? "Your Bestie opens once this community is connected."
- : (connection.error ??
- "Choose a community to continue with your Bestie.")
- }
- action={
- connection.status === "error" ? (
- Retry connection
- ) : undefined
+ : connection.error
+ ? connectionFailureText(connection)
+ : "Choose a community to continue with your Bestie."
}
+ action={connectionFailureAction({
+ connection,
+ relay,
+ openUpdates: openUpdatesWith(props.navigation),
+ })}
/>
);
diff --git a/src/bundled/bestie/testing.ts b/src/bundled/bestie/testing.ts
index 5d3ad62a4e..0f322c0e98 100644
--- a/src/bundled/bestie/testing.ts
+++ b/src/bundled/bestie/testing.ts
@@ -322,6 +322,7 @@ export async function bestieFixture(
const notify = () => {
for (const listener of [...listeners]) listener();
};
+ const retryConnection = vi.fn();
const relay = {
snapshot: () => snapshot,
subscribe(listener: () => void) {
@@ -331,7 +332,7 @@ export async function bestieFixture(
};
},
retry() {},
- tryAgain() {},
+ tryAgain: retryConnection,
disconnect() {},
async clearCache() {},
};
@@ -448,6 +449,13 @@ export async function bestieFixture(
query,
viewer: identity.pubkey,
agent: remote.pubkey,
+ /** The relay connection's Retry connection. */
+ retryConnection,
+ /** Moves the relay connection on, as its service would. */
+ updateRelay(patch: Partial) {
+ snapshot = { ...snapshot, ...patch } as RelaySnapshot;
+ notify();
+ },
/** Each request the native session forwarded to Builderlab. */
forwarded: federated.request,
/** The bodies of what Builderlab was sent at `path`, in order. */
diff --git a/src/bundled/channels/ChannelBody.test.tsx b/src/bundled/channels/ChannelBody.test.tsx
new file mode 100644
index 0000000000..3e56467108
--- /dev/null
+++ b/src/bundled/channels/ChannelBody.test.tsx
@@ -0,0 +1,115 @@
+// @vitest-environment jsdom
+import "@testing-library/jest-dom/vitest";
+import { cleanup, fireEvent, render, screen } from "@testing-library/react";
+import { afterEach, expect, it, vi } from "vitest";
+import { ReadError } from "../../features/relay/errors";
+import { ApiPaused } from "../../features/relay/http-admission";
+import type { WindowFailure } from "../../features/relay/window-failure";
+import { UPDATES_LINK } from "../../features/messages/history-failure";
+import { ChannelBody } from "./ChannelBody";
+
+const conversation = {
+ ensure: vi.fn(),
+ onOpenLink: vi.fn((_url: string) => true),
+};
+let failure: WindowFailure | undefined;
+let recovers = false;
+vi.mock("../../features/conversation/Conversation", () => ({
+ useConversation: () => ({
+ session: { channels: { ensure: conversation.ensure } },
+ channelId: "alpha",
+ ownSoloConversation: false,
+ onOpenLink: conversation.onOpenLink,
+ }),
+ ConversationTimeline: () => null,
+}));
+vi.mock("../../features/relay/react", () => ({
+ useChannelWindow: () => ({
+ channelId: "alpha",
+ status: "error",
+ rows: [],
+ hasMore: false,
+ loadingOlder: false,
+ error: failure,
+ }),
+}));
+vi.mock("../../features/relay/RecoveryNotice", () => ({
+ useRecoveryOwns: () => recovers,
+}));
+vi.mock("../../features/developer/client-metrics", () => ({
+ clientMetrics: {
+ channelMounted() {},
+ channelUnmounted() {},
+ channelRendered() {},
+ channelEmpty() {},
+ },
+}));
+
+afterEach(() => {
+ cleanup();
+ vi.clearAllMocks();
+ recovers = false;
+});
+
+function show(error: WindowFailure) {
+ failure = error;
+ render( );
+ return screen.getByRole("alert");
+}
+
+it.each([
+ {
+ name: "a server error",
+ error: new ReadError("unavailable", "Relay read failed (503)", 503),
+ },
+ { name: "a native timeout", error: "network-timeout" },
+ { name: "an unknown failure", error: new Error("boom") },
+])("says messages didn’t load and offers Try again for $name", ({ error }) => {
+ const alert = show(error);
+ expect(alert).toHaveTextContent("Messages didn’t load.");
+ expect(alert).not.toHaveTextContent(/relay|503|network-timeout|boom/i);
+ expect(
+ screen.queryByRole("button", { name: "Check for updates" }),
+ ).toBeNull();
+ fireEvent.click(screen.getByRole("button", { name: "Try again" }));
+ expect(conversation.ensure).toHaveBeenCalledWith("alpha");
+});
+
+it("says a busy Buzz can try again", () => {
+ const alert = show(new ApiPaused(3000));
+ expect(alert).toHaveTextContent(
+ "Messages didn’t load. Buzz is busy. Try again in a few seconds.",
+ );
+ expect(screen.getByRole("button", { name: "Try again" })).toBeVisible();
+});
+
+it("offers only Check for updates for an answer Buzz can't use", () => {
+ const alert = show(
+ new ReadError("invalid-response", "Relay response is not an event array"),
+ );
+ expect(alert).toHaveTextContent("Messages didn’t load.");
+ expect(alert).not.toHaveTextContent(/event array|relay/i);
+ expect(screen.queryByRole("button", { name: "Try again" })).toBeNull();
+ fireEvent.click(screen.getByRole("button", { name: "Check for updates" }));
+ expect(conversation.onOpenLink).toHaveBeenCalledExactlyOnceWith(UPDATES_LINK);
+});
+
+it("offers no button for a refusal and shows no raw text or Details", () => {
+ const alert = show(new ReadError("denied", "Relay read failed (403)", 403));
+ expect(alert).toHaveTextContent(
+ "Messages didn’t load. You don’t have access to this conversation.",
+ );
+ expect(alert).not.toHaveTextContent(/relay|403/i);
+ expect(screen.queryByRole("button", { name: "Try again" })).toBeNull();
+ expect(
+ screen.queryByRole("button", { name: "Check for updates" }),
+ ).toBeNull();
+ expect(screen.queryByText("Details")).toBeNull();
+});
+
+it("leaves the empty timeline to the recovery notice while recovery retries", () => {
+ recovers = true;
+ failure = new ReadError("unavailable", "Relay read failed (503)", 503);
+ render( );
+ expect(screen.queryByRole("alert")).toBeNull();
+});
diff --git a/src/bundled/channels/ChannelBody.tsx b/src/bundled/channels/ChannelBody.tsx
index 593da82cc2..31e7723ec4 100644
--- a/src/bundled/channels/ChannelBody.tsx
+++ b/src/bundled/channels/ChannelBody.tsx
@@ -9,6 +9,10 @@ import { useChannelWindow } from "../../features/relay/react";
import { useRecoveryOwns } from "../../features/relay/RecoveryNotice";
import { headRecovering } from "../../features/relay/live-recovery";
import { clientMetrics } from "../../features/developer/client-metrics";
+import {
+ historyFailure,
+ UPDATES_LINK,
+} from "../../features/messages/history-failure";
import { Button } from "../../shared/design-system/ui/Button";
import styles from "./Channels.module.css";
@@ -45,6 +49,7 @@ export const ChannelBody = memo(function ChannelBody({
session: queries,
channelId,
ownSoloConversation: solo,
+ onOpenLink,
} = useConversation();
// ChannelWorkspace already keys this lifetime by viewer/scope/generation.
const continuityKey = useId();
@@ -94,21 +99,30 @@ export const ChannelBody = memo(function ChannelBody({
}, [cached, navigation, window.status]);
// While recovery retries this timeline's head read, its notice is the one
// report, after the quiet delay; the empty timeline waits.
- if (window.status === "error" && !window.rows.length && !headRecovers)
+ if (window.status === "error" && !window.rows.length && !headRecovers) {
+ const failure = historyFailure(window.error, "Messages didn’t load.");
return (
-
{window.error}
-
queries.channels.ensure(channelId)}
- >
- Retry messages
-
+
{failure.text}
+ {failure.retry && (
+
queries.channels.ensure(channelId)}
+ >
+ Try again
+
+ )}
+ {failure.update && (
+
onOpenLink(UPDATES_LINK)}>
+ Check for updates
+
+ )}
);
+ }
if (holding || (window.status !== "ready" && !window.rows.length))
return (
)}
- ) : session.status === "error" && session.denied ? (
- // The community refused this account. Its notice, on every page,
- // says what fixes it and offers that; this only says why it's empty.
-
- }
- title={communityRefusalTitle(session.refusal)}
- />
-
) : session.status === "error" &&
- (!session.retrying || visitSettled) ? (
+ (session.denied || !session.retrying || visitSettled) ? (
// Usually a failed visit's card covers this page, but a visit that
// already opened from cached content keeps it: say so plainly.
-
- }
- title="Can’t connect"
- description={
- session.retrying
- ? "Check your connection. We’ll keep trying."
- : "Check your connection, then try again."
- }
- action={
- relay.tryAgain()}
- >
- Try again
-
- }
- />
-
+
+ void navigator.open({
+ version: 1,
+ kind: "settings",
+ section: "updates",
+ })
+ }
+ />
) : (
// Only real connection work animates.
diff --git a/src/bundled/inbox/InboxPage.test.tsx b/src/bundled/inbox/InboxPage.test.tsx
index 79e1552358..67c36ba9c7 100644
--- a/src/bundled/inbox/InboxPage.test.tsx
+++ b/src/bundled/inbox/InboxPage.test.tsx
@@ -817,7 +817,10 @@ it("retries the card's connection, activity and read-state failures", async () =
act(() => h.failConnection("relay offline"));
render(previewAt(h));
- expect(await screen.findByText("relay offline")).toBeInTheDocument();
+ expect(
+ await screen.findByText("Check your connection, then try again."),
+ ).toBeInTheDocument();
+ expect(screen.queryByText("relay offline")).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Retry connection" }));
expect(h.retryConnection).toHaveBeenCalledOnce();
// A refusal is the community's, not the connection's: its notice on every
@@ -835,6 +838,35 @@ it("retries the card's connection, activity and read-state failures", async () =
).not.toBeInTheDocument();
});
+it("offers Check for updates, not Retry connection, for an answer Buzz can't use", async () => {
+ const h = fixture();
+ const unusable = {
+ kind: "unusable",
+ details: "ReadError: Relay response is not an event array",
+ } as const;
+ act(() => h.failConnection(unusable.details, { error: unusable }));
+ const sentence = "Buzz couldn’t connect to this community.";
+ for (const view of [h.view, previewAt(h)]) {
+ const shown = render(view);
+ expect(await screen.findByText(sentence)).toBeInTheDocument();
+ expect(
+ screen.queryByText(/event array|try again/i),
+ ).not.toBeInTheDocument();
+ expect(
+ screen.queryByRole("button", { name: "Retry connection" }),
+ ).not.toBeInTheDocument();
+ h.open.mockClear();
+ fireEvent.click(screen.getByRole("button", { name: "Check for updates" }));
+ expect(h.open).toHaveBeenCalledExactlyOnceWith({
+ version: 1,
+ kind: "settings",
+ section: "updates",
+ });
+ shown.unmount();
+ }
+ expect(h.retryConnection).not.toHaveBeenCalled();
+});
+
function fixture(
options: {
failRoster?: boolean;
@@ -1467,7 +1499,7 @@ function fixture(
connection = {
...connection,
status: "error",
- error,
+ error: { kind: "connection", details: error },
generation: 2,
...refused,
};
diff --git a/src/bundled/inbox/InboxPage.tsx b/src/bundled/inbox/InboxPage.tsx
index e3519f4a9e..9f365d6144 100644
--- a/src/bundled/inbox/InboxPage.tsx
+++ b/src/bundled/inbox/InboxPage.tsx
@@ -10,6 +10,11 @@ import {
useSyncExternalStore,
} from "react";
import type { RelayData } from "../../features/relay/service";
+import {
+ connectionFailureAction,
+ connectionFailureText,
+ openUpdatesWith,
+} from "../../features/relay/ConnectionFailed";
import type { RelaySession } from "../../features/relay/session";
import type { InboxItem } from "../../features/relay/inbox";
import type {
@@ -229,12 +234,15 @@ export function InboxPage({
: connection.denied
? // Its notice, on every page, offers what fixes it.
communityRefusalTitle(connection.refusal)
- : (connection.error ??
- "Choose a community to see your activity.")}
+ : connection.error
+ ? connectionFailureText(connection)
+ : "Choose a community to see your activity."}
- {connection.status === "error" && !connection.denied && (
- Retry connection
- )}
+ {connectionFailureAction({
+ connection,
+ relay,
+ openUpdates: openUpdatesWith(navigator),
+ })}
)}
diff --git a/src/bundled/inbox/InboxPreview.tsx b/src/bundled/inbox/InboxPreview.tsx
index 8ea28402fe..38db69c9c0 100644
--- a/src/bundled/inbox/InboxPreview.tsx
+++ b/src/bundled/inbox/InboxPreview.tsx
@@ -23,6 +23,11 @@ import {
} from "../../features/relay/react";
import { communityRefusalTitle } from "../../features/federated-identity/CommunitySignIn";
import type { RelayData } from "../../features/relay/service";
+import {
+ connectionFailureAction,
+ connectionFailureText,
+ openUpdatesWith,
+} from "../../features/relay/ConnectionFailed";
import type { RelaySession } from "../../features/relay/session";
import { ArrowRightIcon, ChecksIcon } from "../../shared/design-system/icons";
import { Button } from "../../shared/design-system/ui/Button";
@@ -80,6 +85,12 @@ export function InboxPreview({
close={close}
/>
);
+ const action = connectionFailureAction({
+ connection,
+ relay,
+ openUpdates: openUpdatesWith(navigator),
+ size: "sm",
+ });
return (
@@ -88,15 +99,11 @@ export function InboxPreview({
: connection.denied
? // Its notice, on every page, offers what fixes it.
communityRefusalTitle(connection.refusal)
- : (connection.error ?? "Choose a community to see your activity.")}
+ : connection.error
+ ? connectionFailureText(connection)
+ : "Choose a community to see your activity."}
- {connection.status === "error" && !connection.denied && (
-
-
- Retry connection
-
-
- )}
+ {action && {action}
}
);
}
diff --git a/src/bundled/relay-notes/NotesPage.test.tsx b/src/bundled/relay-notes/NotesPage.test.tsx
index b487fee2f1..63f4d35909 100644
--- a/src/bundled/relay-notes/NotesPage.test.tsx
+++ b/src/bundled/relay-notes/NotesPage.test.tsx
@@ -1288,3 +1288,89 @@ it.each([false, true])(
).toHaveFocus();
},
);
+
+function unavailable(snapshot: Partial) {
+ const current = { generation: 1, ...snapshot } as RelaySnapshot;
+ const relay = {
+ snapshot: () => current,
+ subscribe: () => () => {},
+ tryAgain: vi.fn(),
+ } as unknown as RelayData;
+ render( );
+ return relay;
+}
+
+it("says it can't connect, with Try again, when the community session fails", async () => {
+ const relay = unavailable({
+ status: "error",
+ retrying: true,
+ error: {
+ kind: "connection",
+ details: "ReadError: Relay read failed (502)",
+ },
+ });
+ expect(
+ screen.getByRole("heading", { name: "Can’t connect" }),
+ ).toBeInTheDocument();
+ expect(
+ screen.getByText("Check your connection. We’ll keep trying."),
+ ).toBeInTheDocument();
+ expect(screen.queryByText(/ReadError|502/)).toBeNull();
+ expect(screen.queryByText("Details")).toBeNull();
+ expect(screen.queryByText(/Connect to a community/)).toBeNull();
+ expect(screen.queryByRole("button", { name: "Retry connection" })).toBeNull();
+ await userEvent.click(screen.getByRole("button", { name: "Try again" }));
+ expect(relay.tryAgain).toHaveBeenCalledOnce();
+});
+
+it("says only that Buzz couldn't connect, beside Check for updates, for an answer it can't use", async () => {
+ const open = vi.fn(async () => ({ status: "opened" }) as const);
+ const current = {
+ generation: 1,
+ status: "error",
+ error: { kind: "unusable", details: "ReadError: Relay read failed (400)" },
+ } as RelaySnapshot;
+ const relay = {
+ snapshot: () => current,
+ subscribe: () => () => {},
+ tryAgain: vi.fn(),
+ } as unknown as RelayData;
+ render(
+ ,
+ );
+ const alert = screen.getByRole("alert");
+ expect(alert).toHaveTextContent(
+ "Can’t connectBuzz couldn’t connect to this community.",
+ );
+ // Its only action is Check for updates, so nothing asks to try again.
+ expect(alert).not.toHaveTextContent(/try again|check your connection/i);
+ expect(screen.queryByRole("button", { name: "Try again" })).toBeNull();
+ await userEvent.click(
+ screen.getByRole("button", { name: "Check for updates" }),
+ );
+ expect(open).toHaveBeenCalledExactlyOnceWith({
+ version: 1,
+ kind: "settings",
+ section: "updates",
+ });
+ expect(relay.tryAgain).not.toHaveBeenCalled();
+});
+
+it("shows the refusal, and no Try again, when the community refused the account", () => {
+ unavailable({
+ status: "error",
+ denied: true,
+ refusal: "sign-in",
+ error: { kind: "refused", details: "Federated sign-in is required" },
+ });
+ expect(screen.getByRole("heading", { name: /Sign in/ })).toBeInTheDocument();
+ expect(screen.queryByRole("button", { name: "Try again" })).toBeNull();
+ expect(screen.queryByText(/Federated/)).toBeNull();
+});
+
+it("keeps asking for a community only when none is connected", () => {
+ unavailable({ status: "disconnected" });
+ expect(screen.getByRole("status")).toHaveTextContent(
+ "Connect to a community to read notes.",
+ );
+});
diff --git a/src/bundled/relay-notes/NotesPage.tsx b/src/bundled/relay-notes/NotesPage.tsx
index 19784defd9..1ae8f2b3ad 100644
--- a/src/bundled/relay-notes/NotesPage.tsx
+++ b/src/bundled/relay-notes/NotesPage.tsx
@@ -32,6 +32,7 @@ import type { Profile } from "../../features/relay/contracts";
import type { EventData } from "../../features/relay/events";
import { useRelayConnection } from "../../features/relay/react";
import type { RelayData } from "../../features/relay/service";
+import { ConnectionFailed } from "../../features/relay/ConnectionFailed";
import type { RelaySession } from "../../features/relay/session";
import {
ArrowLeftIcon,
@@ -217,16 +218,26 @@ export function NotesPage({
);
return (
-
- {connection.status === "connecting"
- ? "Connecting to your community…"
- : "Connect to a community to read notes."}
-
- {connection.status === "error" && (
- <>
- {connection.error}
- relay.tryAgain()}>Retry connection
- >
+ {connection.status === "error" ? (
+
+ void navigator.open({
+ version: 1,
+ kind: "settings",
+ section: "updates",
+ }))
+ }
+ />
+ ) : (
+
+ {connection.status === "connecting"
+ ? "Connecting to your community…"
+ : "Connect to a community to read notes."}
+
)}
);
diff --git a/src/bundled/reminders/RemindersPage.test.tsx b/src/bundled/reminders/RemindersPage.test.tsx
index 787ae2d39e..d7ef2bf8e9 100644
--- a/src/bundled/reminders/RemindersPage.test.tsx
+++ b/src/bundled/reminders/RemindersPage.test.tsx
@@ -790,6 +790,61 @@ function staticPage(connection: () => unknown) {
);
}
+it.each([
+ {
+ failure: "an answer this Buzz can't use",
+ error: {
+ kind: "unusable",
+ details: "ReadError: Relay response is not an event array",
+ },
+ sentence: "Buzz couldn’t connect to this community.",
+ action: "Check for updates",
+ },
+ {
+ failure: "a connection that failed",
+ error: { kind: "connection", details: "TypeError: Failed to fetch" },
+ sentence: "Check your connection, then try again.",
+ action: "Retry connection",
+ },
+] as const)(
+ "offers what the other pages do beside $failure",
+ ({ error, sentence, action }) => {
+ const connection = { status: "error", error };
+ const retry = vi.fn();
+ const open = vi.fn(() => Promise.resolve({ status: "opened" }));
+ render(
+ connection,
+ subscribe: () => () => {},
+ tryAgain: retry,
+ } as unknown as RelayData
+ }
+ navigator={{ open } as unknown as Navigation}
+ clock={{ subscribe: () => () => {}, read: () => now }}
+ />,
+ );
+ expect(screen.getByRole("status")).toHaveTextContent(sentence);
+ // Only one of them: retrying an answer Buzz can't use gets the same one.
+ expect(
+ screen.getAllByRole("button").map((button) => button.textContent),
+ ).toEqual([action]);
+ fireEvent.click(screen.getByRole("button", { name: action }));
+ if (error.kind === "unusable") {
+ expect(open).toHaveBeenCalledExactlyOnceWith({
+ version: 1,
+ kind: "settings",
+ section: "updates",
+ });
+ expect(retry).not.toHaveBeenCalled();
+ } else {
+ expect(retry).toHaveBeenCalledOnce();
+ expect(open).not.toHaveBeenCalled();
+ }
+ },
+);
+
it("keeps focus on a control that claims it in the commit that removes Retry", () => {
// A store that changes only when the test re-renders, so the failure clears
// and the claim opens in one commit.
diff --git a/src/bundled/reminders/RemindersPage.tsx b/src/bundled/reminders/RemindersPage.tsx
index c856494c05..2b07f2c1af 100644
--- a/src/bundled/reminders/RemindersPage.tsx
+++ b/src/bundled/reminders/RemindersPage.tsx
@@ -19,6 +19,11 @@ import {
useReducedMotion,
} from "motion/react";
import type { RelayData } from "../../features/relay/service";
+import {
+ connectionFailureAction,
+ connectionFailureText,
+ openUpdatesWith,
+} from "../../features/relay/ConnectionFailed";
import type { RelaySession } from "../../features/relay/session";
import type { Reminder, Reminders } from "../../features/relay/reminders";
import type { Navigation } from "../../features/navigation/controller";
@@ -135,7 +140,7 @@ export function RemindersPage({
};
const status = !reminders
? connection.status === "error"
- ? (connection.error ?? "Could not connect.")
+ ? connectionFailureText(connection)
: "Connect to a community to see your reminders."
: state.status === "loading" && !state.hydrated
? "Loading reminders…"
@@ -193,9 +198,18 @@ export function RemindersPage({
)}
{status || !reminders ? (
-
- {status}
-
+
+
{status}
+ {/* Beside a failed connection, what the other pages offer:
+ Retry connection, or Check for updates for an answer this
+ Buzz can't use. */}
+ {!reminders &&
+ connectionFailureAction({
+ connection,
+ relay,
+ openUpdates: openUpdatesWith(navigator),
+ })}
+
) : (failed && !state.hydrated && !state.reminders.length) ||
connection.status !== "ready" ? null : (
) {
+ const current = { generation: 1, ...snapshot } as RelaySnapshot;
+ const relay = {
+ snapshot: () => current,
+ subscribe: () => () => {},
+ tryAgain: vi.fn(),
+ } as unknown as RelayData;
+ render( );
+ return relay;
+}
+
+it("says it can't connect, with Try again, when the community session fails", async () => {
+ const relay = disconnected({
+ status: "error",
+ error: {
+ kind: "connection",
+ details: "ReadError: Relay read failed (502)",
+ },
+ });
+ expect(
+ screen.getByRole("heading", { name: "Can’t connect" }),
+ ).toBeInTheDocument();
+ expect(
+ screen.getByText("Check your connection, then try again."),
+ ).toBeInTheDocument();
+ expect(screen.queryByText(/ReadError|502/)).toBeNull();
+ expect(screen.queryByText("Details")).toBeNull();
+ expect(screen.queryByText(/Connect to a community/)).toBeNull();
+ expect(screen.queryByRole("button", { name: "Retry connection" })).toBeNull();
+ await userEvent.click(screen.getByRole("button", { name: "Try again" }));
+ expect(relay.tryAgain).toHaveBeenCalledOnce();
+});
+
+it("says only that Buzz couldn't connect, beside Check for updates, for an answer it can't use", async () => {
+ const open = vi.fn(async () => ({ status: "opened" }) as const);
+ const current = {
+ generation: 1,
+ status: "error",
+ error: { kind: "unusable", details: "ReadError: Relay read failed (400)" },
+ } as RelaySnapshot;
+ const relay = {
+ snapshot: () => current,
+ subscribe: () => () => {},
+ tryAgain: vi.fn(),
+ } as unknown as RelayData;
+ render(
+ ,
+ );
+ const alert = screen.getByRole("alert");
+ expect(alert).toHaveTextContent(
+ "Can’t connectBuzz couldn’t connect to this community.",
+ );
+ // Its only action is Check for updates, so nothing asks to try again.
+ expect(alert).not.toHaveTextContent(/try again|check your connection/i);
+ expect(screen.queryByRole("button", { name: "Try again" })).toBeNull();
+ await userEvent.click(
+ screen.getByRole("button", { name: "Check for updates" }),
+ );
+ expect(open).toHaveBeenCalledExactlyOnceWith({
+ version: 1,
+ kind: "settings",
+ section: "updates",
+ });
+ expect(relay.tryAgain).not.toHaveBeenCalled();
+});
+
+it("shows the refusal, and no Try again, when the community refused the account", () => {
+ disconnected({
+ status: "error",
+ denied: true,
+ refusal: "access",
+ error: { kind: "refused", details: "restricted: authorization denied" },
+ });
+ expect(
+ screen.getByRole("heading", {
+ name: "You don’t have access to this community",
+ }),
+ ).toBeInTheDocument();
+ expect(screen.queryByRole("button")).toBeNull();
+ expect(screen.queryByText(/restricted/)).toBeNull();
+});
+
+it("keeps asking for a community only when none is connected", () => {
+ disconnected({ status: "disconnected" });
+ expect(
+ screen.getByText("Connect to a community to browse workflows."),
+ ).toBeInTheDocument();
+ expect(screen.queryByRole("button")).toBeNull();
+});
diff --git a/src/bundled/workflows/WorkflowsPage.tsx b/src/bundled/workflows/WorkflowsPage.tsx
index a34932e6e3..74996073d0 100644
--- a/src/bundled/workflows/WorkflowsPage.tsx
+++ b/src/bundled/workflows/WorkflowsPage.tsx
@@ -9,6 +9,8 @@ import type { RelayData } from "../../features/relay/service";
import type { RelaySession } from "../../features/relay/session";
import type { WorkflowDefinition } from "../../features/workflows/types";
import { useChannelList, useRelayConnection } from "../../features/relay/react";
+import { ConnectionFailed } from "../../features/relay/ConnectionFailed";
+import type { Navigation } from "../../features/navigation/controller";
import {
ArrowsClockwiseIcon,
LightningIcon,
@@ -36,7 +38,14 @@ import { PanelWorkspace } from "../../features/panels/PanelWorkspace";
import { usePanelSplit } from "../../features/panels/usePanelSplit";
import "./workflows.css";
-export function WorkflowsPage({ relay }: { relay: RelayData }) {
+export function WorkflowsPage({
+ relay,
+ navigator,
+}: {
+ relay: RelayData;
+ /** Opens Settings → Software updates when the community can't be used. */
+ navigator?: Navigation | undefined;
+}) {
const connection = useRelayConnection(relay);
return connection.status === "ready" ? (
-
-
- {connection.status === "connecting"
- ? "Connecting to your community…"
- : "Connect to a community to browse workflows."}
-
- {connection.status === "error" && (
- <>
-
{connection.error}
-
relay.tryAgain()}>Retry connection
- >
- )}
-
+ {connection.status === "error" ? (
+
+
+ void navigator.open({
+ version: 1,
+ kind: "settings",
+ section: "updates",
+ }))
+ }
+ />
+
+ ) : (
+
+
+ {connection.status === "connecting"
+ ? "Connecting to your community…"
+ : "Connect to a community to browse workflows."}
+
+
+ )}
);
}
diff --git a/src/bundled/workflows/index.tsx b/src/bundled/workflows/index.tsx
index 895f3d8877..f07e5beb37 100644
--- a/src/bundled/workflows/index.tsx
+++ b/src/bundled/workflows/index.tsx
@@ -1,6 +1,6 @@
import type { PluginModule } from "../../plugins/api";
import { WorkflowsPage } from "./WorkflowsPage";
-export const inject = ["pages", "relay"];
+export const inject = ["pages", "relay", "navigation"];
export const apply: PluginModule["apply"] = (ctx) => {
const relay = ctx.relay;
ctx.pages.register({
@@ -8,6 +8,6 @@ export const apply: PluginModule["apply"] = (ctx) => {
title: "Workflows",
layout: "workspace",
primary: true,
- component: () => ,
+ component: () => ,
});
};
diff --git a/src/features/agents/inventory.test.ts b/src/features/agents/inventory.test.ts
index 700ea93b06..d1e0217d90 100644
--- a/src/features/agents/inventory.test.ts
+++ b/src/features/agents/inventory.test.ts
@@ -109,7 +109,7 @@ it("quietly retries a transient relay inventory failure before merging", async (
const identity = { pubkey: "b".repeat(64), name: "Relay" };
const relay = vi
.fn()
- .mockRejectedValueOnce(new TypeError("offline"))
+ .mockRejectedValueOnce(new TypeError("Failed to fetch"))
.mockResolvedValue({ definitions: [], identities: [identity] });
const pending = inventoryReader(
async () => ({ definitions: [], identities: [] }),
diff --git a/src/features/agents/service-testing.ts b/src/features/agents/service-testing.ts
index b3f52606d4..e77bc44274 100644
--- a/src/features/agents/service-testing.ts
+++ b/src/features/agents/service-testing.ts
@@ -255,7 +255,12 @@ export function fakeRelay(origin = "https://relay.example.test") {
},
/** Fails the connection, as a first connect that timed out does. */
fail() {
- snapshot = { status: "error", generation: 4, session, error: "down" };
+ snapshot = {
+ status: "error",
+ generation: 4,
+ session,
+ error: { kind: "timeout", details: "down" },
+ };
for (const listener of changes) listener();
},
/** Replaces the session, as after the transport was lost. */
diff --git a/src/features/direct-messages/NewMessage.recovery.test.tsx b/src/features/direct-messages/NewMessage.recovery.test.tsx
index 919da4c9d2..e98034a6c4 100644
--- a/src/features/direct-messages/NewMessage.recovery.test.tsx
+++ b/src/features/direct-messages/NewMessage.recovery.test.tsx
@@ -17,6 +17,7 @@ import {
import userEvent from "@testing-library/user-event";
import { useChannelList } from "../relay/react";
import { createRelaySession } from "../relay/session";
+import { ReadError } from "../relay/errors";
import {
keypair,
profile,
@@ -200,6 +201,7 @@ function setup(
return owner;
};
const onStarted = vi.fn();
+ const navigate = vi.fn();
function Draft({
owner,
extensions,
@@ -229,7 +231,7 @@ function setup(
scope,
}),
},
- navigation: { open: vi.fn() },
+ navigation: { open: navigate },
} as unknown as ConversationHostServices
}
onOpenThread={vi.fn()}
@@ -251,6 +253,7 @@ function setup(
};
return {
storage,
+ navigate,
query,
events,
openDirectMessage,
@@ -1123,11 +1126,10 @@ it.each([false, true])(
if (filters.some((filter) => filter.kinds?.includes(9))) {
started = true;
await pending;
+ if (broken)
+ throw new ReadError("unavailable", "Relay read failed (503)", 503);
}
- const result = await read(filters);
- return broken && filters.some((filter) => filter.kinds?.includes(9))
- ? result.filter((event) => event.kind === 9)
- : result;
+ return read(filters);
});
const owner = t.create();
t.mount(owner);
@@ -1136,7 +1138,12 @@ it.each([false, true])(
expect(owner.session.channels.window(channel).status).toBe("error"),
);
await t.user.type(screen.getByRole("textbox"), "Keep this draft");
- const retry = screen.getByRole("button", { name: "Retry messages" });
+ expect(screen.getByRole("alert")).toHaveTextContent(
+ "Messages didn’t load.",
+ );
+ expect(screen.getByRole("alert")).not.toHaveTextContent(/relay|503/i);
+ expect(screen.queryByText("Details")).toBeNull();
+ const retry = screen.getByRole("button", { name: "Try again" });
const recipients = screen.getByRole("combobox", {
name: "Message recipients",
});
@@ -1191,6 +1198,32 @@ it.each([false, true])(
},
);
+it("says an unusable history answer can't be retried and offers updates instead", async () => {
+ const t = setup();
+ const read = t.query.getMockImplementation();
+ assert.exists(read);
+ // A history answer without its window bounds: the same read fails every time.
+ t.query.mockImplementation(async (filters) => {
+ const result = await read(filters);
+ return filters.some((filter) => filter.kinds?.includes(9))
+ ? result.filter((event) => event.kind === 9)
+ : result;
+ });
+ const owner = t.create();
+ t.mount(owner);
+ await t.user.click(await screen.findByRole("option", { name: "Avery" }));
+ const alert = await screen.findByRole("alert");
+ expect(alert).toHaveTextContent("Messages didn’t load.");
+ expect(alert).not.toHaveTextContent(/relay|bounds/i);
+ expect(screen.queryByRole("button", { name: "Try again" })).toBeNull();
+ await t.user.click(screen.getByRole("button", { name: "Check for updates" }));
+ expect(t.navigate).toHaveBeenCalledExactlyOnceWith({
+ version: 1,
+ kind: "settings",
+ section: "updates",
+ });
+});
+
const photo = {
name: "photo.png",
url: `https://relay.example/media/${"f".repeat(64)}.png`,
diff --git a/src/features/direct-messages/NewMessage.tsx b/src/features/direct-messages/NewMessage.tsx
index 9cf8164a02..2fef84833e 100644
--- a/src/features/direct-messages/NewMessage.tsx
+++ b/src/features/direct-messages/NewMessage.tsx
@@ -31,6 +31,7 @@ import { MessageComposer } from "../messages/MessageComposer";
import { mentionDraft, type MentionDraft } from "../messages/mention-draft";
import { clearView, readView, writeView } from "../../shared/view-state";
import { Button } from "../../shared/design-system/ui/Button";
+import { historyFailure } from "../messages/history-failure";
import {
useVerifiedNames,
withVerifiedNames,
@@ -631,14 +632,15 @@ function ExistingDiscussion({
if (document.activeElement === retry.current) onRecovered();
setRecovering(false);
}, [waiting, recovering, onRecovered]);
- if (waiting || recovering)
+ if (waiting || recovering) {
+ const failure =
+ window.status === "error"
+ ? historyFailure(window.error, "Messages didn’t load.")
+ : undefined;
return (
-
-
{window.status === "error" ? window.error : "Loading messages…"}
- {(window.status === "error" || recovering) && (
+
+
{failure ? failure.text : "Loading messages…"}
+ {(failure?.retry || recovering) && (
- Retry messages
+ Try again
+
+ )}
+ {failure?.update && (
+
+ void host.navigation.open({
+ version: 1,
+ kind: "settings",
+ section: "updates",
+ })
+ }
+ >
+ Check for updates
)}
);
+ }
return (
Promise) {
let fail = false;
let gate: Promise | undefined;
let historyGate: Promise | undefined;
+ let historyError: Error | undefined;
let historyStarted = () => {};
const published: RelayEvent[] = [];
const publish = vi.fn(async (event: RelayEvent) => {
@@ -116,6 +118,7 @@ function fixture(upload?: () => Promise) {
(
await Promise.all(
filters.map(async (filter) => {
+ if (historyError && filter.top_level) throw historyError;
if (
historyGate &&
(filter.top_level ||
@@ -250,6 +253,9 @@ function fixture(upload?: () => Promise) {
},
};
},
+ failHistory: (error: Error | undefined) => {
+ historyError = error;
+ },
liveMessage: () => {
const next = message(other, "room", "New live message", 1000000);
events.push(next);
@@ -931,6 +937,58 @@ it("opens a cold sent channel preview with an empty composer and locates the mes
expect(within(region).queryByText(/could not be found/)).toBeNull();
});
+async function openFailedSentPreview(error: Error) {
+ const h = fixture();
+ h.seedSent(false);
+ h.failHistory(error);
+ h.render();
+ fireEvent.click(screen.getByRole("tab", { name: "Sent" }));
+ fireEvent.click(
+ await screen.findByRole("button", { name: /View message.*Already sent/ }),
+ );
+ const region = screen.getByRole("region", {
+ name: "Sent message in #Design",
+ });
+ // The preview's own notice; a loaded timeline may add its edge notice.
+ const notice = (await within(region).findAllByRole("alert")).find(
+ (alert) => alert.tagName === "P",
+ )?.parentElement;
+ assert.exists(notice);
+ return { h, notice };
+}
+
+it("says a transient history failure didn’t load and retries on Try again", async () => {
+ const { h, notice } = await openFailedSentPreview(
+ new ReadError("unavailable", "Relay read failed (503)", 503),
+ );
+ expect(within(notice).getByRole("alert")).toHaveTextContent(
+ "Messages didn’t load.",
+ );
+ expect(within(notice).getByRole("alert")).not.toHaveTextContent(/relay|503/i);
+ expect(within(notice).queryByText("Details")).toBeNull();
+ h.failHistory(undefined);
+ fireEvent.click(within(notice).getByRole("button", { name: "Try again" }));
+ await waitFor(() => expect(notice).not.toBeInTheDocument());
+});
+
+it("offers only Check for updates when the history answer can never be used", async () => {
+ const { h, notice } = await openFailedSentPreview(
+ new ReadError("invalid-response", "Relay response is not an event array"),
+ );
+ expect(within(notice).getByRole("alert")).toHaveTextContent(
+ "Messages didn’t load.",
+ );
+ expect(
+ within(notice).queryByRole("button", { name: "Try again" }),
+ ).toBeNull();
+ fireEvent.click(
+ within(notice).getByRole("button", { name: "Check for updates" }),
+ );
+ expect(h.open).toHaveBeenCalledWith(
+ expect.objectContaining({ kind: "settings", section: "updates" }),
+ );
+});
+
it("reveals and highlights an off-screen Sent reply in its visible preview", async () => {
const h = fixture();
const sent = h.seedSent(true);
diff --git a/src/features/drafts-sent/DraftPreview.tsx b/src/features/drafts-sent/DraftPreview.tsx
index cf02e14e00..bc828afaf3 100644
--- a/src/features/drafts-sent/DraftPreview.tsx
+++ b/src/features/drafts-sent/DraftPreview.tsx
@@ -27,6 +27,7 @@ import { useChannelWindow } from "../relay/react";
import { readView, viewRevision } from "../../shared/view-state";
import { writeClipboardText } from "../../shared/clipboard";
import { Button } from "../../shared/design-system/ui/Button";
+import { historyFailure, UPDATES_LINK } from "../messages/history-failure";
import { IconButton } from "../../shared/design-system/ui/IconButton";
import {
PanelHeader,
@@ -304,8 +305,12 @@ function DraftChannel({
onSaved(): void;
onSend(id: string): void;
}) {
- const { session, channelId } = useConversation();
+ const { session, channelId, onOpenLink } = useConversation();
const channelWindow = useChannelWindow(session.channels, channelId);
+ const failure =
+ channelWindow.status === "error" || channelWindow.error
+ ? historyFailure(channelWindow.error, "Messages didn’t load.")
+ : undefined;
const [seekError, setSeekError] = useState(false);
const [noticeHeight, setNoticeHeight] = useState(0);
const [outgoing, setOutgoing] = useState();
@@ -375,17 +380,24 @@ function DraftChannel({
inlineTarget={reveal}
/>
)}
- {channelWindow.status === "error" || channelWindow.error ? (
+ {failure ? (
- {channelWindow.error ?? "Could not load conversation."}
+ {failure.text}
-
session.channels.refresh?.(channelId)}
- >
- Retry conversation
-
+ {failure.retry && (
+
session.channels.refresh?.(channelId)}
+ >
+ Try again
+
+ )}
+ {failure.update && (
+
onOpenLink(UPDATES_LINK)}>
+ Check for updates
+
+ )}
) : (
!channelWindow.rows.length &&
diff --git a/src/features/huddle/discussion.ts b/src/features/huddle/discussion.ts
index 7fe4f62cbe..4428103ad2 100644
--- a/src/features/huddle/discussion.ts
+++ b/src/features/huddle/discussion.ts
@@ -4,6 +4,7 @@ import type { Attachment, ChannelMessage } from "../relay/contracts";
import type { Delivery } from "../relay/outbox";
import { formatPublicKey } from "../../shared/identity/public-key";
import { avatarMedia, avatarSource } from "../../shared/avatar-source";
+import { userCopy } from "../relay/user-copy";
export type HuddleDiscussion = {
composer?: string | undefined;
@@ -148,7 +149,12 @@ export function createHuddleDiscussion(
: !resolved || window.status !== "ready"
? "loading"
: "ready",
- error: error ?? window.error,
+ error:
+ error ??
+ (window.error
+ ? userCopy(window.error, "Couldn’t load this Huddle conversation.")
+ .text
+ : undefined),
writable: writable(),
sending,
sent,
diff --git a/src/features/messages/ChannelTimeline.test.tsx b/src/features/messages/ChannelTimeline.test.tsx
index f8ca1913f8..7a431cf3c1 100644
--- a/src/features/messages/ChannelTimeline.test.tsx
+++ b/src/features/messages/ChannelTimeline.test.tsx
@@ -18,6 +18,10 @@ import {
import type { LiveCallbacks } from "../relay/live";
import type { RelaySession } from "../relay/session";
import type { ChannelMessage, ChannelWindow } from "../relay/contracts";
+import { ReadError } from "../relay/errors";
+import { ApiPaused } from "../relay/http-admission";
+import type { WindowFailure } from "../relay/window-failure";
+import { UPDATES_LINK } from "./history-failure";
// Boundary test, not a browser renderer. Capture this production component's
// effects/refs and invoke its returned DOM handlers. Deliberately stale Virtua
@@ -134,7 +138,10 @@ function setup({
hasMore = false,
loadingOlder = false,
historyLimited = false,
- error = undefined as string | undefined,
+ error = undefined as WindowFailure | undefined,
+ /** Where `error` came from: an older page unless a test says otherwise. */
+ errorFrom = "older" as "older" | "elsewhere",
+ openLink = (() => false) as (url: string) => boolean,
recovering = false,
initial = undefined as unknown,
mounted = [] as { id: string; y: number }[],
@@ -259,6 +266,9 @@ function setup({
loadingOlder: loadingOlder || accepted,
historyLimited,
error,
+ ...(error && errorFrom === "older"
+ ? { errorSource: "older" as const }
+ : {}),
});
const loadOlder = vi.fn(() => {
if (!blocked && !accepted && !loadingOlder) {
@@ -321,12 +331,15 @@ function setup({
status,
freshness,
error,
+ ...(error && errorFrom === "older"
+ ? { errorSource: "older" as const }
+ : {}),
rows,
hasMore,
loadingOlder,
historyLimited,
},
- onOpenLink: () => false,
+ onOpenLink: openLink,
revealMessageId,
});
if (key !== null && key !== scoped.key) {
@@ -2072,8 +2085,8 @@ it.each(["button", "scroll"] as const)(
historyControl,
error: "Read failed",
});
- expect(failed.alert()).toBe("Read failed");
- expect(failed.historyButton()).toBe("Retry older messages");
+ expect(failed.alert()).toBe("Older messages didn’t load.");
+ expect(failed.historyButton()).toBe("Try again");
failed.unmount();
// Recovery does not retry older history, and paging stops on a failed
// read, so hiding Retry would strand paging until recovery settles.
@@ -2084,11 +2097,92 @@ it.each(["button", "scroll"] as const)(
recovering: true,
});
expect(recovering.alert()).toBeUndefined();
- expect(recovering.historyButton()).toBe("Retry older messages");
+ expect(recovering.historyButton()).toBe("Try again");
recovering.unmount();
},
);
+it.each([
+ {
+ name: "a paused queue",
+ error: new ApiPaused(3000),
+ alert:
+ "Older messages didn’t load. Buzz is busy. Try again in a few seconds.",
+ },
+ {
+ name: "a native timeout",
+ error: "network-timeout",
+ alert: "Older messages didn’t load.",
+ },
+ {
+ name: "a server error",
+ error: new ReadError("unavailable", "Relay read failed (503)", 503),
+ alert: "Older messages didn’t load.",
+ },
+])(
+ "says older messages didn’t load and offers Try again for $name",
+ ({ error, alert }) => {
+ const h = setup({ hasMore: true, error });
+ expect(h.alert()).toBe(alert);
+ expect(h.historyButton()).toBe("Try again");
+ expect(JSON.stringify(h.alert())).not.toMatch(/Relay|503|network-timeout/);
+ h.retry();
+ expect(h.loadOlder).toHaveBeenCalledExactlyOnceWith("channel");
+ h.unmount();
+ },
+);
+
+it("offers only Check for updates when older messages can never load here", () => {
+ const openLink = vi.fn(() => true);
+ const h = setup({
+ hasMore: true,
+ error: new ReadError("unavailable", "Relay read failed (400)", 400),
+ openLink,
+ });
+ expect(h.alert()).toBe("Older messages didn’t load.");
+ expect(h.historyButton()).toBe("Check for updates");
+ h.retry();
+ expect(h.loadOlder).not.toHaveBeenCalled();
+ expect(openLink).toHaveBeenCalledExactlyOnceWith(UPDATES_LINK);
+ h.unmount();
+});
+
+it("offers no button when the community refused the read", () => {
+ const h = setup({
+ hasMore: true,
+ error: new ReadError("denied", "Relay read failed (403)", 403),
+ });
+ expect(h.alert()).toBe(
+ "Older messages didn’t load. You don’t have access to this conversation.",
+ );
+ expect(h.hasHistoryButton()).toBe(false);
+ h.unmount();
+});
+
+it.each([
+ ["a head revalidation", "Head revalidation failed"],
+ ["a cached-metadata resolve", new ReadError("unavailable", "down", 503)],
+ ["a newer-page seek", new ReadError("invalid-response", "bad page")],
+ ["a refused head read", new ReadError("denied", "Relay read failed (403)")],
+] as const)(
+ "keeps older paging for %s failure and does not blame older messages",
+ (_, error) => {
+ for (const historyControl of ["button", "scroll"] as const) {
+ const h = setup({
+ hasMore: true,
+ historyControl,
+ error,
+ errorFrom: "elsewhere",
+ });
+ expect(h.alert()).toBeUndefined();
+ expect(h.historyButton()).toBe("Load older messages");
+ h.retry();
+ expect(h.loadOlder).toHaveBeenCalledExactlyOnceWith("channel");
+ h.unmount();
+ }
+ },
+);
+
it("pages Solo chat history on scroll without an initial button and keeps error recovery", () => {
const h = setup({ hasMore: true, historyControl: "scroll" });
expect(h.hasHistoryButton()).toBe(false);
diff --git a/src/features/messages/ChannelTimeline.tsx b/src/features/messages/ChannelTimeline.tsx
index 4d245a68ba..30ee5a033d 100644
--- a/src/features/messages/ChannelTimeline.tsx
+++ b/src/features/messages/ChannelTimeline.tsx
@@ -59,6 +59,7 @@ import { messageViewKey } from "./view-key";
import { useKnownAgentPubkeys } from "../agents/use-known";
import { JumpToLatestButton } from "./JumpToLatestButton";
import { moveMessageSelection } from "./message-selection";
+import { historyFailure, UPDATES_LINK } from "./history-failure";
import { useMessageSelection } from "./use-message-selection";
import { useMessageVisit } from "./MessageManagement";
@@ -776,8 +777,14 @@ function Timeline({
// While the connection recovers, its notice is the one report, so the
// history edge does not repeat the reason. Recovery does not retry older
// history, and paging stops on a failed read, so Retry stays either way.
+ // Only an older page's failure is reported here; a head, catch-up or seek
+ // failure leaves older paging as it was.
const { recovering } = useRecovery(queries.recovery);
- const edgeError = recovering ? undefined : window.error;
+ const failure =
+ window.error && window.errorSource === "older"
+ ? historyFailure(window.error, "Older messages didn’t load.")
+ : undefined;
+ const edgeError = recovering ? undefined : failure;
// Keep pagination space through EOF so removing its control cannot change
// Virtua's start margin in the same measurement cycle as a prepend.
const [reserveHistorySpace, setReserveHistorySpace] = useState(false);
@@ -1943,12 +1950,21 @@ function Timeline({
data-intro={!!shownIntro || undefined}
>
{shownIntro}
- {edgeError && {edgeError} }
+ {edgeError && {edgeError.text} }
+ {edgeError?.update && (
+ onOpenLink(UPDATES_LINK)}>
+ Check for updates
+
+ )}
{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) ? (
+ ) : window.hasMore &&
+ (failure
+ ? failure.retry
+ : // Any failed read stops automatic paging, so offer the button.
+ historyControl === "button" || !!window.error) ? (
{window.loadingOlder
? "Loading older…"
- : window.error
- ? "Retry older messages"
+ : failure
+ ? "Try again"
: "Load older messages"}
) : window.loadingOlder ? (
diff --git a/src/features/messages/MediaReviewViewer.comments.test.tsx b/src/features/messages/MediaReviewViewer.comments.test.tsx
index 3ae894a0fb..00d87779ca 100644
--- a/src/features/messages/MediaReviewViewer.comments.test.tsx
+++ b/src/features/messages/MediaReviewViewer.comments.test.tsx
@@ -21,6 +21,8 @@ import type { RelayEvent } from "../relay/events";
import { createRelaySession } from "../relay/session";
import { keypair, message, metadata, roster, signed } from "../relay/testing";
import { MediaReviewViewer } from "./MediaReviewViewer";
+import { ReadError } from "../relay/errors";
+import { UPDATES_LINK } from "./history-failure";
import { ThreadPanel } from "./ThreadPanel";
import { LinkThreadTabContext } from "../conversation/LinkLabelContext";
@@ -208,6 +210,66 @@ function deferredRead() {
return { promise, resolve, reject };
}
+it.each([
+ ["with its media", (url: string) => url],
+ // The media and the comments both show the failure; the button shows once.
+ ["before its media resolves", () => undefined],
+])(
+ "offers one Check for updates, not Try again, when the thread read can never succeed (%s)",
+ async (_, media) => {
+ const viewer = keypair();
+ const relay = keypair();
+ let traffic!: LiveCallbacks;
+ const item = {
+ kind: "image" as const,
+ url: "https://fixture.test/item.png",
+ };
+ const root = message(viewer, "one", "One attachment", 1, [
+ ["imeta", `url ${item.url}`, `m ${mime(item.kind)}`],
+ ]);
+ const query = vi.fn(async (): Promise => {
+ throw new ReadError(
+ "invalid-response",
+ "Thread window bounds are invalid",
+ );
+ });
+ const owner = createRelaySession({
+ viewer: viewer.pubkey,
+ relayAuthor: relay.pubkey,
+ media,
+ query,
+ subscribe(callbacks) {
+ traffic = callbacks;
+ return { update() {}, retry() {}, dispose() {} };
+ },
+ });
+ owners.push(owner);
+ traffic.receive([roster(relay, "one", [viewer.pubkey], 10)]);
+ const onOpenLink = vi.fn(() => true);
+ render(
+ {}}
+ />,
+ );
+ await screen.findAllByText("This attachment didn’t load.");
+ expect(screen.queryByText(/Relay|400/)).toBeNull();
+ expect(screen.queryByRole("button", { name: "Try again" })).toBeNull();
+ // One button, as with Try again.
+ const update = screen.getByRole("button", { name: "Check for updates" });
+ fireEvent.click(update);
+ expect(onOpenLink).toHaveBeenCalledExactlyOnceWith(UPDATES_LINK);
+ },
+);
+
it.each([
{ firstKind: "image", outcome: "ready" },
{ firstKind: "video", outcome: "ready" },
@@ -279,12 +341,13 @@ it.each([
).toBeEnabled();
await waitFor(() => expect(query).toHaveBeenCalled());
await act(async () => gate?.reject(new Error("thread offline")));
- await screen.findByText("Error: thread offline");
+ await screen.findByText("This attachment didn’t load.");
+ expect(screen.queryByText(/thread offline/)).toBeNull();
fireEvent.click(screen.getByRole("button", { name: nextLabel }));
fireEvent.click(screen.getByRole("button", { name: nextLabel }));
expect(selectedMedia()).toHaveAttribute("src", last.url);
gate = deferredRead();
- fireEvent.click(screen.getByRole("button", { name: "Retry" }));
+ fireEvent.click(screen.getByRole("button", { name: "Try again" }));
expect(selectedMedia()).toHaveAttribute("src", last.url);
const edit = (items: typeof attachments, time: number) =>
signed(viewer, {
@@ -326,7 +389,7 @@ it.each([
expect(screen.queryByRole("button", { name: nextLabel })).toBeNull();
gate = deferredRead();
const readsBeforeRetry = query.mock.calls.length;
- fireEvent.click(screen.getByRole("button", { name: "Retry" }));
+ fireEvent.click(screen.getByRole("button", { name: "Try again" }));
await waitFor(() =>
expect(query.mock.calls.length).toBeGreaterThan(readsBeforeRetry),
);
diff --git a/src/features/messages/MediaReviewViewer.tsx b/src/features/messages/MediaReviewViewer.tsx
index 07f0692e21..1fade91d68 100644
--- a/src/features/messages/MediaReviewViewer.tsx
+++ b/src/features/messages/MediaReviewViewer.tsx
@@ -11,6 +11,8 @@ import {
} from "../../shared/design-system/ui/PanelHeader";
import { Checkbox } from "../../shared/design-system/ui/Checkbox";
import { Button } from "../../shared/design-system/ui/Button";
+import { historyFailure, UPDATES_LINK } from "./history-failure";
+import { windowFailure, type WindowFailure } from "../relay/window-failure";
import { IconButton } from "../../shared/design-system/ui/IconButton";
import {
useEffect,
@@ -85,7 +87,7 @@ export function MediaReviewViewer(props: MediaReviewViewerProps) {
request: 0,
}));
const [view, setView] = useState();
- const [threadError, setThreadError] = useState();
+ const [threadError, setThreadError] = useState();
useEffect(() => {
try {
const owned = session.thread(channelId, messageId, { exact: true });
@@ -94,7 +96,7 @@ export function MediaReviewViewer(props: MediaReviewViewerProps) {
void owned.refresh();
return () => owned.dispose();
} catch (error) {
- setThreadError(String(error));
+ setThreadError(windowFailure(error));
}
}, [session, channelId, messageId]);
const activeProps = {
@@ -142,7 +144,7 @@ function ResolvedReview({
...props
}: ActiveReviewProps & {
view: ThreadView | undefined;
- threadError: string | undefined;
+ threadError: WindowFailure | undefined;
}) {
const snapshot = useSyncExternalStore(
view?.subscribe ?? noThreadSubscription,
@@ -181,28 +183,34 @@ function ResolvedReview({
? knownRows.flatMap((row) => row.attachments)
: props.initialAttachments;
const pendingProps = { ...props, galleryAttachments };
- if (threadError) return ;
+ if (threadError)
+ return (
+
+ );
if (!view || !snapshot) return ;
if (invalidated || (blockedView === view && !authoritative))
return (
);
if (snapshot.error)
return (
);
if (snapshot.status === "loading" || snapshot.status === "idle")
@@ -239,6 +247,20 @@ function ResolvedReview({
);
}
+/** The shell's error, retry and update for a failed thread read. */
+function failureProps(
+ error: unknown,
+ onOpenLink: (url: string) => boolean,
+ refresh?: () => void | Promise,
+) {
+ const failure = historyFailure(error, "This attachment didn’t load.");
+ return {
+ error: failure.text,
+ ...(failure.retry && refresh ? { retry: refresh } : {}),
+ ...(failure.update ? { update: () => void onOpenLink(UPDATES_LINK) } : {}),
+ };
+}
+
function ReviewShell({
attachment,
galleryAttachments = [],
@@ -262,6 +284,7 @@ function ReviewShell({
unavailable = false,
error,
retry,
+ update,
restoreFocus,
selectAttachment,
selectionRequest,
@@ -276,6 +299,8 @@ function ReviewShell({
unavailable?: boolean;
error?: string;
retry?: () => void | Promise;
+ /** Offered when this Buzz can't use the answer and a retry fails again. */
+ update?: () => void;
}) {
const source = session.media(attachment.url);
// Listener unavailability is not a playback error the selection resets.
@@ -288,6 +313,13 @@ function ReviewShell({
frame.current?.focus({ preventScroll: true });
void retry?.();
};
+ // Like Try again, shown once: beside the media when it is unavailable,
+ // otherwise beside the comments.
+ const updateButton = update && (
+
+ Check for updates
+
+ );
const video = useRef(null);
const [commentsChoice, setCommentsOpen] = useState();
const [selectedImageUrl, setSelectedImageUrl] = useState(attachment.url);
@@ -547,9 +579,10 @@ function ReviewShell({
{error ?? "Loading comments…"}
{retry && !unavailable && (
- Retry
+ Try again
)}
+ {!unavailable && updateButton}
)}
@@ -568,9 +601,10 @@ function ReviewShell({
{error ?? (loading ? "Loading media…" : "Media unavailable")}
{retry && unavailable && (
- Retry
+ Try again
)}
+ {unavailable && updateButton}
) : attachment.kind === "video" && source ? (
false,
+ onOpenLink: vi.fn((_url: string) => false),
...(onOpenMediaReview ? { onOpenMediaReview } : {}),
};
let mounted: ReturnType | undefined;
@@ -919,7 +922,8 @@ it("keeps older-page loading quiet and retry between root and replies", () => {
h.snapshot.error = "offline";
h.render();
const alert = within(section).getByRole("alert");
- expect(alert).toHaveTextContent("offline");
+ expect(alert).toHaveTextContent("Older replies didn’t load.");
+ expect(alert).not.toHaveTextContent("offline");
expect(
root.compareDocumentPosition(alert) & Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy();
@@ -935,6 +939,49 @@ it("keeps older-page loading quiet and retry between root and replies", () => {
h.render();
expect(within(section).queryByText("Loading older replies…")).toBeNull();
});
+it.each([
+ {
+ name: "a server error",
+ error: new ReadError("unavailable", "Relay read failed (503)", 503),
+ alert: "Older replies didn’t load.",
+ retry: true,
+ },
+ {
+ name: "a paused queue",
+ error: new ApiPaused(3000),
+ alert:
+ "Older replies didn’t load. Buzz is busy. Try again in a few seconds.",
+ retry: true,
+ },
+ {
+ name: "a request this Buzz can't make",
+ error: new ReadError("unavailable", "Relay read failed (400)", 400),
+ alert: "Older replies didn’t load.",
+ retry: false,
+ },
+])(
+ "maps a failed older-replies read for $name without raw text",
+ ({ error, alert, retry }) => {
+ const h = messagesHarness();
+ h.snapshot.direction = "older";
+ h.snapshot.readKind = "older";
+ h.snapshot.status = "error";
+ h.snapshot.error = error;
+ const section = h.render();
+ expect(within(section).getByRole("alert")).toHaveTextContent(alert);
+ expect(within(section).getByRole("alert")).not.toHaveTextContent(
+ /relay|\b(400|503)\b/i,
+ );
+ expect(!!within(section).queryByRole("button", { name: "Try again" })).toBe(
+ retry,
+ );
+ if (retry) return;
+ fireEvent.click(
+ within(section).getByRole("button", { name: "Check for updates" }),
+ );
+ expect(h.props.onOpenLink).toHaveBeenCalledExactlyOnceWith(UPDATES_LINK);
+ },
+);
it("keeps retained-range repair quiet and retry after the replies", () => {
const h = messagesHarness();
h.snapshot.direction = "older";
diff --git a/src/features/messages/ThreadPanel.tsx b/src/features/messages/ThreadPanel.tsx
index f210954af5..b8ede95e34 100644
--- a/src/features/messages/ThreadPanel.tsx
+++ b/src/features/messages/ThreadPanel.tsx
@@ -9,6 +9,7 @@ import {
useStableRowActivity,
} from "./activity-presentation";
import { activityRuns } from "./activity-groups";
+import { historyFailure, UPDATES_LINK } from "./history-failure";
import { DayDivider } from "./MessageTimestamp";
import { workflowLabel } from "../relay/workflow-attribution";
import { usePanelTabHost } from "../panels/PanelWorkspace";
@@ -124,6 +125,33 @@ export type ThreadPanelProps = {
// Read errors are diagnostics, not copy; the notice says what happened.
const THREAD_FAILED = "This thread didn’t load.";
+function OlderRepliesFailure({
+ error,
+ onRetry,
+ onOpenLink,
+}: {
+ error: unknown;
+ onRetry(): void;
+ onOpenLink(url: string): boolean;
+}) {
+ const failure = historyFailure(error, "Older replies didn’t load.");
+ return (
+ <>
+ {failure.text}
+ {failure.retry && (
+
+ Try again
+
+ )}
+ {failure.update && (
+ onOpenLink(UPDATES_LINK)}>
+ Check for updates
+
+ )}
+ >
+ );
+}
+
/** Safe to retarget through ordinary props; callers do not own internal remount keys. */
export function ThreadPanel(props: ThreadPanelProps) {
useMessageSelectionCopy();
@@ -1935,10 +1963,11 @@ function ThreadMessages({
{showOlderPageStatus && snapshot.error && (
- {snapshot.error}
-
- Try again
-
+
)}
{renderReplies(undefined)}
diff --git a/src/features/messages/attachment-panes.test.tsx b/src/features/messages/attachment-panes.test.tsx
index 70d89fce1b..e67d8e5512 100644
--- a/src/features/messages/attachment-panes.test.tsx
+++ b/src/features/messages/attachment-panes.test.tsx
@@ -309,7 +309,7 @@ it.each([false, true])(
});
expect(view.container.contains(pane)).toBe(false); // Actual document.body portal.
const target = unavailable
- ? within(pane).getByText("Error: Unavailable review")
+ ? within(pane).getByText("This attachment didn’t load.")
: await within(pane).findByText("Existing review comment");
expect(target.closest("form")).toBeNull();
if (!unavailable)
diff --git a/src/features/messages/history-failure.test.ts b/src/features/messages/history-failure.test.ts
new file mode 100644
index 0000000000..3e1b1c4c35
--- /dev/null
+++ b/src/features/messages/history-failure.test.ts
@@ -0,0 +1,87 @@
+import { expect, it } from "vitest";
+import { ReadError } from "../relay/errors";
+import { ApiCapacity, ApiPaused } from "../relay/http-admission";
+import { windowFailure } from "../relay/window-failure";
+import { historyFailure } from "./history-failure";
+
+const context = "Messages didn’t load.";
+
+it.each([
+ ["a 503", new ReadError("unavailable", "Relay read failed (503)", 503)],
+ ["a native timeout", "network-timeout"],
+ ["a native outage", "network-unavailable"],
+ ["a cancelled read", new ReadError("cancelled", "Relay read cancelled")],
+ ["an unknown failure", new Error("boom")],
+ ["no failure", undefined],
+])("keeps only the context and offers Try again for %s", (_, error) => {
+ expect(historyFailure(error, context)).toMatchObject({
+ text: context,
+ retry: true,
+ update: false,
+ });
+});
+
+it.each([new ApiPaused(3000), new ApiCapacity()])(
+ "tells the reader a busy Buzz can be tried again",
+ (error) => {
+ expect(historyFailure(error, context)).toMatchObject({
+ text: `${context} Buzz is busy. Try again in a few seconds.`,
+ retry: true,
+ update: false,
+ });
+ },
+);
+
+it("offers only an update when the answer can never be used", () => {
+ const failure = historyFailure(
+ new ReadError("invalid-response", "Relay response is not an event array"),
+ "Older messages didn’t load.",
+ );
+ // The surface's own sentence, as for any other failure; only the button differs.
+ expect(failure).toMatchObject({
+ text: "Older messages didn’t load.",
+ retry: false,
+ update: true,
+ });
+ // The raw text is for diagnostics, not the surface.
+ expect(failure).not.toHaveProperty("details");
+ expect(
+ historyFailure(
+ new ReadError("unavailable", "Relay read failed (400)", 400),
+ context,
+ ),
+ ).toMatchObject({ text: context, retry: false, update: true });
+});
+
+it("offers neither button for a refusal and never shows the raw text", () => {
+ const failure = historyFailure(
+ new ReadError("denied", "Relay read failed (403)", 403),
+ context,
+ );
+ expect(failure).toMatchObject({
+ text: `${context} You don’t have access to this conversation.`,
+ retry: false,
+ update: false,
+ });
+ expect(failure.text).not.toMatch(/relay|403/i);
+});
+
+it("keeps a known refusal reason instead of the fallback", () => {
+ expect(
+ historyFailure(
+ new ReadError("denied", "restricted: not a channel member", 403),
+ context,
+ ),
+ ).toMatchObject({
+ text: `${context} You’re not a member of this channel.`,
+ retry: false,
+ });
+});
+
+it("keeps a window failure non-empty, whatever rejected", () => {
+ const error = new ReadError("unavailable", "down", 503);
+ expect(windowFailure(error)).toBe(error);
+ expect(windowFailure("network-timeout")).toBe("network-timeout");
+ for (const value of ["", undefined, null, 0])
+ expect(windowFailure(value)).toBeInstanceOf(Error);
+});
diff --git a/src/features/messages/history-failure.ts b/src/features/messages/history-failure.ts
new file mode 100644
index 0000000000..68926e5e67
--- /dev/null
+++ b/src/features/messages/history-failure.ts
@@ -0,0 +1,40 @@
+import { failureKind, userCopy } from "../relay/user-copy";
+
+/** A refused read whose reason Buzz doesn't recognize still says why. */
+const NO_ACCESS = "You don’t have access to this conversation.";
+
+/** Opens Settings → Software updates through a conversation's `onOpenLink`. */
+export const UPDATES_LINK = "buzz://settings?section=updates";
+
+export type HistoryFailure = Readonly<{
+ text: string;
+ /** Offer "Try again": the same read can succeed. */
+ retry: boolean;
+ /** Offer "Check for updates": this Buzz can't use the answer, and the same
+ * read fails again. */
+ update: boolean;
+}>;
+
+/** What a failed history read (a `WindowFailure`, or nothing) says on the surface that shows it. `context` is
+ * the surface's own sentence, such as "Messages didn’t load.", and is the
+ * whole text for a connection failure, timeout, unusable answer or unknown
+ * failure. A busy Buzz or a refusal adds its reason. */
+export function historyFailure(
+ error: unknown,
+ context: string,
+): HistoryFailure {
+ const copy = userCopy(error, context);
+ const kind = failureKind(error);
+ if (kind === "unusable") return { text: context, retry: false, update: true };
+ // Refused keeps the helper's text, the context plus a known reason, and
+ // otherwise says the conversation isn't open to this account.
+ const text =
+ kind === "refused"
+ ? copy.text === context
+ ? `${context} ${NO_ACCESS}`
+ : copy.text
+ : kind === "busy"
+ ? copy.text
+ : context;
+ return { text, retry: copy.retry, update: false };
+}
diff --git a/src/features/messages/use-media-read-recovery.ts b/src/features/messages/use-media-read-recovery.ts
index 8acf27997f..e560e1b79d 100644
--- a/src/features/messages/use-media-read-recovery.ts
+++ b/src/features/messages/use-media-read-recovery.ts
@@ -1,9 +1,11 @@
import { useEffect, useRef } from "react";
+import { ReadError } from "../relay/errors";
import { retryRead } from "../relay/read-retry";
function mediaFailure(element: HTMLMediaElement): Error {
+ // MEDIA_ERR_NETWORK: the bytes stopped arriving, so a reload can recover.
return element.error?.code === 2
- ? new TypeError("Media network read failed")
+ ? new ReadError("unavailable", "Media network read failed")
: new Error("Media could not be decoded");
}
diff --git a/src/features/messages/use-message-opening.test.ts b/src/features/messages/use-message-opening.test.ts
index 3c391b21a5..fbc89a5ab0 100644
--- a/src/features/messages/use-message-opening.test.ts
+++ b/src/features/messages/use-message-opening.test.ts
@@ -347,7 +347,7 @@ it("waits for a queued catch-up retry instead of deciding on its earlier failure
expect(channels.window("a")).toMatchObject({
status: "ready",
freshness: "cached",
- error: expect.any(String),
+ error: expect.any(ReadError),
});
// The target arrived while the stream was down.
const target = message(alice, "a", "target", 20);
diff --git a/src/features/relay/ConnectionFailed.test.tsx b/src/features/relay/ConnectionFailed.test.tsx
new file mode 100644
index 0000000000..54230c5b7f
--- /dev/null
+++ b/src/features/relay/ConnectionFailed.test.tsx
@@ -0,0 +1,147 @@
+// @vitest-environment jsdom
+import "@testing-library/jest-dom/vitest";
+import { cleanup, render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { afterEach, expect, it, vi } from "vitest";
+import {
+ ConnectionFailed,
+ connectionFailureAction,
+ connectionFailureText,
+} from "./ConnectionFailed";
+import type { RelaySnapshot } from "./service";
+
+afterEach(cleanup);
+const error = {
+ kind: "connection",
+ details: "ReadError: Relay read failed (502)",
+} as const;
+const view = (
+ connection: Parameters[0]["connection"],
+) => {
+ const relay = { tryAgain: vi.fn() };
+ render( );
+ return relay;
+};
+
+it("says it can't connect and offers Try again, without the raw failure", async () => {
+ const relay = view({ error });
+ expect(screen.getByRole("alert")).toBeInTheDocument();
+ expect(
+ screen.getByRole("heading", { name: "Can’t connect" }),
+ ).toBeInTheDocument();
+ expect(
+ screen.getByText("Check your connection, then try again."),
+ ).toBeInTheDocument();
+ // The raw text is for diagnostics, with no Details disclosure.
+ expect(screen.queryByText(/ReadError|502/)).toBeNull();
+ expect(screen.queryByText("Details")).toBeNull();
+ await userEvent.click(screen.getByRole("button", { name: "Try again" }));
+ expect(relay.tryAgain).toHaveBeenCalledOnce();
+});
+
+it("says it keeps trying while the connection retries on its own", () => {
+ view({ error, retrying: true });
+ expect(
+ screen.getByText("Check your connection. We’ll keep trying."),
+ ).toBeInTheDocument();
+});
+
+it("offers Check for updates, not Try again, when the community's answer can't be used", async () => {
+ const openUpdates = vi.fn();
+ const relay = { tryAgain: vi.fn() };
+ render(
+ ,
+ );
+ const alert = screen.getByRole("alert");
+ // Its only action is Check for updates, so the sentence neither asks to
+ // try again nor says an update fixes it.
+ expect(alert).toHaveTextContent(
+ "Can’t connectBuzz couldn’t connect to this community.",
+ );
+ expect(alert).not.toHaveTextContent(
+ /try again|Check your connection|Updating Buzz|event array/i,
+ );
+ expect(screen.queryByRole("button", { name: "Try again" })).toBeNull();
+ await userEvent.click(
+ screen.getByRole("button", { name: "Check for updates" }),
+ );
+ expect(openUpdates).toHaveBeenCalledOnce();
+ expect(relay.tryAgain).not.toHaveBeenCalled();
+ expect(screen.queryByText("Details")).toBeNull();
+});
+
+it("explains a refusal with no Try again", () => {
+ view({ error, denied: true, refusal: "access" });
+ expect(
+ screen.getByRole("heading", {
+ name: "You don’t have access to this community",
+ }),
+ ).toBeInTheDocument();
+ expect(screen.queryByRole("button")).toBeNull();
+ expect(screen.queryByRole("alert")).toBeNull();
+ expect(screen.queryByText("Details")).toBeNull();
+});
+
+it("gives one sentence for pages that say it in their own status line", () => {
+ const sentence = (connection: Partial) =>
+ connectionFailureText(connection);
+ expect(sentence({})).toBe("Check your connection, then try again.");
+ expect(sentence({ retrying: true })).toBe(
+ "Check your connection. We’ll keep trying.",
+ );
+ expect(sentence({ denied: true, refusal: "sign-in" })).not.toMatch(
+ /connection/,
+ );
+ // As the card says it, beside Check for updates: no "try again".
+ expect(sentence({ error: { kind: "unusable", details: "x" } })).toBe(
+ "Buzz couldn’t connect to this community.",
+ );
+});
+
+it("offers the card's action beside that sentence", async () => {
+ const relay = { tryAgain: vi.fn() };
+ const openUpdates = vi.fn();
+ const action = (connection: Partial) => {
+ cleanup();
+ render(
+
+ {connectionFailureAction({
+ connection: { status: "error", ...connection },
+ relay,
+ openUpdates,
+ })}
+
,
+ );
+ return screen.queryAllByRole("button").map((button) => button.textContent);
+ };
+ expect(action({ error })).toEqual(["Retry connection"]);
+ await userEvent.click(screen.getByRole("button"));
+ expect(relay.tryAgain).toHaveBeenCalledOnce();
+ expect(action({ error: { kind: "unusable", details: "x" } })).toEqual([
+ "Check for updates",
+ ]);
+ await userEvent.click(screen.getByRole("button"));
+ expect(openUpdates).toHaveBeenCalledOnce();
+ expect(relay.tryAgain).toHaveBeenCalledOnce();
+ // A refusal's notice offers the fix; a connection that hasn't failed, nothing.
+ expect(action({ error, denied: true })).toEqual([]);
+ expect(action({ status: "connecting" })).toEqual([]);
+ expect(
+ connectionFailureAction({
+ connection: {
+ status: "error",
+ error: { kind: "unusable", details: "x" },
+ },
+ relay,
+ }),
+ ).toBeUndefined();
+});
diff --git a/src/features/relay/ConnectionFailed.tsx b/src/features/relay/ConnectionFailed.tsx
new file mode 100644
index 0000000000..b4de200b2c
--- /dev/null
+++ b/src/features/relay/ConnectionFailed.tsx
@@ -0,0 +1,120 @@
+import { communityRefusalTitle } from "../federated-identity/CommunitySignIn";
+import { CloudOffIcon, LockIcon } from "../../shared/design-system/icons/index";
+import { Button } from "../../shared/design-system/ui/Button";
+import { EmptyState } from "../../shared/design-system/ui/EmptyState";
+import type { Navigation } from "../navigation/controller";
+import type { RelayData, RelaySnapshot } from "./service";
+
+/** The community answered in a way this Buzz can't use. Its only action is
+ * Check for updates, so the sentence neither asks to try again nor promises
+ * an update fixes it; everywhere the failure shows, it says this. */
+export const UNUSABLE_CONNECTION = "Buzz couldn’t connect to this community.";
+
+/** One sentence for a failed connection, for the pages that say it in their
+ * own status line rather than with the card. */
+export function connectionFailureText(
+ connection: Pick,
+) {
+ if (connection.denied) return communityRefusalTitle(connection.refusal);
+ if (connection.error?.kind === "unusable") return UNUSABLE_CONNECTION;
+ return connection.retrying
+ ? "Check your connection. We’ll keep trying."
+ : "Check your connection, then try again.";
+}
+
+/** Opens Settings → Software updates, for `openUpdates`. */
+export const openUpdatesWith = (navigator: Pick) => () =>
+ void navigator.open({ version: 1, kind: "settings", section: "updates" });
+
+/** The action beside `connectionFailureText` on those pages, as the card
+ * offers it: Retry connection, or for an answer this Buzz can't use, Check
+ * for updates (nothing without `openUpdates`). Nothing for a refusal, whose
+ * own notice offers what fixes it, or a connection that hasn't failed. */
+export function connectionFailureAction({
+ connection,
+ relay,
+ openUpdates,
+ size,
+}: {
+ connection: Pick;
+ relay: Pick;
+ openUpdates?: (() => void) | undefined;
+ size?: "sm";
+}) {
+ if (connection.status !== "error" || connection.denied) return undefined;
+ const sized = size ? { size } : {};
+ if (connection.error?.kind === "unusable")
+ return openUpdates ? (
+
+ Check for updates
+
+ ) : undefined;
+ return (
+ relay.tryAgain()}>
+ Retry connection
+
+ );
+}
+
+/** What a page shows in place of its content while the community session is in
+ * `error`: the refusal notice when the community refused this account, with no
+ * Try again; otherwise the plain "Can’t connect" card, which for an answer
+ * this Buzz can't use says only that and offers Check for updates in place of
+ * Try again. `className` frames it within the page. */
+export function ConnectionFailed({
+ connection,
+ relay,
+ className,
+ openUpdates,
+}: {
+ connection: Pick;
+ relay: Pick;
+ className?: string | undefined;
+ /** Opens Settings → Software updates, for a failure an update may fix. */
+ openUpdates?: (() => void) | undefined;
+}) {
+ if (connection.denied)
+ // The community refused this account. Its notice, on every page, says what
+ // fixes it and offers that; this only says why it's empty.
+ return (
+
+ }
+ title={communityRefusalTitle(connection.refusal)}
+ />
+
+ );
+ const unusable = connection.error?.kind === "unusable";
+ return (
+
+ }
+ title="Can’t connect"
+ description={
+ unusable
+ ? UNUSABLE_CONNECTION
+ : connection.retrying
+ ? "Check your connection. We’ll keep trying."
+ : "Check your connection, then try again."
+ }
+ action={
+ unusable ? (
+ openUpdates && (
+
+ Check for updates
+
+ )
+ ) : (
+ relay.tryAgain()}
+ >
+ Try again
+
+ )
+ }
+ />
+
+ );
+}
diff --git a/src/features/relay/channel-lifecycle.test.ts b/src/features/relay/channel-lifecycle.test.ts
index 24f5731a2f..b04e232e3a 100644
--- a/src/features/relay/channel-lifecycle.test.ts
+++ b/src/features/relay/channel-lifecycle.test.ts
@@ -208,7 +208,7 @@ describe("bounded visibility recovery", () => {
]);
await h.owner.capability.refreshVisibility();
h.read.mockClear();
- h.read.mockRejectedValueOnce(new TypeError("fetch failed"));
+ h.read.mockRejectedValueOnce(new TypeError("Failed to fetch"));
const pending = h.owner.capability.refreshVisibility();
await vi.advanceTimersByTimeAsync(0);
expect(h.owner.capability.snapshot()).toEqual({
@@ -309,7 +309,7 @@ describe("bounded visibility recovery", () => {
expect(concurrent).toBe(first);
await vi.advanceTimersByTimeAsync(0);
expect(h.read).toHaveBeenCalledOnce();
- gate.reject(new TypeError("offline"));
+ gate.reject(new TypeError("Failed to fetch"));
await vi.advanceTimersByTimeAsync(0);
expect(h.owner.capability.refreshVisibility()).toBe(first);
await vi.advanceTimersByTimeAsync(750);
@@ -323,7 +323,7 @@ describe("bounded visibility recovery", () => {
async (action) => {
const h = harness();
const caller = new AbortController();
- h.read.mockRejectedValue(new TypeError("offline"));
+ h.read.mockRejectedValue(new TypeError("Failed to fetch"));
const pending = h.owner.capability.refreshVisibility(caller.signal);
await vi.advanceTimersByTimeAsync(0);
if (action === "caller") caller.abort();
diff --git a/src/features/relay/channel-opening.test.ts b/src/features/relay/channel-opening.test.ts
index dae8be8af8..d110341500 100644
--- a/src/features/relay/channel-opening.test.ts
+++ b/src/features/relay/channel-opening.test.ts
@@ -71,7 +71,10 @@ it("a failed post-stream cold head leaves loading and exposes a retryable window
await flush();
expect(h.owner.session.channels.window("z")).toMatchObject({
status: "error",
- error: "Relay read timed out",
+ error: expect.objectContaining({
+ kind: "unavailable",
+ message: "Relay read timed out",
+ }),
rows: [],
});
h.owner.session.channels.ensure("z");
@@ -114,7 +117,11 @@ it("quota refusal settles queued cold windows without another request and ensure
await flush();
expect(h.owner.session.channels.window("z")).toMatchObject({
status: "error",
- error: "Relay requests paused",
+ error: expect.objectContaining({
+ kind: "unavailable",
+ message: "Relay requests paused",
+ status: 429,
+ }),
rows: [],
});
h.owner.session.channels.ensure("z");
diff --git a/src/features/relay/contracts.ts b/src/features/relay/contracts.ts
index 8c3b2448e9..0cc999b6f4 100644
--- a/src/features/relay/contracts.ts
+++ b/src/features/relay/contracts.ts
@@ -1,3 +1,4 @@
+import type { WindowFailure } from "./window-failure";
import type { VerifiedProfile } from "./verified-profile";
import type { ReadFilter } from "./events";
import type { CustomEmoji } from "./emoji";
@@ -218,7 +219,11 @@ export type ChannelWindow = Readonly<{
/** False only after the relay's window bounds reported no more history. */
hasMore: boolean;
loadingOlder: boolean;
- error: string | undefined;
+ error: WindowFailure | undefined;
+ /** `older` when `error` came from an older page (`loadOlder`). Head
+ * revalidation, catch-up and seek failures leave it unset, so they never
+ * read as older history failing or hide older paging. */
+ errorSource?: "older" | undefined;
/** Cached rows are immediately readable, but not claimed fresh until revalidated. */
freshness?: "cached" | "verified";
historyLimited?: boolean;
diff --git a/src/features/relay/errors.ts b/src/features/relay/errors.ts
index bf64606b82..5e7f827dbb 100644
--- a/src/features/relay/errors.ts
+++ b/src/features/relay/errors.ts
@@ -48,6 +48,20 @@ const NATIVE_TRANSIENT = new Set([
"network-unavailable",
"network-timeout",
]);
+/** The messages fetch rejects with when the request never reached the host:
+ * Chromium, WebKit (current, then the platform's own wording it surfaced
+ * before), Gecko. Then the ones a body read rejects with when the connection
+ * drops after the headers arrived: Chromium, Gecko (WebKit says "Load failed"
+ * again). Any other TypeError is a bug, not the network. */
+export const FETCH_FAILURES: ReadonlySet = new Set([
+ "Failed to fetch",
+ "Load failed",
+ "The network connection was lost.",
+ "The Internet connection appears to be offline.",
+ "NetworkError when attempting to fetch resource.",
+ "network error",
+ "Error in input stream",
+]);
/** True when trying the same read again can succeed without anyone acting:
* the relay or network was unavailable, slow, or the read was cancelled. A
* denial, an invalid response or a deterministic local check is not. */
@@ -57,7 +71,7 @@ export function isTransientFailure(error: unknown): boolean {
if (typeof error === "string") return NATIVE_TRANSIENT.has(error);
// fetch rejects with TypeError when the network is unreachable.
if (
- error instanceof TypeError ||
+ (error instanceof TypeError && FETCH_FAILURES.has(error.message)) ||
(error instanceof Error &&
(error.name === "AbortError" ||
error.name === "TimeoutError" ||
diff --git a/src/features/relay/events.ts b/src/features/relay/events.ts
index 591660759e..705da7ba7d 100644
--- a/src/features/relay/events.ts
+++ b/src/features/relay/events.ts
@@ -6,6 +6,7 @@ import {
type VerifiedEvent,
} from "nostr-tools";
import { ByteLru } from "./budget.ts";
+import { ReadError } from "./errors.ts";
/** A relay event whose signature has been verified at the transport boundary. */
export type RelayEvent = VerifiedEvent;
/** Event payload shared by locally authored intent and verified relay records.
@@ -62,7 +63,10 @@ export function eventDto(value: unknown): RelayEvent {
/** A whole query response; one bad event rejects it, as relay reads do. */
export function eventsDto(value: unknown): RelayEvent[] {
if (!Array.isArray(value))
- throw new Error("Relay response is not an event array");
+ throw new ReadError(
+ "invalid-response",
+ "Relay response is not an event array",
+ );
return value.map(eventDto);
}
diff --git a/src/features/relay/live-session.test.ts b/src/features/relay/live-session.test.ts
index eacb4b9c8d..a79fb9d7b8 100644
--- a/src/features/relay/live-session.test.ts
+++ b/src/features/relay/live-session.test.ts
@@ -715,7 +715,12 @@ it.each<
string | undefined,
]
>([
- ["fails", (h) => h.read().fail(new Error("offline")), () => {}, "offline"],
+ [
+ "fails",
+ (h) => h.read().fail(new Error("offline")),
+ () => {},
+ expect.objectContaining({ message: "offline" }),
+ ],
[
"is cancelled by the stream dropping",
(h) => {
@@ -862,7 +867,7 @@ it("an interrupted fill drops the rows kept across the drop and retains the repl
status: "ready",
freshness: "cached",
hasMore: true,
- error: "offline",
+ error: expect.objectContaining({ message: "offline" }),
});
} finally {
h.owner.dispose();
@@ -1871,7 +1876,7 @@ describe("a reconnect whose replay was continuous", () => {
h.owner.session.channels.loadOlder("c");
h.read().fail(new Error("Relay request failed"));
await flush();
- expect(h.window().error).toBe("Relay request failed");
+ expect(h.window().error).toEqual(new Error("Relay request failed"));
h.queries.length = 0;
await resume(h, [], true);
expect(channelReads(h)).toEqual([]);
@@ -2589,7 +2594,7 @@ it("retries transient sidebar hint failures through existing preference recovery
});
try {
await owner.session.sidebarPreferences.ensure();
- decode.mockRejectedValueOnce(new TypeError("network unavailable"));
+ decode.mockRejectedValueOnce(new TypeError("Failed to fetch"));
live.receive([
signed(viewer, {
kind: 30078,
diff --git a/src/features/relay/outbox.ts b/src/features/relay/outbox.ts
index ba921dd40b..2a70b7803e 100644
--- a/src/features/relay/outbox.ts
+++ b/src/features/relay/outbox.ts
@@ -123,7 +123,17 @@ export class PublishRejected extends Error {
/** Proven not delivered, but not refused: the request never left this
* device, or the relay asked it to wait. Retrying may succeed. */
-export class PublishNotSent extends PublishRejected {}
+export class PublishNotSent extends PublishRejected {
+ constructor(
+ message: string,
+ reason?: string,
+ /** Set when the relay or Buzz asked to wait (a quota, or a paused or full
+ * request queue), with the wait in milliseconds when it named one. */
+ readonly wait?: Readonly<{ retryAfterMs?: number }>,
+ ) {
+ super(message, reason);
+ }
+}
export { browserOutboxStorage } from "./outbox-storage";
const abortError = () => new DOMException("Relay session closed", "AbortError");
diff --git a/src/features/relay/read-retry.test.ts b/src/features/relay/read-retry.test.ts
index c45a87fe3c..b5bf36992c 100644
--- a/src/features/relay/read-retry.test.ts
+++ b/src/features/relay/read-retry.test.ts
@@ -14,7 +14,7 @@ afterEach(() => {
it("retries transient reads at the backoff boundaries and returns success", async () => {
const read = vi
.fn()
- .mockRejectedValueOnce(new TypeError("offline"))
+ .mockRejectedValueOnce(new TypeError("Failed to fetch"))
.mockResolvedValue("loaded");
const pending = retryRead(read, new AbortController().signal);
await vi.advanceTimersByTimeAsync(749);
@@ -122,7 +122,7 @@ it.each([
it("aborts backoff and releases the timer", async () => {
const controller = new AbortController();
- const read = vi.fn().mockRejectedValue(new TypeError("offline"));
+ const read = vi.fn().mockRejectedValue(new TypeError("Failed to fetch"));
const pending = retryRead(read, controller.signal);
const rejected = expect(pending).rejects.toMatchObject({
name: "AbortError",
diff --git a/src/features/relay/service.test.ts b/src/features/relay/service.test.ts
index 6c9c8c7c00..57f057a7aa 100644
--- a/src/features/relay/service.test.ts
+++ b/src/features/relay/service.test.ts
@@ -1,5 +1,6 @@
import { afterEach, expect, it, vi } from "vitest";
import { Context } from "@deepseek-ai/cordis";
+import { ReadError } from "./errors";
import { provideRelay } from "./service";
import type { ReadTransport } from "./transport";
import {
@@ -218,6 +219,219 @@ it("makes Try again on a ready connection retry what failed, without reconnectin
expect(recovery?.snapshot().reauthorized).toBe(1);
expect(connect).toHaveBeenCalledTimes(1);
});
+it("keeps what a failed connection means, not an exception's words, in the snapshot", async () => {
+ vi.useFakeTimers();
+ const connect = vi
+ .fn<() => Promise>()
+ .mockRejectedValueOnce(
+ new ReadError("unavailable", "Relay read failed (502)", 502),
+ )
+ .mockRejectedValueOnce(
+ new ReadError("denied", "Relay read failed (403)", 403),
+ );
+ const data = provideRelay(root(), connect);
+ await vi.advanceTimersByTimeAsync(0);
+ // A 502 can clear without anyone acting, so the service keeps trying.
+ expect(data.snapshot()).toMatchObject({
+ status: "error",
+ retrying: true,
+ error: {
+ kind: "connection",
+ details: "ReadError: Relay read failed (502)",
+ },
+ });
+ expect(data.snapshot().denied).toBeUndefined();
+ await vi.advanceTimersByTimeAsync(30_000);
+ // A refusal waits for the person.
+ expect(data.snapshot()).toMatchObject({
+ status: "error",
+ denied: true,
+ error: { kind: "refused", details: "ReadError: Relay read failed (403)" },
+ });
+ expect(data.snapshot().retrying).toBeUndefined();
+});
+it("asks again slowly, and on focus, after an answer this Buzz can't use", async () => {
+ vi.useFakeTimers();
+ // Hints arrive from the window; this file runs without a DOM.
+ vi.stubGlobal("window", new EventTarget());
+ try {
+ const notFound = () =>
+ new ReadError("unavailable", "Relay read failed (404)", 404);
+ const connect = vi
+ .fn<() => Promise>()
+ .mockRejectedValueOnce(notFound())
+ .mockRejectedValueOnce(notFound())
+ .mockResolvedValue(transport("viewer"));
+ const data = provideRelay(root(), connect);
+ await vi.advanceTimersByTimeAsync(0);
+ expect(data.snapshot()).toMatchObject({
+ status: "error",
+ error: {
+ kind: "unusable",
+ details: "ReadError: Relay read failed (404)",
+ },
+ });
+ // Its copy says an update may fix it, never "We’ll keep trying".
+ expect(data.snapshot().retrying).toBeUndefined();
+ // An edge answering during a relay deploy clears, so it is asked again,
+ // first after 30 s, then less often.
+ await vi.advanceTimersByTimeAsync(29_999);
+ expect(connect).toHaveBeenCalledOnce();
+ await vi.advanceTimersByTimeAsync(1);
+ expect(connect).toHaveBeenCalledTimes(2);
+ expect(data.snapshot()).toMatchObject({
+ status: "error",
+ error: { kind: "unusable" },
+ });
+ expect(data.snapshot().retrying).toBeUndefined();
+ await vi.advanceTimersByTimeAsync(59_999);
+ expect(connect).toHaveBeenCalledTimes(2);
+ // Coming back to the window asks now, with nothing cached.
+ expect(data.snapshot().cached).toBeUndefined();
+ window.dispatchEvent(new Event("focus"));
+ await vi.advanceTimersByTimeAsync(0);
+ expect(connect).toHaveBeenCalledTimes(3);
+ expect(data.snapshot().status).toBe("ready");
+ } finally {
+ vi.unstubAllGlobals();
+ }
+});
+it.each([
+ { asked: "after 30 s", ask: () => vi.advanceTimersByTimeAsync(30_000) },
+ {
+ asked: "on focus",
+ ask: async () => {
+ window.dispatchEvent(new Event("focus"));
+ await vi.advanceTimersByTimeAsync(0);
+ },
+ },
+])(
+ "keeps an answer Buzz can't use on screen while it is asked again $asked",
+ async ({ ask }) => {
+ vi.useFakeTimers();
+ vi.stubGlobal("window", new EventTarget());
+ try {
+ let answer!: {
+ resolve(transport: ReadTransport): void;
+ reject(error: unknown): void;
+ };
+ const connect = vi
+ .fn<() => Promise>()
+ .mockRejectedValueOnce(
+ new ReadError("unavailable", "Relay read failed (404)", 404),
+ )
+ .mockImplementation(
+ () =>
+ new Promise((resolve, reject) => {
+ answer = { resolve, reject };
+ }),
+ );
+ const data = provideRelay(root(), connect);
+ await vi.advanceTimersByTimeAsync(0);
+ const failed = data.snapshot();
+ expect(failed).toMatchObject({
+ status: "error",
+ error: { kind: "unusable" },
+ });
+ const published: string[] = [];
+ data.subscribe(() => published.push(data.snapshot().status));
+ await ask();
+ expect(connect).toHaveBeenCalledTimes(2);
+ // The card, and Check for updates on it, stay put: not "connecting",
+ // and not retrying for the App's failed-visit card either.
+ expect(data.snapshot()).toBe(failed);
+ expect(published).toEqual([]);
+ // Another hint while it is asked waits for that answer.
+ window.dispatchEvent(new Event("focus"));
+ await vi.advanceTimersByTimeAsync(0);
+ expect(connect).toHaveBeenCalledTimes(2);
+ expect(data.snapshot()).toBe(failed);
+ // The answer replaces it: the same failure again, then success.
+ answer.reject(
+ new ReadError("unavailable", "Relay read failed (421)", 421),
+ );
+ await vi.advanceTimersByTimeAsync(0);
+ expect(published).toEqual(["error"]);
+ expect(data.snapshot()).toMatchObject({
+ status: "error",
+ error: {
+ kind: "unusable",
+ details: "ReadError: Relay read failed (421)",
+ },
+ });
+ expect(data.snapshot().retrying).toBeUndefined();
+ window.dispatchEvent(new Event("focus"));
+ await vi.advanceTimersByTimeAsync(0);
+ expect(connect).toHaveBeenCalledTimes(3);
+ expect(data.snapshot().status).toBe("error");
+ answer.resolve(transport("viewer"));
+ await vi.advanceTimersByTimeAsync(0);
+ expect(published).toEqual(["error", "ready"]);
+ expect(data.snapshot()).toMatchObject({
+ status: "ready",
+ viewer: "viewer",
+ });
+ } finally {
+ vi.unstubAllGlobals();
+ }
+ },
+);
+it("says it is connecting when the person retries an answer Buzz can't use", async () => {
+ vi.useFakeTimers();
+ vi.stubGlobal("window", new EventTarget());
+ try {
+ const answers: ((transport: ReadTransport) => void)[] = [];
+ const connect = vi
+ .fn<() => Promise>()
+ .mockRejectedValueOnce(
+ new ReadError("unavailable", "Relay read failed (404)", 404),
+ )
+ .mockImplementation(
+ () => new Promise((resolve) => answers.push(resolve)),
+ );
+ const data = provideRelay(root(), connect);
+ await vi.advanceTimersByTimeAsync(0);
+ expect(data.snapshot().status).toBe("error");
+ // Asked again in the background, then by the person, who sees it.
+ window.dispatchEvent(new Event("focus"));
+ await vi.advanceTimersByTimeAsync(0);
+ expect(data.snapshot().status).toBe("error");
+ data.tryAgain();
+ expect(data.snapshot().status).toBe("connecting");
+ await vi.advanceTimersByTimeAsync(0);
+ expect(connect).toHaveBeenCalledTimes(3);
+ // The background attempt it replaced answers too late to count.
+ answers[0]?.(transport("stale"));
+ await vi.advanceTimersByTimeAsync(0);
+ expect(data.snapshot().status).toBe("connecting");
+ answers[1]?.(transport("viewer"));
+ await vi.advanceTimersByTimeAsync(0);
+ expect(data.snapshot()).toMatchObject({
+ status: "ready",
+ viewer: "viewer",
+ });
+ } finally {
+ vi.unstubAllGlobals();
+ }
+});
+it("leaves a refusal to the person, even on focus", async () => {
+ vi.stubGlobal("window", new EventTarget());
+ try {
+ const connect = vi
+ .fn<() => Promise>()
+ .mockRejectedValue(
+ new ReadError("denied", "Relay read failed (403)", 403),
+ );
+ const data = provideRelay(root(), connect);
+ await flush();
+ expect(data.snapshot()).toMatchObject({ status: "error", denied: true });
+ window.dispatchEvent(new Event("focus"));
+ await flush();
+ expect(connect).toHaveBeenCalledOnce();
+ } finally {
+ vi.unstubAllGlobals();
+ }
+});
it("asks a live socket that may have died during sleep to answer", async () => {
vi.useFakeTimers();
const check = vi.fn();
@@ -261,7 +475,7 @@ it("turns a community refusal heard after connecting into one denied connection"
denied: true,
refusal: "sign-in",
viewer: "viewer",
- error: "Federated sign-in is required",
+ error: { kind: "refused", details: "Federated sign-in is required" },
});
expect(data.snapshot().retrying).toBeUndefined();
expect(data.snapshot().session).not.toBe(ready);
diff --git a/src/features/relay/service.ts b/src/features/relay/service.ts
index f937926f85..6cce72f32b 100644
--- a/src/features/relay/service.ts
+++ b/src/features/relay/service.ts
@@ -15,12 +15,26 @@ import { isTransientFailure, ReadError, readErrorKind } from "./errors";
import { RECOVERY_DELAYS_MS } from "./recovery";
import type { ReadTransport } from "./transport";
import { refusalOfError, type CommunityRefusal } from "./http-admission";
-/** The snapshot's error for a refusal heard after connecting, for logs and
+import { failureKind, rawText, type FailureKind } from "./user-copy";
+/** What went wrong connecting, without the words: screens say what it means
+ * from `kind` and keep `details` for diagnostics and sign-in recovery that
+ * reads the community's own refusal. */
+export type RelayFailure = Readonly<{ kind: FailureKind; details: string }>;
+const failureOf = (error: unknown): RelayFailure => ({
+ kind: failureKind(error),
+ details: rawText(error) || "Connection failed",
+});
+/** The snapshot's failure for a refusal heard after connecting, for logs and
* diagnostics; what it offers follows `refusal`, never this text. */
-const REFUSAL_ERRORS: Record = {
- "sign-in": "Federated sign-in is required",
- access: "restricted: authorization denied",
+const REFUSAL_ERRORS: Record = {
+ "sign-in": { kind: "refused", details: "Federated sign-in is required" },
+ access: { kind: "refused", details: "restricted: authorization denied" },
};
+/** An answer this Buzz can't use (a 4xx, a reply it can't read) usually comes
+ * back the same until Buzz is updated, but an edge answering while the relay
+ * deploys clears on its own. So it is asked again slowly in the background,
+ * without the "We’ll keep trying" of a transient failure. */
+const UNUSABLE_DELAYS_MS = Object.freeze([30_000, 60_000, 120_000, 300_000]);
const WAKE_TICK_MS = 5_000;
const WAKE_GAP_MS = 15_000;
@@ -33,7 +47,7 @@ export type RelaySnapshot = Readonly<{
origin?: string;
session: RelaySession;
viewer?: string;
- error?: string;
+ error?: RelayFailure;
/** A usable device snapshot while the real transport is connecting/unavailable. */
cached?: true;
/** Only local disk bootstrap is pending; never waits for the network. */
@@ -81,6 +95,9 @@ export function provideRelay(
let generation = 0;
let controller: AbortController | undefined;
let connecting = false;
+ /** The attempt under way is a background one that left the failure on
+ * screen (see `quietly` below); an explicit retry may replace it. */
+ let quiet = false;
let clearing: Promise | undefined;
/** This community's origin, once a connection named it; a reconnect's
* snapshot does not until it is ready again. */
@@ -96,16 +113,24 @@ export function provideRelay(
});
const listeners = new Set<() => void>();
// A connection that failed for a transient reason (network, timeout) always
- // tries again. Only disconnect() stops trying. A denial or a deterministic
+ // tries again. Only disconnect() stops trying. An answer this Buzz can't use
+ // is asked again too, slowly and without saying so (UNUSABLE_DELAYS_MS),
+ // its failure staying on screen meanwhile. A denial or a deterministic
// failure keeps its error visible and waits for the user.
let reconnectTimer: ReturnType | undefined;
let reconnects = 0;
+ let unusableRetries = 0;
/** Whether the latest connection failure can clear without anyone acting.
* Later publishes of the same failure (cache handoff) keep it. */
let transientFailure = false;
+ /** Whether it was an answer this Buzz can't use, kept the same way. */
+ let unusableFailure = false;
const publish = ({ retrying: _retrying, ...next }: RelaySnapshot) => {
if (next.origin) origin = next.origin;
- if (next.status !== "error") transientFailure = false;
+ if (next.status !== "error") {
+ transientFailure = false;
+ unusableFailure = false;
+ }
snapshot = Object.freeze(
transientFailure && !disposed
? { ...next, retrying: true as const }
@@ -113,19 +138,29 @@ export function provideRelay(
);
clearTimeout(reconnectTimer);
reconnectTimer = undefined;
- if (next.status === "ready" && !next.cached) reconnects = 0;
- else if (next.status === "error" && transientFailure && !disposed)
+ if (next.status === "ready" && !next.cached) {
+ reconnects = 0;
+ unusableRetries = 0;
+ } else if (next.status === "error" && transientFailure && !disposed)
reconnectTimer = setTimeout(
() => service.retry(),
RECOVERY_DELAYS_MS[
Math.min(reconnects++, RECOVERY_DELAYS_MS.length - 1)
] ?? 30_000,
);
+ else if (next.status === "error" && unusableFailure && !disposed)
+ reconnectTimer = setTimeout(
+ () => connectAgain(true),
+ UNUSABLE_DELAYS_MS[
+ Math.min(unusableRetries++, UNUSABLE_DELAYS_MS.length - 1)
+ ] ?? 300_000,
+ );
for (const listener of listeners) listener();
};
const reset = () => {
generation++;
connecting = false;
+ quiet = false;
controller?.abort();
clearTimeout(deadline);
store.dispose();
@@ -217,7 +252,7 @@ export function provideRelay(
async function deny(
current: number,
signal: AbortSignal,
- error: string,
+ error: RelayFailure,
kind?: CommunityRefusal,
/** The connection it refused, once one was made. */
refused?: { viewer: string; origin: string },
@@ -244,6 +279,7 @@ export function provideRelay(
store.dispose();
store = createRelaySession(null, { identityNames });
transientFailure = false;
+ unusableFailure = false;
publish({
status: "error",
generation,
@@ -255,6 +291,174 @@ export function provideRelay(
...refused,
});
}
+ function connectAgain(background: boolean) {
+ // A person's Try again asks the sign-in server within this attempt.
+ const ask = asking;
+ asking = undefined;
+ if (disposed || !connect) return;
+ // Only an explicit retry replaces an attempt under way, and only a
+ // background one that left the failure on screen.
+ if (clearing || (connecting && (background || !quiet))) {
+ // A press joins the attempt under way (or the one after a clear)
+ // but still asks now, so that attempt or the next one doesn't
+ // repeat a cached refusal. Asking twice only forgets twice.
+ void ask?.().catch(() => {});
+ return;
+ }
+ const retained = snapshot.cached;
+ // Asked again by itself, an answer this Buzz can't use stays on screen
+ // until the new one arrives: "connecting" in its place would swap the
+ // card, and Check for updates with it, every time the window gains
+ // focus. Like the retained path, it keeps its store until then.
+ const quietly =
+ background && !retained && snapshot.status === "error" && unusableFailure;
+ if (!retained && !quietly) reset();
+ connecting = true;
+ quiet = quietly;
+ const current = generation;
+ controller = new AbortController();
+ const signal = controller.signal;
+ if (retained) {
+ const { error: _error, ...retainedSnapshot } = snapshot;
+ publish({ ...retainedSnapshot, status: "connecting" });
+ } else if (!quietly)
+ publish({
+ status: "connecting",
+ generation,
+ session: store.session,
+ // Only the first attempt bootstraps from disk behind the launch
+ // surface; a retry after a failure keeps the app on screen.
+ ...(resume && snapshot.status !== "error"
+ ? { restoring: true as const }
+ : {}),
+ });
+ // A quiet attempt's failure stays published until the attempt settles.
+ // A quiet attempt found nothing saved the first time.
+ const restoring =
+ retained || quietly ? Promise.resolve() : restoreLocal(current, signal);
+ const timer = setTimeout(() => {
+ if (disposed || current !== generation || signal !== controller?.signal)
+ return;
+ controller.abort();
+ connecting = false;
+ transientFailure = true;
+ unusableFailure = false;
+ publish({
+ ...snapshot,
+ status: "error",
+ error: {
+ kind: "timeout",
+ details: "Relay connection timed out. Try again.",
+ },
+ });
+ }, 8_000);
+ deadline = timer;
+ void Promise.resolve()
+ // Asking again is best effort: the attempt still connects.
+ .then(() => ask?.().catch(() => {}))
+ .then(() => connect(signal))
+ .then(async (transport) => {
+ clearTimeout(timer);
+ // A request can be refused while this connection is still being
+ // prepared, or at any time after: either way the community is.
+ let refused: CommunityRefusal | undefined;
+ let ready = false;
+ const refuse = (kind: CommunityRefusal) =>
+ deny(current, signal, REFUSAL_ERRORS[kind], kind, {
+ viewer: transport.viewer,
+ origin: transportOrigin(transport),
+ });
+ const stopRefusals = transport.observeRefusal?.((kind) => {
+ if (refused || disposed || current !== generation) return;
+ refused = kind;
+ if (ready) void refuse(kind);
+ });
+ if (stopRefusals)
+ signal.addEventListener("abort", stopRefusals, { once: true });
+ await restoring;
+ if (
+ disposed ||
+ signal.aborted ||
+ current !== generation ||
+ signal !== controller?.signal
+ )
+ return;
+ const successor = createRelaySession(transport, {
+ identityNames,
+ agentChoices,
+ localAgents,
+ ...(presenceActivity ? { presenceActivity } : {}),
+ prepared: true,
+ // Keep intent preparation, but do not fetch every unopened channel.
+ warm: false,
+ initialChannelId: readView(
+ transportPartition(transport),
+ "selected-channel",
+ undefined,
+ ),
+ persistence: createHeadPersistence(
+ transport.viewer,
+ transportOrigin(transport),
+ ),
+ });
+ await successor.restore();
+ if (
+ disposed ||
+ signal.aborted ||
+ current !== generation ||
+ signal !== controller?.signal
+ ) {
+ successor.dispose();
+ return;
+ }
+ // Materialize retained readers before publishing so React never sees
+ // an idle window between two already-hydrated owners.
+ for (const id of store.retainedChannels())
+ successor.session.channels.ensure(id);
+ store.dispose();
+ store = successor;
+ if (refused) {
+ await refuse(refused);
+ return;
+ }
+ publish({
+ status: "ready",
+ generation,
+ viewer: transport.viewer,
+ origin: transportOrigin(transport),
+ scope: transportPartition(transport),
+ session: store.session,
+ });
+ ready = true;
+ store.session.channels.ensureList();
+ })
+ .catch(async (error) => {
+ await restoring;
+ if (
+ disposed ||
+ signal.aborted ||
+ current !== generation ||
+ signal !== controller?.signal
+ )
+ return;
+ if (readErrorKind(error) === "denied")
+ await deny(current, signal, failureOf(error), refusalOfError(error));
+ else {
+ const failure = failureOf(error);
+ // An answer this Buzz can't use is asked again only slowly.
+ unusableFailure = failure.kind === "unusable";
+ transientFailure = !unusableFailure && isTransientFailure(error);
+ publish({ ...snapshot, status: "error", error: failure });
+ }
+ })
+ .finally(() => {
+ clearTimeout(timer);
+ if (current === generation && signal === controller?.signal) {
+ connecting = false;
+ quiet = false;
+ }
+ });
+ }
const service: RelayData & {
/** The federated session changed to signed in: retry what it refused. */
reauthorize(): void;
@@ -266,160 +470,7 @@ export function provideRelay(
listeners.delete(listener);
};
},
- retry() {
- // A person's Try again asks the sign-in server within this attempt.
- const ask = asking;
- asking = undefined;
- if (disposed || !connect) return;
- if (connecting || clearing) {
- // A press joins the attempt under way (or the one after a clear)
- // but still asks now, so that attempt or the next one doesn't
- // repeat a cached refusal. Asking twice only forgets twice.
- void ask?.().catch(() => {});
- return;
- }
- const retained = snapshot.cached;
- if (!retained) reset();
- connecting = true;
- const current = generation;
- controller = new AbortController();
- const signal = controller.signal;
- if (!retained)
- publish({
- status: "connecting",
- generation,
- session: store.session,
- // Only the first attempt bootstraps from disk behind the launch
- // surface; a retry after a failure keeps the app on screen.
- ...(resume && snapshot.status !== "error"
- ? { restoring: true as const }
- : {}),
- });
- else {
- const { error: _error, ...retainedSnapshot } = snapshot;
- publish({ ...retainedSnapshot, status: "connecting" });
- }
- const restoring = retained
- ? Promise.resolve()
- : restoreLocal(current, signal);
- const timer = setTimeout(() => {
- if (disposed || current !== generation || signal !== controller?.signal)
- return;
- controller.abort();
- connecting = false;
- transientFailure = true;
- publish({
- ...snapshot,
- status: "error",
- error: "Relay connection timed out. Try again.",
- });
- }, 8_000);
- deadline = timer;
- void Promise.resolve()
- // Asking again is best effort: the attempt still connects.
- .then(() => ask?.().catch(() => {}))
- .then(() => connect(signal))
- .then(async (transport) => {
- clearTimeout(timer);
- // A request can be refused while this connection is still being
- // prepared, or at any time after: either way the community is.
- let refused: CommunityRefusal | undefined;
- let ready = false;
- const refuse = (kind: CommunityRefusal) =>
- deny(current, signal, REFUSAL_ERRORS[kind], kind, {
- viewer: transport.viewer,
- origin: transportOrigin(transport),
- });
- const stopRefusals = transport.observeRefusal?.((kind) => {
- if (refused || disposed || current !== generation) return;
- refused = kind;
- if (ready) void refuse(kind);
- });
- if (stopRefusals)
- signal.addEventListener("abort", stopRefusals, { once: true });
- await restoring;
- if (
- disposed ||
- signal.aborted ||
- current !== generation ||
- signal !== controller?.signal
- )
- return;
- const successor = createRelaySession(transport, {
- identityNames,
- agentChoices,
- localAgents,
- ...(presenceActivity ? { presenceActivity } : {}),
- prepared: true,
- // Keep intent preparation, but do not fetch every unopened channel.
- warm: false,
- initialChannelId: readView(
- transportPartition(transport),
- "selected-channel",
- undefined,
- ),
- persistence: createHeadPersistence(
- transport.viewer,
- transportOrigin(transport),
- ),
- });
- await successor.restore();
- if (
- disposed ||
- signal.aborted ||
- current !== generation ||
- signal !== controller?.signal
- ) {
- successor.dispose();
- return;
- }
- // Materialize retained readers before publishing so React never sees
- // an idle window between two already-hydrated owners.
- for (const id of store.retainedChannels())
- successor.session.channels.ensure(id);
- store.dispose();
- store = successor;
- if (refused) {
- await refuse(refused);
- return;
- }
- publish({
- status: "ready",
- generation,
- viewer: transport.viewer,
- origin: transportOrigin(transport),
- scope: transportPartition(transport),
- session: store.session,
- });
- ready = true;
- store.session.channels.ensureList();
- })
- .catch(async (error) => {
- await restoring;
- if (
- disposed ||
- signal.aborted ||
- current !== generation ||
- signal !== controller?.signal
- )
- return;
- if (readErrorKind(error) === "denied")
- await deny(current, signal, String(error), refusalOfError(error));
- else {
- transientFailure = isTransientFailure(error);
- publish({
- ...snapshot,
- status: "error",
- error: String(error),
- });
- }
- })
- .finally(() => {
- clearTimeout(timer);
- if (current === generation && signal === controller?.signal)
- connecting = false;
- });
- },
+ retry: () => connectAgain(false),
reauthorize() {
if (disposed) return;
if (snapshot.status === "error") service.retry();
@@ -485,8 +536,13 @@ export function provideRelay(
// Network, focus and wake are hints that the relay may answer again, and
// that a socket which still looks open may have died while asleep.
// Failed background reads retry now; a live socket is asked to answer.
+ // A failed connection that may clear is asked again now, cached or not.
const hint = (network: boolean) => {
- if (snapshot.cached) service.retry();
+ if (
+ snapshot.cached ||
+ (snapshot.status === "error" && (transientFailure || unusableFailure))
+ )
+ connectAgain(true);
else {
store.session.recovery?.retryNow();
store.session.checkLive(network);
diff --git a/src/features/relay/session-read-retry.test.ts b/src/features/relay/session-read-retry.test.ts
index bb1151c8c3..b9b1223b15 100644
--- a/src/features/relay/session-read-retry.test.ts
+++ b/src/features/relay/session-read-retry.test.ts
@@ -53,7 +53,7 @@ it("retains observed data and coalesces refreshes during finite recovery", async
h.query.mockResolvedValueOnce([row]);
const view = h.owner.session.observe(h.filter);
await view.refresh();
- h.query.mockRejectedValueOnce(new TypeError("offline"));
+ h.query.mockRejectedValueOnce(new TypeError("Failed to fetch"));
const pending = view.refresh();
await vi.advanceTimersByTimeAsync(0);
await view.refresh();
@@ -132,7 +132,7 @@ it.each(["caller", "clearCache", "dispose"] as const)(
async (action) => {
const h = setup();
const caller = new AbortController();
- h.query.mockRejectedValue(new TypeError("offline"));
+ h.query.mockRejectedValue(new TypeError("Failed to fetch"));
const pending = h.owner.session.read(h.filter, { signal: caller.signal });
const rejected = expect(pending).rejects.toMatchObject({
name: "AbortError",
@@ -162,7 +162,7 @@ it("surfaces a cached-only inbox failure without retrying it", async () => {
it("retries a transient inbox read through the session", async () => {
const h = setup();
- h.query.mockRejectedValueOnce(new TypeError("offline"));
+ h.query.mockRejectedValueOnce(new TypeError("Failed to fetch"));
const pending = h.owner.session.inboxFeed.ensure();
await vi.advanceTimersByTimeAsync(750);
await pending;
diff --git a/src/features/relay/signed-admission.test.ts b/src/features/relay/signed-admission.test.ts
index 06a68006e1..fddfa424ce 100644
--- a/src/features/relay/signed-admission.test.ts
+++ b/src/features/relay/signed-admission.test.ts
@@ -2,6 +2,7 @@ import { beforeEach, afterEach, assert, expect, it, vi } from "vitest";
import { connectSignedTransport } from "./transport";
import { createOutbox, PublishNotSent, PublishRejected } from "./outbox";
import { hostSigner } from "./testing";
+import { userCopy } from "./user-copy";
const filters = [{ kinds: [0], limit: 1 }];
beforeEach(() => {
@@ -111,6 +112,49 @@ it("explicit quota rejection is retryable; missing response stays unknown with n
await vi.advanceTimersByTimeAsync(500);
expect(fetcher).toHaveBeenCalledTimes(2);
});
+it("a publish held for quota reads as busy with the relay's wait, answered or paused", async () => {
+ const identity = signer();
+ const transport = await connectSignedTransport(
+ identity,
+ "https://wait.test",
+ "relay",
+ );
+ const event = await identity.signEvent({
+ kind: 9,
+ content: "exact",
+ tags: [["h", "c"]],
+ });
+ assert.exists(transport.writer);
+ const fetcher = vi.fn(async () =>
+ Response.json(
+ { error: "rate-limited: quota exceeded; retry in 600s" },
+ { status: 429 },
+ ),
+ );
+ vi.stubGlobal("fetch", fetcher);
+ const publish = () =>
+ transport.writer?.publish(event, new AbortController().signal).then(
+ () => assert.fail("published"),
+ (error: unknown) => error,
+ );
+ // The relay's own answer, then the lane it paused: both wait, neither is offline.
+ const answered = await publish();
+ expect(answered).toBeInstanceOf(PublishNotSent);
+ expect(userCopy(answered, "Couldn’t send.")).toMatchObject({
+ kind: "busy",
+ retry: true,
+ text: "Couldn’t send. Buzz is busy. Try again in about 10 minutes.",
+ });
+ await vi.advanceTimersByTimeAsync(60_000);
+ const held = await publish();
+ expect(fetcher).toHaveBeenCalledOnce();
+ expect(held).toBeInstanceOf(PublishNotSent);
+ expect(userCopy(held, "Couldn’t send.")).toMatchObject({
+ kind: "busy",
+ retry: true,
+ text: "Couldn’t send. Buzz is busy. Try again in about 9 minutes.",
+ });
+});
it.each([
[
409,
diff --git a/src/features/relay/startup-service.test.ts b/src/features/relay/startup-service.test.ts
index 0f9b98f063..c78728c18b 100644
--- a/src/features/relay/startup-service.test.ts
+++ b/src/features/relay/startup-service.test.ts
@@ -183,7 +183,7 @@ it("retains a timed-out cached startup, rejects the late handshake and promotes
expect(data.snapshot()).toMatchObject({
status: "error",
cached: true,
- error: expect.stringContaining("timed out"),
+ error: { kind: "timeout", details: expect.stringContaining("timed out") },
// A timeout reconnects by itself; navigation waits instead of failing.
retrying: true,
});
diff --git a/src/features/relay/store.test.ts b/src/features/relay/store.test.ts
index 23df9f8744..a4b08ab695 100644
--- a/src/features/relay/store.test.ts
+++ b/src/features/relay/store.test.ts
@@ -365,8 +365,13 @@ describe("channel store", () => {
await flush();
expect(queries.window("c")).toMatchObject({
status: "error",
- error: expect.stringMatching(/window bounds/),
+ error: expect.objectContaining({
+ kind: "invalid-response",
+ message: expect.stringMatching(/window bounds/),
+ }),
});
+ // A head read's failure is not an older page's.
+ expect(queries.window("c").errorSource).toBeUndefined();
queries.ensure("c");
expect(queries.window("c").status).toBe("loading");
next().respond([
@@ -382,9 +387,20 @@ describe("channel store", () => {
expect(queries.window("c")).toMatchObject({
status: "ready",
loadingOlder: false,
- error: "relay down",
+ error: expect.objectContaining({ message: "relay down" }),
+ errorSource: "older",
hasMore: true,
});
+ // Retrying clears the failure and its source together.
+ queries.loadOlder("c");
+ expect(queries.window("c")).toMatchObject({
+ loadingOlder: true,
+ error: undefined,
+ });
+ expect(queries.window("c").errorSource).toBeUndefined();
+ next().fail(new Error("relay down again"));
+ await flush();
+ expect(queries.window("c").errorSource).toBe("older");
});
});
diff --git a/src/features/relay/store.ts b/src/features/relay/store.ts
index 7ae1803e4e..88dae22b74 100644
--- a/src/features/relay/store.ts
+++ b/src/features/relay/store.ts
@@ -5,6 +5,7 @@ import { MessageProjection } from "./message-projection";
import { createRelayProfiler, type RelayProfiler } from "./profiling";
import { isTransientFailure, ReadError, readErrorKind } from "./errors";
import { refusalOfError } from "./http-admission";
+import { windowFailure } from "./window-failure";
import type {
ChannelReference,
ChannelList,
@@ -585,8 +586,16 @@ export function createChannelStore(
state.projection.reconcile([...combined.values()], operations),
);
}
+ // A failure's source belongs to the failure that set it.
+ const source =
+ "error" in patch &&
+ !("errorSource" in patch) &&
+ state.snapshot.errorSource !== undefined
+ ? { errorSource: undefined }
+ : {};
const next = {
...state.snapshot,
+ ...source,
...patch,
rows,
hasNewer: state.ceiling !== undefined || undefined,
@@ -925,7 +934,7 @@ export function createChannelStore(
function denyOmitted(omitted: ReadonlyMap) {
for (const [id, version] of omitted)
if (!discovery?.authorized(id) && unchangedPreview(id, version))
- denyChannel(id, new Error("Conversation is unavailable"));
+ denyChannel(id, new ReadError("denied", "Conversation is unavailable"));
}
/** Ends a window's reads and leaves it showing only the refusal. */
function closeWindow(state: WindowState, error: unknown) {
@@ -940,7 +949,7 @@ export function createChannelStore(
setWindow(state, {
status: "error",
rows: EMPTY_ROWS,
- error: describe(error),
+ error: windowFailure(error),
loadingOlder: false,
refreshing: undefined,
});
@@ -1150,12 +1159,18 @@ export function createChannelStore(
setWindow(
state,
cursor || state.snapshot.rows.length
- ? { loadingOlder: false, error: describe(error), freshness: "cached" }
+ ? {
+ loadingOlder: false,
+ error: windowFailure(error),
+ // Only `loadOlder` reads with a cursor.
+ ...(cursor ? { errorSource: "older" as const } : {}),
+ freshness: "cached",
+ }
: {
status: "error",
rows: EMPTY_ROWS,
loadingOlder: false,
- error: describe(error),
+ error: windowFailure(error),
},
);
} finally {
@@ -2348,7 +2363,7 @@ export function createChannelStore(
hasTag(event, "p", transport.viewer),
)
)
- denyChannel(id, new Error("Conversation is unavailable"));
+ denyChannel(id, new ReadError("denied", "Conversation is unavailable"));
if (
!events.some(
(event) =>
@@ -2358,7 +2373,10 @@ export function createChannelStore(
)
) {
if (omittedPreview(id, events, started))
- denyChannel(id, new Error("Conversation is unavailable"));
+ denyChannel(
+ id,
+ new ReadError("denied", "Conversation is unavailable"),
+ );
} else if (!discovery.named(id))
throw new Error("Channel metadata capacity unavailable");
}
@@ -3206,7 +3224,7 @@ export function createChannelStore(
state &&
authorized(channelId)
)
- setWindow(state, { error: describe(error) });
+ setWindow(state, { error: windowFailure(error) });
})
.finally(() => {
controllers.delete(controller);
@@ -3820,7 +3838,10 @@ export function createChannelStore(
void fetchProfiles(state.snapshot.rows);
} catch (error) {
if (isAbort(error) || !live(state, generation)) return;
- setWindow(state, { loadingNewer: undefined, error: describe(error) });
+ setWindow(state, {
+ loadingNewer: undefined,
+ error: windowFailure(error),
+ });
} finally {
if (state.controller === controller) {
state.controller = undefined;
@@ -4471,7 +4492,7 @@ export function createChannelStore(
setWindow(retained, {
status: retained.snapshot.rows.length ? "ready" : "error",
freshness: "cached",
- error: describe(error),
+ error: windowFailure(error),
});
throw error;
} finally {
@@ -4486,7 +4507,7 @@ export function createChannelStore(
setWindow(state, {
status: state.snapshot.rows.length ? "ready" : "error",
freshness: "cached",
- error: describe(error),
+ error: windowFailure(error),
});
},
/** The stream re-established a channel whose replay was continuous: it
diff --git a/src/features/relay/thread-profile-fixture.ts b/src/features/relay/thread-profile-fixture.ts
index b6046c2a76..b26dc99417 100644
--- a/src/features/relay/thread-profile-fixture.ts
+++ b/src/features/relay/thread-profile-fixture.ts
@@ -338,7 +338,7 @@ export async function threadSample(
)
firstRowsMs = performance.now() - start;
if (snapshot.status === "error")
- done.reject(fixtureError ?? new Error(snapshot.error));
+ done.reject(fixtureError ?? new Error(String(snapshot.error)));
if (
snapshot.status === "ready" &&
!snapshot.canLoadMore &&
diff --git a/src/features/relay/thread-window.test.ts b/src/features/relay/thread-window.test.ts
index 166aa75e01..a69062e7e8 100644
--- a/src/features/relay/thread-window.test.ts
+++ b/src/features/relay/thread-window.test.ts
@@ -832,8 +832,12 @@ it("revalidates a missing strict root before repair and preserves tombstones on
replies: [],
canLoadMore: false,
});
- expect(h.view.snapshot().error).toContain(
- "original thread message is unavailable",
+ expect(h.view.snapshot().error).toEqual(
+ expect.objectContaining({
+ message: expect.stringContaining(
+ "original thread message is unavailable",
+ ),
+ }),
);
missing = false;
await h.view.refresh();
diff --git a/src/features/relay/threads.test.ts b/src/features/relay/threads.test.ts
index b28f79cb9b..07e10dc26d 100644
--- a/src/features/relay/threads.test.ts
+++ b/src/features/relay/threads.test.ts
@@ -943,7 +943,9 @@ it("keeps a failed establishment repair visible and drops the older page request
await flush();
expect(h.pending).toHaveLength(0);
expect(h.view.snapshot()).toMatchObject({ status: "error" });
- expect(h.view.snapshot().error).toContain("offline");
+ expect(h.view.snapshot().error).toEqual(
+ expect.objectContaining({ message: "offline" }),
+ );
});
it("offers retry on failures and rejects a non-advancing cursor without erasing readable rows", async () => {
@@ -963,7 +965,11 @@ it("offers retry on failures and rejects a non-advancing cursor without erasing
const more = h.view.loadMore();
h.next().respond([root, one]);
await more;
- expect(h.view.snapshot().error).toContain("did not advance");
+ expect(h.view.snapshot().error).toEqual(
+ expect.objectContaining({
+ message: expect.stringContaining("did not advance"),
+ }),
+ );
expect(h.view.snapshot().replies).toHaveLength(1);
});
diff --git a/src/features/relay/threads.ts b/src/features/relay/threads.ts
index cf923d7e6f..465ac9c3ef 100644
--- a/src/features/relay/threads.ts
+++ b/src/features/relay/threads.ts
@@ -1,4 +1,5 @@
import { isRetryableRead, retryRead } from "./read-retry";
+import { windowFailure, type WindowFailure } from "./window-failure";
import {
canonicalChannel,
MissingThreadBounds,
@@ -37,7 +38,7 @@ export type ThreadSnapshot = Readonly<{
status: "idle" | "loading" | "ready" | "error";
root: ChannelMessage | undefined;
replies: readonly ChannelMessage[];
- error: string | undefined;
+ error: WindowFailure | undefined;
/** Continuation is possible, not a claim about total thread size or exhaustion. */
canLoadMore: boolean;
limited: boolean;
@@ -914,7 +915,7 @@ export function createThreadView({
shown = undefined;
publish();
}
- publishStatus({ status: "error", error: String(error) });
+ publishStatus({ status: "error", error: windowFailure(error) });
}
} finally {
if (controller === owned) {
diff --git a/src/features/relay/transport.test.ts b/src/features/relay/transport.test.ts
index 5c78b85fda..93aae8f430 100644
--- a/src/features/relay/transport.test.ts
+++ b/src/features/relay/transport.test.ts
@@ -10,6 +10,8 @@ import {
READ_STATE_TIMESTAMP_REFUSAL,
} from "./read-state-host";
import { PublishRejected } from "./outbox";
+import { isTransientFailure, ReadError } from "./errors";
+import { userCopy } from "./user-copy";
import { hostSigner, keypair, signed } from "./testing";
const key = keypair();
afterEach(() => vi.unstubAllGlobals());
@@ -315,6 +317,47 @@ it.each([
).toHaveLength(1);
},
);
+it.each([
+ "network error",
+ "Error in input stream",
+ // Wording no browser list has yet is still the network once headers came.
+ "terminated",
+])(
+ "reads a body that stops arriving after its headers as the network: %s",
+ async (message) => {
+ // The headers arrive, then the connection drops mid-body.
+ const cut = () =>
+ new Response(
+ new ReadableStream({
+ pull: (controller) => controller.error(new TypeError(message)),
+ }),
+ { headers: { "Content-Type": "application/json" } },
+ );
+ const expectNetwork = (failure: unknown) => {
+ expect(failure).toBeInstanceOf(ReadError);
+ expect(failure).toMatchObject({ kind: "unavailable", message });
+ // Copy and automatic retry agree on what the network did.
+ expect(isTransientFailure(failure)).toBe(true);
+ expect(userCopy(failure, "Couldn’t load this.").kind).toBe("connection");
+ };
+ vi.stubGlobal("fetch", async () => cut());
+ // The broker's session body (`response.json()`).
+ expectNetwork(
+ await connectBrokerTransport().catch((reason: unknown) => reason),
+ );
+ // A signed read's body (`response.text()`).
+ const signedTransport = await connectSignedTransport(
+ hostSigner(key),
+ "https://relay.test",
+ "relay",
+ );
+ expectNetwork(
+ await signedTransport
+ .query([{ kinds: [9], limit: 1 }], new AbortController().signal)
+ .catch((reason: unknown) => reason),
+ );
+ },
+);
it("the broker advertises and supplies writes through the same connection", async () => {
const event = signed(key, { kind: 9, content: "hello", tags: [["h", "c"]] });
const fetcher = vi.fn(async (url: string) => {
diff --git a/src/features/relay/transport.ts b/src/features/relay/transport.ts
index f7558cf6e4..c6e6d6d6a4 100644
--- a/src/features/relay/transport.ts
+++ b/src/features/relay/transport.ts
@@ -49,6 +49,7 @@ import { createHostAdmission } from "./host-admission";
import { relayOrigin } from "../communities/destination";
import {
admittedApiRequest,
+ ApiCapacity,
ApiPaused,
ApiNotSent,
readApiFailure,
@@ -475,6 +476,28 @@ async function readResponseError(response: Response) {
);
}
+/** A body that stops arriving after its headers did is the network, not a
+ * bug, whatever the browser calls it: read it as an unavailable read, so the
+ * copy and the retry agree. Reading a used body stays the bug it is. */
+function networkBody(response: Response): Response {
+ for (const method of ["json", "text", "arrayBuffer", "blob"] as const) {
+ const read = response[method] as (() => Promise) | undefined;
+ if (typeof read !== "function") continue;
+ Object.defineProperty(response, method, {
+ configurable: true,
+ value: () => {
+ if (response.bodyUsed) return read.call(response);
+ return read.call(response).catch((error: unknown) => {
+ throw error instanceof TypeError
+ ? new ReadError("unavailable", error.message)
+ : error;
+ });
+ },
+ });
+ }
+ return response;
+}
+
/** The broker marks any response whose upstream relay request met a community
* refusal with `x-buzz-refusal` (`relay-broker.mjs`), whatever its route does
* with the relay's body. Every broker request below goes through here. */
@@ -490,7 +513,7 @@ function brokerRefusals() {
const kind = response.headers.get(BROKER_REFUSAL_HEADER);
if (kind === "sign-in" || kind === "access")
for (const listener of [...listeners]) listener(kind);
- return response;
+ return networkBody(response);
},
};
}
@@ -1509,7 +1532,16 @@ export async function connectSignedTransport(
false,
refusalText,
).catch((error) => {
- if (error instanceof ApiPaused || error instanceof ApiNotSent)
+ // A paused or full lane asked to wait; keep that, and its hint.
+ if (error instanceof ApiPaused)
+ throw new PublishNotSent(error.message, undefined, {
+ ...(error.retryAfterMs !== undefined && {
+ retryAfterMs: error.retryAfterMs,
+ }),
+ });
+ if (error instanceof ApiCapacity)
+ throw new PublishNotSent(error.message, undefined, {});
+ if (error instanceof ApiNotSent)
throw new PublishNotSent(error.message);
throw error;
}),
@@ -1587,7 +1619,7 @@ async function signedPost(
return dispatch(() => {
signal?.throwIfAborted();
return profiling.measureAsync("http.fetch", id, () =>
- signer.request(url, body, signal),
+ signer.request(url, body, signal).then(networkBody),
);
});
});
@@ -1684,7 +1716,17 @@ export async function acceptPublish(response: Response, id: string) {
return publicationRefusal(failure.error);
});
if (body.sent === false || body.quota === "api")
- throw new PublishNotSent(body.error);
+ throw new PublishNotSent(
+ body.error,
+ undefined,
+ body.quota === "api" || body.paused
+ ? {
+ ...(body.retryAfterMs !== undefined && {
+ retryAfterMs: body.retryAfterMs,
+ }),
+ }
+ : undefined,
+ );
throw new Error(
`Relay delivery could not be confirmed (${response.status})`,
);
diff --git a/src/features/relay/user-copy.test.ts b/src/features/relay/user-copy.test.ts
new file mode 100644
index 0000000000..38936128b5
--- /dev/null
+++ b/src/features/relay/user-copy.test.ts
@@ -0,0 +1,340 @@
+import { describe, expect, it } from "vitest";
+import { UploadError } from "./attachments";
+import { isTransientFailure, ReadError } from "./errors";
+import { ApiCapacity, ApiPaused } from "./http-admission";
+import { PublishNotSent, PublishRejected } from "./outbox";
+import { failureKind, refusalReason, userCopy } from "./user-copy";
+
+const CONTEXT = "Couldn’t load messages.";
+/** Raw shapes that must never reach `text`. */
+const RAW =
+ /ReadError|TypeError|DOMException|Error:|\(\d{3}\)|https?:|Relay |relay|network-|blocked:|restricted:|signal timed out|Failed to fetch/;
+
+function expectClean(text: string) {
+ expect(text).not.toMatch(RAW);
+ expect(text.startsWith(CONTEXT)).toBe(true);
+}
+
+describe("userCopy", () => {
+ it.each([
+ [new ReadError("unavailable", "Relay read failed (503)", 503)],
+ [new ReadError("unavailable", "Relay unreachable")],
+ [new ReadError("unavailable", "Relay request failed (408)", 408)],
+ // An edge proxy's 401/403 page in front of the relay clears on its own.
+ [new ReadError("unavailable", "Relay request failed (401)", 401)],
+ [new ReadError("unavailable", "Relay request failed (403)", 403)],
+ ])("maps a transient ReadError to connection copy with retry", (error) => {
+ const copy = userCopy(error, CONTEXT);
+ expect(copy).toEqual({
+ text: `${CONTEXT} Check your connection, then try again.`,
+ retry: true,
+ kind: "connection",
+ details: String(error),
+ });
+ expectClean(copy.text);
+ });
+
+ it.each([
+ [new ReadError("invalid-response", "Relay response is not an event array")],
+ [new ReadError("unavailable", "Relay request failed (400)", 400)],
+ [new ReadError("unavailable", "Relay request failed (404)", 404)],
+ ])("maps an unusable request or reply to no retry", (error) => {
+ const copy = userCopy(error, CONTEXT);
+ expect(copy.kind).toBe("unusable");
+ expect(copy.retry).toBe(false);
+ expect(copy.text).toBe(CONTEXT);
+ expect(copy.details).toContain("Relay");
+ expectClean(copy.text);
+ });
+
+ it.each([
+ [new ReadError("denied", "Relay read failed (403)", 403)],
+ [new ReadError("denied", "Relay read failed (401)", 401)],
+ [new ReadError("denied", "Conversation is unavailable")],
+ ])("maps a denied read to refused without retry", (error) => {
+ const copy = userCopy(error, CONTEXT);
+ expect(copy).toMatchObject({
+ kind: "refused",
+ retry: false,
+ text: CONTEXT,
+ });
+ });
+
+ it.each([
+ [new ApiPaused(3000)],
+ [new ApiCapacity()],
+ [new ReadError("unavailable", "Query concurrency limit", 503)],
+ [new ReadError("unavailable", "Live stream capacity reached")],
+ [new ReadError("unavailable", "Too many", 429)],
+ ["Query concurrency limit"],
+ ])("maps paused and full queues to busy", (error) => {
+ const copy = userCopy(error, CONTEXT);
+ expect(copy.kind).toBe("busy");
+ expect(copy.retry).toBe(true);
+ expect(copy.text).toBe(
+ `${CONTEXT} Buzz is busy. Try again in a few seconds.`,
+ );
+ expectClean(copy.text);
+ });
+
+ it.each([
+ [new ApiPaused(60_000)],
+ // A relay's 60s quota hint, padded by a second (`apiFailure`).
+ [new ApiPaused(61_000)],
+ [new ReadError("unavailable", "Too many", 429, 5000)],
+ ])("keeps a wait of a minute or less as a few seconds", (error) => {
+ expect(userCopy(error, CONTEXT).text).toBe(
+ `${CONTEXT} Buzz is busy. Try again in a few seconds.`,
+ );
+ });
+
+ it.each([
+ [new ApiPaused(61_001), "about 1 minute"],
+ [new ApiPaused(89_999), "about 1 minute"],
+ [new ApiPaused(90_000), "about 2 minutes"],
+ // A relay's 300s hint, padded by a second.
+ [new ApiPaused(301_000), "about 5 minutes"],
+ [new ApiPaused(5 * 60_000), "about 5 minutes"],
+ [
+ new ReadError("unavailable", "Too many", 429, 59 * 60_000),
+ "about 59 minutes",
+ ],
+ [new ApiPaused(60 * 60_000), "about 1 hour"],
+ [new ApiPaused(3 * 60 * 60_000), "about 3 hours"],
+ ])("names a long wait instead of a few seconds", (error, wait) => {
+ const copy = userCopy(error, CONTEXT);
+ expect(copy).toMatchObject({
+ kind: "busy",
+ retry: true,
+ text: `${CONTEXT} Buzz is busy. Try again in ${wait}.`,
+ });
+ expect(copy.text).not.toContain("a few seconds");
+ expectClean(copy.text);
+ });
+
+ it("maps a cancelled read to the context sentence", () => {
+ for (const error of [
+ new ReadError("cancelled", "Relay read cancelled"),
+ new DOMException("Relay session closed", "AbortError"),
+ ])
+ expect(userCopy(error, CONTEXT)).toMatchObject({
+ kind: "cancelled",
+ text: CONTEXT,
+ retry: true,
+ });
+ });
+
+ it("maps fetch's TypeError to connection copy, and retries it", () => {
+ for (const message of [
+ "Failed to fetch",
+ "Load failed",
+ "The network connection was lost.",
+ "The Internet connection appears to be offline.",
+ "NetworkError when attempting to fetch resource.",
+ // A body read that loses the connection after the headers arrived.
+ "network error",
+ "Error in input stream",
+ ]) {
+ const error = new TypeError(message);
+ const copy = userCopy(error, CONTEXT);
+ expect(copy.kind).toBe("connection");
+ expect(copy.retry).toBe(true);
+ expect(copy.details).toBe(`TypeError: ${message}`);
+ expectClean(copy.text);
+ // Copy and automatic retry agree on what the network did.
+ expect(isTransientFailure(error)).toBe(true);
+ }
+ });
+
+ it("keeps any other TypeError unknown, not a network failure", () => {
+ for (const error of [
+ new TypeError("Cannot read properties of undefined (reading 'id')"),
+ new TypeError("x is not a function"),
+ ]) {
+ const copy = userCopy(error, CONTEXT);
+ expect(copy).toMatchObject({
+ kind: "unknown",
+ retry: true,
+ text: CONTEXT,
+ });
+ expect(copy.details).toBe(String(error));
+ // A bug doesn't clear by reading again.
+ expect(isTransientFailure(error)).toBe(false);
+ }
+ });
+
+ it("maps DOMException names", () => {
+ const timeout = userCopy(
+ new DOMException("signal timed out", "TimeoutError"),
+ CONTEXT,
+ );
+ expect(timeout).toMatchObject({
+ kind: "timeout",
+ retry: true,
+ text: `${CONTEXT} It took too long. Try again.`,
+ });
+ expect(timeout.details).toContain("signal timed out");
+ expectClean(timeout.text);
+ expect(failureKind(new DOMException("offline", "NetworkError"))).toBe(
+ "connection",
+ );
+ expect(
+ userCopy(new DOMException("denied by user", "NotAllowedError"), CONTEXT),
+ ).toMatchObject({ kind: "refused", retry: false, text: CONTEXT });
+ });
+
+ it.each([
+ "Relay request could not be confirmed",
+ "Relay response was interrupted",
+ "Community discovery failed",
+ "network-unavailable",
+ "network-timeout",
+ ])("maps the native transient string %s to connection copy", (raw) => {
+ for (const error of [raw, new Error(raw)]) {
+ const copy = userCopy(error, CONTEXT);
+ expect(copy.kind).toBe("connection");
+ expect(copy.retry).toBe(true);
+ expect(copy.details).toContain(raw);
+ expectClean(copy.text);
+ }
+ });
+
+ it("keeps any other native string or Error only in details", () => {
+ for (const error of [
+ "Saved agent identities are malformed",
+ new Error("Fixture close failed. Try End session again."),
+ "Too many terminal sessions (maximum 20)",
+ ]) {
+ const copy = userCopy(error, CONTEXT);
+ expect(copy).toMatchObject({
+ kind: "unknown",
+ retry: true,
+ text: CONTEXT,
+ });
+ expect(copy.details).toContain(
+ typeof error === "string" ? error : error.message,
+ );
+ }
+ });
+
+ it("has no details for an empty rejection", () => {
+ expect(userCopy(undefined, CONTEXT)).toEqual({
+ kind: "unknown",
+ retry: true,
+ text: CONTEXT,
+ });
+ expect(userCopy({ code: 7 }, CONTEXT).details).toBe('{"code":7}');
+ });
+
+ it("classifies a wrapper by its cause", () => {
+ const error = new Error("Preference decode: x", {
+ cause: new ReadError("invalid-response", "bad"),
+ });
+ expect(failureKind(error)).toBe("unusable");
+ });
+
+ it("keeps UploadError copy and its retry policy", () => {
+ const failed = userCopy(new UploadError("failed"), CONTEXT);
+ expect(failed).toMatchObject({
+ text: "Upload did not finish. Retry or remove this file.",
+ retry: true,
+ kind: "connection",
+ });
+ expect(userCopy(new UploadError("size"), CONTEXT).retry).toBe(false);
+ expect(userCopy(new UploadError("denied"), CONTEXT)).toMatchObject({
+ text: "You don’t have permission to upload here.",
+ retry: false,
+ });
+ expect(userCopy(new UploadError("capacity"), CONTEXT).kind).toBe("busy");
+ });
+
+ it("maps a relay refusal to its reason without the machine prefix", () => {
+ const copy = userCopy(
+ new PublishRejected(
+ "You are not allowed to add members to this channel",
+ "blocked: you are not allowed to add members to this channel",
+ ),
+ "Couldn’t add Sam.",
+ );
+ expect(copy).toEqual({
+ text: "Couldn’t add Sam. You don’t have permission to add people to this channel.",
+ retry: false,
+ kind: "refused",
+ details:
+ "Error: You are not allowed to add members to this channel (blocked: you are not allowed to add members to this channel)",
+ });
+ });
+
+ it("maps an unknown refusal to the context alone", () => {
+ const copy = userCopy(
+ new PublishRejected("invalid: event kind not allowed"),
+ CONTEXT,
+ );
+ expect(copy).toMatchObject({
+ text: CONTEXT,
+ retry: false,
+ kind: "refused",
+ });
+ expect(copy.details).toContain("invalid: event kind not allowed");
+ });
+
+ it("maps a publish that never left the device to connection copy", () => {
+ expect(
+ userCopy(new PublishNotSent("Relay API queue capacity reached"), CONTEXT),
+ ).toMatchObject({ kind: "connection", retry: true });
+ expect(
+ userCopy(new PublishNotSent("rate-limited: slow down"), CONTEXT),
+ ).toMatchObject({ kind: "busy", retry: true });
+ });
+
+ it.each([
+ [
+ new PublishNotSent("Relay requests paused; retry in 3s", undefined, {
+ retryAfterMs: 3000,
+ }),
+ "a few seconds",
+ ],
+ [
+ new PublishNotSent("Relay API queue capacity reached", undefined, {}),
+ "a few seconds",
+ ],
+ [
+ new PublishNotSent(
+ "rate-limited: quota exceeded; retry in 300s",
+ undefined,
+ { retryAfterMs: 5 * 60_000 },
+ ),
+ "about 5 minutes",
+ ],
+ ])("reads a publish asked to wait as busy, with its wait", (error, wait) => {
+ const copy = userCopy(error, CONTEXT);
+ expect(copy).toMatchObject({
+ kind: "busy",
+ retry: true,
+ text: `${CONTEXT} Buzz is busy. Try again in ${wait}.`,
+ });
+ expectClean(copy.text);
+ });
+});
+
+describe("refusalReason", () => {
+ it("formats a timeout's end", () => {
+ const until = Math.floor(new Date(2026, 10, 6, 21, 20).getTime() / 1000);
+ expect(refusalReason(`restricted: you are timed out until ${until}`)).toBe(
+ "You’re timed out until Nov 6, 9:20 PM.",
+ );
+ });
+
+ it("maps known community refusals", () => {
+ expect(refusalReason("restricted: authorization denied")).toBe(
+ "You don’t have permission to do this.",
+ );
+ expect(refusalReason("invalid: channel is archived")).toBe(
+ "This channel is archived.",
+ );
+ expect(refusalReason("restricted: not a channel member")).toBe(
+ "You’re not a member of this channel.",
+ );
+ expect(refusalReason("internal error")).toBeUndefined();
+ });
+});
diff --git a/src/features/relay/user-copy.ts b/src/features/relay/user-copy.ts
new file mode 100644
index 0000000000..070e20f288
--- /dev/null
+++ b/src/features/relay/user-copy.ts
@@ -0,0 +1,257 @@
+import { UploadError, type UploadCode } from "./attachments.ts";
+import { FETCH_FAILURES, isTransientFailure, ReadError } from "./errors.ts";
+import { ApiCapacity, ApiPaused } from "./http-admission.ts";
+import { PublishNotSent, PublishRejected } from "./outbox.ts";
+
+/** What a failure means for the person who saw it, independent of which host,
+ * transport or library produced it. */
+export type FailureKind =
+ /** The network, the relay or the host was unreachable or answered 5xx. */
+ | "connection"
+ /** The request ran out of time. */
+ | "timeout"
+ /** Buzz or the relay asked to wait: a paused or full request queue, 429. */
+ | "busy"
+ /** The request was cancelled before it finished. */
+ | "cancelled"
+ /** The community refused this account or action. */
+ | "refused"
+ /** The answer, or the request this client sent, couldn't be used: a 4xx
+ * other than 401/403/408/429, or a malformed reply. The same request fails
+ * again. */
+ | "unusable"
+ | "unknown";
+
+export type UserCopy = Readonly<{
+ /** Plain-language copy. Never an exception name, HTTP status, URL or raw
+ * native/relay string. */
+ text: string;
+ /** Whether repeating the same action unchanged can succeed. */
+ retry: boolean;
+ kind: FailureKind;
+ /** The raw failure text, for diagnostics only; never shown as the copy. */
+ details?: string;
+}>;
+
+/** Messages the local relay broker writes for its own capacity limits
+ * (`http-admission.ts`). */
+const BROKER_BUSY = new Set([
+ "Query concurrency limit",
+ "Live stream capacity reached",
+]);
+/** NIP-01 machine-readable prefixes (`transport.ts` NIP01_PREFIX). */
+const NIP01_PREFIX =
+ /^(invalid|blocked|restricted|error|pow|duplicate|rate-limited|conflict|auth-required|mute):\s*/;
+/** Upload codes that the same file can't get past by retrying unchanged. */
+const UPLOAD_FINAL: ReadonlySet = new Set([
+ "unavailable",
+ "size",
+ "ffmpeg",
+ "metadata",
+ "resolution",
+ "duration",
+ "codec",
+ "rejected",
+ "denied",
+]);
+
+const UPLOAD_KIND: Partial> = {
+ capacity: "busy",
+ cancelled: "cancelled",
+ failed: "connection",
+ invalid: "unusable",
+ io: "unknown",
+ image: "unknown",
+ video: "unknown",
+};
+
+const SECOND_SENTENCE: Record = {
+ connection: "Check your connection, then try again.",
+ timeout: "It took too long. Try again.",
+ /** Depends on the wait hint: `busyCopy`. */
+ busy: "",
+ cancelled: "",
+ refused: "",
+ unusable: "",
+ unknown: "",
+};
+
+/** One plain-language mapping for any rejection value. `context` is the
+ * site's own sentence, such as "Couldn’t load messages.", and is the whole
+ * text whenever nothing more specific is known. */
+export function userCopy(error: unknown, context: string): UserCopy {
+ const details = rawText(error);
+ const base = { ...(details ? { details } : {}) };
+ if (error instanceof UploadError)
+ // Upload copy is already written for people (attachments.ts).
+ return {
+ ...base,
+ text: error.message,
+ retry: !UPLOAD_FINAL.has(error.code),
+ kind: UPLOAD_KIND[error.code] ?? "refused",
+ };
+ const kind = failureKind(error);
+ if (kind === "refused") {
+ const reason = refusalReason(error);
+ return {
+ ...base,
+ text: reason ? `${context} ${reason}` : context,
+ retry: false,
+ kind,
+ };
+ }
+ const next = kind === "busy" ? busyCopy(error) : SECOND_SENTENCE[kind];
+ return {
+ ...base,
+ text: next ? `${context} ${next}` : context,
+ retry: kind !== "unusable",
+ kind,
+ };
+}
+
+/** `apiFailure` adds a second to a relay's own quota hint, so the lane reopens
+ * just after the relay's wait. A 60s hint arrives as 61s. */
+const HINT_PAD_MS = 1000;
+
+/** A wait hint of a minute or less reads as "a few seconds"; a longer one is
+ * named to the nearest minute or hour, so a long admission pause never
+ * promises seconds. */
+function busyCopy(error: unknown) {
+ const wait =
+ error instanceof ReadError
+ ? error.retryAfterMs
+ : error instanceof PublishNotSent
+ ? error.wait?.retryAfterMs
+ : undefined;
+ if (
+ wait === undefined ||
+ !Number.isFinite(wait) ||
+ wait <= 60_000 + HINT_PAD_MS
+ )
+ return "Buzz is busy. Try again in a few seconds.";
+ const minutes = Math.round(wait / 60_000);
+ if (minutes < 60)
+ return `Buzz is busy. Try again in about ${minutes} ${minutes === 1 ? "minute" : "minutes"}.`;
+ const hours = Math.round(minutes / 60);
+ return `Buzz is busy. Try again in about ${hours} ${hours === 1 ? "hour" : "hours"}.`;
+}
+
+/** Classifies a rejection by the typed errors and known native strings Buzz
+ * already produces. Anything else is `unknown`, never parsed from its text. */
+export function failureKind(error: unknown): FailureKind {
+ if (error instanceof ApiPaused || error instanceof ApiCapacity) return "busy";
+ if (error instanceof ReadError) {
+ if (error.kind === "cancelled") return "cancelled";
+ if (error.kind === "denied") return "refused";
+ if (error.kind === "invalid-response") return "unusable";
+ if (BROKER_BUSY.has(error.message)) return "busy";
+ const status = error.status;
+ if (status === 429) return "busy";
+ // Only a `denied` read is a refusal. An `unavailable` 401/403 is an edge
+ // proxy's page in front of the relay, which clears on its own.
+ if (
+ status !== undefined &&
+ status >= 400 &&
+ status < 500 &&
+ status !== 401 &&
+ status !== 403 &&
+ status !== 408
+ )
+ return "unusable";
+ return "connection";
+ }
+ if (error instanceof PublishNotSent)
+ return error.wait ||
+ relayPrefix(error.reason ?? error.message) === "rate-limited"
+ ? "busy"
+ : "connection";
+ if (error instanceof PublishRejected) {
+ const prefix = relayPrefix(error.reason ?? error.message);
+ return prefix === "rate-limited" ? "busy" : "refused";
+ }
+ if (error instanceof Error && error.name === "TimeoutError") return "timeout";
+ if (error instanceof Error && error.name === "AbortError") return "cancelled";
+ if (error instanceof Error && error.name === "NotAllowedError")
+ return "refused";
+ if (error instanceof Error && error.name === "NetworkError")
+ return "connection";
+ if (error instanceof TypeError) {
+ if (FETCH_FAILURES.has(error.message)) return "connection";
+ } else if (typeof error === "string" || error instanceof Error) {
+ const text = typeof error === "string" ? error : error.message;
+ if (BROKER_BUSY.has(text)) return "busy";
+ if (isTransientFailure(error)) return "connection";
+ }
+ // A wrapper that only adds context keeps its cause.
+ if (error instanceof Error && error.cause !== undefined)
+ return failureKind(error.cause);
+ return "unknown";
+}
+
+/** The raw text of a rejection, for diagnostics. */
+export function rawText(error: unknown): string | undefined {
+ if (error === undefined || error === null) return undefined;
+ if (typeof error === "string") return error || undefined;
+ if (error instanceof Error) {
+ const reason = error instanceof PublishRejected ? error.reason : undefined;
+ const own = String(error);
+ return reason && !own.includes(reason) ? `${own} (${reason})` : own;
+ }
+ try {
+ return JSON.stringify(error) ?? String(error);
+ } catch {
+ return String(error);
+ }
+}
+
+function relayPrefix(text: string | undefined) {
+ return text ? NIP01_PREFIX.exec(text)?.[1] : undefined;
+}
+
+/** Known community refusals, written for people. A refusal Buzz doesn't
+ * recognize adds nothing, so only the site's sentence is shown. */
+export function refusalReason(error: unknown): string | undefined {
+ const raw =
+ error instanceof PublishRejected
+ ? (error.reason ?? error.message)
+ : error instanceof Error
+ ? error.message
+ : typeof error === "string"
+ ? error
+ : undefined;
+ const text = raw?.trim();
+ if (!text) return undefined;
+ const timedOut = /\btimed out until (\d{9,11})\b/i.exec(text);
+ if (timedOut) {
+ const until = formatUntil(Number(timedOut[1]));
+ return until ? `You’re timed out until ${until}.` : "You’re timed out.";
+ }
+ if (/\bnot allowed to add members\b/i.test(text))
+ return "You don’t have permission to add people to this channel.";
+ if (/\bnot allowed to remove members\b/i.test(text))
+ return "You don’t have permission to remove people from this channel.";
+ if (/\bchannel is archived\b/i.test(text)) return "This channel is archived.";
+ if (/\bnot a channel member\b|\bchannel access revoked\b/i.test(text))
+ return "You’re not a member of this channel.";
+ if (/\bnot a relay member\b/i.test(text))
+ return "You’re not a member of this community.";
+ if (/^auth-required:/i.test(text) || /\bevidence rejected\b/i.test(text))
+ return "Sign in again to continue.";
+ if (
+ /^(blocked|restricted):/i.test(text) ||
+ /\bauthorization denied\b/.test(text)
+ )
+ return "You don’t have permission to do this.";
+ if (/^mute:/i.test(text)) return "You’re muted in this community.";
+ return undefined;
+}
+
+function formatUntil(unixSeconds: number) {
+ if (!Number.isSafeInteger(unixSeconds) || unixSeconds <= 0) return undefined;
+ return new Intl.DateTimeFormat(undefined, {
+ month: "short",
+ day: "numeric",
+ hour: "numeric",
+ minute: "2-digit",
+ }).format(new Date(unixSeconds * 1000));
+}
diff --git a/src/features/relay/window-failure.ts b/src/features/relay/window-failure.ts
new file mode 100644
index 0000000000..067a0ea4e4
--- /dev/null
+++ b/src/features/relay/window-failure.ts
@@ -0,0 +1,14 @@
+/** What a failed history read rejected with: a `ReadError`, another `Error` or
+ * a native string. Map it with `userCopy` (see `historyFailure`); never render
+ * it. */
+export type WindowFailure = Error | string;
+
+/** A history view keeps the failure itself, so each surface can say what it
+ * means. Never empty: a view's `error` also says that a failure happened. */
+export function windowFailure(error: unknown): WindowFailure {
+ if (error instanceof Error || (typeof error === "string" && error))
+ return error;
+ return new Error(
+ error === undefined || error === null ? "Unknown failure" : String(error),
+ );
+}
diff --git a/src/features/relay/window.ts b/src/features/relay/window.ts
index bfb819fdaf..e528718d13 100644
--- a/src/features/relay/window.ts
+++ b/src/features/relay/window.ts
@@ -1,3 +1,4 @@
+import { ReadError } from "./errors";
import { CHANNEL_ROW_KINDS } from "./membership";
import { isObject, objectBody } from "./body";
import { hasTag, type ReadFilter, type RelayEvent } from "./events";
@@ -69,11 +70,21 @@ export function parseWindow(
);
const [bound] = bounds;
if (bounds.length !== 1 || !bound)
- throw new Error("Channel history response has invalid window bounds");
+ throw new ReadError(
+ "invalid-response",
+ "Channel history response has invalid window bounds",
+ );
const body = objectBody(bound.content);
- if (!body) throw new Error("Channel history bounds are malformed");
+ if (!body)
+ throw new ReadError(
+ "invalid-response",
+ "Channel history bounds are malformed",
+ );
if (typeof body.has_more !== "boolean")
- throw new Error("Channel history bounds omit has_more");
+ throw new ReadError(
+ "invalid-response",
+ "Channel history bounds omit has_more",
+ );
const next = body.next_cursor;
let nextCursor: WindowCursor | null = null;
if (next != null) {
@@ -85,7 +96,10 @@ export function parseWindow(
typeof next.id !== "string" ||
!/^[0-9a-f]{64}$/.test(next.id)
)
- throw new Error("Channel history cursor is malformed");
+ throw new ReadError(
+ "invalid-response",
+ "Channel history cursor is malformed",
+ );
nextCursor = { createdAt: next.created_at, eventId: next.id };
// History order is descending timestamp, then ascending id. Equal-time
// pages must move to a lexically greater id; a repeated cursor is not progress.
@@ -95,10 +109,16 @@ export function parseWindow(
(nextCursor.createdAt === cursor.createdAt &&
nextCursor.eventId <= cursor.eventId))
)
- throw new Error("Channel history cursor does not advance");
+ throw new ReadError(
+ "invalid-response",
+ "Channel history cursor does not advance",
+ );
}
if (body.has_more !== (nextCursor !== null))
- throw new Error("Channel history bounds contradict their cursor");
+ throw new ReadError(
+ "invalid-response",
+ "Channel history bounds contradict their cursor",
+ );
return {
events: events.filter((event) => event.kind !== 39006),
hasMore: body.has_more,
diff --git a/src/shared/ErrorDetails.test.tsx b/src/shared/ErrorDetails.test.tsx
new file mode 100644
index 0000000000..750b38e532
--- /dev/null
+++ b/src/shared/ErrorDetails.test.tsx
@@ -0,0 +1,24 @@
+// @vitest-environment jsdom
+import "@testing-library/jest-dom/vitest";
+import { afterEach, expect, test } from "vitest";
+import { cleanup, render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { ErrorDetails } from "./ErrorDetails";
+
+afterEach(cleanup);
+
+test("keeps raw failure text collapsed under Details", async () => {
+ render( );
+ const trigger = screen.getByRole("button", { name: "Details" });
+ expect(trigger).toHaveAttribute("aria-expanded", "false");
+ expect(
+ screen.queryByText("ReadError: Relay read failed (502)"),
+ ).not.toBeInTheDocument();
+ await userEvent.click(trigger);
+ expect(screen.getByText("ReadError: Relay read failed (502)")).toBeVisible();
+});
+
+test("renders nothing without raw text", () => {
+ const { container } = render( );
+ expect(container).toBeEmptyDOMElement();
+});
diff --git a/src/shared/ErrorDetails.tsx b/src/shared/ErrorDetails.tsx
new file mode 100644
index 0000000000..dc26103a35
--- /dev/null
+++ b/src/shared/ErrorDetails.tsx
@@ -0,0 +1,21 @@
+import { Accordion } from "./design-system/ui/Accordion";
+
+/** The raw failure text behind a plain-language message (`userCopy`'s
+ * `details`), collapsed so support can read it without it becoming the
+ * message. Renders nothing when there is no raw text. */
+export function ErrorDetails({ details }: { details?: string | undefined }) {
+ if (!details) return null;
+ return (
+ {details}
+ ),
+ },
+ ]}
+ />
+ );
+}
diff --git a/tests/browser/history-loading.spec.mjs b/tests/browser/history-loading.spec.mjs
index 5981d5384e..95df0acd95 100644
--- a/tests/browser/history-loading.spec.mjs
+++ b/tests/browser/history-loading.spec.mjs
@@ -175,7 +175,7 @@ test.describe("quota retry without threshold-driven continuation", () => {
await history(page).hover();
await page.mouse.wheel(0, -100000);
await expect(history(page).getByRole("alert")).toContainText(
- "rate-limited",
+ "Buzz is busy. Try again in a few seconds.",
);
const count = cursors().length;
expect(count).toBe(1);
@@ -187,7 +187,7 @@ test.describe("quota retry without threshold-driven continuation", () => {
// Only the broker lane holds older-page retries; it reopens at this bound.
await expect.poll(() => app.relay.brokerCooldownOver()).toBe(true);
await history(page)
- .getByRole("button", { name: "Retry older messages", exact: true })
+ .getByRole("button", { name: "Try again", exact: true })
.click();
await expect.poll(() => app.pending.length).toBe(1);
expect(cursors()).toHaveLength(count + 1);
@@ -738,7 +738,7 @@ test.describe("pages inside one second", () => {
// alert does not prove the reader left the bottom. upper() does.
await upper(page);
await expect(history(page).getByRole("alert")).toContainText(
- "rate-limited",
+ "Buzz is busy. Try again in a few seconds.",
);
await settle(page);
const { filter } = app.report.queries.find(
diff --git a/tests/browser/live-status.spec.mjs b/tests/browser/live-status.spec.mjs
index 460a6cf1c4..b212d75a60 100644
--- a/tests/browser/live-status.spec.mjs
+++ b/tests/browser/live-status.spec.mjs
@@ -360,12 +360,14 @@ test.describe("public preview recovery", () => {
"error: Preview stream interrupted",
);
// Both failures are outages: recovery owns them, quietly at first, with
- // no per-panel Retry messages.
+ // no per-panel Try again.
const warning = notice(page);
await expect
.poll(() => app.report.startupFailures?.length ?? 0)
.toBeGreaterThan(0);
- await expect(page.getByRole("button", { name: /Retry/ })).toHaveCount(0);
+ await expect(
+ page.getByRole("button", { name: /Retry|Try again/ }),
+ ).toHaveCount(0);
const history = page.locator("[data-channel-history]");
await expect(history.locator("[data-message-scroller]")).toHaveCount(0);
failHead = false;
diff --git a/tests/browser/new-message.spec.mjs b/tests/browser/new-message.spec.mjs
index d2be277a85..6b3cc9d4f4 100644
--- a/tests/browser/new-message.spec.mjs
+++ b/tests/browser/new-message.spec.mjs
@@ -145,6 +145,9 @@ const test = base.extend({
];
reads.push(...body);
if (body.some((filter) => filter.top_level)) await historyReady;
+ // An outage, not a malformed answer: only an outage offers Try again.
+ if (failHistory && body.some((filter) => filter.top_level))
+ return Response.json({ error: "offline" }, { status: 503 });
if (body.some((filter) => filter.kinds?.includes(0) && filter.page))
await directoryReady;
if (
@@ -914,8 +917,10 @@ test("existing DM previews inline and opens its exact thread", async ({
await open(page, app);
await page.getByRole("option", { name: "Avery Chen", exact: true }).click();
const body = page.locator("[data-new-message-body]");
- const retry = body.getByRole("button", { name: "Retry messages" });
- await expect(retry).toBeVisible();
+ const retry = body.getByRole("button", { name: "Try again" });
+ // The head read retries quietly for a few seconds before it surfaces.
+ await expect(retry).toBeVisible({ timeout: 20_000 });
+ await expect(body.getByRole("alert")).toContainText("Messages didn’t load.");
await page
.getByRole("combobox", { name: "Message recipients" })
.press("Escape");
From 0e35fbc3f585936994d22e29c58f31a8c01c5936 Mon Sep 17 00:00:00 2001
From: Matt Toohey
Date: Tue, 10 Nov 2026 03:06:03 +1100
Subject: [PATCH 2/2] test(browser): follow the plain history and media copy
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Five journeys still asserted the copy that the previous commit replaced:
- thread-window: the older-replies alert said "Older page failed"; it now
says "Older replies didn’t load." (ThreadPanel's OlderRepliesFailure).
- media-controls, message-overlays, gallery-theme: MediaReviewViewer's
unavailable-media button was "Retry"; it is "Try again". The
message-overlays title and the gallery comment follow the rename.
- navigation-repairs: the keyboard hand-off journey failed the session
with an empty JSON object. That is now an answer Buzz can't use, so
the card says "Buzz couldn’t connect to this community." and offers
Check for updates, not Try again. The journey now fails the broker
registration with a recorded 502 instead, a failure the service leaves
for the user, so the card still says "Check your connection, then try
again." and offers Try again. Every caller registers the community, so
every registration fails until the user acts, and the retry is counted
from then on.
Product copy is unchanged.
Signed-off-by: Matt Toohey
Co-Authored-By: Claude Fable 5.1
---
tests/browser/gallery-theme.spec.mjs | 4 ++--
tests/browser/media-controls.spec.mjs | 8 ++++----
tests/browser/message-overlays.spec.mjs | 6 +++---
tests/browser/navigation-repairs.spec.mjs | 25 ++++++++++++++++-------
tests/browser/thread-window.spec.mjs | 8 +++++---
5 files changed, 32 insertions(+), 19 deletions(-)
diff --git a/tests/browser/gallery-theme.spec.mjs b/tests/browser/gallery-theme.spec.mjs
index a6dc67ee1d..9ef064fc8d 100644
--- a/tests/browser/gallery-theme.spec.mjs
+++ b/tests/browser/gallery-theme.spec.mjs
@@ -230,7 +230,7 @@ test("an open image gallery follows host appearance while retaining its dark sta
});
// Browser boundary: ChannelsPage's real opener must retain the collapsed batch;
-// native focus and key bubbling must survive Retry removing its own button.
+// native focus and key bubbling must survive Try again removing its own button.
test("primary gallery keeps hidden media and keyboard navigation through thread retry", async ({
page,
app,
@@ -314,7 +314,7 @@ test("primary gallery keeps hidden media and keyboard navigation through thread
await expect(dialog.getByText("5 / 6", { exact: true })).toBeVisible();
releaseFirst();
const retryButton = dialog.getByRole("button", {
- name: "Retry",
+ name: "Try again",
exact: true,
});
await expect(retryButton).toBeVisible();
diff --git a/tests/browser/media-controls.spec.mjs b/tests/browser/media-controls.spec.mjs
index 54e36ed01d..61104e21dc 100644
--- a/tests/browser/media-controls.spec.mjs
+++ b/tests/browser/media-controls.spec.mjs
@@ -284,13 +284,13 @@ test("unavailable media keeps its explanation readable and recovery visible", as
await page.setViewportSize({ width: 320, height: 720 });
await page.goto("/tests/fixtures/media-review.html?photo&missing-root");
const dialog = page.getByRole("dialog", { name: "Image viewer" });
- const message = dialog
- .getByRole("alert")
- .filter({ has: page.getByRole("button", { name: "Retry", exact: true }) });
+ const message = dialog.getByRole("alert").filter({
+ has: page.getByRole("button", { name: "Try again", exact: true }),
+ });
await expect(message).toContainText("Original message unavailable.");
await expect(message).toHaveCSS("color", "rgb(255, 255, 255)");
await expect(
- message.getByRole("button", { name: "Retry", exact: true }),
+ message.getByRole("button", { name: "Try again", exact: true }),
).toBeVisible();
await expect(dialog.getByRole("heading", { name: "Comments" })).toBeVisible();
});
diff --git a/tests/browser/message-overlays.spec.mjs b/tests/browser/message-overlays.spec.mjs
index bd88b3e258..63e5ae04d5 100644
--- a/tests/browser/message-overlays.spec.mjs
+++ b/tests/browser/message-overlays.spec.mjs
@@ -254,8 +254,8 @@ test("video speed options escape the thread and restore focus after selection an
await expect(selected).toBeFocused();
});
-// Retry is a Button under the viewer's unavailable-media branch, not its header.
-test("unavailable dark media review keeps Retry readable in a light host", async ({
+// Try again is a Button under the viewer's unavailable-media branch, not its header.
+test("unavailable dark media review keeps Try again readable in a light host", async ({
page,
}) => {
await page.goto("/tests/fixtures/media-review.html?photo&missing-root");
@@ -264,7 +264,7 @@ test("unavailable dark media review keeps Retry readable in a light host", async
document.documentElement.dataset.colorMode = "light";
});
const dialog = page.getByRole("dialog", { name: "Image viewer" });
- const retry = dialog.getByRole("button", { name: "Retry", exact: true });
+ const retry = dialog.getByRole("button", { name: "Try again", exact: true });
await expect(dialog.getByRole("alert").first()).toContainText(
"Original message unavailable.",
);
diff --git a/tests/browser/navigation-repairs.spec.mjs b/tests/browser/navigation-repairs.spec.mjs
index a4700203e1..a2ff15b2c7 100644
--- a/tests/browser/navigation-repairs.spec.mjs
+++ b/tests/browser/navigation-repairs.spec.mjs
@@ -68,19 +68,30 @@ test("keyboard Retry hands off focus while reconnecting a failed Messages tab op
page,
app,
}) => {
- let requests = 0;
+ let failing = true;
+ let retries = 0;
let release;
const held = new Promise((resolve) => {
release = resolve;
});
- await page.route("**/api/relay/primary/session", async (route) => {
- requests++;
- if (requests === 1) return route.fulfill({ json: {} });
+ // Registration fails for a reason the service leaves for the user, so the
+ // card offers Try again. (An empty session is an answer Buzz can’t use: that
+ // card offers Check for updates and asks again by itself.) Every caller
+ // registers the community, so every registration fails until the user acts.
+ await page.route("**/api/relay/register", async (route) => {
+ if (failing) {
+ app.report.startupFailures ??= [];
+ app.report.startupFailures.push(route.request().url());
+ return route.fulfill({
+ status: 502,
+ json: { error: "Registration failed" },
+ });
+ }
+ retries++;
await held;
return route.continue();
});
// Start outside Messages so its header activation owns preserve-focus intent.
- // Settings also reads the community profile through /session, racing this gate.
await page.goto(
`${app.origin}/#buzz=${encodeURIComponent(JSON.stringify({ version: 1, kind: "page", pluginId: "buzz.workflows", pageId: "workflows" }))}`,
);
@@ -97,15 +108,15 @@ test("keyboard Retry hands off focus while reconnecting a failed Messages tab op
await expect(
page.getByText("Check your connection, then try again."),
).toBeVisible();
- expect(requests).toBe(1);
const visit = await entry(page);
const main = page.getByRole("main");
// The sidebar offers its own Try again for the channel list.
const retry = button(main, "Try again");
+ failing = false;
try {
await retry.focus();
await retry.press("Enter");
- await expect.poll(() => requests, { timeout: 1500 }).toBe(2);
+ await expect.poll(() => retries, { timeout: 1500 }).toBeGreaterThan(0);
await expect(retry).toHaveCount(0);
await expect(main).toBeFocused();
} finally {
diff --git a/tests/browser/thread-window.spec.mjs b/tests/browser/thread-window.spec.mjs
index 27267bb42a..741353f300 100644
--- a/tests/browser/thread-window.spec.mjs
+++ b/tests/browser/thread-window.spec.mjs
@@ -119,7 +119,7 @@ test("older-page retry repeats the failed continuation at the scrollback cue", a
await page.evaluate(() => window.messagesFixture.releaseOlderPage());
const error = history.getByRole("alert");
const retry = history.getByRole("button", { name: "Try again" });
- await expect(error).toContainText("Older page failed");
+ await expect(error).toContainText("Older replies didn’t load.");
await expect(retry).toBeInViewport();
await retry.click();
await expect
@@ -127,7 +127,7 @@ test("older-page retry repeats the failed continuation at the scrollback cue", a
page.evaluate(() => window.messagesFixture.report.filters.length),
)
.toBe(4);
- await expect(error).toContainText("Older page failed");
+ await expect(error).toContainText("Older replies didn’t load.");
await expect(retry).toBeInViewport();
await retry.click();
await expect
@@ -399,7 +399,9 @@ test("older-page retry reparents a visible reply under its late parent", async (
// The wheel may demand history at the top without moving the scroller.
await page.mouse.wheel(0, -4000);
const retry = history.getByRole("button", { name: "Try again" });
- await expect(history.getByRole("alert")).toContainText("Older page failed");
+ await expect(history.getByRole("alert")).toContainText(
+ "Older replies didn’t load.",
+ );
await child.scrollIntoViewIfNeeded();
await expect(child).toBeInViewport();
await page.evaluate(() => window.messagesFixture.holdOlderPage());