feat: open add community from deep links (#1970)

Signed-off-by: npub1dccv64krpcpse5cmkzfeh998cftungyatw3djt8jwdw6g43f7fyqzzmrf7 <6e30cd56c30e030cd31bb0939b94a7c257c9a09d5ba2d92cf2735da45629f248@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: npub1dccv64krpcpse5cmkzfeh998cftungyatw3djt8jwdw6g43f7fyqzzmrf7 <6e30cd56c30e030cd31bb0939b94a7c257c9a09d5ba2d92cf2735da45629f248@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
Kalvin C
2026-07-16 13:13:59 -07:00
committed by GitHub
parent 8969745549
commit adb48311e6
10 changed files with 383 additions and 56 deletions

View File

@@ -13,6 +13,7 @@ pub(crate) struct PendingCommunityDeepLink {
kind: String,
relay_url: String,
code: Option<String>,
name: Option<String>,
}
#[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<String>,
name: Option<String>,
) {
app.state::<PendingCommunityDeepLinks>()
.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<serde_json::Value> {
/// `code`; returns `None` otherwise so the frontend never sees a half-formed
/// payload.
fn parse_join_deep_link(url: &Url) -> Option<serde_json::Value> {
let mut relay: Option<String> = None;
let mut code: Option<String> = None;
let mut policy_receipt: Option<String> = None;
for (k, v) in url.query_pairs() {
@@ -142,17 +145,13 @@ fn parse_join_deep_link(url: &Url) -> Option<serde_json::Value> {
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<serde_json::Value> {
}))
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
struct AddCommunityDeepLinkPayload {
relay_url: String,
name: Option<String>,
}
fn parse_websocket_relay_param(url: &Url) -> Option<String> {
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<AddCommunityDeepLinkPayload> {
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=<uuid>&id=<eventId>[&thread=<rootId>]`
//
@@ -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();

View File

@@ -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());

View File

@@ -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<string | null>(
@@ -766,7 +767,7 @@ export function AppShell() {
activeCommunityId={
communitiesHook.activeCommunity?.id ?? null
}
onAddCommunity={() => setIsAddCommunityOpen(true)}
onAddCommunity={addCommunityDialog.openDialog}
onRemoveCommunity={communitiesHook.removeCommunity}
onSwitchCommunity={handleSwitchCommunity}
onUpdateCommunity={communitiesHook.updateCommunity}
@@ -837,7 +838,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}
@@ -848,10 +850,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}

View File

@@ -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) };
}

View File

@@ -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<string | null>(null);
const shouldReduceMotion = useReducedMotion();
const appliedPrefillId = React.useRef<string | null>(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 (
<Dialog onOpenChange={onOpenChange} open={open}>
<Dialog
onOpenChange={(nextOpen) => {
if (!nextOpen) handleClose();
else onOpenChange(true);
}}
open={open}
>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Add Community</DialogTitle>

View File

@@ -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({
/>
<AddCommunityDialog
prefill={addCommunityPrefill}
onOpenChange={onAddCommunityOpenChange ?? (() => {})}
onSubmit={onAddCommunity}
open={isAddCommunityOpen ?? false}

View File

@@ -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<boolean>("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<UnlistenFn> {
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<string>("deep-link-connect", drain);
const joinPromise = listen<JoinDeepLinkPayload>("deep-link-join", drain);
const unlistens = await Promise.all([connectPromise, joinPromise]);
const addCommunityPromise = listen<AddCommunityDeepLinkPayload>(
"deep-link-add-community",
drain,
);
const unlistens = await Promise.all([
connectPromise,
joinPromise,
addCommunityPromise,
]);
drain();
return () => {
stopAvailabilityListener();
for (const unlisten of unlistens) unlisten();
};
}

View File

@@ -250,16 +250,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.
@@ -3596,12 +3597,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) {

View File

@@ -26,6 +26,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,
}) => {
@@ -94,6 +110,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,
}) => {

View File

@@ -307,15 +307,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