mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
co-authored by
Taylor Ho
parent
814d1eee27
commit
008a1d599a
@@ -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>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
}) => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user