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/ProfileSettings.avatar.test.tsx b/src/app/ProfileSettings.avatar.test.tsx
index f9867920a5..544c3843e1 100644
--- a/src/app/ProfileSettings.avatar.test.tsx
+++ b/src/app/ProfileSettings.avatar.test.tsx
@@ -14,6 +14,8 @@ import type {
Communities,
} from "../features/communities/service";
import * as api from "../features/communities/api";
+import { ReadError } from "../features/relay/errors";
+import { PublishNotSent, PublishRejected } from "../features/relay/outbox";
import { ProfileSettings } from "./ProfileSettings";
import { ToastProvider } from "../shared/design-system/ui/Toast";
import { useSyncExternalStore } from "react";
@@ -251,7 +253,7 @@ it("failed publication retains edits for explicit retry; Cancel restores the loa
new Error("Publication unconfirmed"),
);
save();
- await screen.findByText("Publication unconfirmed");
+ await screen.findByText("Couldn’t save your profile. Try again.");
expect(screen.getByLabelText("Display name")).toHaveValue("Changed");
save();
await screen.findByText("Profile updated");
@@ -263,6 +265,80 @@ it("failed publication retains edits for explicit retry; Cancel restores the loa
expect(api.publishProfile).toHaveBeenCalledTimes(2);
});
+const SAVE_FAILED = "Couldn’t save your profile. Try again.";
+const SAVE_REFUSED = "This community didn’t accept your profile change.";
+const SAVE_UNCONFIRMED =
+ "Buzz couldn’t confirm your profile was saved. Save again to check.";
+
+it.each([
+ ["not sent", () => new PublishNotSent("offline"), SAVE_FAILED],
+ [
+ "a server error",
+ () => new ReadError("unavailable", "internal error", 500),
+ SAVE_FAILED,
+ ],
+ ["a network failure", () => new TypeError("Failed to fetch"), SAVE_FAILED],
+ [
+ "a reply that does not confirm the change",
+ () => new ReadError("invalid-response", "not confirmed"),
+ SAVE_UNCONFIRMED,
+ ],
+ [
+ "a refusal Buzz has no wording for",
+ () => new PublishRejected("invalid: nope", "invalid: nope"),
+ SAVE_REFUSED,
+ ],
+ [
+ "a refusal with a known reason",
+ () => new PublishRejected("no", "blocked: you are not allowed to do that"),
+ `${SAVE_REFUSED} You don’t have permission to do this.`,
+ ],
+])("words a save failed by %s without raw text", async (_name, make, text) => {
+ setup();
+ await editName();
+ vi.mocked(api.publishProfile).mockRejectedValueOnce(make());
+ save();
+ expect(await screen.findByRole("alert")).toHaveTextContent(text);
+ const shown = screen.getByRole("alert").textContent ?? "";
+ expect(shown).not.toMatch(
+ /Failed to fetch|internal error|offline|nope|ReadError/,
+ );
+ // Edits stay, and Save stays enabled to try again, for every variant.
+ expect(screen.getByLabelText("Display name")).toHaveValue("Changed");
+ expect(screen.getByRole("button", { name: "Save" })).toBeEnabled();
+ // Refusals do not promise that trying again helps.
+ expect(/Try again/.test(shown)).toBe(text.startsWith("Couldn’t save"));
+});
+
+it("names a timeout the community refused the profile change with", async () => {
+ setup();
+ await editName();
+ vi.mocked(api.publishProfile).mockRejectedValueOnce(
+ new PublishRejected(
+ "restricted: you are timed out until 1794000000",
+ "restricted: you are timed out until 1794000000",
+ ),
+ );
+ save();
+ const alert = await screen.findByRole("alert");
+ expect(alert).toHaveTextContent(
+ "This community didn’t accept your profile change. You’re timed out in this community.",
+ );
+ expect(alert).not.toHaveTextContent(/1794000000|restricted/);
+});
+
+it("clears the failure message when the form is edited", async () => {
+ setup();
+ await editName();
+ vi.mocked(api.publishProfile).mockRejectedValueOnce(new Error("boom"));
+ save();
+ await screen.findByText(SAVE_FAILED);
+ fireEvent.change(screen.getByLabelText("Display name"), {
+ target: { value: "Changed again" },
+ });
+ expect(screen.queryByText(SAVE_FAILED)).not.toBeInTheDocument();
+});
+
it("late initial reads cannot replace another community's profile", async () => {
const fixture = setup();
await screen.findByDisplayValue("Alpha human");
@@ -325,7 +401,7 @@ it("accepted but superseded publication retains the avatar draft for explicit re
vi.mocked(api.publishProfile).mockResolvedValueOnce(undefined);
save();
await screen.findByText(
- "Your profile change is not current. Your edits are retained; save again to retry.",
+ "Buzz couldn’t confirm your profile was saved. Save again to check.",
);
expect(screen.queryByText("Profile updated")).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: "Save" })).toBeEnabled();
@@ -349,7 +425,9 @@ it("failed confirmation keeps edits and does not automatically republish", async
.mockResolvedValueOnce(original("Alpha human"))
.mockRejectedValueOnce(new Error("Confirmation unavailable"));
save();
- await screen.findByText("Confirmation unavailable");
+ await screen.findByText(
+ "Buzz couldn’t confirm your profile was saved. Save again to check.",
+ );
expect(screen.getByLabelText("Display name")).toHaveValue("Changed");
expect(api.publishProfile).toHaveBeenCalledTimes(1);
expect(screen.queryByText("Profile updated")).not.toBeInTheDocument();
diff --git a/src/app/ProfileSettings.test.tsx b/src/app/ProfileSettings.test.tsx
index 531923fe6d..b980587807 100644
--- a/src/app/ProfileSettings.test.tsx
+++ b/src/app/ProfileSettings.test.tsx
@@ -439,6 +439,9 @@ it("keeps a community draft when publication fails and retries a failed read", a
await user.type(name, "Keep this draft");
await user.click(screen.getByRole("button", { name: "Save" }));
expect(await screen.findByRole("alert")).toHaveTextContent(
+ "Couldn’t save your profile. Try again.",
+ );
+ expect(screen.getByRole("alert")).not.toHaveTextContent(
"publish unavailable",
);
expect(name).toHaveValue("Keep this draft");
@@ -648,7 +651,9 @@ it("retains edits on failure and hides actions after a successful retry", async
throw new Error("Could not save");
});
fireEvent.click(screen.getByRole("button", { name: "Save" }));
- expect(screen.getByRole("alert")).toHaveTextContent("Could not save");
+ expect(screen.getByRole("alert")).toHaveTextContent(
+ "Couldn’t save your profile. Try again.",
+ );
expect(name).toHaveValue("Updated");
fireEvent.click(screen.getByRole("button", { name: "Save" }));
expect(
@@ -693,7 +698,9 @@ it("keeps the focused Save on failure and hands focus off after retry", async ()
throw new Error("Could not save");
});
await user.keyboard("{Enter}");
- expect(screen.getByRole("alert")).toHaveTextContent("Could not save");
+ expect(screen.getByRole("alert")).toHaveTextContent(
+ "Couldn’t save your profile. Try again.",
+ );
expect(save).toHaveFocus();
await user.keyboard("{Enter}");
expect(name).toHaveFocus();
diff --git a/src/app/ProfileSettings.tsx b/src/app/ProfileSettings.tsx
index 0424937214..229fcd7577 100644
--- a/src/app/ProfileSettings.tsx
+++ b/src/app/ProfileSettings.tsx
@@ -31,6 +31,33 @@ import {
import * as communityApi from "../features/communities/api";
import type { Membership } from "../features/communities/service";
import { communityName } from "../features/communities/names";
+import { ReadError } from "../features/relay/errors";
+import { failureKind, refusalReason } from "../features/relay/user-copy";
+
+const SAVE_FAILED = "Couldn’t save your profile. Try again.";
+const SAVE_REFUSED = "This community didn’t accept your profile change.";
+const SAVE_UNCONFIRMED =
+ "Buzz couldn’t confirm your profile was saved. Save again to check.";
+
+/** Where a failed save stopped: before anything was sent, while the community
+ * was answering, or after it accepted the change and Buzz read it back. */
+type SavePhase = "prepare" | "publish" | "confirm" | "local";
+
+function saveFailureText(error: unknown, phase: SavePhase) {
+ if (phase === "confirm") return SAVE_UNCONFIRMED;
+ if (failureKind(error) === "refused") {
+ const reason = refusalReason(error);
+ return reason ? `${SAVE_REFUSED} ${reason}` : SAVE_REFUSED;
+ }
+ // The community answered, but not with a receipt for this change.
+ if (
+ phase === "publish" &&
+ error instanceof ReadError &&
+ error.kind === "invalid-response"
+ )
+ return SAVE_UNCONFIRMED;
+ return SAVE_FAILED;
+}
type LoadedProfile = Awaited>;
@@ -370,28 +397,30 @@ export function ProfileSettings({
setSaved(false);
setError("");
void (async () => {
+ let phase: SavePhase = "prepare";
try {
if (community && loaded) {
// Preserve fields outside this editor even if another client edited them.
const latest = await inspect(community.id);
if (!mounted.current) return;
setDispatched(true);
+ phase = "publish";
await communityApi.publishProfile(
community.id,
next,
latest.existing,
);
// Once dispatched, finish on this session even if Settings closes.
+ phase = "confirm";
const confirmed = await inspect(community.id);
if (
!confirmed.exists ||
!profilesEqual(confirmed.profile, next)
)
- throw new Error(
- "Your profile change is not current. Your edits are retained; save again to retry.",
- );
+ throw new Error("Profile readback is not current");
setLoaded(confirmed);
}
+ phase = "local";
if (isCurrentProfileSave(communities, saveGeneration))
communities.saveProfile(
profileDefault(
@@ -404,11 +433,7 @@ export function ProfileSettings({
setSaved(true);
} catch (reason) {
if (mounted.current)
- setError(
- reason instanceof Error
- ? reason.message
- : String(reason),
- );
+ setError(saveFailureText(reason, phase));
} finally {
if (mounted.current) {
setSaving(false);
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/communities/api.test.ts b/src/features/communities/api.test.ts
index 882d06909b..be7ce27c51 100644
--- a/src/features/communities/api.test.ts
+++ b/src/features/communities/api.test.ts
@@ -1,5 +1,9 @@
import { afterEach, expect, it, vi } from "vitest";
-import { communityRequest } from "./api";
+import { communityRequest, publishProfile } from "./api";
+import { CommunityRefused } from "./native-api";
+import { ReadError } from "../relay/errors";
+import { PublishRejected } from "../relay/outbox";
+import { failureKind, refusalReason } from "../relay/user-copy";
afterEach(() => vi.unstubAllGlobals());
@@ -102,3 +106,134 @@ it.each([408, 429, 500, 502, 503])(
});
},
);
+
+function answer(status: number, body: BodyInit | null, type?: string) {
+ vi.stubGlobal(
+ "fetch",
+ vi.fn(async (url: string) =>
+ url.endsWith("/register")
+ ? Response.json({})
+ : new Response(body, {
+ status,
+ ...(type ? { headers: { "Content-Type": type } } : {}),
+ }),
+ ),
+ );
+}
+
+it.each([
+ [502, "Bad gateway", "text/html"],
+ [500, "", undefined],
+ [503, "upstream connect error", "text/plain"],
+])(
+ "checks the status before parsing: a non-JSON %s answer is a typed failure, not a parser error",
+ async (status, body, type) => {
+ answer(status, body, type);
+ const error = await communityRequest(
+ "https://unvisited.example",
+ "profile",
+ {},
+ ).catch((reason: unknown) => reason);
+ expect(error).toBeInstanceOf(ReadError);
+ expect(error).toMatchObject({
+ kind: "unavailable",
+ status,
+ message: `Community request failed (${status})`,
+ });
+ expect(error).not.toBeInstanceOf(SyntaxError);
+ },
+);
+
+it("carries a refusal's status and the community's own reason without a parser error", async () => {
+ answer(
+ 403,
+ JSON.stringify({ error: "restricted: you are timed out until 1794000000" }),
+ "application/json",
+ );
+ const error = await communityRequest(
+ "https://unvisited.example",
+ "profile",
+ {},
+ ).catch((reason: unknown) => reason);
+ expect(error).toBeInstanceOf(CommunityRefused);
+ expect(error).toMatchObject({
+ status: 403,
+ reason: "restricted: you are timed out until 1794000000",
+ });
+ expect(failureKind(error)).toBe("refused");
+ expect(refusalReason(error)).toBe("You’re timed out in this community.");
+});
+
+it("keeps a 403 without a usable body a refusal with no reason", async () => {
+ answer(403, "Forbidden", "text/html");
+ const error = await communityRequest(
+ "https://unvisited.example",
+ "profile",
+ {},
+ ).catch((reason: unknown) => reason);
+ expect(error).toMatchObject({
+ status: 403,
+ message: "Community request failed (403)",
+ });
+ expect(error).not.toHaveProperty("reason", expect.any(String));
+ expect(failureKind(error)).toBe("refused");
+ expect(refusalReason(error)).toBeUndefined();
+});
+
+it("reports a successful answer that is not JSON as an invalid response", async () => {
+ answer(200, "ok", "text/html");
+ const error = await communityRequest(
+ "https://unvisited.example",
+ "profile",
+ {},
+ ).catch((reason: unknown) => reason);
+ expect(error).toBeInstanceOf(ReadError);
+ expect(error).toMatchObject({
+ kind: "invalid-response",
+ message: "Invalid community response",
+ });
+});
+
+it("types a relay OK false as refused and any other missing receipt as unconfirmed", async () => {
+ const profile = { name: "Ada", picture: "" };
+ const reply = (receipt: unknown) =>
+ vi.stubGlobal(
+ "fetch",
+ vi.fn(async (url: string) =>
+ Response.json(url.endsWith("/register") ? {} : receipt),
+ ),
+ );
+ reply({
+ accepted: false,
+ event_id: "",
+ message: "restricted: you are timed out until 1794000000",
+ });
+ const refused = await publishProfile(
+ "https://unvisited.example",
+ profile,
+ {},
+ ).catch((reason: unknown) => reason);
+ expect(refused).toBeInstanceOf(PublishRejected);
+ expect(failureKind(refused)).toBe("refused");
+ expect(refusalReason(refused)).toBe("You’re timed out in this community.");
+ for (const receipt of [
+ { accepted: true },
+ { accepted: false, event_id: "" },
+ ]) {
+ reply(receipt);
+ const unconfirmed = await publishProfile(
+ "https://unvisited.example",
+ profile,
+ {},
+ ).catch((reason: unknown) => reason);
+ expect(unconfirmed).toMatchObject({
+ name: "ReadError",
+ kind: "invalid-response",
+ message: "Profile publication was not confirmed",
+ });
+ }
+ reply({ accepted: true, event_id: "e".repeat(64) });
+ await expect(
+ publishProfile("https://unvisited.example", profile, {}),
+ ).resolves.toBeUndefined();
+});
diff --git a/src/features/communities/api.ts b/src/features/communities/api.ts
index c67cb0f5ef..b7f8cc343d 100644
--- a/src/features/communities/api.ts
+++ b/src/features/communities/api.ts
@@ -1,7 +1,12 @@
import { nativeIdentityEnabled } from "../identity/service";
import { retryRead } from "../relay/read-retry";
import { ReadError, transientStatus } from "../relay/errors";
-import { nativeCommunityRequest } from "./native-api";
+import {
+ bodyError,
+ CommunityRefused,
+ nativeCommunityRequest,
+} from "./native-api";
+import { PublishRejected } from "../relay/outbox";
import { connectCommunityTransport, registerCommunity } from "./connection";
import { settledRefusal, type SettledRefusal } from "./leave-protocol";
import { registerBrokerCommunity } from "../relay/transport";
@@ -43,16 +48,25 @@ export async function communityRequest(
signal,
},
);
- const result = await response.json();
+ // Check `ok` before parsing: an error page that is not JSON must not become
+ // a parser error. Only the body's `error` string is read, and only as the
+ // community's own words in `reason`; the UI maps typed errors, not this text.
if (!response.ok) {
- const message =
- result.error ?? `Community request failed (${response.status})`;
+ const reason = bodyError(await response.json().catch(() => undefined));
+ const message = reason ?? `Community request failed (${response.status})`;
// Preserve retryable HTTP failures across the broker boundary without
// reclassifying validation or admission refusals as network outages.
if (transientStatus(response.status))
throw new ReadError("unavailable", message, response.status);
- throw new Error(message);
+ throw new CommunityRefused(message, response.status, reason);
}
+ const result: unknown = await response.json().catch(() => {
+ throw new ReadError(
+ "invalid-response",
+ "Invalid community response",
+ response.status,
+ );
+ });
return result as T;
}
export async function inspectProfile(id: string, session?: RelaySession) {
@@ -115,8 +129,13 @@ export async function publishProfile(
event_id: string;
message?: string;
}>(id, "profile", { ...profile, existing });
- if (!receipt.accepted || !receipt.event_id)
- throw new Error(receipt.message ?? "Profile publication was not confirmed");
+ if (!receipt.accepted || !receipt.event_id) {
+ const message = receipt.message ?? "Profile publication was not confirmed";
+ // The relay answered `OK false`: refused. Anything else may have published.
+ if (receipt.accepted === false && receipt.message)
+ throw new PublishRejected(message, receipt.message);
+ throw new ReadError("invalid-response", message);
+ }
}
export type LeaveOutcome = "left" | SettledRefusal;
/** Publishes a NIP-43 leave request to the community's relay by origin, without
diff --git a/src/features/communities/native-api.test.ts b/src/features/communities/native-api.test.ts
index e9fe0fe5ec..c53e04e42a 100644
--- a/src/features/communities/native-api.test.ts
+++ b/src/features/communities/native-api.test.ts
@@ -11,6 +11,8 @@ import {
import { keypair, nativeSignatureChecks, signed } from "../relay/testing";
import { fetchCommunityPresentation } from "./community-icon";
import { NOT_A_COMMUNITY_ERROR } from "../relay/native";
+import { PublishRejected } from "../relay/outbox";
+import { failureKind, refusalReason } from "../relay/user-copy";
vi.mock("@tauri-apps/api/core", () => ({
invoke: vi.fn(),
@@ -170,7 +172,8 @@ it.each([
pubkey: "2".repeat(64),
}).catch((reason: Error) => reason);
// Only allowlisted relay refusals reach the UI; no upstream body or parser text.
- expect(error).toEqual(new Error(shown));
+ expect(error).toBeInstanceOf(Error);
+ expect((error as Error).message).toBe(shown);
expect(requests).toMatchObject([{ path: "/events", body: { kind: 9031 } }]);
},
);
@@ -292,6 +295,47 @@ it("restores a signed community profile and preserves extra fields when publishi
).rejects.toThrow("not confirmed");
});
+it("types a native profile failure as refused, not sent or not confirmed", async () => {
+ const profile = { name: "Ada", picture: "", about: "" };
+ const publish = () =>
+ publishProfile(community, profile, {}).catch((reason: unknown) => reason);
+ respond = () => ({
+ status: 403,
+ body: { error: "restricted: you are timed out until 1794000000" },
+ });
+ const refused = await publish();
+ expect(refused).toMatchObject({
+ status: 403,
+ message: "Community request failed (403)",
+ reason: "restricted: you are timed out until 1794000000",
+ });
+ expect(failureKind(refused)).toBe("refused");
+ expect(refusalReason(refused)).toBe("You’re timed out in this community.");
+ // `OK false` answered with the relay's text is a refusal as well.
+ respond = (_path, body) => ({
+ body: {
+ accepted: false,
+ event_id: (body as { id: string }).id,
+ message: "blocked: you are not allowed to do that",
+ },
+ });
+ const rejected = await publish();
+ expect(rejected).toBeInstanceOf(PublishRejected);
+ expect(refusalReason(rejected)).toBe("You don’t have permission to do this.");
+ // A server error is not a refusal, and its text stays out of the message.
+ respond = () => ({ status: 500, body: undefined, raw: "oops" });
+ const unavailable = await publish();
+ expect(failureKind(unavailable)).toBe("unknown");
+ expect((unavailable as Error).message).toBe("Community request failed (500)");
+ // An answer for another event may still have published this one.
+ respond = () => ({ body: { accepted: true, event_id: "unrelated" } });
+ expect(await publish()).toMatchObject({
+ name: "ReadError",
+ kind: "invalid-response",
+ message: "Profile publication was not confirmed",
+ });
+});
+
it("signs the NIP-43 leave request for the community and classifies the relay's answer", async () => {
respond = (path, body) =>
path === "/events"
diff --git a/src/features/communities/native-api.ts b/src/features/communities/native-api.ts
index 405855a184..39a94244ff 100644
--- a/src/features/communities/native-api.ts
+++ b/src/features/communities/native-api.ts
@@ -13,9 +13,35 @@ import { adminReason, memberCommand } from "./admin-protocol";
import { readRelayLibrary } from "../agents/relay-library";
import { eventDto } from "../relay/events";
import { readApiFailure } from "../relay/http-admission";
+import { PublishRejected } from "../relay/outbox";
+import { ReadError, transientStatus } from "../relay/errors";
import { admitSignedRequest } from "../relay/transport";
import type { PersonalProfile } from "./service";
+/** A community request the community answered with a non-retryable status.
+ * `PublishRejected` has the relay's `reason` but no status and `ReadError` the
+ * status but no reason, so this carries both. `message` stays a status summary;
+ * `reason` is the community's own text, for classification and diagnostics. */
+export class CommunityRefused extends PublishRejected {
+ constructor(
+ message: string,
+ readonly status: number,
+ reason?: string,
+ ) {
+ super(message, reason);
+ }
+}
+
+/** The `error` text of a failure body, bounded; `undefined` for any other body
+ * (proxy HTML, plain text, no `error`). */
+export function bodyError(body: unknown): string | undefined {
+ const error =
+ body && typeof body === "object" ? (body as { error?: unknown }).error : "";
+ return typeof error === "string" && error.trim()
+ ? error.trim().slice(0, 256)
+ : undefined;
+}
+
export async function nativeCommunityRequest(
community: string,
route: string,
@@ -200,8 +226,14 @@ export async function nativeCommunityRequest(
const result = await readResponse(
await nativeRelayRequest(community, "/events", event, signal),
);
- if (result.event_id !== event.id || result.accepted !== true)
- throw new Error("Profile publication was not confirmed");
+ if (result.event_id !== event.id || result.accepted !== true) {
+ const message = "Profile publication was not confirmed";
+ // `OK false` with the relay's text is a refusal; any other answer that
+ // does not confirm this event may still have published it.
+ if (result.accepted === false && typeof result.message === "string")
+ throw new PublishRejected(message, result.message.slice(0, 256));
+ throw new ReadError("invalid-response", message);
+ }
return result;
}
if (route === "member") {
@@ -280,10 +312,17 @@ async function readResponse(
// A body that is not JSON (proxy HTML, plain text) is never shown: it maps to
// the status-based failure or, on success, an invalid response.
const result: unknown = await response.json().catch(() => undefined);
- if (!response.ok)
- throw new Error(
- refusal(result) ?? `Community request failed (${response.status})`,
+ if (!response.ok) {
+ const known = refusal(result);
+ const message = known ?? `Community request failed (${response.status})`;
+ // Retryable statuses stay plain: callers treat them as an unknown failure.
+ if (transientStatus(response.status)) throw new Error(message);
+ throw new CommunityRefused(
+ message,
+ response.status,
+ known ?? bodyError(result),
);
+ }
if (!result || typeof result !== "object" || Array.isArray(result))
throw new Error("Invalid community response");
return result as Record;
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..c777e5b69f
--- /dev/null
+++ b/src/features/relay/user-copy.test.ts
@@ -0,0 +1,339 @@
+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("names a relay timeout without the relay's end time", () => {
+ expect(
+ refusalReason("restricted: you are timed out until 1794000000"),
+ ).toBe("You’re timed out in this community.");
+ });
+
+ 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..753d028216
--- /dev/null
+++ b/src/features/relay/user-copy.ts
@@ -0,0 +1,244 @@
+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;
+ if (/\btimed out until\b/i.test(text))
+ return "You’re timed out in this community.";
+ 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;
+}
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/gallery-theme.spec.mjs b/tests/browser/gallery-theme.spec.mjs
index a6dc67ee1d..9ef064fc8d 100644
--- a/tests/browser/gallery-theme.spec.mjs
+++ b/tests/browser/gallery-theme.spec.mjs
@@ -230,7 +230,7 @@ test("an open image gallery follows host appearance while retaining its dark sta
});
// Browser boundary: ChannelsPage's real opener must retain the collapsed batch;
-// native focus and key bubbling must survive Retry removing its own button.
+// native focus and key bubbling must survive Try again removing its own button.
test("primary gallery keeps hidden media and keyboard navigation through thread retry", async ({
page,
app,
@@ -314,7 +314,7 @@ test("primary gallery keeps hidden media and keyboard navigation through thread
await expect(dialog.getByText("5 / 6", { exact: true })).toBeVisible();
releaseFirst();
const retryButton = dialog.getByRole("button", {
- name: "Retry",
+ name: "Try again",
exact: true,
});
await expect(retryButton).toBeVisible();
diff --git a/tests/browser/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/media-controls.spec.mjs b/tests/browser/media-controls.spec.mjs
index 54e36ed01d..61104e21dc 100644
--- a/tests/browser/media-controls.spec.mjs
+++ b/tests/browser/media-controls.spec.mjs
@@ -284,13 +284,13 @@ test("unavailable media keeps its explanation readable and recovery visible", as
await page.setViewportSize({ width: 320, height: 720 });
await page.goto("/tests/fixtures/media-review.html?photo&missing-root");
const dialog = page.getByRole("dialog", { name: "Image viewer" });
- const message = dialog
- .getByRole("alert")
- .filter({ has: page.getByRole("button", { name: "Retry", exact: true }) });
+ const message = dialog.getByRole("alert").filter({
+ has: page.getByRole("button", { name: "Try again", exact: true }),
+ });
await expect(message).toContainText("Original message unavailable.");
await expect(message).toHaveCSS("color", "rgb(255, 255, 255)");
await expect(
- message.getByRole("button", { name: "Retry", exact: true }),
+ message.getByRole("button", { name: "Try again", exact: true }),
).toBeVisible();
await expect(dialog.getByRole("heading", { name: "Comments" })).toBeVisible();
});
diff --git a/tests/browser/message-overlays.spec.mjs b/tests/browser/message-overlays.spec.mjs
index e9d8c78a7b..bfa193d5c2 100644
--- a/tests/browser/message-overlays.spec.mjs
+++ b/tests/browser/message-overlays.spec.mjs
@@ -253,8 +253,8 @@ test("video speed options escape the thread and restore focus after selection an
await expect(selected).toBeFocused();
});
-// Retry is a Button under the viewer's unavailable-media branch, not its header.
-test("unavailable dark media review keeps Retry readable in a light host", async ({
+// Try again is a Button under the viewer's unavailable-media branch, not its header.
+test("unavailable dark media review keeps Try again readable in a light host", async ({
page,
}) => {
await page.goto("/tests/fixtures/media-review.html?photo&missing-root");
@@ -263,7 +263,7 @@ test("unavailable dark media review keeps Retry readable in a light host", async
document.documentElement.dataset.colorMode = "light";
});
const dialog = page.getByRole("dialog", { name: "Image viewer" });
- const retry = dialog.getByRole("button", { name: "Retry", exact: true });
+ const retry = dialog.getByRole("button", { name: "Try again", exact: true });
await expect(dialog.getByRole("alert").first()).toContainText(
"Original message unavailable.",
);
diff --git a/tests/browser/navigation-repairs.spec.mjs b/tests/browser/navigation-repairs.spec.mjs
index a4700203e1..a2ff15b2c7 100644
--- a/tests/browser/navigation-repairs.spec.mjs
+++ b/tests/browser/navigation-repairs.spec.mjs
@@ -68,19 +68,30 @@ test("keyboard Retry hands off focus while reconnecting a failed Messages tab op
page,
app,
}) => {
- let requests = 0;
+ let failing = true;
+ let retries = 0;
let release;
const held = new Promise((resolve) => {
release = resolve;
});
- await page.route("**/api/relay/primary/session", async (route) => {
- requests++;
- if (requests === 1) return route.fulfill({ json: {} });
+ // Registration fails for a reason the service leaves for the user, so the
+ // card offers Try again. (An empty session is an answer Buzz can’t use: that
+ // card offers Check for updates and asks again by itself.) Every caller
+ // registers the community, so every registration fails until the user acts.
+ await page.route("**/api/relay/register", async (route) => {
+ if (failing) {
+ app.report.startupFailures ??= [];
+ app.report.startupFailures.push(route.request().url());
+ return route.fulfill({
+ status: 502,
+ json: { error: "Registration failed" },
+ });
+ }
+ retries++;
await held;
return route.continue();
});
// Start outside Messages so its header activation owns preserve-focus intent.
- // Settings also reads the community profile through /session, racing this gate.
await page.goto(
`${app.origin}/#buzz=${encodeURIComponent(JSON.stringify({ version: 1, kind: "page", pluginId: "buzz.workflows", pageId: "workflows" }))}`,
);
@@ -97,15 +108,15 @@ test("keyboard Retry hands off focus while reconnecting a failed Messages tab op
await expect(
page.getByText("Check your connection, then try again."),
).toBeVisible();
- expect(requests).toBe(1);
const visit = await entry(page);
const main = page.getByRole("main");
// The sidebar offers its own Try again for the channel list.
const retry = button(main, "Try again");
+ failing = false;
try {
await retry.focus();
await retry.press("Enter");
- await expect.poll(() => requests, { timeout: 1500 }).toBe(2);
+ await expect.poll(() => retries, { timeout: 1500 }).toBeGreaterThan(0);
await expect(retry).toHaveCount(0);
await expect(main).toBeFocused();
} finally {
diff --git a/tests/browser/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");
diff --git a/tests/browser/thread-window.spec.mjs b/tests/browser/thread-window.spec.mjs
index 27267bb42a..741353f300 100644
--- a/tests/browser/thread-window.spec.mjs
+++ b/tests/browser/thread-window.spec.mjs
@@ -119,7 +119,7 @@ test("older-page retry repeats the failed continuation at the scrollback cue", a
await page.evaluate(() => window.messagesFixture.releaseOlderPage());
const error = history.getByRole("alert");
const retry = history.getByRole("button", { name: "Try again" });
- await expect(error).toContainText("Older page failed");
+ await expect(error).toContainText("Older replies didn’t load.");
await expect(retry).toBeInViewport();
await retry.click();
await expect
@@ -127,7 +127,7 @@ test("older-page retry repeats the failed continuation at the scrollback cue", a
page.evaluate(() => window.messagesFixture.report.filters.length),
)
.toBe(4);
- await expect(error).toContainText("Older page failed");
+ await expect(error).toContainText("Older replies didn’t load.");
await expect(retry).toBeInViewport();
await retry.click();
await expect
@@ -399,7 +399,9 @@ test("older-page retry reparents a visible reply under its late parent", async (
// The wheel may demand history at the top without moving the scroller.
await page.mouse.wheel(0, -4000);
const retry = history.getByRole("button", { name: "Try again" });
- await expect(history.getByRole("alert")).toContainText("Older page failed");
+ await expect(history.getByRole("alert")).toContainText(
+ "Older replies didn’t load.",
+ );
await child.scrollIntoViewIfNeeded();
await expect(child).toBeInViewport();
await page.evaluate(() => window.messagesFixture.holdOlderPage());