1use 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
15pub trait SendCallback: Send + Sync {
27 fn on_pending(&self, _chat_id: &str, _msg: &Message) {}
29
30 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 fn on_upload_complete(&self, _chat_id: &str, _pending_id: &str, _attachment_id: &str, _url: &str) {}
42
43 fn on_sent(&self, _chat_id: &str, _old_id: &str, _msg: &Message) {}
46
47 fn on_failed(&self, _chat_id: &str, _old_id: &str, _msg: &Message) {}
49
50 fn on_persist(&self, _chat_id: &str, _msg: &Message) {}
53}
54
55pub struct NoOpSendCallback;
57impl SendCallback for NoOpSendCallback {}
58
59pub struct SendConfig {
65 pub max_send_attempts: u32,
67 pub retry_delay: std::time::Duration,
69 pub self_send: bool,
71 pub cancel_token: Option<Arc<AtomicBool>>,
73 pub upload_retries: u32,
75 pub upload_retry_delay: std::time::Duration,
77 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 pub fn gui() -> Self {
100 Self {
101 max_send_attempts: 12,
102 self_send: true,
103 ..Default::default()
104 }
105 }
106
107 pub fn headless() -> Self {
109 Self {
110 max_send_attempts: 3,
111 self_send: true,
112 ..Default::default()
113 }
114 }
115}
116
117#[derive(serde::Serialize, Clone, Debug)]
123pub struct SendResult {
124 pub pending_id: String,
126 pub event_id: Option<String>,
128 pub chat_id: String,
130}
131
132struct WrapConfirm {
149 wrap_id: EventId,
150 chat_id: String,
151 pending_id: String,
152 rumor_event_id: String,
154 rumor: UnsignedEvent,
155 callback: Arc<dyn SendCallback>,
156 self_send: bool,
157 confirmed: AtomicBool,
158 rescued: AtomicBool,
161 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
173const 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
187pub fn clear_wrap_confirms() {
189 WRAP_CONFIRMS.lock().unwrap().clear();
190}
191
192pub 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
223async 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
251fn 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
282async 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 let mut built: Option<crate::inbox_relays::BuiltGiftWrap> = None;
312 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 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 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 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 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 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 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
497async 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
534pub 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 let emoji_tags = crate::emoji_packs::resolve_outbound_emoji_tags(content);
564
565 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 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 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 retry_send_gift_wrap(
616 &client, &receiver, receiver_npub, &pending_id,
617 built_rumor, &event_id, config, callback,
618 ).await
619}
620
621pub 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
648pub 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 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 let webxdc_topic = (extension.eq_ignore_ascii_case("xdc"))
688 .then(|| crate::webxdc::mint_topic_id(&file_hash, &my_pk.to_hex()));
689
690 let download_dir = crate::db::get_download_dir();
692 let _ = std::fs::create_dir_all(&download_dir);
693 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 let local_path = crypto::resolve_unique_filename(&download_dir, &local_name);
707 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 let img_meta = crypto::generate_image_metadata(&file_bytes);
715
716 let params = crypto::generate_encryption_params();
718 let encrypted = crypto::encrypt_data(&file_bytes, ¶ms)?;
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 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 let upload_url = match crate::blossom::upload_blob_with_progress_and_failover(
758 signer.clone(), servers, Arc::new(encrypted), Some(mime_type),
759 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 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 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#[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 #[test]
1006 fn file_dm_fresh_upload_full_sequence() {
1007 let cb = MockCallback::new();
1008 let msg = Message::default();
1009
1010 cb.on_pending("npub1recv", &msg);
1012 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 cb.on_upload_complete("npub1recv", "pending-42", "deadbeef", "https://blossom.example/deadbeef");
1022 cb.on_sent("npub1recv", "pending-42", &msg);
1024 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 cb.on_pending("npub1recv", &msg);
1045 cb.on_upload_complete("npub1recv", "pending-99", "existinghash", "https://blossom.example/existing");
1049 cb.on_sent("npub1recv", "pending-99", &msg);
1051 cb.on_persist("npub1recv", &msg);
1053
1054 let e = cb.events();
1055 assert_eq!(e.len(), 4);
1056 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 let err = cb.on_upload_progress("p", 30, 3000);
1072 assert!(err.is_err());
1073 assert!(err.unwrap_err().contains("Cancelled"));
1074 cb.on_failed("npub1recv", "p", &msg);
1076
1077 let e = cb.events();
1078 assert_eq!(e.len(), 5);
1079 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 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 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 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 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 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 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 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 token.store(true, std::sync::atomic::Ordering::Relaxed);
1162 assert!(c.cancel_token.as_ref().unwrap().load(std::sync::atomic::Ordering::Relaxed));
1163 }
1164}