diff --git a/desktop/src/app/App.tsx b/desktop/src/app/App.tsx index 90b5bf5ebc..ff24112472 100644 --- a/desktop/src/app/App.tsx +++ b/desktop/src/app/App.tsx @@ -308,6 +308,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 @@ -501,7 +507,9 @@ function CommunityApp({ appContent = ( ); } else if ("error" in community && community.error) { diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index abbbf29610..025bec9970 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -761,7 +761,7 @@ export function AppShell() { communitiesHook.activeCommunity?.id ?? null } onAddCommunity={addCommunityDialog.openDialog} - onRemoveCommunity={(id) => void handleRemoveCommunity(id)} + onRemoveCommunity={handleRemoveCommunity} onReorderCommunities={communitiesHook.reorderCommunities} onSwitchCommunity={handleSwitchCommunity} onUpdateCommunity={communitiesHook.updateCommunity} @@ -854,9 +854,7 @@ export function AppShell() { onOpenAddCommunity={addCommunityDialog.openDialog} onSendFeedback={() => setIsSendFeedbackOpen(true)} onUpdateCommunity={communitiesHook.updateCommunity} - onRemoveCommunity={(id) => - void handleRemoveCommunity(id) - } + onRemoveCommunity={handleRemoveCommunity} onSwitchCommunity={handleSwitchCommunity} onCreateAgent={() => requestOpenCreateAgent()} selfPresenceStatus={presenceSession.currentStatus} diff --git a/desktop/src/app/useCommunityNavigationTransitions.ts b/desktop/src/app/useCommunityNavigationTransitions.ts index 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/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" ? ( community.id !== id); + return { + communities: next, + activeId: activeId === id ? (next[0]?.id ?? null) : activeId, + }; +} + export type UseCommunitiesReturn = { communities: Community[]; activeCommunity: Community | null; @@ -205,39 +222,38 @@ 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); - removeChannelSnapshotForRelay(removed.relayUrl); - removeMessageSnapshotsForRelay(removed.relayUrl); - clearSavedCommunitySnapshot(id); - removeCommunityDestination(id); - } - } + removeSelfProfileCachesForRelay(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 55f467f215..014ed6fd02 100644 --- a/desktop/src/features/sidebar/ui/AppSidebar.tsx +++ b/desktop/src/features/sidebar/ui/AppSidebar.tsx @@ -140,7 +140,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 386ee20691..2727f13fb6 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 5db10509bf..f0b9aa3fc0 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 4cfc553df1..686bfa7327 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -9212,6 +9212,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 0c78108190..4035f66834 100644 --- a/desktop/tests/e2e/community-rail.spec.ts +++ b/desktop/tests/e2e/community-rail.spec.ts @@ -561,7 +561,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 @@ -601,6 +601,41 @@ 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(page.getByTestId("welcome-setup-back")).toHaveCount(0); + await expect(page.getByTestId("community-choice-join")).toBeVisible(); + await expect + .poll(() => + page.evaluate(() => window.localStorage.getItem("buzz-communities")), + ) + .toBeNull(); + await expect + .poll(() => + page.evaluate(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);