diff --git a/desktop/src-tauri/src/commands/link_preview.rs b/desktop/src-tauri/src/commands/link_preview.rs index 5cc9c01d33..1145a670ac 100644 --- a/desktop/src-tauri/src/commands/link_preview.rs +++ b/desktop/src-tauri/src/commands/link_preview.rs @@ -9,6 +9,192 @@ use url::Url; const MAX_TITLE_FETCH_BYTES: usize = 256 * 1024; const TITLE_FETCH_TIMEOUT: Duration = Duration::from_secs(4); +const MAX_METADATA_REDIRECTS: usize = 3; + +#[derive(Debug, Clone, PartialEq, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct LinkPreviewMetadata { + pub href: String, + pub site_name: Option, + pub title: Option, + pub description: Option, + pub image_url: Option, + pub favicon_url: Option, +} + +/// Fetch generic OpenGraph metadata (title/description/image/site name) for an +/// arbitrary http(s) URL. Returns `None` when the page yields no usable title. +#[tauri::command] +pub async fn fetch_link_preview_metadata( + href: String, +) -> Result, String> { + let url = Url::parse(href.trim()).map_err(|error| format!("invalid URL: {error}"))?; + if url.scheme() != "https" && url.scheme() != "http" { + return Ok(None); + } + if url.host_str().is_none() { + return Ok(None); + } + + let client = reqwest::Client::builder() + .redirect(Policy::limited(MAX_METADATA_REDIRECTS)) + .pool_idle_timeout(Duration::from_secs(10)) + .pool_max_idle_per_host(1) + .build() + .map_err(|error| format!("link preview metadata client failed: {error}"))?; + + let request = client + .get(url.as_str()) + .header( + ACCEPT, + "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + ) + .header(USER_AGENT, "Buzz Desktop link preview"); + + let response = tokio::time::timeout(TITLE_FETCH_TIMEOUT, request.send()) + .await + .map_err(|_| "link preview metadata request timed out".to_string())? + .map_err(|error| format!("link preview metadata request failed: {error}"))?; + + if !response.status().is_success() { + return Ok(None); + } + + let is_html = response + .headers() + .get(CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .map(|value| value.to_ascii_lowercase().contains("text/html")) + .unwrap_or(true); + if !is_html { + return Ok(None); + } + + let final_url = response.url().clone(); + let body = read_limited_text(response).await?; + Ok(extract_link_preview_metadata(&final_url, &body)) +} + +fn extract_link_preview_metadata(url: &Url, html: &str) -> Option { + let title = meta_content(html, &["og:title", "twitter:title"]) + .or_else(|| extract_title_tag(html)) + .map(|title| normalize_meta_text(&title, 180)) + .filter(|title| !title.is_empty())?; + + let description = meta_content( + html, + &["og:description", "twitter:description", "description"], + ) + .map(|value| normalize_meta_text(&value, 300)) + .filter(|value| !value.is_empty()); + + let site_name = meta_content(html, &["og:site_name"]) + .map(|value| normalize_meta_text(&value, 80)) + .filter(|value| !value.is_empty()); + + let image_url = meta_content(html, &["og:image", "twitter:image"]) + .and_then(|value| resolve_href(url, value.trim())); + + let favicon_url = extract_favicon_href(html) + .and_then(|value| resolve_href(url, value.trim())) + .or_else(|| resolve_href(url, "/favicon.ico")); + + Some(LinkPreviewMetadata { + href: url.to_string(), + site_name, + title: Some(title), + description, + image_url, + favicon_url, + }) +} + +/// Return the `content` of the first `` tag matching any of `names` +/// (checked against both `property` and `name` attributes), in `names` order. +fn meta_content(html: &str, names: &[&str]) -> Option { + for name in names { + let lower = html.to_ascii_lowercase(); + let mut search_from = 0; + + while let Some(relative_start) = lower[search_from..].find("') else { + break; + }; + let end = start + relative_end + 1; + let tag = &html[start..end]; + + let matches_name = attr_value(tag, "property") + .or_else(|| attr_value(tag, "name")) + .map(|value| value.eq_ignore_ascii_case(name)) + .unwrap_or(false); + + if matches_name { + if let Some(content) = attr_value(tag, "content") { + if !content.trim().is_empty() { + return Some(content); + } + } + } + + search_from = end; + } + } + + None +} + +fn extract_favicon_href(html: &str) -> Option { + let lower = html.to_ascii_lowercase(); + let mut search_from = 0; + + while let Some(relative_start) = lower[search_from..].find("') else { + break; + }; + let end = start + relative_end + 1; + let tag = &html[start..end]; + + let is_icon = attr_value(tag, "rel") + .map(|rel| { + rel.to_ascii_lowercase().split_whitespace().any(|token| { + token == "icon" || token == "shortcut" || token == "apple-touch-icon" + }) + }) + .unwrap_or(false); + + if is_icon { + if let Some(href) = attr_value(tag, "href") { + if !href.trim().is_empty() { + return Some(href); + } + } + } + + search_from = end; + } + + None +} + +fn resolve_href(base: &Url, href: &str) -> Option { + let resolved = base.join(href).ok()?; + if resolved.scheme() != "https" && resolved.scheme() != "http" { + return None; + } + Some(resolved.to_string()) +} + +fn normalize_meta_text(raw: &str, max_chars: usize) -> String { + decode_html_entities(raw) + .split_whitespace() + .collect::>() + .join(" ") + .chars() + .take(max_chars) + .collect() +} #[tauri::command] pub async fn fetch_link_preview_title(href: String) -> Result, String> { @@ -251,9 +437,68 @@ fn decode_html_entities(value: &str) -> String { #[cfg(test)] mod tests { - use super::{extract_google_title, is_supported_google_link}; + use super::{extract_google_title, extract_link_preview_metadata, is_supported_google_link}; use url::Url; + #[test] + fn metadata_extracts_open_graph_fields() { + let html = r#" + + + + + + + + Fallback title + + + "#; + let url = Url::parse("https://news.example.com/story/1").unwrap(); + + let metadata = extract_link_preview_metadata(&url, html).unwrap(); + assert_eq!(metadata.site_name.as_deref(), Some("Example News")); + assert_eq!(metadata.title.as_deref(), Some("Big & important story")); + assert_eq!( + metadata.description.as_deref(), + Some("A story about things.") + ); + assert_eq!( + metadata.image_url.as_deref(), + Some("https://news.example.com/images/story.png") + ); + assert_eq!( + metadata.favicon_url.as_deref(), + Some("https://news.example.com/favicon.svg") + ); + } + + #[test] + fn metadata_falls_back_to_title_tag_and_default_favicon() { + let html = "Plain page"; + let url = Url::parse("https://example.com/page").unwrap(); + + let metadata = extract_link_preview_metadata(&url, html).unwrap(); + assert_eq!(metadata.title.as_deref(), Some("Plain page")); + assert_eq!(metadata.site_name, None); + assert_eq!(metadata.description, None); + assert_eq!(metadata.image_url, None); + assert_eq!( + metadata.favicon_url.as_deref(), + Some("https://example.com/favicon.ico") + ); + } + + #[test] + fn metadata_requires_a_title() { + let url = Url::parse("https://example.com/").unwrap(); + assert_eq!(extract_link_preview_metadata(&url, ""), None); + assert_eq!( + extract_link_preview_metadata(&url, " "), + None + ); + } + #[test] fn title_prefers_open_graph_title() { let html = r#" diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index c005d511e6..63665d4881 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -705,6 +705,7 @@ pub fn run() { get_relay_ws_url, get_relay_http_url, get_media_proxy_port, + fetch_link_preview_metadata, fetch_link_preview_title, discover_acp_auth_methods, discover_acp_providers, diff --git a/desktop/src-tauri/src/linux_media.rs b/desktop/src-tauri/src/linux_media.rs index c768e15422..5fda0ec511 100644 --- a/desktop/src-tauri/src/linux_media.rs +++ b/desktop/src-tauri/src/linux_media.rs @@ -22,17 +22,19 @@ //! which is the backend WebKitGTK media capture is reliable on. /// The origin Tauri serves the packaged app from on Linux. +#[cfg(any(target_os = "linux", test))] const PROD_ORIGIN: &str = "tauri://localhost"; /// The Vite dev-server origin (`devUrl` in `tauri.conf.json`, `strictPort` /// 1420 in `vite.config.ts`). Only trusted in debug builds. -#[cfg(debug_assertions)] +#[cfg(all(debug_assertions, any(target_os = "linux", test)))] const DEV_ORIGIN: &str = "http://localhost:1420"; /// Whether `uri` (the webview's current document URI) is a trusted app origin /// allowed to use mic/camera. Matches the origin exactly or as a path prefix so /// `tauri://localhost.evil.com` and `http://localhost:14200` do not slip /// through. Pure and platform-independent so it can be unit-tested everywhere. +#[cfg(any(target_os = "linux", test))] fn is_trusted_media_origin(uri: &str) -> bool { fn matches(uri: &str, origin: &str) -> bool { uri == origin diff --git a/desktop/src/shared/lib/linkPreview.test.mjs b/desktop/src/shared/lib/linkPreview.test.mjs index a56b35f54a..6a2b229cda 100644 --- a/desktop/src/shared/lib/linkPreview.test.mjs +++ b/desktop/src/shared/lib/linkPreview.test.mjs @@ -2,6 +2,7 @@ import assert from "node:assert/strict"; import test from "node:test"; import { + extractGenericLinkPreviewUrls, extractSupportedLinkPreviews, isSupportedLinkAutolinkLabel, parseSupportedLinkPreview, @@ -252,3 +253,61 @@ test("isSupportedLinkAutolinkLabel matches normalized bare URL labels", () => { ); assert.equal(isSupportedLinkAutolinkLabel("review this", preview), false); }); + +test("extractGenericLinkPreviewUrls extracts non-provider https URLs", () => { + assert.deepEqual( + extractGenericLinkPreviewUrls( + "Check https://example.com/article and https://blog.rust-lang.org/2024/post/", + ), + ["https://example.com/article", "https://blog.rust-lang.org/2024/post/"], + ); +}); + +test("extractGenericLinkPreviewUrls skips provider URLs covered by compact cards", () => { + assert.deepEqual( + extractGenericLinkPreviewUrls( + "See https://github.com/block/sprout/pull/1 and https://example.com/x", + ), + ["https://example.com/x"], + ); +}); + +test("extractGenericLinkPreviewUrls skips media URLs", () => { + assert.deepEqual( + extractGenericLinkPreviewUrls( + "https://example.com/pic.png https://example.com/clip.mp4?t=1 https://example.com/page", + ), + ["https://example.com/page"], + ); +}); + +test("extractGenericLinkPreviewUrls skips code blocks and dedupes", () => { + assert.deepEqual( + extractGenericLinkPreviewUrls( + [ + "`https://example.com/inline-code`", + "```", + "https://example.com/fenced", + "```", + "https://example.com/page https://example.com/page", + ].join("\n"), + ), + ["https://example.com/page"], + ); +}); + +test("extractGenericLinkPreviewUrls trims trailing punctuation and caps at three", () => { + assert.deepEqual( + extractGenericLinkPreviewUrls( + "https://a.example/1. https://b.example/2, https://c.example/3! https://d.example/4", + ), + ["https://a.example/1", "https://b.example/2", "https://c.example/3"], + ); +}); + +test("extractGenericLinkPreviewUrls extracts markdown link targets", () => { + assert.deepEqual( + extractGenericLinkPreviewUrls("[the docs](https://example.com/docs)"), + ["https://example.com/docs"], + ); +}); diff --git a/desktop/src/shared/lib/linkPreview.ts b/desktop/src/shared/lib/linkPreview.ts index 5cba96da87..94ab157df2 100644 --- a/desktop/src/shared/lib/linkPreview.ts +++ b/desktop/src/shared/lib/linkPreview.ts @@ -37,6 +37,13 @@ const MARKDOWN_SUPPORTED_LINK_RE = /!?\[([^\]\n]+)\]\(((?:https?:\/\/)?(?:(?:www\.)?github\.com|(?:www\.)?linear\.app|drive\.google\.com|docs\.google\.com)\/[^)\s<>"']+)\)/gi; const MAX_PREVIEWS = 8; +const GENERIC_URL_RE = /(^|[\s([{<>"'])(https?:\/\/[^\s<>"'\]]+)/gi; +const MARKDOWN_GENERIC_LINK_RE = /!?\[[^\]\n]*\]\((https?:\/\/[^)\s<>"']+)\)/gi; +// Image/video URLs render inline as media, never as preview cards. +const MEDIA_URL_RE = + /\.(?:png|jpe?g|gif|webp|avif|svg|mp4|webm|mov)(?:[?#]\S*)?$/i; +const MAX_GENERIC_PREVIEWS = 3; + type HiddenRange = { start: number; end: number; @@ -524,3 +531,79 @@ export function extractSupportedLinkPreviews( return previews; } + +/** Rich OpenGraph-style metadata for an arbitrary external URL. */ +export type RichLinkPreviewMetadata = { + href: string; + siteName: string | null; + title: string | null; + description: string | null; + imageUrl: string | null; + faviconUrl: string | null; +}; + +function normalizeGenericHref(href: string): string | null { + let parsed: URL; + try { + parsed = new URL(trimUrlCandidate(href)); + } catch { + return null; + } + + if (parsed.protocol !== "https:" && parsed.protocol !== "http:") { + return null; + } + if (MEDIA_URL_RE.test(parsed.pathname)) { + return null; + } + // Provider-specific compact cards already cover these hosts. + if (parseSupportedLinkPreview(parsed.href)) { + return null; + } + + return parsed.href; +} + +/** + * Extract generic (non-provider) http(s) URLs eligible for a rich preview + * card, preserving first-seen order. Media URLs and URLs already covered by + * `extractSupportedLinkPreviews` are excluded. + */ +export function extractGenericLinkPreviewUrls(content: string): string[] { + const urls: string[] = []; + const seen = new Set(); + const searchable = stripHiddenLinkPreviewContent(content); + const candidates: LinkPreviewCandidate[] = []; + let order = 0; + + for (const match of searchable.matchAll(MARKDOWN_GENERIC_LINK_RE)) { + if (match[0]?.startsWith("!")) continue; + candidates.push({ href: match[1], index: match.index ?? 0, order }); + order += 1; + } + + for (const match of searchable.matchAll(GENERIC_URL_RE)) { + const prefix = match[1] ?? ""; + const href = match[2]; + if (!href) continue; + candidates.push({ + href, + index: (match.index ?? 0) + prefix.length, + order, + }); + order += 1; + } + + candidates.sort((a, b) => a.index - b.index || a.order - b.order); + + for (const candidate of candidates) { + const href = normalizeGenericHref(candidate.href); + if (!href || seen.has(href)) continue; + + seen.add(href); + urls.push(href); + if (urls.length >= MAX_GENERIC_PREVIEWS) break; + } + + return urls; +} diff --git a/desktop/src/shared/lib/useRichLinkPreviews.ts b/desktop/src/shared/lib/useRichLinkPreviews.ts new file mode 100644 index 0000000000..20449c83a3 --- /dev/null +++ b/desktop/src/shared/lib/useRichLinkPreviews.ts @@ -0,0 +1,92 @@ +import * as React from "react"; + +import { invokeTauri } from "@/shared/api/tauri"; + +import type { RichLinkPreviewMetadata } from "./linkPreview"; + +type CacheEntry = + | Promise + | RichLinkPreviewMetadata + | null; + +const metadataCache = new Map(); + +function fetchMetadata(href: string): Promise { + return invokeTauri( + "fetch_link_preview_metadata", + { href }, + ); +} + +function cacheMetadata(href: string): Promise { + const cached = metadataCache.get(href); + if (cached instanceof Promise) return cached; + if (cached !== undefined) return Promise.resolve(cached); + + const promise = fetchMetadata(href) + .then((metadata) => { + metadataCache.set(href, metadata); + return metadata; + }) + .catch(() => { + metadataCache.set(href, null); + return null; + }); + metadataCache.set(href, promise); + return promise; +} + +function isRenderable(metadata: RichLinkPreviewMetadata | null): boolean { + return metadata !== null && (metadata.title ?? "").trim() !== ""; +} + +/** + * Resolve rich OpenGraph metadata for generic message URLs. Only URLs whose + * fetch produced a usable title are returned, so cards never render empty. + */ +export function useRichLinkPreviews(urls: string[]): RichLinkPreviewMetadata[] { + const [resolved, setResolved] = React.useState< + Record + >({}); + + React.useEffect(() => { + if (urls.length === 0) return undefined; + let cancelled = false; + + for (const href of urls) { + const cached = metadataCache.get(href); + if (cached !== undefined && !(cached instanceof Promise)) { + if (isRenderable(cached)) { + const metadata = cached as RichLinkPreviewMetadata; + setResolved((current) => + current[href] === metadata + ? current + : { ...current, [href]: metadata }, + ); + } + continue; + } + + void cacheMetadata(href).then((metadata) => { + if (cancelled || !isRenderable(metadata)) return; + const value = metadata as RichLinkPreviewMetadata; + setResolved((current) => + current[href] === value ? current : { ...current, [href]: value }, + ); + }); + } + + return () => { + cancelled = true; + }; + }, [urls]); + + return React.useMemo( + () => + urls.flatMap((href) => { + const metadata = resolved[href]; + return metadata ? [metadata] : []; + }), + [urls, resolved], + ); +} diff --git a/desktop/src/shared/ui/link-preview-attachment.tsx b/desktop/src/shared/ui/link-preview-attachment.tsx index 5d6b80767a..3633c0c589 100644 --- a/desktop/src/shared/ui/link-preview-attachment.tsx +++ b/desktop/src/shared/ui/link-preview-attachment.tsx @@ -1,15 +1,22 @@ +import * as React from "react"; import { ExternalLink } from "lucide-react"; -import type { SupportedLinkPreview } from "@/shared/lib/linkPreview"; +import { + extractSupportedLinkPreviews, + type SupportedLinkPreview, +} from "@/shared/lib/linkPreview"; +import { useResolvedLinkPreviews } from "@/shared/lib/useResolvedLinkPreviews"; import { cn } from "@/shared/lib/cn"; import { Attachment, AttachmentActions, AttachmentContent, + AttachmentGroup, AttachmentMedia, AttachmentTitle, AttachmentTrigger, } from "@/shared/ui/attachment"; +import { RichLinkPreviews } from "@/shared/ui/rich-link-preview-card"; function LinearLogo({ className }: { className?: string }) { return ( @@ -156,3 +163,31 @@ export function LinkPreviewAttachment({ ); } + +/** + * All link preview cards for a message body: compact provider cards + * (GitHub/Linear/Google) followed by rich OpenGraph cards for generic URLs. + */ +export function MessageLinkPreviews({ content }: { content: string }) { + const linkPreviews = React.useMemo( + () => extractSupportedLinkPreviews(content), + [content], + ); + const resolvedLinkPreviews = useResolvedLinkPreviews(linkPreviews); + + return ( + <> + {resolvedLinkPreviews.length > 0 ? ( + + {resolvedLinkPreviews.map((preview) => ( + + ))} + + ) : null} + + + ); +} diff --git a/desktop/src/shared/ui/markdown.tsx b/desktop/src/shared/ui/markdown.tsx index b1f9623f3e..081ec5d27e 100644 --- a/desktop/src/shared/ui/markdown.tsx +++ b/desktop/src/shared/ui/markdown.tsx @@ -24,15 +24,11 @@ import { invokeTauri } from "@/shared/api/tauri"; import { useChannelNavigation } from "@/shared/context/ChannelNavigationContext"; import { cn } from "@/shared/lib/cn"; import { copyTextToClipboard } from "@/shared/lib/clipboard"; -import { - extractSupportedLinkPreviews, - parseSupportedLinkPreview, -} from "@/shared/lib/linkPreview"; -import { useResolvedLinkPreviews } from "@/shared/lib/useResolvedLinkPreviews"; +import { parseSupportedLinkPreview } from "@/shared/lib/linkPreview"; import { rewriteRelayUrl } from "@/shared/lib/mediaUrl"; import { AttachmentGroup } from "@/shared/ui/attachment"; import { ConfigNudgeCard } from "@/shared/ui/config-nudge-attachment"; -import { LinkPreviewAttachment } from "@/shared/ui/link-preview-attachment"; +import { MessageLinkPreviews } from "@/shared/ui/link-preview-attachment"; import { useSmoothCorners } from "@/shared/ui/smoothCorners"; import { computeConfigNudge, @@ -1868,10 +1864,6 @@ function MarkdownInner({ }, [goChannel], ); - const linkPreviews = React.useMemo( - () => (interactive ? extractSupportedLinkPreviews(content) : []), - [content, interactive], - ); const configNudge = React.useMemo( () => computeConfigNudge(content, interactive, configNudgeAuthorPubkey), [content, interactive, configNudgeAuthorPubkey], @@ -1921,8 +1913,6 @@ function MarkdownInner({ processedContent = `${processedContent}\u200B`; } - const resolvedLinkPreviews = useResolvedLinkPreviews(linkPreviews); - // When a config-nudge suppresses the prose (selectProseOrNudge returns // null), skip the parse entirely — it would be thrown away unrendered. const componentSet = getMarkdownComponents(interactive, mediaInset); @@ -1971,16 +1961,7 @@ function MarkdownInner({ ) : null} - {resolvedLinkPreviews.length > 0 ? ( - - {resolvedLinkPreviews.map((preview) => ( - - ))} - - ) : null} + {interactive ? : null} diff --git a/desktop/src/shared/ui/rich-link-preview-card.tsx b/desktop/src/shared/ui/rich-link-preview-card.tsx new file mode 100644 index 0000000000..1e930febb0 --- /dev/null +++ b/desktop/src/shared/ui/rich-link-preview-card.tsx @@ -0,0 +1,128 @@ +import * as React from "react"; +import { Globe } from "lucide-react"; + +import { + extractGenericLinkPreviewUrls, + type RichLinkPreviewMetadata, +} from "@/shared/lib/linkPreview"; +import { useRichLinkPreviews } from "@/shared/lib/useRichLinkPreviews"; +import { cn } from "@/shared/lib/cn"; +import { AttachmentGroup } from "@/shared/ui/attachment"; +import { useSmoothCorners } from "@/shared/ui/smoothCorners"; + +function hostnameOf(href: string): string { + try { + return new URL(href).hostname.replace(/^www\./, ""); + } catch { + return href; + } +} + +function Favicon({ metadata }: { metadata: RichLinkPreviewMetadata }) { + const [failed, setFailed] = React.useState(false); + + if (!metadata.faviconUrl || failed) { + return