mirror of
https://github.com/block/buzz.git
synced 2026-10-31 22:41:12 +00:00
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:
@@ -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",
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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], []), []);
|
||||
});
|
||||
|
||||
@@ -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[] {
|
||||
|
||||
@@ -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],
|
||||
|
||||
76
desktop/src/shared/ui/markdown/useMessageLinkPreviews.ts
Normal file
76
desktop/src/shared/ui/markdown/useMessageLinkPreviews.ts
Normal 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,
|
||||
]);
|
||||
}
|
||||
138
desktop/tests/e2e/entity-link-recipient-cards.spec.ts
Normal file
138
desktop/tests/e2e/entity-link-recipient-cards.spec.ts
Normal 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`,
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user