diff --git a/desktop/src-tauri/src/commands/link_preview.rs b/desktop/src-tauri/src/commands/link_preview.rs index 3ebf7fe60..d63d14da0 100644 --- a/desktop/src-tauri/src/commands/link_preview.rs +++ b/desktop/src-tauri/src/commands/link_preview.rs @@ -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 { .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 { Some(normalized.chars().take(MAX_METADATA_CHARS).collect()) } +fn normalize_metadata_description(raw: &str) -> Option { + let decoded = decode_html_entities(raw) + .replace("\r\n", "\n") + .replace('\r', "\n"); + let normalized = decoded + .split('\n') + .map(|line| line.split_whitespace().collect::>().join(" ")) + .collect::>() + .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#" + "#; + 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#""# + ); + 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(); diff --git a/desktop/src/shared/ui/rich-link-preview-attachment.tsx b/desktop/src/shared/ui/rich-link-preview-attachment.tsx index 692129050..c9937c557 100644 --- a/desktop/src/shared/ui/rich-link-preview-attachment.tsx +++ b/desktop/src/shared/ui/rich-link-preview-attachment.tsx @@ -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 ( +
+
+
+ ); + } + + return ( + <> + + + + ); +} + 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({
) : null} {contentExpanded && reserveImage ? ( - -
- {showImage ? ( - {`Preview - ) : ( -
- )} -
-
+ ) : null} {hasExpandableContent ? (