fix(desktop): render compact link preview images

Co-authored-by: Taylor Ho <taylorkmho@gmail.com>
Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
This commit is contained in:
npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w
2026-07-30 23:21:00 -07:00
co-authored by Taylor Ho
parent 814d1eee27
commit 008a1d599a
6 changed files with 258 additions and 37 deletions
@@ -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);
});
@@ -50,12 +50,47 @@ function cacheMetadata(href: string): Promise<LinkPreviewMetadata | null> {
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<string, LinkPreviewMetadata>
>({});
): ResolvedLinkPreview[] {
const [resolvedMetadata, setResolvedMetadata] =
React.useState<ResolvedMetadataByHref>({});
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],
);
}
@@ -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 (
<div className={cn("relative w-80 max-w-full shrink-0", className)}>
<Attachment
className="w-full no-underline shadow-none"
data-image-state={preview.imageState}
data-link-preview={preview.kind}
orientation={showImage ? "vertical" : "horizontal"}
orientation="horizontal"
>
{showImage ? (
<div className="relative -mx-3 -mt-2.5 w-[calc(100%+1.5rem)] overflow-hidden border-b border-border/70 bg-muted">
<img
alt={`Preview from ${preview.imageDomain}`}
className="aspect-[1.91/1] w-full object-cover"
src={preview.imageDataUrl ?? undefined}
/>
</div>
) : null}
{!showImage ? (
{reserveImage ? (
<AttachmentMedia
aria-hidden={showImage ? undefined : "true"}
className="h-12 w-16 rounded-lg bg-muted sm:h-14 sm:w-[6.6875rem]"
data-link-preview-thumbnail=""
variant="image"
>
{showImage ? (
<img
alt={`Preview from ${preview.imageDomain}`}
className="h-full w-full object-cover"
src={preview.imageDataUrl ?? undefined}
/>
) : (
<div
className="h-full w-full animate-pulse bg-muted-foreground/10"
data-link-preview-skeleton=""
/>
)}
</AttachmentMedia>
) : (
<AttachmentMedia className="link-preview-media">
<LinkPreviewLogo preview={preview} />
</AttachmentMedia>
) : null}
<AttachmentContent className={showImage ? "w-full" : undefined}>
)}
<AttachmentContent>
<div className="truncate text-xs font-medium leading-4 text-muted-foreground">
{preview.provider}
</div>
+14
View File
@@ -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) {
+119 -1
View File
@@ -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,
}) => {
+7
View File
@@ -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;