Skip to content
Merged
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/AppShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,7 @@ export function AppShell() {
const relayConnectionCard = useSidebarRelayConnectionCard(
channelsErrorMessage,
communitiesHook.activeCommunity?.relayUrl,
`${communitiesHook.activeCommunity?.id ?? "none"}-${communitiesHook.reinitKey}`,
);
const memberChannels = React.useMemo(
() => channels.filter((channel) => channel.isMember),
Expand Down
35 changes: 24 additions & 11 deletions desktop/src/features/sidebar/ui/useSidebarRelayConnectionCard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ function isDocumentVisible() {
export function useSidebarRelayConnectionCard(
errorMessage?: string,
relayUrl?: string | null,
relayLifecycleKey = relaySuccessKey(relayUrl),
) {
const relayConnectionState = useRelayConnection();
const hasRelayUnreachableError = errorMessage
Expand All @@ -79,7 +80,6 @@ export function useSidebarRelayConnectionCard(
relayConnectionState === "stalled" ||
(relayConnectionState === "disconnected" && !hasNonUnreachableError);
const isRelayConnectionConnected = relayConnectionState === "connected";
const isRelayConnectionDisconnected = relayConnectionState === "disconnected";
const [isDismissed, setIsDismissed] = React.useState(false);
const hasSuccess = React.useSyncExternalStore(
subscribeRelayConnectivitySuccess,
Expand All @@ -95,6 +95,8 @@ export function useSidebarRelayConnectionCard(
const isRelayConnectionSuccess = hasSuccess && isRelayConnectionConnected;
const canShow = isRelayConnectionActuallyDegraded || isRelayConnectionSuccess;
const show = canShow && !isDismissed;
const outageActiveRef = React.useRef(false);
const outageRelayLifecycleKeyRef = React.useRef(relayLifecycleKey);
const wasProblemCardVisibleRef = React.useRef(false);
const {
isPending: isReconnectPending,
Expand All @@ -111,30 +113,41 @@ export function useSidebarRelayConnectionCard(
isReconnectPending || connectivityAction === "relay-connection";

React.useEffect(() => {
if (!isRelayConnectionActuallyDegraded && !isRelayConnectionSuccess) {
if (outageRelayLifecycleKeyRef.current !== relayLifecycleKey) {
outageRelayLifecycleKeyRef.current = relayLifecycleKey;
outageActiveRef.current = false;
wasProblemCardVisibleRef.current = false;
setIsDismissed(false);
}
}, [isRelayConnectionSuccess, isRelayConnectionActuallyDegraded]);

React.useEffect(() => {
if (isRelayConnectionStateDegraded || isRelayConnectionDisconnected) {
setRelayConnectivitySuccess(relayUrl, false);
if (relayConnectionState === "idle") {
outageActiveRef.current = false;
wasProblemCardVisibleRef.current = false;
setIsDismissed(false);
return;
}
}, [isRelayConnectionDisconnected, isRelayConnectionStateDegraded, relayUrl]);

React.useEffect(() => {
if (isRelayConnectionActuallyDegraded) {
if (!outageActiveRef.current) {
outageActiveRef.current = true;
setRelayConnectivitySuccess(relayUrl, false);
setIsDismissed(false);
}
wasProblemCardVisibleRef.current = show && !isRelayConnectionSuccess;
return;
}

if (wasProblemCardVisibleRef.current && isRelayConnectionConnected) {
wasProblemCardVisibleRef.current = false;
setRelayConnectivitySuccess(relayUrl, true);
if (outageActiveRef.current && isRelayConnectionConnected) {
outageActiveRef.current = false;
if (wasProblemCardVisibleRef.current) {
wasProblemCardVisibleRef.current = false;
setRelayConnectivitySuccess(relayUrl, true);
}
}
}, [
isRelayConnectionSuccess,
relayLifecycleKey,
relayConnectionState,
relayUrl,
show,
isRelayConnectionActuallyDegraded,
Expand Down
68 changes: 68 additions & 0 deletions desktop/tests/e2e/sidebar-relay-card.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,25 @@ async function setRelayConnectionState(
}, state);
}

async function emitRelayConnectionState(
page: Page,
state: RelayConnectionState,
) {
await page.evaluate((nextState) => {
const setConnectionState = (
window as Window & {
__BUZZ_E2E_SET_RELAY_CONNECTION_STATE__?: (
state: RelayConnectionState,
) => void;
}
).__BUZZ_E2E_SET_RELAY_CONNECTION_STATE__;
if (!setConnectionState) {
throw new Error("Mock relay connection state helper is not installed.");
}
setConnectionState(nextState);
}, state);
}

async function expectGenericReconnectCard(page: Page) {
const card = page.getByTestId("sidebar-relay-unreachable");
await expect(card).toBeVisible();
Expand All @@ -109,6 +128,55 @@ test("sidebar generic relay failures use the reconnect card", async ({
await expectGenericReconnectCard(page);
});

test("relay outage notification stays dismissed through retries and re-arms after recovery", async ({
page,
}) => {
await installMockBridge(page, { channelsReadError: CONNECT_ERROR });
await page.goto("/");
await setRelayConnectionState(page, "disconnected");

const card = await expectGenericReconnectCard(page);
await card
.getByRole("button", { name: "Dismiss relay notification" })
.click({ force: true });
await expect(card).toBeHidden();

// Retry churn is still the same outage: no successful connection occurred.
await emitRelayConnectionState(page, "connecting");
await emitRelayConnectionState(page, "disconnected");
await emitRelayConnectionState(page, "reconnecting");
await page.waitForTimeout(2_100);
await expect(card).toBeHidden();

// A successful connection ends the episode and re-arms the next outage.
await setChannelsReadError(page, null);
await emitRelayConnectionState(page, "connected");
await setChannelsReadError(page, CONNECT_ERROR);
await emitRelayConnectionState(page, "disconnected");
await expectGenericReconnectCard(page);
});

test("relay outage notification re-arms after same-URL lifecycle teardown", async ({
page,
}) => {
await installMockBridge(page, { channelsReadError: CONNECT_ERROR });
await page.goto("/");
await setRelayConnectionState(page, "disconnected");

const card = await expectGenericReconnectCard(page);
await card
.getByRole("button", { name: "Dismiss relay notification" })
.click({ force: true });
await expect(card).toBeHidden();

// Community switches and reconnectCommunity() tear down the singleton to
// idle before applying the next lifecycle. The next lifecycle may reuse the
// same relay URL, so URL identity alone must not preserve the old dismissal.
await emitRelayConnectionState(page, "idle");
await emitRelayConnectionState(page, "disconnected");
await expectGenericReconnectCard(page);
});

test("sidebar proxy sign-in failures use the reconnect card", async ({
page,
}) => {
Expand Down
Loading