Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion desktop/scripts/check-file-sizes.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
]);

Expand Down
91 changes: 91 additions & 0 deletions desktop/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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}");
}
Expand Down Expand Up @@ -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() {
Expand All @@ -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());
}
}
4 changes: 4 additions & 0 deletions desktop/src/app/AppShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 = () => {};
Expand Down
1 change: 1 addition & 0 deletions desktop/src/features/home/ui/InboxDetailPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,7 @@ export function InboxDetailPane({
<InboxMessageRow
activeReplyTargetId={replyTargetId}
canReply={canReply}
channelId={item.item.channelId}
isFocusHighlightVisible={isFocusHighlightVisible}
message={message}
onSelectReplyTarget={handleSelectReplyTarget}
Expand Down
4 changes: 4 additions & 0 deletions desktop/src/features/home/ui/InboxMessageRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -42,6 +44,7 @@ type InboxMessageRowProps = {
export function InboxMessageRow({
activeReplyTargetId,
canReply,
channelId = null,
isFocusHighlightVisible,
message,
onSelectReplyTarget,
Expand Down Expand Up @@ -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
Expand Down
105 changes: 105 additions & 0 deletions desktop/src/features/messages/lib/messageLink.test.mjs
Original file line number Diff line number Diff line change
@@ -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);
});
106 changes: 106 additions & 0 deletions desktop/src/features/messages/lib/messageLink.ts
Original file line number Diff line number Diff line change
@@ -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";
}
Loading