Files
buzz/desktop/src/app/useCommunityNavigationTransitions.ts
Will Pfleger 83aab8cb5c feat(desktop): remove communities locally and stop their agent pairs (#7997)
🤖 Leave contacts the relay before it removes anything locally: it checks
the relay's `/info` for NIP-43 membership support, then waits for the
relay to accept a signed leave request. When a relay has been torn down,
that check fails, Leave throws, and the community stays in the rail.
Removing a community also left its relay's managed agent pairs running,
and they kept reconnecting to it until the app was quit.

**Remove from this device**
- A new community action, **Remove from this device**, sits under
**Leave community** in the profile menu's community actions. It never
contacts the relay. A confirm dialog explains that relay membership
isn't revoked.
- `useCommunityNavigationTransitions` splits the old `removeCommunity`
into `removeCommunityFromDevice` (local-only cleanup and navigation to a
fallback community or the discovery screen) and
`leaveAndRemoveCommunity` (calls `leaveCommunity`, then runs the same
cleanup). Leave's flow hasn't changed.
- When Leave fails, the error now adds: "If the community no longer
exists, use Remove from this device." The error shown when community
discovery state can't be saved no longer says "Membership was removed",
so its wording fits both actions.

**Agent pairs on a removed relay**
- Once the local removal completes, the shared cleanup stops every live
agent pair on that relay, including manually started ones. It matches
relays with `canonicalRelayUrl`. Stopping is best-effort: failures are
logged and the removal still completes. Pairs on other relays aren't
touched, and nothing is stopped if another community still uses the same
relay.
- Both reconcile call sites (`useManagedAgentRuntimeReconciliation` and
`bootstrapManagedAgentRuntimePairs`) now go through
`reconcileConfiguredManagedAgentRuntimes`. A reconcile that is already
running in Rust can't be cancelled, so after it resolves, this helper
stops any pair it started whose relay is no longer in the saved
community list.
- Launch restore runs in the background after `apply_workspace` and can
finish after its community is removed. `apply_workspace` takes a restore
ticket (the current `launch_restore_generation` in `AppState`) before
handing its lock to the restore task. Removing the active community
calls the new `invalidate_launch_restore` command, which bumps that
generation while holding the runtime transition lock.
`restore_managed_agents_on_launch` re-checks its ticket under the same
lock and spawns nothing if it changed. If restore takes the lock first,
its pairs are registered before invalidation returns, and the removal
stop then finds them. Removal never changes `relay_url_override`: the
outgoing relay stays applied until the next `apply_workspace`, exactly
as on `main`, so in-flight work never falls back to the default relay.
Invalidation never takes `workspace_apply_lock`, which restore holds
while waiting on the transition lock, so lock order is unchanged.
- With no relay applied, backend readers fall back to the default relay,
the same state as launch before the first `apply_workspace`. A normal
quit and relaunch doesn't bring a pair back on a removed relay: restore
targets the applied workspace, and the legacy per-record relay pin is
ignored.

**Tests**
- A `community-rail` e2e case makes the mocked relay's membership check
fail, the way an unreachable relay does. It checks that Leave shows the
error and keeps the community, and that Remove from this device then
removes it, switches to the fallback, stops the agent pair on the
removed relay, and leaves the pair on the other relay running.
- Unit tests in `managedAgentRelayCleanup.test.mjs` cover relay
matching, a stop or listing failure not throwing, and the reconcile
fence stopping a pair that started on a relay removed mid-flight.

---------

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
2026-10-01 18:43:21 +00:00

182 lines
6.3 KiB
TypeScript

import { useRouter } from "@tanstack/react-router";
import * as React from "react";
import type { deriveShellRoute } from "@/app/AppShell.helpers";
import type { useAppNavigation } from "@/app/navigation/useAppNavigation";
import {
replaceCommunityDestinationRoute,
runCommunityViewTransition,
} from "@/app/communityViewTransition";
import {
loadCommunityDestination,
markPendingCommunityRestore,
saveCommunityDestination,
} from "@/features/communities/communityNavigationStorage";
import { canonicalRelayUrl } from "@/features/agents/managedAgentRuntimeStatus";
import {
markRelayRemoved,
refuseRelayAdmission,
stopManagedAgentPairsOnRelay,
} from "@/features/agents/managedAgentRelayCleanup";
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>;
type GoHome = ReturnType<typeof useAppNavigation>["goHome"];
export function useCommunityNavigationTransitions({
communities,
goHome,
selectedChannelId,
selectedView,
}: {
communities: Communities;
goHome: GoHome;
selectedChannelId: ShellRoute["selectedChannelId"];
selectedView: ShellRoute["selectedView"];
}) {
const router = useRouter();
const saveActiveDestination = React.useCallback(() => {
const activeCommunityId = communities.activeCommunity?.id;
if (!activeCommunityId) return;
saveCommunityDestination(
activeCommunityId,
selectedView === "channel" && selectedChannelId
? { kind: "channel", channelId: selectedChannelId }
: { kind: "home" },
);
}, [communities.activeCommunity?.id, selectedChannelId, selectedView]);
// Home is a teardown barrier: the outgoing channel must unmount before the
// relay changes, or its read effect can advance markers on the wrong relay.
const switchCommunity = React.useCallback(
async (id: string) => {
const activeCommunityId = communities.activeCommunity?.id;
if (id === activeCommunityId) return;
if (!activeCommunityId) {
communities.switchCommunity(id);
return;
}
await runCommunityViewTransition(async () => {
saveActiveDestination();
await goHome({ replace: true });
markPendingCommunityRestore(id);
const destination = loadCommunityDestination(id);
if (destination?.kind === "channel") {
replaceCommunityDestinationRoute(
destination.channelId,
router.history,
);
}
communities.switchCommunity(id);
});
},
[communities, goHome, router.history, saveActiveDestination],
);
// Local-only cleanup shared by Leave and "Remove from this device". It never
// contacts the relay, so it still works when the relay is gone. Once the
// community is gone, its relay's agent pairs are stopped so none keep
// reconnecting to it.
const removeCommunityFromDevice = React.useCallback(
async (id: string) => {
const target = communities.communities.find(
(community) => community.id === id,
);
if (!target) return;
// Another community can point at the same relay; its pairs stay up.
const relayStillUsed = communities.communities.some(
(community) =>
community.id !== id &&
canonicalRelayUrl(community.relayUrl) ===
canonicalRelayUrl(target.relayUrl),
);
// Fences any reconcile in flight for this relay; see markRelayRemoved.
const markRemoved = () => {
if (!relayStillUsed) markRelayRemoved(target.relayUrl);
};
const stopRelayPairs = () => {
if (!relayStillUsed) void stopManagedAgentPairsOnRelay(target.relayUrl);
};
// Refuses every local pair start on the relay in Rust, including a
// launch restore or start already in flight; pairs registered first are
// caught by the stop. Relay routing is left untouched.
const refuseRelay = async () => {
if (!relayStillUsed) await refuseRelayAdmission(target.relayUrl);
};
if (id !== communities.activeCommunity?.id) {
markRemoved();
await refuseRelay();
communities.removeCommunity(id);
stopRelayPairs();
return;
}
const fallback = communities.communities.find(
(community) => community.id !== id,
);
if (!fallback) {
if (!markCommunityDiscoveryAfterLeave()) {
throw new Error(
"Couldn't finish removing the community from this device because community discovery state could not be saved. Restart Buzz and try again.",
);
}
markRemoved();
await refuseRelay();
await goHome({ replace: true });
communities.removeCommunity(id);
stopRelayPairs();
return;
}
markRemoved();
await refuseRelay();
await runCommunityViewTransition(async () => {
saveActiveDestination();
await goHome({ replace: true });
markPendingCommunityRestore(fallback.id);
const destination = loadCommunityDestination(fallback.id);
if (destination?.kind === "channel") {
replaceCommunityDestinationRoute(
destination.channelId,
router.history,
);
}
communities.removeCommunity(id);
});
stopRelayPairs();
},
[communities, goHome, router.history, saveActiveDestination],
);
const leaveAndRemoveCommunity = React.useCallback(
async (id: string) => {
const target = communities.communities.find(
(community) => community.id === id,
);
if (!target) 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
// menu so the person can retry without losing their community config.
const leaveResult = await leaveCommunity(
target.relayUrl,
communities.activeCommunity?.relayUrl,
);
await removeCommunityFromDevice(id);
return leaveResult;
},
[communities, removeCommunityFromDevice],
);
return {
leaveAndRemoveCommunity,
removeCommunityFromDevice,
switchCommunity,
};
}