From 4b2e0556ab7a2fdce03619a7057a17627872a3d8 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 | 15 +-
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 | 11 +-
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 | 363 ++++++++++--------
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, 2979 insertions(+), 403 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 20476e27fc..9267f411de 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 {
@@ -432,6 +433,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")}
>
}
/>
@@ -537,6 +543,7 @@ function ConnectedAppContent({ services }: { services: AppServices }) {
description="Turn it on in Manage plugins to open this page."
action={
<>
+ {messagesButton("quiet")}
Manage plugins
- {messagesButton("quiet")}
>
}
/>
@@ -557,8 +563,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")}
>
}
/>
@@ -569,8 +575,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 519be088c4..b3590d945d 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);
@@ -1258,7 +1295,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;
}
@@ -1352,7 +1389,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 41c68159da..9312564328 100644
--- a/src/app/services.test.ts
+++ b/src/app/services.test.ts
@@ -441,7 +441,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();
@@ -503,7 +503,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 892cc8a1af..e7a784d311 100644
--- a/src/app/shell/PageSearch.test.tsx
+++ b/src/app/shell/PageSearch.test.tsx
@@ -838,7 +838,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 b3a6cc8986..9b18c644d6 100644
--- a/src/app/shell/PrimarySidebar.test.tsx
+++ b/src/app/shell/PrimarySidebar.test.tsx
@@ -1022,7 +1022,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",
@@ -1050,7 +1053,10 @@ it("shows Try again beside a settled failure, and Connecting while the connectio
const f = fixture(true);
const retry = vi.spyOn(f.relay, "retry");
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();
@@ -1062,7 +1068,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 5c1f50ff3f..334edb5300 100644
--- a/src/app/shell/PrimarySidebar.tsx
+++ b/src/app/shell/PrimarySidebar.tsx
@@ -266,7 +266,7 @@ function ScopedSidebar({
sidebarRows={props.sidebarRows}
cached={!!connection.cached}
layoutReady={filter === "team" || soloReady}
- connectionError={connection.error}
+ connectionError={connection.error?.details}
render={({
sidebar,
dmPreviews,
@@ -411,7 +411,7 @@ function ScopedSidebar({
props.federatedIdentity,
connection.origin,
)}
- error={connection.error}
+ error={connection.error?.details}
retry={props.relay.retry}
relay={connection.origin}
memberships={props.memberships}
diff --git a/src/bundled/bestie/PinnedBestie.test.tsx b/src/bundled/bestie/PinnedBestie.test.tsx
index b7b102cfea..235eb43b0a 100644
--- a/src/bundled/bestie/PinnedBestie.test.tsx
+++ b/src/bundled/bestie/PinnedBestie.test.tsx
@@ -735,15 +735,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 51f00008d0..1c857bc897 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 33ed4f3027..894a910e09 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 06b8ca48e2..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 779ff2769c..02dd2a0840 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) {
@@ -330,7 +331,7 @@ export async function bestieFixture(
listeners.delete(listener);
};
},
- retry() {},
+ retry: retryConnection,
disconnect() {},
async clearCache() {},
};
@@ -446,6 +447,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 d94cfe6d2d..41d0278af8 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";
@@ -43,6 +47,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();
@@ -92,21 +97,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.retry()}
- >
- 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 331f899fd7..bac6f6e7c3 100644
--- a/src/bundled/inbox/InboxPage.test.tsx
+++ b/src/bundled/inbox/InboxPage.test.tsx
@@ -810,7 +810,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
@@ -828,6 +831,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;
@@ -1458,7 +1490,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 017556a9c2..96508132e0 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 7e9f464aa5..4fe3dc95e4 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 7b74666fed..f9d6c12401 100644
--- a/src/bundled/relay-notes/NotesPage.test.tsx
+++ b/src/bundled/relay-notes/NotesPage.test.tsx
@@ -1286,3 +1286,89 @@ it.each([false, true])(
).toHaveFocus();
},
);
+
+function unavailable(snapshot: Partial) {
+ const current = { generation: 1, ...snapshot } as RelaySnapshot;
+ const relay = {
+ snapshot: () => current,
+ subscribe: () => () => {},
+ retry: 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.retry).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: () => () => {},
+ retry: 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.retry).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 db82ea2b1e..948b0b4869 100644
--- a/src/bundled/relay-notes/NotesPage.tsx
+++ b/src/bundled/relay-notes/NotesPage.tsx
@@ -22,6 +22,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,
@@ -203,16 +204,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.retry()}>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..813c264ffc 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: () => () => {},
+ 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 : (
{
screen.getByText("Unreadable configuration").closest("button"),
).toBeNull();
});
+
+function disconnected(snapshot: Partial) {
+ const current = { generation: 1, ...snapshot } as RelaySnapshot;
+ const relay = {
+ snapshot: () => current,
+ subscribe: () => () => {},
+ retry: 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.retry).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: () => () => {},
+ retry: 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.retry).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 d84da29c1b..807c7a6d71 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.retry()}>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 00942648ac..cbfc263bc6 100644
--- a/src/features/agents/service-testing.ts
+++ b/src/features/agents/service-testing.ts
@@ -254,7 +254,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 ||
@@ -249,6 +252,9 @@ function fixture(upload?: () => Promise) {
},
};
},
+ failHistory: (error: Error | undefined) => {
+ historyError = error;
+ },
liveMessage: () => {
const next = message(other, "room", "New live message", 1000000);
events.push(next);
@@ -930,6 +936,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 43427b312e..392ef3c01c 100644
--- a/src/features/messages/ChannelTimeline.tsx
+++ b/src/features/messages/ChannelTimeline.tsx
@@ -57,6 +57,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";
@@ -706,8 +707,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);
@@ -1852,12 +1859,21 @@ function Timeline({
className={styles.edge}
data-history-space={reserveHistorySpace || undefined}
>
- {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;
@@ -918,7 +921,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();
@@ -934,6 +938,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 d1799f15de..220da874ea 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();
@@ -1925,10 +1953,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 c04950f987..2faa3aee7a 100644
--- a/src/features/messages/attachment-panes.test.tsx
+++ b/src/features/messages/attachment-panes.test.tsx
@@ -308,7 +308,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..717979e712
--- /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 = { retry: 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.retry).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 = { retry: 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.retry).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 = { retry: 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.retry).toHaveBeenCalledOnce();
+ expect(action({ error: { kind: "unusable", details: "x" } })).toEqual([
+ "Check for updates",
+ ]);
+ await userEvent.click(screen.getByRole("button"));
+ expect(openUpdates).toHaveBeenCalledOnce();
+ expect(relay.retry).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..fdf174f8c2
--- /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.retry()}>
+ 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.retry()}
+ >
+ Try again
+
+ )
+ }
+ />
+
+ );
+}
diff --git a/src/features/relay/channel-lifecycle.test.ts b/src/features/relay/channel-lifecycle.test.ts
index 9d0fed26e4..3a7df0b7a2 100644
--- a/src/features/relay/channel-lifecycle.test.ts
+++ b/src/features/relay/channel-lifecycle.test.ts
@@ -206,7 +206,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({
@@ -276,7 +276,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);
@@ -290,7 +290,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 2dd674de67..f7b75a693a 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";
@@ -215,7 +216,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 e8e8d49115..2479ac92fa 100644
--- a/src/features/relay/live-session.test.ts
+++ b/src/features/relay/live-session.test.ts
@@ -714,7 +714,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) => {
@@ -861,7 +866,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();
@@ -1869,7 +1874,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([]);
diff --git a/src/features/relay/outbox.ts b/src/features/relay/outbox.ts
index cd3f63178a..1ae4767136 100644
--- a/src/features/relay/outbox.ts
+++ b/src/features/relay/outbox.ts
@@ -122,7 +122,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 6b79a52125..9579d0b032 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 {
@@ -140,6 +141,219 @@ it("reconnects a connection the federated session refused once it is signed in",
expect(connect).toHaveBeenCalledTimes(2);
expect(data.snapshot().status).toBe("ready");
});
+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.retry();
+ 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();
@@ -183,7 +397,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 9e56a366fb..7df0870f38 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. */
@@ -74,6 +88,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;
let deadline: ReturnType | undefined;
let store = createRelaySession(null, { identityNames });
@@ -84,15 +101,23 @@ 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.status !== "error") transientFailure = false;
+ if (next.status !== "error") {
+ transientFailure = false;
+ unusableFailure = false;
+ }
snapshot = Object.freeze(
transientFailure && !disposed
? { ...next, retrying: true as const }
@@ -100,19 +125,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();
@@ -204,7 +239,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 },
@@ -231,6 +266,7 @@ export function provideRelay(
store.dispose();
store = createRelaySession(null, { identityNames });
transientFailure = false;
+ unusableFailure = false;
publish({
status: "error",
generation,
@@ -242,6 +278,163 @@ export function provideRelay(
...refused,
});
}
+ function connectAgain(background: boolean) {
+ if (disposed || !connect || clearing) return;
+ // Only an explicit retry replaces an attempt under way, and only a
+ // background one that left the failure on screen.
+ if (connecting && (background || !quiet)) 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()
+ .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;
@@ -253,148 +446,7 @@ export function provideRelay(
listeners.delete(listener);
};
},
- retry() {
- if (disposed || !connect || connecting || clearing) 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()
- .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();
@@ -442,8 +494,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 5569ba0af9..622010d957 100644
--- a/src/features/relay/store.test.ts
+++ b/src/features/relay/store.test.ts
@@ -364,8 +364,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([
@@ -381,9 +386,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 dbf21bed1c..85cf4d3d8b 100644
--- a/src/features/relay/store.ts
+++ b/src/features/relay/store.ts
@@ -4,6 +4,7 @@ import type { Outbox } from "./outbox";
import { MessageProjection } from "./message-projection";
import { createRelayProfiler, type RelayProfiler } from "./profiling";
import { isTransientFailure, ReadError, readErrorKind } from "./errors";
+import { windowFailure } from "./window-failure";
import type {
ChannelReference,
ChannelList,
@@ -578,8 +579,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,
@@ -918,7 +927,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"));
}
function denyChannel(channelId: string, error: unknown) {
// A chat still starting is not there to deny yet; only a refused or
@@ -943,7 +952,7 @@ export function createChannelStore(
setWindow(state, {
status: "error",
rows: EMPTY_ROWS,
- error: describe(error),
+ error: windowFailure(error),
loadingOlder: false,
});
}
@@ -1128,12 +1137,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 {
@@ -2293,7 +2308,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) =>
@@ -2303,7 +2318,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");
}
@@ -3151,7 +3169,7 @@ export function createChannelStore(
state &&
authorized(channelId)
)
- setWindow(state, { error: describe(error) });
+ setWindow(state, { error: windowFailure(error) });
})
.finally(() => {
controllers.delete(controller);
@@ -3765,7 +3783,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;
@@ -4416,7 +4437,7 @@ export function createChannelStore(
setWindow(retained, {
status: retained.snapshot.rows.length ? "ready" : "error",
freshness: "cached",
- error: describe(error),
+ error: windowFailure(error),
});
throw error;
} finally {
@@ -4431,7 +4452,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 0237536c98..6a6e209c15 100644
--- a/src/features/relay/thread-profile-fixture.ts
+++ b/src/features/relay/thread-profile-fixture.ts
@@ -335,7 +335,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 09a5723ead..f49899aa97 100644
--- a/src/features/relay/threads.test.ts
+++ b/src/features/relay/threads.test.ts
@@ -699,7 +699,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 () => {
@@ -719,7 +721,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 fae68324cf..4ba00ca272 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;
@@ -841,7 +842,7 @@ export function createThreadView({
// would clear that error.
older = false;
if (targetStatus === "loading") targetStatus = "error";
- 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 0f33adbc90..dc5f2cc00f 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 c658d52385..0176f5e36a 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,
@@ -473,6 +474,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. */
@@ -488,7 +511,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);
},
};
}
@@ -1506,7 +1529,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;
}),
@@ -1584,7 +1616,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),
);
});
});
@@ -1681,7 +1713,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 2d8d26f30f..2d94175089 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);
@@ -684,7 +684,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 a094825ec4..ff4dd9a856 100644
--- a/tests/browser/new-message.spec.mjs
+++ b/tests/browser/new-message.spec.mjs
@@ -144,6 +144,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 (
@@ -913,8 +916,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 e8c577c4e51a88426d3b061846806f7c7ae4488d Mon Sep 17 00:00:00 2001
From: mtoohey
Date: Mon, 9 Nov 2026 15:21:53 +0000
Subject: [PATCH 2/2] fix(pages): offer Try again when a page or panel crashes,
and name the plugin behind it
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The page and panel error boundaries printed String(error) and, for
every page, the plugin-only "Open Settings to disable, delete, or roll
back this plugin.", with no way to recover. Both now render
ContributionFailure:
- built-in: "This page couldn’t open" (or "This panel couldn’t open"),
"Something went wrong showing this page." and "If it keeps happening,
restart Buzz.", with "Try again"
- external plugin: " had a problem showing it.", with "Manage
plugin", then "Try again" as the trailing primary action
Try again clears the failure and remounts the content. The exception
text moves under a collapsed "Details" (ErrorDetails).
App provides the plugin owners above AppShell through
PluginOwnersProvider, so pages, the panel callers (useChannelPanels,
ConversationHost, PanelCard) and the launcher-opened companion can name
an external plugin. pluginOwners() looks a plugin ID up in the catalog
and, for an external plugin, returns its name and a Settings → Plugins
opener; bundled, unknown and not-ready catalog entries stay built-in.
Co-Authored-By: Claude Fable 5.1
Signed-off-by: mtoohey
---
src/app/App.plugin-owners.test.tsx | 147 ++++++++++
src/app/App.tsx | 265 +++++++++---------
src/bundled/channels/useChannelPanels.tsx | 4 +
.../conversation/ConversationHost.tsx | 3 +
src/features/pages/ContributionFailure.tsx | 61 ++++
src/features/pages/PageView.test.tsx | 138 +++++++++
src/features/pages/PageView.tsx | 78 ++++--
src/features/pages/plugin-owners.test.ts | 51 ++++
src/features/pages/plugin-owners.ts | 22 ++
src/features/panels/PanelCard.tsx | 9 +-
src/features/panels/PanelView.test.tsx | 78 ++++++
src/features/panels/PanelView.tsx | 42 ++-
tests/browser/navigation-lifecycle.spec.mjs | 44 +++
13 files changed, 775 insertions(+), 167 deletions(-)
create mode 100644 src/app/App.plugin-owners.test.tsx
create mode 100644 src/features/pages/ContributionFailure.tsx
create mode 100644 src/features/pages/PageView.test.tsx
create mode 100644 src/features/pages/plugin-owners.test.ts
create mode 100644 src/features/pages/plugin-owners.ts
create mode 100644 src/features/panels/PanelView.test.tsx
diff --git a/src/app/App.plugin-owners.test.tsx b/src/app/App.plugin-owners.test.tsx
new file mode 100644
index 0000000000..8415ce55b4
--- /dev/null
+++ b/src/app/App.plugin-owners.test.tsx
@@ -0,0 +1,147 @@
+// @vitest-environment jsdom
+import "@testing-library/jest-dom/vitest";
+import { cleanup, fireEvent, render, screen } from "@testing-library/react";
+import { afterEach, beforeEach, expect, it, vi } from "vitest";
+import type { Context } from "@deepseek-ai/cordis";
+import { App } from "./App";
+import { createServices, type AppServices } from "./services";
+
+// One plugin with a plain page and a launcher panel that always throws. The
+// page doesn't host the companion, so the shell renders the panel outside it.
+vi.mock("../bundled", () => ({
+ bundledPlugins: [
+ {
+ manifest: { id: "test.owned", name: "Owned Tools", apiVersion: 1 },
+ module: {
+ inject: ["pages", "panels"],
+ apply(ctx: Context) {
+ ctx.pages.register({
+ id: "home",
+ title: "Home",
+ route: { version: 1, validate: () => true },
+ component: () => Home content
,
+ });
+ ctx.panels.register({
+ id: "broken",
+ title: "Broken tools",
+ matches: (target) => target === "test:broken",
+ launcher: { icon: "/buzz-mark.svg", target: "test:broken" },
+ component: () => {
+ throw new Error("Fixture companion failure");
+ },
+ });
+ },
+ },
+ },
+ ],
+}));
+
+beforeEach(() => {
+ vi.spyOn(console, "error").mockImplementation(() => {});
+ vi.stubGlobal("matchMedia", (media: string) => ({
+ media,
+ matches: false,
+ addEventListener() {},
+ removeEventListener() {},
+ }));
+ vi.stubGlobal(
+ "ResizeObserver",
+ class {
+ observe() {}
+ unobserve() {}
+ disconnect() {}
+ },
+ );
+});
+let services: AppServices | undefined;
+afterEach(async () => {
+ cleanup();
+ await services?.dispose();
+ services = undefined;
+ vi.restoreAllMocks();
+ vi.unstubAllGlobals();
+ localStorage.clear();
+ window.history.replaceState(null, "", "/");
+});
+
+// The catalog lists this bundled fixture as an external plugin when asked to.
+function start(external: boolean) {
+ window.history.replaceState(
+ null,
+ "",
+ `/#buzz=${encodeURIComponent(
+ JSON.stringify({
+ version: 1,
+ kind: "page",
+ pluginId: "test.owned",
+ pageId: "home",
+ }),
+ )}`,
+ );
+ const current = createServices();
+ services = current;
+ if (external) {
+ const read = current.plugins.snapshot;
+ let seen: ReturnType | undefined;
+ let shown: ReturnType | undefined;
+ current.plugins.snapshot = () => {
+ const next = read();
+ if (next !== seen) {
+ seen = next;
+ const { configuration } = next;
+ shown =
+ configuration.status === "ready"
+ ? {
+ ...next,
+ configuration: {
+ ...configuration,
+ catalog: {
+ ...configuration.catalog,
+ plugins: configuration.catalog.plugins.map((plugin) =>
+ plugin.manifest.id === "test.owned"
+ ? { ...plugin, source: "external" as const }
+ : plugin,
+ ),
+ },
+ },
+ }
+ : next;
+ }
+ return shown ?? next;
+ };
+ }
+ render( );
+ return current;
+}
+
+it("names an external plugin when its launcher companion fails", async () => {
+ start(true);
+ expect(await screen.findByText("Home content")).toBeVisible();
+ fireEvent.click(screen.getByRole("button", { name: "Broken tools" }));
+
+ expect(
+ await screen.findByRole("heading", { name: "This panel couldn’t open" }),
+ ).toBeVisible();
+ expect(
+ screen.getByText("Owned Tools had a problem showing it."),
+ ).toBeVisible();
+ fireEvent.click(screen.getByRole("button", { name: "Manage plugin" }));
+ expect(await screen.findByRole("dialog", { name: "Settings" })).toBeVisible();
+ const target = decodeURIComponent(window.location.hash);
+ expect(target).toContain('"section":"plugins"');
+ expect(target).toContain('"pluginId":"test.owned"');
+});
+
+it("keeps a built-in companion's failure generic", async () => {
+ start(false);
+ expect(await screen.findByText("Home content")).toBeVisible();
+ fireEvent.click(screen.getByRole("button", { name: "Broken tools" }));
+
+ expect(
+ await screen.findByRole("heading", { name: "This panel couldn’t open" }),
+ ).toBeVisible();
+ expect(
+ screen.getByText("Something went wrong showing this panel."),
+ ).toBeVisible();
+ expect(screen.queryByRole("button", { name: "Manage plugin" })).toBeNull();
+});
diff --git a/src/app/App.tsx b/src/app/App.tsx
index 9267f411de..4b75782bd2 100644
--- a/src/app/App.tsx
+++ b/src/app/App.tsx
@@ -45,6 +45,8 @@ import { SettingsSidebar } from "./SettingsSidebar";
import { pendingManaged } from "../plugins/managed";
import { RecoveryScreen } from "./RecoveryScreen";
import { PageView } from "../features/pages/PageView";
+import { PluginOwnersProvider } from "../features/pages/ContributionFailure";
+import { pluginOwners } from "../features/pages/plugin-owners";
import { OwnedContribution } from "../plugins/OwnedContribution";
import { SidebarFrame } from "../features/channel-navigation/SidebarFrame";
import { SidebarList } from "../features/channel-navigation/SidebarList";
@@ -406,6 +408,9 @@ function ConnectedAppContent({ services }: { services: AppServices }) {
: { scope: null }),
});
};
+ const owners = pluginOwners(configuration, (pluginId) =>
+ openSettingsSection("plugins", pluginId),
+ );
// As for pages, a timed-out reopen of the shown section keeps its editor.
const settingsUnavailable =
(!route.state.ingress && !!route.failure) || failedPage;
@@ -607,138 +612,144 @@ function ConnectedAppContent({ services }: { services: AppServices }) {
/>
)}
- (
-
- )}
- workspaceRef={setWorkspaceElement}
- sidebar={(pageNavigation, creationAction) =>
- workspace.page?.sidebar && pageSidebarReady ? (
-
-
-
- This page’s sidebar is unavailable.
- {pageNavigation}
-
-
-
- }
- >
- {(entry) => {
- const Sidebar = entry.sidebar;
- return Sidebar ? (
-
- {pageNavigation}
-
- ) : null;
- }}
-
- ) : workspace.page?.sidebar ? (
-
-
- {pageNavigation}
-
-
- ) : (
-
+ (
+
- {pageNavigation}
-
- )
- }
- navigationControls={
-
- }
- onCommunitySelect={selectCommunity}
- // A scoped Settings target selects its community on the way.
- onOpenTarget={(target) => void services.navigation.open(target)}
- communities={services.communities}
- federatedIdentity={services.federatedIdentity}
- invite={invite?.viewer === client.viewer ? invite : undefined}
- onInviteClose={services.invites.clear}
- settingsCards={services.settingsCards}
- accountActions={services.accountActions}
- appearance={services.appearance}
- pluginRecommendations={pluginRecommendations}
- searchServices={services}
- launchers={
-
- }
- backdrop={ }
- companion={pageOwnsCompanion ? undefined : companion}
- pages={startup === "ready" ? route.pages : []}
- selected={workspace.selected}
- navigationAttempt={workspace.attempt}
- onSelect={select}
- tone={presentation.tone}
- workspace={
- startup === "ready" && workspace.page?.layout === "workspace"
- }
- >
-
-
-
+ )}
+ workspaceRef={setWorkspaceElement}
+ sidebar={(pageNavigation, creationAction) =>
+ workspace.page?.sidebar && pageSidebarReady ? (
+
+
+
+
+ This page’s sidebar is unavailable.
+
+ {pageNavigation}
+
+
+
+ }
+ >
+ {(entry) => {
+ const Sidebar = entry.sidebar;
+ return Sidebar ? (
+
+ {pageNavigation}
+
+ ) : null;
+ }}
+
+ ) : workspace.page?.sidebar ? (
+
+
+ {pageNavigation}
+
+
+ ) : (
+
+ {pageNavigation}
+
+ )
+ }
+ navigationControls={
+
+ }
+ onCommunitySelect={selectCommunity}
+ // A scoped Settings target selects its community on the way.
+ onOpenTarget={(target) => void services.navigation.open(target)}
communities={services.communities}
- relay={services.relay}
- />
-
-
-
-
-
- {!settings && startup === "recovery" ? (
-
- ) : !settings &&
- ((!route.state.ingress && route.failure) || failedPage) ? (
- destinationFailure
- ) : !settings && route.waiting ? (
- opening
- ) : startup === "loading" || restoring ? (
- settings ? null : (
- opening
- )
- ) : workspace.page ? (
-
+ }
+ backdrop={ }
+ companion={pageOwnsCompanion ? undefined : companion}
+ pages={startup === "ready" ? route.pages : []}
+ selected={workspace.selected}
+ navigationAttempt={workspace.attempt}
+ onSelect={select}
+ tone={presentation.tone}
+ workspace={
+ startup === "ready" && workspace.page?.layout === "workspace"
+ }
+ >
+
+
+
- ) : null}
-
+
+
+
+
+
+ {!settings && startup === "recovery" ? (
+
+ ) : !settings &&
+ ((!route.state.ingress && route.failure) || failedPage) ? (
+ destinationFailure
+ ) : !settings && route.waiting ? (
+ opening
+ ) : startup === "loading" || restoring ? (
+ settings ? null : (
+ opening
+ )
+ ) : workspace.page ? (
+
+ ) : null}
+
+
@@ -59,6 +60,7 @@ export function useChannelPanels(
fallbackFocus?: RefObject | undefined;
}>,
) {
+ const owners = usePluginOwners();
const available = useSyncExternalStore(
panels.subscribe,
panels.snapshot,
@@ -205,6 +207,7 @@ export function useChannelPanels(
>
(null);
useEffect(() => {
@@ -174,6 +176,7 @@ export function ConversationHost({
>
{
if (shown.current === overlay) setOverlay(undefined);
diff --git a/src/features/pages/ContributionFailure.tsx b/src/features/pages/ContributionFailure.tsx
new file mode 100644
index 0000000000..5dedd4dac7
--- /dev/null
+++ b/src/features/pages/ContributionFailure.tsx
@@ -0,0 +1,61 @@
+import { createContext, useContext } from "react";
+import { Button } from "../../shared/design-system/ui/Button";
+import { ErrorDetails } from "../../shared/ErrorDetails";
+
+/** The external plugin behind a failed page or panel, so the failure can name
+ * it and offer its management UI. Built-in contributions pass nothing. */
+export type FailedPlugin = Readonly<{
+ name: string;
+ /** Open this plugin in Settings → Plugins. */
+ manage(): void;
+}>;
+
+/** The external plugin with this ID, or undefined for a built-in one. */
+export type PluginOwners = (pluginId: string) => FailedPlugin | undefined;
+
+const Owners = createContext(undefined);
+/** Given by the app to the whole shell (pages, launcher companions), so a
+ * panel's caller can name the panel's plugin when it fails. */
+export const PluginOwnersProvider = Owners.Provider;
+/** Resolves contributions' plugins; undefined without a provider, where every
+ * failure reads as built-in. */
+export const usePluginOwners = () => useContext(Owners);
+
+/** What a page or panel shows in place of itself after it threw while
+ * rendering. The raw exception text stays behind a collapsed "Details". */
+export function ContributionFailure({
+ kind,
+ plugin,
+ details,
+ onRetry,
+}: {
+ kind: "page" | "panel";
+ plugin?: FailedPlugin | undefined;
+ details?: string | undefined;
+ onRetry(): void;
+}) {
+ const Title = kind === "page" ? "h2" : "h3";
+ return (
+
+
This {kind} couldn’t open
+
+ {plugin
+ ? `${plugin.name} had a problem showing it.`
+ : `Something went wrong showing this ${kind}.`}
+
+ {!plugin &&
If it keeps happening, restart Buzz.
}
+ {/* Secondary first, primary last, as in dialog footers and forms. */}
+
+ {plugin && (
+
+ Manage plugin
+
+ )}
+
+ Try again
+
+
+
+
+ );
+}
diff --git a/src/features/pages/PageView.test.tsx b/src/features/pages/PageView.test.tsx
new file mode 100644
index 0000000000..533e87af59
--- /dev/null
+++ b/src/features/pages/PageView.test.tsx
@@ -0,0 +1,138 @@
+// @vitest-environment jsdom
+import "@testing-library/jest-dom/vitest";
+import { cleanup, fireEvent, render, screen } from "@testing-library/react";
+import { afterEach, beforeEach, expect, it, vi } from "vitest";
+import { PanelCard } from "../panels/PanelCard";
+import type { RegisteredPanel } from "../panels/service";
+import { PageView } from "./PageView";
+import type { RegisteredPage } from "./service";
+
+beforeEach(() => {
+ vi.spyOn(console, "error").mockImplementation(() => {});
+});
+afterEach(() => {
+ cleanup();
+ vi.restoreAllMocks();
+});
+
+function pageThatThrows(state: { broken: boolean }): RegisteredPage {
+ return {
+ key: "acme.notes/home",
+ pluginId: "acme.notes",
+ revision: "r1",
+ id: "home",
+ title: "Home",
+ component: () => {
+ if (state.broken) throw new Error("Fixture page render failure");
+ return Page content
;
+ },
+ };
+}
+
+it("keeps a built-in page's raw failure in collapsed Details and offers Try again", () => {
+ const state = { broken: true };
+ render( );
+
+ expect(screen.getByRole("alert")).toBeVisible();
+ expect(
+ screen.getByRole("heading", { name: "This page couldn’t open" }),
+ ).toBeVisible();
+ expect(
+ screen.getByText("Something went wrong showing this page."),
+ ).toBeVisible();
+ expect(
+ screen.getByText("If it keeps happening, restart Buzz."),
+ ).toBeVisible();
+ expect(screen.queryByRole("button", { name: "Manage plugin" })).toBeNull();
+ expect(screen.queryByText(/Open Settings to disable/)).toBeNull();
+ // The exception text is only behind the collapsed disclosure.
+ expect(screen.getByRole("button", { name: "Details" })).toHaveAttribute(
+ "aria-expanded",
+ "false",
+ );
+ expect(screen.queryByText(/Fixture page render failure/)).toBeNull();
+ fireEvent.click(screen.getByRole("button", { name: "Details" }));
+ expect(screen.getByText("Error: Fixture page render failure")).toBeVisible();
+
+ state.broken = false;
+ fireEvent.click(screen.getByRole("button", { name: "Try again" }));
+ expect(screen.getByText("Page content")).toBeVisible();
+ expect(screen.queryByRole("alert")).toBeNull();
+});
+
+it("stays on the failure when Try again fails again", () => {
+ render( );
+ fireEvent.click(screen.getByRole("button", { name: "Try again" }));
+ expect(screen.getByRole("alert")).toBeVisible();
+});
+
+it("names an external plugin and opens its management", () => {
+ const manage = vi.fn();
+ const owners = vi.fn((pluginId: string) =>
+ pluginId === "acme.notes" ? { name: "Acme Notes", manage } : undefined,
+ );
+ render( );
+ expect(owners).toHaveBeenCalledWith("acme.notes");
+ expect(
+ screen.getByText("Acme Notes had a problem showing it."),
+ ).toBeVisible();
+ expect(screen.queryByText(/restart Buzz/)).toBeNull();
+ expect(screen.getByRole("button", { name: "Try again" })).toBeVisible();
+ // Secondary first, primary last, as in dialog footers and forms.
+ expect(
+ screen
+ .getAllByRole("button", { name: /^(Manage plugin|Try again)$/ })
+ .map((button) => button.textContent),
+ ).toEqual(["Manage plugin", "Try again"]);
+ fireEvent.click(screen.getByRole("button", { name: "Manage plugin" }));
+ expect(manage).toHaveBeenCalledOnce();
+});
+
+it("keeps the built-in copy for a page whose plugin isn't external", () => {
+ render(
+ undefined}
+ />,
+ );
+ expect(
+ screen.getByText("Something went wrong showing this page."),
+ ).toBeVisible();
+ expect(screen.queryByRole("button", { name: "Manage plugin" })).toBeNull();
+});
+
+it("lets a panel inside the page name its own plugin", () => {
+ const manage = vi.fn();
+ const panel = {
+ key: "other.tools/side",
+ pluginId: "other.tools",
+ revision: "r1",
+ id: "side",
+ title: "Tools",
+ component: () => {
+ throw new Error("Fixture panel render failure");
+ },
+ } as unknown as RegisteredPanel;
+ const page: RegisteredPage = {
+ ...pageThatThrows({ broken: false }),
+ component: () => (
+ {}} />
+ ),
+ };
+ render(
+
+ pluginId === "other.tools" ? { name: "Other Tools", manage } : undefined
+ }
+ />,
+ );
+ expect(
+ screen.getByRole("heading", { name: "This panel couldn’t open" }),
+ ).toBeVisible();
+ expect(
+ screen.getByText("Other Tools had a problem showing it."),
+ ).toBeVisible();
+ fireEvent.click(screen.getByRole("button", { name: "Manage plugin" }));
+ expect(manage).toHaveBeenCalledOnce();
+});
diff --git a/src/features/pages/PageView.tsx b/src/features/pages/PageView.tsx
index 31c9e56e67..6fc52eb020 100644
--- a/src/features/pages/PageView.tsx
+++ b/src/features/pages/PageView.tsx
@@ -1,21 +1,32 @@
-import { Component, useEffect, type ReactNode } from "react";
+import { Component, Fragment, useEffect, type ReactNode } from "react";
+import { rawText } from "../relay/user-copy";
+import {
+ ContributionFailure,
+ type FailedPlugin,
+ type PluginOwners,
+ PluginOwnersProvider,
+} from "./ContributionFailure";
import type { PageProps, RegisteredPage } from "./service";
type BoundaryProps = {
children: ReactNode;
page: RegisteredPage;
navigation?: PageProps["navigation"];
+ plugin?: FailedPlugin | undefined;
};
class PageBoundary extends Component<
BoundaryProps,
{
- error: string | null;
+ failure: { details: string | undefined } | null;
+ /** Bumped by Try again so the page remounts with fresh state. */
+ attempt: number;
page: RegisteredPage;
navigation: PageProps["navigation"];
}
> {
state = {
- error: null as string | null,
+ failure: null as { details: string | undefined } | null,
+ attempt: 0,
page: this.props.page,
navigation: this.props.navigation,
};
@@ -26,11 +37,11 @@ class PageBoundary extends Component<
// A new attempt or registration can retry a failed subtree, even within one visit.
// Do not remount healthy content on a reclick: its local drafts/focus still belong to it.
if (props.page !== state.page || props.navigation !== state.navigation)
- return { error: null, page: props.page, navigation: props.navigation };
+ return { failure: null, page: props.page, navigation: props.navigation };
return null;
}
static getDerivedStateFromError(error: unknown) {
- return { error: String(error) };
+ return { failure: { details: rawText(error) } };
}
componentDidCatch() {
this.props.navigation?.complete({
@@ -39,41 +50,52 @@ class PageBoundary extends Component<
});
}
render() {
- return this.state.error ? (
-
+ const { failure, attempt } = this.state;
+ return failure ? (
+
+ this.setState((state) => ({
+ failure: null,
+ attempt: state.attempt + 1,
+ }))
+ }
+ />
) : (
- this.props.children
+ {this.props.children}
);
}
}
-function Failure({ message }: { message: string }) {
- return (
-
-
This page couldn’t open
-
{message}
-
Open Settings to disable, delete, or roll back this plugin.
-
- );
-}
export function PageView({
page,
companion,
companionOpening,
navigation,
-}: { page: RegisteredPage } & PageProps) {
+ owners,
+}: {
+ page: RegisteredPage;
+ /** Names external plugins, so a failed page or panel can name its own and
+ * offer Manage plugin. Without it every failure reads as built-in. */
+ owners?: PluginOwners | undefined;
+} & PageProps) {
return (
-
-
+
-
+ plugin={owners?.(page.pluginId)}
+ key={`${page.key}:${page.revision}`}
+ >
+
+
+
);
}
function PresentedPage({
diff --git a/src/features/pages/plugin-owners.test.ts b/src/features/pages/plugin-owners.test.ts
new file mode 100644
index 0000000000..2e1348ef03
--- /dev/null
+++ b/src/features/pages/plugin-owners.test.ts
@@ -0,0 +1,51 @@
+import { expect, it, vi } from "vitest";
+import type { ConfigurationState, PluginInfo } from "../../plugins/types";
+import { pluginOwners } from "./plugin-owners";
+
+const plugin = (
+ id: string,
+ name: string,
+ source: PluginInfo["source"],
+): PluginInfo => ({
+ manifest: { id, name, apiVersion: 1 },
+ source,
+ enabled: true,
+ revision: "r1",
+ previous: null,
+ reloadable: false,
+ error: null,
+});
+const ready: ConfigurationState = {
+ status: "ready",
+ externalPluginsPaused: false,
+ catalog: {
+ profile: "fixture",
+ location: "Fixture",
+ plugins: [
+ plugin("acme.notes", "Acme Notes", "external"),
+ plugin("buzz.channels", "Channels", "bundled"),
+ ],
+ },
+};
+
+it("names an external plugin and opens its management in Settings", () => {
+ const manage = vi.fn();
+ const owner = pluginOwners(ready, manage)("acme.notes");
+ expect(owner?.name).toBe("Acme Notes");
+ owner?.manage();
+ expect(manage).toHaveBeenCalledExactlyOnceWith("acme.notes");
+});
+
+it("leaves built-in and unknown plugins unnamed", () => {
+ const owners = pluginOwners(ready, vi.fn());
+ expect(owners("buzz.channels")).toBeUndefined();
+ expect(owners("missing.plugin")).toBeUndefined();
+});
+
+it("names nothing until the catalog is ready", () => {
+ for (const configuration of [
+ { status: "loading" },
+ { status: "recovery", reason: "Unreadable", canReset: true },
+ ] satisfies ConfigurationState[])
+ expect(pluginOwners(configuration, vi.fn())("acme.notes")).toBeUndefined();
+});
diff --git a/src/features/pages/plugin-owners.ts b/src/features/pages/plugin-owners.ts
new file mode 100644
index 0000000000..98d7680c59
--- /dev/null
+++ b/src/features/pages/plugin-owners.ts
@@ -0,0 +1,22 @@
+import type { ConfigurationState } from "../../plugins/types";
+import type { PluginOwners } from "./ContributionFailure";
+
+/** Names the external plugins in the saved catalog, so a failed page or panel
+ * can name its own plugin and open its management UI. Built-in plugins, unknown
+ * IDs, and a catalog that hasn't loaded all resolve to undefined. */
+export function pluginOwners(
+ configuration: ConfigurationState,
+ manage: (pluginId: string) => void,
+): PluginOwners {
+ return (pluginId) => {
+ const info =
+ configuration.status === "ready"
+ ? configuration.catalog.plugins.find(
+ (plugin) => plugin.manifest.id === pluginId,
+ )
+ : undefined;
+ return info?.source === "external"
+ ? { name: info.manifest.name, manage: () => manage(pluginId) }
+ : undefined;
+ };
+}
diff --git a/src/features/panels/PanelCard.tsx b/src/features/panels/PanelCard.tsx
index 7b5202d6d3..7b74ec6281 100644
--- a/src/features/panels/PanelCard.tsx
+++ b/src/features/panels/PanelCard.tsx
@@ -10,6 +10,7 @@ import { XIcon } from "../../shared/design-system/icons/index";
import type { PanelProps, RegisteredPanel } from "./service";
import { PanelSubviewHost } from "./PanelSubview";
import { PanelView } from "./PanelView";
+import { usePluginOwners } from "../pages/ContributionFailure";
import styles from "./Panels.module.css";
// Ordinary shared UI: the caller still owns placement and selection.
@@ -24,6 +25,7 @@ export function PanelCard({
contentKey?: string;
}) {
const tabbed = !!usePanelTabHost();
+ const owners = usePluginOwners();
return (
)}
diff --git a/src/features/panels/PanelView.test.tsx b/src/features/panels/PanelView.test.tsx
new file mode 100644
index 0000000000..e039320b75
--- /dev/null
+++ b/src/features/panels/PanelView.test.tsx
@@ -0,0 +1,78 @@
+// @vitest-environment jsdom
+import "@testing-library/jest-dom/vitest";
+import { cleanup, fireEvent, render, screen } from "@testing-library/react";
+import { afterEach, beforeEach, expect, it, vi } from "vitest";
+import { PanelView } from "./PanelView";
+import type { RegisteredPanel } from "./service";
+
+beforeEach(() => {
+ vi.spyOn(console, "error").mockImplementation(() => {});
+});
+afterEach(() => {
+ cleanup();
+ vi.restoreAllMocks();
+});
+
+function panelThatThrows(state: { broken: boolean }) {
+ return {
+ key: "acme.notes/side",
+ pluginId: "acme.notes",
+ revision: "r1",
+ id: "side",
+ component: () => {
+ if (state.broken) throw new Error("Fixture panel render failure");
+ return Panel content
;
+ },
+ } as unknown as RegisteredPanel;
+}
+
+const props = { target: "channel:alpha", close: () => {} };
+
+it("shows a built-in panel failure, keeps raw text collapsed, and recovers on Try again", () => {
+ const state = { broken: true };
+ render( );
+
+ expect(
+ screen.getByRole("heading", { name: "This panel couldn’t open" }),
+ ).toBeVisible();
+ expect(
+ screen.getByText("Something went wrong showing this panel."),
+ ).toBeVisible();
+ expect(
+ screen.getByText("If it keeps happening, restart Buzz."),
+ ).toBeVisible();
+ expect(screen.queryByRole("button", { name: "Manage plugin" })).toBeNull();
+ expect(screen.getByRole("button", { name: "Details" })).toHaveAttribute(
+ "aria-expanded",
+ "false",
+ );
+ expect(screen.queryByText(/Fixture panel render failure/)).toBeNull();
+ fireEvent.click(screen.getByRole("button", { name: "Details" }));
+ expect(screen.getByText("Error: Fixture panel render failure")).toBeVisible();
+
+ state.broken = false;
+ fireEvent.click(screen.getByRole("button", { name: "Try again" }));
+ expect(screen.getByText("Panel content")).toBeVisible();
+});
+
+it("names an external plugin and opens its management", () => {
+ const manage = vi.fn();
+ render(
+ ,
+ );
+ expect(
+ screen.getByText("Acme Notes had a problem showing it."),
+ ).toBeVisible();
+ // Secondary first, primary last, as in dialog footers and forms.
+ expect(
+ screen
+ .getAllByRole("button", { name: /^(Manage plugin|Try again)$/ })
+ .map((button) => button.textContent),
+ ).toEqual(["Manage plugin", "Try again"]);
+ fireEvent.click(screen.getByRole("button", { name: "Manage plugin" }));
+ expect(manage).toHaveBeenCalledOnce();
+});
diff --git a/src/features/panels/PanelView.tsx b/src/features/panels/PanelView.tsx
index f14bca0cdc..dcca39015a 100644
--- a/src/features/panels/PanelView.tsx
+++ b/src/features/panels/PanelView.tsx
@@ -1,19 +1,28 @@
-import { Component, type ReactNode } from "react";
+import { Component, Fragment, type ReactNode } from "react";
+import { rawText } from "../relay/user-copy";
+import {
+ ContributionFailure,
+ type FailedPlugin,
+} from "../pages/ContributionFailure";
import type { PanelProps, RegisteredPanel } from "./service";
// Pages own placement and closing; this boundary contains a plugin's render errors.
export function PanelView({
panel,
contentKey,
+ plugin,
...props
}: PanelProps & {
panel: RegisteredPanel;
+ /** The external plugin that owns this panel, so a failure can name it. */
+ plugin?: FailedPlugin | undefined;
/** Host-owned identity when several addresses inspect the same content. */
contentKey?: string | undefined;
}) {
const Content = panel.component;
return (
@@ -22,21 +31,32 @@ export function PanelView({
}
class PanelBoundary extends Component<
- { children: ReactNode },
- { error: string | null }
+ { children: ReactNode; plugin?: FailedPlugin | undefined },
+ { failure: { details: string | undefined } | null; attempt: number }
> {
- state: { error: string | null } = { error: null };
+ state: {
+ failure: { details: string | undefined } | null;
+ attempt: number;
+ } = { failure: null, attempt: 0 };
static getDerivedStateFromError(error: unknown) {
- return { error: String(error) };
+ return { failure: { details: rawText(error) } };
}
render() {
- return this.state.error ? (
-
-
This panel couldn’t open
-
{this.state.error}
-
+ const { failure, attempt } = this.state;
+ return failure ? (
+
+ this.setState((state) => ({
+ failure: null,
+ attempt: state.attempt + 1,
+ }))
+ }
+ />
) : (
- this.props.children
+ {this.props.children}
);
}
}
diff --git a/tests/browser/navigation-lifecycle.spec.mjs b/tests/browser/navigation-lifecycle.spec.mjs
index 6f0506259b..a532ecf492 100644
--- a/tests/browser/navigation-lifecycle.spec.mjs
+++ b/tests/browser/navigation-lifecycle.spec.mjs
@@ -50,6 +50,21 @@ test.describe("page render failure", () => {
exact: true,
}),
).toBeVisible();
+ await expect(
+ page.getByText("Something went wrong showing this page.", {
+ exact: true,
+ }),
+ ).toBeVisible();
+ await expect(
+ page.getByText("If it keeps happening, restart Buzz.", { exact: true }),
+ ).toBeVisible();
+ // The exception text stays behind a collapsed Details.
+ await expect(button(page, "Details")).toHaveAttribute(
+ "aria-expanded",
+ "false",
+ );
+ await expect(page.getByText("Fixture page render failure")).toHaveCount(0);
+ await expect(page.getByText("Open Settings to disable")).toHaveCount(0);
await openPage(page, "Retry fixture");
await expect(button(page, "Break fixture page")).toBeVisible({
timeout: 1500,
@@ -64,6 +79,35 @@ test.describe("page render failure", () => {
),
).toBe(true);
});
+ test("Try again remounts a page that crashed after opening", async ({
+ page,
+ app,
+ }) => {
+ await page.goto(app.origin);
+ await openPage(page, "Retry fixture");
+ await button(page, "Break fixture page").click();
+ await expect(
+ page.getByRole("heading", {
+ name: "This page couldn’t open",
+ exact: true,
+ }),
+ ).toBeVisible();
+ await button(page, "Details").click();
+ await expect(
+ page.getByText("Error: Fixture page render failure", { exact: true }),
+ ).toBeVisible();
+ // The page's state is fresh after the remount, so it no longer throws.
+ await button(page, "Try again").click();
+ await expect(button(page, "Break fixture page")).toBeVisible();
+ await expect(
+ page.getByRole("heading", { name: "This page couldn’t open" }),
+ ).toHaveCount(0);
+ expect(
+ app.report.consoleErrors.some((message) =>
+ message.includes("Fixture page render failure"),
+ ),
+ ).toBe(true);
+ });
test("an initial render failure never acknowledges opened", async ({
page,
app,