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