refactor(desktop): unify link preview identity styling

- Remove legacy provider-specific compact preview logos and layouts in favor of hostname-driven metadata.
- Present imageless compact previews with the same 84px height, description support, and left-rule treatment as image-backed cards.
- Preserve fetched favicons and make favicon/hostname identity rows link directly to their source URL.
- Align rich and compact hostname and description colors on the same secondary foreground treatment.
- Expand messaging E2E coverage for equal-height cards, generic favicons, linked hostnames, and imageless descriptions.

Co-authored-by: Taylor Ho <taylorkmho@gmail.com>
Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
This commit is contained in:
Taylor Ho
2026-07-30 23:21:08 -07:00
parent bab244e3d6
commit fb0eca68e5
3 changed files with 73 additions and 157 deletions
@@ -1,5 +1,3 @@
import { Globe } from "lucide-react";
import type { ResolvedLinkPreview } from "@/shared/lib/useResolvedLinkPreviews";
import { cn } from "@/shared/lib/cn";
import {
@@ -12,84 +10,6 @@ import {
} from "@/shared/ui/attachment";
import { LinkPreviewControls } from "@/shared/ui/link-preview-controls";
function LinearLogo({ className }: { className?: string }) {
return (
<svg
aria-hidden="true"
className={className}
fill="currentColor"
viewBox="0 0 24 24"
>
<path d="M2.886 4.18A11.982 11.982 0 0 1 11.99 0C18.624 0 24 5.376 24 12.009c0 3.64-1.62 6.903-4.18 9.105L2.887 4.18ZM1.817 5.626l16.556 16.556c-.524.33-1.075.62-1.65.866L.951 7.277c.247-.575.537-1.126.866-1.65ZM.322 9.163l14.515 14.515c-.71.172-1.443.282-2.195.322L0 11.358a12 12 0 0 1 .322-2.195Zm-.17 4.862 9.823 9.824a12.02 12.02 0 0 1-9.824-9.824Z" />
</svg>
);
}
function GitHubLogo({ className }: { className?: string }) {
return (
<svg
aria-hidden="true"
className={className}
fill="currentColor"
viewBox="0 0 24 24"
>
<path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12" />
</svg>
);
}
function GoogleDriveLogo({ className }: { className?: string }) {
return (
<svg
aria-hidden="true"
className={className}
fill="currentColor"
viewBox="0 0 24 24"
>
<path d="M12.01 1.485c-2.082 0-3.754.02-3.743.047.01.02 1.708 3.001 3.774 6.62l3.76 6.574h3.76c2.081 0 3.753-.02 3.742-.047-.005-.02-1.708-3.001-3.775-6.62l-3.76-6.574zm-4.76 1.73a789.828 789.861 0 0 0-3.63 6.319L0 15.868l1.89 3.298 1.885 3.297 3.62-6.335 3.618-6.33-1.88-3.287C8.1 4.704 7.255 3.22 7.25 3.214zm2.259 12.653-.203.348c-.114.198-.96 1.672-1.88 3.287a423.93 423.948 0 0 1-1.698 2.97c-.01.026 3.24.042 7.222.042h7.244l1.796-3.157c.992-1.734 1.85-3.23 1.906-3.323l.104-.167h-7.249z" />
</svg>
);
}
function GoogleDocsLogo({ className }: { className?: string }) {
return (
<svg
aria-hidden="true"
className={className}
fill="currentColor"
viewBox="0 0 24 24"
>
<path d="M14.727 6.727H14V0H4.91c-.905 0-1.637.732-1.637 1.636v20.728c0 .904.732 1.636 1.636 1.636h14.182c.904 0 1.636-.732 1.636-1.636V6.727h-6zm-.545 10.455H7.09v-1.364h7.09v1.364zm2.727-3.273H7.091v-1.364h9.818v1.364zm0-3.273H7.091V9.273h9.818v1.363zM14.727 6h6l-6-6v6z" />
</svg>
);
}
function GoogleSheetsLogo({ className }: { className?: string }) {
return (
<svg
aria-hidden="true"
className={className}
fill="currentColor"
viewBox="0 0 24 24"
>
<path d="M11.318 12.545H7.91v-1.909h3.41v1.91zM14.728 0v6h6l-6-6zm1.363 10.636h-3.41v1.91h3.41v-1.91zm0 3.273h-3.41v1.91h3.41v-1.91zM20.727 6.5v15.864c0 .904-.732 1.636-1.636 1.636H4.909a1.636 1.636 0 0 1-1.636-1.636V1.636C3.273.732 4.005 0 4.909 0h9.318v6.5h6.5zm-3.273 2.773H6.545v7.909h10.91v-7.91zm-6.136 4.636H7.91v1.91h3.41v-1.91z" />
</svg>
);
}
function GoogleSlidesLogo({ className }: { className?: string }) {
return (
<svg
aria-hidden="true"
className={className}
fill="currentColor"
viewBox="0 0 24 24"
>
<path d="M16.09 15.273H7.91v-4.637h8.18v4.637zm1.728-8.523h2.91v15.614c0 .904-.733 1.636-1.637 1.636H4.909a1.636 1.636 0 0 1-1.636-1.636V1.636C3.273.732 4.005 0 4.909 0h9.068v6.75h3.841zm-.363 2.523H6.545v7.363h10.91V9.273zm-2.728-5.979V6h6.001l-6-6v3.294z" />
</svg>
);
}
function getHostname(preview: ResolvedLinkPreview): string {
try {
return new URL(preview.href).hostname.replace(/^www\./, "");
@@ -98,40 +18,6 @@ function getHostname(preview: ResolvedLinkPreview): string {
}
}
function LinkPreviewLogo({ preview }: { preview: ResolvedLinkPreview }) {
if (preview.faviconDataUrl) {
return (
<img
alt=""
aria-hidden="true"
className="size-4 rounded-sm object-contain"
data-link-preview-favicon=""
src={preview.faviconDataUrl}
/>
);
}
switch (preview.kind) {
case "github-issue":
case "github-pull-request":
case "github-repository":
return <GitHubLogo className="h-4 w-4" />;
case "linear-issue":
return <LinearLogo className="h-4 w-4" />;
case "google-drive-file":
case "google-drive-folder":
return <GoogleDriveLogo className="h-4 w-4" />;
case "google-docs-document":
return <GoogleDocsLogo className="h-4 w-4" />;
case "google-sheets-spreadsheet":
return <GoogleSheetsLogo className="h-4 w-4" />;
case "google-slides-presentation":
return <GoogleSlidesLogo className="h-4 w-4" />;
case "generic-link":
return <Globe aria-hidden="true" className="h-4 w-4" />;
}
}
export function CompactLinkPreviewAttachment({
className,
onRemove,
@@ -151,8 +37,10 @@ export function CompactLinkPreviewAttachment({
<div className={cn("relative w-96 max-w-full shrink-0", className)}>
<Attachment
className={cn(
"w-full border-0 bg-transparent no-underline shadow-none hover:border-transparent hover:bg-transparent",
reserveImage && "h-22 min-h-22 max-h-22 gap-0 p-0",
"h-21 min-h-21 max-h-21 w-full bg-transparent no-underline shadow-none hover:bg-transparent",
reserveImage
? "gap-0 border-0 p-0 hover:border-transparent"
: "rounded-none border-0 border-l-[3px] border-border px-0 py-1 pl-3 hover:border-border",
)}
data-image-state={preview.imageState}
data-link-preview={preview.kind}
@@ -178,17 +66,16 @@ export function CompactLinkPreviewAttachment({
/>
)}
</AttachmentMedia>
) : (
<AttachmentMedia className="link-preview-media">
<LinkPreviewLogo preview={preview} />
</AttachmentMedia>
)}
) : null}
<AttachmentContent className={reserveImage ? "px-2 py-2" : undefined}>
<div
className="flex min-w-0 items-center gap-1.5 text-xs font-normal leading-4 text-muted-foreground/70"
<a
className="relative z-20 flex w-fit max-w-full min-w-0 items-center gap-1.5 text-xs font-normal leading-4 text-muted-foreground/70 hover:underline"
data-link-preview-hostname=""
href={preview.href}
rel="noreferrer"
target="_blank"
>
{reserveImage && preview.faviconDataUrl ? (
{preview.faviconDataUrl ? (
<img
alt=""
aria-hidden="true"
@@ -197,20 +84,21 @@ export function CompactLinkPreviewAttachment({
src={preview.faviconDataUrl}
/>
) : null}
<span className="truncate">
{reserveImage ? hostname : preview.provider}
</span>
</div>
<span className="truncate">{hostname}</span>
</a>
<AttachmentTitle className="line-clamp-2 whitespace-normal">
{preview.title}
</AttachmentTitle>
{reserveImage && preview.description ? (
{preview.description ? (
<AttachmentDescription className="text-muted-foreground/70">
{preview.description}
</AttachmentDescription>
) : null}
</AttachmentContent>
<AttachmentTrigger asChild>
<AttachmentTrigger
asChild
className={reserveImage ? undefined : "rounded-none"}
>
<a
aria-label={`Open ${preview.provider} ${preview.typeLabel}: ${preview.title}`}
href={preview.href}
@@ -152,12 +152,15 @@ function TweetPreview({
data-link-preview={preview.kind}
data-tweet-preview=""
>
<div
className="text-xs leading-4 text-muted-foreground"
<a
className="block w-fit max-w-full truncate text-xs leading-4 text-muted-foreground/70 hover:underline"
data-link-preview-hostname=""
href={preview.href}
rel="noreferrer"
target="_blank"
>
{hostname}
</div>
</a>
<a
className="mt-0.5 line-clamp-2 whitespace-normal text-sm font-semibold leading-5 text-foreground hover:underline"
href={preview.href}
@@ -168,7 +171,7 @@ function TweetPreview({
</a>
{contentExpanded && preview.description ? (
<LinkPreviewDescription
className="text-foreground"
className="text-muted-foreground/70"
description={preview.description}
/>
) : null}
@@ -246,13 +249,17 @@ export function RichLinkPreviewAttachment({
<div
className={cn(contentExpanded && reserveImage && "min-h-[3.875rem]")}
>
<div
className="flex items-center gap-1.5 text-xs leading-4 text-muted-foreground"
<a
className="flex w-fit max-w-full items-center gap-1.5 text-xs leading-4 text-muted-foreground/70 hover:underline"
data-link-preview-identity=""
data-link-preview-hostname=""
href={preview.href}
rel="noreferrer"
target="_blank"
>
<LinkPreviewIdentity preview={preview} />
<span data-link-preview-hostname="">{hostname}</span>
</div>
<span className="truncate">{hostname}</span>
</a>
<a
aria-label={`Open ${preview.provider} ${preview.typeLabel}: ${preview.title}`}
className="mt-0.5 block text-sm font-semibold leading-5 text-foreground hover:underline"
@@ -264,7 +271,7 @@ export function RichLinkPreviewAttachment({
</a>
{contentExpanded && preview.description ? (
<LinkPreviewDescription
className="text-muted-foreground"
className="text-muted-foreground/70"
description={preview.description}
/>
) : null}
+39 -18
View File
@@ -146,9 +146,17 @@ test.beforeEach(async ({ page }, testInfo) => {
},
linkPreviewMetadataDelayMs: 800,
}
: testInfo.title.includes("link preview no-image collapse")
: testInfo.title.includes("link preview no-image layout")
? {
linkPreviewMetadata: null,
linkPreviewMetadata: {
title: "Buzz",
siteName: "GitHub",
description: "Open-source collaboration for the Buzz app.",
imageDataUrl: null,
imageDomain: null,
faviconDataUrl:
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=",
},
linkPreviewMetadataDelayMs: 2_000,
}
: undefined;
@@ -312,9 +320,11 @@ test("link preview style defaults to compact and Rich unfurls descriptions", asy
await page.getByTestId("send-message").click();
const row = page.getByTestId("message-row").last();
await expect(
row.locator('[data-link-preview="github-pull-request"]'),
).toHaveCSS("border-top-left-radius", "16px");
const compactPreview = row.locator(
'[data-link-preview="github-pull-request"]',
);
await expect(compactPreview).toHaveCSS("border-top-left-radius", "0px");
await expect(compactPreview).toHaveCSS("border-left-width", "3px");
await openSettings(page, "appearance");
await expect(page.getByTestId("link-preview-style-trigger")).toHaveText(
@@ -338,9 +348,9 @@ test("link preview style defaults to compact and Rich unfurls descriptions", asy
'[data-link-preview="github-pull-request"][data-link-preview-inline]',
);
await expect(richPreview).toBeVisible();
await expect(richPreview.locator("[data-link-preview-hostname]")).toHaveText(
"github.com",
);
const richHostname = richPreview.locator("[data-link-preview-hostname]");
await expect(richHostname).toHaveText("github.com");
await expect(richHostname).toHaveAttribute("href", previewUrl);
await openSettings(page, "appearance");
await page.getByTestId("link-preview-style-trigger").click();
@@ -428,9 +438,9 @@ test("link preview image geometry stays stable while loading", async ({
}));
expect(pending.width).toBe(width < 640 ? 325 : 384);
expect(pending.height).toBe(88);
expect(pending.height).toBe(84);
expect(pending.textInset).toBe(8);
expect(pending.thumbnailHeight).toBe(88);
expect(pending.thumbnailHeight).toBe(84);
expect(loaded.height).toBe(pending.height);
expect(loaded.textLeft).toBe(pending.textLeft);
expect(loaded.thumbnailHeight).toBe(pending.thumbnailHeight);
@@ -443,9 +453,7 @@ test("link preview image geometry stays stable while loading", async ({
}
});
test("link preview no-image collapse restores the compact card", async ({
page,
}) => {
test("link preview no-image layout keeps compact height", async ({ page }) => {
const previewUrl = "https://github.com/block/buzz/pull/3246?inline=none";
for (const width of [800, 420]) {
@@ -468,7 +476,7 @@ test("link preview no-image collapse restores the compact card", async ({
.poll(() =>
card.evaluate((element) => element.getBoundingClientRect().height),
)
.toBe(80);
.toBe(84);
const pending = await card.evaluate((element) => ({
height: element.getBoundingClientRect().height,
textLeft: element
@@ -478,16 +486,29 @@ test("link preview no-image collapse restores the compact card", async ({
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) => ({
await expect(card.locator('[data-slot="attachment-media"]')).toHaveCount(0);
const hostnameLink = card.locator("[data-link-preview-hostname]");
await expect(hostnameLink).toHaveText("github.com");
await expect(hostnameLink).toHaveAttribute(
"href",
`${previewUrl}&viewport=${width}`,
);
await expect(
card.locator("[data-link-preview-hostname-favicon]"),
).toHaveAttribute("src", /data:image\/png;base64/);
await expect(
card.locator('[data-slot="attachment-description"]'),
).toHaveText("Open-source collaboration for the Buzz app.");
await expect(card).toHaveCSS("border-left-width", "3px");
const resolved = 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);
expect(resolved.height).toBe(pending.height);
expect(resolved.textLeft).toBeLessThan(pending.textLeft ?? 0);
}
});