diff --git a/desktop/src-tauri/src/deep_link.rs b/desktop/src-tauri/src/deep_link.rs index ffe951dc36..098b3f1e79 100644 --- a/desktop/src-tauri/src/deep_link.rs +++ b/desktop/src-tauri/src/deep_link.rs @@ -20,6 +20,79 @@ pub(crate) struct PendingCommunityDeepLink { #[derive(Default)] pub(crate) struct PendingCommunityDeepLinks(Mutex>); +#[derive(Debug, Clone, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct PendingNavigationDeepLink { + id: String, + kind: String, + channel_id: String, + message_id: Option, + thread_root_id: Option, +} + +#[derive(Default)] +pub(crate) struct PendingNavigationDeepLinks(Mutex>); + +impl PendingNavigationDeepLinks { + fn lock(&self) -> std::sync::MutexGuard<'_, VecDeque> { + 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 { + 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 { + 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"); @@ -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::() + .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; @@ -104,6 +191,19 @@ fn activate_main_window(app: &tauri::AppHandle) { } } +fn parse_channel_deep_link(url: &Url) -> Option { + 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 @@ -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=&id=[&thread=]` // @@ -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) { @@ -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 { @@ -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")); @@ -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(); diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 1e73b15232..7dd405af70 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -46,8 +46,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, @@ -290,7 +291,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(); @@ -302,6 +302,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()) @@ -611,6 +612,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, diff --git a/desktop/src/features/communities/useCommunityInit.ts b/desktop/src/features/communities/useCommunityInit.ts index 1bd1e090a7..3248fd0fe8 100644 --- a/desktop/src/features/communities/useCommunityInit.ts +++ b/desktop/src/features/communities/useCommunityInit.ts @@ -14,6 +14,7 @@ import { clearTrayAgentActivity } from "@/shared/api/trayMenu"; import { getOverrides } from "@/shared/features"; import { resetMediaCaches } from "@/shared/lib/mediaUrl"; import { clearSearchHitEventCache } from "@/app/navigation/searchHitEventCache"; +import { resetNavigationDeepLinkDrain } from "@/shared/deep-link"; import { clearAllDrafts, initDraftStore, @@ -46,12 +47,13 @@ import type { Community } from "./types"; * destroyed via effect cleanup and do not need entries here. * See AGENTS.md "Community Switching" for the full contract. */ -function resetCommunityState({ +async function resetCommunityState({ resetAvatarState, }: { resetAvatarState: boolean; -}): void { +}): Promise { relayClient.disconnect(); + await resetNavigationDeepLinkDrain(); resetRateLimitGate(); clearAllDrafts(); resetAgentObserverStore(); @@ -120,6 +122,12 @@ export function useCommunityInit( async function init() { if (!activeCommunity) { + if (hasInitializedRef.current) { + await resetCommunityState({ resetAvatarState: true }); + hasInitializedRef.current = false; + prevCommunityIdRef.current = null; + appliedRelayUrlRef.current = null; + } try { const defaultRelayUrl = await getDefaultRelayUrl(); const autoConnectDefaultRelay = @@ -194,7 +202,7 @@ export function useCommunityInit( // store under the outgoing community ID and delete its snapshot. prevCommunityIdRef.current = null; } - resetCommunityState({ + await resetCommunityState({ resetAvatarState: appliedRelayUrlRef.current !== activeCommunity.relayUrl, }); diff --git a/desktop/src/features/messages/lib/channelLink.test.mjs b/desktop/src/features/messages/lib/channelLink.test.mjs new file mode 100644 index 0000000000..7f51a39cef --- /dev/null +++ b/desktop/src/features/messages/lib/channelLink.test.mjs @@ -0,0 +1,60 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { isChannelLink, parseChannelLink } from "./channelLink.ts"; + +test("parseChannelLink accepts the canonical channel path", () => { + assert.deepEqual( + parseChannelLink("buzz://channel/580ca78b-9dae-46f3-8854-bd671853ba32"), + { + ok: true, + value: { channelId: "580ca78b-9dae-46f3-8854-bd671853ba32" }, + }, + ); +}); + +test("parseChannelLink accepts v7 and canonicalizes uppercase UUIDs", () => { + assert.deepEqual( + parseChannelLink("buzz://channel/018fdb5d-3a64-7c35-b5f9-4a23e1f9d2d9"), + { + ok: true, + value: { channelId: "018fdb5d-3a64-7c35-b5f9-4a23e1f9d2d9" }, + }, + ); + assert.deepEqual( + parseChannelLink("buzz://channel/580CA78B-9DAE-46F3-8854-BD671853BA32"), + { + ok: true, + value: { channelId: "580ca78b-9dae-46f3-8854-bd671853ba32" }, + }, + ); +}); + +test("parseChannelLink rejects malformed channel links", () => { + for (const href of [ + "buzz://channel", + "buzz://channel/", + "buzz://channel/one/two", + "buzz://channel/one?extra=true", + "buzz://channel/one#fragment", + "https://channel/one", + "buzz://channel/not-a-uuid", + "buzz://channel/%", + "buzz://channel/%ZZ", + "buzz://channel/%2F", + "buzz://channel/%00", + ]) { + assert.equal(parseChannelLink(href).ok, false, href); + } +}); + +test("isChannelLink recognizes only a valid canonical link", () => { + assert.equal( + isChannelLink("buzz://channel/580ca78b-9dae-46f3-8854-bd671853ba32"), + true, + ); + assert.equal( + isChannelLink("buzz://message?channel=channel-1&id=message-1"), + false, + ); +}); diff --git a/desktop/src/features/messages/lib/channelLink.ts b/desktop/src/features/messages/lib/channelLink.ts new file mode 100644 index 0000000000..42cecadb4d --- /dev/null +++ b/desktop/src/features/messages/lib/channelLink.ts @@ -0,0 +1,48 @@ +/** `buzz://channel/` link encoding and parsing. */ + +const CHANNEL_LINK_SCHEME = "buzz:"; +const CHANNEL_LINK_HOST = "channel"; +const CHANNEL_UUID_PATTERN = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu; + +export type ParsedChannelLink = { channelId: string }; + +export type ChannelLinkParseResult = + | { ok: true; value: ParsedChannelLink } + | { ok: false; reason: string }; + +export function parseChannelLink(url: string): ChannelLinkParseResult { + let parsed: URL; + try { + parsed = new URL(url); + } catch { + return { ok: false, reason: "invalid-url" }; + } + if (parsed.protocol !== CHANNEL_LINK_SCHEME) { + return { ok: false, reason: "wrong-scheme" }; + } + if (parsed.hostname !== CHANNEL_LINK_HOST) { + return { ok: false, reason: "wrong-host" }; + } + if (parsed.search || parsed.hash || parsed.username || parsed.password) { + return { ok: false, reason: "unexpected-components" }; + } + const segments = parsed.pathname.split("/").filter(Boolean); + if (segments.length !== 1) { + return { ok: false, reason: "missing-or-extra-channel" }; + } + let channelId: string; + try { + channelId = decodeURIComponent(segments[0]); + } catch { + return { ok: false, reason: "invalid-channel-encoding" }; + } + if (!CHANNEL_UUID_PATTERN.test(channelId)) { + return { ok: false, reason: "invalid-channel-uuid" }; + } + return { ok: true, value: { channelId: channelId.toLowerCase() } }; +} + +export function isChannelLink(href: string | undefined | null): boolean { + return href ? parseChannelLink(href).ok : false; +} diff --git a/desktop/src/features/messages/lib/remarkChannelDeepLinks.test.mjs b/desktop/src/features/messages/lib/remarkChannelDeepLinks.test.mjs new file mode 100644 index 0000000000..ce45d2fa54 --- /dev/null +++ b/desktop/src/features/messages/lib/remarkChannelDeepLinks.test.mjs @@ -0,0 +1,35 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import remarkChannelDeepLinks from "./remarkChannelDeepLinks.ts"; + +function run(value) { + const tree = { + type: "root", + children: [{ type: "paragraph", children: [{ type: "text", value }] }], + }; + remarkChannelDeepLinks()(tree); + return tree.children[0].children; +} + +test("turns a bare channel deep link into a custom node", () => { + const children = run( + "Open buzz://channel/580ca78b-9dae-46f3-8854-bd671853ba32 now", + ); + assert.equal(children[1].type, "channel-deep-link"); + assert.equal( + children[1].value, + "buzz://channel/580ca78b-9dae-46f3-8854-bd671853ba32", + ); +}); + +test("peels trailing sentence punctuation", () => { + const children = run( + "Open buzz://channel/580ca78b-9dae-46f3-8854-bd671853ba32.", + ); + assert.equal( + children[1].value, + "buzz://channel/580ca78b-9dae-46f3-8854-bd671853ba32", + ); + assert.equal(children[2].value, "."); +}); diff --git a/desktop/src/features/messages/lib/remarkChannelDeepLinks.ts b/desktop/src/features/messages/lib/remarkChannelDeepLinks.ts new file mode 100644 index 0000000000..efafec770e --- /dev/null +++ b/desktop/src/features/messages/lib/remarkChannelDeepLinks.ts @@ -0,0 +1,22 @@ +/** Detect bare `buzz://channel/` URLs in markdown text nodes. */ +import { createRemarkPrefixPlugin } from "../../../shared/lib/createRemarkPrefixPlugin.ts"; + +const CHANNEL_URL_PATTERN = /buzz:\/\/channel\/[^\s<>"')\]]+/g; +const TRAILING_PUNCTUATION_PATTERN = /[.,;:!?]+$/; + +export default function remarkChannelDeepLinks() { + return createRemarkPrefixPlugin(CHANNEL_URL_PATTERN, (matchText) => { + const value = matchText.replace(TRAILING_PUNCTUATION_PATTERN, ""); + return { + node: { + type: "channel-deep-link", + value, + data: { + hName: "channel-deep-link", + hChildren: [{ type: "text", value }], + }, + }, + trailing: matchText.slice(value.length), + }; + }); +} diff --git a/desktop/src/shared/deep-link.test.mjs b/desktop/src/shared/deep-link.test.mjs new file mode 100644 index 0000000000..80be3ef55e --- /dev/null +++ b/desktop/src/shared/deep-link.test.mjs @@ -0,0 +1,375 @@ +import assert from "node:assert/strict"; +import { afterEach, test } from "node:test"; + +const ipcHandlers = new Map(); +let nextCallbackId = 1; +const callbacks = new Map(); + +const tauriInternals = { + invoke: (cmd, args) => { + const handler = ipcHandlers.get(cmd); + if (handler) return Promise.resolve(handler(args)); + return Promise.reject(new Error(`unmocked Tauri command: ${cmd}`)); + }, + transformCallback: (callback) => { + const id = nextCallbackId++; + callbacks.set(id, callback); + return id; + }, +}; +globalThis.window = { + __TAURI_INTERNALS__: tauriInternals, + __TAURI_EVENT_PLUGIN_INTERNALS__: { unregisterListener: () => {} }, +}; +globalThis.__TAURI_INTERNALS__ = tauriInternals; + +const { listenForNavigationDeepLinks, resetNavigationDeepLinkDrain } = + await import("@/shared/deep-link.ts"); + +function deferred() { + let resolve; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} + +async function settle() { + await new Promise((resolve) => setTimeout(resolve, 0)); +} + +afterEach(() => { + ipcHandlers.clear(); + callbacks.clear(); +}); + +test("listener teardown leaves an unaccepted FIFO item for the next mount", async () => { + const queue = [ + { + id: "first", + kind: "channel", + channelId: "channel-1", + messageId: null, + threadRootId: null, + }, + { + id: "second", + kind: "message", + channelId: "channel-2", + messageId: "message-2", + threadRootId: "root-2", + }, + ]; + const firstAcknowledge = deferred(); + const acknowledged = []; + let unlistenCount = 0; + + ipcHandlers.set("plugin:event|listen", () => nextCallbackId); + ipcHandlers.set("plugin:event|unlisten", () => { + unlistenCount += 1; + }); + ipcHandlers.set("take_pending_navigation_deep_link", () => queue[0] ?? null); + ipcHandlers.set( + "acknowledge_pending_navigation_deep_link", + async ({ id }) => { + if (id === "first") await firstAcknowledge.promise; + assert.equal(queue[0]?.id, id); + acknowledged.push(id); + queue.shift(); + return true; + }, + ); + + let firstMountActive = true; + const firstOpened = []; + const firstUnlisten = await listenForNavigationDeepLinks( + (payload) => { + if (!firstMountActive) return false; + firstOpened.push(payload.channelId); + return true; + }, + (payload) => { + if (!firstMountActive) return false; + firstOpened.push(payload.messageId); + return true; + }, + ); + await settle(); + assert.deepEqual(firstOpened, ["channel-1"]); + + firstMountActive = false; + firstUnlisten(); + firstAcknowledge.resolve(); + await settle(); + + assert.deepEqual(acknowledged, ["first"]); + assert.equal(queue[0]?.id, "second"); + + const secondOpened = []; + const secondUnlisten = await listenForNavigationDeepLinks( + (payload) => { + secondOpened.push(payload.channelId); + return true; + }, + (payload) => { + secondOpened.push(payload.messageId); + return true; + }, + ); + await settle(); + + assert.deepEqual(secondOpened, ["message-2"]); + assert.deepEqual(acknowledged, ["first", "second"]); + assert.equal(queue.length, 0); + secondUnlisten(); + assert.equal(unlistenCount, 4); +}); + +test("concurrent listener remount does not take or acknowledge the in-flight head twice", async () => { + const queue = [ + { + id: "in-flight", + kind: "channel", + channelId: "channel-1", + messageId: null, + threadRootId: null, + }, + ]; + const acknowledgeGate = deferred(); + const opened = []; + const acknowledged = []; + + ipcHandlers.set("plugin:event|listen", () => nextCallbackId); + ipcHandlers.set("plugin:event|unlisten", () => {}); + ipcHandlers.set("take_pending_navigation_deep_link", () => queue[0] ?? null); + ipcHandlers.set( + "acknowledge_pending_navigation_deep_link", + async ({ id }) => { + acknowledged.push(id); + await acknowledgeGate.promise; + assert.equal(queue[0]?.id, id); + queue.shift(); + return true; + }, + ); + + const firstUnlisten = await listenForNavigationDeepLinks( + (payload) => { + opened.push(`first:${payload.channelId}`); + return true; + }, + () => true, + ); + await settle(); + assert.deepEqual(opened, ["first:channel-1"]); + assert.deepEqual(acknowledged, ["in-flight"]); + + firstUnlisten(); + const secondUnlisten = await listenForNavigationDeepLinks( + (payload) => { + opened.push(`second:${payload.channelId}`); + return true; + }, + () => true, + ); + await settle(); + + assert.deepEqual(opened, ["first:channel-1"]); + assert.deepEqual(acknowledged, ["in-flight"]); + + acknowledgeGate.resolve(); + await settle(); + await settle(); + + assert.deepEqual(opened, ["first:channel-1"]); + assert.deepEqual(acknowledged, ["in-flight"]); + assert.equal(queue.length, 0); + secondUnlisten(); +}); + +test("community reset prevents an in-flight route from acknowledging", async () => { + const pending = { + id: "old-community", + kind: "channel", + channelId: "channel-1", + messageId: null, + threadRootId: null, + }; + const routeGate = deferred(); + let acknowledgeCount = 0; + let clearCount = 0; + + ipcHandlers.set("plugin:event|listen", () => nextCallbackId); + ipcHandlers.set("plugin:event|unlisten", () => {}); + ipcHandlers.set("clear_pending_navigation_deep_links", () => { + clearCount += 1; + }); + ipcHandlers.set("take_pending_navigation_deep_link", () => pending); + ipcHandlers.set("acknowledge_pending_navigation_deep_link", () => { + acknowledgeCount += 1; + return true; + }); + + const unlisten = await listenForNavigationDeepLinks( + async () => { + await routeGate.promise; + return true; + }, + () => true, + ); + await settle(); + + await resetNavigationDeepLinkDrain(); + routeGate.resolve(); + await settle(); + + assert.equal(clearCount, 1); + assert.equal(acknowledgeCount, 0); + unlisten(); +}); + +test("community reset after take does not route the stale item", async () => { + const takeGate = deferred(); + const opened = []; + + ipcHandlers.set("plugin:event|listen", () => nextCallbackId); + ipcHandlers.set("plugin:event|unlisten", () => {}); + ipcHandlers.set("clear_pending_navigation_deep_links", () => {}); + ipcHandlers.set("take_pending_navigation_deep_link", async () => { + await takeGate.promise; + return { + id: "old-community", + kind: "channel", + channelId: "channel-1", + messageId: null, + threadRootId: null, + }; + }); + ipcHandlers.set("acknowledge_pending_navigation_deep_link", () => true); + + const unlisten = await listenForNavigationDeepLinks( + (payload) => { + opened.push(payload.channelId); + return true; + }, + () => true, + ); + await settle(); + + await resetNavigationDeepLinkDrain(); + takeGate.resolve(); + await settle(); + + assert.deepEqual(opened, []); + unlisten(); +}); + +test("community reset stops the stale drain before taking another item", async () => { + const queue = [ + { + id: "first", + kind: "channel", + channelId: "channel-1", + messageId: null, + threadRootId: null, + }, + { + id: "second", + kind: "channel", + channelId: "channel-2", + messageId: null, + threadRootId: null, + }, + ]; + const opened = []; + let takeCount = 0; + + ipcHandlers.set("plugin:event|listen", () => nextCallbackId); + ipcHandlers.set("plugin:event|unlisten", () => {}); + ipcHandlers.set("clear_pending_navigation_deep_links", () => { + queue.length = 0; + }); + ipcHandlers.set("take_pending_navigation_deep_link", () => { + takeCount += 1; + return queue[0] ?? null; + }); + ipcHandlers.set( + "acknowledge_pending_navigation_deep_link", + async ({ id }) => { + assert.equal(queue[0]?.id, id); + queue.shift(); + await resetNavigationDeepLinkDrain(); + return true; + }, + ); + + const unlisten = await listenForNavigationDeepLinks( + (payload) => { + opened.push(payload.channelId); + return true; + }, + () => true, + ); + await settle(); + await settle(); + + assert.deepEqual(opened, ["channel-1"]); + assert.equal(takeCount, 1); + unlisten(); +}); + +test("community reset tolerates native queue clear rejection", async () => { + const warnings = []; + const originalWarn = console.warn; + ipcHandlers.set("clear_pending_navigation_deep_links", () => { + throw new Error("clear failed"); + }); + console.warn = (...args) => warnings.push(args); + + try { + await resetNavigationDeepLinkDrain(); + assert.equal(warnings.length, 1); + assert.match(String(warnings[0][1]), /clear failed/); + } finally { + console.warn = originalWarn; + } +}); + +test("rejected navigation remains queued and is not acknowledged", async () => { + const pending = { + id: "retry-me", + kind: "channel", + channelId: "channel-1", + messageId: null, + threadRootId: null, + }; + let acknowledgeCount = 0; + const warnings = []; + const originalWarn = console.warn; + + ipcHandlers.set("plugin:event|listen", () => nextCallbackId); + ipcHandlers.set("plugin:event|unlisten", () => {}); + ipcHandlers.set("take_pending_navigation_deep_link", () => pending); + ipcHandlers.set("acknowledge_pending_navigation_deep_link", () => { + acknowledgeCount += 1; + return true; + }); + console.warn = (...args) => warnings.push(args); + + try { + const unlisten = await listenForNavigationDeepLinks( + async () => { + throw new Error("route failed"); + }, + async () => true, + ); + await settle(); + + assert.equal(acknowledgeCount, 0); + assert.equal(warnings.length, 1); + assert.match(String(warnings[0][1]), /route failed/); + unlisten(); + } finally { + console.warn = originalWarn; + } +}); diff --git a/desktop/src/shared/deep-link.ts b/desktop/src/shared/deep-link.ts index c62a8bec3b..f2962c995f 100644 --- a/desktop/src/shared/deep-link.ts +++ b/desktop/src/shared/deep-link.ts @@ -15,6 +15,8 @@ export interface DeepLinkDeps { onAddCommunityAvailable: (listener: () => void) => () => void; } +export type ChannelDeepLinkPayload = { channelId: string }; + /** * Payload emitted by the Rust deep-link handler for `buzz://message?…`. * Field names match the JSON shape produced in `desktop/src-tauri/src/lib.rs`. @@ -25,6 +27,14 @@ export type MessageDeepLinkPayload = { threadRootId: string | null; }; +type PendingNavigationDeepLink = { + id: string; + kind: "channel" | "message"; + channelId: string; + messageId: string | null; + threadRootId: string | null; +}; + export type NostrBindDeepLinkPayload = { challengeId: string; nonce: string; @@ -152,17 +162,105 @@ export async function listenForDeepLinks( }; } +let navigationDrainTail: Promise = Promise.resolve(); +let navigationDrainGeneration = 0; + +export async function resetNavigationDeepLinkDrain(): Promise { + navigationDrainGeneration += 1; + try { + await invoke("clear_pending_navigation_deep_links"); + } catch (error: unknown) { + // A community switch must not strand the app behind its loading gate if + // the best-effort native queue cleanup is unavailable. The generation + // bump above still prevents in-flight JavaScript drains from acknowledging. + console.warn("Failed to clear pending navigation deep links", error); + } +} + +function serializeNavigationDrain(task: () => Promise): Promise { + const drain = navigationDrainTail.then(task, task); + // Keep the shared tail fulfilled so one route failure cannot poison future + // listener mounts. The caller still receives `drain` and reports the error. + navigationDrainTail = drain.catch(() => {}); + return drain; +} + +async function drainPendingNavigationDeepLinks( + onOpenChannel: ( + payload: ChannelDeepLinkPayload, + ) => boolean | Promise, + onOpenMessage: ( + payload: MessageDeepLinkPayload, + ) => boolean | Promise, +) { + const generation = navigationDrainGeneration; + while (generation === navigationDrainGeneration) { + const pending = await invoke( + "take_pending_navigation_deep_link", + ); + if (!pending || generation !== navigationDrainGeneration) return; + const accepted = await (pending.kind === "channel" + ? onOpenChannel({ channelId: pending.channelId }) + : pending.messageId + ? onOpenMessage({ + channelId: pending.channelId, + messageId: pending.messageId, + threadRootId: pending.threadRootId, + }) + : false); + if (!accepted || generation !== navigationDrainGeneration) return; + const acknowledged = await invoke( + "acknowledge_pending_navigation_deep_link", + { id: pending.id }, + ); + if (!acknowledged) return; + } +} + /** - * Register a listener for `deep-link-message` events. Must be called from - * inside the router tree (e.g. AppShell) because the navigation callback - * uses TanStack Router state. + * Register listeners for queued channel/message navigation emitted by Rust. + * A consumer must explicitly accept each item before it is acknowledged, so + * effect teardown leaves an in-flight queue head available for the next mount. */ -export function listenForMessageDeepLinks( - onOpen: (payload: MessageDeepLinkPayload) => void, +export async function listenForNavigationDeepLinks( + onOpenChannel: ( + payload: ChannelDeepLinkPayload, + ) => boolean | Promise, + onOpenMessage: ( + payload: MessageDeepLinkPayload, + ) => boolean | Promise, ): Promise { - return listen("deep-link-message", (event) => { - onOpen(event.payload); - }); + let drainRunning = false; + let drainRequested = false; + const drain = () => { + drainRequested = true; + if (drainRunning) return; + drainRunning = true; + void (async () => { + try { + while (drainRequested) { + drainRequested = false; + await serializeNavigationDrain(() => + drainPendingNavigationDeepLinks(onOpenChannel, onOpenMessage), + ); + } + } catch (error: unknown) { + console.warn("Failed to drain pending navigation deep links", error); + } finally { + drainRunning = false; + if (drainRequested) drain(); + } + })(); + }; + + const unlistens = await Promise.all([ + listen("deep-link-channel", drain), + listen("deep-link-message", drain), + ]); + drain(); + return () => { + for (const unlisten of unlistens) unlisten(); + }; } export function listenForNostrBindDeepLinks( diff --git a/desktop/src/shared/ui/markdown.test.mjs b/desktop/src/shared/ui/markdown.test.mjs index aa3e02984d..26fb3a5803 100644 --- a/desktop/src/shared/ui/markdown.test.mjs +++ b/desktop/src/shared/ui/markdown.test.mjs @@ -533,11 +533,12 @@ import React from "react"; import { renderToStaticMarkup } from "react-dom/server"; import ReactMarkdown, { defaultUrlTransform } from "react-markdown"; +import { isChannelLink } from "../../features/messages/lib/channelLink.ts"; import { isMessageLink } from "../../features/messages/lib/messageLink.ts"; import remarkSpoilers from "../lib/remarkSpoilers.ts"; function messageLinkUrlTransform(value, key) { - if (key === "href" && isMessageLink(value)) { + if (key === "href" && (isMessageLink(value) || isChannelLink(value))) { return value; } return defaultUrlTransform(value); @@ -573,6 +574,23 @@ test("messageLinkUrlTransform: preserves buzz://message href with thread", () => assert.match(html, /href="buzz:\/\/message\?[^"]*thread=t1"/); }); +test("messageLinkUrlTransform: preserves buzz://channel href", () => { + const html = renderMarkdown( + "Click [here](buzz://channel/580ca78b-9dae-46f3-8854-bd671853ba32)", + ); + assert.match( + html, + /href="buzz:\/\/channel\/580ca78b-9dae-46f3-8854-bd671853ba32"/, + ); +}); + +test("messageLinkUrlTransform: rejects malformed buzz://channel href", () => { + const html = renderMarkdown( + "Click [here](buzz://channel/580ca78b-9dae-46f3-8854-bd671853ba32?extra=true)", + ); + assert.match(html, /href=""/); +}); + test("messageLinkUrlTransform: still strips javascript: scheme", () => { const html = renderMarkdown("[xss](javascript:alert(1))"); // defaultUrlTransform replaces unsafe schemes with the empty string. diff --git a/desktop/src/shared/ui/markdown.tsx b/desktop/src/shared/ui/markdown.tsx index b1f9623f3e..31d733b9c7 100644 --- a/desktop/src/shared/ui/markdown.tsx +++ b/desktop/src/shared/ui/markdown.tsx @@ -14,6 +14,7 @@ import { toast } from "sonner"; import { useAppNavigation } from "@/app/navigation/useAppNavigation"; import { requestOpenSnapshotImport } from "@/features/agents/openSnapshotImportFromUrlEvent"; +import { parseChannelLink } from "@/features/messages/lib/channelLink"; import { parseMessageLink, resolveMessageLinkRenderTarget, @@ -60,6 +61,11 @@ import { SyntaxHighlightedCode, } from "./markdown/CodeBlock"; import { FileCard } from "./markdown/FileCard"; +import { + ChannelDeepLinkAnchor, + MarkdownChannelDeepLink, + MarkdownChannelReference, +} from "./markdown/ChannelDeepLink"; import { InlineEmojiPopover } from "./markdown/InlineEmojiPopover"; import { MarkdownInput } from "./markdown/MarkdownInput"; import { @@ -1426,10 +1432,16 @@ function createMarkdownComponents( ); } - // Intercept `buzz://message?channel=…&id=…` links so a click navigates - // in-app instead of opening the URL in the OS browser. http(s) links - // continue to use the existing target="_blank" behavior. + // Intercept `buzz://channel/` and `buzz://message?...` links so + // clicks navigate in-app instead of opening the URL in the OS browser. if (href) { + if (parseChannelLink(href).ok) { + return ( + + {children} + + ); + } const messageLinkTarget = resolveMessageLinkRenderTarget({ href, label, @@ -1727,46 +1739,16 @@ function createMarkdownComponents( } return ; }, - "channel-link": function MarkdownChannelLink({ - children, - }: { - children?: React.ReactNode; - }) { - const { channels, onOpenChannel } = useMarkdownRuntime(); - const text = String(children ?? ""); - const channelName = text.startsWith("#") ? text.slice(1) : text; - const channel = channels.find( - (c) => - c.channelType !== "dm" && - c.name.toLowerCase() === channelName.toLowerCase(), - ); - - if (channel && interactive) { - return ( - - ); - } - - return ( - - {children} - - ); - }, + "channel-deep-link": ({ children }: { children?: React.ReactNode }) => ( + + {children} + + ), + "channel-link": ({ children }: { children?: React.ReactNode }) => ( + + {children} + + ), "message-link": function MarkdownMessageLink({ children, }: { diff --git a/desktop/src/shared/ui/markdown/ChannelDeepLink.tsx b/desktop/src/shared/ui/markdown/ChannelDeepLink.tsx new file mode 100644 index 0000000000..6d11bf604e --- /dev/null +++ b/desktop/src/shared/ui/markdown/ChannelDeepLink.tsx @@ -0,0 +1,94 @@ +import type * as React from "react"; + +import { parseChannelLink } from "@/features/messages/lib/channelLink"; + +import { useMarkdownRuntime } from "./runtimeContext"; + +const CHANNEL_LINK_CLASSES = + "font-medium text-primary underline underline-offset-4 transition-colors hover:text-primary/80 cursor-pointer"; + +export function ChannelDeepLinkAnchor({ + children, + href, + ...props +}: React.ComponentPropsWithoutRef<"a">) { + const { onOpenChannel } = useMarkdownRuntime(); + if (!href) return <>{children}; + const parsed = parseChannelLink(href); + if (!parsed.ok) return <>{children}; + return ( + { + event.preventDefault(); + onOpenChannel(parsed.value.channelId); + }} + > + {children} + + ); +} + +export function MarkdownChannelDeepLink({ + children, + interactive, +}: { + children?: React.ReactNode; + interactive: boolean; +}) { + const { onOpenChannel } = useMarkdownRuntime(); + const href = String(children ?? ""); + const parsed = parseChannelLink(href); + if (!parsed.ok || !interactive) { + return {href}; + } + return ( + + ); +} + +export function MarkdownChannelReference({ + children, + interactive, +}: { + children?: React.ReactNode; + interactive: boolean; +}) { + const { channels, onOpenChannel } = useMarkdownRuntime(); + const text = String(children ?? ""); + const channelName = text.startsWith("#") ? text.slice(1) : text; + const channel = channels.find( + (candidate) => + candidate.channelType !== "dm" && + candidate.name.toLowerCase() === channelName.toLowerCase(), + ); + const baseClasses = + "inline-flex items-center rounded-md bg-primary/10 px-1 py-0.5 font-medium text-primary"; + if (!channel || !interactive) { + return ( + + {children} + + ); + } + return ( + + ); +} diff --git a/desktop/src/shared/ui/markdown/nodeCache.ts b/desktop/src/shared/ui/markdown/nodeCache.ts index 97cbe212d3..9fd639b59c 100644 --- a/desktop/src/shared/ui/markdown/nodeCache.ts +++ b/desktop/src/shared/ui/markdown/nodeCache.ts @@ -3,6 +3,7 @@ import ReactMarkdown, { type Components } from "react-markdown"; import remarkBreaks from "remark-breaks"; import remarkGfm from "remark-gfm"; +import remarkChannelDeepLinks from "@/features/messages/lib/remarkChannelDeepLinks"; import remarkMessageLinks from "@/features/messages/lib/remarkMessageLinks"; import rehypeImageGallery from "@/shared/lib/rehypeImageGallery"; import rehypeSearchHighlight from "@/shared/lib/rehypeSearchHighlight"; @@ -98,6 +99,7 @@ function buildMarkdownElement(input: MarkdownParseInputs): React.ReactElement { remarkGfm, remarkBreaks, remarkSpoilers, + remarkChannelDeepLinks, remarkMessageLinks, [remarkMentions, { mentionNames: input.mentionNames }], [remarkChannelLinks, { channelNames: input.channelNames }], diff --git a/desktop/src/shared/ui/markdown/utils.ts b/desktop/src/shared/ui/markdown/utils.ts index db9629921e..20cbb1325e 100644 --- a/desktop/src/shared/ui/markdown/utils.ts +++ b/desktop/src/shared/ui/markdown/utils.ts @@ -1,6 +1,7 @@ import * as React from "react"; import { defaultUrlTransform } from "react-markdown"; +import { isChannelLink } from "@/features/messages/lib/channelLink"; import { isMessageLink } from "@/features/messages/lib/messageLink"; export function useStableArray(arr: T[]): T[] { @@ -172,7 +173,7 @@ export function isInsideHiddenSpoiler(element: Element): boolean { * click end-to-end. Everything else delegates to `defaultUrlTransform`. */ export function messageLinkUrlTransform(value: string, key: string): string { - if (key === "href" && isMessageLink(value)) { + if (key === "href" && (isMessageLink(value) || isChannelLink(value))) { return value; } return defaultUrlTransform(value); diff --git a/desktop/src/shared/useMessageDeepLinks.ts b/desktop/src/shared/useMessageDeepLinks.ts index d4478a4422..fbbe4b9f67 100644 --- a/desktop/src/shared/useMessageDeepLinks.ts +++ b/desktop/src/shared/useMessageDeepLinks.ts @@ -1,7 +1,7 @@ import * as React from "react"; import { useAppNavigation } from "@/app/navigation/useAppNavigation"; -import { listenForMessageDeepLinks } from "@/shared/deep-link"; +import { listenForNavigationDeepLinks } from "@/shared/deep-link"; /** * Subscribe to `buzz://message` deep links emitted by the Tauri backend @@ -24,16 +24,24 @@ export function useMessageDeepLinks(enabled = true) { if (!enabled) return; let cancelled = false; - const unlistenPromise = listenForMessageDeepLinks((payload) => { - if (cancelled) return; - void goChannel(payload.channelId, { - messageId: payload.messageId, - threadRootId: payload.threadRootId, - }); - }); + const unlistenPromise = listenForNavigationDeepLinks( + async (payload) => { + if (cancelled) return false; + await goChannel(payload.channelId); + return true; + }, + async (payload) => { + if (cancelled) return false; + await goChannel(payload.channelId, { + messageId: payload.messageId, + threadRootId: payload.threadRootId, + }); + return true; + }, + ); return () => { cancelled = true; - void unlistenPromise.then((fn) => fn()); + void unlistenPromise.then((unlisten) => unlisten()); }; }, [enabled, goChannel]); } diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index c6027fec67..83d5e3a7fe 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -424,6 +424,13 @@ type E2eConfig = { code?: string | null; name?: string | null; }>; + pendingNavigationDeepLinks?: Array<{ + id: string; + kind: "channel" | "message"; + channelId: string; + messageId?: string | null; + threadRootId?: 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. identityLost?: boolean; @@ -4252,6 +4259,24 @@ function resetMockPendingCommunityDeepLinks(config: E2eConfig | null) { })); } +let mockPendingNavigationDeepLinks: Array<{ + id: string; + kind: "channel" | "message"; + channelId: string; + messageId: string | null; + threadRootId: string | null; +}> = []; + +function resetMockPendingNavigationDeepLinks(config: E2eConfig | null) { + mockPendingNavigationDeepLinks = ( + config?.mock?.pendingNavigationDeepLinks ?? [] + ).map((pending) => ({ + ...pending, + messageId: pending.messageId ?? null, + threadRootId: pending.threadRootId ?? null, + })); +} + function recordMockUserStatus(event: RelayEvent) { const dTag = event.tags.find((tag) => tag[0] === "d")?.[1]; if (dTag) { @@ -9821,6 +9846,7 @@ export function maybeInstallE2eTauriMocks() { resetMockPersonaCatalogEvents(config); resetMockSaveSubscriptions(config); resetMockPendingCommunityDeepLinks(config); + resetMockPendingNavigationDeepLinks(config); initializeMockHuddle(config.mock?.huddle, config); mockWebsocketSendMutexWedged = false; if (config.mock?.windowLabel) { @@ -11522,6 +11548,17 @@ export function maybeInstallE2eTauriMocks() { mockPendingCommunityDeepLinks.splice(index, 1); return true; } + case "clear_pending_navigation_deep_links": + mockPendingNavigationDeepLinks.length = 0; + return; + case "take_pending_navigation_deep_link": + return mockPendingNavigationDeepLinks[0] ?? null; + case "acknowledge_pending_navigation_deep_link": { + const { id } = payload as { id: string }; + if (mockPendingNavigationDeepLinks[0]?.id !== id) return false; + mockPendingNavigationDeepLinks.shift(); + return true; + } case "get_relay_http_url": return getRelayHttpUrl(activeConfig); case "relay_requires_membership": diff --git a/desktop/tests/e2e/community-rail.spec.ts b/desktop/tests/e2e/community-rail.spec.ts index b432d17f2a..1daa614d78 100644 --- a/desktop/tests/e2e/community-rail.spec.ts +++ b/desktop/tests/e2e/community-rail.spec.ts @@ -759,6 +759,16 @@ test.describe("community rail", () => { // The app settles into the new community once apply completes. await expect(buttonB).toHaveAttribute("aria-current", "true"); + await expect + .poll(() => + page.evaluate( + () => + window.__BUZZ_E2E_COMMANDS__?.filter( + (command) => command === "clear_pending_navigation_deep_links", + ).length ?? 0, + ), + ) + .toBe(1); }); test("hides the rail with a single community", async ({ page }) => { diff --git a/desktop/tests/e2e/navigation.spec.ts b/desktop/tests/e2e/navigation.spec.ts index 18db55a50a..e55921bc29 100644 --- a/desktop/tests/e2e/navigation.spec.ts +++ b/desktop/tests/e2e/navigation.spec.ts @@ -425,3 +425,63 @@ test("message deep links survive reload", async ({ page }) => { "Engineering shipped the desktop build.", ); }); + +// Cold-start OS links are queued natively until AppShell mounts its router listener. + +test("cold-start channel deep link drains after the router mounts", async ({ + page, +}) => { + await installMockBridge(page, { + pendingNavigationDeepLinks: [ + { + id: "navigation-channel-1", + kind: "channel", + channelId: ENGINEERING_CHANNEL_ID, + }, + ], + }); + + await page.goto("/"); + + await expect(page.getByTestId("chat-title")).toHaveText("engineering"); + await expect(page).toHaveURL( + new RegExp(`#/channels/${ENGINEERING_CHANNEL_ID}$`), + ); + await expect + .poll(() => + page.evaluate(() => + (window.__BUZZ_E2E_COMMAND_LOG__ ?? []).filter( + (entry) => + entry.command === "acknowledge_pending_navigation_deep_link", + ), + ), + ) + .toEqual([ + { + command: "acknowledge_pending_navigation_deep_link", + payload: { id: "navigation-channel-1" }, + }, + ]); +}); + +test("cold-start message deep link preserves its thread target", async ({ + page, +}) => { + await installMockBridge(page, { + pendingNavigationDeepLinks: [ + { + id: "navigation-message-1", + kind: "message", + channelId: WATERCOLOR_CHANNEL_ID, + messageId: "mock-forum-release-reply", + threadRootId: "mock-forum-release-thread", + }, + ], + }); + + await page.goto("/"); + + await expect(page.getByTestId("chat-title")).toHaveText("watercooler"); + await expect(page).toHaveURL(/messageId=mock-forum-release-reply/); + await expect(page).toHaveURL(/threadRootId=mock-forum-release-thread/); +}); diff --git a/desktop/tests/helpers/bridge.ts b/desktop/tests/helpers/bridge.ts index 5e90e5a7f9..f2295f598d 100644 --- a/desktop/tests/helpers/bridge.ts +++ b/desktop/tests/helpers/bridge.ts @@ -416,6 +416,14 @@ type MockBridgeOptions = { code?: string | null; name?: string | null; }>; + /** Pending channel/message links that arrived before AppShell mounted. */ + pendingNavigationDeepLinks?: Array<{ + id: string; + kind: "channel" | "message"; + channelId: string; + messageId?: string | null; + threadRootId?: string | null; + }>; /** * Global agent config returned by `get_global_agent_config`. Defaults to * an empty config (no provider, model, or env vars) if not specified. diff --git a/mobile/lib/features/channels/deep_link_dispatcher.dart b/mobile/lib/features/channels/deep_link_dispatcher.dart index b264b31b69..84eb1cfc0d 100644 --- a/mobile/lib/features/channels/deep_link_dispatcher.dart +++ b/mobile/lib/features/channels/deep_link_dispatcher.dart @@ -17,7 +17,7 @@ import 'channels_provider.dart'; /// held (not dropped) while channels are still loading, so cold-start links /// dispatch as soon as the first channel fetch completes. typedef DeepLinkDestinationBuilder = - Widget Function(Channel channel, MessageDeepLink link); + Widget Function(Channel channel, BuzzDeepLink link); class DeepLinkDispatcher extends ConsumerStatefulWidget { final Widget child; @@ -68,8 +68,16 @@ class _DeepLinkDispatcherState extends ConsumerState { _maybeDispatchInvite(link); return; } - if (link is! MessageDeepLink || !widget.dispatchMessageLinks) return; + if ((link is! MessageDeepLink && link is! ChannelDeepLink) || + !widget.dispatchMessageLinks) { + return; + } + final channelId = switch (link) { + MessageDeepLink(:final channelId) => channelId, + ChannelDeepLink(:final channelId) => channelId, + _ => throw StateError('unsupported navigable deep link: $link'), + }; final channels = ref.read(channelsProvider).asData?.value; // Channels not loaded yet — keep the link parked; the channelsProvider // listener re-attempts once data arrives. @@ -78,13 +86,12 @@ class _DeepLinkDispatcherState extends ConsumerState { ref.read(pendingDeepLinkProvider.notifier).consume(); final channel = channels - .where((c) => c.id == link.channelId) + .where((c) => c.id == channelId) .cast() .firstOrNull; if (channel == null) { debugPrint( - 'deep-link: channel ${link.channelId} not found in workspace; ' - 'dropping link', + 'deep-link: channel $channelId not found in workspace; dropping link', ); ScaffoldMessenger.maybeOf(context)?.showSnackBar( const SnackBar(content: Text('Channel not found in this workspace')), @@ -99,8 +106,10 @@ class _DeepLinkDispatcherState extends ConsumerState { widget.destinationBuilder?.call(channel, link) ?? ChannelDetailPage( channel: channel, - initialMessageId: link.messageId, - initialThreadRootId: link.threadRootId, + initialMessageId: link is MessageDeepLink ? link.messageId : null, + initialThreadRootId: link is MessageDeepLink + ? link.threadRootId + : null, ), ), ); diff --git a/mobile/lib/features/channels/message_content.dart b/mobile/lib/features/channels/message_content.dart index 8f4281aeb0..1ccf27e5cc 100644 --- a/mobile/lib/features/channels/message_content.dart +++ b/mobile/lib/features/channels/message_content.dart @@ -15,6 +15,8 @@ import 'package:url_launcher/url_launcher.dart'; import 'package:video_player/video_player.dart'; import '../../shared/clipboard_utils.dart'; +import '../../shared/deeplink/deep_link.dart'; +import '../../shared/deeplink/pending_deep_link_provider.dart'; import '../../shared/relay/relay.dart'; import '../../shared/syntax_highlight.dart'; import '../../shared/theme/theme.dart'; @@ -23,7 +25,9 @@ import '../../shared/custom_emoji/custom_emoji_provider.dart'; import '../../shared/custom_emoji/custom_emoji_render.dart'; import '../../shared/emoji/emoji_data_provider.dart'; import '../../shared/emoji/emoji_only.dart'; +import 'channels_provider.dart'; import 'media_viewer_page.dart'; +import 'message_content/link_normalizer.dart'; import 'message_media.dart'; part 'message_content/media_carousel.dart'; @@ -152,6 +156,26 @@ class MessageContent extends HookConsumerWidget { final resolvedAgentMentionPubkeys = { ...agentMentionPubkeys.map((pubkey) => pubkey.toLowerCase()), }; + final resolvedChannelNames = channelNames.isNotEmpty + ? channelNames + : { + for (final channel + in ref.watch(channelsProvider).asData?.value ?? const []) + channel.name.toLowerCase(): channel.id, + }; + final resolvedChannelTap = + onChannelTap ?? + (String channelId) { + ref + .read(pendingDeepLinkProvider.notifier) + .open(Uri(scheme: 'buzz', host: 'channel', path: channelId)); + }; + final channelPresentationKey = [ + for (final entry + in (resolvedChannelNames.entries.toList() + ..sort((a, b) => a.key.compareTo(b.key)))) + '${entry.key}\u0000${entry.value}', + ].join('\u0001'); final imetaByUrl = parseImetaTags(tags); final trailingGallery = maxLines == null ? _extractTrailingImageGallery(content, imetaByUrl) @@ -189,45 +213,17 @@ class MessageContent extends HookConsumerWidget { ? kEmojiOnlyCustomEmojiSize : kCustomEmojiInlineSize; - final finalContent = useMemoized(() { - // Convert autolinks and bare URLs to standard markdown links, - // but skip content inside backticks (inline code / fenced blocks). - final buffer = StringBuffer(); - final parts = markdownContent.split('`'); - for (var i = 0; i < parts.length; i++) { - if (i.isOdd) { - // Inside backticks — preserve as-is. - buffer.write('`${parts[i]}`'); - } else { - // 1. Angle-bracket autolinks: - var segment = parts[i].replaceAllMapped( - RegExp(r'<(https?://[^>]+)>'), - (m) => '[${m[1]}](${m[1]})', - ); - // 2. Bare URLs not already inside markdown link/image syntax. - // Negative lookbehind avoids matching URLs preceded by ]( or = - // which are already part of markdown links or imeta tags. - segment = segment.replaceAllMapped( - RegExp(r'(?\]]+'), - (m) { - final url = m[0]!; - // Skip if this URL is already a markdown link label that equals - // the URL (produced by step 1 or authored as [url](url)). - final start = m.start; - if (start >= 1 && segment[start - 1] == '[') return url; - return '[$url]($url)'; - }, - ); - buffer.write(segment); - } - } - final processed = buffer.toString(); + final linkNormalizedContent = useMemoized( + () => normalizeBareLinks(markdownContent), + [markdownContent], + ); + final finalContent = useMemoized(() { // Replace spaces with non-breaking spaces inside known mention names // so the gpt_markdown combined regex can match multi-word names // even when caseSensitive is not preserved. // Skip content inside backticks to avoid altering inline code. - final mentionParts = processed.split('`'); + final mentionParts = linkNormalizedContent.split('`'); final mentionBuf = StringBuffer(); for (var i = 0; i < mentionParts.length; i++) { if (i.isOdd) { @@ -246,27 +242,35 @@ class MessageContent extends HookConsumerWidget { mentionBuf.write(segment); } } - final mentionProcessed = mentionBuf.toString(); + var result = mentionBuf.toString(); // Ensure channel links at the very start of content don't get // swallowed by markdown processing. - var result = mentionProcessed; if (RegExp(r'^#[A-Za-z0-9_]').hasMatch(result)) { result = '\u200B$result'; } return result; - }, [markdownContent, resolvedMentionNames]); + }, [linkNormalizedContent, resolvedMentionNames]); final markdown = KeyedSubtree( - key: ValueKey('$finalContent\u0000$mentionPresentationKey'), + key: ValueKey( + '$finalContent\u0000$mentionPresentationKey\u0000$channelPresentationKey', + ), child: GptMarkdown( finalContent, style: style, followLinkColor: false, codeBuilder: (context, name, code, closed) => _MessageCodeBlock(name: name, code: code), - linkBuilder: (context, linkText, url, linkStyle) => - _buildLink(context, ref, linkText, url, linkStyle, style), + linkBuilder: (context, linkText, url, linkStyle) => _buildLink( + context, + ref, + linkText, + url, + linkStyle, + style, + resolvedChannelTap, + ), imageBuilder: (context, imageUrl) => _buildMedia(context, imageUrl, imetaByUrl[imageUrl]), maxLines: maxLines, @@ -278,8 +282,8 @@ class MessageContent extends HookConsumerWidget { ), CustomEmojiMd(customEmoji, size: inlineCustomEmojiSize), _ChannelLinkMd( - channelNames: channelNames, - onChannelTap: onChannelTap, + channelNames: resolvedChannelNames, + onChannelTap: resolvedChannelTap, ), ...MarkdownComponent.inlineComponents, ], @@ -331,6 +335,7 @@ class MessageContent extends HookConsumerWidget { String url, TextStyle linkStyle, TextStyle? fallbackStyle, + void Function(String channelId) resolvedChannelTap, ) { String text = ''; linkText.visitChildren((span) { @@ -345,9 +350,22 @@ class MessageContent extends HookConsumerWidget { return GestureDetector( onTap: () async { final uri = Uri.tryParse(url); - if (uri == null || (uri.scheme != 'http' && uri.scheme != 'https')) { + if (uri == null) return; + + // Rendered channel URLs must use the same callback as `#channel` + // references so detail-page callers can suppress self-navigation. + // Message and join links still need the top-level authenticated + // dispatcher. + if (uri.scheme == 'buzz') { + final deepLink = parseBuzzDeepLink(uri); + if (deepLink case ChannelDeepLink(:final channelId)) { + resolvedChannelTap(channelId); + } else if (deepLink != null) { + ref.read(pendingDeepLinkProvider.notifier).open(uri); + } return; } + if (uri.scheme != 'http' && uri.scheme != 'https') return; final auth = ref.read(mediaGetAuthServiceProvider); if (!auth.isRelayMediaUrl(url)) { diff --git a/mobile/lib/features/channels/message_content/link_normalizer.dart b/mobile/lib/features/channels/message_content/link_normalizer.dart new file mode 100644 index 0000000000..ba6bff3310 --- /dev/null +++ b/mobile/lib/features/channels/message_content/link_normalizer.dart @@ -0,0 +1,93 @@ +const _markdownDelimiters = ['***', '___', '**', '__', '~~', '*', '_']; + +final _autolinkPattern = RegExp( + r'<((?:https?://|buzz://(?:message\?|join\?|channel/))[^>]+)>', +); +final _bareLinkPattern = RegExp( + r'(?\]]+', +); +final _trailingPunctuationPattern = RegExp(r'[.,!?:;]+$'); + +/// Converts supported autolinks and bare links into Markdown links while +/// leaving inline and fenced code untouched. +String normalizeBareLinks(String content) { + final buffer = StringBuffer(); + final parts = content.split('`'); + for (var i = 0; i < parts.length; i++) { + if (i.isOdd) { + buffer.write('`${parts[i]}`'); + continue; + } + + var segment = parts[i].replaceAllMapped( + _autolinkPattern, + (match) => '[${match[1]}](${match[1]})', + ); + segment = segment.replaceAllMapped( + _bareLinkPattern, + (match) => _normalizeBareLink(segment, match), + ); + buffer.write(segment); + } + return buffer.toString(); +} + +String _normalizeBareLink(String segment, Match match) { + final matched = match[0]!; + var url = matched; + var trailing = ''; + final start = match.start; + + final outsidePunctuation = _trailingPunctuationPattern.firstMatch(url); + if (outsidePunctuation != null) { + url = url.substring(0, outsidePunctuation.start); + trailing = outsidePunctuation[0]!; + } + + var strippedDelimiter = true; + while (strippedDelimiter) { + strippedDelimiter = false; + for (final delimiter in _markdownDelimiters) { + if (url.endsWith(delimiter) && + _hasUnclosedMarkdownDelimiter( + segment.substring(0, start), + delimiter, + )) { + url = url.substring(0, url.length - delimiter.length); + trailing = '$delimiter$trailing'; + strippedDelimiter = true; + break; + } + } + } + + final punctuation = _trailingPunctuationPattern.firstMatch(url); + if (punctuation != null) { + url = url.substring(0, punctuation.start); + trailing = '${punctuation[0]}$trailing'; + } + + // Preserve a URL already used as its own Markdown label. This covers both + // converted autolinks and authored `[url](url)` links. + if (start >= 1 && segment[start - 1] == '[') return matched; + return '[$url]($url)$trailing'; +} + +bool _hasUnclosedMarkdownDelimiter(String prefix, String delimiter) { + var open = false; + var offset = 0; + while (true) { + final index = prefix.indexOf(delimiter, offset); + if (index < 0) return open; + final before = index == 0 ? null : prefix[index - 1]; + final afterIndex = index + delimiter.length; + final after = afterIndex == prefix.length ? null : prefix[afterIndex]; + final canOpen = + (after == null || after.trim().isNotEmpty) && + (before == null || + before.trim().isEmpty || + RegExp(r'[^\w]').hasMatch(before)); + if (open || canOpen) open = !open; + offset = afterIndex; + } +} diff --git a/mobile/lib/shared/deeplink/deep_link.dart b/mobile/lib/shared/deeplink/deep_link.dart index 0ef7b8e596..8f0e399f1c 100644 --- a/mobile/lib/shared/deeplink/deep_link.dart +++ b/mobile/lib/shared/deeplink/deep_link.dart @@ -50,6 +50,26 @@ class InviteDeepLink extends BuzzDeepLink { 'InviteDeepLink(relay: $relayUrl, code: $code, policyReceipt: $policyReceipt)'; } +/// A parsed channel-only deep link. +/// +/// Canonical form: `buzz://channel/`. +class ChannelDeepLink extends BuzzDeepLink { + /// Channel UUID from the sole path segment. + final String channelId; + + const ChannelDeepLink({required this.channelId}); + + @override + bool operator ==(Object other) => + other is ChannelDeepLink && other.channelId == channelId; + + @override + int get hashCode => channelId.hashCode; + + @override + String toString() => 'ChannelDeepLink(channel: $channelId)'; +} + /// A parsed `buzz://message` deep link. class MessageDeepLink extends BuzzDeepLink { /// Channel UUID from the `channel` query param. @@ -115,6 +135,27 @@ String buildMessageLink({ ).toString(); } +/// Parse a canonical `buzz://channel/` URI. +/// +/// The channel ID must be the URI's sole non-empty path segment. Query +/// parameters and fragments are rejected so malformed or ambiguous links never +/// become navigation targets. +ChannelDeepLink? parseChannelDeepLink(Uri uri) { + if (uri.scheme != 'buzz' || uri.host != 'channel') return null; + if (uri.hasQuery || uri.hasFragment || uri.userInfo.isNotEmpty) return null; + if (uri.pathSegments.length != 1 || uri.pathSegments.single.isEmpty) { + return null; + } + final channelId = uri.pathSegments.single; + if (!RegExp( + r'^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$', + caseSensitive: false, + ).hasMatch(channelId)) { + return null; + } + return ChannelDeepLink(channelId: channelId.toLowerCase()); +} + /// Parse a `buzz://message?…` URI into a [MessageDeepLink]. /// /// Returns `null` for non-`buzz` schemes, non-`message` hosts (e.g. @@ -218,4 +259,6 @@ InviteDeepLink? parseInviteDeepLink(Uri uri) { /// Parse any supported Buzz deep link. BuzzDeepLink? parseBuzzDeepLink(Uri uri) => - parseInviteDeepLink(uri) ?? parseMessageDeepLink(uri); + parseInviteDeepLink(uri) ?? + parseChannelDeepLink(uri) ?? + parseMessageDeepLink(uri); diff --git a/mobile/lib/shared/deeplink/pending_deep_link_provider.dart b/mobile/lib/shared/deeplink/pending_deep_link_provider.dart index 8dc46d9f10..4fd2a67e88 100644 --- a/mobile/lib/shared/deeplink/pending_deep_link_provider.dart +++ b/mobile/lib/shared/deeplink/pending_deep_link_provider.dart @@ -23,7 +23,7 @@ class PendingDeepLinkNotifier extends Notifier { @override BuzzDeepLink? build() { final stream = debugUriStreamOverride ?? AppLinks().uriLinkStream; - _subscription = stream.listen(handleUri); + _subscription = stream.listen(open); ref.onDispose(() { _subscription?.cancel(); _subscription = null; @@ -32,8 +32,7 @@ class PendingDeepLinkNotifier extends Notifier { } /// Parse and park an incoming URI. Unsupported links are ignored loudly. - @visibleForTesting - void handleUri(Uri uri) { + void open(Uri uri) { final link = parseBuzzDeepLink(uri); if (link == null) { debugPrint('deep-link: ignoring unsupported link: $uri'); diff --git a/mobile/test/features/channels/deep_link_dispatcher_test.dart b/mobile/test/features/channels/deep_link_dispatcher_test.dart index 0771a7bb38..f616c6a46a 100644 --- a/mobile/test/features/channels/deep_link_dispatcher_test.dart +++ b/mobile/test/features/channels/deep_link_dispatcher_test.dart @@ -46,9 +46,44 @@ void main() { final destination = tester.widget<_CapturedDestination>( find.byType(_CapturedDestination), ); + final messageLink = destination.link as MessageDeepLink; expect(destination.channel.id, 'channel-1'); - expect(destination.link.messageId, 'message-2'); - expect(destination.link.threadRootId, 'message-1'); + expect(messageLink.messageId, 'message-2'); + expect(messageLink.threadRootId, 'message-1'); + }); + + testWidgets('dispatches a channel-only link to the channel root', ( + tester, + ) async { + const link = ChannelDeepLink(channelId: 'channel-1'); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + pendingDeepLinkProvider.overrideWith( + () => _FakePendingDeepLinkNotifier(link), + ), + channelsProvider.overrideWith( + () => _FakeChannelsNotifier(Future.value([_channel])), + ), + ], + child: MaterialApp( + home: DeepLinkDispatcher( + destinationBuilder: (channel, link) => + _CapturedDestination(channel: channel, link: link), + child: const Scaffold(body: SizedBox()), + ), + ), + ), + ); + + await tester.pumpAndSettle(); + + final destination = tester.widget<_CapturedDestination>( + find.byType(_CapturedDestination), + ); + expect(destination.channel.id, 'channel-1'); + expect(destination.link, same(link)); }); testWidgets('retains invite and surfaces prepare failure', (tester) async { @@ -259,7 +294,7 @@ class _CapturedDestination extends StatelessWidget { const _CapturedDestination({required this.channel, required this.link}); final Channel channel; - final MessageDeepLink link; + final BuzzDeepLink link; @override Widget build(BuildContext context) => const SizedBox(); diff --git a/mobile/test/features/channels/message_content/link_normalizer_test.dart b/mobile/test/features/channels/message_content/link_normalizer_test.dart new file mode 100644 index 0000000000..446983cecc --- /dev/null +++ b/mobile/test/features/channels/message_content/link_normalizer_test.dart @@ -0,0 +1,36 @@ +import 'package:buzz/features/channels/message_content/link_normalizer.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + const url = 'buzz://message?channel=channel-1&id=message-1'; + + test('normalizes supported bare and autolinked Buzz URLs', () { + expect( + normalizeBareLinks('See $url and <$url>'), + 'See [$url]($url) and [$url]($url)', + ); + }); + + test('keeps punctuation and open Markdown delimiters outside links', () { + expect( + normalizeBareLinks('**open $url**. and **_${url}_**!'), + '**open [$url]($url)**. and **_[$url]($url)_**!', + ); + }); + + test('preserves URL suffix characters without a matching opener', () { + expect( + normalizeBareLinks( + 'See $url' + '_ and $url~~', + ), + 'See [$url' + '_]($url' + '_) and [$url~~]($url~~)', + ); + }); + + test('leaves links inside backticks untouched', () { + expect(normalizeBareLinks('`$url` then $url'), '`$url` then [$url]($url)'); + }); +} diff --git a/mobile/test/features/channels/message_content_test.dart b/mobile/test/features/channels/message_content_test.dart index 7c84a74715..4fc1cd73fa 100644 --- a/mobile/test/features/channels/message_content_test.dart +++ b/mobile/test/features/channels/message_content_test.dart @@ -4,8 +4,12 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/misc.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:nostr/nostr.dart' as nostr; +import 'package:buzz/features/channels/channel.dart'; +import 'package:buzz/features/channels/channels_provider.dart'; import 'package:buzz/features/channels/message_content.dart'; import 'package:buzz/features/channels/media_viewer_page.dart'; +import 'package:buzz/shared/deeplink/deep_link.dart'; +import 'package:buzz/shared/deeplink/pending_deep_link_provider.dart'; import 'package:buzz/shared/emoji/emoji_only.dart'; import 'package:buzz/shared/relay/relay.dart'; import 'package:buzz/shared/theme/theme.dart'; @@ -153,6 +157,15 @@ bool _spanHasStyle( return found; } +class _TestChannelsNotifier extends ChannelsNotifier { + _TestChannelsNotifier(this.channels); + + final Future> channels; + + @override + Future> build() => channels; +} + void main() { group('MessageContent', () { testWidgets('opens local file links through an authenticated download', ( @@ -412,6 +425,305 @@ void main() { expect(allText, isNot(contains('(https://example.com)'))); }); + testWidgets('renders and routes a buzz message link', (tester) async { + const url = + 'buzz://message?channel=channel-1&id=message-2&thread=root-1'; + + await tester.pumpWidget( + _testable(const MessageContent(content: '[Open message]($url)')), + ); + + expect(find.text('Open message'), findsOneWidget); + await tester.tap(find.text('Open message')); + await tester.pump(); + + final container = ProviderScope.containerOf( + tester.element(find.byType(MessageContent)), + ); + expect( + container.read(pendingDeepLinkProvider), + const MessageDeepLink( + channelId: 'channel-1', + messageId: 'message-2', + threadRootId: 'root-1', + ), + ); + }); + + testWidgets('renders and routes bare Buzz message links', (tester) async { + const url = 'buzz://message?channel=channel-1&id=message-1'; + + await tester.pumpWidget( + _testable(const MessageContent(content: 'See $url now')), + ); + + expect(find.text(url), findsOneWidget); + await tester.tap(find.text(url)); + await tester.pump(); + + final container = ProviderScope.containerOf( + tester.element(find.byType(MessageContent)), + ); + expect( + container.read(pendingDeepLinkProvider), + const MessageDeepLink(channelId: 'channel-1', messageId: 'message-1'), + ); + }); + + testWidgets('keeps Markdown delimiters outside bare Buzz links', ( + tester, + ) async { + const url = 'buzz://message?channel=channel-1&id=message-1'; + + await tester.pumpWidget( + _testable(const MessageContent(content: '**$url**. and _${url}_')), + ); + + expect(find.text(url), findsNWidgets(2)); + + await tester.tap(find.text(url).first); + await tester.pump(); + final container = ProviderScope.containerOf( + tester.element(find.byType(MessageContent)), + ); + expect( + container.read(pendingDeepLinkProvider), + const MessageDeepLink(channelId: 'channel-1', messageId: 'message-1'), + ); + }); + + testWidgets('keeps non-adjacent Markdown delimiters outside links', ( + tester, + ) async { + const url = 'buzz://message?channel=channel-1&id=message-1'; + + await tester.pumpWidget( + _testable( + const MessageContent( + content: + '*join $url* and **open $url** and ' + '~~visit $url~~ and **_${url}_**.', + ), + ), + ); + + expect(find.text(url), findsNWidgets(4)); + final container = ProviderScope.containerOf( + tester.element(find.byType(MessageContent)), + ); + for (final link in find.text(url).evaluate()) { + await tester.tap(find.byWidget(link.widget)); + await tester.pump(); + expect( + container.read(pendingDeepLinkProvider), + const MessageDeepLink( + channelId: 'channel-1', + messageId: 'message-1', + ), + ); + container.read(pendingDeepLinkProvider.notifier).state = null; + } + }); + + testWidgets('excludes sentence punctuation from bare Buzz links', ( + tester, + ) async { + const messageUrl = 'buzz://message?channel=channel-1&id=message-1'; + const joinUrl = + 'buzz://join?relay=wss%3A%2F%2Frelay.example.com&code=invite-1'; + + await tester.pumpWidget( + _testable( + const MessageContent(content: 'See $messageUrl. Then $joinUrl!'), + ), + ); + + expect(find.text(messageUrl), findsOneWidget); + expect(find.text(joinUrl), findsOneWidget); + expect(_allRichText(tester), contains('See \u{FFFC}. Then \u{FFFC}!')); + + await tester.tap(find.text(messageUrl)); + await tester.pump(); + final container = ProviderScope.containerOf( + tester.element(find.byType(MessageContent)), + ); + expect( + container.read(pendingDeepLinkProvider), + const MessageDeepLink(channelId: 'channel-1', messageId: 'message-1'), + ); + + await tester.tap(find.text(joinUrl)); + await tester.pump(); + expect( + container.read(pendingDeepLinkProvider), + const InviteDeepLink( + relayUrl: 'wss://relay.example.com', + code: 'invite-1', + ), + ); + }); + + testWidgets('renders and routes autolinked Buzz thread links', ( + tester, + ) async { + const url = 'buzz://message?channel=channel-1&id=reply-1&thread=root-1'; + + await tester.pumpWidget( + _testable(const MessageContent(content: '<$url>')), + ); + + expect(find.text(url), findsOneWidget); + await tester.tap(find.text(url)); + await tester.pump(); + + final container = ProviderScope.containerOf( + tester.element(find.byType(MessageContent)), + ); + expect( + container.read(pendingDeepLinkProvider), + const MessageDeepLink( + channelId: 'channel-1', + messageId: 'reply-1', + threadRootId: 'root-1', + ), + ); + }); + + testWidgets('renders and routes bare Buzz join links', (tester) async { + const url = + 'buzz://join?relay=wss%3A%2F%2Frelay.example.com&code=invite-1'; + + await tester.pumpWidget( + _testable(const MessageContent(content: 'Join with $url')), + ); + + expect(find.text(url), findsOneWidget); + await tester.tap(find.text(url)); + await tester.pump(); + + final container = ProviderScope.containerOf( + tester.element(find.byType(MessageContent)), + ); + expect( + container.read(pendingDeepLinkProvider), + const InviteDeepLink( + relayUrl: 'wss://relay.example.com', + code: 'invite-1', + ), + ); + }); + + testWidgets('renders and routes bare Buzz channel links', (tester) async { + const url = 'buzz://channel/580ca78b-9dae-46f3-8854-bd671853ba32'; + + await tester.pumpWidget( + _testable(const MessageContent(content: 'See $url now')), + ); + + expect(find.text(url), findsOneWidget); + await tester.tap(find.text(url)); + await tester.pump(); + + final container = ProviderScope.containerOf( + tester.element(find.byType(MessageContent)), + ); + expect( + container.read(pendingDeepLinkProvider), + const ChannelDeepLink( + channelId: '580ca78b-9dae-46f3-8854-bd671853ba32', + ), + ); + }); + + testWidgets('renders and routes labeled Buzz channel links', ( + tester, + ) async { + const url = 'buzz://channel/580ca78b-9dae-46f3-8854-bd671853ba32'; + + await tester.pumpWidget( + _testable(const MessageContent(content: '[Open channel]($url)')), + ); + + await tester.tap(find.text('Open channel')); + await tester.pump(); + + final container = ProviderScope.containerOf( + tester.element(find.byType(MessageContent)), + ); + expect( + container.read(pendingDeepLinkProvider), + const ChannelDeepLink( + channelId: '580ca78b-9dae-46f3-8854-bd671853ba32', + ), + ); + }); + + testWidgets('routes rendered Buzz channel links through callback', ( + tester, + ) async { + const channelId = '580ca78b-9dae-46f3-8854-bd671853ba32'; + const url = 'buzz://channel/$channelId'; + String? tappedChannelId; + + await tester.pumpWidget( + _testable( + MessageContent( + content: '[Open channel]($url)', + onChannelTap: (id) => tappedChannelId = id, + ), + ), + ); + + await tester.tap(find.text('Open channel')); + await tester.pump(); + + expect(tappedChannelId, channelId); + final container = ProviderScope.containerOf( + tester.element(find.byType(MessageContent)), + ); + expect(container.read(pendingDeepLinkProvider), isNull); + }); + + testWidgets('renders and routes autolinked Buzz channel links', ( + tester, + ) async { + const url = 'buzz://channel/580ca78b-9dae-46f3-8854-bd671853ba32'; + + await tester.pumpWidget( + _testable(const MessageContent(content: '<$url>')), + ); + + await tester.tap(find.text(url)); + await tester.pump(); + + final container = ProviderScope.containerOf( + tester.element(find.byType(MessageContent)), + ); + expect( + container.read(pendingDeepLinkProvider), + const ChannelDeepLink( + channelId: '580ca78b-9dae-46f3-8854-bd671853ba32', + ), + ); + }); + + testWidgets('leaves malformed Buzz channel forms as plain text', ( + tester, + ) async { + const url = 'buzz://channel?channel=channel-1'; + + await tester.pumpWidget( + _testable(const MessageContent(content: 'See $url now')), + ); + + expect(find.text(url), findsNothing); + expect(_allRichText(tester), contains(url)); + final container = ProviderScope.containerOf( + tester.element(find.byType(MessageContent)), + ); + expect(container.read(pendingDeepLinkProvider), isNull); + }); + testWidgets('renders bare URL as link', (tester) async { await tester.pumpWidget( _testable( @@ -1506,6 +1818,48 @@ Photos expect(tappedId, 'ch-id-1'); }); + testWidgets('resolved #channel defaults to in-app navigation', ( + tester, + ) async { + final channels = Future.value([ + Channel( + id: '580ca78b-9dae-46f3-8854-bd671853ba32', + name: 'general', + channelType: 'stream', + visibility: 'open', + description: '', + createdBy: 'creator', + createdAt: DateTime(2026), + memberCount: 1, + isMember: true, + ), + ]); + await tester.pumpWidget( + _testable( + const MessageContent(content: 'See #general'), + overrides: [ + channelsProvider.overrideWith( + () => _TestChannelsNotifier(channels), + ), + ], + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.text('#general')); + await tester.pump(); + + final container = ProviderScope.containerOf( + tester.element(find.byType(MessageContent)), + ); + expect( + container.read(pendingDeepLinkProvider), + const ChannelDeepLink( + channelId: '580ca78b-9dae-46f3-8854-bd671853ba32', + ), + ); + }); + testWidgets('unknown channel renders without tap', (tester) async { await tester.pumpWidget( _testable( diff --git a/mobile/test/shared/deeplink/deep_link_test.dart b/mobile/test/shared/deeplink/deep_link_test.dart index 70e2066308..52b7032643 100644 --- a/mobile/test/shared/deeplink/deep_link_test.dart +++ b/mobile/test/shared/deeplink/deep_link_test.dart @@ -3,6 +3,7 @@ import 'package:flutter_test/flutter_test.dart'; void main() { _inviteTests(); + _channelTests(); _buildMessageLinkTests(); group('parseMessageDeepLink', () { @@ -65,6 +66,67 @@ void main() { }); } +void _channelTests() { + group('parseChannelDeepLink', () { + test('parses canonical channel path', () { + expect( + parseChannelDeepLink( + Uri.parse('buzz://channel/580ca78b-9dae-46f3-8854-bd671853ba32'), + ), + const ChannelDeepLink( + channelId: '580ca78b-9dae-46f3-8854-bd671853ba32', + ), + ); + }); + + test('accepts v7 and canonicalizes uppercase UUIDs', () { + expect( + parseChannelDeepLink( + Uri.parse('buzz://channel/018fdb5d-3a64-7c35-b5f9-4a23e1f9d2d9'), + ), + const ChannelDeepLink( + channelId: '018fdb5d-3a64-7c35-b5f9-4a23e1f9d2d9', + ), + ); + expect( + parseChannelDeepLink( + Uri.parse('buzz://channel/580CA78B-9DAE-46F3-8854-BD671853BA32'), + ), + const ChannelDeepLink( + channelId: '580ca78b-9dae-46f3-8854-bd671853ba32', + ), + ); + }); + + test('rejects missing, extra, query, and fragment forms', () { + for (final url in [ + 'buzz://channel', + 'buzz://channel/', + 'buzz://channel/one/two', + 'buzz://channel/one?extra=true', + 'buzz://channel/one#fragment', + 'https://channel/one', + 'buzz://channel/not-a-uuid', + 'buzz://channel/%2F', + 'buzz://channel/%00', + ]) { + expect(parseChannelDeepLink(Uri.parse(url)), isNull, reason: url); + } + }); + + test('is included in the top-level parser', () { + expect( + parseBuzzDeepLink( + Uri.parse('buzz://channel/580ca78b-9dae-46f3-8854-bd671853ba32'), + ), + const ChannelDeepLink( + channelId: '580ca78b-9dae-46f3-8854-bd671853ba32', + ), + ); + }); + }); +} + void _inviteTests() { group('parseInviteDeepLink', () { test('parses canonical HTTPS invite URL', () {