Skip to content
Open
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
1 change: 1 addition & 0 deletions desktop/src/app/RelayConnectionOverlay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ export function RelayConnectionOverlay({
>
<div className="pointer-events-auto rounded-xl bg-background shadow-md">
<SidebarRelayConnectionCard
isAutoReconnecting={card.isRelayAutoReconnecting}
isConnected={card.isRelayConnectionSuccess}
isReconnectPending={card.isRelayReconnectPending}
isWaitingOnReconnectHook={card.isWaitingOnReconnectHook}
Expand Down
1 change: 1 addition & 0 deletions desktop/src/features/sidebar/ui/AppSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -872,6 +872,7 @@ export function AppSidebar({
(isMobile ? openMobile : sidebarOpen) ? (
<SidebarRelayConnectionCard
className="mb-2"
isAutoReconnecting={relayConnectionCard.isRelayAutoReconnecting}
isConnected={relayConnectionCard.isRelayConnectionSuccess}
isReconnectPending={relayConnectionCard.isRelayReconnectPending}
isWaitingOnReconnectHook={
Expand Down
32 changes: 25 additions & 7 deletions desktop/src/features/sidebar/ui/SidebarRelayConnectionCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,12 @@ type SidebarRelayConnectionCardProps = {
isActionDisabled?: boolean;
actionTestId?: string;
className?: string;
/**
* The client's own backoff loop is already retrying — no user action is
* needed. Distinct from `isReconnectPending`, which tracks a *manual*
* reconnect the user asked for.
*/
isAutoReconnecting?: boolean;
isConnected?: boolean;
isReconnectPending: boolean;
isWaitingOnReconnectHook?: boolean;
Expand All @@ -23,6 +29,7 @@ export function SidebarRelayConnectionCard({
actionTestId,
className,
isActionDisabled = false,
isAutoReconnecting = false,
isConnected = false,
isReconnectPending,
isWaitingOnReconnectHook = false,
Expand All @@ -35,6 +42,7 @@ export function SidebarRelayConnectionCard({
actionTestId={actionTestId ?? "sidebar-reconnect"}
className={className}
isActionDisabled={isActionDisabled}
isAutoReconnecting={isAutoReconnecting}
isConnected={isConnected}
isReconnectPending={isReconnectPending}
isWaitingOnReconnectHook={isWaitingOnReconnectHook}
Expand All @@ -50,6 +58,7 @@ export function SidebarRelayConnectionCompactCard({
actionTestId,
className,
isActionDisabled = false,
isAutoReconnecting = false,
isConnected = false,
isReconnectPending,
isWaitingOnReconnectHook = false,
Expand All @@ -64,27 +73,32 @@ export function SidebarRelayConnectionCompactCard({
const reconnectDescription = isWaitingOnReconnectHook
? "Complete any prompts opened by the reconnect helper to continue."
: "Reconnecting";
// A manual reconnect the user asked for outranks the background loop.
const isRetryingWithoutUser = isAutoReconnecting && !isReconnectPending;
const isBusy = isReconnectPending || isRetryingWithoutUser;

return (
<SidebarCompactActionCard
actionAriaLabel={isConnected ? "Connected" : "Connect to relay"}
// The background loop still yields the button: a user who does not want
// to wait out the backoff can force an attempt now.
actionDisabled={isActionDisabled || isReconnectPending || isConnected}
actionTestId={actionTestId}
description={
isConnected
? undefined
: isReconnectPending
? reconnectDescription
: "Click to connect"
: isRetryingWithoutUser
? "Trying to restore the connection"
: "Click to connect"
}
dismissLabel="Dismiss relay notification"
iconKey={
isConnected ? "connected" : isReconnectPending ? "pending" : "idle"
}
iconKey={isConnected ? "connected" : isBusy ? "pending" : "idle"}
icon={
isConnected ? (
<Check aria-hidden="true" className="h-5 w-5" />
) : isReconnectPending ? (
) : isBusy ? (
<Spinner aria-hidden="true" className="h-5 w-5 border-2" />
) : (
<CloudOff aria-hidden="true" className="h-5 w-5" />
Expand All @@ -93,15 +107,19 @@ export function SidebarRelayConnectionCompactCard({
className={className}
onAction={onReconnect}
onDismiss={onDismiss}
role={isConnected ? "status" : "alert"}
// Recovering on its own is a status, not an alarm — only escalate to
// `alert` once the connection needs the user.
role={isConnected || isRetryingWithoutUser ? "status" : "alert"}
surface={surface}
testId={testId}
title={
isConnected
? "Connected"
: isReconnectPending
? reconnectTitle
: "Can't reach the relay"
: isRetryingWithoutUser
? "Reconnecting"
: "Can't reach the relay"
}
tone={isConnected ? "success" : "neutral"}
/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,9 @@ export function useSidebarRelayConnectionCard(

return {
hasRelayUnreachableError,
// The relay client's own backoff loop is mid-retry. The card uses this to
// say so instead of implying the user has to click something.
isRelayAutoReconnecting: relayConnectionState === "reconnecting",
isRelayConnectionSuccess,
isRelayReconnectPending,
isWaitingOnReconnectHook,
Expand Down
36 changes: 20 additions & 16 deletions desktop/tests/e2e/relay-connectivity.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { expect, test } from "@playwright/test";

import { waitForAnimations } from "../helpers/animations";
import { installMockBridge } from "../helpers/bridge";

const RELAY_UNREACHABLE = "relay unreachable: connection refused";
Expand All @@ -13,15 +14,12 @@ const MOCK_PUBKEY = "deadbeef".repeat(8);
const MOCK_RELAY_URL = "ws://localhost:3000";
const SELF_PROFILE_CACHE_KEY = `buzz-self-profile.v1:${MOCK_RELAY_URL}:${MOCK_PUBKEY}`;

async function settle(page: import("@playwright/test").Page) {
await page.evaluate(() =>
// Tolerate cancelled animations: a SkeletonReveal animation cancelled
// mid-flight (skeleton → live content swap) rejects `.finished` with an
// AbortError. allSettled lets the animations that DO finish settle instead
// of aborting the whole wait on the first cancel.
Promise.allSettled(document.getAnimations().map((a) => a.finished)),
);
}
// Defer to the shared bounded helper. The degraded relay card now renders a
// looping spinner while the client auto-reconnects, and a looping animation's
// `.finished` never resolves — an unbounded wait here hangs until Playwright
// aborts the evaluate. `waitForAnimations` races the settle against a ceiling
// and also tolerates animations cancelled mid-flight.
const settle = waitForAnimations;

type ConnectionState =
| "idle"
Expand Down Expand Up @@ -103,8 +101,11 @@ test.describe("relay connectivity", () => {
await expect(relayCard).toBeVisible({
timeout: 5_000,
});
await expect(relayCard).toContainText("Can't reach the relay");
await expect(relayCard).toContainText("Click to connect");
// The backoff loop is retrying on its own — the card reports that rather
// than asking the user to click.
await expect(relayCard).toContainText("Reconnecting");
await expect(relayCard).toContainText("Trying to restore the connection");
await expect(relayCard).not.toContainText("Click to connect");
await expect(page.getByTestId("sidebar-reconnect")).toBeVisible();
await settle(page);

Expand All @@ -124,8 +125,9 @@ test.describe("relay connectivity", () => {
await expect(relayCard).toBeVisible({
timeout: 5_000,
});
await expect(relayCard).toContainText("Can't reach the relay");
await expect(relayCard).toContainText("Click to connect");
await expect(relayCard).toContainText("Reconnecting");
await expect(relayCard).toContainText("Trying to restore the connection");
await expect(relayCard).not.toContainText("Click to connect");
await expect(page.getByTestId("sidebar-reconnect")).toBeVisible();
await settle(page);

Expand Down Expand Up @@ -218,13 +220,15 @@ test.describe("relay connectivity", () => {
await expect(relayCard).toBeVisible({
timeout: 5_000,
});
await expect(relayCard).toContainText("Can't reach the relay");
await expect(relayCard).toContainText("Click to connect");
await expect(relayCard).toContainText("Reconnecting");
await expect(relayCard).toContainText("Trying to restore the connection");

await driveConnectionDegraded(page, "connected");

await expect(relayCard).toContainText("Connected");
await expect(relayCard).not.toContainText("Click to connect");
await expect(relayCard).not.toContainText(
"Trying to restore the connection",
);
await page.waitForTimeout(3_000);
await expect(relayCard).toContainText("Connected");
await expect(relayCard).toBeHidden({
Expand Down