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/app/App.tsx b/desktop/src/app/App.tsx index 104edcbaaf..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,6 +320,8 @@ function CommunityApp({ const [isCommunityChangeOpen, setIsCommunityChangeOpen] = useState(false); const [resumeFirstCommunityPage, setResumeFirstCommunityPage] = useState(null); + const isFindingCommunityAfterLeave = + activeCommunity === null && loadCommunityDiscoveryAfterLeave(); // Surface nest-related backend events (repos-dir errors, legacy migration) // as toasts. Mounted before useCommunityInit so the listeners are registered @@ -343,6 +346,7 @@ function CommunityApp({ activeCommunity, communityKey, sharedIdentity, + isFindingCommunityAfterLeave, ); const transitionCommunity = useCallback( @@ -512,7 +516,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 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} diff --git a/desktop/src/app/useCommunityNavigationTransitions.ts b/desktop/src/app/useCommunityNavigationTransitions.ts index 88acb30ad0..d2cf39fbc7 100644 --- a/desktop/src/app/useCommunityNavigationTransitions.ts +++ b/desktop/src/app/useCommunityNavigationTransitions.ts @@ -12,7 +12,9 @@ import { markPendingCommunityRestore, saveCommunityDestination, } from "@/features/communities/communityNavigationStorage"; +import { markCommunityDiscoveryAfterLeave } from "@/features/communities/communityStorage"; import type { useCommunities } from "@/features/communities/useCommunities"; +import { leaveCommunity } from "@/features/communities/leaveCommunity"; type Communities = ReturnType; type ShellRoute = ReturnType; @@ -71,14 +73,38 @@ export function useCommunityNavigationTransitions({ const removeCommunity = React.useCallback( async (id: string) => { - if (id !== communities.activeCommunity?.id) { - communities.removeCommunity(id); - return; - } + const target = communities.communities.find( + (community) => community.id === id, + ); + if (!target) return; + const fallback = communities.communities.find( (community) => community.id !== id, ); - if (!fallback) return; + + // Do not touch local state until this relay has explicitly accepted the + // signed NIP-43 leave request. Rejections and timeouts bubble back to the + // dialog so the person can retry without losing their community config. + const leaveResult = await leaveCommunity( + target.relayUrl, + communities.activeCommunity?.relayUrl, + ); + + if (id !== communities.activeCommunity?.id) { + communities.removeCommunity(id); + return leaveResult; + } + + if (!fallback) { + if (!markCommunityDiscoveryAfterLeave()) { + throw new Error( + "Membership was removed, but community discovery state could not be saved. Restart Buzz and try again.", + ); + } + await goHome({ replace: true }); + communities.removeCommunity(id); + return leaveResult; + } await runCommunityViewTransition(async () => { saveActiveDestination(); @@ -93,6 +119,7 @@ export function useCommunityNavigationTransitions({ } communities.removeCommunity(id); }); + return leaveResult; }, [communities, goHome, router.history, saveActiveDestination], ); diff --git a/desktop/src/features/communities/communityStorage.test.mjs b/desktop/src/features/communities/communityStorage.test.mjs index 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/src/features/communities/leaveCommunity.test.mjs b/desktop/src/features/communities/leaveCommunity.test.mjs new file mode 100644 index 0000000000..50f0775925 --- /dev/null +++ b/desktop/src/features/communities/leaveCommunity.test.mjs @@ -0,0 +1,175 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { KIND_NIP43_LEAVE_REQUEST, leaveCommunity } from "./leaveCommunity.ts"; + +const signedEvent = { + id: "event-id", + pubkey: "a".repeat(64), + created_at: 1, + kind: KIND_NIP43_LEAVE_REQUEST, + tags: [["-"]], + content: "", + sig: "b".repeat(128), +}; + +function dependencies(overrides = {}) { + return { + requiresMembership: async () => true, + sign: async (input) => ({ ...signedEvent, ...input }), + publishActive: async () => {}, + createRelayClient: () => ({ + publishEvent: async () => {}, + disconnect() {}, + }), + ...overrides, + }; +} + +test("skips relay publishing when the relay does not enforce membership", async () => { + let checkedRelay; + await leaveCommunity( + "wss://open.example", + "wss://open.example", + dependencies({ + requiresMembership: async (relayUrl) => { + checkedRelay = relayUrl; + return false; + }, + sign: async () => { + throw new Error("open relay leave should not be signed"); + }, + publishActive: async () => { + throw new Error("open relay leave should not be published"); + }, + }), + ); + + assert.equal(checkedRelay, "wss://open.example"); +}); + +test("signs the protected NIP-43 leave request and awaits active relay acceptance", async () => { + let signInput; + let published; + await leaveCommunity( + "wss://active.example", + "wss://active.example", + dependencies({ + sign: async (input) => { + signInput = input; + return signedEvent; + }, + publishActive: async (event) => { + published = event; + }, + createRelayClient: () => { + throw new Error("inactive client should not be created"); + }, + }), + ); + + assert.deepEqual(signInput, { + kind: KIND_NIP43_LEAVE_REQUEST, + content: "", + tags: [["-"]], + }); + assert.equal(published, signedEvent); +}); + +test("targets an inactive community relay and always disconnects", async () => { + const calls = []; + await leaveCommunity( + "wss://inactive.example", + "wss://active.example", + dependencies({ + publishActive: async () => { + throw new Error("active relay should not be used"); + }, + createRelayClient: (relayUrl) => ({ + publishEvent: async (event) => calls.push(["publish", relayUrl, event]), + disconnect: () => calls.push(["disconnect"]), + }), + }), + ); + + assert.deepEqual(calls, [ + ["publish", "wss://inactive.example", signedEvent], + ["disconnect"], + ]); +}); + +test("treats an already-absent active membership as successful cleanup", async () => { + const result = await leaveCommunity( + "wss://active.example", + "wss://active.example", + dependencies({ + publishActive: async () => { + throw new Error("invalid: you are not a relay member"); + }, + }), + ); + + assert.deepEqual(result, { status: "already-absent" }); +}); + +test("treats an already-absent inactive membership as successful cleanup and disconnects", async () => { + let disconnected = false; + const result = await leaveCommunity( + "wss://inactive.example", + "wss://active.example", + dependencies({ + createRelayClient: () => ({ + publishEvent: async () => { + throw new Error("invalid: you are not a relay member"); + }, + disconnect: () => { + disconnected = true; + }, + }), + }), + ); + assert.deepEqual(result, { status: "already-absent" }); + assert.equal(disconnected, true); +}); + +test("preserves other relay rejections and disconnects without falling through", async () => { + const rejection = new Error("invalid: relay owner cannot leave"); + let disconnected = false; + + await assert.rejects( + leaveCommunity( + "wss://inactive.example", + "wss://active.example", + dependencies({ + createRelayClient: () => ({ + publishEvent: async () => { + throw rejection; + }, + disconnect: () => { + disconnected = true; + }, + }), + }), + ), + rejection, + ); + assert.equal(disconnected, true); +}); + +test("turns an inactive relay timeout into an actionable leave error", async () => { + await assert.rejects( + leaveCommunity( + "wss://inactive.example", + "wss://active.example", + dependencies({ + createRelayClient: () => ({ + publishEvent: async () => { + throw new Error("Timed out publishing to observer relay."); + }, + disconnect() {}, + }), + }), + ), + /Timed out while leaving the community\. Try again\./, + ); +}); diff --git a/desktop/src/features/communities/leaveCommunity.ts b/desktop/src/features/communities/leaveCommunity.ts new file mode 100644 index 0000000000..15e3f8ba8d --- /dev/null +++ b/desktop/src/features/communities/leaveCommunity.ts @@ -0,0 +1,88 @@ +import { relayClient } from "@/shared/api/relayClient"; +import { ReadOnlyRelayClient } from "@/shared/api/readOnlyRelayClient"; +import { relayRequiresMembership } from "@/shared/api/relayMembers"; +import { signRelayEvent } from "@/shared/api/tauri"; +import type { RelayEvent } from "@/shared/api/types"; + +export const KIND_NIP43_LEAVE_REQUEST = 28936; + +type LeaveCommunityDependencies = { + requiresMembership: (relayUrl: string) => Promise; + sign: typeof signRelayEvent; + publishActive: (event: RelayEvent) => Promise; + createRelayClient: (relayUrl: string) => { + publishEvent: (event: RelayEvent) => Promise; + disconnect: () => void; + }; +}; + +const defaultDependencies: LeaveCommunityDependencies = { + requiresMembership: relayRequiresMembership, + sign: signRelayEvent, + publishActive: (event) => + relayClient.publishEvent( + event, + "Timed out while leaving the community. Try again.", + "Couldn't send the leave request. Check your connection and try again.", + ), + createRelayClient: (relayUrl) => new ReadOnlyRelayClient(relayUrl), +}; + +function membershipIsAlreadyAbsent(error: unknown): boolean { + return ( + error instanceof Error && + error.message.toLowerCase().includes("not a relay member") + ); +} + +export type LeaveCommunityResult = + | { status: "left" } + | { status: "already-absent" }; + +async function publishLeaveRequest( + publish: () => Promise, +): Promise { + try { + await publish(); + return { status: "left" }; + } catch (error) { + if (!membershipIsAlreadyAbsent(error)) throw error; + return { status: "already-absent" }; + } +} + +/** Revoke relay membership and resolve only after the relay accepts the request. */ +export async function leaveCommunity( + relayUrl: string, + activeRelayUrl: string | undefined, + dependencies: LeaveCommunityDependencies = defaultDependencies, +): Promise { + if (!(await dependencies.requiresMembership(relayUrl))) { + return { status: "left" }; + } + + const event = await dependencies.sign({ + kind: KIND_NIP43_LEAVE_REQUEST, + content: "", + tags: [["-"]], + }); + + if (relayUrl === activeRelayUrl) { + return publishLeaveRequest(() => dependencies.publishActive(event)); + } + + const client = dependencies.createRelayClient(relayUrl); + try { + return await publishLeaveRequest(() => client.publishEvent(event)); + } catch (error) { + if ( + error instanceof Error && + error.message.toLowerCase().includes("timed out") + ) { + throw new Error("Timed out while leaving the community. Try again."); + } + throw error; + } finally { + client.disconnect(); + } +} diff --git a/desktop/src/features/communities/resolveCommunityRemoval.test.mjs b/desktop/src/features/communities/resolveCommunityRemoval.test.mjs new file mode 100644 index 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..cd530e6908 100644 --- a/desktop/src/features/communities/ui/CommunitySwitcher.tsx +++ b/desktop/src/features/communities/ui/CommunitySwitcher.tsx @@ -6,11 +6,14 @@ import { MoreHorizontal, Plus, Settings2, + LogOut, Ticket, WifiOff, } from "lucide-react"; import * as React from "react"; +import { toast } from "sonner"; +import type { LeaveCommunityResult } from "@/features/communities/leaveCommunity"; import type { Community } from "@/features/communities/types"; import { DropdownMenu, @@ -57,7 +60,7 @@ type CommunitySwitcherProps = { id: string, updates: Partial>, ) => void; - onRemoveCommunity: (id: string) => void; + onRemoveCommunity: (id: string) => Promise; }; export function CommunityEmojiIcon({ @@ -103,6 +106,8 @@ export function CommunitySwitcher({ const [editingCommunity, setEditingCommunity] = React.useState(null); const [dropdownOpen, setDropdownOpen] = React.useState(false); + const [leaveError, setLeaveError] = React.useState(null); + const [isLeaving, setIsLeaving] = React.useState(false); const profileMenuHoverTimer = React.useRef(null); const connectionState = useRelayConnection(); const degraded = isRelayConnectionDegraded(connectionState); @@ -147,6 +152,36 @@ export function CommunitySwitcher({ [], ); + const handleLeaveCommunity = React.useCallback(async () => { + if (!activeCommunity || isLeaving) return; + + if (profileMenuHoverTimer.current !== null) { + window.clearTimeout(profileMenuHoverTimer.current); + profileMenuHoverTimer.current = null; + } + setIsLeaving(true); + setLeaveError(null); + try { + const result = await onRemoveCommunity(activeCommunity.id); + setDropdownOpen(false); + if (result?.status === "already-absent") { + toast("Community removed", { + description: + "You were no longer a member, so Buzz removed the community from this device.", + }); + } + } catch (error) { + setLeaveError( + error instanceof Error + ? error.message + : "Couldn't leave the community. Try again.", + ); + setDropdownOpen(true); + } finally { + setIsLeaving(false); + } + }, [activeCommunity, isLeaving, onRemoveCommunity]); + const triggerContent = ( <> {degraded ? ( @@ -278,6 +313,24 @@ export function CommunitySwitcher({ Community settings + + {leaveError ? ( +

+ {leaveError} +

+ ) : null}
) : null} @@ -391,11 +444,9 @@ export function CommunitySwitcher({ )} 1} onOpenChange={(open) => { if (!open) setEditingCommunity(null); }} - onRemove={onRemoveCommunity} onSave={onUpdateCommunity} open={editingCommunity !== null} community={editingCommunity} diff --git a/desktop/src/features/communities/ui/EditCommunityDialog.tsx b/desktop/src/features/communities/ui/EditCommunityDialog.tsx index 3b963979bc..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) => void; - canRemove?: boolean; showIconEditor?: boolean; }; @@ -38,8 +36,6 @@ export function EditCommunityDialog({ open, onOpenChange, onSave, - onRemove, - canRemove, showIconEditor = false, }: EditCommunityDialogProps) { const [name, setName] = React.useState(""); @@ -122,13 +118,6 @@ export function EditCommunityDialog({ [community, name, relayUrl, token, reposDir, onSave, handleClose], ); - const handleRemove = React.useCallback(() => { - if (community && onRemove) { - onRemove(community.id); - handleClose(); - } - }, [community, onRemove, handleClose]); - if (!community) { return null; } @@ -235,28 +224,13 @@ export function EditCommunityDialog({ the default location.

-
-
- {canRemove && onRemove ? ( - - ) : null} -
-
- - -
+
+ +
diff --git a/desktop/src/features/communities/ui/WelcomeSetup.tsx b/desktop/src/features/communities/ui/WelcomeSetup.tsx index 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; @@ -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/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/src/features/sidebar/ui/AppSidebar.tsx b/desktop/src/features/sidebar/ui/AppSidebar.tsx index 7d9472f709..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) => 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..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) => void; onReorderCommunities: (orderedIds: string[]) => void; }; @@ -305,7 +304,6 @@ export function CommunityRail({ onSwitchCommunity, onAddCommunity, onUpdateCommunity, - onRemoveCommunity, onReorderCommunities, }: CommunityRailProps) { const { unreadByCommunity, markCommunityRead } = useCommunityUnread( @@ -423,11 +421,9 @@ export function CommunityRail({ Add community 1} onOpenChange={(open) => { if (!open) setEditingCommunity(null); }} - onRemove={onRemoveCommunity} onSave={onUpdateCommunity} open={editingCommunity !== null} community={editingCommunity} diff --git a/desktop/src/features/sidebar/ui/SidebarProfileCard.tsx b/desktop/src/features/sidebar/ui/SidebarProfileCard.tsx index 9c6ba0f9b6..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) => void; + onRemoveCommunity: (id: string) => Promise; onSendFeedback?: () => void; onSetPresenceStatus?: (status: PresenceStatus) => void; onSetUserStatus: (text: string, emoji: string) => void; diff --git a/desktop/src/shared/api/relayMembers.ts b/desktop/src/shared/api/relayMembers.ts index 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 { 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..19bcfc23e5 100644 --- a/desktop/tests/e2e/community-rail.spec.ts +++ b/desktop/tests/e2e/community-rail.spec.ts @@ -230,6 +230,9 @@ test.describe("community rail", () => { await expect( menu.getByRole("menuitem", { name: "Community settings" }), ).toBeVisible(); + await expect( + menu.getByRole("menuitem", { name: "Leave community" }), + ).toBeVisible(); await expect( menu.getByRole("menuitem", { name: "Add a community" }), ).toBeVisible(); @@ -290,6 +293,9 @@ test.describe("community rail", () => { await expect( page.getByRole("dialog", { name: "Edit Community" }), ).toBeVisible(); + await expect( + page.getByRole("button", { name: "Leave Community" }), + ).toHaveCount(0); }); test("switches the active community on click", async ({ page }) => { @@ -717,11 +723,12 @@ test.describe("community rail", () => { await page.getByTestId(`community-rail-button-${COMMUNITY_A.id}`).click(); await page.getByTestId("channel-general").click(); + await page.getByTestId("sidebar-profile-avatar-button").click(); + await page.getByTestId("community-switcher").click(); await page - .getByTestId(`community-rail-button-${COMMUNITY_A.id}`) - .click({ button: "right" }); - await page.getByRole("menuitem", { name: "Community settings" }).click(); - await page.getByRole("button", { name: "Remove Community" }).click(); + .getByRole("menu", { name: "Community actions" }) + .getByRole("menuitem", { name: "Leave community" }) + .click(); await expect(page).toHaveURL(randomUrl); await expect @@ -761,6 +768,74 @@ test.describe("community rail", () => { await expect(buttonB).toHaveAttribute("aria-current", "true"); }); + test("leaving the final community returns to setup without resetting identity", async ({ + context, + page, + }) => { + await installMockBridge(page, undefined, { + autoConnectDefaultRelay: true, + skipCommunitySeed: true, + }); + await seedCommunities(page, [COMMUNITY_A], COMMUNITY_A.id); + await page.goto("/"); + + await expect + .poll(() => + page.evaluate(() => typeof window.__BUZZ_E2E_INVOKE_MOCK_COMMAND__), + ) + .toBe("function"); + const identityBefore = await page.evaluate(async () => + window.__BUZZ_E2E_INVOKE_MOCK_COMMAND__("get_identity"), + ); + await page.getByTestId("sidebar-profile-avatar-button").click(); + await page.getByTestId("community-switcher").click(); + await page + .getByRole("menu", { name: "Community actions" }) + .getByRole("menuitem", { name: "Leave community" }) + .click(); + + await expect(page.getByText("Join or create a community")).toBeVisible(); + await expect(page.getByTestId("welcome-setup-back")).toHaveCount(0); + await expect(page.getByTestId("community-choice-join")).toBeVisible(); + await expect + .poll(() => + page.evaluate(() => window.localStorage.getItem("buzz-communities")), + ) + .toBeNull(); + await expect + .poll(() => + page.evaluate(() => + window.localStorage.getItem("buzz-community-discovery-after-leave"), + ), + ) + .toBe("1"); + + const relaunchPage = await context.newPage(); + await installMockBridge(relaunchPage, undefined, { + autoConnectDefaultRelay: true, + skipCommunitySeed: true, + }); + await relaunchPage.goto("/"); + await expect( + relaunchPage.getByText("Join or create a community"), + ).toBeVisible(); + await expect(relaunchPage.getByTestId("welcome-setup-back")).toHaveCount(0); + await expect + .poll(() => + relaunchPage.evaluate(() => + window.localStorage.getItem("buzz-communities"), + ), + ) + .toBeNull(); + await expect + .poll(() => + relaunchPage.evaluate(async () => + window.__BUZZ_E2E_INVOKE_MOCK_COMMAND__("get_identity"), + ), + ) + .toEqual(identityBefore); + }); + test("hides the rail with a single community", async ({ page }) => { await installMockBridge(page, undefined, { skipCommunitySeed: true }); await seedCommunities(page, [COMMUNITY_A], COMMUNITY_A.id);