From 5acb930821ba56b5f4d1b487bffd237dd3ebe76a Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Fri, 14 Aug 2026 10:18:18 -0700 Subject: [PATCH] feat(desktop-messages): render compact Buzz permalink chips (#5638) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **Category:** improvement **User Impact:** Buzz channel, message, repository, pull request, and issue links now open reliably and display recognizable context in the desktop app. **Problem:** Buzz links could appear as raw or ambiguous URLs, and navigation links received during startup or community transitions could be dropped before the UI was ready. Repository and issue shares in particular required hover context to understand at a glance. **Solution:** Queue desktop channel/message navigation until the UI is ready, then render bare Buzz permalinks as icon-prefixed chips with concise entity context while preserving user-authored Markdown labels as ordinary links.
File changes **desktop/src-tauri/src/deep_link.rs** Adds validated channel-link parsing and a deduplicated, acknowledged queue so navigation survives frontend startup. **desktop/src-tauri/src/lib.rs** Registers the pending-navigation state and commands with the desktop application. **desktop/src/features/communities/useCommunityInit.ts** Resets queued navigation safely across community boundaries without leaking stale destinations. **desktop/src/features/messages/lib/channelLink.test.mjs** Covers valid, malformed, and canonical channel permalink forms. **desktop/src/features/messages/lib/channelLink.ts** Defines strict parsing and detection for `buzz://channel/` links. **desktop/src/features/messages/lib/composerMessageLinkNode.test.mjs** Extends composer-node coverage for normalized Buzz link content. **desktop/src/features/messages/lib/composerMessageLinkNode.ts** Keeps composer link-node handling aligned with the expanded Buzz link surface. **desktop/src/features/messages/lib/remarkChannelDeepLinks.test.mjs** Verifies bare channel URLs become renderable deep-link nodes without touching code. **desktop/src/features/messages/lib/remarkChannelDeepLinks.ts** Transforms eligible bare channel links into dedicated Markdown nodes. **desktop/src/features/messages/lib/remarkEntityLinks.test.mjs** Covers bare repository, pull-request, and issue detection and code-span exclusions. **desktop/src/features/messages/lib/remarkEntityLinks.ts** Adds dedicated Markdown nodes for bare Buzz project entities. **desktop/src/shared/deep-link.test.mjs** Exercises queued navigation, acknowledgement, serialization, and community-switch behavior. **desktop/src/shared/deep-link.ts** Serializes pending deep-link drains and acknowledges destinations only after successful navigation. **desktop/src/shared/styles/globals/markdown.css** Aligns permalink icon geometry and spacing with agent mention chips. **desktop/src/shared/ui/markdown.test.mjs** Adds integration coverage for every permalink chip, authored labels, fallbacks, icons, and static rendering. **desktop/src/shared/ui/markdown.tsx** Routes channel and entity nodes through the shared presentation path while preserving authored link text. **desktop/src/shared/ui/markdown/BuzzLinkChip.tsx** Introduces the shared interactive/static permalink chip and authored-label inline-link components. **desktop/src/shared/ui/markdown/ChannelDeepLink.tsx** Renders channel shares and references with Hash icons, names, and shortened-ID fallbacks. **desktop/src/shared/ui/markdown/MessageLinkPill.tsx** Renders ordinary message shares with message icons and channel/message context while retaining sent-from-thread behavior. **desktop/src/shared/ui/markdown/entityLinks.tsx** Maps repositories, pull requests, and issues to Projects-aligned icons and contextual labels. **desktop/src/shared/ui/markdown/nodeCache.ts** Includes entity-link rendering in cached Markdown node handling. **desktop/src/shared/ui/markdown/utils.ts** Allows validated channel links through the Buzz URL transform. **desktop/src/shared/useMessageDeepLinks.ts** Drains queued navigation links safely and clears them during teardown. **desktop/src/testing/e2eBridge.ts** Extends the mock bridge with pending-navigation command behavior. **desktop/tests/e2e/community-rail.spec.ts** Verifies queued links do not cross community boundaries. **desktop/tests/e2e/navigation.spec.ts** Covers channel/message deep-link navigation during startup and active sessions. **desktop/tests/helpers/bridge.ts** Adds reusable deep-link mock state and acknowledgement helpers.
## Reproduction steps 1. Run the desktop app and open a channel containing bare `buzz://channel`, `buzz://message`, `buzz://repo`, `buzz://pr`, and `buzz://issue` URLs. 2. Confirm each bare URL renders as one cohesive chip with a type icon, a useful name or shortened identifier, and no duplicated channel `#` character. 3. Add an authored Markdown link such as `[design discussion](buzz://issue?...)` and confirm the supplied label remains an ordinary link rather than becoming a chip. 4. Select channel and message links and confirm they navigate correctly in warm and cold-start states. ## Screenshots / demos Houston dark theme with custom purple accent (`#a855f7`), captured from rebased visual implementation `ad411cc06`; current head `0aafa144f` only adjusts E2E expectations for the visible mention-label behavior shown here. **Composer — channel, message, repository, pull request, and issue pills** ![Composer with all Buzz permalink pill types in Houston dark theme and purple accent](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/5638/composer-all-permalink-pills-dark-purple.png) **Message list — channel, message, repository, pull request, and issue pills** ![Message list with all Buzz permalink pill types in Houston dark theme and purple accent](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/5638/message-list-all-pill-types-dark-purple.png) --------- Signed-off-by: Taylor Ho Signed-off-by: Carl Co-authored-by: Carl --- desktop/src-tauri/src/deep_link.rs | 249 ++++++++++- desktop/src-tauri/src/lib.rs | 10 +- .../features/communities/useCommunityInit.ts | 48 ++- .../messages/lib/channelLink.test.mjs | 60 +++ .../src/features/messages/lib/channelLink.ts | 55 +++ .../lib/composerMessageLinkNode.test.mjs | 104 ++++- .../messages/lib/composerMessageLinkNode.ts | 212 +++++++--- .../messages/lib/mentionHighlightExtension.ts | 21 +- .../lib/remarkChannelDeepLinks.test.mjs | 35 ++ .../messages/lib/remarkChannelDeepLinks.ts | 22 + .../messages/lib/remarkEntityLinks.test.mjs | 35 ++ .../messages/lib/remarkEntityLinks.ts | 22 + .../features/messages/ui/SystemMessageRow.tsx | 34 +- desktop/src/shared/deep-link.test.mjs | 399 ++++++++++++++++++ desktop/src/shared/deep-link.ts | 124 +++++- .../src/shared/styles/globals/markdown.css | 99 +++-- desktop/src/shared/ui/InlineChip.tsx | 70 +++ desktop/src/shared/ui/markdown.test.mjs | 261 +++++++++++- desktop/src/shared/ui/markdown.tsx | 133 +++--- .../src/shared/ui/markdown/BuzzLinkChip.tsx | 152 +++++++ .../shared/ui/markdown/ChannelDeepLink.tsx | 118 ++++++ .../shared/ui/markdown/MessageLinkPill.tsx | 82 ++-- .../src/shared/ui/markdown/entityLinks.tsx | 64 ++- desktop/src/shared/ui/markdown/nodeCache.ts | 4 + desktop/src/shared/ui/markdown/types.ts | 2 + desktop/src/shared/ui/markdown/utils.ts | 3 +- desktop/src/shared/ui/mentionChip.ts | 24 +- desktop/src/shared/useMessageDeepLinks.ts | 26 +- desktop/src/testing/e2eBridge.ts | 44 ++ desktop/tests/e2e/channels.spec.ts | 4 +- desktop/tests/e2e/community-rail.spec.ts | 41 ++ desktop/tests/e2e/empty-edit-delete.spec.ts | 6 +- desktop/tests/e2e/integration.spec.ts | 4 +- desktop/tests/e2e/mentions.spec.ts | 67 ++- desktop/tests/e2e/messaging.spec.ts | 10 +- desktop/tests/e2e/navigation.spec.ts | 181 +++++++- desktop/tests/e2e/onboarding.spec.ts | 4 +- desktop/tests/e2e/relay-reconnect.spec.ts | 2 +- desktop/tests/e2e/smoke.spec.ts | 6 +- desktop/tests/helpers/bridge.ts | 10 + 40 files changed, 2520 insertions(+), 327 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/features/messages/lib/remarkEntityLinks.test.mjs create mode 100644 desktop/src/features/messages/lib/remarkEntityLinks.ts create mode 100644 desktop/src/shared/deep-link.test.mjs create mode 100644 desktop/src/shared/ui/InlineChip.tsx create mode 100644 desktop/src/shared/ui/markdown/BuzzLinkChip.tsx 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 ffe951dc3..098b3f1e7 100644 --- a/desktop/src-tauri/src/deep_link.rs +++ b/desktop/src-tauri/src/deep_link.rs @@ -20,6 +20,79 @@ pub(crate) struct PendingCommunityDeepLink { #[derive(Default)] pub(crate) struct PendingCommunityDeepLinks(Mutex>); +#[derive(Debug, Clone, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct PendingNavigationDeepLink { + id: String, + kind: String, + channel_id: String, + message_id: Option, + thread_root_id: Option, +} + +#[derive(Default)] +pub(crate) struct PendingNavigationDeepLinks(Mutex>); + +impl PendingNavigationDeepLinks { + fn lock(&self) -> std::sync::MutexGuard<'_, VecDeque> { + self.0.lock().unwrap_or_else(|poisoned| { + eprintln!("buzz-desktop: recovering poisoned pending navigation deep-link queue"); + poisoned.into_inner() + }) + } + + fn enqueue(&self, pending: PendingNavigationDeepLink) { + let mut queue = self.lock(); + if queue.iter().any(|item| { + item.kind == pending.kind + && item.channel_id == pending.channel_id + && item.message_id == pending.message_id + && item.thread_root_id == pending.thread_root_id + }) { + return; + } + queue.push_back(pending); + } + + fn clear(&self) { + self.lock().clear(); + } + + fn first(&self) -> Option { + self.lock().front().cloned() + } + + fn acknowledge(&self, id: &str) -> bool { + let mut queue = self.lock(); + if queue.front().is_some_and(|item| item.id == id) { + queue.pop_front(); + true + } else { + false + } + } +} + +#[tauri::command] +pub(crate) fn clear_pending_navigation_deep_links(pending: State<'_, PendingNavigationDeepLinks>) { + pending.clear(); +} + +#[tauri::command] +pub(crate) fn take_pending_navigation_deep_link( + pending: State<'_, PendingNavigationDeepLinks>, +) -> Option { + pending.first() +} + +#[tauri::command] +pub(crate) fn acknowledge_pending_navigation_deep_link( + id: String, + pending: State<'_, PendingNavigationDeepLinks>, +) -> bool { + pending.acknowledge(&id) +} + impl PendingCommunityDeepLinks { fn enqueue(&self, pending: PendingCommunityDeepLink) { let mut queue = self.0.lock().expect("pending deep-link queue poisoned"); @@ -88,6 +161,20 @@ fn queue_community_deep_link( }); } +fn queue_navigation_deep_link(app: &tauri::AppHandle, kind: &str, payload: &serde_json::Value) { + let Some(channel_id) = payload["channelId"].as_str() else { + return; + }; + app.state::() + .enqueue(PendingNavigationDeepLink { + id: uuid::Uuid::new_v4().to_string(), + kind: kind.to_owned(), + channel_id: channel_id.to_owned(), + message_id: payload["messageId"].as_str().map(str::to_owned), + thread_root_id: payload["threadRootId"].as_str().map(str::to_owned), + }); +} + fn activate_main_window(app: &tauri::AppHandle) { let Some(window) = app.get_webview_window("main") else { return; @@ -104,6 +191,19 @@ fn activate_main_window(app: &tauri::AppHandle) { } } +fn parse_channel_deep_link(url: &Url) -> Option { + if url.query().is_some() || url.fragment().is_some() || !url.username().is_empty() { + return None; + } + let mut segments = url.path_segments()?; + let channel_id = segments.next()?; + if segments.next().is_some() { + return None; + } + let channel_id = uuid::Uuid::parse_str(channel_id).ok()?.to_string(); + Some(serde_json::json!({ "channelId": channel_id })) +} + /// Parse the query string of a `buzz://message?…` URL into the JSON /// payload emitted on `deep-link-message`. Returns `None` when a required /// param (`channel`, `id`) is missing or empty — mirroring the validation @@ -350,6 +450,15 @@ pub(crate) fn handle_deep_link_url(app: &tauri::AppHandle, url_str: &str) { ); let _ = app.emit("deep-link-add-community", payload); } + Some("channel") => { + let Some(payload) = parse_channel_deep_link(&url) else { + eprintln!("buzz-desktop: channel deep link missing/invalid channel: {url_str}"); + return; + }; + activate_main_window(app); + queue_navigation_deep_link(app, "channel", &payload); + let _ = app.emit("deep-link-channel", payload); + } Some("message") => { // `buzz://message?channel=&id=[&thread=]` // @@ -364,6 +473,7 @@ pub(crate) fn handle_deep_link_url(app: &tauri::AppHandle, url_str: &str) { return; }; activate_main_window(app); + queue_navigation_deep_link(app, "message", &payload); let _ = app.emit("deep-link-message", payload); } Some("nostr-bind") => match parse_nostr_bind_deep_link(&url) { @@ -389,8 +499,9 @@ mod tests { use url::Url; use super::{ - parse_add_community_deep_link, parse_join_deep_link, parse_message_deep_link, - parse_nostr_bind_deep_link, PendingCommunityDeepLink, PendingCommunityDeepLinks, + parse_add_community_deep_link, parse_channel_deep_link, parse_join_deep_link, + parse_message_deep_link, parse_nostr_bind_deep_link, PendingCommunityDeepLink, + PendingCommunityDeepLinks, PendingNavigationDeepLink, PendingNavigationDeepLinks, }; fn pending(id: &str, relay_url: &str, code: Option<&str>) -> PendingCommunityDeepLink { @@ -404,6 +515,100 @@ mod tests { } } + fn pending_navigation( + id: &str, + kind: &str, + channel_id: &str, + message_id: Option<&str>, + thread_root_id: Option<&str>, + ) -> PendingNavigationDeepLink { + PendingNavigationDeepLink { + id: id.to_owned(), + kind: kind.to_owned(), + channel_id: channel_id.to_owned(), + message_id: message_id.map(str::to_owned), + thread_root_id: thread_root_id.map(str::to_owned), + } + } + + #[test] + fn pending_navigation_links_are_fifo_acknowledged_and_deduplicated() { + let queue = PendingNavigationDeepLinks::default(); + queue.enqueue(pending_navigation( + "first", + "channel", + "channel-1", + None, + None, + )); + queue.enqueue(pending_navigation( + "duplicate", + "channel", + "channel-1", + None, + None, + )); + queue.enqueue(pending_navigation( + "second", + "message", + "channel-1", + Some("message-1"), + Some("root-1"), + )); + + assert_eq!(queue.first().unwrap().id, "first"); + assert!(!queue.acknowledge("second")); + assert!(queue.acknowledge("first")); + assert_eq!(queue.first().unwrap().id, "second"); + assert!(queue.acknowledge("second")); + assert!(queue.first().is_none()); + } + + #[test] + fn pending_navigation_links_can_be_cleared() { + let queue = PendingNavigationDeepLinks::default(); + queue.enqueue(pending_navigation( + "first", + "channel", + "channel-1", + None, + None, + )); + queue.enqueue(pending_navigation( + "second", + "message", + "channel-1", + Some("message-1"), + None, + )); + + queue.clear(); + assert!(queue.first().is_none()); + } + + #[test] + fn pending_navigation_queue_recovers_after_mutex_poisoning() { + let queue = std::sync::Arc::new(PendingNavigationDeepLinks::default()); + let poisoner = std::sync::Arc::clone(&queue); + assert!(std::thread::spawn(move || { + let _guard = poisoner.0.lock().unwrap(); + panic!("poison queue for recovery regression"); + }) + .join() + .is_err()); + + queue.enqueue(pending_navigation( + "after-poison", + "channel", + "channel-1", + None, + None, + )); + assert_eq!(queue.first().unwrap().id, "after-poison"); + assert!(queue.acknowledge("after-poison")); + assert!(queue.first().is_none()); + } + #[test] fn pending_join_serializes_policy_receipt_for_cold_launch_recovery() { let mut link = pending("join", "wss://relay.example", Some("invite")); @@ -477,6 +682,46 @@ mod tests { } } + #[test] + fn parse_channel_deep_link_accepts_one_path_segment() { + let url = Url::parse("buzz://channel/580ca78b-9dae-46f3-8854-bd671853ba32").unwrap(); + let payload = parse_channel_deep_link(&url).unwrap(); + assert_eq!(payload["channelId"], "580ca78b-9dae-46f3-8854-bd671853ba32"); + } + + #[test] + fn parse_channel_deep_link_accepts_v7_and_normalizes_uppercase() { + for (raw, expected) in [ + ( + "buzz://channel/018fdb5d-3a64-7c35-b5f9-4a23e1f9d2d9", + "018fdb5d-3a64-7c35-b5f9-4a23e1f9d2d9", + ), + ( + "buzz://channel/580CA78B-9DAE-46F3-8854-BD671853BA32", + "580ca78b-9dae-46f3-8854-bd671853ba32", + ), + ] { + let payload = parse_channel_deep_link(&Url::parse(raw).unwrap()).unwrap(); + assert_eq!(payload["channelId"], expected); + } + } + + #[test] + fn parse_channel_deep_link_rejects_malformed_forms() { + for raw in [ + "buzz://channel", + "buzz://channel/", + "buzz://channel/one/two", + "buzz://channel/one?extra=true", + "buzz://channel/one#fragment", + "buzz://channel/not-a-uuid", + "buzz://channel/%2F", + "buzz://channel/%00", + ] { + assert!(parse_channel_deep_link(&Url::parse(raw).unwrap()).is_none()); + } + } + #[test] fn parse_message_deep_link_extracts_required_params() { let url = Url::parse("buzz://message?channel=abc&id=xyz").unwrap(); diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 7aa954ce8..0dd0ee717 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -49,8 +49,9 @@ use app_state::{build_app_state, resolve_persisted_identity, AppState}; use builderlab::*; use commands::*; use deep_link::{ - acknowledge_pending_community_deep_link, handle_deep_link_url, - take_pending_community_deep_link, PendingCommunityDeepLinks, + acknowledge_pending_community_deep_link, acknowledge_pending_navigation_deep_link, + clear_pending_navigation_deep_links, handle_deep_link_url, take_pending_community_deep_link, + take_pending_navigation_deep_link, PendingCommunityDeepLinks, PendingNavigationDeepLinks, }; use huddle::audio_output::{ get_audio_output_device, list_audio_output_devices, set_audio_output_device, @@ -291,7 +292,6 @@ pub fn run() { } else { builder.plugin(tauri_plugin_updater::Builder::new().build()) }; - let app = app_menu::install(builder) .register_asynchronous_uri_scheme_protocol("buzz-media", |ctx, request, responder| { let app = ctx.app_handle().clone(); @@ -303,6 +303,7 @@ pub fn run() { .manage(build_app_state()) .manage(ClipboardState::new()) .manage(PendingCommunityDeepLinks::default()) + .manage(PendingNavigationDeepLinks::default()) .manage(BuilderlabSession::default()) .manage(BuilderlabLogin::default()) .manage(commands::pairing::PairingHandle::new()) @@ -615,6 +616,9 @@ pub fn run() { terminal_runtime::terminal_focus, take_pending_community_deep_link, acknowledge_pending_community_deep_link, + take_pending_navigation_deep_link, + acknowledge_pending_navigation_deep_link, + clear_pending_navigation_deep_links, start_builderlab_login, cancel_builderlab_login, get_builderlab_auth, diff --git a/desktop/src/features/communities/useCommunityInit.ts b/desktop/src/features/communities/useCommunityInit.ts index 0c27ab054..dc0ac167c 100644 --- a/desktop/src/features/communities/useCommunityInit.ts +++ b/desktop/src/features/communities/useCommunityInit.ts @@ -15,6 +15,7 @@ import { getOverrides } from "@/shared/features"; import { resetMediaCaches } from "@/shared/lib/mediaUrl"; import { resetLinkPreviewMetadataCache } from "@/shared/lib/useResolvedLinkPreviews"; import { clearSearchHitEventCache } from "@/app/navigation/searchHitEventCache"; +import { resetNavigationDeepLinkDrain } from "@/shared/deep-link"; import { clearAllDrafts, initDraftStore, @@ -47,12 +48,13 @@ import type { Community } from "./types"; * destroyed via effect cleanup and do not need entries here. * See AGENTS.md "Community Switching" for the full contract. */ -function resetCommunityState({ +async function resetCommunityState({ resetAvatarState, }: { resetAvatarState: boolean; -}): void { +}): Promise { relayClient.disconnect(); + await resetNavigationDeepLinkDrain(); resetRateLimitGate(); clearAllDrafts(); resetAgentObserverStore(); @@ -128,7 +130,23 @@ export function useCommunityInit( saveActiveAgentTurnsForCommunity(prevCommunityIdRef.current); prevCommunityIdRef.current = null; } - resetCommunityState({ resetAvatarState: true }); + try { + await resetCommunityState({ resetAvatarState: true }); + } catch (error) { + console.error("Failed to reset community state:", error); + if (!cancelled) { + setResult({ + isReady: false, + needsSetup: false, + appliedKey: null, + error: + error instanceof Error + ? `Could not safely leave community: ${error.message}` + : "Could not safely leave community", + }); + } + return; + } appliedRelayUrlRef.current = null; hasInitializedRef.current = false; } @@ -207,10 +225,26 @@ export function useCommunityInit( // store under the outgoing community ID and delete its snapshot. prevCommunityIdRef.current = null; } - resetCommunityState({ - resetAvatarState: - appliedRelayUrlRef.current !== activeCommunity.relayUrl, - }); + try { + await resetCommunityState({ + resetAvatarState: + appliedRelayUrlRef.current !== activeCommunity.relayUrl, + }); + } catch (error) { + console.error("Failed to reset community state:", error); + if (!cancelled) { + setResult({ + isReady: false, + needsSetup: false, + appliedKey: null, + error: + error instanceof Error + ? `Could not safely switch communities: ${error.message}` + : "Could not safely switch communities", + }); + } + return; + } } hasInitializedRef.current = true; appliedRelayUrlRef.current = activeCommunity.relayUrl; diff --git a/desktop/src/features/messages/lib/channelLink.test.mjs b/desktop/src/features/messages/lib/channelLink.test.mjs new file mode 100644 index 000000000..7f51a39ce --- /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 000000000..4bfc15deb --- /dev/null +++ b/desktop/src/features/messages/lib/channelLink.ts @@ -0,0 +1,55 @@ +/** `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 buildChannelLink(channelId: string): string { + if (!channelId) { + throw new Error("buildChannelLink: channelId is required"); + } + return `${CHANNEL_LINK_SCHEME}//${CHANNEL_LINK_HOST}/${encodeURIComponent(channelId)}`; +} + +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/composerMessageLinkNode.test.mjs b/desktop/src/features/messages/lib/composerMessageLinkNode.test.mjs index 867f37677..85bcb5455 100644 --- a/desktop/src/features/messages/lib/composerMessageLinkNode.test.mjs +++ b/desktop/src/features/messages/lib/composerMessageLinkNode.test.mjs @@ -3,6 +3,7 @@ import { createRequire } from "node:module"; import test from "node:test"; import { + ComposerMessageLinkNode, registerComposerMessageLinkMarkdownIt, resolveComposerMessageLinkAttributes, } from "./composerMessageLinkNode.ts"; @@ -13,6 +14,11 @@ const MarkdownIt = requireFromTiptap("markdown-it"); const CHANNEL_ID = "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50"; const MESSAGE_ID = "root-event"; const HREF = `buzz://message?channel=${CHANNEL_ID}&id=${MESSAGE_ID}`; +const CHANNEL_HREF = `buzz://channel/${CHANNEL_ID}`; +const OWNER = "a".repeat(64); +const REPO_HREF = `buzz://repo?owner=${OWNER}&d=buzz-world`; +const ISSUE_ID = "b".repeat(64); +const ISSUE_HREF = `buzz://issue?id=${ISSUE_ID}&owner=${OWNER}&d=buzz-world`; test("resolves a composer preview and canonicalizes the underlying href", () => { assert.deepEqual( @@ -34,6 +40,23 @@ test("rejects malformed message links", () => { ); }); +test("resolves channel and entity links as composer chips", () => { + assert.deepEqual( + resolveComposerMessageLinkAttributes(CHANNEL_HREF, (channelId) => + channelId === CHANNEL_ID ? "general" : undefined, + ), + { channelName: "general", href: CHANNEL_HREF }, + ); + assert.deepEqual( + resolveComposerMessageLinkAttributes(REPO_HREF, () => undefined), + { channelName: "", href: REPO_HREF }, + ); + assert.deepEqual( + resolveComposerMessageLinkAttributes(ISSUE_HREF, () => undefined), + { channelName: "", href: ISSUE_HREF }, + ); +}); + function captureMarkdownRule() { let capturedAnchor = null; let capturedRule = null; @@ -84,11 +107,38 @@ test("real markdown-it parsing materializes a restored message link", () => { }); const html = md.renderInline(`See ${HREF}.`); - assert.match(html, /See { + const md = new MarkdownIt(); + registerComposerMessageLinkMarkdownIt(md, { + resolveChannelName: (channelId) => + channelId === CHANNEL_ID ? "general" : undefined, + }); + + const html = md.renderInline(`${HREF} ${CHANNEL_HREF} ${REPO_HREF}`); + assert.equal((html.match(/data-composer-buzz-link=""/g) ?? []).length, 3); + assert.match(html, /data-href="buzz:\/\/channel\/9a1657ac/); + assert.match(html, /data-href="buzz:\/\/repo\?owner=a{64}&d=buzz-world/); +}); + +test("real markdown-it parsing preserves underscores in restored entity links", () => { + const md = new MarkdownIt(); + registerComposerMessageLinkMarkdownIt(md, { + resolveChannelName: () => undefined, + }); + const href = `buzz://repo?owner=${OWNER}&d=my_repo`; + + const html = md.renderInline(href); + + assert.equal((html.match(/data-composer-buzz-link=""/g) ?? []).length, 1); + assert.match(html, /data-href="buzz:\/\/repo\?owner=a{64}&d=my_repo"/); + assert.doesNotMatch(html, /<\/span>_repo/); +}); + test("markdown parsing resumes after markdown-it consumes the buzz prefix", () => { const { rule } = captureMarkdownRule(); let token = null; @@ -125,12 +175,62 @@ test("markdown parsing stops message links before emphasis delimiters", () => { assert.deepEqual(token.meta, { channelName: "general", href: HREF }); }); +test("composer node uses the sent-message chip presentation", () => { + const node = { + attrs: { channelName: "general", href: HREF }, + }; + const rendered = globalThis.structuredClone( + // TipTap invokes renderHTML with the extension instance as `this`. + // Exercise the production renderer directly so the composer and message + // list cannot silently drift back to separate visual languages. + ComposerMessageLinkNode.config.renderHTML.call( + { options: { resolveChannelName: () => "general" } }, + { HTMLAttributes: {}, node }, + ), + ); + + assert.equal(rendered[0], "span"); + assert.match(rendered[1].class, /mention-chip/); + assert.match(rendered[1].class, /inline-chip-with-icon/); + assert.match(rendered[1].class, /inline-chip-icon-message/); + assert.equal(rendered[1]["data-buzz-link"], ""); + assert.equal(rendered[2], "general · root-eve"); +}); + +test("composer node renders channel and entity chip presentations", () => { + const render = (href) => + globalThis.structuredClone( + ComposerMessageLinkNode.config.renderHTML.call( + { options: { resolveChannelName: () => "general" } }, + { + HTMLAttributes: {}, + node: { attrs: { channelName: "general", href } }, + }, + ), + ); + + const channel = render(CHANNEL_HREF); + assert.equal(channel[1]["data-channel-deep-link"], ""); + assert.match(channel[1].class, /inline-chip-icon-channel/); + assert.equal(channel[2], "general"); + + const repo = render(REPO_HREF); + assert.equal(repo[1]["data-buzz-link-kind"], "repo"); + assert.match(repo[1].class, /inline-chip-icon-repo/); + assert.equal(repo[2], "buzz-world"); + + const issue = render(ISSUE_HREF); + assert.equal(issue[1]["data-buzz-link-kind"], "issue"); + assert.match(issue[1].class, /inline-chip-icon-issue/); + assert.equal(issue[2], "buzz-world · bbbbbbbb"); +}); + test("markdown rendering stores identity in attributes, not visible id text", () => { const { md } = captureMarkdownRule(); const render = md.renderer.rules.buzz_composer_message_link; const html = render([{ meta: { channelName: "general", href: HREF } }], 0); - assert.match(html, /data-composer-message-link=""/); + assert.match(html, /data-composer-buzz-link=""/); assert.match(html, /data-channel-name="general"/); assert.match(html, /data-href="buzz:\/\/message\?channel=.*&id=/); assert.doesNotMatch(html, />[^<]*root-event/); diff --git a/desktop/src/features/messages/lib/composerMessageLinkNode.ts b/desktop/src/features/messages/lib/composerMessageLinkNode.ts index 5431e2c96..83cc0aa58 100644 --- a/desktop/src/features/messages/lib/composerMessageLinkNode.ts +++ b/desktop/src/features/messages/lib/composerMessageLinkNode.ts @@ -3,12 +3,19 @@ import type { Node as ProseMirrorNode } from "@tiptap/pm/model"; import { TextSelection } from "@tiptap/pm/state"; import type { EditorView } from "@tiptap/pm/view"; -import { MENTION_CHIP_BASE_CLASSES } from "@/shared/ui/mentionChip"; import { - getMessageLinkChannelLabel, - getMessageLinkLabel, - MESSAGE_LINK_PREFIX, -} from "./messageLinkLabel"; + buildIssueLink, + buildPullRequestLink, + buildRepoLink, + parseEntityLink, +} from "@/shared/lib/entityLink"; +import { + inlineChipIconClasses, + type InlineChipIconKind, + MENTION_CHIP_BASE_CLASSES, +} from "@/shared/ui/mentionChip"; +import { buildChannelLink, parseChannelLink } from "./channelLink"; +import { getMessageLinkLabel } from "./messageLinkLabel"; import { buildMessageLink, parseMessageLink } from "./messageLink"; export const COMPOSER_MESSAGE_LINK_NODE_NAME = "composerMessageLink"; @@ -22,10 +29,13 @@ export type ComposerMessageLinkAttributes = { href: string; }; -const BARE_MESSAGE_LINK_AT_START = /^(?:buzz):\/\/message\?[^\s<>"')\]}*_]+/i; +const BARE_BUZZ_LINK_AT_START = + /^buzz:\/\/(?:message\?|channel\/|(?:pr|issue|repo)\?)[^\s<>"')\]}*]+/i; +const BUZZ_LINK_SUFFIX_AT_START = + /^:\/\/(?:message\?|channel\/|(?:pr|issue|repo)\?)[^\s<>"')\]}*]+/i; const TRAILING_PUNCTUATION = /[.,;:!?]+$/; -function trimBareMessageLink(value: string): string { +function trimBareBuzzLink(value: string): string { let trimmed = value.replace(TRAILING_PUNCTUATION, ""); while (/[)\]]$/.test(trimmed)) { const closing = trimmed.at(-1) ?? ""; @@ -40,23 +50,56 @@ export function resolveComposerMessageLinkAttributes( href: string, resolveChannelName: ComposerMessageLinkNodeOptions["resolveChannelName"], ): ComposerMessageLinkAttributes | null { - const parsed = parseMessageLink(href); - if (!parsed.ok) return null; - return { - channelName: resolveChannelName(parsed.value.channelId) ?? "", - href: buildMessageLink({ - channelId: parsed.value.channelId, - messageId: parsed.value.messageId, - threadRootId: parsed.value.threadRootId, - }), - }; + const message = parseMessageLink(href); + if (message.ok) { + return { + channelName: resolveChannelName(message.value.channelId) ?? "", + href: buildMessageLink({ + channelId: message.value.channelId, + messageId: message.value.messageId, + threadRootId: message.value.threadRootId, + }), + }; + } + + const channel = parseChannelLink(href); + if (channel.ok) { + return { + channelName: resolveChannelName(channel.value.channelId) ?? "", + href: buildChannelLink(channel.value.channelId), + }; + } + + const entity = parseEntityLink(href); + if (!entity.ok) return null; + switch (entity.value.type) { + case "repo": + return { + channelName: "", + href: buildRepoLink(entity.value), + }; + case "pr": + return { + channelName: "", + href: buildPullRequestLink(entity.value), + }; + case "issue": + return { + channelName: "", + href: buildIssueLink(entity.value), + }; + } } -function unwrapExactMessageLink(text: string): string | null { +function unwrapExactBuzzLink(text: string): string | null { const href = text.startsWith("<") && text.endsWith(">") ? text.slice(1, -1) : text; if (!href || /\s/.test(href)) return null; - return parseMessageLink(href).ok ? href : null; + return parseMessageLink(href).ok || + parseChannelLink(href).ok || + parseEntityLink(href).ok + ? href + : null; } function unwrapExactHttpLink(text: string): string | null { @@ -83,16 +126,16 @@ export function createComposerLinkPasteHandler( ) { return (view: EditorView, event: ClipboardEvent): boolean => { const text = event.clipboardData?.getData("text/plain") ?? ""; - const messageHref = unwrapExactMessageLink(text); - const messageLinkType = + const buzzHref = unwrapExactBuzzLink(text); + const buzzLinkType = view.state.schema.nodes[COMPOSER_MESSAGE_LINK_NODE_NAME]; - if (messageHref && messageLinkType) { + if (buzzHref && buzzLinkType) { const attrs = resolveComposerMessageLinkAttributes( - messageHref, + buzzHref, resolveChannelName, ); if (attrs) { - replaceSelectionWithNode(view, messageLinkType.create(attrs)); + replaceSelectionWithNode(view, buzzLinkType.create(attrs)); event.preventDefault(); return true; } @@ -122,14 +165,14 @@ export function registerComposerMessageLinkMarkdownIt( // biome-ignore lint/suspicious/noExplicitAny: markdown-it state/silent const rule = (state: any, silent: boolean): boolean => { const remaining = state.src.slice(state.pos); - const fullMatch = BARE_MESSAGE_LINK_AT_START.exec(remaining); - const suffixMatch = /^:\/\/message\?[^\s<>"')\]}*_]+/i.exec(remaining); + const fullMatch = BARE_BUZZ_LINK_AT_START.exec(remaining); + const suffixMatch = BUZZ_LINK_SUFFIX_AT_START.exec(remaining); const resumesTextToken = !fullMatch && suffixMatch && /buzz$/i.test(state.pending ?? ""); const rawHref = fullMatch?.[0] ?? (resumesTextToken ? `buzz${suffixMatch[0]}` : null); if (!rawHref) return false; - const href = trimBareMessageLink(rawHref); + const href = trimBareBuzzLink(rawHref); const attrs = resolveComposerMessageLinkAttributes( href, options.resolveChannelName, @@ -149,7 +192,79 @@ export function registerComposerMessageLinkMarkdownIt( md.renderer.rules[tokenType] = (tokens: any[], index: number): string => { const attrs = tokens[index].meta as ComposerMessageLinkAttributes; const escapeHtml = md.utils.escapeHtml; - return ``; + return ``; + }; +} + +type ComposerLinkPresentation = { + ariaLabel: string; + channelName: string; + dataAttributes: Record; + icon: InlineChipIconKind; + label: string; +}; + +function composerLinkPresentation( + href: string, + channelName: string, + resolveChannelName: ComposerMessageLinkNodeOptions["resolveChannelName"], +): ComposerLinkPresentation { + const message = parseMessageLink(href); + if (message.ok) { + const resolvedChannelName = + resolveChannelName(message.value.channelId) || channelName || "channel"; + return { + ariaLabel: getMessageLinkLabel({ channelName: resolvedChannelName }), + channelName: resolvedChannelName, + dataAttributes: { + "data-composer-message-link": "", + "data-message-link": "", + }, + icon: "message", + label: `${resolvedChannelName} · ${message.value.messageId.slice(0, 8)}`, + }; + } + + const channel = parseChannelLink(href); + if (channel.ok) { + const resolvedChannelName = + resolveChannelName(channel.value.channelId) || + channelName || + channel.value.channelId.slice(0, 8); + return { + ariaLabel: `Open channel ${resolvedChannelName}`, + channelName: resolvedChannelName, + dataAttributes: { "data-channel-deep-link": "" }, + icon: "channel", + label: resolvedChannelName, + }; + } + + const entity = parseEntityLink(href); + if (!entity.ok) { + return { + ariaLabel: "Buzz link", + channelName: "", + dataAttributes: {}, + icon: "message", + label: "Buzz link", + }; + } + + const shortId = + entity.value.type === "repo" ? "" : entity.value.id.slice(0, 8); + return { + ariaLabel: + entity.value.type === "repo" + ? `Open repository ${entity.value.dtag}` + : `Open ${entity.value.type === "pr" ? "pull request" : "issue"} ${shortId} in repository ${entity.value.dtag}`, + channelName: "", + dataAttributes: { "data-buzz-link-kind": entity.value.type }, + icon: entity.value.type, + label: + entity.value.type === "repo" + ? entity.value.dtag + : `${entity.value.dtag} · ${shortId}`, }; } @@ -183,39 +298,32 @@ export const ComposerMessageLinkNode = }, parseHTML() { - return [{ tag: "span[data-composer-message-link]" }]; + return [ + { tag: "span[data-composer-buzz-link]" }, + { tag: "span[data-composer-message-link]" }, + ]; }, renderHTML({ node, HTMLAttributes }) { const href = String(node.attrs.href ?? ""); - const parsed = parseMessageLink(href); - const channelName = parsed.ok - ? (this.options.resolveChannelName(parsed.value.channelId) ?? - (String(node.attrs.channelName ?? "") || "channel")) - : "channel"; - const label = getMessageLinkLabel({ channelName }); - const channelLinkLabel = getMessageLinkChannelLabel(channelName); + const presentation = composerLinkPresentation( + href, + String(node.attrs.channelName ?? ""), + this.options.resolveChannelName, + ); return [ "span", mergeAttributes(HTMLAttributes, { - "aria-label": label, - class: - "inline-flex min-w-0 max-w-80 items-center gap-1.5 align-baseline", - "data-channel-name": channelName, - "data-composer-message-link": "", + "aria-label": presentation.ariaLabel, + class: `${MENTION_CHIP_BASE_CLASSES} ${inlineChipIconClasses(presentation.icon)} cursor-text`, + "data-buzz-link": "", + "data-channel-name": presentation.channelName, + "data-composer-buzz-link": "", "data-href": href, - "data-message-link": "", - title: label, + ...presentation.dataAttributes, + title: presentation.ariaLabel, }), - ["span", { class: "shrink-0" }, MESSAGE_LINK_PREFIX], - [ - "span", - { - class: `${MENTION_CHIP_BASE_CLASSES} min-w-0 max-w-full truncate`, - "data-channel-link": "", - }, - channelLinkLabel, - ], + presentation.label, ]; }, diff --git a/desktop/src/features/messages/lib/mentionHighlightExtension.ts b/desktop/src/features/messages/lib/mentionHighlightExtension.ts index 1fad41408..a8d3ef8ff 100644 --- a/desktop/src/features/messages/lib/mentionHighlightExtension.ts +++ b/desktop/src/features/messages/lib/mentionHighlightExtension.ts @@ -2,6 +2,11 @@ import { Extension } from "@tiptap/core"; import { Plugin, PluginKey, type Transaction } from "@tiptap/pm/state"; import { Decoration, DecorationSet } from "@tiptap/pm/view"; +import { + inlineChipIconClasses, + MENTION_CHIP_BASE_CLASSES, +} from "@/shared/ui/mentionChip"; + export const mentionHighlightKey = new PluginKey("mentionHighlight"); /** @@ -267,22 +272,24 @@ function buildDecorations( node.text, pos, mentionPatterns, - "mention-chip", + `${MENTION_CHIP_BASE_CLASSES} ${inlineChipIconClasses("human")}`, + { hidePrefix: true }, ); addMatchesForPatterns( decorations, node.text, pos, agentMentionPatterns, - "mention-chip agent-mention-highlight", - { hideMentionPrefix: true }, + `${MENTION_CHIP_BASE_CLASSES} ${inlineChipIconClasses("agent")}`, + { hidePrefix: true }, ); addMatchesForPatterns( decorations, node.text, pos, channelPatterns, - "mention-chip", + `${MENTION_CHIP_BASE_CLASSES} ${inlineChipIconClasses("channel")}`, + { hidePrefix: true }, ); }); @@ -295,7 +302,7 @@ function addMatchesForPatterns( position: number, patterns: RegExp[], className: string, - options?: { hideMentionPrefix?: boolean }, + options?: { hidePrefix?: boolean }, ) { for (const pattern of patterns) { pattern.lastIndex = 0; @@ -303,10 +310,10 @@ function addMatchesForPatterns( while (match !== null) { const from = position + match.index; const to = from + match[0].length; - if (options?.hideMentionPrefix && match[0].startsWith("@")) { + if (options?.hidePrefix && /^[@#]/.test(match[0])) { decorations.push( Decoration.inline(from, from + 1, { - class: "agent-mention-at-hidden", + class: "mention-prefix-hidden", spellcheck: "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 000000000..ce45d2fa5 --- /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 000000000..efafec770 --- /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/features/messages/lib/remarkEntityLinks.test.mjs b/desktop/src/features/messages/lib/remarkEntityLinks.test.mjs new file mode 100644 index 000000000..1d7fdeacb --- /dev/null +++ b/desktop/src/features/messages/lib/remarkEntityLinks.test.mjs @@ -0,0 +1,35 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import remarkEntityLinks from "./remarkEntityLinks.ts"; + +function run(value) { + const tree = { + type: "root", + children: [{ type: "paragraph", children: [{ type: "text", value }] }], + }; + remarkEntityLinks()(tree); + return tree.children[0].children; +} + +test("turns every bare Buzz entity permalink family into a chip node", () => { + const owner = "ab".repeat(32); + const id = "cd".repeat(32); + const links = [ + `buzz://repo?owner=${owner}&d=buzz`, + `buzz://pr?id=${id}&owner=${owner}&d=buzz`, + `buzz://issue?id=${id}&owner=${owner}&d=buzz`, + ]; + for (const link of links) { + const children = run(link); + assert.equal(children[0].type, "entity-link"); + assert.equal(children[0].value, link); + } +}); + +test("keeps sentence punctuation outside entity chip nodes", () => { + const link = `buzz://repo?owner=${"ab".repeat(32)}&d=buzz`; + const children = run(`${link}.`); + assert.equal(children[0].value, link); + assert.equal(children[1].value, "."); +}); diff --git a/desktop/src/features/messages/lib/remarkEntityLinks.ts b/desktop/src/features/messages/lib/remarkEntityLinks.ts new file mode 100644 index 000000000..41cf4af20 --- /dev/null +++ b/desktop/src/features/messages/lib/remarkEntityLinks.ts @@ -0,0 +1,22 @@ +/** Detect bare `buzz://pr|issue|repo?…` URLs in markdown text nodes. */ +import { createRemarkPrefixPlugin } from "../../../shared/lib/createRemarkPrefixPlugin.ts"; + +const ENTITY_URL_PATTERN = /buzz:\/\/(?:pr|issue|repo)\?[^\s<>"')\]]+/g; +const TRAILING_PUNCTUATION_PATTERN = /[.,;:!?]+$/; + +export default function remarkEntityLinks() { + return createRemarkPrefixPlugin(ENTITY_URL_PATTERN, (matchText) => { + const value = matchText.replace(TRAILING_PUNCTUATION_PATTERN, ""); + return { + node: { + type: "entity-link", + value, + data: { + hName: "entity-link", + hChildren: [{ type: "text", value }], + }, + }, + trailing: matchText.slice(value.length), + }; + }); +} diff --git a/desktop/src/features/messages/ui/SystemMessageRow.tsx b/desktop/src/features/messages/ui/SystemMessageRow.tsx index fa95d23d4..cbeb4fec7 100644 --- a/desktop/src/features/messages/ui/SystemMessageRow.tsx +++ b/desktop/src/features/messages/ui/SystemMessageRow.tsx @@ -19,12 +19,8 @@ import { cn } from "@/shared/lib/cn"; import { normalizePubkey } from "@/shared/lib/pubkey"; import { Button } from "@/shared/ui/button"; import { isPositiveEmojiParticle } from "@/shared/ui/EmojiBurstProvider"; -import { - MENTION_CHIP_BASE_CLASSES, - MENTION_CHIP_HOVER_CLASSES, - MENTION_CHIP_PREFIX_CLASS, - MESSAGE_MARKDOWN_CLASS, -} from "@/shared/ui/mentionChip"; +import { InlineChip } from "@/shared/ui/InlineChip"; +import { MESSAGE_MARKDOWN_CLASS } from "@/shared/ui/mentionChip"; import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip"; import { UserAvatar } from "@/shared/ui/UserAvatar"; @@ -278,24 +274,26 @@ function ProfileName({ underlineOnHover?: boolean; }) { const isAgentMention = highlight && isAgent; - const node = ( + const node = highlight ? ( + + {children} + + ) : ( - {highlight && !isAgentMention ? ( - @ - ) : null} {children} ); diff --git a/desktop/src/shared/deep-link.test.mjs b/desktop/src/shared/deep-link.test.mjs new file mode 100644 index 000000000..ea478aff4 --- /dev/null +++ b/desktop/src/shared/deep-link.test.mjs @@ -0,0 +1,399 @@ +import assert from "node:assert/strict"; +import { afterEach, test } from "node:test"; + +const ipcHandlers = new Map(); +let nextCallbackId = 1; +const callbacks = new Map(); + +const tauriInternals = { + invoke: (cmd, args) => { + const handler = ipcHandlers.get(cmd); + if (handler) return Promise.resolve(handler(args)); + return Promise.reject(new Error(`unmocked Tauri command: ${cmd}`)); + }, + transformCallback: (callback) => { + const id = nextCallbackId++; + callbacks.set(id, callback); + return id; + }, +}; +globalThis.window = { + __TAURI_INTERNALS__: tauriInternals, + __TAURI_EVENT_PLUGIN_INTERNALS__: { unregisterListener: () => {} }, +}; +globalThis.__TAURI_INTERNALS__ = tauriInternals; + +const { listenForNavigationDeepLinks, resetNavigationDeepLinkDrain } = + await import("@/shared/deep-link.ts"); + +function deferred() { + let resolve; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} + +async function settle() { + await new Promise((resolve) => setTimeout(resolve, 0)); +} + +afterEach(() => { + ipcHandlers.clear(); + callbacks.clear(); +}); + +test("listener teardown leaves an unaccepted FIFO item for the next mount", async () => { + const queue = [ + { + id: "first", + kind: "channel", + channelId: "channel-1", + messageId: null, + threadRootId: null, + }, + { + id: "second", + kind: "message", + channelId: "channel-2", + messageId: "message-2", + threadRootId: "root-2", + }, + ]; + const firstAcknowledge = deferred(); + const acknowledged = []; + let unlistenCount = 0; + + ipcHandlers.set("plugin:event|listen", () => nextCallbackId); + ipcHandlers.set("plugin:event|unlisten", () => { + unlistenCount += 1; + }); + ipcHandlers.set("take_pending_navigation_deep_link", () => queue[0] ?? null); + ipcHandlers.set( + "acknowledge_pending_navigation_deep_link", + async ({ id }) => { + if (id === "first") await firstAcknowledge.promise; + assert.equal(queue[0]?.id, id); + acknowledged.push(id); + queue.shift(); + return true; + }, + ); + + let firstMountActive = true; + const firstOpened = []; + const firstUnlisten = await listenForNavigationDeepLinks( + (payload) => { + if (!firstMountActive) return false; + firstOpened.push(payload.channelId); + return true; + }, + (payload) => { + if (!firstMountActive) return false; + firstOpened.push(payload.messageId); + return true; + }, + ); + await settle(); + assert.deepEqual(firstOpened, ["channel-1"]); + + firstMountActive = false; + firstUnlisten(); + firstAcknowledge.resolve(); + await settle(); + + assert.deepEqual(acknowledged, ["first"]); + assert.equal(queue[0]?.id, "second"); + + const secondOpened = []; + const secondUnlisten = await listenForNavigationDeepLinks( + (payload) => { + secondOpened.push(payload.channelId); + return true; + }, + (payload) => { + secondOpened.push(payload.messageId); + return true; + }, + ); + await settle(); + + assert.deepEqual(secondOpened, ["message-2"]); + assert.deepEqual(acknowledged, ["first", "second"]); + assert.equal(queue.length, 0); + secondUnlisten(); + assert.equal(unlistenCount, 4); +}); + +test("concurrent listener remount does not take or acknowledge the in-flight head twice", async () => { + const queue = [ + { + id: "in-flight", + kind: "channel", + channelId: "channel-1", + messageId: null, + threadRootId: null, + }, + ]; + const acknowledgeGate = deferred(); + const opened = []; + const acknowledged = []; + + ipcHandlers.set("plugin:event|listen", () => nextCallbackId); + ipcHandlers.set("plugin:event|unlisten", () => {}); + ipcHandlers.set("take_pending_navigation_deep_link", () => queue[0] ?? null); + ipcHandlers.set( + "acknowledge_pending_navigation_deep_link", + async ({ id }) => { + acknowledged.push(id); + await acknowledgeGate.promise; + assert.equal(queue[0]?.id, id); + queue.shift(); + return true; + }, + ); + + const firstUnlisten = await listenForNavigationDeepLinks( + (payload) => { + opened.push(`first:${payload.channelId}`); + return true; + }, + () => true, + ); + await settle(); + assert.deepEqual(opened, ["first:channel-1"]); + assert.deepEqual(acknowledged, ["in-flight"]); + + firstUnlisten(); + const secondUnlisten = await listenForNavigationDeepLinks( + (payload) => { + opened.push(`second:${payload.channelId}`); + return true; + }, + () => true, + ); + await settle(); + + assert.deepEqual(opened, ["first:channel-1"]); + assert.deepEqual(acknowledged, ["in-flight"]); + + acknowledgeGate.resolve(); + await settle(); + await settle(); + + assert.deepEqual(opened, ["first:channel-1"]); + assert.deepEqual(acknowledged, ["in-flight"]); + assert.equal(queue.length, 0); + secondUnlisten(); +}); + +test("community reset prevents an in-flight route from acknowledging", async () => { + const pending = { + id: "old-community", + kind: "channel", + channelId: "channel-1", + messageId: null, + threadRootId: null, + }; + const routeGate = deferred(); + let acknowledgeCount = 0; + let clearCount = 0; + + ipcHandlers.set("plugin:event|listen", () => nextCallbackId); + ipcHandlers.set("plugin:event|unlisten", () => {}); + ipcHandlers.set("clear_pending_navigation_deep_links", () => { + clearCount += 1; + }); + ipcHandlers.set("take_pending_navigation_deep_link", () => pending); + ipcHandlers.set("acknowledge_pending_navigation_deep_link", () => { + acknowledgeCount += 1; + return true; + }); + + const unlisten = await listenForNavigationDeepLinks( + async () => { + await routeGate.promise; + return true; + }, + () => true, + ); + await settle(); + + await resetNavigationDeepLinkDrain(); + routeGate.resolve(); + await settle(); + + assert.equal(clearCount, 1); + assert.equal(acknowledgeCount, 0); + unlisten(); +}); + +test("community reset after take does not route the stale item", async () => { + const takeGate = deferred(); + const opened = []; + + ipcHandlers.set("plugin:event|listen", () => nextCallbackId); + ipcHandlers.set("plugin:event|unlisten", () => {}); + ipcHandlers.set("clear_pending_navigation_deep_links", () => {}); + ipcHandlers.set("take_pending_navigation_deep_link", async () => { + await takeGate.promise; + return { + id: "old-community", + kind: "channel", + channelId: "channel-1", + messageId: null, + threadRootId: null, + }; + }); + ipcHandlers.set("acknowledge_pending_navigation_deep_link", () => true); + + const unlisten = await listenForNavigationDeepLinks( + (payload) => { + opened.push(payload.channelId); + return true; + }, + () => true, + ); + await settle(); + + await resetNavigationDeepLinkDrain(); + takeGate.resolve(); + await settle(); + + assert.deepEqual(opened, []); + unlisten(); +}); + +test("community reset stops the stale drain before taking another item", async () => { + const queue = [ + { + id: "first", + kind: "channel", + channelId: "channel-1", + messageId: null, + threadRootId: null, + }, + { + id: "second", + kind: "channel", + channelId: "channel-2", + messageId: null, + threadRootId: null, + }, + ]; + const opened = []; + let takeCount = 0; + + ipcHandlers.set("plugin:event|listen", () => nextCallbackId); + ipcHandlers.set("plugin:event|unlisten", () => {}); + ipcHandlers.set("clear_pending_navigation_deep_links", () => { + queue.length = 0; + }); + ipcHandlers.set("take_pending_navigation_deep_link", () => { + takeCount += 1; + return queue[0] ?? null; + }); + ipcHandlers.set( + "acknowledge_pending_navigation_deep_link", + async ({ id }) => { + assert.equal(queue[0]?.id, id); + queue.shift(); + await resetNavigationDeepLinkDrain(); + return true; + }, + ); + + const unlisten = await listenForNavigationDeepLinks( + (payload) => { + opened.push(payload.channelId); + return true; + }, + () => true, + ); + await settle(); + await settle(); + + assert.deepEqual(opened, ["channel-1"]); + assert.equal(takeCount, 1); + unlisten(); +}); + +test("failed community clear quarantines stale navigation from the next listener", async () => { + const stale = { + id: "old-community", + kind: "channel", + channelId: "old-channel", + 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", () => { + throw new Error("clear failed"); + }); + ipcHandlers.set("take_pending_navigation_deep_link", () => { + takeCount += 1; + return stale; + }); + + await assert.rejects(resetNavigationDeepLinkDrain(), /clear failed/); + const unlisten = await listenForNavigationDeepLinks( + (payload) => { + opened.push(payload.channelId); + return true; + }, + () => true, + ); + await settle(); + + assert.equal(takeCount, 0); + assert.deepEqual(opened, []); + unlisten(); + + // Restore the module-level gate for later tests, just as a successful retry + // does in the application. + ipcHandlers.set("clear_pending_navigation_deep_links", () => {}); + await resetNavigationDeepLinkDrain(); +}); + +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 c62a8bec3..8bd767518 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; @@ -106,7 +116,7 @@ async function drainPendingCommunityDeepLinks(deps: DeepLinkDeps) { * relay's HTTP API — signed by this app's identity key — and only adds and * switches to the community once the relay has admitted the key. * - * `buzz://message?…` is handled separately by `listenForMessageDeepLinks`, + * `buzz://message?…` is handled separately by `listenForNavigationDeepLinks`, * because it needs to dispatch into the router which only exists below the * `RouterProvider` in the component tree. */ @@ -152,17 +162,113 @@ export async function listenForDeepLinks( }; } +let navigationDrainTail: Promise = Promise.resolve(); +let navigationDrainGeneration = 0; +let navigationDrainEnabled = true; + +export async function resetNavigationDeepLinkDrain(): Promise { + const generation = ++navigationDrainGeneration; + // Fail closed while the outgoing community's native queue is being cleared. + // A rejected clear leaves that queue's identity unknown, so no later listener + // may route it against a different community. + navigationDrainEnabled = false; + await invoke("clear_pending_navigation_deep_links"); + if (generation === navigationDrainGeneration) { + navigationDrainEnabled = true; + } +} + +function serializeNavigationDrain(task: () => Promise): Promise { + const drain = navigationDrainTail.then(task, task); + // Keep the shared tail fulfilled so one route failure cannot poison future + // listener mounts. The caller still receives `drain` and reports the error. + navigationDrainTail = drain.catch(() => {}); + return drain; +} + +async function drainPendingNavigationDeepLinks( + onOpenChannel: ( + payload: ChannelDeepLinkPayload, + ) => boolean | Promise, + onOpenMessage: ( + payload: MessageDeepLinkPayload, + ) => boolean | Promise, +) { + const generation = navigationDrainGeneration; + if (!navigationDrainEnabled) return; + while (navigationDrainEnabled && generation === navigationDrainGeneration) { + const pending = await invoke( + "take_pending_navigation_deep_link", + ); + if ( + !pending || + !navigationDrainEnabled || + generation !== navigationDrainGeneration + ) { + return; + } + const accepted = await (pending.kind === "channel" + ? onOpenChannel({ channelId: pending.channelId }) + : pending.messageId + ? onOpenMessage({ + channelId: pending.channelId, + messageId: pending.messageId, + threadRootId: pending.threadRootId, + }) + : false); + if (!accepted || generation !== navigationDrainGeneration) return; + const acknowledged = await invoke( + "acknowledge_pending_navigation_deep_link", + { id: pending.id }, + ); + if (!acknowledged) return; + } +} + /** - * Register a listener for `deep-link-message` events. Must be called from - * inside the router tree (e.g. AppShell) because the navigation callback - * uses TanStack Router state. + * Register listeners for queued channel/message navigation emitted by Rust. + * A consumer must explicitly accept each item before it is acknowledged, so + * effect teardown leaves an in-flight queue head available for the next mount. */ -export function listenForMessageDeepLinks( - onOpen: (payload: MessageDeepLinkPayload) => void, +export async function listenForNavigationDeepLinks( + onOpenChannel: ( + payload: ChannelDeepLinkPayload, + ) => boolean | Promise, + onOpenMessage: ( + payload: MessageDeepLinkPayload, + ) => boolean | Promise, ): Promise { - return listen("deep-link-message", (event) => { - onOpen(event.payload); - }); + let drainRunning = false; + let drainRequested = false; + const drain = () => { + drainRequested = true; + if (drainRunning) return; + drainRunning = true; + void (async () => { + try { + while (drainRequested) { + drainRequested = false; + await serializeNavigationDrain(() => + drainPendingNavigationDeepLinks(onOpenChannel, onOpenMessage), + ); + } + } catch (error: unknown) { + console.warn("Failed to drain pending navigation deep links", error); + } finally { + drainRunning = false; + if (drainRequested) drain(); + } + })(); + }; + + const unlistens = await Promise.all([ + listen("deep-link-channel", drain), + listen("deep-link-message", drain), + ]); + drain(); + return () => { + for (const unlisten of unlistens) unlisten(); + }; } export function listenForNostrBindDeepLinks( diff --git a/desktop/src/shared/styles/globals/markdown.css b/desktop/src/shared/styles/globals/markdown.css index 6148ffeb7..720dc30a4 100644 --- a/desktop/src/shared/styles/globals/markdown.css +++ b/desktop/src/shared/styles/globals/markdown.css @@ -8,8 +8,8 @@ var(--inline-chip-padding-block-end) ); --inline-code-font-size: var(--text-xs); - --agent-icon-size: 0.95em; - --agent-icon-gap: 0.125rem; + --inline-chip-icon-size: 0.95em; + --inline-chip-icon-gap: 0.1875rem; } .message-markdown p:empty { @@ -145,10 +145,70 @@ word-break: normal; } -.message-markdown .mention-chip-prefix { - display: inline-block; - line-height: 1; - transform: translateY(-0.12em); +.message-markdown .inline-chip-with-icon { + position: relative; + padding-left: calc( + var(--inline-chip-padding-inline) + + var(--inline-chip-icon-size) + + var(--inline-chip-icon-gap) + ); +} + +.message-markdown .inline-chip-with-icon::before { + content: ""; + position: absolute; + top: 50%; + left: var(--inline-chip-padding-inline); + width: var(--inline-chip-icon-size); + height: var(--inline-chip-icon-size); + background: currentColor; + pointer-events: none; + transform: translateY(-50%); +} + +.message-markdown .inline-chip-icon-message::before { + mask: + url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24'%3E%3Cpath d='M21 15a4 4 0 0 1-4 4H8l-5 3V7a4 4 0 0 1 4-4h10a4 4 0 0 1 4 4z' fill='none' stroke='%23000' stroke-linecap='round' stroke-linejoin='round' stroke-width='2'/%3E%3C/svg%3E") + center / contain no-repeat; + -webkit-mask: + url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24'%3E%3Cpath d='M21 15a4 4 0 0 1-4 4H8l-5 3V7a4 4 0 0 1 4-4h10a4 4 0 0 1 4 4z' fill='none' stroke='%23000' stroke-linecap='round' stroke-linejoin='round' stroke-width='2'/%3E%3C/svg%3E") + center / contain no-repeat; +} + +.message-markdown .inline-chip-icon-channel::before { + mask: + url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-linecap='round' stroke-width='2'%3E%3Cpath d='M4 9h16M4 15h16M10 3 8 21M16 3l-2 18'/%3E%3C/svg%3E") + center / contain no-repeat; + -webkit-mask: + url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-linecap='round' stroke-width='2'%3E%3Cpath d='M4 9h16M4 15h16M10 3 8 21M16 3l-2 18'/%3E%3C/svg%3E") + center / contain no-repeat; +} + +.message-markdown .inline-chip-icon-repo::before { + mask: + url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-linejoin='round' stroke-width='2'%3E%3Cpath d='M3 7a2 2 0 0 1 2-2h5l2 2h7a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Z'/%3E%3Cpath d='M9 13h6M12 10v6'/%3E%3C/svg%3E") + center / contain no-repeat; + -webkit-mask: + url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-linejoin='round' stroke-width='2'%3E%3Cpath d='M3 7a2 2 0 0 1 2-2h5l2 2h7a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Z'/%3E%3Cpath d='M9 13h6M12 10v6'/%3E%3C/svg%3E") + center / contain no-repeat; +} + +.message-markdown .inline-chip-icon-pr::before { + mask: + url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-linecap='round' stroke-linejoin='round' stroke-width='2'%3E%3Ccircle cx='6' cy='18' r='3'/%3E%3Ccircle cx='18' cy='6' r='3'/%3E%3Cpath d='M6 3v12M18 9a9 9 0 0 1-9 9'/%3E%3C/svg%3E") + center / contain no-repeat; + -webkit-mask: + url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-linecap='round' stroke-linejoin='round' stroke-width='2'%3E%3Ccircle cx='6' cy='18' r='3'/%3E%3Ccircle cx='18' cy='6' r='3'/%3E%3Cpath d='M6 3v12M18 9a9 9 0 0 1-9 9'/%3E%3C/svg%3E") + center / contain no-repeat; +} + +.message-markdown .inline-chip-icon-issue::before { + mask: + url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2'%3E%3Ccircle cx='12' cy='12' r='9'/%3E%3Ccircle cx='12' cy='12' r='1' fill='%23000'/%3E%3C/svg%3E") + center / contain no-repeat; + -webkit-mask: + url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2'%3E%3Ccircle cx='12' cy='12' r='9'/%3E%3Ccircle cx='12' cy='12' r='1' fill='%23000'/%3E%3C/svg%3E") + center / contain no-repeat; } .message-markdown .inline-code-chip, @@ -183,7 +243,7 @@ color: hsl(var(--primary) / 0.9); } -.message-markdown .agent-mention-at-hidden { +.message-markdown .mention-prefix-hidden { display: inline-block; width: 0; max-width: 0; @@ -196,25 +256,16 @@ line-height: 1; } -.message-markdown .agent-mention-highlight { - position: relative; - padding-left: calc( - var(--inline-chip-padding-inline) + - var(--agent-icon-size) + - var(--agent-icon-gap) - ); +.message-markdown .inline-chip-icon-human::before { + -webkit-mask: + url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='4'/%3E%3Cpath d='M16 8v5a3 3 0 0 0 6 0v-1a10 10 0 1 0-4 8'/%3E%3C/svg%3E") + center / contain no-repeat; + mask: + url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='4'/%3E%3Cpath d='M16 8v5a3 3 0 0 0 6 0v-1a10 10 0 1 0-4 8'/%3E%3C/svg%3E") + center / contain no-repeat; } -.message-markdown .agent-mention-highlight::before { - content: ""; - position: absolute; - top: 50%; - left: var(--inline-chip-padding-inline); - width: var(--agent-icon-size); - height: var(--agent-icon-size); - background: currentColor; - pointer-events: none; - transform: translateY(-50%); +.message-markdown .inline-chip-icon-agent::before { -webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M12 8V4H8'/%3E%3Crect width='16' height='12' x='4' y='8' rx='2'/%3E%3Cpath d='M2 14h2'/%3E%3Cpath d='M20 14h2'/%3E%3Cpath d='M15 13v2'/%3E%3Cpath d='M9 13v2'/%3E%3C/svg%3E") center / contain no-repeat; diff --git a/desktop/src/shared/ui/InlineChip.tsx b/desktop/src/shared/ui/InlineChip.tsx new file mode 100644 index 000000000..3bddd2f4e --- /dev/null +++ b/desktop/src/shared/ui/InlineChip.tsx @@ -0,0 +1,70 @@ +import type * as React from "react"; + +import { cn } from "@/shared/lib/cn"; +import { + inlineChipIconClasses, + type InlineChipIconKind, + MENTION_CHIP_BASE_CLASSES, + MENTION_CHIP_HOVER_CLASSES, +} from "@/shared/ui/mentionChip"; + +type InlineChipCommonProps = { + children: React.ReactNode; + className?: string; + icon: InlineChipIconKind; + interactive?: boolean; +}; + +type InlineChipProps = + | (InlineChipCommonProps & + Omit< + React.ComponentPropsWithoutRef<"span">, + keyof InlineChipCommonProps + > & { + as?: "span"; + }) + | (InlineChipCommonProps & + Omit< + React.ComponentPropsWithoutRef<"button">, + keyof InlineChipCommonProps + > & { + as: "button"; + }); + +/** Shared visual primitive for mention, channel, and Buzz permalink chips. */ +export function InlineChip({ + as = "span", + children, + className, + icon, + interactive = false, + ...props +}: InlineChipProps) { + const classes = cn( + MENTION_CHIP_BASE_CLASSES, + inlineChipIconClasses(icon), + interactive && "cursor-pointer", + interactive && MENTION_CHIP_HOVER_CLASSES, + className, + ); + if (as === "button") { + return ( + + ); + } + + return ( + )} + className={classes} + > + {children} + + ); +} diff --git a/desktop/src/shared/ui/markdown.test.mjs b/desktop/src/shared/ui/markdown.test.mjs index 670725f1f..d040dd55b 100644 --- a/desktop/src/shared/ui/markdown.test.mjs +++ b/desktop/src/shared/ui/markdown.test.mjs @@ -534,6 +534,7 @@ 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 { parseEntityLink } from "../lib/entityLink.ts"; import remarkSpoilers from "../lib/remarkSpoilers.ts"; @@ -545,7 +546,7 @@ const EVENT_HEX = function buzzDeepLinkUrlTransform(value, key) { if (key !== "href") return defaultUrlTransform(value); - if (isMessageLink(value)) return value; + if (isMessageLink(value) || isChannelLink(value)) return value; if (parseEntityLink(value).ok) return value; return defaultUrlTransform(value); } @@ -580,6 +581,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. @@ -654,13 +672,15 @@ test("buzzDeepLinkUrlTransform: strips malformed buzz://pr (unknown param)", () // the inline anchor click path (not just card extraction). import { renderEntityLinkAnchor } from "../ui/markdown/entityLinks.tsx"; +import { createMarkdownComponents } from "../ui/markdown.tsx"; +import { renderCachedMarkdown } from "../ui/markdown/nodeCache.ts"; +import { MarkdownRuntimeContext } from "../ui/markdown/runtimeContext.ts"; const CLONE_URL = `https://relay.example/git/${OWNER_HEX}/my-repo`; test("renderEntityLinkAnchor_matchingOriginCloneUrl_returnsEntityAnchor", () => { // Origin matches active relay — anchor should navigate in-app (non-null). const el = renderEntityLinkAnchor({ - anchorProps: {}, children: React.createElement("span", null, "my-repo"), href: CLONE_URL, onOpenEntityLink: () => {}, @@ -684,7 +704,6 @@ test("renderEntityLinkAnchor_matchingOriginCloneUrl_returnsEntityAnchor", () => test("renderEntityLinkAnchor_lookalikeDomainCloneUrl_returnsNull", () => { // Origin does NOT match active relay — must fall through to ExternalLinkAnchor. const el = renderEntityLinkAnchor({ - anchorProps: {}, children: React.createElement("span", null, "my-repo"), href: CLONE_URL, onOpenEntityLink: () => {}, @@ -700,7 +719,6 @@ test("renderEntityLinkAnchor_lookalikeDomainCloneUrl_returnsNull", () => { test("renderEntityLinkAnchor_noRelayOrigin_cloneUrlReturnsNull", () => { // No known relay origin — must fail closed, not guess. const el = renderEntityLinkAnchor({ - anchorProps: {}, children: React.createElement("span", null, "my-repo"), href: CLONE_URL, onOpenEntityLink: () => {}, @@ -717,7 +735,6 @@ test("renderEntityLinkAnchor_directEntityLink_returnsAnchorRegardlessOfOrigin", // A direct buzz://pr link always resolves in-app — it does not require origin. const prLink = `buzz://pr?id=${EVENT_HEX}&owner=${OWNER_HEX}&d=buzz-world`; const el = renderEntityLinkAnchor({ - anchorProps: {}, children: React.createElement("span", null, "My PR"), href: prLink, onOpenEntityLink: () => {}, @@ -1042,3 +1059,237 @@ test("nudgeGuard_noSentinel_proseRenderedCardAbsent", () => { "markdownNode must render when no sentinel is present", ); }); + +test("bare Buzz permalinks render cohesive icon-prefixed chips", () => { + const channelId = "580ca78b-9dae-46f3-8854-bd671853ba32"; + const messageLink = `buzz://message?channel=${channelId}&id=${EVENT_HEX}`; + const channelLink = `buzz://channel/${channelId}`; + const links = [ + messageLink, + channelLink, + `buzz://pr?id=${EVENT_HEX}&owner=${OWNER_HEX}&d=buzz-world`, + `buzz://issue?id=${EVENT_HEX}&owner=${OWNER_HEX}&d=buzz-world`, + `buzz://repo?owner=${OWNER_HEX}&d=buzz-world`, + ]; + const markdown = renderCachedMarkdown({ + components: createMarkdownComponents(true, false), + content: links.join(" "), + variant: "entity-link-integration-test", + }); + const html = renderToStaticMarkup( + React.createElement( + MarkdownRuntimeContext.Provider, + { + value: { + channels: [{ id: channelId, name: "engineering" }], + onOpenChannel: () => {}, + onOpenEntityLink: () => {}, + onOpenMessageLink: () => {}, + relayOrigin: null, + }, + }, + markdown, + ), + ); + + assert.equal((html.match(/data-buzz-link=""/g) ?? []).length, 5); + assert.match(html, /inline-chip-icon-message/); + assert.match(html, />engineering · c3b589faengineeringbuzz-world · c3b589fabuzz-world { + const channelId = "580ca78b-9dae-46f3-8854-bd671853ba32"; + const links = [ + `[the message](buzz://message?channel=${channelId}&id=${EVENT_HEX})`, + `[**design discussion**](buzz://channel/${channelId})`, + `[the issue](buzz://issue?id=${EVENT_HEX}&owner=${OWNER_HEX}&d=buzz-world)`, + ]; + const markdown = renderCachedMarkdown({ + components: createMarkdownComponents(true, false), + content: links.join(" "), + variant: "authored-buzz-link-integration-test", + }); + const html = renderToStaticMarkup( + React.createElement( + MarkdownRuntimeContext.Provider, + { + value: { + channels: [{ id: channelId, name: "engineering" }], + onOpenChannel: () => {}, + onOpenEntityLink: () => {}, + onOpenMessageLink: () => {}, + relayOrigin: null, + }, + }, + markdown, + ), + ); + + assert.equal((html.match(/data-buzz-link=""/g) ?? []).length, 0); + assert.match(html, />the messagedesign discussionthe issue { + const channelId = "580ca78b-9dae-46f3-8854-bd671853ba32"; + const markdown = renderCachedMarkdown({ + components: createMarkdownComponents(true, false), + content: [ + `buzz://message?channel=${channelId}&id=${EVENT_HEX}`, + `buzz://channel/${channelId}`, + ].join(" "), + variant: "unknown-channel-buzz-link-integration-test", + }); + const html = renderToStaticMarkup( + React.createElement( + MarkdownRuntimeContext.Provider, + { + value: { + channels: [], + onOpenChannel: () => {}, + onOpenEntityLink: () => {}, + onOpenMessageLink: () => {}, + relayOrigin: null, + }, + }, + markdown, + ), + ); + + assert.match(html, />580ca78b · c3b589fa580ca78b { + const channelId = "580ca78b-9dae-46f3-8854-bd671853ba32"; + const markdown = renderCachedMarkdown({ + channelNames: ["engineering"], + components: createMarkdownComponents(true, false), + content: "See #engineering", + variant: "channel-reference-icon-integration-test", + }); + const html = renderToStaticMarkup( + React.createElement( + MarkdownRuntimeContext.Provider, + { + value: { + channels: [{ id: channelId, name: "engineering" }], + onOpenChannel: () => {}, + onOpenEntityLink: () => {}, + onOpenMessageLink: () => {}, + relayOrigin: null, + }, + }, + markdown, + ), + ); + + assert.match(html, /inline-chip-icon-channel/); + assert.match(html, />engineering#engineering { + const markdown = renderCachedMarkdown({ + components: createMarkdownComponents(false, false), + content: "Ask @alice", + mentionNames: ["alice"], + variant: "human-mention-icon-integration-test", + }); + const html = renderToStaticMarkup( + React.createElement( + MarkdownRuntimeContext.Provider, + { + value: { + channels: [], + mentionPubkeysByName: { alice: HUMAN_PUBKEY }, + onOpenChannel: () => {}, + onOpenEntityLink: () => {}, + onOpenMessageLink: () => {}, + relayOrigin: null, + }, + }, + markdown, + ), + ); + + assert.match(html, /data-mention=""/); + assert.match(html, /inline-chip-icon-human/); + assert.match(html, />alice@alice { + const markdown = renderCachedMarkdown({ + components: createMarkdownComponents(false, false), + content: "Ask @alice", + mentionNames: ["alice"], + variant: "agent-mention-icon-integration-test", + }); + const html = renderToStaticMarkup( + React.createElement( + MarkdownRuntimeContext.Provider, + { + value: { + agentMentionPubkeysByName: { alice: AGENT_PUBKEY }, + channels: [], + mentionPubkeysByName: { alice: AGENT_PUBKEY }, + onOpenChannel: () => {}, + onOpenEntityLink: () => {}, + onOpenMessageLink: () => {}, + relayOrigin: null, + }, + }, + markdown, + ), + ); + + assert.match(html, /data-mention=""/); + assert.match(html, /agent-mention-highlight/); + assert.match(html, /inline-chip-icon-agent/); + assert.match(html, />alice@alice { + const prLink = `buzz://pr?id=${EVENT_HEX}&owner=${OWNER_HEX}&d=buzz-world`; + const el = renderEntityLinkAnchor({ + children: "PR · abc123", + href: prLink, + interactive: true, + onOpenEntityLink: () => {}, + relayOrigin: null, + }); + const html = renderToStaticMarkup(el); + assert.match(html, /data-buzz-link=""/); + assert.match(html, / - ); - } - - return ( - - {children} - - ); + const { onOpenEntityLink, relayOrigin } = useMarkdownRuntime(); + const href = String(children ?? ""); + if (!parseEntityLink(href).ok) + return {href}; + return renderEntityLinkAnchor({ + children: href, + href, + interactive, + onOpenEntityLink, + relayOrigin, + }); }, "message-link": function MarkdownMessageLink({ children, @@ -1697,11 +1686,9 @@ function createMarkdownComponents( const href = String(children ?? ""); const parsed = parseMessageLink(href); if (!parsed.ok) { - // Malformed `buzz://message?…` — render the raw URL as plain text - // rather than a misleading clickable pill. + // Malformed link: render the raw URL rather than a misleading pill. return {href}; } - return ( (); type MarkdownComponentSet = { components: Components; variant: string }; diff --git a/desktop/src/shared/ui/markdown/BuzzLinkChip.tsx b/desktop/src/shared/ui/markdown/BuzzLinkChip.tsx new file mode 100644 index 000000000..451bd9c30 --- /dev/null +++ b/desktop/src/shared/ui/markdown/BuzzLinkChip.tsx @@ -0,0 +1,152 @@ +import * as React from "react"; + +import { copyTextToClipboard } from "@/shared/lib/clipboard"; +import { InlineChip } from "@/shared/ui/InlineChip"; +import type { InlineChipIconKind } from "@/shared/ui/mentionChip"; + +import { + MediaContextMenu, + type MediaContextMenuPosition, + useDismissMediaContextMenu, +} from "./MediaContextMenu"; + +function useBuzzLinkContextMenu({ + href, + interactive, + onOpenLink, +}: { + href: string | undefined; + interactive: boolean; + onOpenLink: () => void; +}) { + const [position, setPosition] = + React.useState(null); + const closeMenu = React.useCallback(() => setPosition(null), []); + useDismissMediaContextMenu(Boolean(position), closeMenu); + + const onContextMenuCapture = React.useCallback( + (event: React.MouseEvent) => { + if (!interactive || !href) return; + event.preventDefault(); + setPosition({ x: event.clientX, y: event.clientY }); + }, + [href, interactive], + ); + + const contextMenu = + position && href ? ( + { + closeMenu(); + onOpenLink(); + }, + }, + { + label: "Copy link", + onSelect: () => { + closeMenu(); + copyTextToClipboard(href, "Link copied to clipboard"); + }, + }, + ]} + position={position} + /> + ) : null; + + return { contextMenu, onContextMenuCapture }; +} + +export function BuzzLinkChip({ + children, + className, + href, + icon: Icon, + interactive, + onOpenLink, + ...props +}: Omit, "onClick"> & { + href?: string; + icon: InlineChipIconKind; + interactive: boolean; + onOpenLink: () => void; +}) { + const { contextMenu, onContextMenuCapture } = useBuzzLinkContextMenu({ + href, + interactive, + onOpenLink, + }); + + if (!interactive) { + return ( + )} + data-buzz-link="" + className={className} + icon={Icon} + > + {children} + + ); + } + + return ( + <> + + {children} + + {contextMenu} + + ); +} + +export function BuzzInlineLink({ + children, + href, + interactive, + onOpenLink, + ...props +}: Omit, "onClick"> & { + href?: string; + interactive: boolean; + onOpenLink: () => void; +}) { + const contextMenuHref = + href ?? (typeof props.title === "string" ? props.title : undefined); + const { contextMenu, onContextMenuCapture } = useBuzzLinkContextMenu({ + href: contextMenuHref, + interactive, + onOpenLink, + }); + + if (!interactive) { + return {children}; + } + + return ( + <> + + {contextMenu} + + ); +} diff --git a/desktop/src/shared/ui/markdown/ChannelDeepLink.tsx b/desktop/src/shared/ui/markdown/ChannelDeepLink.tsx new file mode 100644 index 000000000..e5ca9eae8 --- /dev/null +++ b/desktop/src/shared/ui/markdown/ChannelDeepLink.tsx @@ -0,0 +1,118 @@ +import type * as React from "react"; + +import { + buildChannelLink, + parseChannelLink, +} from "@/features/messages/lib/channelLink"; + +import { BuzzInlineLink, BuzzLinkChip } from "./BuzzLinkChip"; +import { useMarkdownRuntime } from "./runtimeContext"; +import { getReactNodeText } from "./utils"; + +function channelPermalinkLabel( + channels: ReturnType["channels"], + channelId: string, +): string { + return ( + channels.find((candidate) => candidate.id === channelId)?.name ?? + channelId.slice(0, 8) + ); +} + +export function ChannelDeepLinkAnchor({ + children, + href, + interactive, +}: React.ComponentPropsWithoutRef<"a"> & { interactive: boolean }) { + const { channels, onOpenChannel } = useMarkdownRuntime(); + if (!href) return <>{children}; + const parsed = parseChannelLink(href); + if (!parsed.ok) return <>{children}; + const authoredLabel = getReactNodeText(children); + if (authoredLabel !== href) { + return ( + onOpenChannel(parsed.value.channelId)} + > + {children} + + ); + } + const label = channelPermalinkLabel(channels, parsed.value.channelId); + return ( + onOpenChannel(parsed.value.channelId)} + > + {label} + + ); +} + +export function MarkdownChannelDeepLink({ + children, + interactive, +}: { + children?: React.ReactNode; + interactive: boolean; +}) { + const { channels, onOpenChannel } = useMarkdownRuntime(); + const href = String(children ?? ""); + const parsed = parseChannelLink(href); + if (!parsed.ok) return {href}; + const label = channelPermalinkLabel(channels, parsed.value.channelId); + return ( + onOpenChannel(parsed.value.channelId)} + > + {label} + + ); +} + +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(), + ); + return ( + { + if (channel) onOpenChannel(channel.id); + }} + > + {channelName} + + ); +} diff --git a/desktop/src/shared/ui/markdown/MessageLinkPill.tsx b/desktop/src/shared/ui/markdown/MessageLinkPill.tsx index 73824d58c..1bbe33d95 100644 --- a/desktop/src/shared/ui/markdown/MessageLinkPill.tsx +++ b/desktop/src/shared/ui/markdown/MessageLinkPill.tsx @@ -1,17 +1,11 @@ import * as React from "react"; +import { buildMessageLink } from "@/features/messages/lib/messageLink"; import { cn } from "@/shared/lib/cn"; -import { - MENTION_CHIP_BASE_CLASSES, - MENTION_CHIP_HOVER_CLASSES, -} from "@/shared/ui/mentionChip"; +import { BuzzLinkChip } from "./BuzzLinkChip"; import type { MessageLinkPillProps } from "./types"; -import { - getMessageLinkChannelLabel, - getMessageLinkLabel, - MESSAGE_LINK_PREFIX, -} from "@/features/messages/lib/messageLinkLabel"; +import { getMessageLinkLabel } from "@/features/messages/lib/messageLinkLabel"; const graphemeSegmenter = typeof Intl.Segmenter === "function" @@ -46,6 +40,7 @@ function segmentLinkLabel(label: string): Array<{ export function MessageLinkPill({ channels, + href, interactive, link, onOpenMessageLink, @@ -54,65 +49,38 @@ export function MessageLinkPill({ }: MessageLinkPillProps) { const [isHovered, setIsHovered] = React.useState(false); const channel = channels.find((c) => c.id === link.channelId); - const channelLabel = channel?.name ?? "channel"; + const channelLabel = channel?.name ?? link.channelId.slice(0, 8); + const shortId = link.messageId.slice(0, 8); const isSentFromThread = variant === "sent-from-thread"; + const permalink = href ?? buildMessageLink(link); const label = getMessageLinkLabel({ channelName: channelLabel, threadExcerpt, variant, }); - const channelLinkLabel = getMessageLinkChannelLabel(channelLabel); - - if (!interactive) { - if (!isSentFromThread) { - return ( - - {MESSAGE_LINK_PREFIX} - - {channelLinkLabel} - - - ); - } - return ( - - {label} - - ); - } if (!isSentFromThread) { return ( - { + onOpenMessageLink(link); + }} > - {MESSAGE_LINK_PREFIX} - + {channelLabel} · {shortId} + + ); + } + + if (!interactive) { + return ( + + {label} ); } diff --git a/desktop/src/shared/ui/markdown/entityLinks.tsx b/desktop/src/shared/ui/markdown/entityLinks.tsx index b215110b8..d7d65e724 100644 --- a/desktop/src/shared/ui/markdown/entityLinks.tsx +++ b/desktop/src/shared/ui/markdown/entityLinks.tsx @@ -12,6 +12,31 @@ import { type SupportedLinkPreview, } from "@/shared/lib/linkPreview"; +import { BuzzInlineLink, BuzzLinkChip } from "./BuzzLinkChip"; + +function entityLinkPresentation(link: ParsedEntityLink) { + switch (link.type) { + case "repo": + return { + ariaLabel: `Open repository ${link.dtag}`, + icon: "repo" as const, + label: link.dtag, + }; + case "pr": + return { + ariaLabel: `Open pull request ${link.id.slice(0, 8)} in repository ${link.dtag}`, + icon: "pr" as const, + label: `${link.dtag} · ${link.id.slice(0, 8)}`, + }; + case "issue": + return { + ariaLabel: `Open issue ${link.id.slice(0, 8)} in repository ${link.dtag}`, + icon: "issue" as const, + label: `${link.dtag} · ${link.id.slice(0, 8)}`, + }; + } +} + /** * Navigate to the project detail view for a `buzz://pr|issue|repo` link. * The link's (owner, d) coordinate is exactly the `/projects/$projectId` @@ -76,17 +101,19 @@ function resolveEntityHref( * default anchor. */ export function renderEntityLinkAnchor({ - anchorProps, children, href, onOpenEntityLink, relayOrigin, + interactive = true, + asChip = true, }: { - anchorProps: React.ComponentPropsWithoutRef<"a">; children: React.ReactNode; href: string | undefined; onOpenEntityLink: (link: ParsedEntityLink) => void; relayOrigin: string | null; + interactive?: boolean; + asChip?: boolean; }): React.ReactElement | null { if (!href) return null; @@ -95,18 +122,33 @@ export function renderEntityLinkAnchor({ const parsed = parseEntityLink(canonicalHref); if (!parsed.ok) return null; + const presentation = entityLinkPresentation(parsed.value); + + if (!asChip) { + return ( + onOpenEntityLink(parsed.value)} + > + {children} + + ); + } return ( - { - event.preventDefault(); - onOpenEntityLink(parsed.value); - }} + icon={presentation.icon} + title={href} + aria-label={presentation.ariaLabel} + interactive={interactive} + onOpenLink={() => onOpenEntityLink(parsed.value)} > - {children} - + {presentation.label} + ); } diff --git a/desktop/src/shared/ui/markdown/nodeCache.ts b/desktop/src/shared/ui/markdown/nodeCache.ts index 1e2069342..a3ae0f5e2 100644 --- a/desktop/src/shared/ui/markdown/nodeCache.ts +++ b/desktop/src/shared/ui/markdown/nodeCache.ts @@ -3,7 +3,9 @@ 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 remarkEntityLinks from "@/features/messages/lib/remarkEntityLinks"; import rehypeImageGallery from "@/shared/lib/rehypeImageGallery"; import rehypeLeadingInlineContent from "@/shared/lib/rehypeLeadingInlineContent"; import rehypeSearchHighlight from "@/shared/lib/rehypeSearchHighlight"; @@ -104,7 +106,9 @@ function buildMarkdownElement(input: MarkdownParseInputs): React.ReactElement { remarkGfm, remarkBreaks, remarkSpoilers, + remarkChannelDeepLinks, remarkMessageLinks, + remarkEntityLinks, [remarkMentions, { mentionNames: input.mentionNames }], [remarkChannelLinks, { channelNames: input.channelNames }], [remarkCustomEmoji, { customEmoji: input.customEmoji }], diff --git a/desktop/src/shared/ui/markdown/types.ts b/desktop/src/shared/ui/markdown/types.ts index 947c06d34..69e575acb 100644 --- a/desktop/src/shared/ui/markdown/types.ts +++ b/desktop/src/shared/ui/markdown/types.ts @@ -22,6 +22,8 @@ export type ImetaLookup = Map; export type MessageLinkPillProps = { channels: Channel[]; + /** Original permalink text, preserved for the context menu's Copy action. */ + href?: string; interactive: boolean; link: ParsedMessageLink; onOpenMessageLink: (link: ParsedMessageLink) => void; diff --git a/desktop/src/shared/ui/markdown/utils.ts b/desktop/src/shared/ui/markdown/utils.ts index a35e60cad..f84984d51 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"; import { parseEntityLink } from "@/shared/lib/entityLink"; @@ -182,7 +183,7 @@ export function isInsideHiddenSpoiler(element: Element): boolean { */ export function buzzDeepLinkUrlTransform(value: string, key: string): string { if (key !== "href") return defaultUrlTransform(value); - if (isMessageLink(value)) return value; + if (isMessageLink(value) || isChannelLink(value)) return value; if (parseEntityLink(value).ok) return value; return defaultUrlTransform(value); } diff --git a/desktop/src/shared/ui/mentionChip.ts b/desktop/src/shared/ui/mentionChip.ts index e2f7c9880..0fa18f560 100644 --- a/desktop/src/shared/ui/mentionChip.ts +++ b/desktop/src/shared/ui/mentionChip.ts @@ -2,7 +2,29 @@ export const MENTION_CHIP_BASE_CLASSES = "mention-chip"; export const MENTION_CHIP_HOVER_CLASSES = "mention-chip-hover"; -export const MENTION_CHIP_PREFIX_CLASS = "mention-chip-prefix"; +export type InlineChipIconKind = + | "agent" + | "human" + | "channel" + | "message" + | "repo" + | "pr" + | "issue"; + +const INLINE_CHIP_ICON_KIND_CLASSES: Record = { + agent: "inline-chip-icon-agent agent-mention-highlight", + human: "inline-chip-icon-human human-mention-highlight", + channel: "inline-chip-icon-channel", + message: "inline-chip-icon-message", + repo: "inline-chip-icon-repo", + pr: "inline-chip-icon-pr", + issue: "inline-chip-icon-issue", +}; + +/** Shared icon-box contract for React chips and ProseMirror decorations. */ +export function inlineChipIconClasses(kind: InlineChipIconKind): string { + return `inline-chip-with-icon ${INLINE_CHIP_ICON_KIND_CLASSES[kind]}`; +} /** Wrapper on rendered message Markdown — scopes inline chip CSS. */ export const MESSAGE_MARKDOWN_CLASS = "message-markdown"; diff --git a/desktop/src/shared/useMessageDeepLinks.ts b/desktop/src/shared/useMessageDeepLinks.ts index d4478a442..fbbe4b9f6 100644 --- a/desktop/src/shared/useMessageDeepLinks.ts +++ b/desktop/src/shared/useMessageDeepLinks.ts @@ -1,7 +1,7 @@ import * as React from "react"; import { useAppNavigation } from "@/app/navigation/useAppNavigation"; -import { listenForMessageDeepLinks } from "@/shared/deep-link"; +import { listenForNavigationDeepLinks } from "@/shared/deep-link"; /** * Subscribe to `buzz://message` deep links emitted by the Tauri backend @@ -24,16 +24,24 @@ export function useMessageDeepLinks(enabled = true) { if (!enabled) return; let cancelled = false; - const unlistenPromise = listenForMessageDeepLinks((payload) => { - if (cancelled) return; - void goChannel(payload.channelId, { - messageId: payload.messageId, - threadRootId: payload.threadRootId, - }); - }); + const unlistenPromise = listenForNavigationDeepLinks( + async (payload) => { + if (cancelled) return false; + await goChannel(payload.channelId); + return true; + }, + async (payload) => { + if (cancelled) return false; + await goChannel(payload.channelId, { + messageId: payload.messageId, + threadRootId: payload.threadRootId, + }); + return true; + }, + ); return () => { cancelled = true; - void unlistenPromise.then((fn) => fn()); + void unlistenPromise.then((unlisten) => unlisten()); }; }, [enabled, goChannel]); } diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 4a224709e..20cc81a28 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -326,6 +326,8 @@ type E2eConfig = { /** Delay (ms) for `apply_workspace` so e2e tests can observe the * community-switch gate. 0/undefined = instant. */ applyCommunityDelayMs?: number; + /** Reject `clear_pending_navigation_deep_links` with this message. */ + clearPendingNavigationDeepLinksError?: string; openDmDelayMs?: number; sendMessageDelayMs?: number; /** Hold the media proxy at port 0 until the E2E release seam is invoked. */ @@ -476,6 +478,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; @@ -4363,6 +4372,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) { @@ -10176,6 +10203,7 @@ export function maybeInstallE2eTauriMocks() { resetMockPersonaCatalogEvents(config); resetMockSaveSubscriptions(config); resetMockPendingCommunityDeepLinks(config); + resetMockPendingNavigationDeepLinks(config); initializeMockHuddle(config.mock?.huddle, config); mockWebsocketSendMutexWedged = false; if (config.mock?.windowLabel) { @@ -11926,6 +11954,22 @@ export function maybeInstallE2eTauriMocks() { mockPendingCommunityDeepLinks.splice(index, 1); return true; } + case "clear_pending_navigation_deep_links": + if (activeConfig?.mock?.clearPendingNavigationDeepLinksError) { + throw new Error( + activeConfig.mock.clearPendingNavigationDeepLinksError, + ); + } + mockPendingNavigationDeepLinks.length = 0; + return; + case "take_pending_navigation_deep_link": + return mockPendingNavigationDeepLinks[0] ?? null; + case "acknowledge_pending_navigation_deep_link": { + const { id } = payload as { id: string }; + if (mockPendingNavigationDeepLinks[0]?.id !== id) return false; + mockPendingNavigationDeepLinks.shift(); + return true; + } case "get_relay_http_url": return getRelayHttpUrl(activeConfig); case "relay_requires_membership": diff --git a/desktop/tests/e2e/channels.spec.ts b/desktop/tests/e2e/channels.spec.ts index f80f3588e..074b13cfe 100644 --- a/desktop/tests/e2e/channels.spec.ts +++ b/desktop/tests/e2e/channels.spec.ts @@ -1871,7 +1871,7 @@ test("channel with messages shows content", async ({ page }) => { ); await expect(page.getByTestId("message-timeline-day-divider")).toBeVisible(); await expect(page.getByTestId("message-timeline")).toContainText( - "Welcome to #general", + "Welcome to general", ); }); @@ -2384,7 +2384,7 @@ test("sidebar shows unread indicator for newly active channels", async ({ await page.getByTestId("channel-random").click(); await expect(page.getByTestId("chat-title")).toHaveText("random"); await expect(page.getByTestId("message-timeline")).toContainText( - "Unread update for #random", + "Unread update for random", ); await expect(page.getByTestId("channel-unread-random")).toHaveCount(0); }); diff --git a/desktop/tests/e2e/community-rail.spec.ts b/desktop/tests/e2e/community-rail.spec.ts index 51b4867bf..4e6c73719 100644 --- a/desktop/tests/e2e/community-rail.spec.ts +++ b/desktop/tests/e2e/community-rail.spec.ts @@ -834,6 +834,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("leaving the final community returns to setup without resetting identity", async ({ @@ -904,6 +914,37 @@ test.describe("community rail", () => { .toEqual(identityBefore); }); + test("shows a recoverable error when leaving the final community cannot clear navigation", async ({ + page, + }) => { + await installMockBridge( + page, + { clearPendingNavigationDeepLinksError: "queue unavailable" }, + { skipCommunitySeed: true }, + ); + await seedCommunities(page, [COMMUNITY_A], COMMUNITY_A.id); + await page.goto("/"); + + await page.getByTestId("sidebar-profile-avatar-button").click(); + await page.getByTestId("community-switcher").click(); + await page + .getByRole("menu", { name: "Community actions" }) + .getByRole("menuitem", { name: "Leave community" }) + .click(); + + const error = page.getByTestId("community-apply-error"); + await expect(error).toBeVisible(); + await expect(error).toContainText( + "Could not safely leave community: queue unavailable", + ); + await expect(page.getByText("Join or create a community")).toHaveCount(0); + await expect(page.getByTestId("community-switch-gate")).toHaveCount(0); + await expect(page.getByTestId("community-apply-error-retry")).toBeVisible(); + await expect( + page.getByRole("button", { name: "Change community" }), + ).toBeVisible(); + }); + test("hides the rail with a single community", async ({ page }) => { await installMockBridge(page, undefined, { skipCommunitySeed: true }); await seedCommunities(page, [COMMUNITY_A], COMMUNITY_A.id); diff --git a/desktop/tests/e2e/empty-edit-delete.spec.ts b/desktop/tests/e2e/empty-edit-delete.spec.ts index 23a890ee2..2d8149966 100644 --- a/desktop/tests/e2e/empty-edit-delete.spec.ts +++ b/desktop/tests/e2e/empty-edit-delete.spec.ts @@ -7,6 +7,7 @@ import { installMockBridge } from "../helpers/bridge"; // message is exactly Sam's workflow: "delete a message by clearing its edit." const OWN_MESSAGE_ID = "mock-general-welcome"; const ORIGINAL_CONTENT = "Welcome to #general"; +const RENDERED_ORIGINAL_CONTENT = "Welcome to general"; // Open the more-actions menu for a message row and wait for the menu to mount. async function openMoreActionsMenu( @@ -87,8 +88,9 @@ test("cancelling the empty-edit delete keeps the message", async ({ page }) => { await expect(page.getByTestId("edit-target")).toBeVisible(); await expect(row).toBeVisible(); await expect(page.getByTestId("message-timeline")).toContainText( - ORIGINAL_CONTENT, + RENDERED_ORIGINAL_CONTENT, ); + await expect(row.getByLabel("Open channel general")).toBeVisible(); }); test("a non-empty edit still edits and never deletes", async ({ page }) => { @@ -115,6 +117,6 @@ test("a non-empty edit still edits and never deletes", async ({ page }) => { editedContent, ); await expect(page.getByTestId("message-timeline")).not.toContainText( - ORIGINAL_CONTENT, + RENDERED_ORIGINAL_CONTENT, ); }); diff --git a/desktop/tests/e2e/integration.spec.ts b/desktop/tests/e2e/integration.spec.ts index 688749e94..730bf5460 100644 --- a/desktop/tests/e2e/integration.spec.ts +++ b/desktop/tests/e2e/integration.spec.ts @@ -314,7 +314,7 @@ test("live mentions refetch the home feed without waiting for polling", async ({ .click(); await expect(targetPage.getByTestId("home-inbox-list")).toBeVisible(); await expect(targetPage.getByTestId("home-inbox-list")).toContainText( - message, + message.replace("@tyler", "tyler"), ); await expect(targetPage.getByTestId("sidebar-home-count")).toHaveCount(0); await expect.poll(() => getLoggedNotificationCount(targetPage)).toBe(1); @@ -371,7 +371,7 @@ test("live forum mentions refetch the home feed without waiting for polling", as await expect(targetPage.getByTestId("home-inbox-list")).toBeVisible(); await expect(targetPage.getByTestId("home-inbox-list")).toBeVisible(); await expect(targetPage.getByTestId("home-inbox-list")).toContainText( - message, + message.replace("@tyler", "tyler"), ); await expect(targetPage.getByTestId("sidebar-home-count")).toHaveCount(0); await expect.poll(() => getLoggedNotificationCount(targetPage)).toBe(1); diff --git a/desktop/tests/e2e/mentions.spec.ts b/desktop/tests/e2e/mentions.spec.ts index 06fa8cc70..801000189 100644 --- a/desktop/tests/e2e/mentions.spec.ts +++ b/desktop/tests/e2e/mentions.spec.ts @@ -634,11 +634,56 @@ test("selecting a person mention inserts @Name into input", async ({ await dropdown.getByText("bob").click(); await expect(input).toHaveText("Hey @bob "); - const mentionChip = input.locator(".mention-chip", { - hasText: "@bob", + const mentionChip = input.locator(".human-mention-highlight", { + hasText: "bob", }); await expect(mentionChip).toBeVisible(); + await expect(mentionChip).toHaveText("bob"); await expect(mentionChip).not.toHaveClass(/agent-mention-highlight/); + await expect(mentionChip).toHaveCSS("display", "inline-flex"); + await expect( + input.locator(".mention-prefix-hidden", { hasText: "@" }), + ).toHaveCount(1); + const iconMask = await mentionChip.evaluate((element) => + getComputedStyle(element, "::before").getPropertyValue( + "-webkit-mask-image", + ), + ); + expect(iconMask).toContain("data:image/svg+xml"); +}); + +test("channel references keep caret movement through the channel name", async ({ + page, +}) => { + await page.goto("/"); + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); + + const input = page.getByTestId("message-input"); + await input.fill("#general"); + + const channelChip = input.locator(".inline-chip-icon-channel", { + hasText: "general", + }); + await expect(channelChip).toBeVisible(); + await expect(channelChip).toHaveText("general"); + await expect( + input.locator(".mention-prefix-hidden", { hasText: "#" }), + ).toHaveCount(1); + const iconMask = await channelChip.evaluate((element) => + getComputedStyle(element, "::before").getPropertyValue( + "-webkit-mask-image", + ), + ); + expect(iconMask).toContain("data:image/svg+xml"); + + await input.focus(); + await input.press("ArrowLeft"); + await input.press("ArrowLeft"); + await input.press("ArrowLeft"); + await page.keyboard.type("X"); + + await expect(input).toHaveText("#geneXral"); }); test("selecting a managed agent mention inserts @Name into input", async ({ @@ -2112,9 +2157,9 @@ test("sent non-member person mention uses the normal mention style", async ({ const mentionChip = page .getByTestId("message-row") .last() - .locator("[data-mention]", { hasText: "@outsider" }); + .locator("[data-mention]", { hasText: "outsider" }); await expect(mentionChip).toBeVisible(); - await expect(mentionChip.locator("svg")).toHaveCount(0); + await expect(mentionChip).toHaveClass(/inline-chip-icon-human/); }); test("sent managed non-member agent mention uses the agent mention style", async ({ @@ -2252,8 +2297,8 @@ test("mention text is highlighted in sent messages", async ({ page }) => { .last() .locator("[data-mention].mention-chip", { hasText: "bob" }); await expect(mentionChip).toBeVisible(); - await expect(mentionChip.locator(".mention-chip-prefix")).toHaveText("@"); - await expect(mentionChip.locator("svg")).toHaveCount(0); + await expect(mentionChip).toHaveText("bob"); + await expect(mentionChip).toHaveClass(/inline-chip-icon-human/); }); test("clicking author name opens user profile panel", async ({ page }) => { @@ -2312,8 +2357,8 @@ test("clicking a mention chip in the timeline opens the profile panel", async ({ const mentionChip = page .getByTestId("message-row") - .filter({ hasText: "Ping @bob about the launch" }) - .locator("[data-mention]", { hasText: "@bob" }); + .filter({ hasText: "Ping bob about the launch" }) + .locator("[data-mention]", { hasText: "bob" }); await expect(mentionChip).toBeVisible(); await mentionChip.click(); @@ -2340,8 +2385,8 @@ test("mention text matching the kind-0 name alias resolves and opens the profile const mentionChip = page .getByTestId("message-row") - .filter({ hasText: "Ask @bobby to review the doc" }) - .locator("[data-mention]", { hasText: "@bobby" }); + .filter({ hasText: "Ask bobby to review the doc" }) + .locator("[data-mention]", { hasText: "bobby" }); await expect(mentionChip).toBeVisible(); await mentionChip.click(); @@ -2366,7 +2411,7 @@ test("clicking a mention chip in a forum post opens the profile panel", async ({ await page.getByTestId("channel-watercooler").click(); await expect(page.getByTestId("chat-title")).toHaveText("watercooler"); - const mentionChip = page.locator("[data-mention]", { hasText: "@bob" }); + const mentionChip = page.locator("[data-mention]", { hasText: "bob" }); await expect(mentionChip).toBeVisible(); await mentionChip.click(); diff --git a/desktop/tests/e2e/messaging.spec.ts b/desktop/tests/e2e/messaging.spec.ts index cdfe8e84b..6e003195b 100644 --- a/desktop/tests/e2e/messaging.spec.ts +++ b/desktop/tests/e2e/messaging.spec.ts @@ -1852,7 +1852,7 @@ test("day divider appears in timeline", async ({ page }) => { await expect(page.getByTestId("chat-title")).toHaveText("general"); await expect(page.getByTestId("message-timeline")).toContainText( - "Welcome to #general", + "Welcome to general", ); await expect(page.getByTestId("message-timeline-day-divider")).toBeVisible(); }); @@ -2210,7 +2210,7 @@ test("opens a single-level thread panel with inline expansion", async ({ await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); await expect(page.getByTestId("message-timeline")).toContainText( - "Welcome to #general", + "Welcome to general", ); const timeline = page.getByTestId("message-timeline"); @@ -2233,7 +2233,7 @@ test("opens a single-level thread panel with inline expansion", async ({ await rootMessage.getByRole("button", { name: "Reply" }).click(); await expect(threadPanel).toBeVisible(); await expect(threadPanel.getByTestId("message-thread-head")).toContainText( - "Welcome to #general", + "Welcome to general", ); await threadComposer.fill(firstReply); @@ -2379,7 +2379,7 @@ test("opens a single-level thread panel with inline expansion", async ({ await rootSummaryRow.click(); await expect(threadPanel).toBeVisible(); await expect(threadPanel.getByTestId("message-thread-head")).toContainText( - "Welcome to #general", + "Welcome to general", ); const firstReplyRow = threadReplies @@ -2390,7 +2390,7 @@ test("opens a single-level thread panel with inline expansion", async ({ await firstReplyRow.getByRole("button", { name: "Reply" }).click(); await expect(threadPanel.getByTestId("message-thread-head")).toContainText( - "Welcome to #general", + "Welcome to general", ); await expect(threadPanel.getByTestId("message-thread-back")).toHaveCount(0); diff --git a/desktop/tests/e2e/navigation.spec.ts b/desktop/tests/e2e/navigation.spec.ts index eb76ef3a4..8adee2c0c 100644 --- a/desktop/tests/e2e/navigation.spec.ts +++ b/desktop/tests/e2e/navigation.spec.ts @@ -337,6 +337,61 @@ test("settings shortcut returns without opening search dialog", async ({ await expect(page.getByTestId("search-results")).not.toBeVisible(); }); +test("mixed Buzz permalinks render as chips in the composer", async ({ + page, +}) => { + await page.goto("/"); + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); + + const channelId = "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50"; + const owner = "a".repeat(64); + const pullRequestId = "c".repeat(64); + const issueId = "b".repeat(64); + const links = [ + `buzz://message?channel=${channelId}&id=mock-general-welcome`, + `buzz://channel/${channelId}`, + `buzz://repo?owner=${owner}&d=buzz-world`, + `buzz://pr?id=${pullRequestId}&owner=${owner}&d=buzz-world`, + `buzz://issue?id=${issueId}&owner=${owner}&d=buzz-world`, + ].join(" "); + const composerInput = page.getByTestId("message-input"); + await composerInput.evaluate((element, text) => { + const clipboardData = new DataTransfer(); + clipboardData.setData("text/plain", text); + element.dispatchEvent( + new ClipboardEvent("paste", { + bubbles: true, + cancelable: true, + clipboardData, + }), + ); + }, links); + + const chips = composerInput.locator('[data-composer-buzz-link=""]'); + await expect(chips).toHaveCount(5); + await expect(chips.nth(0)).toHaveText("general · mock-gen"); + await expect(chips.nth(1)).toHaveText("general"); + await expect(chips.nth(2)).toHaveText("buzz-world"); + await expect(chips.nth(3)).toHaveText("buzz-world · cccccccc"); + await expect(chips.nth(4)).toHaveText("buzz-world · bbbbbbbb"); + await expect(chips.nth(1)).toHaveClass(/inline-chip-icon-channel/); + await expect(chips.nth(2)).toHaveClass(/inline-chip-icon-repo/); + await expect(chips.nth(3)).toHaveClass(/inline-chip-icon-pr/); + await expect(chips.nth(4)).toHaveClass(/inline-chip-icon-issue/); + for (const index of [0, 1, 2, 3, 4]) { + const iconMask = await chips + .nth(index) + .evaluate((element) => + getComputedStyle(element, "::before").getPropertyValue( + "-webkit-mask-image", + ), + ); + expect(iconMask).toContain("data:image/svg+xml"); + } + await expect(composerInput).not.toContainText("buzz://"); +}); + test("message links to visible root messages open the thread panel", async ({ page, }) => { @@ -344,13 +399,13 @@ test("message links to visible root messages open the thread panel", async ({ await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); await expect(page.getByTestId("message-timeline")).toContainText( - "Welcome to #general", + "Welcome to general", ); const link = "buzz://message?channel=9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50&id=mock-general-welcome"; const composerInput = page.getByTestId("message-input"); - await composerInput.fill("Root link repro "); + await composerInput.fill("Root link repro #random "); await composerInput.focus(); await composerInput.evaluate((element, href) => { const clipboardData = new DataTransfer(); @@ -364,11 +419,10 @@ test("message links to visible root messages open the thread panel", async ({ ); }, link); const composerLink = composerInput.locator('[data-composer-message-link=""]'); - await expect(composerLink).toContainText("Thread in"); - const composerChannelLink = composerLink.locator('[data-channel-link=""]'); - await expect(composerChannelLink).toHaveText("#general"); - await expect(composerChannelLink).toHaveClass(/mention-chip/); - await expect(composerLink).not.toHaveClass(/mention-chip/); + await expect(composerLink).toHaveText("general · mock-gen"); + await expect(composerLink).toHaveClass(/mention-chip/); + await expect(composerLink).toHaveClass(/inline-chip-icon-message/); + await expect(composerLink).toHaveAttribute("data-buzz-link", ""); await expect(composerLink).toHaveAttribute("title", "Thread in #general"); await expect(composerInput).not.toContainText("buzz://message"); await page.getByTestId("send-message").click(); @@ -379,20 +433,51 @@ test("message links to visible root messages open the thread panel", async ({ .last(); await expect(linkMessage).toBeVisible(); const rootThreadLink = linkMessage.getByRole("button", { - name: "Open thread in general", + name: "Open message mock-gen in channel general", }); - await expect(linkMessage.locator('[data-message-link=""]')).toContainText( - "Thread in", - ); - await expect(rootThreadLink).toHaveText("#general"); + await expect(rootThreadLink).toHaveText("general · mock-gen"); await expect(rootThreadLink).toHaveClass(/mention-chip/); - await rootThreadLink.click(); + const randomChannelLink = linkMessage.getByRole("button", { + name: "Open channel random", + }); + await expect(randomChannelLink).toBeVisible(); + await rootThreadLink.click({ button: "right" }); + + const linkMenu = page.locator("[data-buzz-link-context-menu]"); + await expect(linkMenu).toBeVisible(); + await randomChannelLink.click({ button: "right" }); + await expect(linkMenu).toHaveCount(1); + await rootThreadLink.click({ button: "right" }); + await expect(linkMenu).toHaveCount(1); + await expect( + linkMenu.getByRole("button", { name: "Open link" }), + ).toBeVisible(); + await linkMenu.getByRole("button", { name: "Copy link" }).click(); + await expect + .poll(() => + page.evaluate(() => { + return ( + window as Window & { + __BUZZ_E2E_COMMAND_LOG__?: Array<{ + command: string; + payload: { text?: string }; + }>; + } + ).__BUZZ_E2E_COMMAND_LOG__?.findLast( + ({ command }) => command === "copy_text_to_clipboard", + )?.payload.text; + }), + ) + .toBe(link); + + await rootThreadLink.click({ button: "right" }); + await linkMenu.getByRole("button", { name: "Open link" }).click(); const threadPanel = page.getByTestId("message-thread-panel"); await expect(threadPanel).toBeVisible(); await expect(page).toHaveURL(/thread=mock-general-welcome/); await expect(threadPanel.getByTestId("message-thread-head")).toContainText( - "Welcome to #general", + "Welcome to general", ); }); @@ -407,7 +492,7 @@ test("message links reopen a closed thread when the same messageId is already in const threadPanel = page.getByTestId("message-thread-panel"); await expect(threadPanel).toBeVisible(); await expect(threadPanel.getByTestId("message-thread-head")).toContainText( - "Welcome to #general", + "Welcome to general", ); await threadPanel.getByRole("button", { name: "Close panel" }).click(); @@ -426,14 +511,14 @@ test("message links reopen a closed thread when the same messageId is already in .last(); await expect(linkMessage).toBeVisible(); const rootThreadLink = linkMessage.getByRole("button", { - name: "Open thread in general", + name: "Open message mock-gen in channel general", }); - await expect(rootThreadLink).toHaveText("#general"); + await expect(rootThreadLink).toHaveText("general · mock-gen"); await rootThreadLink.click(); await expect(threadPanel).toBeVisible(); await expect(threadPanel.getByTestId("message-thread-head")).toContainText( - "Welcome to #general", + "Welcome to general", ); }); @@ -454,3 +539,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/e2e/onboarding.spec.ts b/desktop/tests/e2e/onboarding.spec.ts index 2ef3d5825..2d8e58492 100644 --- a/desktop/tests/e2e/onboarding.spec.ts +++ b/desktop/tests/e2e/onboarding.spec.ts @@ -3214,7 +3214,7 @@ test("first-run onboarding posts the live Fizz kickoff", async ({ page }) => { // Greeted by the name typed above — the @mention pill also files the opener // into the new user's Inbox mentions feed. await expect(page.getByTestId("message-timeline")).toContainText( - "Hi @Morty QA, I'm Fizz. Welcome to Buzz.", + "Hi Morty QA, I'm Fizz. Welcome to Buzz.", ); await expect(page.getByTestId("message-timeline")).toContainText( "Honey and Bumble, introduce yourselves", @@ -3238,7 +3238,7 @@ test("first-run onboarding lands before Welcome team bootstrap completes", async await expectPrivateWelcomeLanding(page); await expect(page.getByTestId("app-loading-gate")).toHaveCount(0); await expect(page.getByTestId("message-timeline")).toContainText( - "Hi @Morty QA, I'm Fizz. Welcome to Buzz.", + "Hi Morty QA, I'm Fizz. Welcome to Buzz.", ); await page.waitForTimeout(1_500); expect(await commandCount(page, "create_managed_agent")).toBe(3); diff --git a/desktop/tests/e2e/relay-reconnect.spec.ts b/desktop/tests/e2e/relay-reconnect.spec.ts index 289d8ef31..74f819cc5 100644 --- a/desktop/tests/e2e/relay-reconnect.spec.ts +++ b/desktop/tests/e2e/relay-reconnect.spec.ts @@ -349,7 +349,7 @@ test("passive relay watchdog does not write while the websocket is half-open", a await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); await expect(page.getByTestId("message-timeline")).toContainText( - "Welcome to #general", + "Welcome to general", ); await setMockWebsocketSendsStalled(page, true); diff --git a/desktop/tests/e2e/smoke.spec.ts b/desktop/tests/e2e/smoke.spec.ts index 11de497f4..b3ea8bea6 100644 --- a/desktop/tests/e2e/smoke.spec.ts +++ b/desktop/tests/e2e/smoke.spec.ts @@ -437,7 +437,7 @@ test("global search offers an optional current-channel scope", async ({ const firstScopedResult = page .locator('[data-search-section="messages"] .search-result-row') .first(); - await expect(page.getByText("Welcome to #general")).toBeVisible(); + await expect(page.getByText("Welcome to general")).toBeVisible(); await expect(page.getByText(/Searching messages in/)).toHaveCount(0); await expect(relevantHeader).toBeVisible(); await expect(firstScopedResult).toBeVisible(); @@ -756,7 +756,7 @@ test("replaces the channel pane when switching channels", async ({ page }) => { await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); await expect(page.getByTestId("message-timeline")).toContainText( - "Welcome to #general", + "Welcome to general", ); await page.getByTestId("channel-random").click(); @@ -766,7 +766,7 @@ test("replaces the channel pane when switching channels", async ({ page }) => { "This is the beginning of the regular channel.", ); await expect(page.getByTestId("message-timeline")).not.toContainText( - "Welcome to #general", + "Welcome to general", ); await expect(page.getByTestId("message-timeline")).toHaveCount(1); await expect(page.getByTestId("message-timeline-day-divider")).toHaveCount(0); diff --git a/desktop/tests/helpers/bridge.ts b/desktop/tests/helpers/bridge.ts index c7a8ba1a0..79ada74c8 100644 --- a/desktop/tests/helpers/bridge.ts +++ b/desktop/tests/helpers/bridge.ts @@ -280,6 +280,8 @@ type MockBridgeOptions = { canvasReadError?: string; /** Delay (ms) for `apply_workspace`; see e2eBridge mock config. */ applyCommunityDelayMs?: number; + /** Reject `clear_pending_navigation_deep_links` with this message. */ + clearPendingNavigationDeepLinksError?: string; openDmDelayMs?: number; sendMessageDelayMs?: number; /** Hold the media proxy at port 0 until the E2E release seam is invoked. */ @@ -463,6 +465,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.