fix(desktop): render autolinked message links as chips (#1241)

Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Pinky <44b8e82baa6e0e254e0208d68f335c283c94e7b78dd1fa10d5a49d3f13dd0435@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
Wes
2026-06-24 09:47:12 -07:00
committed by GitHub
parent 733c766eba
commit 45067ec135
4 changed files with 140 additions and 34 deletions

View File

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

View File

@@ -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 (`<buzz://message?...>`) 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,
};
}

View File

@@ -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("<buzz://message?channel=abc&id=xyz>");
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)",

View File

@@ -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<string, ImetaEntry>;
type MessageLinkPillProps = {
channels: Channel[];
href: string;
interactive: boolean;
link: ParsedMessageLink;
onOpenMessageLink: (link: ParsedMessageLink) => void;
};
let shikiHighlighter: HighlighterGeneric<BundledLanguage, BundledTheme> | null =
null;
let shikiInitPromise: Promise<void> | 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 <span data-message-link="">{label}</span>;
}
return (
<button
type="button"
data-message-link=""
aria-label={`Open message in ${channelLabel}`}
title={href}
className={cn(
"cursor-pointer",
MENTION_CHIP_BASE_CLASSES,
MENTION_CHIP_HOVER_CLASSES,
)}
onClick={() => {
onOpenMessageLink(link);
}}
>
{label}
</button>
);
}
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 (
<MessageLinkPill
channels={runtimeRef.current.channels}
href={href}
interactive={interactive}
link={messageLinkTarget.link}
onOpenMessageLink={onOpenMessageLink}
/>
);
}
return (
<a
{...props}
@@ -1799,7 +1862,7 @@ function createMarkdownComponents(
href={href}
onClick={(event) => {
event.preventDefault();
onOpenMessageLink(target);
onOpenMessageLink(messageLinkTarget.link);
}}
>
{children}
@@ -2095,36 +2158,14 @@ function createMarkdownComponents(
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);
if (!interactive) {
return (
<span data-message-link="">
#{channelLabel} · {shortId}
</span>
);
}
return (
<button
type="button"
data-message-link=""
aria-label={`Open message in ${channelLabel}`}
title={href}
className={cn(
"cursor-pointer",
MENTION_CHIP_BASE_CLASSES,
MENTION_CHIP_HOVER_CLASSES,
)}
onClick={() => {
onOpenMessageLink(parsed.value);
}}
>
#{channelLabel} · {shortId}
</button>
<MessageLinkPill
channels={channels}
href={href}
interactive={interactive}
link={parsed.value}
onOpenMessageLink={onOpenMessageLink}
/>
);
},
} as Components;