fix(desktop): recover stale community entries

Co-authored-by: Bradley Axen <baxen@squareup.com>
Signed-off-by: Bradley Axen <baxen@squareup.com>
This commit is contained in:
Bradley Axen
2026-07-18 21:42:55 -07:00
parent af4b7087dc
commit d049320f5a
6 changed files with 233 additions and 22 deletions
@@ -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",
});
});
@@ -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
@@ -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 ? (
<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>
);
@@ -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,
@@ -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);
});
@@ -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<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)
@@ -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 ? (
<CommunityChangeOverlay
onClose={() => 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);
}}
/>