Skip to content
Closed
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
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
{
Expand Down Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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",
});
});
68 changes: 62 additions & 6 deletions desktop/src/features/communities/ui/CommunityChangeOverlay.tsx
Original file line number Diff line number Diff line change
@@ -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<string | null>(null);
const [duplicateCommunity, setDuplicateCommunity] =
React.useState<Community | null>(null);
const overlayRef = React.useRef<HTMLDivElement>(null);

// Focus trap: focus the overlay on mount
Expand All @@ -36,31 +41,58 @@ 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) {
onClose();
}
// 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 (
Expand Down Expand Up @@ -93,6 +125,30 @@ export function CommunityChangeOverlay({
{error ? (
<p className="mt-4 text-center text-sm text-destructive">{error}</p>
) : null}
{duplicateCommunity ? (
<div className="mt-4 rounded-xl border border-amber-500/30 bg-amber-500/10 p-4 text-sm">
<p className="font-medium text-foreground">
{duplicateCommunity.name} already uses this relay URL.
</p>
<p className="mt-1 text-muted-foreground">
Remove {activeCommunity.name} from this device and switch to the
saved community? This does not change either relay.
</p>
<div className="mt-4 flex justify-end gap-2">
<Button
onClick={() => setDuplicateCommunity(null)}
size="sm"
type="button"
variant="outline"
>
Keep editing
</Button>
<Button onClick={handleReplace} size="sm" type="button">
Replace stale entry
</Button>
</div>
</div>
) : null}
</div>
</div>
);
Expand Down
85 changes: 78 additions & 7 deletions desktop/src/features/communities/useCommunities.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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" };

/**
Expand All @@ -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 =
Expand All @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -249,6 +319,7 @@ function useCommunitiesInternal(): UseCommunitiesReturn {
addCommunity,
clearCommunities,
removeCommunity,
replaceCommunity,
switchCommunity,
reconnectCommunity,
updateCommunity,
Expand Down
37 changes: 37 additions & 0 deletions desktop/src/features/onboarding/communityOnboarding.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import test from "node:test";

import {
clearCommunityOnboardingTransaction,
communityReplacementOnboardingPatch,
loadCommunityOnboardingTransaction,
markCommunityOnboardingComplete,
startCommunityOnboarding,
Expand Down Expand Up @@ -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);
});
16 changes: 16 additions & 0 deletions desktop/src/features/onboarding/communityOnboarding.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -76,6 +77,21 @@ export type StartCommunityOnboardingInput = {
policyReceipt?: string;
};

/** Retarget a wedged onboarding transaction to an existing saved community. */
export function communityReplacementOnboardingPatch(
replacement: Pick<Community, "id" | "name" | "relayUrl">,
): 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)
Expand Down
Loading
Loading