diff --git a/crates/buzz-relay/src/handlers/imeta.rs b/crates/buzz-relay/src/handlers/imeta.rs index b75060ce6f1..e3d564e448a 100644 --- a/crates/buzz-relay/src/handlers/imeta.rs +++ b/crates/buzz-relay/src/handlers/imeta.rs @@ -369,6 +369,55 @@ fn extract_ext_from_media_url(url: &str) -> Option<&str> { } } +/// Validate an authored link-preview media URL/hash pair. +/// +/// Empty pairs are allowed. Non-empty media must be an exact, credential-free +/// URL on this tenant's media origin with a content-addressed image path. +pub fn validate_local_image_media_pair( + media_url: &str, + sha256: &str, + media_base_url: &str, +) -> bool { + const IMAGE_EXTS: &[&str] = &["jpg", "png", "gif", "webp"]; + if media_url.is_empty() && sha256.is_empty() { + return true; + } + if media_url.is_empty() + || sha256.len() != 64 + || !sha256.chars().all(|c| matches!(c, '0'..='9' | 'a'..='f')) + { + return false; + } + + let Ok(parsed) = url::Url::parse(media_url) else { + return false; + }; + let Ok(base) = url::Url::parse(media_base_url) else { + return false; + }; + if parsed.scheme() != base.scheme() + || parsed.host_str() != base.host_str() + || parsed.port_or_known_default() != base.port_or_known_default() + || !parsed.username().is_empty() + || parsed.password().is_some() + || parsed.query().is_some() + || parsed.fragment().is_some() + { + return false; + } + + let Some(filename) = parsed.path().strip_prefix("/media/") else { + return false; + }; + let Some((path_hash, ext)) = filename.split_once('.') else { + return false; + }; + !filename.contains('/') + && !filename.contains('%') + && path_hash == sha256 + && IMAGE_EXTS.contains(&ext) +} + /// Validate that a URL references a valid local media blob path. fn is_local_media_url(url: &str, media_base_url: &str) -> bool { // A safe extension token: 1–8 lowercase alphanumeric chars. Covers media @@ -423,6 +472,48 @@ mod tests { const HASH: &str = "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789"; const BASE: &str = "https://relay.example.com/media"; + #[test] + fn link_preview_media_pair_requires_exact_local_image_url_and_hash() { + let hash = HASH; + let valid = format!("{BASE}/{hash}.png"); + assert!(validate_local_image_media_pair(&valid, hash, BASE)); + assert!(!validate_local_image_media_pair( + &format!("https://evil.example/media/{hash}.png"), + hash, + BASE + )); + assert!(!validate_local_image_media_pair( + &format!("{BASE}/{hash}.png?token=leak"), + hash, + BASE + )); + assert!(!validate_local_image_media_pair( + &format!("{BASE}/{hash}.png#fragment"), + hash, + BASE + )); + assert!(!validate_local_image_media_pair( + &format!("https://user@relay.example.com/media/{hash}.png"), + hash, + BASE + )); + assert!(!validate_local_image_media_pair( + &format!("{BASE}/{hash}.svg"), + hash, + BASE + )); + assert!(!validate_local_image_media_pair( + &format!("{BASE}/{hash}.png/extra"), + hash, + BASE + )); + assert!(!validate_local_image_media_pair( + &format!("{BASE}/{hash}.png"), + &"0".repeat(64), + BASE + )); + } + #[test] fn test_local_media_url_relative() { assert!(is_local_media_url(&format!("/media/{HASH}.jpg"), BASE)); diff --git a/crates/buzz-relay/src/handlers/ingest.rs b/crates/buzz-relay/src/handlers/ingest.rs index 55a468144c1..7aea2ed02e4 100644 --- a/crates/buzz-relay/src/handlers/ingest.rs +++ b/crates/buzz-relay/src/handlers/ingest.rs @@ -212,6 +212,72 @@ pub fn reject_with_transport(transport: &'static str, reason: &'static str) { .increment(1); } +fn valid_link_preview_text(value: &str, max: usize, allow_newlines: bool) -> bool { + value.len() <= max + && !value + .chars() + .any(|character| character.is_control() && !(allow_newlines && character == '\n')) +} + +fn validate_link_preview_tags(event: &Event, media_base_url: &str) -> Result<(), String> { + const MAX_SNAPSHOTS: usize = 8; + const MAX_TITLE: usize = 300; + const MAX_SITE: usize = 100; + const MAX_DESCRIPTION: usize = 1000; + + let mut count = 0; + let mut suppressed = false; + let mut seen = std::collections::HashSet::new(); + for tag in event.tags.iter() { + let parts = tag.as_slice(); + if parts.first().map(String::as_str) != Some("link-preview") { + continue; + } + count += 1; + if parts == ["link-preview", "none"] { + if count > 1 { + return Err("link-preview suppression cannot include snapshots".into()); + } + suppressed = true; + continue; + } + if suppressed + || count > MAX_SNAPSHOTS + || parts.len() != 11 + || parts[1] != "snapshot" + || parts[2] != "1" + { + return Err("invalid link-preview snapshot tag".into()); + } + let canonical = + url::Url::parse(&parts[3]).map_err(|_| "invalid link-preview canonical URL")?; + if canonical.scheme() != "https" + || !canonical.username().is_empty() + || canonical.password().is_some() + || canonical.fragment().is_some() + || !seen.insert(parts[3].clone()) + || !event.content.contains(&parts[3]) + { + return Err("invalid link-preview canonical URL".into()); + } + for (value, max, allow_newlines) in [ + (&parts[4], MAX_TITLE, false), + (&parts[5], MAX_SITE, false), + (&parts[6], MAX_DESCRIPTION, true), + ] { + if !valid_link_preview_text(value, max, allow_newlines) { + return Err("invalid link-preview snapshot text".into()); + } + } + if !super::imeta::validate_local_image_media_pair(&parts[7], &parts[8], media_base_url) + || !super::imeta::validate_local_image_media_pair(&parts[9], &parts[10], media_base_url) + { + return Err("link-preview media must reference matching local image blobs".into()); + } + } + Ok(()) +} + /// Successful ingestion result. pub struct IngestResult { /// Hex-encoded event ID. @@ -2647,6 +2713,13 @@ async fn ingest_event_inner( }); } + let tenant_media_base = + crate::api::media::media_base_url_for_tenant(&state.config.relay_url, tenant.host()); + if kind_u32 == KIND_STREAM_MESSAGE { + validate_link_preview_tags(&event, &tenant_media_base) + .map_err(|e| IngestError::Rejected(format!("invalid: {e}")))?; + } + let imeta_tags: Vec> = event .tags .iter() @@ -2654,8 +2727,6 @@ async fn ingest_event_inner( .map(|t| t.as_slice().iter().map(|s| s.to_string()).collect()) .collect(); if !imeta_tags.is_empty() { - let tenant_media_base = - crate::api::media::media_base_url_for_tenant(&state.config.relay_url, tenant.host()); crate::api::validate_imeta_tags(&imeta_tags, &tenant_media_base) .map_err(|e| IngestError::Rejected(format!("invalid: {e}")))?; crate::api::verify_imeta_blobs(tenant, &imeta_tags, &state.media_storage) @@ -3632,6 +3703,109 @@ mod tests { assert!(validate_diff_event(&event).is_err()); } + #[test] + fn link_preview_suppression_accepts_blanket_marker() { + let event = make_event_with_tags( + KIND_STREAM_MESSAGE, + "https://example.com", + &[&["link-preview", "none"]], + ); + + assert!(validate_link_preview_tags(&event, "https://media.example.com").is_ok()); + } + + #[test] + fn link_preview_suppression_rejects_duplicate_marker() { + let event = make_event_with_tags( + KIND_STREAM_MESSAGE, + "https://example.com", + &[&["link-preview", "none"], &["link-preview", "none"]], + ); + + assert_eq!( + validate_link_preview_tags(&event, "https://media.example.com"), + Err("link-preview suppression cannot include snapshots".into()) + ); + } + + #[test] + fn link_preview_suppression_rejects_mixed_snapshot_tags_in_either_order() { + let snapshot = [ + "link-preview", + "snapshot", + "1", + "https://example.com", + "Example", + "Example", + "Description", + "", + "", + "", + "", + ]; + for tags in [ + vec![&["link-preview", "none"][..], &snapshot[..]], + vec![&snapshot[..], &["link-preview", "none"][..]], + ] { + let event = make_event_with_tags(KIND_STREAM_MESSAGE, "https://example.com", &tags); + assert!(validate_link_preview_tags(&event, "https://media.example.com").is_err()); + } + } + + fn make_link_preview_event(title: &str, site: &str, description: &str) -> Event { + make_event_with_tags( + KIND_STREAM_MESSAGE, + "https://example.com", + &[&[ + "link-preview", + "snapshot", + "1", + "https://example.com", + title, + site, + description, + "", + "", + "", + "", + ]], + ) + } + + #[test] + fn link_preview_snapshot_accepts_description_newlines() { + let event = make_link_preview_event( + "Example title", + "Example site", + "First paragraph\n\nSecond paragraph", + ); + + assert!(validate_link_preview_tags(&event, "https://media.example.com").is_ok()); + } + + #[test] + fn link_preview_snapshot_rejects_title_and_site_newlines() { + for (title, site) in [ + ("Example\ntitle", "Example site"), + ("Example title", "Example\nsite"), + ] { + let event = make_link_preview_event(title, site, "Description"); + assert!(validate_link_preview_tags(&event, "https://media.example.com").is_err()); + } + } + + #[test] + fn link_preview_snapshot_rejects_non_newline_controls_in_all_text_fields() { + for (title, site, description) in [ + ("Example\ttitle", "Example site", "Description"), + ("Example title", "Example\rsite", "Description"), + ("Example title", "Example site", "Unsafe\tdescription"), + ] { + let event = make_link_preview_event(title, site, description); + assert!(validate_link_preview_tags(&event, "https://media.example.com").is_err()); + } + } + fn make_dummy_event() -> Event { let keys = nostr::Keys::generate(); nostr::EventBuilder::new(nostr::Kind::Custom(9), "") diff --git a/desktop/src-tauri/src/commands/link_preview.rs b/desktop/src-tauri/src/commands/link_preview.rs index 5cc9c01d333..5ada1b38840 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 00000000000..c3f7ed7188c --- /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 b7c37bec3df..7b4b9b785f1 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 @@ -535,6 +485,7 @@ pub async fn send_channel_message( media_tags: Option>>, emoji_tags: Option>>, mention_tags: Option>>, + link_preview_tags: Option>>, mention_pubkeys: Option>, kind: Option, state: State<'_, AppState>, @@ -546,6 +497,8 @@ pub async fn send_channel_message( let media = media_tags.unwrap_or_default(); let emoji = emoji_tags.unwrap_or_default(); let mention_refs_only = mention_tags.unwrap_or_default(); + let link_previews = link_preview_tags.unwrap_or_default(); + let relay_base = crate::relay::relay_api_base_url_with_override(&state); let kind_num = kind.unwrap_or(buzz_core_pkg::kind::KIND_STREAM_MESSAGE); let mut resolved_root: Option = None; @@ -590,6 +543,8 @@ pub async fn send_channel_message( &media, &emoji, &mention_refs_only, + &link_previews, + &relay_base, )? } }; @@ -756,6 +711,8 @@ fn build_managed_agent_channel_message( &[], &[], &[], + &[], + &crate::relay::relay_api_base_url(), client_tags, ) } @@ -919,38 +876,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 ffcf3a62e05..086e8c9f793 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/egress_guard_tests.rs b/desktop/src-tauri/src/egress_guard_tests.rs index f487c8ce167..23cb2ba220c 100644 --- a/desktop/src-tauri/src/egress_guard_tests.rs +++ b/desktop/src-tauri/src/egress_guard_tests.rs @@ -156,14 +156,34 @@ async fn boundary_submit_signed_event_with_keys_blocks_ncryptsec() { fn boundary_huddle_stt_blocks_ncryptsec() { let keys = nostr::Keys::generate(); let channel = uuid::Uuid::new_v4(); - let builder = - crate::events::build_message(channel, NCRYPTSEC, None, &[], &[], &[], &[]).unwrap(); + let builder = crate::events::build_message( + channel, + NCRYPTSEC, + None, + &[], + &[], + &[], + &[], + &[], + &crate::relay::relay_api_base_url(), + ) + .unwrap(); let err = crate::huddle::pipeline::sign_and_guard_stt_body(builder, &keys).unwrap_err(); assert_guard_error(&err); // Clean transcripts pass through the same seam. - let builder = - crate::events::build_message(channel, "hello huddle", None, &[], &[], &[], &[]).unwrap(); + let builder = crate::events::build_message( + channel, + "hello huddle", + None, + &[], + &[], + &[], + &[], + &[], + &crate::relay::relay_api_base_url(), + ) + .unwrap(); assert!(crate::huddle::pipeline::sign_and_guard_stt_body(builder, &keys).is_ok()); } diff --git a/desktop/src-tauri/src/events.rs b/desktop/src-tauri/src/events.rs index 777d56d02ef..b7937419bf1 100644 --- a/desktop/src-tauri/src/events.rs +++ b/desktop/src-tauri/src/events.rs @@ -8,11 +8,9 @@ //! //! Each function validates inputs and returns a nostr::EventBuilder. //! Signing and submission happen in relay::submit_event. - use buzz_core_pkg::kind::{KIND_IA_ARCHIVE_REQUEST, KIND_IA_UNARCHIVE_REQUEST}; use nostr::{EventBuilder, EventId, Kind, Tag}; use uuid::Uuid; - // ── Constants ──────────────────────────────────────────────────────────────── /// Maximum content size — matches buzz-sdk (64 KiB). @@ -180,7 +178,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( @@ -295,6 +292,7 @@ pub fn build_remove_member(channel_id: Uuid, target_pubkey: &str) -> Result], custom_emoji_tags: &[Vec], mention_ref_tags: &[Vec], + link_preview_tags: &[Vec], + relay_base: &str, ) -> Result { build_message_with_client_tags( channel_id, @@ -312,6 +312,8 @@ pub fn build_message( media_tags, custom_emoji_tags, mention_ref_tags, + link_preview_tags, + relay_base, &[], ) } @@ -330,6 +332,8 @@ pub fn build_message_with_client_tags( media_tags: &[Vec], custom_emoji_tags: &[Vec], mention_ref_tags: &[Vec], + link_preview_tags: &[Vec], + relay_base: &str, client_tags: &[Vec], ) -> Result { check_content(content)?; @@ -341,6 +345,7 @@ pub fn build_message_with_client_tags( imeta_tags(media_tags, &mut tags)?; emoji_tags(custom_emoji_tags, &mut tags)?; mention_reference_tags(mention_ref_tags, &mut tags)?; + crate::link_preview_tags::append(link_preview_tags, relay_base, &mut tags)?; append_client_tags(client_tags, &mut tags)?; Ok(EventBuilder::new(Kind::Custom(9), content).tags(tags)) } @@ -396,18 +401,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 +410,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 +420,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 +947,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/huddle/pipeline.rs b/desktop/src-tauri/src/huddle/pipeline.rs index e523ee22bf2..4d4e840104e 100644 --- a/desktop/src-tauri/src/huddle/pipeline.rs +++ b/desktop/src-tauri/src/huddle/pipeline.rs @@ -659,14 +659,23 @@ pub(crate) fn spawn_transcription_task( .clone(); let p_tags: Vec<&str> = agent_pubkeys.iter().map(|s| s.as_str()).collect(); - let builder = - match events::build_message(channel_uuid, &t, None, &p_tags, &[], &[], &[]) { - Ok(b) => b, - Err(e) => { - eprintln!("buzz-desktop: STT build_message: {e}"); - continue; - } - }; + let builder = match events::build_message( + channel_uuid, + &t, + None, + &p_tags, + &[], + &[], + &[], + &[], + &crate::relay::relay_api_base_url(), + ) { + Ok(b) => b, + Err(e) => { + eprintln!("buzz-desktop: STT build_message: {e}"); + continue; + } + }; // Wait before signing: the relay enforces NIP-98 freshness (±60s) // and the gate may hold for up to MAX_HINT_SECONDS (300s). Sign // the kind event and build NIP-98 auth after the wait so both diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 4f935631b60..66816f8b988 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -12,6 +12,7 @@ mod huddle; mod identity_storage; mod initial_window; mod key_backup; +mod link_preview_tags; mod linux_media; #[cfg(target_os = "macos")] mod macos_notifications; @@ -82,7 +83,6 @@ use tauri::{Emitter, Manager, RunEvent, WindowEvent}; use tauri_plugin_window_state::StateFlags; #[cfg(target_os = "macos")] use tray_menu::show_main_window; - #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { // mesh-llm's async chains (model download, node start/join) overflow @@ -672,7 +672,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-tauri/src/link_preview_tags.rs b/desktop/src-tauri/src/link_preview_tags.rs new file mode 100644 index 00000000000..fc57e2e47cd --- /dev/null +++ b/desktop/src-tauri/src/link_preview_tags.rs @@ -0,0 +1,189 @@ +use nostr::Tag; +use std::collections::HashSet; + +const MAX_SNAPSHOTS: usize = 8; +const MAX_TITLE: usize = 300; +const MAX_SITE: usize = 100; +const MAX_DESCRIPTION: usize = 1000; + +fn valid_text(value: &str, max: usize, allow_newlines: bool) -> bool { + value.len() <= max + && !value + .chars() + .any(|character| character.is_control() && !(allow_newlines && character == '\n')) +} + +fn valid_sha256(value: &str) -> bool { + value.len() == 64 && value.chars().all(|c| matches!(c, '0'..='9' | 'a'..='f')) +} + +fn valid_media_pair(url: &str, hash: &str, relay_base: &url::Url) -> bool { + if url.is_empty() && hash.is_empty() { + return true; + } + if url.is_empty() || !valid_sha256(hash) { + return false; + } + let Ok(parsed) = url::Url::parse(url) else { + return false; + }; + if parsed.origin() != relay_base.origin() + || !parsed.username().is_empty() + || parsed.password().is_some() + || parsed.query().is_some() + || parsed.fragment().is_some() + { + return false; + } + let Some(filename) = parsed.path().strip_prefix("/media/") else { + return false; + }; + if filename.contains('/') || filename.contains('%') { + return false; + } + let Some((path_hash, ext)) = filename.split_once('.') else { + return false; + }; + path_hash == hash && valid_sha256(path_hash) && matches!(ext, "jpg" | "png" | "gif" | "webp") +} + +pub fn append( + preview_tags: &[Vec], + relay_base: &str, + tags: &mut Vec, +) -> Result<(), String> { + if preview_tags.len() > MAX_SNAPSHOTS { + return Err(format!( + "too many link preview snapshots (max {MAX_SNAPSHOTS})" + )); + } + let base = url::Url::parse(relay_base).map_err(|_| "invalid relay base URL")?; + let mut seen = HashSet::new(); + for preview_tag in preview_tags { + if preview_tag.as_slice() == ["link-preview", "none"] { + if preview_tags.len() != 1 { + return Err("link-preview suppression cannot include snapshots".into()); + } + tags.push( + Tag::parse(["link-preview", "none"]) + .map_err(|e| format!("invalid link-preview tag: {e}"))?, + ); + continue; + } + let valid = preview_tag.len() == 11 + && preview_tag[0] == "link-preview" + && preview_tag[1] == "snapshot" + && preview_tag[2] == "1" + && url::Url::parse(&preview_tag[3]).is_ok_and(|url| { + url.scheme() == "https" + && url.username().is_empty() + && url.password().is_none() + && url.fragment().is_none() + }) + && seen.insert(preview_tag[3].clone()) + && valid_text(&preview_tag[4], MAX_TITLE, false) + && valid_text(&preview_tag[5], MAX_SITE, false) + && valid_text(&preview_tag[6], MAX_DESCRIPTION, true) + && valid_media_pair(&preview_tag[7], &preview_tag[8], &base) + && valid_media_pair(&preview_tag[9], &preview_tag[10], &base); + if !valid { + return Err("invalid link-preview snapshot tag".into()); + } + let parts: Vec<&str> = preview_tag.iter().map(String::as_str).collect(); + tags.push(Tag::parse(parts).map_err(|e| format!("invalid link-preview tag: {e}"))?); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + const HASH: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + const BASE: &str = "https://relay.example"; + + fn tag(image_url: &str, image_hash: &str) -> Vec { + [ + "link-preview", + "snapshot", + "1", + "https://linear.app/acme/issue/ABC-123/example", + "Example", + "Linear", + "Description", + image_url, + image_hash, + "", + "", + ] + .map(str::to_string) + .to_vec() + } + + #[test] + fn append_accepts_complete_local_snapshot() { + let mut tags = Vec::new(); + append( + &[tag(&format!("{BASE}/media/{HASH}.png"), HASH)], + BASE, + &mut tags, + ) + .unwrap(); + assert_eq!(tags.len(), 1); + } + + #[test] + fn append_accepts_blanket_suppression_by_itself() { + let mut tags = Vec::new(); + append( + &[vec!["link-preview".into(), "none".into()]], + BASE, + &mut tags, + ) + .unwrap(); + assert_eq!(tags[0].as_slice(), ["link-preview", "none"]); + assert!(append( + &[vec!["link-preview".into(), "none".into()], tag("", ""),], + BASE, + &mut Vec::new(), + ) + .is_err()); + } + + #[test] + fn append_accepts_description_newlines() { + let mut preview_tag = tag("", ""); + preview_tag[6] = "First paragraph\n\nSecond paragraph".into(); + assert!(append(&[preview_tag], BASE, &mut Vec::new()).is_ok()); + } + + #[test] + fn append_rejects_other_control_characters() { + let mut preview_tag = tag("", ""); + preview_tag[6] = "Unsafe\tdescription".into(); + assert!(append(&[preview_tag], BASE, &mut Vec::new()).is_err()); + } + + #[test] + fn append_rejects_untrusted_or_malformed_snapshot_media() { + for url in [ + format!("https://evil.example/media/{HASH}.png"), + format!("{BASE}/media/{HASH}.png?token=leak"), + format!("{BASE}/media/{HASH}.png#fragment"), + format!("https://user@relay.example/media/{HASH}.png"), + format!("{BASE}/media/{HASH}.svg"), + format!("{BASE}/media/{HASH}.png/extra"), + ] { + assert!( + append(&[tag(&url, HASH)], BASE, &mut Vec::new()).is_err(), + "{url}" + ); + } + assert!(append( + &[tag(&format!("{BASE}/media/{HASH}.png"), &"b".repeat(64))], + BASE, + &mut Vec::new(), + ) + .is_err()); + } +} diff --git a/desktop/src/features/communities/useCommunityInit.ts b/desktop/src/features/communities/useCommunityInit.ts index baf12613103..0c27ab0541f 100644 --- a/desktop/src/features/communities/useCommunityInit.ts +++ b/desktop/src/features/communities/useCommunityInit.ts @@ -13,7 +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 { resetLinkPreviewTitleCache } from "@/shared/lib/useResolvedLinkPreviews"; +import { resetLinkPreviewMetadataCache } from "@/shared/lib/useResolvedLinkPreviews"; import { clearSearchHitEventCache } from "@/app/navigation/searchHitEventCache"; import { clearAllDrafts, @@ -67,12 +67,12 @@ function resetCommunityState({ } resetSidebarRelayConnectionCardState(); resetMediaCaches(); + resetLinkPreviewMetadataCache(); resetVideoPlayerState(); resetRenderScopedReactionHydration(); resetBackgroundMediaUploads(); clearSearchHitEventCache(); clearMarkdownNodeCache(); - resetLinkPreviewTitleCache(); } type CommunityInitResult = diff --git a/desktop/src/features/forum/ui/ForumPostCard.tsx b/desktop/src/features/forum/ui/ForumPostCard.tsx index 1fb3c35cc4f..8cbbdff2b4e 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,9 @@ export function ForumPostCard({ diff --git a/desktop/src/features/home/ui/InboxMessageRow.tsx b/desktop/src/features/home/ui/InboxMessageRow.tsx index d57db1a5d5b..039d418a148 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 & { @@ -215,6 +216,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/hooks.ts b/desktop/src/features/messages/hooks.ts index 062b0ee40b4..545500bfe41 100644 --- a/desktop/src/features/messages/hooks.ts +++ b/desktop/src/features/messages/hooks.ts @@ -458,6 +458,7 @@ export function useSendMessageMutation( mediaTags: imetaTags, emojiTags, mentionTags, + linkPreviewTags, } = splitOutgoingTags(mediaTags); const recipientPubkeys = messageMentionPubkeys( effectiveChannel, @@ -468,7 +469,12 @@ export function useSendMessageMutation( // Messages carrying media OR custom-emoji tags MUST go through REST so // the relay's tag validation runs. The WebSocket path emits no extra // tags, so emoji-only messages would otherwise lose their emoji tag. - if (parentEventId || imetaTags.length > 0 || emojiTags.length > 0) { + if ( + parentEventId || + imetaTags.length > 0 || + emojiTags.length > 0 || + linkPreviewTags.length > 0 + ) { const cachedMessages = queryClient.getQueryData( channelMessagesKey(effectiveChannel.id), @@ -482,6 +488,7 @@ export function useSendMessageMutation( undefined, emojiTags, mentionTags, + linkPreviewTags, ); // Build tags matching relay-emitted shape: h, author p, mention ps, reply es, imeta, emoji. @@ -519,6 +526,7 @@ export function useSendMessageMutation( ...imetaTags, ...emojiTags, ...mentionTags, + ...linkPreviewTags, ], content: content.trim(), sig: "", diff --git a/desktop/src/features/messages/lib/formatTimelineMessages.test.mjs b/desktop/src/features/messages/lib/formatTimelineMessages.test.mjs index 926738a60bf..ee4cc628f26 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 640c12bb758..ab35ecfcc41 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/lib/imetaMediaMarkdown.test.mjs b/desktop/src/features/messages/lib/imetaMediaMarkdown.test.mjs index a2edaa6f8cf..79c193fb19b 100644 --- a/desktop/src/features/messages/lib/imetaMediaMarkdown.test.mjs +++ b/desktop/src/features/messages/lib/imetaMediaMarkdown.test.mjs @@ -662,6 +662,7 @@ test("round-trip: sparse imeta from legacy tags rebuilds without empty x/size", const IMETA = ["imeta", "url https://blossom/abc.png", "m image/png"]; const EMOJI_A = ["emoji", "shipit", "https://relay/s.png"]; const EMOJI_B = ["emoji", "party", "https://relay/p.gif"]; +const LINK_PREVIEW = ["link-preview", "snapshot", "1", "https://example.com/"]; const MENTION_REF = [ "mention", "1111111111111111111111111111111111111111111111111111111111111111", @@ -672,55 +673,64 @@ test("splitOutgoingTags: undefined input yields three empty arrays", () => { mediaTags: [], emojiTags: [], mentionTags: [], + linkPreviewTags: [], }); }); test("splitOutgoingTags: separates emoji tags from imeta tags", () => { - const { mediaTags, emojiTags, mentionTags } = splitOutgoingTags([ - IMETA, - EMOJI_A, - EMOJI_B, - ]); + const { mediaTags, emojiTags, mentionTags, linkPreviewTags } = + splitOutgoingTags([IMETA, EMOJI_A, EMOJI_B]); assert.deepEqual(mediaTags, [IMETA]); assert.deepEqual(emojiTags, [EMOJI_A, EMOJI_B]); assert.deepEqual(mentionTags, []); + assert.deepEqual(linkPreviewTags, []); }); test("splitOutgoingTags: emoji-only set leaves mediaTags empty", () => { - const { mediaTags, emojiTags, mentionTags } = splitOutgoingTags([EMOJI_A]); + const { mediaTags, emojiTags, mentionTags, linkPreviewTags } = + splitOutgoingTags([EMOJI_A]); assert.deepEqual(mediaTags, []); assert.deepEqual(emojiTags, [EMOJI_A]); assert.deepEqual(mentionTags, []); + assert.deepEqual(linkPreviewTags, []); }); test("splitOutgoingTags: separates reference-only mention tags", () => { - const { mediaTags, emojiTags, mentionTags } = splitOutgoingTags([ - IMETA, - MENTION_REF, - EMOJI_A, - ]); + const { mediaTags, emojiTags, mentionTags, linkPreviewTags } = + splitOutgoingTags([IMETA, MENTION_REF, EMOJI_A]); assert.deepEqual(mediaTags, [IMETA]); assert.deepEqual(emojiTags, [EMOJI_A]); assert.deepEqual(mentionTags, [MENTION_REF]); + assert.deepEqual(linkPreviewTags, []); +}); + +test("splitOutgoingTags: separates authored link-preview snapshots", () => { + const { mediaTags, emojiTags, mentionTags, linkPreviewTags } = + splitOutgoingTags([IMETA, LINK_PREVIEW]); + assert.deepEqual(mediaTags, [IMETA]); + assert.deepEqual(emojiTags, []); + assert.deepEqual(mentionTags, []); + assert.deepEqual(linkPreviewTags, [LINK_PREVIEW]); }); test("splitOutgoingTags: unknown prefixes stay with mediaTags (injection defense)", () => { // A forged ["p", ...] must NOT be misrouted to the emoji channel; it stays on // mediaTags where the server-side imeta guard rejects it. const forged = ["p", "deadbeef"]; - const { mediaTags, emojiTags, mentionTags } = splitOutgoingTags([ - forged, - EMOJI_A, - ]); + const { mediaTags, emojiTags, mentionTags, linkPreviewTags } = + splitOutgoingTags([forged, EMOJI_A]); assert.deepEqual(mediaTags, [forged]); assert.deepEqual(emojiTags, [EMOJI_A]); assert.deepEqual(mentionTags, []); + assert.deepEqual(linkPreviewTags, []); }); test("splitOutgoingTags is the inverse of mergeOutgoingTags", () => { const merged = mergeOutgoingTags([IMETA], [EMOJI_A, EMOJI_B]); - const { mediaTags, emojiTags, mentionTags } = splitOutgoingTags(merged); + const { mediaTags, emojiTags, mentionTags, linkPreviewTags } = + splitOutgoingTags(merged); assert.deepEqual(mediaTags, [IMETA]); assert.deepEqual(emojiTags, [EMOJI_A, EMOJI_B]); assert.deepEqual(mentionTags, []); + assert.deepEqual(linkPreviewTags, []); }); diff --git a/desktop/src/features/messages/lib/imetaMediaMarkdown.ts b/desktop/src/features/messages/lib/imetaMediaMarkdown.ts index 3e16cb332d0..fde84922897 100644 --- a/desktop/src/features/messages/lib/imetaMediaMarkdown.ts +++ b/desktop/src/features/messages/lib/imetaMediaMarkdown.ts @@ -359,18 +359,22 @@ export function splitOutgoingTags(tags: string[][] | undefined): { mediaTags: string[][]; emojiTags: string[][]; mentionTags: string[][]; + linkPreviewTags: string[][]; } { const mediaTags: string[][] = []; const emojiTags: string[][] = []; const mentionTags: string[][] = []; + const linkPreviewTags: string[][] = []; for (const tag of tags ?? []) { if (tag[0] === "emoji") { emojiTags.push(tag); } else if (tag[0] === "mention") { mentionTags.push(tag); + } else if (tag[0] === "link-preview") { + linkPreviewTags.push(tag); } else { mediaTags.push(tag); } } - return { mediaTags, emojiTags, mentionTags }; + return { mediaTags, emojiTags, mentionTags, linkPreviewTags }; } diff --git a/desktop/src/features/messages/lib/linkPreviewContent.ts b/desktop/src/features/messages/lib/linkPreviewContent.ts new file mode 100644 index 00000000000..ef7d57bccc9 --- /dev/null +++ b/desktop/src/features/messages/lib/linkPreviewContent.ts @@ -0,0 +1,24 @@ +import type { Node as ProseMirrorNode } from "@tiptap/pm/model"; + +import { buildPlainTextProjection } from "./plainTextProjection"; + +export function buildPreviewUpdate( + doc: ProseMirrorNode, + selectionAnchor: number, +) { + const projection = buildPlainTextProjection(doc); + const plainText = projection.text; + const hrefs = new Set(); + doc.descendants((node) => { + if (!node.isText || node.marks.some((mark) => mark.type.name === "spoiler")) + return; + const href = node.marks.find((mark) => mark.type.name === "link")?.attrs + .href; + if (typeof href === "string") hrefs.add(href); + }); + return { + cursor: projection.mapPMToTextOffset(selectionAnchor), + linkPreviewContent: [plainText, ...hrefs].join("\n"), + text: plainText, + }; +} diff --git a/desktop/src/features/messages/lib/useLinkEditor.tsx b/desktop/src/features/messages/lib/useLinkEditor.tsx index 0b46a3082a4..22098ae76c4 100644 --- a/desktop/src/features/messages/lib/useLinkEditor.tsx +++ b/desktop/src/features/messages/lib/useLinkEditor.tsx @@ -113,36 +113,12 @@ export function useLinkEditor(richText: UseRichTextEditorResult) { [richText.editor], ); - const showCard = React.useCallback( - (info: LinkSelectionInfo | null) => { - if (!info) { - setCardState(null); - return; - } - - const rect = getLinkRect(info); - if (!rect) { - setCardState(null); - return; - } - - setCardState((prev) => { - const sameLink = - prev?.info.href === info.href && - prev.info.from === info.from && - prev.info.to === info.to && - prev.info.text === info.text; - const sameRect = - prev?.rect.left === rect.left && - prev.rect.top === rect.top && - prev.rect.width === rect.width && - prev.rect.height === rect.height; - if (sameLink && sameRect) return prev; - return { info, rect }; - }); - }, - [getLinkRect], - ); + const showCard = React.useCallback((_info: LinkSelectionInfo | null) => { + // Keep clicks and caret movement inside composer links focused in the + // editor without opening contextual controls. Link editing remains + // available through the formatting toolbar and Cmd/Ctrl+K. + setCardState(null); + }, []); const openDialogFromInfo = React.useCallback((info: LinkSelectionInfo) => { setDraft({ diff --git a/desktop/src/features/messages/lib/useRichTextEditor.ts b/desktop/src/features/messages/lib/useRichTextEditor.ts index c761081cc3b..054b8778bba 100644 --- a/desktop/src/features/messages/lib/useRichTextEditor.ts +++ b/desktop/src/features/messages/lib/useRichTextEditor.ts @@ -29,6 +29,8 @@ import { import { CUSTOM_EMOJI_NODE_NAME } from "./customEmojiNode"; import { useComposerCustomEmoji } from "./useComposerCustomEmoji"; import { buildPlainTextProjection } from "./plainTextProjection"; +import { parseSnapshotClipboardHtml } from "./agentSnapshotClipboard"; +import { buildPreviewUpdate } from "./linkPreviewContent"; import { createLinkInteractionExtension } from "./linkInteractionExtension"; import { CodeBlockAfterHardBreak, @@ -76,7 +78,7 @@ export type AutocompleteEdit = { export type RichTextEditorOptions = { placeholder?: string; - onUpdate?: (info: { text: string; cursor: number }) => void; + onUpdate?: (info: ReturnType) => void; editable?: boolean; mentionNames?: string[]; agentMentionNames?: string[]; @@ -135,6 +137,11 @@ function shouldAppendSpaceAfterPaste(text: string): boolean { return PASTED_LINK_AT_END_RE.test(trimmedEnd); } +function unwrapExactHttpLink(text: string): string | null { + const match = /^(?:<(https?:\/\/[^\s<>]+)>|(https?:\/\/\S+))$/i.exec(text); + return match?.[1] ?? match?.[2] ?? null; +} + const LinkPasteTrailingSpace = Extension.create({ name: "linkPasteTrailingSpace", @@ -487,6 +494,36 @@ export function useRichTextEditor({ }), ], editorProps: { + handleDOMEvents: { + paste: (view, event) => { + const clipboard = (event as ClipboardEvent).clipboardData; + if ( + parseSnapshotClipboardHtml(clipboard?.getData("text/html") ?? "") + ) + return false; + const url = unwrapExactHttpLink( + clipboard?.getData("text/plain") ?? "", + ); + if (!url) return false; + const link = view.state.schema.marks.link; + if (!link) return false; + const { from, to } = view.state.selection; + let transaction = view.state.tr.replaceRangeWith( + from, + to, + view.state.schema.text(url, [link.create({ href: url })]), + ); + const end = transaction.mapping.map(to); + transaction = transaction.insertText(" ", end); + transaction = transaction.removeMark(end, end + 1, link); + transaction = transaction.setSelection( + TextSelection.create(transaction.doc, end + 1), + ); + view.dispatch(transaction.setStoredMarks([]).scrollIntoView()); + event.preventDefault(); + return true; + }, + }, attributes: { autocapitalize: "none", autocorrect: "off", @@ -619,11 +656,9 @@ export function useRichTextEditor({ // still available through `getMarkdown()` for send/draft boundaries; // per-keystroke consumers only need textarea-shaped plain text for // autocomplete and empty/non-empty state. - const projection = buildPlainTextProjection(ed.state.doc); - onUpdateRef.current?.({ - cursor: projection.mapPMToTextOffset(ed.state.selection.anchor), - text: projection.text, - }); + onUpdateRef.current?.( + buildPreviewUpdate(ed.state.doc, ed.state.selection.anchor), + ); }, }, [], diff --git a/desktop/src/features/messages/ui/MessageComposer.tsx b/desktop/src/features/messages/ui/MessageComposer.tsx index 9e1fee53d31..0dc289eefaf 100644 --- a/desktop/src/features/messages/ui/MessageComposer.tsx +++ b/desktop/src/features/messages/ui/MessageComposer.tsx @@ -57,6 +57,7 @@ import { usePersistentAgentMentionHydration } from "./usePersistentAgentMentionH import { useComposerContentState } from "./useComposerContentState"; import { useDraftPersistLifecycle } from "./useDraftPersistSnapshot"; import { submitMessageEdit } from "./submitMessageEdit"; +import { useComposerLinkPreviews } from "./useComposerLinkPreviews"; import type { MessageComposerProps } from "./MessageComposer.types"; function MessageComposerImpl({ audienceContext = null, @@ -98,6 +99,12 @@ function MessageComposerImpl({ syncComposerContentFromEditor, syncContentRefFromEditorRef, } = useComposerContentState(); + const [previewContent, setPreviewContent] = React.useState(""); + const deferredPreviewContent = React.useDeferredValue(previewContent); + const { + previewList: composerLinkPreviews, + getReadyTags: getReadyLinkPreviewTags, + } = useComposerLinkPreviews(deferredPreviewContent); const [isEmojiPickerOpen, setIsEmojiPickerOpen] = React.useState(false); const [isFormattingOpen, setIsFormattingOpen] = React.useState(false); const [spoileredAttachmentUrls, setSpoileredAttachmentUrls] = React.useState< @@ -255,8 +262,9 @@ function MessageComposerImpl({ onEditLink: (info) => onEditLinkRef.current?.(info), onLinkSelectionChange: (info) => onLinkSelectionChangeRef.current?.(info), onLinkShortcut: () => onLinkShortcutRef.current?.() ?? false, - onUpdate: ({ cursor, text }) => { + onUpdate: ({ cursor, linkPreviewContent, text }) => { setComposerContentFromText(text); + setPreviewContent(linkPreviewContent); mentions.updateMentionQuery(text, cursor); channelLinks.updateChannelQuery(text, cursor); emojiAutocomplete.updateEmojiQuery(text, cursor); @@ -499,7 +507,6 @@ function MessageComposerImpl({ richText.focus, mentions.updateMentionQuery, ]); - // ── Submit message ────────────────────────────────────────────────── const submitMessage = React.useCallback(async () => { const trimmed = syncComposerContentFromEditor().trim(); // Edit mode @@ -575,6 +582,7 @@ function MessageComposerImpl({ capturedThreadContext, pendingImeta: currentPendingImeta, queuedAttachments: currentQueuedAttachments, + linkPreviewTags: getReadyLinkPreviewTags(), sentDraftKey: resolveSentDraftKey( effectiveDraftKeyRef.current, drafts.loadDraft, @@ -595,6 +603,7 @@ function MessageComposerImpl({ customEmoji, drafts.loadDraft, emojiAutocomplete.clearEmojis, + getReadyLinkPreviewTags, media.clearQueuedAttachments, media.pendingImetaRef, media.queuedAttachmentsRef, @@ -699,7 +708,6 @@ function MessageComposerImpl({ } return; } - // Escape in edit mode if ( event.key === "Escape" && @@ -725,14 +733,11 @@ function MessageComposerImpl({ onCancelEdit, ], ); - // ── Media paste + ⌘K link shortcut via Tiptap editorProps ────────── const uploadFileRef = React.useRef(media.uploadFile); uploadFileRef.current = media.uploadFile; - React.useEffect(() => { if (!richText.editor) return; - richText.editor.setOptions({ editorProps: { ...richText.editor.options.editorProps, @@ -750,7 +755,6 @@ function MessageComposerImpl({ } return true; } - // --- Buzz code-block paste --- // The code block copy button writes a small Buzz marker alongside // plain text. Use it to paste back as a literal code block so Markdown @@ -777,7 +781,6 @@ function MessageComposerImpl({ scrollComposerToBottom(); return true; } - // Restore Buzz snapshots before normal styled-HTML normalization. if (handleAgentSnapshotPaste(event, media.setPendingImeta)) return true; @@ -789,18 +792,15 @@ function MessageComposerImpl({ _view.pasteHTML(cleanHtml); return true; } - const plainText = event.clipboardData?.getData("text/plain") ?? ""; if (plainText.includes("\n")) { scrollComposerToBottom(); } - return false; }, }, }); }, [media.setPendingImeta, richText.editor, scrollComposerToBottom]); - // ── Send button state ─────────────────────────────────────────────── const sendDisabled = React.useMemo( () => @@ -819,7 +819,6 @@ function MessageComposerImpl({ media.queuedAttachments.length, ], ); - const handleCaptureSelection = React.useCallback(() => {}, []); const handlePaperclipClick = React.useCallback(() => { @@ -951,6 +950,7 @@ function MessageComposerImpl({ ) : null} + {composerLinkPreviews} {(media.pendingImeta.length > 0 || media.queuedAttachments.length > 0 || media.isUploading) && ( diff --git a/desktop/src/features/messages/ui/MessageRow.tsx b/desktop/src/features/messages/ui/MessageRow.tsx index 22bb355d706..51d9832c128 100644 --- a/desktop/src/features/messages/ui/MessageRow.tsx +++ b/desktop/src/features/messages/ui/MessageRow.tsx @@ -44,6 +44,9 @@ import { useOpenVideoReviewAt } from "@/shared/ui/VideoReviewNavigation"; import { parseVideoReviewTimecode } from "@/shared/ui/videoReviewTimecode"; import { VideoReviewTimecodeButton } from "@/shared/ui/VideoReviewTimecodeButton"; 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"; @@ -156,6 +159,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, ); @@ -380,6 +406,10 @@ export const MessageRow = React.memo( isKnownAgentPubkey, )} content={reviewTimecode?.text ?? message.body} + messageId={message.id} + linkPreviewsSuppressed={linkPreviewsSuppressed} + linkPreviewTags={message.tags} + onRemoveLinkPreviewsForEveryone={removeLinkPreviewsForEveryone} customEmoji={customEmoji} imetaByUrl={imetaByUrl} agentMentionPubkeysByName={agentMentionPubkeysByName} diff --git a/desktop/src/features/messages/ui/useComposerLinkPreviews.tsx b/desktop/src/features/messages/ui/useComposerLinkPreviews.tsx new file mode 100644 index 00000000000..39d5d9db8ee --- /dev/null +++ b/desktop/src/features/messages/ui/useComposerLinkPreviews.tsx @@ -0,0 +1,252 @@ +import * as React from "react"; +import { ImageOff, LoaderCircle, X } from "lucide-react"; + +import { getRelayHttpUrl, uploadMediaBytes } from "@/shared/api/tauri"; +import { extractSupportedLinkPreviews } from "@/shared/lib/linkPreview"; +import { + buildLinkPreviewSnapshotTag, + isValidLinkPreviewSnapshotCanonicalUrl, +} from "@/shared/lib/linkPreviewSnapshot"; +import { + beginRelayOriginFetch, + getCachedRelayOrigin, +} from "@/shared/lib/mediaUrl"; +import type { ResolvedLinkPreview } from "@/shared/lib/useResolvedLinkPreviews"; +import { useResolvedLinkPreviews } from "@/shared/lib/useResolvedLinkPreviews"; +import { + Attachment, + AttachmentContent, + AttachmentDescription, + AttachmentGroup, + AttachmentMedia, + AttachmentTitle, + AttachmentTrigger, +} from "@/shared/ui/attachment"; +import { Button } from "@/shared/ui/button"; + +function previewHostname(href: string): string { + try { + return new URL(href).hostname.replace(/^www\./, ""); + } catch { + return href; + } +} + +function ComposerLinkPreviewCard({ + preview, +}: { + preview: ResolvedLinkPreview; +}) { + const imageSrc = preview.imageState === "image" ? preview.imageDataUrl : null; + const [failedImageSrc, setFailedImageSrc] = React.useState( + null, + ); + const showImage = Boolean(imageSrc && failedImageSrc !== imageSrc); + const hostname = previewHostname(preview.href); + let path = ""; + try { + const url = new URL(preview.href); + path = `${url.pathname}${url.search}`; + } catch {} + + return ( + + + {showImage ? ( + setFailedImageSrc(imageSrc ?? null)} + src={imageSrc ?? undefined} + /> + ) : preview.imageState === "pending" ? ( + + ) : preview.faviconDataUrl ? ( + + ) : ( + + + + {preview.snapshotReady ? preview.title : hostname} + + + {preview.snapshotReady + ? preview.provider || hostname + : path && path !== "/" + ? path + : preview.typeLabel} + + + + + Open {preview.title} + + + + ); +} + +function dataUrlBytes(dataUrl: string): Uint8Array | null { + const match = /^data:([^;,]+);base64,([A-Za-z0-9+/=]+)$/.exec(dataUrl); + if (!match) return null; + try { + return Uint8Array.from(atob(match[2]), (char) => char.charCodeAt(0)); + } catch { + return null; + } +} + +async function uploadDataUrl( + dataUrl: string | null | undefined, + filename: string, +) { + if (!dataUrl) return { url: "", sha256: "" }; + const bytes = dataUrlBytes(dataUrl); + if (!bytes) throw new Error("invalid preview media data"); + const uploaded = await uploadMediaBytes([...bytes], filename); + return { url: uploaded.url, sha256: uploaded.sha256 }; +} + +export function useComposerLinkPreviews(content: string) { + const [suppressed, setSuppressed] = React.useState(false); + const candidates = React.useMemo( + () => + extractSupportedLinkPreviews(content).filter((preview) => + preview.href.startsWith("buzz://") + ? true + : isValidLinkPreviewSnapshotCanonicalUrl(preview.href), + ), + [content], + ); + const previews = useResolvedLinkPreviews(suppressed ? [] : candidates); + React.useEffect(() => { + if (candidates.length === 0) setSuppressed(false); + }, [candidates.length]); + const [readyTags, setReadyTags] = React.useState>( + {}, + ); + const readyTagsRef = React.useRef([]); + const readyTagsByHrefRef = React.useRef(readyTags); + readyTagsByHrefRef.current = readyTags; + const suppressedRef = React.useRef(suppressed); + suppressedRef.current = suppressed; + const uploadsRef = React.useRef(new Set()); + const activeHrefsRef = React.useRef(new Set()); + activeHrefsRef.current = new Set(candidates.map((preview) => preview.href)); + + React.useEffect(() => { + if (getCachedRelayOrigin()) return; + const publishRelayOrigin = beginRelayOriginFetch(); + void getRelayHttpUrl() + .then((url) => publishRelayOrigin(url)) + .catch(() => publishRelayOrigin(null)); + }, []); + + React.useEffect(() => { + const active = new Set(candidates.map((preview) => preview.href)); + setReadyTags((current) => + Object.fromEntries( + Object.entries(current).filter(([href]) => active.has(href)), + ), + ); + }, [candidates]); + + React.useEffect(() => { + for (const preview of previews) { + if ( + !preview.snapshotReady || + readyTags[preview.href] || + uploadsRef.current.has(preview.href) + ) + continue; + uploadsRef.current.add(preview.href); + void Promise.all([ + uploadDataUrl(preview.imageDataUrl, "link-preview-image.png"), + uploadDataUrl(preview.faviconDataUrl, "link-preview-favicon.png"), + ]) + .then(([image, favicon]) => { + if (!activeHrefsRef.current.has(preview.href)) return; + const tag = buildLinkPreviewSnapshotTag({ + canonicalUrl: preview.href, + title: preview.title, + siteName: preview.provider, + description: preview.description ?? "", + imageUrl: image.url, + imageSha256: image.sha256, + faviconUrl: favicon.url, + faviconSha256: favicon.sha256, + }); + if (!tag) return; + setReadyTags((current) => ({ ...current, [preview.href]: tag })); + }) + .catch(() => {}) + .finally(() => uploadsRef.current.delete(preview.href)); + } + }, [previews, readyTags]); + + readyTagsRef.current = suppressed + ? [["link-preview", "none"]] + : candidates.flatMap((candidate) => + readyTags[candidate.href] ? [readyTags[candidate.href]] : [], + ); + const hideAll = React.useCallback(() => setSuppressed(true), []); + const previewList = previews.length ? ( +
+
+ + {previews.map((preview) => ( + + ))} + + +
+
+ ) : null; + const getReadyTags = React.useCallback(() => { + if (suppressedRef.current) return [["link-preview", "none"]]; + return [...activeHrefsRef.current].flatMap((href) => { + const tag = readyTagsByHrefRef.current[href]; + return tag ? [tag] : []; + }); + }, []); + return { previewList, getReadyTags }; +} diff --git a/desktop/src/features/messages/ui/useMentionSendFlow.helpers.ts b/desktop/src/features/messages/ui/useMentionSendFlow.helpers.ts index 76503bfab1e..b2eb3893b7f 100644 --- a/desktop/src/features/messages/ui/useMentionSendFlow.helpers.ts +++ b/desktop/src/features/messages/ui/useMentionSendFlow.helpers.ts @@ -36,6 +36,7 @@ export type SendMessageWithMentionFlowInput = { capturedThreadContext?: PendingNonMemberMentionSend["capturedThreadContext"]; pendingImeta: ImetaMedia[]; queuedAttachments?: QueuedMediaAttachment[]; + linkPreviewTags?: string[][]; sentDraftKey: string | null | undefined; recoveryDraftKey: string | null | undefined; spoileredAttachmentUrls?: ReadonlySet; diff --git a/desktop/src/features/messages/ui/useMentionSendFlow.ts b/desktop/src/features/messages/ui/useMentionSendFlow.ts index 647ad4cbe8f..cc51c733453 100644 --- a/desktop/src/features/messages/ui/useMentionSendFlow.ts +++ b/desktop/src/features/messages/ui/useMentionSendFlow.ts @@ -187,7 +187,6 @@ export function useMentionSendFlow({ pubkeys: [] as string[], }; } - const managedAgentsByPubkey = await getManagedAgentsByPubkey(); for (const agent of preparedManagedAgents) { managedAgentsByPubkey.set(normalizePubkey(agent.pubkey), agent); @@ -198,13 +197,11 @@ export function useMentionSendFlow({ ]); const errors: string[] = []; const pubkeys: string[] = []; - for (const pubkey of uniqueNormalizedPubkeys(mentionPubkeys)) { const agent = managedAgentsByPubkey.get(pubkey); if (!agent) { continue; } - try { if (participantPubkeys.has(pubkey)) { if (isProviderBackedAgent(agent)) { @@ -231,7 +228,6 @@ export function useMentionSendFlow({ ); } } - return { errors, pubkeys: uniqueNormalizedPubkeys(pubkeys), @@ -713,6 +709,7 @@ export function useMentionSendFlow({ capturedThreadContext = null, pendingImeta, queuedAttachments = [], + linkPreviewTags = [], sentDraftKey, recoveryDraftKey, spoileredAttachmentUrls = new Set(), @@ -775,7 +772,10 @@ export function useMentionSendFlow({ createdPersonaAgentPubkeySet.has(pubkey), ); const pubkeys = explicitMentionPubkeys; - const outgoingTags = buildCustomEmojiTags(trimmed, customEmoji); + const outgoingTags = [ + ...buildCustomEmojiTags(trimmed, customEmoji), + ...linkPreviewTags, + ]; const nonMemberPubkeys = getNonMemberMentionPubkeys(pubkeys); let promptNonMemberPubkeys = nonMemberPubkeys.filter( (pubkey) => diff --git a/desktop/src/features/settings/ui/SettingsPanels.tsx b/desktop/src/features/settings/ui/SettingsPanels.tsx index 901c9152b71..64ec8ff552e 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 { useCommunities } from "@/features/communities/useCommunities"; import { Badge } from "@/shared/ui/badge"; @@ -694,11 +699,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 00000000000..fc63502e49b --- /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 6e29f77c143..52aa8f19ebb 100644 --- a/desktop/src/shared/api/tauri.ts +++ b/desktop/src/shared/api/tauri.ts @@ -549,6 +549,7 @@ export async function sendChannelMessage( kind?: number, emojiTags?: string[][], mentionTags?: string[][], + linkPreviewTags?: string[][], ): Promise { const response = await invokeTauri( "send_channel_message", @@ -559,6 +560,7 @@ export async function sendChannelMessage( mediaTags: mediaTags ?? null, emojiTags: emojiTags ?? null, mentionTags: mentionTags ?? null, + linkPreviewTags, mentionPubkeys: mentionPubkeys ?? null, kind: kind ?? null, }, @@ -615,23 +617,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 43cfa66060e..4bf3245dbff 100644 --- a/desktop/src/shared/lib/linkPreview.test.mjs +++ b/desktop/src/shared/lib/linkPreview.test.mjs @@ -71,12 +71,12 @@ test("parseSupportedLinkPreview parses Buzz relay git clone URLs", () => { typeLabel: "repo", }, ); - // Same URL without a matching origin stays external. + // Same URL without a matching origin stays an ordinary external preview. assert.equal( parseSupportedLinkPreview( `https://buzz.block.builderlab.xyz/git/${BUZZ_OWNER}/buzz-world-galaxy`, - ), - null, + )?.kind, + "generic-link", ); }); @@ -108,10 +108,10 @@ test("parseSupportedLinkPreview rejects malformed Buzz git URLs", () => { // Deeper transport paths are not repo links. `https://relay.example/git/${BUZZ_OWNER}/repo/info/refs`, ]) { - // Even with a matching origin, structural issues return null. + // Structural non-matches remain ordinary external previews. assert.equal( - parseSupportedLinkPreview(href, "https://relay.example"), - null, + parseSupportedLinkPreview(href, "https://relay.example")?.kind, + "generic-link", href, ); } @@ -123,8 +123,8 @@ test("parseSupportedLinkPreview rejects clone URLs from non-relay hosts", () => parseSupportedLinkPreview( `https://evil.example/git/${BUZZ_OWNER}/my-repo`, "https://buzz.block.builderlab.xyz", - ), - null, + )?.kind, + "generic-link", ); // github.com sharing the path shape must never become a Buzz repo card. assert.equal( @@ -139,8 +139,8 @@ test("parseSupportedLinkPreview rejects clone URLs from non-relay hosts", () => parseSupportedLinkPreview( `https://buzz.block.builderlab.xyz/git/${BUZZ_OWNER}/buzz-world`, null, - ), - null, + )?.kind, + "generic-link", ); }); @@ -288,8 +288,8 @@ test("extractSupportedLinkPreviews picks up bare Buzz clone URLs in prose", () = assert.deepEqual( extractSupportedLinkPreviews( `clone: https://buzz.block.builderlab.xyz/git/${BUZZ_OWNER}/buzz-world-galaxy`, - ), - [], + ).map((preview) => preview.kind), + ["generic-link"], ); }); @@ -323,6 +323,7 @@ test("clone URLs and buzz://repo links for the same repo dedupe to one card", () `https://relay.example/git/${BUZZ_OWNER}/buzz-world-galaxy`, `buzz://repo?owner=${BUZZ_OWNER}&d=buzz-world-galaxy`, ].join(" "), + "https://relay.example", ).map((preview) => preview.href), [`buzz://repo?owner=${BUZZ_OWNER}&d=buzz-world-galaxy`], ); @@ -412,7 +413,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( [ @@ -421,7 +422,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"], ); }); @@ -467,105 +468,38 @@ test("isSupportedLinkAutolinkLabel matches normalized bare URL labels", () => { assert.equal(isSupportedLinkAutolinkLabel("review this", preview), false); }); -// ── useResolvedLinkPreviews: behavioral regression pins ────────────────────── -// -// These tests pin the three behaviors that were implemented without tests in -// the initial fix round. They use the exported pure helpers directly so no -// React hook environment is required. - -import { - getLinkPreviewCacheGeneration, - resetLinkPreviewTitleCache, - shouldResolveTitle, -} from "./useResolvedLinkPreviews.ts"; -import { buzzEntityFallbackTitle } from "./linkPreview.ts"; - -const OWNER_HEX = - "71d67180ba17e749ee825fc8819c9c6ee7003617e1c126504f9b658070ab9224"; -const EVENT_HEX = - "c3b589fa5713ba25bad6dc095e2de00a4ac8f50050fdea00fc6444e603be1dd1"; - -function makePrPreview(title) { - return { - kind: "buzz-pull-request", - href: `buzz://pr?id=${EVENT_HEX}&owner=${OWNER_HEX}&d=buzz-world`, - title, - provider: "Buzz", - typeLabel: "pr", - }; -} - -// 1. Cache epoch: stale promise cannot seed the new generation. -// `resetLinkPreviewTitleCache` must increment the generation counter so that -// a promise captured before the reset sees a different generation and skips -// writing back. -test("resetLinkPreviewTitleCache_incrementsGenerationCounter", () => { - const before = getLinkPreviewCacheGeneration(); - resetLinkPreviewTitleCache(); - const after = getLinkPreviewCacheGeneration(); - assert.equal(after, before + 1, "each reset must bump the generation by 1"); - resetLinkPreviewTitleCache(); - assert.equal( - getLinkPreviewCacheGeneration(), - before + 2, - "second reset must increment again", - ); -}); - -// 2. Mismatched a-tag: shouldResolveTitle uses buzzEntityFallbackTitle to -// decide whether to attempt a relay lookup. When the link's href parses to a -// PR/issue with the expected fallback title, resolution should proceed. When -// the title has already been set to something else (explicit label or earlier -// relay result), shouldResolveTitle must return false so the label wins. -test("shouldResolveTitle_fallbackTitle_returnsTrue", () => { - const parsed = { - ok: true, - value: { type: "pr", id: EVENT_HEX, owner: OWNER_HEX, dtag: "buzz-world" }, - }; - // Construct the expected fallback title and verify shouldResolveTitle allows lookup. - const fallback = buzzEntityFallbackTitle(parsed.value); - const preview = makePrPreview(fallback); - assert.equal( - shouldResolveTitle(preview), - true, - "fallback title should trigger relay lookup", +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("shouldResolveTitle_customLabel_returnsFalse_labelMustWin", () => { - // User has written `[My custom PR title](buzz://pr?...)` — the label must - // win; shouldResolveTitle must return false to skip writing the relay title. - const preview = makePrPreview("My custom PR title"); +test("parseSupportedLinkPreview rejects generic HTTP URLs", () => { assert.equal( - shouldResolveTitle(preview), - false, - "custom label must suppress relay title lookup (label-must-win invariant)", + parseSupportedLinkPreview("http://example.com/articles/rich-previews"), + null, ); }); -// 3. Label-rerender: converting a bare link to `[label](link)` changes the -// preview title away from the fallback — shouldResolveTitle transitions -// from true to false, so a cached relay title is not applied. -test("shouldResolveTitle_transitionsFromTrueToFalseWhenLabelApplied", () => { - const parsed = { - ok: true, - value: { type: "pr", id: EVENT_HEX, owner: OWNER_HEX, dtag: "buzz-world" }, - }; - const fallback = buzzEntityFallbackTitle(parsed.value); - - // Before the label: bare link with fallback title — should resolve. - const barePreview = makePrPreview(fallback); - assert.equal( - shouldResolveTitle(barePreview), - true, - "bare link should resolve", - ); - - // After the label: same href but title is now the user's label — must NOT resolve. - const labeledPreview = makePrPreview("My labeled PR"); - assert.equal( - shouldResolveTitle(labeledPreview), - false, - "labeled link must not overwrite label with cached relay title", +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 b518b0728ee..58d8739ef14 100644 --- a/desktop/src/shared/lib/linkPreview.ts +++ b/desktop/src/shared/lib/linkPreview.ts @@ -19,20 +19,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: - | "Buzz" - | "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" @@ -41,16 +38,17 @@ export type SupportedLinkPreview = { | "folder" | "document" | "spreadsheet" - | "presentation"; + | "presentation" + | "link"; }; // Buzz relay hosts differ per community, so relay git URLs are recognized by // their distinctive path shape (`/git/<64-hex-pubkey>/`) rather than by -// hostname, and require an explicit scheme. +// hostname, and require an explicit scheme. Generic previews remain HTTPS-only. const SUPPORTED_URL_RE = - /(^|[\s([{<>"'])((?:https?:\/\/)?(?:(?:www\.)?github\.com|(?:www\.)?linear\.app|drive\.google\.com|docs\.google\.com)\/[^\s<>"'\]]+|https?:\/\/[^\s<>"'\]]+\/git\/[a-f0-9]{64}\/[^\s<>"'\]]+|buzz:\/\/(?:pr|issue|repo)\?[^\s<>"'\]]+)/gi; + /(^|[\s([{<>"'])(https:\/\/[^\s<>"'\]]+|https?:\/\/[^\s<>"'\]]+\/git\/[a-f0-9]{64}\/[^\s<>"'\]]+|buzz:\/\/(?:pr|issue|repo)\?[^\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<>"']+|https?:\/\/[^)\s<>"']+\/git\/[a-f0-9]{64}\/[^)\s<>"']+|buzz:\/\/(?:pr|issue|repo)\?[^)\s<>"']+)\)/gi; + /!?\[([^\]\n]+)\]\((https:\/\/[^)\s<>"']+|https?:\/\/[^)\s<>"']+\/git\/[a-f0-9]{64}\/[^)\s<>"']+|buzz:\/\/(?:pr|issue|repo)\?[^)\s<>"']+|(?:(?:www\.)?github\.com|(?:www\.)?linear\.app|drive\.google\.com|docs\.google\.com)\/[^)\s<>"']+)\)/gi; const MAX_PREVIEWS = 8; type HiddenRange = { @@ -543,13 +541,28 @@ export function parseSupportedLinkPreview( return null; } - return ( + const recognized = parseBuzzGitLink(parsed, activeRelayOrigin ?? null) ?? 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/linkPreviewSnapshot.test.mjs b/desktop/src/shared/lib/linkPreviewSnapshot.test.mjs new file mode 100644 index 00000000000..c001e3fb6f3 --- /dev/null +++ b/desktop/src/shared/lib/linkPreviewSnapshot.test.mjs @@ -0,0 +1,116 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + buildLinkPreviewSnapshotTag, + parseLinkPreviewSnapshots, +} from "./linkPreviewSnapshot.ts"; + +const HASH = "a".repeat(64); +const ORIGIN = "https://relay.example"; +const CONTENT = "Read https://linear.app/acme/issue/ABC-123/example"; +const URL = "https://linear.app/acme/issue/ABC-123/example"; +const valid = [ + "link-preview", + "snapshot", + "1", + URL, + "Example", + "Linear", + "Description", + `${ORIGIN}/media/${HASH}.png`, + HASH, + "", + "", +]; + +test("authored snapshots render only exact local media tied to message content", () => { + assert.equal(parseLinkPreviewSnapshots([valid], CONTENT, ORIGIN).length, 1); + assert.equal( + parseLinkPreviewSnapshots([valid], "no matching link", ORIGIN).length, + 0, + ); + assert.equal(parseLinkPreviewSnapshots([valid], CONTENT, null).length, 0); +}); + +test("authored snapshots reject remote, malformed, credentialed, and hash-mismatched media", () => { + for (const image of [ + `https://evil.example/media/${HASH}.png`, + `${ORIGIN}/media/${HASH}.png?token=leak`, + `${ORIGIN}/media/${HASH}.png#fragment`, + `https://user@relay.example/media/${HASH}.png`, + `${ORIGIN}/media/${HASH}.svg`, + `${ORIGIN}/media/${HASH}.png/extra`, + ]) { + const tag = [...valid]; + tag[7] = image; + assert.equal( + parseLinkPreviewSnapshots([tag], CONTENT, ORIGIN).length, + 0, + image, + ); + } + const mismatch = [...valid]; + mismatch[8] = "b".repeat(64); + assert.equal( + parseLinkPreviewSnapshots([mismatch], CONTENT, ORIGIN).length, + 0, + ); +}); + +test("snapshot tags sanitize control characters and enforce UTF-8 byte limits", () => { + const tag = buildLinkPreviewSnapshotTag({ + canonicalUrl: URL, + title: `Title\n${"😀".repeat(100)}`, + siteName: `Linear\u0000${"é".repeat(100)}`, + description: `First\nSecond\t${"😀".repeat(300)}`, + imageUrl: "", + imageSha256: "", + faviconUrl: "", + faviconSha256: "", + }); + + assert.ok(tag); + assert.equal(tag[6].startsWith("First\nSecond"), true); + assert.equal(tag[6].includes("\t"), false); + for (const value of tag.slice(4, 6)) { + assert.ok( + Array.from(value).every((char) => { + const code = char.charCodeAt(0); + return code > 0x1f && code !== 0x7f; + }), + ); + } + assert.ok(Buffer.byteLength(tag[4], "utf8") <= 300); + assert.ok(Buffer.byteLength(tag[5], "utf8") <= 100); + assert.ok(Buffer.byteLength(tag[6], "utf8") <= 1000); +}); + +test("snapshot tags omit canonical URLs rejected by native validation", () => { + for (const canonicalUrl of [ + `${URL}#`, + `${URL}#details`, + `http://linear.app/acme/issue/ABC-123/example`, + `https://user@linear.app/acme/issue/ABC-123/example`, + ]) { + assert.equal( + buildLinkPreviewSnapshotTag({ + canonicalUrl, + title: "Example", + siteName: "Linear", + description: "Description", + imageUrl: "", + imageSha256: "", + faviconUrl: "", + faviconSha256: "", + }), + null, + canonicalUrl, + ); + } +}); + +test("messages without authored snapshots never create recipient previews", () => { + assert.deepEqual(parseLinkPreviewSnapshots([], CONTENT, ORIGIN), []); + assert.deepEqual(parseLinkPreviewSnapshots(undefined, CONTENT, ORIGIN), []); +}); diff --git a/desktop/src/shared/lib/linkPreviewSnapshot.ts b/desktop/src/shared/lib/linkPreviewSnapshot.ts new file mode 100644 index 00000000000..59284f26169 --- /dev/null +++ b/desktop/src/shared/lib/linkPreviewSnapshot.ts @@ -0,0 +1,179 @@ +import { + extractSupportedLinkPreviews, + parseSupportedLinkPreview, +} from "./linkPreview"; +import type { ResolvedLinkPreview } from "./useResolvedLinkPreviews"; + +export const LINK_PREVIEW_SNAPSHOT_VERSION = "1"; +const MAX_SNAPSHOTS = 8; +const SHA256_RE = /^[0-9a-f]{64}$/; +const IMAGE_EXT_RE = /^(?:jpg|png|gif|webp)$/; + +export function isValidLinkPreviewSnapshotCanonicalUrl(value: string): boolean { + if (value.includes("#")) return false; + try { + const url = new URL(value); + return ( + url.protocol === "https:" && !url.username && !url.password && !url.hash + ); + } catch { + return false; + } +} + +export type LinkPreviewSnapshot = { + canonicalUrl: string; + title: string; + siteName: string; + description: string; + imageUrl: string; + imageSha256: string; + faviconUrl: string; + faviconSha256: string; +}; + +function isControlCharacter(char: string, allowNewlines = false): boolean { + if (allowNewlines && char === "\n") return false; + const code = char.charCodeAt(0); + return code <= 0x1f || code === 0x7f; +} + +function sanitizeSnapshotText( + value: string, + maxBytes: number, + allowNewlines = false, +): string { + let result = ""; + let byteLength = 0; + for (const rawChar of value) { + const char = isControlCharacter(rawChar, allowNewlines) ? " " : rawChar; + const charBytes = new TextEncoder().encode(char).length; + if (byteLength + charBytes > maxBytes) break; + result += char; + byteLength += charBytes; + } + return result; +} + +function validText(value: string, max: number, allowNewlines = false): boolean { + return ( + new TextEncoder().encode(value).length <= max && + !Array.from(value).some((char) => { + return isControlCharacter(char, allowNewlines); + }) + ); +} + +function isRelayMediaPair( + url: string, + sha256: string, + relayOrigin: string, +): boolean { + if (!url && !sha256) return true; + if (!url || !SHA256_RE.test(sha256)) return false; + try { + const parsed = new URL(url); + if ( + parsed.origin !== relayOrigin || + parsed.username || + parsed.password || + parsed.search || + parsed.hash + ) + return false; + const match = /^\/media\/([0-9a-f]{64})\.([a-z0-9]{1,8})$/.exec( + parsed.pathname, + ); + return Boolean( + match && match[1] === sha256 && IMAGE_EXT_RE.test(match[2] ?? ""), + ); + } catch { + return false; + } +} + +export function parseLinkPreviewSnapshots( + tags: readonly (readonly string[])[] | undefined, + content: string, + relayOrigin: string | null, +): ResolvedLinkPreview[] { + if (!relayOrigin || !tags) return []; + const contentUrls = new Set( + extractSupportedLinkPreviews(content).map((preview) => preview.href), + ); + const seen = new Set(); + const snapshots: ResolvedLinkPreview[] = []; + for (const tag of tags) { + if (tag[0] !== "link-preview" || tag[1] !== "snapshot") continue; + if ( + snapshots.length >= MAX_SNAPSHOTS || + tag.length !== 11 || + tag[2] !== LINK_PREVIEW_SNAPSHOT_VERSION + ) + continue; + const [ + , + , + , + canonicalUrl, + title, + siteName, + description, + imageUrl, + imageSha256, + faviconUrl, + faviconSha256, + ] = tag; + const parsed = parseSupportedLinkPreview(canonicalUrl); + if ( + !parsed || + parsed.href !== canonicalUrl || + !contentUrls.has(canonicalUrl) || + seen.has(canonicalUrl) + ) + continue; + if ( + !validText(title, 300) || + !validText(siteName, 100) || + !validText(description, 1000, true) + ) + continue; + if ( + !isRelayMediaPair(imageUrl, imageSha256, relayOrigin) || + !isRelayMediaPair(faviconUrl, faviconSha256, relayOrigin) + ) + continue; + seen.add(canonicalUrl); + snapshots.push({ + ...parsed, + title: title || parsed.title, + provider: siteName || parsed.provider, + description: description || null, + faviconDataUrl: faviconUrl || null, + imageDataUrl: imageUrl || null, + imageDomain: imageUrl ? new URL(imageUrl).hostname : null, + imageState: imageUrl ? "image" : "none", + }); + } + return snapshots; +} + +export function buildLinkPreviewSnapshotTag( + snapshot: LinkPreviewSnapshot, +): string[] | null { + if (!isValidLinkPreviewSnapshotCanonicalUrl(snapshot.canonicalUrl)) + return null; + return [ + "link-preview", + "snapshot", + LINK_PREVIEW_SNAPSHOT_VERSION, + snapshot.canonicalUrl, + sanitizeSnapshotText(snapshot.title, 300), + sanitizeSnapshotText(snapshot.siteName, 100), + sanitizeSnapshotText(snapshot.description, 1000, true), + snapshot.imageUrl, + snapshot.imageSha256, + snapshot.faviconUrl, + snapshot.faviconSha256, + ]; +} diff --git a/desktop/src/shared/lib/linkPreviewStylePreference.test.mjs b/desktop/src/shared/lib/linkPreviewStylePreference.test.mjs new file mode 100644 index 00000000000..2cbfdfad481 --- /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 00000000000..3a3c5d78920 --- /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 00000000000..bf7e3936126 --- /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 d1e3d27b35d..136f8f4b1c3 100644 --- a/desktop/src/shared/lib/useResolvedLinkPreviews.ts +++ b/desktop/src/shared/lib/useResolvedLinkPreviews.ts @@ -13,189 +13,380 @@ import { type SupportedLinkPreview, } from "./linkPreview"; -const GOOGLE_FALLBACK_TITLES = new Set([ - "Drive file", - "Drive folder", - "Document", - "Spreadsheet", - "Presentation", -]); - -const titleCache = new Map | string | null>(); -/** - * Generation counter incremented on every `resetLinkPreviewTitleCache` call. - * Each in-flight promise captures the generation at creation time and only - * writes back if no reset has happened since — preventing a resolved promise - * from a previous community from repopulating stale entries into the fresh - * cache of a new community. - */ -let cacheGeneration = 0; +type LinkPreviewImageFetchState = + | "none" + | "image" + | "transient_failure" + | "rejected"; + +export 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; /** - * Buzz entity titles come from relay events, so they are community-scoped — - * wired into `resetCommunityState()` (see useCommunityInit.ts) to avoid - * leaking titles across community switches. + * React may flush an interaction-triggered effect before the browser paints. + * Start uncached preview I/O after a frame plus a task boundary so the pasted + * text and loading card are visible before native IPC work begins. */ -export function resetLinkPreviewTitleCache(): void { - cacheGeneration += 1; - titleCache.clear(); +function scheduleAfterPaint(task: () => void): () => void { + let frameId: number | null = null; + let timeoutId: ReturnType | null = null; + const run = () => { + timeoutId = setTimeout(task, 0); + }; + + if (typeof requestAnimationFrame === "function") { + frameId = requestAnimationFrame(run); + } else { + run(); + } + + return () => { + if (frameId !== null) cancelAnimationFrame(frameId); + if (timeoutId !== null) clearTimeout(timeoutId); + }; } -/** - * Returns the current cache generation counter. Used in tests to verify that - * `resetLinkPreviewTitleCache` increments the generation so stale in-flight - * promises cannot seed the new cache. - * - * @internal test-only export - */ -export function getLinkPreviewCacheGeneration(): number { - return cacheGeneration; +function metadataCacheKey(href: string): string { + try { + const url = new URL(href); + url.hash = ""; + return url.href; + } catch { + return href.split("#", 1)[0] ?? href; + } } -function fetchLinkPreviewTitle(href: string): Promise { - return invokeTauri("fetch_link_preview_title", { href }); +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; } -/** - * Resolve a Buzz PR/issue card title from the relay event's `subject` tag - * (first content line as fallback — the same precedence the projects views - * use). - * - * Security: the fetched event's canonical `a` tag must equal - * `30617::` from the link before we adopt its title. Without this - * check, a crafted link could pair the real title of a legitimate PR with an - * unrelated repository destination. - */ -async function fetchBuzzEntityTitle(href: string): Promise { - const parsed = parseEntityLink(href); - if (!parsed.ok || parsed.value.type === "repo") return null; - - const { id, owner, dtag } = parsed.value; - const expectedCoordinate = `30617:${owner}:${dtag}`; - - const events = await relayClient.fetchEvents({ - kinds: [ - parsed.value.type === "pr" ? KIND_GIT_PULL_REQUEST : KIND_GIT_ISSUE, - ], - ids: [id], - limit: 1, - }); - const event = events[0]; - if (!event) return null; - - // Verify the event belongs to the claimed repository coordinate. - const aTag = event.tags.find( - (tag) => tag[0] === "a" && tag[1] === expectedCoordinate, - ); - if (!aTag) return null; +function createTaskScheduler(concurrency: number) { + const pending: Array<() => void> = []; + let active = 0; - const subject = event.tags.find((tag) => tag[0] === "subject")?.[1]; - return subject || event.content.split("\n")[0] || null; + 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(); + }); } -/** - * Returns true when the preview's current title is still the auto-generated - * fallback and a relay lookup should be attempted to replace it. Returns false - * once the user has applied a markdown label (`[My label](link)`) so that the - * label wins over any cached relay title. - * - * Exported for unit testing of the label-must-win invariant. - */ -export function shouldResolveTitle(preview: SupportedLinkPreview): boolean { - if (preview.kind === "buzz-pull-request" || preview.kind === "buzz-issue") { - // A markdown-label override replaces the fallback title and must win - // over the relay lookup. - const parsed = parseEntityLink(preview.href); - return parsed.ok && preview.title === buzzEntityFallbackTitle(parsed.value); - } +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; - return ( - preview.kind.startsWith("google-") && - GOOGLE_FALLBACK_TITLES.has(preview.title) - ); + 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 resolveTitle(preview: SupportedLinkPreview): Promise { - return preview.href.startsWith("buzz://") - ? fetchBuzzEntityTitle(preview.href) - : fetchLinkPreviewTitle(preview.href); +function fetchLinkPreviewMetadata( + href: string, +): Promise { + return invokeTauri( + "fetch_link_preview_metadata", + { + href, + }, + ); } -function cacheTitle(preview: SupportedLinkPreview): Promise { - const cached = titleCache.get(preview.href); - if (cached instanceof Promise) return cached; - if (cached !== undefined) return Promise.resolve(cached); - - const generation = cacheGeneration; - const promise = resolveTitle(preview) - .then((title) => { - // Only write back if no community switch has happened since we started. - if (cacheGeneration === generation) { - titleCache.set(preview.href, title); - } - return title; - }) - .catch(() => { - if (cacheGeneration === generation) { - titleCache.set(preview.href, null); - } - return null; +const metadataLoader = createMetadataLoader({ + fetcher: fetchLinkPreviewMetadata, +}); +const entityTitleLoader = createMetadataLoader({ + fetcher: async (href) => { + const parsed = parseEntityLink(href); + if (!parsed.ok || parsed.value.type === "repo") return null; + + const { id, owner, dtag } = parsed.value; + const expectedCoordinate = `30617:${owner}:${dtag}`; + const events = await relayClient.fetchEvents({ + kinds: [ + parsed.value.type === "pr" ? KIND_GIT_PULL_REQUEST : KIND_GIT_ISSUE, + ], + ids: [id], + limit: 1, }); - titleCache.set(preview.href, promise); - return promise; + const event = events[0]; + if ( + !event?.tags.some( + (tag) => tag[0] === "a" && tag[1] === expectedCoordinate, + ) + ) { + return null; + } + + const subject = event.tags.find((tag) => tag[0] === "subject")?.[1]; + const title = subject || event.content.split("\n")[0] || null; + return title + ? { + title, + siteName: "Buzz", + description: null, + imageDataUrl: null, + imageDomain: null, + } + : null; + }, +}); + +/** Clear ephemeral metadata when the active relay/community changes. */ +export function resetLinkPreviewMetadataCache(): void { + metadataLoader.reset(); + entityTitleLoader.reset(); +} + +export type LinkPreviewImageState = "pending" | "image" | "fallback" | "none"; + +export type ResolvedLinkPreview = SupportedLinkPreview & { + description?: string | null; + faviconDataUrl?: string | null; + imageState: LinkPreviewImageState; + /** Metadata extraction completed successfully; safe to snapshot after media uploads. */ + snapshotReady?: boolean; +}; + +type ResolvedMetadataByHref = Record< + string, + LinkPreviewMetadata | null | undefined +>; + +/** Only auto-generated titles may be replaced; explicit markdown labels win. */ +export function shouldResolveTitle(preview: SupportedLinkPreview): boolean { + if (preview.kind !== "buzz-pull-request" && preview.kind !== "buzz-issue") { + return true; + } + const parsed = parseEntityLink(preview.href); + return parsed.ok && preview.title === buzzEntityFallbackTitle(parsed.value); +} + +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, + snapshotReady: !preview.href.startsWith("buzz://"), + title: shouldResolveTitle(preview) ? metadata.title : preview.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, + loader: typeof metadataLoader, + ) => { + if (expiresAt === null || expiresAt >= retryAt) return; + retryAt = expiresAt; + if (retryTimer !== null) clearTimeout(retryTimer); + retryTimer = setTimeout( + () => { + loader.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()), + ); + }; + + const cancelScheduledLoads: Array<() => void> = []; + for (const preview of previews) { + const loader = preview.href.startsWith("buzz://") + ? entityTitleLoader + : metadataLoader; + const cached = loader.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, loader); continue; } - void cacheTitle(preview).then((title) => { - if (cancelled || !title) return; - setResolvedTitles((current) => - current[preview.href] === title - ? current - : { ...current, [preview.href]: title }, - ); - }); + cancelScheduledLoads.push( + scheduleAfterPaint(() => { + void loader.load(preview.href).then((result) => { + if (cancelled) return; + setResolvedMetadata((current) => + current[result.key] === result.metadata + ? current + : { ...current, [result.key]: result.metadata }, + ); + scheduleRetry(result, loader); + }); + }), + ); } return () => { cancelled = true; + for (const cancel of cancelScheduledLoads) cancel(); + if (retryTimer !== null) clearTimeout(retryTimer); }; - }, [previews]); + }, [previews, retryGeneration]); return React.useMemo( () => - previews.map((preview) => { - const title = resolvedTitles[preview.href]; - // Only apply a relay-resolved title while the preview still has the - // fallback title (i.e. no markdown-label override). If the user edits - // a bare link into `[My label](same-link)`, `shouldResolveTitle` - // returns false and the label wins — the cached relay title is not - // applied to prevent it from silently overriding the explicit label. - return title && shouldResolveTitle(preview) - ? { ...preview, title } - : preview; + previews.flatMap((preview) => { + const metadata = resolvedMetadata[metadataCacheKey(preview.href)]; + return metadata === null ? [] : [resolveLinkPreview(preview, metadata)]; }), - [previews, resolvedTitles], + [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 00000000000..89ec2c34e77 --- /dev/null +++ b/desktop/src/shared/ui/compact-link-preview-attachment.tsx @@ -0,0 +1,176 @@ +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, + onOpen, + onRemove, + preview, + showControls = false, +}: { + className?: string; + onOpen?: () => void; + 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} + + { + event.preventDefault(); + onOpen(); + } + : undefined + } + rel="noreferrer" + target="_blank" + > + {preview.faviconDataUrl ? ( + + ) : null} + {hostname} + + + {preview.title} + + {preview.description ? ( + + {preview.description} + + ) : null} + + {onOpen ? ( + + + Open {preview.provider} {preview.typeLabel}: {preview.title} + + + ) : ( + + + + 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 ad848a42ba2..743d2471ef2 100644 --- a/desktop/src/shared/ui/link-preview-attachment.tsx +++ b/desktop/src/shared/ui/link-preview-attachment.tsx @@ -1,181 +1,47 @@ -import { ExternalLink } from "lucide-react"; - -import type { SupportedLinkPreview } from "@/shared/lib/linkPreview"; -import { cn } from "@/shared/lib/cn"; -import { BuzzMark } from "@/shared/ui/buzz-logo/BuzzMark"; +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 "buzz-issue": - case "buzz-pull-request": - case "buzz-repository": - return ; - 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, onOpen, + onRemove, preview, + showControls, }: { className?: string; - /** - * In-app navigation handler for links the OS cannot open (e.g. `buzz://` - * entity deep links). When set, the card renders a button trigger instead - * of an external anchor. - */ + ImageLightbox: LinkPreviewImageLightboxComponent; onOpen?: () => void; - preview: SupportedLinkPreview; + onRemove?: () => void; + preview: ResolvedLinkPreview; + showControls?: boolean; }) { + const style = useLinkPreviewStyle(); + if (style === "rich") { + return ( + + ); + } + return ( - - - - - -
- {preview.provider} - - {preview.typeLabel} -
- {preview.title} -
- - - {onOpen ? ( - - - Open {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 00000000000..66ac98bb6fe --- /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 00000000000..a389e3a1365 --- /dev/null +++ b/desktop/src/shared/ui/link-preview-list.tsx @@ -0,0 +1,98 @@ +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, + onOpenByHref, + onRemoveForEveryone, + previews, +}: { + ImageLightbox: LinkPreviewImageLightboxComponent; + onOpenByHref?: ReadonlyMap void>; + 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 414b44a9664..a2fb0e42d61 100644 --- a/desktop/src/shared/ui/markdown.tsx +++ b/desktop/src/shared/ui/markdown.tsx @@ -22,16 +22,13 @@ import { UserProfilePopover } from "@/features/profile/ui/UserProfilePopover"; import { invokeTauri } from "@/shared/api/tauri"; import { useChannelNavigation } from "@/shared/context/ChannelNavigationContext"; import { cn } from "@/shared/lib/cn"; -import { - extractSupportedLinkPreviews, - parseSupportedLinkPreview, -} from "@/shared/lib/linkPreview"; -import { useResolvedLinkPreviews } from "@/shared/lib/useResolvedLinkPreviews"; +import { parseSupportedLinkPreview } from "@/shared/lib/linkPreview"; +import { parseLinkPreviewSnapshots } from "@/shared/lib/linkPreviewSnapshot"; import { rewriteRelayUrl } from "@/shared/lib/mediaUrl"; import { useRelayOrigin } from "@/shared/lib/useRelayOrigin"; 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, @@ -66,6 +63,7 @@ import { import { ExternalLinkAnchor } from "./markdown/ExternalLinkAnchor"; import { FileCard } from "./markdown/FileCard"; import { InlineEmojiPopover } from "./markdown/InlineEmojiPopover"; +import { createLinkPreviewImageLightbox } from "./markdown/LinkPreviewImageLightbox"; import { MarkdownInput } from "./markdown/MarkdownInput"; import { MediaContextMenu, @@ -663,16 +661,10 @@ function ImageZoomOverlay({ const frameCornerRadii = isReturning ? returnCornerRadii : imageLightboxExpandedCornerRadii(); - // Once fully settled at 1x, drop the transform to `none` so the wrapper - // leaves the GPU-composited path and the repaints through WebKit's - // high-quality paint rasterizer — matching inline-image sharpness. An - // identity `translate3d` would keep it composited, so it must be `none`. const atRest = isOpen && hasEntered && zoom === IMAGE_LIGHTBOX_MIN_ZOOM && - // Holds composited through the trackpad gesture-end idle window: after a - // pinch settles back to exactly 1x, `isAdjustingZoom` stays true for // IMAGE_LIGHTBOX_TRACKPAD_ZOOM_IDLE_MS, avoiding a demote/re-promote thrash. !isAdjustingZoom; const transform = atRest @@ -999,6 +991,9 @@ function ImageZoomOverlay({ ); } +export const LinkPreviewImageLightbox = + createLinkPreviewImageLightbox(ImageZoomOverlay); + /** * Inline image embed with click-to-zoom lightbox and right-click download. * @@ -1777,6 +1772,10 @@ function MarkdownInner({ interactive = true, agentMentionPubkeysByName, mediaInset = false, + messageId, + linkPreviewsSuppressed = false, + linkPreviewTags, + onRemoveLinkPreviewsForEveryone, mentionNames, mentionPubkeysByName, searchQuery, @@ -1810,10 +1809,18 @@ function MarkdownInner({ [goChannel], ); const relayOrigin = useRelayOrigin(); - const linkPreviews = React.useMemo( + const resolvedLinkPreviews = React.useMemo( () => - interactive ? extractSupportedLinkPreviews(content, relayOrigin) : [], - [content, interactive, relayOrigin], + interactive && !linkPreviewsSuppressed + ? parseLinkPreviewSnapshots(linkPreviewTags, content, relayOrigin) + : [], + [ + content, + interactive, + linkPreviewTags, + linkPreviewsSuppressed, + relayOrigin, + ], ); const configNudge = React.useMemo( () => computeConfigNudge(content, interactive, configNudgeAuthorPubkey), @@ -1868,7 +1875,6 @@ function MarkdownInner({ processedContent = `${processedContent}\u200B`; } - const resolvedLinkPreviews = useResolvedLinkPreviews(linkPreviews); const entityCardOpenHandlers = useEntityCardOpenHandlers( resolvedLinkPreviews, onOpenEntityLink, @@ -1922,20 +1928,13 @@ function MarkdownInner({ ) : null} - {resolvedLinkPreviews.length > 0 ? ( - - {resolvedLinkPreviews.map((preview) => ( - - ))} - - ) : null} +
diff --git a/desktop/src/shared/ui/markdown/LinkPreviewImageLightbox.tsx b/desktop/src/shared/ui/markdown/LinkPreviewImageLightbox.tsx new file mode 100644 index 00000000000..c689dd9efdd --- /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 1c57cb9e5a9..4c0c01f93e5 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 f736922f93e..20ecfc2e084 100644 --- a/desktop/src/shared/ui/markdown/types.ts +++ b/desktop/src/shared/ui/markdown/types.ts @@ -68,6 +68,11 @@ export type MarkdownProps = { mentionNames?: string[]; mentionPubkeysByName?: Record; mediaInset?: boolean; + /** Event/message identity used only for local preview-image visibility. */ + messageId?: string; + linkPreviewsSuppressed?: boolean; + linkPreviewTags?: readonly (readonly string[])[]; + 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 00000000000..4d32aa1f123 --- /dev/null +++ b/desktop/src/shared/ui/rich-link-preview-attachment.tsx @@ -0,0 +1,350 @@ +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, + onOpen, + onRemove, + preview, + showControls = false, +}: { + className?: string; + ImageLightbox: LinkPreviewImageLightboxComponent; + onOpen?: () => void; + 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 ( +
+ + {contentExpanded && reserveImage ? ( + + ) : null} + {hasExpandableContent ? ( + + ) : null} + {showControls ? ( + + ) : null} +
+ ); +} diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index abf74078dac..6961488fefa 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -62,6 +62,10 @@ import type { RuntimeFileConfigSubset, } from "@/shared/api/tauri"; import { normalizePubkey } from "@/shared/lib/pubkey"; +import { + isValidLinkPreviewSnapshotCanonicalUrl, + parseLinkPreviewSnapshots, +} from "@/shared/lib/linkPreviewSnapshot"; type TestIdentity = { privateKey: string; @@ -335,6 +339,32 @@ 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; + /** Simulates native cold-cache startup work before the async response. */ + linkPreviewMetadataStartBlockMs?: number; searchProfiles?: MockSearchProfileSeed[]; updateAvailable?: boolean; updateChannelDelayMs?: number; @@ -8912,12 +8942,30 @@ async function resolveMockUploadDescriptorForBytes( value.toString(16).padStart(2, "0"), ).join(""); const filename = args.filename ?? "upload.bin"; - const isAgentJson = filename.toLowerCase().endsWith(".agent.json"); + const normalizedFilename = filename.toLowerCase(); + const isAgentJson = normalizedFilename.endsWith(".agent.json"); + const extension = isAgentJson + ? "json" + : normalizedFilename.endsWith(".png") + ? "png" + : normalizedFilename.endsWith(".jpg") || + normalizedFilename.endsWith(".jpeg") + ? "jpg" + : normalizedFilename.endsWith(".gif") + ? "gif" + : normalizedFilename.endsWith(".webp") + ? "webp" + : "bin"; + const type = isAgentJson + ? "application/json" + : extension === "bin" + ? "application/octet-stream" + : `image/${extension === "jpg" ? "jpeg" : extension}`; return { - url: `https://mock.relay/media/${sha256}${isAgentJson ? ".json" : ".bin"}`, + url: `${getRelayHttpUrl(config)}/media/${sha256}.${extension}`, sha256, size: bytes.length, - type: isAgentJson ? "application/json" : "application/octet-stream", + type, uploaded: Math.floor(Date.now() / 1000), filename, }; @@ -8933,6 +8981,8 @@ async function handleSendChannelMessage( mediaTags?: string[][] | null; emojiTags?: string[][] | null; mentionTags?: string[][] | null; + linkPreviewTags?: string[][] | null; + suppressLinkPreviews?: boolean; }, config: E2eConfig | undefined, ): Promise { @@ -8955,7 +9005,44 @@ async function handleSendChannelMessage( // Reference-only mentions are already part of the outbound event. Preserve // them in the mock event too so local echoes match the complete sent tag set. const mentionTags = args.mentionTags ?? []; - const extraTags = [...mediaTags, ...emojiTags, ...mentionTags]; + // Sender-authored link preview snapshots are independently validated by the + // real command and echoed on the stored event. Preserve them in the mock so + // E2E recipient rendering exercises the same authored-snapshot path. + const linkPreviewTags = args.linkPreviewTags ?? []; + if (linkPreviewTags.length > 0) { + const suppression = linkPreviewTags.some( + (tag) => + tag.length === 2 && tag[0] === "link-preview" && tag[1] === "none", + ); + const snapshots = linkPreviewTags.filter( + (tag) => tag[0] === "link-preview" && tag[1] === "snapshot", + ); + const validSnapshots = parseLinkPreviewSnapshots( + snapshots, + args.content, + new URL(getRelayHttpUrl(config)).origin, + ); + if ( + (suppression && linkPreviewTags.length !== 1) || + (!suppression && + (snapshots.length !== linkPreviewTags.length || + validSnapshots.length !== snapshots.length || + snapshots.some( + (tag) => !isValidLinkPreviewSnapshotCanonicalUrl(tag[3] ?? ""), + ))) + ) { + throw new Error("invalid link-preview snapshot tag"); + } + } + // All independently validated kinds end up on the stored event's tag set, + // just like the real relay. + const extraTags = [ + ...mediaTags, + ...emojiTags, + ...mentionTags, + ...linkPreviewTags, + ...(args.suppressLinkPreviews ? [["link-preview", "none"]] : []), + ]; const identity = getIdentity(config); if (!identity) { const createdAt = Math.floor(Date.now() / 1000); @@ -11122,6 +11209,26 @@ export function maybeInstallE2eTauriMocks() { return; case "fetch_join_policy": return activeConfig?.mock?.joinPolicy ?? null; + case "fetch_link_preview_metadata": { + const startBlockMs = + activeConfig?.mock?.linkPreviewMetadataStartBlockMs ?? 0; + if (startBlockMs > 0) { + const stopAt = performance.now() + startBlockMs; + while (performance.now() < stopAt) { + // Deliberately block to model uncached native command startup. + } + } + 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) { @@ -12502,7 +12609,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/composer-link-shortcut.spec.ts b/desktop/tests/e2e/composer-link-shortcut.spec.ts index 81f8896f7db..43a6646f4c1 100644 --- a/desktop/tests/e2e/composer-link-shortcut.spec.ts +++ b/desktop/tests/e2e/composer-link-shortcut.spec.ts @@ -61,10 +61,11 @@ test("⌘K with caret inside an existing link opens the edit-link dialog", async "docs", ); - // Click into the linked text to place the caret inside it, then re-trigger - // the shortcut. (The click also surfaces the composer link hover card — - // ⌘K must open the full dialog from that state.) + // Click into the linked text to place the caret inside it. This must not + // surface contextual controls; editing stays accessible through ⌘K. await input.locator('a[href="https://example.com"]').click(); + await expect(page.getByRole("button", { name: "Edit link" })).toHaveCount(0); + await expect(page.getByRole("button", { name: "Unlink" })).toHaveCount(0); await page.keyboard.press("ControlOrMeta+k"); const editDialog = page.getByRole("dialog", { name: "Edit link" }); diff --git a/desktop/tests/e2e/inbox-edit.spec.ts b/desktop/tests/e2e/inbox-edit.spec.ts index a962056fa65..63cc3c570da 100644 --- a/desktop/tests/e2e/inbox-edit.spec.ts +++ b/desktop/tests/e2e/inbox-edit.spec.ts @@ -290,17 +290,19 @@ test("editing an immediate attachment reply preserves its media tags", async ({ }); expect(editPayload).toEqual( expect.objectContaining({ - eventId: replyId, - mediaTags: [ - [ - "imeta", - `url ${ATTACHMENT_URL}`, - "m application/pdf", - `x ${"a".repeat(64)}`, - "size 12345", - `filename ${ATTACHMENT_FILENAME}`, + input: expect.objectContaining({ + eventId: replyId, + mediaTags: [ + [ + "imeta", + `url ${ATTACHMENT_URL}`, + "m application/pdf", + `x ${"a".repeat(64)}`, + "size 12345", + `filename ${ATTACHMENT_FILENAME}`, + ], ], - ], + }), }), ); diff --git a/desktop/tests/e2e/messaging.spec.ts b/desktop/tests/e2e/messaging.spec.ts index 5d833f77133..a0808e4a6af 100644 --- a/desktop/tests/e2e/messaging.spec.ts +++ b/desktop/tests/e2e/messaging.spec.ts @@ -1,9 +1,20 @@ 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"; +async function waitForReadyComposerSnapshots( + page: import("@playwright/test").Page, + count = 1, +) { + await expect(page.locator("[data-composer-link-previews]")).toHaveAttribute( + "data-ready-snapshot-count", + String(count), + ); +} + async function expectThreadReplyUnobscured(row: Locator) { await expect .poll(async () => @@ -93,7 +104,7 @@ async function measureThreadSummaryGeometry(summaryRow: Locator) { } test.beforeEach(async ({ page }, testInfo) => { - const mock = testInfo.title.includes("agent owner label") + const baseMock = testInfo.title.includes("agent owner label") ? { searchProfiles: [ { @@ -108,7 +119,137 @@ 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/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=", + 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: null, + imageDomain: null, + imageFetchState: "rejected", + 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/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=", + imageDomain: "opengraph.githubassets.com", + faviconDataUrl: + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=", + }, + linkPreviewMetadataDelayMs: 800, + } + : testInfo.title.includes("link preview no-image layout") || + testInfo.title.includes("composer no-image link embeds") + ? { + 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, + } + : testInfo.title.includes( + "rich link preview preserves description newlines", + ) + ? { + linkPreviewMetadata: { + title: "Buzz pull request", + siteName: "GitHub", + description: + "First paragraph line one.\nFirst paragraph line two.\n\nSecond paragraph.", + imageDataUrl: null, + imageDomain: null, + }, + } + : testInfo.title.includes("link preview") || + testInfo.title.includes("supported Compact") + ? { + linkPreviewMetadata: { + title: "Buzz pull request", + siteName: "GitHub", + description: "A sender-authored preview snapshot.", + imageDataUrl: null, + imageDomain: null, + }, + linkPreviewMetadataDelayMs: testInfo.title.includes( + "loading card before cold resolver work", + ) + ? 10_000 + : testInfo.title.includes("style defaults") || + testInfo.title.includes("send does not wait") || + testInfo.title.includes("attachment-sized") + ? 1_500 + : undefined, + linkPreviewMetadataStartBlockMs: + testInfo.title.includes( + "loading card before cold resolver work", + ) + ? 150 + : undefined, + } + : undefined; + const mock = testInfo.title.includes("unresolvable preview") + ? { linkPreviewMetadata: null, linkPreviewMetadataDelayMs: 150 } + : baseMock; await installMockBridge(page, mock); }); @@ -250,7 +391,529 @@ 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); + const composerPreview = page + .locator("[data-composer-link-previews]") + .locator('[data-link-preview="github-pull-request"]'); + await expect(composerPreview).toHaveAttribute("data-image-state", "pending"); + if (process.env.BUZZ_LINK_PREVIEW_SCREENSHOTS_DIR) { + await waitForAnimations(page); + await page.screenshot({ + animations: "disabled", + path: `${process.env.BUZZ_LINK_PREVIEW_SCREENSHOTS_DIR}/compact-composer-loading.png`, + }); + } + await waitForReadyComposerSnapshots(page); + await expect(composerPreview).toHaveAttribute("data-image-state", "none"); + if (process.env.BUZZ_LINK_PREVIEW_SCREENSHOTS_DIR) { + await waitForAnimations(page); + await page.screenshot({ + animations: "disabled", + path: `${process.env.BUZZ_LINK_PREVIEW_SCREENSHOTS_DIR}/compact-composer-ready.png`, + }); + } + 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"); + if (process.env.BUZZ_LINK_PREVIEW_SCREENSHOTS_DIR) { + await waitForAnimations(page); + await page.screenshot({ + animations: "disabled", + path: `${process.env.BUZZ_LINK_PREVIEW_SCREENSHOTS_DIR}/recipient-compact.png`, + }); + } + + 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); + if (process.env.BUZZ_LINK_PREVIEW_SCREENSHOTS_DIR) { + await waitForAnimations(page); + await page.screenshot({ + animations: "disabled", + path: `${process.env.BUZZ_LINK_PREVIEW_SCREENSHOTS_DIR}/recipient-rich.png`, + }); + const richComposerUrl = `${previewUrl}&composer=rich`; + await page.getByTestId("message-input").fill(richComposerUrl); + const richComposerPreview = page + .locator("[data-composer-link-previews]") + .locator('[data-link-preview="github-pull-request"]'); + await expect(richComposerPreview).toHaveAttribute( + "data-image-state", + "pending", + ); + await waitForAnimations(page); + await page.screenshot({ + animations: "disabled", + path: `${process.env.BUZZ_LINK_PREVIEW_SCREENSHOTS_DIR}/rich-composer-loading.png`, + }); + await waitForReadyComposerSnapshots(page); + await expect(richComposerPreview).toHaveAttribute( + "data-image-state", + "none", + ); + await waitForAnimations(page); + await page.screenshot({ + animations: "disabled", + path: `${process.env.BUZZ_LINK_PREVIEW_SCREENSHOTS_DIR}/rich-composer-ready.png`, + }); + await page.getByTestId("message-input").fill(""); + } + + await openSettings(page, "appearance"); + await page.getByTestId("link-preview-style-trigger").click(); + await page.getByTestId("link-preview-style-compact").click(); +}); + +for (const [pasteShape, wrapUrl] of [ + ["bare", (url: string) => url], + ["angle-bracket", (url: string) => `<${url}>`], +] as const) { + test(`${pasteShape} link preview paste paints before cold resolver work`, async ({ + page, + }) => { + const previewUrl = `https://github.com/block/buzz/pull/3246?paste=${pasteShape}`; + await page.goto("/"); + await page.getByTestId("channel-general").click(); + const input = page.getByTestId("message-input"); + await input.focus(); + + const pasteText = wrapUrl(previewUrl); + const firstPaint = await input.evaluate(async (element, pasteText) => { + const clipboardData = new DataTransfer(); + clipboardData.setData("text/plain", pasteText); + const startedAt = performance.now(); + element.dispatchEvent( + new ClipboardEvent("paste", { + bubbles: true, + cancelable: true, + clipboardData, + }), + ); + await new Promise((resolve) => + requestAnimationFrame(() => resolve()), + ); + return { + elapsedMs: performance.now() - startedAt, + resolverStarted: (window.__BUZZ_E2E_COMMAND_PAYLOADS__ ?? []).some( + (entry) => entry.command === "fetch_link_preview_metadata", + ), + text: element.textContent, + }; + }, pasteText); + expect(firstPaint.text).toContain(previewUrl); + expect(firstPaint.resolverStarted).toBe(false); + expect(firstPaint.elapsedMs).toBeLessThan(100); + const composerPreview = page + .locator("[data-composer-link-previews]") + .locator('[data-link-preview="github-pull-request"]'); + await expect(input).toContainText(previewUrl, { timeout: 1_000 }); + await expect(composerPreview).toHaveAttribute( + "data-state", + /^(processing|done)$/, + { timeout: 1_000 }, + ); + await expect(page.getByTestId("send-message")).toBeEnabled(); + }); +} + +test("display-text link preview produces and sends its preview", async ({ + page, +}) => { + const previewUrl = "https://github.com/block/buzz/pull/3246?display=text"; + await page.goto("/"); + await page.getByTestId("channel-general").click(); + + const input = page.getByTestId("message-input"); + await input.click(); + await input.pressSequentially("review the pull request"); + await page.keyboard.press("ControlOrMeta+a"); + await page.keyboard.press("ControlOrMeta+k"); + const dialog = page.getByRole("dialog", { name: "Add link" }); + await dialog.getByLabel("URL").fill(previewUrl); + await dialog.getByRole("button", { name: "Save" }).click(); + + await expect(input.locator(`a[href="${previewUrl}"]`)).toHaveText( + "review the pull request", + ); + await expect(page.locator("[data-composer-link-previews]")).toBeVisible(); + await waitForReadyComposerSnapshots(page); + await page.getByTestId("send-message").click(); + + const row = page.getByTestId("message-row").last(); + await expect( + row.getByRole("link", { name: "review the pull request", exact: true }), + ).toHaveAttribute("href", previewUrl); + await expect(row.locator("[data-link-preview]")).toBeVisible(); + const linkPreviewTags = await page.evaluate(() => { + const call = [...(window.__BUZZ_E2E_COMMAND_PAYLOADS__ ?? [])] + .reverse() + .find((entry) => entry.command === "send_channel_message"); + return ( + call?.payload as { linkPreviewTags?: string[][] | null } | undefined + )?.linkPreviewTags; + }); + expect(linkPreviewTags?.map((tag) => tag[3])).toEqual([previewUrl]); +}); + +test("rich link preview preserves description newlines after sending", async ({ + page, +}) => { + const previewUrl = "https://github.com/block/buzz/pull/3246?newlines=1"; + await page.addInitScript(() => + localStorage.setItem("buzz.appearance.linkPreviewStyle", "rich"), + ); + await page.goto("/"); + await page.getByTestId("channel-general").click(); + await page.getByTestId("message-input").fill(previewUrl); + await waitForReadyComposerSnapshots(page); + await page.getByTestId("send-message").click(); + + const description = page + .getByTestId("message-row") + .last() + .locator('[data-link-preview-inline] [data-slot="attachment-description"]'); + await expect(description.locator("p")).toHaveCount(2); + await expect(description).toHaveText( + "First paragraph line one.\nFirst paragraph line two.\n\nSecond paragraph.", + { useInnerText: true }, + ); +}); + +test("completed link previews send when one URL has an unsnapshotable fragment", async ({ + page, +}) => { + const previewUrls = [ + "https://twitter.com/tellaho", + "https://github.com/block/buzz/pull/3246", + "https://x.com/tellaho/status/1884289176381841506#", + ]; + const pastedText = previewUrls.join("\n"); + await page.goto("/"); + await page.getByTestId("channel-general").click(); + const input = page.getByTestId("message-input"); + await input.focus(); + await input.evaluate((element, text) => { + const clipboardData = new DataTransfer(); + clipboardData.setData("text/plain", text); + element.dispatchEvent( + new ClipboardEvent("paste", { + bubbles: true, + cancelable: true, + clipboardData, + }), + ); + }, pastedText); + const composerPreviewCards = page.locator( + "[data-link-preview-composer-card]", + ); + await expect(composerPreviewCards).toHaveCount(2); + await expect( + composerPreviewCards.locator(`a[href="${previewUrls[2]}"]`), + ).toHaveCount(0); + await waitForReadyComposerSnapshots(page, 2); + + const send = page.getByTestId("send-message"); + await expect(send).toBeEnabled(); + await send.click(); + await expect(page.getByTestId("message-input")).toHaveText(""); + await expect(page.locator("[data-composer-link-previews]")).toHaveCount(0); + await page.waitForTimeout(250); + await expect(page.getByTestId("message-input")).toHaveText(""); + + const calls = await page.evaluate(() => + (window.__BUZZ_E2E_COMMAND_PAYLOADS__ ?? []).filter( + (entry) => entry.command === "send_channel_message", + ), + ); + expect(calls).toHaveLength(1); + expect( + ( + calls[0]?.payload as { linkPreviewTags?: string[][] | null } | undefined + )?.linkPreviewTags?.map((tag) => tag[3]), + ).toEqual(previewUrls.slice(0, 2)); +}); + +test("unresolvable preview disappears after the terminal miss", async ({ + page, +}) => { + const previewUrl = "https://x.com/tellaho/status"; + await page.goto("/"); + await page.getByTestId("channel-general").click(); + await page.getByTestId("message-input").fill(previewUrl); + + const composerPreviews = page.locator("[data-composer-link-previews]"); + await expect(composerPreviews).toBeVisible(); + await expect( + composerPreviews.locator("[data-link-preview-composer-card]"), + ).toHaveAttribute("data-image-state", "pending"); + await expect(composerPreviews).toHaveCount(0); + + await page.getByTestId("send-message").click(); + const row = page.getByTestId("message-row").last(); + await expect(row).toContainText(previewUrl); + await expect(row.locator("[data-link-preview]")).toHaveCount(0); +}); + +test("send does not wait for a pending link preview snapshot", async ({ + page, +}) => { + const previewUrl = "https://github.com/block/buzz/pull/3246?send=pending"; + await page.goto("/"); + await page.getByTestId("channel-general").click(); + await page.getByTestId("message-input").fill(previewUrl); + + const composerPreviews = page.locator("[data-composer-link-previews]"); + await expect(composerPreviews).toHaveAttribute( + "data-ready-snapshot-count", + "0", + ); + await expect( + composerPreviews.locator('[data-link-preview="github-pull-request"]'), + ).toHaveAttribute("data-image-state", "pending"); + + await page.getByTestId("send-message").click(); + const row = page.getByTestId("message-row").last(); + await expect(row).toContainText(previewUrl); + await expect(row.locator("[data-link-preview]")).toHaveCount(0); + + const linkPreviewTags = await page.evaluate(() => { + const call = [...(window.__BUZZ_E2E_COMMAND_PAYLOADS__ ?? [])] + .reverse() + .find((entry) => entry.command === "send_channel_message"); + return ( + call?.payload as { linkPreviewTags?: string[][] | null } | undefined + )?.linkPreviewTags; + }); + expect(linkPreviewTags ?? []).toEqual([]); +}); + +test("hiding composer link previews suppresses the whole draft and emits the blanket marker", async ({ + page, +}) => { + const firstUrl = "https://github.com/block/buzz/pull/3246?hide=all"; + const secondUrl = "https://linear.app/acme/issue/ABC-123/hidden-too"; + await page.goto("/"); + await page.getByTestId("channel-general").click(); + await page.getByTestId("message-input").fill(firstUrl); + await expect(page.locator("[data-composer-link-previews]")).toBeVisible(); + await waitForReadyComposerSnapshots(page); + + await page.getByTestId("composer-hide-link-previews").click(); + await expect(page.locator("[data-composer-link-previews]")).toHaveCount(0); + await page.getByTestId("message-input").fill(`${firstUrl} ${secondUrl}`); + await expect(page.locator("[data-composer-link-previews]")).toHaveCount(0); + await page.getByTestId("send-message").click(); + + const row = page.getByTestId("message-row").last(); + await expect(row).toContainText(firstUrl); + await expect(row).toContainText(secondUrl); + await expect(row.locator("[data-link-preview]")).toHaveCount(0); + const linkPreviewTags = await page.evaluate(() => { + const call = [...(window.__BUZZ_E2E_COMMAND_PAYLOADS__ ?? [])] + .reverse() + .find((entry) => entry.command === "send_channel_message"); + return ( + call?.payload as { linkPreviewTags?: string[][] | null } | undefined + )?.linkPreviewTags; + }); + expect(linkPreviewTags).toEqual([["link-preview", "none"]]); + + await page.getByTestId("message-input").fill(firstUrl); + await expect(page.locator("[data-composer-link-previews]")).toBeVisible(); +}); + +test("composer link preview embeds stay attachment-sized while loading and ready", 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}`); + const card = page + .locator("[data-composer-link-previews]") + .locator('[data-link-preview="github-pull-request"]'); + await expect(card).toBeVisible(); + const initial = await card.evaluate((element) => ({ + height: element.getBoundingClientRect().height, + width: element.getBoundingClientRect().width, + thumbnailHeight: element + .querySelector("[data-link-preview-thumbnail]") + ?.getBoundingClientRect().height, + thumbnailWidth: element + .querySelector("[data-link-preview-thumbnail]") + ?.getBoundingClientRect().width, + })); + if (process.env.BUZZ_LINK_PREVIEW_SCREENSHOTS_DIR) { + await waitForAnimations(page); + await page.screenshot({ + animations: "disabled", + path: `${process.env.BUZZ_LINK_PREVIEW_SCREENSHOTS_DIR}/composer-${width}-loading.png`, + }); + } + + await expect + .poll(() => + card.evaluate((element) => element.getAttribute("data-state")), + ) + .toBe("done"); + const ready = await card.evaluate((element) => ({ + height: element.getBoundingClientRect().height, + width: element.getBoundingClientRect().width, + thumbnailHeight: element + .querySelector("[data-link-preview-thumbnail]") + ?.getBoundingClientRect().height, + thumbnailWidth: element + .querySelector("[data-link-preview-thumbnail]") + ?.getBoundingClientRect().width, + })); + if (process.env.BUZZ_LINK_PREVIEW_SCREENSHOTS_DIR) { + await waitForAnimations(page); + await page.screenshot({ + animations: "disabled", + path: `${process.env.BUZZ_LINK_PREVIEW_SCREENSHOTS_DIR}/composer-${width}-ready.png`, + }); + } + + expect(initial.height).toBe(55); + expect(initial.width).toBe(320); + expect(initial.thumbnailHeight).toBe(55); + expect(initial.thumbnailWidth).toBe(55); + expect(ready).toEqual(initial); + } +}); + +test("composer no-image link embeds keep the attachment footprint", async ({ + page, +}) => { + const previewUrl = "https://github.com/block/buzz/pull/3246?inline=none"; + await page.goto("/"); + await page.getByTestId("channel-general").click(); + await page.getByTestId("message-input").fill(previewUrl); + const card = page + .locator("[data-composer-link-previews]") + .locator('[data-link-preview="github-pull-request"]'); + await expect(card).toHaveAttribute("data-image-state", "none"); + await expect(card.locator("[data-link-preview-thumbnail]")).toBeVisible(); + await expect(card.locator('[data-slot="attachment-title"]')).toContainText( + /github\.com|Buzz/, + ); + await expect(card).toHaveCSS("height", "55px"); +}); + +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 waitForReadyComposerSnapshots(page, 2); + 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("[data-link-preview-thumbnail]"), + ).toBeVisible(); + await expect(compactCards.nth(1)).toHaveAttribute("data-image-state", "none"); + await expect( + compactCards.nth(1).locator("[data-link-preview-image-fallback]"), + ).toHaveCount(0); + + 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]"), + ).toHaveCount(0); +}); + +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 waitForReadyComposerSnapshots(page); + 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).toHaveAttribute("data-image-state", "none"); + await expect( + compactCard.locator("[data-link-preview-image-fallback]"), + ).toHaveCount(0); + + 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]"), + ).toHaveCount(0); +}); + +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"; @@ -260,6 +923,7 @@ test("supported link previews keep the message link visible", async ({ await expect(page.getByTestId("chat-title")).toHaveText("general"); await page.getByTestId("message-input").fill(previewUrl); + await waitForReadyComposerSnapshots(page); await page.getByTestId("send-message").click(); const row = page.getByTestId("message-row").last(); @@ -268,8 +932,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 9b516473352..21d3e986fa3 100644 --- a/desktop/tests/e2e/project-commit-detail.spec.ts +++ b/desktop/tests/e2e/project-commit-detail.spec.ts @@ -526,7 +526,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 02342544102..f7a6c4ccecc 100644 --- a/desktop/tests/helpers/bridge.ts +++ b/desktop/tests/helpers/bridge.ts @@ -295,6 +295,32 @@ 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; + /** Simulates native cold-cache startup work before the async response. */ + linkPreviewMetadataStartBlockMs?: number; searchProfiles?: MockSearchProfileSeed[]; updateAvailable?: boolean; updateChannelDelayMs?: number;