fix(desktop): stabilize link preview thumbnails

- Classify native preview image outcomes and honor bounded Retry-After cooldowns for rate-limited hosts
- Extract host cooldown bookkeeping to keep the preview command within the desktop file-size guard
- Fetch preview images and favicons independently so secondary failures preserve resolved metadata
- Add bounded frontend concurrency, fragment deduplication, expiring negative cache entries, and cooldown-aware retries
- Render deterministic Compact and Rich fallbacks for transient fetch and browser decode failures
- Expand Rust, metadata-loader, mock bridge, and Playwright coverage for throttling and recovery

Co-authored-by: Taylor Ho <taylorkmho@gmail.com>
Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
This commit is contained in:
Taylor Ho
2026-08-01 20:10:23 -07:00
committed by npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w
parent 010b533f9c
commit 3a9dbe2c83
9 changed files with 832 additions and 96 deletions

View File

@@ -11,6 +11,14 @@ use reqwest::{
use serde::Serialize;
use url::Url;
#[path = "link_preview_rate_limit.rs"]
mod rate_limit;
use rate_limit::{
image_host_cooldown_remaining, retry_after_duration, set_image_host_cooldown,
MAX_IMAGE_RETRY_AFTER,
};
const MAX_PREVIEW_FETCH_BYTES: usize = 256 * 1024;
const MAX_IMAGE_FETCH_BYTES: usize = 2 * 1024 * 1024;
const MAX_IMAGE_DIMENSION: u32 = 4096;
@@ -22,6 +30,15 @@ const MAX_REDIRECTS: usize = 3;
const MAX_METADATA_CHARS: usize = 180;
const MAX_METADATA_DESCRIPTION_CHARS: usize = 280;
#[derive(Clone, Copy, Debug, PartialEq, Serialize)]
#[serde(rename_all = "snake_case")]
enum LinkPreviewImageFetchState {
None,
Image,
TransientFailure,
Rejected,
}
#[derive(Debug, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct LinkPreviewMetadata {
@@ -30,6 +47,8 @@ pub struct LinkPreviewMetadata {
description: Option<String>,
image_data_url: Option<String>,
image_domain: Option<String>,
image_fetch_state: LinkPreviewImageFetchState,
image_retry_after_ms: Option<u64>,
favicon_data_url: Option<String>,
}
@@ -79,16 +98,38 @@ async fn fetch_link_preview_metadata_inner(
let Some(mut metadata) = extract_link_preview_metadata(&body) else {
return Ok(None);
};
if let Some(image_url) = extract_image_url(&body, &url) {
if let Ok((data_url, domain)) = fetch_sanitized_image(image_url, false).await {
metadata.image_data_url = Some(data_url);
metadata.image_domain = Some(domain);
}
}
if let Some(favicon_url) = extract_favicon_url(&body, &url) {
if let Ok((data_url, _)) = fetch_sanitized_image(favicon_url, true).await {
metadata.favicon_data_url = Some(data_url);
let image_url = extract_image_url(&body, &url);
let favicon_url = extract_favicon_url(&body, &url);
let (image_result, favicon_result) = tokio::join!(
async {
match image_url {
Some(image_url) => Some(
tokio::time::timeout(
PREVIEW_FETCH_TIMEOUT,
fetch_sanitized_image(image_url, false),
)
.await
.unwrap_or(Err(ImageFetchError::Transient { retry_after: None })),
),
None => None,
}
},
async {
match favicon_url {
Some(favicon_url) => tokio::time::timeout(
PREVIEW_FETCH_TIMEOUT,
fetch_sanitized_image(favicon_url, true),
)
.await
.ok(),
None => None,
}
}
);
apply_image_result(&mut metadata, image_result);
if let Some(Ok((data_url, _))) = favicon_result {
metadata.favicon_data_url = Some(data_url);
}
return Ok(Some(metadata));
}
@@ -96,6 +137,28 @@ async fn fetch_link_preview_metadata_inner(
Ok(None)
}
fn apply_image_result(
metadata: &mut LinkPreviewMetadata,
image_result: Option<Result<(String, String), ImageFetchError>>,
) {
match image_result {
Some(Ok((data_url, domain))) => {
metadata.image_data_url = Some(data_url);
metadata.image_domain = Some(domain);
metadata.image_fetch_state = LinkPreviewImageFetchState::Image;
}
Some(Err(ImageFetchError::Transient { retry_after })) => {
metadata.image_fetch_state = LinkPreviewImageFetchState::TransientFailure;
metadata.image_retry_after_ms =
retry_after.and_then(|duration| u64::try_from(duration.as_millis()).ok());
}
Some(Err(ImageFetchError::Rejected)) => {
metadata.image_fetch_state = LinkPreviewImageFetchState::Rejected;
}
None => {}
}
}
async fn validate_public_https_url(url: &Url) -> Result<(), String> {
if url.scheme() != "https" || url.username() != "" || url.password().is_some() {
return Err("link previews require an HTTPS URL without credentials".to_string());
@@ -251,30 +314,57 @@ fn extract_image_url(html: &str, page_url: &Url) -> Option<Url> {
page_url.join(raw.trim()).ok()
}
#[derive(Debug, PartialEq)]
enum ImageFetchError {
Transient { retry_after: Option<Duration> },
Rejected,
}
async fn fetch_sanitized_image(
mut url: Url,
preserve_transparency: bool,
) -> Result<(String, String), String> {
validate_public_https_url(&url).await?;
) -> Result<(String, String), ImageFetchError> {
validate_public_https_url(&url)
.await
.map_err(|_| ImageFetchError::Rejected)?;
for redirect_count in 0..=MAX_REDIRECTS {
let response = send_pinned_request(&url, "image/jpeg,image/png,image/webp").await?;
if let Some(retry_after) = image_host_cooldown_remaining(&url) {
return Err(ImageFetchError::Transient {
retry_after: Some(retry_after),
});
}
let response = send_pinned_request(&url, "image/jpeg,image/png,image/webp")
.await
.map_err(|_| ImageFetchError::Transient { retry_after: None })?;
if response.status().is_redirection() {
if redirect_count == MAX_REDIRECTS {
return Err("link preview image redirected too many times".to_string());
return Err(ImageFetchError::Rejected);
}
let location = response
.headers()
.get(LOCATION)
.and_then(|value| value.to_str().ok())
.ok_or_else(|| "link preview image redirect has an invalid location".to_string())?;
url = url
.join(location)
.map_err(|error| format!("invalid link preview image redirect: {error}"))?;
validate_public_https_url(&url).await?;
.ok_or(ImageFetchError::Rejected)?;
url = url.join(location).map_err(|_| ImageFetchError::Rejected)?;
validate_public_https_url(&url)
.await
.map_err(|_| ImageFetchError::Rejected)?;
continue;
}
if !response.status().is_success() {
return Err("link preview image request was unsuccessful".to_string());
let status = response.status();
if status == reqwest::StatusCode::TOO_MANY_REQUESTS
|| status == reqwest::StatusCode::REQUEST_TIMEOUT
|| status == reqwest::StatusCode::TOO_EARLY
|| status.is_server_error()
{
let retry_after = retry_after_duration(&response);
if let Some(retry_after) = retry_after {
set_image_host_cooldown(&url, retry_after);
}
return Err(ImageFetchError::Transient { retry_after });
}
return Err(ImageFetchError::Rejected);
}
let declared_mime = response
.headers()
@@ -288,29 +378,32 @@ async fn fetch_sanitized_image(
.trim()
.to_ascii_lowercase()
})
.ok_or_else(|| "link preview image has no content type".to_string())?;
.ok_or(ImageFetchError::Rejected)?;
if !matches!(
declared_mime.as_str(),
"image/jpeg" | "image/png" | "image/webp"
) {
return Err("link preview image type is unsupported".to_string());
return Err(ImageFetchError::Rejected);
}
if response
.content_length()
.is_some_and(|size| size > MAX_IMAGE_FETCH_BYTES as u64)
{
return Err("link preview image exceeded the size limit".to_string());
return Err(ImageFetchError::Rejected);
}
let bytes = read_limited_bytes(response, MAX_IMAGE_FETCH_BYTES).await?;
let bytes = read_limited_bytes(response, MAX_IMAGE_FETCH_BYTES)
.await
.map_err(|_| ImageFetchError::Rejected)?;
let data_url = tokio::task::spawn_blocking(move || {
sanitize_image(&bytes, &declared_mime, preserve_transparency)
})
.await
.map_err(|_| "link preview image sanitizer failed".to_string())??;
.map_err(|_| ImageFetchError::Rejected)?
.map_err(|_| ImageFetchError::Rejected)?;
let domain = url.host_str().unwrap_or_default().to_string();
return Ok((data_url, domain));
}
Err("link preview image fetch failed".to_string())
Err(ImageFetchError::Rejected)
}
fn sanitize_image(
@@ -411,6 +504,8 @@ fn extract_link_preview_metadata(html: &str) -> Option<LinkPreviewMetadata> {
description,
image_data_url: None,
image_domain: None,
image_fetch_state: LinkPreviewImageFetchState::None,
image_retry_after_ms: None,
favicon_data_url: None,
})
}
@@ -557,9 +652,10 @@ fn decode_html_entities(value: &str) -> String {
#[cfg(test)]
mod tests {
use super::{
declares_animation, extract_favicon_url, extract_image_url, extract_link_preview_metadata,
is_html_response, read_bytes_prefix, sanitize_image, LinkPreviewMetadata,
MAX_METADATA_DESCRIPTION_CHARS,
apply_image_result, declares_animation, extract_favicon_url, extract_image_url,
extract_link_preview_metadata, is_html_response, read_bytes_prefix, retry_after_duration,
sanitize_image, ImageFetchError, LinkPreviewImageFetchState, LinkPreviewMetadata,
MAX_IMAGE_RETRY_AFTER, MAX_METADATA_DESCRIPTION_CHARS,
};
use axum::{body::Body, http::Response, routing::get, Router};
use base64::Engine as _;
@@ -594,11 +690,45 @@ mod tests {
description: Some("Safe & useful previews".to_string()),
image_data_url: None,
image_domain: None,
image_fetch_state: LinkPreviewImageFetchState::None,
image_retry_after_ms: None,
favicon_data_url: None,
})
);
}
#[test]
fn image_results_preserve_absence_and_classify_recovery() {
let mut metadata = extract_link_preview_metadata("<title>Preview result</title>").unwrap();
apply_image_result(&mut metadata, None);
assert_eq!(metadata.image_fetch_state, LinkPreviewImageFetchState::None);
apply_image_result(
&mut metadata,
Some(Err(ImageFetchError::Transient {
retry_after: Some(std::time::Duration::from_secs(15)),
})),
);
assert_eq!(
metadata.image_fetch_state,
LinkPreviewImageFetchState::TransientFailure
);
assert_eq!(metadata.image_retry_after_ms, Some(15_000));
apply_image_result(
&mut metadata,
Some(Ok((
"data:image/jpeg;base64,abc".to_string(),
"images.example.com".to_string(),
))),
);
assert_eq!(
metadata.image_fetch_state,
LinkPreviewImageFetchState::Image
);
assert_eq!(metadata.image_domain.as_deref(), Some("images.example.com"));
}
#[test]
fn metadata_falls_back_to_twitter_then_title() {
assert_eq!(
@@ -716,6 +846,44 @@ mod tests {
assert!(extract_image_url(&html, &Url::parse("https://example.com").unwrap()).is_some());
}
#[tokio::test]
async fn image_retry_after_uses_bounded_delta_seconds() {
let response = test_response(
Router::new().route(
"/rate-limited",
get(|| async {
Response::builder()
.status(429)
.header("retry-after", "900")
.body(Body::empty())
.unwrap()
}),
),
"/rate-limited",
)
.await;
assert_eq!(
retry_after_duration(&response),
Some(std::time::Duration::from_secs(900))
);
let response = test_response(
Router::new().route(
"/excessive",
get(|| async {
Response::builder()
.status(429)
.header("retry-after", "7200")
.body(Body::empty())
.unwrap()
}),
),
"/excessive",
)
.await;
assert_eq!(retry_after_duration(&response), Some(MAX_IMAGE_RETRY_AFTER));
}
#[tokio::test]
async fn oversized_chunked_html_ignores_metadata_beyond_the_bounded_prefix() {
const LIMIT: usize = 256;

View File

@@ -0,0 +1,62 @@
use std::{
collections::HashMap,
sync::{Mutex, OnceLock},
time::{Duration, Instant},
};
use reqwest::header::RETRY_AFTER;
use url::Url;
pub(super) const MAX_IMAGE_RETRY_AFTER: Duration = Duration::from_secs(60 * 60);
const MAX_IMAGE_HOST_COOLDOWNS: usize = 128;
static IMAGE_HOST_COOLDOWNS: OnceLock<Mutex<HashMap<String, Instant>>> = OnceLock::new();
pub(super) fn retry_after_duration(response: &reqwest::Response) -> Option<Duration> {
response
.headers()
.get(RETRY_AFTER)
.and_then(|value| value.to_str().ok())
.and_then(|value| value.trim().parse::<u64>().ok())
.map(Duration::from_secs)
.map(|duration| duration.min(MAX_IMAGE_RETRY_AFTER))
}
pub(super) fn image_host_cooldown_remaining(url: &Url) -> Option<Duration> {
let host = url.host_str()?;
let cooldowns = IMAGE_HOST_COOLDOWNS.get_or_init(|| Mutex::new(HashMap::new()));
let Ok(mut cooldowns) = cooldowns.lock() else {
return None;
};
let expires_at = cooldowns.get(host).copied()?;
let now = Instant::now();
if expires_at <= now {
cooldowns.remove(host);
return None;
}
Some(expires_at.duration_since(now))
}
pub(super) fn set_image_host_cooldown(url: &Url, retry_after: Duration) {
let Some(host) = url.host_str() else {
return;
};
let now = Instant::now();
let Some(expires_at) = now.checked_add(retry_after) else {
return;
};
let cooldowns = IMAGE_HOST_COOLDOWNS.get_or_init(|| Mutex::new(HashMap::new()));
if let Ok(mut cooldowns) = cooldowns.lock() {
cooldowns.retain(|_, current_expiry| *current_expiry > now);
if cooldowns.len() >= MAX_IMAGE_HOST_COOLDOWNS && !cooldowns.contains_key(host) {
let oldest_host = cooldowns
.iter()
.min_by_key(|(_, current_expiry)| *current_expiry)
.map(|(current_host, _)| current_host.clone());
if let Some(oldest_host) = oldest_host {
cooldowns.remove(&oldest_host);
}
}
cooldowns.insert(host.to_string(), expires_at);
}
}

View File

@@ -1,7 +1,10 @@
import assert from "node:assert/strict";
import test from "node:test";
import { resolveLinkPreview } from "./useResolvedLinkPreviews.ts";
import {
__linkPreviewMetadataTest,
resolveLinkPreview,
} from "./useResolvedLinkPreviews.ts";
const preview = {
kind: "generic-link",
@@ -11,6 +14,19 @@ const preview = {
typeLabel: "link",
};
function metadata(overrides = {}) {
return {
title: "A story",
siteName: "Example",
description: "Story description",
imageDataUrl: null,
imageDomain: null,
imageFetchState: "none",
imageRetryAfterMs: null,
...overrides,
};
}
test("pending metadata reserves the image treatment", () => {
assert.deepEqual(resolveLinkPreview(preview, undefined), {
...preview,
@@ -43,3 +59,124 @@ test("resolved metadata without a complete image collapses to the compact treatm
assert.equal(resolved.imageDataUrl, null);
assert.equal(resolved.imageDomain, null);
});
test("transient and rejected image fetches use the stable fallback treatment", () => {
const transient = resolveLinkPreview(
preview,
metadata({
imageFetchState: "transient_failure",
imageRetryAfterMs: 900_000,
}),
);
const rejected = resolveLinkPreview(
preview,
metadata({ imageFetchState: "rejected" }),
);
assert.equal(transient.imageState, "fallback");
assert.equal(rejected.imageState, "fallback");
});
test("metadata cache keys deduplicate URL fragments", () => {
assert.equal(
__linkPreviewMetadataTest.metadataCacheKey(
"https://github.com/block/buzz/pull/3834#issuecomment-1",
),
"https://github.com/block/buzz/pull/3834",
);
});
test("transient metadata expires at the server retry boundary", () => {
assert.equal(
__linkPreviewMetadataTest.metadataExpiry(
metadata({
imageFetchState: "transient_failure",
imageRetryAfterMs: 900_000,
}),
1_000,
),
901_000,
);
});
test("metadata loader retries transient images after the server cooldown", async () => {
let now = 1_000;
let calls = 0;
const loader = __linkPreviewMetadataTest.createMetadataLoader({
fetcher: async () => {
calls += 1;
return calls === 1
? metadata({
imageFetchState: "transient_failure",
imageRetryAfterMs: 10_000,
})
: metadata({
imageDataUrl: "data:image/jpeg;base64,abc",
imageDomain: "images.example.com",
imageFetchState: "image",
});
},
now: () => now,
});
assert.equal(
(await loader.load(preview.href)).metadata?.imageFetchState,
"transient_failure",
);
assert.equal(calls, 1);
now += 10_000;
assert.equal(
(await loader.load(preview.href)).metadata?.imageFetchState,
"image",
);
assert.equal(calls, 2);
});
test("metadata loader retries rejected requests after the negative-cache TTL", async () => {
let now = 1_000;
let calls = 0;
const loader = __linkPreviewMetadataTest.createMetadataLoader({
fetcher: async () => {
calls += 1;
if (calls === 1) throw new Error("temporary failure");
return metadata();
},
now: () => now,
});
assert.equal((await loader.load(preview.href)).metadata, null);
assert.equal((await loader.load(preview.href)).metadata, null);
assert.equal(calls, 1);
now += 5 * 60_000;
assert.deepEqual((await loader.load(preview.href)).metadata, metadata());
assert.equal(calls, 2);
});
test("metadata loader coalesces fragment variants and bounds concurrency", async () => {
let active = 0;
let maxActive = 0;
let calls = 0;
const loader = __linkPreviewMetadataTest.createMetadataLoader({
concurrency: 2,
fetcher: async () => {
calls += 1;
active += 1;
maxActive = Math.max(maxActive, active);
await new Promise((resolve) => setImmediate(resolve));
active -= 1;
return metadata();
},
});
await Promise.all([
loader.load("https://example.com/one#first"),
loader.load("https://example.com/one#second"),
loader.load("https://example.com/two"),
loader.load("https://example.com/three"),
]);
assert.equal(calls, 3);
assert.equal(maxActive, 2);
});

View File

@@ -4,23 +4,153 @@ import { invokeTauri } from "@/shared/api/tauri";
import type { SupportedLinkPreview } from "./linkPreview";
type LinkPreviewImageFetchState =
| "none"
| "image"
| "transient_failure"
| "rejected";
type LinkPreviewMetadata = {
title: string;
siteName: string | null;
description: string | null;
imageDataUrl: string | null;
imageDomain: string | null;
imageFetchState?: LinkPreviewImageFetchState;
imageRetryAfterMs?: number | null;
faviconDataUrl?: string | null;
};
const metadataCache = new Map<
string,
Promise<LinkPreviewMetadata | null> | LinkPreviewMetadata | null
>();
type MetadataCacheEntry = {
expiresAt: number | null;
metadata: LinkPreviewMetadata | null;
};
/** Clear ephemeral metadata when the active relay/community changes. */
export function resetLinkPreviewMetadataCache(): void {
metadataCache.clear();
type MetadataLoadResult = MetadataCacheEntry & {
key: string;
};
const DEFAULT_TRANSIENT_RETRY_MS = 30_000;
const NULL_METADATA_RETRY_MS = 5 * 60_000;
const MAX_CONCURRENT_METADATA_FETCHES = 2;
function metadataCacheKey(href: string): string {
try {
const url = new URL(href);
url.hash = "";
return url.href;
} catch {
return href.split("#", 1)[0] ?? href;
}
}
function metadataExpiry(
metadata: LinkPreviewMetadata | null,
now: number,
): number | null {
if (metadata === null) return now + NULL_METADATA_RETRY_MS;
if (metadata.imageFetchState !== "transient_failure") return null;
const retryAfterMs =
typeof metadata.imageRetryAfterMs === "number" &&
Number.isFinite(metadata.imageRetryAfterMs)
? Math.max(1_000, metadata.imageRetryAfterMs)
: DEFAULT_TRANSIENT_RETRY_MS;
return now + retryAfterMs;
}
function createTaskScheduler(concurrency: number) {
const pending: Array<() => void> = [];
let active = 0;
const drain = () => {
while (active < concurrency) {
const run = pending.shift();
if (!run) return;
active += 1;
run();
}
};
return <T>(task: () => Promise<T>): Promise<T> =>
new Promise<T>((resolve, reject) => {
pending.push(() => {
void task()
.then(resolve, reject)
.finally(() => {
active -= 1;
drain();
});
});
drain();
});
}
function createMetadataLoader({
concurrency = MAX_CONCURRENT_METADATA_FETCHES,
fetcher,
now = Date.now,
}: {
concurrency?: number;
fetcher: (href: string) => Promise<LinkPreviewMetadata | null>;
now?: () => number;
}) {
const cache = new Map<
string,
MetadataCacheEntry | Promise<MetadataLoadResult>
>();
const schedule = createTaskScheduler(Math.max(1, concurrency));
let generation = 0;
const peek = (href: string): MetadataLoadResult | undefined => {
const key = metadataCacheKey(href);
const cached = cache.get(key);
if (!cached || cached instanceof Promise) return undefined;
if (cached.expiresAt !== null && cached.expiresAt <= now()) {
cache.delete(key);
return undefined;
}
return { key, ...cached };
};
const load = (href: string): Promise<MetadataLoadResult> => {
const key = metadataCacheKey(href);
const cached = cache.get(key);
if (cached instanceof Promise) return cached;
if (cached) {
if (cached.expiresAt === null || cached.expiresAt > now()) {
return Promise.resolve({ key, ...cached });
}
cache.delete(key);
}
const requestGeneration = generation;
const promise = schedule(() => fetcher(href))
.catch(() => null)
.then((metadata) => {
const entry = {
expiresAt: metadataExpiry(metadata, now()),
metadata,
};
if (requestGeneration === generation) {
cache.set(key, entry);
}
return { key, ...entry };
});
cache.set(key, promise);
return promise;
};
return {
deleteKey(key: string) {
cache.delete(key);
},
load,
peek,
reset() {
generation += 1;
cache.clear();
},
};
}
function fetchLinkPreviewMetadata(
@@ -34,25 +164,16 @@ function fetchLinkPreviewMetadata(
);
}
function cacheMetadata(href: string): Promise<LinkPreviewMetadata | null> {
const cached = metadataCache.get(href);
if (cached instanceof Promise) return cached;
if (cached !== undefined) return Promise.resolve(cached);
const metadataLoader = createMetadataLoader({
fetcher: fetchLinkPreviewMetadata,
});
const promise = fetchLinkPreviewMetadata(href)
.then((metadata) => {
metadataCache.set(href, metadata);
return metadata;
})
.catch(() => {
metadataCache.set(href, null);
return null;
});
metadataCache.set(href, promise);
return promise;
/** Clear ephemeral metadata when the active relay/community changes. */
export function resetLinkPreviewMetadataCache(): void {
metadataLoader.reset();
}
export type LinkPreviewImageState = "pending" | "image" | "none";
export type LinkPreviewImageState = "pending" | "image" | "fallback" | "none";
export type ResolvedLinkPreview = SupportedLinkPreview & {
description?: string | null;
@@ -77,6 +198,13 @@ export function resolveLinkPreview(
}
const hasImage = Boolean(metadata.imageDataUrl && metadata.imageDomain);
const imageState: LinkPreviewImageState = hasImage
? "image"
: metadata.imageFetchState === "image" ||
metadata.imageFetchState === "transient_failure" ||
metadata.imageFetchState === "rejected"
? "fallback"
: "none";
return {
...preview,
title: metadata.title,
@@ -88,7 +216,7 @@ export function resolveLinkPreview(
: preview.provider,
imageDataUrl: hasImage ? metadata.imageDataUrl : null,
imageDomain: hasImage ? metadata.imageDomain : null,
imageState: hasImage ? "image" : "none",
imageState,
};
}
@@ -97,41 +225,79 @@ export function useResolvedLinkPreviews(
): ResolvedLinkPreview[] {
const [resolvedMetadata, setResolvedMetadata] =
React.useState<ResolvedMetadataByHref>({});
const [retryGeneration, setRetryGeneration] = React.useState(0);
React.useEffect(() => {
let cancelled = false;
let retryAt = Number.POSITIVE_INFINITY;
let retryTimer: ReturnType<typeof setTimeout> | null = null;
const scheduleRetry = ({
expiresAt,
key,
}: Pick<MetadataLoadResult, "expiresAt" | "key">) => {
if (expiresAt === null || expiresAt >= retryAt) return;
retryAt = expiresAt;
if (retryTimer !== null) clearTimeout(retryTimer);
retryTimer = setTimeout(
() => {
metadataLoader.deleteKey(key);
setResolvedMetadata((current) => {
if (!(key in current)) return current;
const next = { ...current };
delete next[key];
return next;
});
setRetryGeneration(retryGeneration + 1);
},
Math.max(0, expiresAt - Date.now()),
);
};
for (const preview of previews) {
const cached = metadataCache.get(preview.href);
if (cached && !(cached instanceof Promise)) {
const cached = metadataLoader.peek(preview.href);
if (cached !== undefined) {
setResolvedMetadata((current) =>
current[preview.href] === cached
current[cached.key] === cached.metadata
? current
: { ...current, [preview.href]: cached },
: { ...current, [cached.key]: cached.metadata },
);
scheduleRetry(cached);
continue;
}
void cacheMetadata(preview.href).then((metadata) => {
void metadataLoader.load(preview.href).then((result) => {
if (cancelled) return;
setResolvedMetadata((current) =>
current[preview.href] === metadata
current[result.key] === result.metadata
? current
: { ...current, [preview.href]: metadata },
: { ...current, [result.key]: result.metadata },
);
scheduleRetry(result);
});
}
return () => {
cancelled = true;
if (retryTimer !== null) clearTimeout(retryTimer);
};
}, [previews]);
}, [previews, retryGeneration]);
return React.useMemo(
() =>
previews.map((preview) =>
resolveLinkPreview(preview, resolvedMetadata[preview.href]),
resolveLinkPreview(
preview,
resolvedMetadata[metadataCacheKey(preview.href)],
),
),
[previews, resolvedMetadata],
);
}
export const __linkPreviewMetadataTest = {
createMetadataLoader,
createTaskScheduler,
metadataCacheKey,
metadataExpiry,
};

View File

@@ -1,3 +1,6 @@
import { ImageOff } from "lucide-react";
import { useState } from "react";
import type { ResolvedLinkPreview } from "@/shared/lib/useResolvedLinkPreviews";
import { cn } from "@/shared/lib/cn";
import {
@@ -18,6 +21,30 @@ function getHostname(preview: ResolvedLinkPreview): string {
}
}
function LinkPreviewImageFallback({
preview,
}: {
preview: ResolvedLinkPreview;
}) {
return (
<div
aria-hidden="true"
className="flex h-full w-full items-center justify-center bg-muted text-muted-foreground"
data-link-preview-image-fallback=""
>
{preview.faviconDataUrl ? (
<img
alt=""
className="size-7 rounded-md object-contain opacity-70"
src={preview.faviconDataUrl}
/>
) : (
<ImageOff className="size-5 opacity-60" />
)}
</div>
);
}
export function CompactLinkPreviewAttachment({
className,
onRemove,
@@ -30,7 +57,12 @@ export function CompactLinkPreviewAttachment({
showControls?: boolean;
}) {
const reserveImage = preview.imageState !== "none";
const showImage = preview.imageState === "image";
const imageSrc =
preview.imageState === "image" ? (preview.imageDataUrl ?? null) : null;
const [failedImageSrc, setFailedImageSrc] = useState<string | null>(null);
const showImage = Boolean(imageSrc && failedImageSrc !== imageSrc);
const showFallback =
preview.imageState === "fallback" || Boolean(imageSrc && !showImage);
const hostname = getHostname(preview);
return (
@@ -57,8 +89,11 @@ export function CompactLinkPreviewAttachment({
<img
alt={`Preview from ${preview.imageDomain}`}
className="h-full w-full object-cover"
src={preview.imageDataUrl ?? undefined}
onError={() => setFailedImageSrc(imageSrc)}
src={imageSrc ?? undefined}
/>
) : showFallback ? (
<LinkPreviewImageFallback preview={preview} />
) : (
<div
className="h-full w-full animate-pulse bg-muted-foreground/10"

View File

@@ -1,4 +1,4 @@
import { ChevronDown, ChevronUp } from "lucide-react";
import { ChevronDown, ChevronUp, ImageOff } from "lucide-react";
import type { ComponentType, ReactNode } from "react";
import { useState } from "react";
@@ -64,21 +64,45 @@ function LinkPreviewImage({
}) {
const imageSrc =
preview.imageState === "image" ? preview.imageDataUrl : undefined;
const [failedImageSrc, setFailedImageSrc] = useState<string | null>(null);
const imageFailed = Boolean(imageSrc && failedImageSrc === imageSrc);
const showFallback = preview.imageState === "fallback" || imageFailed;
const alt = `Preview from ${preview.imageDomain}`;
if (!imageSrc) {
if (!imageSrc || imageFailed) {
return (
<div
className={cn("overflow-hidden rounded-xl bg-muted", className)}
data-link-preview-thumbnail=""
>
<div
className={cn(
"w-full animate-pulse bg-muted-foreground/10",
aspectClassName,
)}
data-link-preview-skeleton=""
/>
{showFallback ? (
<div
aria-hidden="true"
className={cn(
"flex w-full items-center justify-center bg-muted text-muted-foreground",
aspectClassName,
)}
data-link-preview-image-fallback=""
>
{preview.faviconDataUrl ? (
<img
alt=""
className="size-10 rounded-lg object-contain opacity-70"
src={preview.faviconDataUrl}
/>
) : (
<ImageOff className="size-7 opacity-60" />
)}
</div>
) : (
<div
className={cn(
"w-full animate-pulse bg-muted-foreground/10",
aspectClassName,
)}
data-link-preview-skeleton=""
/>
)}
</div>
);
}
@@ -96,6 +120,7 @@ function LinkPreviewImage({
<img
alt={alt}
className="h-full w-full rounded-xl object-cover"
onError={() => setFailedImageSrc(imageSrc)}
src={imageSrc}
/>
</div>

View File

@@ -336,8 +336,23 @@ type E2eConfig = {
description: string | null;
imageDataUrl: string | null;
imageDomain: string | null;
imageFetchState?: "none" | "image" | "transient_failure" | "rejected";
imageRetryAfterMs?: number | null;
faviconDataUrl?: string | null;
} | null;
linkPreviewMetadataByHref?: Record<
string,
{
title: string;
siteName: string | null;
description: string | null;
imageDataUrl: string | null;
imageDomain: string | null;
imageFetchState?: "none" | "image" | "transient_failure" | "rejected";
imageRetryAfterMs?: number | null;
faviconDataUrl?: string | null;
} | null
>;
linkPreviewMetadataDelayMs?: number;
searchProfiles?: MockSearchProfileSeed[];
updateAvailable?: boolean;
@@ -10967,6 +10982,11 @@ export function maybeInstallE2eTauriMocks() {
if (delayMs > 0) {
await new Promise((resolve) => window.setTimeout(resolve, delayMs));
}
const href = (payload as { href?: string }).href;
const metadataByHref = activeConfig?.mock?.linkPreviewMetadataByHref;
if (href && metadataByHref && Object.hasOwn(metadataByHref, href)) {
return metadataByHref[href];
}
return activeConfig?.mock?.linkPreviewMetadata ?? null;
}
case "apply_workspace": {

View File

@@ -131,35 +131,72 @@ test.beforeEach(async ({ page }, testInfo) => {
imageDomain: "pbs.twimg.com",
},
}
: testInfo.title.includes("link preview image geometry")
: testInfo.title.includes("mixed link preview image outcomes")
? {
linkPreviewMetadata: {
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='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",
faviconDataUrl:
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=",
},
linkPreviewMetadataDelayMs: 800,
}
: testInfo.title.includes("link preview no-image layout")
? {
linkPreviewMetadata: {
title: "Buzz",
linkPreviewMetadataByHref: {
"https://github.com/block/buzz/pull/4001": {
title: "Loaded preview image",
siteName: "GitHub",
description: "Open-source collaboration for the Buzz app.",
description: "The image request completed.",
imageDataUrl:
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='1200' height='630'%3E%3Crect width='1200' height='630' fill='%237c3aed'/%3E%3C/svg%3E",
imageDomain: "opengraph.githubassets.com",
imageFetchState: "image",
imageRetryAfterMs: null,
},
"https://github.com/block/buzz/pull/4002": {
title: "Rate-limited preview image",
siteName: "GitHub",
description: "Metadata remains available during cooldown.",
imageDataUrl: null,
imageDomain: null,
faviconDataUrl:
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=",
imageFetchState: "transient_failure",
imageRetryAfterMs: 900_000,
},
},
}
: testInfo.title.includes("link preview browser image error")
? {
linkPreviewMetadata: {
title: "Invalid decoded preview image",
siteName: "GitHub",
description: "The browser should replace this image.",
imageDataUrl: "data:image/png;base64,not-a-valid-image",
imageDomain: "opengraph.githubassets.com",
imageFetchState: "image",
imageRetryAfterMs: null,
},
linkPreviewMetadataDelayMs: 2_000,
}
: undefined;
: testInfo.title.includes("link preview image geometry")
? {
linkPreviewMetadata: {
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='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",
faviconDataUrl:
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=",
},
linkPreviewMetadataDelayMs: 800,
}
: testInfo.title.includes("link preview no-image layout")
? {
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;
await installMockBridge(page, mock);
});
@@ -508,6 +545,77 @@ test("link preview no-image layout keeps compact height", async ({ page }) => {
}
});
test("mixed link preview image outcomes keep Compact and Rich fallbacks stable", async ({
page,
}) => {
const loadedUrl = "https://github.com/block/buzz/pull/4001";
const rateLimitedUrl = "https://github.com/block/buzz/pull/4002";
await page.goto("/");
await page.getByTestId("channel-general").click();
await page
.getByTestId("message-input")
.fill(`${loadedUrl}\n${rateLimitedUrl}`);
await page.getByTestId("send-message").click();
const row = page.getByTestId("message-row").last();
const compactCards = row.locator('[data-link-preview="github-pull-request"]');
await expect(compactCards).toHaveCount(2);
await expect(compactCards.nth(0)).toHaveAttribute(
"data-image-state",
"image",
);
await expect(compactCards.nth(0).locator("img")).toBeVisible();
await expect(compactCards.nth(1)).toHaveAttribute(
"data-image-state",
"fallback",
);
await expect(
compactCards.nth(1).locator("[data-link-preview-image-fallback]"),
).toBeVisible();
await openSettings(page, "appearance");
await page.getByTestId("link-preview-style-trigger").click();
await page.getByTestId("link-preview-style-rich").click();
await page.getByTestId("settings-back-to-app").click();
const richCards = row.locator(
'[data-link-preview="github-pull-request"][data-link-preview-inline]',
);
await expect(richCards).toHaveCount(2);
await expect(
richCards.nth(1).locator("[data-link-preview-image-fallback]"),
).toBeVisible();
});
test("link preview browser image errors render a fallback", async ({
page,
}) => {
await page.goto("/");
await page.getByTestId("channel-general").click();
await page
.getByTestId("message-input")
.fill("https://github.com/block/buzz/pull/4003");
await page.getByTestId("send-message").click();
const row = page.getByTestId("message-row").last();
const compactCard = row.locator('[data-link-preview="github-pull-request"]');
await expect(
compactCard.locator("[data-link-preview-image-fallback]"),
).toBeVisible();
await openSettings(page, "appearance");
await page.getByTestId("link-preview-style-trigger").click();
await page.getByTestId("link-preview-style-rich").click();
await page.getByTestId("settings-back-to-app").click();
const richCard = row.locator(
'[data-link-preview="github-pull-request"][data-link-preview-inline]',
);
await expect(
richCard.locator("[data-link-preview-image-fallback]"),
).toBeVisible();
});
test("supported Compact link previews keep the message link visible with square outer corners", async ({
page,
}) => {

View File

@@ -298,8 +298,23 @@ type MockBridgeOptions = {
description: string | null;
imageDataUrl: string | null;
imageDomain: string | null;
imageFetchState?: "none" | "image" | "transient_failure" | "rejected";
imageRetryAfterMs?: number | null;
faviconDataUrl?: string | null;
} | null;
linkPreviewMetadataByHref?: Record<
string,
{
title: string;
siteName: string | null;
description: string | null;
imageDataUrl: string | null;
imageDomain: string | null;
imageFetchState?: "none" | "image" | "transient_failure" | "rejected";
imageRetryAfterMs?: number | null;
faviconDataUrl?: string | null;
} | null
>;
linkPreviewMetadataDelayMs?: number;
searchProfiles?: MockSearchProfileSeed[];
updateAvailable?: boolean;