fix(desktop): add support for deeplinking to channel/messages (#759)

Signed-off-by: Wes <wesbillman@users.noreply.github.com>
This commit is contained in:
Wes
2026-05-27 12:27:34 -07:00
committed by GitHub
parent 11c8c0d4cf
commit 8946653af8
16 changed files with 807 additions and 16 deletions

View File

@@ -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
]);

View File

@@ -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<serde_json::Value> {
let mut channel: Option<String> = None;
let mut message_id: Option<String> = None;
let mut thread: Option<String> = 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=<uuid>&id=<eventId>[&thread=<rootId>]`
//
// 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());
}
}

View File

@@ -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 = () => {};

View File

@@ -260,6 +260,7 @@ export function InboxDetailPane({
<InboxMessageRow
activeReplyTargetId={replyTargetId}
canReply={canReply}
channelId={item.item.channelId}
isFocusHighlightVisible={isFocusHighlightVisible}
message={message}
onSelectReplyTarget={handleSelectReplyTarget}

View File

@@ -29,6 +29,8 @@ function toTimelineMessage(message: InboxDisplayMessage): TimelineMessage {
type InboxMessageRowProps = {
activeReplyTargetId: string | null;
canReply: boolean;
/** Channel UUID for "Copy link" — passed straight through to MessageActionBar. */
channelId?: string | null;
isFocusHighlightVisible: boolean;
message: InboxDisplayMessage;
onSelectReplyTarget: (message: InboxDisplayMessage) => void;
@@ -42,6 +44,7 @@ type InboxMessageRowProps = {
export function InboxMessageRow({
activeReplyTargetId,
canReply,
channelId = null,
isFocusHighlightVisible,
message,
onSelectReplyTarget,
@@ -87,6 +90,7 @@ export function InboxMessageRow({
<div className="absolute right-2 top-1 z-10">
<MessageActionBar
activeReplyTargetId={activeReplyTargetId}
channelId={channelId}
message={timelineMessage}
onReactionSelect={
canToggleReactions ? handleReactionSelect : undefined

View File

@@ -0,0 +1,105 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
buildMessageLink,
isMessageLink,
parseMessageLink,
} from "./messageLink.ts";
const CHANNEL = "f570339f-8f8a-4e08-a779-8d954aa44109";
const MESSAGE =
"b04819ffc1f7c8ffb49c6d30b5899f470198264680d02e78894a658e30a9059f";
const THREAD =
"1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef";
test("buildMessageLink → parseMessageLink round-trips without thread", () => {
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);
});

View File

@@ -0,0 +1,106 @@
/**
* `sprout://message` link encoding for "Copy link" / deep-link-to-message.
*
* Format: `sprout://message?channel=<uuid>&id=<eventId>[&thread=<rootId>]`
*
* 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";
}

View File

@@ -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 `<a>` 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,
};
});
}

View File

@@ -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",
},

View File

@@ -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({
</DropdownMenuItem>
) : null}
{hasCopyActions && channelId ? (
<DropdownMenuItem
data-testid={`copy-message-link-${message.id}`}
onClick={() => {
const { rootId } = getThreadReference(message.tags ?? []);
const link = buildMessageLink({
channelId,
messageId: message.id,
threadRootId: rootId,
});
copyToClipboard(link, "Link copied to clipboard");
}}
>
<Link2 className="h-4 w-4" />
Copy link
</DropdownMenuItem>
) : null}
{onDelete ? (
<>
<DropdownMenuSeparator />
@@ -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 ? (
<MoreActionsMenu
channelId={channelId}
message={message}
onDelete={onDelete}
onEdit={onEdit}

View File

@@ -28,6 +28,7 @@ const NESTED_REPLY_OFFSET_PX = 28;
export const MessageRow = React.memo(
function MessageRow({
activeReplyTargetId = null,
channelId = null,
highlighted = false,
layoutVariant = "default",
message,
@@ -199,6 +200,7 @@ export const MessageRow = React.memo(
<div className="absolute right-2 top-1 z-10">
<MessageActionBar
activeReplyTargetId={activeReplyTargetId}
channelId={channelId}
message={message}
onDelete={onDelete}
onEdit={onEdit}

View File

@@ -13,12 +13,26 @@ export interface DeepLinkDeps {
reconnectWorkspace: () => 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=<url>` 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<UnlistenFn> {
return listen<string>("deep-link-connect", (event) => {
@@ -37,3 +51,16 @@ export function listenForDeepLinks(deps: DeepLinkDeps): Promise<UnlistenFn> {
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<UnlistenFn> {
return listen<MessageDeepLinkPayload>("deep-link-message", (event) => {
onOpen(event.payload);
});
}

View File

@@ -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;
}

View File

@@ -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 `<ReactMarkdown>` 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");
});

View File

@@ -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<string, { image?: string; thumb?: string }>;
/**
* `urlTransform` for `<ReactMarkdown>` 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<string, string>,
): Components {
@@ -196,17 +219,43 @@ function createMarkdownComponents(
: "space-y-1 pl-6 marker:text-muted-foreground";
return {
a: ({ children, href, ...props }) => (
<a
{...props}
className="font-medium text-primary underline underline-offset-4 transition-colors hover:text-primary/80"
href={href}
rel="noreferrer"
target="_blank"
>
{children}
</a>
),
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 (
<a
{...props}
className="font-medium text-primary underline underline-offset-4 transition-colors hover:text-primary/80 cursor-pointer"
href={href}
onClick={(event) => {
event.preventDefault();
onOpenMessageLink(target);
}}
>
{children}
</a>
);
}
// Malformed sprout://message link — fall through to the default
// anchor (renders as a normal external link).
}
return (
<a
{...props}
className="font-medium text-primary underline underline-offset-4 transition-colors hover:text-primary/80"
href={href}
rel="noreferrer"
target="_blank"
>
{children}
</a>
);
},
blockquote: ({ children }) => (
<blockquote className="border-l-2 border-border pl-4 italic text-muted-foreground">
{children}
@@ -442,6 +491,35 @@ function createMarkdownComponents(
</span>
);
},
"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 <span data-message-link="">{href}</span>;
}
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 (
<button
type="button"
data-message-link=""
aria-label={`Open message in ${channelLabel}`}
title={href}
className="rounded-md bg-primary/15 px-1 py-0.5 text-sm font-medium text-primary cursor-pointer hover:bg-primary/25 transition-colors"
onClick={() => {
onOpenMessageLink(parsed.value);
}}
>
#{channelLabel} · {shortId}
</button>
);
},
} 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}
</ReactMarkdown>

View File

@@ -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]);
}