feat(desktop): refine rich link preview presentation

- Update rich preview cards with wider layouts, compact image sizing, and unified content expansion controls
- Allow link titles to span two lines and preserve full expanded descriptions
- Raise fetched description metadata to the standard 280-character X post limit
- Preserve encoded description line breaks while normalizing intra-line whitespace
- Add metadata tests covering line breaks and description length limits

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:06 -07:00
parent 53dc813ecb
commit e7805b8c18
2 changed files with 113 additions and 24 deletions
+46 -1
View File
@@ -20,6 +20,7 @@ const PREVIEW_FETCH_TIMEOUT: Duration = Duration::from_secs(4);
const PREVIEW_TOTAL_TIMEOUT: Duration = Duration::from_secs(10);
const MAX_REDIRECTS: usize = 3;
const MAX_METADATA_CHARS: usize = 180;
const MAX_METADATA_DESCRIPTION_CHARS: usize = 280;
#[derive(Debug, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
@@ -402,7 +403,7 @@ fn extract_link_preview_metadata(html: &str) -> Option<LinkPreviewMetadata> {
.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));
.and_then(|value| normalize_metadata_description(&value));
Some(LinkPreviewMetadata {
title,
@@ -503,6 +504,27 @@ fn normalize_metadata_text(raw: &str) -> Option<String> {
Some(normalized.chars().take(MAX_METADATA_CHARS).collect())
}
fn normalize_metadata_description(raw: &str) -> Option<String> {
let decoded = decode_html_entities(raw)
.replace("\r\n", "\n")
.replace('\r', "\n");
let normalized = decoded
.split('\n')
.map(|line| line.split_whitespace().collect::<Vec<_>>().join(" "))
.collect::<Vec<_>>()
.join("\n");
let normalized = normalized.trim();
if normalized.is_empty() {
return None;
}
Some(
normalized
.chars()
.take(MAX_METADATA_DESCRIPTION_CHARS)
.collect(),
)
}
fn decode_html_entities(value: &str) -> String {
let mut decoded = value
.replace("&amp;", "&")
@@ -537,6 +559,7 @@ 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,
};
use axum::{body::Body, http::Response, routing::get, Router};
use base64::Engine as _;
@@ -590,6 +613,28 @@ mod tests {
);
}
#[test]
fn metadata_preserves_description_line_breaks() {
let html = r#"<meta property="og:title" content="Tweet title">
<meta property="og:description" content="First paragraph.&#10;&#10;Agents:&#10;- One&#10;- Two">"#;
assert_eq!(
extract_link_preview_metadata(html).and_then(|metadata| metadata.description),
Some("First paragraph.\n\nAgents:\n- One\n- Two".to_string())
);
}
#[test]
fn metadata_description_supports_standard_x_posts() {
let description = "x".repeat(MAX_METADATA_DESCRIPTION_CHARS + 1);
let html = format!(
r#"<meta property="og:title" content="Long post"><meta property="og:description" content="{description}">"#
);
let extracted = extract_link_preview_metadata(&html)
.and_then(|metadata| metadata.description)
.unwrap();
assert_eq!(extracted.chars().count(), MAX_METADATA_DESCRIPTION_CHARS);
}
#[test]
fn favicon_metadata_resolves_relative_icon_links() {
let page = Url::parse("https://example.com/articles/one").unwrap();
@@ -4,6 +4,7 @@ import { useState } from "react";
import type { ResolvedLinkPreview } from "@/shared/lib/useResolvedLinkPreviews";
import { cn } from "@/shared/lib/cn";
import { Button } from "@/shared/ui/button";
import { SimpleImageLightbox } from "@/shared/ui/SimpleImageLightbox";
function LinkPreviewIdentity({ preview }: { preview: ResolvedLinkPreview }) {
if (preview.faviconDataUrl) {
@@ -40,6 +41,67 @@ function isTweetPreview(preview: ResolvedLinkPreview): boolean {
}
}
function LinkPreviewImage({
aspectClassName,
className,
preview,
}: {
aspectClassName: string;
className?: string;
preview: ResolvedLinkPreview;
}) {
const [lightboxOpen, setLightboxOpen] = useState(false);
const imageSrc =
preview.imageState === "image" ? preview.imageDataUrl : undefined;
const alt = `Preview from ${preview.imageDomain}`;
if (!imageSrc) {
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=""
/>
</div>
);
}
return (
<>
<button
aria-label={`Open ${alt.toLowerCase()} in lightbox`}
className={cn(
"block cursor-zoom-in overflow-hidden rounded-xl bg-muted",
className,
)}
data-link-preview-thumbnail=""
onClick={() => setLightboxOpen(true)}
type="button"
>
<div className={cn("w-full", aspectClassName)}>
<img
alt={alt}
className="h-full w-full object-cover"
src={imageSrc}
/>
</div>
</button>
<SimpleImageLightbox
alt={alt}
onOpenChange={setLightboxOpen}
open={lightboxOpen}
src={imageSrc}
/>
</>
);
}
function TweetPreview({
className,
onRemove,
@@ -51,7 +113,6 @@ function TweetPreview({
}) {
const [contentExpanded, setContentExpanded] = useState(true);
const reserveImage = preview.imageState !== "none";
const showImage = preview.imageState === "image";
const hasExpandableContent = Boolean(preview.description) || reserveImage;
const hostname = getHostname(preview);
@@ -88,28 +149,11 @@ function TweetPreview({
</div>
) : null}
{contentExpanded && reserveImage ? (
<a
aria-label={`Open tweet: ${preview.title}`}
className="mt-2 block w-full max-w-75 overflow-hidden rounded-xl bg-muted"
href={preview.href}
rel="noreferrer"
target="_blank"
>
<div className="aspect-video w-full" data-link-preview-thumbnail="">
{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=""
/>
)}
</div>
</a>
<LinkPreviewImage
aspectClassName="aspect-video"
className="mt-2 w-full max-w-75"
preview={preview}
/>
) : null}
{hasExpandableContent ? (
<button