mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
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:
@@ -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();
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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}
|
||||
|
||||
64
desktop/src/features/communities/addCommunityPrefill.ts
Normal file
64
desktop/src/features/communities/addCommunityPrefill.ts
Normal 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) };
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
}) => {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user