Skip to main content

vector_core/
sending.rs

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