fix(desktop): add support for deeplinking to channel/messages#759
Merged
Conversation
Adds a Slack-style "Copy link" action to message rows that produces a
`sprout://message?channel=<uuid>&id=<eventId>[&thread=<rootId>]` URL.
Pasted/typed sprout:// links render as clickable in-app links and route
through the existing channel route's scroll-into-view + getEventById
backfill. The same scheme is also handled at the OS level via the
already-registered Tauri deep-link plugin.
- New `features/messages/lib/messageLink.{ts,test.mjs}` — build/parse
helpers + 10 round-trip / validation tests.
- `MessageActionBar` (+ `MessageRow`, `InboxMessageRow`/`InboxDetailPane`)
— Copy link dropdown item, gated on a `channelId` prop so callers
without it (rare) silently hide the action.
- `shared/ui/markdown.tsx` — `a` override branches on
`isMessageLink(href)` and dispatches to `useAppNavigation.goChannel`.
http(s) behavior unchanged.
- `features/messages/lib/useRichTextEditor.ts` — adds `protocols: ["sprout"]`
so TipTap's URL sanitiser doesn't strip the scheme on paste/typed input.
- `src-tauri/src/lib.rs` — `Some("message")` arm in `handle_deep_link_url`
emits `deep-link-message` with a JSON payload (channelId, messageId,
threadRootId).
- `shared/deep-link.ts` — adds `listenForMessageDeepLinks` +
`MessageDeepLinkPayload`. New `shared/useMessageDeepLinks.ts` hook
wires it into the router from inside `AppShell` (kept out of `App.tsx`
because `useAppNavigation` requires the router tree).
Routing simplification: the brief allowed skipping forum-vs-stream
detection if not trivially detectable. Rather than guess from
`threadRootId`, both the in-app and OS handlers always go through
`goChannel` with `messageId`; the channel route's existing infra
resolves the target whether it's a stream reply or a forum thread.
`threadRootId` is still parsed and preserved end-to-end for future use.
Verified: pnpm typecheck, pnpm check (biome + file-size), 289 node:test
units (incl. 10 new), pnpm build, just desktop-tauri-fmt-check, just
desktop-tauri-check, cargo test --lib (374 passed).
Signed-off-by: Wes <wesbillman@users.noreply.github.com>
…k params Beth-flagged review issues from PR #copy-link-to-messages: 1. react-markdown's defaultUrlTransform strips unknown schemes to "" before the <a> component override sees them, breaking copy → paste → click for sprout://message?… end-to-end. Add a urlTransform that passes message-link hrefs through unchanged and delegates everything else to defaultUrlTransform (so javascript: etc. stay safe). Covered by 5 new tests in markdown.test.mjs that render real <ReactMarkdown> and assert on the emitted href. 2. The Tauri sprout://message arm in lib.rs accepted empty values: sprout://message?channel=&id=foo would emit channelId: "" to the frontend. Filter empty params on the way in. Extracted the parse logic into parse_message_deep_link so it can be unit-tested without a live AppHandle; added 6 unit tests including a regression for the empty-channel case (NIT #4 from the review). 3. Verified cross-workspace fallback in ChannelRouteScreen: when the linked channel isn't in the active workspace, channelsQuery settles, activeChannel is null, ChannelScreen renders <ChannelScreenEmptyState/> ("Select a channel to view messages.") — no spinner-loop, no crash. Already graceful, no toast needed. 4. Documented threadRootId in messageLink.ts as reserved for future "open in thread view" routing (NIT #5). File-size override for lib.rs bumped 715→730 with a precise note for the parse helper extraction + 6 unit tests. Signed-off-by: Wes <wesbillman@users.noreply.github.com>
The existing inlined copy + comment was reused from the markdownUtils pattern, but messageLink.ts has no React dependency so it can be imported directly. Removes one source of drift between markdown.tsx and its test. Signed-off-by: Wes <wesbillman@users.noreply.github.com>
remark-gfm only autolinks http(s)://, so bare sprout://message URLs in message text rendered as raw 100-char strings. Add remarkMessageLinks plugin (built on the existing createRemarkPrefixPlugin factory, mirroring remarkChannelLinks/remarkMentions) that replaces matched URLs with a custom message-link HAST element. markdown.tsx renders that as a clickable pill (#channel · 6-char-id), routed through the existing onOpenMessageLink handler. Malformed URLs render as plain text rather than misleading clickable pills. Trailing punctuation and whitespace terminate the match. Plugin uses the explicit .ts extension on its factory import so the file is consumable from both Vite (markdown.tsx) and node --test --experimental-strip-types (markdown.test.mjs). Signed-off-by: Wes <wesbillman@users.noreply.github.com>
…age URLs
Bare `sprout://message?…` URLs at end-of-sentence (e.g. "See sprout://…abc.")
were being matched whole, including the trailing `.`, which made
`parseMessageLink` produce `messageId: "abc."` and route to a nonexistent
message instead of the copied event id.
Peel trailing sentence punctuation (`. , ; : ! ?`) and unmatched closing
brackets off each match before emitting the message-link node; the trimmed
characters are appended as plain text. Implemented by extending
`createRemarkPrefixPlugin` to accept `{ node, trailing }` from its builder
so `remarkMessageLinks` doesn't need its own walker — the same factory now
handles mentions, channels, and message links uniformly, including
link/code/inlineCode skipping which was previously per-plugin.
Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Collaborator
Author
This was referenced May 28, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.

What
Polishes the
sprout://messageautolinker added earlier in this branch and fixes one real bug surfaced in review.Bug fix — trailing punctuation eaten by URL match.
Bare
sprout://message?…URLs at end-of-sentence were matched whole, including the trailing./,/;/:/!/?, soparseMessageLinkproduced e.g.messageId: "abcdef."and the pill routed to a nonexistent message instead of the copied event id. The match now peels off trailing sentence punctuation and unmatched closing brackets, emitting them as plain text after the pill.Structural cleanup.
The fix initially landed as a duplicate tree-walker in
remarkMessageLinks.ts. Folded it back intocreateRemarkPrefixPluginso the factory now:{ node, trailing }from its builder (mentions/channels still return a bare node — legacy path preserved);link/code/inlineCodenodes once, on behalf of all three plugins.Net effect:
remarkMessageLinks.tsis 36 lines, no duplicated walker.Files
desktop/src/shared/lib/createRemarkPrefixPlugin.ts—{ node, trailing }return shape; centralized skip list.desktop/src/features/messages/lib/remarkMessageLinks.ts— trim helper + builder, no walker.desktop/src/shared/ui/markdown.test.mjs— cases for. , ; : ! ?, paren wrapping, no-trailing sanity.Verification
node --test --experimental-strip-types src/shared/ui/markdown.test.mjs✅pnpm typecheck,pnpm check,pnpm build✅Deferred
Wes asked whether the pill should show channel name + message snippet instead of
#channel · 6char. Three options were considered (cache-only snippet, async fetch with skeleton, channel-only). Decision: keep current channel + 6-char shape for now; revisit when there's a story for fetching the linked message body synchronously at render time.