Skip to main content

vector_core/
sending.rs

1//! Message sending — NIP-17 gift-wrapped DMs (text and file attachments).
2//!
3//! This is the core send pipeline used by all Vector interfaces (GUI, CLI, SDK).
4//! Clients provide a `SendCallback` for status notifications (pending/sent/failed/progress)
5//! and a `SendConfig` for retry/cancel behavior.
6
7use std::sync::Arc;
8use std::sync::atomic::AtomicBool;
9use nostr_sdk::prelude::*;
10
11use crate::state::{nostr_client, my_public_key, STATE};
12use crate::types::{Message, Attachment};
13use crate::crypto;
14
15// ============================================================================
16// SendCallback — Client notification trait
17// ============================================================================
18
19/// Callbacks invoked during the DM send pipeline.
20///
21/// Each method has a default no-op so simple callers (CLI, bots, tests)
22/// implement only what they need. Methods are synchronous and non-fallible
23/// by design — they should never block the send pipeline.
24///
25/// Exception: `on_upload_progress` returns `Result` — return `Err` to cancel.
26pub trait SendCallback: Send + Sync {
27    /// Message created and added to STATE as pending.
28    fn on_pending(&self, _chat_id: &str, _msg: &Message) {}
29
30    /// File upload progress. Return Err("...") to cancel the upload.
31    fn on_upload_progress(
32        &self,
33        _pending_id: &str,
34        _percentage: u8,
35        _bytes_sent: u64,
36    ) -> Result<(), String> {
37        Ok(())
38    }
39
40    /// Upload complete, attachment URL now available.
41    fn on_upload_complete(&self, _chat_id: &str, _pending_id: &str, _attachment_id: &str, _url: &str) {}
42
43    /// Message successfully delivered to at least one relay.
44    /// `old_id` is the pending ID, `msg` has the real event ID.
45    fn on_sent(&self, _chat_id: &str, _old_id: &str, _msg: &Message) {}
46
47    /// Message delivery failed after all retry attempts.
48    fn on_failed(&self, _chat_id: &str, _old_id: &str, _msg: &Message) {}
49
50    /// Persist message to database. Default is no-op.
51    /// Tauri implements this to call save_message + save_slim_chat.
52    fn on_persist(&self, _chat_id: &str, _msg: &Message) {}
53}
54
55/// No-op callback for headless/CLI/test use.
56pub struct NoOpSendCallback;
57impl SendCallback for NoOpSendCallback {}
58
59// ============================================================================
60// SendConfig — Per-call configuration
61// ============================================================================
62
63/// Configuration for a send operation.
64pub struct SendConfig {
65    /// Max gift-wrap send attempts (default: 1).
66    pub max_send_attempts: u32,
67    /// Delay between send retries (default: 5 seconds).
68    pub retry_delay: std::time::Duration,
69    /// Send copy to own inbox for recovery/sync (default: false).
70    pub self_send: bool,
71    /// Cancel token for file uploads — set to true to abort.
72    pub cancel_token: Option<Arc<AtomicBool>>,
73    /// Max Blossom upload retries per server (default: 3).
74    pub upload_retries: u32,
75    /// Delay between upload retries (default: 2 seconds).
76    pub upload_retry_delay: std::time::Duration,
77}
78
79impl Default for SendConfig {
80    fn default() -> Self {
81        Self {
82            max_send_attempts: 1,
83            retry_delay: std::time::Duration::from_secs(5),
84            self_send: false,
85            cancel_token: None,
86            upload_retries: 3,
87            upload_retry_delay: std::time::Duration::from_secs(2),
88        }
89    }
90}
91
92impl SendConfig {
93    /// Preset for GUI clients (12 retries, self-send enabled).
94    pub fn gui() -> Self {
95        Self {
96            max_send_attempts: 12,
97            self_send: true,
98            ..Default::default()
99        }
100    }
101
102    /// Preset for headless/background mode (3 retries, self-send enabled).
103    pub fn headless() -> Self {
104        Self {
105            max_send_attempts: 3,
106            self_send: true,
107            ..Default::default()
108        }
109    }
110}
111
112// ============================================================================
113// SendResult
114// ============================================================================
115
116/// Result of sending a message.
117#[derive(serde::Serialize, Clone, Debug)]
118pub struct SendResult {
119    /// The pending ID used while sending
120    pub pending_id: String,
121    /// The real event ID after successful send (None if failed)
122    pub event_id: Option<String>,
123    /// The chat ID (receiver npub for DMs)
124    pub chat_id: String,
125}
126
127// ============================================================================
128// Internal: retry gift-wrap send
129// ============================================================================
130
131/// Shared tail of send_dm / send_file_dm / send_rumor_dm:
132/// gift-wrap → retry loop → finalize/fail → self-send.
133///
134/// Each successful wrap is persisted via `db::nip17_keys::store_wrap_key`
135/// so that the user can later issue a NIP-09 deletion against the
136/// kind-1059 wrap event id. Recipient wrap and self-send wrap each
137/// retain their own ephemeral key.
138async fn retry_send_gift_wrap(
139    client: &Client,
140    receiver: &PublicKey,
141    receiver_npub: &str,
142    pending_id: &str,
143    rumor: UnsignedEvent,
144    event_id: &str,
145    config: &SendConfig,
146    callback: Arc<dyn SendCallback>,
147) -> Result<SendResult, String> {
148    let my_pk = my_public_key().ok_or("Public key not set")?;
149    let inner_rumor_id = rumor.id;
150
151    for attempt in 0..config.max_send_attempts {
152        match crate::inbox_relays::send_gift_wrap_retained(client, receiver, rumor.clone(), []).await {
153            Ok(outcome) if outcome.output.success.is_empty() => {
154                // The publish round-trip succeeded but every targeted relay
155                // rejected the wrap (auth required, kind filter, rate-limit,
156                // timed-out OK, etc.). Surface the per-relay failure reasons
157                // so the user can see WHY their DMs aren't being accepted.
158                let failures: Vec<String> = outcome.output.failed.iter()
159                    .map(|(url, err)| format!("{}: {}", url, err))
160                    .collect();
161                let targeted_count = outcome.targeted_relays.len();
162                crate::log_warn!(
163                    "[Send] attempt {}/{} — 0 of {} relays accepted (targeted: {}). Per-relay errors: {}",
164                    attempt + 1,
165                    config.max_send_attempts,
166                    targeted_count,
167                    outcome.targeted_relays.join(", "),
168                    if failures.is_empty() {
169                        "(none reported — likely all timed out before responding)".to_string()
170                    } else {
171                        failures.join(" | ")
172                    },
173                );
174                if attempt + 1 >= config.max_send_attempts {
175                    // All attempts exhausted — mark failed
176                    let failed_msg = {
177                        let mut state = STATE.lock().await;
178                        state.update_message(pending_id, |msg| {
179                            msg.set_failed(true);
180                            msg.set_pending(false);
181                        })
182                    };
183                    if let Some((_chat_id, ref msg)) = failed_msg {
184                        callback.on_failed(receiver_npub, pending_id, msg);
185                        callback.on_persist(receiver_npub, msg);
186                    }
187                    return Err(format!("Failed to send DM after {} attempts (no relays accepted the gift-wrap)", config.max_send_attempts));
188                }
189                tokio::time::sleep(config.retry_delay).await;
190                continue;
191            }
192            Ok(outcome) => {
193                // At least one relay accepted — success.
194                // Persist the retained ephemeral key so the user can
195                // later issue NIP-09 deletion against this wrap.
196                if let Some(rid) = inner_rumor_id {
197                    let role = if attempt == 0 {
198                        crate::db::nip17_keys::WrapRole::Recipient
199                    } else {
200                        crate::db::nip17_keys::WrapRole::Retry
201                    };
202                    if let Err(e) = crate::db::nip17_keys::store_wrap_key(
203                        &outcome.wrap_event_id,
204                        &rid,
205                        receiver,
206                        role,
207                        &outcome.wrap_secret,
208                        &outcome.targeted_relays,
209                    ) {
210                        eprintln!("[NIP-17] failed to persist wrap key: {}", e);
211                    }
212                }
213
214                let finalized = {
215                    let mut state = STATE.lock().await;
216                    state.finalize_pending_message(receiver_npub, pending_id, event_id)
217                };
218
219                if let Some((_old_id, ref finalized_msg)) = finalized {
220                    callback.on_sent(receiver_npub, pending_id, finalized_msg);
221                    callback.on_persist(receiver_npub, finalized_msg);
222                }
223
224                // Self-send for recovery + retain wrap key so the user
225                // can later delete their own copy from inbox relays.
226                // SessionGuard skips publish + DB write on swap; without
227                // it account A's wrap key would corrupt account B's
228                // nip17_keys delete-history.
229                if config.self_send {
230                    let client = client.clone();
231                    let my_pk_clone = my_pk;
232                    let rumor_clone = rumor.clone();
233                    let rid_for_self = inner_rumor_id;
234                    let session = crate::state::SessionGuard::capture();
235                    tokio::spawn(async move {
236                        if !session.is_valid() { return; }
237                        match crate::inbox_relays::send_gift_wrap_retained(
238                            &client, &my_pk_clone, rumor_clone, [],
239                        ).await {
240                            Ok(self_outcome) if !self_outcome.output.success.is_empty() => {
241                                if !session.is_valid() { return; }
242                                if let Some(rid) = rid_for_self {
243                                    if let Err(e) = crate::db::nip17_keys::store_wrap_key(
244                                        &self_outcome.wrap_event_id,
245                                        &rid,
246                                        &my_pk_clone,
247                                        crate::db::nip17_keys::WrapRole::SelfSend,
248                                        &self_outcome.wrap_secret,
249                                        &self_outcome.targeted_relays,
250                                    ) {
251                                        eprintln!("[NIP-17] failed to persist self-wrap key: {}", e);
252                                    }
253                                }
254                            }
255                            _ => {}
256                        }
257                    });
258                }
259
260                return Ok(SendResult {
261                    pending_id: pending_id.to_string(),
262                    event_id: Some(event_id.to_string()),
263                    chat_id: receiver_npub.to_string(),
264                });
265            }
266            Err(e) => {
267                // send_gift_wrap_retained itself errored before even
268                // attempting a publish (seal/wrap failure, signer issue,
269                // pool resolution problem). Log so we can tell this apart
270                // from "publishes ran but all relays rejected".
271                crate::log_warn!(
272                    "[Send] attempt {}/{} — send_gift_wrap_retained errored: {}",
273                    attempt + 1,
274                    config.max_send_attempts,
275                    e,
276                );
277                if attempt + 1 >= config.max_send_attempts {
278                    let failed_msg = {
279                        let mut state = STATE.lock().await;
280                        state.update_message(pending_id, |msg| {
281                            msg.set_failed(true);
282                            msg.set_pending(false);
283                        })
284                    };
285                    if let Some((_chat_id, ref msg)) = failed_msg {
286                        callback.on_failed(receiver_npub, pending_id, msg);
287                        callback.on_persist(receiver_npub, msg);
288                    }
289                    return Err(format!("Failed to send DM after {} attempts: {}", config.max_send_attempts, e));
290                }
291                tokio::time::sleep(config.retry_delay).await;
292            }
293        }
294    }
295
296    Err("Send loop exited unexpectedly".to_string())
297}
298
299// ============================================================================
300// send_dm — Text DMs
301// ============================================================================
302
303/// Send a NIP-17 gift-wrapped text DM.
304///
305/// Flow: pending msg → callback.on_pending → build Kind 14 rumor →
306/// gift-wrap with retry → finalize → callback.on_sent → optional self-send.
307pub async fn send_dm(
308    receiver_npub: &str,
309    content: &str,
310    reply_to: Option<&str>,
311    config: &SendConfig,
312    callback: Arc<dyn SendCallback>,
313) -> Result<SendResult, String> {
314    let client = nostr_client().ok_or("Not logged in")?;
315    let my_pk = my_public_key().ok_or("Public key not set")?;
316
317    let now = std::time::SystemTime::now()
318        .duration_since(std::time::UNIX_EPOCH).unwrap();
319    let pending_id = format!("pending-{}", now.as_nanos());
320
321    let receiver = PublicKey::from_bech32(receiver_npub)
322        .map_err(|e| format!("Invalid npub: {}", e))?;
323
324    // NIP-30: resolve any `:shortcode:` in the outbound text against the
325    // user's subscribed packs so the rumor carries `["emoji", ...]` tags.
326    // Recipients without the pack subscribed still render correctly, and
327    // our own-view echo populates `emoji_tags` for the renderer.
328    let emoji_tags = crate::emoji_packs::resolve_outbound_emoji_tags(content);
329
330    // Build pending message and add to state
331    let msg = Message {
332        id: pending_id.clone(),
333        content: content.to_string(),
334        replied_to: reply_to.unwrap_or("").to_string(),
335        at: now.as_millis() as u64,
336        pending: true,
337        mine: true,
338        npub: my_pk.to_bech32().ok(),
339        emoji_tags: emoji_tags.clone(),
340        ..Default::default()
341    };
342
343    {
344        let mut state = STATE.lock().await;
345        state.add_message_to_participant(receiver_npub, msg.clone());
346    }
347
348    callback.on_pending(receiver_npub, &msg);
349
350    // Build the rumor
351    let milliseconds = now.as_millis() % 1000;
352    let mut rumor = EventBuilder::private_msg_rumor(receiver, content);
353
354    if let Some(reply_id) = reply_to {
355        if !reply_id.is_empty() {
356            rumor = rumor.tag(Tag::custom(
357                TagKind::e(),
358                [reply_id.to_string(), String::new(), "reply".to_string()],
359            ));
360        }
361    }
362
363    let mut rumor = rumor.tag(Tag::custom(TagKind::custom("ms"), [milliseconds.to_string()]));
364    for et in &emoji_tags {
365        rumor = rumor.tag(Tag::custom(
366            TagKind::custom("emoji"),
367            [et.shortcode.clone(), et.url.clone()],
368        ));
369    }
370    let built_rumor = rumor.build(my_pk);
371    let event_id = built_rumor.id.ok_or("Rumor has no id")?.to_hex();
372
373    // Send via gift-wrap with retry
374    retry_send_gift_wrap(
375        &client, &receiver, receiver_npub, &pending_id,
376        built_rumor, &event_id, config, callback,
377    ).await
378}
379
380// ============================================================================
381// send_rumor_dm — Pre-built rumor (custom events)
382// ============================================================================
383
384/// Send a pre-built rumor via NIP-17 gift-wrap.
385///
386/// Used when the caller has already built the rumor. Skips encryption/upload.
387pub async fn send_rumor_dm(
388    receiver_npub: &str,
389    pending_id: &str,
390    rumor: UnsignedEvent,
391    config: &SendConfig,
392    callback: Arc<dyn SendCallback>,
393) -> Result<SendResult, String> {
394    let client = nostr_client().ok_or("Not logged in")?;
395
396    let receiver = PublicKey::from_bech32(receiver_npub)
397        .map_err(|e| format!("Invalid npub: {}", e))?;
398
399    let event_id = rumor.id.ok_or("Rumor has no id")?.to_hex();
400
401    retry_send_gift_wrap(
402        &client, &receiver, receiver_npub, pending_id,
403        rumor, &event_id, config, callback,
404    ).await
405}
406
407// ============================================================================
408// send_file_dm — File Attachment DMs
409// ============================================================================
410
411/// Send a NIP-17 gift-wrapped file attachment DM.
412///
413/// Flow: hash → save locally → encrypt → upload → build Kind 15 rumor → gift-wrap + send.
414pub async fn send_file_dm(
415    receiver_npub: &str,
416    file_bytes: Arc<Vec<u8>>,
417    filename: &str,
418    extension: &str,
419    content: Option<&str>,
420    config: &SendConfig,
421    callback: Arc<dyn SendCallback>,
422) -> Result<SendResult, String> {
423    let client = nostr_client().ok_or("Not logged in")?;
424    let my_pk = my_public_key().ok_or("Public key not set")?;
425    // Sign the Blossom auth event via the active client signer so bunker
426    // accounts route through NostrConnect (the user's identity key lives on
427    // the remote signer; MY_SECRET_KEY only holds the NIP-46 client key).
428    let signer = client.signer().await
429        .map_err(|e| format!("Signer unavailable: {}", e))?;
430
431    let now = std::time::SystemTime::now()
432        .duration_since(std::time::UNIX_EPOCH).unwrap();
433    let pending_id = format!("pending-{}", now.as_nanos());
434    let milliseconds = now.as_millis() % 1000;
435
436    let receiver = PublicKey::from_bech32(receiver_npub)
437        .map_err(|e| format!("Invalid npub: {}", e))?;
438
439    let file_hash = crypto::sha256_hex(&file_bytes);
440    let mime_type = crypto::mime_from_extension(extension);
441
442    // WebXDC Mini Apps: mint the realtime-channel topic at send time and carry
443    // it on the rumor — locally-derived topics are asymmetric in DMs (each
444    // side's chat_id is the other party's npub), splitting players onto
445    // disjoint gossip topics.
446    let webxdc_topic = (extension.eq_ignore_ascii_case("xdc"))
447        .then(|| crate::webxdc::mint_topic_id(&file_hash, &my_pk.to_hex()));
448
449    // Save file locally so the attachment is immediately viewable
450    let download_dir = crate::db::get_download_dir();
451    let _ = std::fs::create_dir_all(&download_dir);
452    // Save with an extension matching the actual content. The caller's
453    // `extension` argument is post-compression (e.g. JPEG when an
454    // original PNG was compressed), but `filename` is the user-facing
455    // name which may still carry the pre-compression extension. If we
456    // honored `filename` verbatim we'd save JPEG bytes as `.png` and
457    // poison any future re-upload with a MIP-04 mismatch.
458    let local_name = if filename.is_empty() {
459        format!("{}.{}", &file_hash, extension)
460    } else {
461        let stem = filename.rsplit_once('.').map(|(s, _)| s).unwrap_or(filename);
462        format!("{}.{}", stem, extension)
463    };
464    // Resolve unique path (pasted_image.png → pasted_image-1.png on collision)
465    let local_path = crypto::resolve_unique_filename(&download_dir, &local_name);
466    // Atomic write: temp file then rename
467    let tmp = download_dir.join(format!(".{}.tmp", &file_hash));
468    let _ = std::fs::write(&tmp, &*file_bytes);
469    let _ = std::fs::rename(&tmp, &local_path);
470    let local_path_str = local_path.to_string_lossy().to_string();
471
472    // === Generate image metadata (thumbhash + dimensions) for image files ===
473    let img_meta = crypto::generate_image_metadata(&file_bytes);
474
475    // === Encrypt → upload → build rumor → send ===
476    let params = crypto::generate_encryption_params();
477    let encrypted = crypto::encrypt_data(&file_bytes, &params)?;
478    let encrypted_size = encrypted.len() as u64;
479
480    let attachment = Attachment {
481        id: file_hash.clone(), key: params.key.clone(), nonce: params.nonce.clone(),
482        extension: extension.to_string(), name: filename.to_string(),
483        url: String::new(), path: local_path_str.clone(), size: encrypted_size,
484        img_meta: img_meta.clone(), downloading: false, downloaded: true,
485        webxdc_topic: webxdc_topic.clone(),
486        ..Default::default()
487    };
488    let msg = Message {
489        id: pending_id.clone(), content: content.unwrap_or("").to_string(),
490        at: now.as_millis() as u64, pending: true, mine: true,
491        npub: my_pk.to_bech32().ok(), attachments: vec![attachment],
492        ..Default::default()
493    };
494    {
495        let mut state = STATE.lock().await;
496        state.add_message_to_participant(receiver_npub, msg.clone());
497    }
498    callback.on_pending(receiver_npub, &msg);
499
500    // Upload to Blossom — bridge SendCallback.on_upload_progress to Blossom ProgressCallback
501    let servers = crate::state::get_blossom_servers();
502    let cb_for_progress = callback.clone();
503    let pid_for_progress = pending_id.clone();
504    let progress_cb: crate::blossom::ProgressCallback = Arc::new(move |percentage, bytes| {
505        cb_for_progress.on_upload_progress(
506            &pid_for_progress,
507            percentage.unwrap_or(0),
508            bytes.unwrap_or(0),
509        )
510    });
511
512    // Send the original MIME even though bytes are ciphertext: many
513    // Blossom servers reject `application/octet-stream` but accept the
514    // same bytes under their original type.
515    let upload_url = match crate::blossom::upload_blob_with_progress_and_failover(
516        signer.clone(), servers, Arc::new(encrypted), Some(mime_type),
517        /* is_encrypted */ true,
518        progress_cb, Some(config.upload_retries), Some(config.upload_retry_delay),
519        config.cancel_token.clone(),
520    ).await {
521        Ok(url) => url,
522        Err(e) => {
523            let failed_msg = {
524                let mut state = STATE.lock().await;
525                state.update_message(&pending_id, |msg| {
526                    msg.set_failed(true);
527                    msg.set_pending(false);
528                })
529            };
530            if let Some((_chat_id, ref msg)) = failed_msg {
531                callback.on_failed(receiver_npub, &pending_id, msg);
532                callback.on_persist(receiver_npub, msg);
533            }
534            return Err(format!("Upload failed: {}", e));
535        }
536    };
537
538    {
539        let mut state = STATE.lock().await;
540        state.update_message(&pending_id, |msg| {
541            if let Some(att) = msg.attachments.last_mut() {
542                att.url = upload_url.clone().into_boxed_str();
543            }
544        });
545    }
546    callback.on_upload_complete(receiver_npub, &pending_id, &file_hash, &upload_url);
547
548    // Build Kind 15
549    let mut file_rumor = EventBuilder::new(Kind::from_u16(15), &upload_url)
550        .tag(Tag::public_key(receiver))
551        .tag(Tag::custom(TagKind::custom("file-type"), [mime_type]))
552        .tag(Tag::custom(TagKind::custom("size"), [encrypted_size.to_string()]))
553        .tag(Tag::custom(TagKind::custom("encryption-algorithm"), ["aes-gcm"]))
554        .tag(Tag::custom(TagKind::custom("decryption-key"), [params.key.as_str()]))
555        .tag(Tag::custom(TagKind::custom("decryption-nonce"), [params.nonce.as_str()]))
556        .tag(Tag::custom(TagKind::custom("ox"), [file_hash.clone()]));
557    if !filename.is_empty() {
558        file_rumor = file_rumor.tag(Tag::custom(TagKind::custom("name"), [filename]));
559    }
560    if let Some(ref topic) = webxdc_topic {
561        file_rumor = file_rumor.tag(Tag::custom(TagKind::custom("webxdc-topic"), [topic.as_str()]));
562    }
563    // Include image preview metadata for compatible rendering across all clients
564    if let Some(ref meta) = img_meta {
565        if !meta.thumbhash.is_empty() {
566            file_rumor = file_rumor.tag(Tag::custom(TagKind::custom("thumb"), [meta.thumbhash.as_str()]));
567        }
568        file_rumor = file_rumor.tag(Tag::custom(TagKind::custom("dim"), [format!("{}x{}", meta.width, meta.height)]));
569    }
570    file_rumor = file_rumor.tag(Tag::custom(TagKind::custom("ms"), [milliseconds.to_string()]));
571
572    let built_rumor = file_rumor.build(my_pk);
573    let event_id = built_rumor.id.ok_or("Rumor has no id")?.to_hex();
574
575    retry_send_gift_wrap(
576        &client, &receiver, receiver_npub, &pending_id,
577        built_rumor, &event_id, config, callback,
578    ).await
579}
580
581// ============================================================================
582// Tests
583// ============================================================================
584
585#[cfg(test)]
586mod tests {
587    use super::*;
588    use std::sync::Mutex;
589
590    #[derive(Debug, Clone, PartialEq)]
591    enum CbEvent {
592        Pending(String),
593        UploadProgress(String, u8, u64),
594        UploadComplete(String, String),
595        Sent(String, String),
596        Failed(String, String),
597        Persist(String),
598    }
599
600    struct MockCallback {
601        events: Mutex<Vec<CbEvent>>,
602        cancel_at: Option<u8>,
603    }
604
605    impl MockCallback {
606        fn new() -> Self { Self { events: Mutex::new(vec![]), cancel_at: None } }
607        fn with_cancel(pct: u8) -> Self { Self { events: Mutex::new(vec![]), cancel_at: Some(pct) } }
608        fn events(&self) -> Vec<CbEvent> { self.events.lock().unwrap().clone() }
609    }
610
611    impl SendCallback for MockCallback {
612        fn on_pending(&self, cid: &str, _: &Message) {
613            self.events.lock().unwrap().push(CbEvent::Pending(cid.into()));
614        }
615        fn on_upload_progress(&self, pid: &str, pct: u8, bytes: u64) -> Result<(), String> {
616            self.events.lock().unwrap().push(CbEvent::UploadProgress(pid.into(), pct, bytes));
617            if self.cancel_at.map_or(false, |c| pct >= c) { return Err("Cancelled".into()); }
618            Ok(())
619        }
620        fn on_upload_complete(&self, cid: &str, _: &str, _: &str, url: &str) {
621            self.events.lock().unwrap().push(CbEvent::UploadComplete(cid.into(), url.into()));
622        }
623        fn on_sent(&self, cid: &str, old: &str, _: &Message) {
624            self.events.lock().unwrap().push(CbEvent::Sent(cid.into(), old.into()));
625        }
626        fn on_failed(&self, cid: &str, old: &str, _: &Message) {
627            self.events.lock().unwrap().push(CbEvent::Failed(cid.into(), old.into()));
628        }
629        fn on_persist(&self, cid: &str, _: &Message) {
630            self.events.lock().unwrap().push(CbEvent::Persist(cid.into()));
631        }
632    }
633
634    #[test]
635    fn config_default() {
636        let c = SendConfig::default();
637        assert_eq!(c.max_send_attempts, 1);
638        assert!(!c.self_send);
639        assert!(c.cancel_token.is_none());
640        assert_eq!(c.upload_retries, 3);
641    }
642
643    #[test]
644    fn config_gui() {
645        let c = SendConfig::gui();
646        assert_eq!(c.max_send_attempts, 12);
647        assert!(c.self_send);
648    }
649
650    #[test]
651    fn config_headless() {
652        let c = SendConfig::headless();
653        assert_eq!(c.max_send_attempts, 3);
654        assert!(c.self_send);
655    }
656
657    #[test]
658    fn config_custom_override() {
659        let c = SendConfig { max_send_attempts: 5, ..SendConfig::gui() };
660        assert_eq!(c.max_send_attempts, 5);
661        assert!(c.self_send);
662    }
663
664    #[test]
665    fn noop_callback_all_methods() {
666        let cb = NoOpSendCallback;
667        let msg = Message::default();
668        cb.on_pending("c", &msg);
669
670        assert!(cb.on_upload_progress("p", 50, 1024).is_ok());
671        cb.on_upload_complete("c", "p", "a", "url");
672        cb.on_sent("c", "o", &msg);
673        cb.on_failed("c", "o", &msg);
674        cb.on_persist("c", &msg);
675    }
676
677    #[test]
678    fn text_dm_sequence() {
679        let cb = MockCallback::new();
680        let msg = Message::default();
681        cb.on_pending("r", &msg);
682        cb.on_sent("r", "p-1", &msg);
683        cb.on_persist("r", &msg);
684        assert_eq!(cb.events(), vec![
685            CbEvent::Pending("r".into()),
686            CbEvent::Sent("r".into(), "p-1".into()),
687            CbEvent::Persist("r".into()),
688        ]);
689    }
690
691    #[test]
692    fn file_dm_sequence() {
693        let cb = MockCallback::new();
694        let msg = Message::default();
695        cb.on_pending("r", &msg);
696
697        cb.on_upload_progress("p", 0, 0).ok();
698        cb.on_upload_progress("p", 50, 5000).ok();
699        cb.on_upload_progress("p", 100, 10000).ok();
700        cb.on_upload_complete("r", "p", "h", "https://blossom/h");
701        cb.on_sent("r", "p", &msg);
702        cb.on_persist("r", &msg);
703        let e = cb.events();
704        assert_eq!(e.len(), 7);
705        assert!(matches!(&e[4], CbEvent::UploadComplete(_, url) if url.contains("blossom")));
706    }
707
708    #[test]
709    fn failed_sequence() {
710        let cb = MockCallback::new();
711        let msg = Message::default();
712        cb.on_pending("r", &msg);
713        cb.on_failed("r", "p", &msg);
714        cb.on_persist("r", &msg);
715        assert_eq!(cb.events(), vec![
716            CbEvent::Pending("r".into()),
717            CbEvent::Failed("r".into(), "p".into()),
718            CbEvent::Persist("r".into()),
719        ]);
720    }
721
722    #[test]
723    fn cancel_upload_at_threshold() {
724        let cb = MockCallback::with_cancel(50);
725        assert!(cb.on_upload_progress("p", 25, 512).is_ok());
726        assert!(cb.on_upload_progress("p", 50, 1024).is_err());
727        assert_eq!(cb.events().len(), 2);
728    }
729
730    #[test]
731    fn cancel_triggers_failed() {
732        let cb = MockCallback::with_cancel(30);
733        let msg = Message::default();
734        cb.on_pending("r", &msg);
735
736        cb.on_upload_progress("p", 10, 1000).ok();
737        assert!(cb.on_upload_progress("p", 30, 3000).is_err());
738        cb.on_failed("r", "p", &msg);
739        assert!(matches!(cb.events().last(), Some(CbEvent::Failed(..))));
740    }
741
742    #[test]
743    fn send_result_serialize() {
744        let r = SendResult { pending_id: "p".into(), event_id: Some("e".into()), chat_id: "c".into() };
745        let j = serde_json::to_string(&r).unwrap();
746        assert!(j.contains("\"pending_id\":\"p\""));
747    }
748
749    #[test]
750    fn send_result_none_event() {
751        let r = SendResult { pending_id: "p".into(), event_id: None, chat_id: "c".into() };
752        let j = serde_json::to_string(&r).unwrap();
753        assert!(j.contains("null"));
754    }
755
756    // ========================================================================
757    // File DM callback sequences
758    // ========================================================================
759
760    #[test]
761    fn file_dm_fresh_upload_full_sequence() {
762        let cb = MockCallback::new();
763        let msg = Message::default();
764
765        // 1. Pending message created
766        cb.on_pending("npub1recv", &msg);
767        // 2. Attachment preview added
768
769        // 3. Upload progress (0% → 25% → 50% → 75% → 100%)
770        cb.on_upload_progress("pending-42", 0, 0).unwrap();
771        cb.on_upload_progress("pending-42", 25, 2500).unwrap();
772        cb.on_upload_progress("pending-42", 50, 5000).unwrap();
773        cb.on_upload_progress("pending-42", 75, 7500).unwrap();
774        cb.on_upload_progress("pending-42", 100, 10000).unwrap();
775        // 4. Upload complete
776        cb.on_upload_complete("npub1recv", "pending-42", "deadbeef", "https://blossom.example/deadbeef");
777        // 5. Gift-wrap sent successfully
778        cb.on_sent("npub1recv", "pending-42", &msg);
779        // 6. Persisted to DB
780        cb.on_persist("npub1recv", &msg);
781
782        let e = cb.events();
783        assert_eq!(e.len(), 9);
784        assert!(matches!(&e[0], CbEvent::Pending(c) if c == "npub1recv"));
785        assert!(matches!(&e[1], CbEvent::UploadProgress(_, 0, 0)));
786        assert!(matches!(&e[5], CbEvent::UploadProgress(_, 100, 10000)));
787        assert!(matches!(&e[6], CbEvent::UploadComplete(_, url) if url.contains("deadbeef")));
788        assert!(matches!(&e[7], CbEvent::Sent(..)));
789        assert!(matches!(&e[8], CbEvent::Persist(..)));
790    }
791
792    #[test]
793    fn file_dm_skip_upload_sequence() {
794        let cb = MockCallback::new();
795        let msg = Message::default();
796
797        // Dedup hit: no upload, existing URL reused
798        // 1. Pending message
799        cb.on_pending("npub1recv", &msg);
800        // 2. Attachment preview (with reused URL already set)
801
802        // 3. Upload complete (immediate — URL was already known)
803        cb.on_upload_complete("npub1recv", "pending-99", "existinghash", "https://blossom.example/existing");
804        // 4. Gift-wrap sent
805        cb.on_sent("npub1recv", "pending-99", &msg);
806        // 5. Persisted
807        cb.on_persist("npub1recv", &msg);
808
809        let e = cb.events();
810        assert_eq!(e.len(), 4);
811        // No UploadProgress events — upload was skipped
812        assert!(!e.iter().any(|ev| matches!(ev, CbEvent::UploadProgress(..))));
813        assert!(matches!(&e[1], CbEvent::UploadComplete(..)));
814    }
815
816    #[test]
817    fn file_dm_upload_cancelled_at_30pct() {
818        let cb = MockCallback::with_cancel(30);
819        let msg = Message::default();
820
821        cb.on_pending("npub1recv", &msg);
822
823        assert!(cb.on_upload_progress("p", 10, 1000).is_ok());
824        assert!(cb.on_upload_progress("p", 20, 2000).is_ok());
825        // Cancel triggers at 30%
826        let err = cb.on_upload_progress("p", 30, 3000);
827        assert!(err.is_err());
828        assert!(err.unwrap_err().contains("Cancelled"));
829        // Pipeline marks as failed
830        cb.on_failed("npub1recv", "p", &msg);
831
832        let e = cb.events();
833        assert_eq!(e.len(), 5);
834        // No Sent, no Persist after cancel — just Failed
835        assert!(!e.iter().any(|ev| matches!(ev, CbEvent::Sent(..))));
836        assert!(matches!(e.last(), Some(CbEvent::Failed(..))));
837    }
838
839    #[test]
840    fn file_dm_upload_fails_marks_failed() {
841        let cb = MockCallback::new();
842        let msg = Message::default();
843
844        cb.on_pending("npub1recv", &msg);
845
846        cb.on_upload_progress("p", 0, 0).ok();
847        cb.on_upload_progress("p", 10, 500).ok();
848        // Upload fails (server error, all retries exhausted)
849        cb.on_failed("npub1recv", "p", &msg);
850        cb.on_persist("npub1recv", &msg);
851
852        let e = cb.events();
853        assert_eq!(e.len(), 5);
854        assert!(matches!(&e[3], CbEvent::Failed(..)));
855        assert!(matches!(&e[4], CbEvent::Persist(..)));
856        // No UploadComplete, no Sent
857        assert!(!e.iter().any(|ev| matches!(ev, CbEvent::UploadComplete(..))));
858        assert!(!e.iter().any(|ev| matches!(ev, CbEvent::Sent(..))));
859    }
860
861    #[test]
862    fn file_dm_gift_wrap_fails_after_upload() {
863        let cb = MockCallback::new();
864        let msg = Message::default();
865
866        // Upload succeeds but gift-wrap fails
867        cb.on_pending("npub1recv", &msg);
868
869        cb.on_upload_progress("p", 100, 10000).ok();
870        cb.on_upload_complete("npub1recv", "p", "hash", "https://blossom/hash");
871        // Gift-wrap retry exhausted
872        cb.on_failed("npub1recv", "p", &msg);
873        cb.on_persist("npub1recv", &msg);
874
875        let e = cb.events();
876        assert_eq!(e.len(), 5);
877        // Upload succeeded but send failed
878        assert!(matches!(&e[2], CbEvent::UploadComplete(..)));
879        assert!(matches!(&e[3], CbEvent::Failed(..)));
880    }
881
882    #[test]
883    fn file_dm_with_image_metadata_sequence() {
884        let cb = MockCallback::new();
885        let msg = Message::default();
886
887        // Image with thumbhash + dimensions
888        cb.on_pending("npub1recv", &msg);
889
890        cb.on_upload_progress("p", 0, 0).ok();
891        cb.on_upload_progress("p", 50, 50000).ok();
892        cb.on_upload_progress("p", 100, 100000).ok();
893        cb.on_upload_complete("npub1recv", "p", "imghash", "https://blossom/imghash.jpg");
894        cb.on_sent("npub1recv", "p", &msg);
895        cb.on_persist("npub1recv", &msg);
896
897        let e = cb.events();
898        assert_eq!(e.len(), 7);
899        // Verify ordering: pending → progress(3x) → complete → sent → persist
900        assert!(matches!(&e[0], CbEvent::Pending(..)));
901        assert!(matches!(&e[4], CbEvent::UploadComplete(_, url) if url.ends_with(".jpg")));
902        assert!(matches!(&e[5], CbEvent::Sent(..)));
903    }
904
905    #[test]
906    fn cancel_token_config_with_upload() {
907        let token = Arc::new(std::sync::atomic::AtomicBool::new(false));
908        let c = SendConfig {
909            cancel_token: Some(token.clone()),
910            ..SendConfig::gui()
911        };
912        assert!(c.cancel_token.is_some());
913        assert!(!token.load(std::sync::atomic::Ordering::Relaxed));
914
915        // Simulate cancel
916        token.store(true, std::sync::atomic::Ordering::Relaxed);
917        assert!(c.cancel_token.as_ref().unwrap().load(std::sync::atomic::Ordering::Relaxed));
918    }
919}