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 crate::event_ext::FinalizeUnsignedWithId;
8use std::ops::Range;
9use std::sync::Arc;
10use std::sync::atomic::{AtomicBool, Ordering};
11use nostr_sdk::prelude::*;
12
13/// NIP-59's gift-wrap backdating window (0 to 2 days).
14///
15/// nostr 0.45 made its own copy of this private, but the wrap timestamps we mint
16/// must stay inside the window readers expect, so it's pinned here rather than
17/// re-guessed per call site.
18pub const NIP59_RANDOM_TIMESTAMP_TWEAK: Range<u64> = 0..172_800;
19
20use crate::state::{nostr_client, my_public_key, STATE};
21use crate::types::{Message, Attachment};
22use crate::crypto;
23
24// ============================================================================
25// SendCallback — Client notification trait
26// ============================================================================
27
28/// Callbacks invoked during the DM send pipeline.
29///
30/// Each method has a default no-op so simple callers (CLI, bots, tests)
31/// implement only what they need. Methods are synchronous and non-fallible
32/// by design — they should never block the send pipeline.
33///
34/// Exception: `on_upload_progress` returns `Result` — return `Err` to cancel.
35pub trait SendCallback: Send + Sync {
36    /// Message created and added to STATE as pending.
37    fn on_pending(&self, _chat_id: &str, _msg: &Message) {}
38
39    /// File upload progress. Return Err("...") to cancel the upload.
40    fn on_upload_progress(
41        &self,
42        _pending_id: &str,
43        _percentage: u8,
44        _bytes_sent: u64,
45    ) -> Result<(), String> {
46        Ok(())
47    }
48
49    /// Upload complete, attachment URL now available.
50    fn on_upload_complete(&self, _chat_id: &str, _pending_id: &str, _attachment_id: &str, _url: &str) {}
51
52    /// Message successfully delivered to at least one relay.
53    /// `old_id` is the pending ID, `msg` has the real event ID.
54    fn on_sent(&self, _chat_id: &str, _old_id: &str, _msg: &Message) {}
55
56    /// Message delivery failed after all retry attempts.
57    fn on_failed(&self, _chat_id: &str, _old_id: &str, _msg: &Message) {}
58
59    /// Persist message to database. Default is no-op.
60    /// Tauri implements this to call save_message + save_slim_chat.
61    fn on_persist(&self, _chat_id: &str, _msg: &Message) {}
62}
63
64/// No-op callback for headless/CLI/test use.
65pub struct NoOpSendCallback;
66impl SendCallback for NoOpSendCallback {}
67
68// ============================================================================
69// SendConfig — Per-call configuration
70// ============================================================================
71
72/// Configuration for a send operation.
73pub struct SendConfig {
74    /// Max gift-wrap send attempts (default: 1).
75    pub max_send_attempts: u32,
76    /// Delay between send retries (default: 5 seconds).
77    pub retry_delay: std::time::Duration,
78    /// Send copy to own inbox for recovery/sync (default: false).
79    pub self_send: bool,
80    /// Cancel token for file uploads — set to true to abort.
81    pub cancel_token: Option<Arc<AtomicBool>>,
82    /// Max Blossom upload retries per server (default: 3).
83    pub upload_retries: u32,
84    /// Delay between upload retries (default: 2 seconds).
85    pub upload_retry_delay: std::time::Duration,
86    /// NIP-40 self-destruct expiry (unix secs) to stamp on the outgoing rumor
87    /// and mirror onto the outer wrap. None = permanent. Resolved per-chat by
88    /// the caller (the "Self-Destruct Timer" setting).
89    pub expiration: Option<u64>,
90}
91
92impl Default for SendConfig {
93    fn default() -> Self {
94        Self {
95            max_send_attempts: 1,
96            retry_delay: std::time::Duration::from_secs(5),
97            self_send: false,
98            cancel_token: None,
99            upload_retries: 3,
100            upload_retry_delay: std::time::Duration::from_secs(2),
101            expiration: None,
102        }
103    }
104}
105
106impl SendConfig {
107    /// Preset for GUI clients (12 retries, self-send enabled).
108    pub fn gui() -> Self {
109        Self {
110            max_send_attempts: 12,
111            self_send: true,
112            ..Default::default()
113        }
114    }
115
116    /// Preset for headless/background mode (3 retries, self-send enabled).
117    pub fn headless() -> Self {
118        Self {
119            max_send_attempts: 3,
120            self_send: true,
121            ..Default::default()
122        }
123    }
124}
125
126// ============================================================================
127// SendResult
128// ============================================================================
129
130/// Result of sending a message.
131#[derive(serde::Serialize, Clone, Debug)]
132pub struct SendResult {
133    /// The pending ID used while sending
134    pub pending_id: String,
135    /// The real event ID after successful send (None if failed)
136    pub event_id: Option<String>,
137    /// The chat ID (receiver npub for DMs)
138    pub chat_id: String,
139}
140
141// ============================================================================
142// Late-OK confirmation registry
143// ============================================================================
144//
145// A relay's OK can outlive the per-attempt wait: slow links push the
146// round-trip past nostr-sdk's OK timeout, and mobile handovers drop the
147// socket after the EVENT frame was already delivered. The wrap is then
148// on the relay while the sender believes it failed — the user re-sends
149// and the recipient sees a double-post.
150//
151// Every wrap publish registers here before its first attempt. Hosts feed
152// relay OKs back via `note_relay_ok` from their notification loop; an
153// accepted OK counts as delivery no matter how late it arrives — it wakes
154// the in-flight retry loop early, or rescues a message already marked
155// Failed back to Sent.
156
157struct WrapConfirm {
158    wrap_id: EventId,
159    chat_id: String,
160    pending_id: String,
161    /// Inner rumor id — the message's final id after finalization.
162    rumor_event_id: String,
163    rumor: UnsignedEvent,
164    callback: Arc<dyn SendCallback>,
165    self_send: bool,
166    confirmed: AtomicBool,
167    /// Claimed by whichever path (retry loop or note_relay_ok) performs
168    /// the failed→sent rescue, so it happens exactly once.
169    rescued: AtomicBool,
170    /// Set once the retry loop has exited after marking the message
171    /// failed — from then on `note_relay_ok` performs the rescue itself.
172    loop_exited: AtomicBool,
173    notify: tokio::sync::Notify,
174    session: crate::state::SessionGuard,
175    registered_at: std::time::Instant,
176}
177
178static WRAP_CONFIRMS: std::sync::LazyLock<
179    std::sync::Mutex<std::collections::HashMap<EventId, Arc<WrapConfirm>>>,
180> = std::sync::LazyLock::new(|| std::sync::Mutex::new(std::collections::HashMap::new()));
181
182/// An OK this long after the last publish attempt is a ghost — drop the
183/// entry rather than resurrect a message the user has moved past.
184const WRAP_CONFIRM_TTL: std::time::Duration = std::time::Duration::from_secs(15 * 60);
185
186fn register_wrap_confirm(entry: Arc<WrapConfirm>) {
187    let mut map = WRAP_CONFIRMS.lock().unwrap();
188    map.retain(|_, e| e.registered_at.elapsed() < WRAP_CONFIRM_TTL);
189    map.insert(entry.wrap_id, entry);
190}
191
192fn remove_wrap_confirm(wrap_id: &EventId) {
193    WRAP_CONFIRMS.lock().unwrap().remove(wrap_id);
194}
195
196/// Clear on session swap — entries carry per-account chat/message ids.
197pub fn clear_wrap_confirms() {
198    WRAP_CONFIRMS.lock().unwrap().clear();
199}
200
201/// Feed a relay `OK` for an outbound event back into the send pipeline.
202///
203/// Hosts call this from their notification loop for every
204/// `RelayMessage::Ok`. Ids that aren't in-flight wraps miss the registry
205/// and return immediately.
206pub fn note_relay_ok(event_id: &EventId, accepted: bool) {
207    if !accepted {
208        return;
209    }
210    let entry = {
211        let map = WRAP_CONFIRMS.lock().unwrap();
212        map.get(event_id).cloned()
213    };
214    let Some(entry) = entry else { return };
215    entry.confirmed.store(true, Ordering::SeqCst);
216    entry.notify.notify_one();
217    if !entry.loop_exited.load(Ordering::SeqCst)
218        || entry.rescued.swap(true, Ordering::SeqCst)
219    {
220        return;
221    }
222    if !entry.session.is_valid() {
223        remove_wrap_confirm(&entry.wrap_id);
224        return;
225    }
226    tokio::spawn(async move {
227        rescue_failed_as_sent(&entry).await;
228        remove_wrap_confirm(&entry.wrap_id);
229    });
230}
231
232/// Flip an already-failed message back to Sent — a late relay OK proved
233/// the wrap was delivered.
234async fn rescue_failed_as_sent(entry: &WrapConfirm) {
235    if !entry.session.is_valid() {
236        return;
237    }
238    let finalized = {
239        let mut state = STATE.lock().await;
240        state.update_message(&entry.pending_id, |msg| {
241            msg.set_failed(false);
242        });
243        state.finalize_pending_message(&entry.chat_id, &entry.pending_id, &entry.rumor_event_id)
244    };
245    let Some((_old_id, ref msg)) = finalized else { return };
246    crate::log_info!(
247        "[Send] late relay OK confirmed wrap {} — message {} rescued to sent",
248        entry.wrap_id,
249        entry.rumor_event_id,
250    );
251    entry.callback.on_sent(&entry.chat_id, &entry.pending_id, msg);
252    entry.callback.on_persist(&entry.chat_id, msg);
253    // A late OK proved delivery — drop the retained resend body.
254    if let Some(rid) = entry.rumor.id {
255        let _ = crate::db::nip17_keys::clear_resend_payload(&rid);
256    }
257    if entry.self_send {
258        if let (Some(client), Some(my_pk)) = (nostr_client(), my_public_key()) {
259            spawn_self_send(client, my_pk, entry.rumor.clone());
260        }
261    }
262}
263
264/// Fire-and-forget the self-send recovery copy + persist its wrap key.
265/// SessionGuard skips publish + DB write on swap; without it account A's
266/// wrap key would corrupt account B's nip17_keys delete-history.
267fn spawn_self_send(client: Client, my_pk: PublicKey, rumor: UnsignedEvent) {
268    let rid_for_self = rumor.id;
269    let session = crate::state::SessionGuard::capture();
270    tokio::spawn(async move {
271        if !session.is_valid() { return; }
272        match crate::inbox_relays::send_gift_wrap_retained(
273            &client, &my_pk, rumor, [],
274        ).await {
275            Ok(self_outcome) if !self_outcome.output.success.is_empty() => {
276                if !session.is_valid() { return; }
277                if let Some(rid) = rid_for_self {
278                    if let Err(e) = crate::db::nip17_keys::store_wrap_key(
279                        &self_outcome.wrap_event_id,
280                        &rid,
281                        &my_pk,
282                        crate::db::nip17_keys::WrapRole::SelfSend,
283                        &self_outcome.wrap_secret,
284                        &self_outcome.targeted_relays,
285                    ) {
286                        eprintln!("[NIP-17] failed to persist self-wrap key: {}", e);
287                    }
288                }
289            }
290            _ => {}
291        }
292    });
293}
294
295// ============================================================================
296// Internal: retry gift-wrap send
297// ============================================================================
298
299/// Shared tail of send_dm / send_file_dm / send_rumor_dm:
300/// gift-wrap → retry loop → finalize/fail → self-send.
301///
302/// The wrap is built ONCE and the identical event republished on every
303/// attempt: a relay that already stored it answers the resend with
304/// OK-true "duplicate", so a lost OK becomes a delivery confirmation on
305/// the next attempt instead of an unconfirmed extra copy. The wrap's
306/// ephemeral key is persisted via `db::nip17_keys::store_wrap_key`
307/// BEFORE the first publish — a wrap can land without us ever seeing
308/// the OK, and the user must still be able to NIP-09 it later.
309async fn retry_send_gift_wrap(
310    client: &Client,
311    receiver: &PublicKey,
312    receiver_npub: &str,
313    pending_id: &str,
314    rumor: UnsignedEvent,
315    event_id: &str,
316    config: &SendConfig,
317    callback: Arc<dyn SendCallback>,
318    // Manual retry injects the retained wrap so the EXACT same event
319    // republishes (relay dedups the duplicate). `None` = a fresh send that
320    // builds + retains the wrap in-loop.
321    prebuilt: Option<crate::inbox_relays::BuiltGiftWrap>,
322) -> Result<SendResult, String> {
323    let my_pk = my_public_key().ok_or("Public key not set")?;
324    let inner_rumor_id = rumor.id;
325
326    // A resend already holds a persisted wrap key + retained body — don't
327    // re-store either (that would reset the row's clock and re-stash bytes).
328    let is_resend = prebuilt.is_some();
329    // Built lazily in-loop so transient signer failures (NIP-46 bunker
330    // round-trips) still get the full retry schedule; a resend seeds it.
331    let mut built: Option<crate::inbox_relays::BuiltGiftWrap> = prebuilt;
332    // Targets resolve once; transient inbox connections live across the
333    // whole retry window rather than reconnecting per attempt.
334    let mut targets: Option<crate::inbox_relays::GiftWrapTargets> = None;
335    let mut confirm: Option<Arc<WrapConfirm>> = None;
336    let mut last_error: Option<String> = None;
337
338    let max_attempts = config.max_send_attempts.max(1);
339
340    for attempt in 0..max_attempts {
341        if built.is_none() {
342            // Mirror any NIP-40 expiry from the rumor onto the outer wrap so
343            // relays drop the gift-wrap on schedule (the inner tag is encrypted).
344            let wrap_extra: Vec<Tag> = rumor.tags.iter()
345                .find(|t| t.as_slice().first().map(|k| k.as_str() == "expiration").unwrap_or(false))
346                .cloned()
347                .into_iter()
348                .collect();
349            match crate::inbox_relays::build_gift_wrap_retained(
350                client, receiver, rumor.clone(), wrap_extra,
351            ).await {
352                Ok(b) => built = Some(b),
353                Err(e) => {
354                    crate::log_warn!(
355                        "[Send] attempt {}/{} — building gift-wrap failed: {}",
356                        attempt + 1, max_attempts, e,
357                    );
358                    last_error = Some(e);
359                    if attempt + 1 < max_attempts {
360                        tokio::time::sleep(config.retry_delay).await;
361                    }
362                    continue;
363                }
364            }
365        }
366        let wrap = built.as_ref().unwrap();
367
368        if confirm.is_none() {
369            let entry = Arc::new(WrapConfirm {
370                wrap_id: wrap.event.id,
371                chat_id: receiver_npub.to_string(),
372                pending_id: pending_id.to_string(),
373                rumor_event_id: event_id.to_string(),
374                rumor: rumor.clone(),
375                callback: callback.clone(),
376                self_send: config.self_send,
377                confirmed: AtomicBool::new(false),
378                rescued: AtomicBool::new(false),
379                loop_exited: AtomicBool::new(false),
380                notify: tokio::sync::Notify::new(),
381                session: crate::state::SessionGuard::capture(),
382                registered_at: std::time::Instant::now(),
383            });
384            register_wrap_confirm(entry.clone());
385            confirm = Some(entry);
386        }
387        let confirm_ref = confirm.as_ref().unwrap();
388
389        if targets.is_none() {
390            let t = crate::inbox_relays::resolve_gift_wrap_targets(client, receiver).await;
391            // First send only: persist the wrap key (NIP-09 delete) AND retain
392            // the exact wrap event + rumor (byte-identical resend on manual
393            // retry). A resend already holds both.
394            if !is_resend {
395                if let Some(rid) = inner_rumor_id {
396                    if let Err(e) = crate::db::nip17_keys::store_wrap_key(
397                        &wrap.event.id,
398                        &rid,
399                        receiver,
400                        crate::db::nip17_keys::WrapRole::Recipient,
401                        &wrap.secret,
402                        &t.targeted_relays,
403                    ) {
404                        eprintln!("[NIP-17] failed to persist wrap key: {}", e);
405                    }
406                    if let Err(e) = crate::db::nip17_keys::stash_resend_payload(
407                        &wrap.event.id, pending_id, &wrap.event, &rumor,
408                    ) {
409                        eprintln!("[NIP-17] failed to retain resend payload: {}", e);
410                    }
411                }
412            }
413            targets = Some(t);
414        } else {
415            crate::inbox_relays::reconnect_gift_wrap_targets(targets.as_ref().unwrap()).await;
416        }
417        let targets_ref = targets.as_ref().unwrap();
418
419        match crate::inbox_relays::publish_gift_wrap_to_targets(
420            client, targets_ref, &wrap.event,
421        ).await {
422            Ok(output) if !output.success.is_empty() => {
423                return Ok(finalize_gift_wrap_sent(
424                    client, my_pk, receiver_npub, pending_id, event_id,
425                    &rumor, config, &callback, confirm_ref, targets_ref,
426                ).await);
427            }
428            Ok(output) => {
429                // The publish round-trip ran but no targeted relay confirmed
430                // (auth required, kind filter, rate-limit, timed-out OK,
431                // etc.). Surface the per-relay failure reasons so the user
432                // can see WHY their DMs aren't being accepted.
433                let failures: Vec<String> = output.failed.iter()
434                    .map(|(url, err)| format!("{}: {}", url, err))
435                    .collect();
436                crate::log_warn!(
437                    "[Send] attempt {}/{} — 0 of {} relays accepted (targeted: {}). Per-relay errors: {}",
438                    attempt + 1,
439                    max_attempts,
440                    targets_ref.targeted_relays.len(),
441                    targets_ref.targeted_relays.join(", "),
442                    if failures.is_empty() {
443                        "(none reported — likely all timed out before responding)".to_string()
444                    } else {
445                        failures.join(" | ")
446                    },
447                );
448                last_error = None;
449            }
450            Err(e) => {
451                crate::log_warn!(
452                    "[Send] attempt {}/{} — publish errored: {}",
453                    attempt + 1, max_attempts, e,
454                );
455                last_error = Some(e);
456            }
457        }
458
459        // A late OK for an earlier attempt may have arrived while this one
460        // was publishing.
461        if confirm_ref.confirmed.load(Ordering::SeqCst) {
462            return Ok(finalize_gift_wrap_sent(
463                client, my_pk, receiver_npub, pending_id, event_id,
464                &rumor, config, &callback, confirm_ref, targets_ref,
465            ).await);
466        }
467
468        if attempt + 1 < max_attempts {
469            // Sleep out the retry delay, waking instantly on a late OK
470            // (notify_one stores a permit, so an OK landing before this
471            // line still wakes us).
472            let _ = tokio::time::timeout(
473                config.retry_delay,
474                confirm_ref.notify.notified(),
475            ).await;
476            if confirm_ref.confirmed.load(Ordering::SeqCst) {
477                return Ok(finalize_gift_wrap_sent(
478                    client, my_pk, receiver_npub, pending_id, event_id,
479                    &rumor, config, &callback, confirm_ref, targets_ref,
480                ).await);
481            }
482        }
483    }
484
485    // Exhausted every attempt with no OK observed. Mark failed, but leave
486    // the confirmation entry armed: a straggler OK can still rescue this
487    // message to Sent (see note_relay_ok).
488    if let Some(t) = targets.as_ref() {
489        crate::inbox_relays::teardown_gift_wrap_targets(client, t).await;
490    }
491    let failed_msg = {
492        let mut state = STATE.lock().await;
493        state.update_message(pending_id, |msg| {
494            msg.set_failed(true);
495            msg.set_pending(false);
496        })
497    };
498    if let Some((_chat_id, ref msg)) = failed_msg {
499        callback.on_failed(receiver_npub, pending_id, msg);
500        callback.on_persist(receiver_npub, msg);
501    }
502    if let Some(entry) = confirm.as_ref() {
503        entry.loop_exited.store(true, Ordering::SeqCst);
504        // An OK that landed between the last attempt and loop_exited going
505        // up would see loop_exited=false and skip its rescue — catch it here.
506        if entry.confirmed.load(Ordering::SeqCst)
507            && !entry.rescued.swap(true, Ordering::SeqCst)
508        {
509            rescue_failed_as_sent(entry).await;
510            remove_wrap_confirm(&entry.wrap_id);
511            return Ok(SendResult {
512                pending_id: pending_id.to_string(),
513                event_id: Some(event_id.to_string()),
514                chat_id: receiver_npub.to_string(),
515            });
516        }
517    }
518    match last_error {
519        Some(e) => Err(format!("Failed to send DM after {} attempts: {}", max_attempts, e)),
520        None => Err(format!(
521            "Failed to send DM after {} attempts (no relays accepted the gift-wrap)",
522            max_attempts
523        )),
524    }
525}
526
527/// Success epilogue shared by every confirmed path in the retry loop:
528/// finalize the pending message, notify, persist, fire the self-send.
529async fn finalize_gift_wrap_sent(
530    client: &Client,
531    my_pk: PublicKey,
532    receiver_npub: &str,
533    pending_id: &str,
534    event_id: &str,
535    rumor: &UnsignedEvent,
536    config: &SendConfig,
537    callback: &Arc<dyn SendCallback>,
538    confirm: &Arc<WrapConfirm>,
539    targets: &crate::inbox_relays::GiftWrapTargets,
540) -> SendResult {
541    remove_wrap_confirm(&confirm.wrap_id);
542    crate::inbox_relays::teardown_gift_wrap_targets(client, targets).await;
543
544    let finalized = {
545        let mut state = STATE.lock().await;
546        state.finalize_pending_message(receiver_npub, pending_id, event_id)
547    };
548    if let Some((_old_id, ref finalized_msg)) = finalized {
549        callback.on_sent(receiver_npub, pending_id, finalized_msg);
550        callback.on_persist(receiver_npub, finalized_msg);
551    }
552
553    // Delivery confirmed — drop the retained resend body (the key row stays for
554    // NIP-09). Steady-state, only genuinely-unsent messages carry a body.
555    if let Some(rid) = rumor.id {
556        let _ = crate::db::nip17_keys::clear_resend_payload(&rid);
557    }
558
559    if config.self_send {
560        spawn_self_send(client.clone(), my_pk, rumor.clone());
561    }
562
563    SendResult {
564        pending_id: pending_id.to_string(),
565        event_id: Some(event_id.to_string()),
566        chat_id: receiver_npub.to_string(),
567    }
568}
569
570// ============================================================================
571// send_dm — Text DMs
572// ============================================================================
573
574/// Send a NIP-17 gift-wrapped text DM.
575///
576/// Flow: pending msg → callback.on_pending → build Kind 14 rumor →
577/// gift-wrap with retry → finalize → callback.on_sent → optional self-send.
578pub async fn send_dm(
579    receiver_npub: &str,
580    content: &str,
581    reply_to: Option<&str>,
582    config: &SendConfig,
583    callback: Arc<dyn SendCallback>,
584) -> Result<SendResult, String> {
585    let client = nostr_client().ok_or("Not logged in")?;
586    let my_pk = my_public_key().ok_or("Public key not set")?;
587
588    let now = std::time::SystemTime::now()
589        .duration_since(std::time::UNIX_EPOCH).unwrap();
590    let pending_id = format!("pending-{}", now.as_nanos());
591
592    let receiver = PublicKey::from_bech32(receiver_npub)
593        .map_err(|e| format!("Invalid npub: {}", e))?;
594
595    // NIP-30: resolve any `:shortcode:` in the outbound text against the
596    // user's subscribed packs so the rumor carries `["emoji", ...]` tags.
597    // Recipients without the pack subscribed still render correctly, and
598    // our own-view echo populates `emoji_tags` for the renderer.
599    let emoji_tags = crate::emoji_packs::resolve_outbound_emoji_tags(content);
600
601    // Build pending message and add to state
602    let msg = Message {
603        id: pending_id.clone(),
604        content: content.to_string(),
605        replied_to: reply_to.unwrap_or("").to_string(),
606        at: now.as_millis() as u64,
607        pending: true,
608        mine: true,
609        npub: my_pk.to_bech32().ok(),
610        emoji_tags: emoji_tags.clone(),
611        expiration: config.expiration,
612        ..Default::default()
613    };
614
615    {
616        let mut state = STATE.lock().await;
617        state.add_message_to_participant(receiver_npub, &msg);
618    }
619
620    callback.on_pending(receiver_npub, &msg);
621
622    // Build the rumor
623    let milliseconds = now.as_millis() % 1000;
624    // NIP-17 rumor: kind 14 + recipient p-tag (upstream's `make_rumor` is private).
625    let mut rumor = EventBuilder::new(Kind::PrivateDirectMessage, content)
626        .tag(Tag::public_key(receiver));
627
628    if let Some(reply_id) = reply_to {
629        if !reply_id.is_empty() {
630            rumor = rumor.tag(Tag::custom(
631                "e",
632                [reply_id.to_string(), String::new(), "reply".to_string()],
633            ));
634        }
635    }
636
637    let mut rumor = rumor.tag(Tag::custom("ms", [milliseconds.to_string()]));
638    for et in &emoji_tags {
639        rumor = rumor.tag(Tag::custom(
640            "emoji",
641            [et.shortcode.clone(), et.url.clone()],
642        ));
643    }
644    // NIP-40 self-destruct: stamp the rumor so compliant receivers honor the
645    // expiry; retry_send_gift_wrap mirrors it onto the outer wrap for relays.
646    if let Some(exp) = config.expiration {
647        rumor = rumor.tag(Tag::expiration(Timestamp::from_secs(exp)));
648    }
649    let built_rumor = rumor.finalize_unsigned_with_id(my_pk);
650    let event_id = built_rumor.id.ok_or("Rumor has no id")?.to_hex();
651
652    // Send via gift-wrap with retry
653    retry_send_gift_wrap(
654        &client, &receiver, receiver_npub, &pending_id,
655        built_rumor, &event_id, config, callback, None,
656    ).await
657}
658
659// ============================================================================
660// send_rumor_dm — Pre-built rumor (custom events)
661// ============================================================================
662
663/// Send a pre-built rumor via NIP-17 gift-wrap.
664///
665/// Used when the caller has already built the rumor. Skips encryption/upload.
666pub async fn send_rumor_dm(
667    receiver_npub: &str,
668    pending_id: &str,
669    rumor: UnsignedEvent,
670    config: &SendConfig,
671    callback: Arc<dyn SendCallback>,
672) -> Result<SendResult, String> {
673    let client = nostr_client().ok_or("Not logged in")?;
674
675    let receiver = PublicKey::from_bech32(receiver_npub)
676        .map_err(|e| format!("Invalid npub: {}", e))?;
677
678    let event_id = rumor.id.ok_or("Rumor has no id")?.to_hex();
679
680    retry_send_gift_wrap(
681        &client, &receiver, receiver_npub, pending_id,
682        rumor, &event_id, config, callback, None,
683    ).await
684}
685
686/// Manually retry a failed DM by republishing the EXACT retained recipient
687/// wrap. Same outer event id → relays no-op the duplicate, so a first send
688/// that silently landed can never double-post, regardless of the recipient's
689/// client.
690///
691/// `Ok(true)`  — a retained wrap was found and the resend went through the
692///               normal retry loop (message ends sent, or red again ready to
693///               retry). The caller must NOT fall back.
694/// `Ok(false)` — nothing retained (old/pruned message, or the first send
695///               failed before a wrap was ever built, e.g. an upload error).
696///               The caller falls back to a fresh send — safe, since no wrap
697///               reached a relay in that case.
698pub async fn resend_failed_dm(
699    receiver_npub: &str,
700    failed_msg_id: &str,
701    config: &SendConfig,
702    callback: Arc<dyn SendCallback>,
703) -> Result<bool, String> {
704    // Guard the read→flip→republish against a mid-tap account swap: the payload
705    // is this account's; a swap before the STATE flip must abort (returning
706    // "handled" so the caller never falls back to a fresh send in the WRONG
707    // account). retry_send_gift_wrap re-guards its own STATE/DB writes.
708    let session = crate::state::SessionGuard::capture();
709    let payload = match crate::db::nip17_keys::get_resend_payload_by_pending(failed_msg_id)? {
710        Some(p) => p,
711        None => return Ok(false),
712    };
713    let client = nostr_client().ok_or("Not logged in")?;
714    let receiver = PublicKey::from_bech32(receiver_npub)
715        .map_err(|e| format!("Invalid npub: {}", e))?;
716    if !session.is_valid() {
717        return Ok(true);
718    }
719
720    // Flip the red row back to "sending"; the retry loop's own fail/finalize
721    // path returns it to red or promotes it to sent.
722    let repending = {
723        let mut state = STATE.lock().await;
724        state.update_message(failed_msg_id, |msg| {
725            msg.set_failed(false);
726            msg.set_pending(true);
727        })
728    };
729    if let Some((_chat_id, ref msg)) = repending {
730        callback.on_pending(receiver_npub, msg);
731    }
732
733    let event_id = payload.rumor.id.ok_or("Retained rumor has no id")?.to_hex();
734    let built = crate::inbox_relays::BuiltGiftWrap {
735        event: payload.wrap_event,
736        secret: payload.secret,
737    };
738    // Inject the retained wrap: the loop republishes these exact bytes.
739    if let Err(e) = retry_send_gift_wrap(
740        &client, &receiver, receiver_npub, failed_msg_id,
741        payload.rumor, &event_id, config, callback, Some(built),
742    )
743    .await
744    {
745        // The resend was attempted — the loop already marked the row red again.
746        // Still "handled": the caller must not fall back to a fresh wrap, or the
747        // retained wrap that may yet be sitting on a relay would double-post.
748        crate::log_warn!("[Send] idempotent resend of {} did not land: {}", failed_msg_id, e);
749    }
750    Ok(true)
751}
752
753// ============================================================================
754// send_file_dm — File Attachment DMs
755// ============================================================================
756
757/// Send a NIP-17 gift-wrapped file attachment DM.
758///
759/// Flow: hash → save locally → encrypt → upload → build Kind 15 rumor → gift-wrap + send.
760pub async fn send_file_dm(
761    receiver_npub: &str,
762    file_bytes: Arc<Vec<u8>>,
763    filename: &str,
764    extension: &str,
765    content: Option<&str>,
766    config: &SendConfig,
767    callback: Arc<dyn SendCallback>,
768) -> Result<SendResult, String> {
769    let client = nostr_client().ok_or("Not logged in")?;
770    let my_pk = my_public_key().ok_or("Public key not set")?;
771    // Sign the Blossom auth event via the active client signer so bunker
772    // accounts route through NostrConnect (the user's identity key lives on
773    // the remote signer; MY_SECRET_KEY only holds the NIP-46 client key).
774    let signer = crate::signer::active_signer()
775        .map_err(|e| format!("Signer unavailable: {}", e))?;
776
777    let now = std::time::SystemTime::now()
778        .duration_since(std::time::UNIX_EPOCH).unwrap();
779    let pending_id = format!("pending-{}", now.as_nanos());
780    let milliseconds = now.as_millis() % 1000;
781
782    let receiver = PublicKey::from_bech32(receiver_npub)
783        .map_err(|e| format!("Invalid npub: {}", e))?;
784
785    let file_hash = crypto::sha256_hex(&file_bytes);
786    let mime_type = crypto::mime_from_extension(extension);
787
788    // WebXDC Mini Apps: mint the realtime-channel topic at send time and carry
789    // it on the rumor — locally-derived topics are asymmetric in DMs (each
790    // side's chat_id is the other party's npub), splitting players onto
791    // disjoint gossip topics.
792    let webxdc_topic = (extension.eq_ignore_ascii_case("xdc"))
793        .then(|| crate::webxdc::mint_topic_id(&file_hash, &my_pk.to_hex()));
794
795    // Save file locally so the attachment is immediately viewable
796    let download_dir = crate::db::get_download_dir();
797    let _ = std::fs::create_dir_all(&download_dir);
798    // Save with an extension matching the actual content. The caller's
799    // `extension` argument is post-compression (e.g. JPEG when an
800    // original PNG was compressed), but `filename` is the user-facing
801    // name which may still carry the pre-compression extension. If we
802    // honored `filename` verbatim we'd save JPEG bytes as `.png` and
803    // poison any future re-upload with a MIP-04 mismatch.
804    let local_name = if filename.is_empty() {
805        format!("{}.{}", &file_hash, extension)
806    } else {
807        let stem = filename.rsplit_once('.').map(|(s, _)| s).unwrap_or(filename);
808        format!("{}.{}", stem, extension)
809    };
810    // Resolve unique path (pasted_image.png → pasted_image-1.png on collision)
811    let local_path = crypto::resolve_unique_filename(&download_dir, &local_name);
812    // Atomic write: temp file then rename
813    let tmp = download_dir.join(format!(".{}.tmp", &file_hash));
814    let _ = std::fs::write(&tmp, &*file_bytes);
815    let _ = std::fs::rename(&tmp, &local_path);
816    let local_path_str = local_path.to_string_lossy().to_string();
817
818    // === Generate image metadata (thumbhash + dimensions) for image files ===
819    let img_meta = crypto::generate_image_metadata(&file_bytes);
820
821    // === Encrypt → upload → build rumor → send ===
822    let params = crypto::generate_encryption_params();
823    let encrypted = crypto::encrypt_data(&file_bytes, &params)?;
824    let encrypted_size = encrypted.len() as u64;
825
826    let attachment = Attachment {
827        id: file_hash.clone(), key: params.key.clone(), nonce: params.nonce.clone(),
828        extension: extension.to_string(), name: filename.to_string(),
829        url: String::new(), path: local_path_str.clone(), size: encrypted_size,
830        img_meta: img_meta.clone(), downloading: false, downloaded: true,
831        webxdc_topic: webxdc_topic.clone(),
832        ..Default::default()
833    };
834    let msg = Message {
835        id: pending_id.clone(), content: content.unwrap_or("").to_string(),
836        at: now.as_millis() as u64, pending: true, mine: true,
837        npub: my_pk.to_bech32().ok(), attachments: vec![attachment],
838        expiration: config.expiration,
839        ..Default::default()
840    };
841    {
842        let mut state = STATE.lock().await;
843        state.add_message_to_participant(receiver_npub, &msg);
844    }
845    callback.on_pending(receiver_npub, &msg);
846
847    // Upload to Blossom — bridge SendCallback.on_upload_progress to Blossom ProgressCallback
848    let servers = crate::state::get_blossom_servers();
849    let cb_for_progress = callback.clone();
850    let pid_for_progress = pending_id.clone();
851    let progress_cb: crate::blossom::ProgressCallback = Arc::new(move |percentage, bytes| {
852        cb_for_progress.on_upload_progress(
853            &pid_for_progress,
854            percentage.unwrap_or(0),
855            bytes.unwrap_or(0),
856        )
857    });
858
859    // Send the original MIME even though bytes are ciphertext: many
860    // Blossom servers reject `application/octet-stream` but accept the
861    // same bytes under their original type.
862    let upload_url = match crate::blossom::upload_blob_with_progress_and_failover(
863        signer.clone(), servers, Arc::new(encrypted), Some(mime_type),
864        /* is_encrypted */ true,
865        progress_cb, Some(config.upload_retries), Some(config.upload_retry_delay),
866        config.cancel_token.clone(),
867    ).await {
868        Ok(url) => url,
869        Err(e) => {
870            let failed_msg = {
871                let mut state = STATE.lock().await;
872                state.update_message(&pending_id, |msg| {
873                    msg.set_failed(true);
874                    msg.set_pending(false);
875                })
876            };
877            if let Some((_chat_id, ref msg)) = failed_msg {
878                callback.on_failed(receiver_npub, &pending_id, msg);
879                callback.on_persist(receiver_npub, msg);
880            }
881            return Err(format!("Upload failed: {}", e));
882        }
883    };
884
885    {
886        let mut state = STATE.lock().await;
887        state.update_message(&pending_id, |msg| {
888            if let Some(att) = msg.attachments.last_mut() {
889                att.url = upload_url.clone().into_boxed_str();
890            }
891        });
892    }
893    callback.on_upload_complete(receiver_npub, &pending_id, &file_hash, &upload_url);
894
895    // BUD-04 mirror fan-out: bounded, best-effort. Verified mirrors ride the
896    // rumor as NIP-17 `fallback` tags so the message survives its primary
897    // host dying later; zero mirrors never delays or fails the send.
898    let mirror_urls = crate::blossom::mirror_blob_to_servers(
899        signer.clone(),
900        &upload_url,
901        crate::state::get_blossom_servers(),
902        2,
903        std::time::Duration::from_secs(5),
904    ).await;
905    if !mirror_urls.is_empty() {
906        let mut state = STATE.lock().await;
907        state.update_message(&pending_id, |msg| {
908            if let Some(att) = msg.attachments.last_mut() {
909                att.fallback_urls = Some(mirror_urls.iter().map(|s| s.as_str().into()).collect());
910            }
911        });
912    }
913
914    // Build Kind 15
915    let mut file_rumor = EventBuilder::new(Kind::from_u16(15), &upload_url)
916        .tag(Tag::public_key(receiver))
917        .tag(Tag::custom("file-type", [mime_type]))
918        .tag(Tag::custom("size", [encrypted_size.to_string()]))
919        .tag(Tag::custom("encryption-algorithm", ["aes-gcm"]))
920        .tag(Tag::custom("decryption-key", [params.key.as_str()]))
921        .tag(Tag::custom("decryption-nonce", [params.nonce.as_str()]))
922        .tag(Tag::custom("ox", [file_hash.clone()]));
923    for fb in &mirror_urls {
924        file_rumor = file_rumor.tag(Tag::custom("fallback", [fb.as_str()]));
925    }
926    if !filename.is_empty() {
927        file_rumor = file_rumor.tag(Tag::custom("name", [filename]));
928    }
929    if let Some(ref topic) = webxdc_topic {
930        file_rumor = file_rumor.tag(Tag::custom("webxdc-topic", [topic.as_str()]));
931    }
932    // Include image preview metadata for compatible rendering across all clients
933    if let Some(ref meta) = img_meta {
934        if !meta.thumbhash.is_empty() {
935            // `thumbhash` names the value accurately; receivers read `thumb` too
936            // (legacy), so this stays backward-compatible in both directions.
937            file_rumor = file_rumor.tag(Tag::custom("thumbhash", [meta.thumbhash.as_str()]));
938        }
939        file_rumor = file_rumor.tag(Tag::custom("dim", [format!("{}x{}", meta.width, meta.height)]));
940    }
941    file_rumor = file_rumor.tag(Tag::custom("ms", [milliseconds.to_string()]));
942    if let Some(exp) = config.expiration {
943        file_rumor = file_rumor.tag(Tag::expiration(Timestamp::from_secs(exp)));
944    }
945
946    let built_rumor = file_rumor.finalize_unsigned_with_id(my_pk);
947    let event_id = built_rumor.id.ok_or("Rumor has no id")?.to_hex();
948
949    retry_send_gift_wrap(
950        &client, &receiver, receiver_npub, &pending_id,
951        built_rumor, &event_id, config, callback, None,
952    ).await
953}
954
955// ============================================================================
956// Tests
957// ============================================================================
958
959#[cfg(test)]
960mod tests {
961    use super::*;
962    use std::sync::Mutex;
963
964    #[derive(Debug, Clone, PartialEq)]
965    enum CbEvent {
966        Pending(String),
967        UploadProgress(String, u8, u64),
968        UploadComplete(String, String),
969        Sent(String, String),
970        Failed(String, String),
971        Persist(String),
972    }
973
974    struct MockCallback {
975        events: Mutex<Vec<CbEvent>>,
976        cancel_at: Option<u8>,
977    }
978
979    impl MockCallback {
980        fn new() -> Self { Self { events: Mutex::new(vec![]), cancel_at: None } }
981        fn with_cancel(pct: u8) -> Self { Self { events: Mutex::new(vec![]), cancel_at: Some(pct) } }
982        fn events(&self) -> Vec<CbEvent> { self.events.lock().unwrap().clone() }
983    }
984
985    impl SendCallback for MockCallback {
986        fn on_pending(&self, cid: &str, _: &Message) {
987            self.events.lock().unwrap().push(CbEvent::Pending(cid.into()));
988        }
989        fn on_upload_progress(&self, pid: &str, pct: u8, bytes: u64) -> Result<(), String> {
990            self.events.lock().unwrap().push(CbEvent::UploadProgress(pid.into(), pct, bytes));
991            if self.cancel_at.map_or(false, |c| pct >= c) { return Err("Cancelled".into()); }
992            Ok(())
993        }
994        fn on_upload_complete(&self, cid: &str, _: &str, _: &str, url: &str) {
995            self.events.lock().unwrap().push(CbEvent::UploadComplete(cid.into(), url.into()));
996        }
997        fn on_sent(&self, cid: &str, old: &str, _: &Message) {
998            self.events.lock().unwrap().push(CbEvent::Sent(cid.into(), old.into()));
999        }
1000        fn on_failed(&self, cid: &str, old: &str, _: &Message) {
1001            self.events.lock().unwrap().push(CbEvent::Failed(cid.into(), old.into()));
1002        }
1003        fn on_persist(&self, cid: &str, _: &Message) {
1004            self.events.lock().unwrap().push(CbEvent::Persist(cid.into()));
1005        }
1006    }
1007
1008    #[test]
1009    fn config_default() {
1010        let c = SendConfig::default();
1011        assert_eq!(c.max_send_attempts, 1);
1012        assert!(!c.self_send);
1013        assert!(c.cancel_token.is_none());
1014        assert_eq!(c.upload_retries, 3);
1015    }
1016
1017    #[test]
1018    fn config_gui() {
1019        let c = SendConfig::gui();
1020        assert_eq!(c.max_send_attempts, 12);
1021        assert!(c.self_send);
1022    }
1023
1024    #[test]
1025    fn config_headless() {
1026        let c = SendConfig::headless();
1027        assert_eq!(c.max_send_attempts, 3);
1028        assert!(c.self_send);
1029    }
1030
1031    #[test]
1032    fn config_custom_override() {
1033        let c = SendConfig { max_send_attempts: 5, ..SendConfig::gui() };
1034        assert_eq!(c.max_send_attempts, 5);
1035        assert!(c.self_send);
1036    }
1037
1038    #[test]
1039    fn noop_callback_all_methods() {
1040        let cb = NoOpSendCallback;
1041        let msg = Message::default();
1042        cb.on_pending("c", &msg);
1043
1044        assert!(cb.on_upload_progress("p", 50, 1024).is_ok());
1045        cb.on_upload_complete("c", "p", "a", "url");
1046        cb.on_sent("c", "o", &msg);
1047        cb.on_failed("c", "o", &msg);
1048        cb.on_persist("c", &msg);
1049    }
1050
1051    #[test]
1052    fn text_dm_sequence() {
1053        let cb = MockCallback::new();
1054        let msg = Message::default();
1055        cb.on_pending("r", &msg);
1056        cb.on_sent("r", "p-1", &msg);
1057        cb.on_persist("r", &msg);
1058        assert_eq!(cb.events(), vec![
1059            CbEvent::Pending("r".into()),
1060            CbEvent::Sent("r".into(), "p-1".into()),
1061            CbEvent::Persist("r".into()),
1062        ]);
1063    }
1064
1065    #[test]
1066    fn file_dm_sequence() {
1067        let cb = MockCallback::new();
1068        let msg = Message::default();
1069        cb.on_pending("r", &msg);
1070
1071        cb.on_upload_progress("p", 0, 0).ok();
1072        cb.on_upload_progress("p", 50, 5000).ok();
1073        cb.on_upload_progress("p", 100, 10000).ok();
1074        cb.on_upload_complete("r", "p", "h", "https://blossom/h");
1075        cb.on_sent("r", "p", &msg);
1076        cb.on_persist("r", &msg);
1077        let e = cb.events();
1078        assert_eq!(e.len(), 7);
1079        assert!(matches!(&e[4], CbEvent::UploadComplete(_, url) if url.contains("blossom")));
1080    }
1081
1082    #[test]
1083    fn failed_sequence() {
1084        let cb = MockCallback::new();
1085        let msg = Message::default();
1086        cb.on_pending("r", &msg);
1087        cb.on_failed("r", "p", &msg);
1088        cb.on_persist("r", &msg);
1089        assert_eq!(cb.events(), vec![
1090            CbEvent::Pending("r".into()),
1091            CbEvent::Failed("r".into(), "p".into()),
1092            CbEvent::Persist("r".into()),
1093        ]);
1094    }
1095
1096    #[test]
1097    fn cancel_upload_at_threshold() {
1098        let cb = MockCallback::with_cancel(50);
1099        assert!(cb.on_upload_progress("p", 25, 512).is_ok());
1100        assert!(cb.on_upload_progress("p", 50, 1024).is_err());
1101        assert_eq!(cb.events().len(), 2);
1102    }
1103
1104    #[test]
1105    fn cancel_triggers_failed() {
1106        let cb = MockCallback::with_cancel(30);
1107        let msg = Message::default();
1108        cb.on_pending("r", &msg);
1109
1110        cb.on_upload_progress("p", 10, 1000).ok();
1111        assert!(cb.on_upload_progress("p", 30, 3000).is_err());
1112        cb.on_failed("r", "p", &msg);
1113        assert!(matches!(cb.events().last(), Some(CbEvent::Failed(..))));
1114    }
1115
1116    #[test]
1117    fn send_result_serialize() {
1118        let r = SendResult { pending_id: "p".into(), event_id: Some("e".into()), chat_id: "c".into() };
1119        let j = serde_json::to_string(&r).unwrap();
1120        assert!(j.contains("\"pending_id\":\"p\""));
1121    }
1122
1123    #[test]
1124    fn send_result_none_event() {
1125        let r = SendResult { pending_id: "p".into(), event_id: None, chat_id: "c".into() };
1126        let j = serde_json::to_string(&r).unwrap();
1127        assert!(j.contains("null"));
1128    }
1129
1130    // ========================================================================
1131    // File DM callback sequences
1132    // ========================================================================
1133
1134    #[test]
1135    fn file_dm_fresh_upload_full_sequence() {
1136        let cb = MockCallback::new();
1137        let msg = Message::default();
1138
1139        // 1. Pending message created
1140        cb.on_pending("npub1recv", &msg);
1141        // 2. Attachment preview added
1142
1143        // 3. Upload progress (0% → 25% → 50% → 75% → 100%)
1144        cb.on_upload_progress("pending-42", 0, 0).unwrap();
1145        cb.on_upload_progress("pending-42", 25, 2500).unwrap();
1146        cb.on_upload_progress("pending-42", 50, 5000).unwrap();
1147        cb.on_upload_progress("pending-42", 75, 7500).unwrap();
1148        cb.on_upload_progress("pending-42", 100, 10000).unwrap();
1149        // 4. Upload complete
1150        cb.on_upload_complete("npub1recv", "pending-42", "deadbeef", "https://blossom.example/deadbeef");
1151        // 5. Gift-wrap sent successfully
1152        cb.on_sent("npub1recv", "pending-42", &msg);
1153        // 6. Persisted to DB
1154        cb.on_persist("npub1recv", &msg);
1155
1156        let e = cb.events();
1157        assert_eq!(e.len(), 9);
1158        assert!(matches!(&e[0], CbEvent::Pending(c) if c == "npub1recv"));
1159        assert!(matches!(&e[1], CbEvent::UploadProgress(_, 0, 0)));
1160        assert!(matches!(&e[5], CbEvent::UploadProgress(_, 100, 10000)));
1161        assert!(matches!(&e[6], CbEvent::UploadComplete(_, url) if url.contains("deadbeef")));
1162        assert!(matches!(&e[7], CbEvent::Sent(..)));
1163        assert!(matches!(&e[8], CbEvent::Persist(..)));
1164    }
1165
1166    #[test]
1167    fn file_dm_skip_upload_sequence() {
1168        let cb = MockCallback::new();
1169        let msg = Message::default();
1170
1171        // Dedup hit: no upload, existing URL reused
1172        // 1. Pending message
1173        cb.on_pending("npub1recv", &msg);
1174        // 2. Attachment preview (with reused URL already set)
1175
1176        // 3. Upload complete (immediate — URL was already known)
1177        cb.on_upload_complete("npub1recv", "pending-99", "existinghash", "https://blossom.example/existing");
1178        // 4. Gift-wrap sent
1179        cb.on_sent("npub1recv", "pending-99", &msg);
1180        // 5. Persisted
1181        cb.on_persist("npub1recv", &msg);
1182
1183        let e = cb.events();
1184        assert_eq!(e.len(), 4);
1185        // No UploadProgress events — upload was skipped
1186        assert!(!e.iter().any(|ev| matches!(ev, CbEvent::UploadProgress(..))));
1187        assert!(matches!(&e[1], CbEvent::UploadComplete(..)));
1188    }
1189
1190    #[test]
1191    fn file_dm_upload_cancelled_at_30pct() {
1192        let cb = MockCallback::with_cancel(30);
1193        let msg = Message::default();
1194
1195        cb.on_pending("npub1recv", &msg);
1196
1197        assert!(cb.on_upload_progress("p", 10, 1000).is_ok());
1198        assert!(cb.on_upload_progress("p", 20, 2000).is_ok());
1199        // Cancel triggers at 30%
1200        let err = cb.on_upload_progress("p", 30, 3000);
1201        assert!(err.is_err());
1202        assert!(err.unwrap_err().contains("Cancelled"));
1203        // Pipeline marks as failed
1204        cb.on_failed("npub1recv", "p", &msg);
1205
1206        let e = cb.events();
1207        assert_eq!(e.len(), 5);
1208        // No Sent, no Persist after cancel — just Failed
1209        assert!(!e.iter().any(|ev| matches!(ev, CbEvent::Sent(..))));
1210        assert!(matches!(e.last(), Some(CbEvent::Failed(..))));
1211    }
1212
1213    #[test]
1214    fn file_dm_upload_fails_marks_failed() {
1215        let cb = MockCallback::new();
1216        let msg = Message::default();
1217
1218        cb.on_pending("npub1recv", &msg);
1219
1220        cb.on_upload_progress("p", 0, 0).ok();
1221        cb.on_upload_progress("p", 10, 500).ok();
1222        // Upload fails (server error, all retries exhausted)
1223        cb.on_failed("npub1recv", "p", &msg);
1224        cb.on_persist("npub1recv", &msg);
1225
1226        let e = cb.events();
1227        assert_eq!(e.len(), 5);
1228        assert!(matches!(&e[3], CbEvent::Failed(..)));
1229        assert!(matches!(&e[4], CbEvent::Persist(..)));
1230        // No UploadComplete, no Sent
1231        assert!(!e.iter().any(|ev| matches!(ev, CbEvent::UploadComplete(..))));
1232        assert!(!e.iter().any(|ev| matches!(ev, CbEvent::Sent(..))));
1233    }
1234
1235    #[test]
1236    fn file_dm_gift_wrap_fails_after_upload() {
1237        let cb = MockCallback::new();
1238        let msg = Message::default();
1239
1240        // Upload succeeds but gift-wrap fails
1241        cb.on_pending("npub1recv", &msg);
1242
1243        cb.on_upload_progress("p", 100, 10000).ok();
1244        cb.on_upload_complete("npub1recv", "p", "hash", "https://blossom/hash");
1245        // Gift-wrap retry exhausted
1246        cb.on_failed("npub1recv", "p", &msg);
1247        cb.on_persist("npub1recv", &msg);
1248
1249        let e = cb.events();
1250        assert_eq!(e.len(), 5);
1251        // Upload succeeded but send failed
1252        assert!(matches!(&e[2], CbEvent::UploadComplete(..)));
1253        assert!(matches!(&e[3], CbEvent::Failed(..)));
1254    }
1255
1256    #[test]
1257    fn file_dm_with_image_metadata_sequence() {
1258        let cb = MockCallback::new();
1259        let msg = Message::default();
1260
1261        // Image with thumbhash + dimensions
1262        cb.on_pending("npub1recv", &msg);
1263
1264        cb.on_upload_progress("p", 0, 0).ok();
1265        cb.on_upload_progress("p", 50, 50000).ok();
1266        cb.on_upload_progress("p", 100, 100000).ok();
1267        cb.on_upload_complete("npub1recv", "p", "imghash", "https://blossom/imghash.jpg");
1268        cb.on_sent("npub1recv", "p", &msg);
1269        cb.on_persist("npub1recv", &msg);
1270
1271        let e = cb.events();
1272        assert_eq!(e.len(), 7);
1273        // Verify ordering: pending → progress(3x) → complete → sent → persist
1274        assert!(matches!(&e[0], CbEvent::Pending(..)));
1275        assert!(matches!(&e[4], CbEvent::UploadComplete(_, url) if url.ends_with(".jpg")));
1276        assert!(matches!(&e[5], CbEvent::Sent(..)));
1277    }
1278
1279    #[test]
1280    fn cancel_token_config_with_upload() {
1281        let token = Arc::new(std::sync::atomic::AtomicBool::new(false));
1282        let c = SendConfig {
1283            cancel_token: Some(token.clone()),
1284            ..SendConfig::gui()
1285        };
1286        assert!(c.cancel_token.is_some());
1287        assert!(!token.load(std::sync::atomic::Ordering::Relaxed));
1288
1289        // Simulate cancel
1290        token.store(true, std::sync::atomic::Ordering::Relaxed);
1291        assert!(c.cancel_token.as_ref().unwrap().load(std::sync::atomic::Ordering::Relaxed));
1292    }
1293}