mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
co-authored by
Taylor Ho
parent
805a968bd6
commit
9ae57fb956
@@ -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 & cards" property="og:title">
|
||||
<meta content="Safe & 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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -288,6 +288,7 @@ type E2eConfig = {
|
||||
linkPreviewMetadata?: {
|
||||
title: string;
|
||||
siteName: string | null;
|
||||
description: string | null;
|
||||
imageDataUrl: string | null;
|
||||
imageDomain: string | null;
|
||||
} | null;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -273,6 +273,7 @@ type MockBridgeOptions = {
|
||||
linkPreviewMetadata?: {
|
||||
title: string;
|
||||
siteName: string | null;
|
||||
description: string | null;
|
||||
imageDataUrl: string | null;
|
||||
imageDomain: string | null;
|
||||
} | null;
|
||||
|
||||
Reference in New Issue
Block a user