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..fc58760046 --- /dev/null +++ b/desktop/src/features/messages/lib/messageEmbed.test.mjs @@ -0,0 +1,82 @@ +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 without truncating source text", () => { + assert.equal(messageEmbedExcerpt("hello\n\n world"), "hello world"); + assert.equal(messageEmbedExcerpt("x".repeat(500)).length, 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..d864d078c0 --- /dev/null +++ b/desktop/src/features/messages/lib/messageEmbed.ts @@ -0,0 +1,80 @@ +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; + +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 { + return content.replace(/\s+/g, " ").trim(); +} 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; + const excerpt = event + ? messageEmbedExcerpt(event.content) || "Message has no text" + : ""; + const [expanded, setExpanded] = useState(false); + const [isClamped, setIsClamped] = useState(false); + const excerptRef = useRef(null); + + useEffect(() => { + const excerptElement = excerptRef.current; + if (!excerptElement) return; + const updateClampedState = () => + setIsClamped(excerptElement.scrollHeight > excerptElement.clientHeight); + updateClampedState(); + const observer = new ResizeObserver(updateClampedState); + observer.observe(excerptElement); + return () => observer.disconnect(); + }); + + if (!canRead || eventQuery.isError || (eventQuery.data && !event)) { + return ( +
+
+ ); + } + + if (!event) { + return ( +
+ Loading message preview +
+
+
+
+ ); + } + + const displayName = + profile?.displayName?.trim() || truncatePubkey(event.pubkey); + + return ( +
+
+ + + {displayName} + + + +
+ + {excerpt} + + {expanded || isClamped ? ( + + ) : null} +
+ ); +} diff --git a/desktop/src/shared/ui/markdown/MessageEmbedList.tsx b/desktop/src/shared/ui/markdown/MessageEmbedList.tsx new file mode 100644 index 0000000000..8ba9abd116 --- /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..5050d61b7d 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,16 +15,28 @@ 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 or channel ); - if (!interactive) { - return {label}; + if (!interactive || !canRead) { + return ( + + {label} + + ); } return ( diff --git a/desktop/tests/e2e/navigation.spec.ts b/desktop/tests/e2e/navigation.spec.ts index f7a96cd568..5461683297 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.getByRole("button", { name: /Open message by/ }).click(); const threadPanel = page.getByTestId("message-thread-panel"); await expect(threadPanel).toBeVisible();