Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion desktop/src/app/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -308,6 +308,12 @@ function CommunityApp({
const [isCommunityChangeOpen, setIsCommunityChangeOpen] = useState(false);
const [resumeFirstCommunityPage, setResumeFirstCommunityPage] =
useState<FirstCommunityPage | null>(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
Expand Down Expand Up @@ -501,7 +507,9 @@ function CommunityApp({
appContent = (
<WelcomeSetup
initialPage={resumeFirstCommunityPage ?? undefined}
onBack={onBackToMachineConfig}
onBack={
isFindingCommunityAfterLeave ? undefined : onBackToMachineConfig
}
/>
);
} else if ("error" in community && community.error) {
Expand Down
6 changes: 2 additions & 4 deletions desktop/src/app/AppShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down Expand Up @@ -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}
Expand Down
21 changes: 20 additions & 1 deletion desktop/src/app/useCommunityNavigationTransitions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof useCommunities>;
type ShellRoute = ReturnType<typeof deriveShellRoute>;
Expand Down Expand Up @@ -71,14 +72,32 @@ 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;
}
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();
Expand Down
118 changes: 118 additions & 0 deletions desktop/src/features/communities/leaveCommunity.test.mjs
Original file line number Diff line number Diff line change
@@ -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\./,
);
});
59 changes: 59 additions & 0 deletions desktop/src/features/communities/leaveCommunity.ts
Original file line number Diff line number Diff line change
@@ -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<unknown>;
createRelayClient: (relayUrl: string) => {
publishEvent: (event: RelayEvent) => Promise<unknown>;
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<void> {
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();
}
}
Original file line number Diff line number Diff line change
@@ -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",
});
});
4 changes: 2 additions & 2 deletions desktop/src/features/communities/ui/CommunitySwitcher.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ type CommunitySwitcherProps = {
id: string,
updates: Partial<Pick<Community, "name" | "relayUrl" | "token">>,
) => void;
onRemoveCommunity: (id: string) => void;
onRemoveCommunity: (id: string) => Promise<void>;
};

export function CommunityEmojiIcon({
Expand Down Expand Up @@ -391,7 +391,7 @@ export function CommunitySwitcher({
)}

<EditCommunityDialog
canRemove={communities.length > 1}
canRemove
onOpenChange={(open) => {
if (!open) setEditingCommunity(null);
}}
Expand Down
Loading
Loading