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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 17 additions & 11 deletions src/app/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -433,6 +434,9 @@ function ConnectedAppContent({ services }: { services: AppServices }) {
route.reconnecting &&
!route.state.ingress &&
(failureReason === "timeout" || failureReason === "unavailable");
// An answer this Buzz can't use comes back the same on Try again.
const unusableConnection =
connection.status === "error" && connection.error?.kind === "unusable";
const retryButton = (variant: "primary" | "quiet") => (
<Button
type="button"
Expand Down Expand Up @@ -515,18 +519,20 @@ function ConnectedAppContent({ services }: { services: AppServices }) {
icon={<CloudOffIcon />}
title="Can’t connect"
description={
retriedEntry === route.state.entry.id
? keepTrying
? "Still can’t connect. We’ll keep trying."
: "Still can’t connect. Check your connection, then try again."
: keepTrying
? "Check your connection. We’ll keep trying."
: "Check your connection, then try again."
unusableConnection
? UNUSABLE_CONNECTION
: retriedEntry === route.state.entry.id
? keepTrying
? "Still can’t connect. We’ll keep trying."
: "Still can’t connect. Check your connection, then try again."
: keepTrying
? "Check your connection. We’ll keep trying."
: "Check your connection, then try again."
}
action={
<>
{retryButton("primary")}
{messagesButton("quiet")}
{!unusableConnection && retryButton("primary")}
</>
}
/>
Expand All @@ -538,6 +544,7 @@ function ConnectedAppContent({ services }: { services: AppServices }) {
description="Turn it on in Manage plugins to open this page."
action={
<>
{messagesButton("quiet")}
<Button
type="button"
variant="primary"
Expand All @@ -547,7 +554,6 @@ function ConnectedAppContent({ services }: { services: AppServices }) {
>
Manage plugins
</Button>
{messagesButton("quiet")}
</>
}
/>
Expand All @@ -558,8 +564,8 @@ function ConnectedAppContent({ services }: { services: AppServices }) {
description="It may have been removed, or you may need to update Buzz."
action={
<>
{messagesButton("primary")}
{retryButton(leaveForMessages ? "quiet" : "primary")}
{messagesButton("primary")}
</>
}
/>
Expand All @@ -570,8 +576,8 @@ function ConnectedAppContent({ services }: { services: AppServices }) {
description="It didn’t load this time. Try again in a moment."
action={
<>
{retryButton("primary")}
{messagesButton("quiet")}
{retryButton("primary")}
</>
}
/>
Expand Down
53 changes: 45 additions & 8 deletions src/app/entity-navigation.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 {
Expand All @@ -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({});
Expand Down Expand Up @@ -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<HTMLElement>('[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 {
Expand Down Expand Up @@ -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(),
);
}
Expand All @@ -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);
Expand Down Expand Up @@ -1298,7 +1335,7 @@ async function activityTurnedOff() {
within(card)
.getAllByRole("button")
.map((button) => button.textContent),
).toEqual(["Manage plugins", "Go to Messages"]);
).toEqual(["Go to Messages", "Manage plugins"]);
return card;
}

Expand Down Expand Up @@ -1392,7 +1429,7 @@ it("turning a plugin back on over its failed page offers Try again, which opens
within(card)
.getAllByRole("button")
.map((button) => button.textContent),
).toEqual(["Try again", "Go to Messages"]);
).toEqual(["Go to Messages", "Try again"]);
expect(
screen.queryByRole("heading", { name: "Activity is turned off" }),
).not.toBeInTheDocument();
Expand Down
4 changes: 2 additions & 2 deletions src/app/services.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -446,7 +446,7 @@ it("restores the selected native community and recovers discovery failure withou
await vi.advanceTimersByTimeAsync(0);
expect(services.relay.snapshot()).toMatchObject({
status: "error",
error: expect.stringContaining("Discovery offline"),
error: { details: expect.stringContaining("Discovery offline") },
});
unavailable = false;
services.relay.retry();
Expand Down Expand Up @@ -508,7 +508,7 @@ it("seeds a packaged native community after onboarding and preserves the user's
});
expect(services.relay.snapshot()).toMatchObject({
status: "error",
error: expect.stringContaining("Discovery offline"),
error: { details: expect.stringContaining("Discovery offline") },
});
unavailable = false;
services.relay.retry();
Expand Down
2 changes: 1 addition & 1 deletion src/app/shell/PageSearch.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1034,7 +1034,7 @@ it("finds and opens saved conversations while the community is unreachable or st
);
let connection: RelaySnapshot = {
status: "error",
error: "Relay broker session is malformed",
error: { kind: "connection", details: "Relay broker session is malformed" },
cached: true,
generation: 1,
scope: "s",
Expand Down
17 changes: 13 additions & 4 deletions src/app/shell/PrimarySidebar.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1080,7 +1080,10 @@ it("moves a refusal's recovery from the sidebar to its one notice", async () =>
);
const sidebar = () => screen.getByRole("complementary", { name: "Sidebar" });
// A connection failure with native's sign-in error asks for sign-in here.
f.connection({ status: "error", error: "Federated sign-in is required" });
f.connection({
status: "error",
error: { kind: "refused", details: "Federated sign-in is required" },
});
expect(
within(sidebar()).getByRole("button", {
name: "Sign in with builderlab.example",
Expand Down Expand Up @@ -1125,7 +1128,7 @@ it("makes Try again a person's retry, which asks the issuer again", async () =>
const sidebar = () => screen.getByRole("complementary", { name: "Sidebar" });
f.connection({
status: "error",
error: "grant-refused",
error: { kind: "unknown", details: "grant-refused" },
origin: "wss://relay.example",
});
// So its issuer-refusal advice is that Try again, not a restart.
Expand All @@ -1143,7 +1146,10 @@ it("shows Try again beside a settled failure, and Connecting while the connectio
const f = fixture(true);
const retry = vi.spyOn(f.relay, "tryAgain");
const sidebar = () => screen.getByRole("complementary", { name: "Sidebar" });
const error = "ReadError: Relay read failed (502)";
const error = {
kind: "connection",
details: "ReadError: Relay read failed (502)",
} as const;
f.connection({ status: "error", error });
expect(within(sidebar()).getByText("Couldn’t load channels.")).toBeVisible();
expect(within(sidebar()).queryByText(/ReadError/)).toBeNull();
Expand All @@ -1155,7 +1161,10 @@ it("shows Try again beside a settled failure, and Connecting while the connectio
// every page; the sidebar neither repeats it nor offers Try again.
f.connection({
status: "error",
error: "ReadError: Relay read failed (403)",
error: {
kind: "refused",
details: "ReadError: Relay read failed (403)",
},
denied: true,
});
const notice = await screen.findByRole("dialog", {
Expand Down
4 changes: 2 additions & 2 deletions src/app/shell/PrimarySidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -273,7 +273,7 @@ function ScopedSidebar({
cached={!!connection.cached}
layoutReady={filter === "team" || soloReady}
preserveSidebar={preserveSidebar}
connectionError={connection.error}
connectionError={connection.error?.details}
render={({
sidebar,
dmPreviews,
Expand Down Expand Up @@ -426,7 +426,7 @@ function ScopedSidebar({
props.federatedIdentity,
connection.origin,
)}
error={connection.error}
error={connection.error?.details}
retry={props.relay.tryAgain}
relay={connection.origin}
memberships={props.memberships}
Expand Down
51 changes: 49 additions & 2 deletions src/bundled/bestie/PinnedBestie.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -858,15 +858,62 @@ it("keeps the saved Bestie visible through reconnect and offers connection recov
screen.queryByRole("button", { name: "Set up Bestie" }),
).not.toBeInTheDocument();
act(() =>
f.updateRelay({ status: "error", error: "Community temporarily offline" }),
f.updateRelay({
status: "error",
error: {
kind: "connection",
details: "ReadError: Relay read failed (502)",
},
}),
);
expect(screen.getByText("Community temporarily offline")).toBeVisible();
expect(
screen.getByText("Check your connection, then try again."),
).toBeVisible();
expect(screen.queryByText(/ReadError|502/)).not.toBeInTheDocument();
await userEvent.click(
screen.getByRole("button", { name: "Retry connection" }),
);
expect(f.retryConnection).toHaveBeenCalledOnce();
});

it("offers Check for updates, not Retry connection, for an answer Buzz can't use", async () => {
const f = await fixture({ existing: true });
f.choose(pollen);
const open = vi.fn(async () => ({ status: "opened" }) as const);
render(
f.element({
renderedNavigator: Object.create(f.navigator, { open: { value: open } }),
}),
);
await screen.findByRole("textbox", { name: "Message Pollen" });
act(() =>
f.updateRelay({
status: "error",
error: {
kind: "unusable",
details: "ReadError: Relay response is not an event array",
},
}),
);
expect(
screen.getByText("Buzz couldn’t connect to this community."),
).toBeVisible();
expect(screen.queryByText(/try again/i)).not.toBeInTheDocument();
// Reconnecting gets the same answer; an update may not.
expect(
screen.queryByRole("button", { name: "Retry connection" }),
).not.toBeInTheDocument();
await userEvent.click(
screen.getByRole("button", { name: "Check for updates" }),
);
expect(open).toHaveBeenCalledExactlyOnceWith({
version: 1,
kind: "settings",
section: "updates",
});
expect(f.retryConnection).not.toHaveBeenCalled();
});

it("recovers a saved agent missing from a partial inventory through refresh", async () => {
const f = await fixture({ localFailed: true });
f.choose(pollen);
Expand Down
20 changes: 13 additions & 7 deletions src/bundled/bestie/PinnedBestie.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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" ? (
<Button onClick={relay.tryAgain}>Retry connection</Button>
) : undefined
: connection.error
? connectionFailureText(connection)
: "Choose a community to chat with your bestie."
}
action={connectionFailureAction({
connection,
relay,
openUpdates: openUpdatesWith(navigator),
})}
/>
);
} else if (channel && saved) {
Expand Down
Loading
Loading