1use std::sync::Arc;
8use std::sync::atomic::AtomicBool;
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}
78
79impl Default for SendConfig {
80 fn default() -> Self {
81 Self {
82 max_send_attempts: 1,
83 retry_delay: std::time::Duration::from_secs(5),
84 self_send: false,
85 cancel_token: None,
86 upload_retries: 3,
87 upload_retry_delay: std::time::Duration::from_secs(2),
88 }
89 }
90}
91
92impl SendConfig {
93 pub fn gui() -> Self {
95 Self {
96 max_send_attempts: 12,
97 self_send: true,
98 ..Default::default()
99 }
100 }
101
102 pub fn headless() -> Self {
104 Self {
105 max_send_attempts: 3,
106 self_send: true,
107 ..Default::default()
108 }
109 }
110}
111
112#[derive(serde::Serialize, Clone, Debug)]
118pub struct SendResult {
119 pub pending_id: String,
121 pub event_id: Option<String>,
123 pub chat_id: String,
125}
126
127async fn retry_send_gift_wrap(
139 client: &Client,
140 receiver: &PublicKey,
141 receiver_npub: &str,
142 pending_id: &str,
143 rumor: UnsignedEvent,
144 event_id: &str,
145 config: &SendConfig,
146 callback: Arc<dyn SendCallback>,
147) -> Result<SendResult, String> {
148 let my_pk = my_public_key().ok_or("Public key not set")?;
149 let inner_rumor_id = rumor.id;
150
151 for attempt in 0..config.max_send_attempts {
152 match crate::inbox_relays::send_gift_wrap_retained(client, receiver, rumor.clone(), []).await {
153 Ok(outcome) if outcome.output.success.is_empty() => {
154 let failures: Vec<String> = outcome.output.failed.iter()
159 .map(|(url, err)| format!("{}: {}", url, err))
160 .collect();
161 let targeted_count = outcome.targeted_relays.len();
162 crate::log_warn!(
163 "[Send] attempt {}/{} — 0 of {} relays accepted (targeted: {}). Per-relay errors: {}",
164 attempt + 1,
165 config.max_send_attempts,
166 targeted_count,
167 outcome.targeted_relays.join(", "),
168 if failures.is_empty() {
169 "(none reported — likely all timed out before responding)".to_string()
170 } else {
171 failures.join(" | ")
172 },
173 );
174 if attempt + 1 >= config.max_send_attempts {
175 let failed_msg = {
177 let mut state = STATE.lock().await;
178 state.update_message(pending_id, |msg| {
179 msg.set_failed(true);
180 msg.set_pending(false);
181 })
182 };
183 if let Some((_chat_id, ref msg)) = failed_msg {
184 callback.on_failed(receiver_npub, pending_id, msg);
185 callback.on_persist(receiver_npub, msg);
186 }
187 return Err(format!("Failed to send DM after {} attempts (no relays accepted the gift-wrap)", config.max_send_attempts));
188 }
189 tokio::time::sleep(config.retry_delay).await;
190 continue;
191 }
192 Ok(outcome) => {
193 if let Some(rid) = inner_rumor_id {
197 let role = if attempt == 0 {
198 crate::db::nip17_keys::WrapRole::Recipient
199 } else {
200 crate::db::nip17_keys::WrapRole::Retry
201 };
202 if let Err(e) = crate::db::nip17_keys::store_wrap_key(
203 &outcome.wrap_event_id,
204 &rid,
205 receiver,
206 role,
207 &outcome.wrap_secret,
208 &outcome.targeted_relays,
209 ) {
210 eprintln!("[NIP-17] failed to persist wrap key: {}", e);
211 }
212 }
213
214 let finalized = {
215 let mut state = STATE.lock().await;
216 state.finalize_pending_message(receiver_npub, pending_id, event_id)
217 };
218
219 if let Some((_old_id, ref finalized_msg)) = finalized {
220 callback.on_sent(receiver_npub, pending_id, finalized_msg);
221 callback.on_persist(receiver_npub, finalized_msg);
222 }
223
224 if config.self_send {
230 let client = client.clone();
231 let my_pk_clone = my_pk;
232 let rumor_clone = rumor.clone();
233 let rid_for_self = inner_rumor_id;
234 let session = crate::state::SessionGuard::capture();
235 tokio::spawn(async move {
236 if !session.is_valid() { return; }
237 match crate::inbox_relays::send_gift_wrap_retained(
238 &client, &my_pk_clone, rumor_clone, [],
239 ).await {
240 Ok(self_outcome) if !self_outcome.output.success.is_empty() => {
241 if !session.is_valid() { return; }
242 if let Some(rid) = rid_for_self {
243 if let Err(e) = crate::db::nip17_keys::store_wrap_key(
244 &self_outcome.wrap_event_id,
245 &rid,
246 &my_pk_clone,
247 crate::db::nip17_keys::WrapRole::SelfSend,
248 &self_outcome.wrap_secret,
249 &self_outcome.targeted_relays,
250 ) {
251 eprintln!("[NIP-17] failed to persist self-wrap key: {}", e);
252 }
253 }
254 }
255 _ => {}
256 }
257 });
258 }
259
260 return Ok(SendResult {
261 pending_id: pending_id.to_string(),
262 event_id: Some(event_id.to_string()),
263 chat_id: receiver_npub.to_string(),
264 });
265 }
266 Err(e) => {
267 crate::log_warn!(
272 "[Send] attempt {}/{} — send_gift_wrap_retained errored: {}",
273 attempt + 1,
274 config.max_send_attempts,
275 e,
276 );
277 if attempt + 1 >= config.max_send_attempts {
278 let failed_msg = {
279 let mut state = STATE.lock().await;
280 state.update_message(pending_id, |msg| {
281 msg.set_failed(true);
282 msg.set_pending(false);
283 })
284 };
285 if let Some((_chat_id, ref msg)) = failed_msg {
286 callback.on_failed(receiver_npub, pending_id, msg);
287 callback.on_persist(receiver_npub, msg);
288 }
289 return Err(format!("Failed to send DM after {} attempts: {}", config.max_send_attempts, e));
290 }
291 tokio::time::sleep(config.retry_delay).await;
292 }
293 }
294 }
295
296 Err("Send loop exited unexpectedly".to_string())
297}
298
299pub async fn send_dm(
308 receiver_npub: &str,
309 content: &str,
310 reply_to: Option<&str>,
311 config: &SendConfig,
312 callback: Arc<dyn SendCallback>,
313) -> Result<SendResult, String> {
314 let client = nostr_client().ok_or("Not logged in")?;
315 let my_pk = my_public_key().ok_or("Public key not set")?;
316
317 let now = std::time::SystemTime::now()
318 .duration_since(std::time::UNIX_EPOCH).unwrap();
319 let pending_id = format!("pending-{}", now.as_nanos());
320
321 let receiver = PublicKey::from_bech32(receiver_npub)
322 .map_err(|e| format!("Invalid npub: {}", e))?;
323
324 let emoji_tags = crate::emoji_packs::resolve_outbound_emoji_tags(content);
329
330 let msg = Message {
332 id: pending_id.clone(),
333 content: content.to_string(),
334 replied_to: reply_to.unwrap_or("").to_string(),
335 at: now.as_millis() as u64,
336 pending: true,
337 mine: true,
338 npub: my_pk.to_bech32().ok(),
339 emoji_tags: emoji_tags.clone(),
340 ..Default::default()
341 };
342
343 {
344 let mut state = STATE.lock().await;
345 state.add_message_to_participant(receiver_npub, msg.clone());
346 }
347
348 callback.on_pending(receiver_npub, &msg);
349
350 let milliseconds = now.as_millis() % 1000;
352 let mut rumor = EventBuilder::private_msg_rumor(receiver, content);
353
354 if let Some(reply_id) = reply_to {
355 if !reply_id.is_empty() {
356 rumor = rumor.tag(Tag::custom(
357 TagKind::e(),
358 [reply_id.to_string(), String::new(), "reply".to_string()],
359 ));
360 }
361 }
362
363 let mut rumor = rumor.tag(Tag::custom(TagKind::custom("ms"), [milliseconds.to_string()]));
364 for et in &emoji_tags {
365 rumor = rumor.tag(Tag::custom(
366 TagKind::custom("emoji"),
367 [et.shortcode.clone(), et.url.clone()],
368 ));
369 }
370 let built_rumor = rumor.build(my_pk);
371 let event_id = built_rumor.id.ok_or("Rumor has no id")?.to_hex();
372
373 retry_send_gift_wrap(
375 &client, &receiver, receiver_npub, &pending_id,
376 built_rumor, &event_id, config, callback,
377 ).await
378}
379
380pub async fn send_rumor_dm(
388 receiver_npub: &str,
389 pending_id: &str,
390 rumor: UnsignedEvent,
391 config: &SendConfig,
392 callback: Arc<dyn SendCallback>,
393) -> Result<SendResult, String> {
394 let client = nostr_client().ok_or("Not logged in")?;
395
396 let receiver = PublicKey::from_bech32(receiver_npub)
397 .map_err(|e| format!("Invalid npub: {}", e))?;
398
399 let event_id = rumor.id.ok_or("Rumor has no id")?.to_hex();
400
401 retry_send_gift_wrap(
402 &client, &receiver, receiver_npub, pending_id,
403 rumor, &event_id, config, callback,
404 ).await
405}
406
407pub async fn send_file_dm(
415 receiver_npub: &str,
416 file_bytes: Arc<Vec<u8>>,
417 filename: &str,
418 extension: &str,
419 content: Option<&str>,
420 config: &SendConfig,
421 callback: Arc<dyn SendCallback>,
422) -> Result<SendResult, String> {
423 let client = nostr_client().ok_or("Not logged in")?;
424 let my_pk = my_public_key().ok_or("Public key not set")?;
425 let signer = client.signer().await
429 .map_err(|e| format!("Signer unavailable: {}", e))?;
430
431 let now = std::time::SystemTime::now()
432 .duration_since(std::time::UNIX_EPOCH).unwrap();
433 let pending_id = format!("pending-{}", now.as_nanos());
434 let milliseconds = now.as_millis() % 1000;
435
436 let receiver = PublicKey::from_bech32(receiver_npub)
437 .map_err(|e| format!("Invalid npub: {}", e))?;
438
439 let file_hash = crypto::sha256_hex(&file_bytes);
440 let mime_type = crypto::mime_from_extension(extension);
441
442 let webxdc_topic = (extension.eq_ignore_ascii_case("xdc"))
447 .then(|| crate::webxdc::mint_topic_id(&file_hash, &my_pk.to_hex()));
448
449 let download_dir = crate::db::get_download_dir();
451 let _ = std::fs::create_dir_all(&download_dir);
452 let local_name = if filename.is_empty() {
459 format!("{}.{}", &file_hash, extension)
460 } else {
461 let stem = filename.rsplit_once('.').map(|(s, _)| s).unwrap_or(filename);
462 format!("{}.{}", stem, extension)
463 };
464 let local_path = crypto::resolve_unique_filename(&download_dir, &local_name);
466 let tmp = download_dir.join(format!(".{}.tmp", &file_hash));
468 let _ = std::fs::write(&tmp, &*file_bytes);
469 let _ = std::fs::rename(&tmp, &local_path);
470 let local_path_str = local_path.to_string_lossy().to_string();
471
472 let img_meta = crypto::generate_image_metadata(&file_bytes);
474
475 let params = crypto::generate_encryption_params();
477 let encrypted = crypto::encrypt_data(&file_bytes, ¶ms)?;
478 let encrypted_size = encrypted.len() as u64;
479
480 let attachment = Attachment {
481 id: file_hash.clone(), key: params.key.clone(), nonce: params.nonce.clone(),
482 extension: extension.to_string(), name: filename.to_string(),
483 url: String::new(), path: local_path_str.clone(), size: encrypted_size,
484 img_meta: img_meta.clone(), downloading: false, downloaded: true,
485 webxdc_topic: webxdc_topic.clone(),
486 ..Default::default()
487 };
488 let msg = Message {
489 id: pending_id.clone(), content: content.unwrap_or("").to_string(),
490 at: now.as_millis() as u64, pending: true, mine: true,
491 npub: my_pk.to_bech32().ok(), attachments: vec![attachment],
492 ..Default::default()
493 };
494 {
495 let mut state = STATE.lock().await;
496 state.add_message_to_participant(receiver_npub, msg.clone());
497 }
498 callback.on_pending(receiver_npub, &msg);
499
500 let servers = crate::state::get_blossom_servers();
502 let cb_for_progress = callback.clone();
503 let pid_for_progress = pending_id.clone();
504 let progress_cb: crate::blossom::ProgressCallback = Arc::new(move |percentage, bytes| {
505 cb_for_progress.on_upload_progress(
506 &pid_for_progress,
507 percentage.unwrap_or(0),
508 bytes.unwrap_or(0),
509 )
510 });
511
512 let upload_url = match crate::blossom::upload_blob_with_progress_and_failover(
516 signer.clone(), servers, Arc::new(encrypted), Some(mime_type),
517 true,
518 progress_cb, Some(config.upload_retries), Some(config.upload_retry_delay),
519 config.cancel_token.clone(),
520 ).await {
521 Ok(url) => url,
522 Err(e) => {
523 let failed_msg = {
524 let mut state = STATE.lock().await;
525 state.update_message(&pending_id, |msg| {
526 msg.set_failed(true);
527 msg.set_pending(false);
528 })
529 };
530 if let Some((_chat_id, ref msg)) = failed_msg {
531 callback.on_failed(receiver_npub, &pending_id, msg);
532 callback.on_persist(receiver_npub, msg);
533 }
534 return Err(format!("Upload failed: {}", e));
535 }
536 };
537
538 {
539 let mut state = STATE.lock().await;
540 state.update_message(&pending_id, |msg| {
541 if let Some(att) = msg.attachments.last_mut() {
542 att.url = upload_url.clone().into_boxed_str();
543 }
544 });
545 }
546 callback.on_upload_complete(receiver_npub, &pending_id, &file_hash, &upload_url);
547
548 let mut file_rumor = EventBuilder::new(Kind::from_u16(15), &upload_url)
550 .tag(Tag::public_key(receiver))
551 .tag(Tag::custom(TagKind::custom("file-type"), [mime_type]))
552 .tag(Tag::custom(TagKind::custom("size"), [encrypted_size.to_string()]))
553 .tag(Tag::custom(TagKind::custom("encryption-algorithm"), ["aes-gcm"]))
554 .tag(Tag::custom(TagKind::custom("decryption-key"), [params.key.as_str()]))
555 .tag(Tag::custom(TagKind::custom("decryption-nonce"), [params.nonce.as_str()]))
556 .tag(Tag::custom(TagKind::custom("ox"), [file_hash.clone()]));
557 if !filename.is_empty() {
558 file_rumor = file_rumor.tag(Tag::custom(TagKind::custom("name"), [filename]));
559 }
560 if let Some(ref topic) = webxdc_topic {
561 file_rumor = file_rumor.tag(Tag::custom(TagKind::custom("webxdc-topic"), [topic.as_str()]));
562 }
563 if let Some(ref meta) = img_meta {
565 if !meta.thumbhash.is_empty() {
566 file_rumor = file_rumor.tag(Tag::custom(TagKind::custom("thumb"), [meta.thumbhash.as_str()]));
567 }
568 file_rumor = file_rumor.tag(Tag::custom(TagKind::custom("dim"), [format!("{}x{}", meta.width, meta.height)]));
569 }
570 file_rumor = file_rumor.tag(Tag::custom(TagKind::custom("ms"), [milliseconds.to_string()]));
571
572 let built_rumor = file_rumor.build(my_pk);
573 let event_id = built_rumor.id.ok_or("Rumor has no id")?.to_hex();
574
575 retry_send_gift_wrap(
576 &client, &receiver, receiver_npub, &pending_id,
577 built_rumor, &event_id, config, callback,
578 ).await
579}
580
581#[cfg(test)]
586mod tests {
587 use super::*;
588 use std::sync::Mutex;
589
590 #[derive(Debug, Clone, PartialEq)]
591 enum CbEvent {
592 Pending(String),
593 UploadProgress(String, u8, u64),
594 UploadComplete(String, String),
595 Sent(String, String),
596 Failed(String, String),
597 Persist(String),
598 }
599
600 struct MockCallback {
601 events: Mutex<Vec<CbEvent>>,
602 cancel_at: Option<u8>,
603 }
604
605 impl MockCallback {
606 fn new() -> Self { Self { events: Mutex::new(vec![]), cancel_at: None } }
607 fn with_cancel(pct: u8) -> Self { Self { events: Mutex::new(vec![]), cancel_at: Some(pct) } }
608 fn events(&self) -> Vec<CbEvent> { self.events.lock().unwrap().clone() }
609 }
610
611 impl SendCallback for MockCallback {
612 fn on_pending(&self, cid: &str, _: &Message) {
613 self.events.lock().unwrap().push(CbEvent::Pending(cid.into()));
614 }
615 fn on_upload_progress(&self, pid: &str, pct: u8, bytes: u64) -> Result<(), String> {
616 self.events.lock().unwrap().push(CbEvent::UploadProgress(pid.into(), pct, bytes));
617 if self.cancel_at.map_or(false, |c| pct >= c) { return Err("Cancelled".into()); }
618 Ok(())
619 }
620 fn on_upload_complete(&self, cid: &str, _: &str, _: &str, url: &str) {
621 self.events.lock().unwrap().push(CbEvent::UploadComplete(cid.into(), url.into()));
622 }
623 fn on_sent(&self, cid: &str, old: &str, _: &Message) {
624 self.events.lock().unwrap().push(CbEvent::Sent(cid.into(), old.into()));
625 }
626 fn on_failed(&self, cid: &str, old: &str, _: &Message) {
627 self.events.lock().unwrap().push(CbEvent::Failed(cid.into(), old.into()));
628 }
629 fn on_persist(&self, cid: &str, _: &Message) {
630 self.events.lock().unwrap().push(CbEvent::Persist(cid.into()));
631 }
632 }
633
634 #[test]
635 fn config_default() {
636 let c = SendConfig::default();
637 assert_eq!(c.max_send_attempts, 1);
638 assert!(!c.self_send);
639 assert!(c.cancel_token.is_none());
640 assert_eq!(c.upload_retries, 3);
641 }
642
643 #[test]
644 fn config_gui() {
645 let c = SendConfig::gui();
646 assert_eq!(c.max_send_attempts, 12);
647 assert!(c.self_send);
648 }
649
650 #[test]
651 fn config_headless() {
652 let c = SendConfig::headless();
653 assert_eq!(c.max_send_attempts, 3);
654 assert!(c.self_send);
655 }
656
657 #[test]
658 fn config_custom_override() {
659 let c = SendConfig { max_send_attempts: 5, ..SendConfig::gui() };
660 assert_eq!(c.max_send_attempts, 5);
661 assert!(c.self_send);
662 }
663
664 #[test]
665 fn noop_callback_all_methods() {
666 let cb = NoOpSendCallback;
667 let msg = Message::default();
668 cb.on_pending("c", &msg);
669
670 assert!(cb.on_upload_progress("p", 50, 1024).is_ok());
671 cb.on_upload_complete("c", "p", "a", "url");
672 cb.on_sent("c", "o", &msg);
673 cb.on_failed("c", "o", &msg);
674 cb.on_persist("c", &msg);
675 }
676
677 #[test]
678 fn text_dm_sequence() {
679 let cb = MockCallback::new();
680 let msg = Message::default();
681 cb.on_pending("r", &msg);
682 cb.on_sent("r", "p-1", &msg);
683 cb.on_persist("r", &msg);
684 assert_eq!(cb.events(), vec![
685 CbEvent::Pending("r".into()),
686 CbEvent::Sent("r".into(), "p-1".into()),
687 CbEvent::Persist("r".into()),
688 ]);
689 }
690
691 #[test]
692 fn file_dm_sequence() {
693 let cb = MockCallback::new();
694 let msg = Message::default();
695 cb.on_pending("r", &msg);
696
697 cb.on_upload_progress("p", 0, 0).ok();
698 cb.on_upload_progress("p", 50, 5000).ok();
699 cb.on_upload_progress("p", 100, 10000).ok();
700 cb.on_upload_complete("r", "p", "h", "https://blossom/h");
701 cb.on_sent("r", "p", &msg);
702 cb.on_persist("r", &msg);
703 let e = cb.events();
704 assert_eq!(e.len(), 7);
705 assert!(matches!(&e[4], CbEvent::UploadComplete(_, url) if url.contains("blossom")));
706 }
707
708 #[test]
709 fn failed_sequence() {
710 let cb = MockCallback::new();
711 let msg = Message::default();
712 cb.on_pending("r", &msg);
713 cb.on_failed("r", "p", &msg);
714 cb.on_persist("r", &msg);
715 assert_eq!(cb.events(), vec![
716 CbEvent::Pending("r".into()),
717 CbEvent::Failed("r".into(), "p".into()),
718 CbEvent::Persist("r".into()),
719 ]);
720 }
721
722 #[test]
723 fn cancel_upload_at_threshold() {
724 let cb = MockCallback::with_cancel(50);
725 assert!(cb.on_upload_progress("p", 25, 512).is_ok());
726 assert!(cb.on_upload_progress("p", 50, 1024).is_err());
727 assert_eq!(cb.events().len(), 2);
728 }
729
730 #[test]
731 fn cancel_triggers_failed() {
732 let cb = MockCallback::with_cancel(30);
733 let msg = Message::default();
734 cb.on_pending("r", &msg);
735
736 cb.on_upload_progress("p", 10, 1000).ok();
737 assert!(cb.on_upload_progress("p", 30, 3000).is_err());
738 cb.on_failed("r", "p", &msg);
739 assert!(matches!(cb.events().last(), Some(CbEvent::Failed(..))));
740 }
741
742 #[test]
743 fn send_result_serialize() {
744 let r = SendResult { pending_id: "p".into(), event_id: Some("e".into()), chat_id: "c".into() };
745 let j = serde_json::to_string(&r).unwrap();
746 assert!(j.contains("\"pending_id\":\"p\""));
747 }
748
749 #[test]
750 fn send_result_none_event() {
751 let r = SendResult { pending_id: "p".into(), event_id: None, chat_id: "c".into() };
752 let j = serde_json::to_string(&r).unwrap();
753 assert!(j.contains("null"));
754 }
755
756 #[test]
761 fn file_dm_fresh_upload_full_sequence() {
762 let cb = MockCallback::new();
763 let msg = Message::default();
764
765 cb.on_pending("npub1recv", &msg);
767 cb.on_upload_progress("pending-42", 0, 0).unwrap();
771 cb.on_upload_progress("pending-42", 25, 2500).unwrap();
772 cb.on_upload_progress("pending-42", 50, 5000).unwrap();
773 cb.on_upload_progress("pending-42", 75, 7500).unwrap();
774 cb.on_upload_progress("pending-42", 100, 10000).unwrap();
775 cb.on_upload_complete("npub1recv", "pending-42", "deadbeef", "https://blossom.example/deadbeef");
777 cb.on_sent("npub1recv", "pending-42", &msg);
779 cb.on_persist("npub1recv", &msg);
781
782 let e = cb.events();
783 assert_eq!(e.len(), 9);
784 assert!(matches!(&e[0], CbEvent::Pending(c) if c == "npub1recv"));
785 assert!(matches!(&e[1], CbEvent::UploadProgress(_, 0, 0)));
786 assert!(matches!(&e[5], CbEvent::UploadProgress(_, 100, 10000)));
787 assert!(matches!(&e[6], CbEvent::UploadComplete(_, url) if url.contains("deadbeef")));
788 assert!(matches!(&e[7], CbEvent::Sent(..)));
789 assert!(matches!(&e[8], CbEvent::Persist(..)));
790 }
791
792 #[test]
793 fn file_dm_skip_upload_sequence() {
794 let cb = MockCallback::new();
795 let msg = Message::default();
796
797 cb.on_pending("npub1recv", &msg);
800 cb.on_upload_complete("npub1recv", "pending-99", "existinghash", "https://blossom.example/existing");
804 cb.on_sent("npub1recv", "pending-99", &msg);
806 cb.on_persist("npub1recv", &msg);
808
809 let e = cb.events();
810 assert_eq!(e.len(), 4);
811 assert!(!e.iter().any(|ev| matches!(ev, CbEvent::UploadProgress(..))));
813 assert!(matches!(&e[1], CbEvent::UploadComplete(..)));
814 }
815
816 #[test]
817 fn file_dm_upload_cancelled_at_30pct() {
818 let cb = MockCallback::with_cancel(30);
819 let msg = Message::default();
820
821 cb.on_pending("npub1recv", &msg);
822
823 assert!(cb.on_upload_progress("p", 10, 1000).is_ok());
824 assert!(cb.on_upload_progress("p", 20, 2000).is_ok());
825 let err = cb.on_upload_progress("p", 30, 3000);
827 assert!(err.is_err());
828 assert!(err.unwrap_err().contains("Cancelled"));
829 cb.on_failed("npub1recv", "p", &msg);
831
832 let e = cb.events();
833 assert_eq!(e.len(), 5);
834 assert!(!e.iter().any(|ev| matches!(ev, CbEvent::Sent(..))));
836 assert!(matches!(e.last(), Some(CbEvent::Failed(..))));
837 }
838
839 #[test]
840 fn file_dm_upload_fails_marks_failed() {
841 let cb = MockCallback::new();
842 let msg = Message::default();
843
844 cb.on_pending("npub1recv", &msg);
845
846 cb.on_upload_progress("p", 0, 0).ok();
847 cb.on_upload_progress("p", 10, 500).ok();
848 cb.on_failed("npub1recv", "p", &msg);
850 cb.on_persist("npub1recv", &msg);
851
852 let e = cb.events();
853 assert_eq!(e.len(), 5);
854 assert!(matches!(&e[3], CbEvent::Failed(..)));
855 assert!(matches!(&e[4], CbEvent::Persist(..)));
856 assert!(!e.iter().any(|ev| matches!(ev, CbEvent::UploadComplete(..))));
858 assert!(!e.iter().any(|ev| matches!(ev, CbEvent::Sent(..))));
859 }
860
861 #[test]
862 fn file_dm_gift_wrap_fails_after_upload() {
863 let cb = MockCallback::new();
864 let msg = Message::default();
865
866 cb.on_pending("npub1recv", &msg);
868
869 cb.on_upload_progress("p", 100, 10000).ok();
870 cb.on_upload_complete("npub1recv", "p", "hash", "https://blossom/hash");
871 cb.on_failed("npub1recv", "p", &msg);
873 cb.on_persist("npub1recv", &msg);
874
875 let e = cb.events();
876 assert_eq!(e.len(), 5);
877 assert!(matches!(&e[2], CbEvent::UploadComplete(..)));
879 assert!(matches!(&e[3], CbEvent::Failed(..)));
880 }
881
882 #[test]
883 fn file_dm_with_image_metadata_sequence() {
884 let cb = MockCallback::new();
885 let msg = Message::default();
886
887 cb.on_pending("npub1recv", &msg);
889
890 cb.on_upload_progress("p", 0, 0).ok();
891 cb.on_upload_progress("p", 50, 50000).ok();
892 cb.on_upload_progress("p", 100, 100000).ok();
893 cb.on_upload_complete("npub1recv", "p", "imghash", "https://blossom/imghash.jpg");
894 cb.on_sent("npub1recv", "p", &msg);
895 cb.on_persist("npub1recv", &msg);
896
897 let e = cb.events();
898 assert_eq!(e.len(), 7);
899 assert!(matches!(&e[0], CbEvent::Pending(..)));
901 assert!(matches!(&e[4], CbEvent::UploadComplete(_, url) if url.ends_with(".jpg")));
902 assert!(matches!(&e[5], CbEvent::Sent(..)));
903 }
904
905 #[test]
906 fn cancel_token_config_with_upload() {
907 let token = Arc::new(std::sync::atomic::AtomicBool::new(false));
908 let c = SendConfig {
909 cancel_token: Some(token.clone()),
910 ..SendConfig::gui()
911 };
912 assert!(c.cancel_token.is_some());
913 assert!(!token.load(std::sync::atomic::Ordering::Relaxed));
914
915 token.store(true, std::sync::atomic::Ordering::Relaxed);
917 assert!(c.cancel_token.as_ref().unwrap().load(std::sync::atomic::Ordering::Relaxed));
918 }
919}