diff --git a/desktop/src-tauri/src/deep_link.rs b/desktop/src-tauri/src/deep_link.rs index a890a99add9..3c98c66e366 100644 --- a/desktop/src-tauri/src/deep_link.rs +++ b/desktop/src-tauri/src/deep_link.rs @@ -13,6 +13,7 @@ pub(crate) struct PendingCommunityDeepLink { kind: String, relay_url: String, code: Option, + name: Option, } #[derive(Default)] @@ -25,6 +26,7 @@ impl PendingCommunityDeepLinks { item.kind == pending.kind && item.relay_url == pending.relay_url && item.code == pending.code + && item.name == pending.name }) { return; } @@ -70,6 +72,7 @@ fn queue_community_deep_link( kind: &str, relay_url: String, code: Option, + name: Option, ) { app.state::() .enqueue(PendingCommunityDeepLink { @@ -77,6 +80,7 @@ fn queue_community_deep_link( kind: kind.to_owned(), relay_url, code, + name, }); } @@ -133,7 +137,6 @@ fn parse_message_deep_link(url: &Url) -> Option { /// `code`; returns `None` otherwise so the frontend never sees a half-formed /// payload. fn parse_join_deep_link(url: &Url) -> Option { - let mut relay: Option = None; let mut code: Option = None; let mut policy_receipt: Option = None; for (k, v) in url.query_pairs() { @@ -142,17 +145,13 @@ fn parse_join_deep_link(url: &Url) -> Option { continue; } match k.as_ref() { - "relay" => relay = Some(v), "code" => code = Some(v), "policy_receipt" => policy_receipt = Some(v), _ => {} } } - let (relay_url, code) = (relay?, code?); - match Url::parse(&relay_url) { - Ok(parsed) if parsed.scheme() == "ws" || parsed.scheme() == "wss" => {} - _ => return None, - } + let code = code?; + let relay_url = parse_websocket_relay_param(url)?; Some(serde_json::json!({ "relayUrl": relay_url, "code": code, @@ -160,6 +159,33 @@ fn parse_join_deep_link(url: &Url) -> Option { })) } +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +struct AddCommunityDeepLinkPayload { + relay_url: String, + name: Option, +} + +fn parse_websocket_relay_param(url: &Url) -> Option { + let relay_url = url + .query_pairs() + .find(|(key, _)| key == "relay") + .map(|(_, value)| value.into_owned()) + .filter(|value| !value.is_empty())?; + let parsed = Url::parse(&relay_url).ok()?; + if !matches!(parsed.scheme(), "ws" | "wss") || parsed.host_str().is_none() { + return None; + } + Some(relay_url) +} + +fn parse_add_community_deep_link(url: &Url) -> Option { + Some(AddCommunityDeepLinkPayload { + relay_url: parse_websocket_relay_param(url)?, + name: optional_non_empty_param(url, "name"), + }) +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize)] #[serde(rename_all = "camelCase")] struct NostrBindDeepLinkPayload { @@ -281,31 +307,12 @@ pub(crate) fn handle_deep_link_url(app: &tauri::AppHandle, url_str: &str) { match url.host_str() { Some("connect") => { - let relay = url - .query_pairs() - .find(|(k, _)| k == "relay") - .map(|(_, v)| v.into_owned()); - let Some(relay_url) = relay else { - eprintln!("buzz-desktop: connect deep link missing relay param: {url_str}"); + let Some(relay_url) = parse_websocket_relay_param(&url) else { + eprintln!("buzz-desktop: connect deep link missing/invalid relay: {url_str}"); return; }; - // Validate the relay URL is ws:// or wss:// - match Url::parse(&relay_url) { - Ok(parsed) if parsed.scheme() == "ws" || parsed.scheme() == "wss" => {} - Ok(parsed) => { - eprintln!( - "buzz-desktop: rejecting non-websocket relay URL scheme {:?}: {relay_url}", - parsed.scheme() - ); - return; - } - Err(e) => { - eprintln!("buzz-desktop: invalid relay URL {relay_url:?}: {e}"); - return; - } - } activate_main_window(app); - queue_community_deep_link(app, "connect", relay_url.clone(), None); + queue_community_deep_link(app, "connect", relay_url.clone(), None, None); let _ = app.emit("deep-link-connect", relay_url); } Some("join") => { @@ -319,9 +326,24 @@ pub(crate) fn handle_deep_link_url(app: &tauri::AppHandle, url_str: &str) { activate_main_window(app); let relay_url = payload["relayUrl"].as_str().unwrap_or_default().to_owned(); let code = payload["code"].as_str().map(str::to_owned); - queue_community_deep_link(app, "join", relay_url, code); + queue_community_deep_link(app, "join", relay_url, code, None); let _ = app.emit("deep-link-join", payload); } + Some("add-community") => { + let Some(payload) = parse_add_community_deep_link(&url) else { + eprintln!("buzz-desktop: add-community deep link missing/invalid relay: {url_str}"); + return; + }; + activate_main_window(app); + queue_community_deep_link( + app, + "add-community", + payload.relay_url.clone(), + None, + payload.name.clone(), + ); + let _ = app.emit("deep-link-add-community", payload); + } Some("message") => { // `buzz://message?channel=&id=[&thread=]` // @@ -361,8 +383,8 @@ mod tests { use url::Url; use super::{ - parse_join_deep_link, parse_message_deep_link, parse_nostr_bind_deep_link, - PendingCommunityDeepLink, PendingCommunityDeepLinks, + parse_add_community_deep_link, parse_join_deep_link, parse_message_deep_link, + parse_nostr_bind_deep_link, PendingCommunityDeepLink, PendingCommunityDeepLinks, }; fn pending(id: &str, relay_url: &str, code: Option<&str>) -> PendingCommunityDeepLink { @@ -371,6 +393,7 @@ mod tests { kind: if code.is_some() { "join" } else { "connect" }.to_owned(), relay_url: relay_url.to_owned(), code: code.map(str::to_owned), + name: None, } } @@ -401,6 +424,43 @@ mod tests { .unwrap() } + #[test] + fn parse_add_community_deep_link_extracts_relay_and_name() { + let url = Url::parse( + "buzz://add-community?relay=wss%3A%2F%2Facme.communities.buzz.xyz&name=Acme%20Team&ignored=value", + ) + .unwrap(); + let payload = parse_add_community_deep_link(&url).unwrap(); + assert_eq!(payload.relay_url, "wss://acme.communities.buzz.xyz"); + assert_eq!(payload.name.as_deref(), Some("Acme Team")); + } + + #[test] + fn parse_add_community_deep_link_accepts_an_omitted_or_empty_name() { + for raw in [ + "buzz://add-community?relay=wss%3A%2F%2Facme.example", + "buzz://add-community?relay=wss%3A%2F%2Facme.example&name=", + ] { + assert!(parse_add_community_deep_link(&Url::parse(raw).unwrap()) + .unwrap() + .name + .is_none()); + } + } + + #[test] + fn parse_add_community_deep_link_rejects_invalid_relays() { + for raw in [ + "buzz://add-community", + "buzz://add-community?relay=", + "buzz://add-community?relay=not-a-url", + "buzz://add-community?relay=https%3A%2F%2Facme.example", + "buzz://add-community?relay=wss%3A%2F%2F", + ] { + assert!(parse_add_community_deep_link(&Url::parse(raw).unwrap()).is_none()); + } + } + #[test] fn parse_message_deep_link_extracts_required_params() { let url = Url::parse("buzz://message?channel=abc&id=xyz").unwrap(); diff --git a/desktop/src/app/App.tsx b/desktop/src/app/App.tsx index dbdd0a2c640..ac92ffee8dc 100644 --- a/desktop/src/app/App.tsx +++ b/desktop/src/app/App.tsx @@ -28,6 +28,10 @@ import { ResetFailedScreen } from "@/features/onboarding/ui/ResetFailedScreen"; import { useCommunityInit } from "@/features/communities/useCommunityInit"; import { useNestNotifications } from "@/features/communities/useNestNotifications"; import { useCommunities } from "@/features/communities/useCommunities"; +import { + onAddCommunityPrefillAvailable, + requestAddCommunityPrefill, +} from "@/features/communities/addCommunityPrefill"; import { WelcomeSetup } from "@/features/communities/ui/WelcomeSetup"; import { CommunityApplyErrorScreen } from "@/features/communities/ui/CommunityApplyErrorScreen"; import { CommunityChangeOverlay } from "@/features/communities/ui/CommunityChangeOverlay"; @@ -396,6 +400,8 @@ function MachineBootstrap({ sharedIdentity }: { sharedIdentity: boolean }) { useEffect(() => { const unlisten = listenForDeepLinks({ startCommunityOnboarding: communityOnboarding.start, + openAddCommunity: requestAddCommunityPrefill, + onAddCommunityAvailable: onAddCommunityPrefillAvailable, }); return () => { void unlisten.then((fn) => fn()); diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index eadb84d9d71..db0a046b7b0 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -72,6 +72,7 @@ import { CommunityRail } from "@/features/sidebar/ui/CommunityRail"; import { useChannelMutes } from "@/features/sidebar/lib/useChannelMutes"; import { useChannelStars } from "@/features/sidebar/lib/useChannelStars"; import { useCommunities } from "@/features/communities/useCommunities"; +import { useAddCommunityDialogState } from "@/features/communities/addCommunityPrefill"; import { useApplyTemplate } from "@/features/channel-templates/useApplyTemplate"; import { relayClient } from "@/shared/api/relayClient"; import { useFeatureEnabled } from "@/shared/features"; @@ -103,7 +104,7 @@ export function AppShell() { const communitiesHook = useCommunities(); const communityRailEnabled = useFeatureEnabled("workspaceRail"); - const [isAddCommunityOpen, setIsAddCommunityOpen] = React.useState(false); + const addCommunityDialog = useAddCommunityDialogState(); const [isChannelManagementOpen, setIsChannelManagementOpen] = React.useState(false); const [managedChannelId, setManagedChannelId] = React.useState( @@ -763,7 +764,7 @@ export function AppShell() { activeCommunityId={ communitiesHook.activeCommunity?.id ?? null } - onAddCommunity={() => setIsAddCommunityOpen(true)} + onAddCommunity={addCommunityDialog.openDialog} onRemoveCommunity={communitiesHook.removeCommunity} onSwitchCommunity={handleSwitchCommunity} onUpdateCommunity={communitiesHook.updateCommunity} @@ -834,7 +835,8 @@ export function AppShell() { errorMessage={channelsErrorMessage} fallbackDisplayName={identityQuery.data?.displayName} homeBadgeCount={homeBadgeCount + dueReminderBadge} - isAddCommunityOpen={isAddCommunityOpen} + addCommunityPrefill={addCommunityDialog.prefill} + isAddCommunityOpen={addCommunityDialog.open} relayConnectionCard={relayConnectionCard} isCreatingChannel={createChannelMutation.isPending} isCreatingForum={createForumMutation.isPending} @@ -845,10 +847,12 @@ export function AppShell() { const id = communitiesHook.addCommunity(community); handleSwitchCommunity(id); }} - onAddCommunityOpenChange={setIsAddCommunityOpen} + onAddCommunityOpenChange={ + addCommunityDialog.onOpenChange + } onNewMessage={handleOpenNewDm} onCreateChannelOpenChange={setIsCreateChannelOpen} - onOpenAddCommunity={() => setIsAddCommunityOpen(true)} + onOpenAddCommunity={addCommunityDialog.openDialog} onSendFeedback={() => setIsSendFeedbackOpen(true)} onUpdateCommunity={communitiesHook.updateCommunity} onRemoveCommunity={communitiesHook.removeCommunity} diff --git a/desktop/src/features/communities/addCommunityPrefill.ts b/desktop/src/features/communities/addCommunityPrefill.ts new file mode 100644 index 00000000000..375a85d598a --- /dev/null +++ b/desktop/src/features/communities/addCommunityPrefill.ts @@ -0,0 +1,64 @@ +import * as React from "react"; + +import type { AddCommunityDeepLinkPayload } from "@/shared/deep-link"; + +export type AddCommunityPrefillRequest = AddCommunityDeepLinkPayload & { + requestId: string; +}; + +let currentRequest: AddCommunityPrefillRequest | null = null; +const listeners = new Set<() => void>(); +const availableListeners = new Set<() => void>(); + +export function requestAddCommunityPrefill( + request: AddCommunityPrefillRequest, +): boolean { + if (currentRequest) return false; + currentRequest = request; + for (const listener of listeners) listener(); + return true; +} + +export function clearAddCommunityPrefill(requestId: string): void { + if (!currentRequest || currentRequest.requestId !== requestId) return; + currentRequest = null; + for (const listener of listeners) listener(); + for (const listener of availableListeners) listener(); +} + +export function onAddCommunityPrefillAvailable( + listener: () => void, +): () => void { + availableListeners.add(listener); + return () => availableListeners.delete(listener); +} + +function useAddCommunityPrefill(): AddCommunityPrefillRequest | null { + return React.useSyncExternalStore( + (listener) => { + listeners.add(listener); + return () => listeners.delete(listener); + }, + () => currentRequest, + () => null, + ); +} + +export function useAddCommunityDialogState() { + const prefill = useAddCommunityPrefill(); + const [open, setOpen] = React.useState(false); + + React.useEffect(() => { + if (prefill) setOpen(true); + }, [prefill]); + + const onOpenChange = React.useCallback( + (nextOpen: boolean) => { + setOpen(nextOpen); + if (!nextOpen && prefill) clearAddCommunityPrefill(prefill.requestId); + }, + [prefill], + ); + + return { prefill, open, onOpenChange, openDialog: () => setOpen(true) }; +} diff --git a/desktop/src/features/communities/ui/AddCommunityDialog.tsx b/desktop/src/features/communities/ui/AddCommunityDialog.tsx index 00986205f22..227804e7b45 100644 --- a/desktop/src/features/communities/ui/AddCommunityDialog.tsx +++ b/desktop/src/features/communities/ui/AddCommunityDialog.tsx @@ -1,6 +1,7 @@ import * as React from "react"; import { AnimatePresence, motion, useReducedMotion } from "motion/react"; +import type { AddCommunityPrefillRequest } from "@/features/communities/addCommunityPrefill"; import { deriveCommunityName, expandTilde, @@ -30,6 +31,7 @@ const POLICY_DISCOVERY_DELAY_MS = 250; const POLICY_REVEAL_EASE = [0.23, 1, 0.32, 1] as const; type AddCommunityDialogProps = { + prefill?: AddCommunityPrefillRequest | null; onSubmit?: ( community: import("@/features/communities/types").Community, ) => void; @@ -38,6 +40,7 @@ type AddCommunityDialogProps = { }; export function AddCommunityDialog({ + prefill, open, onOpenChange, }: AddCommunityDialogProps) { @@ -53,6 +56,18 @@ export function AddCommunityDialog({ const communityOnboarding = useCommunityOnboarding(); const [reposDirError, setReposDirError] = React.useState(null); const shouldReduceMotion = useReducedMotion(); + const appliedPrefillId = React.useRef(null); + + React.useEffect(() => { + if (!prefill || appliedPrefillId.current === prefill.requestId) return; + appliedPrefillId.current = prefill.requestId; + setName(prefill.name ?? deriveCommunityName(prefill.relayUrl)); + setRelayUrl(prefill.relayUrl); + setToken(""); + setInviteCode(""); + setReposDir(""); + setReposDirError(null); + }, [prefill]); React.useEffect(() => { if (!open || !relayUrl.trim()) return; @@ -181,7 +196,13 @@ export function AddCommunityDialog({ ); return ( - + { + if (!nextOpen) handleClose(); + else onOpenChange(true); + }} + open={open} + > Add Community diff --git a/desktop/src/features/sidebar/ui/AppSidebar.tsx b/desktop/src/features/sidebar/ui/AppSidebar.tsx index 7010986fe25..ce36486b7fe 100644 --- a/desktop/src/features/sidebar/ui/AppSidebar.tsx +++ b/desktop/src/features/sidebar/ui/AppSidebar.tsx @@ -5,6 +5,7 @@ import { SidebarDndContext } from "@/features/sidebar/ui/SidebarDnd"; import type { Community } from "@/features/communities/types"; import { AddCommunityDialog } from "@/features/communities/ui/AddCommunityDialog"; +import type { AddCommunityPrefillRequest } from "@/features/communities/addCommunityPrefill"; import { useIsMobile } from "@/shared/hooks/use-mobile"; import { useDeferredLoad } from "@/shared/hooks/useDeferredStartup"; import { @@ -76,6 +77,7 @@ type CollapsibleSidebarGroup = type CreateChannelKind = "stream" | "forum"; type AppSidebarProps = { + addCommunityPrefill?: AddCommunityPrefillRequest | null; activeCommunity: Community | null; channels: Channel[]; currentPubkey?: string; @@ -167,6 +169,7 @@ type AppSidebarProps = { }; export function AppSidebar({ + addCommunityPrefill, activeCommunity, channels, currentPubkey, @@ -871,6 +874,7 @@ export function AppSidebar({ /> {})} onSubmit={onAddCommunity} open={isAddCommunityOpen ?? false} diff --git a/desktop/src/shared/deep-link.ts b/desktop/src/shared/deep-link.ts index d93a3910d17..c62a8bec3ba 100644 --- a/desktop/src/shared/deep-link.ts +++ b/desktop/src/shared/deep-link.ts @@ -2,8 +2,17 @@ import { listen, type UnlistenFn } from "@tauri-apps/api/event"; import { invoke } from "@tauri-apps/api/core"; import type { StartCommunityOnboardingInput } from "@/features/onboarding/communityOnboarding"; +export type AddCommunityDeepLinkPayload = { + relayUrl: string; + name?: string; +}; + export interface DeepLinkDeps { startCommunityOnboarding: (input: StartCommunityOnboardingInput) => boolean; + openAddCommunity: ( + payload: AddCommunityDeepLinkPayload & { requestId: string }, + ) => boolean; + onAddCommunityAvailable: (listener: () => void) => () => void; } /** @@ -42,9 +51,10 @@ export type JoinDeepLinkPayload = { type PendingCommunityDeepLink = { id: string; - kind: "connect" | "join"; + kind: "connect" | "join" | "add-community"; relayUrl: string; code: string | null; + name: string | null; policyReceipt: string | null; }; @@ -52,12 +62,20 @@ function acceptPendingCommunityDeepLink( pending: PendingCommunityDeepLink, deps: DeepLinkDeps, ) { - const accepted = deps.startCommunityOnboarding({ - source: pending.kind === "join" ? "deep-link-join" : "deep-link-connect", - relayUrl: pending.relayUrl, - inviteCode: pending.code ?? undefined, - policyReceipt: pending.policyReceipt ?? undefined, - }); + const accepted = + pending.kind === "add-community" + ? deps.openAddCommunity({ + requestId: pending.id, + relayUrl: pending.relayUrl, + name: pending.name ?? undefined, + }) + : deps.startCommunityOnboarding({ + source: + pending.kind === "join" ? "deep-link-join" : "deep-link-connect", + relayUrl: pending.relayUrl, + inviteCode: pending.code ?? undefined, + policyReceipt: pending.policyReceipt ?? undefined, + }); return accepted ? invoke("acknowledge_pending_community_deep_link", { id: pending.id, @@ -72,6 +90,7 @@ async function drainPendingCommunityDeepLinks(deps: DeepLinkDeps) { ); if (!pending) return; if (!(await acceptPendingCommunityDeepLink(pending, deps))) return; + if (pending.kind === "add-community") return; } } @@ -94,16 +113,41 @@ async function drainPendingCommunityDeepLinks(deps: DeepLinkDeps) { export async function listenForDeepLinks( deps: DeepLinkDeps, ): Promise { + let drainRunning = false; + let drainRequested = false; const drain = () => { - void drainPendingCommunityDeepLinks(deps).catch((error: unknown) => { - console.warn("Failed to drain pending community deep links", error); - }); + drainRequested = true; + if (drainRunning) return; + drainRunning = true; + void (async () => { + try { + while (drainRequested) { + drainRequested = false; + await drainPendingCommunityDeepLinks(deps); + } + } catch (error: unknown) { + console.warn("Failed to drain pending community deep links", error); + } finally { + drainRunning = false; + if (drainRequested) drain(); + } + })(); }; + const stopAvailabilityListener = deps.onAddCommunityAvailable(drain); const connectPromise = listen("deep-link-connect", drain); const joinPromise = listen("deep-link-join", drain); - const unlistens = await Promise.all([connectPromise, joinPromise]); + const addCommunityPromise = listen( + "deep-link-add-community", + drain, + ); + const unlistens = await Promise.all([ + connectPromise, + joinPromise, + addCommunityPromise, + ]); drain(); return () => { + stopAvailabilityListener(); for (const unlisten of unlistens) unlisten(); }; } diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index da09520a8a8..0eb4f19cc63 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -242,16 +242,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 + // Pending community deep links (buzz://join / buzz://connect / buzz://add-community) 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"; + kind: "connect" | "join" | "add-community"; relayUrl: string; code?: string | null; + name?: 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. @@ -3562,12 +3563,17 @@ let mockPendingCommunityDeepLinks: Array<{ kind: string; relayUrl: string; code: string | null; + name: string | null; }> = []; function resetMockPendingCommunityDeepLinks(config: E2eConfig | null) { mockPendingCommunityDeepLinks = ( config?.mock?.pendingCommunityDeepLinks ?? [] - ).map((pending) => ({ ...pending, code: pending.code ?? null })); + ).map((pending) => ({ + ...pending, + code: pending.code ?? null, + name: pending.name ?? null, + })); } function recordMockUserStatus(event: RelayEvent) { diff --git a/desktop/tests/e2e/deep-link-invite.spec.ts b/desktop/tests/e2e/deep-link-invite.spec.ts index a2457a58be5..6ea94df25c4 100644 --- a/desktop/tests/e2e/deep-link-invite.spec.ts +++ b/desktop/tests/e2e/deep-link-invite.spec.ts @@ -24,6 +24,22 @@ const PENDING_CONNECT_LINK = { code: null, }; +const PENDING_ADD_COMMUNITY_LINK = { + id: "dl-add-community-1", + kind: "add-community" as const, + relayUrl: "wss://acme.communities.buzz.xyz", + code: null, + name: "Acme Team", +}; + +const SECOND_PENDING_ADD_COMMUNITY_LINK = { + id: "dl-add-community-2", + kind: "add-community" as const, + relayUrl: "wss://beta.communities.buzz.xyz", + code: null, + name: "Beta Team", +}; + test("join deep link is acknowledged without claiming before setup", async ({ page, }) => { @@ -92,6 +108,107 @@ test("connect deep link shows a static acknowledgment during setup", async ({ .toContain('"acknowledged":true'); }); +test("add-community deep link opens one editable prefill and acknowledges the queue", async ({ + page, +}) => { + await installMockBridge( + page, + { pendingCommunityDeepLinks: [PENDING_ADD_COMMUNITY_LINK] }, + { seedPreviewFeatures: true }, + ); + await page.goto("/"); + + await expect( + page.getByRole("heading", { name: "Add Community" }), + ).toBeVisible(); + const relayInput = page.locator("#ws-relay-url"); + const nameInput = page.locator("#ws-name"); + await expect(relayInput).toHaveValue(PENDING_ADD_COMMUNITY_LINK.relayUrl); + await expect(nameInput).toHaveValue(PENDING_ADD_COMMUNITY_LINK.name); + + await nameInput.fill("Edited Team"); + await expect(nameInput).toHaveValue("Edited Team"); + + await page.getByRole("button", { name: "Cancel" }).click(); + await expect( + page.getByRole("heading", { name: "Add Community" }), + ).toHaveCount(0); + + await page.getByTestId("sidebar-profile-avatar-button").click(); + await page.getByTestId("community-switcher").click(); + await page.getByRole("menuitem", { name: "Add Community" }).click(); + await expect(relayInput).toHaveValue(""); + await expect(nameInput).toHaveValue(""); + + const acknowledgements = await page.evaluate(() => + (window.__BUZZ_E2E_COMMAND_LOG__ ?? []).filter( + (entry) => entry.command === "acknowledge_pending_community_deep_link", + ), + ); + expect(acknowledgements).toEqual([ + { + command: "acknowledge_pending_community_deep_link", + payload: { id: PENDING_ADD_COMMUNITY_LINK.id }, + }, + ]); +}); + +test("queued add-community links open and acknowledge one at a time", async ({ + page, +}) => { + await installMockBridge( + page, + { + pendingCommunityDeepLinks: [ + PENDING_ADD_COMMUNITY_LINK, + SECOND_PENDING_ADD_COMMUNITY_LINK, + ], + }, + { seedPreviewFeatures: true }, + ); + await page.goto("/"); + + const relayInput = page.locator("#ws-relay-url"); + const nameInput = page.locator("#ws-name"); + await expect(relayInput).toHaveValue(PENDING_ADD_COMMUNITY_LINK.relayUrl); + await expect(nameInput).toHaveValue(PENDING_ADD_COMMUNITY_LINK.name); + + await expect + .poll(() => + page.evaluate(() => + (window.__BUZZ_E2E_COMMAND_LOG__ ?? []) + .filter( + (entry) => + entry.command === "acknowledge_pending_community_deep_link", + ) + .map((entry) => entry.payload), + ), + ) + .toEqual([{ id: PENDING_ADD_COMMUNITY_LINK.id }]); + + await page.getByRole("button", { name: "Cancel" }).click(); + + await expect(relayInput).toHaveValue( + SECOND_PENDING_ADD_COMMUNITY_LINK.relayUrl, + ); + await expect(nameInput).toHaveValue(SECOND_PENDING_ADD_COMMUNITY_LINK.name); + await expect + .poll(() => + page.evaluate(() => + (window.__BUZZ_E2E_COMMAND_LOG__ ?? []) + .filter( + (entry) => + entry.command === "acknowledge_pending_community_deep_link", + ) + .map((entry) => entry.payload), + ), + ) + .toEqual([ + { id: PENDING_ADD_COMMUNITY_LINK.id }, + { id: SECOND_PENDING_ADD_COMMUNITY_LINK.id }, + ]); +}); + test("Welcome failure can be skipped without abandoning community onboarding", async ({ page, }) => { diff --git a/desktop/tests/helpers/bridge.ts b/desktop/tests/helpers/bridge.ts index d7727e2d4fc..6262fe2c4ca 100644 --- a/desktop/tests/helpers/bridge.ts +++ b/desktop/tests/helpers/bridge.ts @@ -295,15 +295,16 @@ type MockBridgeOptions = { */ 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. + * Pending community deep links seeded into the mocked Rust-side queue. + * The frontend drains these on boot into onboarding or an editable Add + * Community prefill. */ pendingCommunityDeepLinks?: Array<{ id: string; - kind: "connect" | "join"; + kind: "connect" | "join" | "add-community"; relayUrl: string; code?: string | null; + name?: string | null; }>; /** * Global agent config returned by `get_global_agent_config`. Defaults to