From 7e6e9c547fa97abff6929cf2702b956586eec9bc Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Mon, 10 Aug 2026 18:38:38 -0700 Subject: [PATCH] fix(link-preview): restore Buzz entity link cards (#5494) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **Category:** fix **User Impact:** Buzz pull request, issue, and repository links now show compact, useful metadata cards in received messages, including messages sent by agents and the CLI. **Problem:** Sender-authored snapshots protect recipients from external preview fetches, but that change also removed recipient-side cards for trusted Buzz entity links when the sender did not attach snapshots. **Solution:** Resolve recognized Buzz entities only against the active relay and show signed repository identity, title, and compact builder context with the current inline Buzz mark in the favicon slot, but without avatars, thumbnails, or external image fetches. Entity metadata wins over conflicting sender snapshots, while unsupported or unavailable metadata retains a safe text fallback.
File changes **desktop/playwright.config.ts** Adds the entity-link regression spec to the smoke test project. **desktop/src/features/messages/ui/useComposerLinkPreviews.tsx** Treats recognized Buzz entity cards as complete without generating snapshot tags and retains fallback cards when relay metadata is absent. **desktop/src/shared/lib/useResolvedLinkPreviews.test.mjs** Covers kind-scoped entity detection, trusted relay metadata, root-scoped lifecycle queries, exact single-repository root binding, image-less pending state, and fallback behavior. **desktop/src/shared/lib/useResolvedLinkPreviews.ts** Resolves signed repository, pull request, and issue metadata from the active relay. Entity roots fail closed unless they carry exactly one matching repository tag; lifecycle queries are root-scoped before limits; successful metadata remains stable until relay/community reset, and PR commit context uses the immutable root event rather than an unindexed update query. **desktop/src/shared/ui/compact-link-preview-attachment.tsx** Uses Buzz repository identity as the compact card provider and avoids reserving thumbnail space for image-less entity cards. **desktop/src/shared/ui/markdown.tsx** Routes message cards through the combined entity/snapshot preview hook. **desktop/src/shared/ui/markdown/useMessageLinkPreviews.test.mjs** Proves relay-authenticated entity metadata beats a forged sender snapshot while preserving mixed-link content order. **desktop/src/shared/ui/markdown/useMessageLinkPreviews.ts** Combines recipient-resolved Buzz entities with sender-authored external snapshots using explicit trust precedence and first-seen ordering. **desktop/tests/e2e/entity-link-recipient-cards.spec.ts** Exercises repository identity, PR workflow context, repository metadata, image-less rendering, and composer send behavior for agent/CLI-style entity links.
## Reproduction steps 1. Open a channel containing a message sent without `link-preview` tags whose content includes valid `buzz://pr`, `buzz://issue`, or `buzz://repo` links. 2. Confirm each card shows its repository identity and signed title; PRs/issues also show compact lifecycle context, and repositories show description/status/default branch. 3. Confirm the cards use the Buzz mark in the favicon slot with no avatar, thumbnail, or reserved image area. 4. Compose and send a message containing a Buzz entity link; confirm sending is not blocked waiting for a snapshot. 5. Send a message containing both a Buzz entity link and a snapshot-backed HTTPS link; confirm cards follow content order and the HTTPS link remains sender-snapshot-only. ## Screenshots ### Recipient view — Buzz-branded metadata cards Repository identity, title, and compact builder context render with the current inline Buzz mark in the favicon slot and no avatar, thumbnail, or reserved image space. ![Recipient view showing Buzz-branded PR and repository cards](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/5494/01-recipient-entity-cards-current-buzz-mark.png) ## Validation At commit `7bc70b0a9f70392bd062ed25b1d2362cc4021a40` with a clean working tree: - Pre-push hooks passed: branch skew, desktop check, desktop typecheck, and full desktop unit suite - Full desktop unit suite: 4,560 passed - Purpose-built Playwright regression after a fresh E2E build: 2 passed - Screenshot regenerated from the same commit and visually inspected Originating conversation: Buzz channel `c2859932-b679-4091-9c7e-f5a65deddd64`, thread `93c3e7be59a8d1ec10b4992efd783a2a79f253a10f10d39746c6ad41b0d5bb42`. --------- Signed-off-by: Taylor Ho --- desktop/playwright.config.ts | 1 + .../messages/ui/useComposerLinkPreviews.tsx | 31 ++- .../lib/useResolvedLinkPreviews.test.mjs | 254 +++++++++++++++++- .../src/shared/lib/useResolvedLinkPreviews.ts | 187 ++++++++++--- .../ui/compact-link-preview-attachment.tsx | 20 +- desktop/src/shared/ui/markdown.tsx | 22 +- .../markdown/useMessageLinkPreviews.test.mjs | 64 +++++ .../ui/markdown/useMessageLinkPreviews.ts | 102 +++++++ .../e2e/entity-link-recipient-cards.spec.ts | 167 ++++++++++++ 9 files changed, 786 insertions(+), 62 deletions(-) create mode 100644 desktop/src/shared/ui/markdown/useMessageLinkPreviews.test.mjs create mode 100644 desktop/src/shared/ui/markdown/useMessageLinkPreviews.ts create mode 100644 desktop/tests/e2e/entity-link-recipient-cards.spec.ts diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index c1ea0e061..b574e026e 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -62,6 +62,7 @@ export default defineConfig({ "**/video-attachment.spec.ts", "**/spoiler.spec.ts", "**/composer-link-shortcut.spec.ts", + "**/entity-link-recipient-cards.spec.ts", "**/composer-selection-formatting.spec.ts", "**/composer-tooltip-dismiss.spec.ts", "**/mentions.spec.ts", diff --git a/desktop/src/features/messages/ui/useComposerLinkPreviews.tsx b/desktop/src/features/messages/ui/useComposerLinkPreviews.tsx index 39d5d9db8..25ae7ada2 100644 --- a/desktop/src/features/messages/ui/useComposerLinkPreviews.tsx +++ b/desktop/src/features/messages/ui/useComposerLinkPreviews.tsx @@ -11,8 +11,12 @@ import { beginRelayOriginFetch, getCachedRelayOrigin, } from "@/shared/lib/mediaUrl"; -import type { ResolvedLinkPreview } from "@/shared/lib/useResolvedLinkPreviews"; -import { useResolvedLinkPreviews } from "@/shared/lib/useResolvedLinkPreviews"; +import { + isBuzzEntityPreview, + type ResolvedLinkPreview, + useResolvedLinkPreviews, + withEntityFallbacks, +} from "@/shared/lib/useResolvedLinkPreviews"; import { Attachment, AttachmentContent, @@ -43,6 +47,10 @@ function ComposerLinkPreviewCard({ ); const showImage = Boolean(imageSrc && failedImageSrc !== imageSrc); const hostname = previewHostname(preview.href); + // `buzz://` entity links never produce snapshot tags (recipients render + // them from message content against the relay), so they are "done" as soon + // as they exist — there is no snapshot to wait for. + const done = preview.snapshotReady || isBuzzEntityPreview(preview); let path = ""; try { const url = new URL(preview.href); @@ -55,7 +63,7 @@ function ComposerLinkPreviewCard({ data-image-state={preview.imageState} data-link-preview={preview.kind} data-link-preview-composer-card="" - state={preview.snapshotReady ? "done" : "processing"} + state={done ? "done" : "processing"} > - {preview.snapshotReady ? preview.title : hostname} + {done ? preview.title : hostname} - {preview.snapshotReady + {done ? preview.provider || hostname : path && path !== "/" ? path @@ -136,13 +144,22 @@ export function useComposerLinkPreviews(content: string) { const candidates = React.useMemo( () => extractSupportedLinkPreviews(content).filter((preview) => - preview.href.startsWith("buzz://") + isBuzzEntityPreview(preview) ? true : isValidLinkPreviewSnapshotCanonicalUrl(preview.href), ), [content], ); - const previews = useResolvedLinkPreviews(suppressed ? [] : candidates); + const resolvedPreviews = useResolvedLinkPreviews( + suppressed ? [] : candidates, + ); + // Entity links resolve to null metadata when the relay lookup has nothing + // for them (repo links always do — only PR/issue titles are fetched); keep + // their cards on the fallback title rather than dropping them. + const previews = React.useMemo( + () => withEntityFallbacks(suppressed ? [] : candidates, resolvedPreviews), + [suppressed, candidates, resolvedPreviews], + ); React.useEffect(() => { if (candidates.length === 0) setSuppressed(false); }, [candidates.length]); diff --git a/desktop/src/shared/lib/useResolvedLinkPreviews.test.mjs b/desktop/src/shared/lib/useResolvedLinkPreviews.test.mjs index bf7e39361..c0c91d9f1 100644 --- a/desktop/src/shared/lib/useResolvedLinkPreviews.test.mjs +++ b/desktop/src/shared/lib/useResolvedLinkPreviews.test.mjs @@ -3,7 +3,10 @@ import test from "node:test"; import { __linkPreviewMetadataTest, + fetchBuzzEntityMetadata, + isBuzzEntityPreview, resolveLinkPreview, + withEntityFallbacks, } from "./useResolvedLinkPreviews.ts"; const preview = { @@ -27,13 +30,27 @@ function metadata(overrides = {}) { }; } -test("pending metadata reserves the image treatment", () => { +test("pending external metadata reserves the image treatment", () => { assert.deepEqual(resolveLinkPreview(preview, undefined), { ...preview, imageState: "pending", }); }); +test("pending Buzz entity metadata remains image-less", () => { + const entityPreview = { + kind: "buzz-repository", + href: `buzz://repo?owner=${"cd".repeat(32)}&d=buzz`, + provider: "Buzz", + title: "buzz", + typeLabel: "repo", + }; + assert.deepEqual(resolveLinkPreview(entityPreview, undefined), { + ...entityPreview, + imageState: "none", + }); +}); + test("resolved image metadata keeps the reserved image treatment", () => { const resolved = resolveLinkPreview(preview, { title: "A story", @@ -180,3 +197,238 @@ test("metadata loader coalesces fragment variants and bounds concurrency", async assert.equal(calls, 3); assert.equal(maxActive, 2); }); + +test("withEntityFallbacks re-adds previews dropped by null metadata", () => { + const entityPreview = { + kind: "buzz-pull-request", + href: `buzz://pr?id=${"ab".repeat(32)}&owner=${"cd".repeat(32)}&d=buzz`, + provider: "Buzz", + title: `buzz #${"ab".repeat(4)}`, + typeLabel: "PR", + }; + + assert.deepEqual(withEntityFallbacks([entityPreview], []), [ + { ...entityPreview, imageState: "none" }, + ]); +}); + +test("withEntityFallbacks keeps resolved previews and preserves order", () => { + const first = { + kind: "buzz-repository", + href: `buzz://repo?owner=${"cd".repeat(32)}&d=buzz`, + provider: "Buzz", + title: "buzz", + typeLabel: "repo", + }; + const second = { + kind: "buzz-issue", + href: `buzz://issue?id=${"ef".repeat(32)}&owner=${"cd".repeat(32)}&d=buzz`, + provider: "Buzz", + title: `buzz #${"ef".repeat(4)}`, + typeLabel: "issue", + }; + const resolvedSecond = { + ...second, + title: "Fix the preview cards", + imageState: "none", + }; + + assert.deepEqual(withEntityFallbacks([first, second], [resolvedSecond]), [ + { ...first, imageState: "none" }, + resolvedSecond, + ]); +}); + +test("entity fallback eligibility is kind-scoped", () => { + assert.equal( + isBuzzEntityPreview({ + ...preview, + kind: "buzz-repository", + href: `buzz://repo?owner=${"cd".repeat(32)}&d=buzz`, + }), + true, + ); + assert.equal( + isBuzzEntityPreview({ ...preview, href: "buzz://future?id=example" }), + false, + ); +}); + +test("withEntityFallbacks still drops unresolved external links", () => { + assert.deepEqual(withEntityFallbacks([preview], []), []); + assert.deepEqual( + withEntityFallbacks([{ ...preview, href: "buzz://future?id=example" }], []), + [], + ); +}); + +function relayEvent({ + id, + kind, + pubkey, + content = "", + tags = [], + createdAt = 1, +}) { + return { id, kind, pubkey, created_at: createdAt, content, tags, sig: "" }; +} + +test("Buzz PR metadata includes repository identity and trusted root context", async () => { + const owner = "cd".repeat(32); + const attacker = "ef".repeat(32); + const id = "ab".repeat(32); + const repoAddress = `30617:${owner}:buzz`; + const commit = "1234567".padEnd(40, "0"); + const events = [ + relayEvent({ + id: "01".repeat(32), + kind: 30617, + pubkey: owner, + tags: [ + ["d", "buzz"], + ["name", "Buzz Desktop"], + ["default-branch", "main"], + ], + }), + relayEvent({ + id, + kind: 1618, + pubkey: owner, + content: "Body", + tags: [ + ["a", repoAddress], + ["subject", "Restore entity cards"], + ["branch-name", "fix/cards"], + ["target-branch", "release"], + ["c", commit], + ], + }), + relayEvent({ + id: "02".repeat(32), + kind: 1633, + pubkey: attacker, + createdAt: 20, + tags: [["e", id]], + }), + relayEvent({ + id: "03".repeat(32), + kind: 1630, + pubkey: owner, + createdAt: 10, + tags: [["e", id]], + }), + ...Array.from({ length: 25 }, (_, index) => + relayEvent({ + id: index.toString(16).padStart(64, "0"), + kind: 1633, + pubkey: owner, + createdAt: 100 + index, + tags: [["e", index.toString(16).padStart(64, "f")]], + }), + ), + ]; + const fetchEvents = async (filter) => + events + .filter( + (event) => + (!filter.kinds || filter.kinds.includes(event.kind)) && + (!filter.ids || filter.ids.includes(event.id)) && + (!filter.authors || filter.authors.includes(event.pubkey)) && + (!filter["#d"] || + event.tags.some( + (tag) => tag[0] === "d" && filter["#d"].includes(tag[1]), + )) && + (!filter["#a"] || + event.tags.some( + (tag) => tag[0] === "a" && filter["#a"].includes(tag[1]), + )) && + (!filter["#e"] || + event.tags.some( + (tag) => tag[0] === "e" && filter["#e"].includes(tag[1]), + )), + ) + .sort((left, right) => right.created_at - left.created_at) + .slice(0, filter.limit); + + const result = await fetchBuzzEntityMetadata( + `buzz://pr?id=${id}&owner=${owner}&d=buzz`, + fetchEvents, + ); + assert.equal(result?.siteName, "Buzz Desktop"); + assert.equal(result?.title, "Restore entity cards"); + assert.equal(result?.description, "Open · fix/cards → release · 1234567"); + assert.equal(result?.faviconDataUrl, null); + assert.equal(result?.imageDataUrl, null); +}); + +test("Buzz entity roots reject ambiguous repository tags", async () => { + const owner = "cd".repeat(32); + const attacker = "ef".repeat(32); + const targetAddress = `30617:${owner}:buzz`; + const attackerAddress = `30617:${attacker}:other`; + const repository = relayEvent({ + id: "01".repeat(32), + kind: 30617, + pubkey: owner, + tags: [ + ["d", "buzz"], + ["name", "Buzz Desktop"], + ["default-branch", "main"], + ], + }); + + for (const [type, kind] of [ + ["pr", 1618], + ["issue", 1621], + ]) { + const id = (type === "pr" ? "ab" : "bc").repeat(32); + const root = relayEvent({ + id, + kind, + pubkey: attacker, + tags: [ + ["a", attackerAddress], + ["a", targetAddress], + ["subject", "Misbound entity"], + ], + }); + const result = await fetchBuzzEntityMetadata( + `buzz://${type}?id=${id}&owner=${owner}&d=buzz`, + async (filter) => + filter.kinds?.includes(30617) + ? [repository] + : filter.ids?.includes(id) + ? [root] + : [], + ); + assert.equal(result, null, `${type} with multiple repository tags`); + } +}); + +test("Buzz repository metadata stays image-less and exposes default branch", async () => { + const owner = "cd".repeat(32); + const result = await fetchBuzzEntityMetadata( + `buzz://repo?owner=${owner}&d=relay-tools`, + async () => [ + relayEvent({ + id: "01".repeat(32), + kind: 30617, + pubkey: owner, + content: "Fallback description", + tags: [ + ["d", "relay-tools"], + ["name", "Relay Tools"], + ["description", "Operator tooling for relays"], + ["status", "active"], + ["default-branch", "trunk"], + ], + }), + ], + ); + assert.equal(result?.siteName, "Relay Tools"); + assert.equal(result?.title, "Operator tooling for relays"); + assert.equal(result?.description, "active · default: trunk"); + assert.equal(result?.faviconDataUrl, null); + assert.equal(result?.imageDataUrl, null); + assert.equal(result?.imageDomain, null); +}); diff --git a/desktop/src/shared/lib/useResolvedLinkPreviews.ts b/desktop/src/shared/lib/useResolvedLinkPreviews.ts index 136f8f4b1..5f2a5535b 100644 --- a/desktop/src/shared/lib/useResolvedLinkPreviews.ts +++ b/desktop/src/shared/lib/useResolvedLinkPreviews.ts @@ -2,9 +2,18 @@ import * as React from "react"; import { invokeTauri } from "@/shared/api/tauri"; import { relayClient } from "@/shared/api/relayClient"; +import type { RelayEvent } from "@/shared/api/types"; +import { eventToRepository } from "@/features/projects/projectModels"; +import { eventToProjectIssue } from "@/features/projects/projectIssues.mjs"; +import { eventToProjectPullRequest } from "@/features/projects/projectPullRequests.mjs"; import { KIND_GIT_ISSUE, KIND_GIT_PULL_REQUEST, + KIND_GIT_STATUS_CLOSED, + KIND_GIT_STATUS_DRAFT, + KIND_GIT_STATUS_MERGED, + KIND_GIT_STATUS_OPEN, + KIND_REPO_ANNOUNCEMENT, } from "@/shared/constants/kinds"; import { parseEntityLink } from "./entityLink"; @@ -200,47 +209,116 @@ function fetchLinkPreviewMetadata( const metadataLoader = createMetadataLoader({ fetcher: fetchLinkPreviewMetadata, }); -const entityTitleLoader = createMetadataLoader({ - fetcher: async (href) => { - const parsed = parseEntityLink(href); - if (!parsed.ok || parsed.value.type === "repo") return null; +const ENTITY_STATUS_KINDS = [ + KIND_GIT_STATUS_OPEN, + KIND_GIT_STATUS_MERGED, + KIND_GIT_STATUS_CLOSED, + KIND_GIT_STATUS_DRAFT, +]; - const { id, owner, dtag } = parsed.value; - const expectedCoordinate = `30617:${owner}:${dtag}`; - const events = await relayClient.fetchEvents({ - kinds: [ - parsed.value.type === "pr" ? KIND_GIT_PULL_REQUEST : KIND_GIT_ISSUE, - ], - ids: [id], - limit: 1, - }); - const event = events[0]; - if ( - !event?.tags.some( - (tag) => tag[0] === "a" && tag[1] === expectedCoordinate, - ) - ) { - return null; - } +type EntityEventFetcher = ( + filter: Parameters[0], +) => Promise; - const subject = event.tags.find((tag) => tag[0] === "subject")?.[1]; - const title = subject || event.content.split("\n")[0] || null; - return title - ? { - title, - siteName: "Buzz", - description: null, - imageDataUrl: null, - imageDomain: null, - } - : null; - }, +function compactMetadata( + parts: Array, +): string | null { + const values = parts.filter((part): part is string => Boolean(part)); + return values.length > 0 ? values.join(" · ") : null; +} + +/** Resolve builder-focused metadata only from the active relay. */ +export async function fetchBuzzEntityMetadata( + href: string, + fetchEvents: EntityEventFetcher = (filter) => relayClient.fetchEvents(filter), +): Promise { + const parsed = parseEntityLink(href); + if (!parsed.ok) return null; + + const { owner, dtag } = parsed.value; + const repoAddress = `${KIND_REPO_ANNOUNCEMENT}:${owner}:${dtag}`; + const repoEvents = await fetchEvents({ + kinds: [KIND_REPO_ANNOUNCEMENT], + authors: [owner], + "#d": [dtag], + limit: 1, + }); + const repository = repoEvents + .map((event) => eventToRepository(event)) + .find((candidate) => candidate?.repoAddress === repoAddress); + if (!repository) return null; + + const base = { + siteName: repository.name, + faviconDataUrl: null, + imageDataUrl: null, + imageDomain: null, + }; + if (parsed.value.type === "repo") { + return { + ...base, + title: repository.description || repository.name, + description: compactMetadata([ + repository.status, + `default: ${repository.defaultBranch}`, + ]), + }; + } + + const { id, type } = parsed.value; + const rootEvents = await fetchEvents({ + kinds: [type === "pr" ? KIND_GIT_PULL_REQUEST : KIND_GIT_ISSUE], + ids: [id], + limit: 1, + }); + const root = rootEvents.find((event) => { + const repositoryTags = event.tags.filter((tag) => tag[0] === "a"); + return ( + event.id === id && + repositoryTags.length === 1 && + repositoryTags[0][1] === repoAddress + ); + }); + if (!root) return null; + + const trustedAuthors = [...new Set([root.pubkey.toLowerCase(), owner])]; + const statusEvents = await fetchEvents({ + kinds: ENTITY_STATUS_KINDS, + authors: trustedAuthors, + "#e": [id], + limit: 20, + }); + if (type === "issue") { + const issue = eventToProjectIssue(root, statusEvents); + return { + ...base, + title: issue.title, + description: compactMetadata([issue.status, ...issue.labels.slice(0, 2)]), + }; + } + + const pullRequest = eventToProjectPullRequest(root, [], [], statusEvents); + const source = pullRequest.branchName; + const target = pullRequest.targetBranch ?? repository.defaultBranch; + return { + ...base, + title: pullRequest.title, + description: compactMetadata([ + pullRequest.status, + source ? `${source} → ${target}` : null, + pullRequest.commit?.slice(0, 7), + ]), + }; +} + +const entityMetadataLoader = createMetadataLoader({ + fetcher: fetchBuzzEntityMetadata, }); /** Clear ephemeral metadata when the active relay/community changes. */ export function resetLinkPreviewMetadataCache(): void { metadataLoader.reset(); - entityTitleLoader.reset(); + entityMetadataLoader.reset(); } export type LinkPreviewImageState = "pending" | "image" | "fallback" | "none"; @@ -272,7 +350,10 @@ export function resolveLinkPreview( metadata: LinkPreviewMetadata | null | undefined, ): ResolvedLinkPreview { if (metadata === undefined) { - return { ...preview, imageState: "pending" }; + return { + ...preview, + imageState: isBuzzEntityPreview(preview) ? "none" : "pending", + }; } if (metadata === null) { return { ...preview, imageState: "none" }; @@ -293,7 +374,8 @@ export function resolveLinkPreview( description: metadata.description, faviconDataUrl: metadata.faviconDataUrl, provider: - preview.kind === "generic-link" && metadata.siteName + (preview.kind === "generic-link" || isBuzzEntityPreview(preview)) && + metadata.siteName ? metadata.siteName : preview.provider, imageDataUrl: hasImage ? metadata.imageDataUrl : null, @@ -302,6 +384,37 @@ export function resolveLinkPreview( }; } +export function isBuzzEntityPreview(preview: SupportedLinkPreview): boolean { + return ( + preview.kind === "buzz-pull-request" || + preview.kind === "buzz-issue" || + preview.kind === "buzz-repository" + ); +} + +/** + * Recipient-side `buzz://` entity cards must render even when the relay + * lookup yields no metadata: `useResolvedLinkPreviews` drops null-metadata + * previews (correct for external links — no metadata means no card), but + * entity links always carry a usable fallback title (the repo d-tag, or + * ` #` for PRs/issues — see `buzzEntityFallbackTitle`). Re-adds + * recognized entity previews on their fallback title; non-entity previews + * keep the hook's drop behavior. + */ +export function withEntityFallbacks( + previews: SupportedLinkPreview[], + resolved: ResolvedLinkPreview[], +): ResolvedLinkPreview[] { + const byHref = new Map(resolved.map((preview) => [preview.href, preview])); + return previews.flatMap((preview) => { + const match = byHref.get(preview.href); + if (match) return [match]; + return isBuzzEntityPreview(preview) + ? [{ ...preview, imageState: "none" as const }] + : []; + }); +} + export function useResolvedLinkPreviews( previews: SupportedLinkPreview[], ): ResolvedLinkPreview[] { @@ -339,7 +452,7 @@ export function useResolvedLinkPreviews( const cancelScheduledLoads: Array<() => void> = []; for (const preview of previews) { const loader = preview.href.startsWith("buzz://") - ? entityTitleLoader + ? entityMetadataLoader : metadataLoader; const cached = loader.peek(preview.href); if (cached !== undefined) { diff --git a/desktop/src/shared/ui/compact-link-preview-attachment.tsx b/desktop/src/shared/ui/compact-link-preview-attachment.tsx index 89ec2c34e..7b2fb48ff 100644 --- a/desktop/src/shared/ui/compact-link-preview-attachment.tsx +++ b/desktop/src/shared/ui/compact-link-preview-attachment.tsx @@ -12,8 +12,10 @@ import { AttachmentTrigger, } from "@/shared/ui/attachment"; import { LinkPreviewControls } from "@/shared/ui/link-preview-controls"; +import { BuzzMark } from "@/shared/ui/buzz-logo/BuzzMark"; function getHostname(preview: ResolvedLinkPreview): string { + if (preview.href.startsWith("buzz://")) return preview.provider; try { return new URL(preview.href).hostname.replace(/^www\./, ""); } catch { @@ -66,14 +68,18 @@ export function CompactLinkPreviewAttachment({ const showFallback = preview.imageState === "fallback" || Boolean(imageSrc && !showImage); const hostname = getHostname(preview); + const showBuzzMark = + preview.kind === "buzz-pull-request" || + preview.kind === "buzz-issue" || + preview.kind === "buzz-repository"; return (
- {preview.faviconDataUrl ? ( + {showBuzzMark ? ( + + ) : preview.faviconDataUrl ? ( - interactive && !linkPreviewsSuppressed - ? parseLinkPreviewSnapshots(linkPreviewTags, content, relayOrigin) - : [], - [ - content, - interactive, - linkPreviewTags, - linkPreviewsSuppressed, - relayOrigin, - ], - ); + const resolvedLinkPreviews = useMessageLinkPreviews({ + content, + interactive, + linkPreviewTags, + linkPreviewsSuppressed, + relayOrigin, + }); const configNudge = React.useMemo( () => computeConfigNudge(content, interactive, configNudgeAuthorPubkey), [content, interactive, configNudgeAuthorPubkey], diff --git a/desktop/src/shared/ui/markdown/useMessageLinkPreviews.test.mjs b/desktop/src/shared/ui/markdown/useMessageLinkPreviews.test.mjs new file mode 100644 index 000000000..a4f5a528e --- /dev/null +++ b/desktop/src/shared/ui/markdown/useMessageLinkPreviews.test.mjs @@ -0,0 +1,64 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { extractSupportedLinkPreviews } from "../../lib/linkPreview.ts"; +import { parseLinkPreviewSnapshots } from "../../lib/linkPreviewSnapshot.ts"; +import { mergeMessageLinkPreviews } from "./useMessageLinkPreviews.ts"; + +const OWNER = "a".repeat(64); +const EVENT_ID = "b".repeat(64); +const ENTITY_HREF = `buzz://pr?id=${EVENT_ID}&owner=${OWNER}&d=buzz-world`; +const EXTERNAL_HREF = "https://example.com/story"; +const RELAY_ORIGIN = "https://relay.example"; + +function snapshotTag(href, title, siteName) { + return [ + "link-preview", + "snapshot", + "1", + href, + title, + siteName, + "", + "", + "", + "", + "", + ]; +} + +test("relay-resolved Buzz entities beat conflicting sender snapshots in content order", () => { + const content = `${ENTITY_HREF} then ${EXTERNAL_HREF}`; + const candidates = extractSupportedLinkPreviews(content, RELAY_ORIGIN); + const snapshots = parseLinkPreviewSnapshots( + [ + snapshotTag(ENTITY_HREF, "Forged sender title", "Definitely Real Buzz"), + snapshotTag(EXTERNAL_HREF, "External story", "Example"), + ], + content, + RELAY_ORIGIN, + ); + const entity = { + ...candidates[0], + title: "Relay-authenticated PR title", + imageState: "none", + }; + + assert.deepEqual( + mergeMessageLinkPreviews(candidates, snapshots, [entity]).map( + ({ href, title, provider }) => ({ href, title, provider }), + ), + [ + { + href: ENTITY_HREF, + title: "Relay-authenticated PR title", + provider: "Buzz", + }, + { + href: EXTERNAL_HREF, + title: "External story", + provider: "Example", + }, + ], + ); +}); diff --git a/desktop/src/shared/ui/markdown/useMessageLinkPreviews.ts b/desktop/src/shared/ui/markdown/useMessageLinkPreviews.ts new file mode 100644 index 000000000..0f76412a0 --- /dev/null +++ b/desktop/src/shared/ui/markdown/useMessageLinkPreviews.ts @@ -0,0 +1,102 @@ +import * as React from "react"; + +import { + extractSupportedLinkPreviews, + type SupportedLinkPreview, +} from "@/shared/lib/linkPreview"; +import { parseLinkPreviewSnapshots } from "@/shared/lib/linkPreviewSnapshot"; +import { + isBuzzEntityPreview, + type ResolvedLinkPreview, + useResolvedLinkPreviews, + withEntityFallbacks, +} from "@/shared/lib/useResolvedLinkPreviews"; + +/** + * Resolve the link-preview cards for a rendered message. + * + * External URLs render exclusively from sender-authored + * `["link-preview","snapshot",…]` tags (`parseLinkPreviewSnapshots`) — the + * privacy model shipped in the rich-link-previews work: recipients never + * contact external sites. + * + * `buzz://pr|issue|repo` entity links (and relay clone URLs, which normalize + * onto `buzz://repo`) are the exception and are rendered recipient-side: + * their metadata comes from the community relay itself via the entity metadata + * loader, so the sender-snapshot privacy model does not apply — and senders + * (CLI, agents, mobile) attach no snapshot tags for them. Recognized entity + * cards win + * over conflicting sender snapshots, and cards retain their first-seen order in + * the message. + */ +export function mergeMessageLinkPreviews( + candidates: SupportedLinkPreview[], + snapshots: ResolvedLinkPreview[], + entities: ResolvedLinkPreview[], +): ResolvedLinkPreview[] { + const snapshotsByHref = new Map( + snapshots.map((preview) => [preview.href, preview]), + ); + const entitiesByHref = new Map( + entities.map((preview) => [preview.href, preview]), + ); + + return candidates.flatMap((candidate) => { + const preview = isBuzzEntityPreview(candidate) + ? entitiesByHref.get(candidate.href) + : snapshotsByHref.get(candidate.href); + return preview ? [preview] : []; + }); +} + +export function useMessageLinkPreviews({ + content, + interactive, + linkPreviewTags, + linkPreviewsSuppressed, + relayOrigin, +}: { + content: string; + interactive: boolean; + linkPreviewTags?: readonly (readonly string[])[]; + linkPreviewsSuppressed: boolean; + relayOrigin: string | null; +}): ResolvedLinkPreview[] { + const linkPreviewCandidates = React.useMemo( + () => + interactive && !linkPreviewsSuppressed + ? extractSupportedLinkPreviews(content, relayOrigin) + : [], + [content, interactive, linkPreviewsSuppressed, relayOrigin], + ); + const entityLinkPreviews = React.useMemo( + () => linkPreviewCandidates.filter(isBuzzEntityPreview), + [linkPreviewCandidates], + ); + const relayResolvedEntityLinkPreviews = + useResolvedLinkPreviews(entityLinkPreviews); + const resolvedEntityLinkPreviews = React.useMemo( + () => + withEntityFallbacks(entityLinkPreviews, relayResolvedEntityLinkPreviews), + [entityLinkPreviews, relayResolvedEntityLinkPreviews], + ); + return React.useMemo(() => { + const snapshots = + interactive && !linkPreviewsSuppressed + ? parseLinkPreviewSnapshots(linkPreviewTags, content, relayOrigin) + : []; + return mergeMessageLinkPreviews( + linkPreviewCandidates, + snapshots, + resolvedEntityLinkPreviews, + ); + }, [ + content, + interactive, + linkPreviewCandidates, + linkPreviewTags, + linkPreviewsSuppressed, + relayOrigin, + resolvedEntityLinkPreviews, + ]); +} diff --git a/desktop/tests/e2e/entity-link-recipient-cards.spec.ts b/desktop/tests/e2e/entity-link-recipient-cards.spec.ts new file mode 100644 index 000000000..5f62077be --- /dev/null +++ b/desktop/tests/e2e/entity-link-recipient-cards.spec.ts @@ -0,0 +1,167 @@ +import { expect, test } from "@playwright/test"; + +import { waitForAnimations } from "../helpers/animations"; +import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge"; + +const SHOTS = "test-results/entity-link-recipient-cards"; + +// Regression coverage for buzz:// entity links posted WITHOUT sender +// snapshot tags (CLI / agent senders): #3818 moved external-link previews to +// sender-authored snapshots, which silently killed the recipient-side +// buzz://pr|issue|repo cards from #4695. These cards resolve their titles +// from the active relay itself, so they must render for recipients even when +// the message carries no link-preview tags. + +const ALICE_PUBKEY = TEST_IDENTITIES.alice.pubkey; +const REPO_ADDRESS = `30617:${ALICE_PUBKEY}:relay-tools`; +const PR_ID = `e0${"ca4d".repeat(15)}ff`; // 64-hex event id +const PR_SUBJECT = "Restore recipient-side entity cards"; + +test("agent-style message with bare buzz:// links renders entity cards without snapshot tags", async ({ + page, +}) => { + await page.addInitScript( + ({ repoAddress, prId, alicePubkey, subject }) => { + window.__BUZZ_E2E_EXTRA_PROJECT_EVENTS__ = [ + { + id: prId, + kind: 1618, // KIND_GIT_PULL_REQUEST + pubkey: alicePubkey, + created_at: Math.floor(Date.now() / 1000) - 60, + content: "PR body", + tags: [ + ["a", repoAddress], + ["subject", subject], + ["c", "abc123".padEnd(40, "0")], + ["branch-name", "fix/entity-cards"], + ["clone", "https://github.com/block/relay-tools.git"], + ], + }, + ]; + }, + { + repoAddress: REPO_ADDRESS, + prId: PR_ID, + alicePubkey: ALICE_PUBKEY, + subject: PR_SUBJECT, + }, + ); + await installMockBridge(page); + await page.setViewportSize({ width: 900, height: 800 }); + await page.goto("/", { waitUntil: "domcontentloaded" }); + await page.getByTestId("channel-general").click(); + await page.waitForFunction( + () => typeof window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function", + ); + + // Simulate an agent/CLI sender: plain kind-9 message with bare buzz:// + // URLs in the content and NO link-preview snapshot tags. + await page.evaluate( + ({ prId, alicePubkey }) => { + window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({ + channelName: "general", + pubkey: alicePubkey, + content: [ + "PR is up — review when you can:", + `buzz://pr?id=${prId}&owner=${alicePubkey}&d=relay-tools`, + `Repo: buzz://repo?owner=${alicePubkey}&d=relay-tools`, + ].join("\n"), + }); + }, + { prId: PR_ID, alicePubkey: ALICE_PUBKEY }, + ); + + const row = page + .getByTestId("message-row") + .filter({ hasText: "PR is up" }) + .last(); + await expect(row).toBeVisible(); + + // The PR card resolves builder metadata from the signed root and repository. + const prCard = row.locator('[data-link-preview="buzz-pull-request"]'); + await expect(prCard).toBeVisible(); + await expect(prCard).toContainText("relay-tools"); + await expect(prCard).toContainText(PR_SUBJECT); + await expect(prCard).toContainText( + "Open · fix/entity-cards → main · abc1230", + ); + await expect(prCard).toHaveAttribute("data-image-state", "none"); + await expect(prCard.locator("[data-link-preview-thumbnail]")).toHaveCount(0); + await expect( + prCard.locator("[data-link-preview-hostname-buzz-mark]"), + ).toBeVisible(); + await expect( + prCard.locator("[data-link-preview-hostname-favicon]"), + ).toHaveCount(0); + + // The repository card uses its signed announcement metadata and remains + // image-less. + const repoCard = row.locator('[data-link-preview="buzz-repository"]'); + await expect(repoCard).toBeVisible(); + await expect(repoCard).toContainText("relay-tools"); + await expect(repoCard).toContainText( + "Operator tooling and admin CLI for relay deployments.", + ); + await expect(repoCard).toContainText("active · default: main"); + await expect(repoCard).toHaveAttribute("data-image-state", "none"); + await expect(repoCard.locator("[data-link-preview-thumbnail]")).toHaveCount( + 0, + ); + await expect( + repoCard.locator("[data-link-preview-hostname-buzz-mark]"), + ).toBeVisible(); + await expect( + repoCard.locator("[data-link-preview-hostname-favicon]"), + ).toHaveCount(0); + expect( + await repoCard.evaluate((card) => card.getBoundingClientRect().height), + ).toBeLessThan(84); + + await waitForAnimations(page); + await page.screenshot({ + animations: "disabled", + path: `${SHOTS}/01-recipient-entity-cards.png`, + }); +}); + +test("desktop composer shows entity card and send is not blocked by missing snapshot", async ({ + page, +}) => { + await installMockBridge(page); + await page.setViewportSize({ width: 900, height: 800 }); + await page.goto("/", { waitUntil: "domcontentloaded" }); + await page.getByTestId("channel-general").click(); + + const repoLink = `buzz://repo?owner=${ALICE_PUBKEY}&d=relay-tools`; + await page.getByTestId("message-input").fill(`Check out ${repoLink}`); + + // buzz:// links never produce snapshot tags, so the composer card must + // show as done (not stuck "processing") with zero ready snapshots. + const composerCard = page + .locator("[data-composer-link-previews]") + .locator('[data-link-preview="buzz-repository"]'); + await expect(composerCard).toBeVisible(); + await expect(page.locator("[data-composer-link-previews]")).toHaveAttribute( + "data-ready-snapshot-count", + "0", + ); + + await waitForAnimations(page); + await page.screenshot({ + animations: "disabled", + path: `${SHOTS}/02-composer-entity-card.png`, + }); + + await page.getByTestId("send-message").click(); + + const row = page.getByTestId("message-row").last(); + const repoCard = row.locator('[data-link-preview="buzz-repository"]'); + await expect(repoCard).toBeVisible(); + await expect(repoCard).toContainText("relay-tools"); + + await waitForAnimations(page); + await page.screenshot({ + animations: "disabled", + path: `${SHOTS}/03-sent-entity-card.png`, + }); +});