From e40cc4954552123b698d72f1cd1440a2cc4cfc36 Mon Sep 17 00:00:00 2001 From: Phuoc Do Date: Thu, 23 Jul 2026 10:37:02 -0700 Subject: [PATCH] fix(desktop): switch to existing community on buzz://connect deep links Canonical relay URL matching and an early switch path keep connect links from restarting onboarding when the target community is already configured. Co-authored-by: Cursor --- desktop/src/app/App.tsx | 41 ++++++++++-- .../communities/communityStorage.test.mjs | 65 ++++++++++++++++++ .../features/communities/communityStorage.ts | 40 +++++++++++ .../features/communities/useCommunities.tsx | 26 ++++--- .../onboarding/communityOnboarding.tsx | 27 ++++---- desktop/src/shared/deep-link.ts | 15 ++++- desktop/tests/e2e/deep-link-invite.spec.ts | 67 +++++++++++++++++++ 7 files changed, 251 insertions(+), 30 deletions(-) diff --git a/desktop/src/app/App.tsx b/desktop/src/app/App.tsx index ed9c465bcc..5195b37e9a 100644 --- a/desktop/src/app/App.tsx +++ b/desktop/src/app/App.tsx @@ -41,6 +41,7 @@ import { onAddCommunityPrefillAvailable, requestAddCommunityPrefill, } from "@/features/communities/addCommunityPrefill"; +import { findCommunityByRelayUrl } from "@/features/communities/communityStorage"; import { WelcomeSetup } from "@/features/communities/ui/WelcomeSetup"; import { CommunityApplyErrorScreen } from "@/features/communities/ui/CommunityApplyErrorScreen"; import { CommunityChangeOverlay } from "@/features/communities/ui/CommunityChangeOverlay"; @@ -333,9 +334,19 @@ function CommunityApp({ return; } const previousCommunityId = activeCommunity?.id; - const relayAlreadyExists = communities.some( - (community) => community.relayUrl === transaction.relayUrl, + const existingCommunity = findCommunityByRelayUrl( + communities, + transaction.relayUrl, ); + if (existingCommunity) { + switchCommunity(existingCommunity.id); + reconnectCommunity(); + if (currentPubkey) { + markCommunityOnboardingComplete(currentPubkey, transaction.relayUrl); + } + communityOnboarding.clear(); + return; + } const id = addCommunity({ id: crypto.randomUUID(), name: transaction.communityName, @@ -348,7 +359,7 @@ function CommunityApp({ communityOnboarding.update({ communityId: id, previousCommunityId, - addedCommunity: !relayAlreadyExists, + addedCommunity: true, error: undefined, }); switchCommunity(id); @@ -540,7 +551,8 @@ function CommunityApp({ } function MachineBootstrap({ sharedIdentity }: { sharedIdentity: boolean }) { - const { activeCommunity } = useCommunities(); + const { activeCommunity, communities, switchCommunity, reconnectCommunity } = + useCommunities(); const communityOnboarding = useCommunityOnboarding(); const machine = useMachineOnboardingState({ activeCommunityPubkey: activeCommunity @@ -576,6 +588,24 @@ function MachineBootstrap({ sharedIdentity }: { sharedIdentity: boolean }) { [activeCommunity, communityOnboarding.start], ); + const switchToCommunityRelay = useCallback( + (relayUrl: string) => { + const existing = findCommunityByRelayUrl(communities, relayUrl); + if (!existing) return false; + + communityOnboarding.clear(); + switchCommunity(existing.id); + reconnectCommunity(); + return true; + }, + [ + communities, + communityOnboarding.clear, + reconnectCommunity, + switchCommunity, + ], + ); + // 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 @@ -584,13 +614,14 @@ function MachineBootstrap({ sharedIdentity }: { sharedIdentity: boolean }) { useEffect(() => { const unlisten = listenForDeepLinks({ startCommunityOnboarding: communityOnboarding.start, + switchToCommunityRelay, openAddCommunity, onAddCommunityAvailable: onAddCommunityPrefillAvailable, }); return () => { void unlisten.then((fn) => fn()); }; - }, [communityOnboarding.start, openAddCommunity]); + }, [communityOnboarding.start, openAddCommunity, switchToCommunityRelay]); if (machine.stage === "reset-failed") return ; if (machine.stage === "keyring-locked") return ; diff --git a/desktop/src/features/communities/communityStorage.test.mjs b/desktop/src/features/communities/communityStorage.test.mjs index 633fccd202..967318af6f 100644 --- a/desktop/src/features/communities/communityStorage.test.mjs +++ b/desktop/src/features/communities/communityStorage.test.mjs @@ -2,7 +2,9 @@ import assert from "node:assert/strict"; import test from "node:test"; import { + canonicalCommunityRelayUrl, clearCommunityStorage, + findCommunityByRelayUrl, initFirstCommunity, migrateLegacyCommunityStorage, shouldAutoConnectDefaultRelay, @@ -102,3 +104,66 @@ test("clearCommunityStorage removes new and legacy state", () => { assert.equal(storage.length, 0); }); + +test("canonicalCommunityRelayUrl normalizes case and trailing slashes", () => { + assert.equal( + canonicalCommunityRelayUrl("WSS://Buzz.Block.Builderlab.xyz/"), + "wss://buzz.block.builderlab.xyz", + ); + assert.equal( + canonicalCommunityRelayUrl("wss://buzz.block.builderlab.xyz"), + "wss://buzz.block.builderlab.xyz", + ); +}); + +test("canonicalCommunityRelayUrl normalizes loopback host spellings", () => { + assert.equal( + canonicalCommunityRelayUrl("ws://localhost:3000"), + "ws://127.0.0.1:3000", + ); + assert.equal( + canonicalCommunityRelayUrl("ws://127.0.0.1:3000"), + "ws://127.0.0.1:3000", + ); +}); + +test("findCommunityByRelayUrl matches stored communities canonically", () => { + const communities = [ + { + id: "builderlab", + name: "Builderlab", + relayUrl: "wss://Buzz.Block.Builderlab.xyz/", + addedAt: "2026-07-23T00:00:00.000Z", + }, + { + id: "other", + name: "Other", + relayUrl: "wss://other.example", + addedAt: "2026-07-23T00:00:00.000Z", + }, + ]; + + assert.equal( + findCommunityByRelayUrl(communities, "wss://buzz.block.builderlab.xyz")?.id, + "builderlab", + ); + assert.equal( + findCommunityByRelayUrl(communities, "wss://missing.example"), + undefined, + ); +}); + +test("findCommunityByRelayUrl matches localhost against 127.0.0.1", () => { + const communities = [ + { + id: "local", + name: "Local Dev", + relayUrl: "ws://127.0.0.1:3000", + addedAt: "2026-07-23T00:00:00.000Z", + }, + ]; + assert.equal( + findCommunityByRelayUrl(communities, "ws://localhost:3000")?.id, + "local", + ); +}); diff --git a/desktop/src/features/communities/communityStorage.ts b/desktop/src/features/communities/communityStorage.ts index 4a99e1f2e0..fbbdf90634 100644 --- a/desktop/src/features/communities/communityStorage.ts +++ b/desktop/src/features/communities/communityStorage.ts @@ -127,6 +127,46 @@ export function shouldAutoConnectDefaultRelay(relayUrl: string): boolean { } } +/** + * Canonical ws(s) relay URL for community matching and deduplication. + * Deep links, stored communities, and relay probes may spell the same relay + * differently (case, trailing slash); compare via this form. + */ +export function canonicalCommunityRelayUrl(rawRelayUrl: string): string { + const trimmed = rawRelayUrl.trim(); + const withScheme = /^(ws|wss):\/\//i.test(trimmed) + ? trimmed + : normalizeRelayUrl(trimmed); + const parsed = new URL(withScheme); + const protocol = parsed.protocol.toLowerCase(); + let host = parsed.hostname.toLowerCase(); + if (host === "localhost" || host === "[::1]" || host.startsWith("127.")) { + host = "127.0.0.1"; + } + const defaultPort = protocol === "ws:" ? "80" : "443"; + const port = + parsed.port && parsed.port !== defaultPort ? `:${parsed.port}` : ""; + const path = + parsed.pathname.replace(/\/+$/, "") === "" || + parsed.pathname.replace(/\/+$/, "") === "/" + ? "" + : parsed.pathname.replace(/\/+$/, ""); + return `${protocol}//${host}${port}${path}${parsed.search}`.replace( + /\/+$/, + "", + ); +} + +export function findCommunityByRelayUrl( + communities: readonly Community[], + relayUrl: string, +): Community | undefined { + const canonical = canonicalCommunityRelayUrl(relayUrl); + return communities.find( + (community) => canonicalCommunityRelayUrl(community.relayUrl) === canonical, + ); +} + export function deriveCommunityName(relayUrl: string): string { try { const url = new URL( diff --git a/desktop/src/features/communities/useCommunities.tsx b/desktop/src/features/communities/useCommunities.tsx index 5744d42ba7..6d5c99ad3e 100644 --- a/desktop/src/features/communities/useCommunities.tsx +++ b/desktop/src/features/communities/useCommunities.tsx @@ -11,6 +11,7 @@ import type { ReactNode } from "react"; import type { Community } from "./types"; import { clearCommunityStorage, + canonicalCommunityRelayUrl, loadActiveCommunityId, loadCommunities, saveActiveCommunityId, @@ -43,12 +44,18 @@ 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 nextRelayUrl = updates.relayUrl; + if ( + communities.some( + (w) => + w.id !== id && + canonicalCommunityRelayUrl(w.relayUrl) === + canonicalCommunityRelayUrl(nextRelayUrl), + ) + ) { + return { kind: "duplicate-relay" }; + } } const hasChange = @@ -165,12 +172,15 @@ function useCommunitiesInternal(): UseCommunitiesReturn { ); const addCommunity = useCallback((community: Community): string => { + const canonical = canonicalCommunityRelayUrl(community.relayUrl); const existing = communitiesRef.current.find( - (w) => w.relayUrl === community.relayUrl, + (w) => canonicalCommunityRelayUrl(w.relayUrl) === canonical, ); const resolvedId = existing?.id ?? community.id; setCommunitiesState((prev) => { - const dup = prev.find((w) => w.relayUrl === community.relayUrl); + const dup = prev.find( + (w) => canonicalCommunityRelayUrl(w.relayUrl) === canonical, + ); let next: Community[]; if (dup) { next = prev.map((w) => diff --git a/desktop/src/features/onboarding/communityOnboarding.tsx b/desktop/src/features/onboarding/communityOnboarding.tsx index bbb636b3a1..220c2e282e 100644 --- a/desktop/src/features/onboarding/communityOnboarding.tsx +++ b/desktop/src/features/onboarding/communityOnboarding.tsx @@ -1,6 +1,6 @@ import { + canonicalCommunityRelayUrl, deriveCommunityName, - normalizeRelayUrl, } from "@/features/communities/communityStorage"; import { setLocalStorageItemWithRecovery } from "@/shared/lib/localStorageQuota"; import type { Profile } from "@/shared/api/types"; @@ -82,18 +82,6 @@ export type StartCommunityOnboardingInput = { policyReceipt?: string; }; -function canonicalRelayUrl(rawRelayUrl: string) { - const trimmed = rawRelayUrl.trim(); - const withScheme = /^(ws|wss):\/\//i.test(trimmed) - ? trimmed - : normalizeRelayUrl(trimmed); - const parsed = new URL(withScheme); - parsed.protocol = parsed.protocol.toLowerCase(); - parsed.hostname = parsed.hostname.toLowerCase(); - parsed.pathname = parsed.pathname.replace(/\/+$/, "") || "/"; - return parsed.toString().replace(/\/$/, ""); -} - function isTransaction( value: unknown, ): value is CommunityOnboardingTransaction { @@ -151,9 +139,12 @@ export function startCommunityOnboarding( storage: Storage = localStorage, now = new Date(), ): CommunityOnboardingTransaction { - const relayUrl = canonicalRelayUrl(input.relayUrl); + const relayUrl = canonicalCommunityRelayUrl(input.relayUrl); const existing = loadCommunityOnboardingTransaction(storage); if (existing?.relayUrl === relayUrl) { + const reopenConnect = + input.source === "deep-link-connect" && + existing.source === "deep-link-connect"; const updated = { ...existing, firstCommunityPage: @@ -163,6 +154,12 @@ export function startCommunityOnboarding( token: input.token?.trim() || existing.token, reposDir: input.reposDir ?? existing.reposDir, policyReceipt: input.policyReceipt ?? existing.policyReceipt, + stage: reopenConnect ? "connecting" : existing.stage, + communityId: reopenConnect ? undefined : existing.communityId, + previousCommunityId: reopenConnect + ? undefined + : existing.previousCommunityId, + addedCommunity: reopenConnect ? undefined : existing.addedCommunity, updatedAt: now.toISOString(), error: undefined, // A freshly opened link deserves fresh feedback — re-present the gate @@ -339,7 +336,7 @@ export function CommunityOnboardingProvider({ (input: StartCommunityOnboardingInput) => { if ( transaction && - canonicalRelayUrl(input.relayUrl) !== transaction.relayUrl + canonicalCommunityRelayUrl(input.relayUrl) !== transaction.relayUrl ) { return false; } diff --git a/desktop/src/shared/deep-link.ts b/desktop/src/shared/deep-link.ts index c62a8bec3b..8f1f92fb8e 100644 --- a/desktop/src/shared/deep-link.ts +++ b/desktop/src/shared/deep-link.ts @@ -9,6 +9,7 @@ export type AddCommunityDeepLinkPayload = { export interface DeepLinkDeps { startCommunityOnboarding: (input: StartCommunityOnboardingInput) => boolean; + switchToCommunityRelay: (relayUrl: string) => boolean; openAddCommunity: ( payload: AddCommunityDeepLinkPayload & { requestId: string }, ) => boolean; @@ -62,6 +63,15 @@ function acceptPendingCommunityDeepLink( pending: PendingCommunityDeepLink, deps: DeepLinkDeps, ) { + if ( + pending.kind === "connect" && + deps.switchToCommunityRelay(pending.relayUrl) + ) { + return invoke("acknowledge_pending_community_deep_link", { + id: pending.id, + }); + } + const accepted = pending.kind === "add-community" ? deps.openAddCommunity({ @@ -98,8 +108,9 @@ async function drainPendingCommunityDeepLinks(deps: DeepLinkDeps) { * Register listeners for deep-link events emitted by the Rust backend. * * When a `buzz://connect?relay=` link is opened, the handler - * adds a community for the relay (deduplicating by URL) and switches - * to it. Returns an unlisten function to tear down all listeners. + * switches to an existing community for that relay (matching canonically) + * or starts community onboarding to add and connect a new one. Returns an + * unlisten function to tear down all listeners. * * When a `buzz://join?relay=&code=` link is opened (relay * invite landing page), the handler first claims the invite against the diff --git a/desktop/tests/e2e/deep-link-invite.spec.ts b/desktop/tests/e2e/deep-link-invite.spec.ts index 34f310d9ee..d62dc41538 100644 --- a/desktop/tests/e2e/deep-link-invite.spec.ts +++ b/desktop/tests/e2e/deep-link-invite.spec.ts @@ -76,6 +76,73 @@ test("join deep link is acknowledged without claiming before setup", async ({ .toContain('"stage":"claiming"'); }); +test("connect deep link switches to an existing community without onboarding", async ({ + page, +}) => { + const activeCommunityId = "builderlab-community"; + const otherCommunityId = "other-community"; + const builderlabRelay = "wss://buzz.block.builderlab.xyz"; + await page.addInitScript( + ({ activeId, communities }) => { + window.localStorage.setItem( + "buzz-communities", + JSON.stringify(communities), + ); + window.localStorage.setItem("buzz-active-community-id", activeId); + }, + { + activeId: otherCommunityId, + communities: [ + { + id: activeCommunityId, + name: "Builderlab", + relayUrl: "WSS://Buzz.Block.Builderlab.xyz/", + addedAt: "2026-07-23T00:00:00.000Z", + }, + { + id: otherCommunityId, + name: "Other", + relayUrl: "wss://other.example", + addedAt: "2026-07-23T00:00:00.000Z", + }, + ], + }, + ); + await installMockBridge( + page, + { + pendingCommunityDeepLinks: [ + { + id: "dl-connect-builderlab", + kind: "connect", + relayUrl: builderlabRelay, + code: null, + }, + ], + }, + { skipCommunitySeed: true }, + ); + await page.goto("/"); + + await expect(page.getByTestId("community-onboarding-flow")).toHaveCount(0); + await expect(page.getByTestId("sidebar-profile-avatar-button")).toBeVisible(); + await expect + .poll(() => + page.evaluate(() => + window.localStorage.getItem("buzz-active-community-id"), + ), + ) + .toBe(activeCommunityId); + await expect + .poll(() => + page.evaluate( + (key) => window.localStorage.getItem(key), + TRANSACTION_STORAGE_KEY, + ), + ) + .toBeNull(); +}); + test("connect deep link shows a static acknowledgment during setup", async ({ page, }) => {