diff --git a/desktop/src-tauri/src/commands/link_preview.rs b/desktop/src-tauri/src/commands/link_preview.rs index 5cc9c01d33..5ada1b3884 100644 --- a/desktop/src-tauri/src/commands/link_preview.rs +++ b/desktop/src-tauri/src/commands/link_preview.rs @@ -1,116 +1,513 @@ -use std::time::Duration; +use std::{io::Cursor, net::IpAddr, time::Duration}; + +use base64::{engine::general_purpose::STANDARD as BASE64_STANDARD, Engine as _}; +use image::ImageDecoder; use futures_util::StreamExt; use reqwest::{ - header::{ACCEPT, CONTENT_TYPE, USER_AGENT}, + header::{ACCEPT, CONTENT_TYPE, LOCATION, USER_AGENT}, redirect::Policy, }; +use serde::Serialize; use url::Url; -const MAX_TITLE_FETCH_BYTES: usize = 256 * 1024; -const TITLE_FETCH_TIMEOUT: Duration = Duration::from_secs(4); +#[path = "link_preview_rate_limit.rs"] +mod rate_limit; + +use rate_limit::{image_host_cooldown_remaining, retry_after_duration, set_image_host_cooldown}; + +const MAX_PREVIEW_FETCH_BYTES: usize = 256 * 1024; +const MAX_IMAGE_FETCH_BYTES: usize = 2 * 1024 * 1024; +const MAX_IMAGE_DIMENSION: u32 = 4096; +const MAX_IMAGE_PIXELS: u64 = 16_000_000; +const MAX_SANITIZED_DIMENSION: u32 = 1200; +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(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 { + title: String, + site_name: Option, + description: Option, + image_data_url: Option, + image_domain: Option, + image_fetch_state: LinkPreviewImageFetchState, + image_retry_after_ms: Option, + favicon_data_url: Option, +} #[tauri::command] -pub async fn fetch_link_preview_title(href: String) -> Result, String> { - let url = Url::parse(href.trim()).map_err(|error| format!("invalid URL: {error}"))?; - if !is_supported_google_link(&url) { - return Ok(None); +pub async fn fetch_link_preview_metadata( + href: String, +) -> Result, String> { + tokio::time::timeout( + PREVIEW_TOTAL_TIMEOUT, + fetch_link_preview_metadata_inner(href), + ) + .await + .map_err(|_| "link preview request timed out".to_string())? +} + +async fn fetch_link_preview_metadata_inner( + href: String, +) -> Result, String> { + let mut url = Url::parse(href.trim()).map_err(|error| format!("invalid URL: {error}"))?; + validate_public_https_url(&url).await?; + + for redirect_count in 0..=MAX_REDIRECTS { + let response = send_pinned_request(&url, "text/html,application/xhtml+xml;q=0.9").await?; + + if response.status().is_redirection() { + if redirect_count == MAX_REDIRECTS { + return Ok(None); + } + let Some(location) = response.headers().get(LOCATION) else { + return Ok(None); + }; + let location = location + .to_str() + .map_err(|_| "link preview redirect has an invalid location".to_string())?; + url = url + .join(location) + .map_err(|error| format!("invalid link preview redirect: {error}"))?; + validate_public_https_url(&url).await?; + continue; + } + + if !response.status().is_success() || !is_html_response(&response) { + return Ok(None); + } + let body = read_bytes_prefix(response, MAX_PREVIEW_FETCH_BYTES).await?; + let body = String::from_utf8_lossy(&body); + let Some(mut metadata) = extract_link_preview_metadata(&body) else { + return Ok(None); + }; + 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)); } + Ok(None) +} + +fn apply_image_result( + metadata: &mut LinkPreviewMetadata, + image_result: Option>, +) { + 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()); + } + if url.port().is_some_and(|port| port != 443) { + return Err("link previews require the default HTTPS port".to_string()); + } + + let host = url + .host_str() + .ok_or_else(|| "link preview URL has no host".to_string())?; + resolve_public_addresses(host).await.map(|_| ()) +} + +async fn resolve_public_addresses(host: &str) -> Result, String> { + let host = host.to_string(); + let addresses = tokio::net::lookup_host((host.as_str(), 443)) + .await + .map_err(|error| format!("link preview DNS resolution failed: {error}"))? + .map(|address| address.ip()) + .collect::>(); + + if addresses.is_empty() { + return Err("link preview DNS resolution returned no addresses".to_string()); + } + if addresses.iter().any(buzz_core_pkg::network::is_private_ip) { + return Err("link preview host resolved to a private or reserved address".to_string()); + } + + Ok(addresses) +} + +async fn send_pinned_request(url: &Url, accept: &str) -> Result { + let host = url + .host_str() + .ok_or_else(|| "link preview URL has no host".to_string())?; + let addresses = resolve_public_addresses(host).await?; + let socket_addresses = addresses + .into_iter() + .map(|address| std::net::SocketAddr::new(address, 443)) + .collect::>(); let client = reqwest::Client::builder() + .no_proxy() .redirect(Policy::none()) - .pool_idle_timeout(Duration::from_secs(10)) - .pool_max_idle_per_host(1) + .pool_max_idle_per_host(0) + .resolve_to_addrs(host, &socket_addresses) .build() - .map_err(|error| format!("link preview title client failed: {error}"))?; - + .map_err(|error| format!("link preview 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(ACCEPT, accept) .header(USER_AGENT, "Buzz Desktop link preview"); - let response = tokio::time::timeout(TITLE_FETCH_TIMEOUT, request.send()) + tokio::time::timeout(PREVIEW_FETCH_TIMEOUT, request.send()) .await - .map_err(|_| "link preview title request timed out".to_string())? - .map_err(|error| format!("link preview title request failed: {error}"))?; - - if !response.status().is_success() { - return Ok(None); - } + .map_err(|_| "link preview request timed out".to_string())? + .map_err(|error| format!("link preview request failed: {error}")) +} - let is_html = response +fn is_html_response(response: &reqwest::Response) -> bool { + 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 body = read_limited_text(response).await?; - Ok(extract_google_title(&body)) + .map(|value| { + let mime = value.split(';').next().unwrap_or_default().trim(); + mime.eq_ignore_ascii_case("text/html") + || mime.eq_ignore_ascii_case("application/xhtml+xml") + }) + .unwrap_or(false) } -fn is_supported_google_link(url: &Url) -> bool { - if url.scheme() != "https" { - return false; - } +async fn read_bytes_prefix(response: reqwest::Response, limit: usize) -> Result, String> { + let mut stream = response.bytes_stream(); + let mut bytes = Vec::with_capacity(limit); - let Some(host) = url.host_str().map(|host| host.to_ascii_lowercase()) else { - return false; - }; - let segments = url - .path_segments() - .map(|segments| segments.collect::>()) - .unwrap_or_default(); - - match host.trim_start_matches("www.") { - "docs.google.com" => { - matches!( - segments.as_slice(), - ["document", "d", _, ..] - | ["spreadsheets", "d", _, ..] - | ["presentation", "d", _, ..] - ) - } - "drive.google.com" => { - matches!(segments.as_slice(), ["file", "d", _, ..]) - || matches!(segments.as_slice(), ["drive", "folders", _, ..]) - || (segments.first() == Some(&"open") - && url.query_pairs().any(|(key, _)| key == "id")) - } - _ => false, + while bytes.len() < limit { + let Some(chunk) = stream.next().await else { + break; + }; + let chunk = chunk.map_err(|error| format!("reading link preview failed: {error}"))?; + let remaining = limit - bytes.len(); + bytes.extend_from_slice(&chunk[..chunk.len().min(remaining)]); } + Ok(bytes) } -async fn read_limited_text(response: reqwest::Response) -> Result { +async fn read_limited_bytes(response: reqwest::Response, limit: usize) -> Result, String> { let mut stream = response.bytes_stream(); let mut bytes = Vec::new(); while let Some(chunk) = stream.next().await { - let chunk = chunk.map_err(|error| format!("reading title response failed: {error}"))?; - if bytes.len() + chunk.len() > MAX_TITLE_FETCH_BYTES { - let remaining = MAX_TITLE_FETCH_BYTES.saturating_sub(bytes.len()); - bytes.extend_from_slice(&chunk[..remaining]); - break; + let chunk = chunk.map_err(|error| format!("reading link preview failed: {error}"))?; + if bytes.len().saturating_add(chunk.len()) > limit { + return Err("link preview response exceeded the size limit".to_string()); } bytes.extend_from_slice(&chunk); } + Ok(bytes) +} + +fn extract_favicon_url(html: &str, page_url: &Url) -> Option { + let lower = html.to_ascii_lowercase(); + let mut search_from = 0; + let mut fallback = None; + + while let Some(relative_start) = lower[search_from..].find("') else { + break; + }; + let end = start + relative_end + 1; + let tag = &html[start..end]; + let rel = attr_value(tag, "rel"); + let is_icon = rel.as_ref().is_some_and(|value| { + value.split_ascii_whitespace().any(|token| { + token.eq_ignore_ascii_case("icon") || token.eq_ignore_ascii_case("apple-touch-icon") + }) + }); + if is_icon { + if let Some(href) = attr_value(tag, "href") { + if let Ok(url) = page_url.join(href.trim()) { + let declared_type = attr_value(tag, "type"); + let is_supported_raster = declared_type.as_ref().is_some_and(|value| { + matches!( + value.to_ascii_lowercase().as_str(), + "image/jpeg" | "image/png" | "image/webp" + ) + }) || matches!( + url.path() + .rsplit_once('.') + .map(|(_, extension)| extension.to_ascii_lowercase()) + .as_deref(), + Some("jpg" | "jpeg" | "png" | "webp") + ); + if is_supported_raster { + return Some(url); + } + fallback.get_or_insert(url); + } + } + } + search_from = end; + } + + fallback +} + +fn extract_image_url(html: &str, page_url: &Url) -> Option { + let raw = extract_meta_content(html, "property", "og:image") + .or_else(|| extract_meta_content(html, "property", "og:image:secure_url")) + .or_else(|| extract_meta_content(html, "name", "twitter:image"))?; + page_url.join(raw.trim()).ok() +} + +#[derive(Debug, PartialEq)] +enum ImageFetchError { + Transient { retry_after: Option }, + Rejected, +} + +async fn fetch_sanitized_image( + mut url: Url, + preserve_transparency: bool, +) -> Result<(String, String), ImageFetchError> { + validate_public_https_url(&url) + .await + .map_err(|_| ImageFetchError::Rejected)?; + for redirect_count in 0..=MAX_REDIRECTS { + 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(ImageFetchError::Rejected); + } + let location = response + .headers() + .get(LOCATION) + .and_then(|value| value.to_str().ok()) + .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() { + 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() + .get(CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .map(|value| { + value + .split(';') + .next() + .unwrap_or_default() + .trim() + .to_ascii_lowercase() + }) + .ok_or(ImageFetchError::Rejected)?; + if !matches!( + declared_mime.as_str(), + "image/jpeg" | "image/png" | "image/webp" + ) { + return Err(ImageFetchError::Rejected); + } + if response + .content_length() + .is_some_and(|size| size > MAX_IMAGE_FETCH_BYTES as u64) + { + return Err(ImageFetchError::Rejected); + } + 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(|_| ImageFetchError::Rejected)? + .map_err(|_| ImageFetchError::Rejected)?; + let domain = url.host_str().unwrap_or_default().to_string(); + return Ok((data_url, domain)); + } + Err(ImageFetchError::Rejected) +} + +fn sanitize_image( + bytes: &[u8], + declared_mime: &str, + preserve_transparency: bool, +) -> Result { + let sniffed = infer::get(bytes) + .map(|kind| kind.mime_type()) + .ok_or_else(|| "link preview image magic bytes are unsupported".to_string())?; + if sniffed != declared_mime { + return Err("link preview image content type does not match its bytes".to_string()); + } + let format = match sniffed { + "image/jpeg" => image::ImageFormat::Jpeg, + "image/png" => image::ImageFormat::Png, + "image/webp" => image::ImageFormat::WebP, + _ => return Err("link preview image type is unsupported".to_string()), + }; + if declares_animation(bytes, format) { + return Err("animated link preview images are unsupported".to_string()); + } + + let reader = image::ImageReader::with_format(Cursor::new(bytes), format); + let mut decoder = reader + .into_decoder() + .map_err(|_| "link preview image is malformed".to_string())?; + let (width, height) = decoder.dimensions(); + if width == 0 + || height == 0 + || width > MAX_IMAGE_DIMENSION + || height > MAX_IMAGE_DIMENSION + || u64::from(width) * u64::from(height) > MAX_IMAGE_PIXELS + { + return Err("link preview image dimensions exceed safe limits".to_string()); + } + let mut limits = image::Limits::default(); + limits.max_image_width = Some(MAX_IMAGE_DIMENSION); + limits.max_image_height = Some(MAX_IMAGE_DIMENSION); + limits.max_alloc = Some(MAX_IMAGE_PIXELS * 4); + decoder + .set_limits(limits) + .map_err(|_| "link preview image exceeds safe decoding limits".to_string())?; + let orientation = decoder + .orientation() + .unwrap_or(image::metadata::Orientation::NoTransforms); + let mut decoded = image::DynamicImage::from_decoder(decoder) + .map_err(|_| "link preview image could not be decoded".to_string())?; + decoded.apply_orientation(orientation); + let decoded = decoded.thumbnail(MAX_SANITIZED_DIMENSION, MAX_SANITIZED_DIMENSION); + let mut output = Vec::new(); + if preserve_transparency && decoded.color().has_alpha() { + decoded + .write_to(&mut Cursor::new(&mut output), image::ImageFormat::Png) + .map_err(|_| "link preview image could not be sanitized".to_string())?; + return Ok(format!( + "data:image/png;base64,{}", + BASE64_STANDARD.encode(output) + )); + } + image::codecs::jpeg::JpegEncoder::new_with_quality(&mut output, 82) + .encode_image(&decoded) + .map_err(|_| "link preview image could not be sanitized".to_string())?; + Ok(format!( + "data:image/jpeg;base64,{}", + BASE64_STANDARD.encode(output) + )) +} - Ok(String::from_utf8_lossy(&bytes).into_owned()) +fn declares_animation(bytes: &[u8], format: image::ImageFormat) -> bool { + match format { + image::ImageFormat::Png => bytes.windows(4).any(|chunk| chunk == b"acTL"), + image::ImageFormat::WebP => { + bytes.len() >= 21 + && bytes.starts_with(b"RIFF") + && &bytes[8..12] == b"WEBP" + && ((&bytes[12..16] == b"VP8X" && bytes[20] & 0x02 != 0) + || bytes.windows(4).any(|chunk| chunk == b"ANIM")) + } + _ => false, + } } -fn extract_google_title(html: &str) -> Option { - extract_meta_title(html) +fn extract_link_preview_metadata(html: &str) -> Option { + let title = extract_meta_content(html, "property", "og:title") + .or_else(|| extract_meta_content(html, "name", "twitter:title")) .or_else(|| extract_title_tag(html)) - .and_then(|title| normalize_google_title(&title)) + .and_then(|value| normalize_metadata_text(&value))?; + let site_name = extract_meta_content(html, "property", "og:site_name") + .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_description(&value)); + + Some(LinkPreviewMetadata { + title, + site_name, + description, + image_data_url: None, + image_domain: None, + image_fetch_state: LinkPreviewImageFetchState::None, + image_retry_after_ms: None, + favicon_data_url: None, + }) } -fn extract_meta_title(html: &str) -> Option { +fn extract_meta_content(html: &str, key_attr: &str, key_value: &str) -> Option { let lower = html.to_ascii_lowercase(); let mut search_from = 0; @@ -121,14 +518,11 @@ fn extract_meta_title(html: &str) -> Option { }; let end = start + relative_end + 1; let tag = &html[start..end]; - let lower_tag = &lower[start..end]; - - if lower_tag.contains("og:title") || lower_tag.contains("twitter:title") { + if attr_value(tag, key_attr).is_some_and(|value| value.eq_ignore_ascii_case(key_value)) { if let Some(content) = attr_value(tag, "content") { return Some(content); } } - search_from = end; } @@ -140,7 +534,7 @@ fn extract_title_tag(html: &str) -> Option { let start = lower.find("')? + 1; let content_end = content_start + lower[content_start..].find("")?; - Some(html[content_start..content_end].to_string()) + Some(decode_html_entities(&html[content_start..content_end])) } fn attr_value(tag: &str, attr: &str) -> Option { @@ -157,62 +551,70 @@ fn attr_value(tag: &str, attr: &str) -> Option { && !matches!(after, Some(c) if c.is_ascii_alphanumeric() || c == '-' || c == '_'); if has_name_boundary { - let lower_rest = &lower[name_end..]; - let equals_offset = lower_rest.find('=')?; - let value_start = name_end + equals_offset + 1; - let value = tag[value_start..].trim_start(); + let rest = &tag[name_end..]; + let equals_offset = rest.find('=')?; + let value = rest[equals_offset + 1..].trim_start(); let quote = value.chars().next()?; - if quote == '"' || quote == '\'' { let value_body = &value[quote.len_utf8()..]; let value_end = value_body.find(quote)?; return Some(decode_html_entities(&value_body[..value_end])); } - let value_end = value .find(|c: char| c.is_ascii_whitespace() || c == '>') .unwrap_or(value.len()); return Some(decode_html_entities(&value[..value_end])); } - search_from = name_end; } None } -fn normalize_google_title(raw_title: &str) -> Option { - let mut title = decode_html_entities(raw_title) +fn normalize_metadata_text(raw: &str) -> Option { + let mut normalized = decode_html_entities(raw) .split_whitespace() .collect::>() .join(" "); - for suffix in [ " - Google Docs", " - Google Sheets", " - Google Slides", " - Google Drive", ] { - if let Some(stripped) = title.strip_suffix(suffix) { - title = stripped.trim().to_string(); + if let Some(stripped) = normalized.strip_suffix(suffix) { + normalized = stripped.trim().to_string(); break; } } + if matches!( + normalized.as_str(), + "" | "Sign in - Google Accounts" | "Google Docs" | "Google Sheets" | "Google Slides" + ) { + return None; + } + Some(normalized.chars().take(MAX_METADATA_CHARS).collect()) +} - match title.as_str() { - "" - | "Document" - | "Spreadsheet" - | "Presentation" - | "Drive file" - | "Drive folder" - | "Google Docs" - | "Google Sheets" - | "Google Slides" - | "Google Drive" - | "Sign in - Google Accounts" => None, - _ => Some(title.chars().take(180).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 { @@ -231,71 +633,328 @@ fn decode_html_entities(value: &str) -> String { }; let end = start + relative_end + 1; let entity = &decoded[start + 2..end - 1]; - let parsed = if let Some(hex) = entity + let parsed = entity .strip_prefix('x') .or_else(|| entity.strip_prefix('X')) - { - u32::from_str_radix(hex, 16).ok() - } else { - entity.parse::().ok() - }; - + .and_then(|hex| u32::from_str_radix(hex, 16).ok()) + .or_else(|| entity.parse::().ok()); let Some(ch) = parsed.and_then(char::from_u32) else { break; }; decoded.replace_range(start..end, &ch.to_string()); } - decoded } #[cfg(test)] mod tests { - use super::{extract_google_title, is_supported_google_link}; + use super::rate_limit::MAX_IMAGE_RETRY_AFTER; + use super::{ + 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_METADATA_DESCRIPTION_CHARS, + }; + use axum::{body::Body, http::Response, routing::get, Router}; + use base64::Engine as _; + use bytes::Bytes; + use futures_util::stream; + use image::{DynamicImage, ImageFormat, Rgb, RgbImage, Rgba, RgbaImage}; + use std::{convert::Infallible, io::Cursor}; use url::Url; + async fn test_response(router: Router, path: &str) -> reqwest::Response { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + tokio::spawn(async move { + axum::serve(listener, router).await.unwrap(); + }); + reqwest::get(format!("http://{address}{path}")) + .await + .unwrap() + } + #[test] - fn title_prefers_open_graph_title() { - let html = r#" - - - - Fallback - Google Docs - - - "#; + fn metadata_prefers_open_graph_and_reads_site_name() { + let html = r#" + + + Fallback"#; + assert_eq!( + extract_link_preview_metadata(html), + Some(LinkPreviewMetadata { + title: "Rich previews & cards".to_string(), + site_name: Some("Buzz".to_string()), + 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("Preview result").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!( - extract_google_title(html).as_deref(), - Some("Composer links & previews") + metadata.image_fetch_state, + LinkPreviewImageFetchState::Image ); + assert_eq!(metadata.image_domain.as_deref(), Some("images.example.com")); } #[test] - fn title_ignores_generic_google_titles() { + fn metadata_falls_back_to_twitter_then_title() { assert_eq!( - extract_google_title("Sign in - Google Accounts"), + extract_link_preview_metadata("") + .map(|metadata| metadata.title), + Some("Tweet title".to_string()) + ); + assert_eq!( + extract_link_preview_metadata(" Plain title ") + .map(|metadata| metadata.title), + Some("Plain title".to_string()) + ); + } + + #[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(); + let html = r#" + "#; + assert_eq!( + extract_favicon_url(html, &page).unwrap().as_str(), + "https://example.com/favicon.png" + ); + } + + #[test] + fn favicon_metadata_prefers_a_supported_raster_candidate() { + let page = Url::parse("https://github.com/block/buzz").unwrap(); + let html = r#" + + "#; + assert_eq!( + extract_favicon_url(html, &page).unwrap().as_str(), + "https://assets.example/favicon.png" + ); + } + + #[test] + fn favicon_metadata_uses_touch_icon_before_unsupported_ico() { + let page = Url::parse("https://twitter.com/tellaho").unwrap(); + let html = r#" + "#; + assert_eq!( + extract_favicon_url(html, &page).unwrap().as_str(), + "https://twitter.com/apple-touch-icon.png" + ); + } + + #[test] + fn image_metadata_resolves_relative_urls_and_prefers_open_graph() { + let page = Url::parse("https://example.com/articles/one").unwrap(); + let html = r#" + "#; + assert_eq!( + extract_image_url(html, &page).unwrap().as_str(), + "https://example.com/preview.png" + ); + } + + #[tokio::test] + async fn oversized_html_uses_metadata_within_the_bounded_prefix() { + const LIMIT: usize = 256; + let metadata = r#""#; + let body = format!("{metadata}{}", "x".repeat(LIMIT)); + let response = test_response( + Router::new().route( + "/declared", + get(move || { + let body = body.clone(); + async move { + Response::builder() + .header("content-type", "text/html") + .body(Body::from(body)) + .unwrap() + } + }), + ), + "/declared", + ) + .await; + assert!(response + .content_length() + .is_some_and(|size| size > LIMIT as u64)); + assert!(is_html_response(&response)); + + let prefix = read_bytes_prefix(response, LIMIT).await.unwrap(); + assert_eq!(prefix.len(), LIMIT); + let html = String::from_utf8_lossy(&prefix); + assert_eq!( + extract_link_preview_metadata(&html).map(|metadata| metadata.title), + Some("Prefix title".to_string()) + ); + 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; + let response = test_response( + Router::new().route( + "/chunked", + get(|| async { + let chunks = stream::iter([ + Ok::<_, Infallible>(Bytes::from(vec![b'x'; LIMIT])), + Ok(Bytes::from_static( + br#""#, + )), + ]); + Response::builder() + .header("content-type", "text/html") + .body(Body::from_stream(chunks)) + .unwrap() + }), + ), + "/chunked", + ) + .await; + assert_eq!(response.content_length(), None); + + let prefix = read_bytes_prefix(response, LIMIT).await.unwrap(); + assert_eq!(prefix.len(), LIMIT); + let html = String::from_utf8_lossy(&prefix); + assert_eq!(extract_link_preview_metadata(&html), None); + assert_eq!( + extract_image_url(&html, &Url::parse("https://example.com").unwrap()), None ); - assert_eq!(extract_google_title("Google Docs"), None); } #[test] - fn supported_urls_are_google_file_links_only() { - assert!(is_supported_google_link( - &Url::parse("https://docs.google.com/document/d/abc/edit").unwrap() - )); - assert!(is_supported_google_link( - &Url::parse("https://docs.google.com/spreadsheets/d/abc/edit").unwrap() - )); - assert!(is_supported_google_link( - &Url::parse("https://drive.google.com/file/d/abc/view").unwrap() - )); - assert!(!is_supported_google_link( - &Url::parse("https://example.com/document/d/abc/edit").unwrap() - )); - assert!(!is_supported_google_link( - &Url::parse("http://docs.google.com/document/d/abc/edit").unwrap() - )); + fn sanitizer_rejects_mime_mismatch_and_outputs_static_jpeg() { + let source = DynamicImage::ImageRgb8(RgbImage::from_pixel(2, 2, Rgb([10, 20, 30]))); + let mut png = Cursor::new(Vec::new()); + source.write_to(&mut png, ImageFormat::Png).unwrap(); + assert!(sanitize_image(png.get_ref(), "image/jpeg", false).is_err()); + let sanitized = sanitize_image(png.get_ref(), "image/png", false).unwrap(); + assert!(sanitized.starts_with("data:image/jpeg;base64,")); + } + + #[test] + fn favicon_sanitizer_preserves_png_transparency() { + let source = DynamicImage::ImageRgba8(RgbaImage::from_pixel(2, 2, Rgba([36, 41, 47, 0]))); + let mut png = Cursor::new(Vec::new()); + source.write_to(&mut png, ImageFormat::Png).unwrap(); + + let sanitized = sanitize_image(png.get_ref(), "image/png", true).unwrap(); + assert!(sanitized.starts_with("data:image/png;base64,")); + let encoded = sanitized.split_once(',').unwrap().1; + let bytes = base64::engine::general_purpose::STANDARD + .decode(encoded) + .unwrap(); + assert!(image::load_from_memory(&bytes).unwrap().color().has_alpha()); + } + + #[test] + fn animation_markers_are_rejected_before_decode() { + let mut apng = b"\x89PNG\r\n\x1a\n".to_vec(); + apng.extend_from_slice(b"junkacTLjunk"); + assert!(declares_animation(&apng, ImageFormat::Png)); + + let mut webp = b"RIFF\x00\x00\x00\x00WEBPVP8X\x0a\x00\x00\x00".to_vec(); + webp.push(0x02); + assert!(declares_animation(&webp, ImageFormat::WebP)); + } + + #[test] + fn metadata_requires_a_non_empty_title() { + assert_eq!(extract_link_preview_metadata(" "), None); + assert_eq!(extract_link_preview_metadata(""), None); } } diff --git a/desktop/src-tauri/src/commands/link_preview_rate_limit.rs b/desktop/src-tauri/src/commands/link_preview_rate_limit.rs new file mode 100644 index 0000000000..c3f7ed7188 --- /dev/null +++ b/desktop/src-tauri/src/commands/link_preview_rate_limit.rs @@ -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>> = OnceLock::new(); + +pub(super) fn retry_after_duration(response: &reqwest::Response) -> Option { + response + .headers() + .get(RETRY_AFTER) + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.trim().parse::().ok()) + .map(Duration::from_secs) + .map(|duration| duration.min(MAX_IMAGE_RETRY_AFTER)) +} + +pub(super) fn image_host_cooldown_remaining(url: &Url) -> Option { + 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); + } +} diff --git a/desktop/src-tauri/src/commands/messages.rs b/desktop/src-tauri/src/commands/messages.rs index b7c37bec3d..4664a846e5 100644 --- a/desktop/src-tauri/src/commands/messages.rs +++ b/desktop/src-tauri/src/commands/messages.rs @@ -3,16 +3,18 @@ use tauri::{AppHandle, State}; mod forum; -use forum::{forum_message_from_event, forum_reply_from_event}; +use forum::{ + apply_link_preview_suppression, fetch_agent_owner_pubkeys, link_preview_suppression_targets, +}; +pub use forum::{get_forum_posts, get_forum_thread}; use crate::{ app_state::AppState, events, managed_agents::{find_managed_agent_mut, load_managed_agents, ManagedAgentRecord}, models::{ - FeedItemInfo, FeedMeta, FeedResponse, FeedSections, ForumMessageInfo, ForumPostsResponse, - ForumThreadReplyInfo, ForumThreadResponse, SearchResponse, SendChannelMessageResponse, - ThreadRepliesResponse, + FeedItemInfo, FeedMeta, FeedResponse, FeedSections, SearchResponse, + SendChannelMessageResponse, ThreadRepliesResponse, }, nostr_convert, relay::{query_relay, submit_event, submit_event_with_keys}, @@ -113,9 +115,30 @@ pub async fn get_feed( Vec::new() }; + let mention_ids = mention_events + .iter() + .map(|event| event.id.to_hex()) + .collect::>(); + let mention_edits = if mention_ids.is_empty() { + Vec::new() + } else { + query_relay( + &state, + &[serde_json::json!({ "kinds": [40003], "#e": mention_ids })], + ) + .await + .unwrap_or_default() + }; + let mention_owner_pubkeys = fetch_agent_owner_pubkeys(&state, &mention_events).await; + let suppressed_mentions = + link_preview_suppression_targets(&mention_events, &mention_edits, &mention_owner_pubkeys); let mentions: Vec = mention_events .iter() - .map(|ev| feed_item_from_event(ev, "mentions")) + .map(|ev| { + let mut item = feed_item_from_event(ev, "mentions"); + apply_link_preview_suppression(&mut item.tags, &item.id, &suppressed_mentions); + item + }) .collect(); let needs_action: Vec = approval_events .iter() @@ -206,79 +229,6 @@ pub async fn search_messages( Ok(nostr_convert::search_response_from_events(&events)) } -#[tauri::command] -pub async fn get_forum_posts( - channel_id: String, - limit: Option, - before: Option, - state: State<'_, AppState>, -) -> Result { - let cap = limit.unwrap_or(20).min(100); - let mut filter = serde_json::Map::new(); - filter.insert("kinds".to_string(), serde_json::json!([45001])); - filter.insert("#h".to_string(), serde_json::json!([channel_id.clone()])); - filter.insert("limit".to_string(), serde_json::json!(cap)); - if let Some(t) = before { - filter.insert("until".to_string(), serde_json::json!(t)); - } - - let events = query_relay(&state, &[serde_json::Value::Object(filter)]).await?; - let messages: Vec = events - .iter() - .map(|ev| forum_message_from_event(ev, &channel_id)) - .collect(); - - let next_cursor = messages.last().map(|m| m.created_at); - Ok(ForumPostsResponse { - messages, - next_cursor, - }) -} - -#[tauri::command] -pub async fn get_forum_thread( - channel_id: String, - event_id: String, - limit: Option, - cursor: Option, - state: State<'_, AppState>, -) -> Result { - let _ = (limit, cursor); - // Two filters: the root event itself, plus any reply (kinds 9/45003) - // that references it via #e. - let events = query_relay( - &state, - &[ - serde_json::json!({ "ids": [event_id.clone()], "kinds": [9, 40002, 45001, 45003] }), - serde_json::json!({ - "kinds": [9, 45003], - "#e": [event_id.clone()], - "#h": [channel_id.clone()], - }), - ], - ) - .await?; - - let mut root: Option = None; - let mut replies: Vec = Vec::new(); - for ev in &events { - if ev.id.to_hex() == event_id { - root = Some(forum_message_from_event(ev, &channel_id)); - } else { - replies.push(forum_reply_from_event(ev, &channel_id, &event_id)); - } - } - let total_replies = replies.len() as u32; - - let root = root.ok_or_else(|| "forum thread root event not found".to_string())?; - Ok(ForumThreadResponse { - root, - replies, - total_replies, - next_cursor: None, - }) -} - /// Fetch the full reply subtree under a thread root, server-side. /// /// Unlike the channel timeline (which the desktop assembles from its local @@ -919,38 +869,48 @@ pub async fn remove_reaction( Ok(()) } -#[tauri::command] -pub async fn edit_message( +#[derive(serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct EditMessageInput { channel_id: String, event_id: String, content: String, + #[serde(default)] media_tags: Vec>, - emoji_tags: Option>>, - // Pubkeys of mentions *newly added* by this edit (the composer diffs the - // edited body against the original). Only these get a `p` tag, so a typo-fix - // edit that leaves the mention set unchanged never re-wakes anyone. - mention_pubkeys: Option>, + #[serde(default)] + emoji_tags: Vec>, + // Pubkeys of mentions *newly added* by this edit. Only these get a `p` + // tag, so a typo-fix edit never re-wakes existing mentions. + #[serde(default)] + mention_pubkeys: Vec, + #[serde(default)] + suppress_link_previews: bool, +} + +#[tauri::command] +pub async fn edit_message( + input: EditMessageInput, state: State<'_, AppState>, ) -> Result<(), String> { - let channel_uuid = uuid::Uuid::parse_str(&channel_id) - .map_err(|_| format!("invalid channel UUID: {channel_id}"))?; - let target_eid = EventId::from_hex(&event_id).map_err(|e| format!("invalid event ID: {e}"))?; - let trimmed = content.trim(); + let channel_uuid = uuid::Uuid::parse_str(&input.channel_id) + .map_err(|_| format!("invalid channel UUID: {}", input.channel_id))?; + let target_eid = + EventId::from_hex(&input.event_id).map_err(|e| format!("invalid event ID: {e}"))?; + let trimmed = input.content.trim(); // Empty text is allowed when the edit still carries imeta attachments // (a media-only edit). Reject only when both are empty. - if trimmed.is_empty() && media_tags.is_empty() { + if trimmed.is_empty() && input.media_tags.is_empty() { return Err("edit must have content or attachments".into()); } - let emoji = emoji_tags.unwrap_or_default(); - let mentions = mention_pubkeys.unwrap_or_default(); - let mention_refs: Vec<&str> = mentions.iter().map(|s| s.as_str()).collect(); + let mention_refs: Vec<&str> = input.mention_pubkeys.iter().map(|s| s.as_str()).collect(); let builder = events::build_message_edit( channel_uuid, target_eid, trimmed, - &media_tags, - &emoji, + &input.media_tags, + &input.emoji_tags, &mention_refs, + input.suppress_link_previews, )?; submit_event(builder, &state).await?; Ok(()) diff --git a/desktop/src-tauri/src/commands/messages/forum.rs b/desktop/src-tauri/src/commands/messages/forum.rs index ffcf3a62e0..086e8c9f79 100644 --- a/desktop/src-tauri/src/commands/messages/forum.rs +++ b/desktop/src-tauri/src/commands/messages/forum.rs @@ -1,4 +1,41 @@ -use crate::models::{ForumMessageInfo, ForumThreadReplyInfo, ThreadSummary}; +use tauri::State; + +use crate::{ + app_state::AppState, + models::{ + ForumMessageInfo, ForumPostsResponse, ForumThreadReplyInfo, ForumThreadResponse, + ThreadSummary, + }, + relay::query_relay, +}; + +pub(super) async fn fetch_agent_owner_pubkeys( + state: &AppState, + events: &[nostr::Event], +) -> std::collections::HashMap { + let authors = events + .iter() + .map(|event| event.pubkey.to_hex()) + .collect::>() + .into_iter() + .collect::>(); + if authors.is_empty() { + return std::collections::HashMap::new(); + } + + super::query_relay( + state, + &[serde_json::json!({ "kinds": [0], "authors": authors })], + ) + .await + .unwrap_or_default() + .into_iter() + .filter_map(|profile| { + crate::nostr_convert::profile_valid_oa_owner_pubkey(&profile) + .map(|owner| (profile.pubkey.to_hex(), owner)) + }) + .collect() +} fn tags_to_vec(event: &nostr::Event) -> Vec> { event @@ -68,3 +105,214 @@ pub(super) fn forum_reply_from_event( reactions: serde_json::Value::Null, } } + +pub(super) fn link_preview_suppression_targets( + originals: &[nostr::Event], + edits: &[nostr::Event], + owner_pubkeys: &std::collections::HashMap, +) -> std::collections::HashSet { + let originals_by_id = originals + .iter() + .map(|event| (event.id.to_hex(), event)) + .collect::>(); + + edits + .iter() + .filter(|event| { + event.kind.as_u16() == 40003 + && event + .tags + .iter() + .any(|tag| tag.as_slice() == ["link-preview".to_string(), "none".to_string()]) + }) + .filter_map(|edit| { + let target_id = edit.tags.iter().find_map(|tag| { + let values = tag.as_slice(); + (values.first().map(String::as_str) == Some("e")) + .then(|| values.get(1).cloned()) + .flatten() + })?; + let target = originals_by_id.get(&target_id)?; + let author = target.pubkey.to_hex(); + let signer = edit.pubkey.to_hex(); + (signer == author || owner_pubkeys.get(&author) == Some(&signer)).then_some(target_id) + }) + .collect() +} + +pub(super) fn apply_link_preview_suppression( + tags: &mut Vec>, + event_id: &str, + suppressed: &std::collections::HashSet, +) { + if suppressed.contains(event_id) + && !tags + .iter() + .any(|tag| tag.as_slice() == ["link-preview".to_string(), "none".to_string()]) + { + tags.push(vec!["link-preview".to_string(), "none".to_string()]); + } +} + +#[tauri::command] +pub async fn get_forum_posts( + channel_id: String, + limit: Option, + before: Option, + state: State<'_, AppState>, +) -> Result { + let cap = limit.unwrap_or(20).min(100); + let mut filter = serde_json::Map::new(); + filter.insert("kinds".to_string(), serde_json::json!([45001])); + filter.insert("#h".to_string(), serde_json::json!([channel_id.clone()])); + filter.insert("limit".to_string(), serde_json::json!(cap)); + if let Some(t) = before { + filter.insert("until".to_string(), serde_json::json!(t)); + } + + let events = query_relay(&state, &[serde_json::Value::Object(filter)]).await?; + let ids = events + .iter() + .map(|event| event.id.to_hex()) + .collect::>(); + let edits = if ids.is_empty() { + Vec::new() + } else { + query_relay( + &state, + &[serde_json::json!({ "kinds": [40003], "#e": ids })], + ) + .await + .unwrap_or_default() + }; + let owner_pubkeys = fetch_agent_owner_pubkeys(&state, &events).await; + let suppressed = link_preview_suppression_targets(&events, &edits, &owner_pubkeys); + let messages: Vec = events + .iter() + .map(|ev| { + let mut message = forum_message_from_event(ev, &channel_id); + apply_link_preview_suppression(&mut message.tags, &message.event_id, &suppressed); + message + }) + .collect(); + + let next_cursor = messages.last().map(|m| m.created_at); + Ok(ForumPostsResponse { + messages, + next_cursor, + }) +} + +#[tauri::command] +pub async fn get_forum_thread( + channel_id: String, + event_id: String, + limit: Option, + cursor: Option, + state: State<'_, AppState>, +) -> Result { + let _ = (limit, cursor); + // Two filters: the root event itself, plus any reply (kinds 9/45003) + // that references it via #e. + let events = query_relay( + &state, + &[ + serde_json::json!({ "ids": [event_id.clone()], "kinds": [9, 40002, 45001, 45003] }), + serde_json::json!({ + "kinds": [9, 45003], + "#e": [event_id.clone()], + "#h": [channel_id.clone()], + }), + ], + ) + .await?; + let ids = events + .iter() + .map(|event| event.id.to_hex()) + .collect::>(); + let edits = if ids.is_empty() { + Vec::new() + } else { + query_relay( + &state, + &[serde_json::json!({ "kinds": [40003], "#e": ids })], + ) + .await + .unwrap_or_default() + }; + let owner_pubkeys = fetch_agent_owner_pubkeys(&state, &events).await; + let suppressed = link_preview_suppression_targets(&events, &edits, &owner_pubkeys); + + let mut root: Option = None; + let mut replies: Vec = Vec::new(); + for ev in &events { + if ev.id.to_hex() == event_id { + let mut message = forum_message_from_event(ev, &channel_id); + apply_link_preview_suppression(&mut message.tags, &message.event_id, &suppressed); + root = Some(message); + } else if ev.kind.as_u16() as u32 != 40003 { + let mut reply = forum_reply_from_event(ev, &channel_id, &event_id); + apply_link_preview_suppression(&mut reply.tags, &reply.event_id, &suppressed); + replies.push(reply); + } + } + let total_replies = replies.len() as u32; + + let root = root.ok_or_else(|| "forum thread root event not found".to_string())?; + Ok(ForumThreadResponse { + root, + replies, + total_replies, + next_cursor: None, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use nostr::{EventBuilder, Keys, Kind}; + + fn signed_event(keys: &Keys, kind: u16, tags: Vec>) -> nostr::Event { + let tags = tags + .into_iter() + .map(nostr::Tag::parse) + .collect::, _>>() + .expect("valid tags"); + EventBuilder::new(Kind::Custom(kind), "body") + .tags(tags) + .sign_with_keys(keys) + .expect("event signs") + } + + #[test] + fn suppression_targets_accepts_author_and_verified_owner_only() { + let author = Keys::generate(); + let owner = Keys::generate(); + let attacker = Keys::generate(); + let original = signed_event(&author, 9, Vec::new()); + let marker = vec!["link-preview".to_string(), "none".to_string()]; + let target = vec!["e".to_string(), original.id.to_hex()]; + let author_edit = signed_event(&author, 40003, vec![target.clone(), marker.clone()]); + let owner_edit = signed_event(&owner, 40003, vec![target.clone(), marker.clone()]); + let spoofed_edit = signed_event(&attacker, 40003, vec![target, marker]); + let owners = std::collections::HashMap::from([( + author.public_key().to_hex(), + owner.public_key().to_hex(), + )]); + + for edit in [&author_edit, &owner_edit] { + assert!(link_preview_suppression_targets( + std::slice::from_ref(&original), + std::slice::from_ref(edit), + &owners, + ) + .contains(&original.id.to_hex())); + } + assert!(link_preview_suppression_targets( + std::slice::from_ref(&original), + std::slice::from_ref(&spoofed_edit), + &owners, + ) + .is_empty()); + } +} diff --git a/desktop/src-tauri/src/events.rs b/desktop/src-tauri/src/events.rs index 777d56d02e..29df7b563d 100644 --- a/desktop/src-tauri/src/events.rs +++ b/desktop/src-tauri/src/events.rs @@ -180,7 +180,6 @@ pub fn build_leave(channel_id: Uuid) -> Result { } /// Kind 9002 — update channel name/description/visibility/ttl. -/// /// `ttl`: outer `None` leaves it unchanged; `Some(Some(secs))` sets the /// ephemeral timeout; `Some(None)` clears it (emits `["ttl", ""]`). pub fn build_update_channel( @@ -396,18 +395,8 @@ pub fn build_forum_comment( Ok(EventBuilder::new(Kind::Custom(45003), content).tags(tags)) } -/// Kind 40003 — edit a message. Carries the full new content AND a fresh -/// imeta tag set; the receiver overlays the imeta tags onto the original -/// event so the rendered message reflects exactly the edited state. NIP-30 -/// custom-emoji tags ride along the same way so an edited body's `:shortcode:`s -/// stay resolvable (the send path attaches these too). -/// -/// `mentions` carries the pubkeys of mentions that are *newly added* by this -/// edit (the caller diffs the edited body against the original). Only those get -/// a `p` tag so the newly-mentioned party is notified/woken, while a typo-fix -/// edit that leaves the mention set unchanged emits no `p` tags and never -/// re-wakes anyone. This mirrors the send path's `mention_tags` (dedup + -/// lowercase); the receiver overlays these onto the original event's audience. +/// Kind 40003 — edit a message with full content, media, emoji, mentions, +/// and optional monotonic link-preview suppression. pub fn build_message_edit( channel_id: Uuid, target_event_id: EventId, @@ -415,6 +404,7 @@ pub fn build_message_edit( media_tags: &[Vec], custom_emoji_tags: &[Vec], mentions: &[&str], + suppress_link_previews: bool, ) -> Result { check_content(content)?; let mut tags = vec![ @@ -424,6 +414,9 @@ pub fn build_message_edit( tags.extend(mention_tags(mentions)?); imeta_tags(media_tags, &mut tags)?; emoji_tags(custom_emoji_tags, &mut tags)?; + if suppress_link_previews { + tags.push(tag(vec!["link-preview", "none"])?); + } Ok(EventBuilder::new(Kind::Custom(40003), content).tags(tags)) } @@ -948,7 +941,8 @@ mod tests { let target = EventId::from_hex("d24da132115ca0a46233cf4c2ad8338fbf914250cbcaa9181a6dd59533cb5ac1") .unwrap(); - let builder = build_message_edit(channel, target, "hi @alice", &[], &[], mentions).unwrap(); + let builder = + build_message_edit(channel, target, "hi @alice", &[], &[], mentions, false).unwrap(); let secret = nostr::SecretKey::from_hex( "0000000000000000000000000000000000000000000000000000000000000003", ) diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index c4b733e3e0..6023f31a0c 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -721,7 +721,7 @@ pub fn run() { get_relay_ws_url, get_relay_http_url, get_media_proxy_port, - fetch_link_preview_title, + fetch_link_preview_metadata, discover_acp_auth_methods, discover_acp_providers, discover_git_bash_prerequisite, diff --git a/desktop/src/features/communities/useCommunityInit.ts b/desktop/src/features/communities/useCommunityInit.ts index afa69f913f..aaa54ca5c1 100644 --- a/desktop/src/features/communities/useCommunityInit.ts +++ b/desktop/src/features/communities/useCommunityInit.ts @@ -13,6 +13,7 @@ import { getIdentity } from "@/shared/api/tauriIdentity"; import { clearTrayAgentActivity } from "@/shared/api/trayMenu"; import { getOverrides } from "@/shared/features"; import { resetMediaCaches } from "@/shared/lib/mediaUrl"; +import { resetLinkPreviewMetadataCache } from "@/shared/lib/useResolvedLinkPreviews"; import { clearSearchHitEventCache } from "@/app/navigation/searchHitEventCache"; import { clearAllDrafts, @@ -65,6 +66,7 @@ function resetCommunityState({ } resetSidebarRelayConnectionCardState(); resetMediaCaches(); + resetLinkPreviewMetadataCache(); resetVideoPlayerState(); resetRenderScopedReactionHydration(); clearSearchHitEventCache(); diff --git a/desktop/src/features/forum/ui/ForumPostCard.tsx b/desktop/src/features/forum/ui/ForumPostCard.tsx index 1fb3c35cc4..ea8dce2afa 100644 --- a/desktop/src/features/forum/ui/ForumPostCard.tsx +++ b/desktop/src/features/forum/ui/ForumPostCard.tsx @@ -11,6 +11,7 @@ import type { ForumPost } from "@/shared/api/types"; import { cn } from "@/shared/lib/cn"; import { resolveMentionProps } from "@/shared/lib/resolveMentionNames"; import { Markdown } from "@/shared/ui/markdown"; +import { hasLinkPreviewSuppression } from "@/features/messages/lib/formatTimelineMessages"; import { parseImetaTags } from "@/shared/ui/markdown/parseImeta"; import { formatRelativeTime } from "../lib/time"; @@ -121,6 +122,8 @@ export function ForumPostCard({ diff --git a/desktop/src/features/home/ui/InboxMessageRow.tsx b/desktop/src/features/home/ui/InboxMessageRow.tsx index 04deafb3ff..8ad4692d1d 100644 --- a/desktop/src/features/home/ui/InboxMessageRow.tsx +++ b/desktop/src/features/home/ui/InboxMessageRow.tsx @@ -16,6 +16,7 @@ import { UserProfilePopover } from "@/features/profile/ui/UserProfilePopover"; import { cn } from "@/shared/lib/cn"; import { normalizePubkey } from "@/shared/lib/pubkey"; import { Markdown } from "@/shared/ui/markdown"; +import { hasLinkPreviewSuppression } from "@/features/messages/lib/formatTimelineMessages"; import { UserAvatar } from "@/shared/ui/UserAvatar"; export type InboxDisplayMessage = InboxContextMessage & { @@ -212,6 +213,10 @@ export function InboxMessageRow({ isKnownAgentPubkey, )} content={message.content} + messageId={message.id} + linkPreviewsSuppressed={hasLinkPreviewSuppression( + timelineMessage.tags, + )} customEmoji={customEmoji} mentionNames={message.mentionNames} mentionPubkeysByName={message.mentionPubkeysByName} diff --git a/desktop/src/features/messages/lib/formatTimelineMessages.test.mjs b/desktop/src/features/messages/lib/formatTimelineMessages.test.mjs index 926738a60b..ee4cc628f2 100644 --- a/desktop/src/features/messages/lib/formatTimelineMessages.test.mjs +++ b/desktop/src/features/messages/lib/formatTimelineMessages.test.mjs @@ -674,3 +674,102 @@ test("CHANNEL_TIMELINE_CONTENT_KINDS matches isTimelineContentEvent", () => { ); } }); + +test("original message link-preview none marker suppresses all generated previews", () => { + const [message] = formatTimelineMessages( + [ + streamMessage({ + content: "https://one.example https://two.example", + tags: [ + ["h", CHANNEL_ID], + ["link-preview", "none"], + ], + }), + ], + null, + undefined, + null, + ); + assert.deepEqual( + message.tags.find((tag) => tag[0] === "link-preview"), + ["link-preview", "none"], + ); +}); + +test("authorized suppression edit remains monotonic across later body edits", () => { + const suppress = streamEdit( + HEX64_A, + "https://one.example https://two.example", + { + created_at: 1_700_000_001, + tags: [ + ["h", CHANNEL_ID], + ["e", HEX64_A], + ["link-preview", "none"], + ], + }, + ); + const later = streamEdit(HEX64_A, "later body", { + id: "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + created_at: 1_700_000_002, + }); + const [message] = formatTimelineMessages( + [streamMessage(), suppress, later], + null, + undefined, + null, + ); + assert.equal(message.body, "later body"); + assert.equal( + message.tags.some((tag) => tag[0] === "link-preview" && tag[1] === "none"), + true, + ); +}); + +test("spoofed suppression edit cannot hide another author's previews", () => { + const spoof = streamEdit(HEX64_A, "spoofed", { + pubkey: PUBKEY_B, + tags: [ + ["h", CHANNEL_ID], + ["e", HEX64_A], + ["link-preview", "none"], + ], + }); + const [message] = formatTimelineMessages( + [streamMessage(), spoof], + null, + undefined, + null, + ); + assert.equal(message.body, "hello world"); + assert.equal( + message.tags.some((tag) => tag[0] === "link-preview"), + false, + ); +}); + +test("verified agent owner may publish a suppression edit", () => { + const ownerEdit = streamEdit(HEX64_A, "owner edit", { + pubkey: PUBKEY_B, + tags: [ + ["h", CHANNEL_ID], + ["e", HEX64_A], + ["link-preview", "none"], + ], + }); + const profiles = { + [PUBKEY_A]: { ownerPubkey: PUBKEY_B }, + }; + const [message] = formatTimelineMessages( + [streamMessage(), ownerEdit], + null, + undefined, + null, + profiles, + ); + assert.equal(message.body, "owner edit"); + assert.equal( + message.tags.some((tag) => tag[0] === "link-preview"), + true, + ); +}); diff --git a/desktop/src/features/messages/lib/formatTimelineMessages.ts b/desktop/src/features/messages/lib/formatTimelineMessages.ts index 640c12bb75..ab35ecfcc4 100644 --- a/desktop/src/features/messages/lib/formatTimelineMessages.ts +++ b/desktop/src/features/messages/lib/formatTimelineMessages.ts @@ -181,6 +181,36 @@ function getAuthorAvatarUrl(input: { return profiles?.[authorPubkey.toLowerCase()]?.avatarUrl ?? null; } +export function hasLinkPreviewSuppression( + tags: string[][] | undefined, +): boolean { + return ( + tags?.some( + (tag) => + tag[0] === "link-preview" && tag[1] === "none" && tag.length === 2, + ) ?? false + ); +} + +function isAuthorizedMessageEdit( + edit: RelayEvent, + target: RelayEvent, + profiles: UserProfileLookup | undefined, + relaySelfPubkey?: string | null, +): boolean { + const author = normalizePubkey( + resolveEventAuthorPubkey({ + event: target, + preferActorTag: true, + relaySelfPubkey, + requireChannelTagForPTags: true, + }), + ); + const signer = normalizePubkey(edit.pubkey); + if (signer === author) return true; + return normalizePubkey(profiles?.[author]?.ownerPubkey ?? "") === signer; +} + export function formatTimelineMessages( events: RelayEvent[], channel: Channel | null, @@ -219,8 +249,14 @@ export function formatTimelineMessages( } } - // Build a map of latest edit per original message: targetId → { content, tags, createdAt }. - // When multiple edits exist for the same message, the most recent one wins. + const timelineEventsById = new Map( + events.filter(isTimelineContentEvent).map((event) => [event.id, event]), + ); + const previewSuppressedTargetIds = new Set(); + + // Build a map of latest authorized edit per original message. Preview + // suppression is monotonic: any authorized edit carrying the marker wins + // forever, independent of which edit supplies the latest body. // The edit's own tags are kept so the renderer can overlay imeta tags // (attachments) from the edit onto the original event — non-imeta tags on // the original (`h`, `p` mentions, etc.) stay untouched. @@ -240,6 +276,16 @@ export function formatTimelineMessages( if (!targetId || deletedEventIds.has(targetId)) { continue; } + const target = timelineEventsById.get(targetId); + if ( + !target || + !isAuthorizedMessageEdit(event, target, profiles, relaySelfPubkey) + ) { + continue; + } + if (hasLinkPreviewSuppression(event.tags)) { + previewSuppressedTargetIds.add(targetId); + } const existing = editsByTargetId.get(targetId); if (!existing || event.created_at > existing.createdAt) { @@ -469,7 +515,18 @@ export function formatTimelineMessages( // imeta tags. All non-imeta tags on the original are preserved. // Logic lives in `applyEditTagOverlay.mjs` so prod and tests share // a single source. - tags: applyEditTagOverlay(event.tags, edit?.tags), + tags: (() => { + const effectiveTags = applyEditTagOverlay(event.tags, edit?.tags); + if ( + hasLinkPreviewSuppression(event.tags) || + previewSuppressedTargetIds.has(event.id) + ) { + return hasLinkPreviewSuppression(effectiveTags) + ? effectiveTags + : [...effectiveTags, ["link-preview", "none"]]; + } + return effectiveTags; + })(), reactions: (() => { const reactions = reactionsByEventId.get(event.id); if (!reactions) return undefined; diff --git a/desktop/src/features/messages/ui/MessageRow.tsx b/desktop/src/features/messages/ui/MessageRow.tsx index 286526b658..a10226f95c 100644 --- a/desktop/src/features/messages/ui/MessageRow.tsx +++ b/desktop/src/features/messages/ui/MessageRow.tsx @@ -41,6 +41,9 @@ import { resolveMentionProps } from "@/shared/lib/resolveMentionNames"; import { Markdown } from "@/shared/ui/markdown"; import type { VideoReviewContext } from "@/shared/ui/VideoPlayer"; import { MessageActionBar } from "./MessageActionBar"; +import { editMessage } from "@/shared/api/tauri"; +import { hasLinkPreviewSuppression } from "@/features/messages/lib/formatTimelineMessages"; +import { toast } from "sonner"; import { MessageAgentOwner } from "./MessageAgentOwner"; import { MessageAuthorText, MessageHeaderRow } from "./MessageHeader"; import { MessageTimestamp } from "./MessageTimestamp"; @@ -151,6 +154,29 @@ export const MessageRow = React.memo( const [expandedDiffId, setExpandedDiffId] = React.useState( null, ); + const linkPreviewsSuppressed = hasLinkPreviewSuppression(message.tags); + const removeLinkPreviewsForEveryone = + channelId && onEdit && !message.pending && !linkPreviewsSuppressed + ? async () => { + const tags = message.tags ?? []; + try { + await editMessage( + channelId, + message.id, + message.body, + tags.filter((tag) => tag[0] === "imeta"), + tags.filter((tag) => tag[0] === "emoji"), + undefined, + true, + ); + } catch (error) { + toast.error( + `Failed to remove previews: ${error instanceof Error ? error.message : String(error)}`, + ); + throw error; + } + } + : undefined; const [badgeBurstEmoji, setBadgeBurstEmoji] = React.useState( null, ); @@ -372,6 +398,9 @@ export const MessageRow = React.memo( isKnownAgentPubkey, )} content={message.body} + messageId={message.id} + linkPreviewsSuppressed={linkPreviewsSuppressed} + onRemoveLinkPreviewsForEveryone={removeLinkPreviewsForEveryone} customEmoji={customEmoji} imetaByUrl={imetaByUrl} agentMentionPubkeysByName={agentMentionPubkeysByName} diff --git a/desktop/src/features/settings/ui/SettingsPanels.tsx b/desktop/src/features/settings/ui/SettingsPanels.tsx index 5c997efbc4..f39a8ae502 100644 --- a/desktop/src/features/settings/ui/SettingsPanels.tsx +++ b/desktop/src/features/settings/ui/SettingsPanels.tsx @@ -37,6 +37,11 @@ import { useThreadViewMode, type ThreadViewMode, } from "@/features/channels/lib/threadViewModePreference"; +import { + setLinkPreviewStyle, + useLinkPreviewStyle, + type LinkPreviewStyle, +} from "@/shared/lib/linkPreviewStylePreference"; import { cn } from "@/shared/lib/cn"; import { Button } from "@/shared/ui/button"; import { @@ -655,11 +660,86 @@ function ThemeSettingsCard() { )} + ); } +const LINK_PREVIEW_STYLE_OPTIONS: { + value: LinkPreviewStyle; + label: string; + description: string; +}[] = [ + { + value: "compact", + label: "Compact", + description: "Show links as compact horizontal cards", + }, + { + value: "rich", + label: "Rich", + description: "Unfurl links with larger images and descriptions", + }, +]; + +function LinkPreviewStyleSetting() { + const style = useLinkPreviewStyle(); + const activeOption = + LINK_PREVIEW_STYLE_OPTIONS.find((option) => option.value === style) ?? + LINK_PREVIEW_STYLE_OPTIONS[0]; + + return ( + + +
+

Links

+

+ {activeOption.description} +

+
+ + + + + + + setLinkPreviewStyle(next as LinkPreviewStyle) + } + value={style} + > + {LINK_PREVIEW_STYLE_OPTIONS.map((option) => ( + + + {option.label} + + {option.description} + + + + ))} + + + +
+
+ ); +} + const THREAD_VIEW_MODE_OPTIONS: { value: ThreadViewMode; label: string; diff --git a/desktop/src/shared/api/editMessage.ts b/desktop/src/shared/api/editMessage.ts new file mode 100644 index 0000000000..fc63502e49 --- /dev/null +++ b/desktop/src/shared/api/editMessage.ts @@ -0,0 +1,23 @@ +import { invokeTauri } from "@/shared/api/tauri"; + +export async function editMessage( + channelId: string, + eventId: string, + content: string, + mediaTags?: string[][], + emojiTags?: string[][], + mentionPubkeys?: string[], + suppressLinkPreviews?: boolean, +): Promise { + await invokeTauri("edit_message", { + input: { + channelId, + eventId, + content, + mediaTags: mediaTags ?? [], + emojiTags: emojiTags ?? [], + mentionPubkeys: mentionPubkeys ?? [], + suppressLinkPreviews: suppressLinkPreviews ?? false, + }, + }); +} diff --git a/desktop/src/shared/api/tauri.ts b/desktop/src/shared/api/tauri.ts index 69e2e455ec..a42cca1df3 100644 --- a/desktop/src/shared/api/tauri.ts +++ b/desktop/src/shared/api/tauri.ts @@ -614,23 +614,7 @@ export async function uploadMediaBytes( }); } -export async function editMessage( - channelId: string, - eventId: string, - content: string, - mediaTags?: string[][], - emojiTags?: string[][], - mentionPubkeys?: string[], -): Promise { - await invokeTauri("edit_message", { - channelId, - eventId, - content, - mediaTags: mediaTags ?? [], - emojiTags: emojiTags ?? [], - mentionPubkeys: mentionPubkeys ?? null, - }); -} +export { editMessage } from "@/shared/api/editMessage"; export async function deleteMessage( channelId: string, diff --git a/desktop/src/shared/lib/linkPreview.test.mjs b/desktop/src/shared/lib/linkPreview.test.mjs index a56b35f54a..66ab404ace 100644 --- a/desktop/src/shared/lib/linkPreview.test.mjs +++ b/desktop/src/shared/lib/linkPreview.test.mjs @@ -198,7 +198,7 @@ test("extractSupportedLinkPreviews skips markdown image link URLs", () => { ); }); -test("extractSupportedLinkPreviews requires bare URL boundaries", () => { +test("extractSupportedLinkPreviews treats other absolute HTTPS URLs as generic", () => { assert.deepEqual( extractSupportedLinkPreviews( [ @@ -207,7 +207,7 @@ test("extractSupportedLinkPreviews requires bare URL boundaries", () => { "(https://github.com/block/sprout/pull/2)", ].join(" "), ).map((preview) => preview.title), - ["block/sprout #2"], + ["evil-github.com", "example.com", "block/sprout #2"], ); }); @@ -252,3 +252,39 @@ test("isSupportedLinkAutolinkLabel matches normalized bare URL labels", () => { ); assert.equal(isSupportedLinkAutolinkLabel("review this", preview), false); }); + +test("parseSupportedLinkPreview parses generic HTTPS URLs", () => { + assert.deepEqual( + parseSupportedLinkPreview("https://example.com/articles/rich-previews"), + { + kind: "generic-link", + href: "https://example.com/articles/rich-previews", + provider: "example.com", + title: "example.com", + typeLabel: "link", + }, + ); +}); + +test("parseSupportedLinkPreview rejects generic HTTP URLs", () => { + assert.equal( + parseSupportedLinkPreview("http://example.com/articles/rich-previews"), + null, + ); +}); + +test("extractSupportedLinkPreviews finds generic links and preserves exclusions", () => { + assert.deepEqual( + extractSupportedLinkPreviews( + [ + "Read https://example.com/article first.", + "`https://hidden.example.com/secret`", + "then [the details](https://docs.example.org/details)", + ].join(" "), + ).map(({ kind, title }) => ({ kind, title })), + [ + { kind: "generic-link", title: "example.com" }, + { kind: "generic-link", title: "the details" }, + ], + ); +}); diff --git a/desktop/src/shared/lib/linkPreview.ts b/desktop/src/shared/lib/linkPreview.ts index 5cba96da87..6141d48310 100644 --- a/desktop/src/shared/lib/linkPreview.ts +++ b/desktop/src/shared/lib/linkPreview.ts @@ -7,19 +7,17 @@ export type SupportedLinkPreviewKind = | "google-drive-folder" | "google-docs-document" | "google-sheets-spreadsheet" - | "google-slides-presentation"; + | "google-slides-presentation" + | "generic-link"; export type SupportedLinkPreview = { kind: SupportedLinkPreviewKind; href: string; - provider: - | "GitHub" - | "Linear" - | "Google Drive" - | "Google Docs" - | "Google Sheets" - | "Google Slides"; + provider: string; title: string; + /** Sanitized native-fetched bitmap; never a remote URL. */ + imageDataUrl?: string | null; + imageDomain?: string | null; typeLabel: | "PR" | "issue" @@ -28,13 +26,14 @@ export type SupportedLinkPreview = { | "folder" | "document" | "spreadsheet" - | "presentation"; + | "presentation" + | "link"; }; const SUPPORTED_URL_RE = - /(^|[\s([{<>"'])((?:https?:\/\/)?(?:(?:www\.)?github\.com|(?:www\.)?linear\.app|drive\.google\.com|docs\.google\.com)\/[^\s<>"'\]]+)/gi; + /(^|[\s([{<>"'])(https:\/\/[^\s<>"'\]]+|(?:(?:www\.)?github\.com|(?:www\.)?linear\.app|drive\.google\.com|docs\.google\.com)\/[^\s<>"'\]]+)/gi; const MARKDOWN_SUPPORTED_LINK_RE = - /!?\[([^\]\n]+)\]\(((?:https?:\/\/)?(?:(?:www\.)?github\.com|(?:www\.)?linear\.app|drive\.google\.com|docs\.google\.com)\/[^)\s<>"']+)\)/gi; + /!?\[([^\]\n]+)\]\((https:\/\/[^)\s<>"']+|(?:(?:www\.)?github\.com|(?:www\.)?linear\.app|drive\.google\.com|docs\.google\.com)\/[^)\s<>"']+)\)/gi; const MAX_PREVIEWS = 8; type HiddenRange = { @@ -431,12 +430,27 @@ export function parseSupportedLinkPreview( return null; } - return ( + const recognized = parseGithubLink(parsed) ?? parseLinearIssue(parsed) ?? parseGoogleDriveLink(parsed) ?? - parseGoogleDocsLink(parsed) - ); + parseGoogleDocsLink(parsed); + if (recognized) return recognized; + const hostname = normalizeHostname(parsed); + if ( + parsed.protocol !== "https:" || + [ + "github.com", + "linear.app", + "drive.google.com", + "docs.google.com", + ].includes(hostname) + ) { + return null; + } + + const provider = hostname; + return createPreview("generic-link", parsed, provider, "link", provider); } export function isSupportedLinkAutolinkLabel( diff --git a/desktop/src/shared/lib/linkPreviewStylePreference.test.mjs b/desktop/src/shared/lib/linkPreviewStylePreference.test.mjs new file mode 100644 index 0000000000..2cbfdfad48 --- /dev/null +++ b/desktop/src/shared/lib/linkPreviewStylePreference.test.mjs @@ -0,0 +1,23 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +const values = new Map(); +globalThis.localStorage = { + getItem: (key) => values.get(key) ?? null, + setItem: (key, value) => values.set(key, value), +}; + +const preference = await import("./linkPreviewStylePreference.ts"); + +test("defaults invalid and missing link preview styles to compact", () => { + assert.equal(preference.parseLinkPreviewStyle(null), "compact"); + assert.equal(preference.parseLinkPreviewStyle("expanded"), "compact"); + assert.equal(preference.parseLinkPreviewStyle("compact"), "compact"); + assert.equal(preference.parseLinkPreviewStyle("rich"), "rich"); +}); + +test("persists and exposes the selected link preview style", () => { + preference.setLinkPreviewStyle("rich"); + assert.equal(preference.getLinkPreviewStyle(), "rich"); + assert.equal(values.get(preference.LINK_PREVIEW_STYLE_STORAGE_KEY), "rich"); +}); diff --git a/desktop/src/shared/lib/linkPreviewStylePreference.ts b/desktop/src/shared/lib/linkPreviewStylePreference.ts new file mode 100644 index 0000000000..3a3c5d7892 --- /dev/null +++ b/desktop/src/shared/lib/linkPreviewStylePreference.ts @@ -0,0 +1,56 @@ +import * as React from "react"; + +/** User preference for how link previews are presented. */ +export type LinkPreviewStyle = "compact" | "rich"; + +export const LINK_PREVIEW_STYLE_STORAGE_KEY = + "buzz.appearance.linkPreviewStyle"; +export const DEFAULT_LINK_PREVIEW_STYLE: LinkPreviewStyle = "compact"; + +const listeners = new Set<() => void>(); +let linkPreviewStyle = readStoredLinkPreviewStyle(); + +export function parseLinkPreviewStyle( + value: string | null | undefined, +): LinkPreviewStyle { + return value === "rich" || value === "compact" + ? value + : DEFAULT_LINK_PREVIEW_STYLE; +} + +function readStoredLinkPreviewStyle(): LinkPreviewStyle { + try { + return parseLinkPreviewStyle( + globalThis.localStorage?.getItem(LINK_PREVIEW_STYLE_STORAGE_KEY), + ); + } catch { + return DEFAULT_LINK_PREVIEW_STYLE; + } +} + +function subscribe(listener: () => void): () => void { + listeners.add(listener); + return () => listeners.delete(listener); +} + +export function getLinkPreviewStyle(): LinkPreviewStyle { + return linkPreviewStyle; +} + +export function setLinkPreviewStyle(style: LinkPreviewStyle): void { + linkPreviewStyle = style; + try { + globalThis.localStorage?.setItem(LINK_PREVIEW_STYLE_STORAGE_KEY, style); + } catch { + // Persistence is best-effort; the in-memory preference still applies. + } + for (const listener of listeners) listener(); +} + +export function useLinkPreviewStyle(): LinkPreviewStyle { + return React.useSyncExternalStore( + subscribe, + getLinkPreviewStyle, + () => DEFAULT_LINK_PREVIEW_STYLE, + ); +} diff --git a/desktop/src/shared/lib/useResolvedLinkPreviews.test.mjs b/desktop/src/shared/lib/useResolvedLinkPreviews.test.mjs new file mode 100644 index 0000000000..bf7e393612 --- /dev/null +++ b/desktop/src/shared/lib/useResolvedLinkPreviews.test.mjs @@ -0,0 +1,182 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + __linkPreviewMetadataTest, + resolveLinkPreview, +} from "./useResolvedLinkPreviews.ts"; + +const preview = { + kind: "generic-link", + href: "https://example.com/story", + provider: "example.com", + title: "example.com/story", + 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, + imageState: "pending", + }); +}); + +test("resolved image metadata keeps the reserved image treatment", () => { + const resolved = resolveLinkPreview(preview, { + title: "A story", + siteName: "Example", + imageDataUrl: "data:image/jpeg;base64,abc", + imageDomain: "cdn.example.com", + }); + + assert.equal(resolved.imageState, "image"); + assert.equal(resolved.provider, "Example"); + assert.equal(resolved.imageDomain, "cdn.example.com"); +}); + +test("resolved metadata without a complete image collapses to the compact treatment", () => { + const resolved = resolveLinkPreview(preview, { + title: "A story", + siteName: "Example", + imageDataUrl: null, + imageDomain: null, + }); + + assert.equal(resolved.imageState, "none"); + 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); +}); diff --git a/desktop/src/shared/lib/useResolvedLinkPreviews.ts b/desktop/src/shared/lib/useResolvedLinkPreviews.ts index 9773c33945..cd79864c7d 100644 --- a/desktop/src/shared/lib/useResolvedLinkPreviews.ts +++ b/desktop/src/shared/lib/useResolvedLinkPreviews.ts @@ -4,89 +4,300 @@ import { invokeTauri } from "@/shared/api/tauri"; import type { SupportedLinkPreview } from "./linkPreview"; -const GOOGLE_FALLBACK_TITLES = new Set([ - "Drive file", - "Drive folder", - "Document", - "Spreadsheet", - "Presentation", -]); - -const titleCache = new Map | string | null>(); - -function fetchLinkPreviewTitle(href: string): Promise { - return invokeTauri("fetch_link_preview_title", { href }); +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; +}; + +type MetadataCacheEntry = { + expiresAt: number | null; + metadata: LinkPreviewMetadata | null; +}; + +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 shouldResolveTitle(preview: SupportedLinkPreview): boolean { - return ( - preview.kind.startsWith("google-") && - GOOGLE_FALLBACK_TITLES.has(preview.title) - ); +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 cacheTitle(href: string): Promise { - const cached = titleCache.get(href); - if (cached instanceof Promise) return cached; - if (cached !== undefined) return Promise.resolve(cached); - - const promise = fetchLinkPreviewTitle(href) - .then((title) => { - titleCache.set(href, title); - return title; - }) - .catch(() => { - titleCache.set(href, null); - return null; +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 (task: () => Promise): Promise => + new Promise((resolve, reject) => { + pending.push(() => { + void task() + .then(resolve, reject) + .finally(() => { + active -= 1; + drain(); + }); + }); + drain(); }); - titleCache.set(href, promise); - return promise; +} + +function createMetadataLoader({ + concurrency = MAX_CONCURRENT_METADATA_FETCHES, + fetcher, + now = Date.now, +}: { + concurrency?: number; + fetcher: (href: string) => Promise; + now?: () => number; +}) { + const cache = new Map< + string, + MetadataCacheEntry | Promise + >(); + 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 => { + 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( + href: string, +): Promise { + return invokeTauri( + "fetch_link_preview_metadata", + { + href, + }, + ); +} + +const metadataLoader = createMetadataLoader({ + fetcher: fetchLinkPreviewMetadata, +}); + +/** Clear ephemeral metadata when the active relay/community changes. */ +export function resetLinkPreviewMetadataCache(): void { + metadataLoader.reset(); +} + +export type LinkPreviewImageState = "pending" | "image" | "fallback" | "none"; + +export type ResolvedLinkPreview = SupportedLinkPreview & { + description?: string | null; + faviconDataUrl?: string | null; + imageState: LinkPreviewImageState; +}; + +type ResolvedMetadataByHref = Record< + string, + LinkPreviewMetadata | null | undefined +>; + +export function resolveLinkPreview( + preview: SupportedLinkPreview, + metadata: LinkPreviewMetadata | null | undefined, +): ResolvedLinkPreview { + if (metadata === undefined) { + return { ...preview, imageState: "pending" }; + } + if (metadata === null) { + return { ...preview, imageState: "none" }; + } + + 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, + description: metadata.description, + faviconDataUrl: metadata.faviconDataUrl, + provider: + preview.kind === "generic-link" && metadata.siteName + ? metadata.siteName + : preview.provider, + imageDataUrl: hasImage ? metadata.imageDataUrl : null, + imageDomain: hasImage ? metadata.imageDomain : null, + imageState, + }; } export function useResolvedLinkPreviews( previews: SupportedLinkPreview[], -): SupportedLinkPreview[] { - const [resolvedTitles, setResolvedTitles] = React.useState< - Record - >({}); +): ResolvedLinkPreview[] { + const [resolvedMetadata, setResolvedMetadata] = + React.useState({}); + const [retryGeneration, setRetryGeneration] = React.useState(0); React.useEffect(() => { let cancelled = false; - const pending = previews.filter(shouldResolveTitle); - if (pending.length === 0) return undefined; - - for (const preview of pending) { - const cached = titleCache.get(preview.href); - if (typeof cached === "string" && cached) { - setResolvedTitles((current) => - current[preview.href] === cached + let retryAt = Number.POSITIVE_INFINITY; + let retryTimer: ReturnType | null = null; + + const scheduleRetry = ({ + expiresAt, + key, + }: Pick) => { + 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 = metadataLoader.peek(preview.href); + if (cached !== undefined) { + setResolvedMetadata((current) => + current[cached.key] === cached.metadata ? current - : { ...current, [preview.href]: cached }, + : { ...current, [cached.key]: cached.metadata }, ); + scheduleRetry(cached); continue; } - void cacheTitle(preview.href).then((title) => { - if (cancelled || !title) return; - setResolvedTitles((current) => - current[preview.href] === title + void metadataLoader.load(preview.href).then((result) => { + if (cancelled) return; + setResolvedMetadata((current) => + current[result.key] === result.metadata ? current - : { ...current, [preview.href]: title }, + : { ...current, [result.key]: result.metadata }, ); + scheduleRetry(result); }); } return () => { cancelled = true; + if (retryTimer !== null) clearTimeout(retryTimer); }; - }, [previews]); + }, [previews, retryGeneration]); return React.useMemo( () => - previews.map((preview) => { - const title = resolvedTitles[preview.href]; - return title ? { ...preview, title } : preview; - }), - [previews, resolvedTitles], + previews.map((preview) => + resolveLinkPreview( + preview, + resolvedMetadata[metadataCacheKey(preview.href)], + ), + ), + [previews, resolvedMetadata], ); } + +export const __linkPreviewMetadataTest = { + createMetadataLoader, + createTaskScheduler, + metadataCacheKey, + metadataExpiry, +}; diff --git a/desktop/src/shared/ui/compact-link-preview-attachment.tsx b/desktop/src/shared/ui/compact-link-preview-attachment.tsx new file mode 100644 index 0000000000..c82e0d4895 --- /dev/null +++ b/desktop/src/shared/ui/compact-link-preview-attachment.tsx @@ -0,0 +1,154 @@ +import { ImageOff } from "lucide-react"; +import { useState } from "react"; + +import type { ResolvedLinkPreview } from "@/shared/lib/useResolvedLinkPreviews"; +import { cn } from "@/shared/lib/cn"; +import { + Attachment, + AttachmentContent, + AttachmentDescription, + AttachmentMedia, + AttachmentTitle, + AttachmentTrigger, +} from "@/shared/ui/attachment"; +import { LinkPreviewControls } from "@/shared/ui/link-preview-controls"; + +function getHostname(preview: ResolvedLinkPreview): string { + try { + return new URL(preview.href).hostname.replace(/^www\./, ""); + } catch { + return preview.provider; + } +} + +function LinkPreviewImageFallback({ + preview, +}: { + preview: ResolvedLinkPreview; +}) { + return ( + + ); +} + +export function CompactLinkPreviewAttachment({ + className, + onRemove, + preview, + showControls = false, +}: { + className?: string; + onRemove?: () => void; + preview: ResolvedLinkPreview; + showControls?: boolean; +}) { + const reserveImage = preview.imageState !== "none"; + const imageSrc = + preview.imageState === "image" ? (preview.imageDataUrl ?? null) : null; + const [failedImageSrc, setFailedImageSrc] = useState(null); + const showImage = Boolean(imageSrc && failedImageSrc !== imageSrc); + const showFallback = + preview.imageState === "fallback" || Boolean(imageSrc && !showImage); + const hostname = getHostname(preview); + + return ( +
+ + {reserveImage ? ( + + {showImage ? ( + {`Preview setFailedImageSrc(imageSrc)} + src={imageSrc ?? undefined} + /> + ) : showFallback ? ( + + ) : ( +
+ )} + + ) : null} + + + {preview.faviconDataUrl ? ( + + ) : null} + {hostname} + + + {preview.title} + + {preview.description ? ( + + {preview.description} + + ) : null} + + + + + Open {preview.provider} {preview.typeLabel}: {preview.title} + + + + + {showControls ? ( + + ) : null} +
+ ); +} diff --git a/desktop/src/shared/ui/link-preview-attachment.tsx b/desktop/src/shared/ui/link-preview-attachment.tsx index 5d6b80767a..88b61fdaab 100644 --- a/desktop/src/shared/ui/link-preview-attachment.tsx +++ b/desktop/src/shared/ui/link-preview-attachment.tsx @@ -1,158 +1,43 @@ -import { ExternalLink } from "lucide-react"; - -import type { SupportedLinkPreview } from "@/shared/lib/linkPreview"; -import { cn } from "@/shared/lib/cn"; +import type { ResolvedLinkPreview } from "@/shared/lib/useResolvedLinkPreviews"; +import { useLinkPreviewStyle } from "@/shared/lib/linkPreviewStylePreference"; +import { CompactLinkPreviewAttachment } from "@/shared/ui/compact-link-preview-attachment"; import { - Attachment, - AttachmentActions, - AttachmentContent, - AttachmentMedia, - AttachmentTitle, - AttachmentTrigger, -} from "@/shared/ui/attachment"; - -function LinearLogo({ className }: { className?: string }) { - return ( - - ); -} - -function GitHubLogo({ className }: { className?: string }) { - return ( - - ); -} - -function GoogleDriveLogo({ className }: { className?: string }) { - return ( - - ); -} - -function GoogleDocsLogo({ className }: { className?: string }) { - return ( - - ); -} - -function GoogleSheetsLogo({ className }: { className?: string }) { - return ( - - ); -} - -function GoogleSlidesLogo({ className }: { className?: string }) { - return ( - - ); -} - -function LinkPreviewLogo({ preview }: { preview: SupportedLinkPreview }) { - switch (preview.kind) { - case "github-issue": - case "github-pull-request": - case "github-repository": - return ; - case "linear-issue": - return ; - case "google-drive-file": - case "google-drive-folder": - return ; - case "google-docs-document": - return ; - case "google-sheets-spreadsheet": - return ; - case "google-slides-presentation": - return ; - } -} + type LinkPreviewImageLightboxComponent, + RichLinkPreviewAttachment, +} from "@/shared/ui/rich-link-preview-attachment"; export function LinkPreviewAttachment({ className, + ImageLightbox, + onRemove, preview, + showControls, }: { className?: string; - preview: SupportedLinkPreview; + ImageLightbox: LinkPreviewImageLightboxComponent; + onRemove?: () => void; + preview: ResolvedLinkPreview; + showControls?: boolean; }) { + const style = useLinkPreviewStyle(); + if (style === "rich") { + return ( + + ); + } + return ( - - - - - -
- {preview.provider} - - {preview.typeLabel} -
- {preview.title} -
- - - - - - Open {preview.provider} {preview.typeLabel}: {preview.title} - - - -
+ ); } diff --git a/desktop/src/shared/ui/link-preview-controls.tsx b/desktop/src/shared/ui/link-preview-controls.tsx new file mode 100644 index 0000000000..66ac98bb6f --- /dev/null +++ b/desktop/src/shared/ui/link-preview-controls.tsx @@ -0,0 +1,125 @@ +import { EllipsisVertical, EyeOff } from "lucide-react"; +import { toast } from "sonner"; + +import { useAppShell } from "@/app/AppShellContext"; +import { + setLinkPreviewStyle, + type LinkPreviewStyle, + useLinkPreviewStyle, +} from "@/shared/lib/linkPreviewStylePreference"; +import { cn } from "@/shared/lib/cn"; +import { Button } from "@/shared/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuTrigger, +} from "@/shared/ui/dropdown-menu"; + +const CONTROL_BUTTON_CLASS = + "h-5 w-5 rounded-full text-muted-foreground opacity-0 transition-opacity hover:text-foreground focus-visible:opacity-100 group-hover/message:opacity-100 data-[state=open]:opacity-100"; + +const LINK_PREVIEW_STYLE_OPTIONS: { + value: LinkPreviewStyle; + label: string; +}[] = [ + { value: "rich", label: "Rich" }, + { value: "compact", label: "Compact" }, +]; + +export function LinkPreviewControls({ + onRemove, + placement = "right", +}: { + onRemove?: () => void; + placement?: "left" | "right"; +}) { + const style = useLinkPreviewStyle(); + const { onOpenSettings } = useAppShell(); + + const handleStyleChange = (nextStyle: string) => { + if ( + (nextStyle !== "rich" && nextStyle !== "compact") || + nextStyle === style + ) { + return; + } + + setLinkPreviewStyle(nextStyle); + toast.success( + `Link previews set to ${nextStyle === "rich" ? "Rich" : "Compact"}.`, + { + action: onOpenSettings + ? { + label: "Appearance", + onClick: () => onOpenSettings("appearance"), + } + : undefined, + description: + "You can always modify this and other settings in Appearance.", + }, + ); + }; + + return ( +
+ + + + + + + Display + + + {LINK_PREVIEW_STYLE_OPTIONS.map((option) => ( + + {option.label} + + ))} + + + + {onRemove ? ( + <> + + + + + ) : null} + + +
+ ); +} diff --git a/desktop/src/shared/ui/link-preview-list.tsx b/desktop/src/shared/ui/link-preview-list.tsx new file mode 100644 index 0000000000..bcfa3c9aef --- /dev/null +++ b/desktop/src/shared/ui/link-preview-list.tsx @@ -0,0 +1,95 @@ +import { useState } from "react"; + +import { useLinkPreviewStyle } from "@/shared/lib/linkPreviewStylePreference"; +import type { ResolvedLinkPreview } from "@/shared/lib/useResolvedLinkPreviews"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/shared/ui/alert-dialog"; +import { AttachmentGroup } from "@/shared/ui/attachment"; +import { Button } from "@/shared/ui/button"; +import { LinkPreviewAttachment } from "@/shared/ui/link-preview-attachment"; +import type { LinkPreviewImageLightboxComponent } from "@/shared/ui/rich-link-preview-attachment"; + +export function LinkPreviewList({ + ImageLightbox, + onRemoveForEveryone, + previews, +}: { + ImageLightbox: LinkPreviewImageLightboxComponent; + onRemoveForEveryone?: () => Promise; + previews: ResolvedLinkPreview[]; +}) { + const [dialogOpen, setDialogOpen] = useState(false); + const [removed, setRemoved] = useState(false); + const style = useLinkPreviewStyle(); + if (removed || previews.length === 0) return null; + + const previewNoun = previews.length === 1 ? "preview" : "previews"; + const controlsIndex = 0; + return ( + <> + + {previews.map((preview, index) => ( + setDialogOpen(true) + : undefined + } + preview={preview} + showControls={index === controlsIndex} + /> + ))} + + {onRemoveForEveryone ? ( + + + + Remove {previewNoun}? + + This removes{" "} + {previews.length === 1 ? "the preview" : "the previews"} for + everyone. + + + + + + + + + + + + + ) : null} + + ); +} diff --git a/desktop/src/shared/ui/markdown.tsx b/desktop/src/shared/ui/markdown.tsx index b1f9623f3e..1a3718b8c3 100644 --- a/desktop/src/shared/ui/markdown.tsx +++ b/desktop/src/shared/ui/markdown.tsx @@ -32,7 +32,7 @@ import { useResolvedLinkPreviews } from "@/shared/lib/useResolvedLinkPreviews"; 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 { LinkPreviewList } from "@/shared/ui/link-preview-list"; import { useSmoothCorners } from "@/shared/ui/smoothCorners"; import { computeConfigNudge, @@ -61,6 +61,7 @@ import { } from "./markdown/CodeBlock"; import { FileCard } from "./markdown/FileCard"; import { InlineEmojiPopover } from "./markdown/InlineEmojiPopover"; +import { createLinkPreviewImageLightbox } from "./markdown/LinkPreviewImageLightbox"; import { MarkdownInput } from "./markdown/MarkdownInput"; import { MediaContextMenu, @@ -93,6 +94,7 @@ import { IMAGE_LIGHTBOX_WHEEL_ZOOM_SPEED, IMAGE_LIGHTBOX_ZOOM_STEP, IMAGE_LIGHTBOX_ZOOM_TRANSITION_MS, + getImageLightboxFocusableElements, imageLightboxBasisBoxForItem, imageLightboxBoxFromRect, imageLightboxCornerRadiiFromElement, @@ -145,28 +147,6 @@ type WebKitGestureLikeEvent = Event & { scale?: number; }; -function getImageLightboxFocusableElements( - container: HTMLElement, -): HTMLElement[] { - return Array.from( - container.querySelectorAll( - [ - "a[href]", - "button:not(:disabled)", - "input:not(:disabled)", - "select:not(:disabled)", - "textarea:not(:disabled)", - "[tabindex]:not([tabindex='-1'])", - ].join(","), - ), - ).filter( - (element) => - !element.hasAttribute("disabled") && - element.getAttribute("aria-hidden") !== "true" && - element.getClientRects().length > 0, - ); -} - function ImageZoomOverlay({ alt, galleryIndex = 0, @@ -995,6 +975,9 @@ function ImageZoomOverlay({ ); } +const LinkPreviewImageLightbox = + createLinkPreviewImageLightbox(ImageZoomOverlay); + /** * Inline image embed with click-to-zoom lightbox and right-click download. * @@ -1837,6 +1820,9 @@ function MarkdownInner({ interactive = true, agentMentionPubkeysByName, mediaInset = false, + messageId, + linkPreviewsSuppressed = false, + onRemoveLinkPreviewsForEveryone, mentionNames, mentionPubkeysByName, searchQuery, @@ -1868,9 +1854,13 @@ function MarkdownInner({ }, [goChannel], ); + const previewsGloballySuppressed = linkPreviewsSuppressed; const linkPreviews = React.useMemo( - () => (interactive ? extractSupportedLinkPreviews(content) : []), - [content, interactive], + () => + interactive && !previewsGloballySuppressed + ? extractSupportedLinkPreviews(content) + : [], + [content, interactive, previewsGloballySuppressed], ); const configNudge = React.useMemo( () => computeConfigNudge(content, interactive, configNudgeAuthorPubkey), @@ -1907,12 +1897,6 @@ function MarkdownInner({ ); let processedContent = content; - - // Note: stripping the sentinel here is intentionally omitted. When - // configNudge !== null, selectProseOrNudge() returns null — suppressing - // the prose node entirely — so processedContent is never rendered and - // stripConfigNudgeSentinel would be dead work on that path. - if (/^(?:\s{2}\n)+/.test(processedContent)) { processedContent = `\u200B${processedContent}`; } @@ -1971,16 +1955,12 @@ function MarkdownInner({ ) : null} - {resolvedLinkPreviews.length > 0 ? ( - - {resolvedLinkPreviews.map((preview) => ( - - ))} - - ) : null} +
@@ -1995,6 +1975,10 @@ export const Markdown = React.memo( prev.customEmoji === next.customEmoji && prev.interactive === next.interactive && prev.mediaInset === next.mediaInset && + prev.messageId === next.messageId && + prev.linkPreviewsSuppressed === next.linkPreviewsSuppressed && + prev.onRemoveLinkPreviewsForEveryone === + next.onRemoveLinkPreviewsForEveryone && shallowRecordEqual( prev.agentMentionPubkeysByName, next.agentMentionPubkeysByName, diff --git a/desktop/src/shared/ui/markdown/LinkPreviewImageLightbox.tsx b/desktop/src/shared/ui/markdown/LinkPreviewImageLightbox.tsx new file mode 100644 index 0000000000..c689dd9efd --- /dev/null +++ b/desktop/src/shared/ui/markdown/LinkPreviewImageLightbox.tsx @@ -0,0 +1,118 @@ +import type { ComponentType } from "react"; +import { useRef, useState } from "react"; + +import { cn } from "@/shared/lib/cn"; +import type { LinkPreviewImageLightboxProps } from "@/shared/ui/rich-link-preview-attachment"; + +import { + type ImageGalleryItem, + type ImageLightboxBox, + type ImageLightboxCornerRadii, + imageLightboxBoxFromRect, + imageLightboxCornerRadiiFromElement, + visibleImageGalleryForTrigger, +} from "./imageLightbox"; + +type ImageZoomOverlayProps = { + alt: string | undefined; + galleryIndex?: number; + galleryItems?: ImageGalleryItem[]; + onClose: () => void; + onCopy: (src: string | undefined) => void; + onDownload: (src: string | undefined) => void; + resolvedSrc: string; + sourceBox: ImageLightboxBox; + sourceCornerRadii: ImageLightboxCornerRadii; + sourceScope?: Element | null; + src: string | undefined; +}; + +const ignoreUnavailableImageAction = () => undefined; + +export function createLinkPreviewImageLightbox( + ImageZoomOverlay: ComponentType, +): ComponentType { + return function LinkPreviewImageLightbox({ alt, children, className, src }) { + const [lightboxState, setLightboxState] = useState<{ + galleryIndex: number; + galleryItems?: ImageGalleryItem[]; + sourceBox: ImageLightboxBox; + sourceCornerRadii: ImageLightboxCornerRadii; + sourceScope: Element | null; + } | null>(null); + const triggerRef = useRef(null); + + const openLightbox = () => { + const trigger = triggerRef.current; + const image = trigger?.querySelector("img"); + if (!trigger || !image) return; + + const rect = image.getBoundingClientRect(); + if (rect.width <= 0 || rect.height <= 0) return; + + const sourceBox = imageLightboxBoxFromRect(rect); + const sourceCornerRadii = imageLightboxCornerRadiiFromElement(image); + const sourceScope = trigger.closest("[data-link-preview-list]"); + const dim = + image.naturalWidth > 0 && image.naturalHeight > 0 + ? `${image.naturalWidth}x${image.naturalHeight}` + : undefined; + const gallery = visibleImageGalleryForTrigger( + trigger, + { + alt, + dim, + resolvedSrc: src, + src: undefined, + thumbnailBox: sourceBox, + thumbnailCornerRadii: sourceCornerRadii, + }, + sourceScope, + ); + + setLightboxState({ + galleryIndex: gallery.galleryIndex, + galleryItems: gallery.galleryItems, + sourceBox, + sourceCornerRadii, + sourceScope, + }); + }; + + return ( + <> + + {lightboxState ? ( + setLightboxState(null)} + onCopy={ignoreUnavailableImageAction} + onDownload={ignoreUnavailableImageAction} + resolvedSrc={src} + sourceBox={lightboxState.sourceBox} + sourceCornerRadii={lightboxState.sourceCornerRadii} + sourceScope={lightboxState.sourceScope} + src={undefined} + /> + ) : null} + + ); + }; +} diff --git a/desktop/src/shared/ui/markdown/imageLightbox.ts b/desktop/src/shared/ui/markdown/imageLightbox.ts index 1c57cb9e5a..4c0c01f93e 100644 --- a/desktop/src/shared/ui/markdown/imageLightbox.ts +++ b/desktop/src/shared/ui/markdown/imageLightbox.ts @@ -316,9 +316,15 @@ function imageGalleryItemFromTrigger( return null; } + const image = trigger.querySelector("img"); + const inferredDim = + image && image.naturalWidth > 0 && image.naturalHeight > 0 + ? `${image.naturalWidth}x${image.naturalHeight}` + : undefined; + return { alt: trigger.dataset.imageLightboxAlt || undefined, - dim: trigger.dataset.imageLightboxDim || undefined, + dim: trigger.dataset.imageLightboxDim || inferredDim, resolvedSrc, src: trigger.dataset.imageLightboxSrc || undefined, thumbnailBox: thumbnail?.box, @@ -403,3 +409,25 @@ export function visibleImageGalleryForTrigger( galleryItems: galleryItems.length > 1 ? galleryItems : undefined, }; } + +export function getImageLightboxFocusableElements( + container: HTMLElement, +): HTMLElement[] { + return Array.from( + container.querySelectorAll( + [ + "a[href]", + "button:not(:disabled)", + "input:not(:disabled)", + "select:not(:disabled)", + "textarea:not(:disabled)", + "[tabindex]:not([tabindex='-1'])", + ].join(","), + ), + ).filter( + (element) => + !element.hasAttribute("disabled") && + element.getAttribute("aria-hidden") !== "true" && + element.getClientRects().length > 0, + ); +} diff --git a/desktop/src/shared/ui/markdown/types.ts b/desktop/src/shared/ui/markdown/types.ts index 63551024d2..cb31ad5b8e 100644 --- a/desktop/src/shared/ui/markdown/types.ts +++ b/desktop/src/shared/ui/markdown/types.ts @@ -59,6 +59,10 @@ export type MarkdownProps = { mentionNames?: string[]; mentionPubkeysByName?: Record; mediaInset?: boolean; + /** Event/message identity used only for local preview-image visibility. */ + messageId?: string; + linkPreviewsSuppressed?: boolean; + onRemoveLinkPreviewsForEveryone?: () => Promise; searchQuery?: string; /** Display name shown in shared-agent card metadata. */ snapshotSharedBy?: string; diff --git a/desktop/src/shared/ui/rich-link-preview-attachment.tsx b/desktop/src/shared/ui/rich-link-preview-attachment.tsx new file mode 100644 index 0000000000..95a2a1201d --- /dev/null +++ b/desktop/src/shared/ui/rich-link-preview-attachment.tsx @@ -0,0 +1,332 @@ +import { ChevronDown, ChevronUp, ImageOff } from "lucide-react"; +import type { ComponentType, ReactNode } from "react"; +import { useState } from "react"; + +import type { ResolvedLinkPreview } from "@/shared/lib/useResolvedLinkPreviews"; +import { cn } from "@/shared/lib/cn"; +import { LinkPreviewControls } from "@/shared/ui/link-preview-controls"; + +export type LinkPreviewImageLightboxProps = { + alt: string; + children: ReactNode; + className?: string; + src: string; +}; + +export type LinkPreviewImageLightboxComponent = + ComponentType; + +function LinkPreviewIdentity({ preview }: { preview: ResolvedLinkPreview }) { + if (preview.faviconDataUrl) { + return ( + + ); + } + return null; +} + +function getHostname(preview: ResolvedLinkPreview): string { + try { + return new URL(preview.href).hostname.replace(/^www\./, ""); + } catch { + return preview.provider; + } +} + +function isTweetPreview(preview: ResolvedLinkPreview): boolean { + try { + const url = new URL(preview.href); + return ( + (url.hostname === "x.com" || url.hostname === "twitter.com") && + /^\/[^/]+\/status\/\d+/.test(url.pathname) + ); + } catch { + return false; + } +} + +function LinkPreviewImage({ + aspectClassName, + className, + ImageLightbox, + preview, +}: { + aspectClassName: string; + className?: string; + ImageLightbox: LinkPreviewImageLightboxComponent; + preview: ResolvedLinkPreview; +}) { + const imageSrc = + preview.imageState === "image" ? preview.imageDataUrl : undefined; + const [failedImageSrc, setFailedImageSrc] = useState(null); + const imageFailed = Boolean(imageSrc && failedImageSrc === imageSrc); + const showFallback = preview.imageState === "fallback" || imageFailed; + const alt = `Preview from ${preview.imageDomain}`; + + if (!imageSrc || imageFailed) { + return ( +
+ {showFallback ? ( + + ) : ( +
+ )} +
+ ); + } + + return ( + +
+ {alt} setFailedImageSrc(imageSrc)} + src={imageSrc} + /> +
+
+ ); +} + +function LinkPreviewDescription({ + className, + description, +}: { + className?: string; + description: string; +}) { + return ( +
+ {description.split(/\n{2,}/).map((paragraph) => ( +

+ {paragraph} +

+ ))} +
+ ); +} + +function TweetPreview({ + className, + ImageLightbox, + onRemove, + preview, + showControls, +}: { + className?: string; + ImageLightbox: LinkPreviewImageLightboxComponent; + onRemove?: () => void; + preview: ResolvedLinkPreview; + showControls: boolean; +}) { + const [contentExpanded, setContentExpanded] = useState(true); + const reserveImage = preview.imageState !== "none"; + const hasExpandableContent = Boolean(preview.description) || reserveImage; + const hostname = getHostname(preview); + + return ( +
+ + {hostname} + + + {preview.title} + + {contentExpanded && preview.description ? ( + + ) : null} + {contentExpanded && reserveImage ? ( + + ) : null} + {hasExpandableContent ? ( + + ) : null} + {showControls ? ( + + ) : null} +
+ ); +} + +export function RichLinkPreviewAttachment({ + className, + ImageLightbox, + onRemove, + preview, + showControls = false, +}: { + className?: string; + ImageLightbox: LinkPreviewImageLightboxComponent; + onRemove?: () => void; + preview: ResolvedLinkPreview; + showControls?: boolean; +}) { + const [contentExpanded, setContentExpanded] = useState(true); + + if (isTweetPreview(preview)) { + return ( + + ); + } + + const reserveImage = preview.imageState !== "none"; + const hasExpandableContent = Boolean(preview.description) || reserveImage; + const hostname = getHostname(preview); + + return ( +
+
+ + + {hostname} + + + {preview.title} + + {contentExpanded && preview.description ? ( + + ) : null} +
+ {contentExpanded && reserveImage ? ( + + ) : null} + {hasExpandableContent ? ( + + ) : null} + {showControls ? ( + + ) : null} +
+ ); +} diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 355ccea9fc..722bf14093 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -315,6 +315,30 @@ type E2eConfig = { profileHasEvent?: boolean; profileUpdateError?: string; profileUpdateErrors?: string[]; + linkPreviewMetadata?: { + 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; + 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; updateChannelDelayMs?: number; @@ -8659,6 +8683,7 @@ async function handleSendChannelMessage( mentionPubkeys?: string[]; mediaTags?: string[][] | null; emojiTags?: string[][] | null; + suppressLinkPreviews?: boolean; }, config: E2eConfig | undefined, ): Promise { @@ -8679,7 +8704,11 @@ async function handleSendChannelMessage( // emoji renderer keeps resolving `:shortcode:` after the round-trip. const emojiTags = args.emojiTags ?? []; // Both kinds end up on the stored event's tag set, just like the real relay. - const extraTags = [...mediaTags, ...emojiTags]; + const extraTags = [ + ...mediaTags, + ...emojiTags, + ...(args.suppressLinkPreviews ? [["link-preview", "none"]] : []), + ]; const identity = getIdentity(config); if (!identity) { const createdAt = Math.floor(Date.now() / 1000); @@ -10471,6 +10500,18 @@ export function maybeInstallE2eTauriMocks() { return; case "fetch_join_policy": return activeConfig?.mock?.joinPolicy ?? null; + case "fetch_link_preview_metadata": { + const delayMs = activeConfig?.mock?.linkPreviewMetadataDelayMs ?? 0; + 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": { const applyDelayMs = activeConfig?.mock?.applyCommunityDelayMs ?? 0; if (applyDelayMs > 0) { @@ -11770,7 +11811,7 @@ export function maybeInstallE2eTauriMocks() { return null; case "edit_message": return handleEditMessage( - payload as Parameters[0], + (payload as { input: Parameters[0] }).input, activeConfig, ); case "add_reaction": diff --git a/desktop/tests/e2e/messaging.spec.ts b/desktop/tests/e2e/messaging.spec.ts index c6f5aefb9b..9422046017 100644 --- a/desktop/tests/e2e/messaging.spec.ts +++ b/desktop/tests/e2e/messaging.spec.ts @@ -1,5 +1,6 @@ import { expect, test, type Locator } from "@playwright/test"; +import { waitForAnimations } from "../helpers/animations"; import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge"; import { expectCornerRadiusPx, expectSmoothCorners } from "../helpers/css"; import { openSettings } from "../helpers/settings"; @@ -108,7 +109,94 @@ test.beforeEach(async ({ page }, testInfo) => { }, ], } - : undefined; + : testInfo.title.includes("cardless short tweet preview") + ? { + linkPreviewMetadata: { + title: "jack (@jack) on X", + siteName: "X (formerly Twitter)", + description: "just setting up my twttr", + imageDataUrl: null, + imageDomain: null, + }, + } + : testInfo.title.includes("cardless tweet preview") + ? { + linkPreviewMetadata: { + title: "Buzz (@buzz) on X", + siteName: "X (formerly Twitter)", + description: + "This is a real tweet-style description long enough to wrap across several lines while preserving the message-like treatment. It keeps going with enough distinct words to exceed five rendered lines at the preview width, proving that the overflow-aware control appears only when the content is genuinely clipped rather than relying on a brittle character-count guess.", + imageDataUrl: + "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='1200' height='675'%3E%3Crect width='1200' height='675' fill='%231d9bf0'/%3E%3C/svg%3E", + imageDomain: "pbs.twimg.com", + }, + } + : testInfo.title.includes("mixed link preview image outcomes") + ? { + linkPreviewMetadataByHref: { + "https://github.com/block/buzz/pull/4001": { + title: "Loaded preview image", + siteName: "GitHub", + 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, + 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, + }, + } + : 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); }); @@ -258,7 +346,285 @@ test("markdown tables overflow wide content and fill the message when narrow", a .toBeLessThanOrEqual(1); }); -test("supported link previews keep the message link visible", async ({ +test("link preview style defaults to compact and Rich unfurls descriptions", async ({ + page, +}) => { + const previewUrl = "https://github.com/block/buzz/pull/3246?inline=1"; + await page.setViewportSize({ width: 800, height: 900 }); + await page.goto("/"); + await page.getByTestId("channel-general").click(); + await page.getByTestId("message-input").fill(previewUrl); + await page.getByTestId("send-message").click(); + + const row = page.getByTestId("message-row").last(); + const compactPreview = row.locator( + '[data-link-preview="github-pull-request"]', + ); + await expect(compactPreview).toHaveCSS("border-top-left-radius", "0px"); + await expect(compactPreview).toHaveCSS("border-left-width", "3px"); + + await openSettings(page, "appearance"); + await expect(page.getByTestId("link-preview-style-trigger")).toHaveText( + "Compact", + ); + await page.getByTestId("link-preview-style-trigger").click(); + await page.getByTestId("link-preview-style-rich").click(); + await expect(page.getByTestId("link-preview-style-trigger")).toHaveText( + "Rich", + ); + await expect + .poll(() => + page.evaluate(() => + localStorage.getItem("buzz.appearance.linkPreviewStyle"), + ), + ) + .toBe("rich"); + + await page.getByTestId("settings-back-to-app").click(); + const richPreview = row.locator( + '[data-link-preview="github-pull-request"][data-link-preview-inline]', + ); + await expect(richPreview).toBeVisible(); + const richHostname = richPreview.locator("[data-link-preview-hostname]"); + await expect(richHostname).toHaveText("github.com"); + await expect(richHostname).toHaveAttribute("href", previewUrl); + + await openSettings(page, "appearance"); + await page.getByTestId("link-preview-style-trigger").click(); + await page.getByTestId("link-preview-style-compact").click(); +}); + +test("link preview image geometry stays stable while loading", async ({ + page, +}) => { + const previewUrl = "https://github.com/block/buzz/pull/3246"; + + for (const width of [800, 420]) { + await page.setViewportSize({ width: 800, height: 700 }); + await page.goto("/"); + await page.getByTestId("channel-general").click(); + await page.setViewportSize({ width, height: 700 }); + await page + .getByTestId("message-input") + .fill(`${previewUrl}?viewport=${width}`); + await page.getByTestId("send-message").click(); + + const card = page + .getByTestId("message-row") + .last() + .locator('[data-link-preview="github-pull-request"]'); + await expect(card).toHaveAttribute("data-image-state", "pending"); + await expect(card).toBeVisible(); + await expect(card.locator("[data-link-preview-thumbnail]")).toBeVisible(); + if (process.env.BUZZ_LINK_PREVIEW_SCREENSHOTS_DIR) { + await waitForAnimations(page); + await card.screenshot({ + animations: "disabled", + path: `${process.env.BUZZ_LINK_PREVIEW_SCREENSHOTS_DIR}/${width}-pending.png`, + }); + } + const pending = await card.evaluate((element) => ({ + height: element.getBoundingClientRect().height, + width: element.getBoundingClientRect().width, + textLeft: element + .querySelector('[data-slot="attachment-content"]') + ?.getBoundingClientRect().left, + textInset: (() => { + const content = element.querySelector("[data-link-preview-hostname]"); + const thumbnail = element.querySelector( + "[data-link-preview-thumbnail]", + ); + return content && thumbnail + ? content.getBoundingClientRect().left - + thumbnail.getBoundingClientRect().right + : undefined; + })(), + thumbnailHeight: element + .querySelector("[data-link-preview-thumbnail]") + ?.getBoundingClientRect().height, + thumbnailWidth: element + .querySelector("[data-link-preview-thumbnail]") + ?.getBoundingClientRect().width, + })); + + await expect(card).toHaveAttribute("data-image-state", "image"); + await expect( + card.locator("[data-link-preview-hostname-favicon]"), + ).toHaveAttribute("src", /data:image\/png;base64/); + if (process.env.BUZZ_LINK_PREVIEW_SCREENSHOTS_DIR) { + await waitForAnimations(page); + await card.screenshot({ + path: `${process.env.BUZZ_LINK_PREVIEW_SCREENSHOTS_DIR}/${width}-loaded.png`, + }); + } + const loaded = await card.evaluate((element) => ({ + description: element.querySelector('[data-slot="attachment-description"]') + ?.textContent, + height: element.getBoundingClientRect().height, + titleClass: element.querySelector('[data-slot="attachment-title"]') + ?.className, + textLeft: element + .querySelector('[data-slot="attachment-content"]') + ?.getBoundingClientRect().left, + thumbnailHeight: element + .querySelector("[data-link-preview-thumbnail]") + ?.getBoundingClientRect().height, + thumbnailWidth: element + .querySelector("[data-link-preview-thumbnail]") + ?.getBoundingClientRect().width, + })); + + expect(pending.width).toBe(width < 640 ? 325 : 384); + expect(pending.height).toBe(84); + expect(pending.textInset).toBe(8); + expect(pending.thumbnailHeight).toBe(84); + expect(loaded.height).toBe(pending.height); + expect(loaded.textLeft).toBe(pending.textLeft); + expect(loaded.thumbnailHeight).toBe(pending.thumbnailHeight); + expect(loaded.thumbnailWidth).toBe(pending.thumbnailWidth); + expect(loaded.thumbnailWidth).toBe(width < 640 ? 120 : 136); + expect(loaded.titleClass).toContain("line-clamp-2"); + expect(loaded.description).toBe( + "A polished, stable preview for shared links.", + ); + } +}); + +test("link preview no-image layout keeps compact height", async ({ page }) => { + const previewUrl = "https://github.com/block/buzz/pull/3246?inline=none"; + + for (const width of [800, 420]) { + await page.setViewportSize({ width: 800, height: 700 }); + await page.goto("/"); + await page.getByTestId("channel-general").click(); + await page.setViewportSize({ width, height: 700 }); + await page + .getByTestId("message-input") + .fill(`${previewUrl}&viewport=${width}`); + await page.getByTestId("send-message").click(); + + const card = page + .getByTestId("message-row") + .last() + .locator('[data-link-preview="github-pull-request"]'); + await expect(card).toHaveAttribute("data-image-state", "pending"); + await expect(card).toBeVisible(); + await expect + .poll(() => + card.evaluate((element) => element.getBoundingClientRect().height), + ) + .toBe(84); + const pending = await card.evaluate((element) => ({ + height: element.getBoundingClientRect().height, + textLeft: element + .querySelector('[data-slot="attachment-content"]') + ?.getBoundingClientRect().left, + })); + + await expect(card).toHaveAttribute("data-image-state", "none"); + await expect(card.locator("[data-link-preview-thumbnail]")).toHaveCount(0); + await expect(card.locator('[data-slot="attachment-media"]')).toHaveCount(0); + const hostnameLink = card.locator("[data-link-preview-hostname]"); + await expect(hostnameLink).toHaveText("github.com"); + await expect(hostnameLink).toHaveAttribute( + "href", + `${previewUrl}&viewport=${width}`, + ); + await expect( + card.locator("[data-link-preview-hostname-favicon]"), + ).toHaveAttribute("src", /data:image\/png;base64/); + await expect( + card.locator('[data-slot="attachment-description"]'), + ).toHaveText("Open-source collaboration for the Buzz app."); + await expect(card).toHaveCSS("border-left-width", "3px"); + const title = card.locator('[data-slot="attachment-title"]'); + await card.hover(); + await expect(hostnameLink).toHaveCSS("text-decoration-line", "underline"); + await expect(title).toHaveCSS("text-decoration-line", "underline"); + const resolved = await card.evaluate((element) => ({ + height: element.getBoundingClientRect().height, + textLeft: element + .querySelector('[data-slot="attachment-content"]') + ?.getBoundingClientRect().left, + })); + + expect(resolved.height).toBe(pending.height); + expect(resolved.textLeft).toBeLessThan(pending.textLeft ?? 0); + } +}); + +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, }) => { const previewUrl = "https://github.com/block/sprout/pull/1334"; @@ -276,8 +642,7 @@ test("supported link previews keep the message link visible", async ({ ).toBeVisible(); const previewCard = row.locator('[data-link-preview="github-pull-request"]'); await expect(previewCard).toBeVisible(); - await expectCornerRadiusPx(previewCard, 16); - await expectSmoothCorners(previewCard); + await expectCornerRadiusPx(previewCard, 0); }); test("send multiple messages in sequence", async ({ page }) => { diff --git a/desktop/tests/e2e/project-commit-detail.spec.ts b/desktop/tests/e2e/project-commit-detail.spec.ts index 32cd807552..cd74b7931c 100644 --- a/desktop/tests/e2e/project-commit-detail.spec.ts +++ b/desktop/tests/e2e/project-commit-detail.spec.ts @@ -188,7 +188,7 @@ test("commit detail opens from the commits feed with a diff", async ({ page.getByRole("button", { name: "Copy commit hash" }), ).toBeVisible(); await expect( - page.getByRole("link", { name: "project guide" }), + page.getByRole("link", { name: "project guide", exact: true }), ).toHaveAttribute("href", "https://example.com/project-guide"); await expect( page.getByRole("button", { name: "Architecture" }), diff --git a/desktop/tests/helpers/bridge.ts b/desktop/tests/helpers/bridge.ts index 8a9ab2be11..b64851b8b3 100644 --- a/desktop/tests/helpers/bridge.ts +++ b/desktop/tests/helpers/bridge.ts @@ -298,6 +298,30 @@ type MockBridgeOptions = { profileHasEvent?: boolean; profileUpdateError?: string; profileUpdateErrors?: string[]; + linkPreviewMetadata?: { + 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; + 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; updateChannelDelayMs?: number;