Skip to content
Merged
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
124 changes: 92 additions & 32 deletions desktop/src-tauri/src/deep_link.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ pub(crate) struct PendingCommunityDeepLink {
kind: String,
relay_url: String,
code: Option<String>,
name: Option<String>,
}

#[derive(Default)]
Expand All @@ -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;
}
Expand Down Expand Up @@ -70,13 +72,15 @@ fn queue_community_deep_link(
kind: &str,
relay_url: String,
code: Option<String>,
name: Option<String>,
) {
app.state::<PendingCommunityDeepLinks>()
.enqueue(PendingCommunityDeepLink {
id: uuid::Uuid::new_v4().to_string(),
kind: kind.to_owned(),
relay_url,
code,
name,
});
}

Expand Down Expand Up @@ -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() {
Expand All @@ -142,24 +145,47 @@ 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,
"policyReceipt": policy_receipt,
}))
}

#[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 {
Expand Down Expand Up @@ -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") => {
Expand All @@ -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>]`
//
Expand Down Expand Up @@ -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 {
Expand All @@ -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,
}
}

Expand Down Expand Up @@ -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();
Expand Down
6 changes: 6 additions & 0 deletions desktop/src/app/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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());
Expand Down
14 changes: 9 additions & 5 deletions desktop/src/app/AppShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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>(
Expand Down Expand Up @@ -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}
Expand Down Expand Up @@ -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}
Expand All @@ -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}
Expand Down
64 changes: 64 additions & 0 deletions desktop/src/features/communities/addCommunityPrefill.ts
Original file line number Diff line number Diff line change
@@ -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) };
}
Loading