From 008a1d599a11deefbfe5944a98d2ff2f3ac629da Mon Sep 17 00:00:00 2001 From: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w <52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@buzz.block.builderlab.xyz> Date: Wed, 29 Jul 2026 13:39:49 -0700 Subject: [PATCH] fix(desktop): render compact link preview images Co-authored-by: Taylor Ho Signed-off-by: Taylor Ho --- .../lib/useResolvedLinkPreviews.test.mjs | 45 +++++++ .../src/shared/lib/useResolvedLinkPreviews.ts | 62 ++++++--- .../src/shared/ui/link-preview-attachment.tsx | 47 ++++--- desktop/src/testing/e2eBridge.ts | 14 ++ desktop/tests/e2e/messaging.spec.ts | 120 +++++++++++++++++- desktop/tests/helpers/bridge.ts | 7 + 6 files changed, 258 insertions(+), 37 deletions(-) create mode 100644 desktop/src/shared/lib/useResolvedLinkPreviews.test.mjs diff --git a/desktop/src/shared/lib/useResolvedLinkPreviews.test.mjs b/desktop/src/shared/lib/useResolvedLinkPreviews.test.mjs new file mode 100644 index 000000000..a594e316b --- /dev/null +++ b/desktop/src/shared/lib/useResolvedLinkPreviews.test.mjs @@ -0,0 +1,45 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { resolveLinkPreview } from "./useResolvedLinkPreviews.ts"; + +const preview = { + kind: "generic-link", + href: "https://example.com/story", + provider: "example.com", + title: "example.com/story", + typeLabel: "link", +}; + +test("pending metadata reserves the image treatment", () => { + assert.deepEqual(resolveLinkPreview(preview, undefined), { + ...preview, + imageState: "pending", + }); +}); + +test("resolved image metadata keeps the reserved image treatment", () => { + const resolved = resolveLinkPreview(preview, { + title: "A story", + siteName: "Example", + imageDataUrl: "data:image/jpeg;base64,abc", + imageDomain: "cdn.example.com", + }); + + assert.equal(resolved.imageState, "image"); + assert.equal(resolved.provider, "Example"); + assert.equal(resolved.imageDomain, "cdn.example.com"); +}); + +test("resolved metadata without a complete image collapses to the compact treatment", () => { + const resolved = resolveLinkPreview(preview, { + title: "A story", + siteName: "Example", + imageDataUrl: null, + imageDomain: null, + }); + + assert.equal(resolved.imageState, "none"); + assert.equal(resolved.imageDataUrl, null); + assert.equal(resolved.imageDomain, null); +}); diff --git a/desktop/src/shared/lib/useResolvedLinkPreviews.ts b/desktop/src/shared/lib/useResolvedLinkPreviews.ts index 3189b5d94..644f7fc85 100644 --- a/desktop/src/shared/lib/useResolvedLinkPreviews.ts +++ b/desktop/src/shared/lib/useResolvedLinkPreviews.ts @@ -50,12 +50,47 @@ function cacheMetadata(href: string): Promise { return promise; } +export type LinkPreviewImageState = "pending" | "image" | "none"; + +export type ResolvedLinkPreview = SupportedLinkPreview & { + imageState: LinkPreviewImageState; +}; + +type ResolvedMetadataByHref = Record< + string, + LinkPreviewMetadata | null | undefined +>; + +export function resolveLinkPreview( + preview: SupportedLinkPreview, + metadata: LinkPreviewMetadata | null | undefined, +): ResolvedLinkPreview { + if (metadata === undefined) { + return { ...preview, imageState: "pending" }; + } + if (metadata === null) { + return { ...preview, imageState: "none" }; + } + + const hasImage = Boolean(metadata.imageDataUrl && metadata.imageDomain); + return { + ...preview, + title: metadata.title, + provider: + preview.kind === "generic-link" && metadata.siteName + ? metadata.siteName + : preview.provider, + imageDataUrl: hasImage ? metadata.imageDataUrl : null, + imageDomain: hasImage ? metadata.imageDomain : null, + imageState: hasImage ? "image" : "none", + }; +} + export function useResolvedLinkPreviews( previews: SupportedLinkPreview[], -): SupportedLinkPreview[] { - const [resolvedMetadata, setResolvedMetadata] = React.useState< - Record - >({}); +): ResolvedLinkPreview[] { + const [resolvedMetadata, setResolvedMetadata] = + React.useState({}); React.useEffect(() => { let cancelled = false; @@ -72,7 +107,7 @@ export function useResolvedLinkPreviews( } void cacheMetadata(preview.href).then((metadata) => { - if (cancelled || !metadata) return; + if (cancelled) return; setResolvedMetadata((current) => current[preview.href] === metadata ? current @@ -88,20 +123,9 @@ export function useResolvedLinkPreviews( return React.useMemo( () => - previews.map((preview) => { - const metadata = resolvedMetadata[preview.href]; - if (!metadata) return preview; - return { - ...preview, - title: metadata.title, - provider: - preview.kind === "generic-link" && metadata.siteName - ? metadata.siteName - : preview.provider, - imageDataUrl: metadata.imageDataUrl, - imageDomain: metadata.imageDomain, - }; - }), + previews.map((preview) => + resolveLinkPreview(preview, resolvedMetadata[preview.href]), + ), [previews, resolvedMetadata], ); } diff --git a/desktop/src/shared/ui/link-preview-attachment.tsx b/desktop/src/shared/ui/link-preview-attachment.tsx index 65be82e95..84340d12d 100644 --- a/desktop/src/shared/ui/link-preview-attachment.tsx +++ b/desktop/src/shared/ui/link-preview-attachment.tsx @@ -1,6 +1,6 @@ import { Globe, X } from "lucide-react"; -import type { SupportedLinkPreview } from "@/shared/lib/linkPreview"; +import type { ResolvedLinkPreview } from "@/shared/lib/useResolvedLinkPreviews"; import { cn } from "@/shared/lib/cn"; import { Button } from "@/shared/ui/button"; import { @@ -89,7 +89,7 @@ function GoogleSlidesLogo({ className }: { className?: string }) { ); } -function LinkPreviewLogo({ preview }: { preview: SupportedLinkPreview }) { +function LinkPreviewLogo({ preview }: { preview: ResolvedLinkPreview }) { switch (preview.kind) { case "github-issue": case "github-pull-request": @@ -118,32 +118,45 @@ export function LinkPreviewAttachment({ }: { className?: string; onRemove?: () => void; - preview: SupportedLinkPreview; + preview: ResolvedLinkPreview; }) { - const showImage = Boolean(preview.imageDataUrl && preview.imageDomain); + const reserveImage = preview.imageState !== "none"; + const showImage = preview.imageState === "image"; return (
- {showImage ? ( -
- {`Preview -
- ) : null} - {!showImage ? ( + {reserveImage ? ( + + {showImage ? ( + {`Preview + ) : ( +
+ )} + + ) : ( - ) : null} - + )} +
{preview.provider}
diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index f31e94946..7ecd1cfe9 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -285,6 +285,13 @@ type E2eConfig = { profileHasEvent?: boolean; profileUpdateError?: string; profileUpdateErrors?: string[]; + linkPreviewMetadata?: { + title: string; + siteName: string | null; + imageDataUrl: string | null; + imageDomain: string | null; + } | null; + linkPreviewMetadataDelayMs?: number; searchProfiles?: MockSearchProfileSeed[]; updateAvailable?: boolean; updateChannelDelayMs?: number; @@ -10073,6 +10080,13 @@ export function maybeInstallE2eTauriMocks() { return; case "fetch_join_policy": return activeConfig?.mock?.joinPolicy ?? null; + case "fetch_link_preview_metadata": { + const delayMs = activeConfig?.mock?.linkPreviewMetadataDelayMs ?? 0; + if (delayMs > 0) { + await new Promise((resolve) => window.setTimeout(resolve, delayMs)); + } + return activeConfig?.mock?.linkPreviewMetadata ?? null; + } case "apply_workspace": { const applyDelayMs = activeConfig?.mock?.applyCommunityDelayMs ?? 0; if (applyDelayMs > 0) { diff --git a/desktop/tests/e2e/messaging.spec.ts b/desktop/tests/e2e/messaging.spec.ts index c6f5aefb9..e6aaecfc6 100644 --- a/desktop/tests/e2e/messaging.spec.ts +++ b/desktop/tests/e2e/messaging.spec.ts @@ -1,5 +1,7 @@ import { expect, test, type Locator } from "@playwright/test"; +import { waitForAnimations } from "../helpers/animations"; + import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge"; import { expectCornerRadiusPx, expectSmoothCorners } from "../helpers/css"; import { openSettings } from "../helpers/settings"; @@ -108,7 +110,23 @@ test.beforeEach(async ({ page }, testInfo) => { }, ], } - : undefined; + : testInfo.title.includes("link preview image geometry") + ? { + linkPreviewMetadata: { + title: "Ship a compact horizontal link preview", + siteName: "GitHub", + imageDataUrl: + "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='382' height='200'%3E%3Crect width='382' height='200' fill='%237c3aed'/%3E%3Ccircle cx='92' cy='100' r='48' fill='%23fff' fill-opacity='.9'/%3E%3C/svg%3E", + imageDomain: "opengraph.githubassets.com", + }, + linkPreviewMetadataDelayMs: 800, + } + : testInfo.title.includes("link preview no-image collapse") + ? { + linkPreviewMetadata: null, + linkPreviewMetadataDelayMs: 800, + } + : undefined; await installMockBridge(page, mock); }); @@ -258,6 +276,106 @@ test("markdown tables overflow wide content and fill the message when narrow", a .toBeLessThanOrEqual(1); }); +test("link preview image geometry stays stable while loading", async ({ + page, +}) => { + const previewUrl = "https://github.com/block/buzz/pull/3246"; + + for (const width of [800, 420]) { + await page.setViewportSize({ width: 800, height: 700 }); + await page.goto("/"); + await page.getByTestId("channel-general").click(); + await page.setViewportSize({ width, height: 700 }); + await page.getByTestId("message-input").fill(previewUrl); + await page.getByTestId("send-message").click(); + + const card = page + .getByTestId("message-row") + .last() + .locator('[data-link-preview="github-pull-request"]'); + await expect(card).toHaveAttribute("data-image-state", "pending"); + await expect(card).toBeVisible(); + await expect(card.locator("[data-link-preview-thumbnail]")).toBeVisible(); + if (process.env.BUZZ_LINK_PREVIEW_SCREENSHOTS_DIR) { + await card.screenshot({ + animations: "disabled", + path: `${process.env.BUZZ_LINK_PREVIEW_SCREENSHOTS_DIR}/${width}-pending.png`, + }); + } + const pending = await card.evaluate((element) => ({ + height: element.getBoundingClientRect().height, + textLeft: element + .querySelector('[data-slot="attachment-content"]') + ?.getBoundingClientRect().left, + thumbnailWidth: element + .querySelector("[data-link-preview-thumbnail]") + ?.getBoundingClientRect().width, + })); + + await expect(card).toHaveAttribute("data-image-state", "image"); + if (process.env.BUZZ_LINK_PREVIEW_SCREENSHOTS_DIR) { + await waitForAnimations(page); + await card.screenshot({ + path: `${process.env.BUZZ_LINK_PREVIEW_SCREENSHOTS_DIR}/${width}-loaded.png`, + }); + } + const loaded = await card.evaluate((element) => ({ + height: element.getBoundingClientRect().height, + textLeft: element + .querySelector('[data-slot="attachment-content"]') + ?.getBoundingClientRect().left, + thumbnailWidth: element + .querySelector("[data-link-preview-thumbnail]") + ?.getBoundingClientRect().width, + })); + + expect(loaded.height).toBe(pending.height); + expect(loaded.textLeft).toBe(pending.textLeft); + expect(loaded.thumbnailWidth).toBe(pending.thumbnailWidth); + expect(loaded.thumbnailWidth).toBe(width < 640 ? 64 : 107); + } +}); + +test("link preview no-image collapse restores the compact card", async ({ + page, +}) => { + const previewUrl = "https://github.com/block/buzz/pull/3246"; + + for (const width of [800, 420]) { + await page.setViewportSize({ width: 800, height: 700 }); + await page.goto("/"); + await page.getByTestId("channel-general").click(); + await page.setViewportSize({ width, height: 700 }); + await page.getByTestId("message-input").fill(previewUrl); + await page.getByTestId("send-message").click(); + + const card = page + .getByTestId("message-row") + .last() + .locator('[data-link-preview="github-pull-request"]'); + await expect(card).toHaveAttribute("data-image-state", "pending"); + const pending = await card.evaluate((element) => ({ + height: element.getBoundingClientRect().height, + textLeft: element + .querySelector('[data-slot="attachment-content"]') + ?.getBoundingClientRect().left, + })); + + await expect(card).toHaveAttribute("data-image-state", "none"); + await expect(card.locator("[data-link-preview-thumbnail]")).toHaveCount(0); + await expect(card.locator(".link-preview-media")).toBeVisible(); + const collapsed = await card.evaluate((element) => ({ + height: element.getBoundingClientRect().height, + textLeft: element + .querySelector('[data-slot="attachment-content"]') + ?.getBoundingClientRect().left, + })); + + expect(collapsed.height).toBeLessThan(pending.height); + expect(collapsed.textLeft).toBeLessThan(pending.textLeft ?? 0); + } +}); + test("supported link previews keep the message link visible", async ({ page, }) => { diff --git a/desktop/tests/helpers/bridge.ts b/desktop/tests/helpers/bridge.ts index c3473ae4f..550618152 100644 --- a/desktop/tests/helpers/bridge.ts +++ b/desktop/tests/helpers/bridge.ts @@ -270,6 +270,13 @@ type MockBridgeOptions = { profileHasEvent?: boolean; profileUpdateError?: string; profileUpdateErrors?: string[]; + linkPreviewMetadata?: { + title: string; + siteName: string | null; + imageDataUrl: string | null; + imageDomain: string | null; + } | null; + linkPreviewMetadataDelayMs?: number; searchProfiles?: MockSearchProfileSeed[]; updateAvailable?: boolean; updateChannelDelayMs?: number;