From c0189e5aaba82469d9750c84ac6471f2a367717d Mon Sep 17 00:00:00 2001 From: Wes Date: Wed, 24 Jun 2026 08:20:00 -0600 Subject: [PATCH] fix(desktop): render autolinked message links as chips Render CommonMark autolinks such as `` through the same message-link pill component as bare pasted message links, while preserving explicitly labeled message links. Centralize the anchor-rendering decision in the message-link helper so the bare-link plugin path and Markdown anchor path share the same parsing and URL-shape assumptions. Co-authored-by: Pinky <44b8e82baa6e0e254e0208d68f335c283c94e7b78dd1fa10d5a49d3f13dd0435@sprout-oss.stage.blox.sqprod.co> Signed-off-by: Wes --- .../messages/lib/messageLink.test.mjs | 29 +++++ .../src/features/messages/lib/messageLink.ts | 31 +++++ desktop/src/shared/ui/markdown.test.mjs | 5 + desktop/src/shared/ui/markdown.tsx | 109 ++++++++++++------ 4 files changed, 140 insertions(+), 34 deletions(-) diff --git a/desktop/src/features/messages/lib/messageLink.test.mjs b/desktop/src/features/messages/lib/messageLink.test.mjs index 410cafb44e..68ccd385ee 100644 --- a/desktop/src/features/messages/lib/messageLink.test.mjs +++ b/desktop/src/features/messages/lib/messageLink.test.mjs @@ -5,6 +5,7 @@ import { buildMessageLink, isMessageLink, parseMessageLink, + resolveMessageLinkRenderTarget, } from "./messageLink.ts"; const CHANNEL = "f570339f-8f8a-4e08-a779-8d954aa44109"; @@ -118,3 +119,31 @@ test("isMessageLink matches buzz://message and legacy buzz://message", () => { assert.equal(isMessageLink(undefined), false); assert.equal(isMessageLink(""), false); }); + +test("resolveMessageLinkRenderTarget distinguishes autolinks from labeled links", () => { + const href = `buzz://message?channel=${CHANNEL}&id=${MESSAGE}`; + + assert.deepEqual(resolveMessageLinkRenderTarget({ href, label: href }), { + kind: "pill", + link: { + channelId: CHANNEL, + messageId: MESSAGE, + threadRootId: null, + }, + }); + assert.deepEqual(resolveMessageLinkRenderTarget({ href, label: "message" }), { + kind: "label", + link: { + channelId: CHANNEL, + messageId: MESSAGE, + threadRootId: null, + }, + }); + assert.deepEqual( + resolveMessageLinkRenderTarget({ + href: "https://example.com", + label: href, + }), + { kind: "none" }, + ); +}); diff --git a/desktop/src/features/messages/lib/messageLink.ts b/desktop/src/features/messages/lib/messageLink.ts index b8fa899999..56f67b23af 100644 --- a/desktop/src/features/messages/lib/messageLink.ts +++ b/desktop/src/features/messages/lib/messageLink.ts @@ -102,3 +102,34 @@ export function isMessageLink(href: string | undefined | null): boolean { if (!href) return false; return href.startsWith("buzz://message?") || href === "buzz://message"; } + +type MessageLinkRenderInput = { + href: string; + label: string; +}; + +export type MessageLinkRenderTarget = + | { kind: "pill"; link: ParsedMessageLink } + | { kind: "label"; link: ParsedMessageLink } + | { kind: "none" }; + +/** + * Centralizes how markdown-rendered anchors map to message-link UI. Both + * CommonMark autolinks (``) and explicitly labeled links + * arrive as anchors; autolinks have label === href and should render as pills, + * while intentionally labeled links keep their label. + */ +export function resolveMessageLinkRenderTarget({ + href, + label, +}: MessageLinkRenderInput): MessageLinkRenderTarget { + if (!isMessageLink(href)) return { kind: "none" }; + + const parsed = parseMessageLink(href); + if (!parsed.ok) return { kind: "none" }; + + return { + kind: label === href ? "pill" : "label", + link: parsed.value, + }; +} diff --git a/desktop/src/shared/ui/markdown.test.mjs b/desktop/src/shared/ui/markdown.test.mjs index 952d101015..dfedc3360a 100644 --- a/desktop/src/shared/ui/markdown.test.mjs +++ b/desktop/src/shared/ui/markdown.test.mjs @@ -471,6 +471,11 @@ test("messageLinkUrlTransform: preserves buzz://message href", () => { assert.match(html, /href="buzz:\/\/message\?channel=abc&(?:amp;)?id=xyz"/); }); +test("messageLinkUrlTransform: preserves buzz://message autolink href", () => { + const html = renderMarkdown(""); + assert.match(html, /href="buzz:\/\/message\?channel=abc&(?:amp;)?id=xyz"/); +}); + test("messageLinkUrlTransform: preserves buzz://message href with thread", () => { const html = renderMarkdown( "[link](buzz://message?channel=c1&id=m1&thread=t1)", diff --git a/desktop/src/shared/ui/markdown.tsx b/desktop/src/shared/ui/markdown.tsx index 5fea77687c..df72492062 100644 --- a/desktop/src/shared/ui/markdown.tsx +++ b/desktop/src/shared/ui/markdown.tsx @@ -23,6 +23,7 @@ import { useAppNavigation } from "@/app/navigation/useAppNavigation"; import { isMessageLink, parseMessageLink, + resolveMessageLinkRenderTarget, type ParsedMessageLink, } from "@/features/messages/lib/messageLink"; import { UserProfilePopover } from "@/features/profile/ui/UserProfilePopover"; @@ -79,6 +80,14 @@ type ImetaEntry = { type ImetaLookup = Map; +type MessageLinkPillProps = { + channels: Channel[]; + href: string; + interactive: boolean; + link: ParsedMessageLink; + onOpenMessageLink: (link: ParsedMessageLink) => void; +}; + let shikiHighlighter: HighlighterGeneric | null = null; let shikiInitPromise: Promise | null = null; @@ -1252,6 +1261,46 @@ function getCodeBlockText(children: React.ReactNode) { return getReactNodeText(children).replace(/\n$/, ""); } +function MessageLinkPill({ + channels, + href, + interactive, + link, + onOpenMessageLink, +}: MessageLinkPillProps) { + const channel = channels.find((c) => c.id === link.channelId); + const channelLabel = channel?.name ?? "channel"; + const shortId = link.messageId.slice(0, 6); + const label = ( + <> + #{channelLabel} · {shortId} + + ); + + if (!interactive) { + return {label}; + } + + return ( + + ); +} + function InlineEmojiPopover({ alt, resolvedSrc, @@ -1788,10 +1837,24 @@ function createMarkdownComponents( // Intercept `buzz://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; + if (href) { + const messageLinkTarget = resolveMessageLinkRenderTarget({ + href, + label: getReactNodeText(children), + }); + if (messageLinkTarget.kind !== "none") { + if (messageLinkTarget.kind === "pill") { + return ( + + ); + } + return ( { event.preventDefault(); - onOpenMessageLink(target); + onOpenMessageLink(messageLinkTarget.link); }} > {children} @@ -2095,36 +2158,14 @@ function createMarkdownComponents( return {href}; } - const { channelId, messageId } = parsed.value; - const channel = channels.find((c) => c.id === channelId); - const channelLabel = channel?.name ?? "channel"; - const shortId = messageId.slice(0, 6); - - if (!interactive) { - return ( - - #{channelLabel} · {shortId} - - ); - } - return ( - + ); }, } as Components;