From 10a717b4f544cca4a08ec76d7db6bc7a014a9116 Mon Sep 17 00:00:00 2001
From: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w
<52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@buzz.block.builderlab.xyz>
Date: Wed, 29 Jul 2026 14:06:52 -0700
Subject: [PATCH 1/9] feat(desktop): leave communities through relay
Co-authored-by: Taylor Ho
Signed-off-by: Taylor Ho
---
.../app/useCommunityNavigationTransitions.ts | 21 +++-
.../communities/leaveCommunity.test.mjs | 118 ++++++++++++++++++
.../features/communities/leaveCommunity.ts | 59 +++++++++
.../resolveCommunityRemoval.test.mjs | 28 +++++
.../communities/ui/CommunitySwitcher.tsx | 4 +-
.../communities/ui/EditCommunityDialog.tsx | 48 +++++--
.../features/communities/useCommunities.tsx | 70 +++++++----
.../src/features/sidebar/ui/AppSidebar.tsx | 2 +-
.../src/features/sidebar/ui/CommunityRail.tsx | 4 +-
.../sidebar/ui/SidebarProfileCard.tsx | 2 +-
desktop/src/testing/e2eBridge.ts | 5 +
desktop/tests/e2e/community-rail.spec.ts | 35 +++++-
12 files changed, 352 insertions(+), 44 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/app/useCommunityNavigationTransitions.ts b/desktop/src/app/useCommunityNavigationTransitions.ts
index 88acb30ad0..3e123c50c3 100644
--- a/desktop/src/app/useCommunityNavigationTransitions.ts
+++ b/desktop/src/app/useCommunityNavigationTransitions.ts
@@ -13,6 +13,7 @@ import {
saveCommunityDestination,
} from "@/features/communities/communityNavigationStorage";
import type { useCommunities } from "@/features/communities/useCommunities";
+import { leaveCommunity } from "@/features/communities/leaveCommunity";
type Communities = ReturnType;
type ShellRoute = ReturnType;
@@ -71,6 +72,19 @@ export function useCommunityNavigationTransitions({
const removeCommunity = React.useCallback(
async (id: string) => {
+ const target = communities.communities.find(
+ (community) => community.id === id,
+ );
+ if (!target) 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.
+ await leaveCommunity(
+ target.relayUrl,
+ communities.activeCommunity?.relayUrl,
+ );
+
if (id !== communities.activeCommunity?.id) {
communities.removeCommunity(id);
return;
@@ -78,7 +92,12 @@ export function useCommunityNavigationTransitions({
const fallback = communities.communities.find(
(community) => community.id !== id,
);
- if (!fallback) return;
+
+ if (!fallback) {
+ await goHome({ replace: true });
+ communities.removeCommunity(id);
+ return;
+ }
await runCommunityViewTransition(async () => {
saveActiveDestination();
diff --git a/desktop/src/features/communities/leaveCommunity.test.mjs b/desktop/src/features/communities/leaveCommunity.test.mjs
new file mode 100644
index 0000000000..46bbd5e7d2
--- /dev/null
+++ b/desktop/src/features/communities/leaveCommunity.test.mjs
@@ -0,0 +1,118 @@
+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 {
+ sign: async (input) => ({ ...signedEvent, ...input }),
+ publishActive: async () => {},
+ createRelayClient: () => ({
+ publishEvent: async () => {},
+ disconnect() {},
+ }),
+ ...overrides,
+ };
+}
+
+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("preserves relay rejection 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 0000000000..c1aca1e73f
--- /dev/null
+++ b/desktop/src/features/communities/leaveCommunity.ts
@@ -0,0 +1,59 @@
+import { relayClient } from "@/shared/api/relayClient";
+import { ReadOnlyRelayClient } from "@/shared/api/readOnlyRelayClient";
+import { signRelayEvent } from "@/shared/api/tauri";
+import type { RelayEvent } from "@/shared/api/types";
+
+export const KIND_NIP43_LEAVE_REQUEST = 28936;
+
+type LeaveCommunityDependencies = {
+ sign: typeof signRelayEvent;
+ publishActive: (event: RelayEvent) => Promise;
+ createRelayClient: (relayUrl: string) => {
+ publishEvent: (event: RelayEvent) => Promise;
+ disconnect: () => void;
+ };
+};
+
+const defaultDependencies: LeaveCommunityDependencies = {
+ sign: signRelayEvent,
+ publishActive: (event) =>
+ relayClient.publishEvent(
+ event,
+ "Timed out while leaving the community. Try again.",
+ "Failed to send the leave request. Check your connection and try again.",
+ ),
+ createRelayClient: (relayUrl) => new ReadOnlyRelayClient(relayUrl),
+};
+
+/** 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 {
+ const event = await dependencies.sign({
+ kind: KIND_NIP43_LEAVE_REQUEST,
+ content: "",
+ tags: [["-"]],
+ });
+
+ if (relayUrl === activeRelayUrl) {
+ await dependencies.publishActive(event);
+ return;
+ }
+
+ const client = dependencies.createRelayClient(relayUrl);
+ try {
+ await 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 0000000000..6d24ee4db1
--- /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 985d28fa8e..d4becf1d6c 100644
--- a/desktop/src/features/communities/ui/CommunitySwitcher.tsx
+++ b/desktop/src/features/communities/ui/CommunitySwitcher.tsx
@@ -57,7 +57,7 @@ type CommunitySwitcherProps = {
id: string,
updates: Partial>,
) => void;
- onRemoveCommunity: (id: string) => void;
+ onRemoveCommunity: (id: string) => Promise;
};
export function CommunityEmojiIcon({
@@ -391,7 +391,7 @@ export function CommunitySwitcher({
)}
1}
+ canRemove
onOpenChange={(open) => {
if (!open) setEditingCommunity(null);
}}
diff --git a/desktop/src/features/communities/ui/EditCommunityDialog.tsx b/desktop/src/features/communities/ui/EditCommunityDialog.tsx
index 3b963979bc..de9811ede3 100644
--- a/desktop/src/features/communities/ui/EditCommunityDialog.tsx
+++ b/desktop/src/features/communities/ui/EditCommunityDialog.tsx
@@ -28,7 +28,7 @@ type EditCommunityDialogProps = {
Pick
>,
) => void;
- onRemove?: (id: string) => void;
+ onRemove?: (id: string) => Promise;
canRemove?: boolean;
showIconEditor?: boolean;
};
@@ -47,6 +47,8 @@ export function EditCommunityDialog({
const [token, setToken] = React.useState("");
const [reposDir, setReposDir] = React.useState("");
const [reposDirError, setReposDirError] = React.useState(null);
+ const [leaveError, setLeaveError] = React.useState(null);
+ const [isLeaving, setIsLeaving] = React.useState(false);
const membershipQuery = useMyRelayMembershipLookupQuery();
const activeRole = membershipQuery.data?.membership?.role;
const canEditIcon =
@@ -63,6 +65,8 @@ export function EditCommunityDialog({
setToken(community.token ?? "");
setReposDir(community.reposDir ?? "");
setReposDirError(null);
+ setLeaveError(null);
+ setIsLeaving(false);
}
}, [community, open]);
@@ -122,12 +126,24 @@ export function EditCommunityDialog({
[community, name, relayUrl, token, reposDir, onSave, handleClose],
);
- const handleRemove = React.useCallback(() => {
- if (community && onRemove) {
- onRemove(community.id);
+ const handleRemove = React.useCallback(async () => {
+ if (!community || !onRemove || isLeaving) return;
+
+ setIsLeaving(true);
+ setLeaveError(null);
+ try {
+ await onRemove(community.id);
handleClose();
+ } catch (error) {
+ setLeaveError(
+ error instanceof Error
+ ? error.message
+ : "Could not leave the community. Try again.",
+ );
+ } finally {
+ setIsLeaving(false);
}
- }, [community, onRemove, handleClose]);
+ }, [community, onRemove, isLeaving, handleClose]);
if (!community) {
return null;
@@ -235,25 +251,39 @@ export function EditCommunityDialog({
the default location.
+ {leaveError ? (
+
+ {leaveError}
+
+ ) : null}
{canRemove && onRemove ? (
) : null}
-
diff --git a/desktop/src/features/communities/useCommunities.tsx b/desktop/src/features/communities/useCommunities.tsx
index 9051552ad1..e0a1001788 100644
--- a/desktop/src/features/communities/useCommunities.tsx
+++ b/desktop/src/features/communities/useCommunities.tsx
@@ -113,6 +113,23 @@ export function applyCommunitiesOrder(
return reordered;
}
+export type CommunityRemovalResult = {
+ communities: Community[];
+ activeId: string | null;
+};
+
+export function resolveCommunityRemoval(
+ communities: Community[],
+ activeId: string | null,
+ id: string,
+): CommunityRemovalResult {
+ const next = communities.filter((community) => 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);
-
- // If removing the active community, switch to first remaining
- if (activeId === id && next.length > 0) {
- saveActiveCommunityId(next[0].id);
- setActiveId(next[0].id);
+ const result = resolveCommunityRemoval(prev, activeId, id);
+ if (result.communities.length === 0) {
+ clearCommunityStorage();
+ setActiveId(null);
+ } else {
+ saveCommunities(result.communities);
+
+ 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/sidebar/ui/AppSidebar.tsx b/desktop/src/features/sidebar/ui/AppSidebar.tsx
index 7d9472f709..66e7b8b69d 100644
--- a/desktop/src/features/sidebar/ui/AppSidebar.tsx
+++ b/desktop/src/features/sidebar/ui/AppSidebar.tsx
@@ -138,7 +138,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 a87c8e80ba..58f11d30ea 100644
--- a/desktop/src/features/sidebar/ui/CommunityRail.tsx
+++ b/desktop/src/features/sidebar/ui/CommunityRail.tsx
@@ -48,7 +48,7 @@ type CommunityRailProps = {
id: string,
updates: Partial>,
) => void;
- onRemoveCommunity: (id: string) => void;
+ onRemoveCommunity: (id: string) => Promise;
onReorderCommunities: (orderedIds: string[]) => void;
};
@@ -423,7 +423,7 @@ export function CommunityRail({
Add community
1}
+ canRemove
onOpenChange={(open) => {
if (!open) setEditingCommunity(null);
}}
diff --git a/desktop/src/features/sidebar/ui/SidebarProfileCard.tsx b/desktop/src/features/sidebar/ui/SidebarProfileCard.tsx
index 9c6ba0f9b6..dcd4a97f13 100644
--- a/desktop/src/features/sidebar/ui/SidebarProfileCard.tsx
+++ b/desktop/src/features/sidebar/ui/SidebarProfileCard.tsx
@@ -22,7 +22,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/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts
index cf05ce6f19..e8dd86d673 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 b432d17f2a..ccc8bd60fd 100644
--- a/desktop/tests/e2e/community-rail.spec.ts
+++ b/desktop/tests/e2e/community-rail.spec.ts
@@ -721,7 +721,7 @@ test.describe("community rail", () => {
.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();
+ await page.getByRole("button", { name: "Leave Community" }).click();
await expect(page).toHaveURL(randomUrl);
await expect
@@ -761,6 +761,39 @@ test.describe("community rail", () => {
await expect(buttonB).toHaveAttribute("aria-current", "true");
});
+ test("leaving the final community returns to setup without resetting identity", async ({
+ page,
+ }) => {
+ await installMockBridge(page, undefined, { skipCommunitySeed: true });
+ await seedCommunities(page, [COMMUNITY_A], COMMUNITY_A.id);
+ await page.goto("/");
+
+ const identityBefore = await page.evaluate(async () =>
+ window.__TAURI_INTERNALS__.invoke("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: "Community settings" })
+ .click();
+ await page.getByRole("button", { name: "Leave Community" }).click();
+
+ await expect(page.getByText("Join or create a community")).toBeVisible();
+ await expect
+ .poll(() =>
+ page.evaluate(() => window.localStorage.getItem("buzz-communities")),
+ )
+ .toBeNull();
+ await expect
+ .poll(() =>
+ page.evaluate(async () =>
+ window.__TAURI_INTERNALS__.invoke("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);
From 58a91ffbc33cb6ea8bc8540c65d5d982b898ae2a Mon Sep 17 00:00:00 2001
From: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w
<52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@buzz.block.builderlab.xyz>
Date: Thu, 30 Jul 2026 00:47:07 -0700
Subject: [PATCH 2/9] fix(desktop): focus final leave on community discovery
Co-authored-by: Taylor Ho
Signed-off-by: Taylor Ho
---
desktop/src/app/App.tsx | 10 ++++++-
.../features/communities/ui/WelcomeSetup.tsx | 26 ++++++++++---------
desktop/tests/e2e/community-rail.spec.ts | 2 ++
3 files changed, 25 insertions(+), 13 deletions(-)
diff --git a/desktop/src/app/App.tsx b/desktop/src/app/App.tsx
index 104edcbaaf..e8480cfab4 100644
--- a/desktop/src/app/App.tsx
+++ b/desktop/src/app/App.tsx
@@ -319,6 +319,12 @@ function CommunityApp({
const [isCommunityChangeOpen, setIsCommunityChangeOpen] = useState(false);
const [resumeFirstCommunityPage, setResumeFirstCommunityPage] =
useState(null);
+ const hasConfiguredCommunityRef = useRef(activeCommunity !== null);
+ if (activeCommunity) {
+ hasConfiguredCommunityRef.current = true;
+ }
+ const isFindingCommunityAfterLeave =
+ activeCommunity === null && hasConfiguredCommunityRef.current;
// Surface nest-related backend events (repos-dir errors, legacy migration)
// as toasts. Mounted before useCommunityInit so the listeners are registered
@@ -512,7 +518,9 @@ function CommunityApp({
appContent = (
);
} else if ("error" in community && community.error) {
diff --git a/desktop/src/features/communities/ui/WelcomeSetup.tsx b/desktop/src/features/communities/ui/WelcomeSetup.tsx
index bde1458222..530933acc2 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({
-
-
- Back
-
-
+ {onBack ? (
+
+
+ Back
+
+
+ ) : null}
) : page === "existing" ? (
{
await page.getByRole("button", { 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")),
From 8645a09811126ba286441544cab20cef3f5a104c Mon Sep 17 00:00:00 2001
From: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w
<52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@buzz.block.builderlab.xyz>
Date: Sat, 1 Aug 2026 21:37:35 -0700
Subject: [PATCH 3/9] fix(desktop): move leave action to community menu
Co-authored-by: Taylor Ho
Signed-off-by: Taylor Ho
---
.../communities/ui/CommunitySwitcher.tsx | 47 ++++++++++++-
.../communities/ui/EditCommunityDialog.tsx | 70 ++-----------------
.../src/features/sidebar/ui/CommunityRail.tsx | 4 --
desktop/tests/e2e/community-rail.spec.ts | 18 +++--
4 files changed, 64 insertions(+), 75 deletions(-)
diff --git a/desktop/src/features/communities/ui/CommunitySwitcher.tsx b/desktop/src/features/communities/ui/CommunitySwitcher.tsx
index d4becf1d6c..01cb817dcb 100644
--- a/desktop/src/features/communities/ui/CommunitySwitcher.tsx
+++ b/desktop/src/features/communities/ui/CommunitySwitcher.tsx
@@ -6,6 +6,7 @@ import {
MoreHorizontal,
Plus,
Settings2,
+ LogOut,
Ticket,
WifiOff,
} from "lucide-react";
@@ -103,6 +104,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 +150,30 @@ 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 {
+ await onRemoveCommunity(activeCommunity.id);
+ setDropdownOpen(false);
+ } catch (error) {
+ setLeaveError(
+ error instanceof Error
+ ? error.message
+ : "Could not leave the community. Try again.",
+ );
+ setDropdownOpen(true);
+ } finally {
+ setIsLeaving(false);
+ }
+ }, [activeCommunity, isLeaving, onRemoveCommunity]);
+
const triggerContent = (
<>
{degraded ? (
@@ -278,6 +305,24 @@ export function CommunitySwitcher({
Community settings
+ void handleLeaveCommunity()}
+ role="menuitem"
+ type="button"
+ >
+
+ {isLeaving ? "Leaving…" : "Leave community"}
+
+ {leaveError ? (
+
+ {leaveError}
+
+ ) : null}
>
) : null}
@@ -391,11 +436,9 @@ export function CommunitySwitcher({
)}
{
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 de9811ede3..3baa52372e 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) => Promise;
- canRemove?: boolean;
showIconEditor?: boolean;
};
@@ -38,8 +36,6 @@ export function EditCommunityDialog({
open,
onOpenChange,
onSave,
- onRemove,
- canRemove,
showIconEditor = false,
}: EditCommunityDialogProps) {
const [name, setName] = React.useState("");
@@ -47,8 +43,6 @@ export function EditCommunityDialog({
const [token, setToken] = React.useState("");
const [reposDir, setReposDir] = React.useState("");
const [reposDirError, setReposDirError] = React.useState(null);
- const [leaveError, setLeaveError] = React.useState(null);
- const [isLeaving, setIsLeaving] = React.useState(false);
const membershipQuery = useMyRelayMembershipLookupQuery();
const activeRole = membershipQuery.data?.membership?.role;
const canEditIcon =
@@ -65,8 +59,6 @@ export function EditCommunityDialog({
setToken(community.token ?? "");
setReposDir(community.reposDir ?? "");
setReposDirError(null);
- setLeaveError(null);
- setIsLeaving(false);
}
}, [community, open]);
@@ -126,25 +118,6 @@ export function EditCommunityDialog({
[community, name, relayUrl, token, reposDir, onSave, handleClose],
);
- const handleRemove = React.useCallback(async () => {
- if (!community || !onRemove || isLeaving) return;
-
- setIsLeaving(true);
- setLeaveError(null);
- try {
- await onRemove(community.id);
- handleClose();
- } catch (error) {
- setLeaveError(
- error instanceof Error
- ? error.message
- : "Could not leave the community. Try again.",
- );
- } finally {
- setIsLeaving(false);
- }
- }, [community, onRemove, isLeaving, handleClose]);
-
if (!community) {
return null;
}
@@ -251,42 +224,13 @@ export function EditCommunityDialog({
the default location.
- {leaveError ? (
-
- {leaveError}
-
- ) : null}
-
-
- {canRemove && onRemove ? (
- void handleRemove()}
- size="sm"
- type="button"
- variant="ghost"
- >
- {isLeaving ? "Leaving…" : "Leave Community"}
-
- ) : null}
-
-
-
- Cancel
-
-
- Save Changes
-
-
+
+
+ Cancel
+
+
+ Save Changes
+
diff --git a/desktop/src/features/sidebar/ui/CommunityRail.tsx b/desktop/src/features/sidebar/ui/CommunityRail.tsx
index 58f11d30ea..26a0b324e0 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) => Promise;
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
{
if (!open) setEditingCommunity(null);
}}
- onRemove={onRemoveCommunity}
onSave={onUpdateCommunity}
open={editingCommunity !== null}
community={editingCommunity}
diff --git a/desktop/tests/e2e/community-rail.spec.ts b/desktop/tests/e2e/community-rail.spec.ts
index 01d7d5a54e..d1284f1c6a 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: "Leave Community" }).click();
+ .getByRole("menu", { name: "Community actions" })
+ .getByRole("menuitem", { name: "Leave community" })
+ .click();
await expect(page).toHaveURL(randomUrl);
await expect
@@ -775,9 +782,8 @@ test.describe("community rail", () => {
await page.getByTestId("community-switcher").click();
await page
.getByRole("menu", { name: "Community actions" })
- .getByRole("menuitem", { name: "Community settings" })
+ .getByRole("menuitem", { name: "Leave community" })
.click();
- await page.getByRole("button", { name: "Leave Community" }).click();
await expect(page.getByText("Join or create a community")).toBeVisible();
await expect(page.getByTestId("welcome-setup-back")).toHaveCount(0);
From 86fdcf6dcb8d5964f3d6ed25acd5d0bcd3c750c1 Mon Sep 17 00:00:00 2001
From: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w
<52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@buzz.block.builderlab.xyz>
Date: Mon, 3 Aug 2026 15:48:41 -0700
Subject: [PATCH 4/9] fix(desktop): remove open communities locally
Co-authored-by: Taylor Ho
Signed-off-by: Taylor Ho
---
.../src-tauri/src/commands/relay_members.rs | 11 +++++++--
.../communities/leaveCommunity.test.mjs | 23 +++++++++++++++++++
.../features/communities/leaveCommunity.ts | 5 ++++
desktop/src/shared/api/relayMembers.ts | 6 +++--
4 files changed, 41 insertions(+), 4 deletions(-)
diff --git a/desktop/src-tauri/src/commands/relay_members.rs b/desktop/src-tauri/src/commands/relay_members.rs
index a9230dff95..9ccf8baac0 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/features/communities/leaveCommunity.test.mjs b/desktop/src/features/communities/leaveCommunity.test.mjs
index 46bbd5e7d2..d1f580386b 100644
--- a/desktop/src/features/communities/leaveCommunity.test.mjs
+++ b/desktop/src/features/communities/leaveCommunity.test.mjs
@@ -15,6 +15,7 @@ const signedEvent = {
function dependencies(overrides = {}) {
return {
+ requiresMembership: async () => true,
sign: async (input) => ({ ...signedEvent, ...input }),
publishActive: async () => {},
createRelayClient: () => ({
@@ -25,6 +26,28 @@ function dependencies(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;
diff --git a/desktop/src/features/communities/leaveCommunity.ts b/desktop/src/features/communities/leaveCommunity.ts
index c1aca1e73f..ab957d1887 100644
--- a/desktop/src/features/communities/leaveCommunity.ts
+++ b/desktop/src/features/communities/leaveCommunity.ts
@@ -1,11 +1,13 @@
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) => {
@@ -15,6 +17,7 @@ type LeaveCommunityDependencies = {
};
const defaultDependencies: LeaveCommunityDependencies = {
+ requiresMembership: relayRequiresMembership,
sign: signRelayEvent,
publishActive: (event) =>
relayClient.publishEvent(
@@ -31,6 +34,8 @@ export async function leaveCommunity(
activeRelayUrl: string | undefined,
dependencies: LeaveCommunityDependencies = defaultDependencies,
): Promise {
+ if (!(await dependencies.requiresMembership(relayUrl))) return;
+
const event = await dependencies.sign({
kind: KIND_NIP43_LEAVE_REQUEST,
content: "",
diff --git a/desktop/src/shared/api/relayMembers.ts b/desktop/src/shared/api/relayMembers.ts
index 62cae9005a..bc2a473a33 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 {
From 747616b5e44e65ea86ae33a0927394adadb1f1bc Mon Sep 17 00:00:00 2001
From: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w
<52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@buzz.block.builderlab.xyz>
Date: Mon, 3 Aug 2026 15:48:49 -0700
Subject: [PATCH 5/9] fix(desktop): isolate final community leave state
Co-authored-by: Taylor Ho
Signed-off-by: Taylor Ho
---
desktop/src/app/App.tsx | 1 +
.../features/communities/useCommunityInit.ts | 18 +++++++++++++++---
desktop/tests/e2e/community-rail.spec.ts | 5 ++++-
3 files changed, 20 insertions(+), 4 deletions(-)
diff --git a/desktop/src/app/App.tsx b/desktop/src/app/App.tsx
index e8480cfab4..96eda8ca62 100644
--- a/desktop/src/app/App.tsx
+++ b/desktop/src/app/App.tsx
@@ -349,6 +349,7 @@ function CommunityApp({
activeCommunity,
communityKey,
sharedIdentity,
+ isFindingCommunityAfterLeave,
);
const transitionCommunity = useCallback(
diff --git a/desktop/src/features/communities/useCommunityInit.ts b/desktop/src/features/communities/useCommunityInit.ts
index dd25ec074e..baf1261310 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/tests/e2e/community-rail.spec.ts b/desktop/tests/e2e/community-rail.spec.ts
index d1284f1c6a..abb736915a 100644
--- a/desktop/tests/e2e/community-rail.spec.ts
+++ b/desktop/tests/e2e/community-rail.spec.ts
@@ -771,7 +771,10 @@ test.describe("community rail", () => {
test("leaving the final community returns to setup without resetting identity", async ({
page,
}) => {
- await installMockBridge(page, undefined, { skipCommunitySeed: true });
+ await installMockBridge(page, undefined, {
+ autoConnectDefaultRelay: true,
+ skipCommunitySeed: true,
+ });
await seedCommunities(page, [COMMUNITY_A], COMMUNITY_A.id);
await page.goto("/");
From fd8a13ab76ef5179975d6b7047441f2ca7097e1c Mon Sep 17 00:00:00 2001
From: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w
<52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@buzz.block.builderlab.xyz>
Date: Mon, 3 Aug 2026 17:16:00 -0700
Subject: [PATCH 6/9] fix(desktop): clean up absent relay memberships
Co-authored-by: Taylor Ho
Signed-off-by: Taylor Ho
---
.../communities/leaveCommunity.test.mjs | 33 ++++++++++++++++++-
.../features/communities/leaveCommunity.ts | 23 +++++++++++--
2 files changed, 53 insertions(+), 3 deletions(-)
diff --git a/desktop/src/features/communities/leaveCommunity.test.mjs b/desktop/src/features/communities/leaveCommunity.test.mjs
index d1f580386b..b17cfc40a9 100644
--- a/desktop/src/features/communities/leaveCommunity.test.mjs
+++ b/desktop/src/features/communities/leaveCommunity.test.mjs
@@ -98,7 +98,38 @@ test("targets an inactive community relay and always disconnects", async () => {
]);
});
-test("preserves relay rejection and disconnects without falling through", async () => {
+test("treats an already-absent active membership as successful cleanup", async () => {
+ await leaveCommunity(
+ "wss://active.example",
+ "wss://active.example",
+ dependencies({
+ publishActive: async () => {
+ throw new Error("invalid: you are not a relay member");
+ },
+ }),
+ );
+});
+
+test("treats an already-absent inactive membership as successful cleanup and disconnects", async () => {
+ let disconnected = false;
+ 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.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;
diff --git a/desktop/src/features/communities/leaveCommunity.ts b/desktop/src/features/communities/leaveCommunity.ts
index ab957d1887..f16cdaa830 100644
--- a/desktop/src/features/communities/leaveCommunity.ts
+++ b/desktop/src/features/communities/leaveCommunity.ts
@@ -28,6 +28,23 @@ const defaultDependencies: LeaveCommunityDependencies = {
createRelayClient: (relayUrl) => new ReadOnlyRelayClient(relayUrl),
};
+function membershipIsAlreadyAbsent(error: unknown): boolean {
+ return (
+ error instanceof Error &&
+ error.message.toLowerCase().includes("not a relay member")
+ );
+}
+
+async function ignoreAlreadyAbsentMembership(
+ publish: () => Promise,
+): Promise {
+ try {
+ await publish();
+ } catch (error) {
+ if (!membershipIsAlreadyAbsent(error)) throw error;
+ }
+}
+
/** Revoke relay membership and resolve only after the relay accepts the request. */
export async function leaveCommunity(
relayUrl: string,
@@ -43,13 +60,15 @@ export async function leaveCommunity(
});
if (relayUrl === activeRelayUrl) {
- await dependencies.publishActive(event);
+ await ignoreAlreadyAbsentMembership(() =>
+ dependencies.publishActive(event),
+ );
return;
}
const client = dependencies.createRelayClient(relayUrl);
try {
- await client.publishEvent(event);
+ await ignoreAlreadyAbsentMembership(() => client.publishEvent(event));
} catch (error) {
if (
error instanceof Error &&
From 672f884dcaac1055eeafa3de4b9087505e2dfbeb Mon Sep 17 00:00:00 2001
From: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w
<52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@buzz.block.builderlab.xyz>
Date: Mon, 3 Aug 2026 17:16:04 -0700
Subject: [PATCH 7/9] fix(desktop): persist final leave discovery
Co-authored-by: Taylor Ho
Signed-off-by: Taylor Ho
---
desktop/src/app/App.tsx | 7 +---
.../app/useCommunityNavigationTransitions.ts | 13 +++++--
.../communities/communityStorage.test.mjs | 34 ++++++++++++++++-
.../features/communities/communityStorage.ts | 34 ++++++++++++++++-
desktop/tests/e2e/community-rail.spec.ts | 37 +++++++++++++++++--
5 files changed, 111 insertions(+), 14 deletions(-)
diff --git a/desktop/src/app/App.tsx b/desktop/src/app/App.tsx
index 96eda8ca62..ab269621ec 100644
--- a/desktop/src/app/App.tsx
+++ b/desktop/src/app/App.tsx
@@ -41,6 +41,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";
@@ -319,12 +320,8 @@ function CommunityApp({
const [isCommunityChangeOpen, setIsCommunityChangeOpen] = useState(false);
const [resumeFirstCommunityPage, setResumeFirstCommunityPage] =
useState(null);
- const hasConfiguredCommunityRef = useRef(activeCommunity !== null);
- if (activeCommunity) {
- hasConfiguredCommunityRef.current = true;
- }
const isFindingCommunityAfterLeave =
- activeCommunity === null && hasConfiguredCommunityRef.current;
+ activeCommunity === null && loadCommunityDiscoveryAfterLeave();
// Surface nest-related backend events (repos-dir errors, legacy migration)
// as toasts. Mounted before useCommunityInit so the listeners are registered
diff --git a/desktop/src/app/useCommunityNavigationTransitions.ts b/desktop/src/app/useCommunityNavigationTransitions.ts
index 3e123c50c3..d6e37f0054 100644
--- a/desktop/src/app/useCommunityNavigationTransitions.ts
+++ b/desktop/src/app/useCommunityNavigationTransitions.ts
@@ -12,6 +12,7 @@ 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";
@@ -77,6 +78,10 @@ export function useCommunityNavigationTransitions({
);
if (!target) return;
+ const fallback = communities.communities.find(
+ (community) => community.id !== id,
+ );
+
// 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.
@@ -89,11 +94,13 @@ export function useCommunityNavigationTransitions({
communities.removeCommunity(id);
return;
}
- const fallback = communities.communities.find(
- (community) => community.id !== id,
- );
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;
diff --git a/desktop/src/features/communities/communityStorage.test.mjs b/desktop/src/features/communities/communityStorage.test.mjs
index 633fccd202..fca1909137 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 4a99e1f2e0..7c8778712e 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/tests/e2e/community-rail.spec.ts b/desktop/tests/e2e/community-rail.spec.ts
index abb736915a..19bcfc23e5 100644
--- a/desktop/tests/e2e/community-rail.spec.ts
+++ b/desktop/tests/e2e/community-rail.spec.ts
@@ -769,6 +769,7 @@ test.describe("community rail", () => {
});
test("leaving the final community returns to setup without resetting identity", async ({
+ context,
page,
}) => {
await installMockBridge(page, undefined, {
@@ -778,8 +779,13 @@ test.describe("community rail", () => {
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.__TAURI_INTERNALS__.invoke("get_identity"),
+ window.__BUZZ_E2E_INVOKE_MOCK_COMMAND__("get_identity"),
);
await page.getByTestId("sidebar-profile-avatar-button").click();
await page.getByTestId("community-switcher").click();
@@ -798,8 +804,33 @@ test.describe("community rail", () => {
.toBeNull();
await expect
.poll(() =>
- page.evaluate(async () =>
- window.__TAURI_INTERNALS__.invoke("get_identity"),
+ 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);
From 597421b6d217e1b4859e86e97503dffaa4fe1fa8 Mon Sep 17 00:00:00 2001
From: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w
<52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@buzz.block.builderlab.xyz>
Date: Mon, 3 Aug 2026 17:17:39 -0700
Subject: [PATCH 8/9] fix(desktop): preserve leave handlers after merge
Co-authored-by: Taylor Ho
Signed-off-by: Taylor Ho
---
desktop/src/app/AppShell.tsx | 5 +----
1 file changed, 1 insertion(+), 4 deletions(-)
diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx
index fdb4907180..f765b843b3 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}
From 5b017fec4807b0985a0e998c19eae67344455e3f Mon Sep 17 00:00:00 2001
From: npub14ndfusear8wdpe4kss8h7juc7wjk78atnqzf63zvppcpneknv4sq6x9370
Date: Tue, 4 Aug 2026 14:28:58 -0700
Subject: [PATCH 9/9] Show stale community cleanup feedback
Co-authored-by: npub14ndfusear8wdpe4kss8h7juc7wjk78atnqzf63zvppcpneknv4sq6x9370
Signed-off-by: npub14ndfusear8wdpe4kss8h7juc7wjk78atnqzf63zvppcpneknv4sq6x9370
---
.../app/useCommunityNavigationTransitions.ts | 7 +++---
.../communities/leaveCommunity.test.mjs | 7 ++++--
.../features/communities/leaveCommunity.ts | 25 +++++++++++--------
.../communities/ui/CommunitySwitcher.tsx | 14 ++++++++---
.../src/features/sidebar/ui/AppSidebar.tsx | 3 ++-
.../sidebar/ui/SidebarProfileCard.tsx | 3 ++-
6 files changed, 39 insertions(+), 20 deletions(-)
diff --git a/desktop/src/app/useCommunityNavigationTransitions.ts b/desktop/src/app/useCommunityNavigationTransitions.ts
index d6e37f0054..d2cf39fbc7 100644
--- a/desktop/src/app/useCommunityNavigationTransitions.ts
+++ b/desktop/src/app/useCommunityNavigationTransitions.ts
@@ -85,14 +85,14 @@ export function useCommunityNavigationTransitions({
// 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.
- await leaveCommunity(
+ const leaveResult = await leaveCommunity(
target.relayUrl,
communities.activeCommunity?.relayUrl,
);
if (id !== communities.activeCommunity?.id) {
communities.removeCommunity(id);
- return;
+ return leaveResult;
}
if (!fallback) {
@@ -103,7 +103,7 @@ export function useCommunityNavigationTransitions({
}
await goHome({ replace: true });
communities.removeCommunity(id);
- return;
+ return leaveResult;
}
await runCommunityViewTransition(async () => {
@@ -119,6 +119,7 @@ export function useCommunityNavigationTransitions({
}
communities.removeCommunity(id);
});
+ return leaveResult;
},
[communities, goHome, router.history, saveActiveDestination],
);
diff --git a/desktop/src/features/communities/leaveCommunity.test.mjs b/desktop/src/features/communities/leaveCommunity.test.mjs
index b17cfc40a9..50f0775925 100644
--- a/desktop/src/features/communities/leaveCommunity.test.mjs
+++ b/desktop/src/features/communities/leaveCommunity.test.mjs
@@ -99,7 +99,7 @@ test("targets an inactive community relay and always disconnects", async () => {
});
test("treats an already-absent active membership as successful cleanup", async () => {
- await leaveCommunity(
+ const result = await leaveCommunity(
"wss://active.example",
"wss://active.example",
dependencies({
@@ -108,11 +108,13 @@ test("treats an already-absent active membership as successful cleanup", async (
},
}),
);
+
+ assert.deepEqual(result, { status: "already-absent" });
});
test("treats an already-absent inactive membership as successful cleanup and disconnects", async () => {
let disconnected = false;
- await leaveCommunity(
+ const result = await leaveCommunity(
"wss://inactive.example",
"wss://active.example",
dependencies({
@@ -126,6 +128,7 @@ test("treats an already-absent inactive membership as successful cleanup and dis
}),
}),
);
+ assert.deepEqual(result, { status: "already-absent" });
assert.equal(disconnected, true);
});
diff --git a/desktop/src/features/communities/leaveCommunity.ts b/desktop/src/features/communities/leaveCommunity.ts
index f16cdaa830..15e3f8ba8d 100644
--- a/desktop/src/features/communities/leaveCommunity.ts
+++ b/desktop/src/features/communities/leaveCommunity.ts
@@ -23,7 +23,7 @@ const defaultDependencies: LeaveCommunityDependencies = {
relayClient.publishEvent(
event,
"Timed out while leaving the community. Try again.",
- "Failed to send the leave request. Check your connection and try again.",
+ "Couldn't send the leave request. Check your connection and try again.",
),
createRelayClient: (relayUrl) => new ReadOnlyRelayClient(relayUrl),
};
@@ -35,13 +35,19 @@ function membershipIsAlreadyAbsent(error: unknown): boolean {
);
}
-async function ignoreAlreadyAbsentMembership(
+export type LeaveCommunityResult =
+ | { status: "left" }
+ | { status: "already-absent" };
+
+async function publishLeaveRequest(
publish: () => Promise,
-): Promise {
+): Promise {
try {
await publish();
+ return { status: "left" };
} catch (error) {
if (!membershipIsAlreadyAbsent(error)) throw error;
+ return { status: "already-absent" };
}
}
@@ -50,8 +56,10 @@ export async function leaveCommunity(
relayUrl: string,
activeRelayUrl: string | undefined,
dependencies: LeaveCommunityDependencies = defaultDependencies,
-): Promise {
- if (!(await dependencies.requiresMembership(relayUrl))) return;
+): Promise {
+ if (!(await dependencies.requiresMembership(relayUrl))) {
+ return { status: "left" };
+ }
const event = await dependencies.sign({
kind: KIND_NIP43_LEAVE_REQUEST,
@@ -60,15 +68,12 @@ export async function leaveCommunity(
});
if (relayUrl === activeRelayUrl) {
- await ignoreAlreadyAbsentMembership(() =>
- dependencies.publishActive(event),
- );
- return;
+ return publishLeaveRequest(() => dependencies.publishActive(event));
}
const client = dependencies.createRelayClient(relayUrl);
try {
- await ignoreAlreadyAbsentMembership(() => client.publishEvent(event));
+ return await publishLeaveRequest(() => client.publishEvent(event));
} catch (error) {
if (
error instanceof Error &&
diff --git a/desktop/src/features/communities/ui/CommunitySwitcher.tsx b/desktop/src/features/communities/ui/CommunitySwitcher.tsx
index 01cb817dcb..cd530e6908 100644
--- a/desktop/src/features/communities/ui/CommunitySwitcher.tsx
+++ b/desktop/src/features/communities/ui/CommunitySwitcher.tsx
@@ -11,7 +11,9 @@ import {
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,
@@ -58,7 +60,7 @@ type CommunitySwitcherProps = {
id: string,
updates: Partial>,
) => void;
- onRemoveCommunity: (id: string) => Promise;
+ onRemoveCommunity: (id: string) => Promise;
};
export function CommunityEmojiIcon({
@@ -160,13 +162,19 @@ export function CommunitySwitcher({
setIsLeaving(true);
setLeaveError(null);
try {
- await onRemoveCommunity(activeCommunity.id);
+ 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
- : "Could not leave the community. Try again.",
+ : "Couldn't leave the community. Try again.",
);
setDropdownOpen(true);
} finally {
diff --git a/desktop/src/features/sidebar/ui/AppSidebar.tsx b/desktop/src/features/sidebar/ui/AppSidebar.tsx
index 66e7b8b69d..753451f92a 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) => Promise;
+ onRemoveCommunity: (id: string) => Promise;
onCreateAgent: () => void;
onSelectAgents: () => void;
onSelectProjects: () => void;
diff --git a/desktop/src/features/sidebar/ui/SidebarProfileCard.tsx b/desktop/src/features/sidebar/ui/SidebarProfileCard.tsx
index dcd4a97f13..80ced6570e 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) => Promise;
+ onRemoveCommunity: (id: string) => Promise;
onSendFeedback?: () => void;
onSetPresenceStatus?: (status: PresenceStatus) => void;
onSetUserStatus: (text: string, emoji: string) => void;