mirror of
https://github.com/block/buzz.git
synced 2026-10-27 12:40:30 +00:00
fix(desktop): add support for deeplinking to channel/messages (#759)
Signed-off-by: Wes <wesbillman@users.noreply.github.com>
This commit is contained in:
@@ -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
|
||||
]);
|
||||
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 = () => {};
|
||||
|
||||
@@ -260,6 +260,7 @@ export function InboxDetailPane({
|
||||
<InboxMessageRow
|
||||
activeReplyTargetId={replyTargetId}
|
||||
canReply={canReply}
|
||||
channelId={item.item.channelId}
|
||||
isFocusHighlightVisible={isFocusHighlightVisible}
|
||||
message={message}
|
||||
onSelectReplyTarget={handleSelectReplyTarget}
|
||||
|
||||
@@ -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
|
||||
|
||||
105
desktop/src/features/messages/lib/messageLink.test.mjs
Normal file
105
desktop/src/features/messages/lib/messageLink.test.mjs
Normal 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);
|
||||
});
|
||||
106
desktop/src/features/messages/lib/messageLink.ts
Normal file
106
desktop/src/features/messages/lib/messageLink.ts
Normal 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";
|
||||
}
|
||||
55
desktop/src/features/messages/lib/remarkMessageLinks.ts
Normal file
55
desktop/src/features/messages/lib/remarkMessageLinks.ts
Normal 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,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
34
desktop/src/shared/useMessageDeepLinks.ts
Normal file
34
desktop/src/shared/useMessageDeepLinks.ts
Normal 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]);
|
||||
}
|
||||
Reference in New Issue
Block a user