mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
@@ -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("&", "&")
|
||||
@@ -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. Agents: - One - 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
|
||||
|
||||
Reference in New Issue
Block a user