From 719f9730d46541f816946bba8d693e11ff49b439 Mon Sep 17 00:00:00 2001
From: Taylor Ho
Date: Wed, 5 Aug 2026 14:02:43 -0700
Subject: [PATCH] feat(desktop): allow leaving your final community (#3621)
**Category:** improvement
**User Impact:** People can leave their final Buzz community and return
to **Join or create a community** without losing their signed-in
identity.
**Problem:** Buzz Desktop blocked people from leaving when only one
community remained. Its existing remove action also changed local
configuration without ending relay membership.
**Solution:** Allow the final community to be left. Buzz now asks the
relay to end membership, removes the community locally only after
acceptance, and returns the person to the community selector while
keeping their identity signed in. If other communities remain, Buzz
switches to one of them. Relay rejection or timeout keeps the community
in place and shows an actionable retry error.
File changes
**desktop/src/features/communities/leaveCommunity.ts**
Adds signed kind 28936 publishing for active and inactive community
relays with actionable timeout handling.
**desktop/src/features/communities/leaveCommunity.test.mjs**
Covers event shape, relay selection, acceptance gating, rejection,
timeout messaging, and cleanup.
**desktop/src/features/communities/useCommunities.tsx**
Allows final-community removal and clears community-specific storage
without touching identity.
**desktop/src/features/communities/resolveCommunityRemoval.test.mjs**
Covers final, active, and inactive community removal state transitions.
**desktop/src/app/useCommunityNavigationTransitions.ts**
Gates local removal on relay acceptance and routes to a fallback
community or setup selector.
**desktop/src/app/AppShell.tsx**
Passes the asynchronous leave operation through shell entry points.
**desktop/src/features/communities/ui/EditCommunityDialog.tsx**
Replaces the local-only remove action with a pending-aware Leave
Community action that retains actionable errors.
**desktop/src/features/communities/ui/CommunitySwitcher.tsx**
Enables leaving the final community and carries the asynchronous
callback.
**desktop/src/features/sidebar/ui/AppSidebar.tsx**
Carries the asynchronous leave callback through sidebar props.
**desktop/src/features/sidebar/ui/CommunityRail.tsx**
Enables leaving the final community from rail settings.
**desktop/src/features/sidebar/ui/SidebarProfileCard.tsx**
Carries the asynchronous leave callback through profile community
settings.
**desktop/src/testing/e2eBridge.ts**
Teaches the mock relay to accept NIP-43 leave events.
**desktop/tests/e2e/community-rail.spec.ts**
Updates leave interactions and verifies final-community setup
navigation, storage cleanup, and identity preservation.
### Reproduction steps
1. Run Buzz Desktop with a signed-in identity and one joined community.
2. Open Community settings and choose **Leave Community**.
3. Confirm the app shows **Join or create a community** and the existing
identity remains signed in.
4. Repeat with two communities and confirm leaving the active one
switches cleanly to the remaining community.
5. Reject or withhold the relay `OK` response and confirm the community
remains configured with an actionable error in the dialog.
### Test plan
- `pnpm check`
- `pnpm build`
- `pnpm test` (3,913 passing)
- `pnpm build:e2e && pnpm exec playwright test
tests/e2e/community-rail.spec.ts --grep "final community"`
---------
Signed-off-by: Taylor Ho
Signed-off-by: npub14ndfusear8wdpe4kss8h7juc7wjk78atnqzf63zvppcpneknv4sq6x9370
Co-authored-by: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w <52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@buzz.block.builderlab.xyz>
Co-authored-by: npub14ndfusear8wdpe4kss8h7juc7wjk78atnqzf63zvppcpneknv4sq6x9370
---
.../src-tauri/src/commands/relay_members.rs | 11 +-
desktop/src/app/App.tsx | 8 +-
desktop/src/app/AppShell.tsx | 5 +-
.../app/useCommunityNavigationTransitions.ts | 37 +++-
.../communities/communityStorage.test.mjs | 34 +++-
.../features/communities/communityStorage.ts | 34 +++-
.../communities/leaveCommunity.test.mjs | 175 ++++++++++++++++++
.../features/communities/leaveCommunity.ts | 88 +++++++++
.../resolveCommunityRemoval.test.mjs | 28 +++
.../communities/ui/CommunitySwitcher.tsx | 57 +++++-
.../communities/ui/EditCommunityDialog.tsx | 40 +---
.../features/communities/ui/WelcomeSetup.tsx | 26 +--
.../features/communities/useCommunities.tsx | 68 ++++---
.../features/communities/useCommunityInit.ts | 18 +-
.../src/features/sidebar/ui/AppSidebar.tsx | 3 +-
.../src/features/sidebar/ui/CommunityRail.tsx | 4 -
.../sidebar/ui/SidebarProfileCard.tsx | 3 +-
desktop/src/shared/api/relayMembers.ts | 6 +-
desktop/src/testing/e2eBridge.ts | 5 +
desktop/tests/e2e/community-rail.spec.ts | 83 ++++++++-
20 files changed, 629 insertions(+), 104 deletions(-)
create mode 100644 desktop/src/features/communities/leaveCommunity.test.mjs
create mode 100644 desktop/src/features/communities/leaveCommunity.ts
create mode 100644 desktop/src/features/communities/resolveCommunityRemoval.test.mjs
diff --git a/desktop/src-tauri/src/commands/relay_members.rs b/desktop/src-tauri/src/commands/relay_members.rs
index a9230dff9..9ccf8baac 100644
--- a/desktop/src-tauri/src/commands/relay_members.rs
+++ b/desktop/src-tauri/src/commands/relay_members.rs
@@ -17,8 +17,15 @@ struct RelayInformationDocument {
}
#[tauri::command]
-pub async fn relay_requires_membership(state: State<'_, AppState>) -> Result {
- let url = format!("{}/info", relay_api_base_url_with_override(&state));
+pub async fn relay_requires_membership(
+ relay_url: Option,
+ state: State<'_, AppState>,
+) -> Result {
+ let base_url = relay_url
+ .as_deref()
+ .map(crate::relay::relay_http_base_url)
+ .unwrap_or_else(|| relay_api_base_url_with_override(&state));
+ let url = format!("{}/info", base_url.trim_end_matches('/'));
let response = state
.http_client
.get(url)
diff --git a/desktop/src/app/App.tsx b/desktop/src/app/App.tsx
index 0f9085e5f..811da043f 100644
--- a/desktop/src/app/App.tsx
+++ b/desktop/src/app/App.tsx
@@ -42,6 +42,7 @@ import { PendingInviteGate } from "@/features/onboarding/ui/PendingInviteGate";
import { KeyringLockedScreen } from "@/features/onboarding/ui/KeyringLockedScreen";
import { RelaunchRequiredScreen } from "@/features/onboarding/ui/RelaunchRequiredScreen";
import { ResetFailedScreen } from "@/features/onboarding/ui/ResetFailedScreen";
+import { loadCommunityDiscoveryAfterLeave } from "@/features/communities/communityStorage";
import { useCommunityInit } from "@/features/communities/useCommunityInit";
import { useNestNotifications } from "@/features/communities/useNestNotifications";
import { useCommunities } from "@/features/communities/useCommunities";
@@ -320,6 +321,8 @@ function CommunityApp({
const [isCommunityChangeOpen, setIsCommunityChangeOpen] = useState(false);
const [resumeFirstCommunityPage, setResumeFirstCommunityPage] =
useState(null);
+ const isFindingCommunityAfterLeave =
+ activeCommunity === null && loadCommunityDiscoveryAfterLeave();
// Surface nest-related backend events (repos-dir errors, legacy migration)
// as toasts. Mounted before useCommunityInit so the listeners are registered
@@ -344,6 +347,7 @@ function CommunityApp({
activeCommunity,
communityKey,
sharedIdentity,
+ isFindingCommunityAfterLeave,
);
const transitionCommunity = useCallback(
@@ -513,7 +517,9 @@ function CommunityApp({
appContent = (
);
} else if ("error" in community && community.error) {
diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx
index fdb490718..f765b843b 100644
--- a/desktop/src/app/AppShell.tsx
+++ b/desktop/src/app/AppShell.tsx
@@ -779,7 +779,6 @@ export function AppShell() {
void handleRemoveCommunity(id)}
onReorderCommunities={communitiesHook.reorderCommunities}
onSwitchCommunity={handleSwitchCommunity}
onUpdateCommunity={communitiesHook.updateCommunity}
@@ -872,9 +871,7 @@ export function AppShell() {
onOpenAddCommunity={addCommunityDialog.openDialog}
onSendFeedback={() => setIsSendFeedbackOpen(true)}
onUpdateCommunity={communitiesHook.updateCommunity}
- onRemoveCommunity={(id) =>
- void handleRemoveCommunity(id)
- }
+ onRemoveCommunity={handleRemoveCommunity}
onSwitchCommunity={handleSwitchCommunity}
onCreateAgent={() => requestOpenCreateAgent()}
selfPresenceStatus={presenceSession.currentStatus}
diff --git a/desktop/src/app/useCommunityNavigationTransitions.ts b/desktop/src/app/useCommunityNavigationTransitions.ts
index 88acb30ad..d2cf39fbc 100644
--- a/desktop/src/app/useCommunityNavigationTransitions.ts
+++ b/desktop/src/app/useCommunityNavigationTransitions.ts
@@ -12,7 +12,9 @@ import {
markPendingCommunityRestore,
saveCommunityDestination,
} from "@/features/communities/communityNavigationStorage";
+import { markCommunityDiscoveryAfterLeave } from "@/features/communities/communityStorage";
import type { useCommunities } from "@/features/communities/useCommunities";
+import { leaveCommunity } from "@/features/communities/leaveCommunity";
type Communities = ReturnType;
type ShellRoute = ReturnType;
@@ -71,14 +73,38 @@ export function useCommunityNavigationTransitions({
const removeCommunity = React.useCallback(
async (id: string) => {
- if (id !== communities.activeCommunity?.id) {
- communities.removeCommunity(id);
- return;
- }
+ const target = communities.communities.find(
+ (community) => community.id === id,
+ );
+ if (!target) return;
+
const fallback = communities.communities.find(
(community) => community.id !== id,
);
- if (!fallback) return;
+
+ // Do not touch local state until this relay has explicitly accepted the
+ // signed NIP-43 leave request. Rejections and timeouts bubble back to the
+ // dialog so the person can retry without losing their community config.
+ const leaveResult = await leaveCommunity(
+ target.relayUrl,
+ communities.activeCommunity?.relayUrl,
+ );
+
+ if (id !== communities.activeCommunity?.id) {
+ communities.removeCommunity(id);
+ return leaveResult;
+ }
+
+ if (!fallback) {
+ if (!markCommunityDiscoveryAfterLeave()) {
+ throw new Error(
+ "Membership was removed, but community discovery state could not be saved. Restart Buzz and try again.",
+ );
+ }
+ await goHome({ replace: true });
+ communities.removeCommunity(id);
+ return leaveResult;
+ }
await runCommunityViewTransition(async () => {
saveActiveDestination();
@@ -93,6 +119,7 @@ export function useCommunityNavigationTransitions({
}
communities.removeCommunity(id);
});
+ return leaveResult;
},
[communities, goHome, router.history, saveActiveDestination],
);
diff --git a/desktop/src/features/communities/communityStorage.test.mjs b/desktop/src/features/communities/communityStorage.test.mjs
index 633fccd20..fca190913 100644
--- a/desktop/src/features/communities/communityStorage.test.mjs
+++ b/desktop/src/features/communities/communityStorage.test.mjs
@@ -4,7 +4,11 @@ import test from "node:test";
import {
clearCommunityStorage,
initFirstCommunity,
+ loadCommunities,
+ loadCommunityDiscoveryAfterLeave,
+ markCommunityDiscoveryAfterLeave,
migrateLegacyCommunityStorage,
+ saveCommunities,
shouldAutoConnectDefaultRelay,
} from "./communityStorage.ts";
@@ -89,16 +93,42 @@ test("failed first-community write preserves existing community data", () => {
assert.equal(storage.getItem("buzz-active-workspace-id"), "legacy");
});
-test("clearCommunityStorage removes new and legacy state", () => {
+test("loading an existing community clears stale final-leave discovery", () => {
+ const storage = createMemoryStorage({
+ "buzz-communities": '[{"id":"joined"}]',
+ "buzz-community-discovery-after-leave": "1",
+ });
+ globalThis.localStorage = storage;
+ globalThis.window = { localStorage: storage };
+
+ assert.deepEqual(loadCommunities(), [{ id: "joined" }]);
+ assert.equal(loadCommunityDiscoveryAfterLeave(storage), false);
+});
+
+test("completed final leave persists discovery until a community is saved", () => {
+ const storage = createMemoryStorage();
+ globalThis.localStorage = storage;
+ globalThis.window = { localStorage: storage };
+
+ assert.equal(markCommunityDiscoveryAfterLeave(storage), true);
+ assert.equal(loadCommunityDiscoveryAfterLeave(storage), true);
+
+ assert.equal(saveCommunities([{ id: "joined" }]), true);
+ assert.equal(loadCommunityDiscoveryAfterLeave(storage), false);
+});
+
+test("clearCommunityStorage preserves completed final-leave discovery", () => {
const storage = createMemoryStorage({
"buzz-communities": "new",
"buzz-active-community-id": "new",
"buzz-workspaces": "old",
"buzz-active-workspace-id": "old",
+ "buzz-community-discovery-after-leave": "1",
});
clearCommunityStorage(storage);
migrateLegacyCommunityStorage(storage);
- assert.equal(storage.length, 0);
+ assert.equal(storage.length, 1);
+ assert.equal(loadCommunityDiscoveryAfterLeave(storage), true);
});
diff --git a/desktop/src/features/communities/communityStorage.ts b/desktop/src/features/communities/communityStorage.ts
index 4a99e1f2e..7c8778712 100644
--- a/desktop/src/features/communities/communityStorage.ts
+++ b/desktop/src/features/communities/communityStorage.ts
@@ -6,6 +6,8 @@ const COMMUNITIES_KEY = "buzz-communities";
const ACTIVE_COMMUNITY_KEY = "buzz-active-community-id";
const LEGACY_WORKSPACES_KEY = "buzz-workspaces";
const LEGACY_ACTIVE_WORKSPACE_KEY = "buzz-active-workspace-id";
+const COMMUNITY_DISCOVERY_AFTER_LEAVE_KEY =
+ "buzz-community-discovery-after-leave";
/**
* Expand a leading `~` to the user's home directory. The backend rejects
@@ -57,6 +59,9 @@ export function loadCommunities(): Community[] {
if (!Array.isArray(parsed)) {
return [];
}
+ if (parsed.length > 0) {
+ localStorage.removeItem(COMMUNITY_DISCOVERY_AFTER_LEAVE_KEY);
+ }
// Migration: older builds stored the user's `nsec` in localStorage and
// re-applied it to the backend on every reload, which silently overwrote
// any `import_identity` result with the original generated key. The
@@ -82,10 +87,37 @@ export function loadCommunities(): Community[] {
}
export function saveCommunities(communities: Community[]): boolean {
- return setLocalStorageItemWithRecovery(
+ const didSave = setLocalStorageItemWithRecovery(
COMMUNITIES_KEY,
JSON.stringify(communities),
);
+ if (didSave && communities.length > 0) {
+ localStorage.removeItem(COMMUNITY_DISCOVERY_AFTER_LEAVE_KEY);
+ }
+ return didSave;
+}
+
+export function loadCommunityDiscoveryAfterLeave(
+ storage: Storage = localStorage,
+): boolean {
+ return storage.getItem(COMMUNITY_DISCOVERY_AFTER_LEAVE_KEY) === "1";
+}
+
+export function markCommunityDiscoveryAfterLeave(
+ storage: Storage = localStorage,
+): boolean {
+ if (typeof window !== "undefined" && storage === window.localStorage) {
+ return setLocalStorageItemWithRecovery(
+ COMMUNITY_DISCOVERY_AFTER_LEAVE_KEY,
+ "1",
+ );
+ }
+ try {
+ storage.setItem(COMMUNITY_DISCOVERY_AFTER_LEAVE_KEY, "1");
+ return true;
+ } catch {
+ return false;
+ }
}
export function clearCommunityStorage(storage: Storage = localStorage): void {
diff --git a/desktop/src/features/communities/leaveCommunity.test.mjs b/desktop/src/features/communities/leaveCommunity.test.mjs
new file mode 100644
index 000000000..50f077592
--- /dev/null
+++ b/desktop/src/features/communities/leaveCommunity.test.mjs
@@ -0,0 +1,175 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+
+import { KIND_NIP43_LEAVE_REQUEST, leaveCommunity } from "./leaveCommunity.ts";
+
+const signedEvent = {
+ id: "event-id",
+ pubkey: "a".repeat(64),
+ created_at: 1,
+ kind: KIND_NIP43_LEAVE_REQUEST,
+ tags: [["-"]],
+ content: "",
+ sig: "b".repeat(128),
+};
+
+function dependencies(overrides = {}) {
+ return {
+ requiresMembership: async () => true,
+ sign: async (input) => ({ ...signedEvent, ...input }),
+ publishActive: async () => {},
+ createRelayClient: () => ({
+ publishEvent: async () => {},
+ disconnect() {},
+ }),
+ ...overrides,
+ };
+}
+
+test("skips relay publishing when the relay does not enforce membership", async () => {
+ let checkedRelay;
+ await leaveCommunity(
+ "wss://open.example",
+ "wss://open.example",
+ dependencies({
+ requiresMembership: async (relayUrl) => {
+ checkedRelay = relayUrl;
+ return false;
+ },
+ sign: async () => {
+ throw new Error("open relay leave should not be signed");
+ },
+ publishActive: async () => {
+ throw new Error("open relay leave should not be published");
+ },
+ }),
+ );
+
+ assert.equal(checkedRelay, "wss://open.example");
+});
+
+test("signs the protected NIP-43 leave request and awaits active relay acceptance", async () => {
+ let signInput;
+ let published;
+ await leaveCommunity(
+ "wss://active.example",
+ "wss://active.example",
+ dependencies({
+ sign: async (input) => {
+ signInput = input;
+ return signedEvent;
+ },
+ publishActive: async (event) => {
+ published = event;
+ },
+ createRelayClient: () => {
+ throw new Error("inactive client should not be created");
+ },
+ }),
+ );
+
+ assert.deepEqual(signInput, {
+ kind: KIND_NIP43_LEAVE_REQUEST,
+ content: "",
+ tags: [["-"]],
+ });
+ assert.equal(published, signedEvent);
+});
+
+test("targets an inactive community relay and always disconnects", async () => {
+ const calls = [];
+ await leaveCommunity(
+ "wss://inactive.example",
+ "wss://active.example",
+ dependencies({
+ publishActive: async () => {
+ throw new Error("active relay should not be used");
+ },
+ createRelayClient: (relayUrl) => ({
+ publishEvent: async (event) => calls.push(["publish", relayUrl, event]),
+ disconnect: () => calls.push(["disconnect"]),
+ }),
+ }),
+ );
+
+ assert.deepEqual(calls, [
+ ["publish", "wss://inactive.example", signedEvent],
+ ["disconnect"],
+ ]);
+});
+
+test("treats an already-absent active membership as successful cleanup", async () => {
+ const result = await leaveCommunity(
+ "wss://active.example",
+ "wss://active.example",
+ dependencies({
+ publishActive: async () => {
+ throw new Error("invalid: you are not a relay member");
+ },
+ }),
+ );
+
+ assert.deepEqual(result, { status: "already-absent" });
+});
+
+test("treats an already-absent inactive membership as successful cleanup and disconnects", async () => {
+ let disconnected = false;
+ const result = await leaveCommunity(
+ "wss://inactive.example",
+ "wss://active.example",
+ dependencies({
+ createRelayClient: () => ({
+ publishEvent: async () => {
+ throw new Error("invalid: you are not a relay member");
+ },
+ disconnect: () => {
+ disconnected = true;
+ },
+ }),
+ }),
+ );
+ assert.deepEqual(result, { status: "already-absent" });
+ assert.equal(disconnected, true);
+});
+
+test("preserves other relay rejections and disconnects without falling through", async () => {
+ const rejection = new Error("invalid: relay owner cannot leave");
+ let disconnected = false;
+
+ await assert.rejects(
+ leaveCommunity(
+ "wss://inactive.example",
+ "wss://active.example",
+ dependencies({
+ createRelayClient: () => ({
+ publishEvent: async () => {
+ throw rejection;
+ },
+ disconnect: () => {
+ disconnected = true;
+ },
+ }),
+ }),
+ ),
+ rejection,
+ );
+ assert.equal(disconnected, true);
+});
+
+test("turns an inactive relay timeout into an actionable leave error", async () => {
+ await assert.rejects(
+ leaveCommunity(
+ "wss://inactive.example",
+ "wss://active.example",
+ dependencies({
+ createRelayClient: () => ({
+ publishEvent: async () => {
+ throw new Error("Timed out publishing to observer relay.");
+ },
+ disconnect() {},
+ }),
+ }),
+ ),
+ /Timed out while leaving the community\. Try again\./,
+ );
+});
diff --git a/desktop/src/features/communities/leaveCommunity.ts b/desktop/src/features/communities/leaveCommunity.ts
new file mode 100644
index 000000000..15e3f8ba8
--- /dev/null
+++ b/desktop/src/features/communities/leaveCommunity.ts
@@ -0,0 +1,88 @@
+import { relayClient } from "@/shared/api/relayClient";
+import { ReadOnlyRelayClient } from "@/shared/api/readOnlyRelayClient";
+import { relayRequiresMembership } from "@/shared/api/relayMembers";
+import { signRelayEvent } from "@/shared/api/tauri";
+import type { RelayEvent } from "@/shared/api/types";
+
+export const KIND_NIP43_LEAVE_REQUEST = 28936;
+
+type LeaveCommunityDependencies = {
+ requiresMembership: (relayUrl: string) => Promise;
+ sign: typeof signRelayEvent;
+ publishActive: (event: RelayEvent) => Promise;
+ createRelayClient: (relayUrl: string) => {
+ publishEvent: (event: RelayEvent) => Promise;
+ disconnect: () => void;
+ };
+};
+
+const defaultDependencies: LeaveCommunityDependencies = {
+ requiresMembership: relayRequiresMembership,
+ sign: signRelayEvent,
+ publishActive: (event) =>
+ relayClient.publishEvent(
+ event,
+ "Timed out while leaving the community. Try again.",
+ "Couldn't send the leave request. Check your connection and try again.",
+ ),
+ createRelayClient: (relayUrl) => new ReadOnlyRelayClient(relayUrl),
+};
+
+function membershipIsAlreadyAbsent(error: unknown): boolean {
+ return (
+ error instanceof Error &&
+ error.message.toLowerCase().includes("not a relay member")
+ );
+}
+
+export type LeaveCommunityResult =
+ | { status: "left" }
+ | { status: "already-absent" };
+
+async function publishLeaveRequest(
+ publish: () => Promise,
+): Promise {
+ try {
+ await publish();
+ return { status: "left" };
+ } catch (error) {
+ if (!membershipIsAlreadyAbsent(error)) throw error;
+ return { status: "already-absent" };
+ }
+}
+
+/** Revoke relay membership and resolve only after the relay accepts the request. */
+export async function leaveCommunity(
+ relayUrl: string,
+ activeRelayUrl: string | undefined,
+ dependencies: LeaveCommunityDependencies = defaultDependencies,
+): Promise {
+ if (!(await dependencies.requiresMembership(relayUrl))) {
+ return { status: "left" };
+ }
+
+ const event = await dependencies.sign({
+ kind: KIND_NIP43_LEAVE_REQUEST,
+ content: "",
+ tags: [["-"]],
+ });
+
+ if (relayUrl === activeRelayUrl) {
+ return publishLeaveRequest(() => dependencies.publishActive(event));
+ }
+
+ const client = dependencies.createRelayClient(relayUrl);
+ try {
+ return await publishLeaveRequest(() => client.publishEvent(event));
+ } catch (error) {
+ if (
+ error instanceof Error &&
+ error.message.toLowerCase().includes("timed out")
+ ) {
+ throw new Error("Timed out while leaving the community. Try again.");
+ }
+ throw error;
+ } finally {
+ client.disconnect();
+ }
+}
diff --git a/desktop/src/features/communities/resolveCommunityRemoval.test.mjs b/desktop/src/features/communities/resolveCommunityRemoval.test.mjs
new file mode 100644
index 000000000..6d24ee4db
--- /dev/null
+++ b/desktop/src/features/communities/resolveCommunityRemoval.test.mjs
@@ -0,0 +1,28 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+
+import { resolveCommunityRemoval } from "./useCommunities.tsx";
+
+const alpha = { id: "alpha", name: "Alpha", relayUrl: "wss://alpha" };
+const beta = { id: "beta", name: "Beta", relayUrl: "wss://beta" };
+
+test("removing the final community clears the active community", () => {
+ assert.deepEqual(resolveCommunityRemoval([alpha], "alpha", "alpha"), {
+ communities: [],
+ activeId: null,
+ });
+});
+
+test("removing the active community selects a clean fallback", () => {
+ assert.deepEqual(resolveCommunityRemoval([alpha, beta], "alpha", "alpha"), {
+ communities: [beta],
+ activeId: "beta",
+ });
+});
+
+test("removing an inactive community preserves the active community", () => {
+ assert.deepEqual(resolveCommunityRemoval([alpha, beta], "alpha", "beta"), {
+ communities: [alpha],
+ activeId: "alpha",
+ });
+});
diff --git a/desktop/src/features/communities/ui/CommunitySwitcher.tsx b/desktop/src/features/communities/ui/CommunitySwitcher.tsx
index 985d28fa8..cd530e690 100644
--- a/desktop/src/features/communities/ui/CommunitySwitcher.tsx
+++ b/desktop/src/features/communities/ui/CommunitySwitcher.tsx
@@ -6,11 +6,14 @@ import {
MoreHorizontal,
Plus,
Settings2,
+ LogOut,
Ticket,
WifiOff,
} from "lucide-react";
import * as React from "react";
+import { toast } from "sonner";
+import type { LeaveCommunityResult } from "@/features/communities/leaveCommunity";
import type { Community } from "@/features/communities/types";
import {
DropdownMenu,
@@ -57,7 +60,7 @@ type CommunitySwitcherProps = {
id: string,
updates: Partial>,
) => void;
- onRemoveCommunity: (id: string) => void;
+ onRemoveCommunity: (id: string) => Promise;
};
export function CommunityEmojiIcon({
@@ -103,6 +106,8 @@ export function CommunitySwitcher({
const [editingCommunity, setEditingCommunity] =
React.useState(null);
const [dropdownOpen, setDropdownOpen] = React.useState(false);
+ const [leaveError, setLeaveError] = React.useState(null);
+ const [isLeaving, setIsLeaving] = React.useState(false);
const profileMenuHoverTimer = React.useRef(null);
const connectionState = useRelayConnection();
const degraded = isRelayConnectionDegraded(connectionState);
@@ -147,6 +152,36 @@ export function CommunitySwitcher({
[],
);
+ const handleLeaveCommunity = React.useCallback(async () => {
+ if (!activeCommunity || isLeaving) return;
+
+ if (profileMenuHoverTimer.current !== null) {
+ window.clearTimeout(profileMenuHoverTimer.current);
+ profileMenuHoverTimer.current = null;
+ }
+ setIsLeaving(true);
+ setLeaveError(null);
+ try {
+ const result = await onRemoveCommunity(activeCommunity.id);
+ setDropdownOpen(false);
+ if (result?.status === "already-absent") {
+ toast("Community removed", {
+ description:
+ "You were no longer a member, so Buzz removed the community from this device.",
+ });
+ }
+ } catch (error) {
+ setLeaveError(
+ error instanceof Error
+ ? error.message
+ : "Couldn't leave the community. Try again.",
+ );
+ setDropdownOpen(true);
+ } finally {
+ setIsLeaving(false);
+ }
+ }, [activeCommunity, isLeaving, onRemoveCommunity]);
+
const triggerContent = (
<>
{degraded ? (
@@ -278,6 +313,24 @@ export function CommunitySwitcher({
Community settings
+
+ {leaveError ? (
+
+ {leaveError}
+
+ ) : null}
>
) : null}
@@ -391,11 +444,9 @@ export function CommunitySwitcher({
)}
1}
onOpenChange={(open) => {
if (!open) setEditingCommunity(null);
}}
- onRemove={onRemoveCommunity}
onSave={onUpdateCommunity}
open={editingCommunity !== null}
community={editingCommunity}
diff --git a/desktop/src/features/communities/ui/EditCommunityDialog.tsx b/desktop/src/features/communities/ui/EditCommunityDialog.tsx
index 3b963979b..3baa52372 100644
--- a/desktop/src/features/communities/ui/EditCommunityDialog.tsx
+++ b/desktop/src/features/communities/ui/EditCommunityDialog.tsx
@@ -28,8 +28,6 @@ type EditCommunityDialogProps = {
Pick
>,
) => void;
- onRemove?: (id: string) => void;
- canRemove?: boolean;
showIconEditor?: boolean;
};
@@ -38,8 +36,6 @@ export function EditCommunityDialog({
open,
onOpenChange,
onSave,
- onRemove,
- canRemove,
showIconEditor = false,
}: EditCommunityDialogProps) {
const [name, setName] = React.useState("");
@@ -122,13 +118,6 @@ export function EditCommunityDialog({
[community, name, relayUrl, token, reposDir, onSave, handleClose],
);
- const handleRemove = React.useCallback(() => {
- if (community && onRemove) {
- onRemove(community.id);
- handleClose();
- }
- }, [community, onRemove, handleClose]);
-
if (!community) {
return null;
}
@@ -235,28 +224,13 @@ export function EditCommunityDialog({
the default location.
-
-
- {canRemove && onRemove ? (
-
- ) : null}
-
-
-
-
-
+
+
+
diff --git a/desktop/src/features/communities/ui/WelcomeSetup.tsx b/desktop/src/features/communities/ui/WelcomeSetup.tsx
index bde145822..530933acc 100644
--- a/desktop/src/features/communities/ui/WelcomeSetup.tsx
+++ b/desktop/src/features/communities/ui/WelcomeSetup.tsx
@@ -27,7 +27,7 @@ type WelcomeTransitionMode = "initial" | OnboardingTransitionDirection;
type WelcomeSetupProps = {
initialPage?: WelcomeSetupPage;
initialTransitionMode?: WelcomeTransitionMode;
- onBack: () => void;
+ onBack?: () => void;
};
const COMMUNITY_OPTION_CARD_CLASS =
@@ -164,17 +164,19 @@ export function WelcomeSetup({
-
-
-
+ {onBack ? (
+
+
+
+ ) : null}
) : page === "existing" ? (
community.id !== id);
+ return {
+ communities: next,
+ activeId: activeId === id ? (next[0]?.id ?? null) : activeId,
+ };
+}
+
export type UseCommunitiesReturn = {
communities: Community[];
activeCommunity: Community | null;
@@ -206,40 +223,39 @@ function useCommunitiesInternal(): UseCommunitiesReturn {
const removeCommunity = useCallback(
(id: string) => {
- // GC self-profile caches for the removed community's relay. Mirror the
- // updater guard (length > 1) so we only GC when removal will actually
- // proceed. Runs outside the updater — updaters can execute twice under
+ const removed = communitiesRef.current.find(
+ (community) => community.id === id,
+ );
+ if (!removed) return;
+
+ // Relay membership is revoked by the caller before this local cleanup.
+ // Keep side effects outside the updater — updaters can execute twice under
// React StrictMode.
- if (communities.length > 1) {
- const removed = communities.find((w) => w.id === id);
- if (removed) {
- removeSelfProfileCachesForRelay(removed.relayUrl);
- removeUserLabelCacheForRelay(removed.relayUrl);
- removeChannelSnapshotForRelay(removed.relayUrl);
- removeMessageSnapshotsForRelay(removed.relayUrl);
- clearSavedCommunitySnapshot(id);
- removeCommunityDestination(id);
- }
- }
+ removeSelfProfileCachesForRelay(removed.relayUrl);
+ removeUserLabelCacheForRelay(removed.relayUrl);
+ removeChannelSnapshotForRelay(removed.relayUrl);
+ removeMessageSnapshotsForRelay(removed.relayUrl);
+ clearSavedCommunitySnapshot(id);
+ removeCommunityDestination(id);
setCommunitiesState((prev) => {
- // Never allow removing the last community
- if (prev.length <= 1) {
- return prev;
- }
- const next = prev.filter((w) => w.id !== id);
- saveCommunities(next);
+ const result = resolveCommunityRemoval(prev, activeId, id);
+ if (result.communities.length === 0) {
+ clearCommunityStorage();
+ setActiveId(null);
+ } else {
+ saveCommunities(result.communities);
- // If removing the active community, switch to first remaining
- if (activeId === id && next.length > 0) {
- saveActiveCommunityId(next[0].id);
- setActiveId(next[0].id);
+ if (result.activeId !== activeId && result.activeId) {
+ saveActiveCommunityId(result.activeId);
+ setActiveId(result.activeId);
+ }
}
- return next;
+ return result.communities;
});
},
- [activeId, communities],
+ [activeId],
);
const switchCommunity = useCallback(
diff --git a/desktop/src/features/communities/useCommunityInit.ts b/desktop/src/features/communities/useCommunityInit.ts
index dd25ec074..baf126131 100644
--- a/desktop/src/features/communities/useCommunityInit.ts
+++ b/desktop/src/features/communities/useCommunityInit.ts
@@ -97,6 +97,7 @@ export function useCommunityInit(
activeCommunity: Community | null,
communityKey: string,
isSharedIdentity: boolean,
+ suppressAutoConnect = false,
): CommunityInitResult {
const [result, setResult] = useState({
isReady: false,
@@ -122,6 +123,15 @@ export function useCommunityInit(
async function init() {
if (!activeCommunity) {
+ if (hasInitializedRef.current) {
+ if (prevCommunityIdRef.current) {
+ saveActiveAgentTurnsForCommunity(prevCommunityIdRef.current);
+ prevCommunityIdRef.current = null;
+ }
+ resetCommunityState({ resetAvatarState: true });
+ appliedRelayUrlRef.current = null;
+ hasInitializedRef.current = false;
+ }
try {
const defaultRelayUrl = await getDefaultRelayUrl();
const autoConnectDefaultRelay =
@@ -131,9 +141,10 @@ export function useCommunityInit(
// relay as the first community. Public builds retain community
// selection even when BUZZ_RELAY_URL is overridden at runtime.
if (
- isSharedIdentity ||
- (autoConnectDefaultRelay &&
- shouldAutoConnectDefaultRelay(defaultRelayUrl))
+ !suppressAutoConnect &&
+ (isSharedIdentity ||
+ (autoConnectDefaultRelay &&
+ shouldAutoConnectDefaultRelay(defaultRelayUrl)))
) {
const identity = await getIdentity();
if (cancelled) return;
@@ -290,6 +301,7 @@ export function useCommunityInit(
activeCommunity?.token,
activeCommunity?.reposDir,
isSharedIdentity,
+ suppressAutoConnect,
communityKey,
]);
diff --git a/desktop/src/features/sidebar/ui/AppSidebar.tsx b/desktop/src/features/sidebar/ui/AppSidebar.tsx
index 7d9472f70..753451f92 100644
--- a/desktop/src/features/sidebar/ui/AppSidebar.tsx
+++ b/desktop/src/features/sidebar/ui/AppSidebar.tsx
@@ -3,6 +3,7 @@ import * as React from "react";
import { FeatureGate } from "@/shared/features";
import { SidebarDndContext } from "@/features/sidebar/ui/SidebarDnd";
+import type { LeaveCommunityResult } from "@/features/communities/leaveCommunity";
import type { Community } from "@/features/communities/types";
import { AddCommunityDialog } from "@/features/communities/ui/AddCommunityDialog";
import type { AddCommunityPrefillRequest } from "@/features/communities/addCommunityPrefill";
@@ -138,7 +139,7 @@ type AppSidebarProps = {
id: string,
updates: Partial>,
) => void;
- onRemoveCommunity: (id: string) => void;
+ onRemoveCommunity: (id: string) => Promise;
onCreateAgent: () => void;
onSelectAgents: () => void;
onSelectProjects: () => void;
diff --git a/desktop/src/features/sidebar/ui/CommunityRail.tsx b/desktop/src/features/sidebar/ui/CommunityRail.tsx
index a87c8e80b..26a0b324e 100644
--- a/desktop/src/features/sidebar/ui/CommunityRail.tsx
+++ b/desktop/src/features/sidebar/ui/CommunityRail.tsx
@@ -48,7 +48,6 @@ type CommunityRailProps = {
id: string,
updates: Partial>,
) => void;
- onRemoveCommunity: (id: string) => void;
onReorderCommunities: (orderedIds: string[]) => void;
};
@@ -305,7 +304,6 @@ export function CommunityRail({
onSwitchCommunity,
onAddCommunity,
onUpdateCommunity,
- onRemoveCommunity,
onReorderCommunities,
}: CommunityRailProps) {
const { unreadByCommunity, markCommunityRead } = useCommunityUnread(
@@ -423,11 +421,9 @@ export function CommunityRail({
Add community
1}
onOpenChange={(open) => {
if (!open) setEditingCommunity(null);
}}
- onRemove={onRemoveCommunity}
onSave={onUpdateCommunity}
open={editingCommunity !== null}
community={editingCommunity}
diff --git a/desktop/src/features/sidebar/ui/SidebarProfileCard.tsx b/desktop/src/features/sidebar/ui/SidebarProfileCard.tsx
index 9c6ba0f9b..80ced6570 100644
--- a/desktop/src/features/sidebar/ui/SidebarProfileCard.tsx
+++ b/desktop/src/features/sidebar/ui/SidebarProfileCard.tsx
@@ -10,6 +10,7 @@ import {
} from "@/features/profile/ui/MaskedAvatarBadgeFrame";
import { ProfilePopover } from "@/features/profile/ui/ProfilePopover";
import { StatusEmoji } from "@/features/user-status/ui/StatusEmoji";
+import type { LeaveCommunityResult } from "@/features/communities/leaveCommunity";
import type { Community } from "@/features/communities/types";
import { CommunitySwitcher } from "@/features/communities/ui/CommunitySwitcher";
import { useMyRelayMembershipLookupQuery } from "@/features/community-members/hooks";
@@ -22,7 +23,7 @@ type SidebarProfileCardProps = {
isPresencePending?: boolean;
onOpenAddCommunity: () => void;
onOpenSettings: (section?: SettingsSection) => void;
- onRemoveCommunity: (id: string) => void;
+ onRemoveCommunity: (id: string) => Promise;
onSendFeedback?: () => void;
onSetPresenceStatus?: (status: PresenceStatus) => void;
onSetUserStatus: (text: string, emoji: string) => void;
diff --git a/desktop/src/shared/api/relayMembers.ts b/desktop/src/shared/api/relayMembers.ts
index 62cae9005..bc2a473a3 100644
--- a/desktop/src/shared/api/relayMembers.ts
+++ b/desktop/src/shared/api/relayMembers.ts
@@ -127,8 +127,10 @@ export async function listRelayMembers(): Promise {
return event ? relayMembersFromEvent(event) : [];
}
-async function relayRequiresMembership(): Promise {
- return invokeTauri("relay_requires_membership");
+export async function relayRequiresMembership(
+ relayUrl?: string,
+): Promise {
+ return invokeTauri("relay_requires_membership", { relayUrl });
}
export async function getMyRelayMembershipLookup(): Promise {
diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts
index 204f67f51..b88aed1fd 100644
--- a/desktop/src/testing/e2eBridge.ts
+++ b/desktop/src/testing/e2eBridge.ts
@@ -9690,6 +9690,11 @@ function sendToMockSocket(args: {
if (type === "EVENT") {
const event = rest[0] as RelayEvent;
+ if (event.kind === 28936) {
+ sendWsText(socket.handler, ["OK", event.id, true, ""]);
+ return;
+ }
+
if ([9030, 9031, 9032].includes(event.kind)) {
const accepted = updateMockRelayMembershipFromAdminEvent(event);
sendWsText(socket.handler, [
diff --git a/desktop/tests/e2e/community-rail.spec.ts b/desktop/tests/e2e/community-rail.spec.ts
index b432d17f2..19bcfc23e 100644
--- a/desktop/tests/e2e/community-rail.spec.ts
+++ b/desktop/tests/e2e/community-rail.spec.ts
@@ -230,6 +230,9 @@ test.describe("community rail", () => {
await expect(
menu.getByRole("menuitem", { name: "Community settings" }),
).toBeVisible();
+ await expect(
+ menu.getByRole("menuitem", { name: "Leave community" }),
+ ).toBeVisible();
await expect(
menu.getByRole("menuitem", { name: "Add a community" }),
).toBeVisible();
@@ -290,6 +293,9 @@ test.describe("community rail", () => {
await expect(
page.getByRole("dialog", { name: "Edit Community" }),
).toBeVisible();
+ await expect(
+ page.getByRole("button", { name: "Leave Community" }),
+ ).toHaveCount(0);
});
test("switches the active community on click", async ({ page }) => {
@@ -717,11 +723,12 @@ test.describe("community rail", () => {
await page.getByTestId(`community-rail-button-${COMMUNITY_A.id}`).click();
await page.getByTestId("channel-general").click();
+ await page.getByTestId("sidebar-profile-avatar-button").click();
+ await page.getByTestId("community-switcher").click();
await page
- .getByTestId(`community-rail-button-${COMMUNITY_A.id}`)
- .click({ button: "right" });
- await page.getByRole("menuitem", { name: "Community settings" }).click();
- await page.getByRole("button", { name: "Remove Community" }).click();
+ .getByRole("menu", { name: "Community actions" })
+ .getByRole("menuitem", { name: "Leave community" })
+ .click();
await expect(page).toHaveURL(randomUrl);
await expect
@@ -761,6 +768,74 @@ test.describe("community rail", () => {
await expect(buttonB).toHaveAttribute("aria-current", "true");
});
+ test("leaving the final community returns to setup without resetting identity", async ({
+ context,
+ page,
+ }) => {
+ await installMockBridge(page, undefined, {
+ autoConnectDefaultRelay: true,
+ skipCommunitySeed: true,
+ });
+ await seedCommunities(page, [COMMUNITY_A], COMMUNITY_A.id);
+ await page.goto("/");
+
+ await expect
+ .poll(() =>
+ page.evaluate(() => typeof window.__BUZZ_E2E_INVOKE_MOCK_COMMAND__),
+ )
+ .toBe("function");
+ const identityBefore = await page.evaluate(async () =>
+ window.__BUZZ_E2E_INVOKE_MOCK_COMMAND__("get_identity"),
+ );
+ await page.getByTestId("sidebar-profile-avatar-button").click();
+ await page.getByTestId("community-switcher").click();
+ await page
+ .getByRole("menu", { name: "Community actions" })
+ .getByRole("menuitem", { name: "Leave community" })
+ .click();
+
+ await expect(page.getByText("Join or create a community")).toBeVisible();
+ await expect(page.getByTestId("welcome-setup-back")).toHaveCount(0);
+ await expect(page.getByTestId("community-choice-join")).toBeVisible();
+ await expect
+ .poll(() =>
+ page.evaluate(() => window.localStorage.getItem("buzz-communities")),
+ )
+ .toBeNull();
+ await expect
+ .poll(() =>
+ page.evaluate(() =>
+ window.localStorage.getItem("buzz-community-discovery-after-leave"),
+ ),
+ )
+ .toBe("1");
+
+ const relaunchPage = await context.newPage();
+ await installMockBridge(relaunchPage, undefined, {
+ autoConnectDefaultRelay: true,
+ skipCommunitySeed: true,
+ });
+ await relaunchPage.goto("/");
+ await expect(
+ relaunchPage.getByText("Join or create a community"),
+ ).toBeVisible();
+ await expect(relaunchPage.getByTestId("welcome-setup-back")).toHaveCount(0);
+ await expect
+ .poll(() =>
+ relaunchPage.evaluate(() =>
+ window.localStorage.getItem("buzz-communities"),
+ ),
+ )
+ .toBeNull();
+ await expect
+ .poll(() =>
+ relaunchPage.evaluate(async () =>
+ window.__BUZZ_E2E_INVOKE_MOCK_COMMAND__("get_identity"),
+ ),
+ )
+ .toEqual(identityBefore);
+ });
+
test("hides the rail with a single community", async ({ page }) => {
await installMockBridge(page, undefined, { skipCommunitySeed: true });
await seedCommunities(page, [COMMUNITY_A], COMMUNITY_A.id);