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) {