Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 36 additions & 5 deletions desktop/src/app/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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,
Expand All @@ -348,7 +359,7 @@ function CommunityApp({
communityOnboarding.update({
communityId: id,
previousCommunityId,
addedCommunity: !relayAlreadyExists,
addedCommunity: true,
error: undefined,
});
switchCommunity(id);
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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 <ResetFailedScreen />;
if (machine.stage === "keyring-locked") return <KeyringLockedScreen />;
Expand Down
65 changes: 65 additions & 0 deletions desktop/src/features/communities/communityStorage.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@ import assert from "node:assert/strict";
import test from "node:test";

import {
canonicalCommunityRelayUrl,
clearCommunityStorage,
findCommunityByRelayUrl,
initFirstCommunity,
migrateLegacyCommunityStorage,
shouldAutoConnectDefaultRelay,
Expand Down Expand Up @@ -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",
);
});
40 changes: 40 additions & 0 deletions desktop/src/features/communities/communityStorage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
26 changes: 18 additions & 8 deletions desktop/src/features/communities/useCommunities.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import type { ReactNode } from "react";
import type { Community } from "./types";
import {
clearCommunityStorage,
canonicalCommunityRelayUrl,
loadActiveCommunityId,
loadCommunities,
saveActiveCommunityId,
Expand Down Expand Up @@ -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 =
Expand Down Expand Up @@ -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) =>
Expand Down
27 changes: 12 additions & 15 deletions desktop/src/features/onboarding/communityOnboarding.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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:
Expand All @@ -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
Expand Down Expand Up @@ -339,7 +336,7 @@ export function CommunityOnboardingProvider({
(input: StartCommunityOnboardingInput) => {
if (
transaction &&
canonicalRelayUrl(input.relayUrl) !== transaction.relayUrl
canonicalCommunityRelayUrl(input.relayUrl) !== transaction.relayUrl
) {
return false;
}
Expand Down
15 changes: 13 additions & 2 deletions desktop/src/shared/deep-link.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export type AddCommunityDeepLinkPayload = {

export interface DeepLinkDeps {
startCommunityOnboarding: (input: StartCommunityOnboardingInput) => boolean;
switchToCommunityRelay: (relayUrl: string) => boolean;
openAddCommunity: (
payload: AddCommunityDeepLinkPayload & { requestId: string },
) => boolean;
Expand Down Expand Up @@ -62,6 +63,15 @@ function acceptPendingCommunityDeepLink(
pending: PendingCommunityDeepLink,
deps: DeepLinkDeps,
) {
if (
pending.kind === "connect" &&
deps.switchToCommunityRelay(pending.relayUrl)
) {
return invoke<boolean>("acknowledge_pending_community_deep_link", {
id: pending.id,
});
}

const accepted =
pending.kind === "add-community"
? deps.openAddCommunity({
Expand Down Expand Up @@ -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=<url>` 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=<url>&code=<invite>` link is opened (relay
* invite landing page), the handler first claims the invite against the
Expand Down
Loading