diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index c1ea0e061b..b574e026e5 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 39d5d9db8e..89f4d83d65 100644 --- a/desktop/src/features/messages/ui/useComposerLinkPreviews.tsx +++ b/desktop/src/features/messages/ui/useComposerLinkPreviews.tsx @@ -12,7 +12,10 @@ import { getCachedRelayOrigin, } from "@/shared/lib/mediaUrl"; import type { ResolvedLinkPreview } from "@/shared/lib/useResolvedLinkPreviews"; -import { useResolvedLinkPreviews } from "@/shared/lib/useResolvedLinkPreviews"; +import { + useResolvedLinkPreviews, + withEntityFallbacks, +} from "@/shared/lib/useResolvedLinkPreviews"; import { Attachment, AttachmentContent, @@ -43,6 +46,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 || preview.href.startsWith("buzz://"); let path = ""; try { const url = new URL(preview.href); @@ -55,7 +62,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 @@ -142,7 +149,16 @@ export function useComposerLinkPreviews(content: string) { ), [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 bf7e393612..55014b65fc 100644 --- a/desktop/src/shared/lib/useResolvedLinkPreviews.test.mjs +++ b/desktop/src/shared/lib/useResolvedLinkPreviews.test.mjs @@ -4,6 +4,7 @@ import test from "node:test"; import { __linkPreviewMetadataTest, resolveLinkPreview, + withEntityFallbacks, } from "./useResolvedLinkPreviews.ts"; const preview = { @@ -180,3 +181,48 @@ 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("withEntityFallbacks still drops unresolved external links", () => { + assert.deepEqual(withEntityFallbacks([preview], []), []); +}); diff --git a/desktop/src/shared/lib/useResolvedLinkPreviews.ts b/desktop/src/shared/lib/useResolvedLinkPreviews.ts index 136f8f4b1c..19f666a16f 100644 --- a/desktop/src/shared/lib/useResolvedLinkPreviews.ts +++ b/desktop/src/shared/lib/useResolvedLinkPreviews.ts @@ -302,6 +302,29 @@ export function resolveLinkPreview( }; } +/** + * 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 + * any `buzz://` preview the hook dropped, on its 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 preview.href.startsWith("buzz://") + ? [{ ...preview, imageState: "none" as const }] + : []; + }); +} + export function useResolvedLinkPreviews( previews: SupportedLinkPreview[], ): ResolvedLinkPreview[] { diff --git a/desktop/src/shared/ui/markdown.tsx b/desktop/src/shared/ui/markdown.tsx index a2fb0e42d6..b71cc0947e 100644 --- a/desktop/src/shared/ui/markdown.tsx +++ b/desktop/src/shared/ui/markdown.tsx @@ -23,7 +23,6 @@ import { invokeTauri } from "@/shared/api/tauri"; import { useChannelNavigation } from "@/shared/context/ChannelNavigationContext"; import { cn } from "@/shared/lib/cn"; import { parseSupportedLinkPreview } from "@/shared/lib/linkPreview"; -import { parseLinkPreviewSnapshots } from "@/shared/lib/linkPreviewSnapshot"; import { rewriteRelayUrl } from "@/shared/lib/mediaUrl"; import { useRelayOrigin } from "@/shared/lib/useRelayOrigin"; import { AttachmentGroup } from "@/shared/ui/attachment"; @@ -114,6 +113,7 @@ import { MarkdownTable } from "./markdown/MarkdownTable"; import { ProgressiveImage } from "./markdown/ProgressiveImage"; import { MessageLinkPill } from "./markdown/MessageLinkPill"; import { renderCachedMarkdown } from "./markdown/nodeCache"; +import { useMessageLinkPreviews } from "./markdown/useMessageLinkPreviews"; import { MarkdownRuntimeContext, useMarkdownRuntime, @@ -1809,19 +1809,13 @@ function MarkdownInner({ [goChannel], ); const relayOrigin = useRelayOrigin(); - const resolvedLinkPreviews = React.useMemo( - () => - 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.ts b/desktop/src/shared/ui/markdown/useMessageLinkPreviews.ts new file mode 100644 index 0000000000..d3032e5cda --- /dev/null +++ b/desktop/src/shared/ui/markdown/useMessageLinkPreviews.ts @@ -0,0 +1,76 @@ +import * as React from "react"; + +import { extractSupportedLinkPreviews } from "@/shared/lib/linkPreview"; +import { parseLinkPreviewSnapshots } from "@/shared/lib/linkPreviewSnapshot"; +import { + 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 titles come from the community relay itself via `entityTitleLoader`, + * so the sender-snapshot privacy model does not apply — and senders (CLI, + * agents, mobile) attach no snapshot tags for them. Snapshot cards win on + * duplicate hrefs. + */ +export function useMessageLinkPreviews({ + content, + interactive, + linkPreviewTags, + linkPreviewsSuppressed, + relayOrigin, +}: { + content: string; + interactive: boolean; + linkPreviewTags?: readonly (readonly string[])[]; + linkPreviewsSuppressed: boolean; + relayOrigin: string | null; +}): ResolvedLinkPreview[] { + const entityLinkPreviews = React.useMemo( + () => + interactive && !linkPreviewsSuppressed + ? extractSupportedLinkPreviews(content, relayOrigin).filter((preview) => + preview.href.startsWith("buzz://"), + ) + : [], + [content, interactive, linkPreviewsSuppressed, relayOrigin], + ); + const relayResolvedEntityLinkPreviews = + useResolvedLinkPreviews(entityLinkPreviews); + const resolvedEntityLinkPreviews = React.useMemo( + () => + withEntityFallbacks(entityLinkPreviews, relayResolvedEntityLinkPreviews), + [entityLinkPreviews, relayResolvedEntityLinkPreviews], + ); + return React.useMemo(() => { + const snapshots = + interactive && !linkPreviewsSuppressed + ? parseLinkPreviewSnapshots(linkPreviewTags, content, relayOrigin) + : []; + if (resolvedEntityLinkPreviews.length === 0) return snapshots; + const seen = new Set(snapshots.map((preview) => preview.href)); + return [ + ...snapshots, + ...resolvedEntityLinkPreviews.filter( + (preview) => !seen.has(preview.href), + ), + ]; + }, [ + content, + interactive, + 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 0000000000..7e7abca93b --- /dev/null +++ b/desktop/tests/e2e/entity-link-recipient-cards.spec.ts @@ -0,0 +1,138 @@ +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 relay itself (entityTitleLoader), 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 its title from the relay event's subject tag. + const prCard = row.locator('[data-link-preview="buzz-pull-request"]'); + await expect(prCard).toBeVisible(); + await expect(prCard).toContainText(PR_SUBJECT); + + // The repo card renders on its d-tag fallback title (repo announcements + // have no title lookup). + 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}/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`, + }); +});