From 84447c1f43994c3c9d3d2df25a8c0c23212bd8b1 Mon Sep 17 00:00:00 2001 From: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w <52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@buzz.block.builderlab.xyz> Date: Thu, 30 Jul 2026 23:35:32 -0700 Subject: [PATCH 1/6] feat(desktop): embed linked buzz messages Co-authored-by: Taylor Ho Signed-off-by: Taylor Ho --- .../messages/lib/messageEmbed.test.mjs | 83 ++++++++++++++ .../src/features/messages/lib/messageEmbed.ts | 83 ++++++++++++++ desktop/src/shared/ui/markdown.tsx | 14 +-- .../src/shared/ui/markdown/MessageEmbed.tsx | 107 ++++++++++++++++++ .../shared/ui/markdown/MessageEmbedList.tsx | 45 ++++++++ .../shared/ui/markdown/MessageLinkPill.tsx | 12 +- desktop/tests/e2e/navigation.spec.ts | 7 +- 7 files changed, 338 insertions(+), 13 deletions(-) create mode 100644 desktop/src/features/messages/lib/messageEmbed.test.mjs create mode 100644 desktop/src/features/messages/lib/messageEmbed.ts create mode 100644 desktop/src/shared/ui/markdown/MessageEmbed.tsx create mode 100644 desktop/src/shared/ui/markdown/MessageEmbedList.tsx diff --git a/desktop/src/features/messages/lib/messageEmbed.test.mjs b/desktop/src/features/messages/lib/messageEmbed.test.mjs new file mode 100644 index 0000000000..74cf9b785c --- /dev/null +++ b/desktop/src/features/messages/lib/messageEmbed.test.mjs @@ -0,0 +1,83 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + canReadMessageEmbedSource, + extractMessageEmbedLinks, + isMatchingMessageEmbedEvent, + messageEmbedExcerpt, +} from "./messageEmbed.ts"; + +const link = "buzz://message?channel=channel-1&id=event-1"; + +test("extractMessageEmbedLinks extracts and deduplicates bare links", () => { + assert.deepEqual(extractMessageEmbedLinks(`See ${link}. Again: ${link}`), [ + { + channelId: "channel-1", + href: link, + messageId: "event-1", + threadRootId: null, + }, + ]); +}); + +test("extractMessageEmbedLinks skips labeled and code links", () => { + assert.deepEqual( + extractMessageEmbedLinks( + `[context](${link}) \`${link}\`\n\`\`\`\n${link}\n\`\`\``, + ), + [], + ); +}); + +test("canReadMessageEmbedSource allows joined and open channels only", () => { + assert.equal(canReadMessageEmbedSource(undefined), false); + assert.equal( + canReadMessageEmbedSource({ isMember: false, visibility: "private" }), + false, + ); + assert.equal( + canReadMessageEmbedSource({ isMember: true, visibility: "private" }), + true, + ); + assert.equal( + canReadMessageEmbedSource({ isMember: false, visibility: "open" }), + true, + ); +}); + +test("isMatchingMessageEmbedEvent requires both requested id and channel h tag", () => { + const event = { + id: "event-1", + pubkey: "author", + created_at: 1, + kind: 9, + tags: [["h", "channel-1"]], + content: "secret", + sig: "sig", + }; + assert.equal( + isMatchingMessageEmbedEvent(event, extractMessageEmbedLinks(link)[0]), + true, + ); + assert.equal( + isMatchingMessageEmbedEvent( + { ...event, id: "other" }, + extractMessageEmbedLinks(link)[0], + ), + false, + ); + assert.equal( + isMatchingMessageEmbedEvent( + { ...event, tags: [["h", "private-other"]] }, + extractMessageEmbedLinks(link)[0], + ), + false, + ); +}); + +test("messageEmbedExcerpt normalizes whitespace and bounds source text", () => { + assert.equal(messageEmbedExcerpt("hello\n\n world"), "hello world"); + assert.equal(messageEmbedExcerpt("x".repeat(500)).length, 420); + assert.match(messageEmbedExcerpt("x".repeat(500)), /…$/); +}); diff --git a/desktop/src/features/messages/lib/messageEmbed.ts b/desktop/src/features/messages/lib/messageEmbed.ts new file mode 100644 index 0000000000..bcbdc47049 --- /dev/null +++ b/desktop/src/features/messages/lib/messageEmbed.ts @@ -0,0 +1,83 @@ +import type { Channel, RelayEvent } from "@/shared/api/types"; + +import { parseMessageLink, type ParsedMessageLink } from "./messageLink"; + +const MESSAGE_LINK_PATTERN = /(?:buzz):\/\/message\?[^\s<>"')\]]+/g; +const TRAILING_PUNCTUATION_PATTERN = /[.,;:!?]+$/; +const MAX_MESSAGE_EMBEDS = 4; +const MAX_EXCERPT_LENGTH = 420; + +export type MessageEmbedLink = ParsedMessageLink & { href: string }; + +function collectCodeRanges( + content: string, +): Array<{ start: number; end: number }> { + const ranges: Array<{ start: number; end: number }> = []; + for (const match of content.matchAll(/```[\s\S]*?```|~~~[\s\S]*?~~~/g)) { + ranges.push({ + start: match.index ?? 0, + end: (match.index ?? 0) + match[0].length, + }); + } + for (const match of content.matchAll(/`[^`\n]*`/g)) { + ranges.push({ + start: match.index ?? 0, + end: (match.index ?? 0) + match[0].length, + }); + } + return ranges; +} + +function isInsideRange( + index: number, + ranges: Array<{ start: number; end: number }>, +) { + return ranges.some((range) => index >= range.start && index < range.end); +} + +/** Extract only bare message permalinks; authored markdown labels stay labels. */ +export function extractMessageEmbedLinks(content: string): MessageEmbedLink[] { + const codeRanges = collectCodeRanges(content); + const links: MessageEmbedLink[] = []; + const seen = new Set(); + + for (const match of content.matchAll(MESSAGE_LINK_PATTERN)) { + const index = match.index ?? 0; + if (isInsideRange(index, codeRanges)) continue; + // `[label](buzz://…)` is intentionally labeled and must not unfurl. + if (content.slice(Math.max(0, index - 2), index) === "](") continue; + + const href = match[0].replace(TRAILING_PUNCTUATION_PATTERN, ""); + if (seen.has(href)) continue; + const parsed = parseMessageLink(href); + if (!parsed.ok) continue; + + seen.add(href); + links.push({ href, ...parsed.value }); + if (links.length === MAX_MESSAGE_EMBEDS) break; + } + + return links; +} + +export function canReadMessageEmbedSource( + channel: Pick | undefined, +): boolean { + return ( + channel !== undefined && (channel.isMember || channel.visibility === "open") + ); +} + +export function isMatchingMessageEmbedEvent( + event: RelayEvent, + link: ParsedMessageLink, +): boolean { + const eventChannelId = event.tags.find((tag) => tag[0] === "h")?.[1]; + return event.id === link.messageId && eventChannelId === link.channelId; +} + +export function messageEmbedExcerpt(content: string): string { + const normalized = content.replace(/\s+/g, " ").trim(); + if (normalized.length <= MAX_EXCERPT_LENGTH) return normalized; + return `${normalized.slice(0, MAX_EXCERPT_LENGTH - 1).trimEnd()}…`; +} diff --git a/desktop/src/shared/ui/markdown.tsx b/desktop/src/shared/ui/markdown.tsx index b1f9623f3e..7ef41eedb2 100644 --- a/desktop/src/shared/ui/markdown.tsx +++ b/desktop/src/shared/ui/markdown.tsx @@ -110,6 +110,7 @@ import { import { MarkdownTable } from "./markdown/MarkdownTable"; import { MaskedLinkTooltip } from "./markdown/MaskedLinkTooltip"; import { ProgressiveImage } from "./markdown/ProgressiveImage"; +import { MessageEmbedList } from "./markdown/MessageEmbedList"; import { MessageLinkPill } from "./markdown/MessageLinkPill"; import { renderCachedMarkdown } from "./markdown/nodeCache"; import { @@ -1854,13 +1855,6 @@ function MarkdownInner({ ); const onOpenMessageLink = React.useCallback( (link: ParsedMessageLink) => { - // Always route through `goChannel` with `messageId` set: the channel - // route already handles scroll-into-view + highlight via - // `useAnchoredScroll` + `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, threadRootId: link.threadRootId, @@ -1971,6 +1965,12 @@ function MarkdownInner({ ) : null} + {resolvedLinkPreviews.length > 0 ? ( void; +}) { + const canRead = canReadMessageEmbedSource(channel); + const eventQuery = useQuery({ + queryKey: ["message-embed", link.channelId, link.messageId], + queryFn: () => getEventById(link.messageId), + enabled: canRead, + retry: false, + staleTime: 60_000, + }); + const event = + canRead && + eventQuery.data && + isMatchingMessageEmbedEvent(eventQuery.data, link) + ? eventQuery.data + : null; + // Never resolve an author profile until channel access and event-channel + // integrity have both been established. + const profileQuery = useUserProfileQuery(event?.pubkey); + const profile = profileQuery.data; + + if (!canRead || eventQuery.isError || (eventQuery.data && !event)) { + return ( +
+
+ ); + } + + if (!event) { + return ( +
+ Loading message preview +
+
+
+
+ ); + } + + const displayName = + profile?.displayName?.trim() || truncatePubkey(event.pubkey); + const excerpt = messageEmbedExcerpt(event.content) || "Message has no text"; + + return ( + + ); +} diff --git a/desktop/src/shared/ui/markdown/MessageEmbedList.tsx b/desktop/src/shared/ui/markdown/MessageEmbedList.tsx new file mode 100644 index 0000000000..6ec76ee2fb --- /dev/null +++ b/desktop/src/shared/ui/markdown/MessageEmbedList.tsx @@ -0,0 +1,45 @@ +import * as React from "react"; + +import { + extractMessageEmbedLinks, + type MessageEmbedLink, +} from "@/features/messages/lib/messageEmbed"; +import type { ParsedMessageLink } from "@/features/messages/lib/messageLink"; +import type { Channel } from "@/shared/api/types"; +import { AttachmentGroup } from "@/shared/ui/attachment"; + +import { MessageEmbed } from "./MessageEmbed"; + +export function MessageEmbedList({ + channels, + content, + interactive, + onOpenMessageLink, +}: { + channels: Channel[]; + content: string; + interactive: boolean; + onOpenMessageLink: (link: ParsedMessageLink) => void; +}) { + const links = React.useMemo( + () => (interactive ? extractMessageEmbedLinks(content) : []), + [content, interactive], + ); + if (links.length === 0) return null; + + return ( + + {links.map((link: MessageEmbedLink) => ( + channel.id === link.channelId)} + key={link.href} + link={link} + onOpen={() => onOpenMessageLink(link)} + /> + ))} + + ); +} diff --git a/desktop/src/shared/ui/markdown/MessageLinkPill.tsx b/desktop/src/shared/ui/markdown/MessageLinkPill.tsx index fad6904fa8..4eda92a15e 100644 --- a/desktop/src/shared/ui/markdown/MessageLinkPill.tsx +++ b/desktop/src/shared/ui/markdown/MessageLinkPill.tsx @@ -1,3 +1,4 @@ +import { canReadMessageEmbedSource } from "@/features/messages/lib/messageEmbed"; import { cn } from "@/shared/lib/cn"; import { MENTION_CHIP_BASE_CLASSES, @@ -14,15 +15,20 @@ export function MessageLinkPill({ onOpenMessageLink, }: MessageLinkPillProps) { const channel = channels.find((c) => c.id === link.channelId); - const channelLabel = channel?.name ?? "channel"; + const canRead = canReadMessageEmbedSource(channel); + const channelLabel = canRead + ? (channel?.name ?? "channel") + : "private channel"; const shortId = link.messageId.slice(0, 6); - const label = ( + const label = canRead ? ( <> #{channelLabel} · {shortId} + ) : ( + <>Private message ); - if (!interactive) { + if (!interactive || !canRead) { return {label}; } diff --git a/desktop/tests/e2e/navigation.spec.ts b/desktop/tests/e2e/navigation.spec.ts index f7a96cd568..0d411dc795 100644 --- a/desktop/tests/e2e/navigation.spec.ts +++ b/desktop/tests/e2e/navigation.spec.ts @@ -326,9 +326,10 @@ test("message links to visible root messages open the thread panel", async ({ .filter({ hasText: "Root link repro" }) .last(); await expect(linkMessage).toBeVisible(); - await linkMessage - .getByRole("button", { name: "Open message in general" }) - .click(); + const embed = linkMessage.locator('[data-message-embed="resolved"]'); + await expect(embed).toContainText("Welcome to #general"); + await expect(embed).toContainText("#general"); + await embed.click(); const threadPanel = page.getByTestId("message-thread-panel"); await expect(threadPanel).toBeVisible(); From 216b79588ec7f1d2f013393093240e2f93439c8d Mon Sep 17 00:00:00 2001 From: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w <52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@buzz.block.builderlab.xyz> Date: Sat, 1 Aug 2026 09:18:19 -0700 Subject: [PATCH 2/6] fix(desktop): refine message embed spacing Co-authored-by: Taylor Ho Signed-off-by: Taylor Ho --- desktop/src/shared/ui/markdown/MessageEmbed.tsx | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/desktop/src/shared/ui/markdown/MessageEmbed.tsx b/desktop/src/shared/ui/markdown/MessageEmbed.tsx index 1466101b92..22457c274d 100644 --- a/desktop/src/shared/ui/markdown/MessageEmbed.tsx +++ b/desktop/src/shared/ui/markdown/MessageEmbed.tsx @@ -44,7 +44,7 @@ export function MessageEmbed({ if (!canRead || eventQuery.isError || (eventQuery.data && !event)) { return (