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>
This commit is contained in:
Taylor Ho
2026-08-05 14:02:43 -07:00
committed by GitHub
co-authored by npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w npub14ndfusear8wdpe4kss8h7juc7wjk78atnqzf63zvppcpneknv4sq6x9370
parent ed4b3e7afa
commit 719f9730d4
20 changed files with 629 additions and 104 deletions
@@ -17,8 +17,15 @@ struct RelayInformationDocument {
}
#[tauri::command]
pub async fn relay_requires_membership(state: State<'_, AppState>) -> Result<bool, String> {
let url = format!("{}/info", relay_api_base_url_with_override(&state));
pub async fn relay_requires_membership(
relay_url: Option<String>,
state: State<'_, AppState>,
) -> Result<bool, String> {
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)
+7 -1
View File
@@ -42,6 +42,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";
@@ -320,6 +321,8 @@ function CommunityApp({
const [isCommunityChangeOpen, setIsCommunityChangeOpen] = useState(false);
const [resumeFirstCommunityPage, setResumeFirstCommunityPage] =
useState<FirstCommunityPage | null>(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
@@ -344,6 +347,7 @@ function CommunityApp({
activeCommunity,
communityKey,
sharedIdentity,
isFindingCommunityAfterLeave,
);
const transitionCommunity = useCallback(
@@ -513,7 +517,9 @@ function CommunityApp({
appContent = (
<WelcomeSetup
initialPage={resumeFirstCommunityPage ?? undefined}
onBack={onBackToMachineConfig}
onBack={
isFindingCommunityAfterLeave ? undefined : onBackToMachineConfig
}
/>
);
} else if ("error" in community && community.error) {
+1 -4
View File
@@ -779,7 +779,6 @@ export function AppShell() {
<CommunityRail
activeCommunityId={communitiesHook.activeCommunity?.id ?? null}
onAddCommunity={addCommunityDialog.openDialog}
onRemoveCommunity={(id) => 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}
@@ -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<typeof useCommunities>;
type ShellRoute = ReturnType<typeof deriveShellRoute>;
@@ -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],
);
@@ -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);
});
@@ -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 {
@@ -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\./,
);
});
@@ -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<boolean>;
sign: typeof signRelayEvent;
publishActive: (event: RelayEvent) => Promise<unknown>;
createRelayClient: (relayUrl: string) => {
publishEvent: (event: RelayEvent) => Promise<unknown>;
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<unknown>,
): Promise<LeaveCommunityResult> {
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<LeaveCommunityResult> {
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();
}
}
@@ -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",
});
});
@@ -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<Pick<Community, "name" | "relayUrl" | "token">>,
) => void;
onRemoveCommunity: (id: string) => void;
onRemoveCommunity: (id: string) => Promise<LeaveCommunityResult | undefined>;
};
export function CommunityEmojiIcon({
@@ -103,6 +106,8 @@ export function CommunitySwitcher({
const [editingCommunity, setEditingCommunity] =
React.useState<Community | null>(null);
const [dropdownOpen, setDropdownOpen] = React.useState(false);
const [leaveError, setLeaveError] = React.useState<string | null>(null);
const [isLeaving, setIsLeaving] = React.useState(false);
const profileMenuHoverTimer = React.useRef<number | null>(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({
<Settings2 className="h-4 w-4" />
<span>Community settings</span>
</button>
<button
className="flex min-h-9 w-full items-center gap-2 rounded-lg px-3 py-2 text-left text-sm text-destructive outline-hidden transition-colors hover:bg-destructive/10 focus:bg-destructive/10 focus:outline-none focus-visible:bg-destructive/10 focus-visible:outline-none disabled:pointer-events-none disabled:opacity-50"
disabled={isLeaving}
onClick={() => void handleLeaveCommunity()}
role="menuitem"
type="button"
>
<LogOut className="h-4 w-4" />
<span>{isLeaving ? "Leaving…" : "Leave community"}</span>
</button>
{leaveError ? (
<p
className="px-3 py-1 text-xs text-destructive"
role="alert"
>
{leaveError}
</p>
) : null}
<hr className="-mx-1 my-1 h-px border-0 bg-muted" />
</>
) : null}
@@ -391,11 +444,9 @@ export function CommunitySwitcher({
)}
<EditCommunityDialog
canRemove={communities.length > 1}
onOpenChange={(open) => {
if (!open) setEditingCommunity(null);
}}
onRemove={onRemoveCommunity}
onSave={onUpdateCommunity}
open={editingCommunity !== null}
community={editingCommunity}
@@ -28,8 +28,6 @@ type EditCommunityDialogProps = {
Pick<Community, "name" | "relayUrl" | "token" | "reposDir">
>,
) => 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.
</p>
</div>
<div className="flex items-center justify-between pt-2">
<div>
{canRemove && onRemove ? (
<Button
className="text-destructive hover:text-destructive"
onClick={handleRemove}
size="sm"
type="button"
variant="ghost"
>
Remove Community
</Button>
) : null}
</div>
<div className="flex gap-2">
<Button onClick={handleClose} type="button" variant="outline">
Cancel
</Button>
<Button disabled={!name.trim() || !relayUrl.trim()} type="submit">
Save Changes
</Button>
</div>
<div className="flex justify-end gap-2 pt-2">
<Button onClick={handleClose} type="button" variant="outline">
Cancel
</Button>
<Button disabled={!name.trim() || !relayUrl.trim()} type="submit">
Save Changes
</Button>
</div>
</form>
</DialogContent>
@@ -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({
</button>
</Card>
</div>
<OnboardingFooter>
<Button
className="h-9 rounded-full bg-foreground/10 px-6 hover:bg-foreground/15"
data-testid="welcome-setup-back"
onClick={onBack}
type="button"
variant="ghost"
>
Back
</Button>
</OnboardingFooter>
{onBack ? (
<OnboardingFooter>
<Button
className="h-9 rounded-full bg-foreground/10 px-6 hover:bg-foreground/15"
data-testid="welcome-setup-back"
onClick={onBack}
type="button"
variant="ghost"
>
Back
</Button>
</OnboardingFooter>
) : null}
</OnboardingSlideTransition>
) : page === "existing" ? (
<OnboardingSlideTransition
@@ -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);
const result = resolveCommunityRemoval(prev, activeId, id);
if (result.communities.length === 0) {
clearCommunityStorage();
setActiveId(null);
} else {
saveCommunities(result.communities);
// If removing the active community, switch to first remaining
if (activeId === id && next.length > 0) {
saveActiveCommunityId(next[0].id);
setActiveId(next[0].id);
if (result.activeId !== activeId && result.activeId) {
saveActiveCommunityId(result.activeId);
setActiveId(result.activeId);
}
}
return next;
return result.communities;
});
},
[activeId, communities],
[activeId],
);
const switchCommunity = useCallback(
@@ -97,6 +97,7 @@ export function useCommunityInit(
activeCommunity: Community | null,
communityKey: string,
isSharedIdentity: boolean,
suppressAutoConnect = false,
): CommunityInitResult {
const [result, setResult] = useState<CommunityInitResult>({
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,
]);
@@ -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<Pick<Community, "name" | "relayUrl" | "token">>,
) => void;
onRemoveCommunity: (id: string) => void;
onRemoveCommunity: (id: string) => Promise<LeaveCommunityResult | undefined>;
onCreateAgent: () => void;
onSelectAgents: () => void;
onSelectProjects: () => void;
@@ -48,7 +48,6 @@ type CommunityRailProps = {
id: string,
updates: Partial<Pick<Community, "name" | "relayUrl" | "token">>,
) => 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({
<TooltipContent side="right">Add community</TooltipContent>
</Tooltip>
<EditCommunityDialog
canRemove={communities.length > 1}
onOpenChange={(open) => {
if (!open) setEditingCommunity(null);
}}
onRemove={onRemoveCommunity}
onSave={onUpdateCommunity}
open={editingCommunity !== null}
community={editingCommunity}
@@ -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<LeaveCommunityResult | undefined>;
onSendFeedback?: () => void;
onSetPresenceStatus?: (status: PresenceStatus) => void;
onSetUserStatus: (text: string, emoji: string) => void;
+4 -2
View File
@@ -127,8 +127,10 @@ export async function listRelayMembers(): Promise<RelayMember[]> {
return event ? relayMembersFromEvent(event) : [];
}
async function relayRequiresMembership(): Promise<boolean> {
return invokeTauri<boolean>("relay_requires_membership");
export async function relayRequiresMembership(
relayUrl?: string,
): Promise<boolean> {
return invokeTauri<boolean>("relay_requires_membership", { relayUrl });
}
export async function getMyRelayMembershipLookup(): Promise<RelayMembershipLookup> {
+5
View File
@@ -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, [
+79 -4
View File
@@ -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);