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