feat(desktop): enrich link preview image cards

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 16:39:21 -07:00
co-authored by Taylor Ho
parent 805a968bd6
commit 9ae57fb956
7 changed files with 95 additions and 14 deletions
@@ -26,6 +26,7 @@ const MAX_METADATA_CHARS: usize = 180;
pub struct LinkPreviewMetadata {
title: String,
site_name: Option<String>,
description: Option<String>,
image_data_url: Option<String>,
image_domain: Option<String>,
}
@@ -328,10 +329,14 @@ fn extract_link_preview_metadata(html: &str) -> Option<LinkPreviewMetadata> {
.and_then(|value| normalize_metadata_text(&value))?;
let site_name = extract_meta_content(html, "property", "og:site_name")
.and_then(|value| normalize_metadata_text(&value));
let description = extract_meta_content(html, "property", "og:description")
.or_else(|| extract_meta_content(html, "name", "twitter:description"))
.and_then(|value| normalize_metadata_text(&value));
Some(LinkPreviewMetadata {
title,
site_name,
description,
image_data_url: None,
image_domain: None,
})
@@ -483,12 +488,14 @@ mod tests {
fn metadata_prefers_open_graph_and_reads_site_name() {
let html = r#"<meta content="Buzz" property="og:site_name">
<meta content="Rich previews &amp; cards" property="og:title">
<meta content="Safe &amp; useful previews" property="og:description">
<meta name="twitter:title" content="Twitter fallback"><title>Fallback</title>"#;
assert_eq!(
extract_link_preview_metadata(html),
Some(LinkPreviewMetadata {
title: "Rich previews & cards".to_string(),
site_name: Some("Buzz".to_string()),
description: Some("Safe & useful previews".to_string()),
image_data_url: None,
image_domain: None,
})
@@ -7,6 +7,7 @@ import type { SupportedLinkPreview } from "./linkPreview";
type LinkPreviewMetadata = {
title: string;
siteName: string | null;
description: string | null;
imageDataUrl: string | null;
imageDomain: string | null;
};
@@ -53,6 +54,7 @@ function cacheMetadata(href: string): Promise<LinkPreviewMetadata | null> {
export type LinkPreviewImageState = "pending" | "image" | "none";
export type ResolvedLinkPreview = SupportedLinkPreview & {
description?: string | null;
imageState: LinkPreviewImageState;
};
@@ -76,6 +78,7 @@ export function resolveLinkPreview(
return {
...preview,
title: metadata.title,
description: metadata.description,
provider:
preview.kind === "generic-link" && metadata.siteName
? metadata.siteName
@@ -6,6 +6,7 @@ import { Button } from "@/shared/ui/button";
import {
Attachment,
AttachmentContent,
AttachmentDescription,
AttachmentMedia,
AttachmentTitle,
AttachmentTrigger,
@@ -124,11 +125,11 @@ export function LinkPreviewAttachment({
const showImage = preview.imageState === "image";
return (
<div className={cn("relative w-80 max-w-full shrink-0", className)}>
<div className={cn("relative w-[22.5rem] max-w-full shrink-0", className)}>
<Attachment
className={cn(
"w-full no-underline shadow-none",
reserveImage && "gap-0 p-0",
reserveImage && "h-20 min-h-20 max-h-20 gap-0 p-0",
)}
data-image-state={preview.imageState}
data-link-preview={preview.kind}
@@ -137,7 +138,7 @@ export function LinkPreviewAttachment({
{reserveImage ? (
<AttachmentMedia
aria-hidden={showImage ? undefined : "true"}
className="h-auto w-16 self-stretch rounded-none bg-muted sm:w-[6.6875rem]"
className="aspect-auto h-full min-h-0 w-28 min-w-28 max-w-28 self-stretch rounded-none bg-muted sm:w-32 sm:min-w-32 sm:max-w-32"
data-link-preview-thumbnail=""
variant="image"
>
@@ -163,7 +164,20 @@ export function LinkPreviewAttachment({
<div className="truncate text-xs font-medium leading-4 text-muted-foreground">
{preview.provider}
</div>
<AttachmentTitle>{preview.title}</AttachmentTitle>
<AttachmentTitle
className={
reserveImage
? preview.description
? "truncate"
: "line-clamp-2 whitespace-normal"
: undefined
}
>
{preview.title}
</AttachmentTitle>
{reserveImage && preview.description ? (
<AttachmentDescription>{preview.description}</AttachmentDescription>
) : null}
</AttachmentContent>
<AttachmentTrigger asChild>
<a
+3 -2
View File
@@ -2002,11 +2002,12 @@ function MarkdownInner({
className="max-w-full flex-wrap overflow-visible pb-0"
data-link-preview-list=""
>
{resolvedLinkPreviews.map((preview) => (
{resolvedLinkPreviews.map((preview, index) => (
<LinkPreviewAttachment
key={preview.href}
onRemove={
onRemoveLinkPreviewsForEveryone
onRemoveLinkPreviewsForEveryone &&
index === resolvedLinkPreviews.length - 1
? () => setRemovePreviewDialogOpen(true)
: undefined
}
+1
View File
@@ -288,6 +288,7 @@ type E2eConfig = {
linkPreviewMetadata?: {
title: string;
siteName: string | null;
description: string | null;
imageDataUrl: string | null;
imageDomain: string | null;
} | null;
+62 -8
View File
@@ -113,10 +113,12 @@ test.beforeEach(async ({ page }, testInfo) => {
: testInfo.title.includes("link preview image geometry")
? {
linkPreviewMetadata: {
title: "Ship a compact horizontal link preview",
title:
"Ship a wider horizontal preview with a two-line title that wraps cleanly",
siteName: "GitHub",
description: "A polished, stable preview for shared links.",
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",
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='1600' height='120'%3E%3Crect width='1600' height='120' fill='%237c3aed'/%3E%3Ccircle cx='92' cy='60' r='48' fill='%23fff' fill-opacity='.9'/%3E%3C/svg%3E",
imageDomain: "opengraph.githubassets.com",
},
linkPreviewMetadataDelayMs: 800,
@@ -124,7 +126,7 @@ test.beforeEach(async ({ page }, testInfo) => {
: testInfo.title.includes("link preview no-image collapse")
? {
linkPreviewMetadata: null,
linkPreviewMetadataDelayMs: 800,
linkPreviewMetadataDelayMs: 2_000,
}
: undefined;
await installMockBridge(page, mock);
@@ -279,9 +281,8 @@ test("markdown tables overflow wide content and fill the message when narrow", a
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]) {
const previewUrl = `https://github.com/block/buzz/pull/3246?viewport=${width}`;
await page.setViewportSize({ width: 800, height: 700 });
await page.goto("/");
await page.getByTestId("channel-general").click();
@@ -330,8 +331,12 @@ test("link preview image geometry stays stable while loading", async ({
).paddingLeft,
)
: undefined,
titleHeight: element
.querySelector('[data-slot="attachment-title"]')
?.getBoundingClientRect().height,
textLeft: contentRect?.left,
thumbnailWidth: thumbnailRect?.width,
width: cardRect.width,
};
});
@@ -344,6 +349,12 @@ test("link preview image geometry stays stable while loading", async ({
}
const loaded = await card.evaluate((element) => ({
height: element.getBoundingClientRect().height,
titleHeight: element
.querySelector('[data-slot="attachment-title"]')
?.getBoundingClientRect().height,
descriptionHeight: element
.querySelector('[data-slot="attachment-description"]')
?.getBoundingClientRect().height,
textLeft: element
.querySelector('[data-slot="attachment-content"]')
?.getBoundingClientRect().left,
@@ -355,7 +366,12 @@ test("link preview image geometry stays stable while loading", async ({
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);
expect(loaded.thumbnailWidth).toBe(width < 640 ? 112 : 128);
expect(pending.height).toBe(80);
expect(pending.width).toBe(width < 640 ? 325 : 360);
expect(pending.titleHeight).toBe(20);
expect(loaded.titleHeight).toBe(20);
expect(loaded.descriptionHeight).toBe(16);
expect(pending.imageBottomInset).toBeCloseTo(1, 1);
expect(pending.imageLeftInset).toBeCloseTo(1, 1);
expect(pending.imageTopInset).toBeCloseTo(1, 1);
@@ -363,12 +379,48 @@ test("link preview image geometry stays stable while loading", async ({
}
});
test("link preview image geometry constrains multiple cards and shows one remove control", async ({
page,
}) => {
await page.setViewportSize({ width: 1218, height: 700 });
await page.goto("/");
await page.getByTestId("channel-general").click();
const previewUrls = [
"https://github.com/block/buzz/pull/3246?card=1",
"https://github.com/block/buzz/pull/3246?card=2",
"https://github.com/block/buzz/pull/3246?card=3",
];
await page.getByTestId("message-input").fill(previewUrls.join(" "));
await page.getByTestId("send-message").click();
const row = page.getByTestId("message-row").last();
const cards = row.locator('[data-link-preview="github-pull-request"]');
await expect(cards).toHaveCount(3);
for (const card of await cards.all()) {
await expect(card).toHaveAttribute("data-image-state", "image");
await expect(card).toHaveCSS("height", "80px");
await expect(card.locator("[data-link-preview-thumbnail]")).toHaveCSS(
"width",
"128px",
);
}
const removeControls = row.getByRole("button", {
name: "Remove previews for everyone",
});
await expect(removeControls).toHaveCount(1);
await expect(
cards.last().locator("..").getByRole("button"),
).toHaveAccessibleName("Remove previews for everyone");
});
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]) {
const previewUrl = `https://github.com/block/buzz/pull/3246?viewport=${width}`;
await page.setViewportSize({ width: 800, height: 700 });
await page.goto("/");
await page.getByTestId("channel-general").click();
@@ -381,6 +433,8 @@ test("link preview no-image collapse restores the compact card", async ({
.last()
.locator('[data-link-preview="github-pull-request"]');
await expect(card).toHaveAttribute("data-image-state", "pending");
await expect(card).toBeVisible();
await expect(card).toHaveCSS("height", "80px");
const pending = await card.evaluate((element) => ({
height: element.getBoundingClientRect().height,
textLeft: element
+1
View File
@@ -273,6 +273,7 @@ type MockBridgeOptions = {
linkPreviewMetadata?: {
title: string;
siteName: string | null;
description: string | null;
imageDataUrl: string | null;
imageDomain: string | null;
} | null;