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") => ( - {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" ? ( - - ) : 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" ? ( - - ) : 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}

- +

{failure.text}

+ {failure.retry && ( + + )} + {failure.update && ( + + )}
); + } 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={ - - } - /> -
+ + 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 && ( - - )} + {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 && ( -
- -
- )} + {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}

- - + {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}

- - - )} -
+ {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) && ( + )} + {failure?.update && ( + )}
); + } 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}

- + {failure.retry && ( + + )} + {failure.update && ( + + )}
) : ( !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 && ( + + )} {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 ? ( 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 && ( + + ); 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 && ( )} + {!unavailable && updateButton}

)}
@@ -568,9 +601,10 @@ function ReviewShell({ {error ?? (loading ? "Loading media…" : "Media unavailable")} {retry && unavailable && ( )} + {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 && ( + + )} + {failure.update && ( + + )} + + ); +} + /** 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 && (
  1. -

    {snapshot.error}

    - +
  2. )} {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 ? ( + + ) : undefined; + return ( + + ); +} + +/** 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 && ( + + ) + ) : ( + + ) + } + /> +
    + ); +} 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());