diff --git a/desktop/src-tauri/src/deep_link.rs b/desktop/src-tauri/src/deep_link.rs index ffe951dc367..098b3f1e79e 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 7aa954ce8e6..0dd0ee717b0 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 0c27ab0541f..dc0ac167c3a 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 00000000000..7f51a39cefa --- /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 00000000000..4bfc15debaf --- /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 867f37677da..85bcb545520 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 5431e2c9605..83cc0aa58da 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 1fad4140845..a8d3ef8ff0a 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 00000000000..ce45d2fa549 --- /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 00000000000..efafec770e3 --- /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 00000000000..1d7fdeacb3d --- /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 00000000000..41cf4af20b5 --- /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 fa95d23d442..cbeb4fec787 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 00000000000..ea478aff4ef --- /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 c62a8bec3ba..8bd7675182f 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 6148ffeb7e8..720dc30a4a5 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 00000000000..3bddd2f4e39 --- /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 670725f1f29..d040dd55b15 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 00000000000..451bd9c303a --- /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 00000000000..e5ca9eae89b --- /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 73824d58c79..1bbe33d9579 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} - - - ); - } + if (!isSentFromThread) { return ( - - {label} - + { + onOpenMessageLink(link); + }} + > + {channelLabel} · {shortId} + ); } - if (!isSentFromThread) { + if (!interactive) { return ( - - {MESSAGE_LINK_PREFIX} - + + {label} ); } diff --git a/desktop/src/shared/ui/markdown/entityLinks.tsx b/desktop/src/shared/ui/markdown/entityLinks.tsx index b215110b862..d7d65e72491 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 1e206934203..a3ae0f5e202 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 947c06d34a1..69e575acbe2 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 a35e60cadc3..f84984d51e5 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 e2f7c98803b..0fa18f56034 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 d4478a44226..fbbe4b9f67a 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 4a224709ea0..20cc81a2888 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 f80f3588eff..074b13cfe2c 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 51b4867bf46..4e6c737190f 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 23a890ee296..2d8149966ff 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 688749e944e..730bf54608f 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 06fa8cc7066..801000189f6 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 cdfe8e84b6d..6e003195b3f 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 eb76ef3a4f9..8adee2c0cf1 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 2ef3d5825c3..2d8e58492fe 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 289d8ef3182..74f819cc51a 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 11de497f4c4..b3ea8bea617 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 c7a8ba1a087..79ada74c86d 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.