From 218633b8fd6ee41aee8eb18ba9806e8d90694751 Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Tue, 8 Sep 2026 12:59:03 -0700 Subject: [PATCH 01/19] fix(link-preview): keep composer fetches user-paced (#7211) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **Category:** fix **User Impact:** Link previews can keep loading while a message is being composed, while sending still has a finite escape hatch and stalled network transports cannot occupy preview slots forever. **Problem:** Native metadata and image deadlines could collapse slow previews into fallback cards while the user was still composing, and a shared image-host cooldown made pasted batches fail inconsistently after one rate limit. **Solution:** Keep preview resolution user-paced with no aggregate request deadline, bound transport inactivity (15s DNS/connect, 30s idle read), serialize image requests by host, and allow at most one server-directed cooldown wait of up to 30s across an image fetch and its redirects. The existing bounded post-Send preparation and immediate Skip paths remain unchanged.
File changes **desktop/src-tauri/src/commands/link_preview.rs** Removes aggregate native deadlines so composer metadata work can complete at the user's pace, while retaining DNS/connect/idle-read liveness bounds. Adds bounded host-paced image request coordination that releases its gate during cooldown, waits inline at most once for at most 30 seconds, and cannot renew that wait through redirects or the outer transient retry. Same-host image and favicon requests remain deliberately serialized to align with host rate limits. **desktop/src-tauri/src/commands/link_preview_rate_limit.rs** Adds a fixed-size striped host gate so concurrent image requests are serialized without retaining an unbounded attacker-controlled hostname map. **desktop/src-tauri/src/commands/link_preview_tests.rs** Moves native link-preview tests into a dedicated module and covers the user-paced metadata contract, bounded one-shot cooldown behavior, and gate release while a rate-limited request sleeps—including a different host sharing the same bounded gate stripe. **desktop/src-tauri/src/commands/link_preview_youtube.rs** Removes the thumbnail fetch deadline so YouTube previews follow the same composer lifecycle contract while using the shared bounded transport. **desktop/src/shared/lib/useResolvedLinkPreviews.ts** Adds development-only metadata outcome diagnostics with elapsed time and image/fallback state, without logging encoded image payloads.
### Reproduction steps 1. Open the desktop composer and paste several GitHub pull request links whose OpenGraph images share a host. 2. Observe that image requests are paced by host instead of racing, and slow-but-progressing preview work remains pending rather than immediately becoming a completed favicon fallback. 3. Send while preview work is still pending and confirm **Preparing link preview** remains bounded by the existing post-Send budget. 4. Use **Skip** during preparation and confirm the message proceeds immediately. 5. In a development build, inspect the console for `[link-preview] metadata fetch completed` diagnostics containing elapsed time and image state without base64 payloads. ### Related issue N/A — scoped from the linked Buzz implementation room. ### Testing At current head `dfb394aafbee537e9ffb04ad3732d08f65f30b8e`: - Production-bound paused-time metadata regression passed through `fetch_link_preview_metadata`; restoring the former 10-second aggregate wrapper makes it fail at the pending assertion. - Native link-preview module: 19/19 passed. - `cargo check --manifest-path desktop/src-tauri/Cargo.toml` passed. - Rust formatting and `git diff --check` passed. - Pre-push `push-head-scope`, org safety, differential file-size, branch-skew, and `desktop-tauri-checks` hooks passed. At prior head `59e2dcf167b15c7a3e637ad2608008b7f9cef5f3`: - Full Tauri Rust suite: 3,056 passed, 19 ignored; integration crates 7 + 3 passed. - Focused native link-preview suite: 26/26 passed. - The pasted multi-preview workflow was exercised in the desktop app and confirmed improved before draft publication. --------- Signed-off-by: Taylor Ho Co-authored-by: Carl Co-authored-by: Carl --- .../src-tauri/src/commands/link_preview.rs | 530 +++++---------- .../src/commands/link_preview_cancellation.rs | 89 +++ .../src/commands/link_preview_rate_limit.rs | 21 +- .../src/commands/link_preview_tests.rs | 604 ++++++++++++++++++ .../src/commands/link_preview_youtube.rs | 16 +- desktop/src-tauri/src/lib.rs | 2 + .../lib/linkPreviewPreparationStore.test.mjs | 97 ++- .../lib/linkPreviewPreparationStore.ts | 58 +- .../lib/useResolvedLinkPreviews.test.mjs | 262 +++++++- .../src/shared/lib/useResolvedLinkPreviews.ts | 278 ++++++-- 10 files changed, 1510 insertions(+), 447 deletions(-) create mode 100644 desktop/src-tauri/src/commands/link_preview_cancellation.rs create mode 100644 desktop/src-tauri/src/commands/link_preview_tests.rs diff --git a/desktop/src-tauri/src/commands/link_preview.rs b/desktop/src-tauri/src/commands/link_preview.rs index b781f8d9e68..a746cc80598 100644 --- a/desktop/src-tauri/src/commands/link_preview.rs +++ b/desktop/src-tauri/src/commands/link_preview.rs @@ -10,6 +10,8 @@ use reqwest::{ use serde::Serialize; use url::Url; +#[path = "link_preview_cancellation.rs"] +mod cancellation; #[path = "link_preview_image_retry.rs"] mod image_retry; #[path = "link_preview_rate_limit.rs"] @@ -17,15 +19,19 @@ mod rate_limit; #[path = "link_preview_youtube.rs"] mod youtube; -use rate_limit::{image_host_cooldown_remaining, retry_after_duration, set_image_host_cooldown}; +use rate_limit::{ + image_host_cooldown_remaining, image_host_gate, 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 TRANSPORT_CONNECT_TIMEOUT: Duration = Duration::from_secs(15); +const TRANSPORT_IDLE_TIMEOUT: Duration = Duration::from_secs(30); +const DNS_RESOLUTION_TIMEOUT: Duration = Duration::from_secs(15); +const MAX_INLINE_IMAGE_COOLDOWN: Duration = Duration::from_secs(30); const MAX_REDIRECTS: usize = 3; const MAX_METADATA_CHARS: usize = 180; const MAX_METADATA_DESCRIPTION_CHARS: usize = 280; @@ -55,27 +61,46 @@ pub struct LinkPreviewMetadata { #[tauri::command] pub async fn fetch_link_preview_metadata( href: String, + request_id: Option, ) -> Result, String> { - tokio::time::timeout( - PREVIEW_TOTAL_TIMEOUT, - fetch_link_preview_metadata_inner(href), - ) - .await - .map_err(|_| "link preview request timed out".to_string())? + let cancellation = cancellation::begin(request_id.as_deref()); + let result = match cancellation { + Some(cancellation) => { + tokio::select! { + result = fetch_link_preview_metadata_for_url(href) => result, + () = cancellation.cancelled() => Err("link preview request cancelled".to_string()), + } + } + None => fetch_link_preview_metadata_for_url(href).await, + }; + cancellation::finish(request_id.as_deref()); + result } -async fn fetch_link_preview_metadata_inner( +/// Cancel renderer-owned metadata work, including an in-flight response body. +#[tauri::command] +pub fn cancel_link_preview_metadata(request_id: String) { + cancellation::cancel(&request_id); +} + +/// Release a renderer's cancellation record after its invocation settles. +#[tauri::command] +pub fn release_link_preview_metadata(request_id: String) { + cancellation::finish(Some(&request_id)); +} + +async fn fetch_link_preview_metadata_for_url( href: String, ) -> Result, String> { let mut url = Url::parse(href.trim()).map_err(|error| format!("invalid URL: {error}"))?; - validate_public_https_url(&url).await?; + validate_metadata_url(&url).await?; if youtube::is_video_url(&url) { return youtube::fetch_oembed_metadata(&url).await; } for redirect_count in 0..=MAX_REDIRECTS { - let response = send_pinned_request(&url, "text/html,application/xhtml+xml;q=0.9").await?; + let response = send_metadata_request(&url, "text/html,application/xhtml+xml;q=0.9").await?; if response.status().is_redirection() { if redirect_count == MAX_REDIRECTS { @@ -90,7 +115,7 @@ async fn fetch_link_preview_metadata_inner( url = url .join(location) .map_err(|error| format!("invalid link preview redirect: {error}"))?; - validate_public_https_url(&url).await?; + validate_metadata_url(&url).await?; continue; } @@ -107,28 +132,15 @@ async fn fetch_link_preview_metadata_inner( let (image_result, favicon_result) = tokio::join!( async { match image_url { - Some(image_url) => Some( - tokio::time::timeout( - PREVIEW_FETCH_TIMEOUT, - fetch_sanitized_image_with_retry(image_url, false), - ) - .await - .unwrap_or(Err(ImageFetchError::Transient { - retry_after: None, - retry_inline: false, - })), - ), + Some(image_url) => { + Some(fetch_sanitized_image_with_retry(image_url, false).await) + } None => None, } }, async { match favicon_url { - Some(favicon_url) => tokio::time::timeout( - PREVIEW_FETCH_TIMEOUT, - fetch_sanitized_image(favicon_url, true), - ) - .await - .ok(), + Some(favicon_url) => Some(fetch_sanitized_image(favicon_url, true).await), None => None, } } @@ -166,6 +178,15 @@ fn apply_image_result( } } +async fn validate_metadata_url(url: &Url) -> Result<(), String> { + #[cfg(test)] + if METADATA_TEST_SERVER.try_with(|_| ()).is_ok() { + return Ok(()); + } + + validate_public_https_url(url).await +} + 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()); @@ -182,11 +203,15 @@ async fn validate_public_https_url(url: &Url) -> Result<(), String> { 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::>(); + let addresses = tokio::time::timeout( + DNS_RESOLUTION_TIMEOUT, + tokio::net::lookup_host((host.as_str(), 443)), + ) + .await + .map_err(|_| "link preview DNS resolution timed out".to_string())? + .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()); @@ -198,6 +223,25 @@ async fn resolve_public_addresses(host: &str) -> Result, String> { Ok(addresses) } +#[cfg(test)] +tokio::task_local! { + static METADATA_TEST_SERVER: std::net::SocketAddr; +} + +async fn send_metadata_request(url: &Url, accept: &str) -> Result { + #[cfg(test)] + if let Ok(address) = METADATA_TEST_SERVER.try_with(|address| *address) { + return reqwest::Client::new() + .get(format!("http://{address}{}", url.path())) + .header(ACCEPT, accept) + .send() + .await + .map_err(|error| format!("link preview test request failed: {error}")); + } + + send_pinned_request(url, accept).await +} + async fn send_pinned_request(url: &Url, accept: &str) -> Result { let host = url .host_str() @@ -211,6 +255,8 @@ async fn send_pinned_request(url: &Url, accept: &str) -> Result Result bool { + if *waited_for_cooldown { + return false; + } + *waited_for_cooldown = true; + tokio::time::sleep(retry_after).await; + true +} + +fn retryable_image_cooldown( + url: &Url, + retry_after: Option, + waited_for_cooldown: &mut bool, +) -> Option { + let retry_after = retry_after?; + if *waited_for_cooldown { + return None; + } + set_image_host_cooldown(url, retry_after); + if retry_after > MAX_INLINE_IMAGE_COOLDOWN { + return None; + } + *waited_for_cooldown = true; + Some(retry_after) +} + async fn fetch_sanitized_image( - mut url: Url, + url: Url, preserve_transparency: bool, ) -> Result<(String, String), ImageFetchError> { - validate_public_https_url(&url) + fetch_sanitized_image_using( + url, + preserve_transparency, + |url| async move { validate_public_https_url(&url).await }, + |url, accept| async move { send_pinned_request(&url, accept).await }, + ) + .await +} + +async fn fetch_sanitized_image_using( + mut url: Url, + preserve_transparency: bool, + mut validate_url: V, + mut send_request: F, +) -> Result<(String, String), ImageFetchError> +where + V: FnMut(Url) -> VFut, + VFut: std::future::Future>, + F: FnMut(Url, &'static str) -> Fut, + Fut: std::future::Future>, +{ + validate_url(url.clone()) .await .map_err(|_| ImageFetchError::Rejected)?; - for redirect_count in 0..=MAX_REDIRECTS { + let mut redirect_count = 0; + let mut waited_for_cooldown = false; + while redirect_count <= MAX_REDIRECTS { if let Some(retry_after) = image_host_cooldown_remaining(&url) { - return Err(ImageFetchError::Transient { - retry_after: Some(retry_after), - retry_inline: false, - }); + if retry_after > MAX_INLINE_IMAGE_COOLDOWN + || !wait_for_image_host_cooldown(&mut waited_for_cooldown, retry_after).await + { + return Err(ImageFetchError::Transient { + retry_after: Some(retry_after), + retry_inline: false, + }); + } + continue; } - let response = send_pinned_request(&url, "image/jpeg,image/png,image/webp") + + let host_gate = image_host_gate(&url); + let host_guard = host_gate.lock().await; + if image_host_cooldown_remaining(&url).is_some() { + continue; + } + let response = send_request(url.clone(), "image/jpeg,image/png,image/webp") .await .map_err(|_| ImageFetchError::Transient { retry_after: None, - retry_inline: true, + retry_inline: !waited_for_cooldown, })?; if response.status().is_redirection() { if redirect_count == MAX_REDIRECTS { @@ -370,9 +479,10 @@ async fn fetch_sanitized_image( .and_then(|value| value.to_str().ok()) .ok_or(ImageFetchError::Rejected)?; url = url.join(location).map_err(|_| ImageFetchError::Rejected)?; - validate_public_https_url(&url) + validate_url(url.clone()) .await .map_err(|_| ImageFetchError::Rejected)?; + redirect_count += 1; continue; } if !response.status().is_success() { @@ -383,12 +493,18 @@ async fn fetch_sanitized_image( || 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); + if let Some(retry_after) = + retryable_image_cooldown(&url, retry_after, &mut waited_for_cooldown) + { + drop(host_guard); + tokio::time::sleep(retry_after).await; + continue; } return Err(ImageFetchError::Transient { retry_after, - retry_inline: status != reqwest::StatusCode::TOO_MANY_REQUESTS, + retry_inline: retry_after.is_none() + && status != reqwest::StatusCode::TOO_MANY_REQUESTS + && !waited_for_cooldown, }); } return Err(ImageFetchError::Rejected); @@ -677,315 +793,5 @@ fn decode_html_entities(value: &str) -> String { } #[cfg(test)] -mod tests { - 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 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)), - retry_inline: false, - })), - ); - 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!( - metadata.image_fetch_state, - LinkPreviewImageFetchState::Image - ); - assert_eq!(metadata.image_domain.as_deref(), Some("images.example.com")); - } - - #[test] - fn metadata_falls_back_to_twitter_then_title() { - assert_eq!( - 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 - ); - } - - #[test] - 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); - } -} +#[path = "link_preview_tests.rs"] +mod tests; diff --git a/desktop/src-tauri/src/commands/link_preview_cancellation.rs b/desktop/src-tauri/src/commands/link_preview_cancellation.rs new file mode 100644 index 00000000000..38be2b3a386 --- /dev/null +++ b/desktop/src-tauri/src/commands/link_preview_cancellation.rs @@ -0,0 +1,89 @@ +use std::{ + collections::HashMap, + sync::{LazyLock, Mutex}, +}; + +use tokio_util::sync::CancellationToken; + +#[derive(Default)] +struct LinkPreviewCancellations { + tokens: HashMap, +} + +impl LinkPreviewCancellations { + fn begin(&mut self, request_id: &str) -> CancellationToken { + if let Some(cancellation) = self.tokens.get(request_id).cloned() { + return cancellation; + } + let cancellation = CancellationToken::new(); + self.tokens + .insert(request_id.to_string(), cancellation.clone()); + cancellation + } + + fn cancel(&mut self, request_id: &str) { + self.tokens + .entry(request_id.to_string()) + .or_default() + .cancel(); + } + + fn finish(&mut self, request_id: &str) { + self.tokens.remove(request_id); + } +} + +static LINK_PREVIEW_CANCELLATIONS: LazyLock> = + LazyLock::new(|| Mutex::new(LinkPreviewCancellations::default())); + +pub(super) fn begin(request_id: Option<&str>) -> Option { + let request_id = request_id?; + LINK_PREVIEW_CANCELLATIONS + .lock() + .ok() + .map(|mut fetches| fetches.begin(request_id)) +} + +pub(super) fn cancel(request_id: &str) { + if let Ok(mut fetches) = LINK_PREVIEW_CANCELLATIONS.lock() { + fetches.cancel(request_id); + } +} + +pub(super) fn finish(request_id: Option<&str>) { + let Some(request_id) = request_id else { + return; + }; + if let Ok(mut fetches) = LINK_PREVIEW_CANCELLATIONS.lock() { + fetches.finish(request_id); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn cancellation_before_begin_is_retained() { + let mut fetches = LinkPreviewCancellations::default(); + fetches.cancel("cancel-before-begin"); + + let cancellation = fetches.begin("cancel-before-begin"); + + assert!(cancellation.is_cancelled()); + fetches.finish("cancel-before-begin"); + assert!(fetches.tokens.is_empty()); + } + + #[test] + fn cancellation_reaches_active_owner() { + let mut fetches = LinkPreviewCancellations::default(); + let cancellation = fetches.begin("active-fetch"); + + fetches.cancel("active-fetch"); + + assert!(cancellation.is_cancelled()); + fetches.finish("active-fetch"); + assert!(fetches.tokens.is_empty()); + } +} diff --git a/desktop/src-tauri/src/commands/link_preview_rate_limit.rs b/desktop/src-tauri/src/commands/link_preview_rate_limit.rs index c3f7ed7188c..46032fc5f01 100644 --- a/desktop/src-tauri/src/commands/link_preview_rate_limit.rs +++ b/desktop/src-tauri/src/commands/link_preview_rate_limit.rs @@ -1,16 +1,31 @@ use std::{ - collections::HashMap, - sync::{Mutex, OnceLock}, - time::{Duration, Instant}, + collections::{hash_map::DefaultHasher, HashMap}, + hash::{Hash, Hasher}, + sync::{LazyLock, Mutex, OnceLock}, + time::Duration, }; use reqwest::header::RETRY_AFTER; +use tokio::{sync::Mutex as AsyncMutex, time::Instant}; use url::Url; pub(super) const MAX_IMAGE_RETRY_AFTER: Duration = Duration::from_secs(60 * 60); const MAX_IMAGE_HOST_COOLDOWNS: usize = 128; +const IMAGE_HOST_GATE_COUNT: usize = 64; static IMAGE_HOST_COOLDOWNS: OnceLock>> = OnceLock::new(); +// A bounded stripe table serializes image requests by host without retaining an +// unbounded attacker-controlled hostname map. Hash collisions only make two +// unrelated hosts wait for one another; they never weaken the host boundary. +static IMAGE_HOST_GATES: LazyLock<[AsyncMutex<()>; IMAGE_HOST_GATE_COUNT]> = + LazyLock::new(|| std::array::from_fn(|_| AsyncMutex::new(()))); + +pub(super) fn image_host_gate(url: &Url) -> &'static AsyncMutex<()> { + let mut hasher = DefaultHasher::new(); + url.host_str().unwrap_or_default().hash(&mut hasher); + let index = (hasher.finish() as usize) % IMAGE_HOST_GATE_COUNT; + &IMAGE_HOST_GATES[index] +} pub(super) fn retry_after_duration(response: &reqwest::Response) -> Option { response diff --git a/desktop/src-tauri/src/commands/link_preview_tests.rs b/desktop/src-tauri/src/commands/link_preview_tests.rs new file mode 100644 index 00000000000..05ab0eec2ce --- /dev/null +++ b/desktop/src-tauri/src/commands/link_preview_tests.rs @@ -0,0 +1,604 @@ +use super::rate_limit::MAX_IMAGE_RETRY_AFTER; +use super::{ + apply_image_result, cancel_link_preview_metadata, declares_animation, extract_favicon_url, + extract_image_url, extract_link_preview_metadata, fetch_link_preview_metadata, + fetch_sanitized_image_using, is_html_response, read_bytes_prefix, retry_after_duration, + retryable_image_cooldown, sanitize_image, ImageFetchError, LinkPreviewImageFetchState, + LinkPreviewMetadata, MAX_INLINE_IMAGE_COOLDOWN, 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, + sync::{Arc, Mutex}, +}; +use tokio::sync::oneshot; +use url::Url; + +async fn start_test_server(router: Router) -> std::net::SocketAddr { + 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(); + }); + address +} + +async fn test_response(router: Router, path: &str) -> reqwest::Response { + let address = start_test_server(router).await; + reqwest::get(format!("http://{address}{path}")) + .await + .unwrap() +} + +#[tokio::test(start_paused = true)] +async fn metadata_pipeline_remains_pending_beyond_former_aggregate_deadline() { + let (request_started_tx, request_started_rx) = oneshot::channel::<()>(); + let request_started_tx = Arc::new(Mutex::new(Some(request_started_tx))); + let (release_response_tx, release_response_rx) = oneshot::channel::<()>(); + let release_response_rx = Arc::new(Mutex::new(Some(release_response_rx))); + let address = start_test_server(Router::new().route( + "/preview", + get(move || { + let request_started_tx = Arc::clone(&request_started_tx); + let release_response_rx = Arc::clone(&release_response_rx); + async move { + request_started_tx + .lock() + .unwrap() + .take() + .unwrap() + .send(()) + .unwrap(); + let release_response_rx = release_response_rx.lock().unwrap().take().unwrap(); + release_response_rx.await.unwrap(); + Response::builder() + .header("content-type", "text/html") + .body(Body::from("User-paced metadata")) + .unwrap() + } + }), + )) + .await; + let fetch = tokio::spawn(super::METADATA_TEST_SERVER.scope( + address, + fetch_link_preview_metadata("https://user-paced.example/preview".to_string(), None), + )); + + request_started_rx.await.unwrap(); + tokio::time::advance(std::time::Duration::from_secs(11)).await; + assert!(!fetch.is_finished()); + + release_response_tx.send(()).unwrap(); + let metadata = fetch.await.unwrap().unwrap().unwrap(); + assert_eq!(metadata.title, "User-paced metadata"); +} + +#[tokio::test] +async fn metadata_command_cancellation_drops_an_in_flight_response() { + let (request_started_tx, request_started_rx) = oneshot::channel::<()>(); + let request_started_tx = Arc::new(Mutex::new(Some(request_started_tx))); + let (_release_response_tx, release_response_rx) = oneshot::channel::<()>(); + let release_response_rx = Arc::new(Mutex::new(Some(release_response_rx))); + let address = start_test_server(Router::new().route( + "/preview", + get(move || { + let request_started_tx = Arc::clone(&request_started_tx); + let release_response_rx = Arc::clone(&release_response_rx); + async move { + request_started_tx + .lock() + .unwrap() + .take() + .unwrap() + .send(()) + .unwrap(); + let release_response_rx = release_response_rx.lock().unwrap().take().unwrap(); + let _ = release_response_rx.await; + Response::builder() + .header("content-type", "text/html") + .body(Body::from("Too late")) + .unwrap() + } + }), + )) + .await; + let request_id = "cancel-in-flight".to_string(); + let fetch = tokio::spawn(super::METADATA_TEST_SERVER.scope( + address, + fetch_link_preview_metadata( + "https://cancel.example/preview".to_string(), + Some(request_id.clone()), + ), + )); + + request_started_rx.await.unwrap(); + cancel_link_preview_metadata(request_id); + + assert_eq!( + fetch.await.unwrap(), + Err("link preview request cancelled".to_string()) + ); +} + +#[tokio::test(start_paused = true)] +async fn first_rate_limit_and_queued_host_request_share_one_cooldown_boundary() { + let cooldown = std::time::Duration::from_secs(20); + let rate_limited_path = "/rate-limited.png"; + let success_path = "/success.png"; + let url = Url::parse(&format!( + "https://rate-limit-regression.example{rate_limited_path}" + )) + .unwrap(); + let attempts = Arc::new(Mutex::new(0)); + let collision_attempts = Arc::new(Mutex::new(0)); + let image = DynamicImage::ImageRgb8(RgbImage::from_pixel(2, 2, Rgb([10, 20, 30]))); + let mut png = Cursor::new(Vec::new()); + image.write_to(&mut png, ImageFormat::Png).unwrap(); + let image_bytes = png.into_inner(); + let server_attempts = Arc::clone(&attempts); + let address = start_test_server(Router::new().route( + "/{image}", + get( + move |axum::extract::Path(image): axum::extract::Path| { + let image_bytes = image_bytes.clone(); + let server_attempts = Arc::clone(&server_attempts); + async move { + if image == "rate-limited.png" { + let attempt = { + let mut attempts = server_attempts.lock().unwrap(); + *attempts += 1; + *attempts + }; + if attempt == 1 { + return Response::builder() + .status(429) + .header("retry-after", cooldown.as_secs()) + .body(Body::empty()) + .unwrap(); + } + } + Response::builder() + .header("content-type", "image/png") + .body(Body::from(image_bytes)) + .unwrap() + } + }, + ), + )) + .await; + let test_client = reqwest::Client::new(); + let request = move |url: Url, _accept: &'static str| { + let test_client = test_client.clone(); + async move { + test_client + .get(format!("http://{address}{}", url.path())) + .send() + .await + .map_err(|error| error.to_string()) + } + }; + let collision_request = { + let collision_attempts = Arc::clone(&collision_attempts); + let test_client = reqwest::Client::new(); + move |url: Url, _accept: &'static str| { + let collision_attempts = Arc::clone(&collision_attempts); + let test_client = test_client.clone(); + async move { + *collision_attempts.lock().unwrap() += 1; + test_client + .get(format!("http://{address}{}", url.path())) + .send() + .await + .map_err(|error| error.to_string()) + } + } + }; + let validate = |_url: Url| async { Ok(()) }; + let first = tokio::spawn(fetch_sanitized_image_using( + url.clone(), + false, + validate, + request.clone(), + )); + while super::image_host_cooldown_remaining(&url).is_none() { + tokio::task::yield_now().await; + } + assert!(!first.is_finished()); + assert_eq!(*attempts.lock().unwrap(), 1); + assert_eq!(super::image_host_cooldown_remaining(&url), Some(cooldown)); + + let colliding_url = (0..10_000) + .map(|index| { + Url::parse(&format!("https://collision-{index}.example{success_path}")).unwrap() + }) + .find(|candidate| { + std::ptr::eq( + super::image_host_gate(candidate), + super::image_host_gate(&url), + ) + }) + .expect("a different host sharing the bounded gate stripe"); + + let (collision_started_tx, collision_started_rx) = oneshot::channel(); + tokio::spawn(async move { + let collision = + fetch_sanitized_image_using(colliding_url, false, validate, collision_request); + tokio::pin!(collision); + assert!(futures_util::poll!(&mut collision).is_pending()); + collision_started_tx.send(()).ok(); + assert!(collision.await.is_ok()); + }); + collision_started_rx.await.unwrap(); + assert_eq!(*collision_attempts.lock().unwrap(), 1); + assert_eq!(*attempts.lock().unwrap(), 1); + + let queued = tokio::spawn(fetch_sanitized_image_using(url, false, validate, request)); + tokio::task::yield_now().await; + assert!(!queued.is_finished()); + assert_eq!(*attempts.lock().unwrap(), 1); + + tokio::time::advance(cooldown - std::time::Duration::from_millis(1)).await; + assert!(!first.is_finished()); + assert!(!queued.is_finished()); + assert_eq!(*attempts.lock().unwrap(), 1); + + tokio::time::advance(std::time::Duration::from_millis(1)).await; + let (first, queued) = tokio::join!(first, queued); + assert!(first.unwrap().is_ok()); + assert!(queued.unwrap().is_ok()); + assert_eq!(*attempts.lock().unwrap(), 3); +} + +#[tokio::test(start_paused = true)] +async fn transport_failure_after_cooldown_does_not_renew_wait_on_outer_retry() { + let cooldown = std::time::Duration::from_secs(20); + let url = Url::parse("https://transport-after-cooldown.example/image.png").unwrap(); + super::set_image_host_cooldown(&url, cooldown); + let attempts = Arc::new(Mutex::new(0)); + + let result = super::image_retry::retry_transient_image_fetch(|| { + let url = url.clone(); + let attempts = Arc::clone(&attempts); + async move { + fetch_sanitized_image_using( + url, + false, + |_url| async { Ok(()) }, + move |_url, _accept| { + let attempts = Arc::clone(&attempts); + async move { + *attempts.lock().unwrap() += 1; + Err("connection failed".to_string()) + } + }, + ) + .await + } + }) + .await; + + assert_eq!( + result, + Err(ImageFetchError::Transient { + retry_after: None, + retry_inline: false, + }) + ); + assert_eq!(*attempts.lock().unwrap(), 1); +} + +#[test] +fn image_cooldown_wait_is_short_and_one_shot() { + let url = Url::parse("https://bounded-cooldown.example/image.png").unwrap(); + let mut waited = false; + assert_eq!( + retryable_image_cooldown(&url, Some(MAX_INLINE_IMAGE_COOLDOWN), &mut waited,), + Some(MAX_INLINE_IMAGE_COOLDOWN) + ); + assert!(waited); + assert_eq!( + retryable_image_cooldown(&url, Some(MAX_INLINE_IMAGE_COOLDOWN), &mut waited,), + None + ); + let excessive_url = Url::parse("https://excessive-cooldown.example/image.png").unwrap(); + let mut excessive_waited = false; + assert_eq!( + retryable_image_cooldown( + &excessive_url, + Some(MAX_INLINE_IMAGE_COOLDOWN + std::time::Duration::from_secs(1)), + &mut excessive_waited, + ), + None + ); + assert!(!excessive_waited); +} + +#[test] +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)), + retry_inline: false, + })), + ); + 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!( + metadata.image_fetch_state, + LinkPreviewImageFetchState::Image + ); + assert_eq!(metadata.image_domain.as_deref(), Some("images.example.com")); +} + +#[test] +fn metadata_falls_back_to_twitter_then_title() { + assert_eq!( + 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 + ); +} + +#[test] +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_youtube.rs b/desktop/src-tauri/src/commands/link_preview_youtube.rs index a0a5a753dcd..b759046d8f0 100644 --- a/desktop/src-tauri/src/commands/link_preview_youtube.rs +++ b/desktop/src-tauri/src/commands/link_preview_youtube.rs @@ -5,8 +5,8 @@ use url::Url; use super::{ apply_image_result, fetch_sanitized_image, normalize_metadata_description, - normalize_metadata_text, read_limited_bytes, send_pinned_request, ImageFetchError, - LinkPreviewImageFetchState, LinkPreviewMetadata, PREVIEW_FETCH_TIMEOUT, + normalize_metadata_text, read_limited_bytes, send_pinned_request, LinkPreviewImageFetchState, + LinkPreviewMetadata, }; const MAX_OEMBED_FETCH_BYTES: usize = 64 * 1024; @@ -54,17 +54,7 @@ pub(super) async fn fetch_oembed_metadata( return Ok(None); }; let image_result = match thumbnail_url { - Some(thumbnail_url) => Some( - tokio::time::timeout( - PREVIEW_FETCH_TIMEOUT, - fetch_sanitized_image(thumbnail_url, false), - ) - .await - .unwrap_or(Err(ImageFetchError::Transient { - retry_after: None, - retry_inline: false, - })), - ), + Some(thumbnail_url) => Some(fetch_sanitized_image(thumbnail_url, false).await), None => None, }; apply_image_result(&mut metadata, image_result); diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 12082a2a82e..e3ac45953dc 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -600,6 +600,8 @@ pub fn run() { get_relay_http_url, get_media_proxy_port, fetch_link_preview_metadata, + cancel_link_preview_metadata, + release_link_preview_metadata, discover_acp_auth_methods, discover_acp_providers, discover_git_bash_prerequisite, diff --git a/desktop/src/features/messages/lib/linkPreviewPreparationStore.test.mjs b/desktop/src/features/messages/lib/linkPreviewPreparationStore.test.mjs index 293fb63e154..7153ff38518 100644 --- a/desktop/src/features/messages/lib/linkPreviewPreparationStore.test.mjs +++ b/desktop/src/features/messages/lib/linkPreviewPreparationStore.test.mjs @@ -1,5 +1,7 @@ import assert from "node:assert/strict"; -import test from "node:test"; +import { after, before, test } from "node:test"; + +import { JSDOM } from "jsdom"; import { __linkPreviewPreparationTest, @@ -12,6 +14,29 @@ import { const first = { href: "https://example.com/first" }; const second = { href: "https://example.com/second" }; const firstTag = ["link-preview", "snapshot", first.href]; +const dom = new JSDOM("", { + url: "http://localhost", +}); +const ipcHandlers = new Map(); + +before(() => { + Object.assign(globalThis, { + document: dom.window.document, + HTMLElement: dom.window.HTMLElement, + window: dom.window, + }); + dom.window.__TAURI_INTERNALS__ = { + invoke: (cmd, args) => { + const handler = ipcHandlers.get(cmd); + return handler + ? handler(args) + : Promise.reject(new Error(`unmocked Tauri command: ${cmd}`)); + }, + transformCallback: () => Math.random(), + }; +}); + +after(() => dom.window.close()); function deferred() { let resolve; @@ -41,6 +66,7 @@ function seed( test.afterEach(() => { __linkPreviewPreparationTest.reset(); + ipcHandlers.clear(); }); test("adopts one in-flight job for the same canonical URL", () => { @@ -223,6 +249,60 @@ test("Skip after completion cannot replace finalized tags", async () => { }); }); +test("expired settled work replaced by a pending retry keeps deadline and Skip", async () => { + const expiredTag = ["link-preview", "snapshot", first.href, "expired"]; + const pendingRetry = deferred(); + let fetchCalls = 0; + ipcHandlers.set("fetch_link_preview_metadata", () => { + fetchCalls += 1; + return pendingRetry.promise; + }); + ipcHandlers.set("cancel_link_preview_metadata", () => Promise.resolve()); + ipcHandlers.set("release_link_preview_metadata", () => Promise.resolve()); + + const seedExpiredFallback = () => + seed( + first, + Promise.resolve(expiredTag), + true, + Date.now() - 5 * 60_000, + expiredTag, + expiredTag, + ); + + seedExpiredFallback(); + const timeoutPreparation = prepareBackgroundLinkPreviews([first], 0); + assert.ok(timeoutPreparation); + assert.equal( + __linkPreviewPreparationTest.jobs.get(first.href)?.settled, + false, + ); + assert.equal( + fetchCalls, + 1, + "the expired job was replaced through production I/O", + ); + assert.deepEqual(await timeoutPreparation.promise, { + status: "ready", + tags: [], + }); + + seedExpiredFallback(); + const skipPreparation = prepareBackgroundLinkPreviews([first], 1_000); + assert.ok(skipPreparation); + assert.equal( + __linkPreviewPreparationTest.jobs.get(first.href)?.settled, + false, + ); + skipPreparation.skip(); + assert.deepEqual(await skipPreparation.promise, { + status: "ready", + tags: [], + }); + + pendingRetry.resolve(null); +}); + test("already-settled partial results contain only successful tags", async () => { seed(first, Promise.resolve(firstTag), true); seed(second, Promise.resolve(null), true); @@ -272,3 +352,18 @@ test("reset cancels pending preparations instead of authorizing send", async () assert.deepEqual(await preparation.promise, { status: "cancelled" }); }); + +test("Skip aborts an abandoned in-flight preview job", async () => { + const pending = deferred(); + seed(first, pending.promise); + const job = __linkPreviewPreparationTest.jobs.get(first.href); + + const preparation = prepareBackgroundLinkPreviews([first], 1_000); + assert.ok(preparation); + preparation.skip(); + + assert.deepEqual(await preparation.promise, { status: "ready", tags: [] }); + assert.equal(job.controller.signal.aborted, true); + assert.equal(__linkPreviewPreparationTest.jobs.has(first.href), false); + pending.resolve(null); +}); diff --git a/desktop/src/features/messages/lib/linkPreviewPreparationStore.ts b/desktop/src/features/messages/lib/linkPreviewPreparationStore.ts index d61750fcd86..a4f1273ecf2 100644 --- a/desktop/src/features/messages/lib/linkPreviewPreparationStore.ts +++ b/desktop/src/features/messages/lib/linkPreviewPreparationStore.ts @@ -9,6 +9,7 @@ import { } from "@/shared/lib/linkPreviewSnapshot"; import { loadLinkPreviewMetadata, + type LinkPreviewMetadata, resolveLinkPreview, } from "@/shared/lib/useResolvedLinkPreviews"; @@ -27,6 +28,7 @@ type PreviewJob = { type BackgroundPreviewTask = { cancel: () => void; + hrefs: Set; id: number; skip: () => void; }; @@ -113,7 +115,26 @@ async function buildSnapshot( signal: AbortSignal, onMetadataReady: (tag: string[]) => void, ): Promise { - const metadata = await loadLinkPreviewMetadata(candidate.href); + const metadataLoad = loadLinkPreviewMetadata(candidate.href); + let settleAbortedMetadata: (() => void) | null = null; + const abortedMetadata = new Promise((resolve) => { + settleAbortedMetadata = () => resolve(null); + }); + const cancelMetadata = () => { + metadataLoad.cancel(); + settleAbortedMetadata?.(); + }; + signal.addEventListener("abort", cancelMetadata, { once: true }); + if (signal.aborted) cancelMetadata(); + let metadata: LinkPreviewMetadata | null; + try { + metadata = await Promise.race([metadataLoad.promise, abortedMetadata]); + } catch { + return null; + } finally { + signal.removeEventListener("abort", cancelMetadata); + metadataLoad.cancel(); + } if (signal.aborted || !metadata) return null; const preview = resolveLinkPreview(candidate, metadata); if (!preview.snapshotReady) return null; @@ -265,12 +286,15 @@ export function prepareBackgroundLinkPreviews( skip, }); + const preparations = external.map((candidate) => + prepareLinkPreview(candidate), + ); const pending = external.some( (candidate) => !jobs.get(candidate.href)?.settled, ); if (!pending) { return preparedSend( - Promise.all(external.map(prepareLinkPreview)).then((tags) => ({ + Promise.all(preparations).then((tags) => ({ status: "ready" as const, tags: tags.filter((tag): tag is string[] => tag !== null), })), @@ -288,27 +312,45 @@ export function prepareBackgroundLinkPreviews( let finish: ((result: BackgroundLinkPreviewResult) => void) | null = null; let terminal = false; let timer: ReturnType | null = null; - const complete = (result: BackgroundLinkPreviewResult) => { + const complete = ( + result: BackgroundLinkPreviewResult, + abortUnobservedJobs = false, + ) => { if (terminal) return; terminal = true; if (timer !== null) clearTimeout(timer); tasks.delete(taskId); + if (abortUnobservedJobs) { + for (const candidate of external) { + const observedByAnotherSend = [...tasks.values()].some((task) => + task.hrefs.has(candidate.href), + ); + if (!observedByAnotherSend) { + invalidateLinkPreviewPreparation(candidate.href); + } + } + } publishSnapshot(); finish?.(result); }; const promise = new Promise((resolve) => { finish = resolve; }); - const cancel = () => complete({ status: "cancelled" }); - const skip = () => complete({ status: "ready", tags: [] }); - tasks.set(taskId, { cancel, id: taskId, skip }); + const cancel = () => complete({ status: "cancelled" }, true); + const skip = () => complete({ status: "ready", tags: [] }, true); + tasks.set(taskId, { + cancel, + hrefs: new Set(external.map((candidate) => candidate.href)), + id: taskId, + skip, + }); publishSnapshot(); timer = setTimeout( - () => complete({ status: "ready", tags: availableTags() }), + () => complete({ status: "ready", tags: availableTags() }, true), timeoutMs, ); - void Promise.all(external.map(prepareLinkPreview)).then((tags) => { + void Promise.all(preparations).then((tags) => { complete({ status: "ready", tags: tags.filter((tag): tag is string[] => tag !== null), diff --git a/desktop/src/shared/lib/useResolvedLinkPreviews.test.mjs b/desktop/src/shared/lib/useResolvedLinkPreviews.test.mjs index e413f9c7c20..26811489037 100644 --- a/desktop/src/shared/lib/useResolvedLinkPreviews.test.mjs +++ b/desktop/src/shared/lib/useResolvedLinkPreviews.test.mjs @@ -6,6 +6,7 @@ import { JSDOM } from "jsdom"; import { __linkPreviewMetadataTest, fetchBuzzEntityMetadata, + loadLinkPreviewMetadata, isBuzzEntityPreview, resetLinkPreviewMetadataCache, resolveLinkPreview, @@ -33,6 +34,16 @@ function metadata(overrides = {}) { }; } +function deferred() { + let reject; + let resolve; + const promise = new Promise((resolvePromise, rejectPromise) => { + reject = rejectPromise; + resolve = resolvePromise; + }); + return { promise, reject, resolve }; +} + test("pending external metadata reserves the image treatment", () => { assert.deepEqual(resolveLinkPreview(preview, undefined), { ...preview, @@ -140,14 +151,14 @@ test("metadata loader retries transient images after the server cooldown", async }); assert.equal( - (await loader.load(preview.href)).metadata?.imageFetchState, + (await loader.load(preview.href).promise).metadata?.imageFetchState, "transient_failure", ); assert.equal(calls, 1); now += 10_000; assert.equal( - (await loader.load(preview.href)).metadata?.imageFetchState, + (await loader.load(preview.href).promise).metadata?.imageFetchState, "image", ); assert.equal(calls, 2); @@ -165,12 +176,15 @@ test("metadata loader retries rejected requests after the negative-cache TTL", a now: () => now, }); - assert.equal((await loader.load(preview.href)).metadata, null); - assert.equal((await loader.load(preview.href)).metadata, null); + assert.equal((await loader.load(preview.href).promise).metadata, null); + assert.equal((await loader.load(preview.href).promise).metadata, null); assert.equal(calls, 1); now += 5 * 60_000; - assert.deepEqual((await loader.load(preview.href)).metadata, metadata()); + assert.deepEqual( + (await loader.load(preview.href).promise).metadata, + metadata(), + ); assert.equal(calls, 2); }); @@ -191,10 +205,10 @@ test("metadata loader coalesces fragment variants and bounds concurrency", async }); 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"), + loader.load("https://example.com/one#first").promise, + loader.load("https://example.com/one#second").promise, + loader.load("https://example.com/two").promise, + loader.load("https://example.com/three").promise, ]); assert.equal(calls, 3); @@ -504,12 +518,15 @@ test("invalidateNegative drops a cached null miss so the next load refetches", a now: () => now, }); - assert.equal((await loader.load(preview.href)).metadata, null); + assert.equal((await loader.load(preview.href).promise).metadata, null); assert.equal(calls, 1); loader.invalidateNegative(preview.href); nextResult = metadata(); - assert.deepEqual((await loader.load(preview.href)).metadata, metadata()); + assert.deepEqual( + (await loader.load(preview.href).promise).metadata, + metadata(), + ); assert.equal(calls, 2); }); @@ -538,7 +555,7 @@ test("invalidateNegative drops a cached transient failure so the next load refet }); assert.equal( - (await loader.load(preview.href)).metadata?.imageFetchState, + (await loader.load(preview.href).promise).metadata?.imageFetchState, "transient_failure", ); assert.equal(calls, 1); @@ -547,7 +564,7 @@ test("invalidateNegative drops a cached transient failure so the next load refet // the cooldown — is what forces the refetch. loader.invalidateNegative(preview.href); assert.equal( - (await loader.load(preview.href)).metadata?.imageFetchState, + (await loader.load(preview.href).promise).metadata?.imageFetchState, "image", ); assert.equal(calls, 2); @@ -566,11 +583,17 @@ test("invalidateNegative leaves a healthy cached hit untouched", async () => { now: () => now, }); - assert.deepEqual((await loader.load(preview.href)).metadata, metadata()); + assert.deepEqual( + (await loader.load(preview.href).promise).metadata, + metadata(), + ); assert.equal(calls, 1); loader.invalidateNegative(preview.href); - assert.deepEqual((await loader.load(preview.href)).metadata, metadata()); + assert.deepEqual( + (await loader.load(preview.href).promise).metadata, + metadata(), + ); assert.equal(calls, 1); }); @@ -599,10 +622,74 @@ test("invalidateNegative leaves an in-flight fetch untouched", async () => { assert.equal(calls, 1, "no redundant fetch started by the bust"); releaseFetch(); - assert.deepEqual((await pending).metadata, metadata()); + assert.deepEqual((await pending.promise).metadata, metadata()); assert.equal(calls, 1, "the original in-flight fetch resolved, not a retry"); }); +test("an orphaned metadata load is cancelled after its re-entry grace period", async () => { + let cancelled = false; + const loader = __linkPreviewMetadataTest.createMetadataLoader({ + fetcher: (_href, signal) => + new Promise((_resolve, reject) => { + signal.addEventListener( + "abort", + () => { + cancelled = true; + reject(new Error("cancelled")); + }, + { once: true }, + ); + }), + }); + + const load = loader.load(preview.href); + const rejection = assert.rejects(load.promise, /cancelled/); + load.cancel(); + await new Promise((resolve) => setTimeout(resolve, 1_050)); + assert.equal(cancelled, true); + await rejection; +}); + +test("new metadata demand immediately reclaims slots from orphaned loads", async () => { + const started = []; + const cancelled = []; + const releases = new Map(); + const loader = __linkPreviewMetadataTest.createMetadataLoader({ + concurrency: 2, + fetcher: (href, signal) => + new Promise((resolve, reject) => { + started.push(href); + releases.set(href, () => resolve(metadata())); + signal.addEventListener( + "abort", + () => { + cancelled.push(href); + reject(new Error("cancelled")); + }, + { once: true }, + ); + }), + }); + + const firstHref = "https://example.com/stalled-one"; + const secondHref = "https://example.com/stalled-two"; + const thirdHref = "https://example.com/ordinary"; + const first = loader.load(firstHref); + const second = loader.load(secondHref); + const firstSettled = first.promise.catch(() => undefined); + const secondSettled = second.promise.catch(() => undefined); + first.cancel(); + second.cancel(); + + const third = loader.load(thirdHref); + await new Promise((resolve) => setImmediate(resolve)); + assert.deepEqual(started, [firstHref, secondHref, thirdHref]); + assert.deepEqual(new Set(cancelled), new Set([firstHref, secondHref])); + releases.get(thirdHref)(); + assert.deepEqual((await third.promise).metadata, metadata()); + await Promise.all([firstSettled, secondSettled]); +}); + // ── Hook-level regression: retained resolved-state invalidation on re-entry ─── // // The loader tests above prove `invalidateNegative` drops the SHARED loader @@ -899,3 +986,146 @@ test("hook clears a re-entered negative even when the shared cache holds an in-f ipcHandlers.clear(); } }); + +test("preparation abort releases only its own shared metadata lease", async () => { + const { prepareLinkPreview, resetLinkPreviewPreparations } = await import( + "../../features/messages/lib/linkPreviewPreparationStore.ts" + ); + + resetLinkPreviewMetadataCache(); + resetLinkPreviewPreparations(); + ipcHandlers.clear(); + + const href = "https://example.com/shared-pending"; + let fetchRequest; + const cancelledRequestIds = []; + ipcHandlers.set("fetch_link_preview_metadata", (args) => { + const request = deferred(); + fetchRequest = { ...args, ...request }; + return request.promise; + }); + ipcHandlers.set("cancel_link_preview_metadata", ({ requestId }) => { + cancelledRequestIds.push(requestId); + return Promise.resolve(); + }); + ipcHandlers.set("release_link_preview_metadata", () => Promise.resolve()); + + try { + const composerLoad = loadLinkPreviewMetadata(href); + const preparation = prepareLinkPreview({ ...hookPreview, href }); + await new Promise((resolve) => setImmediate(resolve)); + assert.ok(fetchRequest, "shared metadata reached the native fetch seam"); + + resetLinkPreviewPreparations(); + assert.equal(await preparation, null); + await new Promise((resolve) => setTimeout(resolve, 1_050)); + assert.deepEqual( + cancelledRequestIds, + [], + "preparation listener plus finally must not release the composer lease", + ); + + fetchRequest.resolve(metadata()); + assert.deepEqual(await composerLoad.promise, metadata()); + + const finalHref = `${href}/final`; + const finalLoad = loadLinkPreviewMetadata(finalHref); + await new Promise((resolve) => setImmediate(resolve)); + const finalRequest = fetchRequest; + const finalRejection = assert.rejects(finalLoad.promise); + finalLoad.cancel(); + finalLoad.cancel(); + await new Promise((resolve) => setTimeout(resolve, 1_050)); + assert.deepEqual( + cancelledRequestIds, + [finalRequest.requestId], + "one final consumer release permits native abort", + ); + finalRequest.reject(new Error("cancelled by test native seam")); + await finalRejection; + } finally { + resetLinkPreviewPreparations(); + resetLinkPreviewMetadataCache(); + ipcHandlers.clear(); + } +}); + +test("removing two stalled previews cancels native work and admits a third URL", async () => { + const { act, cleanup, renderHook } = await import("@testing-library/react"); + const { useResolvedLinkPreviews } = await import( + "./useResolvedLinkPreviews.ts" + ); + + resetLinkPreviewMetadataCache(); + ipcHandlers.clear(); + + const firstPreview = { + ...hookPreview, + href: "https://example.com/stalled-one", + }; + const secondPreview = { + ...hookPreview, + href: "https://example.com/stalled-two", + }; + const thirdPreview = { ...hookPreview, href: "https://example.com/ordinary" }; + const pending = new Map(); + const started = []; + const cancelled = []; + + ipcHandlers.set("fetch_link_preview_metadata", ({ href, requestId }) => { + started.push(href); + return new Promise((resolve, reject) => { + pending.set(requestId, { href, reject, resolve }); + }); + }); + ipcHandlers.set("cancel_link_preview_metadata", ({ requestId }) => { + const request = pending.get(requestId); + if (!request) return Promise.resolve(); + cancelled.push(request.href); + pending.delete(requestId); + request.reject(new Error("cancelled by test native seam")); + return Promise.resolve(); + }); + ipcHandlers.set("release_link_preview_metadata", () => Promise.resolve()); + + const flushScheduledLoads = async () => { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + }; + + const hook = renderHook(({ previews }) => useResolvedLinkPreviews(previews), { + initialProps: { previews: [firstPreview, secondPreview] }, + }); + + try { + await flushScheduledLoads(); + assert.deepEqual(started, [firstPreview.href, secondPreview.href]); + + hook.rerender({ previews: [thirdPreview] }); + await flushScheduledLoads(); + assert.deepEqual( + new Set(cancelled), + new Set([firstPreview.href, secondPreview.href]), + ); + assert.deepEqual(started, [ + firstPreview.href, + secondPreview.href, + thirdPreview.href, + ]); + + const third = [...pending.values()].find( + (request) => request.href === thirdPreview.href, + ); + assert.ok(third, "third URL reached the native fetch seam"); + await act(async () => { + third.resolve(metadata()); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + assert.equal(hook.result.current[0].title, "A story"); + } finally { + hook.unmount(); + cleanup(); + ipcHandlers.clear(); + } +}); diff --git a/desktop/src/shared/lib/useResolvedLinkPreviews.ts b/desktop/src/shared/lib/useResolvedLinkPreviews.ts index 4591d54e7dd..5111929b3e3 100644 --- a/desktop/src/shared/lib/useResolvedLinkPreviews.ts +++ b/desktop/src/shared/lib/useResolvedLinkPreviews.ts @@ -51,6 +51,10 @@ type MetadataLoadResult = MetadataCacheEntry & { const DEFAULT_TRANSIENT_RETRY_MS = 30_000; const NULL_METADATA_RETRY_MS = 5 * 60_000; const MAX_CONCURRENT_METADATA_FETCHES = 2; +// Keep a just-removed request cacheable through a brief edit/re-entry gap, but +// never let unobserved native work linger indefinitely. New queued demand +// reclaims these loads immediately rather than waiting for this grace period. +const ORPHANED_METADATA_LOAD_GRACE_MS = 1_000; /** * React may flush an interaction-triggered effect before the browser paints. @@ -107,29 +111,70 @@ function metadataExpiry( return now + retryAfterMs; } +type PendingMetadataLoad = { + controller: AbortController; + consumers: number; + orphanTimer: ReturnType | null; + promise: Promise; +}; + +type MetadataCacheValue = MetadataCacheEntry | PendingMetadataLoad; + +function isPendingMetadataLoad( + value: MetadataCacheValue, +): value is PendingMetadataLoad { + return "promise" in value; +} + +function abortError(): DOMException { + return new DOMException("Link preview fetch cancelled", "AbortError"); +} + function createTaskScheduler(concurrency: number) { - const pending: Array<() => void> = []; + const pending: Array<{ + reject: (reason?: unknown) => void; + run: () => void; + signal: AbortSignal; + }> = []; let active = 0; const drain = () => { while (active < concurrency) { - const run = pending.shift(); - if (!run) return; + const next = pending.shift(); + if (!next) return; + if (next.signal.aborted) { + next.reject(abortError()); + continue; + } active += 1; - run(); + next.run(); } }; - return (task: () => Promise): Promise => + return (task: () => Promise, signal: AbortSignal): Promise => new Promise((resolve, reject) => { - pending.push(() => { - void task() - .then(resolve, reject) - .finally(() => { - active -= 1; - drain(); - }); - }); + const entry = { + reject, + signal, + run: () => { + signal.removeEventListener("abort", cancelPending); + void task() + .then(resolve, reject) + .finally(() => { + active -= 1; + drain(); + }); + }, + }; + const cancelPending = () => { + const index = pending.indexOf(entry); + if (index < 0) return; + pending.splice(index, 1); + reject(abortError()); + drain(); + }; + signal.addEventListener("abort", cancelPending, { once: true }); + pending.push(entry); drain(); }); } @@ -140,20 +185,20 @@ function createMetadataLoader({ now = Date.now, }: { concurrency?: number; - fetcher: (href: string) => Promise; + fetcher: ( + href: string, + signal: AbortSignal, + ) => Promise; now?: () => number; }) { - const cache = new Map< - string, - MetadataCacheEntry | Promise - >(); + const cache = new Map(); const schedule = createTaskScheduler(Math.max(1, concurrency)); let generation = 0; const peek = (href: string): MetadataLoadResult | undefined => { const key = metadataCacheKey(href); const cached = cache.get(key); - if (!cached || cached instanceof Promise) return undefined; + if (!cached || isPendingMetadataLoad(cached)) return undefined; if (cached.expiresAt !== null && cached.expiresAt <= now()) { cache.delete(key); return undefined; @@ -161,32 +206,116 @@ function createMetadataLoader({ return { key, ...cached }; }; - const load = (href: string): Promise => { + const abortPending = (key: string, pending: PendingMetadataLoad) => { + if (pending.orphanTimer !== null) clearTimeout(pending.orphanTimer); + pending.orphanTimer = null; + if (cache.get(key) === pending) cache.delete(key); + pending.controller.abort(); + }; + + const release = (key: string, pending: PendingMetadataLoad) => { + if (pending.consumers <= 0) return; + pending.consumers -= 1; + if ( + pending.consumers > 0 || + pending.orphanTimer !== null || + cache.get(key) !== pending + ) { + return; + } + pending.orphanTimer = setTimeout( + () => abortPending(key, pending), + ORPHANED_METADATA_LOAD_GRACE_MS, + ); + }; + + const createRelease = (key: string, pending: PendingMetadataLoad) => { + let released = false; + return () => { + if (released) return; + released = true; + release(key, pending); + }; + }; + + const abortOrphanedLoads = (exceptKey: string) => { + for (const [key, cached] of cache) { + if ( + key !== exceptKey && + isPendingMetadataLoad(cached) && + cached.consumers === 0 + ) { + abortPending(key, cached); + } + } + }; + + const load = ( + href: string, + ): { cancel: () => void; promise: Promise } => { const key = metadataCacheKey(href); const cached = cache.get(key); - if (cached instanceof Promise) return cached; + if (cached && isPendingMetadataLoad(cached)) { + if (cached.orphanTimer !== null) clearTimeout(cached.orphanTimer); + cached.orphanTimer = null; + cached.consumers += 1; + return { + cancel: createRelease(key, cached), + promise: cached.promise, + }; + } if (cached) { if (cached.expiresAt === null || cached.expiresAt > now()) { - return Promise.resolve({ key, ...cached }); + return { + cancel: () => undefined, + promise: Promise.resolve({ key, ...cached }), + }; } cache.delete(key); } + abortOrphanedLoads(key); const requestGeneration = generation; - const promise = schedule(() => fetcher(href)) - .catch(() => null) + const controller = new AbortController(); + const pending: PendingMetadataLoad = { + controller, + consumers: 1, + orphanTimer: null, + promise: Promise.resolve({ + expiresAt: null, + key, + metadata: null, + }), + }; + pending.promise = schedule( + () => fetcher(href, controller.signal), + controller.signal, + ) + .catch((error) => { + if (controller.signal.aborted) throw error; + return null; + }) .then((metadata) => { + if (pending.orphanTimer !== null) clearTimeout(pending.orphanTimer); + pending.orphanTimer = null; const entry = { expiresAt: metadataExpiry(metadata, now()), metadata, }; - if (requestGeneration === generation) { + if ( + requestGeneration === generation && + cache.get(key) === pending && + !controller.signal.aborted + ) { cache.set(key, entry); } return { key, ...entry }; }); - cache.set(key, promise); - return promise; + cache.set(key, pending); + return { + cancel: createRelease(key, pending), + promise: pending.promise, + }; }; return { @@ -205,7 +334,7 @@ function createMetadataLoader({ invalidateNegative(href: string): boolean { const key = metadataCacheKey(href); const cached = cache.get(key); - if (!cached || cached instanceof Promise) return false; + if (!cached || isPendingMetadataLoad(cached)) return false; if (!isNegativeMetadata(cached.metadata)) return false; cache.delete(key); return true; @@ -214,6 +343,9 @@ function createMetadataLoader({ peek, reset() { generation += 1; + for (const [key, cached] of cache) { + if (isPendingMetadataLoad(cached)) abortPending(key, cached); + } cache.clear(); }, }; @@ -221,13 +353,57 @@ function createMetadataLoader({ function fetchLinkPreviewMetadata( href: string, + signal: AbortSignal, ): Promise { - return invokeTauri( + const requestId = crypto.randomUUID(); + const startedAt = performance.now(); + const logResult = ( + result: LinkPreviewMetadata | null, + ): LinkPreviewMetadata | null => { + if (import.meta.env?.DEV) { + console.info("[link-preview] metadata fetch completed", { + href, + elapsedMs: Math.round(performance.now() - startedAt), + result: result === null ? "miss" : "hit", + imageFetchState: result?.imageFetchState ?? "none", + imageRetryAfterMs: result?.imageRetryAfterMs ?? null, + hasImage: Boolean(result?.imageDataUrl && result.imageDomain), + hasFavicon: Boolean(result?.faviconDataUrl), + }); + } + return result; + }; + const logFailure = (error: unknown): never => { + if (import.meta.env?.DEV) { + console.warn("[link-preview] metadata fetch failed", { + href, + elapsedMs: Math.round(performance.now() - startedAt), + error, + }); + } + throw error; + }; + + const request = invokeTauri( "fetch_link_preview_metadata", { href, + requestId, }, ); + const onAbort = () => { + void invokeTauri("cancel_link_preview_metadata", { requestId }).catch( + () => undefined, + ); + }; + signal.addEventListener("abort", onAbort, { once: true }); + if (signal.aborted) onAbort(); + return request.then(logResult, logFailure).finally(() => { + signal.removeEventListener("abort", onAbort); + void invokeTauri("release_link_preview_metadata", { requestId }).catch( + () => undefined, + ); + }); } const metadataLoader = createMetadataLoader({ @@ -235,10 +411,15 @@ const metadataLoader = createMetadataLoader({ }); /** Share the same deduplicated metadata job between composer rendering and send preparation. */ -export async function loadLinkPreviewMetadata( - href: string, -): Promise { - return (await metadataLoader.load(href)).metadata; +export function loadLinkPreviewMetadata(href: string): { + cancel: () => void; + promise: Promise; +} { + const load = metadataLoader.load(href); + return { + cancel: load.cancel, + promise: load.promise.then((result) => result.metadata), + }; } type EntityEventFetcher = ( filter: Parameters[0], @@ -348,14 +529,19 @@ export async function fetchBuzzEntityMetadata( } const entityMetadataLoader = createMetadataLoader({ - fetcher: fetchBuzzEntityMetadata, + fetcher: (href) => fetchBuzzEntityMetadata(href), }); /** Share deduplicated relay-native entity metadata across cards and inline tooltips. */ export async function loadBuzzEntityMetadata( href: string, ): Promise { - return (await entityMetadataLoader.load(href)).metadata; + const load = entityMetadataLoader.load(href); + try { + return (await load.promise).metadata; + } finally { + load.cancel(); + } } /** Clear ephemeral metadata when the active relay/community changes. */ @@ -622,15 +808,19 @@ export function useResolvedLinkPreviews( 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); - }); + const load = loader.load(preview.href); + cancelScheduledLoads.push(load.cancel); + void load.promise + .then((result) => { + if (cancelled) return; + setResolvedMetadata((current) => + current[result.key] === result.metadata + ? current + : { ...current, [result.key]: result.metadata }, + ); + scheduleRetry(result, loader); + }) + .catch(() => undefined); }), ); } From c045321a7fb3ca8939f28519ce7a555a6f597728 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Tue, 8 Sep 2026 17:51:47 -0400 Subject: [PATCH 02/19] fix(acp): pace targeted overflow recovery on consumer capacity (#7325) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 ## Summary When a Buzz agent falls behind on incoming messages, its connection can make the backlog worse while trying to recover. The connection buffers messages from the relay server until the agent is ready to process them; if that buffer overflows, recovery previously requested history for **every subscribed channel** and paused socket reads while sending those requests. That adds traffic to an already overloaded connection. This change requests history only for affected subscriptions, once the code consuming those messages has room, with at least five seconds between attempts. The recovery path now: - Combines repeated losses into one pending recovery per affected subscription, keeping the oldest dropped timestamp so replay starts early enough. - Waits until at least half the consumer queue is free and the relay's existing rate-limit delay has expired. The queue wakes recovery when space becomes available; recovery does not periodically sample capacity or hold queue space away from live messages. - Attempts one subscription at a time, choosing the least recently attempted so a busy channel cannot crowd out other channels or membership notifications. The five-second delay starts when an attempt finishes, including a failed write; failed writes leave recovery pending. Recovery is paced by available capacity, not by how often messages are lost. This is not a larger buffer or a cutoff that abandons recovery. Subscription identifiers, message filters, replay timestamp overlap and duplicate filtering are unchanged; no downstream agent changes are required. This targets a reproducible overload **amplifier**, not every cause of overload or every catch-up limitation. The initial live overload's cause has not been established. Recovery remains best effort: a successful request write is not proof of delivery, and existing history/retention limits, bounded duplicate tracking and replay limitations still apply. There is no exactly-once or complete catch-up guarantee. A stalled write can still pause socket reads for the existing ten-second timeout; the pacing bound does not cover initial subscriptions, reconnects or other retry paths. ### Related issue Closest related: #5014 (channel re-subscription); also #6661 (membership reconciliation) and #6090 (relay backpressure gap signaling). This addresses local overflow recovery scheduling, not those separate mechanisms. ### Testing Recorded offline comparisons against the previous behavior, with the final implementation at `8000636f3073167c5a5107bb179c7d91160f1729`: | Same fixture: 18 subscriptions, three overload rounds | Before | After | | --- | --- | --- | | Recovery history requests | 108 | 3 | | Ping-response delay | About 4.6 seconds | Below the measurement's 1 ms resolution | A separate bounded-history fixture delivered all 320 events plus subsequent live traffic in **both** versions. Regression coverage exercises the real socket-handling task, including intermittent consumer capacity, fairness, failed writes and cancellation of capacity waits before live delivery. These are synthetic results, not production throughput measurements or evidence of a deployed cure. The full local `RUST_TEST_THREADS=4 just ci` run passed on September 4, 2026. Earlier unsuccessful local runs remain part of the validation history. The [recorded validation evidence and separate desktop follow-up](https://github.com/block/buzz/pull/7325#issuecomment-5540592398) preserve the original desktop mock-history scroll failure, its passing rerun and the remaining investigation. That desktop path does not run the agent connection code; neither this repair nor the passing rerun fixes the observed scroll problem. --------- Signed-off-by: Logan Johnson --- crates/buzz-acp/README.md | 4 + crates/buzz-acp/src/relay.rs | 112 +--- crates/buzz-acp/src/relay/recovery.rs | 132 +++++ crates/buzz-acp/src/relay/recovery_tests.rs | 388 ++++++++++++++ .../buzz-acp/src/relay/recovery_wake_tests.rs | 507 ++++++++++++++++++ 5 files changed, 1052 insertions(+), 91 deletions(-) create mode 100644 crates/buzz-acp/src/relay/recovery.rs create mode 100644 crates/buzz-acp/src/relay/recovery_tests.rs create mode 100644 crates/buzz-acp/src/relay/recovery_wake_tests.rs diff --git a/crates/buzz-acp/README.md b/crates/buzz-acp/README.md index 41d9a214bdd..3d011eb8ebb 100644 --- a/crates/buzz-acp/README.md +++ b/crates/buzz-acp/README.md @@ -270,6 +270,10 @@ Forum event kinds: 4. **Prompting** — When events are pending and no prompt is in flight for that channel, drains all queued events for the oldest channel into a single batched prompt via ACP `session/prompt`. 5. **Agent response** — The agent processes the prompt and uses the Buzz CLI (`send_message`, `get_messages`, etc.) to interact with Buzz. 6. **Recovery** — If the agent crashes, the harness respawns it. If the relay disconnects, the harness reconnects with a `since` filter to avoid missing events. + If the inbound queue overflows, the harness attempts replay for affected + subscriptions when capacity and relay quota permit, with at least five seconds + between attempts. Recovery depends on available relay history and the consumer + making progress; complete delivery is not guaranteed. Each channel has at most one prompt in flight. Multiple channels can be processed concurrently when agents > 1. diff --git a/crates/buzz-acp/src/relay.rs b/crates/buzz-acp/src/relay.rs index 23ed454fa2e..a019e758341 100644 --- a/crates/buzz-acp/src/relay.rs +++ b/crates/buzz-acp/src/relay.rs @@ -1160,10 +1160,8 @@ struct BgState { /// On reconnect resubscribe, `since` = min(last_seen, channel_dropped_since). /// Cleared per-channel after a successful resubscribe. channel_dropped_since: HashMap, - /// Set by the backpressure handler when the event channel is full. - /// The main loop checks this flag and triggers a proactive resubscribe - /// (without waiting for a disconnect) so dropped events are replayed. - proactive_resubscribe_needed: bool, + /// Rate/fairness bookkeeping only; replay cursors retain baseline semantics. + recovery: recovery::RecoverySchedule, /// Unix timestamp captured just before the relay connection was established. /// Used as the floor `since` for membership notification replay so events /// predating this session are never re-delivered. @@ -1237,7 +1235,7 @@ impl BgState { membership_sub_active: false, observer_control_sub_active: false, channel_dropped_since: HashMap::new(), - proactive_resubscribe_needed: false, + recovery: recovery::RecoverySchedule::default(), startup_watermark: None, subscribe_since: HashMap::new(), rate_limit_gate: None, @@ -1298,6 +1296,9 @@ impl BgState { /// Prevents stale replay on re-subscribe and avoids unbounded state growth /// for channels that are removed and never re-added. fn clear_channel_state(&mut self, channel_id: &Uuid) { + self.recovery + .last_attempt + .remove(&channel_sub_id(*channel_id)); self.last_seen.remove(channel_id); self.subscribe_since.remove(channel_id); self.channel_dropped_since.remove(channel_id); @@ -1826,82 +1827,6 @@ async fn run_background_task( let mut drain_pacing_next: Option = None; loop { - if state.proactive_resubscribe_needed { - state.proactive_resubscribe_needed = false; - info!("proactive resubscribe triggered by backpressure event loss"); - // Proactive resubscribe runs on the EXISTING socket — do NOT clear the - // rate-limit gate or pending queues. - match resubscribe_after_reconnect( - &mut ws, - &mut cmd_rx, - &mut state, - &agent_pubkey_hex, - false, // existing socket — preserve gate state - ) - .await - { - ResubscribeResult::Ok => {} - ResubscribeResult::Shutdown => return, - ResubscribeResult::RetryConnection => { - warn!("proactive resubscribe had failures — triggering reconnect"); - let _ = event_tx.try_send(None); - match try_autonomous_reconnect( - &mut ws, - &mut cmd_rx, - &mut state, - &keys, - &relay_url, - &agent_pubkey_hex, - &event_tx, - &observer_control_tx, - auth_tag.as_ref(), - ) - .await - { - ReconnectOutcome::Ok => { - if matches!( - drain_post_reconnect( - &mut ws, - &mut cmd_rx, - &mut state, - &agent_pubkey_hex - ) - .await, - ReconnectOutcome::Shutdown - ) { - return; - } - } - ReconnectOutcome::Shutdown => return, - ReconnectOutcome::Failed => { - if matches!( - wait_for_reconnect( - &mut ws, - &mut cmd_rx, - &mut state, - &keys, - &relay_url, - &agent_pubkey_hex, - &event_tx, - &observer_control_tx, - true, - auth_tag.as_ref(), - ) - .await, - ReconnectOutcome::Shutdown - ) { - return; - } - } - } - ping_sent = false; - last_pong = Instant::now(); - connected_since = Instant::now(); - stable_logged = false; - } - } - } - // Drain pending subs, one REQ per pacing tick within the relay's // admission window. let drain_window_open = drain_pacing_next.is_none_or(|t| tokio::time::Instant::now() >= t); @@ -1982,7 +1907,11 @@ async fn run_background_task( } } + let recovery_at = recovery::ready_at(&mut state); tokio::select! { + _ = recovery::ready(&event_tx, recovery_at) => { + recovery::recover_one(&mut ws, &mut state, &event_tx, &agent_pubkey_hex).await; + } raw = ws.next() => { // Determine if the socket is lost. let socket_lost = match raw { @@ -2358,12 +2287,10 @@ async fn handle_ws_message( // replay starts early enough to re-deliver it. state.membership_dropped_since = Some(state.membership_dropped_since.map_or(ts, |d| d.min(ts))); - // Proactively trigger resubscribe without waiting for a disconnect. - state.proactive_resubscribe_needed = true; warn!( channel_id = %channel_uuid, ts, - "membership notification dropped (backpressure) — proactive resubscribe queued" + "membership notification dropped (backpressure) — targeted recovery pending" ); } Err(mpsc::error::TrySendError::Closed(_)) => return false, @@ -2400,12 +2327,10 @@ async fn handle_ws_message( .entry(channel_id) .and_modify(|d| *d = (*d).min(ts)) .or_insert(ts); - // Proactively trigger resubscribe without waiting for a disconnect. - state.proactive_resubscribe_needed = true; warn!( channel_id = %channel_id, ts, - "event channel full — dropping event for channel {channel_id} — proactive resubscribe queued" + "event channel full — dropping event for channel {channel_id} — targeted recovery pending" ); } Err(mpsc::error::TrySendError::Closed(_)) => { @@ -4248,6 +4173,11 @@ async fn wait_for_any_ok( } } +mod recovery; + +#[cfg(test)] +mod recovery_tests; + #[cfg(test)] mod tests { use super::*; @@ -4775,7 +4705,7 @@ mod tests { .expect("signing should succeed") } - async fn test_ws_pair() -> (WsStream, WebSocketStream) { + pub(super) async fn test_ws_pair() -> (WsStream, WebSocketStream) { let listener = tokio::net::TcpListener::bind("127.0.0.1:0") .await .expect("bind test websocket"); @@ -4792,7 +4722,7 @@ mod tests { (client, server.await.expect("join test websocket server")) } - async fn next_test_frame( + pub(super) async fn next_test_frame( server: &mut WebSocketStream, ) -> serde_json::Value { let message = timeout(Duration::from_secs(1), server.next()) @@ -5035,14 +4965,14 @@ mod tests { )); } - fn test_channel_filter() -> ChannelFilter { + pub(super) fn test_channel_filter() -> ChannelFilter { ChannelFilter { kinds: Some(vec![9]), require_mention: false, } } - fn seed_test_subscription(state: &mut BgState, channel_id: Uuid) { + pub(super) fn seed_test_subscription(state: &mut BgState, channel_id: Uuid) { apply_command_to_state( state, RelayCommand::Subscribe { diff --git a/crates/buzz-acp/src/relay/recovery.rs b/crates/buzz-acp/src/relay/recovery.rs new file mode 100644 index 00000000000..62da119a4be --- /dev/null +++ b/crates/buzz-acp/src/relay/recovery.rs @@ -0,0 +1,132 @@ +//! Overflow recovery is an attempted replay, not an EOSE/consumer receipt. +//! Keep the existing IDs and cursor retirement rules; bound when work is sent. +use super::*; + +pub(super) const RECOVERY_INTERVAL: Duration = Duration::from_secs(5); + +#[derive(Default)] +pub(super) struct RecoverySchedule { + next_attempt: Option, + pub(super) last_attempt: HashMap, +} + +/// Attempt at most one affected subscription, with space for replay to arrive. +/// Failed writes retain the loss cursor and are paced too. No EOSE is interpreted +/// as completion: overlapping requests keep their existing stable wire IDs. +pub(super) async fn recover_one( + ws: &mut WsStream, + state: &mut BgState, + event_tx: &mpsc::Sender>, + agent_pubkey_hex: &str, +) { + let now = tokio::time::Instant::now(); + if event_tx.is_closed() + || event_tx.capacity() < event_tx.max_capacity().div_ceil(2) + || state.recovery.next_attempt.is_some_and(|next| now < next) + || state.check_rate_gate().is_some() + { + return; + } + + let channel = next_channel(state); + let Some(channel) = channel else { return }; + let sub = channel.map_or_else(|| MEMBERSHIP_NOTIF_SUB_ID.to_owned(), channel_sub_id); + state.recovery.last_attempt.insert(sub.clone(), now); + info!(subscription = sub, "attempting targeted overflow replay"); + + if let Some(ch) = channel { + if let Some(filter) = state.active_filters.get(&ch).cloned() { + let since = state.channel_since(&ch); + if send_subscribe(ws, state, ch, agent_pubkey_hex, since, &filter).await { + // Baseline retirement point: REQ write, NOT proven delivery. + // New overflow after this attempt creates another pending cursor. + state.channel_dropped_since.remove(&ch); + } + } + } else { + let since = match (state.membership_dropped_since, state.membership_last_seen) { + (Some(d), Some(l)) => Some(d.min(l)), + (Some(d), None) => Some(d), + (None, Some(l)) => Some(l), + (None, None) => state.startup_watermark, + }; + if send_membership_subscribe(ws, agent_pubkey_hex, since).await { + state.membership_dropped_since = None; + } + } + // Pace from the end of a potentially backpressured write. No catch-up burst. + // The existing bounded write timeout and read/ping owner detect socket loss. + state.recovery.next_attempt = Some(tokio::time::Instant::now() + RECOVERY_INTERVAL); +} + +/// No timer or capacity waiter when another authority owns all pending loss. +/// Only actual attempts advance the cooldown; closed gates wake at expiry. +pub(super) fn ready_at(state: &mut BgState) -> Option { + next_channel(state)?; + Some( + state + .recovery + .next_attempt + .unwrap_or_else(tokio::time::Instant::now) + .max( + state + .check_rate_gate() + .unwrap_or_else(tokio::time::Instant::now), + ), + ) +} + +/// Select-local readiness, not a send or a reservation carried across reads. +/// The socket task is the sole producer. `select!` drops this future (including +/// partial permits) BEFORE handling another frame/command, so live try_send +/// never competes with a recovery reservation. Receives only add capacity. +/// Keep this future inside select!: awaiting it alone would block the reader; +/// persisting it across iterations would steal capacity from live delivery. +pub(super) async fn ready( + event_tx: &mpsc::Sender>, + at: Option, +) { + if let Some(at) = at { + if tokio::time::Instant::now() < at { + tokio::time::sleep_until(at).await; + } + // Use the channel's own race-free capacity wake, not periodic samples. + // Return ALL permits before recover_one rechecks capacity and intent. + if let Ok(permits) = event_tx + .reserve_many(event_tx.max_capacity().div_ceil(2)) + .await + { + drop(permits); + return; + } + } + // No loss or a closed receiver: no immediate-ready/error wake loop. + std::future::pending::<()>().await; +} + +fn next_channel(state: &BgState) -> Option> { + // One record per active intent, not per loss or per request generation. + // Least recently attempted prevents a repeatedly overflowing channel from + // starving other channels or membership. Missing filters fail closed. + state + .channel_dropped_since + .keys() + .filter(|ch| { + state.active_subscriptions.contains_key(ch) + && state.active_filters.contains_key(ch) + && !state.rate_limited_pending.contains_key(ch) + && !state.resubscribe_retry.contains(ch) + }) + .copied() + .map(Some) + .chain( + (state.membership_sub_active + && state.membership_dropped_since.is_some() + && !state.membership_resub_needed) + .then_some(None), + ) + .min_by_key(|ch| { + let sub = ch.map_or_else(|| MEMBERSHIP_NOTIF_SUB_ID.to_owned(), channel_sub_id); + (state.recovery.last_attempt.get(&sub).copied(), sub) + }) +} diff --git a/crates/buzz-acp/src/relay/recovery_tests.rs b/crates/buzz-acp/src/relay/recovery_tests.rs new file mode 100644 index 00000000000..4e51a0c6147 --- /dev/null +++ b/crates/buzz-acp/src/relay/recovery_tests.rs @@ -0,0 +1,388 @@ +//! Bounded synthetic WebSocket fixtures; no relay service, proxy or real agent. +use super::tests::{next_test_frame, seed_test_subscription, test_channel_filter, test_ws_pair}; +use super::*; + +fn fixture_event(channel: Uuid, n: u64, kind: u16) -> Event { + let keys = + Keys::parse("0000000000000000000000000000000000000000000000000000000000000001").unwrap(); + EventBuilder::new(Kind::Custom(kind), format!("synthetic-{n}")) + .tags([Tag::parse(["h", &channel.to_string()]).unwrap()]) + .custom_created_at(nostr::Timestamp::from(1_000 + n)) + .sign_with_keys(&keys) + .unwrap() +} + +async fn dispatch( + client: &mut WsStream, + state: &mut BgState, + tx: &mpsc::Sender>, + frame: Value, +) { + let (control_tx, _control_rx) = mpsc::channel(1); + assert!( + handle_ws_message( + Message::Text(frame.to_string().into()), + client, + tx, + &control_tx, + state, + &Keys::generate(), + "ws://127.0.0.1:1", + "synthetic-agent", + None, + ) + .await + ); +} + +#[tokio::test] +async fn repeated_overflow_recovers_only_affected_channel_after_capacity() { + let (mut client, mut server) = test_ws_pair().await; + let mut state = BgState::new(); + let channels: Vec<_> = (0..18).map(|_| Uuid::new_v4()).collect(); + for ch in &channels { + seed_test_subscription(&mut state, *ch); + } + let ch = channels[0]; + let sub = channel_sub_id(ch); + let (tx, mut rx) = mpsc::channel(256); + // Relay history is newest-first; the oldest dropped event must survive + // a watermark already advanced by much newer successfully-enqueued events. + let events: Vec<_> = (0..320).rev().map(|n| fixture_event(ch, n, 9)).collect(); + for event in &events { + dispatch(&mut client, &mut state, &tx, json!(["EVENT", sub, event])).await; + recovery::recover_one(&mut client, &mut state, &tx, "synthetic-agent").await; + } + assert_eq!(rx.len(), 256); + assert_eq!(state.channel_dropped_since[&ch], 1_000); + assert!(timeout(Duration::from_millis(30), server.next()) + .await + .is_err()); + for event in &events[..256] { + assert_eq!(rx.recv().await.unwrap().unwrap().event.id, event.id); + } + recovery::recover_one(&mut client, &mut state, &tx, "synthetic-agent").await; + let req = next_test_frame(&mut server).await; + assert_eq!(req[0], "REQ"); + assert_eq!(req[1], sub); + assert_eq!(req[2]["#h"], json!([ch.to_string()])); + assert_eq!(req[2]["kinds"], json!([9])); + assert_eq!(req[2]["since"], 995); + // Concurrent timer ticks / duplicate arrivals cannot replace the replay. + for _ in 0..40 { + recovery::recover_one(&mut client, &mut state, &tx, "synthetic-agent").await; + } + assert!(timeout(Duration::from_millis(30), server.next()) + .await + .is_err()); + for event in &events { + dispatch(&mut client, &mut state, &tx, json!(["EVENT", sub, event])).await; + } + dispatch(&mut client, &mut state, &tx, json!(["EOSE", sub])).await; + assert_eq!(rx.len(), 64, "delivered IDs must remain deduplicated"); + for event in &events[256..] { + assert_eq!(rx.recv().await.unwrap().unwrap().event.id, event.id); + } + assert_eq!(state.channel_since(&ch), Some(1_319)); + recovery::recover_one(&mut client, &mut state, &tx, "synthetic-agent").await; + assert!(timeout(Duration::from_millis(30), server.next()) + .await + .is_err()); + let live = fixture_event(ch, 400, 9); + dispatch(&mut client, &mut state, &tx, json!(["EVENT", sub, live])).await; + assert_eq!(rx.recv().await.unwrap().unwrap().event.id, live.id); + println!("18 subscriptions; 320 newest-first arrivals; 64 losses coalesced; 0 REQ while full; 1 targeted REQ; 320 unique deliveries + live"); +} + +#[tokio::test] +async fn socket_owner_services_ping_shutdown_and_coalesces_overflow_ticks() { + let (client, mut server) = test_ws_pair().await; + let (tx, mut rx) = mpsc::channel(1); + let (control_tx, _control_rx) = mpsc::channel(1); + let (cmd_tx, cmd_rx) = mpsc::channel(64); + let task = tokio::spawn(run_background_task( + client, + VecDeque::new(), + tx, + control_tx, + cmd_rx, + Keys::generate(), + "ws://127.0.0.1:1".into(), + "synthetic-agent".into(), + None, + )); + let channels: Vec<_> = (0..18).map(|_| Uuid::new_v4()).collect(); + for ch in &channels { + cmd_tx + .send(RelayCommand::Subscribe { + channel_id: *ch, + filter: test_channel_filter(), + replay_since: Some(1_000), + }) + .await + .unwrap(); + } + let mut subscriptions = 0; + while subscriptions < 18 { + let frame = timeout(Duration::from_secs(2), server.next()) + .await + .unwrap() + .unwrap() + .unwrap(); + match frame { + Message::Text(text) => { + let req: Value = serde_json::from_str(&text).unwrap(); + assert_eq!(req[0], "REQ"); + server + .send(Message::Text(json!(["EOSE", req[1]]).to_string().into())) + .await + .unwrap(); + subscriptions += 1; + } + Message::Ping(payload) => server.send(Message::Pong(payload)).await.unwrap(), + other => panic!("unexpected {other:?}"), + } + } + let sub = channel_sub_id(channels[0]); + for n in 0..40 { + server + .send(Message::Text( + json!(["EVENT", sub, fixture_event(channels[0], n, 9)]) + .to_string() + .into(), + )) + .await + .unwrap(); + } + server.send(Message::Ping(vec![42].into())).await.unwrap(); + timeout(Duration::from_secs(2), async { + loop { + match server.next().await.unwrap().unwrap() { + Message::Ping(payload) => server.send(Message::Pong(payload)).await.unwrap(), + Message::Pong(payload) => { + assert_eq!(payload.as_ref(), &[42]); + break; + } + other => panic!("no immediate all-channel recovery before ping: {other:?}"), + } + } + }) + .await + .unwrap(); + // Wait across a recovery tick with the consumer still full. + assert!(socket_frame(&mut server, Duration::from_millis(5_100)) + .await + .is_none()); + rx.recv().await.unwrap().unwrap(); + let frame = timeout(Duration::from_secs(6), server.next()) + .await + .unwrap() + .unwrap() + .unwrap(); + let req: Value = serde_json::from_str(frame.to_text().unwrap()).unwrap(); + assert_eq!(req[1], sub); + assert_eq!(req[2]["since"], 996); + assert!(timeout(Duration::from_millis(100), server.next()) + .await + .is_err()); + // Sustained lag: each replay is followed by another burst, without EOSE. + // Recovery must stay paced, not permanently stall or sweep healthy channels. + for round in 1..=3 { + let started = tokio::time::Instant::now(); + for n in round * 40..(round + 1) * 40 { + server + .send(Message::Text( + json!(["EVENT", sub, fixture_event(channels[0], n, 9)]) + .to_string() + .into(), + )) + .await + .unwrap(); + } + server.send(Message::Ping(vec![43].into())).await.unwrap(); + let pong = timeout(Duration::from_secs(1), server.next()) + .await + .unwrap() + .unwrap() + .unwrap(); + assert!( + matches!(pong, Message::Pong(_)), + "recovery preempted ping: {pong:?}" + ); + rx.recv().await.unwrap().unwrap(); + let frame = timeout(Duration::from_secs(6), server.next()) + .await + .unwrap() + .unwrap() + .unwrap(); + let req: Value = serde_json::from_str(frame.to_text().unwrap()).unwrap(); + assert_eq!(req[1], sub, "healthy channels must not be swept"); + assert!( + started.elapsed() >= Duration::from_secs(4), + "unpaced repeat" + ); + } + cmd_tx.send(RelayCommand::Shutdown).await.unwrap(); + timeout(Duration::from_secs(1), task) + .await + .unwrap() + .unwrap(); + println!("socket-owner seam: 18 live REQs, 39 coalesced losses, PONG while full, zero recovery across full-capacity timer tick, four paced targeted REQs over sustained lag without EOSE, responsive shutdown"); +} + +#[tokio::test] +async fn recovery_is_fair_and_paced_even_with_new_loss_and_stale_eose() { + let (mut client, mut server) = test_ws_pair().await; + let mut state = BgState::new(); + let channels = [Uuid::new_v4(), Uuid::new_v4()]; + for ch in channels { + seed_test_subscription(&mut state, ch); + state.channel_dropped_since.insert(ch, 600); + } + state.membership_sub_active = true; + state.membership_dropped_since = Some(500); + let (tx, _rx) = mpsc::channel(1); + let mut visited = HashSet::new(); + for round in 0..9 { + timeout( + Duration::from_millis(100), + recovery::ready(&tx, recovery::ready_at(&mut state)), + ) + .await + .unwrap(); + recovery::recover_one(&mut client, &mut state, &tx, "agent").await; + let req = next_test_frame(&mut server).await; + let sub = req[1].as_str().unwrap(); + if round < 3 { + assert!(visited.insert(sub.to_owned()), "starved intent"); + } + if sub == MEMBERSHIP_NOTIF_SUB_ID { + assert_eq!(req[2]["since"], 495); + state.membership_dropped_since = Some(500); + } else { + let ch = channel_id_from_sub_id(sub).unwrap(); + assert_eq!(req[2]["since"], 595); + state.channel_dropped_since.insert(ch, 600); + } + // Neither stale nor current EOSE creates completion state or erases loss. + dispatch(&mut client, &mut state, &tx, json!(["EOSE", sub])).await; + for _ in 0..30 { + recovery::recover_one(&mut client, &mut state, &tx, "agent").await; + } + assert!(timeout(Duration::from_millis(1), server.next()) + .await + .is_err()); + advance_clock(recovery::RECOVERY_INTERVAL).await; + } + for ch in channels { + state.active_subscriptions.remove(&ch); + state.clear_channel_state(&ch); + assert!(!state + .recovery + .last_attempt + .contains_key(&channel_sub_id(ch))); + } + assert_eq!(state.recovery.last_attempt.len(), 1); +} + +#[tokio::test] +async fn gate_headroom_failed_writes_and_reconnect_preserve_pending_attempts() { + let (mut client, mut server) = test_ws_pair().await; + let mut state = BgState::new(); + let ch = Uuid::new_v4(); + seed_test_subscription(&mut state, ch); + state.channel_dropped_since.insert(ch, 700); + let (tx, mut rx) = mpsc::channel(4); + for _ in 0..3 { + tx.try_send(None).unwrap(); + } + recovery::recover_one(&mut client, &mut state, &tx, "agent").await; + assert!(state.recovery.last_attempt.is_empty()); + rx.recv().await; + state.rate_limit_gate = Some(tokio::time::Instant::now() + Duration::from_secs(10)); + recovery::recover_one(&mut client, &mut state, &tx, "agent").await; + assert!(state.recovery.last_attempt.is_empty()); + advance_clock(Duration::from_secs(10)).await; + // Close locally, so the actual production writer fails deterministically. + client.close(None).await.unwrap(); + server.next().await; + recovery::recover_one(&mut client, &mut state, &tx, "agent").await; + assert_eq!(state.channel_dropped_since[&ch], 700); + let attempted = state.recovery.last_attempt.clone(); + for _ in 0..30 { + recovery::recover_one(&mut client, &mut state, &tx, "agent").await; + } + assert_eq!(state.recovery.last_attempt, attempted); + advance_clock(recovery::RECOVERY_INTERVAL).await; + recovery::recover_one(&mut client, &mut state, &tx, "agent").await; + assert_ne!( + state.recovery.last_attempt, attempted, + "failed write must be retried" + ); + assert_eq!(state.channel_dropped_since[&ch], 700); + + let (mut client, mut server) = test_ws_pair().await; + let (_cmd_tx, mut cmd_rx) = mpsc::channel(1); + assert!(matches!( + resubscribe_after_reconnect(&mut client, &mut cmd_rx, &mut state, "agent", true,).await, + ResubscribeResult::Ok + )); + let req = next_test_frame(&mut server).await; + assert_eq!(req[1], channel_sub_id(ch)); + assert_eq!(req[2]["since"], 695); + assert!(!state.channel_dropped_since.contains_key(&ch)); + // This is deliberately the baseline write-retirement contract, not a receipt. +} + +async fn advance_clock(duration: Duration) { + tokio::time::pause(); + tokio::time::advance(duration).await; + tokio::time::resume(); +} + +#[tokio::test] +async fn blocked_recovery_write_is_bounded_and_retains_loss() { + let (mut client, _stalled_server) = test_ws_pair().await; + let mut state = BgState::new(); + let ch = Uuid::new_v4(); + seed_test_subscription(&mut state, ch); + // Bounded 16MB JSON request exceeds loopback TCP buffering. The server does + // not read it. This tests the real production write/timeout, not a mock sink. + state.active_filters.get_mut(&ch).unwrap().kinds = Some(vec![9; 8_000_000]); + state.channel_dropped_since.insert(ch, 700); + let (tx, _rx) = mpsc::channel(1); + let started = tokio::time::Instant::now(); + timeout( + Duration::from_secs(15), + recovery::recover_one(&mut client, &mut state, &tx, "agent"), + ) + .await + .unwrap(); + assert!(started.elapsed() >= Duration::from_secs(WS_SEND_TIMEOUT_SECS)); + assert_eq!(state.channel_dropped_since[&ch], 700); + let attempted = state.recovery.last_attempt.clone(); + recovery::recover_one(&mut client, &mut state, &tx, "agent").await; + assert_eq!(state.recovery.last_attempt, attempted); +} + +// Keep the fixture responsive to independent client keepalives while checking +// recovery traffic. Wall-clock scheduling may deliver the initial ping late. +async fn socket_frame( + server: &mut WebSocketStream, + duration: Duration, +) -> Option { + let deadline = tokio::time::Instant::now() + duration; + loop { + match tokio::time::timeout_at(deadline, server.next()).await { + Err(_) => return None, + Ok(Some(Ok(Message::Ping(payload)))) => { + server.send(Message::Pong(payload)).await.unwrap(); + } + Ok(Some(Ok(frame))) => return Some(frame), + other => panic!("unexpected socket state: {other:?}"), + } + } +} + +#[path = "recovery_wake_tests.rs"] +mod wake; diff --git a/crates/buzz-acp/src/relay/recovery_wake_tests.rs b/crates/buzz-acp/src/relay/recovery_wake_tests.rs new file mode 100644 index 00000000000..de27c7d06c9 --- /dev/null +++ b/crates/buzz-acp/src/relay/recovery_wake_tests.rs @@ -0,0 +1,507 @@ +//! Capacity-wake boundaries and the independently reproduced R1 schedule. +use super::*; + +// Reviewer-authored, exact-source comparison of recurring headroom at the socket owner. +// A small periodically refilled queue is empty for most of each five-second period. +async fn review_count_until( + server: &mut WebSocketStream, + deadline: tokio::time::Instant, +) -> usize { + let mut count = 0; + while tokio::time::Instant::now() < deadline { + let remaining = deadline.saturating_duration_since(tokio::time::Instant::now()); + match socket_frame(server, remaining).await { + None => break, + Some(Message::Text(text)) => { + let frame: Value = serde_json::from_str(&text).unwrap(); + assert_eq!(frame[0], "REQ"); + count += 1; + } + other => panic!("unexpected {other:?}"), + } + } + count +} + +async fn review_barrier(server: &mut WebSocketStream) -> usize { + server.send(Message::Ping(vec![77].into())).await.unwrap(); + let mut count = 0; + loop { + match socket_frame(server, Duration::from_secs(2)).await.unwrap() { + Message::Pong(payload) => { + assert_eq!(payload.as_ref(), &[77]); + return count; + } + Message::Text(text) => { + let frame: Value = serde_json::from_str(&text).unwrap(); + assert_eq!(frame[0], "REQ"); + count += 1; + } + other => panic!("unexpected {other:?}"), + } + } +} + +#[tokio::test] +async fn review_recurring_headroom_between_ticks_gets_an_attempt() { + let (client, mut server) = test_ws_pair().await; + let (tx, mut rx) = mpsc::channel(1); + let (control_tx, _control_rx) = mpsc::channel(1); + let (cmd_tx, cmd_rx) = mpsc::channel(8); + let start = tokio::time::Instant::now(); + let task = tokio::spawn(run_background_task( + client, + VecDeque::new(), + tx, + control_tx, + cmd_rx, + Keys::generate(), + "ws://127.0.0.1:1".into(), + "agent".into(), + None, + )); + let ch = Uuid::new_v4(); + let sub = channel_sub_id(ch); + cmd_tx + .send(RelayCommand::Subscribe { + channel_id: ch, + filter: test_channel_filter(), + replay_since: Some(1000), + }) + .await + .unwrap(); + let frame = socket_frame(&mut server, Duration::from_secs(2)) + .await + .unwrap(); + let req: Value = serde_json::from_str(frame.to_text().unwrap()).unwrap(); + assert_eq!(req[1], sub); + let lost = fixture_event(ch, 1, 9); + for event in [fixture_event(ch, 0, 9), lost.clone()] { + server + .send(Message::Text( + json!(["EVENT", sub, event]).to_string().into(), + )) + .await + .unwrap(); + } + let mut attempts = review_barrier(&mut server).await; + assert_eq!(rx.len(), 1); + rx.recv().await.unwrap().unwrap(); + for round in 1..=4 { + // Headroom until 1s before the tick; then only one live arrival, no new + // overflow. The queue stays full across the tick and drains 0.5s later. + attempts += review_count_until( + &mut server, + start + Duration::from_millis(round * 5000 - 1000), + ) + .await; + assert_eq!(rx.len(), 0); + server + .send(Message::Text( + json!(["EVENT", sub, fixture_event(ch, 10 + round, 9)]) + .to_string() + .into(), + )) + .await + .unwrap(); + attempts += review_barrier(&mut server).await; + assert_eq!(rx.len(), 1); + attempts += review_count_until( + &mut server, + start + Duration::from_millis(round * 5000 + 500), + ) + .await; + rx.recv().await.unwrap().unwrap(); + } + println!("REVIEW periodic consumer: 4 full-at-tick windows, empty >=3.5s each period, recovery_requests={attempts}"); + assert_eq!( + attempts, 1, + "recurring headroom must not strand the first attempt" + ); + // Return the missing event using the actual requested stable subscription. + server + .send(Message::Text( + json!(["EVENT", sub, lost]).to_string().into(), + )) + .await + .unwrap(); + assert_eq!(review_barrier(&mut server).await, 0); + assert_eq!(rx.recv().await.unwrap().unwrap().event.id, lost.id); + let after = review_count_until(&mut server, start + Duration::from_millis(25_500)).await; + assert_eq!( + after, 0, + "no timer churn or extra requests after successful write" + ); + println!( + "REVIEW continuous-headroom control: additional_requests={after}; missing event delivered" + ); + cmd_tx.send(RelayCommand::Shutdown).await.unwrap(); + timeout(Duration::from_secs(2), task) + .await + .unwrap() + .unwrap(); +} + +/// Same real socket owner as production, with no control of its internal state. +struct Owner { + server: WebSocketStream, + rx: mpsc::Receiver>, + cmd: mpsc::Sender, + task: tokio::task::JoinHandle<()>, + ch: Uuid, +} + +impl Owner { + async fn new(capacity: usize) -> Self { + let (client, server) = test_ws_pair().await; + let (tx, rx) = mpsc::channel(capacity); + let (control_tx, _control_rx) = mpsc::channel(1); + let (cmd, cmd_rx) = mpsc::channel(8); + let task = tokio::spawn(run_background_task( + client, + VecDeque::new(), + tx, + control_tx, + cmd_rx, + Keys::generate(), + "ws://127.0.0.1:1".into(), + "agent".into(), + None, + )); + let mut owner = Self { + server, + rx, + cmd, + task, + ch: Uuid::new_v4(), + }; + owner.subscribe().await; + owner + } + + async fn subscribe(&mut self) { + self.cmd + .send(RelayCommand::Subscribe { + channel_id: self.ch, + filter: test_channel_filter(), + replay_since: Some(1000), + }) + .await + .unwrap(); + let req = self.request(Duration::from_secs(2)).await; + assert_eq!(req[1], channel_sub_id(self.ch)); + } + + async fn event(&mut self, n: u64) { + self.server + .send(Message::Text( + json!([ + "EVENT", + channel_sub_id(self.ch), + fixture_event(self.ch, n, 9) + ]) + .to_string() + .into(), + )) + .await + .unwrap(); + } + + async fn request(&mut self, duration: Duration) -> Value { + let frame = socket_frame(&mut self.server, duration) + .await + .expect("recovery not woken"); + let req: Value = serde_json::from_str(frame.to_text().unwrap()).unwrap(); + assert_eq!(req[0], "REQ"); + req + } + + async fn shutdown(self) { + self.cmd.send(RelayCommand::Shutdown).await.unwrap(); + timeout(Duration::from_secs(1), self.task) + .await + .unwrap() + .unwrap(); + } +} + +#[tokio::test] +async fn capacity_flapping_cannot_storm_or_delay_an_allowed_attempt() { + let mut owner = Owner::new(1).await; + owner.event(0).await; + owner.event(1).await; + assert_eq!(review_barrier(&mut owner.server).await, 0); + owner.rx.recv().await.unwrap(); + let first = owner.request(Duration::from_millis(500)).await; + let first_at = tokio::time::Instant::now(); + assert_eq!(first[2]["since"], 996); + // New loss, then rapid full/empty transitions during the attempt cooldown. + // No socket activity or capacity transition may reset or bypass that bound. + for n in 1..=20 { + owner.event(n * 2).await; + owner.event(n * 2 + 1).await; + assert_eq!(review_barrier(&mut owner.server).await, 0); + owner.rx.recv().await.unwrap(); + assert!(socket_frame(&mut owner.server, Duration::from_millis(10)) + .await + .is_none()); + } + let req = owner.request(Duration::from_secs(6)).await; + assert_eq!(req[1], channel_sub_id(owner.ch)); + // Arrival timestamps approximate send completion; leave tolerance for TCP. + assert!(first_at.elapsed() >= Duration::from_millis(4_900)); + assert!(first_at.elapsed() < Duration::from_secs(6)); + assert_eq!(req[2]["since"], 998); + assert!(socket_frame(&mut owner.server, Duration::from_millis(100)) + .await + .is_none()); + owner.shutdown().await; +} + +#[tokio::test] +async fn partial_capacity_wait_does_not_steal_live_slots_and_cancels_on_unsubscribe() { + let mut owner = Owner::new(5).await; // odd capacity: threshold rounds UP to 3 + for n in 0..6 { + owner.event(n).await; + } + assert_eq!(review_barrier(&mut owner.server).await, 0); + owner.rx.recv().await.unwrap(); // partial reservation, insufficient for replay + assert!(socket_frame(&mut owner.server, Duration::from_millis(30)) + .await + .is_none()); + owner.event(6).await; + assert_eq!(review_barrier(&mut owner.server).await, 0); + assert_eq!( + owner.rx.len(), + 5, + "select must release partial permits BEFORE try_send" + ); + for _ in 0..2 { + owner.rx.recv().await.unwrap(); + } + assert!(socket_frame(&mut owner.server, Duration::from_millis(30)) + .await + .is_none()); + owner.rx.recv().await.unwrap(); // exactly three slots free, prompt capacity wake + let req = owner.request(Duration::from_millis(500)).await; + assert_eq!(req[2]["since"], 1000); + assert_eq!( + owner + .rx + .recv() + .await + .unwrap() + .unwrap() + .event + .created_at + .as_secs(), + 1004 + ); + assert_eq!( + owner + .rx + .recv() + .await + .unwrap() + .unwrap() + .event + .created_at + .as_secs(), + 1006 + ); + + // Wait out cooldown then create another pending loss and partial reservation. + tokio::time::sleep(recovery::RECOVERY_INTERVAL).await; + for n in 7..13 { + owner.event(n).await; + } + assert_eq!(review_barrier(&mut owner.server).await, 0); + owner.rx.recv().await.unwrap(); + assert!(socket_frame(&mut owner.server, Duration::from_millis(30)) + .await + .is_none()); + owner + .cmd + .send(RelayCommand::Unsubscribe { + channel_id: owner.ch, + }) + .await + .unwrap(); + let close = socket_frame(&mut owner.server, Duration::from_secs(1)) + .await + .unwrap(); + let close: Value = serde_json::from_str(close.to_text().unwrap()).unwrap(); + assert_eq!(close[0], "CLOSE"); + while owner.rx.try_recv().is_ok() {} + assert!(socket_frame(&mut owner.server, Duration::from_millis(100)) + .await + .is_none()); + owner.subscribe().await; + assert!(socket_frame(&mut owner.server, Duration::from_millis(100)) + .await + .is_none()); + // A re-added intent can record and recover fresh loss immediately. + for n in 20..26 { + owner.event(n).await; + } + assert_eq!(review_barrier(&mut owner.server).await, 0); + while owner.rx.try_recv().is_ok() {} + let req = owner.request(Duration::from_millis(500)).await; + assert_eq!(req[2]["since"], 1020); + owner.shutdown().await; +} + +#[tokio::test] +async fn shutdown_and_transport_loss_cancel_a_capacity_wait() { + let mut owner = Owner::new(1).await; + owner.event(0).await; + owner.event(1).await; + assert_eq!(review_barrier(&mut owner.server).await, 0); + // Full queue cannot block commands or processing an actual socket close. + owner.server.close(None).await.unwrap(); + owner.shutdown().await; + let mut owner = Owner::new(1).await; + owner.event(0).await; + owner.event(1).await; + assert_eq!(review_barrier(&mut owner.server).await, 0); + owner.shutdown().await; +} + +#[tokio::test] +async fn readiness_gate_ownership_and_attempt_deadlines_are_not_polling_ticks() { + let (mut client, mut server) = test_ws_pair().await; + let mut state = BgState::new(); + let ch = Uuid::new_v4(); + seed_test_subscription(&mut state, ch); + let (tx, mut rx) = mpsc::channel(4); + assert!(recovery::ready_at(&mut state).is_none()); + state.channel_dropped_since.insert(ch, 700); + state + .rate_limited_pending + .insert(ch, tokio::time::Instant::now()); + assert!(recovery::ready_at(&mut state).is_none()); + state.rate_limited_pending.clear(); + state.resubscribe_retry.insert(ch); + assert!(recovery::ready_at(&mut state).is_none()); + state.resubscribe_retry.clear(); + state.rate_limit_gate = Some(tokio::time::Instant::now() + Duration::from_millis(80)); + let at = recovery::ready_at(&mut state); + assert_eq!(at, state.rate_limit_gate); + assert!(timeout(Duration::from_millis(20), recovery::ready(&tx, at)) + .await + .is_err()); + timeout(Duration::from_millis(200), recovery::ready(&tx, at)) + .await + .unwrap(); + recovery::recover_one(&mut client, &mut state, &tx, "agent").await; + next_test_frame(&mut server).await; + state.channel_dropped_since.insert(ch, 800); + let at = recovery::ready_at(&mut state).unwrap(); + let remaining = at.saturating_duration_since(tokio::time::Instant::now()); + assert!(remaining > Duration::from_millis(4900)); + // Neither new loss nor an intervening gate shorter than cooldown delays it. + state.rate_limit_gate = Some(tokio::time::Instant::now() + Duration::from_millis(10)); + assert_eq!(recovery::ready_at(&mut state), Some(at)); + state.rate_limit_gate = Some(at + Duration::from_secs(1)); + assert_eq!(recovery::ready_at(&mut state), state.rate_limit_gate); + advance_clock(Duration::from_secs(6)).await; + // Cancellation releases partial permits. No hidden reservation survives it. + for _ in 0..3 { + tx.try_send(None).unwrap(); + } + assert!(timeout( + Duration::from_millis(20), + recovery::ready(&tx, recovery::ready_at(&mut state)) + ) + .await + .is_err()); + assert_eq!(tx.capacity(), 1); + rx.recv().await.unwrap(); + timeout( + Duration::from_millis(100), + recovery::ready(&tx, recovery::ready_at(&mut state)), + ) + .await + .unwrap(); + assert_eq!(tx.capacity(), 2); + drop(rx); + assert!(timeout( + Duration::from_millis(20), + recovery::ready(&tx, recovery::ready_at(&mut state)) + ) + .await + .is_err()); +} + +#[tokio::test] +async fn readiness_uses_channel_wakes_without_idle_churn_or_lost_capacity() { + use std::future::Future; + use std::sync::{ + atomic::{AtomicUsize, Ordering}, + Arc, + }; + use std::task::{Context, Wake, Waker}; + #[derive(Default)] + struct Wakes(AtomicUsize); + impl Wake for Wakes { + fn wake(self: Arc) { + self.0.fetch_add(1, Ordering::SeqCst); + } + fn wake_by_ref(self: &Arc) { + self.0.fetch_add(1, Ordering::SeqCst); + } + } + let wakes = Arc::new(Wakes::default()); + let waker = Waker::from(wakes.clone()); + let mut cx = Context::from_waker(&waker); + let (tx, mut rx) = mpsc::channel(4); + for _ in 0..4 { + tx.try_send(None).unwrap(); + } + let mut idle = Box::pin(recovery::ready(&tx, None)); + assert!(idle.as_mut().poll(&mut cx).is_pending()); + rx.recv().await.unwrap(); + rx.recv().await.unwrap(); + assert_eq!( + wakes.0.load(Ordering::SeqCst), + 0, + "no loss: no capacity subscription" + ); + drop(idle); + // Capacity freed before registration cannot be lost; ready on first poll. + let now = Some(tokio::time::Instant::now()); + let mut ready = Box::pin(recovery::ready(&tx, now)); + assert!(ready.as_mut().poll(&mut cx).is_ready()); + assert_eq!(tx.capacity(), 2, "successful readiness returns all permits"); + drop(ready); + for _ in 0..2 { + tx.try_send(None).unwrap(); + } + let mut ready = Box::pin(recovery::ready(&tx, now)); + assert!(ready.as_mut().poll(&mut cx).is_pending()); + assert_eq!(wakes.0.load(Ordering::SeqCst), 0); + rx.recv().await.unwrap(); + assert_eq!( + wakes.0.load(Ordering::SeqCst), + 0, + "below threshold: do not wake" + ); + rx.recv().await.unwrap(); + assert_eq!( + wakes.0.load(Ordering::SeqCst), + 1, + "threshold: channel wakes its waiter" + ); + assert!(ready.as_mut().poll(&mut cx).is_ready()); + assert_eq!(tx.capacity(), 2); + drop(ready); + drop(rx); + let mut closed = Box::pin(recovery::ready(&tx, now)); + let before = wakes.0.load(Ordering::SeqCst); + assert!(closed.as_mut().poll(&mut cx).is_pending()); + assert_eq!( + wakes.0.load(Ordering::SeqCst), + before, + "closed: no self-wake loop" + ); +} From 93761e411ced557d859c00df1933ab053d8d854b Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Wed, 9 Sep 2026 10:31:42 -0400 Subject: [PATCH 03/19] fix(mobile): render push notification sender identity as npub (#7494) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 ## Summary When an iOS push notification comes from someone the app has no cached name for, the notification title showed the first characters of the sender's raw public key — for example `aa4fc866…`. That fragment is unreadable and doesn't match how the same person appears anywhere else in Buzz. This PR changes that title to the compact form of the sender's npub (npub is the human-readable encoding of a Nostr public key): first 8 and last 4 characters — for example `npub14f8…9nsy`, the same identity shape used across the desktop and mobile apps. - Unnamed senders: raw hex fragment → compact npub. - Named senders: unchanged — a sender the app has a display name for still titles the notification with that name. - Unverifiable sender identities (malformed keys, or lookalike strings that are not literal 64-hex-digit keys) now render a neutral "Someone" instead of partial raw key material. - Everything else about the notification is unchanged: body text, subtitle, thread matching and grouping, deep-link navigation, thread identifiers, and the internal hex public key the resolver matches on. The native iOS notification-service package (`BuzzPushKit`) gains a minimal in-house bech32 codec (bech32 is the checksummed string encoding npubs use) — checksum-validated, 32-byte keys only, and no new external dependency. The hex input branch accepts exactly a 64 ASCII hex digit key before any parsing, so strings that merely parse like hex (for example a run of `+a` pairs) cannot become a displayed identity; this is input validation for presentation. Event signature verification is untouched. ### Related issue Fixes: N/A. Searched existing issues/PRs for push-notification npub identity — closest related: none found. ### Testing At head `3e3f2813b8864b76257ccb50dea3a4b31fa4de0d` (base `44316ff72f5f7de014c66b01cbf534298a70c249`; 4 files, +321/−4): - CI `Mobile Swift` lane, at this exact head — all passed: `swift test` (73 tests, 0 failures), the SwiftPM debug and release builds of `mobile/ios/BuzzPushKit`, and the unsigned iOS release build. - Test coverage: npub encoding cross-checked against independent nostr-rs/NIP-19 vectors; rejection of bad checksums, mixed case, wrong lengths, invalid alphabet, padding, and non-32-byte payloads; resolver boundary matrix — hex/npub/invalid sender keys render compact npub or "Someone" while body, subtitle, sender key, and thread identifier pass through; named senders keep cached display names. ### Task provenance Buzz channel: `1f0e4a3d-7e01-4efe-bb16-843b357f85c9` Task: buzz://message?channel=1f0e4a3d-7e01-4efe-bb16-843b357f85c9&id=86b34eb4bd84a1472419e9af22636c011c0fe273e3c196f967d7a36996e149b6 --------- Signed-off-by: Logan Johnson Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz> --- .../Sources/BuzzPushKit/Bech32.swift | 179 ++++++++++++++++++ .../BuzzPushNotificationResolver.swift | 9 +- .../Tests/BuzzPushKitTests/Bech32Tests.swift | 98 ++++++++++ .../BuzzPushNotificationResolverTests.swift | 39 +++- 4 files changed, 321 insertions(+), 4 deletions(-) create mode 100644 mobile/ios/BuzzPushKit/Sources/BuzzPushKit/Bech32.swift create mode 100644 mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/Bech32Tests.swift diff --git a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/Bech32.swift b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/Bech32.swift new file mode 100644 index 00000000000..e6f31bf8e3e --- /dev/null +++ b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/Bech32.swift @@ -0,0 +1,179 @@ +import Foundation + +/// Minimal bech32 (BIP-173) codec backing NIP-19 npub sender labels. +/// +/// Only what push notification presentation needs is implemented: encoding +/// 32-byte public keys as npub and validating npub inputs well enough to +/// canonicalize them. Segwit addresses and bech32m are out of scope; NIP-19 +/// uses the original bech32 checksum. +enum Bech32 { + /// Bech32 data charset from BIP-173, indexed by 5-bit value. + private static let charset: [Character] = Array("qpzry9x8gf2tvdw0s3jn54khce6mua7l") + /// Bech32 charset lookup, built once for decoding. + private static let valueByCharacter: [Character: UInt8] = Dictionary( + uniqueKeysWithValues: charset.enumerated().map { ($1, UInt8($0)) }) + /// Generator polynomial coefficients from BIP-173. + private static let generator: [UInt32] = [ + 0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3, + ] + /// Checksum length in 5-bit values, from BIP-173. + private static let checksumLength = 6 + /// Maximum bech32 string length, from BIP-173. + private static let maximumLength = 90 + + // MARK: Codec + + /// Encodes 5-bit values under a human-readable part (hrp), appending the + /// bech32 checksum. Returns nil for an invalid hrp or out-of-range values. + static func encode(hrp: String, values: [UInt8]) -> String? { + let valuesInRange = values.allSatisfy { $0 < 32 } + guard isPrintableASCII(hrp), valuesInRange, + hrp.count + 1 + values.count + checksumLength <= maximumLength + else { return nil } + let checksum = checksum(hrp: hrp, values: values) + return hrp + "1" + String((values + checksum).map { charset[Int($0)] }) + } + + /// Decodes a bech32 string into its hrp and 5-bit values, checksum-verified + /// and stripped. Follows BIP-173: characters must be printable ASCII, the + /// string must be entirely lowercase or entirely uppercase and at most 90 + /// characters long, and it must end in a valid checksum. + static func decode(_ string: String) -> (hrp: String, values: [UInt8])? { + guard !string.isEmpty, string.count <= maximumLength, isPrintableASCII(string) + else { return nil } + let lowercased = string.lowercased() + guard lowercased == string || lowercased.uppercased() == string, + let separator = lowercased.lastIndex(of: "1"), + separator != lowercased.startIndex + else { return nil } + let hrp = String(lowercased[..= checksumLength, + polymod(hrpExpanded(hrp) + allValues.map(UInt32.init)) == 1 + else { return nil } + return (hrp, Array(allValues.dropLast(checksumLength))) + } + + // MARK: NIP-19 + + /// The npub of a 32-byte public key, or nil for any other length. + static func npub(from bytes: [UInt8]) -> String? { + guard bytes.count == 32, + let values = convertBits(bytes, fromBits: 8, toBits: 5, padding: true) + else { return nil } + return encode(hrp: "npub", values: values) + } + + /// The 32-byte public key encoded by a valid npub, or nil for anything + /// else — an `npub1…` prefix alone is never trusted: the bech32 checksum + /// must validate and the payload must decode to exactly 32 bytes. + static func npubBytes(from string: String) -> [UInt8]? { + guard let (hrp, values) = decode(string), hrp == "npub", + let bytes = convertBits(values, fromBits: 5, toBits: 8, padding: false), + bytes.count == 32 + else { return nil } + return bytes + } + + /// The canonical npub for a public key supplied as exactly 64 ASCII hex + /// digits or as an existing npub (including the uppercase bech32 form, + /// which BIP-173 decoders must accept). Returns nil for anything else; + /// callers must fall back to a neutral label rather than raw key material. + static func canonicalNpub(from identifier: String) -> String? { + if isHexKey(identifier), + let bytes = VerifiedNostrEvent.hexBytes(identifier.lowercased()), bytes.count == 32 + { + return npub(from: bytes) + } + return npubBytes(from: identifier).flatMap { npub(from: $0) } + } + + // MARK: Internals + + /// Whether `value` is exactly 64 ASCII hex digits (0-9, A-F, a-f). + /// + /// `VerifiedNostrEvent.hexBytes` parses pairs with `UInt8(_:radix: 16)`, + /// which also accepts a leading sign — "+a" parses as 10 and "-0" as 0 — + /// so strings like "+a"×32 would otherwise decode to 32 bytes. The hex + /// branch of `canonicalNpub` gates on this literal key shape before any + /// hex parsing or allocation; every other input must arrive as a strictly + /// checksummed npub. + private static func isHexKey(_ value: String) -> Bool { + guard value.count == 64 else { return false } + return value.allSatisfy { character in + guard let ascii = character.asciiValue else { return false } + return (48...57).contains(ascii) // 0-9 + || (65...70).contains(ascii) // A-F + || (97...102).contains(ascii) // a-f + } + } + + private static func isPrintableASCII(_ string: String) -> Bool { + !string.isEmpty && string.allSatisfy { (33...126).contains($0.asciiValue ?? 0) } + } + + private static func checksum(hrp: String, values: [UInt8]) -> [UInt8] { + let expanded = + hrpExpanded(hrp) + values.map(UInt32.init) + + [UInt32](repeating: 0, count: checksumLength) + let polymod = polymod(expanded) ^ 1 + return (0..> shift) & 31) + } + } + + private static func polymod(_ values: [UInt32]) -> UInt32 { + var accumulator: UInt32 = 1 + for value in values { + let top = accumulator >> 25 + accumulator = ((accumulator & 0x1ffffff) << 5) ^ value + for (index, coefficient) in generator.enumerated() where (top >> index) & 1 == 1 { + accumulator ^= coefficient + } + } + return accumulator + } + + private static func hrpExpanded(_ hrp: String) -> [UInt32] { + let scalars = hrp.unicodeScalars.map { $0.value } + return scalars.map { $0 >> 5 } + [0] + scalars.map { $0 & 31 } + } + + /// Regroups a byte string between bit widths, as in BIP-173's `convertbits`. + /// With padding disabled, a nonzero remainder is rejected instead of padded. + private static func convertBits( + _ bytes: [UInt8], fromBits: Int, toBits: Int, padding: Bool + ) -> [UInt8]? { + var accumulator: UInt32 = 0 + var accumulated = 0 + var result: [UInt8] = [] + result.reserveCapacity((bytes.count * fromBits + toBits - 1) / toBits) + let maxValue: UInt32 = (1 << toBits) - 1 + let maxAccumulator: UInt32 = (1 << (fromBits + toBits - 1)) - 1 + let maxInput: Int = 1 << fromBits + for byte in bytes { + guard Int(byte) < maxInput else { return nil } + accumulator = ((accumulator << fromBits) | UInt32(byte)) & maxAccumulator + accumulated += fromBits + while accumulated >= toBits { + accumulated -= toBits + result.append(UInt8((accumulator >> accumulated) & maxValue)) + } + } + if padding { + if accumulated > 0 { + result.append(UInt8((accumulator << (toBits - accumulated)) & maxValue)) + } + } else if accumulated >= fromBits + || ((accumulator << (toBits - accumulated)) & maxValue) != 0 + { + return nil + } + return result + } +} diff --git a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushNotificationResolver.swift b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushNotificationResolver.swift index 47661e81300..7836385a773 100644 --- a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushNotificationResolver.swift +++ b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushNotificationResolver.swift @@ -611,8 +611,15 @@ public final class BuzzPushNotificationResolver: BuzzPushNotificationResolving { ? String(result.prefix(177)).trimmingCharacters(in: .whitespacesAndNewlines) + "…" : result } + /// Compact sender label for unnamed senders: the first 8 and last 4 + /// characters of the sender's full npub, matching the compact npub shape + /// used across Buzz. A key that cannot be encoded as an npub falls back to + /// the neutral "Someone" identity so malformed payloads leak no key + /// material while the notification keeps a useful title. static func shortPubkey(_ pubkey: String) -> String { - pubkey.count > 8 ? String(pubkey.prefix(8)) + "…" : pubkey + guard let npub = Bech32.canonicalNpub(from: pubkey) else { return "Someone" } + return npub.count > 12 + ? String(npub.prefix(8)) + "…" + String(npub.suffix(4)) : npub } private func loadCommunities() -> [PushLeaseCommunity] { diff --git a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/Bech32Tests.swift b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/Bech32Tests.swift new file mode 100644 index 00000000000..44ad58e0f2b --- /dev/null +++ b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/Bech32Tests.swift @@ -0,0 +1,98 @@ +import XCTest +@testable import BuzzPushKit + +/// Bech32 codec tests against independently published npub vectors — the +/// NIP-19 spec example key and the nostr crate 0.44 test suite — plus +/// degenerate key material for the padding and payload-length boundaries. +/// Generic BIP-173 conformance is not asserted here: nothing outside the +/// codec calls raw decode, so alphabet, checksum, case, and length +/// rejection are pinned at canonicalNpub/npubBytes, the npub seam Buzz +/// actually uses. +final class Bech32Tests: XCTestCase { + /// Hex public keys with their published npub equivalents. + static let npubVectors: [(hex: String, npub: String)] = [ + // nostr-rs 0.44 key test: aa4fc866… ↔ npub14f8usejl…qqh9nsy. + ( + "aa4fc8665f5696e33db7e1a572e3b0f5b3d615837b0f362dcb1c8068b098c7b4", + "npub14f8usejl26twx0dhuxjh9cas7keav9vr0v8nvtwtrjqx3vycc76qqh9nsy" + ), + // The NIP-19 spec's example profile key. + ( + "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d", + "npub180cvv07tjdrrgpa0j7j7tmnyl2yr6yr7l8j4s3evf6u64th6gkwsyjh6w6" + ), + // Degenerate key material still encodes (and exercises 5-bit padding). + (String(repeating: "00", count: 32), "npub1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqzqujme"), + ] + + func testNpubEncodingMatchesKnownVectors() throws { + for vector in Self.npubVectors { + let bytes = try XCTUnwrap(VerifiedNostrEvent.hexBytes(vector.hex)) + XCTAssertEqual(bytes.count, 32) + XCTAssertEqual(Bech32.npub(from: bytes), vector.npub, "hex: \(vector.hex)") + XCTAssertEqual(Bech32.canonicalNpub(from: vector.hex), vector.npub, "hex: \(vector.hex)") + } + } + + func testCanonicalNpubValidatesAndCanonicalizesNpubInputs() throws { + for vector in Self.npubVectors { + let npub = try XCTUnwrap(Bech32.canonicalNpub(from: vector.npub)) + XCTAssertEqual(npub, vector.npub, "npub: \(vector.npub)") + XCTAssertEqual( + Bech32.npubBytes(from: npub).map(VerifiedNostrEvent.hex), vector.hex, + "npub: \(vector.npub)") + } + // Uppercase bech32 is valid per BIP-173; canonical output is lowercase. + XCTAssertEqual( + Bech32.canonicalNpub( + from: "NPUB14F8USEJL26TWX0DHUXJH9CAS7KEAV9VR0V8NVTWTRJQX3VYCC76QQH9NSY"), + "npub14f8usejl26twx0dhuxjh9cas7keav9vr0v8nvtwtrjqx3vycc76qqh9nsy") + // Letter case within a hex key stays valid input; digits are caseless, + // so mixed-case letters still canonicalize to the lowercase npub. + let mixedCaseHex = String( + Self.npubVectors[0].hex.enumerated().map { + $0.offset.isMultiple(of: 2) ? $0.element : Character($0.element.uppercased()) + }) + XCTAssertEqual(Bech32.canonicalNpub(from: mixedCaseHex), Self.npubVectors[0].npub) + } + + func testCanonicalNpubRejectsInvalidAndLookalikeKeys() { + let rejected = [ + // Junk and empty payloads. + "", + "author-pubkey", + String(repeating: "a", count: 63), + "0", + // Hex that is not a 32-byte key. + String(repeating: "ab", count: 31), + String(repeating: "ab", count: 33), + // Signed radix-16 chunks ("+a"→10, "-0"→0) parse via UInt8(_:radix:) + // but are not literal ASCII hex digits, so never 32-byte keys. + String(repeating: "+a", count: 32), + String(repeating: "+A", count: 32), + String(repeating: "-0", count: 32), + // Npub-shaped payload with a character outside the bech32 alphabet + // ("b" never appears in the charset). + "npub1bqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqzqujme", + // Valid npub with the checksum's final character mutated. + "npub1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqzqujma", + // Mixed case is never valid bech32. + "Npub14f8usejl26twx0dhuxjh9cas7keav9vr0v8nvtwtrjqx3vycc76qqh9nsy", + // Valid checksum but the wrong payload length for a key. + "npub1qqqqqqqqqqqqqqqqqqqqqqqqqqk7h3rf", + "npub1llllllllllllllllllllllllllllllllllllllllllllllllllll7w6tc2n", + // Valid bech32 under a different human-readable part. + "nsec1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqwkhnav", + ] + for string in rejected { + XCTAssertNil(Bech32.canonicalNpub(from: string), "expected rejection: \(string)") + XCTAssertNil(Bech32.npubBytes(from: string), "expected rejection: \(string)") + } + } + + func testNpubRejectsNon32ByteKeys() { + XCTAssertNil(Bech32.npub(from: [UInt8](repeating: 0, count: 16))) + XCTAssertNil(Bech32.npub(from: [UInt8](repeating: 0xff, count: 33))) + XCTAssertNil(Bech32.npub(from: [])) + } +} diff --git a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushNotificationResolverTests.swift b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushNotificationResolverTests.swift index b25e23595f6..6620f193699 100644 --- a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushNotificationResolverTests.swift +++ b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushNotificationResolverTests.swift @@ -18,6 +18,10 @@ final class BuzzPushNotificationResolverTests: XCTestCase { static let relayPrivateKey = String(repeating: "0", count: 63) + "3" static let gatewayBody = "Reconnect to your relay now" static let channelID = "123e4567-e89b-42d3-a456-426614174000" + static let unnamedSenderHex = + "aa4fc8665f5696e33db7e1a572e3b0f5b3d615837b0f362dcb1c8068b098c7b4" + static let unnamedSenderNpub = + "npub14f8usejl26twx0dhuxjh9cas7keav9vr0v8nvtwtrjqx3vycc76qqh9nsy" override func setUp() { super.setUp() @@ -129,6 +133,35 @@ final class BuzzPushNotificationResolverTests: XCTestCase { XCTAssertEqual(result?.0.body, "lower ID") } + func testDecodeResolutionSenderTitlesCanonicalizeKeysOrFallBackToNeutral() { + // The sender-identity presentation boundary: a verifiable key renders + // as the same compact npub whether it arrives as hex or as an npub, and + // an unverifiable key — including radix impostors like "+a"×32 that + // parse as hex pairs but are not literal hex keys — gets the neutral + // identity, never raw key material, while body, subtitle, and internal + // payload fields pass through untouched. Malformed-input classification + // itself is pinned at the codec (Bech32Tests); named senders winning + // over these labels is covered by the cached-profile resolve tests. + let senders: [(pubkey: String, title: String)] = [ + (Self.unnamedSenderHex, "npub14f8…9nsy"), + (Self.unnamedSenderNpub, "npub14f8…9nsy"), + ("author-pubkey", "Someone"), + (String(repeating: "+a", count: 32), "Someone"), + ] + for sender in senders { + let result = BuzzPushNotificationResolver.decodeResolution( + events: [event(pubkey: sender.pubkey, content: "Preview")], + community: community() + ) + + XCTAssertEqual(result?.0.title, sender.title, "pubkey: \(sender.pubkey)") + XCTAssertEqual(result?.0.body, "Preview", "pubkey: \(sender.pubkey)") + XCTAssertEqual(result?.0.subtitle, "Community", "pubkey: \(sender.pubkey)") + XCTAssertEqual(result?.0.senderPubkey, sender.pubkey, "pubkey: \(sender.pubkey)") + XCTAssertEqual(result?.0.threadIdentifier, "community-id", "pubkey: \(sender.pubkey)") + } + } + func testResolveSucceedsAndMutatesGatewayContent() throws { let event = try JSONDecoder().decode( VerifiedNostrEvent.self, @@ -146,7 +179,7 @@ final class BuzzPushNotificationResolverTests: XCTestCase { XCTAssertNotEqual(result.title, Self.gatewayBody) XCTAssertNotEqual(result.body, Self.gatewayBody) - XCTAssertEqual(result.title, String(event.pubkey.prefix(8)) + "…") + XCTAssertEqual(result.title, "npub1ccz…mnyd") XCTAssertEqual(result.body, "Hello Buzz") XCTAssertEqual(result.subtitle, "Community") XCTAssertEqual( @@ -559,7 +592,7 @@ final class BuzzPushNotificationResolverTests: XCTestCase { ) ) - XCTAssertEqual(result.title, String(message.pubkey.prefix(8)) + "…") + XCTAssertEqual(result.title, "npub1ccz…mnyd") XCTAssertNil(result.conversationDisplayName) XCTAssertEqual(result.subtitle, "Community") XCTAssertEqual(result.body, "Fallback content") @@ -602,7 +635,7 @@ final class BuzzPushNotificationResolverTests: XCTestCase { ) ) - XCTAssertEqual(result.title, String(message.pubkey.prefix(8)) + "…") + XCTAssertEqual(result.title, "npub1ccz…mnyd") XCTAssertNil(result.conversationDisplayName) XCTAssertEqual(result.body, "Bounded fallback") XCTAssertEqual(URLProtocolStub.requests.count, 2) From bfc384855889432df4a333a0edf3080f332ee169 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Wed, 9 Sep 2026 10:32:26 -0400 Subject: [PATCH 04/19] fix(desktop): shared npub identity foundation (canonicalNpub, PubKey gate, strict parser) (#7488) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 ## Summary Identity keys in the desktop app are displayed as raw 64-character hex. A person's key shows up as something like `953d3363…` — unreadable, impossible to recognize as the same identity on another screen, and a hazard when copied by hand. Nostr (the protocol Buzz runs on) has a human-readable spelling for identity keys — the `npub1…` form — but the desktop app did not use it consistently. This is the foundation of the desktop npub changes: it adds the shared pieces every identity surface builds on, and two follow-up slices stack directly on this branch — #7489 converts the identity controls (profile, settings, allowlist, workflow key fields) and #7495 converts the everyday display surfaces (mentions, member lists, sidebar, and other name fallbacks). After this change: - The shared identity widget shows the compact npub form — `npub1j57...fjmv` — instead of a hex prefix, everywhere it renders (for example the owned-agent public-key row on a profile). Copying it puts the full npub on the clipboard. - Copy is a real interaction, verified end-to-end: both popover variants put the exact canonical npub on the actual clipboard — never the raw hex the popover also lists, never a truncation — and a portaled popover's clicks no longer steal focus from the new-DM To-field mid-copy. Pointer copy, a natural Space-then-Enter path, and inner/outer Escape are covered. - Anything that isn't a valid identity key fails neutrally: short or corrupt values — including degenerate values that technically encode to a checksum-valid npub but aren't real identity keys — show "Unavailable" with no copy button, instead of a misleading value. - Both valid npub spellings display: all-lowercase `npub1…` and all-uppercase `NPUB1…` (Bech32, npub's encoding, permits either casing) both render the same canonical lowercase npub. Mixed case is rejected by the display path as written — `canonicalNpub` and the widget don't case-normalize input — while input parsing (`parsePubkeyInput`) keeps its trim-and-lowercase normalization and accepts mixed-case npubs; both paths require the decoded payload to be exactly a 64-character identity key. - Identity-key input is strict on payload: an npub whose decoded payload isn't exactly a 64-character identity key is rejected, matching the validation the app's Rust side already applies to agent allowlists. Intentional scope boundary: only surfaces that render through the shared widget change here. Outer profile copy, settings identity cards, the respond-to allowlist, and workflow key fields still show hex — they move to npub in the controls follow-up (#7489). Nothing else changes identity representation: display names, private keys, event IDs, and the hex the app stores, sends, and matches internally are untouched; only the user-facing spelling of an identity key changes. ## Details - `desktop/src/shared/lib/pubkey.ts` — `canonicalNpub()`: strict canonical full-npub helper (64-char hex in any case, or a checksum-validated npub, returns the canonical npub; anything else returns `null`); `truncateNpub()`: the compact display form; existing exports unchanged. - `desktop/src/shared/ui/PubKey.tsx` — the shared widget's identity gate validates through `canonicalNpub`; the popover copies the npub only. - `desktop/src/shared/lib/nostrUtils.ts` — `parsePubkeyInput` rejects npubs whose payload is not exactly a 64-character identity key. - `desktop/src/features/messages/ui/NewMessageScreen.tsx` — the To-field focuses its search input only for clicks that land inside the field itself, so portaled recipient popovers keep their focus while open (a popover click previously dismissed it mid-copy). - Unit suites cover the helper, widget, and parser (including the degenerate-encode and uppercase regressions); the e2e specs that render these rows assert the npub display. ### Related issue - Fixes: N/A. Searched existing issues/PRs for npub identity display — no existing match. - Stack: #7489 is based on this branch and builds on these primitives; it does not stand alone on main. ### Testing At head `b3310c248` (base: main `44316ff72`; 12 files, +440/−39): - Focused unit suites (pubkey, PubKey, parsePubkeyInput): 20/20 green; mutation-checked — removing the decoded-length predicate fails the short/empty checksum-valid-npub assertions in `canonicalNpub` and the widget, and a wrong-identity clipboard value fails the new copy assertions. - `pnpm typecheck` and `pnpm check`: pass; full desktop unit suite 6459/6459 at this exact head. - Targeted e2e at this exact head: 8/8 across the two specs that own the clipboard flows — `agent-access-warning.spec.ts` (compact variant, agent-access owner hint) and `pubkey-display-screenshots.spec.ts` (full variant, new-DM recipient verification: pointer copy, popover surviving the copy, inner/outer Escape, Space-then-Enter). - No Rust-side or build files change in this PR, so those results are unaffected. ### Task provenance Buzz channel: `1f0e4a3d-7e01-4efe-bb16-843b357f85c9` Task: buzz://message?channel=1f0e4a3d-7e01-4efe-bb16-843b357f85c9&id=86b34eb4bd84a1472419e9af22636c011c0fe273e3c196f967d7a36996e149b6 --------- Signed-off-by: Logan Johnson Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz> --- .../features/messages/ui/NewMessageScreen.tsx | 17 ++- desktop/src/shared/lib/nostrUtils.ts | 10 +- .../src/shared/lib/parsePubkeyInput.test.mjs | 17 +++ desktop/src/shared/lib/pubkey.test.mjs | 69 +++++++++- desktop/src/shared/lib/pubkey.ts | 62 ++++++++- desktop/src/shared/ui/PubKey.test.mjs | 118 ++++++++++++++++++ desktop/src/shared/ui/PubKey.tsx | 81 +++++++----- .../tests/e2e/agent-access-warning.spec.ts | 26 ++++ desktop/tests/e2e/identity-archive.spec.ts | 5 +- desktop/tests/e2e/mentions.spec.ts | 5 +- desktop/tests/e2e/profile.spec.ts | 3 +- .../e2e/pubkey-display-screenshots.spec.ts | 66 ++++++++++ 12 files changed, 440 insertions(+), 39 deletions(-) create mode 100644 desktop/src/shared/ui/PubKey.test.mjs diff --git a/desktop/src/features/messages/ui/NewMessageScreen.tsx b/desktop/src/features/messages/ui/NewMessageScreen.tsx index f7192f6e45c..5c5c527df7c 100644 --- a/desktop/src/features/messages/ui/NewMessageScreen.tsx +++ b/desktop/src/features/messages/ui/NewMessageScreen.tsx @@ -328,7 +328,22 @@ export function NewMessageScreen() {
{ + onClick={(event) => { + // Portaled popovers (recipient inspection and its nested key + // copy) still bubble through React's tree to this handler, + // but their event targets are not DOM descendants of the + // field. Those clicks belong to the popover's own controls + // — they must not steal focus into the search input (which + // dismisses the popover via focus-outside) or reopen the + // picker. Only clicks physically within the recipient field + // focus its input. + const { currentTarget, target } = event; + if ( + !(target instanceof Node) || + !currentTarget.contains(target) + ) { + return; + } setIsRecipientPickerOpen(true); searchInputRef.current?.focus({ preventScroll: true }); }} diff --git a/desktop/src/shared/lib/nostrUtils.ts b/desktop/src/shared/lib/nostrUtils.ts index d98c6ee8cfb..9b6fe0fb23d 100644 --- a/desktop/src/shared/lib/nostrUtils.ts +++ b/desktop/src/shared/lib/nostrUtils.ts @@ -31,7 +31,13 @@ const HEX_PUBKEY_REGEX = /^[0-9a-f]{64}$/; * anything else (does NOT throw — intended for live form validation). * * The input is trimmed first; surrounding whitespace from copy-paste is - * tolerated. + * tolerated. It is also case-normalized before matching and decoding — + * preexisting behavior — so a hex key in any casing resolves, and a + * mixed-case npub (invalid Bech32 as written) is accepted via its + * lowercased form. The identity payload itself stays strict: it must + * decode to exactly a 64-char hex identity key, because `npubEncode` also + * encodes degenerate short payloads (even `""`), which are never valid + * identities. */ export function parsePubkeyInput(input: string): string | null { const trimmed = input.trim().toLowerCase(); @@ -41,7 +47,7 @@ export function parsePubkeyInput(input: string): string | null { if (trimmed.startsWith("npub1")) { try { const decoded = decode(trimmed); - if (decoded.type === "npub") { + if (decoded.type === "npub" && HEX_PUBKEY_REGEX.test(decoded.data)) { return decoded.data; } } catch { diff --git a/desktop/src/shared/lib/parsePubkeyInput.test.mjs b/desktop/src/shared/lib/parsePubkeyInput.test.mjs index f9aa57d87f1..b74331cd984 100644 --- a/desktop/src/shared/lib/parsePubkeyInput.test.mjs +++ b/desktop/src/shared/lib/parsePubkeyInput.test.mjs @@ -19,6 +19,16 @@ describe("parsePubkeyInput", () => { assert.equal(parsePubkeyInput(NPUB), HEX); }); + it("normalizes a mixed-case npub to its canonical hex", () => { + // Preexisting behavior: user input is lowercased before decoding, so a + // mixed-case npub — invalid Bech32 as written — still resolves to the + // identity. canonicalNpub is the strict counterpart (see ../lib/pubkey.ts). + assert.equal( + parsePubkeyInput(`${NPUB.slice(0, 10)}${NPUB.slice(10).toUpperCase()}`), + HEX, + ); + }); + it("tolerates surrounding whitespace from copy-paste", () => { assert.equal(parsePubkeyInput(` ${NPUB}\n`), HEX); assert.equal(parsePubkeyInput(` ${HEX} `), HEX); @@ -42,6 +52,13 @@ describe("parsePubkeyInput", () => { assert.equal(parsePubkeyInput(`${HEX}0`), null); }); + it("rejects degenerate npubs whose payload is not a 64-char identity", () => { + // `npubEncode` happily encodes short payloads with valid checksums — + // those are not identity keys and must never bind as one. + assert.equal(parsePubkeyInput("npub1m6kmamcvty5gd"), null); + assert.equal(parsePubkeyInput("npub106246s"), null); + }); + it("rejects non-hex non-npub input", () => { assert.equal(parsePubkeyInput(""), null); assert.equal(parsePubkeyInput("alice"), null); diff --git a/desktop/src/shared/lib/pubkey.test.mjs b/desktop/src/shared/lib/pubkey.test.mjs index 76d0a29b231..bce5834162d 100644 --- a/desktop/src/shared/lib/pubkey.test.mjs +++ b/desktop/src/shared/lib/pubkey.test.mjs @@ -1,10 +1,20 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { normalizePubkey, truncatePubkey } from "./pubkey.ts"; +import { + canonicalNpub, + normalizePubkey, + truncateNpub, + truncatePubkey, +} from "./pubkey.ts"; const PUBKEY = "44b8e82baa6e0e254e0208d68f335c283c94e7b78dd1fa10d5a49d3f13dd0435"; +const PUBKEY_NPUB = + "npub1gjuws2a2dc8z2nszprtg7v6u9q7ffeah3hgl5yx45jwn7y7aqs6s5e9xj6"; +const HEX = "ea9b4d7a7a78a3e3729e5568b14d764d4962be0e1f20f749bcf8d9dbbf9a9328"; +const HEX_NPUB = + "npub1a2d567n60z37xu57245tzntkf4yk90swrus0wjdulrvah0u6jv5qusyp60"; test("truncates to the canonical 8+4 form with unicode ellipsis", () => { assert.equal(truncatePubkey(PUBKEY), "44b8e82b…0435"); @@ -18,3 +28,60 @@ test("returns short strings unchanged", () => { test("normalizePubkey trims and lowercases", () => { assert.equal(normalizePubkey(" ABCDEF "), "abcdef"); }); + +test("truncateNpub compacts the hex pubkey's npub, not its hex form", () => { + assert.equal(truncateNpub(PUBKEY), "npub1gju…9xj6"); + assert.equal(truncateNpub(HEX), "npub1a2d…yp60"); + assert.equal(truncateNpub(HEX.toUpperCase()), "npub1a2d…yp60"); +}); + +test("truncateNpub accepts already-npub strings", () => { + assert.equal(truncateNpub(PUBKEY_NPUB), "npub1gju…9xj6"); + assert.equal(truncateNpub(` ${HEX_NPUB} `), "npub1a2d…yp60"); + // All-uppercase Bech32 is a valid identity per the parser; render the + // canonical form, never the neutral label. + assert.equal(truncateNpub(HEX_NPUB.toUpperCase()), "npub1a2d…yp60"); +}); + +test("truncateNpub renders the neutral label for invalid identities", () => { + // Never the raw hex/input fallback: a wrong-length or non-hex string is not + // a displayable identity. + assert.equal(truncateNpub(""), "Unavailable"); + assert.equal(truncateNpub("not a pubkey"), "Unavailable"); + assert.equal(truncateNpub(`${HEX.slice(0, 63)}`), "Unavailable"); + assert.equal(truncateNpub(`z${HEX.slice(1)}`), "Unavailable"); + // Corrupted npub checksum is not a valid identity either. + assert.equal(truncateNpub(`${HEX_NPUB.slice(0, -1)}q`), "Unavailable"); + // Other bech32 entities are not pubkeys. + assert.equal( + truncateNpub( + "nsec1vl029mgpspedva04g90vltkh6fvh240zqtv9k0t9af8935ke9laqsnlfe5", + ), + "Unavailable", + ); +}); + +test("canonicalNpub returns the full npub for valid identities only", () => { + assert.equal(canonicalNpub(HEX), HEX_NPUB); + assert.equal(canonicalNpub(HEX.toUpperCase()), HEX_NPUB); + assert.equal(canonicalNpub(HEX_NPUB), HEX_NPUB); + // All-uppercase Bech32 is valid and returns the canonical lowercase npub + // (parser agreement); a mixed-case npub is invalid Bech32. + assert.equal(canonicalNpub(HEX_NPUB.toUpperCase()), HEX_NPUB); + assert.equal( + canonicalNpub( + `${HEX_NPUB.slice(0, 10)}${HEX_NPUB.slice(10).toUpperCase()}`, + ), + null, + ); + // Strict identity keys only — short/degenerate payloads never encode. + assert.equal(canonicalNpub(""), null); + assert.equal(canonicalNpub("deadbeef"), null); + assert.equal(canonicalNpub(`${HEX.slice(0, 63)}`), null); + // Checksum-valid short npubs are degenerate payloads too (8-char and + // empty) — `npubEncode` would happily re-encode them, so never bind them. + assert.equal(canonicalNpub("npub1m6kmamcvty5gd"), null); + assert.equal(canonicalNpub("npub106246s"), null); + // Corrupted checksum never binds as the identity it resembles. + assert.equal(canonicalNpub(`${HEX_NPUB.slice(0, -2)}qq`), null); +}); diff --git a/desktop/src/shared/lib/pubkey.ts b/desktop/src/shared/lib/pubkey.ts index 6dcc48749a3..199ad52d3c1 100644 --- a/desktop/src/shared/lib/pubkey.ts +++ b/desktop/src/shared/lib/pubkey.ts @@ -1,3 +1,7 @@ +import { decode, npubEncode } from "nostr-tools/nip19"; + +import { safeNpub } from "./nostrUtils"; + /** * Canonical pubkey normalisation. * @@ -8,14 +12,22 @@ export function normalizePubkey(pubkey: string): string { return pubkey.trim().toLowerCase(); } +/** Neutral identity label for keys that cannot be encoded for display. */ +export const UNAVAILABLE_KEY_LABEL = "Unavailable"; + +const HEX_64_REGEX = /^[0-9a-f]{64}$/; + /** - * The ONE canonical compact display form for a pubkey: `abcd1234…wxyz`. + * The ONE canonical compact display form for a hex string: `abcd1234…wxyz`. * * A truncated pubkey is a recognition aid, never an identity proof — vanity * grinders forge short prefixes cheaply. Surfaces where the user makes a * trust decision must show the full npub (see ``). * Do not hand-roll `pubkey.slice(…)` display forms; `check-pubkey-truncation` * fails the build if one sneaks in outside this module. + * + * Identity (pubkey) surfaces should use `truncateNpub` instead; this hex form + * remains canonical for non-identity identifiers — event and blob IDs. */ export function truncatePubkey(pubkey: string): string { if (pubkey.length <= 12) { @@ -23,3 +35,51 @@ export function truncatePubkey(pubkey: string): string { } return `${pubkey.slice(0, 8)}…${pubkey.slice(-4)}`; } + +/** + * Canonical full npub for an identity key: a 64-char hex pubkey (any + * case) or an already-npub string (checksum-validated) returns the + * canonical npub; anything else returns null. Strict 64-char identity keys + * only — `npubEncode` happily encodes short/degenerate payloads (even `""`), + * which are not displayable identities. + * + * Bech32 casing is strict on the input as written: a lowercase `npub1…` + * or an all-uppercase `NPUB1…` (both valid Bech32) returns the canonical + * lowercase npub, while a mixed-case npub is invalid Bech32 and returns + * null — `decode` enforces the all-lower/all-upper rule. This is + * intentionally stricter than the parser (`parsePubkeyInput`), which + * normalizes user input before decoding and so also accepts mixed-case + * npubs; the two agree that the payload must be a 64-hex identity key and + * that both valid casings above are acceptable input. + */ +export function canonicalNpub(pubkey: string): string | null { + const trimmed = pubkey.trim(); + if (trimmed.startsWith("npub1") || trimmed.startsWith("NPUB1")) { + try { + const decoded = decode(trimmed); + if (decoded.type !== "npub" || !HEX_64_REGEX.test(decoded.data)) { + return null; + } + return npubEncode(decoded.data); + } catch { + return null; + } + } + const normalized = normalizePubkey(trimmed); + return HEX_64_REGEX.test(normalized) ? safeNpub(normalized) : null; +} + +/** + * The ONE canonical compact identity display for a pubkey: `npub1abcd…wxyz` + * (first 8 + last 4 of the FULL npub). + * + * Identity surfaces render this form so a displayed prefix is always npub- + * shaped; the underlying hex never leaks as the identity display. A + * truncated key is a recognition aid, never an identity proof — trust + * decisions use `` or the full npub directly. Invalid + * keys render `UNAVAILABLE_KEY_LABEL`, never raw hex or raw input. + */ +export function truncateNpub(pubkey: string): string { + const npub = canonicalNpub(pubkey); + return npub === null ? UNAVAILABLE_KEY_LABEL : truncatePubkey(npub); +} diff --git a/desktop/src/shared/ui/PubKey.test.mjs b/desktop/src/shared/ui/PubKey.test.mjs new file mode 100644 index 00000000000..08167ae1c47 --- /dev/null +++ b/desktop/src/shared/ui/PubKey.test.mjs @@ -0,0 +1,118 @@ +/** + * Widget-boundary coverage for the shared identity gate. + * + * Codec vectors and the exact compact/neutral strings live in + * ../lib/pubkey.test.mjs. This suite pins what static rendering shows: the + * rendered text per variant, and that an unencodable identity — including + * degenerate-length hex and short-payload npubs whose npubEncode outputs + * carry valid checksums — renders the neutral label with no copy affordance, + * never a fake npub. The clipboard write behind the copy affordance and the + * popover the widget opens are real-bridge interactions owned by the E2E + * regressions: the full variant's copy is pinned by the new-DM recipient + * verification flow (tests/e2e/pubkey-display-screenshots.spec.ts) and the + * compact variant's by the agent-access owner hint + * (tests/e2e/agent-access-warning.spec.ts); both drive CopyRow through the + * mock bridge into the actual browser clipboard. + */ +import assert from "node:assert/strict"; +import { after, afterEach, before, test } from "node:test"; + +import { JSDOM } from "jsdom"; + +const dom = new JSDOM("", { + url: "http://localhost", +}); + +before(() => { + Object.assign(globalThis, { + document: dom.window.document, + getComputedStyle: dom.window.getComputedStyle.bind(dom.window), + HTMLElement: dom.window.HTMLElement, + IS_REACT_ACT_ENVIRONMENT: true, + Node: dom.window.Node, + ResizeObserver: class { + disconnect() {} + observe() {} + unobserve() {} + }, + window: dom.window, + }); +}); + +afterEach(async () => { + const { cleanup } = await import("@testing-library/react"); + cleanup(); +}); + +after(() => dom.window.close()); + +const HEX = "ea9b4d7a7a78a3e3729e5568b14d764d4962be0e1f20f749bcf8d9dbbf9a9328"; +const NPUB = "npub1a2d567n60z37xu57245tzntkf4yk90swrus0wjdulrvah0u6jv5qusyp60"; +const COMPACT_NPUB = "npub1a2d…yp60"; + +async function renderPubKey(props) { + const React = await import("react"); + const { render, within } = await import("@testing-library/react"); + const { PubKey } = await import("./PubKey.tsx"); + const view = render(React.createElement(PubKey, props)); + // render()'s bound queries search the whole body; scope to this render so + // earlier mounts (cleaned up only per test) stay invisible. + return { ...within(view.container), container: view.container }; +} + +test("compact PubKey renders the truncated npub, never the hex", async () => { + const trigger = await renderPubKey({ pubkey: HEX }); + assert.equal( + trigger.getByRole("button", { name: "Show full public key" }).textContent, + COMPACT_NPUB, + ); + assert.equal(trigger.queryByText(HEX), null); + + // A parent row that owns the interaction gets the same text, not a button. + const text = await renderPubKey({ interactive: false, pubkey: HEX }); + assert.equal(text.getByText(COMPACT_NPUB).tagName, "SPAN"); + assert.equal(text.queryByRole("button"), null); + assert.equal(text.queryByText(HEX), null); + + // An all-uppercase Bech32 npub is a valid identity (parsePubkeyInput + // accepts it); the gate must render its canonical compact form, not the + // neutral label. + const upper = await renderPubKey({ pubkey: NPUB.toUpperCase() }); + assert.equal( + upper.getByRole("button", { name: "Show full public key" }).textContent, + COMPACT_NPUB, + ); + assert.equal(upper.queryByText("Unavailable"), null); +}); + +test("full PubKey renders the complete npub with a copy affordance", async () => { + const view = await renderPubKey({ pubkey: HEX, variant: "full" }); + assert.equal(view.getByText(NPUB).textContent, NPUB); + assert.equal( + view.getByRole("button", { name: "Copy public key" }).tagName, + "BUTTON", + ); + assert.equal(view.queryByText(HEX), null); +}); + +test("unencodable keys render Unavailable with no copy affordance", async () => { + // "zz" cannot decode; "deadbeef" is a degenerate-length hex that npubEncode + // would happily turn into a checksum-valid fake npub; npub1m6kmamcvty5gd + // and npub106246s decode fine but are checksum-valid short-payload npubs + // (8-char and empty identity payloads). All four would masquerade as + // displayable identities — the gate refuses every one. + for (const pubkey of [ + "zz", + "deadbeef", + "npub1m6kmamcvty5gd", + "npub106246s", + ]) { + for (const variant of [undefined, "full"]) { + const view = await renderPubKey({ pubkey, variant }); + const label = `${pubkey} ${variant ?? "compact"}`; + assert.equal(view.getByText("Unavailable").tagName, "SPAN", label); + assert.equal(view.queryByRole("button"), null, label); + assert.equal(view.container.textContent?.includes("npub1"), false, label); + } + } +}); diff --git a/desktop/src/shared/ui/PubKey.tsx b/desktop/src/shared/ui/PubKey.tsx index fb5ac15a441..e8fe0bdead8 100644 --- a/desktop/src/shared/ui/PubKey.tsx +++ b/desktop/src/shared/ui/PubKey.tsx @@ -3,8 +3,11 @@ import * as React from "react"; import { copyTextToClipboard } from "@/shared/lib/clipboard"; import { cn } from "@/shared/lib/cn"; -import { safeNpub } from "@/shared/lib/nostrUtils"; -import { truncatePubkey } from "@/shared/lib/pubkey"; +import { + canonicalNpub, + truncateNpub, + UNAVAILABLE_KEY_LABEL, +} from "@/shared/lib/pubkey"; import { Button } from "@/shared/ui/button"; import { DEFAULT_POPOVER_HOVER_OPEN_DELAY_MS, @@ -19,12 +22,12 @@ type PubKeyProps = { /** 64-char hex pubkey. */ pubkey: string; /** - * `compact` — truncated hex, click/tap opens a popover with the full npub, - * full hex, and copy buttons. The default for identity display in lists, - * cards, and metadata rows. + * `compact` — truncated npub (`npub1abcd…wxyz`), click/tap opens a popover + * with the full npub and its copy button. The default for identity display + * in lists, cards, and metadata rows. * - * `full` — the complete npub rendered inline with copy buttons. Required on - * security-decision surfaces (invite/approve, removal, trust/pairing, new + * `full` — the complete npub rendered inline with a copy button. Required + * on security-decision surfaces (invite/approve, removal, trust/pairing, new * DM, key import): a truncated key is forgeable by vanity grinding, so * decisions must be made against the whole key. */ @@ -66,12 +69,10 @@ function CopyRow({ label, value }: { label: string; value: string }) { ); } -function PubKeyDetails({ pubkey }: { pubkey: string }) { - const npub = safeNpub(pubkey); +function PubKeyDetails({ npub }: { npub: string }) { return (
- {npub ? : null} - +
); } @@ -119,29 +120,47 @@ export function PubKey({ React.useEffect(() => clearHoverTimer, [clearHoverTimer]); + // Strict identity gate: `safeNpub` would happily encode degenerate short + // payloads (e.g. an 8-char hex) as a fake npub, so the widget validates + // through `canonicalNpub` and renders Unavailable for anything else. + const npub = canonicalNpub(pubkey); + if (variant === "full") { - const npub = safeNpub(pubkey); return ( - {npub ?? pubkey} - - - - - - - - + + {npub ?? UNAVAILABLE_KEY_LABEL} + + {npub ? ( + + + + + + + + + ) : null} + + ); + } + + // An unencodable key has no key display to expand: render the neutral + // label without a popover or copy affordance. + if (npub === null) { + return ( + + {UNAVAILABLE_KEY_LABEL} ); } @@ -149,7 +168,7 @@ export function PubKey({ if (!interactive) { return ( - {truncatePubkey(pubkey)} + {truncateNpub(pubkey)} ); } @@ -168,7 +187,7 @@ export function PubKey({ onMouseLeave={handleMouseLeave} type="button" > - {truncatePubkey(pubkey)} + {truncateNpub(pubkey)} event.preventDefault()} > - + ); diff --git a/desktop/tests/e2e/agent-access-warning.spec.ts b/desktop/tests/e2e/agent-access-warning.spec.ts index adb9c6d58ab..55d5c4a0075 100644 --- a/desktop/tests/e2e/agent-access-warning.spec.ts +++ b/desktop/tests/e2e/agent-access-warning.spec.ts @@ -1,4 +1,5 @@ import { expect, test } from "@playwright/test"; +import { npubEncode } from "nostr-tools/nip19"; import { waitForAnimations } from "../helpers/animations"; import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge"; @@ -123,6 +124,31 @@ test("open agent access explains the available access before save", async ({ .getByRole("dialog", { name: "Manage agent access" }) .screenshot({ path: `${SHOTS}/selected-people-warning.png` }); + // Compact-variant clipboard regression (D1a): the owner hint's compact + // PubKey must expand to and copy the viewer's complete canonical npub — + // the truncated trigger is only a recognition aid. The real bridge writes + // the browser clipboard and the poll reads it back. + await page.context().grantPermissions(["clipboard-read", "clipboard-write"]); + await page + .getByTestId("agent-respond-to") + .getByRole("button", { name: "Show full public key" }) + .click(); + const copyNpubButton = page.getByRole("button", { name: "Copy npub" }); + await copyNpubButton.click(); + await expect + .poll(() => page.evaluate(() => navigator.clipboard.readText())) + .toBe(npubEncode("deadbeef".repeat(8))); + await expect( + page.locator("[data-sonner-toast]").filter({ hasText: "npub copied" }), + ).toBeVisible(); + // Dismiss just the key popover: the access dialog stays open for the + // remaining mode assertions below. + await page.keyboard.press("Escape"); + await expect(copyNpubButton).toHaveCount(0); + await expect( + page.getByRole("dialog", { name: "Manage agent access" }), + ).toBeVisible(); + // Only me shares nothing, so the warning goes away entirely. await accessSelect.selectOption("owner-only"); await expect(warning).toHaveCount(0); diff --git a/desktop/tests/e2e/identity-archive.spec.ts b/desktop/tests/e2e/identity-archive.spec.ts index dfbe2ff2690..cb6e83def1b 100644 --- a/desktop/tests/e2e/identity-archive.spec.ts +++ b/desktop/tests/e2e/identity-archive.spec.ts @@ -1,4 +1,5 @@ import { expect, test } from "@playwright/test"; +import { npubEncode } from "nostr-tools/nip19"; import { installMockBridge } from "../helpers/bridge"; @@ -33,7 +34,9 @@ async function openAliceProfile(page: import("@playwright/test").Page) { await aliceMessage.locator("button", { hasText: "alice" }).first().click(); const panel = page.getByTestId("user-profile-panel"); await expect(panel).toBeVisible(); - await expect(panel).toContainText(ALICE_PUBKEY.slice(0, 8)); + // The panel's public key row renders through the shared widget, + // which displays the canonical npub form — assert the npub prefix. + await expect(panel).toContainText(npubEncode(ALICE_PUBKEY).slice(0, 8)); } async function openProfileSettingsMenu(page: import("@playwright/test").Page) { diff --git a/desktop/tests/e2e/mentions.spec.ts b/desktop/tests/e2e/mentions.spec.ts index 6d9baa1e23b..e9248509fd1 100644 --- a/desktop/tests/e2e/mentions.spec.ts +++ b/desktop/tests/e2e/mentions.spec.ts @@ -1,4 +1,5 @@ import { expect, test } from "@playwright/test"; +import { npubEncode } from "nostr-tools/nip19"; import { waitForAnimations } from "../helpers/animations"; @@ -4534,7 +4535,9 @@ test("clicking author name opens user profile panel", async ({ page }) => { // Click now opens the full profile panel instead of the popover const panel = page.getByTestId("user-profile-panel"); await expect(panel).toBeVisible(); - await expect(panel).toContainText("deadbeef"); + // The panel's public key row renders through the shared widget, + // which displays the canonical npub form — assert the npub prefix. + await expect(panel).toContainText(npubEncode(MOCK_VIEWER_PUBKEY).slice(0, 8)); }); test("hovering avatar opens popover, clicking opens profile panel", async ({ diff --git a/desktop/tests/e2e/profile.spec.ts b/desktop/tests/e2e/profile.spec.ts index 095f0fe401e..e47d75706cb 100644 --- a/desktop/tests/e2e/profile.spec.ts +++ b/desktop/tests/e2e/profile.spec.ts @@ -1,4 +1,5 @@ import { expect, test, type Page } from "@playwright/test"; +import { npubEncode } from "nostr-tools/nip19"; import { createMockAgentMemoryListing, @@ -420,7 +421,7 @@ test("owned agent profile stays in parity between Agents and its DM", async ({ .getByRole("button", { name: `Open profile for ${agentName}` }) .click(); await expect(page.getByTestId("user-profile-public-key")).toContainText( - agentPubkey.slice(0, 8), + npubEncode(agentPubkey).slice(0, 8), ); const dmSurface = await readOwnedAgentProfileContract(page); diff --git a/desktop/tests/e2e/pubkey-display-screenshots.spec.ts b/desktop/tests/e2e/pubkey-display-screenshots.spec.ts index 77630467da1..81ff86c3299 100644 --- a/desktop/tests/e2e/pubkey-display-screenshots.spec.ts +++ b/desktop/tests/e2e/pubkey-display-screenshots.spec.ts @@ -1,4 +1,5 @@ import { expect, test } from "@playwright/test"; +import { npubEncode } from "nostr-tools/nip19"; import { installMockBridge, @@ -189,13 +190,68 @@ test("selected new-DM recipient can be verified again through search", async ({ await charlieNameTrigger.click(); await expect(charlieKeyPopover).toBeVisible(); await expect(charliePubkey).toContainText("npub1"); + // The shared PubKey widget is npub-only — no hex text or hex copy row in + // the widget itself. (D1a boundary: the chip's legacy raw-hex popover line + // is removed with the chip change in the descendant slice.) + await expect(charliePubkey).not.toContainText(TEST_IDENTITIES.charlie.pubkey); await expect(charlieKeyPopover).toContainText(TEST_IDENTITIES.charlie.pubkey); await waitForAnimations(page); await page.getByTestId("new-message-page").screenshot({ path: `${SHOTS}/new-dm-selected-recipient-key.png`, }); + + // Full-variant clipboard regression (D1a): the nested copy affordances + // must write the recipient's complete canonical npub through the real + // bridge — never the legacy raw hex the popover also shows, and never a + // truncation. The evidence screenshot above is captured first, so this + // interaction leaves it untouched. + await page.context().grantPermissions(["clipboard-read", "clipboard-write"]); + const copyPublicKeyTrigger = charliePubkey.getByRole("button", { + name: "Copy public key", + }); + await copyPublicKeyTrigger.click(); + const copyNpubButton = page.getByRole("button", { name: "Copy npub" }); + await copyNpubButton.click(); + await expect + .poll(() => page.evaluate(() => navigator.clipboard.readText())) + .toBe(npubEncode(TEST_IDENTITIES.charlie.pubkey)); + await expect( + page.locator("[data-sonner-toast]").filter({ hasText: "npub copied" }), + ).toBeVisible(); + // Copying is not a dismissal: the nested affordance and the inspection + // popover it lives in both survive the copy. + await expect(copyNpubButton).toBeVisible(); + await expect(charlieKeyPopover).toBeVisible(); + + // The inner Escape closes only the nested key popover — the inspection + // stays open. + await page.keyboard.press("Escape"); + await expect(copyNpubButton).toHaveCount(0); + await expect(charlieKeyPopover).toBeVisible(); + + // Keyboard path: after the inner Escape focus returns naturally to the + // full-key trigger; Space reopens the popover, whose auto-focus lands on + // Copy npub, and Enter activates it. The sentinel proves this keyboard + // copy rewrites the clipboard rather than inheriting the value above. + await expect(copyPublicKeyTrigger).toBeFocused(); + await page.evaluate(() => + navigator.clipboard.writeText("keyboard-copy-sentinel"), + ); + await page.keyboard.press("Space"); + await expect(copyNpubButton).toBeVisible(); + await expect(copyNpubButton).toBeFocused(); + await page.keyboard.press("Enter"); + await expect + .poll(() => page.evaluate(() => navigator.clipboard.readText())) + .toBe(npubEncode(TEST_IDENTITIES.charlie.pubkey)); + + // Close the reopened nested popover so the inspection popover owns the + // final Escape; the recipient itself survives both dismissals. + await page.keyboard.press("Escape"); + await expect(copyNpubButton).toHaveCount(0); await page.keyboard.press("Escape"); await expect(charlieKeyPopover).toHaveCount(0); + await expect(charlieChip).toBeVisible(); await search.fill("charlie"); await expect(charlieResult).toBeVisible(); @@ -226,6 +282,16 @@ test("selected new-DM recipient can be verified again through search", async ({ await page.getByTestId("new-message-page").screenshot({ path: `${SHOTS}/new-dm-selected-recipient.png`, }); + + // The To-field guard ignores popover clicks that bubble into the field; + // a click on the label itself — a physical descendant of the field — must + // still focus the input and open the recipient picker. + await page + .getByTestId("new-message-to-field") + .getByText("To:", { exact: true }) + .click(); + await expect(search).toBeFocused(); + await expect(page.getByTestId("new-message-recipient-popover")).toBeVisible(); }); test("member removal confirm shows the full npub inline", async ({ page }) => { From ad2a84131ee99d087b8dba95cff0f319785209df Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Wed, 9 Sep 2026 14:07:24 -0400 Subject: [PATCH 05/19] fix(desktop): npub identity displays for mention, member, and workflow surfaces (#7495) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 ## Summary Every Buzz account is identified by a long public key. Before this change, when someone had no display name, surfaces fell back to inconsistent labels — mostly raw hex fragments like `abcd1234…wxyz`, sometimes a generic role label with no key — so the same person looked different from surface to surface, and nothing looked like an npub address. This PR applies the npub identity foundation from #7488 to the everyday surfaces: a person without a display name now falls back to the same compact npub everywhere — `npub1xxxx…yyyy`, the human-readable spelling of their public key (first 8 + last 4 characters of the full npub) — across messages and mentions, reactions, huddles, member and participant lists, the sidebar and channel activity, search, projects, tray, notifications, and workflow surfaces. - **Mentions and messages**: key-only mention chips render the compact npub. Pasting a copied mention back still re-binds it byte-exactly to the identity it declares, for both the new npub chips and legacy hex-truncated chips copied by older clients — wrong, missing, or tampered key qualification is rejected instead of silently degrading to plain text. - **Reactions and huddles**: huddle reaction events and the huddle roster/participants render the compact npub for unnamed participants; workflow reaction triggers describe authors with the same form. - **Members and sidebar**: channel and community member lists, add-member results and invites, the members sidebar, the channel-activity popover, search, projects (assignees/reviewers/PR panels), the tray menu, and desktop notifications all fall back to the compact npub; titles and aria labels keep the machine-readable full labels. - **Profile labels**: panel/popover display names and owner handles fall back to the compact npub (never raw hex) when there is no name; linked-event (nevent) message metadata shows the npub-shaped author fallback while the event lookup and event IDs are unchanged. - **Workflows**: author-picker secondary labels, step destination keys, and trigger-author references render compact npubs; event and blob IDs keep their existing hex compacts (they are not identities). - **Avatars stay distinct**: fallback avatars for key-only identities derive initials from the key's tail, so prefixed role labels like "Participant npub1…" no longer collapse every unnamed participant onto the same initials; people with names keep their name initials. Preserved exactly: display names and distinct avatars, internal hex keys (storage/API forms unchanged), clipboard identity roundtrips, event/blob ID compaction, private keys (no nsec path is touched), and nevent link handling. Scope: this PR changes what identity labels **display**, not identity controls — profile/settings copy controls, the respond-to allowlist, workflow key fields, and agent dialogs are the sibling slice #7489, and the shared primitives (`canonicalNpub`, `truncateNpub`, the `` gate, strict input parsing) come from the foundation #7488. ### Related issue - Fixes: N/A. Searched existing issues/PRs for duplicates — none found; the related work is the npub identity stack this slice belongs to. - Base/dependency: stacks on #7488 (foundation) — this PR does not stand alone on main. - #7489 is a sibling slice on the same #7488 base (profile/agent/workflow controls), not a dependency: this PR does not require #7489, and #7489 does not require this PR — both only require #7488. ### Testing At exact head `4763cbeae1dd521309755e6d61f657324cb98667` (base: `fix/desktop-npub-identity-d1a` @ `5f3a4a8111998c8aa41ad77cf66992bd1c85343c`; 71 files, +656/−189 — production +277/−136, test support +379/−53): - At this head: targeted `mentions.spec.ts` (1/1), the e2e build, typecheck, and biome — green. - 9 changed/related unit files: 100/100 green; typecheck, e2e build, biome, and px text/truncation checks clean; huddle-roster focused run green; channel-activity e2e 11/11; mutation checks confirm the fallback wiring (removing it collapses shared initials and drops fallback rows). - Known pre-existing local e2e failures, unchanged by this PR and reproduced identically at the upstream merge-base: huddle-transcription voice-menu attribution (25 pass / 1 fail) and the `workflow-local-controls` 438px caret drift. Not claimed green locally. - Update at head `236af9e6137386737e84d3a474d6bc808a704c50` (test-only follow-ups `1143af345` + `236af9e6`): the `workflow-local-controls` races were fixed in the test drivers, and the 438px diff was shown to be a stale Darwin snapshot baseline (name-row enable switch already absent and `message_posted` already MessageSquare at recording commit `9390e11c9`) and refreshed — the focused screenshot test, including keyboard/caret assertions, now passes locally (twice). The full spec was not rerun after the snapshot refresh; the huddle-transcription item above is unchanged. Label/copy text changes are asserted by the e2e specs (`mentions`, `mention-recipients`, `pubkey-display-screenshots`, `huddle-transcription`, `channel-activity-popover`, `workflow-local-controls`) rather than new screenshots; the screenshot spec pins the compact npub text forms. ### Task provenance Buzz channel: `1f0e4a3d-7e01-4efe-bb16-843b357f85c9` Task: buzz://message?channel=1f0e4a3d-7e01-4efe-bb16-843b357f85c9&id=86b34eb4bd84a1472419e9af22636c011c0fe273e3c196f967d7a36996e149b6 --------- Signed-off-by: Logan Johnson Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz> --- desktop/src/app/useTrayMenu.ts | 4 +- .../agents/ui/PersonaShareRecipients.tsx | 4 +- .../src/features/channels/lib/memberUtils.ts | 4 +- .../channels/ui/AddMemberSearchResultRow.tsx | 6 +- .../channels/ui/ChannelMemberInviteCard.tsx | 10 ++-- .../features/channels/ui/MembersSidebar.tsx | 6 +- .../channels/ui/MembersSidebarMemberCard.tsx | 4 +- .../channels/ui/useChannelAgentSessions.ts | 4 +- .../community-members/ui/AddMemberDialog.tsx | 4 +- .../ui/CommunityMembersCard.tsx | 4 +- .../ui/CommunityMembersSettingsCard.tsx | 10 +++- .../ui/ConfirmRemoveDialog.tsx | 4 +- .../useCommunityJoinAlerts.test.mjs | 7 ++- .../forum/ui/ForumComposer.lifecycle.test.mjs | 3 + .../forum/ui/useForumMentionPreparation.ts | 4 +- .../features/home/ui/RecentNotesSection.tsx | 5 +- .../features/huddle/components/HuddleBar.tsx | 4 +- .../huddle/components/ParticipantList.tsx | 17 ++++-- .../features/huddle/lib/huddleChannelName.ts | 4 +- .../messages/lib/formatTimelineMessages.ts | 4 +- .../messages/lib/mentionCandidates.ts | 4 +- .../messages/lib/mentionClipboard.test.mjs | 34 ++++++++--- .../features/messages/lib/mentionClipboard.ts | 16 ++++- .../features/messages/lib/mentionRanking.ts | 4 +- .../lib/normalizeMentionClipboard.test.mjs | 12 ++++ .../messages/lib/threadPanel.test.mjs | 13 +++- .../src/features/messages/lib/threadPanel.ts | 9 ++- .../messages/lib/timelineMentionCopy.test.mjs | 27 +++++---- .../messages/ui/MentionAutocomplete.tsx | 4 +- .../messages/ui/MessageAgentAddressPrefix.tsx | 4 +- .../messages/ui/NewMessageResultRow.tsx | 4 +- .../messages/ui/TypingIndicatorRow.tsx | 4 +- .../messages/ui/useAgentAddressLockPicker.ts | 6 +- .../ui/useMentionSendFlow.test-support.mjs | 3 + .../messages/ui/useMentionSendFlow.ts | 4 +- .../messages/ui/useNewMessageRecipients.ts | 4 +- .../use-feed-desktop-notifications.ts | 4 +- .../features/profile/lib/identity.test.mjs | 52 +++++++++++++++- desktop/src/features/profile/lib/identity.ts | 8 +-- .../src/features/profile/ui/ProfileAvatar.tsx | 14 ++++- .../features/profile/ui/UserProfilePanel.tsx | 6 +- .../profile/ui/UserProfilePanelUtils.ts | 8 +-- .../profile/ui/UserProfilePopover.tsx | 6 +- .../ui/useProfileInteractionActions.ts | 4 +- .../projects/ui/IssueAssigneesRow.tsx | 8 +-- .../projects/ui/ProjectDetailFeedPanels.tsx | 4 +- .../ui/ProjectPullRequestInlineComments.tsx | 4 +- .../projects/ui/ProjectPullRequestsPanel.tsx | 4 +- .../projects/ui/PullRequestReviewersRow.tsx | 8 +-- .../features/pulse/ui/AgentActivityCard.tsx | 4 +- desktop/src/features/pulse/ui/NoteCard.tsx | 6 +- desktop/src/features/pulse/ui/PulseView.tsx | 4 +- .../src/features/search/ui/TopbarSearch.tsx | 4 +- .../settings/ui/ModerationQueueCard.tsx | 19 +++--- .../sidebar/ui/ChannelActivityPopover.tsx | 22 +++++-- .../workflows/ui/WorkflowAuthorPicker.tsx | 4 +- .../ui/workflowStepDescription.test.mjs | 33 +++++++++++ .../workflows/ui/workflowStepDescription.ts | 12 +++- .../ui/workflowTriggerDescription.test.mjs | 33 +++++++++++ .../ui/workflowTriggerDescription.ts | 8 ++- desktop/src/shared/lib/initials.test.mjs | 43 ++++++++++++++ desktop/src/shared/lib/initials.ts | 45 ++++++++++++++ .../src/shared/lib/mentionDisplay.test.mjs | 31 ++++++++-- desktop/src/shared/lib/mentionDisplay.ts | 33 +++++++++-- desktop/src/shared/ui/UserAvatar.tsx | 14 ++++- .../ui/markdown/useMessageLinkMetadata.ts | 4 +- .../shared/ui/markdownMentionDisplay.test.mjs | 4 +- .../e2e/channel-activity-popover.spec.ts | 56 ++++++++++++++++++ desktop/tests/e2e/channels.spec.ts | 40 ++++++++++++- .../tests/e2e/huddle-transcription.spec.ts | 55 ++++++++++++++++- desktop/tests/e2e/mention-recipients.spec.ts | 8 +-- desktop/tests/e2e/mentions.spec.ts | 9 ++- .../e2e/pubkey-display-screenshots.spec.ts | 9 +-- .../tests/e2e/workflow-local-controls.spec.ts | 20 ++++--- ...ate-variable-autocomplete-smoke-darwin.png | Bin 104623 -> 103906 bytes 75 files changed, 716 insertions(+), 198 deletions(-) diff --git a/desktop/src/app/useTrayMenu.ts b/desktop/src/app/useTrayMenu.ts index 355c8e5d4f2..04ce9256a30 100644 --- a/desktop/src/app/useTrayMenu.ts +++ b/desktop/src/app/useTrayMenu.ts @@ -10,7 +10,7 @@ import { useManagedAgentsQuery, useRelayAgentsQuery, } from "@/features/agents/hooks"; -import { normalizePubkey, truncatePubkey } from "@/shared/lib/pubkey"; +import { normalizePubkey, truncateNpub } from "@/shared/lib/pubkey"; import { useNow } from "@/shared/lib/useNow"; import { formatElapsed } from "@/features/agents/ui/agentSessionUtils"; import type { Channel } from "@/shared/api/types"; @@ -72,7 +72,7 @@ export function useTrayMenu({ activityId: `${channelTurn.channelId}:${normalizePubkey(pubkey)}`, agentName: agentNames.get(normalizePubkey(pubkey)) ?? - `Agent ${truncatePubkey(pubkey)}`, + `Agent ${truncateNpub(pubkey)}`, channelId: channelTurn.channelId, channelName: channelNames.get(channelTurn.channelId) ?? "Unknown channel", diff --git a/desktop/src/features/agents/ui/PersonaShareRecipients.tsx b/desktop/src/features/agents/ui/PersonaShareRecipients.tsx index 7db7c32bfa5..89588b57567 100644 --- a/desktop/src/features/agents/ui/PersonaShareRecipients.tsx +++ b/desktop/src/features/agents/ui/PersonaShareRecipients.tsx @@ -15,7 +15,7 @@ import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar"; import { SelectedRecipientChip } from "@/features/profile/ui/SelectedRecipientChip"; import { useIdentityQuery } from "@/shared/api/hooks"; import type { UserSearchResult } from "@/shared/api/types"; -import { normalizePubkey, truncatePubkey } from "@/shared/lib/pubkey"; +import { normalizePubkey, truncateNpub } from "@/shared/lib/pubkey"; import { Popover, PopoverAnchor, PopoverContent } from "@/shared/ui/popover"; import { Skeleton } from "@/shared/ui/skeleton"; @@ -25,7 +25,7 @@ export function formatShareRecipientName(user: UserSearchResult) { return ( user.displayName?.trim() || user.nip05Handle?.trim() || - truncatePubkey(user.pubkey) + truncateNpub(user.pubkey) ); } diff --git a/desktop/src/features/channels/lib/memberUtils.ts b/desktop/src/features/channels/lib/memberUtils.ts index d921457e000..10ad4b7ff0a 100644 --- a/desktop/src/features/channels/lib/memberUtils.ts +++ b/desktop/src/features/channels/lib/memberUtils.ts @@ -1,5 +1,5 @@ import type { ChannelMember } from "@/shared/api/types"; -import { truncatePubkey } from "@/shared/lib/pubkey"; +import { truncateNpub } from "@/shared/lib/pubkey"; export const roleOrder: Record = { owner: 0, @@ -17,7 +17,7 @@ export function formatMemberName( return "You"; } - return member.displayName ?? truncatePubkey(member.pubkey); + return member.displayName ?? truncateNpub(member.pubkey); } export function compareMembersByRole( diff --git a/desktop/src/features/channels/ui/AddMemberSearchResultRow.tsx b/desktop/src/features/channels/ui/AddMemberSearchResultRow.tsx index 7edeb842c8b..9e7dda9c7fe 100644 --- a/desktop/src/features/channels/ui/AddMemberSearchResultRow.tsx +++ b/desktop/src/features/channels/ui/AddMemberSearchResultRow.tsx @@ -3,7 +3,7 @@ import { Bot } from "lucide-react"; import type { UserSearchResult } from "@/shared/api/types"; import { Button } from "@/shared/ui/button"; import { cn } from "@/shared/lib/cn"; -import { truncatePubkey } from "@/shared/lib/pubkey"; +import { truncateNpub } from "@/shared/lib/pubkey"; import { UserAvatar } from "@/shared/ui/UserAvatar"; const MEMBER_ROW_INSET_DIVIDER_CLASS = @@ -13,7 +13,7 @@ export function formatAddCandidateName(user: UserSearchResult) { return ( user.displayName?.trim() || user.nip05Handle?.trim() || - truncatePubkey(user.pubkey) + truncateNpub(user.pubkey) ); } @@ -67,7 +67,7 @@ export function AddMemberSearchResultRow({ />
- {truncatePubkey(user.pubkey)} + {truncateNpub(user.pubkey)} {ownerLabel ? ( diff --git a/desktop/src/features/channels/ui/ChannelMemberInviteCard.tsx b/desktop/src/features/channels/ui/ChannelMemberInviteCard.tsx index 90fb5bfaa9b..335e379ce8e 100644 --- a/desktop/src/features/channels/ui/ChannelMemberInviteCard.tsx +++ b/desktop/src/features/channels/ui/ChannelMemberInviteCard.tsx @@ -2,7 +2,7 @@ import { Search, UserPlus, X } from "lucide-react"; import * as React from "react"; import { parsePubkeyInput } from "@/shared/lib/nostrUtils"; -import { truncatePubkey } from "@/shared/lib/pubkey"; +import { truncateNpub } from "@/shared/lib/pubkey"; import { PubKey } from "@/shared/ui/PubKey"; import { useIsArchivedPredicate } from "@/features/identity-archive/hooks"; import { useUserSearchQuery } from "@/features/profile/hooks"; @@ -19,7 +19,7 @@ function formatSearchUserName(user: UserSearchResult) { return ( user.displayName?.trim() || user.nip05Handle?.trim() || - truncatePubkey(user.pubkey) + truncateNpub(user.pubkey) ); } @@ -270,11 +270,11 @@ export function ChannelMemberInviteCard({

- {truncatePubkey(directInvitee.pubkey)} + {truncateNpub(directInvitee.pubkey)}

by public key @@ -370,7 +370,7 @@ export function ChannelMemberInviteCard({
{submissionErrors.map((error) => (

- {truncatePubkey(error.pubkey)}: {error.error} + {truncateNpub(error.pubkey)}: {error.error}

))}
diff --git a/desktop/src/features/channels/ui/MembersSidebar.tsx b/desktop/src/features/channels/ui/MembersSidebar.tsx index 1b0b7687c1b..3c9afbfaeca 100644 --- a/desktop/src/features/channels/ui/MembersSidebar.tsx +++ b/desktop/src/features/channels/ui/MembersSidebar.tsx @@ -49,7 +49,7 @@ import { } from "@/shared/ui/dialog"; import { useProfilePanel } from "@/shared/context/ProfilePanelContext"; import { useFeedbackToasts } from "@/shared/hooks/useToastEffect"; -import { normalizePubkey, truncatePubkey } from "@/shared/lib/pubkey"; +import { normalizePubkey, truncateNpub } from "@/shared/lib/pubkey"; import { MODAL_SEARCH_INPUT_CLASS, MODAL_SEARCH_SHELL_CLASS, @@ -641,7 +641,7 @@ export function MembersSidebar({ managedAgentRuntime={managedAgentRuntime} member={member} memberIsBot={memberIsBot} - memberAvatarLabel={member.displayName ?? truncatePubkey(member.pubkey)} + memberAvatarLabel={member.displayName ?? truncateNpub(member.pubkey)} memberLabel={formatMemberName(member, currentPubkey)} moderationState={moderationStateByPubkey.get( normalizePubkey(member.pubkey), @@ -886,7 +886,7 @@ export function MembersSidebar({
{inviteSubmissionErrors.map((error) => (

- {truncatePubkey(error.pubkey)}: {error.error} + {truncateNpub(error.pubkey)}: {error.error}

))}
diff --git a/desktop/src/features/channels/ui/MembersSidebarMemberCard.tsx b/desktop/src/features/channels/ui/MembersSidebarMemberCard.tsx index 9f187388645..6cc3da1d9c2 100644 --- a/desktop/src/features/channels/ui/MembersSidebarMemberCard.tsx +++ b/desktop/src/features/channels/ui/MembersSidebarMemberCard.tsx @@ -27,7 +27,7 @@ import { MANAGED_AGENT_PAIR_ACTION_LABELS, type ManagedAgentPairAction, } from "@/features/agents/managedAgentRuntimeStatus"; -import { truncatePubkey } from "@/shared/lib/pubkey"; +import { truncateNpub } from "@/shared/lib/pubkey"; import type { ChannelMember, ManagedAgent, @@ -195,7 +195,7 @@ export function MembersSidebarMemberCard({
- {truncatePubkey(member.pubkey)} + {truncateNpub(member.pubkey)} diff --git a/desktop/src/features/channels/ui/useChannelAgentSessions.ts b/desktop/src/features/channels/ui/useChannelAgentSessions.ts index e12c1bc7768..d5cd1824e0d 100644 --- a/desktop/src/features/channels/ui/useChannelAgentSessions.ts +++ b/desktop/src/features/channels/ui/useChannelAgentSessions.ts @@ -8,7 +8,7 @@ import type { RelayAgent, } from "@/shared/api/types"; import { usePanelReturnTarget } from "@/shared/hooks/usePanelReturnTarget"; -import { normalizePubkey, truncatePubkey } from "@/shared/lib/pubkey"; +import { normalizePubkey, truncateNpub } from "@/shared/lib/pubkey"; import { channelBotMemberPubkeySet, channelMemberPubkeySet, @@ -100,7 +100,7 @@ export function buildChannelAgentSessionCandidates({ byPubkey.set(key, { pubkey: member.pubkey, - name: member.displayName ?? truncatePubkey(member.pubkey), + name: member.displayName ?? truncateNpub(member.pubkey), status: "deployed", agentSource: "member-bot", canInterruptTurn: false, diff --git a/desktop/src/features/community-members/ui/AddMemberDialog.tsx b/desktop/src/features/community-members/ui/AddMemberDialog.tsx index 5464111752f..c2a26b5df3b 100644 --- a/desktop/src/features/community-members/ui/AddMemberDialog.tsx +++ b/desktop/src/features/community-members/ui/AddMemberDialog.tsx @@ -13,7 +13,7 @@ import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar"; import { SelectedRecipientChip } from "@/features/profile/ui/SelectedRecipientChip"; import type { RelayMemberRole, UserSearchResult } from "@/shared/api/types"; import { parsePubkeyInput } from "@/shared/lib/nostrUtils"; -import { truncatePubkey } from "@/shared/lib/pubkey"; +import { truncateNpub } from "@/shared/lib/pubkey"; import { Button } from "@/shared/ui/button"; import { Dialog, @@ -50,7 +50,7 @@ function formatSearchUserName(user: UserSearchResult) { return ( user.displayName?.trim() || user.nip05Handle?.trim() || - truncatePubkey(user.pubkey) + truncateNpub(user.pubkey) ); } diff --git a/desktop/src/features/community-members/ui/CommunityMembersCard.tsx b/desktop/src/features/community-members/ui/CommunityMembersCard.tsx index fdc75f3d183..7693212711b 100644 --- a/desktop/src/features/community-members/ui/CommunityMembersCard.tsx +++ b/desktop/src/features/community-members/ui/CommunityMembersCard.tsx @@ -3,7 +3,7 @@ import { MoreHorizontal, Plus, Shield, ShieldCheck, User } from "lucide-react"; import { toast } from "sonner"; import { useUsersBatchQuery } from "@/features/profile/hooks"; -import { truncatePubkey } from "@/shared/lib/pubkey"; +import { truncateNpub } from "@/shared/lib/pubkey"; import { PubKey } from "@/shared/ui/PubKey"; import { useChangeRelayMemberRoleMutation, @@ -101,7 +101,7 @@ function MemberRow({
- {displayName || truncatePubkey(member.pubkey)} + {displayName || truncateNpub(member.pubkey)} {isSelf ? ( diff --git a/desktop/src/features/community-members/ui/CommunityMembersSettingsCard.tsx b/desktop/src/features/community-members/ui/CommunityMembersSettingsCard.tsx index 29338ff5672..dad1f7ff6bc 100644 --- a/desktop/src/features/community-members/ui/CommunityMembersSettingsCard.tsx +++ b/desktop/src/features/community-members/ui/CommunityMembersSettingsCard.tsx @@ -19,7 +19,11 @@ import type { RelayMemberRole, UserProfileSummary, } from "@/shared/api/types"; -import { normalizePubkey, truncatePubkey } from "@/shared/lib/pubkey"; +import { + normalizePubkey, + truncateNpub, + UNAVAILABLE_KEY_LABEL, +} from "@/shared/lib/pubkey"; import { Button } from "@/shared/ui/button"; import { DropdownMenu, @@ -65,7 +69,7 @@ function HoverMemberIdentity({ displayName: string; pubkey: string; }) { - const npub = npubFromPubkey(pubkey) ?? pubkey; + const npub = npubFromPubkey(pubkey) ?? UNAVAILABLE_KEY_LABEL; return ( - {truncatePubkey(npub)} + {truncateNpub(npub)} ); diff --git a/desktop/src/features/community-members/ui/ConfirmRemoveDialog.tsx b/desktop/src/features/community-members/ui/ConfirmRemoveDialog.tsx index 64735a5d567..087ef952fff 100644 --- a/desktop/src/features/community-members/ui/ConfirmRemoveDialog.tsx +++ b/desktop/src/features/community-members/ui/ConfirmRemoveDialog.tsx @@ -1,6 +1,6 @@ import { toast } from "sonner"; -import { truncatePubkey } from "@/shared/lib/pubkey"; +import { truncateNpub } from "@/shared/lib/pubkey"; import { PubKey } from "@/shared/ui/PubKey"; import { useRemoveRelayMemberMutation } from "@/features/community-members/hooks"; import type { RelayMember } from "@/shared/api/types"; @@ -25,7 +25,7 @@ export function ConfirmRemoveDialog({ onOpenChange: (open: boolean) => void; }) { const removeMutation = useRemoveRelayMemberMutation(); - const label = displayName || (member ? truncatePubkey(member.pubkey) : ""); + const label = displayName || (member ? truncateNpub(member.pubkey) : ""); function handleOpenChange(next: boolean) { if (!next) { diff --git a/desktop/src/features/community-members/useCommunityJoinAlerts.test.mjs b/desktop/src/features/community-members/useCommunityJoinAlerts.test.mjs index 7b34b901619..52d7a0a114d 100644 --- a/desktop/src/features/community-members/useCommunityJoinAlerts.test.mjs +++ b/desktop/src/features/community-members/useCommunityJoinAlerts.test.mjs @@ -250,6 +250,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { useCommunityJoinAlerts } from "@/features/community-members/useCommunityJoinAlerts.ts"; import { joinAlertStorageKey } from "@/features/community-members/lib/joinAlerts.ts"; +import { truncateNpub } from "@/shared/lib/pubkey.ts"; import { relayClient } from "@/shared/api/relayClient.ts"; import { CommunitiesProvider } from "@/features/communities/useCommunities.tsx"; import { useCommunities } from "@/features/communities/useCommunities.tsx"; @@ -1715,7 +1716,7 @@ describe("useCommunityJoinAlerts — mounted subscription behaviour", () => { "a frame that predates the demotion must not re-open disclosure", ); assert.ok( - !notifications.some((entry) => entry.body?.includes(BOB.slice(0, 8))), + !notifications.some((entry) => entry.body?.includes(truncateNpub(BOB))), "the demoted viewer must never learn the new member's identity", ); assert.equal( @@ -1982,11 +1983,11 @@ describe("useCommunityJoinAlerts — mounted subscription behaviour", () => { "a community switch clears the latch: the feature recovers without a reload", ); assert.ok( - notifications.some((entry) => entry.body?.includes(BOB.slice(0, 8))), + notifications.some((entry) => entry.body?.includes(truncateNpub(BOB))), "the join suppressed by the latch is re-announced, not lost", ); assert.ok( - notifications.some((entry) => entry.body?.includes(CAROL.slice(0, 8))), + notifications.some((entry) => entry.body?.includes(truncateNpub(CAROL))), "and the new join lands too", ); diff --git a/desktop/src/features/forum/ui/ForumComposer.lifecycle.test.mjs b/desktop/src/features/forum/ui/ForumComposer.lifecycle.test.mjs index 65e2912ce70..e827635d145 100644 --- a/desktop/src/features/forum/ui/ForumComposer.lifecycle.test.mjs +++ b/desktop/src/features/forum/ui/ForumComposer.lifecycle.test.mjs @@ -86,6 +86,9 @@ async function setup(options = {}) { "@/shared/lib/pubkey": { normalizePubkey: (s) => s.toLowerCase(), truncatePubkey: (s) => s, + // Compact-identity seam stubbed alongside its sibling: this suite + // renders display names, never key-form labels. + truncateNpub: (s) => s, }, "@/features/channels/hooks": { useAddChannelMembersMutation: () => ({ diff --git a/desktop/src/features/forum/ui/useForumMentionPreparation.ts b/desktop/src/features/forum/ui/useForumMentionPreparation.ts index 09a7392a829..3289a5e8068 100644 --- a/desktop/src/features/forum/ui/useForumMentionPreparation.ts +++ b/desktop/src/features/forum/ui/useForumMentionPreparation.ts @@ -4,7 +4,7 @@ import { PRIVATE_CHANNEL_ADD_DENIED_MESSAGE } from "@/features/channels/lib/chan import { useCanAddChannelMembers } from "@/features/channels/useCanAddChannelMembers"; import type { UseMentionsResult } from "@/features/messages/lib/useMentions"; import type { ChannelType } from "@/shared/api/types"; -import { normalizePubkey, truncatePubkey } from "@/shared/lib/pubkey"; +import { normalizePubkey, truncateNpub } from "@/shared/lib/pubkey"; type PendingInvite = { channelId: string; @@ -176,7 +176,7 @@ export function useForumMentionPreparation( isInvitePending: isInviting, names: (pending?.nonMemberPubkeys ?? []).map( (pubkey) => - mentions.getMentionDisplayName(pubkey) ?? truncatePubkey(pubkey), + mentions.getMentionDisplayName(pubkey) ?? truncateNpub(pubkey), ), onDismiss: dismiss, onInvite: () => void invite(), diff --git a/desktop/src/features/home/ui/RecentNotesSection.tsx b/desktop/src/features/home/ui/RecentNotesSection.tsx index e0837b219df..3bff5c015a7 100644 --- a/desktop/src/features/home/ui/RecentNotesSection.tsx +++ b/desktop/src/features/home/ui/RecentNotesSection.tsx @@ -4,7 +4,7 @@ import type { UserNote } from "@/shared/api/socialTypes"; import type { UserProfileSummary } from "@/shared/api/types"; import { Markdown } from "@/shared/ui/markdown"; import { UserAvatar } from "@/shared/ui/UserAvatar"; -import { truncatePubkey } from "@/shared/lib/pubkey"; +import { truncateNpub } from "@/shared/lib/pubkey"; type RecentNotesSectionProps = { notes: UserNote[]; @@ -52,8 +52,7 @@ export function RecentNotesSection({
{notes.slice(0, 5).map((note) => { const profile = profiles[note.pubkey.toLowerCase()]; - const displayName = - profile?.displayName ?? truncatePubkey(note.pubkey); + const displayName = profile?.displayName ?? truncateNpub(note.pubkey); const isAgent = agentPubkeys.has(note.pubkey); return ( diff --git a/desktop/src/features/huddle/components/HuddleBar.tsx b/desktop/src/features/huddle/components/HuddleBar.tsx index d5a0423cf7c..4c01ca68a15 100644 --- a/desktop/src/features/huddle/components/HuddleBar.tsx +++ b/desktop/src/features/huddle/components/HuddleBar.tsx @@ -33,7 +33,7 @@ import { AddAgentDialog, type AgentAddResult } from "./AddAgentDialog"; import type { HuddleAgentVoiceSettings } from "./AgentVoiceMenu"; import { MicControls, SpeakerControls } from "./MicControls"; import { HuddleParticipantsControl } from "./ParticipantList"; -import { truncatePubkey } from "@/shared/lib/pubkey"; +import { truncateNpub } from "@/shared/lib/pubkey"; // Mirrors HuddleState in src-tauri/src/huddle/mod.rs. type HuddleState = { @@ -98,7 +98,7 @@ function clampReactionName(name: string): string { } function fallbackNameForPubkey(pubkey?: string | null): string { - return pubkey ? `Participant ${truncatePubkey(pubkey)}` : "Someone"; + return pubkey ? `Participant ${truncateNpub(pubkey)}` : "Someone"; } function parseHuddleReactionEvent(event: RelayEvent) { diff --git a/desktop/src/features/huddle/components/ParticipantList.tsx b/desktop/src/features/huddle/components/ParticipantList.tsx index 04b6a7453bf..b7d8d5888e9 100644 --- a/desktop/src/features/huddle/components/ParticipantList.tsx +++ b/desktop/src/features/huddle/components/ParticipantList.tsx @@ -11,7 +11,7 @@ import { UserProfilePopover } from "@/features/profile/ui/UserProfilePopover"; import type { VoiceRegistryEntry } from "@/features/settings/ui/voiceSettingsLogic"; import { invokeTauri } from "@/shared/api/tauri"; import { cn } from "@/shared/lib/cn"; -import { truncatePubkey } from "@/shared/lib/pubkey"; +import { truncateNpub } from "@/shared/lib/pubkey"; import { Button } from "@/shared/ui/button"; import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip"; @@ -49,6 +49,13 @@ const MAX_VISIBLE_PARTICIPANTS = 9; type ParticipantIdentity = { avatarUrl: string | null; displayName: string; + /** + * Label the avatar derives initials from: the authored name when one + * exists, otherwise the unprefixed compact key. `displayName` is the + * visible "Participant npub1…" fallback, whose word initials would + * collapse every unnamed participant onto "PN"/"AN". + */ + initialsLabel: string; isActive: boolean; isAgent: boolean; pubkey: string; @@ -110,10 +117,10 @@ function buildParticipantIdentities({ const profile = profiles[normalizedPubkey]; const isAgent = agentSet.has(normalizedPubkey); const agent = agentNames.get(normalizedPubkey); + const authoredName = profile?.displayName?.trim() || agent?.name?.trim(); + const keyLabel = truncateNpub(pubkey); const displayName = - profile?.displayName?.trim() || - agent?.name?.trim() || - `${isAgent ? "Agent" : "Participant"} ${truncatePubkey(pubkey)}`; + authoredName || `${isAgent ? "Agent" : "Participant"} ${keyLabel}`; const speakerLevel = normalizedSpeakerLevels.get(normalizedPubkey) ?? (activeSpeakerSet.has(normalizedPubkey) ? 0.55 : 0); @@ -121,6 +128,7 @@ function buildParticipantIdentities({ return { avatarUrl: profile?.avatarUrl ?? agent?.avatarUrl ?? null, displayName, + initialsLabel: authoredName || keyLabel, isActive: activeSpeakerSet.has(normalizedPubkey) || speakerLevel > 0.04, isAgent, pubkey, @@ -453,6 +461,7 @@ function ParticipantAvatar({ diff --git a/desktop/src/features/huddle/lib/huddleChannelName.ts b/desktop/src/features/huddle/lib/huddleChannelName.ts index c9f6dff2835..bd01c0d3e8a 100644 --- a/desktop/src/features/huddle/lib/huddleChannelName.ts +++ b/desktop/src/features/huddle/lib/huddleChannelName.ts @@ -1,5 +1,5 @@ import type { Channel, ChannelMember } from "@/shared/api/types"; -import { normalizePubkey, truncatePubkey } from "@/shared/lib/pubkey"; +import { normalizePubkey, truncateNpub } from "@/shared/lib/pubkey"; type BuildHuddleChannelNameInput = { channel: Channel; @@ -33,7 +33,7 @@ function channelParticipantLabel( return firstName(fallbackName); } - return truncatePubkey(pubkey); + return truncateNpub(pubkey); } export function buildHuddleChannelName({ diff --git a/desktop/src/features/messages/lib/formatTimelineMessages.ts b/desktop/src/features/messages/lib/formatTimelineMessages.ts index a24da67f700..b35e8140481 100644 --- a/desktop/src/features/messages/lib/formatTimelineMessages.ts +++ b/desktop/src/features/messages/lib/formatTimelineMessages.ts @@ -45,7 +45,7 @@ import { formatTime } from "@/features/messages/lib/dateFormatters"; // Pure overlay helper lives in a sibling .mjs so node:test (no TS loader) // can exercise the exact same source the renderer uses. import { applyEditTagOverlay } from "@/features/messages/lib/applyEditTagOverlay.mjs"; -import { truncatePubkey } from "@/shared/lib/pubkey"; +import { truncateNpub } from "@/shared/lib/pubkey"; const HEX_RE = /^[0-9a-f]+$/i; @@ -387,7 +387,7 @@ export function formatTimelineMessages( ? "You" : profile?.displayName?.trim() || profile?.nip05Handle?.trim() || - truncatePubkey(actorPubkey); + truncateNpub(actorPubkey); existing.users.push({ pubkey: actorPubkey, displayName, diff --git a/desktop/src/features/messages/lib/mentionCandidates.ts b/desktop/src/features/messages/lib/mentionCandidates.ts index 2ee46b01415..9c714015b24 100644 --- a/desktop/src/features/messages/lib/mentionCandidates.ts +++ b/desktop/src/features/messages/lib/mentionCandidates.ts @@ -5,7 +5,7 @@ import type { ChannelRole, UserSearchResult, } from "@/shared/api/types"; -import { truncatePubkey } from "@/shared/lib/pubkey"; +import { truncateNpub } from "@/shared/lib/pubkey"; export function formatSearchUserDisplayName(user: UserSearchResult) { return user.displayName?.trim() || user.nip05Handle?.trim() || null; @@ -54,7 +54,7 @@ export type MentionCandidate = { export function mentionCandidateLabel(candidate: MentionCandidate) { return ( candidate.displayName ?? - (candidate.pubkey ? truncatePubkey(candidate.pubkey) : "agent") + (candidate.pubkey ? truncateNpub(candidate.pubkey) : "agent") ); } diff --git a/desktop/src/features/messages/lib/mentionClipboard.test.mjs b/desktop/src/features/messages/lib/mentionClipboard.test.mjs index 39f16c96791..39db21be0c3 100644 --- a/desktop/src/features/messages/lib/mentionClipboard.test.mjs +++ b/desktop/src/features/messages/lib/mentionClipboard.test.mjs @@ -444,20 +444,38 @@ test("does not treat an uncapped label's ellipsis as a truncation", () => { test("whole compact mention labels are restored only for their declared exact key", () => { const key = `150b20bd${"a".repeat(52)}15dc`; const label = `Scout (${key}) 2`; - const compact = "Scout (150b20bd…15dc) 2"; - assert.equal(matchChipTextToLabel(compact, label, "@", key), "truncated"); + // The npub compact a chip renders today, and the hex compact a chip copied + // before keys displayed as npub still carries: a whole chip in either + // form re-binds; nothing else does. + const npubCompact = "Scout (npub1z59…zwkg) 2"; + const legacyCompact = "Scout (150b20bd…15dc) 2"; + + for (const compact of [npubCompact, legacyCompact]) { + assert.equal(matchChipTextToLabel(compact, label, "@", key), "truncated"); + assert.equal( + matchChipTextToLabel(`@${compact}`, label, "@", key), + "truncated", + ); + } + assert.equal(matchChipTextToLabel(npubCompact, label, "@"), "fragment"); + assert.equal( + matchChipTextToLabel(npubCompact, label, "@", "b".repeat(64)), + "fragment", + ); + assert.equal(matchChipTextToLabel(npubCompact, label, "#", key), "fragment"); + // A different key's compact — npub or legacy hex — is text this record + // never declared: tampered text never gains the identity's binding. assert.equal( - matchChipTextToLabel(`@${compact}`, label, "@", key), - "truncated", + matchChipTextToLabel("Scout (npub1m6k…zuz0) 2", label, "@", key), + "fragment", ); - assert.equal(matchChipTextToLabel(compact, label, "@"), "fragment"); assert.equal( - matchChipTextToLabel(compact, label, "@", "b".repeat(64)), + matchChipTextToLabel("Scout (deadbeef…beef) 2", label, "@", key), "fragment", ); - assert.equal(matchChipTextToLabel(compact, label, "#", key), "fragment"); + // A dropped collision suffix is a partial chip, not a tolerated form. assert.equal( - matchChipTextToLabel("Scout (150b20bd…15dc)", label, "@", key), + matchChipTextToLabel("Scout (npub1z59…zwkg)", label, "@", key), "fragment", ); assert.equal(matchChipTextToLabel("Scout", label, "@", key), "fragment"); diff --git a/desktop/src/features/messages/lib/mentionClipboard.ts b/desktop/src/features/messages/lib/mentionClipboard.ts index 8886bf8c29d..8b093610a49 100644 --- a/desktop/src/features/messages/lib/mentionClipboard.ts +++ b/desktop/src/features/messages/lib/mentionClipboard.ts @@ -1,4 +1,7 @@ -import { formatMentionDisplayLabel } from "@/shared/lib/mentionDisplay"; +import { + formatLegacyMentionDisplayLabel, + formatMentionDisplayLabel, +} from "@/shared/lib/mentionDisplay"; import { truncateInlineChipLabel } from "@/shared/ui/mentionChip"; import { getMentionOffsets } from "./hasMention"; @@ -96,6 +99,17 @@ export function matchChipTextToLabel( if (compact !== label && matches(canonicalMentionLabel(compact))) { return "truncated"; } + // A chip copied before keys displayed as npub carries the hex-truncated + // key in its text. Accept that prior *display* form — derived from the + // same label and pubkey this record declares, never from the pasted text + // itself — so an old whole-chip copy still re-binds instead of losing its + // identity. The two forms are disjoint (a hex truncation cannot contain + // the `n` an npub starts with), so this cannot reclassify any new chip. + const legacy = + sigil === "@" ? formatLegacyMentionDisplayLabel(label, pubkey) : label; + if (legacy !== label && matches(canonicalMentionLabel(legacy))) { + return "truncated"; + } return "fragment"; } diff --git a/desktop/src/features/messages/lib/mentionRanking.ts b/desktop/src/features/messages/lib/mentionRanking.ts index 3df5bba0b0a..deef2d3ebff 100644 --- a/desktop/src/features/messages/lib/mentionRanking.ts +++ b/desktop/src/features/messages/lib/mentionRanking.ts @@ -1,4 +1,4 @@ -import { normalizePubkey, truncatePubkey } from "@/shared/lib/pubkey"; +import { normalizePubkey, truncateNpub } from "@/shared/lib/pubkey"; export type MentionCandidateForRanking = { displayName: string | null; @@ -118,7 +118,7 @@ export function rankMentionCandidates( : ""; const label = candidate.displayName ?? - (candidate.pubkey ? truncatePubkey(candidate.pubkey) : "agent"); + (candidate.pubkey ? truncateNpub(candidate.pubkey) : "agent"); const groupRank = getMentionCandidateGroupRank( candidate, activePersonaIds, diff --git a/desktop/src/features/messages/lib/normalizeMentionClipboard.test.mjs b/desktop/src/features/messages/lib/normalizeMentionClipboard.test.mjs index 3e0563ab96c..a42a3a9aecb 100644 --- a/desktop/src/features/messages/lib/normalizeMentionClipboard.test.mjs +++ b/desktop/src/features/messages/lib/normalizeMentionClipboard.test.mjs @@ -272,10 +272,22 @@ test("compact mention paste expands only complete bound labels", () => { const html = (text) => `${text}`; + // Today's chip text renders the key as npub… + assert.equal( + normalizeMentionClipboardContent(html("Scout (npub1z59…zwkg) 2")).text, + `@${label}`, + ); + // …and a whole chip copied before that switch still carries the hex + // compact, which must keep expanding to the exact declared identity. assert.equal( normalizeMentionClipboardContent(html("Scout (150b20bd…15dc) 2")).text, `@${label}`, ); + // A dropped collision suffix — in either form — is a partial chip. + assert.equal( + normalizeMentionClipboardContent(html("Scout (npub1z59…zwkg)")).text, + "Scout (npub1z59…zwkg)", + ); assert.equal( normalizeMentionClipboardContent(html("Scout (150b20bd…15dc)")).text, "Scout (150b20bd…15dc)", diff --git a/desktop/src/features/messages/lib/threadPanel.test.mjs b/desktop/src/features/messages/lib/threadPanel.test.mjs index 8981d363803..630ebbc6fea 100644 --- a/desktop/src/features/messages/lib/threadPanel.test.mjs +++ b/desktop/src/features/messages/lib/threadPanel.test.mjs @@ -607,6 +607,13 @@ test("buildThreadPanelDataFromIndex matches direct panel data", () => { test("buildMainTimelineEntries renders a relay-only thread summary", () => { const root = message({ id: "root", createdAt: 1 }); + // Realistic 64-hex relay participant keys: a cold/relay-only summary has no + // client messages to derive labels from, so an unnamed participant must + // fall back to the compact npub (`truncateNpub`, the same form the + // client-assembled path derives via `resolveUserLabel`) — never the raw + // hex identity. `participant.author` is what `MessageThreadSummaryRow` + // binds to `UserAvatar`'s visible/accessible `displayName` label. + const bob = "deadbeef".repeat(8); // → npub1m6k…zuz0 const summaries = new Map([ [ "root", @@ -614,7 +621,7 @@ test("buildMainTimelineEntries renders a relay-only thread summary", () => { replyCount: 2, descendantCount: 4, lastReplyAt: 9, - participantPubkeys: ["alice", "bob"], + participantPubkeys: ["alice", bob], }, ], ]); @@ -631,10 +638,10 @@ test("buildMainTimelineEntries renders a relay-only thread summary", () => { threadHeadId: "root", replyCount: 4, lastReplyAt: 9, - // Relay returns participants most-recent-first (["alice", "bob"]); the + // Relay returns participants most-recent-first (["alice", bob]); the // facepile renders them oldest-first so the last replier lands rightmost. participants: [ - { id: "bob", author: "bob", avatarUrl: null }, + { id: bob, author: "npub1m6k…zuz0", avatarUrl: null }, { id: "alice", author: "Alice", avatarUrl: "alice.png" }, ], }); diff --git a/desktop/src/features/messages/lib/threadPanel.ts b/desktop/src/features/messages/lib/threadPanel.ts index 3bc5ee39125..b85b57d1ac9 100644 --- a/desktop/src/features/messages/lib/threadPanel.ts +++ b/desktop/src/features/messages/lib/threadPanel.ts @@ -2,6 +2,7 @@ import type { TimelineMessage } from "@/features/messages/types"; import type { ChannelWindowThreadSummary } from "@/features/messages/lib/channelWindowStore"; import type { UserProfileLookup } from "@/features/profile/lib/identity"; import { isBroadcastReply } from "@/features/messages/lib/threading"; +import { truncateNpub } from "@/shared/lib/pubkey"; import { KIND_HUDDLE_STARTED } from "@/shared/constants/kinds"; type ThreadPanelData = { @@ -408,7 +409,13 @@ function buildRelayThreadSummary( .reverse() .map((pubkey) => ({ id: pubkey, - author: profiles?.[pubkey.toLowerCase()]?.displayName ?? pubkey, + // Unnamed participants fall back to the compact npub — the same label + // the client-assembled path derives via `resolveUserLabel` — so a + // cold/relay-only facepile never surfaces raw hex. This `author` is + // what `MessageThreadSummaryRow` binds to `UserAvatar`'s + // `displayName` (the visible/accessible avatar label). + author: + profiles?.[pubkey.toLowerCase()]?.displayName ?? truncateNpub(pubkey), avatarUrl: profiles?.[pubkey.toLowerCase()]?.avatarUrl ?? null, ...(profiles?.[pubkey.toLowerCase()]?.isAgent === true ? { isAgent: true } diff --git a/desktop/src/features/messages/lib/timelineMentionCopy.test.mjs b/desktop/src/features/messages/lib/timelineMentionCopy.test.mjs index 4f8c05842aa..ceada037c0d 100644 --- a/desktop/src/features/messages/lib/timelineMentionCopy.test.mjs +++ b/desktop/src/features/messages/lib/timelineMentionCopy.test.mjs @@ -190,15 +190,20 @@ test("copy inlines a blockified chip but preserves its block ancestor", () => { test("copy expands compact key text but preserves the exact label and identity", () => { const label = `Scout (${JOHN_SMITH_PUBKEY}) 2`; - const flavors = copyRenderedBody( - `` + - 'Scout (7c7c7c7c…7c7c) 2', - ); - assert.ok(flavors); - assert.ok(flavors.html.includes(`@${label}`)); - assert.ok( - flavors.html.includes(`data-mention-pubkey="${JOHN_SMITH_PUBKEY}"`), - ); - assert.ok(!flavors.html.includes("…")); + // Chips render the npub compact today; a chip copied before that switch + // still carries the hex compact. A whole chip in either form expands to + // the declared identity. + for (const compact of ["npub1037…08vj", "7c7c7c7c…7c7c"]) { + const flavors = copyRenderedBody( + `` + + `Scout (${compact}) 2`, + ); + assert.ok(flavors); + assert.ok(flavors.html.includes(`@${label}`)); + assert.ok( + flavors.html.includes(`data-mention-pubkey="${JOHN_SMITH_PUBKEY}"`), + ); + assert.ok(!flavors.html.includes("…")); + } }); diff --git a/desktop/src/features/messages/ui/MentionAutocomplete.tsx b/desktop/src/features/messages/ui/MentionAutocomplete.tsx index a380ac83a09..65ae331d594 100644 --- a/desktop/src/features/messages/ui/MentionAutocomplete.tsx +++ b/desktop/src/features/messages/ui/MentionAutocomplete.tsx @@ -15,7 +15,7 @@ import { Switch } from "@/shared/ui/switch"; import { Toggle } from "@/shared/ui/toggle"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip"; import { safeNpub } from "@/shared/lib/nostrUtils"; -import { truncatePubkey } from "@/shared/lib/pubkey"; +import { truncateNpub } from "@/shared/lib/pubkey"; import { getPlatformKeysById } from "@/shared/lib/keyboard-shortcuts"; export type MentionSuggestion = { @@ -387,7 +387,7 @@ export const MentionAutocomplete = React.memo(function MentionAutocomplete({ data-testid="mention-collision-npub" title={collisionNpub} > - {truncatePubkey(collisionNpub)} + {truncateNpub(collisionNpub)} ) : null} diff --git a/desktop/src/features/messages/ui/MessageAgentAddressPrefix.tsx b/desktop/src/features/messages/ui/MessageAgentAddressPrefix.tsx index e3ab0c6076a..644a8dc80d3 100644 --- a/desktop/src/features/messages/ui/MessageAgentAddressPrefix.tsx +++ b/desktop/src/features/messages/ui/MessageAgentAddressPrefix.tsx @@ -5,7 +5,7 @@ import { getVisibleAgentAddressPubkeys } from "../lib/getVisibleAgentAddressPubk import type { UserProfileLookup } from "@/features/profile/lib/identity"; import { UserProfilePopover } from "@/features/profile/ui/UserProfilePopover"; -import { truncatePubkey } from "@/shared/lib/pubkey"; +import { truncateNpub } from "@/shared/lib/pubkey"; import { InlineChip } from "@/shared/ui/InlineChip"; /** Resolve all literal competitors before hiding tag-backed address chips. */ @@ -54,7 +54,7 @@ export function MessageAgentAddressPrefix({ const label = profile?.displayName?.trim() || profile?.name?.trim() || - truncatePubkey(pubkey); + truncateNpub(pubkey); return ( {/* biome-ignore lint/a11y/useValidAriaRole: UserProfilePopover uses role for agent classification, not as an ARIA attribute. */} diff --git a/desktop/src/features/messages/ui/NewMessageResultRow.tsx b/desktop/src/features/messages/ui/NewMessageResultRow.tsx index 9625fb7882e..ea07a0607a1 100644 --- a/desktop/src/features/messages/ui/NewMessageResultRow.tsx +++ b/desktop/src/features/messages/ui/NewMessageResultRow.tsx @@ -6,7 +6,7 @@ import type { UserProfileLookup } from "@/features/profile/lib/identity"; import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar"; import type { UserSearchResult } from "@/shared/api/types"; import { cn } from "@/shared/lib/cn"; -import { truncatePubkey } from "@/shared/lib/pubkey"; +import { truncateNpub } from "@/shared/lib/pubkey"; import { formatRecipientName } from "./useNewMessageRecipients"; @@ -28,7 +28,7 @@ function HoverRecipientIdentity({ displayName: string; pubkey: string; }) { - const identityLabel = truncatePubkey(pubkey); + const identityLabel = truncateNpub(pubkey); return ( {typingPubkeys.map((pubkey, index) => { const profile = profiles?.[pubkey.toLowerCase()]; - const label = labels[index] ?? truncatePubkey(pubkey); + const label = labels[index] ?? truncateNpub(pubkey); return (
key.toLowerCase(), truncatePubkey: (key) => key, + // Compact-identity seam stubbed alongside its sibling: these suites + // render display names, never key-form labels. + truncateNpub: (key) => key, }, "@/shared/lib/customEmojiTags": { buildCustomEmojiTags: () => [] }, "./useMentionSendFlow.helpers": helpers, diff --git a/desktop/src/features/messages/ui/useMentionSendFlow.ts b/desktop/src/features/messages/ui/useMentionSendFlow.ts index 9847193101b..299c0144ab4 100644 --- a/desktop/src/features/messages/ui/useMentionSendFlow.ts +++ b/desktop/src/features/messages/ui/useMentionSendFlow.ts @@ -28,7 +28,7 @@ import { useDetachedAgentStart } from "./useDetachedAgentStart"; import { useEnsureAgentMentionsReady } from "./useEnsureAgentMentionsReady"; import { invokeTauri } from "@/shared/api/tauri"; import type { AcpRuntime, ManagedAgent } from "@/shared/api/types"; -import { normalizePubkey, truncatePubkey } from "@/shared/lib/pubkey"; +import { normalizePubkey, truncateNpub } from "@/shared/lib/pubkey"; import { buildCustomEmojiTags } from "@/shared/lib/customEmojiTags"; import { dedupeQueuedAgentWakes, @@ -941,7 +941,7 @@ export function useMentionSendFlow({ if (!pendingNonMemberSend) return []; return pendingNonMemberSend.nonMemberPubkeys.map( (pubkey) => - mentions.getMentionDisplayName(pubkey) ?? truncatePubkey(pubkey), + mentions.getMentionDisplayName(pubkey) ?? truncateNpub(pubkey), ); }, [mentions.getMentionDisplayName, pendingNonMemberSend]); const invitation = useNonMemberInvite({ diff --git a/desktop/src/features/messages/ui/useNewMessageRecipients.ts b/desktop/src/features/messages/ui/useNewMessageRecipients.ts index ffc45c6c9e2..c1b821da62b 100644 --- a/desktop/src/features/messages/ui/useNewMessageRecipients.ts +++ b/desktop/src/features/messages/ui/useNewMessageRecipients.ts @@ -20,7 +20,7 @@ import { import { rankUserCandidatesBySearch } from "@/features/profile/lib/userCandidateSearch"; import { useIdentityQuery } from "@/shared/api/hooks"; import type { ManagedAgent, UserSearchResult } from "@/shared/api/types"; -import { normalizePubkey, truncatePubkey } from "@/shared/lib/pubkey"; +import { normalizePubkey, truncateNpub } from "@/shared/lib/pubkey"; /** Maximum recipients (excluding the current user) a DM can address. */ export const NEW_MESSAGE_RECIPIENT_LIMIT = 8; @@ -37,7 +37,7 @@ export function formatRecipientName(user: UserSearchResult) { return ( user.displayName?.trim() || user.nip05Handle?.trim() || - truncatePubkey(user.pubkey) + truncateNpub(user.pubkey) ); } diff --git a/desktop/src/features/notifications/use-feed-desktop-notifications.ts b/desktop/src/features/notifications/use-feed-desktop-notifications.ts index 4a0865437ed..e4594547eed 100644 --- a/desktop/src/features/notifications/use-feed-desktop-notifications.ts +++ b/desktop/src/features/notifications/use-feed-desktop-notifications.ts @@ -1,6 +1,6 @@ import * as React from "react"; -import { truncatePubkey } from "@/shared/lib/pubkey"; +import { truncateNpub } from "@/shared/lib/pubkey"; import { resolveUserLabel, type UserProfileLookup, @@ -204,7 +204,7 @@ export function useFeedDesktopNotifications( : undefined; // Only use real display names, not truncated pubkey fallbacks. const senderName = - resolvedLabel && resolvedLabel !== truncatePubkey(item.pubkey) + resolvedLabel && resolvedLabel !== truncateNpub(item.pubkey) ? resolvedLabel : undefined; void deliverFeedNotification(item, senderName); diff --git a/desktop/src/features/profile/lib/identity.test.mjs b/desktop/src/features/profile/lib/identity.test.mjs index da0259a66fc..089195213b3 100644 --- a/desktop/src/features/profile/lib/identity.test.mjs +++ b/desktop/src/features/profile/lib/identity.test.mjs @@ -1,11 +1,19 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { formatOwnerLabel, profileLookupsEqual } from "./identity.ts"; +import { + formatOwnerLabel, + profileLookupsEqual, + resolveUserLabel, +} from "./identity.ts"; const OWNER_PUBKEY = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; +// npubEncode(OWNER_PUBKEY), pinned so a fallback regression cannot pass by +// re-deriving the expectation from the code under test. +const OWNER_NPUB_COMPACT = "npub1hwa…04hu"; + const summary = (over = {}) => ({ displayName: "Ada", avatarUrl: "https://x/a.png", @@ -15,13 +23,28 @@ const summary = (over = {}) => ({ ...over, }); -test("formatOwnerLabel resolves a known owner's display name", () => { +test("formatOwnerLabel prefers the owner’s authored labels over the compact npub", () => { + // The label ladder: display name first, then a NIP-05 handle, then the + // key’s compact npub — never raw hex. assert.equal( formatOwnerLabel(OWNER_PUBKEY, null, { [OWNER_PUBKEY]: summary({ displayName: "baxen" }), }), "baxen", ); + assert.equal( + formatOwnerLabel(OWNER_PUBKEY, "c".repeat(64), { + [OWNER_PUBKEY]: summary({ + nip05Handle: "baxen@relay", + displayName: null, + }), + }), + "baxen@relay", + ); + assert.equal( + formatOwnerLabel(OWNER_PUBKEY, "c".repeat(64), {}), + OWNER_NPUB_COMPACT, + ); }); test("formatOwnerLabel calls the viewer-owned agent's owner you", () => { @@ -32,6 +55,31 @@ test("formatOwnerLabel returns null when verified ownership is absent", () => { assert.equal(formatOwnerLabel(null, OWNER_PUBKEY, {}), null); }); +test("resolveUserLabel falls back to the key’s compact npub, never raw hex", () => { + // No profile, no fallback name: the last resort is the npub compact. + assert.equal( + resolveUserLabel({ pubkey: OWNER_PUBKEY, profiles: {} }), + OWNER_NPUB_COMPACT, + ); + // A provided fallback name still wins over the key form. + assert.equal( + resolveUserLabel({ + pubkey: OWNER_PUBKEY, + profiles: {}, + fallbackName: "legacy relay agent", + }), + "legacy relay agent", + ); + // A resolved display name wins over everything. + assert.equal( + resolveUserLabel({ + pubkey: OWNER_PUBKEY, + profiles: { [OWNER_PUBKEY]: summary({ displayName: "baxen" }) }, + }), + "baxen", + ); +}); + test("profileLookupsEqual: same reference is equal", () => { const a = { p1: summary() }; assert.equal(profileLookupsEqual(a, a), true); diff --git a/desktop/src/features/profile/lib/identity.ts b/desktop/src/features/profile/lib/identity.ts index d2e0a4fdd38..a0d32faaaa4 100644 --- a/desktop/src/features/profile/lib/identity.ts +++ b/desktop/src/features/profile/lib/identity.ts @@ -1,9 +1,9 @@ import type { Profile, UserProfileSummary } from "@/shared/api/types"; -import { normalizePubkey, truncatePubkey } from "@/shared/lib/pubkey"; +import { normalizePubkey, truncateNpub } from "@/shared/lib/pubkey"; export type UserProfileLookup = Record; -export { truncatePubkey }; +export { truncateNpub }; /** * Deep-equal two profile lookups by value. Used to stabilise the merged @@ -128,7 +128,7 @@ export function resolveUserLabel(input: { return safeFallback; } - return truncatePubkey(pubkey); + return truncateNpub(pubkey); } /** @@ -188,6 +188,6 @@ export function formatOwnerLabel( return ( owner?.displayName?.trim() || owner?.nip05Handle?.trim() || - truncatePubkey(ownerPubkey) + truncateNpub(ownerPubkey) ); } diff --git a/desktop/src/features/profile/ui/ProfileAvatar.tsx b/desktop/src/features/profile/ui/ProfileAvatar.tsx index debbc42803a..d8d22eaa76a 100644 --- a/desktop/src/features/profile/ui/ProfileAvatar.tsx +++ b/desktop/src/features/profile/ui/ProfileAvatar.tsx @@ -23,6 +23,17 @@ type ProfileAvatarProps = { avatarUrl: string | null; avatarDataUrl?: string | null; label: string; + /** + * Label used to derive fallback initials; defaults to `label`. + * + * `label` stays the full visible/alt identity, but some callers build it + * as a generated role-prefixed key fallback ("Agent npub1abcd…wxyz"), + * which `getInitials` reads as ordinary words — collapsing every unnamed + * identity onto the same "AN"/"PN" initials. Identity-aware callers pass + * the unprefixed compact key here so key-fallback avatars keep distinct + * key-tail initials; authored display names keep their name initials. + */ + initialsLabel?: string; className?: string; iconClassName?: string; imageClassName?: string; @@ -46,6 +57,7 @@ export function ProfileAvatar({ avatarUrl, avatarDataUrl, label, + initialsLabel, className, iconClassName, imageClassName, @@ -54,7 +66,7 @@ export function ProfileAvatar({ testId, untrusted = false, }: ProfileAvatarProps) { - const initials = getInitials(label); + const initials = getInitials(initialsLabel ?? label); const presentation = useAvatarPresentation(avatarUrl); const presentedAvatarUrl = presentation?.displayUrl ?? avatarUrl; diff --git a/desktop/src/features/profile/ui/UserProfilePanel.tsx b/desktop/src/features/profile/ui/UserProfilePanel.tsx index d060deb5323..bad18369fe3 100644 --- a/desktop/src/features/profile/ui/UserProfilePanel.tsx +++ b/desktop/src/features/profile/ui/UserProfilePanel.tsx @@ -77,7 +77,7 @@ import { resolveAgentInstruction, resolvePanelProfile, resolveProfileDisplayName, - truncatePubkey, + truncateNpub, type UserProfilePanelProps, useRetainedPersona, } from "@/features/profile/ui/UserProfilePanelUtils"; @@ -675,7 +675,7 @@ export function UserProfilePanel({ return ( ownerProfile?.nip05Handle?.trim() || ownerProfile?.displayName?.trim() || - truncatePubkey(ownerPubkey) + truncateNpub(ownerPubkey) ); } @@ -687,7 +687,7 @@ export function UserProfilePanel({ return ( currentProfile?.nip05Handle?.trim() || currentProfile?.displayName?.trim() || - truncatePubkey(currentPubkey) + truncateNpub(currentPubkey) ); }, [ currentProfileQuery.data, diff --git a/desktop/src/features/profile/ui/UserProfilePanelUtils.ts b/desktop/src/features/profile/ui/UserProfilePanelUtils.ts index be1a57c112e..e7d5a4d4ef8 100644 --- a/desktop/src/features/profile/ui/UserProfilePanelUtils.ts +++ b/desktop/src/features/profile/ui/UserProfilePanelUtils.ts @@ -8,9 +8,9 @@ import type { RelayAgent, UpdateManagedAgentInput, } from "@/shared/api/types"; -import { normalizePubkey, truncatePubkey } from "@/shared/lib/pubkey"; +import { normalizePubkey, truncateNpub } from "@/shared/lib/pubkey"; -export { truncatePubkey }; +export { truncateNpub }; export type ProfileChannelLink = { id: string; @@ -237,7 +237,7 @@ export function resolveProfileDisplayName({ return ( profile?.displayName ?? persona?.displayName ?? - (pubkey ? truncatePubkey(pubkey) : "Agent") + (pubkey ? truncateNpub(pubkey) : "Agent") ); } @@ -252,7 +252,7 @@ export function resolveOwnerHandle( return ( profile?.nip05Handle?.trim() || profile?.displayName?.trim() || - truncatePubkey(currentPubkey) + truncateNpub(currentPubkey) ); } diff --git a/desktop/src/features/profile/ui/UserProfilePopover.tsx b/desktop/src/features/profile/ui/UserProfilePopover.tsx index 3fab7b8102e..c36b59e3af6 100644 --- a/desktop/src/features/profile/ui/UserProfilePopover.tsx +++ b/desktop/src/features/profile/ui/UserProfilePopover.tsx @@ -30,7 +30,7 @@ import { ProfileAvatarWithStatus } from "@/features/profile/ui/ProfileAvatarWith import { useOpenAgentActivity } from "@/features/agents/useOpenAgentActivity"; import { useProfilePanel } from "@/shared/context/ProfilePanelContext"; import { cn } from "@/shared/lib/cn"; -import { normalizePubkey, truncatePubkey } from "@/shared/lib/pubkey"; +import { normalizePubkey, truncateNpub } from "@/shared/lib/pubkey"; import { useProfileInteractionActions } from "@/features/profile/ui/useProfileInteractionActions"; import { @@ -108,7 +108,7 @@ function HoverPubkeyName({ - {truncatePubkey(pubkey)} + {truncateNpub(pubkey)} ); @@ -297,7 +297,7 @@ function UserProfilePopoverBody({ relayAgentsQuery.isPending || managedAgentsQuery.isPending || usersBatchQuery.isPending); - const displayName = profile?.displayName ?? truncatePubkey(pubkey); + const displayName = profile?.displayName ?? truncateNpub(pubkey); // Owner signal mirrors UserProfilePanel: a declared NIP-OA owner whose agent // runs elsewhere holds no local seckey, so key custody (`isOwner`) alone // wrongly hides the affordance from them — and gating on bot-ness alone shows diff --git a/desktop/src/features/profile/ui/useProfileInteractionActions.ts b/desktop/src/features/profile/ui/useProfileInteractionActions.ts index f5ee47a7702..1993188f319 100644 --- a/desktop/src/features/profile/ui/useProfileInteractionActions.ts +++ b/desktop/src/features/profile/ui/useProfileInteractionActions.ts @@ -21,7 +21,7 @@ import { useIdentityQuery } from "@/shared/api/hooks"; import { sendChannelMessage } from "@/shared/api/tauri"; import type { Channel, RelayEvent } from "@/shared/api/types"; import { KIND_STREAM_MESSAGE } from "@/shared/constants/kinds"; -import { normalizePubkey, truncatePubkey } from "@/shared/lib/pubkey"; +import { normalizePubkey, truncateNpub } from "@/shared/lib/pubkey"; export type ProfileInteractionAction = "huddle" | "message" | "wave"; @@ -207,7 +207,7 @@ export function useProfileInteractionActions({ const senderName = selfProfileQuery.data?.displayName?.trim() || identity.displayName.trim() || - truncatePubkey(identity.pubkey); + truncateNpub(identity.pubkey); const content = buildWaveMessageContent(senderName); const queryKey = channelMessagesKey(dm.id); diff --git a/desktop/src/features/projects/ui/IssueAssigneesRow.tsx b/desktop/src/features/projects/ui/IssueAssigneesRow.tsx index 54073bac5dd..71855770f27 100644 --- a/desktop/src/features/projects/ui/IssueAssigneesRow.tsx +++ b/desktop/src/features/projects/ui/IssueAssigneesRow.tsx @@ -13,7 +13,7 @@ import { useUserSearchQuery } from "@/features/profile/hooks"; import type { UserProfileLookup } from "@/features/profile/lib/identity"; import type { UserSearchResult } from "@/shared/api/types"; import { cn } from "@/shared/lib/cn"; -import { normalizePubkey, truncatePubkey } from "@/shared/lib/pubkey"; +import { normalizePubkey, truncateNpub } from "@/shared/lib/pubkey"; import { Button } from "@/shared/ui/button"; import { Dialog, @@ -37,7 +37,7 @@ function labelForPubkey(pubkey: string, profiles?: UserProfileLookup) { return ( profile?.displayName?.trim() || profile?.nip05Handle?.trim() || - truncatePubkey(pubkey) + truncateNpub(pubkey) ); } @@ -45,7 +45,7 @@ function assigneeSearchLabel(user: UserSearchResult) { return ( user.displayName?.trim() || user.nip05Handle?.trim() || - truncatePubkey(user.pubkey) + truncateNpub(user.pubkey) ); } @@ -383,7 +383,7 @@ export function IssueAssigneesRow({ {candidate.isAgent ? "Agent · " : ""} - {truncatePubkey(candidate.pubkey)} + {truncateNpub(candidate.pubkey)} diff --git a/desktop/src/features/projects/ui/ProjectDetailFeedPanels.tsx b/desktop/src/features/projects/ui/ProjectDetailFeedPanels.tsx index d960d3f09fc..bdb81011b3a 100644 --- a/desktop/src/features/projects/ui/ProjectDetailFeedPanels.tsx +++ b/desktop/src/features/projects/ui/ProjectDetailFeedPanels.tsx @@ -16,7 +16,7 @@ import { selectionItemFromCommit } from "@/features/projects/lib/projectSelectio import { commitShareLink } from "@/features/projects/lib/projectShareLinks"; import { relativeTime } from "@/features/projects/lib/projectsViewHelpers"; import type { ProjectRepoCommit } from "@/shared/api/types"; -import { truncatePubkey } from "@/shared/lib/pubkey"; +import { truncateNpub } from "@/shared/lib/pubkey"; import { BuzzLoadingState } from "@/shared/ui/BuzzLoadingState"; import { resolveUserLabel, @@ -129,7 +129,7 @@ export function ContributorsPanel({ isAgent, label: profile ? resolveUserLabel({ profiles, pubkey }) - : truncatePubkey(pubkey), + : truncateNpub(pubkey), profileLinked: true, pubkey, reviewCount: signedCounts.reviews, diff --git a/desktop/src/features/projects/ui/ProjectPullRequestInlineComments.tsx b/desktop/src/features/projects/ui/ProjectPullRequestInlineComments.tsx index 281aefe71be..fc64ffc6324 100644 --- a/desktop/src/features/projects/ui/ProjectPullRequestInlineComments.tsx +++ b/desktop/src/features/projects/ui/ProjectPullRequestInlineComments.tsx @@ -7,7 +7,7 @@ import type { } from "@/features/projects/projectPullRequests.mjs"; import { relativeTime } from "@/features/projects/lib/projectsViewHelpers"; import type { UserProfileLookup } from "@/features/profile/lib/identity"; -import { normalizePubkey, truncatePubkey } from "@/shared/lib/pubkey"; +import { normalizePubkey, truncateNpub } from "@/shared/lib/pubkey"; import { ProjectRichContent } from "./ProjectRichContent"; function commentAuthor( @@ -18,7 +18,7 @@ function commentAuthor( return ( profile?.displayName?.trim() || profile?.nip05Handle?.trim() || - truncatePubkey(pubkey) + truncateNpub(pubkey) ); } diff --git a/desktop/src/features/projects/ui/ProjectPullRequestsPanel.tsx b/desktop/src/features/projects/ui/ProjectPullRequestsPanel.tsx index b130bf552c5..9fe09f50cd4 100644 --- a/desktop/src/features/projects/ui/ProjectPullRequestsPanel.tsx +++ b/desktop/src/features/projects/ui/ProjectPullRequestsPanel.tsx @@ -33,7 +33,7 @@ import { canReviewProjectPullRequest } from "@/features/projects/pullRequestRevi import type { UserProfileLookup } from "@/features/profile/lib/identity"; import { useIdentityQuery } from "@/shared/api/hooks"; import type { ChannelMember } from "@/shared/api/types"; -import { normalizePubkey, truncatePubkey } from "@/shared/lib/pubkey"; +import { normalizePubkey, truncateNpub } from "@/shared/lib/pubkey"; import { ProjectFeedRow, ProjectFeedRowCluster, @@ -70,7 +70,7 @@ function labelForPubkey(pubkey: string, profiles?: UserProfileLookup) { return ( profile?.displayName?.trim() || profile?.nip05Handle?.trim() || - truncatePubkey(pubkey) + truncateNpub(pubkey) ); } diff --git a/desktop/src/features/projects/ui/PullRequestReviewersRow.tsx b/desktop/src/features/projects/ui/PullRequestReviewersRow.tsx index fa5f205fabb..8b824f917d9 100644 --- a/desktop/src/features/projects/ui/PullRequestReviewersRow.tsx +++ b/desktop/src/features/projects/ui/PullRequestReviewersRow.tsx @@ -19,7 +19,7 @@ import { useUserSearchQuery } from "@/features/profile/hooks"; import type { UserProfileLookup } from "@/features/profile/lib/identity"; import type { UserSearchResult } from "@/shared/api/types"; import { cn } from "@/shared/lib/cn"; -import { normalizePubkey, truncatePubkey } from "@/shared/lib/pubkey"; +import { normalizePubkey, truncateNpub } from "@/shared/lib/pubkey"; import { Button } from "@/shared/ui/button"; import { Dialog, @@ -44,7 +44,7 @@ function labelForPubkey(pubkey: string, profiles?: UserProfileLookup) { return ( profile?.displayName?.trim() || profile?.nip05Handle?.trim() || - truncatePubkey(pubkey) + truncateNpub(pubkey) ); } @@ -52,7 +52,7 @@ function reviewerSearchLabel(user: UserSearchResult) { return ( user.displayName?.trim() || user.nip05Handle?.trim() || - truncatePubkey(user.pubkey) + truncateNpub(user.pubkey) ); } @@ -270,7 +270,7 @@ export function PullRequestReviewersRow({ {candidate.isAgent ? "Agent · " : ""} - {truncatePubkey(candidate.pubkey)} + {truncateNpub(candidate.pubkey)} diff --git a/desktop/src/features/pulse/ui/AgentActivityCard.tsx b/desktop/src/features/pulse/ui/AgentActivityCard.tsx index 30e8601912a..310820363e2 100644 --- a/desktop/src/features/pulse/ui/AgentActivityCard.tsx +++ b/desktop/src/features/pulse/ui/AgentActivityCard.tsx @@ -6,7 +6,7 @@ import { UserProfilePopover } from "@/features/profile/ui/UserProfilePopover"; import type { UserProfileSummary } from "@/shared/api/types"; import { Markdown } from "@/shared/ui/markdown"; import { UserAvatar } from "@/shared/ui/UserAvatar"; -import { truncatePubkey } from "@/shared/lib/pubkey"; +import { truncateNpub } from "@/shared/lib/pubkey"; type AgentActivityCardProps = { group: AgentNoteGroup; @@ -51,7 +51,7 @@ export function AgentActivityCard({ agentStatus, }: AgentActivityCardProps) { const [expanded, setExpanded] = React.useState(false); - const displayName = profile?.displayName ?? truncatePubkey(group.pubkey); + const displayName = profile?.displayName ?? truncateNpub(group.pubkey); const avatarUrl = profile?.avatarUrl ?? null; const isSingleNote = group.notes.length === 1; diff --git a/desktop/src/features/pulse/ui/NoteCard.tsx b/desktop/src/features/pulse/ui/NoteCard.tsx index 96f25a6ede4..7ce04ce3dba 100644 --- a/desktop/src/features/pulse/ui/NoteCard.tsx +++ b/desktop/src/features/pulse/ui/NoteCard.tsx @@ -18,7 +18,7 @@ import { AnimatedCount } from "@/shared/ui/AnimatedCount"; import { Markdown } from "@/shared/ui/markdown"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip"; import { UserAvatar } from "@/shared/ui/UserAvatar"; -import { truncatePubkey } from "@/shared/lib/pubkey"; +import { truncateNpub } from "@/shared/lib/pubkey"; export type NoteCardActions = { reply?: ( @@ -67,7 +67,7 @@ function ReplyParentContext({ const parentDisplayName = parentNote ? (cachedProfile?.displayName ?? fetchedProfile?.displayName ?? - truncatePubkey(parentNote.pubkey)) + truncateNpub(parentNote.pubkey)) : null; const parentAvatarUrl = cachedProfile?.avatarUrl ?? fetchedProfile?.avatarUrl ?? null; @@ -149,7 +149,7 @@ export function NoteCard({ members = [], actions, }: NoteCardProps) { - const displayName = profile?.displayName ?? truncatePubkey(note.pubkey); + const displayName = profile?.displayName ?? truncateNpub(note.pubkey); const avatarUrl = profile?.avatarUrl ?? null; const [isReplyComposerOpen, setIsReplyComposerOpen] = React.useState(false); const actionButtonClass = diff --git a/desktop/src/features/pulse/ui/PulseView.tsx b/desktop/src/features/pulse/ui/PulseView.tsx index 2b595ed2ccb..01cd20d6225 100644 --- a/desktop/src/features/pulse/ui/PulseView.tsx +++ b/desktop/src/features/pulse/ui/PulseView.tsx @@ -30,7 +30,7 @@ import { Input } from "@/shared/ui/input"; import { Skeleton } from "@/shared/ui/skeleton"; import { UserAvatar } from "@/shared/ui/UserAvatar"; import { VirtualizedList } from "@/shared/ui/VirtualizedList"; -import { truncatePubkey } from "@/shared/lib/pubkey"; +import { truncateNpub } from "@/shared/lib/pubkey"; export type PulseTab = | "search" @@ -225,7 +225,7 @@ export function PulseView({ currentPubkey }: PulseViewProps) { : null; const currentDisplayName = currentProfile?.displayName ?? - (currentPubkey ? truncatePubkey(currentPubkey) : "You"); + (currentPubkey ? truncateNpub(currentPubkey) : "You"); const pulseMentionMembers = React.useMemo(() => { const members: ChannelMember[] = []; diff --git a/desktop/src/features/search/ui/TopbarSearch.tsx b/desktop/src/features/search/ui/TopbarSearch.tsx index 68e879a3033..5a032c780a4 100644 --- a/desktop/src/features/search/ui/TopbarSearch.tsx +++ b/desktop/src/features/search/ui/TopbarSearch.tsx @@ -20,7 +20,7 @@ import { HighlightedSearchText } from "@/features/search/ui/HighlightedSearchTex import { useSearchMenuKeyboardNavigation } from "@/features/search/ui/useSearchMenuKeyboardNavigation"; import type { Channel, SearchHit, UserSearchResult } from "@/shared/api/types"; import { cn } from "@/shared/lib/cn"; -import { normalizePubkey, truncatePubkey } from "@/shared/lib/pubkey"; +import { normalizePubkey, truncateNpub } from "@/shared/lib/pubkey"; import { Dialog, DialogContent, DialogTitle } from "@/shared/ui/dialog"; import { useDeferredModalOpen } from "@/shared/ui/deferredModalOpen"; import { @@ -134,7 +134,7 @@ function getUserDisplayName(user: UserSearchResult) { return ( user.displayName?.trim() || user.nip05Handle?.trim() || - truncatePubkey(user.pubkey) + truncateNpub(user.pubkey) ); } diff --git a/desktop/src/features/settings/ui/ModerationQueueCard.tsx b/desktop/src/features/settings/ui/ModerationQueueCard.tsx index 58dbe87ab79..9bb5962585a 100644 --- a/desktop/src/features/settings/ui/ModerationQueueCard.tsx +++ b/desktop/src/features/settings/ui/ModerationQueueCard.tsx @@ -34,7 +34,7 @@ import { type SeverityTier, } from "@/features/settings/lib/moderationQueue"; import { cn } from "@/shared/lib/cn"; -import { truncatePubkey } from "@/shared/lib/pubkey"; +import { truncateNpub, truncatePubkey } from "@/shared/lib/pubkey"; import { Button } from "@/shared/ui/button"; import { DropdownMenu, @@ -195,14 +195,15 @@ const SEVERITY_BADGE: Record = { }; function targetLabel(group: ModerationQueueGroup): string { - const short = truncatePubkey(group.target); switch (group.targetKind) { case "event": - return `Message ${short}`; + // An event id is not a pubkey identity: keep the generic hex form. + return `Message ${truncatePubkey(group.target)}`; case "pubkey": - return `Member ${short}`; + return `Member ${truncateNpub(group.target)}`; case "blob": - return `Attachment ${short}`; + // Blob ids are not pubkey identities either. + return `Attachment ${truncatePubkey(group.target)}`; } } @@ -213,7 +214,7 @@ function ReporterLine({ report: ModerationReport; displayName?: string | null; }) { - const who = displayName?.trim() || truncatePubkey(report.reporterPubkey); + const who = displayName?.trim() || truncateNpub(report.reporterPubkey); return (
@@ -476,9 +477,11 @@ function AuditRow({ action: ModerationAction; actorName?: string | null; }) { - const who = actorName?.trim() || truncatePubkey(action.actorPubkey); + const who = actorName?.trim() || truncateNpub(action.actorPubkey); + // Actor and member targets are pubkey identities; a targeted event keeps + // the generic hex truncation for its event id. const targetShort = action.targetPubkey - ? truncatePubkey(action.targetPubkey) + ? truncateNpub(action.targetPubkey) : action.targetEventId ? truncatePubkey(action.targetEventId) : null; diff --git a/desktop/src/features/sidebar/ui/ChannelActivityPopover.tsx b/desktop/src/features/sidebar/ui/ChannelActivityPopover.tsx index 34fdba0bdfe..1eea8e21789 100644 --- a/desktop/src/features/sidebar/ui/ChannelActivityPopover.tsx +++ b/desktop/src/features/sidebar/ui/ChannelActivityPopover.tsx @@ -13,7 +13,7 @@ import type { UserProfileLookup } from "@/features/profile/lib/identity"; import { useRemindLater } from "@/features/reminders/ui/RemindMeLaterProvider"; import { useIdentityQuery } from "@/shared/api/hooks"; import type { Channel, FeedItem, HomeFeedResponse } from "@/shared/api/types"; -import { normalizePubkey, truncatePubkey } from "@/shared/lib/pubkey"; +import { normalizePubkey, truncateNpub } from "@/shared/lib/pubkey"; import { useNow } from "@/shared/lib/useNow"; import { Markdown } from "@/shared/ui/markdown"; import { @@ -150,12 +150,15 @@ function ThreadPreviewRow({ function WorkingAgentRow({ avatarUrl, elapsed, + initialsLabel, name, onOpen, pubkey, }: { avatarUrl: string | null; elapsed: string; + /** Unprefixed key (or authored name) the avatar derives initials from. */ + initialsLabel: string; name: string; onOpen: () => void; pubkey: string; @@ -171,6 +174,7 @@ function WorkingAgentRow({ avatarUrl={avatarUrl} className="h-9 w-9 shrink-0" displayName={name} + initialsLabel={initialsLabel} shape="squircle" size="md" /> @@ -192,7 +196,12 @@ function WorkingAgentRow({ ); } -function WorkingAgentRows({ +/** + * Working-agent rows for the channel activity popover. Exported for consumer + * tests: it owns the generated `Agent npub1…` fallback label that flows into + * `UserAvatar` initials. + */ +export function WorkingAgentRows({ activeWorking, channelId, onOpen, @@ -212,14 +221,15 @@ function WorkingAgentRows({ return activeWorking.agentPubkeys.map((pubkey, index) => { const profile = profiles?.[normalizePubkey(pubkey)]; - const name = - profile?.displayName?.trim() || - alignedAgentNames?.[index] || - `Agent ${truncatePubkey(pubkey)}`; + const authoredName = + profile?.displayName?.trim() || alignedAgentNames?.[index]; + const keyLabel = truncateNpub(pubkey); + const name = authoredName || `Agent ${keyLabel}`; return ( onOpen(pubkey, channelId)} diff --git a/desktop/src/features/workflows/ui/WorkflowAuthorPicker.tsx b/desktop/src/features/workflows/ui/WorkflowAuthorPicker.tsx index 9d6bdddc005..6550845295d 100644 --- a/desktop/src/features/workflows/ui/WorkflowAuthorPicker.tsx +++ b/desktop/src/features/workflows/ui/WorkflowAuthorPicker.tsx @@ -10,7 +10,7 @@ import { } from "@/features/profile/hooks"; import { resolveUserLabel } from "@/features/profile/lib/identity"; import { cn } from "@/shared/lib/cn"; -import { truncatePubkey } from "@/shared/lib/pubkey"; +import { truncateNpub } from "@/shared/lib/pubkey"; import { Input } from "@/shared/ui/input"; import { UserAvatar } from "@/shared/ui/UserAvatar"; import { @@ -348,7 +348,7 @@ function AuthorOption({ {label} - {truncatePubkey(candidate.pubkey)} + {truncateNpub(candidate.pubkey)} { }), "“Build finished” to team-on-call", ); + // A key destination renders its compact npub (npubEncode of the pinned + // hex key); a checksum-broken npub renders the neutral label, never the + // raw text as if it were a role. + const hexKey = "deadbeef".repeat(8); + const brokenNpub = + "npub1m6kmam774klwlh4dhmhaatd7al02m0h0m6kmam774klwlh4dhmhslezuzy"; + assert.equal( + workflowStepDescription({ + id: "dm", + action: "send_dm", + text: "Build finished", + to: hexKey, + }), + "“Build finished” to npub1m6k…zuz0", + ); + assert.equal( + workflowStepDescription({ + id: "dm", + action: "send_dm", + text: "Build finished", + to: brokenNpub, + }), + "“Build finished” to Unavailable", + ); assert.equal( workflowStepDescription({ id: "approval", @@ -68,6 +92,15 @@ test("describes configured workflow steps on the canvas", () => { }), "“Ship the release?” from release-managers", ); + assert.equal( + workflowStepDescription({ + id: "approval", + action: "request_approval", + message: "Ship the release?", + from: hexKey, + }), + "“Ship the release?” from npub1m6k…zuz0", + ); assert.equal( workflowStepDescription({ id: "reaction", diff --git a/desktop/src/features/workflows/ui/workflowStepDescription.ts b/desktop/src/features/workflows/ui/workflowStepDescription.ts index 136ce026111..40697fe352f 100644 --- a/desktop/src/features/workflows/ui/workflowStepDescription.ts +++ b/desktop/src/features/workflows/ui/workflowStepDescription.ts @@ -1,4 +1,4 @@ -import { truncatePubkey } from "@/shared/lib/pubkey"; +import { truncateNpub } from "@/shared/lib/pubkey"; import { formatDurationSecondsVerbose, parseDurationSeconds, @@ -20,11 +20,17 @@ function quoted(value: string | undefined): string | null { return normalized ? `“${normalized}”` : null; } +// DM/approver references are pubkeys — 64-char hex in any case, or a +// canonical lowercase npub — or freeform role/template text; only a key +// identity renders as a key (an npub-shaped string that fails the shared +// helper's checksum renders the neutral Unavailable, never raw text). +const KEY_REFERENCE = /^(?:[0-9a-f]{64}|npub1[0-9a-z]+)$/i; + function destination(value: string | undefined): string | null { const normalized = value?.trim(); if (!normalized) return null; - return /^[0-9a-f]{64}$/i.test(normalized) - ? truncatePubkey(normalized) + return KEY_REFERENCE.test(normalized) + ? truncateNpub(normalized) : compact(normalized); } diff --git a/desktop/src/features/workflows/ui/workflowTriggerDescription.test.mjs b/desktop/src/features/workflows/ui/workflowTriggerDescription.test.mjs index 1f2d03b2382..9c96abd3539 100644 --- a/desktop/src/features/workflows/ui/workflowTriggerDescription.test.mjs +++ b/desktop/src/features/workflows/ui/workflowTriggerDescription.test.mjs @@ -170,6 +170,39 @@ test("describes selected trigger conditions on the workflow canvas", () => { ), "👾 reaction added by Carl to “hey yourself”", ); + + // Without a resolved label, an author renders its compact npub while the + // referenced message keeps the generic hex truncation — an event id, not + // a pubkey identity. + const unresolvedAuthor = "deadbeef".repeat(8); + assert.equal( + workflowTriggerDescription({ + on: "message_posted", + filter: `trigger_author == "${unresolvedAuthor}"`, + }), + "Message posted by npub1m6k…zuz0", + ); + assert.equal( + workflowTriggerDescription({ + on: "message_posted", + filter: `trigger_author != "${unresolvedAuthor}"`, + }), + "Message posted by anyone except npub1m6k…zuz0", + ); + assert.equal( + workflowTriggerDescription({ + on: "reaction_added", + filter: `trigger_message_id == "${"b".repeat(64)}"`, + }), + "Reaction added to bbbbbbbb…bbbb", + ); + assert.equal( + workflowTriggerDescription({ + on: "reaction_added", + filter: `trigger_emoji == "👾" && trigger_author == "${unresolvedAuthor}" && trigger_message_id == "${"b".repeat(64)}"`, + }), + "👾 reaction added by npub1m6k…zuz0 to bbbbbbbb…bbbb", + ); }); test("compacts only an included emoji already rendered as the node icon", () => { diff --git a/desktop/src/features/workflows/ui/workflowTriggerDescription.ts b/desktop/src/features/workflows/ui/workflowTriggerDescription.ts index 51bba6c0102..a4927444974 100644 --- a/desktop/src/features/workflows/ui/workflowTriggerDescription.ts +++ b/desktop/src/features/workflows/ui/workflowTriggerDescription.ts @@ -1,4 +1,4 @@ -import { truncatePubkey } from "@/shared/lib/pubkey"; +import { truncateNpub, truncatePubkey } from "@/shared/lib/pubkey"; import { parseConditionExpressions } from "./workflowConditionExpression"; import { TRIGGER_LABELS } from "./workflowFormTypes"; import type { ParsedConditionExpression } from "./workflowConditionExpression"; @@ -19,7 +19,9 @@ function authorReference( authorLoading?: boolean, ): string { if (authorLoading) return TRIGGER_AUTHOR_LOADING_LABEL; - return authorLabel ?? truncatePubkey(condition.value); + // The trigger author is a pubkey identity; the unresolved fallback renders + // the npub compact, never raw hex. + return authorLabel ?? truncateNpub(condition.value); } function quotedValue(value: string): string { @@ -35,6 +37,8 @@ function messageReference( messageLoading?: boolean, ): string { if (messageLoading) return TRIGGER_MESSAGE_LOADING_LABEL; + // The referenced message is an event id, not a pubkey identity: it keeps + // the generic hex truncation. return messageLabel ? quotedValue(messageLabel) : truncatePubkey(condition.value); diff --git a/desktop/src/shared/lib/initials.test.mjs b/desktop/src/shared/lib/initials.test.mjs index 19e9f8dbcc8..634489b9245 100644 --- a/desktop/src/shared/lib/initials.test.mjs +++ b/desktop/src/shared/lib/initials.test.mjs @@ -19,4 +19,47 @@ describe("getInitials", () => { it("returns empty for a symbol-only name", () => { assert.equal(getInitials("()"), ""); }); + + it("derives key-label initials from the compact key’s visible tail, not the npub prefix", () => { + // Compact npub labels every start with npub1: the tail is the only + // fragment that distinguishes one key-identified identity from another. + assert.equal(getInitials("npub1z59…zwkg"), "ZW"); + assert.equal(getInitials("npub1m6k…zuz0"), "ZU"); + }); + + it("derives full-npub initials from the same tail fragment", () => { + assert.equal( + getInitials( + "npub1z59jp0d24242424242424242424242424242424242424242zhwqnlzwkg", + ), + "ZW", + ); + }); + + it("leaves authored names that merely resemble npubs on the name path", () => { + // Not a key-shaped label: wrong lengths, separators, or alphabet must + // keep the ordinary name derivation so an authored name is never + // re-derived as a key just for resembling one. + for (const [label, expected] of [ + ["Npub1 Person", "NP"], + ["npub1cool handle", "NH"], + // Compact-label shape with a missing data character. + ["npub1ab…wxy", "NW"], + // Compact-label shape over letters outside the bech32 alphabet. + ["npub1bio…biob", "NB"], + ]) { + assert.equal(getInitials(label), expected); + } + }); + + it("requires a checksum-valid npub before deriving key-tail initials", () => { + // Same length and alphabet as a real npub, but the checksum does not + // decode: an authored lookalike must stay on the name path. + assert.equal( + getInitials( + "npub1z59jp0d24242424242424242424242424242424242424242zhwqnlzwkq", + ), + "N", + ); + }); }); diff --git a/desktop/src/shared/lib/initials.ts b/desktop/src/shared/lib/initials.ts index 23dbf2ff93b..da5833086c0 100644 --- a/desktop/src/shared/lib/initials.ts +++ b/desktop/src/shared/lib/initials.ts @@ -1,5 +1,50 @@ +import { decode } from "nostr-tools/nip19"; + +/** + * Key-form labels carry no name to abbreviate: their head is the constant + * `npub1` prefix, so name-derived initials would collapse every key-identified + * identity onto the same leading letter. A key's distinguishing fragment is + * its tail — the part a compact key actually shows — so derive initials from + * there and keep key-fallback avatars visually distinct. + * + * Detection is deliberately narrow so authored names never lose their name + * initials for merely resembling a key: a full label counts as a key only + * when it decodes as a checksum-valid npub of an identity-length key, and a + * compact label must match the exact `npub` + 4 + `…` + 4 truncation shape + * over the bech32 alphabet (the form `truncateNpub` emits). A display name + * that is a checksum-valid npub is indistinguishable from a real key by + * shape alone, and taking key-tail initials there is the safe side of that + * boundary. + */ +const BECH32_ALPHABET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"; +const COMPACT_NPUB_KEY_LABEL = new RegExp( + `^npub1[${BECH32_ALPHABET}]{3}…[${BECH32_ALPHABET}]{4}$`, +); +const HEX_64_REGEX = /^[0-9a-f]{64}$/; + +function isFullNpubLabel(label: string): boolean { + try { + const decoded = decode(label); + return decoded.type === "npub" && HEX_64_REGEX.test(decoded.data); + } catch { + return false; + } +} + +function keyTailInitials(name: string): string | null { + const trimmed = name.trim(); + if (!COMPACT_NPUB_KEY_LABEL.test(trimmed) && !isFullNpubLabel(trimmed)) { + return null; + } + return trimmed.slice(-4, -2).toUpperCase(); +} + /** Derive up to two uppercase initials from a display name. */ export function getInitials(name: string): string { + const keyInitials = keyTailInitials(name); + if (keyInitials !== null) { + return keyInitials; + } return name .replace(/[^\p{L}\p{N}\s]/gu, " ") .trim() diff --git a/desktop/src/shared/lib/mentionDisplay.test.mjs b/desktop/src/shared/lib/mentionDisplay.test.mjs index 20e0f17eb0b..0f44d5131a2 100644 --- a/desktop/src/shared/lib/mentionDisplay.test.mjs +++ b/desktop/src/shared/lib/mentionDisplay.test.mjs @@ -1,21 +1,27 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { formatMentionDisplayLabel } from "./mentionDisplay.ts"; -import { truncatePubkey } from "./pubkey.ts"; +import { + formatLegacyMentionDisplayLabel, + formatMentionDisplayLabel, +} from "./mentionDisplay.ts"; +import { truncateNpub, truncatePubkey } from "./pubkey.ts"; const KEY = `150b20bd${"a".repeat(52)}15dc`; +// npubEncode(KEY), pinned so a formatter regression cannot pass by +// re-deriving the expectation from the code under test. +const KEY_NPUB_COMPACT = "npub1z59…zwkg"; test("compact mention display uses the member-list formatter and keeps collision suffixes", () => { for (const suffix of ["", " 2", " 10"]) { assert.equal( formatMentionDisplayLabel(`Bad Janet (${KEY})${suffix}`, KEY), - `Bad Janet (${truncatePubkey(KEY)})${suffix}`, + `Bad Janet (${truncateNpub(KEY)})${suffix}`, ); } - assert.equal(formatMentionDisplayLabel(KEY, KEY), truncatePubkey(KEY)); + assert.equal(formatMentionDisplayLabel(KEY, KEY), KEY_NPUB_COMPACT); assert.equal( formatMentionDisplayLabel(KEY.toUpperCase(), KEY), - truncatePubkey(KEY.toUpperCase()), + KEY_NPUB_COMPACT, ); }); @@ -32,10 +38,23 @@ test("display leaves unbound, mismatched, malformed and ordinary labels literal" assert.equal(formatMentionDisplayLabel(label, key), label); }); +test("legacy display keeps the retired hex compaction byte-exact for clipboard validation", () => { + for (const suffix of ["", " 2", " 10"]) { + assert.equal( + formatLegacyMentionDisplayLabel(`Bad Janet (${KEY})${suffix}`, KEY), + `Bad Janet (${truncatePubkey(KEY)})${suffix}`, + ); + } + assert.equal(formatLegacyMentionDisplayLabel(KEY, KEY), truncatePubkey(KEY)); +}); + test("matching compact keys do not become identity keys", () => { const other = KEY.replace("aaaa", "bbbb"); assert.notEqual(KEY, other); - assert.equal( + // The two keys collide under the old hex first8…last4 truncation yet + // compact to distinguishable npubs: npub display keeps them apart. + assert.equal(truncatePubkey(KEY), truncatePubkey(other)); + assert.notEqual( formatMentionDisplayLabel(`Scout (${KEY})`, KEY), formatMentionDisplayLabel(`Scout (${other})`, other), ); diff --git a/desktop/src/shared/lib/mentionDisplay.ts b/desktop/src/shared/lib/mentionDisplay.ts index 10d15643ad4..ec8a199efad 100644 --- a/desktop/src/shared/lib/mentionDisplay.ts +++ b/desktop/src/shared/lib/mentionDisplay.ts @@ -1,17 +1,40 @@ -import { truncatePubkey } from "./pubkey"; +import { truncateNpub, truncatePubkey } from "./pubkey"; -/** Compact only a bound mention's key; its literal label remains authoritative. */ -export function formatMentionDisplayLabel( +type KeyCompaction = (key: string) => string; + +function compactMentionDisplayLabel( label: string, pubkey: string | undefined, + compactKey: KeyCompaction, ): string { if (!pubkey || !/^[0-9a-f]{64}$/i.test(pubkey)) return label; if (label.toLowerCase() === pubkey.toLowerCase()) { - return truncatePubkey(label); + return compactKey(label); } const qualified = label.match( /^(.*) \(([0-9a-f]{64})\)((?: (?:[2-9]|[1-9][0-9]+))?)$/i, ); if (qualified?.[2].toLowerCase() !== pubkey.toLowerCase()) return label; - return `${qualified[1]} (${truncatePubkey(qualified[2])})${qualified[3]}`; + return `${qualified[1]} (${compactKey(qualified[2])})${qualified[3]}`; +} + +/** Compact only a bound mention's key; its literal label remains authoritative. */ +export function formatMentionDisplayLabel( + label: string, + pubkey: string | undefined, +): string { + return compactMentionDisplayLabel(label, pubkey, truncateNpub); +} + +/** + * The pre-npub key compaction a chip rendered before keys displayed as npub. + * Retired from rendering; kept byte-exact so clipboard validation can still + * recognize whole chips copied by an older Buzz, re-binding them to the exact + * identity their record declares instead of degrading them to plain text. + */ +export function formatLegacyMentionDisplayLabel( + label: string, + pubkey: string | undefined, +): string { + return compactMentionDisplayLabel(label, pubkey, truncatePubkey); } diff --git a/desktop/src/shared/ui/UserAvatar.tsx b/desktop/src/shared/ui/UserAvatar.tsx index 1b47bc4c92c..0398ea2ccd9 100644 --- a/desktop/src/shared/ui/UserAvatar.tsx +++ b/desktop/src/shared/ui/UserAvatar.tsx @@ -35,6 +35,17 @@ function fallbackColorClass(displayName: string) { type UserAvatarProps = { avatarUrl: string | null; displayName: string; + /** + * Label used to derive fallback initials; defaults to `displayName`. + * + * Callers whose `displayName` is a generated role-prefixed key fallback + * ("Agent npub1abcd…wxyz") pass the unprefixed compact key here: + * word-initials would collapse every unnamed identity onto "AN"/"PN", + * while the compact key keeps distinct key-tail initials. Authored + * display names keep their name initials. The fallback color keeps + * hashing `displayName`, which still contains the key. + */ + initialsLabel?: string; size?: UserAvatarSize; accent?: boolean; shape?: "circle" | "squircle"; @@ -47,6 +58,7 @@ type UserAvatarProps = { export function UserAvatar({ avatarUrl, displayName, + initialsLabel, size = "md", accent = false, shape, @@ -55,7 +67,7 @@ export function UserAvatar({ imageDraggable, testId, }: UserAvatarProps) { - const initials = getInitials(displayName); + const initials = getInitials(initialsLabel ?? displayName); // Animated avatars show their static poster frame until hovered, then play // the animation. const animated = parseAnimatedAvatarUrl(avatarUrl); diff --git a/desktop/src/shared/ui/markdown/useMessageLinkMetadata.ts b/desktop/src/shared/ui/markdown/useMessageLinkMetadata.ts index 7a051743344..8977c1bc4c6 100644 --- a/desktop/src/shared/ui/markdown/useMessageLinkMetadata.ts +++ b/desktop/src/shared/ui/markdown/useMessageLinkMetadata.ts @@ -4,7 +4,7 @@ import type { ParsedMessageLink } from "@/features/messages/lib/messageLink"; import { summarizeMessageLinkContent } from "@/features/messages/lib/messageLinkMetadata"; import { getEventById } from "@/shared/api/tauri"; import { getUserProfile } from "@/shared/api/tauriProfiles"; -import { truncatePubkey } from "@/shared/lib/pubkey"; +import { truncateNpub } from "@/shared/lib/pubkey"; import { isDefinitiveEventNotFound } from "@/shared/lib/eventLookupError"; const MESSAGE_METADATA_RETRY_DELAY_MS = 750; @@ -70,7 +70,7 @@ function fetchMetadata( author: profile?.displayName?.trim() || profile?.nip05Handle?.trim() || - truncatePubkey(event.pubkey), + truncateNpub(event.pubkey), createdAt: event.created_at, snippet: summarizeMessageLinkContent(event.content), }; diff --git a/desktop/src/shared/ui/markdownMentionDisplay.test.mjs b/desktop/src/shared/ui/markdownMentionDisplay.test.mjs index bf96caeee00..5f9fe6b1126 100644 --- a/desktop/src/shared/ui/markdownMentionDisplay.test.mjs +++ b/desktop/src/shared/ui/markdownMentionDisplay.test.mjs @@ -2,7 +2,7 @@ import assert from "node:assert/strict"; import test from "node:test"; import React from "react"; import { renderToStaticMarkup } from "react-dom/server"; -import { truncatePubkey } from "../lib/pubkey.ts"; +import { truncateNpub } from "../lib/pubkey.ts"; import { createMarkdownComponents } from "./markdown.tsx"; import { renderCachedMarkdown } from "./markdown/nodeCache.ts"; import { MarkdownRuntimeContext } from "./markdown/runtimeContext.ts"; @@ -33,7 +33,7 @@ for (const agent of [false, true]) { ); assert.equal( html.replace(/<[^>]+>/g, ""), - `Ask Scout (${truncatePubkey(KEY)}) 2`, + `Ask Scout (${truncateNpub(KEY)}) 2`, ); assert.ok(html.includes(`data-mention-label="${label}"`)); assert.ok(html.includes(`data-mention-pubkey="${KEY}"`)); diff --git a/desktop/tests/e2e/channel-activity-popover.spec.ts b/desktop/tests/e2e/channel-activity-popover.spec.ts index 0e6caac7762..59d46804615 100644 --- a/desktop/tests/e2e/channel-activity-popover.spec.ts +++ b/desktop/tests/e2e/channel-activity-popover.spec.ts @@ -344,6 +344,12 @@ test.describe("channel activity hover preview", () => { await expect( popover.getByTestId(`channel-activity-agent-${AGENT_PUBKEY}`), ).toContainText("Charlie"); + // The authored name also supplies the row avatar's initials. + await expect( + popover + .getByTestId(`channel-activity-agent-${AGENT_PUBKEY}`) + .getByText("C", { exact: true }), + ).toBeVisible(); await expect( popover.getByTestId(`channel-activity-agent-${AGENT_PUBKEY}`), ).toContainText("Working"); @@ -372,6 +378,56 @@ test.describe("channel activity hover preview", () => { await expect(threadPanel).toContainText("direct thread link"); }); + test("unnamed working agents keep distinct key-tail initials, not AN", async ({ + page, + }) => { + // npubEncode of the fixture keys: "a"×64 → npub1424…rcaj (tail RC), + // "b"×64 → npub1hwa…04hu (tail 04). Neither has a seeded profile, so + // the rows show the generated "Agent npub1…" fallback label. + const unnamedAgents = [ + { initials: "RC", label: "Agent npub1424…rcaj", pubkey: "a".repeat(64) }, + { initials: "04", label: "Agent npub1hwa…04hu", pubkey: "b".repeat(64) }, + ]; + + await page.goto("/"); + await page.waitForFunction( + () => + typeof (window as Window & { __BUZZ_E2E_SEED_ACTIVE_TURNS__?: unknown }) + .__BUZZ_E2E_SEED_ACTIVE_TURNS__ === "function", + ); + for (const { pubkey } of unnamedAgents) { + await page.evaluate( + ({ agentPubkey, channelId }) => { + ( + window as Window & { + __BUZZ_E2E_SEED_ACTIVE_TURNS__?: (input: { + agentPubkey: string; + channelId: string; + turnId: string; + }) => void; + } + ).__BUZZ_E2E_SEED_ACTIVE_TURNS__?.({ + agentPubkey, + channelId, + turnId: "unnamed-agent-initials", + }); + }, + { agentPubkey: pubkey, channelId: CHANNEL_GENERAL }, + ); + } + + const popover = await openActivityPopover(page); + for (const { initials, label, pubkey } of unnamedAgents) { + const row = popover.getByTestId(`channel-activity-agent-${pubkey}`); + await expect(row).toContainText(label); + // The avatar abbreviates its key's visible tail (UserAvatar's fallback + // settles after its 200ms delay; expect retries past it), never the + // "Agent" word initials of the prefixed label. + await expect(row.getByText(initials, { exact: true })).toBeVisible(); + await expect(row.getByText("AN", { exact: true })).toHaveCount(0); + } + }); + test("removes the dot and preview after the final activity is read", async ({ page, }) => { diff --git a/desktop/tests/e2e/channels.spec.ts b/desktop/tests/e2e/channels.spec.ts index f9fd6dca58b..371cffdfb0b 100644 --- a/desktop/tests/e2e/channels.spec.ts +++ b/desktop/tests/e2e/channels.spec.ts @@ -4176,8 +4176,26 @@ test("members sidebar virtualizes large channel rosters", async ({ page }) => { await expect(memberRows.first()).toBeVisible(); expect(await memberRows.count()).toBeLessThan(50); + // Generated members have no display name, so the roster sorts them by + // their npub fallback label: these sequential pubkeys share a `npub1qqq…` + // prefix and order by the checksum tail, not their numeric value. Resolve + // a generated member from the rows the initial window actually rendered + // instead of assuming `pubkeys[0]` sorts into that window. + const generatedPubkeySet = new Set(pubkeys); + const renderedPubkeys = await memberRows.evaluateAll((rows) => + rows.map( + (row) => + (row as HTMLElement).dataset.testid?.slice("sidebar-member-".length) ?? + "", + ), + ); + const firstRenderedGeneratedPubkey = renderedPubkeys.find((pubkey) => + generatedPubkeySet.has(pubkey), + ); + expect(firstRenderedGeneratedPubkey).toBeTruthy(); + const firstGeneratedRow = memberList.getByTestId( - `sidebar-member-${pubkeys[0]}`, + `sidebar-member-${firstRenderedGeneratedPubkey}`, ); await expect .poll(() => @@ -4214,8 +4232,24 @@ test("members sidebar virtualizes large channel rosters", async ({ page }) => { element.scrollTop = element.scrollHeight; element.dispatchEvent(new Event("scroll")); }); - await expect( - memberList.getByTestId(`sidebar-member-${pubkeys.at(-1)}`), + // Fully scrolling must render the roster's true tail, and the tail + // endpoint must be known independently of whatever the virtual window + // happens to render. The sidebar's own roster accounting — the + // "Members · N" header — must read exactly the fixture-known total + // ("random" seeds alice, the mock identity, and bob; this test adds the + // 500 generated pubkeys on top), so fixture or classification drift + // fails loudly here instead of silently weakening the tail check. + const rosterCount = 3 + pubkeys.length; + await expect(memberList.getByText(/^Members · \d+$/)).toHaveText( + `Members · ${rosterCount}`, + ); + // VirtualizedList stamps each rendered row with its item index, so the + // final item's row is a fixed target that no window sample can pick in + // its place: a virtualizer clamped mid-roster never renders it. + await expect( + memberList.locator( + `[data-index="${rosterCount - 1}"] > [data-testid^="sidebar-member-"]`, + ), ).toBeVisible(); }); diff --git a/desktop/tests/e2e/huddle-transcription.spec.ts b/desktop/tests/e2e/huddle-transcription.spec.ts index 7c56648654a..32b4bcde56a 100644 --- a/desktop/tests/e2e/huddle-transcription.spec.ts +++ b/desktop/tests/e2e/huddle-transcription.spec.ts @@ -4,6 +4,7 @@ import { KIND_HUDDLE_ENDED, KIND_HUDDLE_STARTED, } from "../../src/shared/constants/kinds"; +import { truncateNpub } from "../../src/shared/lib/pubkey"; import { waitForAnimations } from "../helpers/animations"; import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge"; @@ -11,6 +12,15 @@ const HUDDLE_CHANNEL_ID = "11111111-1111-4111-8111-111111111111"; const HUDDLE_PARENT_ID = "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50"; const HUDDLE_THREAD_ROOT_ID = "mock-general-welcome"; +/** + * Initials a key-fallback avatar shows: the two characters just before the + * compact npub label's visible tail — the fragment `getInitials` reads from + * a key label, whose unit suite pins the derivation itself. + */ +function keyTailInitials(pubkey: string) { + return truncateNpub(pubkey).slice(-4, -2).toUpperCase(); +} + async function waitForMockLiveSubscription( page: import("@playwright/test").Page, channelName: string, @@ -1075,10 +1085,19 @@ test("returns to the parent channel when leaving a huddle channel in view", asyn test("keeps the huddle avatar strip compact and exposes the full roster", async ({ page, }) => { - const members = Array.from({ length: 11 }, (_, index) => ({ + // One named member plus ten key-only members (hex 01..0a + "a"×62), with + // the last as the huddle bot. The generated keys' npub tails are what + // key-fallback avatars abbreviate: 01… → npub1qx4…x2tc → X2, + // 02… → npub1q24…levs → LE, 0a… → npub1p24…sqjt → SQ. + const namedMember = "77".repeat(32); + const unnamedMembers = Array.from({ length: 10 }, (_, index) => ({ pubkey: `${(index + 1).toString(16).padStart(2, "0")}${"a".repeat(62)}`, - role: index === 10 ? ("bot" as const) : ("member" as const), + role: index === 9 ? ("bot" as const) : ("member" as const), })); + const members = [ + { pubkey: namedMember, role: "member" as const }, + ...unnamedMembers, + ]; await installMockBridge(page, { huddle: { @@ -1086,6 +1105,7 @@ test("keeps the huddle avatar strip compact and exposes the full roster", async ephemeralChannelId: HUDDLE_CHANNEL_ID, members, }, + searchProfiles: [{ pubkey: namedMember, displayName: "Ada Lovelace" }], }); await page.goto("/"); @@ -1100,13 +1120,44 @@ test("keeps the huddle avatar strip compact and exposes the full roster", async await expect(participantTrigger).toContainText("+2"); await expect(page.getByTestId("profile-huddle-control")).toHaveCount(0); + // Strip avatars stay distinct: the named member keeps name initials + // (AL), and a key-only participant shows its npub's tail (X2), never the + // word initials of the "Participant npub1…" label (PN). + const stripAvatars = participantStrip.getByTestId( + "huddle-participant-avatar", + ); + await expect(stripAvatars.first()).toHaveText("AL"); + await expect(stripAvatars.nth(1)).toHaveText( + keyTailInitials(unnamedMembers[0].pubkey), + ); + await participantTrigger.click(); + const roster = page + .getByRole("dialog") + .filter({ has: page.getByRole("heading", { name: "Participants" }) }); await expect( page.getByRole("heading", { name: "Participants" }), ).toBeVisible(); + const rosterRows = roster.getByRole("listitem"); + // Visible labels keep their generated forms alongside the avatar initials. + await expect(rosterRows.filter({ hasText: "Ada Lovelace" })).toHaveCount(1); + await expect( + rosterRows.filter({ + hasText: `Participant ${truncateNpub(unnamedMembers[0].pubkey)}`, + }), + ).toHaveCount(1); + const agentRow = rosterRows.filter({ + hasText: `Agent ${truncateNpub(unnamedMembers[9].pubkey)}`, + }); + await expect(agentRow).toHaveCount(1); await expect( page.getByRole("button", { name: /Remove Agent .* from huddle/ }), ).toHaveCount(1); + // The roster's agent avatar abbreviates its key tail (SQ), never the + // "Agent" word initials of the prefixed label (AN). + await expect(agentRow.getByTestId("huddle-participant-avatar")).toHaveText( + keyTailInitials(unnamedMembers[9].pubkey), + ); }); test("removes an agent from its menu without showing an extra participant control", async ({ diff --git a/desktop/tests/e2e/mention-recipients.spec.ts b/desktop/tests/e2e/mention-recipients.spec.ts index 0e29ed49327..0b81596780f 100644 --- a/desktop/tests/e2e/mention-recipients.spec.ts +++ b/desktop/tests/e2e/mention-recipients.spec.ts @@ -1,5 +1,5 @@ import { expect, test, type Page } from "@playwright/test"; -import { truncatePubkey } from "../../src/shared/lib/pubkey"; +import { truncateNpub } from "../../src/shared/lib/pubkey"; import { waitForAnimations } from "../helpers/animations"; import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge"; @@ -566,7 +566,7 @@ for (const { kind, scale } of [ .last(); await assertFits(markdown, "sent"); const qualifiedChip = row.locator("[data-mention]").last(); - await expect(qualifiedChip).toHaveText(`Scout (${truncatePubkey(SECOND)})`); + await expect(qualifiedChip).toHaveText(`Scout (${truncateNpub(SECOND)})`); await expect(qualifiedChip).toHaveAttribute( "data-mention-label", `Scout (${SECOND})`, @@ -873,7 +873,7 @@ for (const mismatchedKey of [false, true]) { .getByTestId("message-row") .filter({ hasText: "qualified clipboard roundtrip" }) .locator(`[data-mention-pubkey="${SECOND}"]`); - await expect(chip).toHaveText(`Scout (${truncatePubkey(SECOND)})`); + await expect(chip).toHaveText(`Scout (${truncateNpub(SECOND)})`); const flavors = await chip.evaluate((element) => { const range = document.createRange(); range.selectNode(element); @@ -956,7 +956,7 @@ for (const partial of [false, true]) { .filter({ hasText: "compact collision" }); for (const key of keys) { const chip = row.locator(`[data-mention-pubkey="${key}"]`); - await expect(chip).toHaveText(`Scout (${truncatePubkey(key)})`); + await expect(chip).toHaveText(`Scout (${truncateNpub(key)})`); await expect(chip).toHaveAttribute("title", `Scout (${key})`); const flavors = await chip.evaluate((element, partial) => { const range = document.createRange(); diff --git a/desktop/tests/e2e/mentions.spec.ts b/desktop/tests/e2e/mentions.spec.ts index e9248509fd1..ee3731d16d7 100644 --- a/desktop/tests/e2e/mentions.spec.ts +++ b/desktop/tests/e2e/mentions.spec.ts @@ -1,6 +1,6 @@ import { expect, test } from "@playwright/test"; -import { npubEncode } from "nostr-tools/nip19"; +import { truncateNpub } from "../../src/shared/lib/pubkey"; import { waitForAnimations } from "../helpers/animations"; import { @@ -4535,9 +4535,8 @@ test("clicking author name opens user profile panel", async ({ page }) => { // Click now opens the full profile panel instead of the popover const panel = page.getByTestId("user-profile-panel"); await expect(panel).toBeVisible(); - // The panel's public key row renders through the shared widget, - // which displays the canonical npub form — assert the npub prefix. - await expect(panel).toContainText(npubEncode(MOCK_VIEWER_PUBKEY).slice(0, 8)); + await expect(panel).toContainText(truncateNpub(MOCK_VIEWER_PUBKEY)); + await expect(panel).not.toContainText("deadbeefdeadbeef"); }); test("hovering avatar opens popover, clicking opens profile panel", async ({ @@ -4760,7 +4759,7 @@ test("agent profile popover falls back to the owner's pubkey", async ({ profilePopover.getByTestId( `user-profile-popover-owner-${OWNED_AGENT_PROFILE_PUBKEY}`, ), - ).toHaveText("managed by 11111111…1111"); + ).toHaveText(`managed by ${truncateNpub(CASEY_PROFILE_PUBKEY)}`); }); test("human profile popover does not show an owner", async ({ page }) => { diff --git a/desktop/tests/e2e/pubkey-display-screenshots.spec.ts b/desktop/tests/e2e/pubkey-display-screenshots.spec.ts index 81ff86c3299..8dbdbad005f 100644 --- a/desktop/tests/e2e/pubkey-display-screenshots.spec.ts +++ b/desktop/tests/e2e/pubkey-display-screenshots.spec.ts @@ -1,12 +1,13 @@ import { expect, test } from "@playwright/test"; import { npubEncode } from "nostr-tools/nip19"; +import { truncateNpub } from "../../src/shared/lib/pubkey"; +import { waitForAnimations } from "../helpers/animations"; import { installMockBridge, openNewMessagePage, TEST_IDENTITIES, } from "../helpers/bridge"; -import { waitForAnimations } from "../helpers/animations"; const SHOTS = "test-results/pubkey-display"; @@ -121,7 +122,7 @@ test("new-DM agent name swaps to its public key on name hover", async ({ ) .toBe(true); await expect(settledAgentNpub).not.toHaveCSS("opacity", "0"); - await expect(settledAgentNpub).toHaveText("cafef00d…f00d"); + await expect(settledAgentNpub).toHaveText(truncateNpub(AGENT_PUBKEY)); await expect( settledAgentName.getByText("Pinky", { exact: true }), ).not.toHaveCSS("opacity", "1"); @@ -159,7 +160,7 @@ test("selected new-DM recipient can be verified again through search", async ({ await charlieName.hover(); await expect(charlieNpub).toHaveCSS("opacity", "1"); await expect(charlieNpub).toHaveText( - `${TEST_IDENTITIES.charlie.pubkey.slice(0, 8)}…${TEST_IDENTITIES.charlie.pubkey.slice(-4)}`, + truncateNpub(TEST_IDENTITIES.charlie.pubkey), ); await page.mouse.move(1_100, 500); await expect(charlieNpub).toHaveCSS("opacity", "0"); @@ -262,7 +263,7 @@ test("selected new-DM recipient can be verified again through search", async ({ await charlieName.hover(); await expect(charlieNpub).toHaveCSS("opacity", "1"); await expect(charlieNpub).toHaveText( - `${TEST_IDENTITIES.charlie.pubkey.slice(0, 8)}…${TEST_IDENTITIES.charlie.pubkey.slice(-4)}`, + truncateNpub(TEST_IDENTITIES.charlie.pubkey), ); await expect(charlieName.getByText("charlie", { exact: true })).toHaveCSS( "opacity", diff --git a/desktop/tests/e2e/workflow-local-controls.spec.ts b/desktop/tests/e2e/workflow-local-controls.spec.ts index 6a8319082c8..bf3aca2e2d2 100644 --- a/desktop/tests/e2e/workflow-local-controls.spec.ts +++ b/desktop/tests/e2e/workflow-local-controls.spec.ts @@ -1,6 +1,7 @@ import { expect, test } from "@playwright/test"; import { parse as parseYaml, stringify as stringifyYaml } from "yaml"; +import { truncateNpub } from "../../src/shared/lib/pubkey"; import { waitForAnimations } from "../helpers/animations"; import { installMockBridge } from "../helpers/bridge"; @@ -63,7 +64,9 @@ async function addMessageStep( ) { await dialog.getByRole("button", { name: "Add step", exact: true }).click(); await page.getByRole("menuitem", { name: "Send Message" }).click(); - await dialog.getByLabel("Message text").fill("Workflow notification"); + await dialog + .locator('textarea[id^="wf-step-"][id$="-text"]') + .fill("Workflow notification"); } async function createEnabled( @@ -152,7 +155,7 @@ test("inserts template variables with keyboard control and restores the caret", await dialog.getByRole("button", { name: "Add step", exact: true }).click(); await page.getByRole("menuitem", { name: "Send Message" }).click(); - const textarea = dialog.getByLabel("Message text"); + const textarea = dialog.locator('textarea[id^="wf-step-"][id$="-text"]'); const listbox = page.getByRole("listbox"); await textarea.fill("Hello {{trig"); await expect(listbox).toBeVisible(); @@ -286,6 +289,7 @@ test("round-trips and reopens structured message-text conditions", async ({ await dialog.getByRole("tab", { name: "Form" }).click(); await openTriggerInspector(dialog); + await waitForAnimations(page); const matchControls = dialog.getByRole("group", { name: "Match" }); const operatorButtons = matchControls.getByRole("button"); const firstOperatorBox = await operatorButtons.nth(0).boundingBox(); @@ -640,11 +644,11 @@ test("round-trips manual author and reaction message IDs through save and reopen }); await authorSearch.fill(author); await expect( - dialog.getByRole("option", { name: new RegExp(author.slice(0, 8)) }), + dialog.getByRole("option", { name: truncateNpub(author) }), ).toBeVisible(); await authorSearch.press("Enter"); await expect( - dialog.getByRole("option", { name: new RegExp(author.slice(0, 8)) }), + dialog.getByRole("option", { name: truncateNpub(author) }), ).toHaveAttribute("aria-selected", "true"); await expect(dialog.getByRole("button", { name: "Create" })).toBeEnabled(); await dialog.getByRole("tab", { name: "YAML" }).click(); @@ -663,7 +667,7 @@ test("round-trips manual author and reaction message IDs through save and reopen }); await correctedAuthorSearch.fill(author); await expect( - dialog.getByRole("option", { name: new RegExp(author.slice(0, 8)) }), + dialog.getByRole("option", { name: truncateNpub(author) }), ).toBeVisible(); await correctedAuthorSearch.press("Enter"); await dialog @@ -697,7 +701,7 @@ test("round-trips manual author and reaction message IDs through save and reopen await openTriggerInspector(reopened); await reopened.getByText("Author", { exact: true }).locator("..").click(); await expect( - reopened.getByRole("option", { name: new RegExp(author.slice(0, 8)) }), + reopened.getByRole("option", { name: truncateNpub(author) }), ).toHaveAttribute("aria-selected", "true"); await reopened.getByText("Message", { exact: true }).locator("..").click(); await expect( @@ -738,7 +742,7 @@ test("toggles selected author and message filters while preserving sibling condi .locator(".."); await authorField.getByText("Author", { exact: true }).locator("..").click(); const authorOption = dialog.getByRole("option", { - name: new RegExp(author.slice(0, 8)), + name: truncateNpub(author), }); await expect(authorOption).toHaveAttribute("aria-selected", "true"); await expect( @@ -751,7 +755,7 @@ test("toggles selected author and message filters while preserving sibling condi }); await authorSearch.fill(replacementAuthor); const replacementAuthorOption = dialog.getByRole("option", { - name: new RegExp(replacementAuthor.slice(0, 8)), + name: truncateNpub(replacementAuthor), }); await expect(replacementAuthorOption).toBeVisible(); await authorSearch.press("Enter"); diff --git a/desktop/tests/e2e/workflow-local-controls.spec.ts-snapshots/workflow-template-variable-autocomplete-smoke-darwin.png b/desktop/tests/e2e/workflow-local-controls.spec.ts-snapshots/workflow-template-variable-autocomplete-smoke-darwin.png index 54cc63d5b2a520a9fe9aff54caff40b69d25909f..f0826431ce64832ad04ab2ecdc829a2eacba214d 100644 GIT binary patch literal 103906 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�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 Yco8uZ5-Mox|5L>9k6GbY1iCMpC2$VSMA8h} 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 🤖 ## Summary If two people in a channel share a display name, mobile couldn't tell them apart in mentions: picking the second could overwrite the first's selection, a rendered mention linked whichever same-name person matched first, and a later rename or a shorter name could re-bind the text to the wrong recipient. This PR binds every mention to the exact selected identity: - Each same-name selection keeps its own recipient instead of overwriting by name; conflicting picks get a qualified label like `Name (key…)`. - The longest matching label wins, so a shorter or interior name can never claim part of a longer one and steal its identity. - Rendering resolves recipients by the signed identity key from the event's tags — never from message text alone — so qualified labels stay correct regardless of tag order and survive later renames; an untagged ambiguous label blocks shorter mentions instead of silently re-binding. Ports the landed Desktop exact-recipient behavior (see `docs/mention-editor.md`). ### Related issue - Fixes: N/A. No mobile issue; Desktop's landed exact-recipient fixes are the reference this ports. - Independent base (`main`). #7387 (child) persists these exact selections in saved drafts. - Landing note: branches in this series overlap in the composer — when rebasing, keep exact/durable mention bindings, the explicit invite/reference-only choice, the account/visit/revision fences, and authorization before membership preparation and publication; don't resolve conflicts by taking either side wholesale. - Draft — not requesting merge yet; the security advisory run for this range timed out without results (no verdict). ### Testing - Regressions cover same-name collisions, prefix/overlap, removal, tag order, and renames. - At `acd841354a692243f1cb4c04059baad42f1d2abb`: `just mobile-check` and full `just mobile-test` pass (2,082 tests). The earlier full `just ci` receipt linked below is reused only for unchanged non-mobile code/tooling, not claimed as a rerun at this head. - Previous-head evidence: `just mobile-check`, the full mobile test suite, and full local `just ci` all pass — receipts in the [exact-head evidence comment](https://github.com/block/buzz/pull/7385#issuecomment-5574635836). - Verification is widget-test level; no native device or simulator run is claimed. To see it: mention two teammates with the same display name — both stay distinct, the second shows a qualified label, and the rendered message keeps both correct even after either renames. ### Screenshots Flutter production-widget test renders — not native-device screenshots or acceptance captures. | Scenario | Before | After | |---|---|---| | Qualified mention chip for same-name recipients, at 200% text scale (deliberate stress fixture) | ![Before: the short chip is followed by the distinguishing key spilling out as raw text](https://github.com/user-attachments/assets/f1e40acc-24f1-47f7-b2f7-e3ba2348c169) | ![After: the full qualified label wraps inside the bounded chip, keeping the @ and robot glyph visible](https://github.com/user-attachments/assets/b949e44f-e3f6-4c72-98ff-68139b2e1ab3) |
Capture provenance Rendered by the Flutter widget engine in a `flutter test` run (production widgets, production theme; no device or simulator). Before: this PR's declared base `3c7f288c60d67df78577b237e27c3dfc8831aaa1`. After: its head `39afd73b0adfde14164f4b10dbd089cb498312b6`.
Signed-off-by: Logan Johnson --- .../activity/compose_drafts_provider.dart | 44 ++- mobile/lib/features/channels/compose_bar.dart | 1 + .../compose_bar/agent_mention_labels.dart | 54 ++- .../compose_bar/compose_bar_widget.dart | 62 +++- .../channels/compose_bar/draft_lifecycle.dart | 55 ++- .../markdown_editing_controller.dart | 10 + .../channels/mentions/mention_ranking.dart | 4 + .../features/channels/message_content.dart | 85 +++-- .../lib/shared/mentions/mention_bindings.dart | 88 +++++ .../channels/channel_detail_page_test.dart | 70 ++++ .../features/channels/compose_bar_test.dart | 6 + .../compose_bar_test/exact_mention_tests.dart | 312 ++++++++++++++++++ .../message_content_exact_mentions_test.dart | 51 +++ .../mentions/mention_bindings_test.dart | 56 ++++ 14 files changed, 837 insertions(+), 61 deletions(-) create mode 100644 mobile/lib/shared/mentions/mention_bindings.dart create mode 100644 mobile/test/features/channels/compose_bar_test/exact_mention_tests.dart create mode 100644 mobile/test/features/channels/message_content_exact_mentions_test.dart create mode 100644 mobile/test/shared/mentions/mention_bindings_test.dart diff --git a/mobile/lib/features/activity/compose_drafts_provider.dart b/mobile/lib/features/activity/compose_drafts_provider.dart index b5755f19c8d..cdeb7030923 100644 --- a/mobile/lib/features/activity/compose_drafts_provider.dart +++ b/mobile/lib/features/activity/compose_drafts_provider.dart @@ -22,6 +22,10 @@ class ComposeDraft { final String channelId; final String? threadHeadId; final String text; + + /// Literal picker labels bound to exact keys, never authorization metadata. + /// An empty key/value is a malformed-record tombstone; send must refuse it. + final Map mentionKeys; final int updatedAt; // unix seconds const ComposeDraft({ @@ -30,6 +34,7 @@ class ComposeDraft { required this.threadHeadId, required this.text, required this.updatedAt, + this.mentionKeys = const {}, }); Map toJson() => { @@ -37,6 +42,7 @@ class ComposeDraft { 'channel_id': channelId, if (threadHeadId != null) 'thread_head_id': threadHeadId, 'text': text, + 'mention_keys': mentionKeys, 'updated_at': updatedAt, }; @@ -48,10 +54,32 @@ class ComposeDraft { final updatedAt = raw['updated_at']; if (key is! String || channelId is! String || text is! String) return null; if (text.trim().isEmpty) return null; + final bindings = raw['mention_keys']; + final mentionKeys = {}; + if (raw.containsKey('mention_keys')) { + if (bindings is! Map) { + mentionKeys[''] = ''; + } else { + final labels = {}; + for (final entry in bindings.entries) { + if (!labels.add(entry.key.toLowerCase())) mentionKeys[''] = ''; + final value = entry.value; + mentionKeys[entry.key] = + entry.key.isNotEmpty && + value is String && + RegExp(r'^[0-9a-fA-F]{64}$').hasMatch(value) + ? value.toLowerCase() + : ''; + } + } + } return ComposeDraft( key: key, channelId: channelId, - threadHeadId: raw['thread_head_id'] as String?, + threadHeadId: raw['thread_head_id'] is String + ? raw['thread_head_id'] as String + : null, + mentionKeys: Map.unmodifiable(mentionKeys), text: text, updatedAt: updatedAt is int ? updatedAt : 0, ); @@ -108,18 +136,23 @@ class ComposeDraftsNotifier extends Notifier> { required String channelId, String? threadHeadId, required String text, + Map mentionKeys = const {}, }) { if (text.trim().isEmpty) { remove(key); return; } final existing = state.where((d) => d.key == key).firstOrNull; - if (existing?.text == text) return; + if (existing?.text == text && + mapEquals(existing?.mentionKeys, mentionKeys)) { + return; + } final draft = ComposeDraft( key: key, channelId: channelId, threadHeadId: threadHeadId, text: text, + mentionKeys: Map.unmodifiable(mentionKeys), updatedAt: DateTime.now().millisecondsSinceEpoch ~/ 1000, ); final next = [draft, ...state.where((d) => d.key != key)]; @@ -131,8 +164,11 @@ class ComposeDraftsNotifier extends Notifier> { _persist([...state.where((d) => d.key != key)]); } - String? textFor(String key) => - state.where((d) => d.key == key).firstOrNull?.text; + /// Read text and selected identities from the same persisted snapshot. + ComposeDraft? draftFor(String key) => + state.where((d) => d.key == key).firstOrNull; + + String? textFor(String key) => draftFor(key)?.text; void _persist(List drafts) { state = List.unmodifiable(drafts); diff --git a/mobile/lib/features/channels/compose_bar.dart b/mobile/lib/features/channels/compose_bar.dart index 726528a23e6..71ba39d14ec 100644 --- a/mobile/lib/features/channels/compose_bar.dart +++ b/mobile/lib/features/channels/compose_bar.dart @@ -18,6 +18,7 @@ import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:nostr/nostr.dart' as nostr; import '../../shared/mentions/agent_identity_provider.dart'; +import '../../shared/mentions/mention_bindings.dart'; import '../../shared/huddle/huddle_session.dart'; import '../../shared/relay/relay.dart'; import '../../shared/theme/theme.dart'; diff --git a/mobile/lib/features/channels/compose_bar/agent_mention_labels.dart b/mobile/lib/features/channels/compose_bar/agent_mention_labels.dart index bc29d5bb468..b988c3040ec 100644 --- a/mobile/lib/features/channels/compose_bar/agent_mention_labels.dart +++ b/mobile/lib/features/channels/compose_bar/agent_mention_labels.dart @@ -1,10 +1,54 @@ part of '../compose_bar.dart'; Set _agentMentionLabels({ - required Iterable candidates, -}) { - return { - for (final candidate in candidates) - if (candidate.isAgent) candidate.label, + required Map bindings, +}) => { + for (final entry in bindings.entries) + if (entry.value.isAgent) entry.key, +}; + +List _resolveComposerMentions( + String text, + Map selected, + List members, + List restoredCandidates, +) { + // A broken record cannot fall back to a same-name roster entry. + if (selected.values.any((c) => c.pubkey.isEmpty)) { + throw const FormatException( + 'Saved mention identity is invalid. Clear the draft and select again.', + ); + } + final candidates = >{ + for (final e in selected.entries) e.key.toLowerCase(): [e.value], }; + final selectedNames = candidates.keys.toSet(); + for (final member in members) { + final label = member.label.toLowerCase(); + if (!selectedNames.contains(label)) (candidates[label] ??= []).add(member); + } + final winners = {}; + for (final range in mentionOccurrences(text, candidates.keys)) { + final identities = { + for (final c in candidates[range.label]!) c.pubkey.toLowerCase(): c, + }; + if (identities.length > 1) { + throw FormatException( + 'The mention @${range.label} is ambiguous. Choose a recipient from the mention picker.', + ); + } + for (final entry in identities.entries) { + final selected = entry.value; + final current = restoredCandidates + .where((c) => c.pubkey == entry.key) + .firstOrNull; + if (selected.requiresRevalidation && current == null) { + throw const FormatException( + 'Saved mention is no longer available. Select it again from the picker.', + ); + } + winners[entry.key] = selected.requiresRevalidation ? current! : selected; + } + } + return winners.values.toList(); } diff --git a/mobile/lib/features/channels/compose_bar/compose_bar_widget.dart b/mobile/lib/features/channels/compose_bar/compose_bar_widget.dart index ca34ffd2a1b..dbf8b8f0fbe 100644 --- a/mobile/lib/features/channels/compose_bar/compose_bar_widget.dart +++ b/mobile/lib/features/channels/compose_bar/compose_bar_widget.dart @@ -86,7 +86,9 @@ class ComposeBar extends HookConsumerWidget { attachments: attachments, ); final voiceNoteRef = useRef(voiceNote)..value = voiceNote; + final mentionMap = useRef({}); _useComposeDraftLifecycle( + mentionMap: mentionMap, ref: ref, controller: controller, draftKey: draftKey, @@ -219,7 +221,6 @@ class ComposeBar extends HookConsumerWidget { // Map of displayName → selected mention candidate built as the user selects // mentions. Used to pass resolved pubkeys directly to onSend and to attach // selected non-member agents before the message is published. - final mentionMap = useRef({}); // Channel autocomplete state ---------------------------------------------- final channelQuery = useState(null); @@ -244,9 +245,7 @@ class ComposeBar extends HookConsumerWidget { // owners so @mention suggestions show names ("managed by …" included). final relayAgents = ref.watch(agentDirectoryProvider).asData?.value; final agentOwners = ref.watch(agentOwnersProvider).asData?.value; - final agentMentionLabels = _agentMentionLabels( - candidates: mentionMap.value.values, - ); + final agentMentionLabels = _agentMentionLabels(bindings: mentionMap.value); final agentMentionLabelsKey = (agentMentionLabels.toList()..sort()).join( '\u0000', ); @@ -263,6 +262,9 @@ class ComposeBar extends HookConsumerWidget { ); final pubkeys = [ ...memberList.map((m) => m.pubkey), + ...mentionMap.value.values + .where((c) => c.requiresRevalidation && c.pubkey.isNotEmpty) + .map((c) => c.pubkey), ...?relayAgents?.map((a) => a.pubkey), ...?agentOwners?.values, ]; @@ -272,6 +274,8 @@ class ComposeBar extends HookConsumerWidget { return null; }, [ + draftIdentity, + draftKey, membersAsync.asData?.value.length, cachedMembers.length, relayAgents?.length, @@ -382,7 +386,10 @@ class ComposeBar extends HookConsumerWidget { // Insert a selected mention into the text field. void insertMention(MentionCandidate candidate) { - final name = candidate.label; + final name = selectedMentionLabel(candidate.label, candidate.pubkey, { + for (final entry in mentionMap.value.entries) + entry.key: entry.value.pubkey, + }); // Track the resolved candidate so we can pass its pubkey and prepare // selected non-member agents at send time. mentionMap.value[name] = candidate; @@ -470,10 +477,45 @@ class ComposeBar extends HookConsumerWidget { final messenger = ScaffoldMessenger.maybeOf(context); // Extract pubkeys for mentions present in the final text. - final selectedMentions = [ - for (final entry in mentionMap.value.entries) - if (hasMention(text, entry.key)) entry.value, - ]; + List selectedMentions; + try { + selectedMentions = _resolveComposerMentions( + text, + mentionMap.value, + buildMentionCandidates( + members: channelMembersForAutocomplete( + membersAsync: membersAsync, + sessionStatus: sessionStatus, + cachedMembers: cachedMembers, + ), + relayAgents: const [], + sharedChannelIds: const {}, + userCache: userCache, + ownerByAgentPubkey: agentOwners ?? const {}, + ), + buildMentionCandidates( + members: membersAsync.asData?.value ?? const [], + relayAgents: relayAgents ?? const [], + sharedChannelIds: { + for (final c in channels) + if (c.isMember && !c.isArchived) c.id, + }, + userCache: userCache, + ownerByAgentPubkey: agentOwners ?? const {}, + currentPubkey: currentPubkey, + // Reuse ordinary search-result classification, not membership as + // permission. Persisted keys/flags themselves prove no role. + searchResults: [ + for (final c in mentionMap.value.values) + if (c.requiresRevalidation && userCache[c.pubkey] != null) + userCache[c.pubkey]!, + ], + ), + ); + } on FormatException catch (error) { + messenger?.showSnackBar(SnackBar(content: Text(error.message))); + return; + } final outgoing = _OutgoingMentions(selectedMentions); final scan = await _scanNonMemberMentions( ref, @@ -589,12 +631,12 @@ class ComposeBar extends HookConsumerWidget { if (context.mounted && queueGeneration == uploadGeneration.value && draftRevision.value == clearedDraftRevision) { - controller.value = draftText; attachments.value = draftAttachments; retainedForRetry = true; mentionMap.value ..clear() ..addAll(draftMentions); + controller.value = draftText; focusNode.requestFocus(); } } finally { diff --git a/mobile/lib/features/channels/compose_bar/draft_lifecycle.dart b/mobile/lib/features/channels/compose_bar/draft_lifecycle.dart index b712091b716..5ef69eaa59b 100644 --- a/mobile/lib/features/channels/compose_bar/draft_lifecycle.dart +++ b/mobile/lib/features/channels/compose_bar/draft_lifecycle.dart @@ -26,10 +26,10 @@ Future _sendTextOnlyDraft({ draftRevision.value != clearedDraftRevision) { return; } - controller.value = clearedDraftText; mentionMap.value ..clear() ..addAll(clearedDraftMentions); + controller.value = clearedDraftText; focusNode.requestFocus(); } @@ -63,6 +63,7 @@ Future _sendTextOnlyDraft({ } void _useComposeDraftLifecycle({ + required ObjectRef> mentionMap, required WidgetRef ref, required _MarkdownEditingController controller, required String draftKey, @@ -82,11 +83,25 @@ void _useComposeDraftLifecycle({ }) { final lastDraftIdentity = useRef(null); useEffect(() { + final identity = '$draftIdentity\u0000$draftKey'; + final shouldHydrate = lastDraftIdentity.value != identity; final identityChanged = - lastDraftIdentity.value != null && - lastDraftIdentity.value != draftIdentity; - lastDraftIdentity.value = draftIdentity; - final saved = ref.read(composeDraftsProvider.notifier).textFor(draftKey); + lastDraftIdentity.value != null && lastDraftIdentity.value != identity; + lastDraftIdentity.value = identity; + final saved = ref.read(composeDraftsProvider.notifier).draftFor(draftKey); + if (shouldHydrate) { + mentionMap.value + ..clear() + ..addAll({ + for (final entry + in (saved?.mentionKeys ?? {}).entries) + entry.key: MentionCandidate( + pubkey: entry.value, + displayName: entry.key, + requiresRevalidation: true, + ), + }); + } if (identityChanged) { draftRevision.value += 1; onDraftIdentityChanged(); @@ -101,15 +116,38 @@ void _useComposeDraftLifecycle({ final staleAttachments = attachments.value; attachments.value = const []; unawaited(_deleteOwnedAttachments(staleAttachments)); - controller.text = saved ?? ''; + controller.text = saved?.text ?? ''; } else if (saved != null && controller.text.isEmpty) { - controller.text = saved; + controller.text = saved.text; } var lastPersistedText = controller.text; + var lastBindings = { + for (final e in mentionMap.value.entries) e.key: e.value.pubkey, + }; void persistDraft() { final text = controller.text; - if (text == lastPersistedText) return; + if (text != lastPersistedText) { + // Prune before the atomic snapshot, not in a later editor listener. + // Code formatting hides a binding without deleting its literal. + final retained = mentionOccurrences( + text.replaceAll('`', ' '), + mentionMap.value.keys, + ).map((range) => range.label).toSet(); + // Whole-record taint has no literal to prune. Removing every @ is a + // safe reset; unrelated edits must not turn lost keys into name lookup. + mentionMap.value.removeWhere( + (label, _) => + label.isEmpty ? !text.contains('@') : !retained.contains(label), + ); + } + final bindings = { + for (final e in mentionMap.value.entries) e.key: e.value.pubkey, + }; + if (text == lastPersistedText && mapEquals(bindings, lastBindings)) { + return; + } + lastBindings = bindings; lastPersistedText = text; draftRevision.value += 1; ref @@ -119,6 +157,7 @@ void _useComposeDraftLifecycle({ channelId: channelId, threadHeadId: threadHeadId, text: text, + mentionKeys: bindings, ); } diff --git a/mobile/lib/features/channels/compose_bar/markdown_editing_controller.dart b/mobile/lib/features/channels/compose_bar/markdown_editing_controller.dart index 2ae71ac110b..2862aad1f37 100644 --- a/mobile/lib/features/channels/compose_bar/markdown_editing_controller.dart +++ b/mobile/lib/features/channels/compose_bar/markdown_editing_controller.dart @@ -282,6 +282,16 @@ class _MarkdownEditingController extends TextEditingController { if (prefix.isNotEmpty) spans.add(TextSpan(text: prefix, style: style)); final label = match.group(2)!; + if (RegExp(r'\([0-9a-f]{64}\)').hasMatch(label)) { + spans.add( + TextSpan( + text: '@$label', + style: style.copyWith(color: context.colors.primary), + ), + ); + offset = match.end; + continue; + } spans.add( WidgetSpan( alignment: PlaceholderAlignment.baseline, diff --git a/mobile/lib/features/channels/mentions/mention_ranking.dart b/mobile/lib/features/channels/mentions/mention_ranking.dart index 7706df4bdfc..fec710942db 100644 --- a/mobile/lib/features/channels/mentions/mention_ranking.dart +++ b/mobile/lib/features/channels/mentions/mention_ranking.dart @@ -7,6 +7,9 @@ import '../../../shared/utils/string_utils.dart'; @immutable class MentionCandidate { final String pubkey; + + /// Restored identity only: eligibility must come from current community state. + final bool requiresRevalidation; final String? displayName; final String? secondaryLabel; final String? avatarUrl; @@ -17,6 +20,7 @@ class MentionCandidate { const MentionCandidate({ required this.pubkey, + this.requiresRevalidation = false, this.displayName, this.secondaryLabel, this.avatarUrl, diff --git a/mobile/lib/features/channels/message_content.dart b/mobile/lib/features/channels/message_content.dart index b94954c00cf..f2cccbaa237 100644 --- a/mobile/lib/features/channels/message_content.dart +++ b/mobile/lib/features/channels/message_content.dart @@ -14,6 +14,8 @@ import 'package:url_launcher/url_launcher.dart'; import 'package:video_player/video_player.dart'; import '../../shared/clipboard_utils.dart'; +import '../../shared/mentions/mention_bindings.dart'; +import '../../shared/mentions/mention_tags.dart'; import '../../shared/deeplink/deep_link.dart'; import '../../shared/deeplink/pending_deep_link_provider.dart'; import '../../shared/relay/relay.dart'; @@ -158,6 +160,12 @@ class MessageContent extends HookConsumerWidget { baseStyle ?? context.textTheme.bodyMedium?.copyWith(color: context.colors.onSurface); final resolvedMentionNames = mentionNames; + final signedMentionPubkeys = mentionedPubkeysFromTags(tags); + final mentionBindings = renderedMentionBindings( + content, + mentionNames, + signedMentionPubkeys, + ); final resolvedAgentMentionPubkeys = { ...agentMentionPubkeys.map((pubkey) => pubkey.toLowerCase()), }; @@ -196,6 +204,7 @@ class MessageContent extends HookConsumerWidget { ..sort((a, b) => a.key.compareTo(b.key)))) '${entry.key}\u0000${entry.value}', ...(resolvedAgentMentionPubkeys.toList()..sort()), + ...(signedMentionPubkeys.toList()..sort()), ].join('\u0001'); // Decided here rather than by the caller: this is where the event's own @@ -235,14 +244,15 @@ class MessageContent extends HookConsumerWidget { mentionBuf.write('`${mentionParts[i]}`'); } else { var segment = mentionParts[i]; - for (final name in resolvedMentionNames.values) { - if (name.contains(' ')) { - final normalizedName = _markdownMentionName(name); - segment = segment.replaceAllMapped( - RegExp('@${RegExp.escape(name)}', caseSensitive: false), - (m) => '@$normalizedName', - ); - } + for (final range in mentionOccurrences( + segment, + mentionBindings.keys, + ).reversed) { + segment = segment.replaceRange( + range.start, + range.end, + '@${_markdownMentionName(range.label)}', + ); } mentionBuf.write(segment); } @@ -288,6 +298,14 @@ class MessageContent extends HookConsumerWidget { inlineComponents: [ _MentionMd( mentionNames: resolvedMentionNames, + bindings: mentionBindings, + displayLabels: { + for (final range in mentionOccurrences( + content, + mentionBindings.keys, + )) + range.label: content.substring(range.start + 1, range.end), + }, agentMentionPubkeys: resolvedAgentMentionPubkeys, onMentionTap: onMentionTap, ), @@ -807,16 +825,20 @@ class _MessageCodeBlock extends HookWidget { } class _MentionMd extends InlineMd { + final Map> bindings; + final Map displayLabels; final Map mentionNames; final Set agentMentionPubkeys; final void Function(String pubkey)? onMentionTap; late final RegExp _exp = _buildPrefixPattern( prefix: '@', - knownNames: _mentionAliases(mentionNames.values), + knownNames: bindings.keys.map(_markdownMentionName), genericTokenPattern: r'[A-Za-z0-9_][A-Za-z0-9_\u00A0-]*', ); _MentionMd({ + required this.bindings, + required this.displayLabels, required this.mentionNames, required this.agentMentionPubkeys, this.onMentionTap, @@ -837,22 +859,25 @@ class _MentionMd extends InlineMd { } final name = raw.substring(1).replaceAll('\u00A0', ' ').toLowerCase(); - String? displayName; - String? pubkey; - for (final entry in mentionNames.entries) { - final entryName = entry.value.toLowerCase(); - final firstName = entryName.split(RegExp(r'\s+')).first; - if (entryName == name || firstName == name) { - displayName = entry.value; - pubkey = entry.key; - break; - } + final matches = bindings[name] ?? const {}; + final pubkey = matches.length == 1 ? matches.single : null; + if (bindings.containsKey(name) && matches.length != 1) { + return TextSpan(text: text, style: config.style); } + final displayName = name.contains(RegExp(r'\([0-9a-f]{64}\)')) + ? displayLabels[name] + : mentionNames[pubkey]; final isAgent = pubkey != null && agentMentionPubkeys.contains(pubkey.toLowerCase()); + final fullLabel = displayName ?? raw.substring(1); + final visibleLabel = fullLabel.replaceAllMapped( + RegExp(r'\(([0-9a-f]{64})\)'), + (m) => '(${m[1]!.substring(0, 8)}…${m[1]!.substring(60)})', + ); final pill = _MentionPill( - label: displayName ?? raw.substring(1), + label: visibleLabel, + semanticsLabel: fullLabel, isAgent: isAgent, textStyle: config.style, ); @@ -861,7 +886,7 @@ class _MentionMd extends InlineMd { alignment: PlaceholderAlignment.baseline, baseline: TextBaseline.alphabetic, child: pubkey != null && onMentionTap != null - ? GestureDetector(onTap: () => onMentionTap!(pubkey!), child: pill) + ? GestureDetector(onTap: () => onMentionTap!(pubkey), child: pill) : pill, ); } @@ -869,11 +894,13 @@ class _MentionMd extends InlineMd { class _MentionPill extends StatelessWidget { final String label; + final String? semanticsLabel; final bool isAgent; final TextStyle? textStyle; const _MentionPill({ required this.label, + this.semanticsLabel, required this.isAgent, this.textStyle, }); @@ -920,7 +947,9 @@ class _MentionPill extends StatelessWidget { offset: const Offset(0, -Grid.quarter), child: Text('@', style: style), ), - Text(label, style: style), + Flexible( + child: Text(label, style: style, semanticsLabel: semanticsLabel), + ), ], ), ); @@ -928,15 +957,3 @@ class _MentionPill extends StatelessWidget { } String _markdownMentionName(String name) => name.replaceAll(' ', '\u00A0'); - -Iterable _mentionAliases(Iterable mentionNames) sync* { - for (final name in mentionNames) { - final trimmed = name.trim(); - if (trimmed.isEmpty) continue; - yield _markdownMentionName(trimmed); - final firstName = trimmed.split(RegExp(r'\s+')).first; - if (firstName.isNotEmpty) { - yield firstName; - } - } -} diff --git a/mobile/lib/shared/mentions/mention_bindings.dart b/mobile/lib/shared/mentions/mention_bindings.dart new file mode 100644 index 00000000000..8b2305c1328 --- /dev/null +++ b/mobile/lib/shared/mentions/mention_bindings.dart @@ -0,0 +1,88 @@ +/// Reserve an exact label without retargeting an earlier selection. +String selectedMentionLabel( + String name, + String pubkey, + Map bindings, +) { + final normalized = { + for (final e in bindings.entries) + e.key.toLowerCase(): e.value.toLowerCase(), + }; + bool conflicts(String label) => + normalized.containsKey(label.toLowerCase()) && + normalized[label.toLowerCase()] != pubkey.toLowerCase(); + if (!conflicts(name)) return name; + final qualified = '$name (${pubkey.toLowerCase()})'; + var label = qualified; + for (var suffix = 2; conflicts(label); suffix++) { + label = '$qualified $suffix'; + } + return label; +} + +/// Longest literal ranges win, including labels containing another @ sign. +/// Recognition precedes eligibility: ambiguous labels still block shorter ones. +List<({int start, int end, String label})> mentionOccurrences( + String text, + Iterable labels, +) { + final matches = {}; + for (final label in labels) { + if (label.isEmpty) continue; + // A qualifier and reservation suffix belong to the literal even when no + // candidate binds it. Never fall back to a shorter, different recipient. + final suffix = + RegExp(r' \([0-9a-f]{64}\)$', caseSensitive: false).hasMatch(label) + ? r'(?! (?:[2-9]|[1-9][0-9]+)(?=[\s,;.!?:)\]}*_]|$))' + : ''; + final pattern = RegExp( + '(?:^|\\s|[*_]{1,3}|\\|\\|)(@${RegExp.escape(label)})(?! \\([0-9a-f]{64}\\))$suffix(?=\\|\\||[\\s,;.!?:)\\]}*_]|\$)', + caseSensitive: false, + ); + for (final match in pattern.allMatches(text)) { + final start = match.end - match.group(1)!.length; + if (matches[start] == null || matches[start]!.end < match.end) { + matches[start] = (start: start, end: match.end, label: label); + } + } + } + final result = <({int start, int end, String label})>[]; + for (final match + in matches.values.toList()..sort((a, b) => a.start.compareTo(b.start))) { + if (result.isEmpty || match.start >= result.last.end) result.add(match); + } + return result; +} + +/// Bind tagged qualifiers and ordinary aliases, never historical namesakes. +Map> renderedMentionBindings( + String content, + Map names, [ + Iterable signedPubkeys = const [], +]) { + final bindings = >{}; + void add(String label, String key) => + (bindings[label.toLowerCase()] ??= {}).add(key.toLowerCase()); + for (final entry in names.entries) { + add(entry.value, entry.key); + add(entry.value.split(RegExp(r'\s+')).first, entry.key); + } + final keys = signedPubkeys.map((key) => key.toLowerCase()).toSet(); + for (final match in RegExp( + r'@([^@\r\n]+) \(([0-9a-f]{64})\)(?: ((?:[1-9][0-9]+|[2-9])))?', + caseSensitive: false, + ).allMatches(content)) { + final key = match.group(2)!.toLowerCase(); + final label = match.group(0)!.substring(1).toLowerCase(); + if (!mentionOccurrences(content, [ + label, + ]).any((range) => range.start == match.start)) { + continue; + } + // Text can deny historical aliases without a profile, never authorize keys. + bindings[label] = keys.contains(key) ? {key} : {}; + // Recipient tags/current names cannot establish the historical plain owner. + bindings[match.group(1)!.toLowerCase()] = {}; + } + return bindings; +} diff --git a/mobile/test/features/channels/channel_detail_page_test.dart b/mobile/test/features/channels/channel_detail_page_test.dart index 23f547dbad0..7159fb2fd1e 100644 --- a/mobile/test/features/channels/channel_detail_page_test.dart +++ b/mobile/test/features/channels/channel_detail_page_test.dart @@ -476,6 +476,76 @@ void main() { _testPrefs = await SharedPreferences.getInstance(); }); + for (final thread in [false, true]) { + for (final reverse in [false, true]) { + testWidgets('signed qualified caller thread=$thread reverse=$reverse', ( + tester, + ) async { + final first = 'a' * 64, second = 'b' * 64, sibling = 'c' * 64; + Future tapProfile(String label, String key) async { + await tester.tap(find.text(label)); + await tester.pumpAndSettle(); + expect( + tester + .widget(find.byType(UserProfileSheet)) + .pubkey, + key, + ); + await tester.tap(find.byTooltip('Close sheet')); + await tester.pumpAndSettle(); + } + + for (final firstName in ['Scout', 'Renamed Scout', first, null]) { + for (final secondName in [null, 'Scout', 'Renamed Scout', second]) { + for (final bystander in [null, 'Bob', 'Scout']) { + final names = { + first: ?firstName, + second: ?secondName, + sibling: 'Alice', + 'd' * 64: ?bystander, + }; + final keys = [ + first, + second.toUpperCase(), + sibling, + if (bystander != null) 'd' * 64, + ]; + final event = _textMsg( + id: 'qualified', + pubkey: 'author', + content: '@Scout @Scout ($second) @Alice @Other (${'e' * 64})', + extraTags: [ + for (final key in reverse ? keys.reversed : keys) ['p', key], + ], + ); + await tester.pumpWidget( + _buildTestable( + messages: [event], + users: { + for (final e in names.entries) + e.key: UserProfile(pubkey: e.key, displayName: e.value), + }, + threadReplies: const {'qualified': []}, + initialThreadRootId: thread ? 'qualified' : null, + ), + ); + await tester.pumpAndSettle(); + await tapProfile('Scout (bbbbbbbb…bbbb)', second); + expect(find.text('Scout'), findsNothing); + expect(find.text('Bob'), findsNothing); + if (firstName != null) expect(find.text(firstName), findsNothing); + expect(find.text('Other (eeeeeeee…eeee)'), findsNothing); + await tapProfile('Alice', sibling); + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pumpAndSettle(); + } + } + } + }); + } + } + group('ChannelDetailPage', () { testWidgets( 'bot-role author avatars stay squircles in channel and thread', diff --git a/mobile/test/features/channels/compose_bar_test.dart b/mobile/test/features/channels/compose_bar_test.dart index b8025112a25..34a91e7ea3b 100644 --- a/mobile/test/features/channels/compose_bar_test.dart +++ b/mobile/test/features/channels/compose_bar_test.dart @@ -16,6 +16,7 @@ import 'package:nostr/nostr.dart' as nostr; import 'package:buzz/features/channels/channel.dart'; import 'package:buzz/features/channels/channel_management_provider.dart'; import 'package:buzz/features/channels/compose_bar.dart'; +import 'package:buzz/features/channels/send_message_provider.dart'; import 'package:buzz/features/channels/channels_provider.dart'; import 'package:buzz/features/channels/photo_library.dart'; import 'package:buzz/features/channels/voice_note_play_pause_icon.dart'; @@ -25,6 +26,8 @@ import 'package:buzz/shared/custom_emoji/custom_emoji.dart'; import 'package:buzz/shared/custom_emoji/custom_emoji_provider.dart'; import 'package:buzz/shared/mentions/agent_identity_provider.dart'; import 'package:buzz/shared/relay/relay.dart'; +import 'package:buzz/shared/profile/user_cache_provider.dart'; +import 'package:buzz/shared/profile/user_profile.dart'; import 'package:buzz/shared/theme/theme.dart'; import 'package:buzz/shared/utils/string_utils.dart'; import 'package:buzz/shared/widgets/anchored_popover_menu.dart'; @@ -32,6 +35,8 @@ import 'package:buzz/shared/widgets/avatar_image.dart'; import 'package:buzz/shared/widgets/mobile_tab_footer_backdrop.dart'; import 'package:shared_preferences/shared_preferences.dart'; +part 'compose_bar_test/exact_mention_tests.dart'; + final _pngBytes = Uint8List.fromList([ 0x89, 0x50, @@ -643,6 +648,7 @@ class _FakeChannelsNotifier extends ChannelsNotifier { } void main() { + exactMentionTests(); TestWidgetsFlutterBinding.ensureInitialized(); setUp(() async { diff --git a/mobile/test/features/channels/compose_bar_test/exact_mention_tests.dart b/mobile/test/features/channels/compose_bar_test/exact_mention_tests.dart new file mode 100644 index 00000000000..e7b04ddfdb2 --- /dev/null +++ b/mobile/test/features/channels/compose_bar_test/exact_mention_tests.dart @@ -0,0 +1,312 @@ +part of '../compose_bar_test.dart'; + +void exactMentionTests() { + final first = 'a' * 64; + final second = 'b' * 64; + List members() => [ + for (final key in [first, second]) + ChannelMember( + pubkey: key, + displayName: 'Scout', + role: 'member', + joinedAt: DateTime(2025), + ), + ]; + Future mountComposer( + WidgetTester tester, + List roster, + ComposeBarOnSend onSend, { + nostr.Keys? signer, + }) async { + await tester.pumpWidget( + _buildComposeBar( + uploadService: _testUploadService( + (signer ?? nostr.Keys.generate()).nsec, + ), + members: roster, + channels: [_makeCurrentChannel()], + currentPubkey: signer?.public, + relayConfig: signer == null + ? null + : () => _SwitchableRelayConfigNotifier( + RelayConfig( + baseUrl: 'https://relay.example', + nsec: signer.nsec, + ), + ), + onSend: onSend, + ), + ); + if (find.byType(TextField).evaluate().isEmpty && + find.textContaining('@Scout').evaluate().isNotEmpty) { + // A restored draft displays its text, not the empty composer hint. + await tester.tap(find.textContaining('@Scout')); + await tester.pumpAndSettle(); + } else { + await _expandComposer(tester); + } + } + + Future pick( + WidgetTester tester, + String text, { + bool last = false, + }) async { + await tester.enterText(find.byType(TextField), text); + await tester.pumpAndSettle(); + await tester.tap(last ? find.text('Scout').last : find.text('Scout').first); + await tester.pumpAndSettle(); + } + + for (final scenario in [ + 'rename', + 'map edited', + 'map reset', + 'map plain', + 'map reselected', + 'removed', + 'legacy', + 'malformed', + 'tainted', + 'non-member human', + 'non-member denied agent', + ]) { + testWidgets('persisted selection restore to signed send: $scenario', ( + tester, + ) async { + final signer = nostr.Keys.generate(); + final events = >[]; + late SendMessage sendMessage; + Future mount(List roster) async { + await mountComposer( + tester, + roster, + (text, keys, {mediaTags = const []}) => sendMessage( + channelId: 'channel-1', + content: text, + mentionPubkeys: keys, + mediaTags: mediaTags, + ), + signer: signer, + ); + final container = ProviderScope.containerOf( + tester.element(find.byType(ComposeBar)), + ); + final session = container.read(relaySessionProvider.notifier); + session.debugAttachSocketForTest( + _RecordingRelaySocket( + events, + session.debugHandleSocketMessageForTest, + ), + ); + sendMessage = SendMessage( + signedEventRelay: SignedEventRelay( + session: session, + nsec: signer.nsec, + ), + fetchMembers: (_) async => roster, + readUserCache: () => const {}, + addLocalMessage: (_, _) {}, + completeLocalMessage: (_, _) {}, + removeLocalMessage: (_, _) {}, + ); + } + + await mount(members()); + await pick(tester, '@'); + final prefsKey = + 'compose_drafts_v1:https://relay.example:${signer.public}'; + final saved = jsonDecode(_testPrefs.getString(prefsKey)!) as List; + expect(saved.single['text'], '@Scout '); + expect(saved.single['mention_keys'], {'Scout': first}); + // Dispose the actual provider state, not just the text controller. + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pumpAndSettle(); + if (scenario.startsWith('map ')) saved.single['mention_keys'] = null; + if (scenario == 'legacy') saved.single.remove('mention_keys'); + if (scenario == 'malformed') { + saved.single['mention_keys'] = {'Scout': 'not-a-key'}; + } + if (scenario == 'tainted') { + saved.single['mention_keys'] = { + 'Scout': {'pubkey': second, 'is_agent': true}, + }; + } + await _testPrefs.setString(prefsKey, jsonEncode(saved)); + await mount([ + if (![ + 'removed', + 'non-member human', + 'non-member denied agent', + ].contains(scenario)) + ChannelMember( + pubkey: first, + displayName: 'Renamed', + role: 'member', + joinedAt: DateTime(2025), + ), + members().last, + ]); + if (scenario.startsWith('non-member')) { + ProviderScope.containerOf(tester.element(find.byType(ComposeBar))) + .read(userCacheProvider.notifier) + .put( + UserProfile( + pubkey: first, + displayName: 'Renamed', + ownerPubkey: scenario == 'non-member denied agent' + ? second + : null, + ), + ); + await tester.pumpAndSettle(); + } + expect( + tester.widget(find.byType(TextField)).controller!.text, + '@Scout ', + ); + var expectedText = '@Scout '; + if (scenario.startsWith('map ')) { + expectedText = '@Scout hello'; + await tester.enterText(find.byType(TextField), expectedText); + await tester.pumpAndSettle(); + expect( + jsonDecode(_testPrefs.getString(prefsKey)!).single['mention_keys'], + {'': ''}, + ); + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pumpAndSettle(); + await mount(members()); + if (scenario != 'map edited') { + expectedText = scenario == 'map plain' ? 'hello' : ''; + await tester.enterText(find.byType(TextField), expectedText); + await tester.pumpAndSettle(); + if (scenario != 'map plain') { + await pick(tester, '@', last: scenario == 'map reselected'); + expectedText = '@Scout '; + } + } + } + await tester.tap(find.byIcon(LucideIcons.arrowUp)); + await tester.pumpAndSettle(); + if (scenario == 'non-member human') { + expect( + find.textContaining('Renamed is not in this channel'), + findsOneWidget, + ); + await tester.tap(find.text('Do nothing')); + await tester.pumpAndSettle(); + } + final messages = events.where( + (e) => e['kind'] == EventKind.streamMessage, + ); + if ([ + 'rename', + 'legacy', + 'non-member human', + 'map reset', + 'map plain', + 'map reselected', + ].contains(scenario)) { + final event = messages.single; + expect(event['content'], expectedText.trim()); + expect((event['tags'] as List).where((t) => t[0] == 'p').toList(), [ + if (scenario != 'non-member human' && scenario != 'map plain') + [ + 'p', + ['legacy', 'map reselected'].contains(scenario) ? second : first, + ], + ]); + if (scenario == 'non-member human') { + expect( + (event['tags'] as List).where((t) => t[0] == 'mention').toList(), + [ + ['mention', first], + ], + ); + expect(events.where((e) => e['kind'] == 9000), isEmpty); + } + expect(event['pubkey'], signer.public); + expect(event['sig'], matches(RegExp(r'^[0-9a-f]{128}$'))); + expect(nostr.Event.fromMap(event).id, event['id']); + } else { + expect(messages, isEmpty); + expect(events.where((e) => e['kind'] == 9000), isEmpty); + expect(find.textContaining('Saved mention'), findsOneWidget); + expect( + tester.widget(find.byType(TextField)).controller!.text, + expectedText, + ); + } + // Refusal leaves autocomplete open. Dispose before draining its existing + // 250ms search debounce; no send assertion depends on this cleanup pump. + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pump(const Duration(milliseconds: 250)); + }); + } + testWidgets( + 'same-name picker selections retain exact recipients through removal', + (tester) async { + List? sent; + await mountComposer( + tester, + members(), + (_, keys, {mediaTags = const []}) async => sent = keys, + ); + await pick(tester, '@'); + final controller = tester + .widget(find.byType(TextField)) + .controller!; + expect(controller.text, '@Scout '); + await pick(tester, '@Scout @', last: true); + expect(controller.text, '@Scout @Scout ($second) '); + await tester.tap(find.byIcon(LucideIcons.arrowUp)); + await tester.pumpAndSettle(); + expect(sent, [first, second]); + await pick(tester, '@'); + await pick(tester, '@Scout @', last: true); + await tester.enterText(find.byType(TextField), '@Scout ($second) '); + await tester.pumpAndSettle(); + await tester.tap(find.byIcon(LucideIcons.arrowUp)); + await tester.pumpAndSettle(); + expect(sent, [second]); + }, + ); + + testWidgets('unbound qualified text cannot notify a shorter member alias', ( + tester, + ) async { + List? sent; + await mountComposer(tester, [ + members().first, + ], (_, keys, {mediaTags = const []}) async => sent = keys); + await tester.enterText(find.byType(TextField), '@Scout ($second)'); + await tester.pumpAndSettle(); + await tester.tap(find.byIcon(LucideIcons.arrowUp)); + await tester.pumpAndSettle(); + expect(sent, isEmpty); + }); + + testWidgets('ambiguous typed names fail visibly without clearing the draft', ( + tester, + ) async { + var sent = false; + await mountComposer(tester, members(), ( + _, + _, { + mediaTags = const [], + }) async { + sent = true; + }); + await tester.enterText(find.byType(TextField), '@Scout hello'); + await tester.pumpAndSettle(); + await tester.tap(find.byIcon(LucideIcons.arrowUp)); + await tester.pumpAndSettle(); + expect(sent, isFalse); + expect( + tester.widget(find.byType(TextField)).controller!.text, + '@Scout hello', + ); + expect(find.textContaining('is ambiguous'), findsOneWidget); + }); +} diff --git a/mobile/test/features/channels/message_content_exact_mentions_test.dart b/mobile/test/features/channels/message_content_exact_mentions_test.dart new file mode 100644 index 00000000000..7f18431de67 --- /dev/null +++ b/mobile/test/features/channels/message_content_exact_mentions_test.dart @@ -0,0 +1,51 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:buzz/features/channels/message_content.dart'; +import '../../helpers/widget_helpers.dart'; + +void main() { + for (final tagged in [false, true]) { + testWidgets( + 'qualified chips preserve authority and narrow layout: $tagged', + (tester) async { + tester.view.physicalSize = const Size(320, 640); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + final semantics = tester.ensureSemantics(); + final first = 'a' * 64, second = 'b' * 64; + String? tapped; + await tester.pumpWidget( + WidgetHelpers.testable( + child: MediaQuery( + data: const MediaQueryData(textScaler: TextScaler.linear(2)), + child: MessageContent( + content: '@Scout @Scout ($second)', + mentionNames: {second: 'Scout', first: 'Scout'}, + tags: [ + for (final key in [first, if (tagged) second]) ['p', key], + ], + onMentionTap: (key) => tapped = key, + ), + ), + ), + ); + await tester.pumpAndSettle(); + expect(find.text('Scout'), findsNothing); + final qualified = find.text('Scout (bbbbbbbb…bbbb)'); + if (tagged) { + await tester.tap(qualified); + expect(tapped, second); + expect( + find.bySemanticsLabel(RegExp('Scout.*$second')), + findsOneWidget, + ); + } else { + expect(qualified, findsNothing); + expect(tapped, isNull); + } + expect(tester.takeException(), isNull); + semantics.dispose(); + }, + ); + } +} diff --git a/mobile/test/shared/mentions/mention_bindings_test.dart b/mobile/test/shared/mentions/mention_bindings_test.dart new file mode 100644 index 00000000000..b4091d3b6e6 --- /dev/null +++ b/mobile/test/shared/mentions/mention_bindings_test.dart @@ -0,0 +1,56 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:buzz/shared/mentions/mention_bindings.dart'; + +void main() { + final first = 'a' * 64; + final second = 'b' * 64; + test('qualification is case-insensitive and collision-safe', () { + final bindings = {'Scout': first, 'Scout ($second)': first}; + expect(selectedMentionLabel('scout', first, bindings), 'scout'); + expect( + selectedMentionLabel('Scout', second, bindings), + 'Scout ($second) 2', + ); + }); + test('longest occurrences block shorter and interior recipients', () { + expect( + mentionOccurrences('@Scout ($second) 2', [ + 'Scout', + 'Scout ($second)', + 'Scout ($second) 2', + ]).single.label, + 'Scout ($second) 2', + ); + expect(mentionOccurrences('@A @B', ['A @B', 'B']).single.label, 'A @B'); + expect(mentionOccurrences('mail@Scout', ['Scout']), isEmpty); + expect(mentionOccurrences('@Scout ($second)', ['Scout']), isEmpty); + expect( + mentionOccurrences('@Scout ($second) 2', ['Scout ($second)']), + isEmpty, + ); + }); + // Historical plain denial and both tag orders run through channel/thread UI. + test('ordinary ambiguity and qualification require signed authority', () { + expect( + renderedMentionBindings('@Scout', { + first: 'Scout', + second: 'Scout', + })['scout'], + {first, second}, + ); + expect( + renderedMentionBindings('@Scout ($second)', { + first: 'Scout', + })['scout ($second)'], + isEmpty, + ); + expect( + renderedMentionBindings( + '@Old ($second)', + {second: 'New'}, + [second], + )['old ($second)'], + {second}, + ); + }); +} From b1817000ccacce3ee9efabdc10d9a47d9b9e9576 Mon Sep 17 00:00:00 2001 From: Junchao Yan Date: Fri, 11 Sep 2026 10:07:35 -0700 Subject: [PATCH 19/19] fix(desktop): name Waggle in the Pi adapter restart hint This fork ships the desktop app as Waggle (docs/fork-branding.md), but the Pi preset's setup hint -- new from upstream in this sync -- tells the user to "Restart Buzz". On the AdapterMissing and NotInstalled paths a user who also has an upstream Buzz installed can follow that literally, restart the wrong process, and leave the running Waggle holding its stale PATH with buzz-pi-acp still undiscoverable. Upstream authors these hints, so every future sync can reintroduce one in any preset. Alongside the two pinned Pi assertions, assert the whole class: no preset's user-visible setup guidance may contain the upstream app name. Lowercase command names (buzz-pi-acp, buzz-acp) are binaries, not the brand, and stay allowed. Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: Junchao Yan --- .../src/managed_agents/discovery/presets.rs | 36 +++++++++++++++++-- 1 file changed, 33 insertions(+), 3 deletions(-) diff --git a/desktop/src-tauri/src/managed_agents/discovery/presets.rs b/desktop/src-tauri/src/managed_agents/discovery/presets.rs index 058569f6127..f4d9d53dfb4 100644 --- a/desktop/src-tauri/src/managed_agents/discovery/presets.rs +++ b/desktop/src-tauri/src/managed_agents/discovery/presets.rs @@ -110,7 +110,7 @@ pub(super) const PRESET_HARNESSES: &[PresetHarness] = &[ command: "buzz-pi-acp", args: &[], install_instructions_url: "https://github.com/salman1993/pi-acp", - install_hint: "Requires Node.js 22 or newer. Install the Pi ACP adapter with `npm install -g --install-links=true git+https://github.com/salman1993/pi-acp.git#main`. Restart Buzz, then select Pi as the agent harness. Run the same install command again to update the adapter.", + install_hint: "Requires Node.js 22 or newer. Install the Pi ACP adapter with `npm install -g --install-links=true git+https://github.com/salman1993/pi-acp.git#main`. Restart Waggle, then select Pi as the agent harness. Run the same install command again to update the adapter.", underlying_cli: Some("pi"), underlying_cli_install_hint: Some( "Install Pi with `npm install -g @earendil-works/pi-coding-agent`, then run `pi` to configure its model provider.", @@ -434,7 +434,7 @@ mod tests { assert!(adapter_missing.default_args.is_empty()); assert_eq!( adapter_missing.install_hint, - "Requires Node.js 22 or newer. Install the Pi ACP adapter with `npm install -g --install-links=true git+https://github.com/salman1993/pi-acp.git#main`. Restart Buzz, then select Pi as the agent harness. Run the same install command again to update the adapter." + "Requires Node.js 22 or newer. Install the Pi ACP adapter with `npm install -g --install-links=true git+https://github.com/salman1993/pi-acp.git#main`. Restart Waggle, then select Pi as the agent harness. Run the same install command again to update the adapter." ); assert_eq!( adapter_missing.install_instructions_url, @@ -462,7 +462,7 @@ mod tests { ); assert_eq!( not_installed.install_hint, - "Install Pi with `npm install -g @earendil-works/pi-coding-agent`, then run `pi` to configure its model provider. Requires Node.js 22 or newer. Install the Pi ACP adapter with `npm install -g --install-links=true git+https://github.com/salman1993/pi-acp.git#main`. Restart Buzz, then select Pi as the agent harness. Run the same install command again to update the adapter." + "Install Pi with `npm install -g @earendil-works/pi-coding-agent`, then run `pi` to configure its model provider. Requires Node.js 22 or newer. Install the Pi ACP adapter with `npm install -g --install-links=true git+https://github.com/salman1993/pi-acp.git#main`. Restart Waggle, then select Pi as the agent harness. Run the same install command again to update the adapter." ); } @@ -601,4 +601,34 @@ mod tests { "uncapped preset (devin) must have max_parallelism: None" ); } + + /// Fork branding: no preset's user-visible setup guidance may name the + /// upstream app. This fork ships the desktop app as Waggle + /// (`docs/fork-branding.md`), so an instruction to "Restart Buzz" points a + /// user at a different installation while the running Waggle process keeps + /// its stale PATH. Upstream authors these hints, so every fork sync can + /// reintroduce one -- assert the whole class, not just the preset that + /// happened to carry it. Lowercase command names (`buzz-pi-acp`, + /// `buzz-acp`) are binaries, not the brand, and stay allowed. + #[test] + fn preset_setup_hints_name_the_fork_app() { + for preset in PRESET_HARNESSES { + for (field, hint) in [ + ("install_hint", Some(preset.install_hint)), + ( + "underlying_cli_install_hint", + preset.underlying_cli_install_hint, + ), + ] { + let Some(hint) = hint else { + continue; + }; + assert!( + !hint.contains("Buzz"), + "preset `{}` {field} names the upstream app; this fork ships as Waggle: {hint}", + preset.id + ); + } + } + } }