fix(link-preview): restore Buzz entity link cards (#5494)

**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.

<details>
<summary>File changes</summary>

**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.

</details>

## 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 <taylorkmho@gmail.com>
This commit is contained in:
Taylor Ho
2026-08-10 18:38:38 -07:00
committed by GitHub
parent 538e5e113f
commit 7e6e9c547f
9 changed files with 786 additions and 62 deletions
+1
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",
@@ -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"}
>
<AttachmentMedia
className="h-[55px] w-[55px] rounded-none rounded-l-2xl bg-muted"
@@ -86,10 +94,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
@@ -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]);
@@ -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);
});
+150 -37
View File
@@ -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<typeof relayClient.fetchEvents>[0],
) => Promise<RelayEvent[]>;
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 | undefined>,
): 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<LinkPreviewMetadata | null> {
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
* `<dtag> #<id8>` 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) {
@@ -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 (
<div className={cn("relative w-96 max-w-full shrink-0", className)}>
<Attachment
className={cn(
"h-21 min-h-21 max-h-21 w-full bg-transparent no-underline shadow-none hover:bg-transparent",
"w-full bg-transparent no-underline shadow-none hover:bg-transparent",
reserveImage
? "gap-0 border-0 p-0 hover:border-transparent"
? "h-21 min-h-21 max-h-21 gap-0 border-0 p-0 hover:border-transparent"
: "rounded-none border-0 border-l-[3px] border-border px-0 py-1 pl-3 hover:border-border",
)}
data-image-state={preview.imageState}
@@ -120,7 +126,15 @@ export function CompactLinkPreviewAttachment({
rel="noreferrer"
target="_blank"
>
{preview.faviconDataUrl ? (
{showBuzzMark ? (
<span
aria-hidden="true"
className="flex size-3 shrink-0 items-center text-foreground/70"
data-link-preview-hostname-buzz-mark=""
>
<BuzzMark className="h-auto w-full" />
</span>
) : preview.faviconDataUrl ? (
<img
alt=""
aria-hidden="true"
+8 -14
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],
@@ -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",
},
],
);
});
@@ -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,
]);
}
@@ -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`,
});
});