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({
- - - + {onBack ? ( + + + + ) : 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 + + {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 ? ( - - ) : null} -
-
- - -
+
+ +
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;