mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
@@ -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);
|
||||
}}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user