diff --git a/desktop/src-tauri/src/commands/media.rs b/desktop/src-tauri/src/commands/media.rs index ed3b340238..86a91a9842 100644 --- a/desktop/src-tauri/src/commands/media.rs +++ b/desktop/src-tauri/src/commands/media.rs @@ -3,17 +3,17 @@ use nostr::{EventBuilder, JsonUtil, Keys, Kind, Tag, Timestamp}; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use tauri::State; +use tokio_util::sync::CancellationToken; use crate::app_state::AppState; -use crate::relay::{ - classify_request_error, parse_json_response, relay_api_base_url_with_override, - relay_error_message, -}; +use crate::relay::{parse_json_response, relay_api_base_url_with_override, relay_error_message}; use super::media_transcode::{ has_heic_extension, is_heic_file, is_video_file, transcode_and_extract_poster, - transcode_heic_path_to_jpeg_bytes, + transcode_and_extract_poster_with_cancellation, transcode_heic_path_to_jpeg_bytes, + transcode_heic_path_to_jpeg_bytes_with_cancellation, }; +use super::media_upload_progress::{emit_media_upload_phase, send_upload_attempt, UploadAttempt}; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct BlobDescriptor { @@ -410,51 +410,6 @@ fn should_retry_legacy_upload(status: reqwest::StatusCode) -> bool { ) } -async fn send_upload_attempt( - state: &AppState, - url: String, - auth_header: &str, - mime: &str, - sha256: &str, - body: bytes::Bytes, - progress: Option<&(tauri::AppHandle, String)>, -) -> Result { - let req = state - .http_client - .put(url) - .header("Authorization", auth_header) - .header("Content-Type", mime) - .header("X-SHA-256", sha256); - - let response = if let Some((app, progress_id)) = progress { - use tauri::Emitter; - let app = app.clone(); - let progress_id = progress_id.clone(); - let total = body.len() as u64; - let chunk_size = 64 * 1024; - let chunk_count = body.len().div_ceil(chunk_size); - let mut sent: u64 = 0; - let stream = futures_util::stream::iter((0..chunk_count).map(move |i| { - let start = i * chunk_size; - let end = usize::min(start + chunk_size, body.len()); - let chunk = body.slice(start..end); - sent += chunk.len() as u64; - let _ = app.emit( - "media-upload-progress", - serde_json::json!({ "id": progress_id, "sent": sent, "total": total }), - ); - Ok::(chunk) - })); - req.header(reqwest::header::CONTENT_LENGTH, total) - .body(reqwest::Body::wrap_stream(stream)) - .send() - .await - } else { - req.body(body).send().await - }; - response.map_err(|error| classify_request_error(&error)) -} - pub(crate) async fn upload_image_bytes( body: Vec, state: &AppState, @@ -464,7 +419,7 @@ pub(crate) async fn upload_image_bytes( return Err("profile avatar must be an image".to_string()); } let body = sanitize_image_for_upload(body, &mime)?; - do_upload(body, &mime, state, None).await + do_upload(body, &mime, state, None, None).await } async fn do_upload( @@ -472,6 +427,7 @@ async fn do_upload( mime: &str, state: &AppState, progress: Option<(tauri::AppHandle, String)>, + cancellation: Option<&CancellationToken>, ) -> Result { let sha256 = hex::encode(Sha256::digest(&body)); @@ -494,25 +450,34 @@ async fn do_upload( URL_SAFE_NO_PAD.encode(auth_event.as_json().as_bytes()) ); let body = bytes::Bytes::from(body); + if let Some((app, progress_id)) = progress.as_ref() { + emit_media_upload_phase(app, Some(progress_id.as_str()), "uploading"); + } let mut resp = send_upload_attempt( state, - format!("{base_url}/upload"), - &auth_header, - mime, - &sha256, - body.clone(), - progress.as_ref(), + UploadAttempt { + url: format!("{base_url}/upload"), + auth_header: &auth_header, + mime, + sha256: &sha256, + body: body.clone(), + progress: progress.as_ref(), + cancellation, + }, ) .await?; if should_retry_legacy_upload(resp.status()) { resp = send_upload_attempt( state, - format!("{base_url}/media/upload"), - &auth_header, - mime, - &sha256, - body, - progress.as_ref(), + UploadAttempt { + url: format!("{base_url}/media/upload"), + auth_header: &auth_header, + mime, + sha256: &sha256, + body, + progress: progress.as_ref(), + cancellation, + }, ) .await?; } @@ -559,7 +524,7 @@ pub async fn upload_media( let mime = detect_and_validate_mime(&body)?; let body = sanitize_image_for_upload(body, &mime)?; - do_upload(body, &mime, &state, None).await + do_upload(body, &mime, &state, None, None).await } /// Read a picked path through the TOCTOU-safe pipeline (fd pin → sniff → @@ -573,6 +538,7 @@ async fn process_picked_path( path: std::path::PathBuf, state: &AppState, images_only: bool, + progress: Option<(tauri::AppHandle, String)>, ) -> Result { // Pin the inode by opening the fd BEFORE spawn_blocking. This prevents a // local attacker from swapping the file between dialog return and read. @@ -639,10 +605,9 @@ async fn process_picked_path( // Upload video first, then poster (best-effort). If poster upload fails, // the video descriptor is returned without an image field. - let mut descriptor = do_upload(body, &mime, state, None).await?; - + let mut descriptor = do_upload(body, &mime, state, progress, None).await?; if let Some(poster) = poster_bytes { - match do_upload(poster, "image/jpeg", state, None).await { + match do_upload(poster, "image/jpeg", state, None, None).await { Ok(poster_desc) => descriptor.image = Some(poster_desc.url), Err(e) => eprintln!("buzz-desktop: poster upload failed (non-fatal): {e}"), } @@ -675,6 +640,7 @@ async fn process_picked_path( #[tauri::command] pub async fn pick_and_upload_media( app: tauri::AppHandle, + progress_id: Option, state: State<'_, AppState>, ) -> Result, String> { use tauri_plugin_dialog::DialogExt; @@ -694,7 +660,8 @@ pub async fn pick_and_upload_media( let mut descriptors = Vec::with_capacity(file_paths.len()); for file_path in file_paths { let path = file_path.as_path().ok_or("invalid path")?.to_path_buf(); - let descriptor = process_picked_path(path, &state, false).await?; + let progress = progress_id.clone().map(|id| (app.clone(), id)); + let descriptor = process_picked_path(path, &state, false, progress).await?; descriptors.push(descriptor); } @@ -735,30 +702,37 @@ pub async fn pick_and_upload_image( }; let path = file_path.as_path().ok_or("invalid path")?.to_path_buf(); - let descriptor = process_picked_path(path, &state, true).await?; + let descriptor = process_picked_path(path, &state, true, None).await?; Ok(Some(descriptor)) } -/// Upload raw bytes directly (for paste and drag-drop). -/// -/// The renderer already has the bytes in memory from the clipboard/drag event. -/// If the bytes are a video, they're written to a temp file, transcoded via -/// ffmpeg, and the transcoded output is uploaded instead. -#[tauri::command] -pub async fn upload_media_bytes( +pub(super) async fn upload_media_bytes_inner( data: Vec, filename: Option, progress_id: Option, app: tauri::AppHandle, state: State<'_, AppState>, + cancellation: Option<&CancellationToken>, ) -> Result { if data.is_empty() { return Err("empty upload".to_string()); } + if cancellation.is_some_and(CancellationToken::is_cancelled) { + return Err("upload cancelled".to_string()); + } + + emit_media_upload_phase(&app, progress_id.as_deref(), "preparing"); + + let heic_by_extension = filename + .as_deref() + .is_some_and(|name| has_heic_extension(std::path::Path::new(name))); + let (body, poster_bytes) = if is_video_file(&data) { + emit_media_upload_phase(&app, progress_id.as_deref(), "processing-video"); // Video: write to temp → transcode + extract poster → read results. // All blocking I/O runs off the async runtime via spawn_blocking. + let cancellation = cancellation.cloned(); tokio::task::spawn_blocking(move || -> Result<(Vec, Option>), String> { let tmp_input = std::env::temp_dir().join(format!("buzz-drop-{}", uuid::Uuid::new_v4())); @@ -766,17 +740,19 @@ pub async fn upload_media_bytes( let result = (|| { std::fs::write(&tmp_input, &data) .map_err(|e| format!("failed to write temp file: {e}"))?; - transcode_and_extract_poster(&tmp_input) + transcode_and_extract_poster_with_cancellation(&tmp_input, cancellation.as_ref()) })(); let _ = std::fs::remove_file(&tmp_input); result }) .await .map_err(|e| format!("transcode task failed: {e}"))?? - } else if is_heic_file(&data) { + } else if is_heic_file(&data) || heic_by_extension { + emit_media_upload_phase(&app, progress_id.as_deref(), "converting-image"); // HEIC/HEIF still pasted/dropped: no filename here, so detection is // magic-bytes only. ffmpeg needs a path, so write to temp, transcode // to JPEG, and clean up. (Mirrors mobile's pre-upload transcode.) + let cancellation = cancellation.cloned(); tokio::task::spawn_blocking(move || -> Result<(Vec, Option>), String> { let tmp_input = std::env::temp_dir().join(format!("buzz-drop-{}", uuid::Uuid::new_v4())); @@ -784,7 +760,11 @@ pub async fn upload_media_bytes( let result = (|| { std::fs::write(&tmp_input, &data) .map_err(|e| format!("failed to write temp file: {e}"))?; - transcode_heic_path_to_jpeg_bytes(&tmp_input).map(|jpeg| (jpeg, None)) + transcode_heic_path_to_jpeg_bytes_with_cancellation( + &tmp_input, + cancellation.as_ref(), + ) + .map(|jpeg| (jpeg, None)) })(); let _ = std::fs::remove_file(&tmp_input); result @@ -799,11 +779,15 @@ pub async fn upload_media_bytes( let body = sanitize_image_for_upload(body, &mime)?; // Upload video first, then poster (best-effort). - let progress = progress_id.map(|id| (app, id)); - let mut descriptor = do_upload(body, &mime, &state, progress).await?; + let progress = progress_id.as_ref().map(|id| (app.clone(), id.clone())); + if cancellation.is_some_and(CancellationToken::is_cancelled) { + return Err("upload cancelled".to_string()); + } + let mut descriptor = do_upload(body, &mime, &state, progress, cancellation).await?; + emit_media_upload_phase(&app, progress_id.as_deref(), "finishing"); if let Some(poster) = poster_bytes { - match do_upload(poster, "image/jpeg", &state, None).await { + match do_upload(poster, "image/jpeg", &state, None, cancellation).await { Ok(poster_desc) => descriptor.image = Some(poster_desc.url), Err(e) => eprintln!("buzz-desktop: poster upload failed (non-fatal): {e}"), } diff --git a/desktop/src-tauri/src/commands/media_raw.rs b/desktop/src-tauri/src/commands/media_raw.rs new file mode 100644 index 0000000000..a74ccd4dfe --- /dev/null +++ b/desktop/src-tauri/src/commands/media_raw.rs @@ -0,0 +1,96 @@ +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine}; +use tauri::{ + ipc::{InvokeBody, Request}, + State, +}; + +use crate::app_state::AppState; + +use super::{ + media::{upload_media_bytes_inner, BlobDescriptor}, + media_upload_progress::{ + begin_media_upload, cancel_media_upload as cancel_registered_media_upload, + finish_media_upload, + }, +}; + +/// Upload raw bytes directly (for paste and drag-drop). +/// +/// The renderer already has the bytes in memory from the clipboard/drag event. +/// If the bytes are a video, they're written to a temp file, transcoded via +/// ffmpeg, and the transcoded output is uploaded instead. +#[tauri::command] +pub async fn upload_media_bytes( + data: Vec, + filename: Option, + progress_id: Option, + app: tauri::AppHandle, + state: State<'_, AppState>, +) -> Result { + upload_media_bytes_inner(data, filename, progress_id, app, state, None).await +} + +fn decode_raw_upload_header(value: &str) -> Result { + let bytes = URL_SAFE_NO_PAD + .decode(value) + .map_err(|error| format!("invalid raw upload header: {error}"))?; + String::from_utf8(bytes).map_err(|error| format!("invalid raw upload header text: {error}")) +} + +fn optional_raw_upload_header(request: &Request<'_>, name: &str) -> Result, String> { + request + .headers() + .get(name) + .map(|value| { + value + .to_str() + .map_err(|error| format!("invalid {name} header: {error}")) + .and_then(decode_raw_upload_header) + }) + .transpose() +} + +/// Cancel the native upload associated with a background progress ID. +#[tauri::command] +pub fn cancel_media_upload(progress_id: String) { + cancel_registered_media_upload(&progress_id); +} + +/// Upload raw IPC bytes without expanding a large browser File into JSON. +#[tauri::command] +pub async fn upload_media_bytes_raw( + request: Request<'_>, + app: tauri::AppHandle, + state: State<'_, AppState>, +) -> Result { + let data = match request.body() { + InvokeBody::Raw(data) => data.clone(), + InvokeBody::Json(_) => return Err("raw upload requires a byte body".to_string()), + }; + let filename = optional_raw_upload_header(&request, "x-buzz-filename")?; + let progress_id = optional_raw_upload_header(&request, "x-buzz-progress-id")?; + + let cancellation = begin_media_upload(progress_id.as_deref()); + let result = upload_media_bytes_inner( + data, + filename, + progress_id.clone(), + app, + state, + cancellation.as_ref(), + ) + .await; + finish_media_upload(progress_id.as_deref()); + result +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_decode_raw_upload_header_preserves_unicode() { + let encoded = URL_SAFE_NO_PAD.encode("clip 🎬.mp4"); + assert_eq!(decode_raw_upload_header(&encoded).unwrap(), "clip 🎬.mp4"); + } +} diff --git a/desktop/src-tauri/src/commands/media_transcode.rs b/desktop/src-tauri/src/commands/media_transcode.rs index 46a5decaa7..3fb7eda5f0 100644 --- a/desktop/src-tauri/src/commands/media_transcode.rs +++ b/desktop/src-tauri/src/commands/media_transcode.rs @@ -6,6 +6,7 @@ //! `validate_video_file()`) and to produce a JPEG poster frame. use crate::managed_agents::resolve_command; +use tokio_util::sync::CancellationToken; /// Build an ffmpeg command without inheriting user-controlled process knobs. /// @@ -121,7 +122,7 @@ pub(super) fn has_heic_extension(path: &std::path::Path) -> bool { /// blocking a Tokio worker thread indefinitely. const FFMPEG_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(600); -/// Run an ffmpeg command with a wall-clock timeout. +/// Run an ffmpeg command with a wall-clock timeout and optional cancellation. /// /// Spawns the child process, polls `try_wait()` every 500ms, and kills it /// if the deadline is exceeded. Returns the same `Output` as `Command::output()`. @@ -131,10 +132,14 @@ const FFMPEG_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(600); /// enough progress/diagnostic output to fill the OS pipe buffer (~64 KiB), /// the child blocks on write() and never exits — causing a false timeout. /// `-loglevel error` suppresses progress spam, keeping stderr small. -pub(super) fn run_ffmpeg_with_timeout( +fn run_ffmpeg_with_cancellation( cmd: &mut std::process::Command, timeout: std::time::Duration, + cancellation: Option<&CancellationToken>, ) -> Result { + if cancellation.is_some_and(CancellationToken::is_cancelled) { + return Err("upload cancelled".to_string()); + } let mut child = cmd .spawn() .map_err(|e| format!("failed to spawn ffmpeg: {e}"))?; @@ -162,6 +167,11 @@ pub(super) fn run_ffmpeg_with_timeout( } Ok(None) => { // Still running — check deadline. + if cancellation.is_some_and(CancellationToken::is_cancelled) { + let _ = child.kill(); + let _ = child.wait(); + return Err("upload cancelled".to_string()); + } if std::time::Instant::now() > deadline { let _ = child.kill(); let _ = child.wait(); // reap zombie @@ -181,14 +191,15 @@ pub(super) fn run_ffmpeg_with_timeout( /// relay's `validate_video_file()`. /// /// Returns the path to a temp file. Caller must clean up. -pub(super) fn transcode_to_mp4( +fn transcode_to_mp4_with_cancellation( source: &std::path::Path, ffmpeg: &std::path::Path, + cancellation: Option<&CancellationToken>, ) -> Result { // UUID-based temp path — unique across concurrent uploads. let output = std::env::temp_dir().join(format!("buzz-transcode-{}.mp4", uuid::Uuid::new_v4())); - let result = run_ffmpeg_with_timeout( + let result = run_ffmpeg_with_cancellation( ffmpeg_command(ffmpeg) .args([ "-y", @@ -240,7 +251,11 @@ pub(super) fn transcode_to_mp4( .stdout(std::process::Stdio::null()) .stderr(std::process::Stdio::piped()), FFMPEG_TIMEOUT, - )?; + cancellation, + ) + .inspect_err(|_| { + let _ = std::fs::remove_file(&output); + })?; if !result.status.success() { let _ = std::fs::remove_file(&output); @@ -265,9 +280,10 @@ pub(super) fn transcode_to_mp4( /// Uses `-frames:v 1` so multi-image HEIF containers (Live Photos, bursts) /// yield a single still, and `-q:v 2` for high JPEG quality. Returns the path /// to a temp file. Caller must clean up. -pub(super) fn transcode_heic_to_jpeg( +fn transcode_heic_to_jpeg( source: &std::path::Path, ffmpeg: &std::path::Path, + cancellation: Option<&CancellationToken>, ) -> Result { // UUID-based temp path — unique across concurrent uploads. let output = std::env::temp_dir().join(format!("buzz-heic-{}.jpg", uuid::Uuid::new_v4())); @@ -275,7 +291,7 @@ pub(super) fn transcode_heic_to_jpeg( // Single-frame image decode — 60s is generous even for large HEICs. let heic_timeout = std::time::Duration::from_secs(60); - let result = run_ffmpeg_with_timeout( + let result = run_ffmpeg_with_cancellation( ffmpeg_command(ffmpeg) .args([ "-y", @@ -301,7 +317,11 @@ pub(super) fn transcode_heic_to_jpeg( .stdout(std::process::Stdio::null()) .stderr(std::process::Stdio::piped()), heic_timeout, - )?; + cancellation, + ) + .inspect_err(|_| { + let _ = std::fs::remove_file(&output); + })?; if !result.status.success() { let _ = std::fs::remove_file(&output); @@ -323,9 +343,16 @@ pub(super) fn transcode_heic_to_jpeg( /// file. Mirrors `transcode_and_extract_poster` but for images (no poster). pub(super) fn transcode_heic_path_to_jpeg_bytes( source: &std::path::Path, +) -> Result, String> { + transcode_heic_path_to_jpeg_bytes_with_cancellation(source, None) +} + +pub(super) fn transcode_heic_path_to_jpeg_bytes_with_cancellation( + source: &std::path::Path, + cancellation: Option<&CancellationToken>, ) -> Result, String> { let ffmpeg_path = find_ffmpeg()?; - let jpeg_path = transcode_heic_to_jpeg(source, &ffmpeg_path)?; + let jpeg_path = transcode_heic_to_jpeg(source, &ffmpeg_path, cancellation)?; let bytes = std::fs::read(&jpeg_path).map_err(|e| format!("failed to read transcoded HEIC: {e}")); let _ = std::fs::remove_file(&jpeg_path); @@ -340,9 +367,10 @@ pub(super) fn transcode_heic_path_to_jpeg_bytes( /// /// Best-effort: returns `Err` on failure — callers should log and continue /// without a poster rather than failing the entire video upload. -pub(super) fn extract_poster_frame( +fn extract_poster_frame_with_cancellation( mp4_path: &std::path::Path, ffmpeg: &std::path::Path, + cancellation: Option<&CancellationToken>, ) -> Result { let output = std::env::temp_dir().join(format!("buzz-poster-{}.jpg", uuid::Uuid::new_v4())); @@ -350,7 +378,7 @@ pub(super) fn extract_poster_frame( let poster_timeout = std::time::Duration::from_secs(30); // Try seeking to 1s first (avoids black first frames from fade-ins). - let result = run_ffmpeg_with_timeout( + let result = run_ffmpeg_with_cancellation( ffmpeg_command(ffmpeg) .args([ "-y", @@ -369,6 +397,7 @@ pub(super) fn extract_poster_frame( .stdout(std::process::Stdio::null()) .stderr(std::process::Stdio::piped()), poster_timeout, + cancellation, )?; // If seek to 1s failed (video shorter than 1s), retry from first frame. @@ -381,7 +410,7 @@ pub(super) fn extract_poster_frame( eprintln!("buzz-desktop: poster seek-to-1s failed, trying first frame: {stderr}"); } let _ = std::fs::remove_file(&output); - let fallback = run_ffmpeg_with_timeout( + let fallback = run_ffmpeg_with_cancellation( ffmpeg_command(ffmpeg) .args([ "-y", @@ -398,6 +427,7 @@ pub(super) fn extract_poster_frame( .stdout(std::process::Stdio::null()) .stderr(std::process::Stdio::piped()), poster_timeout, + cancellation, )?; if !fallback.status.success() || !output.exists() { @@ -417,22 +447,35 @@ pub(super) fn extract_poster_frame( /// and the video bytes are still valid. All temp files are cleaned up. pub(super) fn transcode_and_extract_poster( source: &std::path::Path, +) -> Result<(Vec, Option>), String> { + transcode_and_extract_poster_with_cancellation(source, None) +} + +pub(super) fn transcode_and_extract_poster_with_cancellation( + source: &std::path::Path, + cancellation: Option<&CancellationToken>, ) -> Result<(Vec, Option>), String> { let ffmpeg_path = find_ffmpeg()?; - let transcoded = transcode_to_mp4(source, &ffmpeg_path)?; + let transcoded = transcode_to_mp4_with_cancellation(source, &ffmpeg_path, cancellation)?; // Extract poster from the transcoded file (not the original — guarantees decodability). - let poster_bytes = match extract_poster_frame(&transcoded, &ffmpeg_path) { - Ok(poster_path) => { - let bytes = std::fs::read(&poster_path).ok(); - let _ = std::fs::remove_file(&poster_path); - bytes - } - Err(e) => { - eprintln!("buzz-desktop: poster extraction failed (non-fatal): {e}"); - None - } - }; + let poster_bytes = + match extract_poster_frame_with_cancellation(&transcoded, &ffmpeg_path, cancellation) { + Ok(poster_path) => { + let bytes = std::fs::read(&poster_path).ok(); + let _ = std::fs::remove_file(&poster_path); + bytes + } + Err(e) => { + eprintln!("buzz-desktop: poster extraction failed (non-fatal): {e}"); + None + } + }; + + if cancellation.is_some_and(CancellationToken::is_cancelled) { + let _ = std::fs::remove_file(&transcoded); + return Err("upload cancelled".to_string()); + } let video_bytes = std::fs::read(&transcoded).map_err(|e| format!("failed to read transcoded file: {e}")); @@ -599,7 +642,8 @@ mod tests { return; } - let output = transcode_to_mp4(&source, &ffmpeg).expect("transcode fixture"); + let output = + transcode_to_mp4_with_cancellation(&source, &ffmpeg, None).expect("transcode fixture"); let bytes = std::fs::read(&output).expect("read transcoded video"); let _ = std::fs::remove_file(&source); let _ = std::fs::remove_file(&output); diff --git a/desktop/src-tauri/src/commands/media_upload_progress.rs b/desktop/src-tauri/src/commands/media_upload_progress.rs new file mode 100644 index 0000000000..850afe1b12 --- /dev/null +++ b/desktop/src-tauri/src/commands/media_upload_progress.rs @@ -0,0 +1,126 @@ +use std::{ + collections::HashMap, + sync::{LazyLock, Mutex}, +}; + +use tauri::Emitter; +use tokio_util::sync::CancellationToken; + +use crate::{app_state::AppState, relay::classify_request_error}; + +static MEDIA_UPLOAD_CANCELLATIONS: LazyLock>> = + LazyLock::new(|| Mutex::new(HashMap::new())); + +pub(super) fn begin_media_upload(progress_id: Option<&str>) -> Option { + let progress_id = progress_id?; + let cancel = CancellationToken::new(); + if let Ok(mut uploads) = MEDIA_UPLOAD_CANCELLATIONS.lock() { + uploads.insert(progress_id.to_string(), cancel.clone()); + } + Some(cancel) +} + +pub(super) fn cancel_media_upload(progress_id: &str) { + if let Ok(uploads) = MEDIA_UPLOAD_CANCELLATIONS.lock() { + if let Some(cancel) = uploads.get(progress_id) { + cancel.cancel(); + } + } +} + +pub(super) fn finish_media_upload(progress_id: Option<&str>) { + let Some(progress_id) = progress_id else { + return; + }; + if let Ok(mut uploads) = MEDIA_UPLOAD_CANCELLATIONS.lock() { + uploads.remove(progress_id); + } +} + +pub(super) struct UploadAttempt<'a> { + pub url: String, + pub auth_header: &'a str, + pub mime: &'a str, + pub sha256: &'a str, + pub body: bytes::Bytes, + pub progress: Option<&'a (tauri::AppHandle, String)>, + pub cancellation: Option<&'a CancellationToken>, +} + +pub(super) async fn send_upload_attempt( + state: &AppState, + attempt: UploadAttempt<'_>, +) -> Result { + let UploadAttempt { + url, + auth_header, + mime, + sha256, + body, + progress, + cancellation, + } = attempt; + let req = state + .http_client + .put(url) + .header("Authorization", auth_header) + .header("Content-Type", mime) + .header("X-SHA-256", sha256); + + let response = if let Some((app, progress_id)) = progress { + let app = app.clone(); + let progress_id = progress_id.clone(); + let total = body.len() as u64; + let chunk_size = 64 * 1024; + let chunk_count = body.len().div_ceil(chunk_size); + let mut sent: u64 = 0; + let stream = futures_util::stream::iter((0..chunk_count).map(move |i| { + let start = i * chunk_size; + let end = usize::min(start + chunk_size, body.len()); + let chunk = body.slice(start..end); + sent += chunk.len() as u64; + let _ = app.emit( + "media-upload-progress", + serde_json::json!({ "id": progress_id, "sent": sent, "total": total }), + ); + Ok::(chunk) + })); + let request = req + .header(reqwest::header::CONTENT_LENGTH, total) + .body(reqwest::Body::wrap_stream(stream)) + .send(); + if let Some(cancellation) = cancellation { + tokio::select! { + _ = cancellation.cancelled() => return Err("upload cancelled".to_string()), + response = request => response, + } + } else { + request.await + } + } else { + let request = req.body(body).send(); + if let Some(cancellation) = cancellation { + tokio::select! { + _ = cancellation.cancelled() => return Err("upload cancelled".to_string()), + response = request => response, + } + } else { + request.await + } + }; + response.map_err(|error| classify_request_error(&error)) +} + +pub(super) fn emit_media_upload_phase( + app: &tauri::AppHandle, + progress_id: Option<&str>, + phase: &'static str, +) { + let Some(id) = progress_id else { + return; + }; + let _ = app.emit( + "media-upload-phase", + serde_json::json!({ "id": id, "phase": phase }), + ); +} diff --git a/desktop/src-tauri/src/commands/mod.rs b/desktop/src-tauri/src/commands/mod.rs index 66ef7ef17b..237bc06e8d 100644 --- a/desktop/src-tauri/src/commands/mod.rs +++ b/desktop/src-tauri/src/commands/mod.rs @@ -28,8 +28,10 @@ pub(crate) mod media; mod media_animated; mod media_download; mod media_gif; +mod media_raw; mod media_snapshot_png; mod media_transcode; +mod media_upload_progress; #[cfg(feature = "mesh-llm")] pub(crate) mod mesh_llm; mod messages; @@ -85,6 +87,7 @@ pub use legacy_storage::*; pub use link_preview::*; pub use media::*; pub use media_download::*; +pub use media_raw::*; #[cfg(feature = "mesh-llm")] pub use mesh_llm::*; pub use messages::*; diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 7c5530db74..31a5609a36 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -595,7 +595,6 @@ pub fn run() { } }); } - Ok(()) }) .invoke_handler(tauri::generate_handler![ @@ -714,6 +713,8 @@ pub fn run() { pick_and_upload_media, pick_and_upload_image, upload_media_bytes, + upload_media_bytes_raw, + cancel_media_upload, download_image, save_png_data_url, download_file, @@ -888,7 +889,6 @@ pub fn run() { ]) .build(tauri::generate_context!()) .expect("error while building tauri application"); - let shutdown_done = Arc::new(AtomicBool::new(false)); #[cfg(unix)] diff --git a/desktop/src/features/channels/ui/ChannelPane.tsx b/desktop/src/features/channels/ui/ChannelPane.tsx index 54c0dcd5d9..9e5152edfe 100644 --- a/desktop/src/features/channels/ui/ChannelPane.tsx +++ b/desktop/src/features/channels/ui/ChannelPane.tsx @@ -4,6 +4,7 @@ import { AnimatePresence } from "motion/react"; import { useAppNavigation } from "@/app/navigation/useAppNavigation"; import { useMediaUpload } from "@/features/messages/lib/useMediaUpload"; import { ComposerDockBackdrop } from "@/features/messages/ui/ComposerDockBackdrop"; +import { ComposerUploadProgressOverlay } from "@/features/messages/ui/ComposerUploadProgressOverlay"; import { MessageComposer } from "@/features/messages/ui/MessageComposer"; import { ComposerTimeoutBanner } from "@/features/moderation/ui/ComposerTimeoutBanner"; import { useTimeoutState } from "@/features/moderation/lib/timeoutStore"; @@ -63,12 +64,10 @@ import { KIND_SYSTEM_MESSAGE } from "@/shared/constants/kinds"; import { useIsThreadPanelOverlay } from "@/shared/hooks/use-mobile"; import { channelChrome } from "@/shared/layout/chromeLayout"; import { cn } from "@/shared/lib/cn"; - const HUDDLE_TRANSCRIPT_ROOT_STYLE = { "--buzz-channel-content-top-padding": "0rem", "--channel-top-chrome-height": "0.25rem", } as React.CSSProperties; - export const ChannelPane = React.memo(function ChannelPane({ activeChannel, agentPubkeys, @@ -179,7 +178,9 @@ export const ChannelPane = React.memo(function ChannelPane({ activeChannel, currentPubkey, ); - const mainComposerMedia = useMediaUpload(); + const mainComposerMedia = useMediaUpload({ deferUploadsUntilSend: true }); + const [isMainDeferredEditPending, setMainDeferredEditPending] = + React.useState(false); const isNonMemberView = activeChannel !== null && !activeChannel.isMember && @@ -230,15 +231,12 @@ export const ChannelPane = React.memo(function ChannelPane({ welcomeComposerHideTimerRef.current = null; } }, []); - React.useEffect( () => () => clearWelcomeComposerDismissTimer(), [clearWelcomeComposerDismissTimer], ); - React.useEffect(() => { clearWelcomeComposerDismissTimer(); - if ( activeChannelId && isActiveWelcomeChannel && @@ -247,14 +245,12 @@ export const ChannelPane = React.memo(function ChannelPane({ setWelcomeComposerBannerState("hidden"); return; } - setWelcomeComposerBannerState("prompt"); }, [ activeChannelId, clearWelcomeComposerDismissTimer, isActiveWelcomeChannel, ]); - const isEditInThread = editTarget != null && threadHeadMessage != null && @@ -262,7 +258,6 @@ export const ChannelPane = React.memo(function ChannelPane({ threadMessages.some((entry) => entry.message.id === editTarget.id)); const mainEditTarget = editTarget && !isEditInThread ? editTarget : null; const threadEditTarget = editTarget && isEditInThread ? editTarget : null; - const findLastOwnEditable = React.useCallback( (candidates: TimelineMessage[]): TimelineMessage | null => { if (!onEdit || !currentPubkey) return null; @@ -283,7 +278,6 @@ export const ChannelPane = React.memo(function ChannelPane({ }, [onEdit, currentPubkey], ); - const handleEditLastOwnMainMessage = React.useCallback((): boolean => { const target = findLastOwnEditable(messages); if (!target || !onEdit) return false; @@ -399,7 +393,10 @@ export const ChannelPane = React.memo(function ChannelPane({ ], ); const canDropInMainColumn = - hasMainComposerOverlay && !isComposerDisabled && !isSinglePanelView; + hasMainComposerOverlay && + !isComposerDisabled && + !isMainDeferredEditPending && + !isSinglePanelView; const hasTypingActivity = typingPubkeys.length > 0; // Unified working set for the composer bar: observer-derived turns primary, // bot typing fallback (both folded together by agentWorkingSignal). This is @@ -733,6 +730,7 @@ export const ChannelPane = React.memo(function ChannelPane({ data-testid="channel-composer-overlay" ref={composerWrapperRef} > +
{ - const eventId = editTargetIdRef.current; + const eventId = capturedEventId ?? editTargetIdRef.current; if (!eventId) { return; } @@ -175,15 +173,19 @@ export function useChannelPaneHandlers({ return; } - await editMutateRef.current({ + await editMessageMutation.mutateAsync({ eventId, content, mediaTags, mentionPubkeys, }); - setEditTargetId(null); + setEditTargetId((current) => (current === eventId ? null : current)); }, - [onRequestEmptyEditDelete, setEditTargetId], + [ + editMessageMutation.mutateAsync, + onRequestEmptyEditDelete, + setEditTargetId, + ], ); const handleOpenThread = React.useCallback( diff --git a/desktop/src/features/communities/useCommunityInit.ts b/desktop/src/features/communities/useCommunityInit.ts index afa69f913f..1bd1e090a7 100644 --- a/desktop/src/features/communities/useCommunityInit.ts +++ b/desktop/src/features/communities/useCommunityInit.ts @@ -19,6 +19,7 @@ import { initDraftStore, } from "@/features/messages/lib/useDrafts"; import { resetRenderScopedReactionHydration } from "@/features/messages/lib/renderScopedReactions"; +import { resetBackgroundMediaUploads } from "@/features/messages/lib/backgroundMediaUploadStore"; import { resetActiveAgentTurnsStore, saveActiveAgentTurnsForCommunity, @@ -67,6 +68,7 @@ function resetCommunityState({ resetMediaCaches(); resetVideoPlayerState(); resetRenderScopedReactionHydration(); + resetBackgroundMediaUploads(); clearSearchHitEventCache(); clearMarkdownNodeCache(); } diff --git a/desktop/src/features/home/useHomeDrafts.ts b/desktop/src/features/home/useHomeDrafts.ts index 8d9accda61..4b4f4afa63 100644 --- a/desktop/src/features/home/useHomeDrafts.ts +++ b/desktop/src/features/home/useHomeDrafts.ts @@ -1,6 +1,6 @@ import * as React from "react"; -import { clearDraftEntry } from "@/features/messages/lib/useDrafts"; +import { deleteDraftEntry } from "@/features/messages/lib/useDrafts"; import { useActiveDraftCount, useDraftViewItems, @@ -62,7 +62,7 @@ export function useHomeDrafts({ const deleteDraft = React.useCallback( (draftKey: string) => { - clearDraftEntry(draftKey); + deleteDraftEntry(draftKey); if (selectedKey === draftKey) { setSelectedKey(null); } diff --git a/desktop/src/features/messages/lib/backgroundMediaUploadPhase.test.mjs b/desktop/src/features/messages/lib/backgroundMediaUploadPhase.test.mjs new file mode 100644 index 0000000000..ed4ab860f7 --- /dev/null +++ b/desktop/src/features/messages/lib/backgroundMediaUploadPhase.test.mjs @@ -0,0 +1,49 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + backgroundMediaUploadPhaseLabel, + isNativeMediaUploadPhase, + resolveBackgroundMediaUploadPhase, +} from "./backgroundMediaUploadPhase.ts"; + +test("upload phase labels describe the real work in plain language", () => { + assert.equal(backgroundMediaUploadPhaseLabel("preparing"), "Preparing"); + assert.equal( + backgroundMediaUploadPhaseLabel("processing-video"), + "Processing", + ); + assert.equal( + backgroundMediaUploadPhaseLabel("converting-image"), + "Converting", + ); + assert.equal( + backgroundMediaUploadPhaseLabel("processing-files"), + "Processing", + ); + assert.equal(backgroundMediaUploadPhaseLabel("uploading"), "Uploading"); + assert.equal(backgroundMediaUploadPhaseLabel("finishing"), "Finishing"); +}); + +test("upload phase validation accepts only phases emitted by native code", () => { + assert.equal(isNativeMediaUploadPhase("processing-video"), true); + assert.equal(isNativeMediaUploadPhase("processing-files"), false); + assert.equal(isNativeMediaUploadPhase("transcribing"), false); + assert.equal(isNativeMediaUploadPhase(null), false); +}); + +test("upload phase aggregation favors active transfer and combines mixed work", () => { + assert.equal(resolveBackgroundMediaUploadPhase([]), "preparing"); + assert.equal( + resolveBackgroundMediaUploadPhase(["processing-video", "uploading"]), + "uploading", + ); + assert.equal( + resolveBackgroundMediaUploadPhase(["processing-video", "preparing"]), + "processing-files", + ); + assert.equal( + resolveBackgroundMediaUploadPhase(["finishing", "finishing"]), + "finishing", + ); +}); diff --git a/desktop/src/features/messages/lib/backgroundMediaUploadPhase.ts b/desktop/src/features/messages/lib/backgroundMediaUploadPhase.ts new file mode 100644 index 0000000000..0d8a98f2ff --- /dev/null +++ b/desktop/src/features/messages/lib/backgroundMediaUploadPhase.ts @@ -0,0 +1,60 @@ +export type BackgroundMediaUploadPhase = + | "preparing" + | "processing-video" + | "converting-image" + | "processing-files" + | "uploading" + | "finishing"; + +export type NativeMediaUploadPhase = Exclude< + BackgroundMediaUploadPhase, + "processing-files" +>; + +const NATIVE_PHASES = new Set([ + "preparing", + "processing-video", + "converting-image", + "uploading", + "finishing", +]); + +export function isNativeMediaUploadPhase( + value: unknown, +): value is NativeMediaUploadPhase { + return ( + typeof value === "string" && + NATIVE_PHASES.has(value as NativeMediaUploadPhase) + ); +} + +export function resolveBackgroundMediaUploadPhase( + phases: BackgroundMediaUploadPhase[], +): BackgroundMediaUploadPhase { + if (phases.length === 0) return "preparing"; + if (phases.includes("uploading")) return "uploading"; + + const activePhases = new Set(phases.filter((phase) => phase !== "finishing")); + if (activePhases.size === 0) return "finishing"; + if (activePhases.size > 1) return "processing-files"; + return activePhases.values().next().value ?? "preparing"; +} + +export function backgroundMediaUploadPhaseLabel( + phase: BackgroundMediaUploadPhase, +): string { + switch (phase) { + case "processing-video": + return "Processing"; + case "converting-image": + return "Converting"; + case "processing-files": + return "Processing"; + case "uploading": + return "Uploading"; + case "finishing": + return "Finishing"; + default: + return "Preparing"; + } +} diff --git a/desktop/src/features/messages/lib/backgroundMediaUploadStore.ts b/desktop/src/features/messages/lib/backgroundMediaUploadStore.ts new file mode 100644 index 0000000000..ace711985a --- /dev/null +++ b/desktop/src/features/messages/lib/backgroundMediaUploadStore.ts @@ -0,0 +1,350 @@ +import * as React from "react"; + +import type { BlobDescriptor } from "@/shared/api/tauri"; +import { cancelMediaUpload, uploadMediaFile } from "@/shared/api/tauriMedia"; +import { + type BackgroundMediaUploadPhase, + isNativeMediaUploadPhase, + resolveBackgroundMediaUploadPhase, +} from "./backgroundMediaUploadPhase"; + +export type QueuedMediaAttachment = { + file: File; + id: number; + previewUrl?: string; + spoilered: boolean; +}; + +type BackgroundUploadTask = { + abortController: AbortController; + canceled: boolean; + filePhases: BackgroundMediaUploadPhase[]; + fileProgress: Array<{ sent: number; total: number }>; + id: number; + isCompleting: boolean; + onCancel?: () => void; +}; + +type BackgroundUploadSnapshot = { + canCancel: boolean; + isUploading: boolean; + phase: BackgroundMediaUploadPhase; + percentage: number; +}; + +type EnqueueBackgroundUploadOptions = { + attachments: QueuedMediaAttachment[]; + onCancel?: () => void; + onComplete: ( + descriptors: BlobDescriptor[], + signal: AbortSignal, + ) => Promise; + onError: (error: unknown) => void; +}; + +type StartBackgroundUploadOptions = Omit< + EnqueueBackgroundUploadOptions, + "attachments" +>; + +export type PreparedBackgroundMediaUpload = { + cancel: () => void; + start: (options: StartBackgroundUploadOptions) => boolean; +}; + +const tasks = new Map(); +const queuedAttachmentsByDraftKey = new Map(); +const listeners = new Set<() => void>(); +let nextTaskId = 0; +let snapshot: BackgroundUploadSnapshot = { + canCancel: false, + isUploading: false, + phase: "preparing", + percentage: 0, +}; +let stopUploadListeners: (() => void)[] = []; +let uploadListenersPromise: Promise | null = null; + +function progressId(taskId: number, fileIndex: number): string { + return `background-media-upload-${taskId}-${fileIndex}`; +} + +function rebuildSnapshot(): void { + const allTasks = [...tasks.values()]; + const allProgress = allTasks.flatMap((task) => task.fileProgress); + const totalBytes = allProgress.reduce( + (total, progress) => total + progress.total, + 0, + ); + const sentBytes = allProgress.reduce( + (total, progress) => total + progress.sent, + 0, + ); + snapshot = { + canCancel: allTasks.some((task) => !task.isCompleting), + isUploading: allTasks.length > 0, + phase: resolveBackgroundMediaUploadPhase( + allTasks.flatMap((task) => task.filePhases), + ), + percentage: + totalBytes === 0 ? 0 : Math.round((sentBytes / totalBytes) * 100), + }; + for (const listener of listeners) listener(); +} + +async function ensureUploadListeners(): Promise { + if (stopUploadListeners.length > 0) return; + if (uploadListenersPromise) { + await uploadListenersPromise; + return; + } + uploadListenersPromise = (async () => { + const disposers: (() => void)[] = []; + try { + const { listen } = await import("@tauri-apps/api/event"); + disposers.push( + await listen<{ + id: string; + sent: number; + total: number; + }>("media-upload-progress", (event) => { + const match = /^background-media-upload-(\d+)-(\d+)$/.exec( + event.payload.id, + ); + if (!match || event.payload.total <= 0) return; + + const task = tasks.get(Number(match[1])); + const fileIndex = Number(match[2]); + if (!task || fileIndex >= task.fileProgress.length) return; + + task.filePhases[fileIndex] = "uploading"; + task.fileProgress[fileIndex] = { + sent: Math.min( + event.payload.total, + Math.max(0, event.payload.sent), + ), + total: event.payload.total, + }; + rebuildSnapshot(); + }), + ); + disposers.push( + await listen<{ id: string; phase: unknown }>( + "media-upload-phase", + (event) => { + const match = /^background-media-upload-(\d+)-(\d+)$/.exec( + event.payload.id, + ); + if (!match || !isNativeMediaUploadPhase(event.payload.phase)) { + return; + } + + const task = tasks.get(Number(match[1])); + const fileIndex = Number(match[2]); + if (!task || fileIndex >= task.filePhases.length) return; + + task.filePhases[fileIndex] = event.payload.phase; + rebuildSnapshot(); + }, + ), + ); + if (tasks.size === 0) { + for (const dispose of disposers) dispose(); + } else { + stopUploadListeners = disposers; + } + } catch { + for (const dispose of disposers) dispose(); + // Browser and E2E runtimes do not emit native upload state. + } finally { + uploadListenersPromise = null; + } + })(); + await uploadListenersPromise; +} + +function finishTask(taskId: number): void { + tasks.delete(taskId); + rebuildSnapshot(); + if (tasks.size === 0) { + for (const dispose of stopUploadListeners) dispose(); + stopUploadListeners = []; + } +} + +function cancelTask( + task: BackgroundUploadTask, + { force = false, notify = true }: { force?: boolean; notify?: boolean } = {}, +): void { + if (task.canceled || (task.isCompleting && !force)) return; + task.canceled = true; + task.abortController.abort(); + if (notify) task.onCancel?.(); + for (let index = 0; index < task.fileProgress.length; index += 1) { + void cancelMediaUpload(progressId(task.id, index)).catch(() => undefined); + } + finishTask(task.id); +} + +function yieldForUploadFeedback(): Promise { + if ( + typeof window === "undefined" || + typeof window.requestAnimationFrame !== "function" || + document.visibilityState === "hidden" + ) { + return new Promise((resolve) => setTimeout(resolve, 0)); + } + + return new Promise((resolve) => { + window.requestAnimationFrame(() => window.setTimeout(resolve, 0)); + }); +} + +export function prepareBackgroundMediaUpload( + attachments: QueuedMediaAttachment[], +): PreparedBackgroundMediaUpload { + if (attachments.length === 0) { + let started = false; + return { + cancel: () => undefined, + start: ({ onComplete, onError }) => { + if (started) return false; + started = true; + void onComplete([], new AbortController().signal).catch(onError); + return true; + }, + }; + } + + const taskId = nextTaskId; + nextTaskId += 1; + const task: BackgroundUploadTask = { + abortController: new AbortController(), + canceled: false, + filePhases: attachments.map(() => "preparing"), + fileProgress: attachments.map((attachment) => ({ + sent: 0, + total: attachment.file.size, + })), + id: taskId, + isCompleting: false, + }; + let started = false; + tasks.set(taskId, task); + rebuildSnapshot(); + + return { + cancel: () => { + cancelTask(task); + }, + start: ({ onCancel, onComplete, onError }) => { + if (started || task.canceled) return false; + started = true; + task.onCancel = onCancel; + + void (async () => { + try { + await ensureUploadListeners(); + // Let React commit and paint the 0% task before file reads or native + // IPC begin, so large attachments never hide the initial feedback. + await yieldForUploadFeedback(); + const descriptors: BlobDescriptor[] = []; + for (let index = 0; index < attachments.length; index += 1) { + if (task.canceled) return; + const attachment = attachments[index]; + const descriptor = await uploadMediaFile( + attachment.file, + progressId(taskId, index), + task.abortController.signal, + ); + if (task.canceled) return; + task.filePhases[index] = "finishing"; + task.fileProgress[index] = { + sent: task.fileProgress[index].total, + total: task.fileProgress[index].total, + }; + rebuildSnapshot(); + descriptors.push(descriptor); + } + + if (!task.canceled) { + task.isCompleting = true; + task.filePhases.fill("finishing"); + rebuildSnapshot(); + await onComplete(descriptors, task.abortController.signal); + } + } catch (error) { + if (!task.canceled) onError(error); + } finally { + finishTask(taskId); + } + })(); + return true; + }, + }; +} + +export function enqueueBackgroundMediaUpload({ + attachments, + onCancel, + onComplete, + onError, +}: EnqueueBackgroundUploadOptions): PreparedBackgroundMediaUpload { + const preparedUpload = prepareBackgroundMediaUpload(attachments); + preparedUpload.start({ onCancel, onComplete, onError }); + return preparedUpload; +} + +export function cancelBackgroundMediaUploads(): void { + for (const task of [...tasks.values()].reverse()) { + if (!task.isCompleting) { + cancelTask(task); + return; + } + } +} + +export function resetBackgroundMediaUploads(): void { + for (const task of [...tasks.values()]) { + cancelTask(task, { force: true, notify: false }); + } + queuedAttachmentsByDraftKey.clear(); +} + +/** + * Retain local files that cannot be serialized with a draft while a deferred + * upload recovers after the user has left its channel. + */ +export function saveQueuedAttachmentsForDraft( + draftKey: string, + attachments: QueuedMediaAttachment[], +): void { + queuedAttachmentsByDraftKey.set(draftKey, attachments); +} + +/** Remove local files retained for a draft without restoring them. */ +export function discardQueuedAttachmentsForDraft(draftKey: string): void { + queuedAttachmentsByDraftKey.delete(draftKey); +} + +/** Return and remove the local files retained for a recovered draft. */ +export function takeQueuedAttachmentsForDraft( + draftKey: string, +): QueuedMediaAttachment[] { + const attachments = queuedAttachmentsByDraftKey.get(draftKey) ?? []; + queuedAttachmentsByDraftKey.delete(draftKey); + return attachments; +} + +function subscribe(listener: () => void): () => void { + listeners.add(listener); + return () => listeners.delete(listener); +} + +function getSnapshot(): BackgroundUploadSnapshot { + return snapshot; +} + +export function useBackgroundMediaUpload(): BackgroundUploadSnapshot { + return React.useSyncExternalStore(subscribe, getSnapshot, getSnapshot); +} diff --git a/desktop/src/features/messages/lib/useDrafts.test.mjs b/desktop/src/features/messages/lib/useDrafts.test.mjs index c66c607b9a..e4741baa8b 100644 --- a/desktop/src/features/messages/lib/useDrafts.test.mjs +++ b/desktop/src/features/messages/lib/useDrafts.test.mjs @@ -488,6 +488,20 @@ test("markDraftSent_new_active_draft_after_send_is_independent", () => { assert.equal(getSentDraftEntries().length, 0, "no sent records"); }); +test("markDraftSent_keeps_a_newer_draft_with_the_same_key", () => { + setup("pubkey-sent-race"); + persistDraftEntry("chan-race", "submitted", "chan-race", [IMG_A], []); + // A background upload is still in flight when the user starts the next + // message in this channel. Its completion must not clear this newer entry. + persistDraftEntry("chan-race", "next draft", "chan-race", [IMG_B], []); + + markDraftSentEntry("chan-race", "submitted", "chan-race", [IMG_A], []); + + const draft = loadDraftEntry("chan-race"); + assert.equal(draft?.content, "next draft"); + assert.deepEqual(draft?.pendingImeta, [IMG_B]); +}); + test("getActiveDraftEntries_excludes_cleared_drafts", () => { setup("pubkey-active"); persistDraftEntry("chan-active", "active draft", "chan-active", [], []); diff --git a/desktop/src/features/messages/lib/useDrafts.ts b/desktop/src/features/messages/lib/useDrafts.ts index 1e9fee6e56..2a78e88132 100644 --- a/desktop/src/features/messages/lib/useDrafts.ts +++ b/desktop/src/features/messages/lib/useDrafts.ts @@ -1,5 +1,6 @@ import * as React from "react"; +import { discardQueuedAttachmentsForDraft } from "@/features/messages/lib/backgroundMediaUploadStore"; import type { ImetaMedia } from "@/features/messages/lib/imetaMediaMarkdown"; import { setLocalStorageItemWithRecovery } from "@/shared/lib/localStorageQuota"; @@ -296,6 +297,11 @@ export function loadDraftEntry(draftKey: string): DraftState | undefined { return readStore().get(draftKey); } +export function deleteDraftEntry(draftKey: string): void { + discardQueuedAttachmentsForDraft(draftKey); + clearDraftEntry(draftKey); +} + export function clearDraftEntry(draftKey: string): void { const map = readStore(); if (map.has(draftKey)) { @@ -495,12 +501,24 @@ export function getSentDraftEntries(): Array<{ */ export function markDraftSentEntry( draftKey: string, - _content: string, - _channelId: string, - _pendingImeta: ImetaMedia[], - _spoileredAttachmentUrls: string[], + content: string, + channelId: string, + pendingImeta: ImetaMedia[], + spoileredAttachmentUrls: string[], ): void { - clearDraftEntry(draftKey); + const draft = loadDraftEntry(draftKey); + // A background upload can finish after the user has started the next draft + // in this same channel. Clear only the exact submitted snapshot rather than + // deleting whichever newer entry currently owns the key. + if ( + draft?.content === content && + draft.channelId === channelId && + JSON.stringify(draft.pendingImeta) === JSON.stringify(pendingImeta) && + JSON.stringify(draft.spoileredAttachmentUrls) === + JSON.stringify(spoileredAttachmentUrls) + ) { + clearDraftEntry(draftKey); + } } // ── Reactive hooks ──────────────────────────────────────────────────────────── diff --git a/desktop/src/features/messages/lib/useMediaUpload.ts b/desktop/src/features/messages/lib/useMediaUpload.ts index b627633be2..b4c3cae44f 100644 --- a/desktop/src/features/messages/lib/useMediaUpload.ts +++ b/desktop/src/features/messages/lib/useMediaUpload.ts @@ -5,6 +5,7 @@ import { pickAndUploadMedia, uploadMediaBytes, } from "@/shared/api/tauri"; +import type { QueuedMediaAttachment } from "./backgroundMediaUploadStore"; /** * First 4 hex chars of the sha256 — used as a short display name. @@ -30,6 +31,7 @@ export type UploadingAttachmentPreview = { * (e.g. video transcoding before the HTTP upload starts). */ progress?: number | null; slotIndex?: number; + spoilered?: boolean; type?: string; }; @@ -133,7 +135,22 @@ async function captureVideoPosterFrame( } } -export function useMediaUpload() { +type UseMediaUploadOptions = { + /** Keep newly selected files local until the message is submitted. */ + deferUploadsUntilSend?: boolean; +}; + +export function useMediaUpload({ + deferUploadsUntilSend = false, +}: UseMediaUploadOptions = {}) { + const e2eConfig = ( + window as Window & { + __BUZZ_E2E__?: { mock?: { deferredComposerUploads?: boolean } }; + } + ).__BUZZ_E2E__; + const queueUntilSend = + deferUploadsUntilSend && + (!e2eConfig || e2eConfig.mock?.deferredComposerUploads === true); const [uploadState, setUploadState] = React.useState({ status: "idle", }); @@ -144,6 +161,11 @@ export function useMediaUpload() { >([]); const uploadingPreviewsRef = React.useRef(uploadingPreviews); uploadingPreviewsRef.current = uploadingPreviews; + const [queuedAttachments, setQueuedAttachmentsState] = React.useState< + QueuedMediaAttachment[] + >([]); + const queuedAttachmentsRef = React.useRef(queuedAttachments); + queuedAttachmentsRef.current = queuedAttachments; React.useEffect(() => { let unlisten: (() => void) | null = null; let cancelled = false; @@ -248,6 +270,99 @@ export function useMediaUpload() { * before React flushes the state update. */ const nextSlotRef = React.useRef(0); const nextUploadingPreviewIdRef = React.useRef(0); + const nextQueuedAttachmentIdRef = React.useRef(0); + + const updateQueuedVideoPoster = React.useCallback( + (id: number, posterUrl: string) => { + setQueuedAttachmentsState((current) => + current.map((attachment) => + attachment.id === id + ? { ...attachment, previewUrl: posterUrl } + : attachment, + ), + ); + }, + [], + ); + + const queueFiles = React.useCallback( + (files: File[]) => { + if (files.length === 0) return; + + const attachments = files.map((file) => { + const id = nextQueuedAttachmentIdRef.current; + nextQueuedAttachmentIdRef.current += 1; + const previewUrl = file.type.startsWith("image/") + ? URL.createObjectURL(file) + : undefined; + if (file.type.startsWith("video/")) { + void captureVideoPosterFrame(file).then((poster) => { + if (poster) updateQueuedVideoPoster(id, poster.posterUrl); + }); + } + return { file, id, previewUrl, spoilered: false }; + }); + + setQueuedAttachmentsState((current) => [...current, ...attachments]); + }, + [updateQueuedVideoPoster], + ); + + const removeQueuedAttachment = React.useCallback((id: number) => { + setQueuedAttachmentsState((current) => { + const removed = current.find((attachment) => attachment.id === id); + if (removed?.previewUrl?.startsWith("blob:")) { + URL.revokeObjectURL(removed.previewUrl); + } + return current.filter((attachment) => attachment.id !== id); + }); + }, []); + + const clearQueuedAttachments = React.useCallback(() => { + setQueuedAttachmentsState((current) => { + for (const attachment of current) { + if (attachment.previewUrl?.startsWith("blob:")) { + URL.revokeObjectURL(attachment.previewUrl); + } + } + return []; + }); + }, []); + + const restoreQueuedAttachments = React.useCallback( + (attachments: QueuedMediaAttachment[]) => { + clearQueuedAttachments(); + queueFiles(attachments.map((attachment) => attachment.file)); + setQueuedAttachmentsState((current) => + current.map((attachment, index) => ({ + ...attachment, + spoilered: attachments[index]?.spoilered ?? false, + })), + ); + }, + [clearQueuedAttachments, queueFiles], + ); + + const toggleQueuedAttachmentSpoiler = React.useCallback((id: number) => { + setQueuedAttachmentsState((current) => + current.map((attachment) => + attachment.id === id + ? { ...attachment, spoilered: !attachment.spoilered } + : attachment, + ), + ); + }, []); + + React.useEffect( + () => () => { + for (const attachment of queuedAttachmentsRef.current) { + if (attachment.previewUrl?.startsWith("blob:")) { + URL.revokeObjectURL(attachment.previewUrl); + } + } + }, + [], + ); const isUploadCanceled = React.useCallback( (previewId?: number) => @@ -367,6 +482,19 @@ export function useMediaUpload() { ); const handlePaperclip = React.useCallback(async () => { + if (queueUntilSend) { + const input = document.createElement("input"); + input.type = "file"; + input.multiple = true; + input.addEventListener( + "change", + () => queueFiles(Array.from(input.files ?? [])), + { once: true }, + ); + input.click(); + return; + } + // Hold a single pending tick while the native picker is open + uploads // run in Rust. We don't know the file count until the dialog returns, // and uploads are already complete by then, so we just append each @@ -374,7 +502,7 @@ export function useMediaUpload() { const previewId = reserveUploadingPreview(); setUploadingCount((c) => c + 1); try { - const descriptors = await pickAndUploadMedia(); + const descriptors = await pickAndUploadMedia(uploadProgressId(previewId)); if (isUploadCanceled(previewId)) return; finishUpload(previewId); for (const descriptor of descriptors) { @@ -385,7 +513,14 @@ export function useMediaUpload() { if (isUploadCanceled(previewId)) return; onUploadError(err, previewId); } - }, [finishUpload, isUploadCanceled, onUploadError, reserveUploadingPreview]); + }, [ + queueUntilSend, + finishUpload, + isUploadCanceled, + onUploadError, + queueFiles, + reserveUploadingPreview, + ]); const handleDrop = React.useCallback( async (event: React.DragEvent) => { @@ -399,6 +534,11 @@ export function useMediaUpload() { // (active-content + executables) and size caps; everything else uploads. const validFiles = files; + if (queueUntilSend) { + queueFiles(validFiles); + return; + } + setUploadingCount((c) => c + validFiles.length); const baseIndex = reserveSlots(validFiles.length); @@ -425,9 +565,11 @@ export function useMediaUpload() { }, [ reserveSlots, + queueUntilSend, fillSlot, isUploadCanceled, onUploadError, + queueFiles, reserveUploadingPreview, ], ); @@ -497,6 +639,11 @@ export function useMediaUpload() { event.preventDefault(); + if (queueUntilSend) { + queueFiles(mediaFiles); + return; + } + setUploadingCount((c) => c + mediaFiles.length); const baseIndex = reserveSlots(mediaFiles.length); @@ -522,9 +669,11 @@ export function useMediaUpload() { }, [ reserveSlots, + queueUntilSend, fillSlot, isUploadCanceled, onUploadError, + queueFiles, reserveUploadingPreview, ], ); @@ -532,6 +681,10 @@ export function useMediaUpload() { /** Upload a File directly — used by Tiptap's editorProps.handlePaste. */ const uploadFile = React.useCallback( async (file: File) => { + if (queueUntilSend) { + queueFiles([file]); + return; + } const previewId = reserveUploadingPreview(file); setUploadingCount((c) => c + 1); try { @@ -547,7 +700,14 @@ export function useMediaUpload() { onUploadError(err, previewId); } }, - [isUploadCanceled, onUploaded, onUploadError, reserveUploadingPreview], + [ + queueUntilSend, + isUploadCanceled, + onUploaded, + onUploadError, + queueFiles, + reserveUploadingPreview, + ], ); /** @@ -642,10 +802,22 @@ export function useMediaUpload() { ); const isUploading = uploadingCount > 0; + const queuedPreviews = React.useMemo( + () => + queuedAttachments.map((attachment) => ({ + filename: attachment.file.name, + id: attachment.id, + posterUrl: attachment.previewUrl, + spoilered: attachment.spoilered, + type: attachment.file.type, + })), + [queuedAttachments], + ); return React.useMemo( () => ({ cancelUpload, + clearQueuedAttachments, handleDragEnter, handleDragLeave, handleDragOver, @@ -657,10 +829,16 @@ export function useMediaUpload() { originalUrlByUrl, pendingImeta, pendingImetaRef, + queuedAttachments, + queuedAttachmentsRef, + queuedPreviews, removeAttachment, + removeQueuedAttachment, + restoreQueuedAttachments, revertAttachment, setPendingImeta, setUploadState, + toggleQueuedAttachmentSpoiler, uploadEditedAttachment, uploadFile, uploadingCount, @@ -669,6 +847,7 @@ export function useMediaUpload() { }), [ cancelUpload, + clearQueuedAttachments, handleDragEnter, handleDragLeave, handleDragOver, @@ -679,9 +858,14 @@ export function useMediaUpload() { isUploading, originalUrlByUrl, pendingImeta, + queuedAttachments, + queuedPreviews, removeAttachment, + removeQueuedAttachment, + restoreQueuedAttachments, revertAttachment, setPendingImeta, + toggleQueuedAttachmentSpoiler, uploadEditedAttachment, uploadFile, uploadingCount, diff --git a/desktop/src/features/messages/ui/ComposerAttachments.tsx b/desktop/src/features/messages/ui/ComposerAttachments.tsx index bee38e321c..8578e4c083 100644 --- a/desktop/src/features/messages/ui/ComposerAttachments.tsx +++ b/desktop/src/features/messages/ui/ComposerAttachments.tsx @@ -61,6 +61,12 @@ type ComposerAttachmentsProps = { attachments: ImetaMedia[]; isUploading?: boolean; onCancelUpload?: (previewId: number) => void; + /** Remove a local attachment that has not started uploading yet. */ + onRemoveQueued?: (previewId: number) => void; + /** Toggle spoiler state for a local attachment before it receives a URL. */ + onToggleQueuedSpoiler?: (previewId: number) => void; + /** Local previews that are queued for upload when the message is sent. */ + queuedPreviews?: UploadingAttachmentPreview[]; uploadingCount?: number; uploadingPreviews?: UploadingAttachmentPreview[]; /** Upload annotated bytes as a replacement for the attachment at `url`. */ @@ -511,6 +517,9 @@ export const ComposerAttachments = React.memo(function ComposerAttachments({ uploadingCount = 0, uploadingPreviews = [], onCancelUpload, + onRemoveQueued, + onToggleQueuedSpoiler, + queuedPreviews = [], onEditSave, onRemove, onRevert, @@ -518,7 +527,8 @@ export const ComposerAttachments = React.memo(function ComposerAttachments({ onToggleSpoiler, spoileredUrls, }: ComposerAttachmentsProps) { - if (attachments.length === 0 && !isUploading) return null; + if (attachments.length === 0 && queuedPreviews.length === 0 && !isUploading) + return null; const uploadPlaceholders: UploadingAttachmentPreview[] = uploadingPreviews.length > 0 @@ -609,6 +619,94 @@ export const ComposerAttachments = React.memo(function ComposerAttachments({ /> ); })} + {queuedPreviews.map((preview) => { + const isMedia = + preview.type?.startsWith("image/") || + preview.type?.startsWith("video/"); + return ( + + {isMedia ? ( +
+
+ {preview.posterUrl ? ( + {preview.filename + ) : ( +
+ +
+ )} +
+ {preview.spoilered ? ( +
+ +
+ ) : null} +
+ ) : ( +
+ + + {preview.filename ?? "Attachment"} + +
+ )} + {onRemoveQueued ? ( + + + + + Remove attachment + + ) : null} + {isMedia && onToggleQueuedSpoiler ? ( + + + + onToggleQueuedSpoiler(preview.id) + } + pressed={preview.spoilered} + type="button" + > + + + + + {preview.spoilered ? "Remove spoiler" : "Mark as spoiler"} + + + ) : null} +
+ ); + })} {isUploading && uploadPlaceholders.map((preview) => ( void; onCancelReply?: () => void; @@ -39,6 +41,7 @@ export function ComposerReplyEditBanner({
+ ); +} diff --git a/desktop/src/features/messages/ui/ComposerUploadProgressPill.tsx b/desktop/src/features/messages/ui/ComposerUploadProgressPill.tsx new file mode 100644 index 0000000000..288b32f8f1 --- /dev/null +++ b/desktop/src/features/messages/ui/ComposerUploadProgressPill.tsx @@ -0,0 +1,171 @@ +import { AnimatePresence, motion, useReducedMotion } from "motion/react"; + +import { + type BackgroundMediaUploadPhase, + backgroundMediaUploadPhaseLabel, +} from "@/features/messages/lib/backgroundMediaUploadPhase"; +import { cn } from "@/shared/lib/cn"; +import { Spinner } from "@/shared/ui/spinner"; + +export function ComposerUploadProgressPill({ + canCancel, + isUploading, + onCancel, + phase, + percentage, +}: { + canCancel: boolean; + isUploading: boolean; + onCancel: () => void; + phase: BackgroundMediaUploadPhase; + percentage: number; +}) { + const reducedMotion = useReducedMotion(); + const phaseLabel = backgroundMediaUploadPhaseLabel(phase); + const isTransferring = phase === "uploading"; + const phaseTransition = reducedMotion + ? { duration: 0 } + : { duration: 0.18, ease: [0.77, 0, 0.175, 1] as const }; + + return ( + + {isUploading ? ( + +
+ +
+ + + + + {phaseLabel} + + + + + + {isTransferring ? ( + + {percentage}% + + ) : ( + + + )} + + + + + {canCancel ? ( + + ) : null} +
+
+
+ ) : null} +
+ ); +} diff --git a/desktop/src/features/messages/ui/MessageComposer.tsx b/desktop/src/features/messages/ui/MessageComposer.tsx index 69e4ec67b5..6f79daa606 100644 --- a/desktop/src/features/messages/ui/MessageComposer.tsx +++ b/desktop/src/features/messages/ui/MessageComposer.tsx @@ -1,5 +1,4 @@ import * as React from "react"; - import { EditorContent } from "@tiptap/react"; import { useChannelLinks } from "@/features/messages/lib/useChannelLinks"; import { handleAgentSnapshotPaste } from "@/features/messages/lib/agentSnapshotClipboard"; @@ -10,20 +9,21 @@ import { resolveSentDraftKey } from "@/features/messages/ui/draftSubmitKey"; import { useEmojiAutocomplete } from "@/features/messages/lib/useEmojiAutocomplete"; import type { EmojiSuggestion } from "@/features/messages/lib/useEmojiAutocomplete"; import { useCustomEmoji } from "@/features/custom-emoji/hooks"; -import { buildCustomEmojiTags } from "@/shared/lib/customEmojiTags"; import { - buildOutgoingMessage, findSpoileredImetaMediaUrls, type ImetaMedia, - mergeOutgoingTags, restoreImetaMediaDisplayLabels, stripImetaMediaLines, } from "@/features/messages/lib/imetaMediaMarkdown"; - import { useAttachmentEditing } from "@/features/messages/lib/useAttachmentEditing"; import { useMediaUpload } from "@/features/messages/lib/useMediaUpload"; +import { + cancelBackgroundMediaUploads, + saveQueuedAttachmentsForDraft, + takeQueuedAttachmentsForDraft, + useBackgroundMediaUpload, +} from "@/features/messages/lib/backgroundMediaUploadStore"; import { useMentions } from "@/features/messages/lib/useMentions"; -import { diffAddedMentionPubkeys } from "@/features/messages/lib/threading"; import { getPersistentAgentAudienceScope } from "@/features/messages/lib/persistentAgentAudience"; import { useIdentityQuery } from "@/shared/api/hooks"; import { @@ -50,14 +50,14 @@ import { type MentionSuggestion, } from "./MentionAutocomplete"; import { ComposerDockToolbar } from "./ComposerDockToolbar"; +import { ComposerUploadProgressPill } from "./ComposerUploadProgressPill"; import { NonMemberMentionDialog } from "./NonMemberMentionDialog"; import { useMentionSendFlow } from "./useMentionSendFlow"; import { usePersistentAgentMentionHydration } from "./usePersistentAgentMentionHydration"; import { useComposerContentState } from "./useComposerContentState"; import { useDraftPersistLifecycle } from "./useDraftPersistSnapshot"; - +import { submitMessageEdit } from "./submitMessageEdit"; import type { MessageComposerProps } from "./MessageComposer.types"; - function MessageComposerImpl({ audienceContext = null, channelId = null, @@ -71,6 +71,7 @@ function MessageComposerImpl({ onAutoSubmitComplete, editTarget = null, isSending = false, + onDeferredEditPendingChange, onCancelEdit, onCancelReply, onCaptureSendContext, @@ -83,6 +84,7 @@ function MessageComposerImpl({ profiles, replyTarget = null, mediaController, + showBackgroundUploadProgress = true, showTopBorder = false, toolbarExtraActions, typingParentEventId = null, @@ -103,12 +105,10 @@ function MessageComposerImpl({ >(() => new Set()); const spoileredAttachmentUrlsRef = React.useRef(spoileredAttachmentUrls); spoileredAttachmentUrlsRef.current = spoileredAttachmentUrls; - const handleFormattingToggle = React.useCallback((pressed: boolean) => { if (pressed) setIsEmojiPickerOpen(false); setIsFormattingOpen(pressed); }, []); - const drafts = useDrafts(); const identityQuery = useIdentityQuery(); const effectiveDraftKey = draftKey ?? channelId; @@ -124,10 +124,10 @@ function MessageComposerImpl({ : null; const effectiveDraftKeyRef = React.useRef(effectiveDraftKey); effectiveDraftKeyRef.current = effectiveDraftKey; - // Snapshot composer state before edit mode so cancel can restore it. const preEditSnapshotRef = React.useRef<{ content: string; pendingImeta: ImetaMedia[]; + queuedAttachments: ReturnType["queuedAttachments"]; spoileredAttachmentUrls: Set; } | null>(null); const mentions = useMentions(channelId, undefined, profiles, { @@ -141,16 +141,20 @@ function MessageComposerImpl({ typingParentEventId, typingRootEventId, ); - - // We pass a custom setter that both updates React state AND inserts - // markdown into the Tiptap editor when media upload completes. - const internalMedia = useMediaUpload(); + const internalMedia = useMediaUpload({ deferUploadsUntilSend: true }); const media = mediaController ?? internalMedia; + const [isDeferredEditPending, setDeferredEditPending] = React.useState(false); + const composerDisabled = disabled || isDeferredEditPending; + const isEditSubmissionLocked = + isSending || media.isUploading || isDeferredEditPending; + const canRestoreEditDraftRef = React.useRef(false); + canRestoreEditDraftRef.current = + contentRef.current.trim().length === 0 && + media.pendingImetaRef.current.length === 0 && + media.queuedAttachmentsRef.current.length === 0; const ownsDropZone = mediaController === undefined; - - // Draft-persist lifecycle: restore/clear content + imeta + spoilered urls on - // key change, and persist the outgoing draft in the cleanup. The StrictMode - // fix lives inside this hook — see useDraftPersistSnapshot.ts. + const backgroundUpload = useBackgroundMediaUpload(); + // Restore/persist drafts at a key boundary; the hook handles StrictMode. useDraftPersistLifecycle({ effectiveDraftKey, channelId, @@ -160,6 +164,11 @@ function MessageComposerImpl({ restoreMentionRefs: mentions.restoreDraftMentionRefs, livePendingImeta: media.pendingImeta, setPendingImeta: media.setPendingImeta, + getQueuedAttachments: () => media.queuedAttachmentsRef.current, + saveQueuedAttachmentsForDraft, + clearQueuedAttachments: media.clearQueuedAttachments, + restoreQueuedAttachments: media.restoreQueuedAttachments, + takeQueuedAttachmentsForDraft, setContent: (content) => { setComposerContent(content); richText.setContent(content); @@ -179,7 +188,6 @@ function MessageComposerImpl({ channelLinks.clearChannels(); emojiAutocomplete.clearEmojis(); }, [effectiveDraftKey]); - const disabledRef = React.useRef(disabled); const isSendingRef = React.useRef(isSending); const isUploadingRef = React.useRef(media.isUploading); @@ -198,16 +206,13 @@ function MessageComposerImpl({ editTargetRef.current = editTarget; extractMentionPubkeysRef.current = mentions.extractMentionPubkeys; ownerPubkeyRef.current = ownerPubkey; - const isAutocompleteOpenRef = React.useRef(false); isAutocompleteOpenRef.current = mentions.isMentionOpen || channelLinks.isChannelOpen || emojiAutocomplete.isEmojiAutocompleteOpen; - const submitMessageRef = React.useRef<() => void>(() => {}); const composerScrollRef = React.useRef(null); - // Set after `useLinkEditor` exists below; the editor's link-click handler // delegates through this ref to break the hook ordering cycle (the editor // needs `onEditLink`, but the link editor needs the editor's `richText`). @@ -218,7 +223,6 @@ function MessageComposerImpl({ ((info: LinkSelectionInfo | null) => void) | null >(null); const onLinkShortcutRef = React.useRef<(() => boolean) | null>(null); - const scrollComposerToBottom = React.useCallback(() => { window.requestAnimationFrame(() => { const scrollElement = composerScrollRef.current; @@ -226,17 +230,15 @@ function MessageComposerImpl({ scrollElement.scrollTop = scrollElement.scrollHeight; }); }, []); - const computedPlaceholder = editTarget ? "Edit your message" : (placeholder ?? (replyTarget ? `Reply to ${replyTarget.author} in #${channelName}` : `Message #${channelName}`)); - const richText = useRichTextEditor({ placeholder: computedPlaceholder, - editable: !disabled, + editable: !composerDisabled, mentionNames: mentions.knownNames, agentMentionNames: mentions.agentKnownNames, channelNames: channelLinks.knownChannelNames, @@ -255,19 +257,15 @@ function MessageComposerImpl({ onLinkShortcut: () => onLinkShortcutRef.current?.() ?? false, onUpdate: ({ cursor, text }) => { setComposerContentFromText(text); - mentions.updateMentionQuery(text, cursor); channelLinks.updateChannelQuery(text, cursor); emojiAutocomplete.updateEmojiQuery(text, cursor); - persistentMentionHydrationRef.current?.reconcile(text); - if (text.trim().length > 0) { notifyTyping(); } }, }); - const linkEditor = useLinkEditor(richText); syncContentRefFromEditorRef.current = () => { const markdown = richText.getMarkdown(); @@ -278,7 +276,6 @@ function MessageComposerImpl({ onLinkSelectionChangeRef.current = linkEditor.showFromCursor; onLinkShortcutRef.current = linkEditor.openFromShortcut; useComposerSpoilerParticles(richText.editor, composerScrollRef); - const persistentMentionHydration = usePersistentAgentMentionHydration({ audienceScope, hydrationKey: effectiveDraftKey, @@ -292,7 +289,6 @@ function MessageComposerImpl({ persistentMentionHydration, ); persistentMentionHydrationRef.current = persistentMentionHydration; - const mentionSendFlow = useMentionSendFlow({ channelId, channelLinks, @@ -308,6 +304,11 @@ function MessageComposerImpl({ setContent: setComposerContent, setIsEmojiPickerOpen, setPendingImeta: media.setPendingImeta, + hasUnsavedMedia: () => + media.pendingImetaRef.current.length > 0 || + media.queuedAttachmentsRef.current.length > 0, + clearQueuedAttachments: media.clearQueuedAttachments, + restoreQueuedAttachments: media.restoreQueuedAttachments, setSpoileredAttachmentUrls, onSuccessfulExplicitAgentAudience: persistentAudience.enabled && audienceContext && ownerPubkey @@ -322,16 +323,18 @@ function MessageComposerImpl({ : undefined, resolvePostSendContent: persistentMentionHydration.resolvePostSendContent, }); - + React.useEffect(() => { + onDeferredEditPendingChange?.(isDeferredEditPending); + return () => onDeferredEditPendingChange?.(false); + }, [isDeferredEditPending, onDeferredEditPendingChange]); // biome-ignore lint/correctness/useExhaustiveDependencies: editTarget?.id is the trigger React.useEffect(() => { if (editTarget) { - // Snapshot the current draft (text + attachments) so the user's - // in-flight work survives the edit-mode hijack and is restored on - // edit-cancel/exit. + // Preserve the user's in-flight draft while editing another message. preEditSnapshotRef.current = { content: syncComposerContentFromEditor(), pendingImeta: [...media.pendingImetaRef.current], + queuedAttachments: [...media.queuedAttachmentsRef.current], spoileredAttachmentUrls: new Set(spoileredAttachmentUrls), }; // Strip the trailing `![image|video](url)` lines that correspond to @@ -348,6 +351,7 @@ function MessageComposerImpl({ // attachments so they show up in `ComposerAttachments` and the user // can remove existing ones / add new ones before saving. media.setPendingImeta(editableImeta); + media.clearQueuedAttachments(); setSpoileredAttachmentUrls( findSpoileredImetaMediaUrls(editTarget.body, editableImeta), ); @@ -363,6 +367,7 @@ function MessageComposerImpl({ const { content: restoredContent, pendingImeta: restoredImeta, + queuedAttachments: restoredQueuedAttachments, spoileredAttachmentUrls: restoredSpoileredAttachmentUrls, } = preEditSnapshotRef.current; preEditSnapshotRef.current = null; @@ -371,21 +376,19 @@ function MessageComposerImpl({ ? richText.setContent(restoredContent) : richText.clearContent(); media.setPendingImeta(restoredImeta); + media.restoreQueuedAttachments(restoredQueuedAttachments); setSpoileredAttachmentUrls(restoredSpoileredAttachmentUrls); } }, [editTarget?.id]); - // ── Focus on reply ────────────────────────────────────────────────── // Use focusPreserve so that re-renders (e.g. new messages arriving in // a thread) don't yank the cursor to the end while the user is editing. React.useEffect(() => { - if (!replyTarget || disabled) return; + if (!replyTarget || composerDisabled) return; richText.focusPreserve(); - }, [disabled, replyTarget, richText.focusPreserve]); - + }, [composerDisabled, replyTarget, richText.focusPreserve]); // ── Autofocus on mount / channel switch ───────────────────────────── - useComposerAutofocus(richText.focus, effectiveDraftKey, disabled); - + useComposerAutofocus(richText.focus, effectiveDraftKey, composerDisabled); // ── Mention / channel / emoji autocomplete insertion ──────────────── // Hooks return a plain-text edit descriptor; `replacePlainTextRange` // applies it as a single ProseMirror transaction (no markdown round-trip). @@ -400,7 +403,6 @@ function MessageComposerImpl({ }, [richText.replacePlainTextRange], ); - const applyMentionInsert = React.useCallback( (suggestion: MentionSuggestion) => { const { cursor } = richText.getPlainTextAndCursor(); @@ -412,7 +414,6 @@ function MessageComposerImpl({ richText.getPlainTextAndCursor, ], ); - const applyChannelInsert = React.useCallback( (suggestion: ChannelSuggestion) => { const { cursor } = richText.getPlainTextAndCursor(); @@ -424,7 +425,6 @@ function MessageComposerImpl({ richText.getPlainTextAndCursor, ], ); - const applyEmojiInsert = React.useCallback( (suggestion: EmojiSuggestion) => { const { cursor } = richText.getPlainTextAndCursor(); @@ -436,7 +436,6 @@ function MessageComposerImpl({ richText.getPlainTextAndCursor, ], ); - // ── Emoji insertion ───────────────────────────────────────────────── const insertEmoji = React.useCallback( (emoji: string) => { @@ -473,12 +472,10 @@ function MessageComposerImpl({ }, [richText.editor, mentions.clearMentions, customEmoji], ); - // ── @ mention picker (toolbar button) ─────────────────────────────── const openMentionPicker = React.useCallback(() => { if (!richText.editor) return; const { text, cursor } = richText.getPlainTextAndCursor(); - // Check if there's already an @-query in progress const beforeCursor = text.slice(0, cursor); if (/(?:^|[\s])@[^\s]*$/.test(beforeCursor)) { @@ -486,14 +483,12 @@ function MessageComposerImpl({ richText.focus(); return; } - // Insert @ at cursor const previousChar = text.slice(0, cursor).slice(-1); const prefix = cursor > 0 && previousChar && !/\s/.test(previousChar) ? " @" : "@"; richText.editor.chain().focus().insertContent(prefix).run(); setIsEmojiPickerOpen(false); - // Trigger mention detection after inserting @ const { text: updatedText, cursor: updatedCursor } = richText.getPlainTextAndCursor(); @@ -504,90 +499,66 @@ function MessageComposerImpl({ richText.focus, mentions.updateMentionQuery, ]); - // ── Submit message ────────────────────────────────────────────────── const submitMessage = React.useCallback(async () => { const trimmed = syncComposerContentFromEditor().trim(); - // Edit mode if (editTargetRef.current && onEditSaveRef.current) { - if (isSendingRef.current || isUploadingRef.current) return; - const currentPendingImeta = media.pendingImetaRef.current; + if (isEditSubmissionLocked) return; // No empty-edit guard here: clearing an edit to empty (no text, no // attachments) flows through to onEditSave as empty content, which // deletes the message instead of publishing it (see handleEditSave). - - // Build the edit's body + imeta tag set. Coerce `mediaTags ?? []` - // because edit semantics use `[]` as the explicit "wipe all - // attachments" signal — the receiver overlay drops imeta when the - // edit carries an empty (but defined) set. - const { content: finalContent, mediaTags } = buildOutgoingMessage( - trimmed, - currentPendingImeta, + await submitMessageEdit({ + content: trimmed, + editTargetId: editTargetRef.current.id, + customEmoji, + originalContent: editTargetRef.current.body, + ownerPubkey: ownerPubkeyRef.current, + getMentionRefs: mentions.getDraftMentionRefs, + pendingImeta: media.pendingImetaRef.current, + queuedAttachments: media.queuedAttachmentsRef.current, spoileredAttachmentUrls, - ); - - // NIP-30: attach `["emoji", shortcode, url]` tags for custom emoji in the - // edited body, exactly like the send path. Without this an edited message - // ships with no emoji tags, so the receiver can't resolve a `:shortcode:` - // and renders the literal text. `?? []` preserves edit semantics (a - // defined-but-empty media set means "wipe attachments"). - const outgoingTags = - mergeOutgoingTags( - mediaTags, - buildCustomEmojiTags(finalContent, customEmoji), - ) ?? []; - - // Notify only mentions this edit *newly adds* (see - // diffAddedMentionPubkeys): a typo-fix edit that leaves the mention set - // unchanged emits no `p` tags and re-wakes nobody. Computed before the - // composer state is cleared below. - const addedMentionPubkeys = diffAddedMentionPubkeys( - extractMentionPubkeysRef.current(editTargetRef.current.body), - extractMentionPubkeysRef.current(finalContent), - ownerPubkeyRef.current ?? "", - ); - - const savedContent = trimmed; - const savedImeta = [...currentPendingImeta]; - const savedSpoileredAttachmentUrls = new Set(spoileredAttachmentUrls); - setComposerContent(""); - richText.clearContent(); - media.setPendingImeta([]); - setSpoileredAttachmentUrls(new Set()); - mentions.clearMentions(); - channelLinks.clearChannels(); - emojiAutocomplete.clearEmojis(); - setIsEmojiPickerOpen(false); - - try { - await onEditSaveRef.current( - finalContent, - outgoingTags, - addedMentionPubkeys, - ); - } catch { - setComposerContent(savedContent); - richText.setContent(savedContent); - media.setPendingImeta(savedImeta); - setSpoileredAttachmentUrls(savedSpoileredAttachmentUrls); - } + extractMentionPubkeys: extractMentionPubkeysRef.current, + save: onEditSaveRef.current, + clearComposer: () => { + setComposerContent(""); + richText.clearContent(); + media.setPendingImeta([]); + media.clearQueuedAttachments(); + setSpoileredAttachmentUrls(new Set()); + mentions.clearMentions(); + channelLinks.clearChannels(); + emojiAutocomplete.clearEmojis(); + setIsEmojiPickerOpen(false); + }, + restoreComposer: (draft) => { + setComposerContent(draft.content); + richText.setContent(draft.content); + media.setPendingImeta(draft.pendingImeta); + media.restoreQueuedAttachments(draft.queuedAttachments); + setSpoileredAttachmentUrls(draft.spoileredAttachmentUrls); + }, + restoreMentionRefs: mentions.restoreDraftMentionRefs, + shouldRestoreComposer: () => canRestoreEditDraftRef.current, + setDeferredUploadPending: setDeferredEditPending, + setUploadError: (message) => + media.setUploadState({ status: "error", message }), + }); return; } - // Normal send const currentPendingImeta = media.pendingImetaRef.current; - const hasMedia = currentPendingImeta.length > 0; + const currentQueuedAttachments = media.queuedAttachmentsRef.current; + const hasMedia = + currentPendingImeta.length > 0 || currentQueuedAttachments.length > 0; if ( (!trimmed && !hasMedia) || disabledRef.current || isSendingRef.current || - isUploadingRef.current || mentionSendFlow.isPreparingMentionSend ) { return; } - const capturedThreadContext = onCaptureSendContext?.() ?? null; if ( capturedThreadContext !== null && @@ -595,7 +566,6 @@ function MessageComposerImpl({ ) { return; } - onPreparingMentionSendChange?.(true); persistentMentionHydration.beginSubmit(); try { @@ -603,10 +573,12 @@ function MessageComposerImpl({ capturedChannelId: channelId, capturedThreadContext, pendingImeta: currentPendingImeta, + queuedAttachments: currentQueuedAttachments, sentDraftKey: resolveSentDraftKey( effectiveDraftKeyRef.current, drafts.loadDraft, ), + recoveryDraftKey: effectiveDraftKey, spoileredAttachmentUrls, trimmed, audienceGeneration: persistentAudience.generation, @@ -622,8 +594,12 @@ function MessageComposerImpl({ customEmoji, drafts.loadDraft, emojiAutocomplete.clearEmojis, + media.clearQueuedAttachments, media.pendingImetaRef, + media.queuedAttachmentsRef, + media.restoreQueuedAttachments, media.setPendingImeta, + media.setUploadState, mentionSendFlow.isPreparingMentionSend, mentionSendFlow.sendMessageWithMentionFlow, mentions.clearMentions, @@ -638,9 +614,12 @@ function MessageComposerImpl({ persistentMentionHydration, persistentAudience.generation, persistentAudience.revision, + isEditSubmissionLocked, + effectiveDraftKey, + mentions.getDraftMentionRefs, + mentions.restoreDraftMentionRefs, ]); submitMessageRef.current = submitMessage; - // ── Auto-submit on draft send ──────────────────────────────────────────── // When `autoSubmitDraftKey` is set (the user clicked "Send message" in the // Drafts panel and confirmed), fire `submitMessage` once after mount so the @@ -654,7 +633,6 @@ function MessageComposerImpl({ // runs, preventing re-fire on re-render or back-navigation. const onAutoSubmitCompleteRef = React.useRef(onAutoSubmitComplete); onAutoSubmitCompleteRef.current = onAutoSubmitComplete; - // biome-ignore lint/correctness/useExhaustiveDependencies: intentionally fires once on mount only React.useEffect(() => { if ( @@ -677,7 +655,6 @@ function MessageComposerImpl({ }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); // mount-only - const handleSubmit = React.useCallback( (event: React.FormEvent) => { event.preventDefault(); @@ -685,7 +662,6 @@ function MessageComposerImpl({ }, [submitMessage], ); - // ── Keyboard handling ─────────────────────────────────────────────── // Tiptap handles formatting shortcuts (⌘B, ⌘I, etc.) natively. // Plain Enter → submit is now handled inside the Tiptap `submitOnEnter` @@ -701,7 +677,6 @@ function MessageComposerImpl({ } return; } - const channelResult = channelLinks.handleChannelKeyDown(event); if (channelResult.handled) { if (channelResult.suggestion) { @@ -709,7 +684,6 @@ function MessageComposerImpl({ } return; } - const { handled, suggestion } = mentions.handleMentionKeyDown(event); if (handled) { if (suggestion) { @@ -717,7 +691,6 @@ function MessageComposerImpl({ } return; } - if (event.key === "Tab" && !event.shiftKey && linkEditor.isCardOpen) { event.preventDefault(); if (!linkEditor.focusCardFirstControl()) { @@ -727,7 +700,12 @@ function MessageComposerImpl({ } // Escape in edit mode - if (event.key === "Escape" && editTargetRef.current && onCancelEdit) { + if ( + event.key === "Escape" && + !isDeferredEditPending && + editTargetRef.current && + onCancelEdit + ) { event.preventDefault(); onCancelEdit(); return; @@ -742,6 +720,7 @@ function MessageComposerImpl({ applyMentionInsert, linkEditor.isCardOpen, linkEditor.focusCardFirstControl, + isDeferredEditPending, onCancelEdit, ], ); @@ -824,22 +803,24 @@ function MessageComposerImpl({ // ── Send button state ─────────────────────────────────────────────── const sendDisabled = React.useMemo( () => - disabled || - media.isUploading || + composerDisabled || + (editTarget !== null && media.isUploading) || mentionSendFlow.isPreparingMentionSend || - (isContentEmpty && media.pendingImeta.length === 0), + (isContentEmpty && + media.pendingImeta.length === 0 && + media.queuedAttachments.length === 0), [ - disabled, + composerDisabled, + editTarget, media.isUploading, mentionSendFlow.isPreparingMentionSend, isContentEmpty, media.pendingImeta.length, + media.queuedAttachments.length, ], ); - const handleCaptureSelection = React.useCallback(() => { - // No-op for Tiptap — selection is managed by ProseMirror. - }, []); + const handleCaptureSelection = React.useCallback(() => {}, []); const handlePaperclipClick = React.useCallback(() => { void media.handlePaperclip(); @@ -893,10 +874,20 @@ function MessageComposerImpl({
+ {showBackgroundUploadProgress ? ( + + ) : null}
{ + if (isDeferredEditPending) { + e.preventDefault(); + return; + } void media.handleDrop(e); } : undefined @@ -956,12 +951,17 @@ function MessageComposerImpl({
) : null} - {(media.pendingImeta.length > 0 || media.isUploading) && ( + {(media.pendingImeta.length > 0 || + media.queuedAttachments.length > 0 || + media.isUploading) && (
void; onCancelEdit?: () => void; onCancelReply?: () => void; /** @@ -66,6 +67,8 @@ export type MessageComposerProps = { content: string, mediaTags?: string[][], mentionPubkeys?: string[], + /** Target captured when the edit was submitted; avoids a later ref swap. */ + eventId?: string, ) => Promise; /** Captures send context synchronously before awaits can change navigation. */ onCaptureSendContext?: () => { @@ -92,6 +95,8 @@ export type MessageComposerProps = { id: string; } | null; showTopBorder?: boolean; + /** Render the app-wide upload queue above this composer dock. */ + showBackgroundUploadProgress?: boolean; toolbarExtraActions?: ReactNode; typingParentEventId?: string | null; typingRootEventId?: string | null; diff --git a/desktop/src/features/messages/ui/MessageComposerDraftImagePersist.test.mjs b/desktop/src/features/messages/ui/MessageComposerDraftImagePersist.test.mjs index f6bfff3841..46b5d91d5b 100644 --- a/desktop/src/features/messages/ui/MessageComposerDraftImagePersist.test.mjs +++ b/desktop/src/features/messages/ui/MessageComposerDraftImagePersist.test.mjs @@ -237,10 +237,15 @@ import { useDraftPersistLifecycle } from "./useDraftPersistSnapshot.ts"; // Real storage functions — the test uses them, not a replica. import { clearAllDrafts, + deleteDraftEntry, initDraftStore, loadDraftEntry, persistDraftEntry, } from "../lib/useDrafts.ts"; +import { + saveQueuedAttachmentsForDraft, + takeQueuedAttachmentsForDraft, +} from "../lib/backgroundMediaUploadStore.ts"; // ── Helpers ─────────────────────────────────────────────────────────────────── @@ -579,3 +584,81 @@ test("draft_lifecycle_empty_target_clears_stale_mention_refs", async () => { await handle.unmount(); }); + +test("draft_lifecycle_preserves_local_files_across_a_b_a_switch", async () => { + setupStore("pubkey-switch-files"); + const FILE_A = { + file: new File(["report"], "report.pdf", { type: "application/pdf" }), + id: 7, + spoilered: false, + }; + let draftKey = "chan-a"; + let editorContent = ""; + let queuedAttachments = []; + const spoileredRef = { current: new Set() }; + + function HarnessComposer() { + useDraftPersistLifecycle({ + effectiveDraftKey: draftKey, + channelId: draftKey, + loadDraft: loadDraftEntry, + persistDraft: persistDraftEntry, + getMentionRefs: () => [], + restoreMentionRefs: () => {}, + livePendingImeta: [], + setPendingImeta: () => {}, + getQueuedAttachments: () => queuedAttachments, + saveQueuedAttachmentsForDraft, + clearQueuedAttachments: () => { + queuedAttachments = []; + }, + restoreQueuedAttachments: (attachments) => { + queuedAttachments = attachments; + }, + takeQueuedAttachmentsForDraft, + setContent: (content) => { + editorContent = content; + }, + clearContent: () => { + editorContent = ""; + }, + setSpoileredAttachmentUrls: () => {}, + spoileredAttachmentUrlsRef: spoileredRef, + syncComposerContentFromEditor: () => editorContent, + }); + return null; + } + + saveQueuedAttachmentsForDraft("chan-a", [FILE_A]); + const handle = await mountStrictMode(HarnessComposer); + assert.equal(queuedAttachments[0]?.file.name, "report.pdf"); + + draftKey = "chan-b"; + await handle.rerender(); + assert.deepEqual(queuedAttachments, [], "B must not inherit A's local files"); + + draftKey = "chan-a"; + await handle.rerender(); + assert.equal( + queuedAttachments[0]?.file.name, + "report.pdf", + "A's attachment-only draft survives a full A → B → A switch", + ); + + await handle.unmount(); +}); + +test("discarding_a_draft_drops_its_retained_local_files", () => { + const retainedFile = { + file: new File(["private"], "private.pdf", { + type: "application/pdf", + }), + id: 8, + spoilered: false, + }; + + saveQueuedAttachmentsForDraft("chan-deleted", [retainedFile]); + deleteDraftEntry("chan-deleted"); + + assert.deepEqual(takeQueuedAttachmentsForDraft("chan-deleted"), []); +}); diff --git a/desktop/src/features/messages/ui/MessageTimeline.tsx b/desktop/src/features/messages/ui/MessageTimeline.tsx index 40e235d2d1..0e73c39e2c 100644 --- a/desktop/src/features/messages/ui/MessageTimeline.tsx +++ b/desktop/src/features/messages/ui/MessageTimeline.tsx @@ -854,8 +854,9 @@ const MessageTimelineBase = React.forwardRef< {!isAtBottom ? (
diff --git a/desktop/src/features/messages/ui/submitMessageEdit.ts b/desktop/src/features/messages/ui/submitMessageEdit.ts new file mode 100644 index 0000000000..8edeea615e --- /dev/null +++ b/desktop/src/features/messages/ui/submitMessageEdit.ts @@ -0,0 +1,135 @@ +import type { QueuedMediaAttachment } from "@/features/messages/lib/backgroundMediaUploadStore"; +import { enqueueBackgroundMediaUpload } from "@/features/messages/lib/backgroundMediaUploadStore"; +import type { DraftMentionRef } from "@/features/messages/lib/useDrafts"; +import { + buildOutgoingMessage, + type ImetaMedia, + mergeOutgoingTags, +} from "@/features/messages/lib/imetaMediaMarkdown"; +import { diffAddedMentionPubkeys } from "@/features/messages/lib/threading"; +import { buildCustomEmojiTags } from "@/shared/lib/customEmojiTags"; +import type { CustomEmoji } from "@/shared/lib/remarkCustomEmoji"; + +type EditDraft = { + content: string; + mentionRefs: DraftMentionRef[]; + pendingImeta: ImetaMedia[]; + queuedAttachments: QueuedMediaAttachment[]; + spoileredAttachmentUrls: Set; +}; + +type SubmitMessageEditOptions = Omit & { + clearComposer: () => void; + customEmoji: ReadonlyArray; + extractMentionPubkeys: (content: string) => string[]; + getMentionRefs: (content: string) => DraftMentionRef[]; + editTargetId: string; + originalContent: string; + ownerPubkey: string | null; + restoreComposer: (draft: EditDraft) => void; + restoreMentionRefs: (refs: DraftMentionRef[]) => void; + shouldRestoreComposer: () => boolean; + setDeferredUploadPending: (isPending: boolean) => void; + save: ( + content: string, + mediaTags?: string[][], + mentionPubkeys?: string[], + eventId?: string, + ) => Promise; + setUploadError: (message: string) => void; +}; + +/** Clear an edited message immediately, then upload and save captured state. */ +export async function submitMessageEdit({ + clearComposer, + content, + customEmoji, + editTargetId, + extractMentionPubkeys, + getMentionRefs, + originalContent, + ownerPubkey, + pendingImeta, + queuedAttachments, + restoreComposer, + restoreMentionRefs, + setDeferredUploadPending, + shouldRestoreComposer, + save, + setUploadError, + spoileredAttachmentUrls, +}: SubmitMessageEditOptions): Promise { + const draft: EditDraft = { + content, + mentionRefs: getMentionRefs(content), + pendingImeta: [...pendingImeta], + queuedAttachments: [...queuedAttachments], + spoileredAttachmentUrls: new Set(spoileredAttachmentUrls), + }; + const restoreDraft = () => { + if (shouldRestoreComposer()) { + restoreComposer(draft); + restoreMentionRefs(draft.mentionRefs); + } + }; + const addedMentionPubkeys = diffAddedMentionPubkeys( + extractMentionPubkeys(originalContent), + extractMentionPubkeys(content), + ownerPubkey ?? "", + ); + const hasQueuedAttachments = draft.queuedAttachments.length > 0; + if (hasQueuedAttachments) setDeferredUploadPending(true); + clearComposer(); + + const finishEdit = async (uploaded: ImetaMedia[], signal?: AbortSignal) => { + // An explicit empty media tag set tells edit receivers to wipe attachments. + const { content: finalContent, mediaTags } = buildOutgoingMessage( + content, + [...draft.pendingImeta, ...uploaded], + new Set([ + ...draft.spoileredAttachmentUrls, + ...draft.queuedAttachments.flatMap((attachment, index) => + attachment.spoilered && uploaded[index] ? [uploaded[index].url] : [], + ), + ]), + ); + const outgoingTags = + mergeOutgoingTags( + mediaTags, + buildCustomEmojiTags(finalContent, customEmoji), + ) ?? []; + if (signal?.aborted) return; + await save(finalContent, outgoingTags, addedMentionPubkeys, editTargetId); + }; + + if (hasQueuedAttachments) { + enqueueBackgroundMediaUpload({ + attachments: draft.queuedAttachments, + onComplete: async (uploaded, signal) => { + try { + await finishEdit(uploaded, signal); + } catch { + restoreDraft(); + } finally { + setDeferredUploadPending(false); + } + }, + onError: (error) => { + restoreDraft(); + setUploadError(String(error)); + setDeferredUploadPending(false); + }, + onCancel: () => { + restoreDraft(); + setDeferredUploadPending(false); + }, + }); + return; + } + + try { + await finishEdit([]); + } catch { + restoreDraft(); + } +} diff --git a/desktop/src/features/messages/ui/useComposerHeightPadding.ts b/desktop/src/features/messages/ui/useComposerHeightPadding.ts index 9990ce5066..6035ed7369 100644 --- a/desktop/src/features/messages/ui/useComposerHeightPadding.ts +++ b/desktop/src/features/messages/ui/useComposerHeightPadding.ts @@ -40,6 +40,11 @@ export function useComposerHeightPadding( return; } + // In CSS-variable mode the timeline controls are siblings of the scroll + // element. Set the measurement on their shared parent so both the virtual + // trailing spacer and floating controls inherit the same live height. + const cssVariableTarget = scrollEl.parentElement ?? scrollEl; + const getScrollElement = (): HTMLElement => mode === "css-variable" ? (scrollEl.querySelector( @@ -79,7 +84,10 @@ export function useComposerHeightPadding( const wasAtBottom = isNearBottom(); if (mode === "css-variable") { - scrollEl.style.setProperty("--composer-overlay-height", `${padding}px`); + cssVariableTarget.style.setProperty( + "--composer-overlay-height", + `${padding}px`, + ); } else { scrollEl.style.paddingBottom = `${padding}px`; } @@ -116,7 +124,7 @@ export function useComposerHeightPadding( cancelAnimationFrame(followBottomFrame); } if (mode === "css-variable") { - scrollEl.style.removeProperty("--composer-overlay-height"); + cssVariableTarget.style.removeProperty("--composer-overlay-height"); } else { scrollEl.style.paddingBottom = ""; } diff --git a/desktop/src/features/messages/ui/useDraftPersistSnapshot.ts b/desktop/src/features/messages/ui/useDraftPersistSnapshot.ts index e2c4134bd6..14dae33adb 100644 --- a/desktop/src/features/messages/ui/useDraftPersistSnapshot.ts +++ b/desktop/src/features/messages/ui/useDraftPersistSnapshot.ts @@ -1,6 +1,7 @@ import * as React from "react"; import type { ImetaMedia } from "@/features/messages/lib/imetaMediaMarkdown"; +import type { QueuedMediaAttachment } from "@/features/messages/lib/backgroundMediaUploadStore"; import type { DraftMentionRef, DraftState, @@ -28,6 +29,19 @@ type UseDraftPersistLifecycleParams = { livePendingImeta: ImetaMedia[]; /** Async setter for pendingImeta — called after the synchronous snapshot. */ setPendingImeta: (imeta: ImetaMedia[]) => void; + /** Snapshot the local files owned by the outgoing draft key. */ + getQueuedAttachments?: () => QueuedMediaAttachment[]; + /** Retain local files in memory under their draft key. */ + saveQueuedAttachmentsForDraft?: ( + draftKey: string, + attachments: QueuedMediaAttachment[], + ) => void; + /** Local files cannot be persisted, so clear them at a draft-key boundary. */ + clearQueuedAttachments?: () => void; + /** Restore local files retained while a deferred upload was off-channel. */ + restoreQueuedAttachments?: (attachments: QueuedMediaAttachment[]) => void; + /** Read and remove local files retained for a recovered draft. */ + takeQueuedAttachmentsForDraft?: (draftKey: string) => QueuedMediaAttachment[]; /** Set the rich-text editor content from a draft string. */ setContent: (content: string) => void; /** Clear the rich-text editor content (no-draft path). */ @@ -80,6 +94,11 @@ export function useDraftPersistLifecycle({ restoreMentionRefs, livePendingImeta, setPendingImeta, + getQueuedAttachments, + saveQueuedAttachmentsForDraft, + clearQueuedAttachments, + restoreQueuedAttachments, + takeQueuedAttachmentsForDraft, setContent, clearContent, setSpoileredAttachmentUrls, @@ -87,6 +106,12 @@ export function useDraftPersistLifecycle({ syncComposerContentFromEditor, }: UseDraftPersistLifecycleParams): void { const pendingImetaForPersistRef = React.useRef([]); + const restoredQueuedAttachmentsRef = React.useRef( + [], + ); + const restoredQueuedAttachmentsDraftKeyRef = React.useRef( + null, + ); // Render-time update: keep the ref in sync with committed state so the // cleanup always reads the latest value during normal mounted operation. pendingImetaForPersistRef.current = livePendingImeta; @@ -99,6 +124,16 @@ export function useDraftPersistLifecycle({ // already reflects the incoming channel, which would corrupt the outgoing // draft's channelId metadata. + // Files cannot be serialized into localStorage. Replace the outgoing + // queue (retained by the cleanup below) with the incoming draft's queue. + clearQueuedAttachments?.(); + if (effectiveDraftKey !== restoredQueuedAttachmentsDraftKeyRef.current) { + restoredQueuedAttachmentsDraftKeyRef.current = effectiveDraftKey ?? null; + restoredQueuedAttachmentsRef.current = effectiveDraftKey + ? (takeQueuedAttachmentsForDraft?.(effectiveDraftKey) ?? []) + : []; + } + restoreQueuedAttachments?.(restoredQueuedAttachmentsRef.current); const saved = effectiveDraftKey ? loadDraft(effectiveDraftKey) : undefined; if (saved) { setContent(saved.content); @@ -121,6 +156,10 @@ export function useDraftPersistLifecycle({ return () => { if (effectiveDraftKey) { + const queuedAttachments = getQueuedAttachments?.() ?? []; + if (queuedAttachments.length > 0) { + saveQueuedAttachmentsForDraft?.(effectiveDraftKey, queuedAttachments); + } const content = syncComposerContentFromEditor(); persistDraft( effectiveDraftKey, diff --git a/desktop/src/features/messages/ui/useMentionSendFlow.helpers.ts b/desktop/src/features/messages/ui/useMentionSendFlow.helpers.ts new file mode 100644 index 0000000000..76503bfab1 --- /dev/null +++ b/desktop/src/features/messages/ui/useMentionSendFlow.helpers.ts @@ -0,0 +1,76 @@ +import type { ManagedAgent } from "@/shared/api/types"; +import type { ImetaMedia } from "@/features/messages/lib/imetaMediaMarkdown"; +import type { QueuedMediaAttachment } from "@/features/messages/lib/backgroundMediaUploadStore"; +import type { DraftMentionRef } from "@/features/messages/lib/useDrafts"; +import { normalizePubkey } from "@/shared/lib/pubkey"; +import { MENTION_REFERENCE_TAG } from "@/shared/lib/resolveMentionNames"; + +export { MENTION_REFERENCE_TAG }; + +export type PendingNonMemberMentionSend = { + capturedChannelId: string | null; + capturedThreadContext: { + parentEventId: string | null; + threadHeadId: string | null; + } | null; + trimmed: string; + mentionPubkeys: string[]; + nonMemberPubkeys: string[]; + outgoingTags?: string[][]; + preparedManagedAgents?: ManagedAgent[]; + readyAgentPubkeys?: string[]; + savedContent: string; + savedImeta: ImetaMedia[]; + queuedAttachments: QueuedMediaAttachment[]; + savedSpoileredAttachmentUrls: Set; + sentDraftKey: string | null | undefined; + recoveryDraftKey: string | null | undefined; + savedMentionRefs: DraftMentionRef[]; + audienceGeneration: number; + audienceRevision: number | null; + explicitAgentPubkeys: string[]; +}; + +export type SendMessageWithMentionFlowInput = { + capturedChannelId: string | null; + capturedThreadContext?: PendingNonMemberMentionSend["capturedThreadContext"]; + pendingImeta: ImetaMedia[]; + queuedAttachments?: QueuedMediaAttachment[]; + sentDraftKey: string | null | undefined; + recoveryDraftKey: string | null | undefined; + spoileredAttachmentUrls?: ReadonlySet; + trimmed: string; + audienceGeneration?: number; + audienceRevision?: number | null; +}; + +export function mergeOutgoingTagsWithReferenceMentions( + outgoingTags: string[][] | undefined, + pubkeys: Iterable, +) { + const normalizedPubkeys = uniqueNormalizedPubkeys(pubkeys); + if (normalizedPubkeys.length === 0) { + return outgoingTags; + } + + return [ + ...(outgoingTags ?? []), + ...normalizedPubkeys.map((pubkey) => [MENTION_REFERENCE_TAG, pubkey]), + ]; +} + +export function getErrorMessage(error: unknown, fallback: string) { + return error instanceof Error && error.message ? error.message : fallback; +} + +export function uniqueNormalizedPubkeys(pubkeys: Iterable) { + return [...new Set([...pubkeys].map(normalizePubkey))].filter(Boolean); +} + +export function isManagedAgentRunning(agent: ManagedAgent) { + return agent.status === "running" || agent.status === "deployed"; +} + +export function isProviderBackedAgent(agent: ManagedAgent) { + return agent.backend.type === "provider"; +} diff --git a/desktop/src/features/messages/ui/useMentionSendFlow.ts b/desktop/src/features/messages/ui/useMentionSendFlow.ts index 5e9ef27925..6ba9f69050 100644 --- a/desktop/src/features/messages/ui/useMentionSendFlow.ts +++ b/desktop/src/features/messages/ui/useMentionSendFlow.ts @@ -1,6 +1,5 @@ import * as React from "react"; import { toast } from "sonner"; - import { type CreateChannelManagedAgentInput, useAttachManagedAgentToChannelMutation, @@ -13,6 +12,11 @@ import { import { resolvePersonaRuntime } from "@/features/agents/lib/resolvePersonaRuntime"; import { useAddChannelMembersMutation } from "@/features/channels/hooks"; import { filterEffectiveExplicitAgentPubkeys } from "@/features/messages/lib/effectiveExplicitAgentPubkeys"; +import { + prepareBackgroundMediaUpload, + saveQueuedAttachmentsForDraft, + type QueuedMediaAttachment, +} from "@/features/messages/lib/backgroundMediaUploadStore"; import type { UseChannelLinksResult } from "@/features/messages/lib/useChannelLinks"; import type { UseEmojiAutocompleteResult } from "@/features/messages/lib/useEmojiAutocomplete"; import { @@ -27,54 +31,24 @@ import { invokeTauri } from "@/shared/api/tauri"; import type { CustomEmoji } from "@/shared/lib/remarkCustomEmoji"; import type { AcpRuntime, ChannelType, ManagedAgent } from "@/shared/api/types"; import { normalizePubkey, truncatePubkey } from "@/shared/lib/pubkey"; -import { MENTION_REFERENCE_TAG } from "@/shared/lib/resolveMentionNames"; import { buildCustomEmojiTags } from "@/shared/lib/customEmojiTags"; - -type PendingNonMemberMentionSend = { - capturedChannelId: string | null; - /** Thread context captured at submit time — null for main-timeline sends. */ - capturedThreadContext: { - parentEventId: string | null; - threadHeadId: string | null; - } | null; - finalContent: string; - mentionPubkeys: string[]; - nonMemberPubkeys: string[]; - outgoingTags?: string[][]; - preparedManagedAgents?: ManagedAgent[]; - readyAgentPubkeys?: string[]; - savedContent: string; - savedImeta: ImetaMedia[]; - savedSpoileredAttachmentUrls: Set; - sentDraftKey: string | null | undefined; - audienceGeneration: number; - audienceRevision: number | null; - /** Agent mentions explicitly authored in this draft (never inferred). */ - explicitAgentPubkeys: string[]; -}; - -type SendMessageWithMentionFlowInput = { - capturedChannelId: string | null; - /** Thread context captured at submit time — null for main-timeline sends. */ - capturedThreadContext?: { - parentEventId: string | null; - threadHeadId: string | null; - } | null; - pendingImeta: ImetaMedia[]; - sentDraftKey: string | null | undefined; - spoileredAttachmentUrls?: ReadonlySet; - trimmed: string; - audienceGeneration?: number; - audienceRevision?: number | null; -}; - +import { + getErrorMessage, + isManagedAgentRunning, + isProviderBackedAgent, + MENTION_REFERENCE_TAG, + mergeOutgoingTagsWithReferenceMentions, + type PendingNonMemberMentionSend, + type SendMessageWithMentionFlowInput, + uniqueNormalizedPubkeys, +} from "./useMentionSendFlow.helpers"; type UseMentionSendFlowOptions = { channelId: string | null; channelLinks: Pick; channelType: ChannelType | null; contentRef: React.MutableRefObject; customEmoji: CustomEmoji[]; - drafts: Pick; + drafts: Pick; emojiAutocomplete: Pick; mentions: UseMentionsResult; onPrepareSendChannel?: ( @@ -99,6 +73,9 @@ type UseMentionSendFlowOptions = { setContent: (content: string) => void; setIsEmojiPickerOpen: React.Dispatch>; setPendingImeta: (pendingImeta: ImetaMedia[]) => void; + hasUnsavedMedia: () => boolean; + clearQueuedAttachments: () => void; + restoreQueuedAttachments: (attachments: QueuedMediaAttachment[]) => void; setSpoileredAttachmentUrls?: React.Dispatch< React.SetStateAction> >; @@ -110,43 +87,10 @@ type UseMentionSendFlowOptions = { }) => void; resolvePostSendContent?: (effectiveExplicitAgentPubkeys: string[]) => string; }; - -function mergeOutgoingTagsWithReferenceMentions( - outgoingTags: string[][] | undefined, - pubkeys: Iterable, -) { - const normalizedPubkeys = uniqueNormalizedPubkeys(pubkeys); - if (normalizedPubkeys.length === 0) { - return outgoingTags; - } - - return [ - ...(outgoingTags ?? []), - ...normalizedPubkeys.map((pubkey) => [MENTION_REFERENCE_TAG, pubkey]), - ]; -} - -function getErrorMessage(error: unknown, fallback: string) { - return error instanceof Error && error.message ? error.message : fallback; -} - -function uniqueNormalizedPubkeys(pubkeys: Iterable) { - return [...new Set([...pubkeys].map(normalizePubkey))].filter(Boolean); -} - -function isManagedAgentRunning(agent: ManagedAgent) { - return agent.status === "running" || agent.status === "deployed"; -} - -function isProviderBackedAgent(agent: ManagedAgent) { - return agent.backend.type === "provider"; -} - const DM_THREAD_AGENT_MENTION_ERROR = "Agents must already be in a DM to be mentioned in its threads. Start a new conversation that includes the agent."; const DM_THREAD_MEMBERS_LOADING_ERROR = "Checking conversation members. Try again in a moment."; - export function useMentionSendFlow({ channelId, channelLinks, @@ -162,6 +106,9 @@ export function useMentionSendFlow({ setContent, setIsEmojiPickerOpen, setPendingImeta, + hasUnsavedMedia, + clearQueuedAttachments, + restoreQueuedAttachments, setSpoileredAttachmentUrls, onSuccessfulExplicitAgentAudience, resolvePostSendContent, @@ -188,7 +135,6 @@ export function useMentionSendFlow({ isMountedRef.current = false; }; }, []); - const addMembersMutation = useAddChannelMembersMutation(channelId); const attachAgentMutation = useAttachManagedAgentToChannelMutation(channelId); const createPersonaAgentMutation = @@ -198,18 +144,15 @@ export function useMentionSendFlow({ const availableRuntimesQuery = useAvailableAcpRuntimes(); const managedAgentsQuery = useManagedAgentsQuery(); const startAgentMutation = useStartManagedAgentMutation(); - const getManagedAgentsByPubkey = React.useCallback(async () => { const agents = managedAgentsQuery.data ?? (await managedAgentsQuery.refetch()).data ?? []; - return new Map( agents.map((agent) => [normalizePubkey(agent.pubkey), agent]), ); }, [managedAgentsQuery.data, managedAgentsQuery.refetch]); - const getAvailableRuntimes = React.useCallback(async (): Promise< AcpRuntime[] > => { @@ -217,7 +160,6 @@ export function useMentionSendFlow({ if (cached.length > 0 || !availableRuntimesQuery.isLoading) { return cached; } - const refetched = await availableRuntimesQuery.refetch(); return (refetched.data ?? []).filter( (runtime): runtime is AcpRuntime => @@ -230,7 +172,6 @@ export function useMentionSendFlow({ availableRuntimesQuery.isLoading, availableRuntimesQuery.refetch, ]); - const ensureManagedAgentMentionsReady = React.useCallback( async ( mentionPubkeys: string[], @@ -399,6 +340,7 @@ export function useMentionSendFlow({ mentions.cancelMentionAutocomplete(); } else richText.clearContent(); setPendingImeta([]); + clearQueuedAttachments(); setSpoileredAttachmentUrls?.(new Set()); if (!postSendContent) mentions.clearMentions(); channelLinks.clearChannels(); @@ -416,6 +358,7 @@ export function useMentionSendFlow({ setContent, setIsEmojiPickerOpen, setPendingImeta, + clearQueuedAttachments, setSpoileredAttachmentUrls, ], ); @@ -442,12 +385,33 @@ export function useMentionSendFlow({ isCompleteSendPendingRef.current = true; setIsCompleteSendPending(true); + const preparedUpload = + draft.queuedAttachments.length > 0 + ? prepareBackgroundMediaUpload(draft.queuedAttachments) + : null; + const persistPreflightDraft = () => { + if (!draft.recoveryDraftKey) return; + drafts.persistDraft( + draft.recoveryDraftKey, + draft.savedContent, + draft.capturedChannelId ?? draft.recoveryDraftKey, + draft.savedImeta, + [...draft.savedSpoileredAttachmentUrls], + draft.savedMentionRefs, + ); + saveQueuedAttachmentsForDraft( + draft.recoveryDraftKey, + draft.queuedAttachments, + ); + }; + let uploadStarted = false; try { const readyAgentPubkeys = new Set( (draft.readyAgentPubkeys ?? []).map(normalizePubkey), ); const managedAgentsByPubkey = await getManagedAgentsByPubkey(); if (!isMountedRef.current) { + persistPreflightDraft(); return; } for (const agent of draft.preparedManagedAgents ?? []) { @@ -473,6 +437,7 @@ export function useMentionSendFlow({ return; } if (!isMountedRef.current) { + persistPreflightDraft(); return; } } @@ -486,6 +451,7 @@ export function useMentionSendFlow({ [...managedAgentsByPubkey.values()], ); if (!isMountedRef.current) { + persistPreflightDraft(); return; } if (agentReadiness.errors.length > 0) { @@ -523,23 +489,87 @@ export function useMentionSendFlow({ mentionPubkeys, ); - // Replace the sent body directly with its final post-send state before - // the async network send starts. This avoids an intermediate blank frame - // for persistent audiences while preserving the ordinary empty state. - if (draft.capturedChannelId === channelIdRef.current) { - clearComposer( - resolvePostSendContent?.(effectiveExplicitAgentPubkeys), + const send = onSendRef.current; + const persistCanceledDraft = () => { + if (!draft.recoveryDraftKey) return; + const existing = drafts.loadDraft(draft.recoveryDraftKey); + if ( + existing && + (existing.content !== draft.savedContent || + existing.channelId !== + (draft.capturedChannelId ?? draft.recoveryDraftKey) || + JSON.stringify(existing.pendingImeta) !== + JSON.stringify(draft.savedImeta) || + JSON.stringify(existing.spoileredAttachmentUrls) !== + JSON.stringify([...draft.savedSpoileredAttachmentUrls])) + ) { + return; + } + drafts.persistDraft( + draft.recoveryDraftKey, + draft.savedContent, + draft.capturedChannelId ?? draft.recoveryDraftKey, + draft.savedImeta, + [...draft.savedSpoileredAttachmentUrls], + draft.savedMentionRefs, ); - } - - try { - await onSendRef.current( - draft.finalContent, + }; + const restoreComposerAfterFailure = () => { + persistCanceledDraft(); + const canRestoreCurrentComposer = + isMountedRef.current && + (draft.capturedChannelId === channelIdRef.current || + channelIdRef.current === null) && + contentRef.current.trim().length === 0 && + !hasUnsavedMedia(); + if (!canRestoreCurrentComposer && draft.recoveryDraftKey) { + saveQueuedAttachmentsForDraft( + draft.recoveryDraftKey, + draft.queuedAttachments, + ); + } + if (!canRestoreCurrentComposer) { + return; + } + setContent(draft.savedContent); + contentRef.current = draft.savedContent; + richText.setContent(draft.savedContent); + setPendingImeta(draft.savedImeta); + restoreQueuedAttachments(draft.queuedAttachments); + mentions.restoreDraftMentionRefs(draft.savedMentionRefs); + setSpoileredAttachmentUrls?.( + new Set(draft.savedSpoileredAttachmentUrls), + ); + }; + const finishSend = async ( + uploaded: ImetaMedia[], + signal?: AbortSignal, + ) => { + const { content: finalContent, mediaTags } = buildOutgoingMessage( + draft.trimmed, + [...draft.savedImeta, ...uploaded], + new Set([ + ...draft.savedSpoileredAttachmentUrls, + ...draft.queuedAttachments.flatMap((attachment, index) => + attachment.spoilered && uploaded[index] + ? [uploaded[index].url] + : [], + ), + ]), + ); + const finalOutgoingTags = mergeOutgoingTags( + mediaTags, + outgoingTags ?? [], + ); + if (signal?.aborted) return; + await send( + finalContent, mentionPubkeys, - outgoingTags, + finalOutgoingTags, sendChannelId, draft.capturedThreadContext, ); + if (signal?.aborted) return; if (effectiveExplicitAgentPubkeys.length > 0) { // Promote only explicitly authored agents that remained effective // for this successful send. "Send without inviting" removes its @@ -555,25 +585,57 @@ export function useMentionSendFlow({ drafts.markDraftSent( draft.sentDraftKey, draft.savedContent, - sendChannelId ?? draft.sentDraftKey, + draft.capturedChannelId ?? draft.sentDraftKey, draft.savedImeta, [...draft.savedSpoileredAttachmentUrls], ); } - } catch { - // Only restore the composer content if the user is still on the - // channel that originated the send. - if (draft.capturedChannelId === channelIdRef.current) { - setContent(draft.savedContent); - contentRef.current = draft.savedContent; - richText.setContent(draft.savedContent); - setPendingImeta(draft.savedImeta); - setSpoileredAttachmentUrls?.( - new Set(draft.savedSpoileredAttachmentUrls), - ); + }; + if (preparedUpload) { + uploadStarted = preparedUpload.start({ + onComplete: async (uploaded, signal) => { + try { + await finishSend(uploaded, signal); + } catch { + restoreComposerAfterFailure(); + } + }, + onError: (error) => { + restoreComposerAfterFailure(); + toast.error( + `Upload failed: ${getErrorMessage(error, "Unknown error")}`, + ); + }, + onCancel: () => { + restoreComposerAfterFailure(); + }, + }); + if (!uploadStarted) { + return; + } + } + + // Replace the sent body directly with its final post-send state before + // the async network send starts. This avoids an intermediate blank frame + // for persistent audiences while preserving the ordinary empty state. + if ( + draft.capturedChannelId === channelIdRef.current || + channelIdRef.current === null + ) { + clearComposer( + resolvePostSendContent?.(effectiveExplicitAgentPubkeys), + ); + } + + if (!preparedUpload) { + try { + await finishSend([]); + } catch { + restoreComposerAfterFailure(); } } } finally { + if (!uploadStarted) preparedUpload?.cancel(); isCompleteSendPendingRef.current = false; if (isMountedRef.current) { setIsCompleteSendPending(false); @@ -594,7 +656,10 @@ export function useMentionSendFlow({ richText.setContent, setContent, setPendingImeta, + restoreQueuedAttachments, setSpoileredAttachmentUrls, + hasUnsavedMedia, + mentions.restoreDraftMentionRefs, ], ); @@ -660,7 +725,9 @@ export function useMentionSendFlow({ capturedChannelId, capturedThreadContext = null, pendingImeta, + queuedAttachments = [], sentDraftKey, + recoveryDraftKey, spoileredAttachmentUrls = new Set(), trimmed, audienceGeneration = 0, @@ -721,15 +788,7 @@ export function useMentionSendFlow({ createdPersonaAgentPubkeySet.has(pubkey), ); const pubkeys = explicitMentionPubkeys; - const { content: finalContent, mediaTags } = buildOutgoingMessage( - trimmed, - pendingImeta, - spoileredAttachmentUrls, - ); - const outgoingTags = mergeOutgoingTags( - mediaTags, - buildCustomEmojiTags(finalContent, customEmoji), - ); + const outgoingTags = buildCustomEmojiTags(trimmed, customEmoji); const nonMemberPubkeys = getNonMemberMentionPubkeys(pubkeys); let promptNonMemberPubkeys = nonMemberPubkeys.filter( (pubkey) => @@ -752,7 +811,7 @@ export function useMentionSendFlow({ const pendingDraft: PendingNonMemberMentionSend = { capturedChannelId: effectiveChannelId, capturedThreadContext, - finalContent, + trimmed, mentionPubkeys: pubkeys, nonMemberPubkeys: promptNonMemberPubkeys, outgoingTags, @@ -763,8 +822,11 @@ export function useMentionSendFlow({ : createdPersonaAgentPubkeys, savedContent: trimmed, savedImeta: [...pendingImeta], + queuedAttachments: [...queuedAttachments], savedSpoileredAttachmentUrls: new Set(spoileredAttachmentUrls), sentDraftKey, + recoveryDraftKey, + savedMentionRefs: mentions.getDraftMentionRefs(trimmed), audienceGeneration, audienceRevision, explicitAgentPubkeys, @@ -793,6 +855,7 @@ export function useMentionSendFlow({ mentions.extractMentionPubkeys, mentions.isAgentPubkey, mentions.isManagedAgentPubkey, + mentions.getDraftMentionRefs, onPrepareSendChannel, ], ); diff --git a/desktop/src/shared/api/tauri.ts b/desktop/src/shared/api/tauri.ts index bb56bc18e5..62a262e4f6 100644 --- a/desktop/src/shared/api/tauri.ts +++ b/desktop/src/shared/api/tauri.ts @@ -596,11 +596,11 @@ export async function uploadMedia( isTemp, }); } - -export async function pickAndUploadMedia(): Promise { - return invokeTauri("pick_and_upload_media", {}); +export async function pickAndUploadMedia( + progressId?: string, +): Promise { + return invokeTauri("pick_and_upload_media", { progressId }); } - export async function uploadMediaBytes( data: number[], filename?: string, diff --git a/desktop/src/shared/api/tauriMedia.ts b/desktop/src/shared/api/tauriMedia.ts index 1dab9b7728..60205a45fe 100644 --- a/desktop/src/shared/api/tauriMedia.ts +++ b/desktop/src/shared/api/tauriMedia.ts @@ -1,5 +1,44 @@ +import { invoke as invokeTauriRaw } from "@tauri-apps/api/core"; import { type BlobDescriptor, invokeTauri } from "./tauri"; +function encodeRawIpcHeader(value: string): string { + const bytes = new TextEncoder().encode(value); + let binary = ""; + for (const byte of bytes) binary += String.fromCharCode(byte); + return window + .btoa(binary) + .replaceAll("+", "-") + .replaceAll("/", "_") + .replace(/=+$/, ""); +} + +/** Transfer a browser File to Rust as a raw IPC body, avoiding JSON expansion. */ +export async function uploadMediaFile( + file: File, + progressId?: string, + signal?: AbortSignal, +): Promise { + const headers: Record = { + "x-buzz-filename": encodeRawIpcHeader(file.name), + }; + if (progressId) { + headers["x-buzz-progress-id"] = encodeRawIpcHeader(progressId); + } + + if (signal?.aborted) throw new Error("upload cancelled"); + const bytes = new Uint8Array(await file.arrayBuffer()); + if (signal?.aborted) throw new Error("upload cancelled"); + + return invokeTauriRaw("upload_media_bytes_raw", bytes, { + headers, + }); +} + +/** Stop the native HTTP request associated with a background media upload. */ +export async function cancelMediaUpload(progressId: string): Promise { + await invokeTauri("cancel_media_upload", { progressId }); +} + /** * Open a native single-file picker constrained to images and upload the * chosen file. Non-image files are rejected in Rust (via MIME sniffing) diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 3fbdaa494a..9911683515 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -369,6 +369,8 @@ type E2eConfig = { // (e.g. a generic PDF) without a real upload pipeline. See // tests/helpers/bridge.ts:MockBridgeOptions.uploadDescriptors. uploadDelayMs?: number; + /** Exercise the production composer path that queues files until send. */ + deferredComposerUploads?: boolean; /** Delay (ms) applied to `encode_agent_snapshot_for_send` so E2E tests can * observe the "preparing" phase before the upload begins. 0/undefined = instant. */ encodeDelayMs?: number; @@ -1065,6 +1067,15 @@ declare global { command: string; payload: unknown; }>; + __BUZZ_E2E_EMIT_MEDIA_UPLOAD_PHASE__?: (input: { + id: string; + phase: string; + }) => Promise; + __BUZZ_E2E_EMIT_MEDIA_UPLOAD_PROGRESS__?: (input: { + id: string; + sent: number; + total: number; + }) => Promise; __BUZZ_E2E_EMIT_MOCK_HUDDLE_TTS_SPEAKER__?: (payload: { pubkey: string | null; level: number; @@ -8792,7 +8803,7 @@ async function resolveMockUploadDescriptors( } async function resolveMockUploadDescriptorForBytes( - args: { data: number[]; filename?: string | null }, + args: { data: number[] | Uint8Array; filename?: string | null }, config: E2eConfig | undefined, ): Promise { const configured = config?.mock?.uploadDescriptors; @@ -9806,6 +9817,12 @@ export function maybeInstallE2eTauriMocks() { emit("huddle-tts-speaker-level", payload); window.__BUZZ_E2E_SIGNED_EVENTS__ = []; window.__BUZZ_E2E_WEBVIEW_ZOOM__ = 1; + window.__BUZZ_E2E_EMIT_MEDIA_UPLOAD_PHASE__ = async (input) => { + await emit("media-upload-phase", input); + }; + window.__BUZZ_E2E_EMIT_MEDIA_UPLOAD_PROGRESS__ = async (input) => { + await emit("media-upload-progress", input); + }; window.__BUZZ_E2E_SET_MOCK_HUDDLE_SNAPSHOT__ = async ({ members, transcriptionEnabled, @@ -10121,6 +10138,9 @@ export function maybeInstallE2eTauriMocks() { const identity = getActiveIdentity(activeConfig); window.__BUZZ_E2E_COMMANDS__?.push(command); const loggedPayload = (() => { + if (payload instanceof Uint8Array) { + return { rawByteLength: payload.byteLength }; + } try { return JSON.parse(JSON.stringify(payload ?? null)); } catch { @@ -12243,6 +12263,13 @@ export function maybeInstallE2eTauriMocks() { payload as { data: number[]; filename?: string | null }, activeConfig, ); + case "upload_media_bytes_raw": + return resolveMockUploadDescriptorForBytes( + { + data: payload as Uint8Array, + }, + activeConfig, + ); case "fetch_media_bytes": { // The real command fetches relay media through Rust reqwest and // replies with raw bytes (`tauri::ipc::Response` → ArrayBuffer). In diff --git a/desktop/tests/e2e/file-attachment.spec.ts b/desktop/tests/e2e/file-attachment.spec.ts index 403d26c136..699e711984 100644 --- a/desktop/tests/e2e/file-attachment.spec.ts +++ b/desktop/tests/e2e/file-attachment.spec.ts @@ -1,5 +1,7 @@ import { expect, test } from "@playwright/test"; +import type { Page } from "@playwright/test"; +import { waitForAnimations } from "../helpers/animations"; import { installMockBridge } from "../helpers/bridge"; import { expectCornerRadiusPx, expectSmoothCorners } from "../helpers/css"; @@ -11,6 +13,7 @@ import { expectCornerRadiusPx, expectSmoothCorners } from "../helpers/css"; test.beforeEach(async ({ page }) => { await installMockBridge(page, { + deferredComposerUploads: true, uploadDescriptors: [ { url: `https://mock.relay/media/${"a".repeat(64)}.pdf`, @@ -24,13 +27,37 @@ test.beforeEach(async ({ page }) => { }); }); +async function chooseQuarterlyReport(page: Page) { + const [chooser] = await Promise.all([ + page.waitForEvent("filechooser"), + page.getByRole("button", { name: "Attach image" }).click(), + ]); + await chooser.setFiles({ + buffer: Buffer.from("quarterly report"), + mimeType: "application/pdf", + name: "quarterly-report.pdf", + }); +} + +async function chooseLargeVideo(page: Page) { + const [chooser] = await Promise.all([ + page.waitForEvent("filechooser"), + page.getByRole("button", { name: "Attach image" }).click(), + ]); + await chooser.setFiles({ + buffer: Buffer.alloc(16 * 1024 * 1024, 1), + mimeType: "video/mp4", + name: "large-video.mp4", + }); +} + test("upload a file and see a FileCard in the timeline", async ({ page }) => { await page.goto("/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); - // Paperclip → mocked pick_and_upload_media returns the PDF descriptor. - await page.getByRole("button", { name: "Attach image" }).click(); + // The paperclip queues the local file without starting its upload. + await chooseQuarterlyReport(page); // The composer shows a chip with the original filename. await expect(page.getByTestId("message-composer")).toContainText( @@ -63,6 +90,206 @@ test("upload a file and see a FileCard in the timeline", async ({ page }) => { .toContain("download_file"); }); +test("sends immediately and keeps upload progress across channels", async ({ + page, +}) => { + await page.goto("/"); + await page.evaluate(() => { + const e2e = ( + window as Window & { + __BUZZ_E2E__?: { mock?: { uploadDelayMs?: number } }; + } + ).__BUZZ_E2E__; + if (e2e?.mock) e2e.mock.uploadDelayMs = 1_000; + }); + await page.getByTestId("channel-general").click(); + await chooseQuarterlyReport(page); + + await expect(page.getByTestId("composer-upload-progress")).toHaveCount(0); + await page.getByTestId("send-message").click(); + + await expect(page.getByTestId("message-composer")).not.toContainText( + "quarterly-report.pdf", + ); + await expect(page.getByTestId("composer-upload-progress")).toBeVisible(); + + await page.getByTestId("channel-random").click(); + await expect(page.getByTestId("chat-title")).toHaveText("random"); + await expect(page.getByTestId("composer-upload-progress")).toBeVisible(); + await expect(page.getByTestId("composer-upload-progress")).toHaveCount(0, { + timeout: 5_000, + }); + + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("file-card").last()).toContainText( + "quarterly-report.pdf", + ); +}); + +test("shows upload feedback before transferring a large file", async ({ + page, +}) => { + await page.goto("/"); + await page.evaluate(() => { + const e2e = ( + window as Window & { + __BUZZ_E2E__?: { mock?: { uploadDelayMs?: number } }; + } + ).__BUZZ_E2E__; + if (e2e?.mock) e2e.mock.uploadDelayMs = 5_000; + }); + await page.getByTestId("channel-general").click(); + await chooseLargeVideo(page); + + const progress = page.getByTestId("composer-upload-progress"); + await Promise.all([ + page.getByTestId("send-message").click(), + expect(progress).toBeVisible({ timeout: 800 }), + ]); + await expect(progress).toHaveAttribute("aria-label", "Preparing"); + await expect(page.getByTestId("composer-upload-spinner")).toBeVisible(); + await expect(page.getByTestId("composer-upload-percentage")).toHaveCount(0); + await expect + .poll(() => + page.evaluate( + () => + ( + window as Window & { + __BUZZ_E2E_COMMAND_PAYLOADS__?: Array<{ + command: string; + payload: { rawByteLength?: number } | null; + }>; + } + ).__BUZZ_E2E_COMMAND_PAYLOADS__ ?? [], + ), + ) + .toContainEqual({ + command: "upload_media_bytes_raw", + payload: { rawByteLength: 16 * 1024 * 1024 }, + }); + + const uploadId = "background-media-upload-0-0"; + await page.evaluate(async (id) => { + await window.__BUZZ_E2E_EMIT_MEDIA_UPLOAD_PHASE__?.({ + id, + phase: "processing-video", + }); + }, uploadId); + await expect(progress).toHaveAttribute("aria-label", "Processing"); + await waitForAnimations(page); + const processingPhaseBox = await page + .getByTestId("composer-upload-phase") + .boundingBox(); + const processingStatusBox = await page + .getByTestId("composer-upload-status") + .boundingBox(); + expect(processingPhaseBox).not.toBeNull(); + expect(processingStatusBox).not.toBeNull(); + expect( + (processingStatusBox?.x ?? 0) - + ((processingPhaseBox?.x ?? 0) + (processingPhaseBox?.width ?? 0)), + ).toBeGreaterThanOrEqual(3); + await expect(page.getByTestId("composer-upload-spinner")).toBeVisible(); + await expect(page.getByTestId("composer-upload-percentage")).toHaveCount(0); + + await page.evaluate(async (id) => { + await window.__BUZZ_E2E_EMIT_MEDIA_UPLOAD_PHASE__?.({ + id, + phase: "uploading", + }); + await window.__BUZZ_E2E_EMIT_MEDIA_UPLOAD_PROGRESS__?.({ + id, + sent: 42, + total: 100, + }); + }, uploadId); + await expect(progress).toHaveAttribute("aria-label", "Uploading 42%"); + await waitForAnimations(page); + await expect(page.getByTestId("composer-upload-spinner")).toHaveCount(0); + await expect(page.getByTestId("composer-upload-percentage")).toHaveText( + "42%", + ); + + await page.getByTestId("composer-upload-cancel").click(); +}); + +test("canceling a background upload prevents the message from publishing", async ({ + page, +}) => { + await page.goto("/"); + await page.evaluate(() => { + const e2e = ( + window as Window & { + __BUZZ_E2E__?: { mock?: { uploadDelayMs?: number } }; + } + ).__BUZZ_E2E__; + if (e2e?.mock) e2e.mock.uploadDelayMs = 1_000; + }); + await page.getByTestId("channel-general").click(); + await chooseQuarterlyReport(page); + await page.getByTestId("send-message").click(); + + await page.getByTestId("composer-upload-cancel").click(); + await expect(page.getByTestId("composer-upload-progress")).toHaveCount(0); + await page.waitForTimeout(1_100); + await expect(page.getByTestId("file-card")).toHaveCount(0); +}); + +test("upload progress floats above the dock and lifts Jump to latest", async ({ + page, +}) => { + await page.goto("/"); + await page.evaluate(() => { + const e2e = ( + window as Window & { + __BUZZ_E2E__?: { mock?: { uploadDelayMs?: number } }; + } + ).__BUZZ_E2E__; + if (e2e?.mock) e2e.mock.uploadDelayMs = 2_000; + }); + await page.getByTestId("channel-deep-history").click(); + + const timeline = page.getByTestId("message-timeline"); + await expect(timeline.locator("[data-message-id]").first()).toBeVisible(); + await timeline.evaluate((element) => { + element.scrollTop = Math.max(500, element.scrollHeight / 2); + element.dispatchEvent(new Event("scroll", { bubbles: true })); + }); + const jumpToLatest = page.getByTestId("message-scroll-to-latest"); + await expect(jumpToLatest).toBeVisible(); + const restingBox = await jumpToLatest.boundingBox(); + + await chooseQuarterlyReport(page); + await page.getByTestId("send-message").click(); + const uploadMotion = page.getByTestId("composer-upload-progress-motion"); + await expect(uploadMotion).toBeVisible(); + await timeline.evaluate((element) => { + element.scrollTop = Math.max(500, element.scrollHeight / 2); + element.dispatchEvent(new Event("scroll", { bubbles: true })); + }); + await expect(jumpToLatest).toBeVisible(); + await page.waitForTimeout(250); + + const [uploadBox, dockBackdropBox, liftedBox] = await Promise.all([ + uploadMotion.boundingBox(), + page.getByTestId("composer-dock-backdrop").boundingBox(), + jumpToLatest.boundingBox(), + ]); + expect(restingBox).not.toBeNull(); + expect(uploadBox).not.toBeNull(); + expect(dockBackdropBox).not.toBeNull(); + expect(liftedBox).not.toBeNull(); + expect((dockBackdropBox?.y ?? 0) + 1).toBeGreaterThanOrEqual( + (uploadBox?.y ?? 0) + (uploadBox?.height ?? 0), + ); + expect((liftedBox?.y ?? 0) + (liftedBox?.height ?? 0)).toBeLessThanOrEqual( + uploadBox?.y ?? 0, + ); + expect(liftedBox?.y ?? 0).toBeLessThan((restingBox?.y ?? 0) - 10); + + await page.getByTestId("composer-upload-cancel").click(); +}); + test("dropping a file on the channel column attaches it to the composer", async ({ page, }) => { diff --git a/desktop/tests/helpers/bridge.ts b/desktop/tests/helpers/bridge.ts index d7fba7c34e..911839e727 100644 --- a/desktop/tests/helpers/bridge.ts +++ b/desktop/tests/helpers/bridge.ts @@ -355,6 +355,8 @@ type MockBridgeOptions = { * explicit `[]` is honoured (models a picker cancel / no files selected). */ uploadDelayMs?: number; + /** Exercise the production composer path that queues files until send. */ + deferredComposerUploads?: boolean; /** Delay (ms) applied to `encode_agent_snapshot_for_send` so E2E tests can * observe the "preparing" phase before the upload begins. 0/undefined = instant. */ encodeDelayMs?: number;