fix(desktop): restore recipient-side rendering for buzz:// entity links

The rich-link-previews work (#3818, shipped 0.5.8) moved all
link-preview rendering to sender-authored ["link-preview","snapshot",…]
tags for privacy. Correct for external URLs, but it killed
buzz://pr|issue|repo entity cards entirely:

- buzz:// links can never get a snapshot — the desktop composer's
  isValidLinkPreviewSnapshotCanonicalUrl requires https:, and CLI/agent
  senders attach no snapshot tags at all.
- So every entity link agents post (as the base prompt instructs them
  to) renders as raw plaintext since 0.5.8.

The snapshot privacy model does not apply to buzz:// links: their
titles come from the community relay itself via entityTitleLoader
(kind 1618/1621 lookups), zero external requests. This restores
recipient-side extraction for buzz:// hrefs only:

- markdown/useMessageLinkPreviews.ts: new hook — extracts buzz://
  previews from message content, resolves titles via
  useResolvedLinkPreviews, and merges them with snapshot-tag previews
  (snapshots win on duplicate hrefs). markdown.tsx calls it in place of
  the inline parseLinkPreviewSnapshots memo.
- useResolvedLinkPreviews.ts: new withEntityFallbacks() re-adds buzz://
  previews the hook drops on null relay metadata (repo links never
  fetch titles) using their fallback title; external links keep the
  drop behavior.
- useComposerLinkPreviews.tsx: composer keeps buzz:// cards via the
  same fallback and treats them as done immediately — there is no
  snapshot to wait for, so send is not blocked.
- New e2e spec (smoke) proves recipient-side cards render for bare
  buzz://pr and buzz://repo links with relay-resolved titles and no
  snapshot tags, and that the composer card does not block send.

External-URL policy fallout of #3818 (pre-0.5.8 history, fragment
URLs, non-desktop senders of https links) is intentionally out of
scope pending design decisions.

Co-authored-by: Wintermute <c0fc581234c3585602139eec347ced7b82af65b6f6c10728348515c0c06c51c3@buzz.block.builderlab.xyz>
Signed-off-by: Wintermute <c0fc581234c3585602139eec347ced7b82af65b6f6c10728348515c0c06c51c3@buzz.block.builderlab.xyz>
This commit is contained in:
Wintermute
2026-08-09 09:23:04 +02:00
parent 5bf78671f4
commit 9566bde879
7 changed files with 313 additions and 19 deletions

View File

@@ -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",

View File

@@ -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"}
>
<AttachmentMedia
className="h-[55px] w-[55px] rounded-none rounded-l-2xl bg-muted"
@@ -86,10 +93,10 @@ function ComposerLinkPreviewCard({
</AttachmentMedia>
<AttachmentContent>
<AttachmentTitle className="line-clamp-1" data-link-preview-hostname="">
{preview.snapshotReady ? preview.title : hostname}
{done ? preview.title : hostname}
</AttachmentTitle>
<AttachmentDescription>
{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]);

View File

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

View File

@@ -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
* `<dtag> #<id8>` 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[] {

View File

@@ -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],

View File

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

View File

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