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 968396431..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: @@ -199,6 +231,21 @@ 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 Some(payload) = parse_message_deep_link(&url) else { + eprintln!("sprout-desktop: message deep link missing channel or id: {url_str}"); + return; + }; + let _ = app.emit("deep-link-message", payload); + } Some(action) => { eprintln!("sprout-desktop: unknown deep link action: {action}"); } @@ -608,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() { @@ -631,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/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..c438c6192 --- /dev/null +++ b/desktop/src/features/messages/lib/messageLink.ts @@ -0,0 +1,106 @@ +/** + * `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). + * + * 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; +}; + +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/remarkMessageLinks.ts b/desktop/src/features/messages/lib/remarkMessageLinks.ts new file mode 100644 index 000000000..d901fcf0a --- /dev/null +++ b/desktop/src/features/messages/lib/remarkMessageLinks.ts @@ -0,0 +1,55 @@ +/** + * 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. 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 +// --experimental-strip-types`. `tsconfig.json` enables `allowImportingTsExtensions`. +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) => { + 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/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/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 9ee5fda56..142f45977 100644 --- a/desktop/src/shared/ui/markdown.test.mjs +++ b/desktop/src/shared/ui/markdown.test.mjs @@ -390,3 +390,205 @@ 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"; + +import { isMessageLink } from "../../features/messages/lib/messageLink.ts"; + +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=""/); +}); + +// ── 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 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 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", () => { + 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 9c08ae5f4..691de0e13 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"; @@ -7,6 +10,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"; @@ -18,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"; @@ -31,6 +40,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; @@ -179,6 +201,7 @@ function createMarkdownComponents( variant: MarkdownVariant, channels: Channel[], onOpenChannel: (channelId: string) => void, + onOpenMessageLink: (link: ParsedMessageLink) => void, imetaByUrl?: ImetaLookup, mentionPubkeysByName?: Record, ): Components { @@ -196,17 +219,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} @@ -442,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; } @@ -472,6 +550,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, ), @@ -483,6 +571,7 @@ function MarkdownInner({ () => [ remarkGfm, remarkBreaks, + remarkMessageLinks, [remarkMentions, { mentionNames }], [remarkChannelLinks, { channelNames }], ], @@ -514,6 +603,7 @@ function MarkdownInner({ components={components} remarkPlugins={remarkPlugins} rehypePlugins={rehypePlugins} + urlTransform={messageLinkUrlTransform} > {processedContent} 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]); +}