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,
}) => {