From 4b43aa207e75bf5c63e23e81db1f53f9be5ef66c Mon Sep 17 00:00:00 2001 From: Wes Date: Wed, 27 May 2026 08:46:21 -0700 Subject: [PATCH 1/5] feat(desktop): copy-link-to-message + sprout:// click + OS deep link MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a Slack-style "Copy link" action to message rows that produces a `sprout://message?channel=&id=[&thread=]` URL. Pasted/typed sprout:// links render as clickable in-app links and route through the existing channel route's scroll-into-view + getEventById backfill. The same scheme is also handled at the OS level via the already-registered Tauri deep-link plugin. - New `features/messages/lib/messageLink.{ts,test.mjs}` — build/parse helpers + 10 round-trip / validation tests. - `MessageActionBar` (+ `MessageRow`, `InboxMessageRow`/`InboxDetailPane`) — Copy link dropdown item, gated on a `channelId` prop so callers without it (rare) silently hide the action. - `shared/ui/markdown.tsx` — `a` override branches on `isMessageLink(href)` and dispatches to `useAppNavigation.goChannel`. http(s) behavior unchanged. - `features/messages/lib/useRichTextEditor.ts` — adds `protocols: ["sprout"]` so TipTap's URL sanitiser doesn't strip the scheme on paste/typed input. - `src-tauri/src/lib.rs` — `Some("message")` arm in `handle_deep_link_url` emits `deep-link-message` with a JSON payload (channelId, messageId, threadRootId). - `shared/deep-link.ts` — adds `listenForMessageDeepLinks` + `MessageDeepLinkPayload`. New `shared/useMessageDeepLinks.ts` hook wires it into the router from inside `AppShell` (kept out of `App.tsx` because `useAppNavigation` requires the router tree). Routing simplification: the brief allowed skipping forum-vs-stream detection if not trivially detectable. Rather than guess from `threadRootId`, both the in-app and OS handlers always go through `goChannel` with `messageId`; the channel route's existing infra resolves the target whether it's a stream reply or a forum thread. `threadRootId` is still parsed and preserved end-to-end for future use. Verified: pnpm typecheck, pnpm check (biome + file-size), 289 node:test units (incl. 10 new), pnpm build, just desktop-tauri-fmt-check, just desktop-tauri-check, cargo test --lib (374 passed). Signed-off-by: Wes --- desktop/src-tauri/src/lib.rs | 31 ++++++ desktop/src/app/AppShell.tsx | 4 + .../src/features/home/ui/InboxDetailPane.tsx | 1 + .../src/features/home/ui/InboxMessageRow.tsx | 4 + .../messages/lib/messageLink.test.mjs | 105 ++++++++++++++++++ .../src/features/messages/lib/messageLink.ts | 101 +++++++++++++++++ .../messages/lib/useRichTextEditor.ts | 4 + .../features/messages/ui/MessageActionBar.tsx | 30 +++++ .../src/features/messages/ui/MessageRow.tsx | 2 + desktop/src/shared/deep-link.ts | 27 +++++ desktop/src/shared/ui/markdown.tsx | 64 +++++++++-- desktop/src/shared/useMessageDeepLinks.ts | 34 ++++++ 12 files changed, 396 insertions(+), 11 deletions(-) create mode 100644 desktop/src/features/messages/lib/messageLink.test.mjs create mode 100644 desktop/src/features/messages/lib/messageLink.ts create mode 100644 desktop/src/shared/useMessageDeepLinks.ts diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 968396431..16ab38f0e 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -199,6 +199,37 @@ fn handle_deep_link_url(app: &tauri::AppHandle, url_str: &str) { } let _ = app.emit("deep-link-connect", relay_url); } + Some("message") => { + // `sprout://message?channel=&id=[&thread=]` + // + // Validation policy mirrors the `connect` arm: parse what we + // need, refuse to emit anything if a required param is missing + // so the frontend never sees a half-formed payload. The + // frontend listener mirrors `parseMessageLink` in TS — we keep + // structure on this side (serde JSON) and let the TS code own + // any further normalisation. + let mut channel: Option = None; + let mut message_id: Option = None; + let mut thread: Option = None; + for (k, v) in url.query_pairs() { + match k.as_ref() { + "channel" => channel = Some(v.into_owned()), + "id" => message_id = Some(v.into_owned()), + "thread" => thread = Some(v.into_owned()), + _ => {} + } + } + let (Some(channel_id), Some(message_id)) = (channel, message_id) else { + eprintln!("sprout-desktop: message deep link missing channel or id: {url_str}"); + return; + }; + let payload = serde_json::json!({ + "channelId": channel_id, + "messageId": message_id, + "threadRootId": thread, + }); + let _ = app.emit("deep-link-message", payload); + } Some(action) => { eprintln!("sprout-desktop: unknown deep link action: {action}"); } diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index ba033d194..a51c2011b 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -60,6 +60,7 @@ import { joinChannel } from "@/shared/api/tauri"; import type { Channel, RelayEvent, SearchHit } from "@/shared/api/types"; import { ChannelNavigationProvider } from "@/shared/context/ChannelNavigationContext"; import { hasPrimaryShortcutModifier } from "@/shared/lib/platform"; +import { useMessageDeepLinks } from "@/shared/useMessageDeepLinks"; import { Button } from "@/shared/ui/button"; import { SidebarInset, @@ -450,6 +451,9 @@ export function AppShell() { void setDesktopAppBadgeCount(unreadChannelIds.size + homeBadgeCount); }, [homeBadgeCount, unreadChannelIds.size]); + // Dispatch `sprout://message` deep links into the router. + useMessageDeepLinks(); + React.useEffect(() => { let isCancelled = false; let cleanup = () => {}; diff --git a/desktop/src/features/home/ui/InboxDetailPane.tsx b/desktop/src/features/home/ui/InboxDetailPane.tsx index dbc779f02..70da049c0 100644 --- a/desktop/src/features/home/ui/InboxDetailPane.tsx +++ b/desktop/src/features/home/ui/InboxDetailPane.tsx @@ -260,6 +260,7 @@ export function InboxDetailPane({ void; @@ -42,6 +44,7 @@ type InboxMessageRowProps = { export function InboxMessageRow({ activeReplyTargetId, canReply, + channelId = null, isFocusHighlightVisible, message, onSelectReplyTarget, @@ -87,6 +90,7 @@ export function InboxMessageRow({
{ + const url = buildMessageLink({ channelId: CHANNEL, messageId: MESSAGE }); + assert.equal(url, `sprout://message?channel=${CHANNEL}&id=${MESSAGE}`); + + const parsed = parseMessageLink(url); + assert.equal(parsed.ok, true); + assert.deepEqual(parsed.ok && parsed.value, { + channelId: CHANNEL, + messageId: MESSAGE, + threadRootId: null, + }); +}); + +test("buildMessageLink → parseMessageLink round-trips with thread", () => { + const url = buildMessageLink({ + channelId: CHANNEL, + messageId: MESSAGE, + threadRootId: THREAD, + }); + const parsed = parseMessageLink(url); + assert.equal(parsed.ok, true); + assert.deepEqual(parsed.ok && parsed.value, { + channelId: CHANNEL, + messageId: MESSAGE, + threadRootId: THREAD, + }); +}); + +test("buildMessageLink treats null/empty thread as absent", () => { + const a = buildMessageLink({ + channelId: CHANNEL, + messageId: MESSAGE, + threadRootId: null, + }); + const b = buildMessageLink({ + channelId: CHANNEL, + messageId: MESSAGE, + threadRootId: "", + }); + assert.equal(a, `sprout://message?channel=${CHANNEL}&id=${MESSAGE}`); + assert.equal(b, `sprout://message?channel=${CHANNEL}&id=${MESSAGE}`); +}); + +test("buildMessageLink rejects missing required params", () => { + assert.throws(() => buildMessageLink({ channelId: "", messageId: MESSAGE })); + assert.throws(() => buildMessageLink({ channelId: CHANNEL, messageId: "" })); +}); + +test("parseMessageLink rejects non-sprout schemes", () => { + const r = parseMessageLink( + `https://example.com/?channel=${CHANNEL}&id=${MESSAGE}`, + ); + assert.equal(r.ok, false); + assert.equal(r.ok === false && r.reason, "wrong-scheme"); +}); + +test("parseMessageLink rejects sprout:// with wrong host", () => { + const r = parseMessageLink(`sprout://connect?relay=wss://example.com`); + assert.equal(r.ok, false); + assert.equal(r.ok === false && r.reason, "wrong-host"); +}); + +test("parseMessageLink rejects missing channel", () => { + const r = parseMessageLink(`sprout://message?id=${MESSAGE}`); + assert.equal(r.ok, false); + assert.equal(r.ok === false && r.reason, "missing-channel"); +}); + +test("parseMessageLink rejects missing id", () => { + const r = parseMessageLink(`sprout://message?channel=${CHANNEL}`); + assert.equal(r.ok, false); + assert.equal(r.ok === false && r.reason, "missing-id"); +}); + +test("parseMessageLink rejects malformed URL strings", () => { + const r = parseMessageLink("not a url"); + assert.equal(r.ok, false); + assert.equal(r.ok === false && r.reason, "invalid-url"); +}); + +test("isMessageLink matches only sprout://message", () => { + assert.equal( + isMessageLink(`sprout://message?channel=${CHANNEL}&id=${MESSAGE}`), + true, + ); + assert.equal(isMessageLink("sprout://connect?relay=wss://x"), false); + assert.equal(isMessageLink("https://example.com"), false); + assert.equal(isMessageLink(undefined), false); + assert.equal(isMessageLink(""), false); +}); diff --git a/desktop/src/features/messages/lib/messageLink.ts b/desktop/src/features/messages/lib/messageLink.ts new file mode 100644 index 000000000..793f82f0b --- /dev/null +++ b/desktop/src/features/messages/lib/messageLink.ts @@ -0,0 +1,101 @@ +/** + * `sprout://message` link encoding for "Copy link" / deep-link-to-message. + * + * Format: `sprout://message?channel=&id=[&thread=]` + * + * Mirrors the existing `sprout://connect?relay=…` scheme already registered + * in `tauri.conf.json` and handled in `desktop/src-tauri/src/lib.rs`. + */ + +const MESSAGE_LINK_HOST = "message"; + +export type MessageLinkInput = { + channelId: string; + messageId: string; + /** + * Optional thread root event id. Present when the linked message is a + * reply (so the caller can route into a thread / forum post view). + */ + threadRootId?: string | null; +}; + +export type ParsedMessageLink = { + channelId: string; + messageId: string; + threadRootId: string | null; +}; + +export type MessageLinkParseResult = + | { ok: true; value: ParsedMessageLink } + | { ok: false; reason: string }; + +/** + * Build a `sprout://message` URL for a given channel + message. + * + * Empty `threadRootId` is treated as "no thread" so callers can pass through + * the result of `getThreadReference(tags).rootId` without extra null checks. + */ +export function buildMessageLink(input: MessageLinkInput): string { + if (!input.channelId) { + throw new Error("buildMessageLink: channelId is required"); + } + if (!input.messageId) { + throw new Error("buildMessageLink: messageId is required"); + } + + const params = new URLSearchParams(); + params.set("channel", input.channelId); + params.set("id", input.messageId); + if (input.threadRootId) { + params.set("thread", input.threadRootId); + } + return `sprout://${MESSAGE_LINK_HOST}?${params.toString()}`; +} + +/** + * Parse a `sprout://message?…` URL. Returns a discriminated result so + * callers can render a fallback (e.g. a plain link) without throwing. + */ +export function parseMessageLink(url: string): MessageLinkParseResult { + let parsed: URL; + try { + parsed = new URL(url); + } catch { + return { ok: false, reason: "invalid-url" }; + } + + if (parsed.protocol !== "sprout:") { + return { ok: false, reason: "wrong-scheme" }; + } + // `new URL("sprout://message?…")` puts "message" in `hostname`. + if (parsed.hostname !== MESSAGE_LINK_HOST) { + return { ok: false, reason: "wrong-host" }; + } + + const channelId = parsed.searchParams.get("channel"); + const messageId = parsed.searchParams.get("id"); + if (!channelId) { + return { ok: false, reason: "missing-channel" }; + } + if (!messageId) { + return { ok: false, reason: "missing-id" }; + } + + return { + ok: true, + value: { + channelId, + messageId, + threadRootId: parsed.searchParams.get("thread") ?? null, + }, + }; +} + +/** + * Convenience: returns true if the given href is a `sprout://message` link. + * Cheap pre-check used by the markdown renderer before parsing. + */ +export function isMessageLink(href: string | undefined | null): boolean { + if (!href) return false; + return href.startsWith("sprout://message?") || href === "sprout://message"; +} diff --git a/desktop/src/features/messages/lib/useRichTextEditor.ts b/desktop/src/features/messages/lib/useRichTextEditor.ts index 109cd7d53..69537f323 100644 --- a/desktop/src/features/messages/lib/useRichTextEditor.ts +++ b/desktop/src/features/messages/lib/useRichTextEditor.ts @@ -254,6 +254,10 @@ export function useRichTextEditor({ openOnClick: false, autolink: true, linkOnPaste: true, + // Allow `sprout://` (used by Copy-link-to-message + sprout://connect) + // through TipTap's URL sanitiser. http(s) and mailto are accepted by + // default; non-listed protocols are stripped on paste/typed input. + protocols: ["sprout"], HTMLAttributes: { class: "text-primary underline underline-offset-4 cursor-pointer", }, diff --git a/desktop/src/features/messages/ui/MessageActionBar.tsx b/desktop/src/features/messages/ui/MessageActionBar.tsx index 9f42b604b..cfdc18a9d 100644 --- a/desktop/src/features/messages/ui/MessageActionBar.tsx +++ b/desktop/src/features/messages/ui/MessageActionBar.tsx @@ -4,6 +4,7 @@ import { Copy, CornerUpLeft, EllipsisVertical, + Link2, MailOpen, Pencil, SmilePlus, @@ -12,6 +13,8 @@ import { import * as React from "react"; import { toast } from "sonner"; +import { buildMessageLink } from "@/features/messages/lib/messageLink"; +import { getThreadReference } from "@/features/messages/lib/threading"; import type { TimelineMessage, TimelineReaction, @@ -55,6 +58,7 @@ function copyToClipboard(text: string, successMessage: string) { // --------------------------------------------------------------------------- function MoreActionsMenu({ + channelId, message, onDelete, onEdit, @@ -62,6 +66,9 @@ function MoreActionsMenu({ onOpenChange, open, }: { + /** Channel UUID for the "Copy link" action. When null/undefined, the + * Copy link entry is hidden (e.g. inbox preview rows that don't have it). */ + channelId?: string | null; message: TimelineMessage; onDelete?: (message: TimelineMessage) => void; onEdit?: (message: TimelineMessage) => void; @@ -148,6 +155,24 @@ function MoreActionsMenu({ ) : null} + {hasCopyActions && channelId ? ( + { + const { rootId } = getThreadReference(message.tags ?? []); + const link = buildMessageLink({ + channelId, + messageId: message.id, + threadRootId: rootId, + }); + copyToClipboard(link, "Link copied to clipboard"); + }} + > + + Copy link + + ) : null} + {onDelete ? ( <> @@ -207,6 +232,7 @@ function MoreActionsMenu({ export function MessageActionBar({ activeReplyTargetId = null, + channelId, message, onDelete, onEdit, @@ -218,6 +244,9 @@ export function MessageActionBar({ reactionPending = false, }: { activeReplyTargetId?: string | null; + /** Channel UUID — required for the "Copy link" action; when omitted the + * action is hidden (callers like the home inbox that lack the context). */ + channelId?: string | null; message: TimelineMessage; onDelete?: (message: TimelineMessage) => void; onEdit?: (message: TimelineMessage) => void; @@ -353,6 +382,7 @@ export function MessageActionBar({ {hasMoreMenuActions ? ( void; } +/** + * Payload emitted by the Rust deep-link handler for `sprout://message?…`. + * Field names match the JSON shape produced in `desktop/src-tauri/src/lib.rs`. + */ +export type MessageDeepLinkPayload = { + channelId: string; + messageId: string; + threadRootId: string | null; +}; + /** * Register listeners for deep-link events emitted by the Rust backend. * * When a `sprout://connect?relay=` link is opened, the handler * adds a workspace for the relay (deduplicating by URL) and switches * to it. Returns an unlisten function to tear down all listeners. + * + * `sprout://message?…` is handled separately by `listenForMessageDeepLinks`, + * because it needs to dispatch into the router which only exists below the + * `RouterProvider` in the component tree. */ export function listenForDeepLinks(deps: DeepLinkDeps): Promise { return listen("deep-link-connect", (event) => { @@ -37,3 +51,16 @@ export function listenForDeepLinks(deps: DeepLinkDeps): Promise { toast.success(`Connected to ${name}`); }); } + +/** + * 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. + */ +export function listenForMessageDeepLinks( + onOpen: (payload: MessageDeepLinkPayload) => void, +): Promise { + return listen("deep-link-message", (event) => { + onOpen(event.payload); + }); +} diff --git a/desktop/src/shared/ui/markdown.tsx b/desktop/src/shared/ui/markdown.tsx index 9c08ae5f4..1e6ce11bf 100644 --- a/desktop/src/shared/ui/markdown.tsx +++ b/desktop/src/shared/ui/markdown.tsx @@ -7,6 +7,11 @@ import remarkGfm from "remark-gfm"; import { toast } from "sonner"; import { useAppNavigation } from "@/app/navigation/useAppNavigation"; +import { + isMessageLink, + parseMessageLink, + type ParsedMessageLink, +} from "@/features/messages/lib/messageLink"; import { UserProfilePopover } from "@/features/profile/ui/UserProfilePopover"; import { invokeTauri } from "@/shared/api/tauri"; import type { Channel } from "@/shared/api/types"; @@ -179,6 +184,7 @@ function createMarkdownComponents( variant: MarkdownVariant, channels: Channel[], onOpenChannel: (channelId: string) => void, + onOpenMessageLink: (link: ParsedMessageLink) => void, imetaByUrl?: ImetaLookup, mentionPubkeysByName?: Record, ): Components { @@ -196,17 +202,43 @@ function createMarkdownComponents( : "space-y-1 pl-6 marker:text-muted-foreground"; return { - a: ({ children, href, ...props }) => ( - - {children} - - ), + a: ({ children, href, ...props }) => { + // Intercept `sprout://message?channel=…&id=…` links so a click navigates + // in-app instead of opening the URL in the OS browser. http(s) links + // continue to use the existing target="_blank" behavior. + if (isMessageLink(href)) { + const parsed = parseMessageLink(href ?? ""); + if (parsed.ok) { + const target = parsed.value; + return ( + { + event.preventDefault(); + onOpenMessageLink(target); + }} + > + {children} + + ); + } + // Malformed sprout://message link — fall through to the default + // anchor (renders as a normal external link). + } + return ( + + {children} + + ); + }, blockquote: ({ children }) => (
{children} @@ -472,6 +504,16 @@ function MarkdownInner({ (channelId) => { void goChannel(channelId); }, + (link) => { + // Always route through `goChannel` with `messageId` set: the + // channel route already handles scroll-into-view + highlight via + // `useTimelineScrollManager` + `getEventById` backfill, and works + // for both stream-message replies and forum threads. Detecting + // "the thread root is a forum post" up front would require an + // event lookup we don't currently have synchronously; the brief + // explicitly allows skipping that detection and falling through. + void goChannel(link.channelId, { messageId: link.messageId }); + }, imetaByUrl, mentionPubkeysByName, ), diff --git a/desktop/src/shared/useMessageDeepLinks.ts b/desktop/src/shared/useMessageDeepLinks.ts new file mode 100644 index 000000000..ff7a74443 --- /dev/null +++ b/desktop/src/shared/useMessageDeepLinks.ts @@ -0,0 +1,34 @@ +import * as React from "react"; + +import { useAppNavigation } from "@/app/navigation/useAppNavigation"; +import { listenForMessageDeepLinks } from "@/shared/deep-link"; + +/** + * Subscribe to `sprout://message` deep links emitted by the Tauri backend + * and route them through the app's navigation helpers. + * + * Lives in a hook (not inline in `AppShell`) so it can be unit-tested + * without the entire shell, and so the shell file stays under its line cap. + * + * Mirrors the cold-start race handling of the `connect` listener in + * `App.tsx`: late-arriving payloads from a fresh launch are picked up the + * first time the listener mounts. Routing matches the in-app sprout:// + * handler in `markdown.tsx`: always `goChannel` with `messageId` and let + * the channel route's existing scroll-into-view + getEventById backfill + * resolve the target (works for both stream replies and forum threads). + */ +export function useMessageDeepLinks() { + const { goChannel } = useAppNavigation(); + + React.useEffect(() => { + let cancelled = false; + const unlistenPromise = listenForMessageDeepLinks((payload) => { + if (cancelled) return; + void goChannel(payload.channelId, { messageId: payload.messageId }); + }); + return () => { + cancelled = true; + void unlistenPromise.then((fn) => fn()); + }; + }, [goChannel]); +} From dcb65862927c95eb9e79f7a0956c6a4aa6095fc5 Mon Sep 17 00:00:00 2001 From: Wes Date: Wed, 27 May 2026 09:18:47 -0700 Subject: [PATCH 2/5] fix(desktop): preserve sprout://message links + filter empty deep-link params MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Beth-flagged review issues from PR #copy-link-to-messages: 1. react-markdown's defaultUrlTransform strips unknown schemes to "" before the component override sees them, breaking copy → paste → click for sprout://message?… end-to-end. Add a urlTransform that passes message-link hrefs through unchanged and delegates everything else to defaultUrlTransform (so javascript: etc. stay safe). Covered by 5 new tests in markdown.test.mjs that render real and assert on the emitted href. 2. The Tauri sprout://message arm in lib.rs accepted empty values: sprout://message?channel=&id=foo would emit channelId: "" to the frontend. Filter empty params on the way in. Extracted the parse logic into parse_message_deep_link so it can be unit-tested without a live AppHandle; added 6 unit tests including a regression for the empty-channel case (NIT #4 from the review). 3. Verified cross-workspace fallback in ChannelRouteScreen: when the linked channel isn't in the active workspace, channelsQuery settles, activeChannel is null, ChannelScreen renders ("Select a channel to view messages.") — no spinner-loop, no crash. Already graceful, no toast needed. 4. Documented threadRootId in messageLink.ts as reserved for future "open in thread view" routing (NIT #5). File-size override for lib.rs bumped 715→730 with a precise note for the parse helper extraction + 6 unit tests. Signed-off-by: Wes --- desktop/scripts/check-file-sizes.mjs | 2 +- desktop/src-tauri/src/lib.rs | 94 +++++++++++++++---- .../src/features/messages/lib/messageLink.ts | 5 + desktop/src/shared/ui/markdown.test.mjs | 74 +++++++++++++++ desktop/src/shared/ui/markdown.tsx | 19 +++- 5 files changed, 175 insertions(+), 19 deletions(-) diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs index f8b1c9be3..d1e07bdb6 100644 --- a/desktop/scripts/check-file-sizes.mjs +++ b/desktop/scripts/check-file-sizes.mjs @@ -77,7 +77,7 @@ const overrides = new Map([ ["src-tauri/src/huddle/tts.rs", 1380], // TTS pipeline + session warmup + cancel/shutdown handling + apply_fade_out (fade-out only — leading fade removed 2026-05-18 after onset-attenuation regression measured in examples/pocket_onset_probe.rs) + FIRST_APPEND_LEAD_IN_SAMPLES + build_sentence_append_plan (pure helper enforcing the lead-in fires exactly once per utterance, not per sentence — see lead_in_pad_fires_exactly_once_per_utterance regression test) + normalize_for_playback (per-sentence peak normalization to -3 dBFS ceiling with MAX_GAIN cap) + 30 unit tests (18 interrupt + 5 fade-out + 1 first-append-lead-in + 3 build-sentence-append-plan + 6 normalize) ["src-tauri/src/relay.rs", 510], // +4 lines for NIP-OA auth tag injection in profile sync (build_profile_event) + verification test ["src-tauri/src/commands/pairing.rs", 600], // NIP-AB pairing actor: 3 Tauri commands + background WS task + NIP-42 auth + NIP-43 probe + event parsing helpers - ["src-tauri/src/lib.rs", 715], // +4 lines for PairingHandle managed state + 3 pairing command registrations + ["src-tauri/src/lib.rs", 730], // +4 lines for PairingHandle managed state + 3 pairing command registrations + parse_message_deep_link helper extracted with 6 unit tests covering empty-param filter regression ["src/shared/api/tauri.ts", 1212], // pairing command wrappers + applyWorkspace + NIP-44 encrypt/decrypt wrappers + observer_url field + relay member API functions (list/get/add/remove/change-role) + prevent sleep + AcpProviderCatalogEntry raw types + fromRawAcpProviderCatalogEntry converter + installAcpRuntime ]); diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 16ab38f0e..6f51609c0 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -154,6 +154,38 @@ fn shutdown_managed_agents(app: &tauri::AppHandle) -> Result<(), String> { Ok(()) } +/// Parse the query string of a `sprout://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 +/// policy of the `connect` arm so the frontend never sees a half-formed +/// payload (e.g. `channelId: ""` from `channel=&id=foo`). +/// +/// Pulled out of `handle_deep_link_url` so it can be unit-tested without +/// a live `tauri::AppHandle`. +fn parse_message_deep_link(url: &Url) -> Option { + let mut channel: Option = None; + let mut message_id: Option = None; + let mut thread: Option = None; + for (k, v) in url.query_pairs() { + let v = v.into_owned(); + if v.is_empty() { + continue; + } + match k.as_ref() { + "channel" => channel = Some(v), + "id" => message_id = Some(v), + "thread" => thread = Some(v), + _ => {} + } + } + let (channel_id, message_id) = (channel?, message_id?); + Some(serde_json::json!({ + "channelId": channel_id, + "messageId": message_id, + "threadRootId": thread, + })) +} + /// Handle an incoming `sprout://` deep link URL. /// /// Currently supports: @@ -208,26 +240,10 @@ fn handle_deep_link_url(app: &tauri::AppHandle, url_str: &str) { // frontend listener mirrors `parseMessageLink` in TS — we keep // structure on this side (serde JSON) and let the TS code own // any further normalisation. - let mut channel: Option = None; - let mut message_id: Option = None; - let mut thread: Option = None; - for (k, v) in url.query_pairs() { - match k.as_ref() { - "channel" => channel = Some(v.into_owned()), - "id" => message_id = Some(v.into_owned()), - "thread" => thread = Some(v.into_owned()), - _ => {} - } - } - let (Some(channel_id), Some(message_id)) = (channel, message_id) else { + let Some(payload) = parse_message_deep_link(&url) else { eprintln!("sprout-desktop: message deep link missing channel or id: {url_str}"); return; }; - let payload = serde_json::json!({ - "channelId": channel_id, - "messageId": message_id, - "threadRootId": thread, - }); let _ = app.emit("deep-link-message", payload); } Some(action) => { @@ -639,8 +655,10 @@ pub fn run() { #[cfg(test)] mod tests { use serde_json::json; + use url::Url; use crate::models::ChannelInfo; + use crate::parse_message_deep_link; #[test] fn channel_info_defaults_is_member_for_legacy_payloads() { @@ -662,4 +680,46 @@ mod tests { assert!(channel.is_member); } + + #[test] + fn parse_message_deep_link_extracts_required_params() { + let url = Url::parse("sprout://message?channel=abc&id=xyz").unwrap(); + let payload = parse_message_deep_link(&url).expect("required params present"); + assert_eq!(payload["channelId"], "abc"); + assert_eq!(payload["messageId"], "xyz"); + assert!(payload["threadRootId"].is_null()); + } + + #[test] + fn parse_message_deep_link_includes_thread_root() { + let url = Url::parse("sprout://message?channel=abc&id=xyz&thread=root1").unwrap(); + let payload = parse_message_deep_link(&url).expect("required params present"); + assert_eq!(payload["threadRootId"], "root1"); + } + + #[test] + fn parse_message_deep_link_rejects_missing_id() { + let url = Url::parse("sprout://message?channel=abc").unwrap(); + assert!(parse_message_deep_link(&url).is_none()); + } + + #[test] + fn parse_message_deep_link_rejects_empty_channel() { + // Regression: `channel=&id=foo` previously produced channelId: "". + let url = Url::parse("sprout://message?channel=&id=foo").unwrap(); + assert!(parse_message_deep_link(&url).is_none()); + } + + #[test] + fn parse_message_deep_link_rejects_empty_id() { + let url = Url::parse("sprout://message?channel=abc&id=").unwrap(); + assert!(parse_message_deep_link(&url).is_none()); + } + + #[test] + fn parse_message_deep_link_treats_empty_thread_as_absent() { + let url = Url::parse("sprout://message?channel=abc&id=xyz&thread=").unwrap(); + let payload = parse_message_deep_link(&url).expect("required params present"); + assert!(payload["threadRootId"].is_null()); + } } diff --git a/desktop/src/features/messages/lib/messageLink.ts b/desktop/src/features/messages/lib/messageLink.ts index 793f82f0b..c438c6192 100644 --- a/desktop/src/features/messages/lib/messageLink.ts +++ b/desktop/src/features/messages/lib/messageLink.ts @@ -15,6 +15,11 @@ export type MessageLinkInput = { /** * Optional thread root event id. Present when the linked message is a * reply (so the caller can route into a thread / forum post view). + * + * Currently emitted into the URL but not consumed by the click handler + * or deep-link listener — both route via `goChannel(channelId, + * { messageId })` and let `useTimelineScrollManager` resolve the target. + * Reserved for future "open in thread view" routing. */ threadRootId?: string | null; }; diff --git a/desktop/src/shared/ui/markdown.test.mjs b/desktop/src/shared/ui/markdown.test.mjs index 9ee5fda56..56c8eab26 100644 --- a/desktop/src/shared/ui/markdown.test.mjs +++ b/desktop/src/shared/ui/markdown.test.mjs @@ -390,3 +390,77 @@ test("rehypeImageGallery: mixed content paragraph is not image-only", () => { // Middle paragraph has text, so it breaks the run assert.equal(tree.children.length, 3); }); + +// ── messageLinkUrlTransform: sprout:// link preservation ────────────── +// Regression test: react-markdown's `defaultUrlTransform` strips unknown +// schemes (returns `""`) before our `a` component override can see them, +// which would break copy → paste → click for `sprout://message?…` links +// end-to-end. We pass a custom `urlTransform` that delegates to the +// default for everything except `sprout://message` hrefs. +// +// This test renders real `` with the production transform +// and asserts the link href survives to the rendered DOM. Mirrors the +// `markdown.tsx` source — keep in sync if either changes. + +import React from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import ReactMarkdown, { defaultUrlTransform } from "react-markdown"; + +function isMessageLink(href) { + if (!href) return false; + return href.startsWith("sprout://message?") || href === "sprout://message"; +} + +function messageLinkUrlTransform(value, key) { + if (key === "href" && isMessageLink(value)) { + return value; + } + return defaultUrlTransform(value); +} + +function renderMarkdown(content) { + return renderToStaticMarkup( + React.createElement( + ReactMarkdown, + { urlTransform: messageLinkUrlTransform }, + content, + ), + ); +} + +test("messageLinkUrlTransform: preserves sprout://message href", () => { + const html = renderMarkdown( + "Click [here](sprout://message?channel=abc&id=xyz)", + ); + // HTML-encoded `&` in attributes is fine — the browser decodes back to `&`. + assert.match(html, /href="sprout:\/\/message\?channel=abc&(?:amp;)?id=xyz"/); +}); + +test("messageLinkUrlTransform: preserves sprout://message href with thread", () => { + const html = renderMarkdown( + "[link](sprout://message?channel=c1&id=m1&thread=t1)", + ); + assert.match(html, /href="sprout:\/\/message\?[^"]*thread=t1"/); +}); + +test("messageLinkUrlTransform: still strips javascript: scheme", () => { + const html = renderMarkdown("[xss](javascript:alert(1))"); + // defaultUrlTransform replaces unsafe schemes with the empty string. + assert.match(html, /href=""/); + assert.doesNotMatch(html, /javascript:/); +}); + +test("messageLinkUrlTransform: passes http(s) through unchanged", () => { + const html = renderMarkdown("[ext](https://example.com/path)"); + assert.match(html, /href="https:\/\/example\.com\/path"/); +}); + +test("messageLinkUrlTransform: leaves non-message sprout:// schemes to default", () => { + // `sprout://connect?relay=…` is handled by a different code path (Tauri + // single-instance). The markdown renderer should let it pass through + // defaultUrlTransform (which strips it) since it's not clickable in-app. + const html = renderMarkdown( + "[connect](sprout://connect?relay=wss://relay.example)", + ); + assert.match(html, /href=""/); +}); diff --git a/desktop/src/shared/ui/markdown.tsx b/desktop/src/shared/ui/markdown.tsx index 1e6ce11bf..81ec07b10 100644 --- a/desktop/src/shared/ui/markdown.tsx +++ b/desktop/src/shared/ui/markdown.tsx @@ -1,5 +1,8 @@ import * as React from "react"; -import ReactMarkdown, { type Components } from "react-markdown"; +import ReactMarkdown, { + type Components, + defaultUrlTransform, +} from "react-markdown"; import * as DialogPrimitive from "@radix-ui/react-dialog"; import { Copy } from "lucide-react"; import remarkBreaks from "remark-breaks"; @@ -36,6 +39,19 @@ import { VideoPlayer } from "./VideoPlayer"; type ImetaLookup = Map; +/** + * `urlTransform` for `` that preserves `sprout://message?…` + * links. The default transform strips unknown schemes (returns `""`) before + * the `a` component override can see them, which would break copy → paste → + * click end-to-end. Everything else delegates to `defaultUrlTransform`. + */ +function messageLinkUrlTransform(value: string, key: string): string { + if (key === "href" && isMessageLink(value)) { + return value; + } + return defaultUrlTransform(value); +} + type MarkdownProps = { channelNames?: string[]; className?: string; @@ -556,6 +572,7 @@ function MarkdownInner({ components={components} remarkPlugins={remarkPlugins} rehypePlugins={rehypePlugins} + urlTransform={messageLinkUrlTransform} > {processedContent} From 6a00ad2d01ac6f403826ddc27758e678136a8de5 Mon Sep 17 00:00:00 2001 From: Wes Date: Wed, 27 May 2026 09:26:00 -0700 Subject: [PATCH 3/5] refactor(desktop): import isMessageLink in markdown.test.mjs The existing inlined copy + comment was reused from the markdownUtils pattern, but messageLink.ts has no React dependency so it can be imported directly. Removes one source of drift between markdown.tsx and its test. Signed-off-by: Wes --- desktop/src/shared/ui/markdown.test.mjs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/desktop/src/shared/ui/markdown.test.mjs b/desktop/src/shared/ui/markdown.test.mjs index 56c8eab26..8fa76fcbc 100644 --- a/desktop/src/shared/ui/markdown.test.mjs +++ b/desktop/src/shared/ui/markdown.test.mjs @@ -406,10 +406,7 @@ import React from "react"; import { renderToStaticMarkup } from "react-dom/server"; import ReactMarkdown, { defaultUrlTransform } from "react-markdown"; -function isMessageLink(href) { - if (!href) return false; - return href.startsWith("sprout://message?") || href === "sprout://message"; -} +import { isMessageLink } from "../../features/messages/lib/messageLink.ts"; function messageLinkUrlTransform(value, key) { if (key === "href" && isMessageLink(value)) { From 4d0973fb66dbecfac1ba5ba3342db196c091d423 Mon Sep 17 00:00:00 2001 From: Wes Date: Wed, 27 May 2026 11:04:56 -0700 Subject: [PATCH 4/5] feat(desktop): autolink bare sprout://message URLs as inline pills MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit remark-gfm only autolinks http(s)://, so bare sprout://message URLs in message text rendered as raw 100-char strings. Add remarkMessageLinks plugin (built on the existing createRemarkPrefixPlugin factory, mirroring remarkChannelLinks/remarkMentions) that replaces matched URLs with a custom message-link HAST element. markdown.tsx renders that as a clickable pill (#channel · 6-char-id), routed through the existing onOpenMessageLink handler. Malformed URLs render as plain text rather than misleading clickable pills. Trailing punctuation and whitespace terminate the match. Plugin uses the explicit .ts extension on its factory import so the file is consumable from both Vite (markdown.tsx) and node --test --experimental-strip-types (markdown.test.mjs). Signed-off-by: Wes --- .../messages/lib/remarkMessageLinks.ts | 30 ++++++ desktop/src/shared/ui/markdown.test.mjs | 100 ++++++++++++++++++ desktop/src/shared/ui/markdown.tsx | 31 ++++++ 3 files changed, 161 insertions(+) create mode 100644 desktop/src/features/messages/lib/remarkMessageLinks.ts diff --git a/desktop/src/features/messages/lib/remarkMessageLinks.ts b/desktop/src/features/messages/lib/remarkMessageLinks.ts new file mode 100644 index 000000000..b0dd89117 --- /dev/null +++ b/desktop/src/features/messages/lib/remarkMessageLinks.ts @@ -0,0 +1,30 @@ +/** + * Remark plugin that detects bare `sprout://message?…` URLs in text nodes and + * replaces each with a custom `message-link` HAST element. The `markdown.tsx` + * components map renders that as an inline pill (channel name + click-to-open) + * instead of the raw 100-char URL. + * + * Why this plugin exists: `remark-gfm`'s autolinker only covers `http(s)://` + * and `www.`. Custom schemes like `sprout://` only reach the `` component + * override when the user wrote an explicit `[label](sprout://…)` link. + * + * Mirrors `remarkChannelLinks` / `remarkMentions` — same factory, same HAST + * shape — so the rendering layer treats all three uniformly. + */ +// Explicit `.ts` extension lets this plugin be imported both by the Vite-built +// `markdown.tsx` and by `markdown.test.mjs` running under `node --test +// --experimental-strip-types`. `tsconfig.json` enables `allowImportingTsExtensions`. +import { createRemarkPrefixPlugin } from "../../../shared/lib/createRemarkPrefixPlugin.ts"; + +const MESSAGE_URL_PATTERN = /sprout:\/\/message\?[^\s<>"')\]]+/g; + +export default function remarkMessageLinks() { + return createRemarkPrefixPlugin(MESSAGE_URL_PATTERN, (matchText) => ({ + type: "message-link", + value: matchText, + data: { + hName: "message-link", + hChildren: [{ type: "text", value: matchText }], + }, + })); +} diff --git a/desktop/src/shared/ui/markdown.test.mjs b/desktop/src/shared/ui/markdown.test.mjs index 8fa76fcbc..f20c4f178 100644 --- a/desktop/src/shared/ui/markdown.test.mjs +++ b/desktop/src/shared/ui/markdown.test.mjs @@ -461,3 +461,103 @@ test("messageLinkUrlTransform: leaves non-message sprout:// schemes to default", ); assert.match(html, /href=""/); }); + +// ── remarkMessageLinks: bare-URL → message-link node ────────────────── +// `remark-gfm`'s autolinker only covers http(s)://, so bare `sprout://message` +// URLs in plain text never reach any rendering path without this plugin. +// The plugin emits a custom `message-link` HAST element which markdown.tsx +// renders as an inline pill. Tests operate on the mdast tree directly — +// the rendering side is a plain React component covered by app-level use. + +import remarkMessageLinks from "../../features/messages/lib/remarkMessageLinks.ts"; + +function runPlugin(tree) { + remarkMessageLinks()(tree); + return tree; +} + +function paragraph(...children) { + return { type: "root", children: [{ type: "paragraph", children }] }; +} + +function text(value) { + return { type: "text", value }; +} + +test("remarkMessageLinks: bare sprout://message URL is replaced", () => { + const tree = runPlugin(paragraph(text("sprout://message?channel=c&id=m"))); + const para = tree.children[0]; + assert.equal(para.children.length, 1); + assert.equal(para.children[0].type, "message-link"); + assert.equal(para.children[0].value, "sprout://message?channel=c&id=m"); + assert.equal(para.children[0].data.hName, "message-link"); +}); + +test("remarkMessageLinks: mid-sentence URL splits surrounding text", () => { + const tree = runPlugin( + paragraph(text("see sprout://message?channel=c&id=m here")), + ); + const kids = tree.children[0].children; + assert.equal(kids.length, 3); + assert.equal(kids[0].type, "text"); + assert.equal(kids[0].value, "see "); + assert.equal(kids[1].type, "message-link"); + assert.equal(kids[2].type, "text"); + assert.equal(kids[2].value, " here"); +}); + +test("remarkMessageLinks: two URLs in one text node both replaced", () => { + const tree = runPlugin( + paragraph( + text( + "first sprout://message?channel=a&id=1 then sprout://message?channel=b&id=2 done", + ), + ), + ); + const kids = tree.children[0].children; + const links = kids.filter((c) => c.type === "message-link"); + assert.equal(links.length, 2); + assert.equal(links[0].value, "sprout://message?channel=a&id=1"); + assert.equal(links[1].value, "sprout://message?channel=b&id=2"); +}); + +test("remarkMessageLinks: trailing `)` is excluded from URL", () => { + const tree = runPlugin( + paragraph(text("see (sprout://message?channel=c&id=m) for details")), + ); + const link = tree.children[0].children.find((c) => c.type === "message-link"); + assert.ok(link); + assert.equal(link.value, "sprout://message?channel=c&id=m"); +}); + +test("remarkMessageLinks: non-message sprout:// URLs are not matched", () => { + const original = "sprout://connect?relay=wss://x.example"; + const tree = runPlugin(paragraph(text(original))); + const kids = tree.children[0].children; + assert.equal(kids.length, 1); + assert.equal(kids[0].type, "text"); + assert.equal(kids[0].value, original); +}); + +test("remarkMessageLinks: text inside inlineCode is left alone", () => { + // The shared factory's tree walker descends into all non-text nodes; an + // `inlineCode` node has its URL stored in `value` (not children), so the + // plugin can't reach it. Guard against a future regression where someone + // turns `inlineCode` into a children-bearing node. + const tree = { + type: "root", + children: [ + { + type: "paragraph", + children: [ + { type: "inlineCode", value: "sprout://message?channel=c&id=m" }, + ], + }, + ], + }; + runPlugin(tree); + const kids = tree.children[0].children; + assert.equal(kids.length, 1); + assert.equal(kids[0].type, "inlineCode"); + assert.equal(kids[0].value, "sprout://message?channel=c&id=m"); +}); diff --git a/desktop/src/shared/ui/markdown.tsx b/desktop/src/shared/ui/markdown.tsx index 81ec07b10..691de0e13 100644 --- a/desktop/src/shared/ui/markdown.tsx +++ b/desktop/src/shared/ui/markdown.tsx @@ -26,6 +26,7 @@ import rehypeImageGallery from "@/shared/lib/rehypeImageGallery"; import rehypeSearchHighlight from "@/shared/lib/rehypeSearchHighlight"; import remarkChannelLinks from "@/shared/lib/remarkChannelLinks"; import remarkMentions from "@/shared/lib/remarkMentions"; +import remarkMessageLinks from "@/features/messages/lib/remarkMessageLinks"; import { Button } from "@/shared/ui/button"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip"; @@ -490,6 +491,35 @@ function createMarkdownComponents( ); }, + "message-link": ({ children }: { children?: React.ReactNode }) => { + const href = String(children ?? ""); + const parsed = parseMessageLink(href); + if (!parsed.ok) { + // Malformed `sprout://message?…` — render the raw URL as plain text + // rather than a misleading clickable pill. + return {href}; + } + + const { channelId, messageId } = parsed.value; + const channel = channels.find((c) => c.id === channelId); + const channelLabel = channel?.name ?? "channel"; + const shortId = messageId.slice(0, 6); + + return ( + + ); + }, } as Components; } @@ -541,6 +571,7 @@ function MarkdownInner({ () => [ remarkGfm, remarkBreaks, + remarkMessageLinks, [remarkMentions, { mentionNames }], [remarkChannelLinks, { channelNames }], ], From caa29dc3ddc9ca28cf21e128e4cb37b7efd42848 Mon Sep 17 00:00:00 2001 From: Wes Date: Wed, 27 May 2026 11:20:11 -0700 Subject: [PATCH 5/5] fix(desktop): trim trailing punctuation from autolinked sprout://message URLs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bare `sprout://message?…` URLs at end-of-sentence (e.g. "See sprout://…abc.") were being matched whole, including the trailing `.`, which made `parseMessageLink` produce `messageId: "abc."` and route to a nonexistent message instead of the copied event id. Peel trailing sentence punctuation (`. , ; : ! ?`) and unmatched closing brackets off each match before emitting the message-link node; the trimmed characters are appended as plain text. Implemented by extending `createRemarkPrefixPlugin` to accept `{ node, trailing }` from its builder so `remarkMessageLinks` doesn't need its own walker — the same factory now handles mentions, channels, and message links uniformly, including link/code/inlineCode skipping which was previously per-plugin. Signed-off-by: Wes --- .../messages/lib/remarkMessageLinks.ts | 43 +++++++++++++++---- .../shared/lib/createRemarkPrefixPlugin.ts | 42 ++++++++++++++++-- desktop/src/shared/ui/markdown.test.mjs | 39 +++++++++++++++-- 3 files changed, 108 insertions(+), 16 deletions(-) diff --git a/desktop/src/features/messages/lib/remarkMessageLinks.ts b/desktop/src/features/messages/lib/remarkMessageLinks.ts index b0dd89117..d901fcf0a 100644 --- a/desktop/src/features/messages/lib/remarkMessageLinks.ts +++ b/desktop/src/features/messages/lib/remarkMessageLinks.ts @@ -9,7 +9,10 @@ * override when the user wrote an explicit `[label](sprout://…)` link. * * Mirrors `remarkChannelLinks` / `remarkMentions` — same factory, same HAST - * shape — so the rendering layer treats all three uniformly. + * shape — so the rendering layer treats all three uniformly. Trailing + * sentence punctuation (`. , ; : ! ?`) and unmatched closing brackets are + * peeled off the match and emitted as plain text after the pill, so a URL + * pasted at end-of-sentence still routes to the correct message id. */ // Explicit `.ts` extension lets this plugin be imported both by the Vite-built // `markdown.tsx` and by `markdown.test.mjs` running under `node --test @@ -17,14 +20,36 @@ import { createRemarkPrefixPlugin } from "../../../shared/lib/createRemarkPrefixPlugin.ts"; const MESSAGE_URL_PATTERN = /sprout:\/\/message\?[^\s<>"')\]]+/g; +const TRAILING_PUNCTUATION_PATTERN = /[.,;:!?]+$/; + +function trimMessageLinkMatch(matchText: string) { + let value = matchText.replace(TRAILING_PUNCTUATION_PATTERN, ""); + while (/[)\]]$/.test(value) && isUnmatchedClosing(value)) { + value = value.slice(0, -1).replace(TRAILING_PUNCTUATION_PATTERN, ""); + } + return { value, trailing: matchText.slice(value.length) }; +} + +function isUnmatchedClosing(value: string): boolean { + const closing = value[value.length - 1]; + const opening = closing === ")" ? "(" : "["; + return value.split(closing).length > value.split(opening).length; +} export default function remarkMessageLinks() { - return createRemarkPrefixPlugin(MESSAGE_URL_PATTERN, (matchText) => ({ - type: "message-link", - value: matchText, - data: { - hName: "message-link", - hChildren: [{ type: "text", value: matchText }], - }, - })); + return createRemarkPrefixPlugin(MESSAGE_URL_PATTERN, (matchText) => { + const { value, trailing } = trimMessageLinkMatch(matchText); + + return { + node: { + type: "message-link", + value, + data: { + hName: "message-link", + hChildren: [{ type: "text", value }], + }, + }, + trailing, + }; + }); } diff --git a/desktop/src/shared/lib/createRemarkPrefixPlugin.ts b/desktop/src/shared/lib/createRemarkPrefixPlugin.ts index 1624f9479..8fe1c91d2 100644 --- a/desktop/src/shared/lib/createRemarkPrefixPlugin.ts +++ b/desktop/src/shared/lib/createRemarkPrefixPlugin.ts @@ -6,11 +6,15 @@ * and text-splitting logic — this factory captures that once. */ -type NodeBuilder = (matchText: string) => { +type Node = { // biome-ignore lint/suspicious/noExplicitAny: building mdast-compatible nodes [key: string]: any; }; +type NodeBuilderResult = Node | { node: Node; trailing?: string }; + +type NodeBuilder = (matchText: string) => NodeBuilderResult; + /** * Create a remark plugin that walks the tree, finds regex matches in text * nodes, and replaces each match with a node produced by `buildNode`. @@ -29,7 +33,11 @@ export function createRemarkPrefixPlugin( // biome-ignore lint/suspicious/noExplicitAny: remark tree types are not available function walkChildren(node: any, pattern: RegExp, buildNode: NodeBuilder) { - if (!node?.children || !Array.isArray(node.children)) { + if ( + !node?.children || + !Array.isArray(node.children) || + shouldSkipNode(node) + ) { return; } @@ -50,6 +58,13 @@ function walkChildren(node: any, pattern: RegExp, buildNode: NodeBuilder) { } } +// biome-ignore lint/suspicious/noExplicitAny: remark tree types are not available +function shouldSkipNode(node: any): boolean { + return ( + node.type === "link" || node.type === "code" || node.type === "inlineCode" + ); +} + function splitByPattern(text: string, pattern: RegExp, buildNode: NodeBuilder) { // Reset lastIndex — the pattern is reused across text nodes with the `g` flag pattern.lastIndex = 0; @@ -68,7 +83,11 @@ function splitByPattern(text: string, pattern: RegExp, buildNode: NodeBuilder) { parts.push({ type: "text", value: text.slice(lastIndex, match.index) }); } - parts.push(buildNode(match[0])); + const result = normalizeBuildNodeResult(buildNode(match[0])); + parts.push(result.node); + if (result.trailing) { + parts.push({ type: "text", value: result.trailing }); + } lastIndex = match.index + match[0].length; } @@ -83,3 +102,20 @@ function splitByPattern(text: string, pattern: RegExp, buildNode: NodeBuilder) { return parts; } + +function normalizeBuildNodeResult(result: NodeBuilderResult): { + node: Node; + trailing?: string; +} { + if (isBuildNodeWithTrailing(result)) { + return result; + } + + return { node: result }; +} + +function isBuildNodeWithTrailing( + result: NodeBuilderResult, +): result is { node: Node; trailing?: string } { + return "node" in result; +} diff --git a/desktop/src/shared/ui/markdown.test.mjs b/desktop/src/shared/ui/markdown.test.mjs index f20c4f178..142f45977 100644 --- a/desktop/src/shared/ui/markdown.test.mjs +++ b/desktop/src/shared/ui/markdown.test.mjs @@ -521,13 +521,44 @@ test("remarkMessageLinks: two URLs in one text node both replaced", () => { assert.equal(links[1].value, "sprout://message?channel=b&id=2"); }); -test("remarkMessageLinks: trailing `)` is excluded from URL", () => { +test("remarkMessageLinks: trailing sentence punctuation stays outside URL", () => { + for (const punctuation of [".", ",", ";", ":", "!", "?"]) { + const tree = runPlugin( + paragraph(text(`see sprout://message?channel=c&id=m${punctuation}`)), + ); + const kids = tree.children[0].children; + + assert.equal(kids.length, 3, punctuation); + assert.equal(kids[0].value, "see ", punctuation); + assert.equal(kids[1].type, "message-link", punctuation); + assert.equal(kids[1].value, "sprout://message?channel=c&id=m", punctuation); + assert.equal(kids[2].type, "text", punctuation); + assert.equal(kids[2].value, punctuation, punctuation); + } +}); + +test("remarkMessageLinks: URL inside parens keeps closing paren outside", () => { const tree = runPlugin( paragraph(text("see (sprout://message?channel=c&id=m) for details")), ); - const link = tree.children[0].children.find((c) => c.type === "message-link"); - assert.ok(link); - assert.equal(link.value, "sprout://message?channel=c&id=m"); + const kids = tree.children[0].children; + + assert.equal(kids.length, 3); + assert.equal(kids[0].value, "see ("); + assert.equal(kids[1].type, "message-link"); + assert.equal(kids[1].value, "sprout://message?channel=c&id=m"); + assert.equal(kids[2].type, "text"); + assert.equal(kids[2].value, ") for details"); +}); + +test("remarkMessageLinks: URL without trailing punctuation matches end-to-end", () => { + const value = "sprout://message?channel=c&id=m"; + const tree = runPlugin(paragraph(text(value))); + const kids = tree.children[0].children; + + assert.equal(kids.length, 1); + assert.equal(kids[0].type, "message-link"); + assert.equal(kids[0].value, value); }); test("remarkMessageLinks: non-message sprout:// URLs are not matched", () => {