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    /// The flag a cancel flips for this pending message, if the client keeps one.
49    /// The uploader polls it between progress ticks, so a cancel lands at once
50    /// even while nothing is moving.
51    fn cancel_token(&self, _pending_id: &str) -> Option<Arc<AtomicBool>> {
52        None
53    }
54
55    /// File upload progress. Return Err("...") to cancel the upload.
56    fn on_upload_progress(
57        &self,
58        _pending_id: &str,
59        _percentage: u8,
60        _bytes_sent: u64,
61    ) -> Result<(), String> {
62        Ok(())
63    }
64
65    /// Upload complete, attachment URL now available.
66    fn on_upload_complete(&self, _chat_id: &str, _pending_id: &str, _attachment_id: &str, _url: &str) {}
67    /// What an upload is doing before its bytes move: sealing (`"encrypting"`, with its
68    /// percentage when it is streamed) or getting underway (`"starting"`).
69    fn on_upload_stage(&self, _pending_id: &str, _stage: &str, _pct: Option<u8>) {}
70
71    /// Message successfully delivered to at least one relay.
72    /// `old_id` is the pending ID, `msg` has the real event ID.
73    fn on_sent(&self, _chat_id: &str, _old_id: &str, _msg: &Message) {}
74
75    /// Message delivery failed after all retry attempts.
76    fn on_failed(&self, _chat_id: &str, _old_id: &str, _msg: &Message) {}
77
78    /// Persist message to database. Default is no-op.
79    /// Tauri implements this to call save_message + save_slim_chat.
80    fn on_persist(&self, _chat_id: &str, _msg: &Message) {}
81}
82
83/// No-op callback for headless/CLI/test use.
84pub struct NoOpSendCallback;
85impl SendCallback for NoOpSendCallback {}
86
87// ============================================================================
88// SendConfig — Per-call configuration
89// ============================================================================
90
91/// Configuration for a send operation.
92pub struct SendConfig {
93    /// Max gift-wrap send attempts (default: 1).
94    pub max_send_attempts: u32,
95    /// Delay between send retries (default: 5 seconds).
96    pub retry_delay: std::time::Duration,
97    /// Send copy to own inbox for recovery/sync (default: false).
98    pub self_send: bool,
99    /// Cancel token for file uploads — set to true to abort.
100    pub cancel_token: Option<Arc<AtomicBool>>,
101    /// Max Blossom upload retries per server (default: 3).
102    pub upload_retries: u32,
103    /// Delay between upload retries (default: 2 seconds).
104    pub upload_retry_delay: std::time::Duration,
105    /// NIP-40 self-destruct expiry (unix secs) to stamp on the outgoing rumor
106    /// and mirror onto the outer wrap. None = permanent. Resolved per-chat by
107    /// the caller (the "Self-Destruct Timer" setting).
108    pub expiration: Option<u64>,
109    /// A self-destruct LIFESPAN (secs) resolved at publish instead: a file send
110    /// stamps `now + lifespan` once its upload is done, so a slow upload does not
111    /// eat into the message's life. Takes precedence over `expiration` there.
112    pub self_destruct_secs: Option<u64>,
113}
114
115impl Default for SendConfig {
116    fn default() -> Self {
117        Self {
118            max_send_attempts: 1,
119            retry_delay: std::time::Duration::from_secs(5),
120            self_send: false,
121            cancel_token: None,
122            upload_retries: 3,
123            upload_retry_delay: std::time::Duration::from_secs(2),
124            expiration: None,
125            self_destruct_secs: None,
126        }
127    }
128}
129
130impl SendConfig {
131    /// Preset for GUI clients (12 retries, self-send enabled).
132    pub fn gui() -> Self {
133        Self {
134            max_send_attempts: 12,
135            self_send: true,
136            ..Default::default()
137        }
138    }
139
140    /// Preset for headless/background mode (3 retries, self-send enabled).
141    pub fn headless() -> Self {
142        Self {
143            max_send_attempts: 3,
144            self_send: true,
145            ..Default::default()
146        }
147    }
148}
149
150// ============================================================================
151// SendResult
152// ============================================================================
153
154/// Result of sending a message.
155#[derive(serde::Serialize, Clone, Debug)]
156pub struct SendResult {
157    /// The pending ID used while sending
158    pub pending_id: String,
159    /// The real event ID after successful send (None if failed)
160    pub event_id: Option<String>,
161    /// The chat ID (receiver npub for DMs)
162    pub chat_id: String,
163}
164
165// ============================================================================
166// Late-OK confirmation registry
167// ============================================================================
168//
169// A relay's OK can outlive the per-attempt wait: slow links push the
170// round-trip past nostr-sdk's OK timeout, and mobile handovers drop the
171// socket after the EVENT frame was already delivered. The wrap is then
172// on the relay while the sender believes it failed — the user re-sends
173// and the recipient sees a double-post.
174//
175// Every wrap publish registers here before its first attempt. Hosts feed
176// relay OKs back via `note_relay_ok` from their notification loop; an
177// accepted OK counts as delivery no matter how late it arrives — it wakes
178// the in-flight retry loop early, or rescues a message already marked
179// Failed back to Sent.
180
181struct WrapConfirm {
182    wrap_id: EventId,
183    chat_id: String,
184    pending_id: String,
185    /// Inner rumor id — the message's final id after finalization.
186    rumor_event_id: String,
187    rumor: UnsignedEvent,
188    callback: Arc<dyn SendCallback>,
189    self_send: bool,
190    confirmed: AtomicBool,
191    /// Claimed by whichever path (retry loop or note_relay_ok) performs
192    /// the failed→sent rescue, so it happens exactly once.
193    rescued: AtomicBool,
194    /// Set once the retry loop has exited after marking the message
195    /// failed — from then on `note_relay_ok` performs the rescue itself.
196    loop_exited: AtomicBool,
197    notify: tokio::sync::Notify,
198    session: std::sync::Arc<crate::db::Session>,
199    registered_at: web_time::Instant,
200}
201
202/// Entries carry this account's chat and message ids, so a late OK must only
203/// ever rescue a message in the account that sent it.
204struct WrapConfirms;
205
206fn wrap_confirms() -> Arc<std::sync::Mutex<std::collections::HashMap<EventId, Arc<WrapConfirm>>>> {
207    crate::db::current_session().scoped::<WrapConfirms, _>()
208}
209
210/// An OK this long after the last publish attempt is a ghost — drop the
211/// entry rather than resurrect a message the user has moved past.
212const WRAP_CONFIRM_TTL: std::time::Duration = std::time::Duration::from_secs(15 * 60);
213
214fn register_wrap_confirm(entry: Arc<WrapConfirm>) {
215    let owner = wrap_confirms();
216    let mut map = owner.lock().unwrap();
217    map.retain(|_, e| e.registered_at.elapsed() < WRAP_CONFIRM_TTL);
218    map.insert(entry.wrap_id, entry);
219}
220
221fn remove_wrap_confirm(wrap_id: &EventId) {
222    wrap_confirms().lock().unwrap().remove(wrap_id);
223}
224
225/// Feed a relay `OK` for an outbound event back into the send pipeline.
226///
227/// Hosts call this from their notification loop for every
228/// `RelayMessage::Ok`. Ids that aren't in-flight wraps miss the registry
229/// and return immediately.
230pub fn note_relay_ok(event_id: &EventId, accepted: bool) {
231    if !accepted {
232        return;
233    }
234    let entry = {
235        let owner = wrap_confirms();
236        let map = owner.lock().unwrap();
237        map.get(event_id).cloned()
238    };
239    let Some(entry) = entry else { return };
240    entry.confirmed.store(true, Ordering::SeqCst);
241    entry.notify.notify_one();
242    if !entry.loop_exited.load(Ordering::SeqCst)
243        || entry.rescued.swap(true, Ordering::SeqCst)
244    {
245        return;
246    }
247    if !entry.session.is_live() {
248        remove_wrap_confirm(&entry.wrap_id);
249        return;
250    }
251    crate::db::spawn_bound(async move {
252        rescue_failed_as_sent(&entry).await;
253        remove_wrap_confirm(&entry.wrap_id);
254    });
255}
256
257/// Flip an already-failed message back to Sent — a late relay OK proved
258/// the wrap was delivered.
259async fn rescue_failed_as_sent(entry: &WrapConfirm) {
260    let finalized = {
261        let mut state = STATE.lock().await;
262        state.update_message(&entry.pending_id, |msg| {
263            msg.set_failed(false);
264        });
265        state.finalize_pending_message(&entry.chat_id, &entry.pending_id, &entry.rumor_event_id)
266    };
267    let Some((_old_id, ref msg)) = finalized else { return };
268    crate::log_info!(
269        "[Send] late relay OK confirmed wrap {} — message {} rescued to sent",
270        entry.wrap_id,
271        entry.rumor_event_id,
272    );
273    entry.callback.on_sent(&entry.chat_id, &entry.pending_id, msg);
274    entry.callback.on_persist(&entry.chat_id, msg);
275    // A late OK proved delivery — drop the retained resend body.
276    if let Some(rid) = entry.rumor.id {
277        let _ = crate::db::nip17_keys::clear_resend_payload(&rid);
278    }
279    if entry.self_send {
280        if let (Some(client), Some(my_pk)) = (nostr_client(), my_public_key()) {
281            spawn_self_send(client, my_pk, entry.rumor.clone());
282        }
283    }
284}
285
286/// Fire-and-forget the self-send recovery copy + persist its wrap key.
287fn spawn_self_send(client: Client, my_pk: PublicKey, rumor: UnsignedEvent) {
288    let rid_for_self = rumor.id;
289    crate::db::spawn_bound(async move {
290        match crate::inbox_relays::send_gift_wrap_retained(
291            &client, &my_pk, rumor, [],
292        ).await {
293            Ok(self_outcome) if !self_outcome.output.success.is_empty() => {
294                if let Some(rid) = rid_for_self {
295                    if let Err(e) = crate::db::nip17_keys::store_wrap_key(
296                        &self_outcome.wrap_event_id,
297                        &rid,
298                        &my_pk,
299                        crate::db::nip17_keys::WrapRole::SelfSend,
300                        &self_outcome.wrap_secret,
301                        &self_outcome.targeted_relays,
302                    ) {
303                        eprintln!("[NIP-17] failed to persist self-wrap key: {}", e);
304                    }
305                }
306            }
307            _ => {}
308        }
309    });
310}
311
312// ============================================================================
313// Internal: retry gift-wrap send
314// ============================================================================
315
316/// Shared tail of send_dm / send_file_dm / send_rumor_dm:
317/// gift-wrap → retry loop → finalize/fail → self-send.
318///
319/// The wrap is built ONCE and the identical event republished on every
320/// attempt: a relay that already stored it answers the resend with
321/// OK-true "duplicate", so a lost OK becomes a delivery confirmation on
322/// the next attempt instead of an unconfirmed extra copy. The wrap's
323/// ephemeral key is persisted via `db::nip17_keys::store_wrap_key`
324/// BEFORE the first publish — a wrap can land without us ever seeing
325/// the OK, and the user must still be able to NIP-09 it later.
326#[allow(clippy::too_many_arguments)]
327async fn retry_send_gift_wrap(
328    client: &Client,
329    receiver: &PublicKey,
330    receiver_npub: &str,
331    pending_id: &str,
332    rumor: UnsignedEvent,
333    event_id: &str,
334    config: &SendConfig,
335    callback: Arc<dyn SendCallback>,
336    // Manual retry injects the retained wrap so the EXACT same event
337    // republishes (relay dedups the duplicate). `None` = a fresh send that
338    // builds + retains the wrap in-loop.
339    prebuilt: Option<crate::inbox_relays::BuiltGiftWrap>,
340) -> Result<SendResult, String> {
341    let my_pk = my_public_key().ok_or("Public key not set")?;
342    let inner_rumor_id = rumor.id;
343
344    // A resend already holds a persisted wrap key + retained body — don't
345    // re-store either (that would reset the row's clock and re-stash bytes).
346    let is_resend = prebuilt.is_some();
347    // Built lazily in-loop so transient signer failures (NIP-46 bunker
348    // round-trips) still get the full retry schedule; a resend seeds it.
349    let mut built: Option<crate::inbox_relays::BuiltGiftWrap> = prebuilt;
350    // Targets resolve once; transient inbox connections live across the
351    // whole retry window rather than reconnecting per attempt.
352    let mut targets: Option<crate::inbox_relays::GiftWrapTargets> = None;
353    let mut confirm: Option<Arc<WrapConfirm>> = None;
354    let mut last_error: Option<String> = None;
355
356    let max_attempts = config.max_send_attempts.max(1);
357
358    for attempt in 0..max_attempts {
359        if built.is_none() {
360            // Mirror any NIP-40 expiry from the rumor onto the outer wrap so
361            // relays drop the gift-wrap on schedule (the inner tag is encrypted).
362            let wrap_extra: Vec<Tag> = rumor.tags.iter()
363                .find(|t| t.as_slice().first().map(|k| k.as_str() == "expiration").unwrap_or(false))
364                .cloned()
365                .into_iter()
366                .collect();
367            match crate::inbox_relays::build_gift_wrap_retained(
368                client, receiver, rumor.clone(), wrap_extra,
369            ).await {
370                Ok(b) => built = Some(b),
371                Err(e) => {
372                    crate::log_warn!(
373                        "[Send] attempt {}/{} — building gift-wrap failed: {}",
374                        attempt + 1, max_attempts, e,
375                    );
376                    last_error = Some(e);
377                    if attempt + 1 < max_attempts {
378                        crate::rt::time::sleep(config.retry_delay).await;
379                    }
380                    continue;
381                }
382            }
383        }
384        let wrap = built.as_ref().unwrap();
385
386        if confirm.is_none() {
387            let entry = Arc::new(WrapConfirm {
388                wrap_id: wrap.event.id,
389                chat_id: receiver_npub.to_string(),
390                pending_id: pending_id.to_string(),
391                rumor_event_id: event_id.to_string(),
392                rumor: rumor.clone(),
393                callback: callback.clone(),
394                self_send: config.self_send,
395                confirmed: AtomicBool::new(false),
396                rescued: AtomicBool::new(false),
397                loop_exited: AtomicBool::new(false),
398                notify: tokio::sync::Notify::new(),
399                session: crate::db::current_session(),
400                registered_at: web_time::Instant::now(),
401            });
402            register_wrap_confirm(entry.clone());
403            confirm = Some(entry);
404        }
405        let confirm_ref = confirm.as_ref().unwrap();
406
407        if let Some(t) = targets.as_ref() {
408            crate::inbox_relays::reconnect_gift_wrap_targets(t).await;
409        } else {
410            let t = crate::inbox_relays::resolve_gift_wrap_targets(client, receiver).await;
411            // First send only: persist the wrap key (NIP-09 delete) AND retain
412            // the exact wrap event + rumor (byte-identical resend on manual
413            // retry). A resend already holds both.
414            if !is_resend {
415                if let Some(rid) = inner_rumor_id {
416                    if let Err(e) = crate::db::nip17_keys::store_wrap_key(
417                        &wrap.event.id,
418                        &rid,
419                        receiver,
420                        crate::db::nip17_keys::WrapRole::Recipient,
421                        &wrap.secret,
422                        &t.targeted_relays,
423                    ) {
424                        eprintln!("[NIP-17] failed to persist wrap key: {}", e);
425                    }
426                    if let Err(e) = crate::db::nip17_keys::stash_resend_payload(
427                        &wrap.event.id, pending_id, &wrap.event, &rumor,
428                    ) {
429                        eprintln!("[NIP-17] failed to retain resend payload: {}", e);
430                    }
431                }
432            }
433            targets = Some(t);
434        }
435        let targets_ref = targets.as_ref().unwrap();
436
437        match crate::inbox_relays::publish_gift_wrap_to_targets(
438            client, targets_ref, &wrap.event,
439        ).await {
440            Ok(output) if !output.success.is_empty() => {
441                return Ok(finalize_gift_wrap_sent(
442                    client, my_pk, receiver_npub, pending_id, event_id,
443                    &rumor, config, &callback, confirm_ref, targets_ref,
444                ).await);
445            }
446            Ok(output) => {
447                // The publish round-trip ran but no targeted relay confirmed
448                // (auth required, kind filter, rate-limit, timed-out OK,
449                // etc.). Surface the per-relay failure reasons so the user
450                // can see WHY their DMs aren't being accepted.
451                let failures: Vec<String> = output.failed.iter()
452                    .map(|(url, err)| format!("{}: {}", url, err))
453                    .collect();
454                crate::log_warn!(
455                    "[Send] attempt {}/{} — 0 of {} relays accepted (targeted: {}). Per-relay errors: {}",
456                    attempt + 1,
457                    max_attempts,
458                    targets_ref.targeted_relays.len(),
459                    targets_ref.targeted_relays.join(", "),
460                    if failures.is_empty() {
461                        "(none reported — likely all timed out before responding)".to_string()
462                    } else {
463                        failures.join(" | ")
464                    },
465                );
466                last_error = None;
467            }
468            Err(e) => {
469                crate::log_warn!(
470                    "[Send] attempt {}/{} — publish errored: {}",
471                    attempt + 1, max_attempts, e,
472                );
473                last_error = Some(e);
474            }
475        }
476
477        // A late OK for an earlier attempt may have arrived while this one
478        // was publishing.
479        if confirm_ref.confirmed.load(Ordering::SeqCst) {
480            return Ok(finalize_gift_wrap_sent(
481                client, my_pk, receiver_npub, pending_id, event_id,
482                &rumor, config, &callback, confirm_ref, targets_ref,
483            ).await);
484        }
485
486        if attempt + 1 < max_attempts {
487            // Sleep out the retry delay, waking instantly on a late OK
488            // (notify_one stores a permit, so an OK landing before this
489            // line still wakes us).
490            let _ = crate::rt::time::timeout(
491                config.retry_delay,
492                confirm_ref.notify.notified(),
493            ).await;
494            if confirm_ref.confirmed.load(Ordering::SeqCst) {
495                return Ok(finalize_gift_wrap_sent(
496                    client, my_pk, receiver_npub, pending_id, event_id,
497                    &rumor, config, &callback, confirm_ref, targets_ref,
498                ).await);
499            }
500        }
501    }
502
503    // Exhausted every attempt with no OK observed. Mark failed, but leave
504    // the confirmation entry armed: a straggler OK can still rescue this
505    // message to Sent (see note_relay_ok).
506    if let Some(t) = targets.as_ref() {
507        crate::inbox_relays::teardown_gift_wrap_targets(client, t).await;
508    }
509    let failed_msg = {
510        let mut state = STATE.lock().await;
511        state.update_message(pending_id, |msg| {
512            msg.set_failed(true);
513            msg.set_pending(false);
514        })
515    };
516    if let Some((_chat_id, ref msg)) = failed_msg {
517        callback.on_failed(receiver_npub, pending_id, msg);
518        callback.on_persist(receiver_npub, msg);
519    }
520    if let Some(entry) = confirm.as_ref() {
521        entry.loop_exited.store(true, Ordering::SeqCst);
522        // An OK that landed between the last attempt and loop_exited going
523        // up would see loop_exited=false and skip its rescue — catch it here.
524        if entry.confirmed.load(Ordering::SeqCst)
525            && !entry.rescued.swap(true, Ordering::SeqCst)
526        {
527            rescue_failed_as_sent(entry).await;
528            remove_wrap_confirm(&entry.wrap_id);
529            return Ok(SendResult {
530                pending_id: pending_id.to_string(),
531                event_id: Some(event_id.to_string()),
532                chat_id: receiver_npub.to_string(),
533            });
534        }
535    }
536    match last_error {
537        Some(e) => Err(format!("Failed to send DM after {} attempts: {}", max_attempts, e)),
538        None => Err(format!(
539            "Failed to send DM after {} attempts (no relays accepted the gift-wrap)",
540            max_attempts
541        )),
542    }
543}
544
545/// Success epilogue shared by every confirmed path in the retry loop:
546/// finalize the pending message, notify, persist, fire the self-send.
547#[allow(clippy::too_many_arguments)]
548async fn finalize_gift_wrap_sent(
549    client: &Client,
550    my_pk: PublicKey,
551    receiver_npub: &str,
552    pending_id: &str,
553    event_id: &str,
554    rumor: &UnsignedEvent,
555    config: &SendConfig,
556    callback: &Arc<dyn SendCallback>,
557    confirm: &Arc<WrapConfirm>,
558    targets: &crate::inbox_relays::GiftWrapTargets,
559) -> SendResult {
560    remove_wrap_confirm(&confirm.wrap_id);
561    crate::inbox_relays::teardown_gift_wrap_targets(client, targets).await;
562
563    let finalized = {
564        let mut state = STATE.lock().await;
565        state.finalize_pending_message(receiver_npub, pending_id, event_id)
566    };
567    if let Some((_old_id, ref finalized_msg)) = finalized {
568        callback.on_sent(receiver_npub, pending_id, finalized_msg);
569        callback.on_persist(receiver_npub, finalized_msg);
570    }
571
572    // Delivery confirmed — drop the retained resend body (the key row stays for
573    // NIP-09). Steady-state, only genuinely-unsent messages carry a body.
574    if let Some(rid) = rumor.id {
575        let _ = crate::db::nip17_keys::clear_resend_payload(&rid);
576    }
577
578    if config.self_send {
579        spawn_self_send(client.clone(), my_pk, rumor.clone());
580    }
581
582    SendResult {
583        pending_id: pending_id.to_string(),
584        event_id: Some(event_id.to_string()),
585        chat_id: receiver_npub.to_string(),
586    }
587}
588
589// ============================================================================
590// send_dm — Text DMs
591// ============================================================================
592
593/// Send a NIP-17 gift-wrapped text DM.
594///
595/// Flow: pending msg → callback.on_pending → build Kind 14 rumor →
596/// gift-wrap with retry → finalize → callback.on_sent → optional self-send.
597pub async fn send_dm(
598    receiver_npub: &str,
599    content: &str,
600    reply_to: Option<&str>,
601    config: &SendConfig,
602    callback: Arc<dyn SendCallback>,
603) -> Result<SendResult, String> {
604    let client = nostr_client().ok_or("Not logged in")?;
605    let my_pk = my_public_key().ok_or("Public key not set")?;
606
607    let now = web_time::SystemTime::now()
608        .duration_since(web_time::UNIX_EPOCH).unwrap();
609    let pending_id = format!("pending-{}", now.as_nanos());
610
611    let receiver = PublicKey::from_bech32(receiver_npub)
612        .map_err(|e| format!("Invalid npub: {}", e))?;
613
614    // NIP-30: resolve any `:shortcode:` in the outbound text against the
615    // user's subscribed packs so the rumor carries `["emoji", ...]` tags.
616    // Recipients without the pack subscribed still render correctly, and
617    // our own-view echo populates `emoji_tags` for the renderer.
618    let emoji_tags = crate::emoji_packs::resolve_outbound_emoji_tags(content);
619
620    // Build pending message and add to state
621    let mut msg = Message {
622        id: pending_id.clone(),
623        content: content.to_string(),
624        replied_to: reply_to.unwrap_or("").to_string(),
625        at: now.as_millis() as u64,
626        pending: true,
627        mine: true,
628        npub: my_pk.to_bech32().ok(),
629        emoji_tags: emoji_tags.clone(),
630        expiration: config.expiration,
631        ..Default::default()
632    };
633    // The quote rides the message itself: the parent may be nowhere in the UI's memory.
634    let _ = crate::db::events::populate_reply_context(&mut msg).await;
635
636    {
637        let mut state = STATE.lock().await;
638        state.add_message_to_participant(receiver_npub, &msg);
639    }
640
641    callback.on_pending(receiver_npub, &msg);
642
643    // Build the rumor
644    let milliseconds = now.as_millis() % 1000;
645    // NIP-17 rumor: kind 14 + recipient p-tag (upstream's `make_rumor` is private).
646    let mut rumor = EventBuilder::new(Kind::PrivateDirectMessage, content)
647        .tag(Tag::public_key(receiver));
648
649    if let Some(reply_id) = reply_to {
650        if !reply_id.is_empty() {
651            rumor = rumor.tag(Tag::custom(
652                "e",
653                [reply_id.to_string(), String::new(), "reply".to_string()],
654            ));
655        }
656    }
657
658    let mut rumor = rumor.tag(Tag::custom("ms", [milliseconds.to_string()]));
659    for et in &emoji_tags {
660        rumor = rumor.tag(Tag::custom(
661            "emoji",
662            [et.shortcode.clone(), et.url.clone()],
663        ));
664    }
665    // NIP-40 self-destruct: stamp the rumor so compliant receivers honor the
666    // expiry; retry_send_gift_wrap mirrors it onto the outer wrap for relays.
667    if let Some(exp) = config.expiration {
668        rumor = rumor.tag(Tag::expiration(Timestamp::from_secs(exp)));
669    }
670    let built_rumor = rumor.finalize_unsigned_with_id(my_pk);
671    let event_id = built_rumor.id.ok_or("Rumor has no id")?.to_hex();
672
673    // Send via gift-wrap with retry
674    retry_send_gift_wrap(
675        &client, &receiver, receiver_npub, &pending_id,
676        built_rumor, &event_id, config, callback, None,
677    ).await
678}
679
680// ============================================================================
681// send_rumor_dm — Pre-built rumor (custom events)
682// ============================================================================
683
684/// Send a pre-built rumor via NIP-17 gift-wrap.
685///
686/// Used when the caller has already built the rumor. Skips encryption/upload.
687pub async fn send_rumor_dm(
688    receiver_npub: &str,
689    pending_id: &str,
690    rumor: UnsignedEvent,
691    config: &SendConfig,
692    callback: Arc<dyn SendCallback>,
693) -> Result<SendResult, String> {
694    let client = nostr_client().ok_or("Not logged in")?;
695
696    let receiver = PublicKey::from_bech32(receiver_npub)
697        .map_err(|e| format!("Invalid npub: {}", e))?;
698
699    let event_id = rumor.id.ok_or("Rumor has no id")?.to_hex();
700
701    retry_send_gift_wrap(
702        &client, &receiver, receiver_npub, pending_id,
703        rumor, &event_id, config, callback, None,
704    ).await
705}
706
707/// Manually retry a failed DM by republishing the EXACT retained recipient
708/// wrap. Same outer event id → relays no-op the duplicate, so a first send
709/// that silently landed can never double-post, regardless of the recipient's
710/// client.
711///
712/// `Ok(true)`  — a retained wrap was found and the resend went through the
713///               normal retry loop (message ends sent, or red again ready to
714///               retry). The caller must NOT fall back.
715/// `Ok(false)` — nothing retained (old/pruned message, or the first send
716///               failed before a wrap was ever built, e.g. an upload error).
717///               The caller falls back to a fresh send — safe, since no wrap
718///               reached a relay in that case.
719pub async fn resend_failed_dm(
720    receiver_npub: &str,
721    failed_msg_id: &str,
722    config: &SendConfig,
723    callback: Arc<dyn SendCallback>,
724) -> Result<bool, String> {
725    // Guard the read→flip→republish against a mid-tap account swap: the payload
726    // is this account's; a swap before the STATE flip must abort (returning
727    // "handled" so the caller never falls back to a fresh send in the WRONG
728    // account). retry_send_gift_wrap re-guards its own STATE/DB writes.
729    let payload = match crate::db::nip17_keys::get_resend_payload_by_pending(failed_msg_id)? {
730        Some(p) => p,
731        None => return Ok(false),
732    };
733    let client = nostr_client().ok_or("Not logged in")?;
734    let receiver = PublicKey::from_bech32(receiver_npub)
735        .map_err(|e| format!("Invalid npub: {}", e))?;
736
737    // Flip the red row back to "sending"; the retry loop's own fail/finalize
738    // path returns it to red or promotes it to sent.
739    let repending = {
740        let mut state = STATE.lock().await;
741        state.update_message(failed_msg_id, |msg| {
742            msg.set_failed(false);
743            msg.set_pending(true);
744        })
745    };
746    if let Some((_chat_id, ref msg)) = repending {
747        callback.on_pending(receiver_npub, msg);
748    }
749
750    let event_id = payload.rumor.id.ok_or("Retained rumor has no id")?.to_hex();
751    let built = crate::inbox_relays::BuiltGiftWrap {
752        event: payload.wrap_event,
753        secret: payload.secret,
754    };
755    // Inject the retained wrap: the loop republishes these exact bytes.
756    if let Err(e) = retry_send_gift_wrap(
757        &client, &receiver, receiver_npub, failed_msg_id,
758        payload.rumor, &event_id, config, callback, Some(built),
759    )
760    .await
761    {
762        // The resend was attempted — the loop already marked the row red again.
763        // Still "handled": the caller must not fall back to a fresh wrap, or the
764        // retained wrap that may yet be sitting on a relay would double-post.
765        crate::log_warn!("[Send] idempotent resend of {} did not land: {}", failed_msg_id, e);
766    }
767    Ok(true)
768}
769
770// ============================================================================
771// send_file_dm — File Attachment DMs
772// ============================================================================
773
774/// Send a NIP-17 gift-wrapped file attachment DM.
775///
776/// Flow: hash → save locally → pending → encrypt → upload → build Kind 15 rumor → gift-wrap + send.
777#[allow(clippy::too_many_arguments)]
778pub async fn send_file_dm(
779    receiver_npub: &str,
780    file_bytes: Arc<Vec<u8>>,
781    filename: &str,
782    extension: &str,
783    content: Option<&str>,
784    reply_to: Option<&str>,
785    config: &SendConfig,
786    callback: Arc<dyn SendCallback>,
787) -> Result<SendResult, String> {
788    send_file_dm_with_meta(receiver_npub, file_bytes, filename, extension, None, content, reply_to, config, callback).await
789}
790
791/// [`send_file_dm`] for a caller that already holds the image's preview metadata,
792/// sparing a second full decode before the bubble can appear. `None` derives it.
793#[allow(clippy::too_many_arguments)]
794pub async fn send_file_dm_with_meta(
795    receiver_npub: &str,
796    file_bytes: Arc<Vec<u8>>,
797    filename: &str,
798    extension: &str,
799    img_meta: Option<crate::types::ImageMetadata>,
800    content: Option<&str>,
801    reply_to: Option<&str>,
802    config: &SendConfig,
803    callback: Arc<dyn SendCallback>,
804) -> Result<SendResult, String> {
805    send_file_dm_from(receiver_npub, FileSource::Bytes(file_bytes), filename, extension, img_meta, content, reply_to, config, callback).await
806}
807
808/// Where an outgoing file's bytes come from.
809pub enum FileSource {
810    /// In memory: images the caller has already processed, pasted bytes.
811    Bytes(Arc<Vec<u8>>),
812    /// On disk, read a chunk at a time: a file of any size costs one chunk of memory.
813    /// Its preview metadata is the caller's to give; nothing here decodes it.
814    Path(std::path::PathBuf),
815}
816
817/// Removes a file when dropped: the ciphertext of an upload that ended either way.
818pub struct TempFile(std::path::PathBuf);
819
820impl Drop for TempFile {
821    fn drop(&mut self) {
822        let _ = std::fs::remove_file(&self.0);
823    }
824}
825
826/// An outgoing attachment, ready for the upload.
827pub enum Sealed {
828    /// A live blob of these bytes already exists: the message just names it.
829    Reused(crate::db::attachments::ReusableUpload),
830    /// Freshly sealed. A disk body's ciphertext goes when the guard drops.
831    Body(crate::blossom::UploadBody, Option<TempFile>),
832}
833
834/// Reuse a live blob of the plaintext `hash`, or seal `plain` under `key`/`nonce`,
835/// reporting each stage against `pending_id`. A file seals file to file, costing no memory.
836pub async fn seal_or_reuse(
837    plain: FileSource,
838    hash: &str,
839    key: &str,
840    nonce: &str,
841    pending_id: &str,
842    callback: Arc<dyn SendCallback>,
843    cancel: Option<Arc<AtomicBool>>,
844) -> Result<Sealed, String> {
845    // Every verified send or download of these bytes left url+key+nonce behind. Liveness
846    // is proven, never assumed: a dead or unreachable blob falls through to a fresh upload.
847    if let Ok(Some(r)) = crate::db::attachments::find_reusable_by_hash(hash) {
848        if crate::blossom::blob_is_served(&r.url, std::time::Duration::from_secs(5)).await {
849            crate::log_info!("[SmartForward] {} reused ({}, {} KB) — no upload", &hash[..8.min(hash.len())], if r.mine { "own blob" } else { "foreign blob, mirroring in background" }, r.size / 1024);
850            return Ok(Sealed::Reused(r));
851        }
852    }
853
854    let (key, nonce) = (key.to_string(), nonce.to_string());
855    // Resolved here, on the task that carries the account, not on a blocking thread.
856    let outgoing = crate::db::current_account_dir()
857        .map(|d| d.join("outgoing"))
858        .unwrap_or_else(|_| std::env::temp_dir());
859    // Only a streamed seal can count its progress; bytes in hand are sealed in one go.
860    callback.on_upload_stage(pending_id, "encrypting", matches!(plain, FileSource::Path(_)).then_some(0));
861    let sealed = match plain {
862        FileSource::Bytes(bytes) => {
863            let sealed = crate::rt::spawn_blocking(move || {
864                crypto::encrypt_data(&bytes, &crypto::EncryptionParams { key, nonce })
865            })
866            .await
867            .map_err(|e| format!("Encryption failed: {}", e))??;
868            Sealed::Body(crate::blossom::UploadBody::Memory(Arc::new(sealed)), None)
869        }
870        FileSource::Path(path) => {
871            let _ = std::fs::create_dir_all(&outgoing);
872            let out = outgoing.join(format!("{}-{}.enc", hash, pending_id));
873            let guard = TempFile(out.clone());
874            let target = out.clone();
875            let (stage_cb, pid) = (callback.clone(), pending_id.to_string());
876            let (sealed_hash, len) = crate::rt::spawn_blocking(move || {
877                crypto::stream::encrypt_file_with_progress(&path, &target, &key, &nonce, &mut |pct| {
878                    stage_cb.on_upload_stage(&pid, "encrypting", Some(pct));
879                    // A cancel lands here, not after the whole file is sealed.
880                    !cancel.as_ref().is_some_and(|c| c.load(Ordering::Relaxed))
881                })
882            })
883            .await
884            .map_err(|e| format!("Encryption failed: {}", e))??;
885            Sealed::Body(crate::blossom::UploadBody::file(out, len, &sealed_hash)?, Some(guard))
886        }
887    };
888    callback.on_upload_stage(pending_id, "starting", None);
889    Ok(sealed)
890}
891
892/// Mark a pending file message failed (in state, in the UI, on disk) and hand back `err`.
893async fn mark_send_failed(callback: &Arc<dyn SendCallback>, receiver_npub: &str, pending_id: &str, err: String) -> String {
894    let failed_msg = {
895        let mut state = STATE.lock().await;
896        state.update_message(pending_id, |msg| {
897            msg.set_failed(true);
898            msg.set_pending(false);
899        })
900    };
901    if let Some((_chat_id, ref msg)) = failed_msg {
902        callback.on_failed(receiver_npub, pending_id, msg);
903        callback.on_persist(receiver_npub, msg);
904    }
905    err
906}
907
908/// [`send_file_dm_with_meta`] from either source.
909#[allow(clippy::too_many_arguments)]
910pub async fn send_file_dm_from(
911    receiver_npub: &str,
912    source: FileSource,
913    filename: &str,
914    extension: &str,
915    img_meta: Option<crate::types::ImageMetadata>,
916    content: Option<&str>,
917    reply_to: Option<&str>,
918    config: &SendConfig,
919    callback: Arc<dyn SendCallback>,
920) -> Result<SendResult, String> {
921    let client = nostr_client().ok_or("Not logged in")?;
922    let my_pk = my_public_key().ok_or("Public key not set")?;
923    let reply_to = reply_to.filter(|r| !r.is_empty());
924    // Sign the Blossom auth event via the active client signer so bunker
925    // accounts route through NostrConnect (the user's identity key lives on
926    // the remote signer; MY_SECRET_KEY only holds the NIP-46 client key).
927    let signer = crate::signer::active_signer()
928        .map_err(|e| format!("Signer unavailable: {}", e))?;
929
930    let now = web_time::SystemTime::now()
931        .duration_since(web_time::UNIX_EPOCH).unwrap();
932    let pending_id = format!("pending-{}", now.as_nanos());
933    let milliseconds = now.as_millis() % 1000;
934
935    let receiver = PublicKey::from_bech32(receiver_npub)
936        .map_err(|e| format!("Invalid npub: {}", e))?;
937
938    let mime_type = crypto::mime_from_extension(extension);
939    let download_dir = crate::db::get_download_dir();
940
941    // The web keeps its copy content-addressed in OPFS, and sends from memory only.
942    #[cfg(target_arch = "wasm32")]
943    let (file_hash, local_path_str, img_meta, plain_len) = {
944        let FileSource::Bytes(bytes) = &source else {
945            return Err("Vector Web sends files from memory".into());
946        };
947        let file_hash = crypto::sha256_hex(bytes);
948        let local_path = download_dir.join(format!("{file_hash}.{extension}"));
949        crate::webfiles::write(&local_path, bytes).await?;
950        let img_meta = img_meta.or_else(|| crypto::generate_image_metadata(bytes));
951        (file_hash, local_path.to_string_lossy().to_string(), img_meta, bytes.len() as u64)
952    };
953    // Hash, save and (if not given) read the preview metadata off the runtime: each
954    // is a full pass over the bytes, and a runtime worker parked on one stalls every
955    // task queued behind it.
956    #[cfg(not(target_arch = "wasm32"))]
957    let (file_hash, local_path_str, img_meta, plain_len) = {
958        let source = match &source {
959            FileSource::Bytes(bytes) => FileSource::Bytes(Arc::clone(bytes)),
960            FileSource::Path(path) => FileSource::Path(path.clone()),
961        };
962        let (filename, extension) = (filename.to_string(), extension.to_string());
963        let tmp_tag = pending_id.clone();
964        crate::rt::spawn_blocking(move || -> Result<_, String> {
965            let (file_hash, plain_len) = match &source {
966                FileSource::Bytes(bytes) => (crypto::sha256_hex(bytes), bytes.len() as u64),
967                FileSource::Path(path) => crypto::stream::hash_file(path)?,
968            };
969            let _ = std::fs::create_dir_all(&download_dir);
970            // Save with an extension matching the actual content. The caller's
971            // `extension` is post-compression (e.g. JPEG when an original PNG was
972            // compressed), but `filename` may still carry the pre-compression one;
973            // JPEG bytes saved as `.png` would poison a later re-upload with a
974            // MIP-04 mismatch.
975            let local_name = if filename.is_empty() {
976                format!("{}.{}", &file_hash, extension)
977            } else {
978                let stem = filename.rsplit_once('.').map(|(s, _)| s).unwrap_or(&filename);
979                format!("{}.{}", stem, extension)
980            };
981            // Resolve unique path (pasted_image.png → pasted_image-1.png on collision)
982            let local_path = crypto::resolve_unique_filename(&download_dir, &local_name);
983            // Atomic write: temp file then rename. A file source copies file to file
984            // (a clone where the filesystem has them), never through memory.
985            // Named for this send: two sends of the same file must not share one.
986            let tmp = download_dir.join(format!(".{}.{}.tmp", &file_hash, &tmp_tag));
987            let img_meta = match &source {
988                FileSource::Bytes(bytes) => {
989                    std::fs::write(&tmp, &**bytes).map_err(|e| format!("Failed to save the file: {e}"))?;
990                    img_meta.or_else(|| crypto::generate_image_metadata(bytes))
991                }
992                FileSource::Path(path) => {
993                    std::fs::copy(path, &tmp).map_err(|e| format!("Failed to save the file: {e}"))?;
994                    img_meta
995                }
996            };
997            if let Err(e) = std::fs::rename(&tmp, &local_path) {
998                let _ = std::fs::remove_file(&tmp);
999                return Err(format!("Failed to save the file: {e}"));
1000            }
1001            Ok((file_hash, local_path.to_string_lossy().to_string(), img_meta, plain_len))
1002        })
1003        .await
1004        .map_err(|e| format!("Attachment prep failed: {}", e))??
1005    };
1006
1007    // WebXDC Mini Apps: mint the realtime-channel topic at send time and carry
1008    // it on the rumor — locally-derived topics are asymmetric in DMs (each
1009    // side's chat_id is the other party's npub), splitting players onto
1010    // disjoint gossip topics.
1011    let webxdc_topic = (extension.eq_ignore_ascii_case("xdc"))
1012        .then(|| crate::webxdc::mint_topic_id(&file_hash, &my_pk.to_hex()));
1013
1014    // The bubble goes up now, on fresh encryption parameters; a reused blob swaps in
1015    // its own below. AES-GCM appends a 16-byte tag, so the size is known unencrypted.
1016    let params = crypto::generate_encryption_params();
1017    let attachment = Attachment {
1018        id: file_hash.clone(), key: params.key.clone(), nonce: params.nonce.clone(),
1019        extension: extension.to_string(), name: filename.to_string(),
1020        url: String::new(), path: local_path_str.clone(), size: plain_len + 16,
1021        img_meta: img_meta.clone(), downloading: false, downloaded: true,
1022        webxdc_topic: webxdc_topic.clone(),
1023        ..Default::default()
1024    };
1025    let mut msg = Message {
1026        id: pending_id.clone(), content: content.unwrap_or("").to_string(),
1027        replied_to: reply_to.unwrap_or("").to_string(),
1028        at: now.as_millis() as u64, pending: true, mine: true,
1029        npub: my_pk.to_bech32().ok(), attachments: vec![attachment.clone()],
1030        // A lifespan is stamped after the upload; the bubble shows no clock until then.
1031        expiration: if config.self_destruct_secs.is_some() { None } else { config.expiration },
1032        ..Default::default()
1033    };
1034    let _ = crate::db::events::populate_reply_context(&mut msg).await;
1035    {
1036        let mut state = STATE.lock().await;
1037        state.add_message_to_participant(receiver_npub, &msg);
1038    }
1039    callback.on_pending(receiver_npub, &msg);
1040    let cancel = config.cancel_token.clone().or_else(|| callback.cancel_token(&pending_id));
1041
1042    // Encrypt and upload, or reference a live blob of the same bytes and skip both.
1043    // A file seals from the local copy made above, not the source the caller may still change.
1044    let plain = match source {
1045        FileSource::Bytes(bytes) => FileSource::Bytes(bytes),
1046        FileSource::Path(_) => FileSource::Path(std::path::PathBuf::from(&local_path_str)),
1047    };
1048    let sealed = match seal_or_reuse(plain, &file_hash, &params.key, &params.nonce, &pending_id, callback.clone(), cancel.clone()).await {
1049        Ok(sealed) => sealed,
1050        // The bubble is up: a seal that fails (or is cancelled) must say so, not stay pending.
1051        Err(e) => return Err(mark_send_failed(&callback, receiver_npub, &pending_id, e).await),
1052    };
1053    // Held to the end of the send, so the ciphertext goes however it ends.
1054    let (reused, encrypted, _ciphertext_file) = match sealed {
1055        Sealed::Reused(r) => (Some(r), None, None),
1056        Sealed::Body(body, guard) => (None, Some(body), guard),
1057    };
1058    let (att_key, att_nonce, att_url, encrypted_size) = match (&reused, &encrypted) {
1059        (Some(r), _) => {
1060            let adopted = crate::compact::CompactAttachment::from_attachment(&Attachment {
1061                key: r.key.clone(), nonce: r.nonce.clone(), url: r.url.clone(), size: r.size,
1062                ..attachment.clone()
1063            });
1064            let mut state = STATE.lock().await;
1065            state.update_message(&pending_id, |msg| {
1066                if let Some(att) = msg.attachments.last_mut() {
1067                    *att = adopted.clone();
1068                }
1069            });
1070            (r.key.clone(), r.nonce.clone(), r.url.clone(), r.size)
1071        }
1072        (None, body) => (params.key.clone(), params.nonce.clone(), String::new(), body.as_ref().map_or(0, |b| b.len())),
1073    };
1074
1075    // Upload to Blossom — bridge SendCallback.on_upload_progress to Blossom ProgressCallback
1076    let servers = crate::state::get_blossom_servers();
1077    let cb_for_progress = callback.clone();
1078    let pid_for_progress = pending_id.clone();
1079    let progress_cb: crate::blossom::ProgressCallback = Arc::new(move |percentage, bytes| {
1080        cb_for_progress.on_upload_progress(
1081            &pid_for_progress,
1082            percentage.unwrap_or(0),
1083            bytes.unwrap_or(0),
1084        )
1085    });
1086
1087    // Send the original MIME even though bytes are ciphertext: many
1088    // Blossom servers reject `application/octet-stream` but accept the
1089    // same bytes under their original type.
1090    let (upload_url, mirror_urls) = match encrypted {
1091        // Reuse: the attachment already points at a live blob. No upload, no
1092        // fan-out — the send is just the message.
1093        None => {
1094            callback.on_upload_complete(receiver_npub, &pending_id, &file_hash, &att_url);
1095            // A FOREIGN blob's lifetime belongs to its uploader; mirror it to
1096            // our servers in the background as availability insurance. Never
1097            // blocks or delays the send — best-effort by design.
1098            if reused.as_ref().is_some_and(|r| !r.mine) {
1099                let signer_bg = signer.clone();
1100                let url_bg = att_url.clone();
1101                crate::db::spawn_bound(async move {
1102                    let _ = crate::blossom::mirror_blob_to_servers(
1103                        signer_bg, &url_bg, crate::state::get_blossom_servers(),
1104                        1, std::time::Duration::from_secs(8), &[],
1105                    ).await;
1106                });
1107            }
1108            (att_url.clone(), Vec::new())
1109        }
1110        Some(encrypted) => {
1111            let accepted = match crate::blossom::upload_body_with_progress_and_failover(
1112                signer.clone(), servers, encrypted, Some(mime_type),
1113                /* is_encrypted */ true,
1114                progress_cb, Some(config.upload_retries), Some(config.upload_retry_delay),
1115                cancel.clone(),
1116            ).await {
1117                Ok(accepted) => accepted,
1118                Err(e) => return Err(mark_send_failed(&callback, receiver_npub, &pending_id, format!("Upload failed: {}", e)).await),
1119            };
1120
1121            let upload_url = accepted.url.clone();
1122            {
1123                let mut state = STATE.lock().await;
1124                state.update_message(&pending_id, |msg| {
1125                    if let Some(att) = msg.attachments.last_mut() {
1126                        att.url = upload_url.clone().into_boxed_str();
1127                    }
1128                });
1129            }
1130            callback.on_upload_complete(receiver_npub, &pending_id, &file_hash, &upload_url);
1131
1132            // BUD-04 mirror fan-out: bounded, best-effort. Verified mirrors ride the
1133            // rumor as NIP-17 `fallback` tags so the message survives its primary
1134            // host dying later; zero mirrors never delays or fails the send.
1135            let mirror_urls = crate::blossom::mirror_blob_to_servers(
1136                signer.clone(),
1137                &upload_url,
1138                crate::state::get_blossom_servers(),
1139                2,
1140                std::time::Duration::from_secs(5),
1141                std::slice::from_ref(&accepted.server),
1142            ).await;
1143            if !mirror_urls.is_empty() {
1144                let mut state = STATE.lock().await;
1145                state.update_message(&pending_id, |msg| {
1146                    if let Some(att) = msg.attachments.last_mut() {
1147                        att.fallback_urls = Some(mirror_urls.iter().map(|s| s.as_str().into()).collect());
1148                    }
1149                });
1150            }
1151            (upload_url, mirror_urls)
1152        }
1153    };
1154
1155    // A cancel during the mirror fan-out or the publish must not deliver: the
1156    // bubble is already gone on the sender's side.
1157    if cancel.as_ref().is_some_and(|c| c.load(Ordering::Relaxed)) {
1158        return Err("Upload cancelled".to_string());
1159    }
1160
1161    // Build Kind 15
1162    let mut file_rumor = EventBuilder::new(Kind::from_u16(15), &upload_url)
1163        .tag(Tag::public_key(receiver))
1164        .tag(Tag::custom("file-type", [mime_type]))
1165        .tag(Tag::custom("size", [encrypted_size.to_string()]))
1166        .tag(Tag::custom("encryption-algorithm", ["aes-gcm"]))
1167        .tag(Tag::custom("decryption-key", [att_key.as_str()]))
1168        .tag(Tag::custom("decryption-nonce", [att_nonce.as_str()]))
1169        .tag(Tag::custom("ox", [file_hash.clone()]));
1170    if let Some(reply_id) = reply_to {
1171        file_rumor = file_rumor.tag(Tag::custom(
1172            "e",
1173            [reply_id.to_string(), String::new(), "reply".to_string()],
1174        ));
1175    }
1176    for fb in &mirror_urls {
1177        file_rumor = file_rumor.tag(Tag::custom("fallback", [fb.as_str()]));
1178    }
1179    if !filename.is_empty() {
1180        file_rumor = file_rumor.tag(Tag::custom("name", [filename]));
1181    }
1182    if let Some(ref topic) = webxdc_topic {
1183        file_rumor = file_rumor.tag(Tag::custom("webxdc-topic", [topic.as_str()]));
1184    }
1185    // Include image preview metadata for compatible rendering across all clients
1186    if let Some(ref meta) = img_meta {
1187        if !meta.thumbhash.is_empty() {
1188            // `thumbhash` names the value accurately; receivers read `thumb` too
1189            // (legacy), so this stays backward-compatible in both directions.
1190            file_rumor = file_rumor.tag(Tag::custom("thumbhash", [meta.thumbhash.as_str()]));
1191        }
1192        file_rumor = file_rumor.tag(Tag::custom("dim", [format!("{}x{}", meta.width, meta.height)]));
1193    }
1194    file_rumor = file_rumor.tag(Tag::custom("ms", [milliseconds.to_string()]));
1195    // The self-destruct clock starts at publish: resolve the lifespan now that the
1196    // upload is done, and put the same stamp on the sender's own bubble.
1197    let expiration = config
1198        .self_destruct_secs
1199        .and_then(crate::self_destruct::expiry_after)
1200        .or(config.expiration);
1201    if let Some(exp) = expiration {
1202        file_rumor = file_rumor.tag(Tag::expiration(Timestamp::from_secs(exp)));
1203        let mut state = STATE.lock().await;
1204        state.update_message(&pending_id, |msg| { msg.expiration_secs = exp as u32; });
1205    }
1206
1207    let built_rumor = file_rumor.finalize_unsigned_with_id(my_pk);
1208    let event_id = built_rumor.id.ok_or("Rumor has no id")?.to_hex();
1209
1210    retry_send_gift_wrap(
1211        &client, &receiver, receiver_npub, &pending_id,
1212        built_rumor, &event_id, config, callback, None,
1213    ).await
1214}
1215
1216// ============================================================================
1217// Tests
1218// ============================================================================
1219
1220#[cfg(test)]
1221mod tests {
1222    use super::*;
1223    use std::sync::Mutex;
1224
1225    #[derive(Debug, Clone, PartialEq)]
1226    enum CbEvent {
1227        Pending(String),
1228        UploadProgress(String, u8, u64),
1229        UploadComplete(String, String),
1230        Sent(String, String),
1231        Failed(String, String),
1232        Persist(String),
1233    }
1234
1235    struct MockCallback {
1236        events: Mutex<Vec<CbEvent>>,
1237        cancel_at: Option<u8>,
1238    }
1239
1240    impl MockCallback {
1241        fn new() -> Self { Self { events: Mutex::new(vec![]), cancel_at: None } }
1242        fn with_cancel(pct: u8) -> Self { Self { events: Mutex::new(vec![]), cancel_at: Some(pct) } }
1243        fn events(&self) -> Vec<CbEvent> { self.events.lock().unwrap().clone() }
1244    }
1245
1246    impl SendCallback for MockCallback {
1247        fn on_pending(&self, cid: &str, _: &Message) {
1248            self.events.lock().unwrap().push(CbEvent::Pending(cid.into()));
1249        }
1250        fn on_upload_progress(&self, pid: &str, pct: u8, bytes: u64) -> Result<(), String> {
1251            self.events.lock().unwrap().push(CbEvent::UploadProgress(pid.into(), pct, bytes));
1252            if self.cancel_at.map_or(false, |c| pct >= c) { return Err("Cancelled".into()); }
1253            Ok(())
1254        }
1255        fn on_upload_complete(&self, cid: &str, _: &str, _: &str, url: &str) {
1256            self.events.lock().unwrap().push(CbEvent::UploadComplete(cid.into(), url.into()));
1257        }
1258        fn on_sent(&self, cid: &str, old: &str, _: &Message) {
1259            self.events.lock().unwrap().push(CbEvent::Sent(cid.into(), old.into()));
1260        }
1261        fn on_failed(&self, cid: &str, old: &str, _: &Message) {
1262            self.events.lock().unwrap().push(CbEvent::Failed(cid.into(), old.into()));
1263        }
1264        fn on_persist(&self, cid: &str, _: &Message) {
1265            self.events.lock().unwrap().push(CbEvent::Persist(cid.into()));
1266        }
1267    }
1268
1269    #[test]
1270    fn config_default() {
1271        let c = SendConfig::default();
1272        assert_eq!(c.max_send_attempts, 1);
1273        assert!(!c.self_send);
1274        assert!(c.cancel_token.is_none());
1275        assert_eq!(c.upload_retries, 3);
1276    }
1277
1278    #[test]
1279    fn config_gui() {
1280        let c = SendConfig::gui();
1281        assert_eq!(c.max_send_attempts, 12);
1282        assert!(c.self_send);
1283    }
1284
1285    #[test]
1286    fn config_headless() {
1287        let c = SendConfig::headless();
1288        assert_eq!(c.max_send_attempts, 3);
1289        assert!(c.self_send);
1290    }
1291
1292    #[test]
1293    fn config_custom_override() {
1294        let c = SendConfig { max_send_attempts: 5, ..SendConfig::gui() };
1295        assert_eq!(c.max_send_attempts, 5);
1296        assert!(c.self_send);
1297    }
1298
1299    #[test]
1300    fn noop_callback_all_methods() {
1301        let cb = NoOpSendCallback;
1302        let msg = Message::default();
1303        cb.on_pending("c", &msg);
1304
1305        assert!(cb.on_upload_progress("p", 50, 1024).is_ok());
1306        cb.on_upload_complete("c", "p", "a", "url");
1307        cb.on_sent("c", "o", &msg);
1308        cb.on_failed("c", "o", &msg);
1309        cb.on_persist("c", &msg);
1310    }
1311
1312    #[test]
1313    fn text_dm_sequence() {
1314        let cb = MockCallback::new();
1315        let msg = Message::default();
1316        cb.on_pending("r", &msg);
1317        cb.on_sent("r", "p-1", &msg);
1318        cb.on_persist("r", &msg);
1319        assert_eq!(cb.events(), vec![
1320            CbEvent::Pending("r".into()),
1321            CbEvent::Sent("r".into(), "p-1".into()),
1322            CbEvent::Persist("r".into()),
1323        ]);
1324    }
1325
1326    #[test]
1327    fn file_dm_sequence() {
1328        let cb = MockCallback::new();
1329        let msg = Message::default();
1330        cb.on_pending("r", &msg);
1331
1332        cb.on_upload_progress("p", 0, 0).ok();
1333        cb.on_upload_progress("p", 50, 5000).ok();
1334        cb.on_upload_progress("p", 100, 10000).ok();
1335        cb.on_upload_complete("r", "p", "h", "https://blossom/h");
1336        cb.on_sent("r", "p", &msg);
1337        cb.on_persist("r", &msg);
1338        let e = cb.events();
1339        assert_eq!(e.len(), 7);
1340        assert!(matches!(&e[4], CbEvent::UploadComplete(_, url) if url.contains("blossom")));
1341    }
1342
1343    #[test]
1344    fn failed_sequence() {
1345        let cb = MockCallback::new();
1346        let msg = Message::default();
1347        cb.on_pending("r", &msg);
1348        cb.on_failed("r", "p", &msg);
1349        cb.on_persist("r", &msg);
1350        assert_eq!(cb.events(), vec![
1351            CbEvent::Pending("r".into()),
1352            CbEvent::Failed("r".into(), "p".into()),
1353            CbEvent::Persist("r".into()),
1354        ]);
1355    }
1356
1357    #[test]
1358    fn cancel_upload_at_threshold() {
1359        let cb = MockCallback::with_cancel(50);
1360        assert!(cb.on_upload_progress("p", 25, 512).is_ok());
1361        assert!(cb.on_upload_progress("p", 50, 1024).is_err());
1362        assert_eq!(cb.events().len(), 2);
1363    }
1364
1365    #[test]
1366    fn cancel_triggers_failed() {
1367        let cb = MockCallback::with_cancel(30);
1368        let msg = Message::default();
1369        cb.on_pending("r", &msg);
1370
1371        cb.on_upload_progress("p", 10, 1000).ok();
1372        assert!(cb.on_upload_progress("p", 30, 3000).is_err());
1373        cb.on_failed("r", "p", &msg);
1374        assert!(matches!(cb.events().last(), Some(CbEvent::Failed(..))));
1375    }
1376
1377    #[test]
1378    fn send_result_serialize() {
1379        let r = SendResult { pending_id: "p".into(), event_id: Some("e".into()), chat_id: "c".into() };
1380        let j = serde_json::to_string(&r).unwrap();
1381        assert!(j.contains("\"pending_id\":\"p\""));
1382    }
1383
1384    #[test]
1385    fn send_result_none_event() {
1386        let r = SendResult { pending_id: "p".into(), event_id: None, chat_id: "c".into() };
1387        let j = serde_json::to_string(&r).unwrap();
1388        assert!(j.contains("null"));
1389    }
1390
1391    // ========================================================================
1392    // File DM callback sequences
1393    // ========================================================================
1394
1395    #[test]
1396    fn file_dm_fresh_upload_full_sequence() {
1397        let cb = MockCallback::new();
1398        let msg = Message::default();
1399
1400        // 1. Pending message created
1401        cb.on_pending("npub1recv", &msg);
1402        // 2. Attachment preview added
1403
1404        // 3. Upload progress (0% → 25% → 50% → 75% → 100%)
1405        cb.on_upload_progress("pending-42", 0, 0).unwrap();
1406        cb.on_upload_progress("pending-42", 25, 2500).unwrap();
1407        cb.on_upload_progress("pending-42", 50, 5000).unwrap();
1408        cb.on_upload_progress("pending-42", 75, 7500).unwrap();
1409        cb.on_upload_progress("pending-42", 100, 10000).unwrap();
1410        // 4. Upload complete
1411        cb.on_upload_complete("npub1recv", "pending-42", "deadbeef", "https://blossom.example/deadbeef");
1412        // 5. Gift-wrap sent successfully
1413        cb.on_sent("npub1recv", "pending-42", &msg);
1414        // 6. Persisted to DB
1415        cb.on_persist("npub1recv", &msg);
1416
1417        let e = cb.events();
1418        assert_eq!(e.len(), 9);
1419        assert!(matches!(&e[0], CbEvent::Pending(c) if c == "npub1recv"));
1420        assert!(matches!(&e[1], CbEvent::UploadProgress(_, 0, 0)));
1421        assert!(matches!(&e[5], CbEvent::UploadProgress(_, 100, 10000)));
1422        assert!(matches!(&e[6], CbEvent::UploadComplete(_, url) if url.contains("deadbeef")));
1423        assert!(matches!(&e[7], CbEvent::Sent(..)));
1424        assert!(matches!(&e[8], CbEvent::Persist(..)));
1425    }
1426
1427    #[test]
1428    fn file_dm_skip_upload_sequence() {
1429        let cb = MockCallback::new();
1430        let msg = Message::default();
1431
1432        // Dedup hit: no upload, existing URL reused
1433        // 1. Pending message
1434        cb.on_pending("npub1recv", &msg);
1435        // 2. Attachment preview (with reused URL already set)
1436
1437        // 3. Upload complete (immediate — URL was already known)
1438        cb.on_upload_complete("npub1recv", "pending-99", "existinghash", "https://blossom.example/existing");
1439        // 4. Gift-wrap sent
1440        cb.on_sent("npub1recv", "pending-99", &msg);
1441        // 5. Persisted
1442        cb.on_persist("npub1recv", &msg);
1443
1444        let e = cb.events();
1445        assert_eq!(e.len(), 4);
1446        // No UploadProgress events — upload was skipped
1447        assert!(!e.iter().any(|ev| matches!(ev, CbEvent::UploadProgress(..))));
1448        assert!(matches!(&e[1], CbEvent::UploadComplete(..)));
1449    }
1450
1451    #[test]
1452    fn file_dm_upload_cancelled_at_30pct() {
1453        let cb = MockCallback::with_cancel(30);
1454        let msg = Message::default();
1455
1456        cb.on_pending("npub1recv", &msg);
1457
1458        assert!(cb.on_upload_progress("p", 10, 1000).is_ok());
1459        assert!(cb.on_upload_progress("p", 20, 2000).is_ok());
1460        // Cancel triggers at 30%
1461        let err = cb.on_upload_progress("p", 30, 3000);
1462        assert!(err.is_err());
1463        assert!(err.unwrap_err().contains("Cancelled"));
1464        // Pipeline marks as failed
1465        cb.on_failed("npub1recv", "p", &msg);
1466
1467        let e = cb.events();
1468        assert_eq!(e.len(), 5);
1469        // No Sent, no Persist after cancel — just Failed
1470        assert!(!e.iter().any(|ev| matches!(ev, CbEvent::Sent(..))));
1471        assert!(matches!(e.last(), Some(CbEvent::Failed(..))));
1472    }
1473
1474    #[test]
1475    fn file_dm_upload_fails_marks_failed() {
1476        let cb = MockCallback::new();
1477        let msg = Message::default();
1478
1479        cb.on_pending("npub1recv", &msg);
1480
1481        cb.on_upload_progress("p", 0, 0).ok();
1482        cb.on_upload_progress("p", 10, 500).ok();
1483        // Upload fails (server error, all retries exhausted)
1484        cb.on_failed("npub1recv", "p", &msg);
1485        cb.on_persist("npub1recv", &msg);
1486
1487        let e = cb.events();
1488        assert_eq!(e.len(), 5);
1489        assert!(matches!(&e[3], CbEvent::Failed(..)));
1490        assert!(matches!(&e[4], CbEvent::Persist(..)));
1491        // No UploadComplete, no Sent
1492        assert!(!e.iter().any(|ev| matches!(ev, CbEvent::UploadComplete(..))));
1493        assert!(!e.iter().any(|ev| matches!(ev, CbEvent::Sent(..))));
1494    }
1495
1496    #[test]
1497    fn file_dm_gift_wrap_fails_after_upload() {
1498        let cb = MockCallback::new();
1499        let msg = Message::default();
1500
1501        // Upload succeeds but gift-wrap fails
1502        cb.on_pending("npub1recv", &msg);
1503
1504        cb.on_upload_progress("p", 100, 10000).ok();
1505        cb.on_upload_complete("npub1recv", "p", "hash", "https://blossom/hash");
1506        // Gift-wrap retry exhausted
1507        cb.on_failed("npub1recv", "p", &msg);
1508        cb.on_persist("npub1recv", &msg);
1509
1510        let e = cb.events();
1511        assert_eq!(e.len(), 5);
1512        // Upload succeeded but send failed
1513        assert!(matches!(&e[2], CbEvent::UploadComplete(..)));
1514        assert!(matches!(&e[3], CbEvent::Failed(..)));
1515    }
1516
1517    #[test]
1518    fn file_dm_with_image_metadata_sequence() {
1519        let cb = MockCallback::new();
1520        let msg = Message::default();
1521
1522        // Image with thumbhash + dimensions
1523        cb.on_pending("npub1recv", &msg);
1524
1525        cb.on_upload_progress("p", 0, 0).ok();
1526        cb.on_upload_progress("p", 50, 50000).ok();
1527        cb.on_upload_progress("p", 100, 100000).ok();
1528        cb.on_upload_complete("npub1recv", "p", "imghash", "https://blossom/imghash.jpg");
1529        cb.on_sent("npub1recv", "p", &msg);
1530        cb.on_persist("npub1recv", &msg);
1531
1532        let e = cb.events();
1533        assert_eq!(e.len(), 7);
1534        // Verify ordering: pending → progress(3x) → complete → sent → persist
1535        assert!(matches!(&e[0], CbEvent::Pending(..)));
1536        assert!(matches!(&e[4], CbEvent::UploadComplete(_, url) if url.ends_with(".jpg")));
1537        assert!(matches!(&e[5], CbEvent::Sent(..)));
1538    }
1539
1540    #[test]
1541    fn cancel_token_config_with_upload() {
1542        let token = Arc::new(std::sync::atomic::AtomicBool::new(false));
1543        let c = SendConfig {
1544            cancel_token: Some(token.clone()),
1545            ..SendConfig::gui()
1546        };
1547        assert!(c.cancel_token.is_some());
1548        assert!(!token.load(std::sync::atomic::Ordering::Relaxed));
1549
1550        // Simulate cancel
1551        token.store(true, std::sync::atomic::Ordering::Relaxed);
1552        assert!(c.cancel_token.as_ref().unwrap().load(std::sync::atomic::Ordering::Relaxed));
1553    }
1554}
1555
1556#[cfg(test)]
1557mod seal_tests {
1558    use super::*;
1559    use std::sync::Mutex;
1560
1561    #[derive(Default)]
1562    struct Stages(Mutex<Vec<(String, Option<u8>)>>);
1563    impl SendCallback for Stages {
1564        fn on_upload_stage(&self, _: &str, stage: &str, pct: Option<u8>) {
1565            self.0.lock().unwrap().push((stage.to_string(), pct));
1566        }
1567    }
1568
1569    #[tokio::test]
1570    async fn bytes_and_files_seal_to_what_their_key_opens() {
1571        let plain: Vec<u8> = (0..300_000u32).map(|i| (i * 7 % 251) as u8).collect();
1572        let params = crypto::generate_encryption_params();
1573        let hash = crypto::sha256_hex(&plain);
1574        let src = std::env::temp_dir().join(format!("vector-seal-{}.bin", std::process::id()));
1575        std::fs::write(&src, &plain).unwrap();
1576
1577        for source in [FileSource::Bytes(Arc::new(plain.clone())), FileSource::Path(src.clone())] {
1578            let from_file = matches!(source, FileSource::Path(_));
1579            let stages = Arc::new(Stages::default());
1580            let sealed = seal_or_reuse(source, &hash, &params.key, &params.nonce, "pending-1", stages.clone(), None).await.unwrap();
1581            let Sealed::Body(body, guard) = sealed else { panic!("nothing to reuse without a database") };
1582            let ciphertext = match &body {
1583                crate::blossom::UploadBody::Memory(b) => b.to_vec(),
1584                crate::blossom::UploadBody::File { path, .. } => std::fs::read(path).unwrap(),
1585            };
1586            assert_eq!(body.len(), plain.len() as u64 + 16);
1587            assert_eq!(crypto::decrypt_data(&ciphertext, &params.key, &params.nonce).unwrap(), plain);
1588            let stages = stages.0.lock().unwrap().clone();
1589            assert_eq!(stages.first().unwrap().0, "encrypting");
1590            assert_eq!(stages.last().unwrap(), &("starting".to_string(), None));
1591            assert_eq!(guard.is_some(), from_file);
1592            if let crate::blossom::UploadBody::File { path, .. } = &body {
1593                drop(guard);
1594                assert!(!path.exists(), "the ciphertext goes with its guard");
1595            }
1596        }
1597        let _ = std::fs::remove_file(&src);
1598    }
1599}