1use crate::event_ext::FinalizeUnsignedWithId;
8use std::ops::Range;
9use std::sync::Arc;
10use std::sync::atomic::{AtomicBool, Ordering};
11use nostr_sdk::prelude::*;
12
13pub const NIP59_RANDOM_TIMESTAMP_TWEAK: Range<u64> = 0..172_800;
19
20use crate::state::{nostr_client, my_public_key, STATE};
21use crate::types::{Message, Attachment};
22use crate::crypto;
23
24pub trait SendCallback: Send + Sync {
36 fn on_pending(&self, _chat_id: &str, _msg: &Message) {}
38
39 fn on_upload_progress(
41 &self,
42 _pending_id: &str,
43 _percentage: u8,
44 _bytes_sent: u64,
45 ) -> Result<(), String> {
46 Ok(())
47 }
48
49 fn on_upload_complete(&self, _chat_id: &str, _pending_id: &str, _attachment_id: &str, _url: &str) {}
51
52 fn on_sent(&self, _chat_id: &str, _old_id: &str, _msg: &Message) {}
55
56 fn on_failed(&self, _chat_id: &str, _old_id: &str, _msg: &Message) {}
58
59 fn on_persist(&self, _chat_id: &str, _msg: &Message) {}
62}
63
64pub struct NoOpSendCallback;
66impl SendCallback for NoOpSendCallback {}
67
68pub struct SendConfig {
74 pub max_send_attempts: u32,
76 pub retry_delay: std::time::Duration,
78 pub self_send: bool,
80 pub cancel_token: Option<Arc<AtomicBool>>,
82 pub upload_retries: u32,
84 pub upload_retry_delay: std::time::Duration,
86 pub expiration: Option<u64>,
90}
91
92impl Default for SendConfig {
93 fn default() -> Self {
94 Self {
95 max_send_attempts: 1,
96 retry_delay: std::time::Duration::from_secs(5),
97 self_send: false,
98 cancel_token: None,
99 upload_retries: 3,
100 upload_retry_delay: std::time::Duration::from_secs(2),
101 expiration: None,
102 }
103 }
104}
105
106impl SendConfig {
107 pub fn gui() -> Self {
109 Self {
110 max_send_attempts: 12,
111 self_send: true,
112 ..Default::default()
113 }
114 }
115
116 pub fn headless() -> Self {
118 Self {
119 max_send_attempts: 3,
120 self_send: true,
121 ..Default::default()
122 }
123 }
124}
125
126#[derive(serde::Serialize, Clone, Debug)]
132pub struct SendResult {
133 pub pending_id: String,
135 pub event_id: Option<String>,
137 pub chat_id: String,
139}
140
141struct WrapConfirm {
158 wrap_id: EventId,
159 chat_id: String,
160 pending_id: String,
161 rumor_event_id: String,
163 rumor: UnsignedEvent,
164 callback: Arc<dyn SendCallback>,
165 self_send: bool,
166 confirmed: AtomicBool,
167 rescued: AtomicBool,
170 loop_exited: AtomicBool,
173 notify: tokio::sync::Notify,
174 session: crate::state::SessionGuard,
175 registered_at: std::time::Instant,
176}
177
178static WRAP_CONFIRMS: std::sync::LazyLock<
179 std::sync::Mutex<std::collections::HashMap<EventId, Arc<WrapConfirm>>>,
180> = std::sync::LazyLock::new(|| std::sync::Mutex::new(std::collections::HashMap::new()));
181
182const WRAP_CONFIRM_TTL: std::time::Duration = std::time::Duration::from_secs(15 * 60);
185
186fn register_wrap_confirm(entry: Arc<WrapConfirm>) {
187 let mut map = WRAP_CONFIRMS.lock().unwrap();
188 map.retain(|_, e| e.registered_at.elapsed() < WRAP_CONFIRM_TTL);
189 map.insert(entry.wrap_id, entry);
190}
191
192fn remove_wrap_confirm(wrap_id: &EventId) {
193 WRAP_CONFIRMS.lock().unwrap().remove(wrap_id);
194}
195
196pub fn clear_wrap_confirms() {
198 WRAP_CONFIRMS.lock().unwrap().clear();
199}
200
201pub fn note_relay_ok(event_id: &EventId, accepted: bool) {
207 if !accepted {
208 return;
209 }
210 let entry = {
211 let map = WRAP_CONFIRMS.lock().unwrap();
212 map.get(event_id).cloned()
213 };
214 let Some(entry) = entry else { return };
215 entry.confirmed.store(true, Ordering::SeqCst);
216 entry.notify.notify_one();
217 if !entry.loop_exited.load(Ordering::SeqCst)
218 || entry.rescued.swap(true, Ordering::SeqCst)
219 {
220 return;
221 }
222 if !entry.session.is_valid() {
223 remove_wrap_confirm(&entry.wrap_id);
224 return;
225 }
226 tokio::spawn(async move {
227 rescue_failed_as_sent(&entry).await;
228 remove_wrap_confirm(&entry.wrap_id);
229 });
230}
231
232async fn rescue_failed_as_sent(entry: &WrapConfirm) {
235 if !entry.session.is_valid() {
236 return;
237 }
238 let finalized = {
239 let mut state = STATE.lock().await;
240 state.update_message(&entry.pending_id, |msg| {
241 msg.set_failed(false);
242 });
243 state.finalize_pending_message(&entry.chat_id, &entry.pending_id, &entry.rumor_event_id)
244 };
245 let Some((_old_id, ref msg)) = finalized else { return };
246 crate::log_info!(
247 "[Send] late relay OK confirmed wrap {} — message {} rescued to sent",
248 entry.wrap_id,
249 entry.rumor_event_id,
250 );
251 entry.callback.on_sent(&entry.chat_id, &entry.pending_id, msg);
252 entry.callback.on_persist(&entry.chat_id, msg);
253 if let Some(rid) = entry.rumor.id {
255 let _ = crate::db::nip17_keys::clear_resend_payload(&rid);
256 }
257 if entry.self_send {
258 if let (Some(client), Some(my_pk)) = (nostr_client(), my_public_key()) {
259 spawn_self_send(client, my_pk, entry.rumor.clone());
260 }
261 }
262}
263
264fn spawn_self_send(client: Client, my_pk: PublicKey, rumor: UnsignedEvent) {
268 let rid_for_self = rumor.id;
269 let session = crate::state::SessionGuard::capture();
270 tokio::spawn(async move {
271 if !session.is_valid() { return; }
272 match crate::inbox_relays::send_gift_wrap_retained(
273 &client, &my_pk, rumor, [],
274 ).await {
275 Ok(self_outcome) if !self_outcome.output.success.is_empty() => {
276 if !session.is_valid() { return; }
277 if let Some(rid) = rid_for_self {
278 if let Err(e) = crate::db::nip17_keys::store_wrap_key(
279 &self_outcome.wrap_event_id,
280 &rid,
281 &my_pk,
282 crate::db::nip17_keys::WrapRole::SelfSend,
283 &self_outcome.wrap_secret,
284 &self_outcome.targeted_relays,
285 ) {
286 eprintln!("[NIP-17] failed to persist self-wrap key: {}", e);
287 }
288 }
289 }
290 _ => {}
291 }
292 });
293}
294
295async fn retry_send_gift_wrap(
310 client: &Client,
311 receiver: &PublicKey,
312 receiver_npub: &str,
313 pending_id: &str,
314 rumor: UnsignedEvent,
315 event_id: &str,
316 config: &SendConfig,
317 callback: Arc<dyn SendCallback>,
318 prebuilt: Option<crate::inbox_relays::BuiltGiftWrap>,
322) -> Result<SendResult, String> {
323 let my_pk = my_public_key().ok_or("Public key not set")?;
324 let inner_rumor_id = rumor.id;
325
326 let is_resend = prebuilt.is_some();
329 let mut built: Option<crate::inbox_relays::BuiltGiftWrap> = prebuilt;
332 let mut targets: Option<crate::inbox_relays::GiftWrapTargets> = None;
335 let mut confirm: Option<Arc<WrapConfirm>> = None;
336 let mut last_error: Option<String> = None;
337
338 let max_attempts = config.max_send_attempts.max(1);
339
340 for attempt in 0..max_attempts {
341 if built.is_none() {
342 let wrap_extra: Vec<Tag> = rumor.tags.iter()
345 .find(|t| t.as_slice().first().map(|k| k.as_str() == "expiration").unwrap_or(false))
346 .cloned()
347 .into_iter()
348 .collect();
349 match crate::inbox_relays::build_gift_wrap_retained(
350 client, receiver, rumor.clone(), wrap_extra,
351 ).await {
352 Ok(b) => built = Some(b),
353 Err(e) => {
354 crate::log_warn!(
355 "[Send] attempt {}/{} — building gift-wrap failed: {}",
356 attempt + 1, max_attempts, e,
357 );
358 last_error = Some(e);
359 if attempt + 1 < max_attempts {
360 tokio::time::sleep(config.retry_delay).await;
361 }
362 continue;
363 }
364 }
365 }
366 let wrap = built.as_ref().unwrap();
367
368 if confirm.is_none() {
369 let entry = Arc::new(WrapConfirm {
370 wrap_id: wrap.event.id,
371 chat_id: receiver_npub.to_string(),
372 pending_id: pending_id.to_string(),
373 rumor_event_id: event_id.to_string(),
374 rumor: rumor.clone(),
375 callback: callback.clone(),
376 self_send: config.self_send,
377 confirmed: AtomicBool::new(false),
378 rescued: AtomicBool::new(false),
379 loop_exited: AtomicBool::new(false),
380 notify: tokio::sync::Notify::new(),
381 session: crate::state::SessionGuard::capture(),
382 registered_at: std::time::Instant::now(),
383 });
384 register_wrap_confirm(entry.clone());
385 confirm = Some(entry);
386 }
387 let confirm_ref = confirm.as_ref().unwrap();
388
389 if targets.is_none() {
390 let t = crate::inbox_relays::resolve_gift_wrap_targets(client, receiver).await;
391 if !is_resend {
395 if let Some(rid) = inner_rumor_id {
396 if let Err(e) = crate::db::nip17_keys::store_wrap_key(
397 &wrap.event.id,
398 &rid,
399 receiver,
400 crate::db::nip17_keys::WrapRole::Recipient,
401 &wrap.secret,
402 &t.targeted_relays,
403 ) {
404 eprintln!("[NIP-17] failed to persist wrap key: {}", e);
405 }
406 if let Err(e) = crate::db::nip17_keys::stash_resend_payload(
407 &wrap.event.id, pending_id, &wrap.event, &rumor,
408 ) {
409 eprintln!("[NIP-17] failed to retain resend payload: {}", e);
410 }
411 }
412 }
413 targets = Some(t);
414 } else {
415 crate::inbox_relays::reconnect_gift_wrap_targets(targets.as_ref().unwrap()).await;
416 }
417 let targets_ref = targets.as_ref().unwrap();
418
419 match crate::inbox_relays::publish_gift_wrap_to_targets(
420 client, targets_ref, &wrap.event,
421 ).await {
422 Ok(output) if !output.success.is_empty() => {
423 return Ok(finalize_gift_wrap_sent(
424 client, my_pk, receiver_npub, pending_id, event_id,
425 &rumor, config, &callback, confirm_ref, targets_ref,
426 ).await);
427 }
428 Ok(output) => {
429 let failures: Vec<String> = output.failed.iter()
434 .map(|(url, err)| format!("{}: {}", url, err))
435 .collect();
436 crate::log_warn!(
437 "[Send] attempt {}/{} — 0 of {} relays accepted (targeted: {}). Per-relay errors: {}",
438 attempt + 1,
439 max_attempts,
440 targets_ref.targeted_relays.len(),
441 targets_ref.targeted_relays.join(", "),
442 if failures.is_empty() {
443 "(none reported — likely all timed out before responding)".to_string()
444 } else {
445 failures.join(" | ")
446 },
447 );
448 last_error = None;
449 }
450 Err(e) => {
451 crate::log_warn!(
452 "[Send] attempt {}/{} — publish errored: {}",
453 attempt + 1, max_attempts, e,
454 );
455 last_error = Some(e);
456 }
457 }
458
459 if confirm_ref.confirmed.load(Ordering::SeqCst) {
462 return Ok(finalize_gift_wrap_sent(
463 client, my_pk, receiver_npub, pending_id, event_id,
464 &rumor, config, &callback, confirm_ref, targets_ref,
465 ).await);
466 }
467
468 if attempt + 1 < max_attempts {
469 let _ = tokio::time::timeout(
473 config.retry_delay,
474 confirm_ref.notify.notified(),
475 ).await;
476 if confirm_ref.confirmed.load(Ordering::SeqCst) {
477 return Ok(finalize_gift_wrap_sent(
478 client, my_pk, receiver_npub, pending_id, event_id,
479 &rumor, config, &callback, confirm_ref, targets_ref,
480 ).await);
481 }
482 }
483 }
484
485 if let Some(t) = targets.as_ref() {
489 crate::inbox_relays::teardown_gift_wrap_targets(client, t).await;
490 }
491 let failed_msg = {
492 let mut state = STATE.lock().await;
493 state.update_message(pending_id, |msg| {
494 msg.set_failed(true);
495 msg.set_pending(false);
496 })
497 };
498 if let Some((_chat_id, ref msg)) = failed_msg {
499 callback.on_failed(receiver_npub, pending_id, msg);
500 callback.on_persist(receiver_npub, msg);
501 }
502 if let Some(entry) = confirm.as_ref() {
503 entry.loop_exited.store(true, Ordering::SeqCst);
504 if entry.confirmed.load(Ordering::SeqCst)
507 && !entry.rescued.swap(true, Ordering::SeqCst)
508 {
509 rescue_failed_as_sent(entry).await;
510 remove_wrap_confirm(&entry.wrap_id);
511 return Ok(SendResult {
512 pending_id: pending_id.to_string(),
513 event_id: Some(event_id.to_string()),
514 chat_id: receiver_npub.to_string(),
515 });
516 }
517 }
518 match last_error {
519 Some(e) => Err(format!("Failed to send DM after {} attempts: {}", max_attempts, e)),
520 None => Err(format!(
521 "Failed to send DM after {} attempts (no relays accepted the gift-wrap)",
522 max_attempts
523 )),
524 }
525}
526
527async fn finalize_gift_wrap_sent(
530 client: &Client,
531 my_pk: PublicKey,
532 receiver_npub: &str,
533 pending_id: &str,
534 event_id: &str,
535 rumor: &UnsignedEvent,
536 config: &SendConfig,
537 callback: &Arc<dyn SendCallback>,
538 confirm: &Arc<WrapConfirm>,
539 targets: &crate::inbox_relays::GiftWrapTargets,
540) -> SendResult {
541 remove_wrap_confirm(&confirm.wrap_id);
542 crate::inbox_relays::teardown_gift_wrap_targets(client, targets).await;
543
544 let finalized = {
545 let mut state = STATE.lock().await;
546 state.finalize_pending_message(receiver_npub, pending_id, event_id)
547 };
548 if let Some((_old_id, ref finalized_msg)) = finalized {
549 callback.on_sent(receiver_npub, pending_id, finalized_msg);
550 callback.on_persist(receiver_npub, finalized_msg);
551 }
552
553 if let Some(rid) = rumor.id {
556 let _ = crate::db::nip17_keys::clear_resend_payload(&rid);
557 }
558
559 if config.self_send {
560 spawn_self_send(client.clone(), my_pk, rumor.clone());
561 }
562
563 SendResult {
564 pending_id: pending_id.to_string(),
565 event_id: Some(event_id.to_string()),
566 chat_id: receiver_npub.to_string(),
567 }
568}
569
570pub async fn send_dm(
579 receiver_npub: &str,
580 content: &str,
581 reply_to: Option<&str>,
582 config: &SendConfig,
583 callback: Arc<dyn SendCallback>,
584) -> Result<SendResult, String> {
585 let client = nostr_client().ok_or("Not logged in")?;
586 let my_pk = my_public_key().ok_or("Public key not set")?;
587
588 let now = std::time::SystemTime::now()
589 .duration_since(std::time::UNIX_EPOCH).unwrap();
590 let pending_id = format!("pending-{}", now.as_nanos());
591
592 let receiver = PublicKey::from_bech32(receiver_npub)
593 .map_err(|e| format!("Invalid npub: {}", e))?;
594
595 let emoji_tags = crate::emoji_packs::resolve_outbound_emoji_tags(content);
600
601 let msg = Message {
603 id: pending_id.clone(),
604 content: content.to_string(),
605 replied_to: reply_to.unwrap_or("").to_string(),
606 at: now.as_millis() as u64,
607 pending: true,
608 mine: true,
609 npub: my_pk.to_bech32().ok(),
610 emoji_tags: emoji_tags.clone(),
611 expiration: config.expiration,
612 ..Default::default()
613 };
614
615 {
616 let mut state = STATE.lock().await;
617 state.add_message_to_participant(receiver_npub, &msg);
618 }
619
620 callback.on_pending(receiver_npub, &msg);
621
622 let milliseconds = now.as_millis() % 1000;
624 let mut rumor = EventBuilder::new(Kind::PrivateDirectMessage, content)
626 .tag(Tag::public_key(receiver));
627
628 if let Some(reply_id) = reply_to {
629 if !reply_id.is_empty() {
630 rumor = rumor.tag(Tag::custom(
631 "e",
632 [reply_id.to_string(), String::new(), "reply".to_string()],
633 ));
634 }
635 }
636
637 let mut rumor = rumor.tag(Tag::custom("ms", [milliseconds.to_string()]));
638 for et in &emoji_tags {
639 rumor = rumor.tag(Tag::custom(
640 "emoji",
641 [et.shortcode.clone(), et.url.clone()],
642 ));
643 }
644 if let Some(exp) = config.expiration {
647 rumor = rumor.tag(Tag::expiration(Timestamp::from_secs(exp)));
648 }
649 let built_rumor = rumor.finalize_unsigned_with_id(my_pk);
650 let event_id = built_rumor.id.ok_or("Rumor has no id")?.to_hex();
651
652 retry_send_gift_wrap(
654 &client, &receiver, receiver_npub, &pending_id,
655 built_rumor, &event_id, config, callback, None,
656 ).await
657}
658
659pub async fn send_rumor_dm(
667 receiver_npub: &str,
668 pending_id: &str,
669 rumor: UnsignedEvent,
670 config: &SendConfig,
671 callback: Arc<dyn SendCallback>,
672) -> Result<SendResult, String> {
673 let client = nostr_client().ok_or("Not logged in")?;
674
675 let receiver = PublicKey::from_bech32(receiver_npub)
676 .map_err(|e| format!("Invalid npub: {}", e))?;
677
678 let event_id = rumor.id.ok_or("Rumor has no id")?.to_hex();
679
680 retry_send_gift_wrap(
681 &client, &receiver, receiver_npub, pending_id,
682 rumor, &event_id, config, callback, None,
683 ).await
684}
685
686pub async fn resend_failed_dm(
699 receiver_npub: &str,
700 failed_msg_id: &str,
701 config: &SendConfig,
702 callback: Arc<dyn SendCallback>,
703) -> Result<bool, String> {
704 let session = crate::state::SessionGuard::capture();
709 let payload = match crate::db::nip17_keys::get_resend_payload_by_pending(failed_msg_id)? {
710 Some(p) => p,
711 None => return Ok(false),
712 };
713 let client = nostr_client().ok_or("Not logged in")?;
714 let receiver = PublicKey::from_bech32(receiver_npub)
715 .map_err(|e| format!("Invalid npub: {}", e))?;
716 if !session.is_valid() {
717 return Ok(true);
718 }
719
720 let repending = {
723 let mut state = STATE.lock().await;
724 state.update_message(failed_msg_id, |msg| {
725 msg.set_failed(false);
726 msg.set_pending(true);
727 })
728 };
729 if let Some((_chat_id, ref msg)) = repending {
730 callback.on_pending(receiver_npub, msg);
731 }
732
733 let event_id = payload.rumor.id.ok_or("Retained rumor has no id")?.to_hex();
734 let built = crate::inbox_relays::BuiltGiftWrap {
735 event: payload.wrap_event,
736 secret: payload.secret,
737 };
738 if let Err(e) = retry_send_gift_wrap(
740 &client, &receiver, receiver_npub, failed_msg_id,
741 payload.rumor, &event_id, config, callback, Some(built),
742 )
743 .await
744 {
745 crate::log_warn!("[Send] idempotent resend of {} did not land: {}", failed_msg_id, e);
749 }
750 Ok(true)
751}
752
753pub async fn send_file_dm(
761 receiver_npub: &str,
762 file_bytes: Arc<Vec<u8>>,
763 filename: &str,
764 extension: &str,
765 content: Option<&str>,
766 config: &SendConfig,
767 callback: Arc<dyn SendCallback>,
768) -> Result<SendResult, String> {
769 let client = nostr_client().ok_or("Not logged in")?;
770 let my_pk = my_public_key().ok_or("Public key not set")?;
771 let signer = crate::signer::active_signer()
775 .map_err(|e| format!("Signer unavailable: {}", e))?;
776
777 let now = std::time::SystemTime::now()
778 .duration_since(std::time::UNIX_EPOCH).unwrap();
779 let pending_id = format!("pending-{}", now.as_nanos());
780 let milliseconds = now.as_millis() % 1000;
781
782 let receiver = PublicKey::from_bech32(receiver_npub)
783 .map_err(|e| format!("Invalid npub: {}", e))?;
784
785 let file_hash = crypto::sha256_hex(&file_bytes);
786 let mime_type = crypto::mime_from_extension(extension);
787
788 let webxdc_topic = (extension.eq_ignore_ascii_case("xdc"))
793 .then(|| crate::webxdc::mint_topic_id(&file_hash, &my_pk.to_hex()));
794
795 let download_dir = crate::db::get_download_dir();
797 let _ = std::fs::create_dir_all(&download_dir);
798 let local_name = if filename.is_empty() {
805 format!("{}.{}", &file_hash, extension)
806 } else {
807 let stem = filename.rsplit_once('.').map(|(s, _)| s).unwrap_or(filename);
808 format!("{}.{}", stem, extension)
809 };
810 let local_path = crypto::resolve_unique_filename(&download_dir, &local_name);
812 let tmp = download_dir.join(format!(".{}.tmp", &file_hash));
814 let _ = std::fs::write(&tmp, &*file_bytes);
815 let _ = std::fs::rename(&tmp, &local_path);
816 let local_path_str = local_path.to_string_lossy().to_string();
817
818 let img_meta = crypto::generate_image_metadata(&file_bytes);
820
821 let params = crypto::generate_encryption_params();
823 let encrypted = crypto::encrypt_data(&file_bytes, ¶ms)?;
824 let encrypted_size = encrypted.len() as u64;
825
826 let attachment = Attachment {
827 id: file_hash.clone(), key: params.key.clone(), nonce: params.nonce.clone(),
828 extension: extension.to_string(), name: filename.to_string(),
829 url: String::new(), path: local_path_str.clone(), size: encrypted_size,
830 img_meta: img_meta.clone(), downloading: false, downloaded: true,
831 webxdc_topic: webxdc_topic.clone(),
832 ..Default::default()
833 };
834 let msg = Message {
835 id: pending_id.clone(), content: content.unwrap_or("").to_string(),
836 at: now.as_millis() as u64, pending: true, mine: true,
837 npub: my_pk.to_bech32().ok(), attachments: vec![attachment],
838 expiration: config.expiration,
839 ..Default::default()
840 };
841 {
842 let mut state = STATE.lock().await;
843 state.add_message_to_participant(receiver_npub, &msg);
844 }
845 callback.on_pending(receiver_npub, &msg);
846
847 let servers = crate::state::get_blossom_servers();
849 let cb_for_progress = callback.clone();
850 let pid_for_progress = pending_id.clone();
851 let progress_cb: crate::blossom::ProgressCallback = Arc::new(move |percentage, bytes| {
852 cb_for_progress.on_upload_progress(
853 &pid_for_progress,
854 percentage.unwrap_or(0),
855 bytes.unwrap_or(0),
856 )
857 });
858
859 let upload_url = match crate::blossom::upload_blob_with_progress_and_failover(
863 signer.clone(), servers, Arc::new(encrypted), Some(mime_type),
864 true,
865 progress_cb, Some(config.upload_retries), Some(config.upload_retry_delay),
866 config.cancel_token.clone(),
867 ).await {
868 Ok(url) => url,
869 Err(e) => {
870 let failed_msg = {
871 let mut state = STATE.lock().await;
872 state.update_message(&pending_id, |msg| {
873 msg.set_failed(true);
874 msg.set_pending(false);
875 })
876 };
877 if let Some((_chat_id, ref msg)) = failed_msg {
878 callback.on_failed(receiver_npub, &pending_id, msg);
879 callback.on_persist(receiver_npub, msg);
880 }
881 return Err(format!("Upload failed: {}", e));
882 }
883 };
884
885 {
886 let mut state = STATE.lock().await;
887 state.update_message(&pending_id, |msg| {
888 if let Some(att) = msg.attachments.last_mut() {
889 att.url = upload_url.clone().into_boxed_str();
890 }
891 });
892 }
893 callback.on_upload_complete(receiver_npub, &pending_id, &file_hash, &upload_url);
894
895 let mirror_urls = crate::blossom::mirror_blob_to_servers(
899 signer.clone(),
900 &upload_url,
901 crate::state::get_blossom_servers(),
902 2,
903 std::time::Duration::from_secs(5),
904 ).await;
905 if !mirror_urls.is_empty() {
906 let mut state = STATE.lock().await;
907 state.update_message(&pending_id, |msg| {
908 if let Some(att) = msg.attachments.last_mut() {
909 att.fallback_urls = Some(mirror_urls.iter().map(|s| s.as_str().into()).collect());
910 }
911 });
912 }
913
914 let mut file_rumor = EventBuilder::new(Kind::from_u16(15), &upload_url)
916 .tag(Tag::public_key(receiver))
917 .tag(Tag::custom("file-type", [mime_type]))
918 .tag(Tag::custom("size", [encrypted_size.to_string()]))
919 .tag(Tag::custom("encryption-algorithm", ["aes-gcm"]))
920 .tag(Tag::custom("decryption-key", [params.key.as_str()]))
921 .tag(Tag::custom("decryption-nonce", [params.nonce.as_str()]))
922 .tag(Tag::custom("ox", [file_hash.clone()]));
923 for fb in &mirror_urls {
924 file_rumor = file_rumor.tag(Tag::custom("fallback", [fb.as_str()]));
925 }
926 if !filename.is_empty() {
927 file_rumor = file_rumor.tag(Tag::custom("name", [filename]));
928 }
929 if let Some(ref topic) = webxdc_topic {
930 file_rumor = file_rumor.tag(Tag::custom("webxdc-topic", [topic.as_str()]));
931 }
932 if let Some(ref meta) = img_meta {
934 if !meta.thumbhash.is_empty() {
935 file_rumor = file_rumor.tag(Tag::custom("thumbhash", [meta.thumbhash.as_str()]));
938 }
939 file_rumor = file_rumor.tag(Tag::custom("dim", [format!("{}x{}", meta.width, meta.height)]));
940 }
941 file_rumor = file_rumor.tag(Tag::custom("ms", [milliseconds.to_string()]));
942 if let Some(exp) = config.expiration {
943 file_rumor = file_rumor.tag(Tag::expiration(Timestamp::from_secs(exp)));
944 }
945
946 let built_rumor = file_rumor.finalize_unsigned_with_id(my_pk);
947 let event_id = built_rumor.id.ok_or("Rumor has no id")?.to_hex();
948
949 retry_send_gift_wrap(
950 &client, &receiver, receiver_npub, &pending_id,
951 built_rumor, &event_id, config, callback, None,
952 ).await
953}
954
955#[cfg(test)]
960mod tests {
961 use super::*;
962 use std::sync::Mutex;
963
964 #[derive(Debug, Clone, PartialEq)]
965 enum CbEvent {
966 Pending(String),
967 UploadProgress(String, u8, u64),
968 UploadComplete(String, String),
969 Sent(String, String),
970 Failed(String, String),
971 Persist(String),
972 }
973
974 struct MockCallback {
975 events: Mutex<Vec<CbEvent>>,
976 cancel_at: Option<u8>,
977 }
978
979 impl MockCallback {
980 fn new() -> Self { Self { events: Mutex::new(vec![]), cancel_at: None } }
981 fn with_cancel(pct: u8) -> Self { Self { events: Mutex::new(vec![]), cancel_at: Some(pct) } }
982 fn events(&self) -> Vec<CbEvent> { self.events.lock().unwrap().clone() }
983 }
984
985 impl SendCallback for MockCallback {
986 fn on_pending(&self, cid: &str, _: &Message) {
987 self.events.lock().unwrap().push(CbEvent::Pending(cid.into()));
988 }
989 fn on_upload_progress(&self, pid: &str, pct: u8, bytes: u64) -> Result<(), String> {
990 self.events.lock().unwrap().push(CbEvent::UploadProgress(pid.into(), pct, bytes));
991 if self.cancel_at.map_or(false, |c| pct >= c) { return Err("Cancelled".into()); }
992 Ok(())
993 }
994 fn on_upload_complete(&self, cid: &str, _: &str, _: &str, url: &str) {
995 self.events.lock().unwrap().push(CbEvent::UploadComplete(cid.into(), url.into()));
996 }
997 fn on_sent(&self, cid: &str, old: &str, _: &Message) {
998 self.events.lock().unwrap().push(CbEvent::Sent(cid.into(), old.into()));
999 }
1000 fn on_failed(&self, cid: &str, old: &str, _: &Message) {
1001 self.events.lock().unwrap().push(CbEvent::Failed(cid.into(), old.into()));
1002 }
1003 fn on_persist(&self, cid: &str, _: &Message) {
1004 self.events.lock().unwrap().push(CbEvent::Persist(cid.into()));
1005 }
1006 }
1007
1008 #[test]
1009 fn config_default() {
1010 let c = SendConfig::default();
1011 assert_eq!(c.max_send_attempts, 1);
1012 assert!(!c.self_send);
1013 assert!(c.cancel_token.is_none());
1014 assert_eq!(c.upload_retries, 3);
1015 }
1016
1017 #[test]
1018 fn config_gui() {
1019 let c = SendConfig::gui();
1020 assert_eq!(c.max_send_attempts, 12);
1021 assert!(c.self_send);
1022 }
1023
1024 #[test]
1025 fn config_headless() {
1026 let c = SendConfig::headless();
1027 assert_eq!(c.max_send_attempts, 3);
1028 assert!(c.self_send);
1029 }
1030
1031 #[test]
1032 fn config_custom_override() {
1033 let c = SendConfig { max_send_attempts: 5, ..SendConfig::gui() };
1034 assert_eq!(c.max_send_attempts, 5);
1035 assert!(c.self_send);
1036 }
1037
1038 #[test]
1039 fn noop_callback_all_methods() {
1040 let cb = NoOpSendCallback;
1041 let msg = Message::default();
1042 cb.on_pending("c", &msg);
1043
1044 assert!(cb.on_upload_progress("p", 50, 1024).is_ok());
1045 cb.on_upload_complete("c", "p", "a", "url");
1046 cb.on_sent("c", "o", &msg);
1047 cb.on_failed("c", "o", &msg);
1048 cb.on_persist("c", &msg);
1049 }
1050
1051 #[test]
1052 fn text_dm_sequence() {
1053 let cb = MockCallback::new();
1054 let msg = Message::default();
1055 cb.on_pending("r", &msg);
1056 cb.on_sent("r", "p-1", &msg);
1057 cb.on_persist("r", &msg);
1058 assert_eq!(cb.events(), vec![
1059 CbEvent::Pending("r".into()),
1060 CbEvent::Sent("r".into(), "p-1".into()),
1061 CbEvent::Persist("r".into()),
1062 ]);
1063 }
1064
1065 #[test]
1066 fn file_dm_sequence() {
1067 let cb = MockCallback::new();
1068 let msg = Message::default();
1069 cb.on_pending("r", &msg);
1070
1071 cb.on_upload_progress("p", 0, 0).ok();
1072 cb.on_upload_progress("p", 50, 5000).ok();
1073 cb.on_upload_progress("p", 100, 10000).ok();
1074 cb.on_upload_complete("r", "p", "h", "https://blossom/h");
1075 cb.on_sent("r", "p", &msg);
1076 cb.on_persist("r", &msg);
1077 let e = cb.events();
1078 assert_eq!(e.len(), 7);
1079 assert!(matches!(&e[4], CbEvent::UploadComplete(_, url) if url.contains("blossom")));
1080 }
1081
1082 #[test]
1083 fn failed_sequence() {
1084 let cb = MockCallback::new();
1085 let msg = Message::default();
1086 cb.on_pending("r", &msg);
1087 cb.on_failed("r", "p", &msg);
1088 cb.on_persist("r", &msg);
1089 assert_eq!(cb.events(), vec![
1090 CbEvent::Pending("r".into()),
1091 CbEvent::Failed("r".into(), "p".into()),
1092 CbEvent::Persist("r".into()),
1093 ]);
1094 }
1095
1096 #[test]
1097 fn cancel_upload_at_threshold() {
1098 let cb = MockCallback::with_cancel(50);
1099 assert!(cb.on_upload_progress("p", 25, 512).is_ok());
1100 assert!(cb.on_upload_progress("p", 50, 1024).is_err());
1101 assert_eq!(cb.events().len(), 2);
1102 }
1103
1104 #[test]
1105 fn cancel_triggers_failed() {
1106 let cb = MockCallback::with_cancel(30);
1107 let msg = Message::default();
1108 cb.on_pending("r", &msg);
1109
1110 cb.on_upload_progress("p", 10, 1000).ok();
1111 assert!(cb.on_upload_progress("p", 30, 3000).is_err());
1112 cb.on_failed("r", "p", &msg);
1113 assert!(matches!(cb.events().last(), Some(CbEvent::Failed(..))));
1114 }
1115
1116 #[test]
1117 fn send_result_serialize() {
1118 let r = SendResult { pending_id: "p".into(), event_id: Some("e".into()), chat_id: "c".into() };
1119 let j = serde_json::to_string(&r).unwrap();
1120 assert!(j.contains("\"pending_id\":\"p\""));
1121 }
1122
1123 #[test]
1124 fn send_result_none_event() {
1125 let r = SendResult { pending_id: "p".into(), event_id: None, chat_id: "c".into() };
1126 let j = serde_json::to_string(&r).unwrap();
1127 assert!(j.contains("null"));
1128 }
1129
1130 #[test]
1135 fn file_dm_fresh_upload_full_sequence() {
1136 let cb = MockCallback::new();
1137 let msg = Message::default();
1138
1139 cb.on_pending("npub1recv", &msg);
1141 cb.on_upload_progress("pending-42", 0, 0).unwrap();
1145 cb.on_upload_progress("pending-42", 25, 2500).unwrap();
1146 cb.on_upload_progress("pending-42", 50, 5000).unwrap();
1147 cb.on_upload_progress("pending-42", 75, 7500).unwrap();
1148 cb.on_upload_progress("pending-42", 100, 10000).unwrap();
1149 cb.on_upload_complete("npub1recv", "pending-42", "deadbeef", "https://blossom.example/deadbeef");
1151 cb.on_sent("npub1recv", "pending-42", &msg);
1153 cb.on_persist("npub1recv", &msg);
1155
1156 let e = cb.events();
1157 assert_eq!(e.len(), 9);
1158 assert!(matches!(&e[0], CbEvent::Pending(c) if c == "npub1recv"));
1159 assert!(matches!(&e[1], CbEvent::UploadProgress(_, 0, 0)));
1160 assert!(matches!(&e[5], CbEvent::UploadProgress(_, 100, 10000)));
1161 assert!(matches!(&e[6], CbEvent::UploadComplete(_, url) if url.contains("deadbeef")));
1162 assert!(matches!(&e[7], CbEvent::Sent(..)));
1163 assert!(matches!(&e[8], CbEvent::Persist(..)));
1164 }
1165
1166 #[test]
1167 fn file_dm_skip_upload_sequence() {
1168 let cb = MockCallback::new();
1169 let msg = Message::default();
1170
1171 cb.on_pending("npub1recv", &msg);
1174 cb.on_upload_complete("npub1recv", "pending-99", "existinghash", "https://blossom.example/existing");
1178 cb.on_sent("npub1recv", "pending-99", &msg);
1180 cb.on_persist("npub1recv", &msg);
1182
1183 let e = cb.events();
1184 assert_eq!(e.len(), 4);
1185 assert!(!e.iter().any(|ev| matches!(ev, CbEvent::UploadProgress(..))));
1187 assert!(matches!(&e[1], CbEvent::UploadComplete(..)));
1188 }
1189
1190 #[test]
1191 fn file_dm_upload_cancelled_at_30pct() {
1192 let cb = MockCallback::with_cancel(30);
1193 let msg = Message::default();
1194
1195 cb.on_pending("npub1recv", &msg);
1196
1197 assert!(cb.on_upload_progress("p", 10, 1000).is_ok());
1198 assert!(cb.on_upload_progress("p", 20, 2000).is_ok());
1199 let err = cb.on_upload_progress("p", 30, 3000);
1201 assert!(err.is_err());
1202 assert!(err.unwrap_err().contains("Cancelled"));
1203 cb.on_failed("npub1recv", "p", &msg);
1205
1206 let e = cb.events();
1207 assert_eq!(e.len(), 5);
1208 assert!(!e.iter().any(|ev| matches!(ev, CbEvent::Sent(..))));
1210 assert!(matches!(e.last(), Some(CbEvent::Failed(..))));
1211 }
1212
1213 #[test]
1214 fn file_dm_upload_fails_marks_failed() {
1215 let cb = MockCallback::new();
1216 let msg = Message::default();
1217
1218 cb.on_pending("npub1recv", &msg);
1219
1220 cb.on_upload_progress("p", 0, 0).ok();
1221 cb.on_upload_progress("p", 10, 500).ok();
1222 cb.on_failed("npub1recv", "p", &msg);
1224 cb.on_persist("npub1recv", &msg);
1225
1226 let e = cb.events();
1227 assert_eq!(e.len(), 5);
1228 assert!(matches!(&e[3], CbEvent::Failed(..)));
1229 assert!(matches!(&e[4], CbEvent::Persist(..)));
1230 assert!(!e.iter().any(|ev| matches!(ev, CbEvent::UploadComplete(..))));
1232 assert!(!e.iter().any(|ev| matches!(ev, CbEvent::Sent(..))));
1233 }
1234
1235 #[test]
1236 fn file_dm_gift_wrap_fails_after_upload() {
1237 let cb = MockCallback::new();
1238 let msg = Message::default();
1239
1240 cb.on_pending("npub1recv", &msg);
1242
1243 cb.on_upload_progress("p", 100, 10000).ok();
1244 cb.on_upload_complete("npub1recv", "p", "hash", "https://blossom/hash");
1245 cb.on_failed("npub1recv", "p", &msg);
1247 cb.on_persist("npub1recv", &msg);
1248
1249 let e = cb.events();
1250 assert_eq!(e.len(), 5);
1251 assert!(matches!(&e[2], CbEvent::UploadComplete(..)));
1253 assert!(matches!(&e[3], CbEvent::Failed(..)));
1254 }
1255
1256 #[test]
1257 fn file_dm_with_image_metadata_sequence() {
1258 let cb = MockCallback::new();
1259 let msg = Message::default();
1260
1261 cb.on_pending("npub1recv", &msg);
1263
1264 cb.on_upload_progress("p", 0, 0).ok();
1265 cb.on_upload_progress("p", 50, 50000).ok();
1266 cb.on_upload_progress("p", 100, 100000).ok();
1267 cb.on_upload_complete("npub1recv", "p", "imghash", "https://blossom/imghash.jpg");
1268 cb.on_sent("npub1recv", "p", &msg);
1269 cb.on_persist("npub1recv", &msg);
1270
1271 let e = cb.events();
1272 assert_eq!(e.len(), 7);
1273 assert!(matches!(&e[0], CbEvent::Pending(..)));
1275 assert!(matches!(&e[4], CbEvent::UploadComplete(_, url) if url.ends_with(".jpg")));
1276 assert!(matches!(&e[5], CbEvent::Sent(..)));
1277 }
1278
1279 #[test]
1280 fn cancel_token_config_with_upload() {
1281 let token = Arc::new(std::sync::atomic::AtomicBool::new(false));
1282 let c = SendConfig {
1283 cancel_token: Some(token.clone()),
1284 ..SendConfig::gui()
1285 };
1286 assert!(c.cancel_token.is_some());
1287 assert!(!token.load(std::sync::atomic::Ordering::Relaxed));
1288
1289 token.store(true, std::sync::atomic::Ordering::Relaxed);
1291 assert!(c.cancel_token.as_ref().unwrap().load(std::sync::atomic::Ordering::Relaxed));
1292 }
1293}