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 @@ -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") => (
<Button
type="button"
Expand Down Expand Up @@ -514,18 +518,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 @@ -537,6 +543,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 @@ -546,7 +553,6 @@ function ConnectedAppContent({ services }: { services: AppServices }) {
>
Manage plugins
</Button>
{messagesButton("quiet")}
</>
}
/>
Expand All @@ -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")}
</>
}
/>
Expand All @@ -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")}
</>
}
/>
Expand Down
84 changes: 81 additions & 3 deletions src/app/ProfileSettings.avatar.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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");
Expand All @@ -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");
Expand Down Expand Up @@ -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();
Expand All @@ -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();
Expand Down
11 changes: 9 additions & 2 deletions src/app/ProfileSettings.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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();
Expand Down
41 changes: 33 additions & 8 deletions src/app/ProfileSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<ReturnType<typeof communityApi.inspectProfile>>;

Expand Down Expand Up @@ -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(
Expand All @@ -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);
Expand Down
Loading
Loading