Files
buzz/desktop/src/app/useCommunityNavigationTransitions.ts
T
719f9730d4 feat(desktop): allow leaving your final community (#3621)
**Category:** improvement
**User Impact:** People can leave their final Buzz community and return
to **Join or create a community** without losing their signed-in
identity.

**Problem:** Buzz Desktop blocked people from leaving when only one
community remained. Its existing remove action also changed local
configuration without ending relay membership.

**Solution:** Allow the final community to be left. Buzz now asks the
relay to end membership, removes the community locally only after
acceptance, and returns the person to the community selector while
keeping their identity signed in. If other communities remain, Buzz
switches to one of them. Relay rejection or timeout keeps the community
in place and shows an actionable retry error.

<details>
<summary>File changes</summary>

**desktop/src/features/communities/leaveCommunity.ts**
Adds signed kind 28936 publishing for active and inactive community
relays with actionable timeout handling.

**desktop/src/features/communities/leaveCommunity.test.mjs**
Covers event shape, relay selection, acceptance gating, rejection,
timeout messaging, and cleanup.

**desktop/src/features/communities/useCommunities.tsx**
Allows final-community removal and clears community-specific storage
without touching identity.

**desktop/src/features/communities/resolveCommunityRemoval.test.mjs**
Covers final, active, and inactive community removal state transitions.

**desktop/src/app/useCommunityNavigationTransitions.ts**
Gates local removal on relay acceptance and routes to a fallback
community or setup selector.

**desktop/src/app/AppShell.tsx**
Passes the asynchronous leave operation through shell entry points.

**desktop/src/features/communities/ui/EditCommunityDialog.tsx**
Replaces the local-only remove action with a pending-aware Leave
Community action that retains actionable errors.

**desktop/src/features/communities/ui/CommunitySwitcher.tsx**
Enables leaving the final community and carries the asynchronous
callback.

**desktop/src/features/sidebar/ui/AppSidebar.tsx**
Carries the asynchronous leave callback through sidebar props.

**desktop/src/features/sidebar/ui/CommunityRail.tsx**
Enables leaving the final community from rail settings.

**desktop/src/features/sidebar/ui/SidebarProfileCard.tsx**
Carries the asynchronous leave callback through profile community
settings.

**desktop/src/testing/e2eBridge.ts**
Teaches the mock relay to accept NIP-43 leave events.

**desktop/tests/e2e/community-rail.spec.ts**
Updates leave interactions and verifies final-community setup
navigation, storage cleanup, and identity preservation.

</details>

### Reproduction steps

1. Run Buzz Desktop with a signed-in identity and one joined community.
2. Open Community settings and choose **Leave Community**.
3. Confirm the app shows **Join or create a community** and the existing
identity remains signed in.
4. Repeat with two communities and confirm leaving the active one
switches cleanly to the remaining community.
5. Reject or withhold the relay `OK` response and confirm the community
remains configured with an actionable error in the dialog.

### Test plan

- `pnpm check`
- `pnpm build`
- `pnpm test` (3,913 passing)
- `pnpm build:e2e && pnpm exec playwright test
tests/e2e/community-rail.spec.ts --grep "final community"`


<img width="557" height="316" alt="image"
src="https://github.com/user-attachments/assets/b628182f-cba5-451d-ae4b-bee8d8dd19aa"
/>

---------

Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
Signed-off-by: npub14ndfusear8wdpe4kss8h7juc7wjk78atnqzf63zvppcpneknv4sq6x9370 <acda9e433d19dcd0e6b6840f7f4b98f3a56f1fab98049d444c087019e6d36560@buzz.block.builderlab.xyz>
Co-authored-by: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w <52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@buzz.block.builderlab.xyz>
Co-authored-by: npub14ndfusear8wdpe4kss8h7juc7wjk78atnqzf63zvppcpneknv4sq6x9370 <acda9e433d19dcd0e6b6840f7f4b98f3a56f1fab98049d444c087019e6d36560@buzz.block.builderlab.xyz>
2026-08-05 14:02:43 -07:00

129 lines
4.3 KiB
TypeScript

import { useRouter } from "@tanstack/react-router";
import * as React from "react";
import type { deriveShellRoute } from "@/app/AppShell.helpers";
import type { useAppNavigation } from "@/app/navigation/useAppNavigation";
import {
replaceCommunityDestinationRoute,
runCommunityViewTransition,
} from "@/app/communityViewTransition";
import {
loadCommunityDestination,
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<typeof useCommunities>;
type ShellRoute = ReturnType<typeof deriveShellRoute>;
type GoHome = ReturnType<typeof useAppNavigation>["goHome"];
export function useCommunityNavigationTransitions({
communities,
goHome,
selectedChannelId,
selectedView,
}: {
communities: Communities;
goHome: GoHome;
selectedChannelId: ShellRoute["selectedChannelId"];
selectedView: ShellRoute["selectedView"];
}) {
const router = useRouter();
const saveActiveDestination = React.useCallback(() => {
const activeCommunityId = communities.activeCommunity?.id;
if (!activeCommunityId) return;
saveCommunityDestination(
activeCommunityId,
selectedView === "channel" && selectedChannelId
? { kind: "channel", channelId: selectedChannelId }
: { kind: "home" },
);
}, [communities.activeCommunity?.id, selectedChannelId, selectedView]);
// Home is a teardown barrier: the outgoing channel must unmount before the
// relay changes, or its read effect can advance markers on the wrong relay.
const switchCommunity = React.useCallback(
async (id: string) => {
const activeCommunityId = communities.activeCommunity?.id;
if (id === activeCommunityId) return;
if (!activeCommunityId) {
communities.switchCommunity(id);
return;
}
await runCommunityViewTransition(async () => {
saveActiveDestination();
await goHome({ replace: true });
markPendingCommunityRestore(id);
const destination = loadCommunityDestination(id);
if (destination?.kind === "channel") {
replaceCommunityDestinationRoute(
destination.channelId,
router.history,
);
}
communities.switchCommunity(id);
});
},
[communities, goHome, router.history, saveActiveDestination],
);
const removeCommunity = React.useCallback(
async (id: string) => {
const target = communities.communities.find(
(community) => community.id === id,
);
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.
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();
await goHome({ replace: true });
markPendingCommunityRestore(fallback.id);
const destination = loadCommunityDestination(fallback.id);
if (destination?.kind === "channel") {
replaceCommunityDestinationRoute(
destination.channelId,
router.history,
);
}
communities.removeCommunity(id);
});
return leaveResult;
},
[communities, goHome, router.history, saveActiveDestination],
);
return { removeCommunity, switchCommunity };
}