Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
22bc50f
feat(desktop): render self-describing permalink chips
tellaho Aug 12, 2026
ab18059
test(desktop): assert rendered channel chip semantics
Aug 12, 2026
bd3940a
test(desktop): align channel chip assertions
tellaho Aug 12, 2026
5252d71
test(desktop): match permalink chip structure
tellaho Aug 12, 2026
7b2a7fb
test(desktop): preserve search result source text
tellaho Aug 12, 2026
55ffccb
feat(desktop): add human mention chips
tellaho Aug 12, 2026
5e59c62
feat(desktop): add permalink chip context menus
tellaho Aug 12, 2026
390b786
fix(desktop): render all permalink chips consistently
tellaho Aug 13, 2026
5de2119
fix(desktop): restore inline chip icon prefixes
tellaho Aug 13, 2026
74fa7dc
test(desktop): cover every composer permalink chip
Aug 13, 2026
ad411cc
fix(desktop): preserve formatted channel link labels
Aug 13, 2026
0aafa14
test(desktop): expect visible mention labels
Aug 13, 2026
56177ec
fix(desktop-messages): preserve underscores in restored links
tellaho Aug 13, 2026
92cf503
fix(desktop-navigation): quarantine stale community links
tellaho Aug 13, 2026
428add4
Merge origin/main into tho/buzz-permalink-chips
tellaho Aug 13, 2026
ebdfbd9
Merge origin/main into tho/buzz-permalink-chips
tellaho Aug 13, 2026
c687692
fix(desktop): surface final community reset failures
tellaho Aug 13, 2026
be1fa04
Merge origin/main into tho/buzz-permalink-chips
tellaho Aug 13, 2026
55e9533
Merge origin/main into tho/buzz-permalink-chips
tellaho Aug 13, 2026
1a3643e
Merge origin/main into tho/buzz-permalink-chips
tellaho Aug 14, 2026
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
249 changes: 247 additions & 2 deletions desktop/src-tauri/src/deep_link.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,79 @@ pub(crate) struct PendingCommunityDeepLink {
#[derive(Default)]
pub(crate) struct PendingCommunityDeepLinks(Mutex<VecDeque<PendingCommunityDeepLink>>);

#[derive(Debug, Clone, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct PendingNavigationDeepLink {
id: String,
kind: String,
channel_id: String,
message_id: Option<String>,
thread_root_id: Option<String>,
}

#[derive(Default)]
pub(crate) struct PendingNavigationDeepLinks(Mutex<VecDeque<PendingNavigationDeepLink>>);

impl PendingNavigationDeepLinks {
fn lock(&self) -> std::sync::MutexGuard<'_, VecDeque<PendingNavigationDeepLink>> {
self.0.lock().unwrap_or_else(|poisoned| {
eprintln!("buzz-desktop: recovering poisoned pending navigation deep-link queue");
poisoned.into_inner()
})
}

fn enqueue(&self, pending: PendingNavigationDeepLink) {
let mut queue = self.lock();
if queue.iter().any(|item| {
item.kind == pending.kind
&& item.channel_id == pending.channel_id
&& item.message_id == pending.message_id
&& item.thread_root_id == pending.thread_root_id
}) {
return;
}
queue.push_back(pending);
}

fn clear(&self) {
self.lock().clear();
}

fn first(&self) -> Option<PendingNavigationDeepLink> {
self.lock().front().cloned()
}

fn acknowledge(&self, id: &str) -> bool {
let mut queue = self.lock();
if queue.front().is_some_and(|item| item.id == id) {
queue.pop_front();
true
} else {
false
}
}
}

#[tauri::command]
pub(crate) fn clear_pending_navigation_deep_links(pending: State<'_, PendingNavigationDeepLinks>) {
pending.clear();
}

#[tauri::command]
pub(crate) fn take_pending_navigation_deep_link(
pending: State<'_, PendingNavigationDeepLinks>,
) -> Option<PendingNavigationDeepLink> {
pending.first()
}

#[tauri::command]
pub(crate) fn acknowledge_pending_navigation_deep_link(
id: String,
pending: State<'_, PendingNavigationDeepLinks>,
) -> bool {
pending.acknowledge(&id)
}

impl PendingCommunityDeepLinks {
fn enqueue(&self, pending: PendingCommunityDeepLink) {
let mut queue = self.0.lock().expect("pending deep-link queue poisoned");
Expand Down Expand Up @@ -88,6 +161,20 @@ fn queue_community_deep_link(
});
}

fn queue_navigation_deep_link(app: &tauri::AppHandle, kind: &str, payload: &serde_json::Value) {
let Some(channel_id) = payload["channelId"].as_str() else {
return;
};
app.state::<PendingNavigationDeepLinks>()
.enqueue(PendingNavigationDeepLink {
id: uuid::Uuid::new_v4().to_string(),
kind: kind.to_owned(),
channel_id: channel_id.to_owned(),
message_id: payload["messageId"].as_str().map(str::to_owned),
thread_root_id: payload["threadRootId"].as_str().map(str::to_owned),
});
}

fn activate_main_window(app: &tauri::AppHandle) {
let Some(window) = app.get_webview_window("main") else {
return;
Expand All @@ -104,6 +191,19 @@ fn activate_main_window(app: &tauri::AppHandle) {
}
}

fn parse_channel_deep_link(url: &Url) -> Option<serde_json::Value> {
if url.query().is_some() || url.fragment().is_some() || !url.username().is_empty() {
return None;
}
let mut segments = url.path_segments()?;
let channel_id = segments.next()?;
if segments.next().is_some() {
return None;
}
let channel_id = uuid::Uuid::parse_str(channel_id).ok()?.to_string();
Some(serde_json::json!({ "channelId": channel_id }))
}

/// Parse the query string of a `buzz://message?…` URL into the JSON
/// payload emitted on `deep-link-message`. Returns `None` when a required
/// param (`channel`, `id`) is missing or empty — mirroring the validation
Expand Down Expand Up @@ -350,6 +450,15 @@ pub(crate) fn handle_deep_link_url(app: &tauri::AppHandle, url_str: &str) {
);
let _ = app.emit("deep-link-add-community", payload);
}
Some("channel") => {
let Some(payload) = parse_channel_deep_link(&url) else {
eprintln!("buzz-desktop: channel deep link missing/invalid channel: {url_str}");
return;
};
activate_main_window(app);
queue_navigation_deep_link(app, "channel", &payload);
let _ = app.emit("deep-link-channel", payload);
}
Some("message") => {
// `buzz://message?channel=<uuid>&id=<eventId>[&thread=<rootId>]`
//
Expand All @@ -364,6 +473,7 @@ pub(crate) fn handle_deep_link_url(app: &tauri::AppHandle, url_str: &str) {
return;
};
activate_main_window(app);
queue_navigation_deep_link(app, "message", &payload);
let _ = app.emit("deep-link-message", payload);
}
Some("nostr-bind") => match parse_nostr_bind_deep_link(&url) {
Expand All @@ -389,8 +499,9 @@ mod tests {
use url::Url;

use super::{
parse_add_community_deep_link, parse_join_deep_link, parse_message_deep_link,
parse_nostr_bind_deep_link, PendingCommunityDeepLink, PendingCommunityDeepLinks,
parse_add_community_deep_link, parse_channel_deep_link, parse_join_deep_link,
parse_message_deep_link, parse_nostr_bind_deep_link, PendingCommunityDeepLink,
PendingCommunityDeepLinks, PendingNavigationDeepLink, PendingNavigationDeepLinks,
};

fn pending(id: &str, relay_url: &str, code: Option<&str>) -> PendingCommunityDeepLink {
Expand All @@ -404,6 +515,100 @@ mod tests {
}
}

fn pending_navigation(
id: &str,
kind: &str,
channel_id: &str,
message_id: Option<&str>,
thread_root_id: Option<&str>,
) -> PendingNavigationDeepLink {
PendingNavigationDeepLink {
id: id.to_owned(),
kind: kind.to_owned(),
channel_id: channel_id.to_owned(),
message_id: message_id.map(str::to_owned),
thread_root_id: thread_root_id.map(str::to_owned),
}
}

#[test]
fn pending_navigation_links_are_fifo_acknowledged_and_deduplicated() {
let queue = PendingNavigationDeepLinks::default();
queue.enqueue(pending_navigation(
"first",
"channel",
"channel-1",
None,
None,
));
queue.enqueue(pending_navigation(
"duplicate",
"channel",
"channel-1",
None,
None,
));
queue.enqueue(pending_navigation(
"second",
"message",
"channel-1",
Some("message-1"),
Some("root-1"),
));

assert_eq!(queue.first().unwrap().id, "first");
assert!(!queue.acknowledge("second"));
assert!(queue.acknowledge("first"));
assert_eq!(queue.first().unwrap().id, "second");
assert!(queue.acknowledge("second"));
assert!(queue.first().is_none());
}

#[test]
fn pending_navigation_links_can_be_cleared() {
let queue = PendingNavigationDeepLinks::default();
queue.enqueue(pending_navigation(
"first",
"channel",
"channel-1",
None,
None,
));
queue.enqueue(pending_navigation(
"second",
"message",
"channel-1",
Some("message-1"),
None,
));

queue.clear();
assert!(queue.first().is_none());
}

#[test]
fn pending_navigation_queue_recovers_after_mutex_poisoning() {
let queue = std::sync::Arc::new(PendingNavigationDeepLinks::default());
let poisoner = std::sync::Arc::clone(&queue);
assert!(std::thread::spawn(move || {
let _guard = poisoner.0.lock().unwrap();
panic!("poison queue for recovery regression");
})
.join()
.is_err());

queue.enqueue(pending_navigation(
"after-poison",
"channel",
"channel-1",
None,
None,
));
assert_eq!(queue.first().unwrap().id, "after-poison");
assert!(queue.acknowledge("after-poison"));
assert!(queue.first().is_none());
}

#[test]
fn pending_join_serializes_policy_receipt_for_cold_launch_recovery() {
let mut link = pending("join", "wss://relay.example", Some("invite"));
Expand Down Expand Up @@ -477,6 +682,46 @@ mod tests {
}
}

#[test]
fn parse_channel_deep_link_accepts_one_path_segment() {
let url = Url::parse("buzz://channel/580ca78b-9dae-46f3-8854-bd671853ba32").unwrap();
let payload = parse_channel_deep_link(&url).unwrap();
assert_eq!(payload["channelId"], "580ca78b-9dae-46f3-8854-bd671853ba32");
}

#[test]
fn parse_channel_deep_link_accepts_v7_and_normalizes_uppercase() {
for (raw, expected) in [
(
"buzz://channel/018fdb5d-3a64-7c35-b5f9-4a23e1f9d2d9",
"018fdb5d-3a64-7c35-b5f9-4a23e1f9d2d9",
),
(
"buzz://channel/580CA78B-9DAE-46F3-8854-BD671853BA32",
"580ca78b-9dae-46f3-8854-bd671853ba32",
),
] {
let payload = parse_channel_deep_link(&Url::parse(raw).unwrap()).unwrap();
assert_eq!(payload["channelId"], expected);
}
}

#[test]
fn parse_channel_deep_link_rejects_malformed_forms() {
for raw in [
"buzz://channel",
"buzz://channel/",
"buzz://channel/one/two",
"buzz://channel/one?extra=true",
"buzz://channel/one#fragment",
"buzz://channel/not-a-uuid",
"buzz://channel/%2F",
"buzz://channel/%00",
] {
assert!(parse_channel_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
10 changes: 7 additions & 3 deletions desktop/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -49,8 +49,9 @@ use app_state::{build_app_state, resolve_persisted_identity, AppState};
use builderlab::*;
use commands::*;
use deep_link::{
acknowledge_pending_community_deep_link, handle_deep_link_url,
take_pending_community_deep_link, PendingCommunityDeepLinks,
acknowledge_pending_community_deep_link, acknowledge_pending_navigation_deep_link,
clear_pending_navigation_deep_links, handle_deep_link_url, take_pending_community_deep_link,
take_pending_navigation_deep_link, PendingCommunityDeepLinks, PendingNavigationDeepLinks,
};
use huddle::audio_output::{
get_audio_output_device, list_audio_output_devices, set_audio_output_device,
Expand Down Expand Up @@ -291,7 +292,6 @@ pub fn run() {
} else {
builder.plugin(tauri_plugin_updater::Builder::new().build())
};

let app = app_menu::install(builder)
.register_asynchronous_uri_scheme_protocol("buzz-media", |ctx, request, responder| {
let app = ctx.app_handle().clone();
Expand All @@ -303,6 +303,7 @@ pub fn run() {
.manage(build_app_state())
.manage(ClipboardState::new())
.manage(PendingCommunityDeepLinks::default())
.manage(PendingNavigationDeepLinks::default())
.manage(BuilderlabSession::default())
.manage(BuilderlabLogin::default())
.manage(commands::pairing::PairingHandle::new())
Expand Down Expand Up @@ -615,6 +616,9 @@ pub fn run() {
terminal_runtime::terminal_focus,
take_pending_community_deep_link,
acknowledge_pending_community_deep_link,
take_pending_navigation_deep_link,
acknowledge_pending_navigation_deep_link,
clear_pending_navigation_deep_links,
start_builderlab_login,
cancel_builderlab_login,
get_builderlab_auth,
Expand Down
Loading
Loading