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