feat(desktop): confirm invites with an Opening-invite loading gate before machine onboarding (#1950)

Signed-off-by: Matt Toohey <contact@matttoohey.com>
Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Pinky <44b8e82baa6e0e254e0208d68f335c283c94e7b78dd1fa10d5a49d3f13dd0435@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
Matt Toohey
2026-07-17 00:42:48 +10:00
committed by GitHub
parent 7eea924f3d
commit 2510a4b4c9
11 changed files with 396 additions and 49 deletions

View File

@@ -93,6 +93,7 @@ export default defineConfig({
"**/buzz-theme-screenshots.spec.ts",
"**/channel-sort.spec.ts",
"**/identity-lost.spec.ts",
"**/deep-link-invite.spec.ts",
"**/global-agent-config-screenshots.spec.ts",
"**/doctor-states.spec.ts",
"**/onboarding-avatar-skip.spec.ts",

View File

@@ -21,6 +21,7 @@ import { useCommunityOnboarding } from "@/features/onboarding/communityOnboardin
import { CommunityOnboardingFlow } from "@/features/onboarding/ui/CommunityOnboardingFlow";
import { MachineOnboardingFlow } from "@/features/onboarding/ui/MachineOnboardingFlow";
import { OnboardingFlow } from "@/features/onboarding/ui/OnboardingFlow";
import { PendingInviteGate } from "@/features/onboarding/ui/PendingInviteGate";
import { KeyringLockedScreen } from "@/features/onboarding/ui/KeyringLockedScreen";
import { RelaunchRequiredScreen } from "@/features/onboarding/ui/RelaunchRequiredScreen";
import { ResetFailedScreen } from "@/features/onboarding/ui/ResetFailedScreen";
@@ -255,14 +256,6 @@ function CommunityApp({ sharedIdentity }: { sharedIdentity: boolean }) {
const communityOnboarding = useCommunityOnboarding();
const [isCommunityChangeOpen, setIsCommunityChangeOpen] = useState(false);
useEffect(() => {
const unlisten = listenForDeepLinks({
startCommunityOnboarding: communityOnboarding.start,
});
return () => {
void unlisten.then((fn) => fn());
};
}, [communityOnboarding.start]);
// Surface nest-related backend events (repos-dir errors, legacy migration)
// as toasts. Mounted before useCommunityInit so the listeners are registered
// ahead of the first apply_workspace call.
@@ -389,11 +382,26 @@ function CommunityApp({ sharedIdentity }: { sharedIdentity: boolean }) {
function MachineBootstrap({ sharedIdentity }: { sharedIdentity: boolean }) {
const { activeCommunity } = useCommunities();
const communityOnboarding = useCommunityOnboarding();
const machine = useMachineOnboardingState({
hasConfiguredCommunity: activeCommunity !== null,
isSharedIdentity: sharedIdentity,
});
// Deep links are captured here — above the machine-onboarding gate — not in
// CommunityApp. The Rust side queues them; draining into the persisted
// community-onboarding transaction immediately means an invite opened on a
// fresh install is acknowledged on screen while the identity steps are
// still pending, and survives a relaunch in between.
useEffect(() => {
const unlisten = listenForDeepLinks({
startCommunityOnboarding: communityOnboarding.start,
});
return () => {
void unlisten.then((fn) => fn());
};
}, [communityOnboarding.start]);
if (machine.stage === "reset-failed") return <ResetFailedScreen />;
if (machine.stage === "keyring-locked") return <KeyringLockedScreen />;
if (machine.stage === "relaunch-required") return <RelaunchRequiredScreen />;
@@ -402,12 +410,24 @@ function MachineBootstrap({ sharedIdentity }: { sharedIdentity: boolean }) {
return <CommunityApp sharedIdentity={sharedIdentity} />;
}
// A community deep link that arrived before machine onboarding finished is
// persisted immediately and acknowledged here. Invite claiming waits until
// setup completes so it is signed only by the user's final identity.
const transaction = communityOnboarding.transaction;
const isDeepLink =
transaction?.source === "deep-link-join" ||
transaction?.source === "deep-link-connect";
const shouldAcknowledgeDeepLink = isDeepLink && !transaction.acknowledged;
return (
<MachineOnboardingFlow
complete={machine.complete}
identityLost={machine.identityLost}
queryClient={machine.queryClient}
/>
<>
<MachineOnboardingFlow
complete={machine.complete}
identityLost={machine.identityLost}
queryClient={machine.queryClient}
/>
{shouldAcknowledgeDeepLink ? <PendingInviteGate /> : null}
</>
);
}

View File

@@ -7,6 +7,7 @@ import {
markCommunityOnboardingComplete,
startCommunityOnboarding,
updateCommunityOnboardingTransaction,
updateCurrentCommunityOnboardingTransaction,
} from "./communityOnboarding.tsx";
function createMemoryStorage(initial = {}) {
@@ -79,6 +80,53 @@ test("same-relay ingress resumes rather than replacing progress", () => {
assert.equal(resumed.inviteCode, "new-code");
});
test("stale asynchronous updates cannot mutate a replacement transaction", () => {
const storage = createMemoryStorage();
const original = startCommunityOnboarding(
{
source: "deep-link-join",
relayUrl: "wss://relay.example",
inviteCode: "invite-code",
},
storage,
);
const replacement = startCommunityOnboarding(
{ source: "deep-link-connect", relayUrl: "wss://other.example" },
storage,
);
const result = updateCurrentCommunityOnboardingTransaction(
replacement,
{ stage: "connecting", error: "stale error" },
original.id,
storage,
);
assert.equal(result, replacement);
assert.equal(loadCommunityOnboardingTransaction(storage)?.id, replacement.id);
assert.equal(loadCommunityOnboardingTransaction(storage)?.error, undefined);
});
test("acknowledgment persists but resets when the same-relay link reopens", () => {
const storage = createMemoryStorage();
const transaction = startCommunityOnboarding(
{ source: "deep-link-connect", relayUrl: "wss://relay.example" },
storage,
);
assert.equal(transaction.stage, "connecting");
updateCommunityOnboardingTransaction(
transaction,
{ acknowledged: true },
storage,
);
assert.equal(loadCommunityOnboardingTransaction(storage)?.acknowledged, true);
const reopened = startCommunityOnboarding(
{ source: "deep-link-connect", relayUrl: "wss://relay.example" },
storage,
);
assert.equal(reopened.acknowledged, undefined);
});
test("malformed persisted state is ignored and can be cleared", () => {
const storage = createMemoryStorage({
"buzz-community-onboarding-transaction.v1": '{"stage":"profile"}',

View File

@@ -33,8 +33,18 @@ export type CommunityOnboardingTransaction = {
createdAt: string;
updatedAt: string;
error?: string;
// Deep links are persisted before machine onboarding completes. Set when
// the user dismisses the acknowledgment so it stays dismissed on relaunch.
acknowledged?: boolean;
};
export type CommunityOnboardingTransactionPatch = Partial<
Pick<
CommunityOnboardingTransaction,
"stage" | "communityId" | "communityName" | "error" | "acknowledged"
>
>;
export type StartCommunityOnboardingInput = {
source: CommunityOnboardingSource;
relayUrl: string;
@@ -119,6 +129,9 @@ export function startCommunityOnboarding(
reposDir: input.reposDir ?? existing.reposDir,
updatedAt: now.toISOString(),
error: undefined,
// A freshly opened link deserves fresh feedback — re-present the gate
// even if a previous link for this relay was already dismissed.
acknowledged: undefined,
};
saveCommunityOnboardingTransaction(updated, storage);
return updated;
@@ -143,12 +156,7 @@ export function startCommunityOnboarding(
export function updateCommunityOnboardingTransaction(
transaction: CommunityOnboardingTransaction,
patch: Partial<
Pick<
CommunityOnboardingTransaction,
"stage" | "communityId" | "communityName" | "error"
>
>,
patch: CommunityOnboardingTransactionPatch,
storage: Storage = localStorage,
now = new Date(),
): CommunityOnboardingTransaction {
@@ -157,6 +165,17 @@ export function updateCommunityOnboardingTransaction(
return updated;
}
export function updateCurrentCommunityOnboardingTransaction(
current: CommunityOnboardingTransaction | null,
patch: CommunityOnboardingTransactionPatch,
expectedId: string | undefined,
storage: Storage = localStorage,
now = new Date(),
): CommunityOnboardingTransaction | null {
if (!current || (expectedId && current.id !== expectedId)) return current;
return updateCommunityOnboardingTransaction(current, patch, storage, now);
}
export function markCommunityOnboardingComplete(
pubkey: string,
relayUrl: string,
@@ -177,12 +196,8 @@ type CommunityOnboardingContextValue = {
transaction: CommunityOnboardingTransaction | null;
start: (input: StartCommunityOnboardingInput) => boolean;
update: (
patch: Partial<
Pick<
CommunityOnboardingTransaction,
"stage" | "communityId" | "communityName" | "error"
>
>,
patch: CommunityOnboardingTransactionPatch,
expectedId?: string,
) => void;
clear: () => void;
};
@@ -212,18 +227,9 @@ export function CommunityOnboardingProvider({
[transaction],
);
const update = React.useCallback(
(
patch: Partial<
Pick<
CommunityOnboardingTransaction,
"stage" | "communityId" | "communityName" | "error"
>
>,
) => {
(patch: CommunityOnboardingTransactionPatch, expectedId?: string) => {
setTransaction((current) =>
current
? updateCommunityOnboardingTransaction(current, patch)
: current,
updateCurrentCommunityOnboardingTransaction(current, patch, expectedId),
);
},
[],

View File

@@ -7,10 +7,9 @@ import {
useCommunityOnboarding,
} from "@/features/onboarding/communityOnboarding";
import { initializeWelcomeChannel } from "@/features/onboarding/hooks";
import { useClaimInvite } from "@/features/onboarding/useClaimInvite";
import { AvatarUpload } from "@/features/profile/ui/AvatarUpload";
import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar";
import { claimInvite } from "@/shared/api/invites";
import { inviteErrorMessage } from "@/shared/api/inviteHelpers";
import { updateProfile } from "@/shared/api/tauriProfiles";
import { getIdentity } from "@/shared/api/tauriIdentity";
import { listPersonas } from "@/shared/api/tauriPersonas";
@@ -50,19 +49,11 @@ export function CommunityOnboardingFlow({
.catch(() => setStarterPersonas([]));
}, [transaction?.stage]);
React.useEffect(() => {
if (transaction?.stage !== "claiming" || isPending) return;
setIsPending(true);
void claimInvite(transaction.relayUrl, transaction.inviteCode ?? "")
.then(() => update({ stage: "connecting", error: undefined }))
.catch((error: unknown) => update({ error: inviteErrorMessage(error) }))
.finally(() => setIsPending(false));
}, [isPending, transaction, update]);
useClaimInvite();
React.useEffect(() => {
if (transaction?.stage !== "connecting") return;
onConnect();
}, [onConnect, transaction]);
if (transaction?.stage === "connecting") onConnect();
}, [onConnect, transaction?.stage]);
const retryClaim = () => update({ stage: "claiming", error: undefined });
const relayUrl = transaction?.relayUrl;

View File

@@ -0,0 +1,50 @@
import { useCommunityOnboarding } from "@/features/onboarding/communityOnboarding";
import { Button } from "@/shared/ui/button";
import { FlappingBee } from "@/shared/ui/buzz-logo/FlappingBee";
/**
* Acknowledge a community deep link received before machine onboarding is
* complete. The transaction is already persisted; claiming and connecting
* wait until setup finishes so only the user's final identity is admitted.
*/
export function PendingInviteGate() {
const { transaction, update, clear } = useCommunityOnboarding();
if (!transaction) return null;
return (
<div
className="buzz-onboarding-neutral-theme fixed inset-0 z-50 flex items-center justify-center bg-background px-4 py-8 text-foreground"
data-testid="pending-invite-gate"
>
<div className="flex w-full max-w-[440px] flex-col items-center text-center">
<FlappingBee className="h-auto w-24" />
<h1 className="mt-6 text-3xl font-semibold tracking-tight">
Opening community link
</h1>
<p className="mt-3 text-sm leading-6 text-muted-foreground">
You’ll connect to {transaction.communityName} once setup is finished.
</p>
<div className="mt-8 flex w-full flex-col gap-3">
<Button
className="h-10 w-full"
data-testid="pending-invite-continue"
onClick={() => update({ acknowledged: true })}
type="button"
>
Continue setup
</Button>
<Button
className="h-10 w-full"
data-testid="pending-invite-cancel"
onClick={clear}
type="button"
variant="ghost"
>
Cancel
</Button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,35 @@
import * as React from "react";
import { useCommunityOnboarding } from "@/features/onboarding/communityOnboarding";
import { inviteErrorMessage } from "@/shared/api/inviteHelpers";
import { claimInvite } from "@/shared/api/invites";
/**
* Drive the `claiming` stage after machine onboarding completes: claim the
* invite with the user's final identity, then advance to `connecting`.
* Completion is fenced by transaction ID so cancelling or replacing the
* transaction while the request is pending cannot mutate the replacement.
*
* The error guard keeps a failed claim parked on the caller's Retry
* affordance — without it the effect refires on the error-bearing transaction
* and re-claims in a loop.
*/
export function useClaimInvite() {
const { transaction, update } = useCommunityOnboarding();
const [isPending, setIsPending] = React.useState(false);
React.useEffect(() => {
if (transaction?.stage !== "claiming" || transaction.error || isPending) {
return;
}
setIsPending(true);
void claimInvite(transaction.relayUrl, transaction.inviteCode ?? "")
.then(() => {
update({ stage: "connecting", error: undefined }, transaction.id);
})
.catch((error: unknown) =>
update({ error: inviteErrorMessage(error) }, transaction.id),
)
.finally(() => setIsPending(false));
}, [isPending, transaction, update]);
}

View File

@@ -12,6 +12,11 @@ import { getRelayHttpUrl, signRelayEvent } from "@/shared/api/tauri";
const NIP98_KIND = 27235;
// Bound invite requests so an unreachable relay surfaces as an error in the
// invite-loading UI within seconds instead of hanging for the OS-level
// connect timeout (a minute or more on macOS).
const INVITE_REQUEST_TIMEOUT_MS = 15_000;
export type MintedInvite = {
code: string;
expiresAt: number;
@@ -71,6 +76,7 @@ async function invitePost<T>(
"Content-Type": "application/json",
},
body,
signal: AbortSignal.timeout(INVITE_REQUEST_TIMEOUT_MS),
});
const json = (await response.json().catch(() => ({}))) as Record<
string,

View File

@@ -234,6 +234,17 @@ type E2eConfig = {
// Event IDs that `get_event` should report as definitively not found.
// Causes `useDraftRootStatus` to classify as `deleted`.
deletedEventIds?: string[];
// Pending community deep links (buzz://join / buzz://connect) seeded into
// the mocked Rust-side queue. Mirrors the real queue's semantics:
// `take_pending_community_deep_link` peeks the head and
// `acknowledge_pending_community_deep_link` removes by id. Drives the
// pending-invite gate and deep-link drain path in tests.
pendingCommunityDeepLinks?: Array<{
id: string;
kind: "connect" | "join";
relayUrl: string;
code?: string | null;
}>;
// When true, `get_identity` returns `lost: true` until `persist_current_identity`
// or `import_identity` is called. Drives the identity-lost recovery UX in tests.
identityLost?: boolean;
@@ -3536,6 +3547,20 @@ function resetMockUserStatuses() {
mockUserStatuses.length = 0;
}
// Mocked Rust-side pending deep-link queue (see desktop/src-tauri/src/deep_link.rs).
let mockPendingCommunityDeepLinks: Array<{
id: string;
kind: string;
relayUrl: string;
code: string | null;
}> = [];
function resetMockPendingCommunityDeepLinks(config: E2eConfig | null) {
mockPendingCommunityDeepLinks = (
config?.mock?.pendingCommunityDeepLinks ?? []
).map((pending) => ({ ...pending, code: pending.code ?? null }));
}
function recordMockUserStatus(event: RelayEvent) {
const dTag = event.tags.find((tag) => tag[0] === "d")?.[1];
if (dTag) {
@@ -8215,6 +8240,7 @@ export function maybeInstallE2eTauriMocks() {
resetMockWorkflows();
resetMockMesh();
resetMockUserStatuses();
resetMockPendingCommunityDeepLinks(config);
mockWebsocketSendMutexWedged = false;
mockWindows("main");
window.__BUZZ_E2E_COMMANDS__ = [];
@@ -8807,6 +8833,20 @@ export function maybeInstallE2eTauriMocks() {
activeWorkspaceId: null,
onboardingCompletions: [],
};
case "take_pending_community_deep_link":
// Mirrors the Rust queue: peek the head; acknowledge removes it.
return mockPendingCommunityDeepLinks[0] ?? null;
case "acknowledge_pending_community_deep_link": {
const { id } = payload as { id: string };
const index = mockPendingCommunityDeepLinks.findIndex(
(pending) => pending.id === id,
);
if (index === -1) {
return false;
}
mockPendingCommunityDeepLinks.splice(index, 1);
return true;
}
case "get_relay_http_url":
return getRelayHttpUrl(activeConfig);
case "discover_acp_providers":

View File

@@ -0,0 +1,139 @@
import { expect, test } from "@playwright/test";
import { installMockBridge } from "../helpers/bridge";
// Community deep links that arrive before machine onboarding complete are
// drained from Rust into a persisted transaction and acknowledged immediately.
// Invite claiming waits until setup finishes and the final identity is known.
const DEFAULT_MOCK_PUBKEY = "deadbeef".repeat(8);
const TRANSACTION_STORAGE_KEY = "buzz-community-onboarding-transaction.v1";
const PENDING_JOIN_LINK = {
id: "dl-join-1",
kind: "join" as const,
relayUrl: "wss://hive.example.com",
code: "abc.def",
};
const PENDING_CONNECT_LINK = {
id: "dl-connect-1",
kind: "connect" as const,
relayUrl: "wss://hive.example.com",
code: null,
};
test("join deep link is acknowledged without claiming before setup", async ({
page,
}) => {
let claimCalls = 0;
await page.route("**/api/invites/claim", async (route) => {
claimCalls++;
await route.abort();
});
await installMockBridge(
page,
{ pendingCommunityDeepLinks: [PENDING_JOIN_LINK] },
{ skipCommunitySeed: true, skipOnboardingSeed: true },
);
await page.goto("/");
const gate = page.getByTestId("pending-invite-gate");
await expect(gate).toBeVisible();
await expect(
page.getByRole("heading", { name: "Opening community link" }),
).toBeVisible();
await page.getByTestId("pending-invite-continue").click();
await expect(gate).toHaveCount(0);
await expect(page.getByTestId("machine-onboarding-gate")).toBeVisible();
expect(claimCalls).toBe(0);
await expect
.poll(() =>
page.evaluate(
(key) => window.localStorage.getItem(key),
TRANSACTION_STORAGE_KEY,
),
)
.toContain('"stage":"claiming"');
});
test("connect deep link shows a static acknowledgment during setup", async ({
page,
}) => {
// No invite code means nothing to confirm against the relay — the gate
// acknowledges the link and waits for the user instead of auto-advancing.
await installMockBridge(
page,
{ pendingCommunityDeepLinks: [PENDING_CONNECT_LINK] },
{ skipCommunitySeed: true, skipOnboardingSeed: true },
);
await page.goto("/");
const gate = page.getByTestId("pending-invite-gate");
await expect(gate).toBeVisible();
await expect(
page.getByRole("heading", { name: "Opening community link" }),
).toBeVisible();
await expect(gate).toContainText("hive");
// Continue setup dismisses the gate but keeps the transaction: the
// connect resumes in CommunityOnboardingFlow after machine setup.
await page.getByTestId("pending-invite-continue").click();
await expect(gate).toHaveCount(0);
await expect(page.getByTestId("machine-onboarding-gate")).toBeVisible();
await expect
.poll(() =>
page.evaluate(
(key) => window.localStorage.getItem(key),
TRANSACTION_STORAGE_KEY,
),
)
.toContain('"acknowledged":true');
});
test("persisted deep-link invite hands off to Joining after machine onboarding", async ({
page,
}) => {
// Deterministic claim failure (no real relay behind the mock bridge): the
// spec asserts the handoff reaches the "Joining …" claiming screen, not
// that the claim itself succeeds.
await page.route("**/api/invites/claim", (route) => route.abort());
await page.addInitScript(
({ pubkey, storageKey }) => {
window.localStorage.setItem(
`buzz-machine-onboarding-complete.v2:${pubkey}`,
"true",
);
const timestamp = new Date().toISOString();
window.localStorage.setItem(
storageKey,
JSON.stringify({
id: "txn-deep-link-1",
source: "deep-link-join",
stage: "claiming",
relayUrl: "wss://hive.example.com",
inviteCode: "abc.def",
communityName: "hive",
createdAt: timestamp,
updatedAt: timestamp,
}),
);
},
{ pubkey: DEFAULT_MOCK_PUBKEY, storageKey: TRANSACTION_STORAGE_KEY },
);
await installMockBridge(page, undefined, {
skipCommunitySeed: true,
skipOnboardingSeed: true,
});
await page.goto("/");
// Machine onboarding is complete, so the transaction owns the screen.
await expect(page.getByTestId("community-onboarding-flow")).toBeVisible();
await expect(
page.getByRole("heading", { name: "Joining hive" }),
).toBeVisible();
await expect(page.getByTestId("pending-invite-gate")).toHaveCount(0);
// The claim was attempted and its failure surfaced with a Retry.
await expect(page.getByRole("button", { name: "Retry" })).toBeVisible();
});

View File

@@ -290,6 +290,17 @@ type MockBridgeOptions = {
* invoked. Drives the keyring-locked screen in tests.
*/
identityLocked?: boolean;
/**
* Pending community deep links (buzz://join / buzz://connect) seeded into
* the mocked Rust-side queue. The frontend drains these on boot into a
* community-onboarding transaction — drives the pending-invite gate.
*/
pendingCommunityDeepLinks?: Array<{
id: string;
kind: "connect" | "join";
relayUrl: string;
code?: string | null;
}>;
/**
* Global agent config returned by `get_global_agent_config`. Defaults to
* an empty config (no provider, model, or env vars) if not specified.