diff --git a/desktop/src/features/communities/resolveCommunityUpdateResult.test.mjs b/desktop/src/features/communities/resolveCommunityUpdateResult.test.mjs index 0a52ef845..37f24e59d 100644 --- a/desktop/src/features/communities/resolveCommunityUpdateResult.test.mjs +++ b/desktop/src/features/communities/resolveCommunityUpdateResult.test.mjs @@ -5,7 +5,10 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { resolveCommunityUpdateResult } from "./useCommunities.tsx"; +import { + resolveCommunityReplacement, + resolveCommunityUpdateResult, +} from "./useCommunities.tsx"; const COMMUNITIES = [ { @@ -56,7 +59,10 @@ test("resolveCommunityUpdateResult_duplicate_relay_returns_duplicate", () => { const result = resolveCommunityUpdateResult(COMMUNITIES, "ws-1", "ws-1", { relayUrl: "wss://relay-b.example.com", }); - assert.deepEqual(result, { kind: "duplicate-relay" }); + assert.deepEqual(result, { + kind: "duplicate-relay", + existingCommunityId: "ws-2", + }); }); test("resolveCommunityUpdateResult_not_found_returns_not_found", () => { @@ -105,3 +111,22 @@ test("resolveCommunityUpdateResult_same_relay_url_is_not_duplicate_of_self", () }); assert.deepEqual(result, { kind: "unchanged" }); }); + +test("resolveCommunityReplacement_drops_stale_entry_and_keeps_replacement", () => { + const result = resolveCommunityReplacement(COMMUNITIES, "ws-1", "ws-2"); + + assert.equal(result.kind, "replaced"); + assert.deepEqual(result.communities, [COMMUNITIES[1]]); + assert.equal(result.removedCommunity, COMMUNITIES[0]); + assert.equal(result.replacementCommunity, COMMUNITIES[1]); +}); + +test("resolveCommunityReplacement_rejects_missing_or_identical_entries", () => { + assert.deepEqual( + resolveCommunityReplacement(COMMUNITIES, "missing", "ws-2"), + { kind: "not-found" }, + ); + assert.deepEqual(resolveCommunityReplacement(COMMUNITIES, "ws-1", "ws-1"), { + kind: "not-found", + }); +}); diff --git a/desktop/src/features/communities/ui/CommunityChangeOverlay.tsx b/desktop/src/features/communities/ui/CommunityChangeOverlay.tsx index d4ebcdc06..26e678acb 100644 --- a/desktop/src/features/communities/ui/CommunityChangeOverlay.tsx +++ b/desktop/src/features/communities/ui/CommunityChangeOverlay.tsx @@ -1,19 +1,24 @@ import * as React from "react"; +import type { Community } from "../types"; import { useCommunities } from "../useCommunities"; +import { Button } from "@/shared/ui/button"; import { CommunityEditForm } from "./CommunityEditForm"; type CommunityChangeOverlayProps = { onClose: () => void; - onUpdated?: (name: string, relayUrl: string) => void; + onUpdated?: (community: Community, replaced: boolean) => void; }; export function CommunityChangeOverlay({ onClose, onUpdated, }: CommunityChangeOverlayProps) { - const { activeCommunity, updateCommunity } = useCommunities(); + const { activeCommunity, communities, replaceCommunity, updateCommunity } = + useCommunities(); const [error, setError] = React.useState(null); + const [duplicateCommunity, setDuplicateCommunity] = + React.useState(null); const overlayRef = React.useRef(null); // Focus trap: focus the overlay on mount @@ -36,13 +41,14 @@ export function CommunityChangeOverlay({ (name: string, relayUrl: string) => { if (!activeCommunity) return; setError(null); + setDuplicateCommunity(null); const result = updateCommunity(activeCommunity.id, { name, relayUrl }); switch (result.kind) { case "unchanged": onClose(); break; case "updated": - onUpdated?.(name, relayUrl); + onUpdated?.({ ...activeCommunity, name, relayUrl }, false); // If reinit is needed, the communityKey change will trigger a remount. // If not (name-only), just close. if (!result.requiresReinit) { @@ -50,17 +56,43 @@ export function CommunityChangeOverlay({ } // If requiresReinit, the tree remounts — overlay unmounts naturally. break; - case "duplicate-relay": - setError("Another community already uses this relay URL."); + case "duplicate-relay": { + const existingCommunity = communities.find( + (community) => community.id === result.existingCommunityId, + ); + if (existingCommunity) { + setDuplicateCommunity(existingCommunity); + } else { + setError("Community not found."); + } break; + } case "not-found": setError("Community not found."); break; } }, - [activeCommunity, onClose, onUpdated, updateCommunity], + [activeCommunity, communities, onClose, onUpdated, updateCommunity], ); + const handleReplace = React.useCallback(() => { + if (!activeCommunity || !duplicateCommunity) return; + const result = replaceCommunity(activeCommunity.id, duplicateCommunity.id); + if (result.kind !== "replaced") { + setDuplicateCommunity(null); + setError("Community not found."); + return; + } + onUpdated?.(result.replacementCommunity, true); + onClose(); + }, [ + activeCommunity, + duplicateCommunity, + onClose, + onUpdated, + replaceCommunity, + ]); + if (!activeCommunity) return null; return ( @@ -93,6 +125,30 @@ export function CommunityChangeOverlay({ {error ? (

{error}

) : null} + {duplicateCommunity ? ( +
+

+ {duplicateCommunity.name} already uses this relay URL. +

+

+ Remove {activeCommunity.name} from this device and switch to the + saved community? This does not change either relay. +

+
+ + +
+
+ ) : null} ); diff --git a/desktop/src/features/communities/useCommunities.tsx b/desktop/src/features/communities/useCommunities.tsx index 48668a374..86527ef54 100644 --- a/desktop/src/features/communities/useCommunities.tsx +++ b/desktop/src/features/communities/useCommunities.tsx @@ -24,7 +24,16 @@ import { clearSavedCommunitySnapshot } from "@/features/agents/activeAgentTurnsS export type UpdateCommunityResult = | { kind: "updated"; requiresReinit: boolean } | { kind: "unchanged" } - | { kind: "duplicate-relay" } + | { kind: "duplicate-relay"; existingCommunityId: string } + | { kind: "not-found" }; + +export type ReplaceCommunityResult = + | { + kind: "replaced"; + communities: Community[]; + removedCommunity: Community; + replacementCommunity: Community; + } | { kind: "not-found" }; /** @@ -43,12 +52,13 @@ export function resolveCommunityUpdateResult( const current = communities.find((w) => w.id === id); if (!current) return { kind: "not-found" }; - if ( - updates.relayUrl !== undefined && - updates.relayUrl !== current.relayUrl && - communities.some((w) => w.id !== id && w.relayUrl === updates.relayUrl) - ) { - return { kind: "duplicate-relay" }; + if (updates.relayUrl !== undefined && updates.relayUrl !== current.relayUrl) { + const existing = communities.find( + (w) => w.id !== id && w.relayUrl === updates.relayUrl, + ); + if (existing) { + return { kind: "duplicate-relay", existingCommunityId: existing.id }; + } } const hasChange = @@ -72,6 +82,32 @@ export function resolveCommunityUpdateResult( return { kind: "updated", requiresReinit: backendFieldsChanged }; } +/** + * Remove a stale local community entry in favor of another saved entry. + * This is local-only recovery: it never mutates either relay. + */ +export function resolveCommunityReplacement( + communities: Community[], + removedId: string, + replacementId: string, +): ReplaceCommunityResult { + const removedCommunity = communities.find((w) => w.id === removedId); + const replacementCommunity = communities.find((w) => w.id === replacementId); + if ( + !removedCommunity || + !replacementCommunity || + removedCommunity.id === replacementCommunity.id + ) { + return { kind: "not-found" }; + } + return { + kind: "replaced", + communities: communities.filter((w) => w.id !== removedCommunity.id), + removedCommunity, + replacementCommunity, + }; +} + export type UseCommunitiesReturn = { communities: Community[]; activeCommunity: Community | null; @@ -81,6 +117,11 @@ export type UseCommunitiesReturn = { addCommunity: (community: Community) => string; clearCommunities: () => void; removeCommunity: (id: string) => void; + /** Drop a stale local entry and activate an already-saved replacement. */ + replaceCommunity: ( + removedId: string, + replacementId: string, + ) => ReplaceCommunityResult; switchCommunity: (id: string) => void; /** Force the active community to re-init (e.g. after a deep-link reconnect). */ reconnectCommunity: () => void; @@ -196,6 +237,35 @@ function useCommunitiesInternal(): UseCommunitiesReturn { [activeId, communities], ); + const replaceCommunity = useCallback( + (removedId: string, replacementId: string): ReplaceCommunityResult => { + const result = resolveCommunityReplacement( + communitiesRef.current, + removedId, + replacementId, + ); + if (result.kind !== "replaced") return result; + + const removedRelayStillUsed = result.communities.some( + (community) => community.relayUrl === result.removedCommunity.relayUrl, + ); + if (!removedRelayStillUsed) { + removeSelfProfileCachesForRelay(result.removedCommunity.relayUrl); + removeChannelSnapshotForRelay(result.removedCommunity.relayUrl); + removeMessageSnapshotsForRelay(result.removedCommunity.relayUrl); + } + clearSavedCommunitySnapshot(result.removedCommunity.id); + + communitiesRef.current = result.communities; + saveCommunities(result.communities); + saveActiveCommunityId(result.replacementCommunity.id); + setCommunitiesState(result.communities); + setActiveId(result.replacementCommunity.id); + return result; + }, + [], + ); + const switchCommunity = useCallback( (id: string) => { if (id === activeId) return; @@ -249,6 +319,7 @@ function useCommunitiesInternal(): UseCommunitiesReturn { addCommunity, clearCommunities, removeCommunity, + replaceCommunity, switchCommunity, reconnectCommunity, updateCommunity, diff --git a/desktop/src/features/onboarding/communityOnboarding.test.mjs b/desktop/src/features/onboarding/communityOnboarding.test.mjs index 8ffa59ec8..15a0cb382 100644 --- a/desktop/src/features/onboarding/communityOnboarding.test.mjs +++ b/desktop/src/features/onboarding/communityOnboarding.test.mjs @@ -3,6 +3,7 @@ import test from "node:test"; import { clearCommunityOnboardingTransaction, + communityReplacementOnboardingPatch, loadCommunityOnboardingTransaction, markCommunityOnboardingComplete, startCommunityOnboarding, @@ -147,3 +148,39 @@ test("completion is scoped by relay and pubkey and preserves legacy gate", () => ); assert.equal(storage.getItem("buzz-onboarding-complete.v1:pubkey"), "true"); }); + +test("community replacement retargets a wedged transaction", () => { + const storage = createMemoryStorage(); + const transaction = startCommunityOnboarding( + { source: "add-community", relayUrl: "wss://stale.example" }, + storage, + ); + const connected = updateCommunityOnboardingTransaction( + transaction, + { + communityId: "stale-id", + previousCommunityId: "previous-id", + addedCommunity: true, + stage: "profile", + error: "not a relay member", + }, + storage, + ); + + const repaired = updateCommunityOnboardingTransaction( + connected, + communityReplacementOnboardingPatch({ + id: "saved-id", + name: "Saved community", + relayUrl: "wss://saved.example", + }), + storage, + ); + + assert.equal(repaired.communityId, "saved-id"); + assert.equal(repaired.previousCommunityId, "saved-id"); + assert.equal(repaired.addedCommunity, false); + assert.equal(repaired.relayUrl, "wss://saved.example"); + assert.equal(repaired.stage, "connecting"); + assert.equal(repaired.error, undefined); +}); diff --git a/desktop/src/features/onboarding/communityOnboarding.tsx b/desktop/src/features/onboarding/communityOnboarding.tsx index 1f0fe45ec..5ef65fa36 100644 --- a/desktop/src/features/onboarding/communityOnboarding.tsx +++ b/desktop/src/features/onboarding/communityOnboarding.tsx @@ -2,6 +2,7 @@ import { deriveCommunityName, normalizeRelayUrl, } from "@/features/communities/communityStorage"; +import type { Community } from "@/features/communities/types"; import { setLocalStorageItemWithRecovery } from "@/shared/lib/localStorageQuota"; const STORAGE_KEY = "buzz-community-onboarding-transaction.v1"; @@ -76,6 +77,21 @@ export type StartCommunityOnboardingInput = { policyReceipt?: string; }; +/** Retarget a wedged onboarding transaction to an existing saved community. */ +export function communityReplacementOnboardingPatch( + replacement: Pick, +): CommunityOnboardingTransactionPatch { + return { + communityId: replacement.id, + communityName: replacement.name, + relayUrl: replacement.relayUrl, + addedCommunity: false, + previousCommunityId: replacement.id, + stage: "connecting", + error: undefined, + }; +} + function canonicalRelayUrl(rawRelayUrl: string) { const trimmed = rawRelayUrl.trim(); const withScheme = /^(ws|wss):\/\//i.test(trimmed) diff --git a/desktop/src/features/onboarding/ui/CommunityOnboardingFlow.tsx b/desktop/src/features/onboarding/ui/CommunityOnboardingFlow.tsx index e09fd8404..a5765adf5 100644 --- a/desktop/src/features/onboarding/ui/CommunityOnboardingFlow.tsx +++ b/desktop/src/features/onboarding/ui/CommunityOnboardingFlow.tsx @@ -3,6 +3,7 @@ import { useQueryClient } from "@tanstack/react-query"; import { Plus, Users, X } from "lucide-react"; import { + communityReplacementOnboardingPatch, markCommunityOnboardingComplete, useCommunityOnboarding, } from "@/features/onboarding/communityOnboarding"; @@ -273,13 +274,18 @@ export function CommunityOnboardingFlow({ {isCommunityChangeOpen ? ( setIsCommunityChangeOpen(false)} - onUpdated={(communityName, updatedRelayUrl) => { - update({ - communityName, - relayUrl: updatedRelayUrl, - stage: "connecting", - error: undefined, - }); + onUpdated={(updatedCommunity, replaced) => { + update( + replaced + ? communityReplacementOnboardingPatch(updatedCommunity) + : { + communityId: updatedCommunity.id, + communityName: updatedCommunity.name, + relayUrl: updatedCommunity.relayUrl, + stage: "connecting", + error: undefined, + }, + ); setIsMembershipDenied(false); }} />