Skip to content
Merged
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
11 changes: 9 additions & 2 deletions desktop/src-tauri/src/commands/relay_members.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
8 changes: 7 additions & 1 deletion desktop/src/app/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -319,6 +320,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
Expand All @@ -343,6 +346,7 @@ function CommunityApp({
activeCommunity,
communityKey,
sharedIdentity,
isFindingCommunityAfterLeave,
);

const transitionCommunity = useCallback(
Expand Down Expand Up @@ -512,7 +516,9 @@ function CommunityApp({
appContent = (
<WelcomeSetup
initialPage={resumeFirstCommunityPage ?? undefined}
onBack={onBackToMachineConfig}
onBack={
isFindingCommunityAfterLeave ? undefined : onBackToMachineConfig
}
/>
);
} else if ("error" in community && community.error) {
Expand Down
5 changes: 1 addition & 4 deletions desktop/src/app/AppShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down Expand Up @@ -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}
Expand Down
37 changes: 32 additions & 5 deletions desktop/src/app/useCommunityNavigationTransitions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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>;
Expand Down Expand Up @@ -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();
Expand All @@ -93,6 +119,7 @@ export function useCommunityNavigationTransitions({
}
communities.removeCommunity(id);
});
return leaveResult;
},
[communities, goHome, router.history, saveActiveDestination],
);
Expand Down
34 changes: 32 additions & 2 deletions desktop/src/features/communities/communityStorage.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,11 @@ import test from "node:test";
import {
clearCommunityStorage,
initFirstCommunity,
loadCommunities,
loadCommunityDiscoveryAfterLeave,
markCommunityDiscoveryAfterLeave,
migrateLegacyCommunityStorage,
saveCommunities,
shouldAutoConnectDefaultRelay,
} from "./communityStorage.ts";

Expand Down Expand Up @@ -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);
});
34 changes: 33 additions & 1 deletion desktop/src/features/communities/communityStorage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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 {
Expand Down
Loading
Loading