From 32ccae9fccf41b2282078785dd9917cf06c61d1b Mon Sep 17 00:00:00 2001 From: npub1em3jmyn4vu57urqf03txrwreccvejvwdy5c4er8nnrwt7rc4tncscs3ssu Date: Tue, 4 Aug 2026 06:50:46 -0400 Subject: [PATCH 01/16] fix(mobile): route rendered buzz message links Signed-off-by: npub1em3jmyn4vu57urqf03txrwreccvejvwdy5c4er8nnrwt7rc4tncscs3ssu --- .../features/channels/message_content.dart | 14 +++++++++- .../channels/message_content_test.dart | 27 +++++++++++++++++++ 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/mobile/lib/features/channels/message_content.dart b/mobile/lib/features/channels/message_content.dart index 8f4281aeb0..80a81a0c45 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'; @@ -345,9 +347,19 @@ 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; + + // `buzz://message` and `buzz://join` are app-owned links. Park the + // parsed target so the top-level dispatcher can route it once the + // authenticated/channel UI is ready; do not hand it to an OS browser, + // which is what made rendered mobile deep links inert. + if (uri.scheme == 'buzz') { + if (parseBuzzDeepLink(uri) != null) { + ref.read(pendingDeepLinkProvider.notifier).handleUri(uri); + } return; } + if (uri.scheme != 'http' && uri.scheme != 'https') return; final auth = ref.read(mediaGetAuthServiceProvider); if (!auth.isRelayMediaUrl(url)) { diff --git a/mobile/test/features/channels/message_content_test.dart b/mobile/test/features/channels/message_content_test.dart index 7c84a74715..b0ee0e4c9e 100644 --- a/mobile/test/features/channels/message_content_test.dart +++ b/mobile/test/features/channels/message_content_test.dart @@ -6,6 +6,8 @@ import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:nostr/nostr.dart' as nostr; 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'; @@ -412,6 +414,31 @@ 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 bare URL as link', (tester) async { await tester.pumpWidget( _testable( From 55f9d59e38fe692140e93bac1a4729d303beeb8e Mon Sep 17 00:00:00 2001 From: npub13n66s06epmqf2kc3v373ez8hj65cuzyvxzjf93vwpervxqn2u7jq2qd9je <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> Date: Tue, 4 Aug 2026 07:08:45 -0400 Subject: [PATCH 02/16] fix(mobile): cover rendered Buzz link forms Signed-off-by: npub13n66s06epmqf2kc3v373ez8hj65cuzyvxzjf93vwpervxqn2u7jq2qd9je <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> --- .../features/channels/message_content.dart | 17 ++-- .../deeplink/pending_deep_link_provider.dart | 5 +- .../channels/message_content_test.dart | 87 +++++++++++++++++++ 3 files changed, 100 insertions(+), 9 deletions(-) diff --git a/mobile/lib/features/channels/message_content.dart b/mobile/lib/features/channels/message_content.dart index 80a81a0c45..5312ad161e 100644 --- a/mobile/lib/features/channels/message_content.dart +++ b/mobile/lib/features/channels/message_content.dart @@ -201,16 +201,21 @@ class MessageContent extends HookConsumerWidget { // Inside backticks — preserve as-is. buffer.write('`${parts[i]}`'); } else { - // 1. Angle-bracket autolinks: + // 1. Angle-bracket autolinks: and supported Buzz + // links. gpt_markdown does not auto-link custom schemes. var segment = parts[i].replaceAllMapped( - RegExp(r'<(https?://[^>]+)>'), + RegExp(r'<((?:https?://|buzz://(?:message\?|join\?))[^>]+)>'), (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. + // Keep this in sync with the app-owned links accepted by + // parseBuzzDeepLink. Negative lookbehind avoids matching URLs + // preceded by ]( or =, which are already part of markdown links + // or imeta tags. segment = segment.replaceAllMapped( - RegExp(r'(?\]]+'), + RegExp( + r'(?\]]+', + ), (m) { final url = m[0]!; // Skip if this URL is already a markdown link label that equals @@ -355,7 +360,7 @@ class MessageContent extends HookConsumerWidget { // which is what made rendered mobile deep links inert. if (uri.scheme == 'buzz') { if (parseBuzzDeepLink(uri) != null) { - ref.read(pendingDeepLinkProvider.notifier).handleUri(uri); + ref.read(pendingDeepLinkProvider.notifier).open(uri); } return; } 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/message_content_test.dart b/mobile/test/features/channels/message_content_test.dart index b0ee0e4c9e..c246ad33b8 100644 --- a/mobile/test/features/channels/message_content_test.dart +++ b/mobile/test/features/channels/message_content_test.dart @@ -439,6 +439,93 @@ void main() { ); }); + 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('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('leaves unsupported Buzz 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( From 4370778e0e02d8fea304d8ae413981354f0ca3f1 Mon Sep 17 00:00:00 2001 From: npub13n66s06epmqf2kc3v373ez8hj65cuzyvxzjf93vwpervxqn2u7jq2qd9je <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> Date: Tue, 4 Aug 2026 08:50:21 -0400 Subject: [PATCH 03/16] fix: route channel references across clients Signed-off-by: npub13n66s06epmqf2kc3v373ez8hj65cuzyvxzjf93vwpervxqn2u7jq2qd9je <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> --- desktop/src-tauri/src/deep_link.rs | 198 +++++++++++++++++- desktop/src-tauri/src/lib.rs | 9 +- .../messages/lib/channelLink.test.mjs | 60 ++++++ .../src/features/messages/lib/channelLink.ts | 48 +++++ .../lib/remarkChannelDeepLinks.test.mjs | 35 ++++ .../messages/lib/remarkChannelDeepLinks.ts | 22 ++ desktop/src/shared/deep-link.ts | 66 +++++- desktop/src/shared/ui/markdown.test.mjs | 20 +- desktop/src/shared/ui/markdown.tsx | 68 +++--- .../shared/ui/markdown/ChannelDeepLink.tsx | 94 +++++++++ desktop/src/shared/ui/markdown/nodeCache.ts | 2 + desktop/src/shared/ui/markdown/utils.ts | 3 +- desktop/src/shared/useMessageDeepLinks.ts | 24 ++- desktop/src/testing/e2eBridge.ts | 34 +++ desktop/tests/e2e/navigation.spec.ts | 60 ++++++ desktop/tests/helpers/bridge.ts | 8 + .../channels/deep_link_dispatcher.dart | 23 +- .../features/channels/message_content.dart | 43 +++- mobile/lib/shared/deeplink/deep_link.dart | 45 +++- .../channels/deep_link_dispatcher_test.dart | 41 +++- .../channels/message_content_test.dart | 161 +++++++++++++- .../test/shared/deeplink/deep_link_test.dart | 62 ++++++ 22 files changed, 1042 insertions(+), 84 deletions(-) create mode 100644 desktop/src/features/messages/lib/channelLink.test.mjs create mode 100644 desktop/src/features/messages/lib/channelLink.ts create mode 100644 desktop/src/features/messages/lib/remarkChannelDeepLinks.test.mjs create mode 100644 desktop/src/features/messages/lib/remarkChannelDeepLinks.ts create mode 100644 desktop/src/shared/ui/markdown/ChannelDeepLink.tsx diff --git a/desktop/src-tauri/src/deep_link.rs b/desktop/src-tauri/src/deep_link.rs index ffe951dc36..bc1a3fd74f 100644 --- a/desktop/src-tauri/src/deep_link.rs +++ b/desktop/src-tauri/src/deep_link.rs @@ -20,6 +20,73 @@ 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 enqueue(&self, pending: PendingNavigationDeepLink) { + let mut queue = self + .0 + .lock() + .expect("pending navigation deep-link queue poisoned"); + 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 first(&self) -> Option { + self.0 + .lock() + .expect("pending navigation deep-link queue poisoned") + .front() + .cloned() + } + + fn acknowledge(&self, id: &str) -> bool { + let mut queue = self + .0 + .lock() + .expect("pending navigation deep-link queue poisoned"); + if queue.front().is_some_and(|item| item.id == id) { + queue.pop_front(); + true + } else { + false + } + } +} + +#[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 +155,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 +185,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 +444,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 +467,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 +493,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 +509,55 @@ 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_join_serializes_policy_receipt_for_cold_launch_recovery() { let mut link = pending("join", "wss://relay.example", Some("invite")); @@ -477,6 +631,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 d59936946f..6e810c9720 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, + 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, @@ -289,7 +290,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(); @@ -301,6 +301,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()) @@ -610,6 +611,8 @@ 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, start_builderlab_login, cancel_builderlab_login, get_builderlab_auth, 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.ts b/desktop/src/shared/deep-link.ts index c62a8bec3b..c1fd4d4be0 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; @@ -157,12 +167,58 @@ export async function listenForDeepLinks( * inside the router tree (e.g. AppShell) because the navigation callback * uses TanStack Router state. */ -export function listenForMessageDeepLinks( - onOpen: (payload: MessageDeepLinkPayload) => void, +export async function listenForNavigationDeepLinks( + onOpenChannel: (payload: ChannelDeepLinkPayload) => void, + onOpenMessage: (payload: MessageDeepLinkPayload) => void, ): 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; + while (true) { + const pending = await invoke( + "take_pending_navigation_deep_link", + ); + if (!pending) break; + if (pending.kind === "channel") { + onOpenChannel({ channelId: pending.channelId }); + } else if (pending.messageId) { + onOpenMessage({ + channelId: pending.channelId, + messageId: pending.messageId, + threadRootId: pending.threadRootId, + }); + } + const acknowledged = await invoke( + "acknowledge_pending_navigation_deep_link", + { id: pending.id }, + ); + if (!acknowledged) break; + } + } + } 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..977773c46c 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,22 @@ 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( + (payload) => { + if (cancelled) return; + void goChannel(payload.channelId); + }, + (payload) => { + if (cancelled) return; + void goChannel(payload.channelId, { + messageId: payload.messageId, + threadRootId: payload.threadRootId, + }); + }, + ); 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 e430549d43..73b47479cc 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -437,6 +437,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; @@ -4263,6 +4270,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) { @@ -9822,6 +9847,7 @@ export function maybeInstallE2eTauriMocks() { resetMockPersonaCatalogEvents(config); resetMockSaveSubscriptions(config); resetMockPendingCommunityDeepLinks(config); + resetMockPendingNavigationDeepLinks(config); initializeMockHuddle(config.mock?.huddle, config); mockWebsocketSendMutexWedged = false; if (config.mock?.windowLabel) { @@ -11479,6 +11505,14 @@ export function maybeInstallE2eTauriMocks() { mockPendingCommunityDeepLinks.splice(index, 1); return true; } + 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/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 d9e2c68e9d..d3f4b03c98 100644 --- a/desktop/tests/helpers/bridge.ts +++ b/desktop/tests/helpers/bridge.ts @@ -438,6 +438,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 5312ad161e..e32e05ebac 100644 --- a/mobile/lib/features/channels/message_content.dart +++ b/mobile/lib/features/channels/message_content.dart @@ -25,6 +25,7 @@ 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_media.dart'; @@ -154,6 +155,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) @@ -204,7 +225,9 @@ class MessageContent extends HookConsumerWidget { // 1. Angle-bracket autolinks: and supported Buzz // links. gpt_markdown does not auto-link custom schemes. var segment = parts[i].replaceAllMapped( - RegExp(r'<((?:https?://|buzz://(?:message\?|join\?))[^>]+)>'), + RegExp( + r'<((?:https?://|buzz://(?:message\?|join\?|channel/))[^>]+)>', + ), (m) => '[${m[1]}](${m[1]})', ); // 2. Bare URLs not already inside markdown link/image syntax. @@ -214,15 +237,17 @@ class MessageContent extends HookConsumerWidget { // or imeta tags. segment = segment.replaceAllMapped( RegExp( - r'(?\]]+', + r'(?\]]+', ), (m) { - final url = m[0]!; + final matched = m[0]!; + final url = matched.replaceFirst(RegExp(r'[.,!?:;]+$'), ''); + final trailingPunctuation = matched.substring(url.length); // 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)'; + if (start >= 1 && segment[start - 1] == '[') return matched; + return '[$url]($url)$trailingPunctuation'; }, ); buffer.write(segment); @@ -265,7 +290,9 @@ class MessageContent extends HookConsumerWidget { }, [markdownContent, resolvedMentionNames]); final markdown = KeyedSubtree( - key: ValueKey('$finalContent\u0000$mentionPresentationKey'), + key: ValueKey( + '$finalContent\u0000$mentionPresentationKey\u0000$channelPresentationKey', + ), child: GptMarkdown( finalContent, style: style, @@ -285,8 +312,8 @@ class MessageContent extends HookConsumerWidget { ), CustomEmojiMd(customEmoji, size: inlineCustomEmojiSize), _ChannelLinkMd( - channelNames: channelNames, - onChannelTap: onChannelTap, + channelNames: resolvedChannelNames, + onChannelTap: resolvedChannelTap, ), ...MarkdownComponent.inlineComponents, ], 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/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_test.dart b/mobile/test/features/channels/message_content_test.dart index c246ad33b8..914b7aeabd 100644 --- a/mobile/test/features/channels/message_content_test.dart +++ b/mobile/test/features/channels/message_content_test.dart @@ -4,6 +4,8 @@ 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'; @@ -155,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', ( @@ -459,6 +470,44 @@ void main() { ); }); + 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 { @@ -509,7 +558,75 @@ void main() { ); }); - testWidgets('leaves unsupported Buzz forms as plain text', ( + 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('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'; @@ -1620,6 +1737,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', () { From 6dc93f83ed9d375f657a9b3ebc2f1da2075c0c64 Mon Sep 17 00:00:00 2001 From: npub13n66s06epmqf2kc3v373ez8hj65cuzyvxzjf93vwpervxqn2u7jq2qd9je <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> Date: Tue, 4 Aug 2026 10:14:18 -0400 Subject: [PATCH 04/16] fix(desktop): preserve queued links across teardown Only acknowledge pending navigation after the active listener accepts it, leaving raced FIFO items for the next mount. Signed-off-by: npub13n66s06epmqf2kc3v373ez8hj65cuzyvxzjf93vwpervxqn2u7jq2qd9je <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> --- desktop/src/shared/deep-link.test.mjs | 125 ++++++++++++++++++++++ desktop/src/shared/deep-link.ts | 59 +++++----- desktop/src/shared/useMessageDeepLinks.ts | 6 +- 3 files changed, 163 insertions(+), 27 deletions(-) create mode 100644 desktop/src/shared/deep-link.test.mjs diff --git a/desktop/src/shared/deep-link.test.mjs b/desktop/src/shared/deep-link.test.mjs new file mode 100644 index 0000000000..e2eff466ba --- /dev/null +++ b/desktop/src/shared/deep-link.test.mjs @@ -0,0 +1,125 @@ +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 } = 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); +}); diff --git a/desktop/src/shared/deep-link.ts b/desktop/src/shared/deep-link.ts index c1fd4d4be0..5901e47c1b 100644 --- a/desktop/src/shared/deep-link.ts +++ b/desktop/src/shared/deep-link.ts @@ -162,14 +162,42 @@ export async function listenForDeepLinks( }; } +async function drainPendingNavigationDeepLinks( + onOpenChannel: (payload: ChannelDeepLinkPayload) => boolean, + onOpenMessage: (payload: MessageDeepLinkPayload) => boolean, +) { + while (true) { + const pending = await invoke( + "take_pending_navigation_deep_link", + ); + if (!pending) return; + const accepted = + pending.kind === "channel" + ? onOpenChannel({ channelId: pending.channelId }) + : pending.messageId + ? onOpenMessage({ + channelId: pending.channelId, + messageId: pending.messageId, + threadRootId: pending.threadRootId, + }) + : false; + if (!accepted) 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 async function listenForNavigationDeepLinks( - onOpenChannel: (payload: ChannelDeepLinkPayload) => void, - onOpenMessage: (payload: MessageDeepLinkPayload) => void, + onOpenChannel: (payload: ChannelDeepLinkPayload) => boolean, + onOpenMessage: (payload: MessageDeepLinkPayload) => boolean, ): Promise { let drainRunning = false; let drainRequested = false; @@ -181,26 +209,7 @@ export async function listenForNavigationDeepLinks( try { while (drainRequested) { drainRequested = false; - while (true) { - const pending = await invoke( - "take_pending_navigation_deep_link", - ); - if (!pending) break; - if (pending.kind === "channel") { - onOpenChannel({ channelId: pending.channelId }); - } else if (pending.messageId) { - onOpenMessage({ - channelId: pending.channelId, - messageId: pending.messageId, - threadRootId: pending.threadRootId, - }); - } - const acknowledged = await invoke( - "acknowledge_pending_navigation_deep_link", - { id: pending.id }, - ); - if (!acknowledged) break; - } + await drainPendingNavigationDeepLinks(onOpenChannel, onOpenMessage); } } catch (error: unknown) { console.warn("Failed to drain pending navigation deep links", error); diff --git a/desktop/src/shared/useMessageDeepLinks.ts b/desktop/src/shared/useMessageDeepLinks.ts index 977773c46c..f9c54795ee 100644 --- a/desktop/src/shared/useMessageDeepLinks.ts +++ b/desktop/src/shared/useMessageDeepLinks.ts @@ -26,15 +26,17 @@ export function useMessageDeepLinks(enabled = true) { let cancelled = false; const unlistenPromise = listenForNavigationDeepLinks( (payload) => { - if (cancelled) return; + if (cancelled) return false; void goChannel(payload.channelId); + return true; }, (payload) => { - if (cancelled) return; + if (cancelled) return false; void goChannel(payload.channelId, { messageId: payload.messageId, threadRootId: payload.threadRootId, }); + return true; }, ); return () => { From dd42d4024da6b0f3cbdee476f9551df79b173a2d Mon Sep 17 00:00:00 2001 From: npub13n66s06epmqf2kc3v373ez8hj65cuzyvxzjf93vwpervxqn2u7jq2qd9je <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> Date: Tue, 4 Aug 2026 10:24:33 -0400 Subject: [PATCH 05/16] chore: retrigger pull request checks Signed-off-by: npub13n66s06epmqf2kc3v373ez8hj65cuzyvxzjf93vwpervxqn2u7jq2qd9je <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> From 173dacd875af85000fec9ea84141c7e6e6714349 Mon Sep 17 00:00:00 2001 From: npub13n66s06epmqf2kc3v373ez8hj65cuzyvxzjf93vwpervxqn2u7jq2qd9je <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> Date: Tue, 4 Aug 2026 11:02:02 -0400 Subject: [PATCH 06/16] fix(desktop): acknowledge links after navigation Recover poisoned navigation queues and keep failed routes pending until a later listener can retry them. Signed-off-by: npub13n66s06epmqf2kc3v373ez8hj65cuzyvxzjf93vwpervxqn2u7jq2qd9je <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> --- desktop/src-tauri/src/deep_link.rs | 46 ++++++++++++++++------- desktop/src/shared/deep-link.test.mjs | 39 +++++++++++++++++++ desktop/src/shared/deep-link.ts | 35 ++++++++++------- desktop/src/shared/useMessageDeepLinks.ts | 8 ++-- 4 files changed, 97 insertions(+), 31 deletions(-) diff --git a/desktop/src-tauri/src/deep_link.rs b/desktop/src-tauri/src/deep_link.rs index bc1a3fd74f..8f0f2ea330 100644 --- a/desktop/src-tauri/src/deep_link.rs +++ b/desktop/src-tauri/src/deep_link.rs @@ -34,11 +34,15 @@ pub(crate) struct PendingNavigationDeepLink { 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 - .0 - .lock() - .expect("pending navigation deep-link queue poisoned"); + let mut queue = self.lock(); if queue.iter().any(|item| { item.kind == pending.kind && item.channel_id == pending.channel_id @@ -51,18 +55,11 @@ impl PendingNavigationDeepLinks { } fn first(&self) -> Option { - self.0 - .lock() - .expect("pending navigation deep-link queue poisoned") - .front() - .cloned() + self.lock().front().cloned() } fn acknowledge(&self, id: &str) -> bool { - let mut queue = self - .0 - .lock() - .expect("pending navigation deep-link queue poisoned"); + let mut queue = self.lock(); if queue.front().is_some_and(|item| item.id == id) { queue.pop_front(); true @@ -558,6 +555,29 @@ mod tests { 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")); diff --git a/desktop/src/shared/deep-link.test.mjs b/desktop/src/shared/deep-link.test.mjs index e2eff466ba..6845cd52a2 100644 --- a/desktop/src/shared/deep-link.test.mjs +++ b/desktop/src/shared/deep-link.test.mjs @@ -123,3 +123,42 @@ test("listener teardown leaves an unaccepted FIFO item for the next mount", asyn secondUnlisten(); assert.equal(unlistenCount, 4); }); + +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 5901e47c1b..941f371e50 100644 --- a/desktop/src/shared/deep-link.ts +++ b/desktop/src/shared/deep-link.ts @@ -163,24 +163,27 @@ export async function listenForDeepLinks( } async function drainPendingNavigationDeepLinks( - onOpenChannel: (payload: ChannelDeepLinkPayload) => boolean, - onOpenMessage: (payload: MessageDeepLinkPayload) => boolean, + onOpenChannel: ( + payload: ChannelDeepLinkPayload, + ) => boolean | Promise, + onOpenMessage: ( + payload: MessageDeepLinkPayload, + ) => boolean | Promise, ) { while (true) { const pending = await invoke( "take_pending_navigation_deep_link", ); if (!pending) return; - const accepted = - pending.kind === "channel" - ? onOpenChannel({ channelId: pending.channelId }) - : pending.messageId - ? onOpenMessage({ - channelId: pending.channelId, - messageId: pending.messageId, - threadRootId: pending.threadRootId, - }) - : false; + 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) return; const acknowledged = await invoke( "acknowledge_pending_navigation_deep_link", @@ -196,8 +199,12 @@ async function drainPendingNavigationDeepLinks( * effect teardown leaves an in-flight queue head available for the next mount. */ export async function listenForNavigationDeepLinks( - onOpenChannel: (payload: ChannelDeepLinkPayload) => boolean, - onOpenMessage: (payload: MessageDeepLinkPayload) => boolean, + onOpenChannel: ( + payload: ChannelDeepLinkPayload, + ) => boolean | Promise, + onOpenMessage: ( + payload: MessageDeepLinkPayload, + ) => boolean | Promise, ): Promise { let drainRunning = false; let drainRequested = false; diff --git a/desktop/src/shared/useMessageDeepLinks.ts b/desktop/src/shared/useMessageDeepLinks.ts index f9c54795ee..fbbe4b9f67 100644 --- a/desktop/src/shared/useMessageDeepLinks.ts +++ b/desktop/src/shared/useMessageDeepLinks.ts @@ -25,14 +25,14 @@ export function useMessageDeepLinks(enabled = true) { let cancelled = false; const unlistenPromise = listenForNavigationDeepLinks( - (payload) => { + async (payload) => { if (cancelled) return false; - void goChannel(payload.channelId); + await goChannel(payload.channelId); return true; }, - (payload) => { + async (payload) => { if (cancelled) return false; - void goChannel(payload.channelId, { + await goChannel(payload.channelId, { messageId: payload.messageId, threadRootId: payload.threadRootId, }); From 97045efe2c8a19134aa9e8ea90bcbd3b5863627e Mon Sep 17 00:00:00 2001 From: npub13n66s06epmqf2kc3v373ez8hj65cuzyvxzjf93vwpervxqn2u7jq2qd9je <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> Date: Tue, 4 Aug 2026 11:29:10 -0400 Subject: [PATCH 07/16] Serialize desktop deep-link drains across remounts Signed-off-by: npub13n66s06epmqf2kc3v373ez8hj65cuzyvxzjf93vwpervxqn2u7jq2qd9je <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> --- desktop/src/shared/deep-link.test.mjs | 62 +++++++++++++++++++++++++++ desktop/src/shared/deep-link.ts | 14 +++++- 2 files changed, 75 insertions(+), 1 deletion(-) diff --git a/desktop/src/shared/deep-link.test.mjs b/desktop/src/shared/deep-link.test.mjs index 6845cd52a2..c137df308e 100644 --- a/desktop/src/shared/deep-link.test.mjs +++ b/desktop/src/shared/deep-link.test.mjs @@ -124,6 +124,68 @@ test("listener teardown leaves an unaccepted FIFO item for the next mount", asyn 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("rejected navigation remains queued and is not acknowledged", async () => { const pending = { id: "retry-me", diff --git a/desktop/src/shared/deep-link.ts b/desktop/src/shared/deep-link.ts index 941f371e50..7416dabb89 100644 --- a/desktop/src/shared/deep-link.ts +++ b/desktop/src/shared/deep-link.ts @@ -162,6 +162,16 @@ export async function listenForDeepLinks( }; } +let navigationDrainTail: Promise = Promise.resolve(); + +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, @@ -216,7 +226,9 @@ export async function listenForNavigationDeepLinks( try { while (drainRequested) { drainRequested = false; - await drainPendingNavigationDeepLinks(onOpenChannel, onOpenMessage); + await serializeNavigationDrain(() => + drainPendingNavigationDeepLinks(onOpenChannel, onOpenMessage), + ); } } catch (error: unknown) { console.warn("Failed to drain pending navigation deep links", error); From 31ca79f5fe6cb9aa24357642c4ac33c6c5575452 Mon Sep 17 00:00:00 2001 From: npub13n66s06epmqf2kc3v373ez8hj65cuzyvxzjf93vwpervxqn2u7jq2qd9je <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> Date: Tue, 4 Aug 2026 14:18:35 -0400 Subject: [PATCH 08/16] fix(mobile): honor channel link callbacks Signed-off-by: npub13n66s06epmqf2kc3v373ez8hj65cuzyvxzjf93vwpervxqn2u7jq2qd9je <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> --- .../features/channels/message_content.dart | 25 +++++++++++++----- .../channels/message_content_test.dart | 26 +++++++++++++++++++ 2 files changed, 44 insertions(+), 7 deletions(-) diff --git a/mobile/lib/features/channels/message_content.dart b/mobile/lib/features/channels/message_content.dart index e32e05ebac..e1477fab46 100644 --- a/mobile/lib/features/channels/message_content.dart +++ b/mobile/lib/features/channels/message_content.dart @@ -299,8 +299,15 @@ class MessageContent extends HookConsumerWidget { 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, @@ -365,6 +372,7 @@ class MessageContent extends HookConsumerWidget { String url, TextStyle linkStyle, TextStyle? fallbackStyle, + void Function(String channelId) resolvedChannelTap, ) { String text = ''; linkText.visitChildren((span) { @@ -381,12 +389,15 @@ class MessageContent extends HookConsumerWidget { final uri = Uri.tryParse(url); if (uri == null) return; - // `buzz://message` and `buzz://join` are app-owned links. Park the - // parsed target so the top-level dispatcher can route it once the - // authenticated/channel UI is ready; do not hand it to an OS browser, - // which is what made rendered mobile deep links inert. + // 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') { - if (parseBuzzDeepLink(uri) != null) { + final deepLink = parseBuzzDeepLink(uri); + if (deepLink case ChannelDeepLink(:final channelId)) { + resolvedChannelTap(channelId); + } else if (deepLink != null) { ref.read(pendingDeepLinkProvider.notifier).open(uri); } return; diff --git a/mobile/test/features/channels/message_content_test.dart b/mobile/test/features/channels/message_content_test.dart index 914b7aeabd..10e06ff834 100644 --- a/mobile/test/features/channels/message_content_test.dart +++ b/mobile/test/features/channels/message_content_test.dart @@ -603,6 +603,32 @@ void main() { ); }); + 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 { From 2e81f489ee463d850c6f3fdc071f79e2def9e904 Mon Sep 17 00:00:00 2001 From: npub13n66s06epmqf2kc3v373ez8hj65cuzyvxzjf93vwpervxqn2u7jq2qd9je <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> Date: Tue, 4 Aug 2026 14:45:29 -0400 Subject: [PATCH 09/16] fix(mobile): preserve emphasis around bare links Signed-off-by: npub13n66s06epmqf2kc3v373ez8hj65cuzyvxzjf93vwpervxqn2u7jq2qd9je <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> --- .../features/channels/message_content.dart | 40 +++++++++++++++++-- .../channels/message_content_test.dart | 22 ++++++++++ 2 files changed, 58 insertions(+), 4 deletions(-) diff --git a/mobile/lib/features/channels/message_content.dart b/mobile/lib/features/channels/message_content.dart index e1477fab46..309e691674 100644 --- a/mobile/lib/features/channels/message_content.dart +++ b/mobile/lib/features/channels/message_content.dart @@ -241,13 +241,45 @@ class MessageContent extends HookConsumerWidget { ), (m) { final matched = m[0]!; - final url = matched.replaceFirst(RegExp(r'[.,!?:;]+$'), ''); - final trailingPunctuation = matched.substring(url.length); + var url = matched; + var trailing = ''; + final start = m.start; + + // A bare URL inside emphasis initially includes the closing + // Markdown delimiter. Peel only a delimiter whose matching + // opener immediately precedes this URL, preserving legitimate + // URL characters in ordinary prose. Sentence punctuation can + // appear on either side of that closing delimiter. + final outsidePunctuation = RegExp(r'[.,!?:;]+$').firstMatch(url); + if (outsidePunctuation != null) { + url = url.substring(0, outsidePunctuation.start); + trailing = outsidePunctuation[0]!; + } + for (final delimiter in const [ + '***', + '___', + '**', + '__', + '~~', + '*', + '_', + ]) { + if (segment.substring(0, start).endsWith(delimiter) && + url.endsWith(delimiter)) { + url = url.substring(0, url.length - delimiter.length); + trailing = '$delimiter$trailing'; + break; + } + } + final punctuation = RegExp(r'[.,!?:;]+$').firstMatch(url); + if (punctuation != null) { + url = url.substring(0, punctuation.start); + trailing = '${punctuation[0]}$trailing'; + } // 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 matched; - return '[$url]($url)$trailingPunctuation'; + return '[$url]($url)$trailing'; }, ); buffer.write(segment); diff --git a/mobile/test/features/channels/message_content_test.dart b/mobile/test/features/channels/message_content_test.dart index 10e06ff834..eafe287b6f 100644 --- a/mobile/test/features/channels/message_content_test.dart +++ b/mobile/test/features/channels/message_content_test.dart @@ -470,6 +470,28 @@ void main() { ); }); + 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('excludes sentence punctuation from bare Buzz links', ( tester, ) async { From 7415b8f9be0debb1dade108021baf992e7cd8397 Mon Sep 17 00:00:00 2001 From: npub13n66s06epmqf2kc3v373ez8hj65cuzyvxzjf93vwpervxqn2u7jq2qd9je <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> Date: Tue, 4 Aug 2026 14:51:29 -0400 Subject: [PATCH 10/16] fix(mobile): satisfy file size ratchet Signed-off-by: npub13n66s06epmqf2kc3v373ez8hj65cuzyvxzjf93vwpervxqn2u7jq2qd9je <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> --- mobile/lib/features/channels/message_content.dart | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/mobile/lib/features/channels/message_content.dart b/mobile/lib/features/channels/message_content.dart index 309e691674..914ce3e246 100644 --- a/mobile/lib/features/channels/message_content.dart +++ b/mobile/lib/features/channels/message_content.dart @@ -222,8 +222,6 @@ class MessageContent extends HookConsumerWidget { // Inside backticks — preserve as-is. buffer.write('`${parts[i]}`'); } else { - // 1. Angle-bracket autolinks: and supported Buzz - // links. gpt_markdown does not auto-link custom schemes. var segment = parts[i].replaceAllMapped( RegExp( r'<((?:https?://|buzz://(?:message\?|join\?|channel/))[^>]+)>', @@ -245,11 +243,8 @@ class MessageContent extends HookConsumerWidget { var trailing = ''; final start = m.start; - // A bare URL inside emphasis initially includes the closing - // Markdown delimiter. Peel only a delimiter whose matching - // opener immediately precedes this URL, preserving legitimate - // URL characters in ordinary prose. Sentence punctuation can - // appear on either side of that closing delimiter. + // Keep balanced Markdown delimiters outside the generated link + // without excluding legitimate URL characters globally. final outsidePunctuation = RegExp(r'[.,!?:;]+$').firstMatch(url); if (outsidePunctuation != null) { url = url.substring(0, outsidePunctuation.start); From 74fe972fdcffd8ecc6fa4d65aeb8a8c98ba5e880 Mon Sep 17 00:00:00 2001 From: npub13n66s06epmqf2kc3v373ez8hj65cuzyvxzjf93vwpervxqn2u7jq2qd9je <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> Date: Tue, 4 Aug 2026 14:52:05 -0400 Subject: [PATCH 11/16] fix(mobile): meet message content size limit Signed-off-by: npub13n66s06epmqf2kc3v373ez8hj65cuzyvxzjf93vwpervxqn2u7jq2qd9je <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> --- mobile/lib/features/channels/message_content.dart | 2 -- 1 file changed, 2 deletions(-) diff --git a/mobile/lib/features/channels/message_content.dart b/mobile/lib/features/channels/message_content.dart index 914ce3e246..2c300994e3 100644 --- a/mobile/lib/features/channels/message_content.dart +++ b/mobile/lib/features/channels/message_content.dart @@ -243,8 +243,6 @@ class MessageContent extends HookConsumerWidget { var trailing = ''; final start = m.start; - // Keep balanced Markdown delimiters outside the generated link - // without excluding legitimate URL characters globally. final outsidePunctuation = RegExp(r'[.,!?:;]+$').firstMatch(url); if (outsidePunctuation != null) { url = url.substring(0, outsidePunctuation.start); From d2c207eef29a3edc5df1d9f9cbe7adaf3fc053c7 Mon Sep 17 00:00:00 2001 From: npub13n66s06epmqf2kc3v373ez8hj65cuzyvxzjf93vwpervxqn2u7jq2qd9je <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> Date: Tue, 4 Aug 2026 14:56:35 -0400 Subject: [PATCH 12/16] fix(mobile): pass message content ratchet Signed-off-by: npub13n66s06epmqf2kc3v373ez8hj65cuzyvxzjf93vwpervxqn2u7jq2qd9je <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> --- mobile/lib/features/channels/message_content.dart | 5 ----- 1 file changed, 5 deletions(-) diff --git a/mobile/lib/features/channels/message_content.dart b/mobile/lib/features/channels/message_content.dart index 2c300994e3..5a8127b36a 100644 --- a/mobile/lib/features/channels/message_content.dart +++ b/mobile/lib/features/channels/message_content.dart @@ -228,11 +228,6 @@ class MessageContent extends HookConsumerWidget { ), (m) => '[${m[1]}](${m[1]})', ); - // 2. Bare URLs not already inside markdown link/image syntax. - // Keep this in sync with the app-owned links accepted by - // parseBuzzDeepLink. Negative lookbehind avoids matching URLs - // preceded by ]( or =, which are already part of markdown links - // or imeta tags. segment = segment.replaceAllMapped( RegExp( r'(?\]]+', From 363bc80f94f7491b58eff664397c18f30ee06dcc Mon Sep 17 00:00:00 2001 From: npub13n66s06epmqf2kc3v373ez8hj65cuzyvxzjf93vwpervxqn2u7jq2qd9je <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> Date: Tue, 4 Aug 2026 15:16:06 -0400 Subject: [PATCH 13/16] fix(desktop): reset deep-link drains on community switch Signed-off-by: npub13n66s06epmqf2kc3v373ez8hj65cuzyvxzjf93vwpervxqn2u7jq2qd9je <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> --- .../features/communities/useCommunityInit.ts | 2 + desktop/src/shared/deep-link.test.mjs | 39 ++++++++++++++++++- desktop/src/shared/deep-link.ts | 12 ++++-- 3 files changed, 49 insertions(+), 4 deletions(-) diff --git a/desktop/src/features/communities/useCommunityInit.ts b/desktop/src/features/communities/useCommunityInit.ts index 1bd1e090a7..fbac533809 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, @@ -52,6 +53,7 @@ function resetCommunityState({ resetAvatarState: boolean; }): void { relayClient.disconnect(); + resetNavigationDeepLinkDrain(); resetRateLimitGate(); clearAllDrafts(); resetAgentObserverStore(); diff --git a/desktop/src/shared/deep-link.test.mjs b/desktop/src/shared/deep-link.test.mjs index c137df308e..37a5de57fb 100644 --- a/desktop/src/shared/deep-link.test.mjs +++ b/desktop/src/shared/deep-link.test.mjs @@ -23,7 +23,8 @@ globalThis.window = { }; globalThis.__TAURI_INTERNALS__ = tauriInternals; -const { listenForNavigationDeepLinks } = await import("@/shared/deep-link.ts"); +const { listenForNavigationDeepLinks, resetNavigationDeepLinkDrain } = + await import("@/shared/deep-link.ts"); function deferred() { let resolve; @@ -186,6 +187,42 @@ test("concurrent listener remount does not take or acknowledge the in-flight hea 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; + + 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; + }); + + const unlisten = await listenForNavigationDeepLinks( + async () => { + await routeGate.promise; + return true; + }, + () => true, + ); + await settle(); + + resetNavigationDeepLinkDrain(); + routeGate.resolve(); + await settle(); + + assert.equal(acknowledgeCount, 0); + unlisten(); +}); + test("rejected navigation remains queued and is not acknowledged", async () => { const pending = { id: "retry-me", diff --git a/desktop/src/shared/deep-link.ts b/desktop/src/shared/deep-link.ts index 7416dabb89..3916fd698a 100644 --- a/desktop/src/shared/deep-link.ts +++ b/desktop/src/shared/deep-link.ts @@ -163,6 +163,11 @@ export async function listenForDeepLinks( } let navigationDrainTail: Promise = Promise.resolve(); +let navigationDrainGeneration = 0; + +export function resetNavigationDeepLinkDrain(): void { + navigationDrainGeneration += 1; +} function serializeNavigationDrain(task: () => Promise): Promise { const drain = navigationDrainTail.then(task, task); @@ -180,11 +185,12 @@ async function drainPendingNavigationDeepLinks( payload: MessageDeepLinkPayload, ) => boolean | Promise, ) { - while (true) { + const generation = navigationDrainGeneration; + while (generation === navigationDrainGeneration) { const pending = await invoke( "take_pending_navigation_deep_link", ); - if (!pending) return; + if (!pending || generation !== navigationDrainGeneration) return; const accepted = await (pending.kind === "channel" ? onOpenChannel({ channelId: pending.channelId }) : pending.messageId @@ -194,7 +200,7 @@ async function drainPendingNavigationDeepLinks( threadRootId: pending.threadRootId, }) : false); - if (!accepted) return; + if (!accepted || generation !== navigationDrainGeneration) return; const acknowledged = await invoke( "acknowledge_pending_navigation_deep_link", { id: pending.id }, From 97dfaace980096a99cdc69d72fc277bbc513fd61 Mon Sep 17 00:00:00 2001 From: npub13n66s06epmqf2kc3v373ez8hj65cuzyvxzjf93vwpervxqn2u7jq2qd9je <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> Date: Tue, 4 Aug 2026 15:38:50 -0400 Subject: [PATCH 14/16] fix(desktop): clear deep links on community switch Signed-off-by: npub13n66s06epmqf2kc3v373ez8hj65cuzyvxzjf93vwpervxqn2u7jq2qd9je <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> --- desktop/src-tauri/src/deep_link.rs | 11 ++++++++++- desktop/src-tauri/src/lib.rs | 5 +++-- desktop/src/features/communities/useCommunityInit.ts | 8 ++++---- desktop/src/shared/deep-link.test.mjs | 7 ++++++- desktop/src/shared/deep-link.ts | 3 ++- desktop/src/testing/e2eBridge.ts | 3 +++ 6 files changed, 28 insertions(+), 9 deletions(-) diff --git a/desktop/src-tauri/src/deep_link.rs b/desktop/src-tauri/src/deep_link.rs index 8f0f2ea330..f8e079c0bb 100644 --- a/desktop/src-tauri/src/deep_link.rs +++ b/desktop/src-tauri/src/deep_link.rs @@ -54,6 +54,10 @@ impl PendingNavigationDeepLinks { queue.push_back(pending); } + fn clear(&self) { + self.lock().clear(); + } + fn first(&self) -> Option { self.lock().front().cloned() } @@ -69,6 +73,11 @@ impl PendingNavigationDeepLinks { } } +#[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>, @@ -551,7 +560,7 @@ mod tests { assert!(!queue.acknowledge("second")); assert!(queue.acknowledge("first")); assert_eq!(queue.first().unwrap().id, "second"); - assert!(queue.acknowledge("second")); + queue.clear(); assert!(queue.first().is_none()); } diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 6e810c9720..780bab8b53 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -47,8 +47,8 @@ use builderlab::*; use commands::*; use deep_link::{ acknowledge_pending_community_deep_link, acknowledge_pending_navigation_deep_link, - handle_deep_link_url, take_pending_community_deep_link, take_pending_navigation_deep_link, - PendingCommunityDeepLinks, PendingNavigationDeepLinks, + 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, @@ -613,6 +613,7 @@ pub fn run() { 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 fbac533809..0706fb600c 100644 --- a/desktop/src/features/communities/useCommunityInit.ts +++ b/desktop/src/features/communities/useCommunityInit.ts @@ -47,13 +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(); - resetNavigationDeepLinkDrain(); + await resetNavigationDeepLinkDrain(); resetRateLimitGate(); clearAllDrafts(); resetAgentObserverStore(); @@ -196,7 +196,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/shared/deep-link.test.mjs b/desktop/src/shared/deep-link.test.mjs index 37a5de57fb..3f6fa12645 100644 --- a/desktop/src/shared/deep-link.test.mjs +++ b/desktop/src/shared/deep-link.test.mjs @@ -197,9 +197,13 @@ test("community reset prevents an in-flight route from acknowledging", async () }; 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; @@ -215,10 +219,11 @@ test("community reset prevents an in-flight route from acknowledging", async () ); await settle(); - resetNavigationDeepLinkDrain(); + await resetNavigationDeepLinkDrain(); routeGate.resolve(); await settle(); + assert.equal(clearCount, 1); assert.equal(acknowledgeCount, 0); unlisten(); }); diff --git a/desktop/src/shared/deep-link.ts b/desktop/src/shared/deep-link.ts index 3916fd698a..5b991f9363 100644 --- a/desktop/src/shared/deep-link.ts +++ b/desktop/src/shared/deep-link.ts @@ -165,8 +165,9 @@ export async function listenForDeepLinks( let navigationDrainTail: Promise = Promise.resolve(); let navigationDrainGeneration = 0; -export function resetNavigationDeepLinkDrain(): void { +export async function resetNavigationDeepLinkDrain(): Promise { navigationDrainGeneration += 1; + await invoke("clear_pending_navigation_deep_links"); } function serializeNavigationDrain(task: () => Promise): Promise { diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 9ffd93f506..8a1424b746 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -11536,6 +11536,9 @@ 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": { From e51441a020ad353286082e91ac5418f82677226d Mon Sep 17 00:00:00 2001 From: npub13n66s06epmqf2kc3v373ez8hj65cuzyvxzjf93vwpervxqn2u7jq2qd9je <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> Date: Tue, 4 Aug 2026 16:32:43 -0400 Subject: [PATCH 15/16] fix: address deep-link review feedback Signed-off-by: npub13n66s06epmqf2kc3v373ez8hj65cuzyvxzjf93vwpervxqn2u7jq2qd9je <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> --- desktop/src-tauri/src/deep_link.rs | 22 ++++ .../features/communities/useCommunityInit.ts | 6 + desktop/src/shared/deep-link.test.mjs | 107 ++++++++++++++++++ desktop/src/shared/deep-link.ts | 9 +- desktop/tests/e2e/community-rail.spec.ts | 10 ++ .../features/channels/message_content.dart | 55 ++++++--- .../channels/message_content_test.dart | 33 ++++++ 7 files changed, 227 insertions(+), 15 deletions(-) diff --git a/desktop/src-tauri/src/deep_link.rs b/desktop/src-tauri/src/deep_link.rs index f8e079c0bb..098b3f1e79 100644 --- a/desktop/src-tauri/src/deep_link.rs +++ b/desktop/src-tauri/src/deep_link.rs @@ -560,6 +560,28 @@ mod tests { 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()); } diff --git a/desktop/src/features/communities/useCommunityInit.ts b/desktop/src/features/communities/useCommunityInit.ts index 0706fb600c..3248fd0fe8 100644 --- a/desktop/src/features/communities/useCommunityInit.ts +++ b/desktop/src/features/communities/useCommunityInit.ts @@ -122,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 = diff --git a/desktop/src/shared/deep-link.test.mjs b/desktop/src/shared/deep-link.test.mjs index 3f6fa12645..80be3ef55e 100644 --- a/desktop/src/shared/deep-link.test.mjs +++ b/desktop/src/shared/deep-link.test.mjs @@ -228,6 +228,113 @@ test("community reset prevents an in-flight route from acknowledging", async () 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", diff --git a/desktop/src/shared/deep-link.ts b/desktop/src/shared/deep-link.ts index 5b991f9363..f2962c995f 100644 --- a/desktop/src/shared/deep-link.ts +++ b/desktop/src/shared/deep-link.ts @@ -167,7 +167,14 @@ let navigationDrainGeneration = 0; export async function resetNavigationDeepLinkDrain(): Promise { navigationDrainGeneration += 1; - await invoke("clear_pending_navigation_deep_links"); + 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 { 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/mobile/lib/features/channels/message_content.dart b/mobile/lib/features/channels/message_content.dart index 5a8127b36a..ba77569577 100644 --- a/mobile/lib/features/channels/message_content.dart +++ b/mobile/lib/features/channels/message_content.dart @@ -76,6 +76,25 @@ String _safeDownloadedFilename(String filename) { return safe.isEmpty ? 'attachment' : safe; } +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; + } +} + /// Renders message content with markdown formatting, @mentions, #channel links, /// and media-aware markdown images/videos. class MessageContent extends HookConsumerWidget { @@ -243,20 +262,28 @@ class MessageContent extends HookConsumerWidget { url = url.substring(0, outsidePunctuation.start); trailing = outsidePunctuation[0]!; } - for (final delimiter in const [ - '***', - '___', - '**', - '__', - '~~', - '*', - '_', - ]) { - if (segment.substring(0, start).endsWith(delimiter) && - url.endsWith(delimiter)) { - url = url.substring(0, url.length - delimiter.length); - trailing = '$delimiter$trailing'; - break; + var strippedDelimiter = true; + while (strippedDelimiter) { + strippedDelimiter = false; + for (final delimiter in const [ + '***', + '___', + '**', + '__', + '~~', + '*', + '_', + ]) { + 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 = RegExp(r'[.,!?:;]+$').firstMatch(url); diff --git a/mobile/test/features/channels/message_content_test.dart b/mobile/test/features/channels/message_content_test.dart index eafe287b6f..4fc1cd73fa 100644 --- a/mobile/test/features/channels/message_content_test.dart +++ b/mobile/test/features/channels/message_content_test.dart @@ -492,6 +492,39 @@ void main() { ); }); + 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 { From 60751407347d4312f6c39203dd583646c18cc71f Mon Sep 17 00:00:00 2001 From: npub13n66s06epmqf2kc3v373ez8hj65cuzyvxzjf93vwpervxqn2u7jq2qd9je <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> Date: Tue, 4 Aug 2026 16:56:30 -0400 Subject: [PATCH 16/16] fix(mobile): satisfy deep-link file size gate Signed-off-by: npub13n66s06epmqf2kc3v373ez8hj65cuzyvxzjf93vwpervxqn2u7jq2qd9je <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> --- .../features/channels/message_content.dart | 102 ++---------------- .../message_content/link_normalizer.dart | 93 ++++++++++++++++ .../message_content/link_normalizer_test.dart | 36 +++++++ 3 files changed, 138 insertions(+), 93 deletions(-) create mode 100644 mobile/lib/features/channels/message_content/link_normalizer.dart create mode 100644 mobile/test/features/channels/message_content/link_normalizer_test.dart diff --git a/mobile/lib/features/channels/message_content.dart b/mobile/lib/features/channels/message_content.dart index ba77569577..1ccf27e5cc 100644 --- a/mobile/lib/features/channels/message_content.dart +++ b/mobile/lib/features/channels/message_content.dart @@ -27,6 +27,7 @@ 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'; @@ -76,25 +77,6 @@ String _safeDownloadedFilename(String filename) { return safe.isEmpty ? 'attachment' : safe; } -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; - } -} - /// Renders message content with markdown formatting, @mentions, #channel links, /// and media-aware markdown images/videos. class MessageContent extends HookConsumerWidget { @@ -231,82 +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 { - var segment = parts[i].replaceAllMapped( - RegExp( - r'<((?:https?://|buzz://(?:message\?|join\?|channel/))[^>]+)>', - ), - (m) => '[${m[1]}](${m[1]})', - ); - segment = segment.replaceAllMapped( - RegExp( - r'(?\]]+', - ), - (m) { - final matched = m[0]!; - var url = matched; - var trailing = ''; - final start = m.start; - - final outsidePunctuation = RegExp(r'[.,!?:;]+$').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 const [ - '***', - '___', - '**', - '__', - '~~', - '*', - '_', - ]) { - 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 = RegExp(r'[.,!?:;]+$').firstMatch(url); - if (punctuation != null) { - url = url.substring(0, punctuation.start); - trailing = '${punctuation[0]}$trailing'; - } - // Skip if this URL is already a markdown link label that equals - // the URL (produced by step 1 or authored as [url](url)). - if (start >= 1 && segment[start - 1] == '[') return matched; - return '[$url]($url)$trailing'; - }, - ); - 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) { @@ -325,16 +242,15 @@ 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( 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/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)'); + }); +}