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 cancel_token(&self, _pending_id: &str) -> Option<Arc<AtomicBool>> {
52 None
53 }
54
55 fn on_upload_progress(
57 &self,
58 _pending_id: &str,
59 _percentage: u8,
60 _bytes_sent: u64,
61 ) -> Result<(), String> {
62 Ok(())
63 }
64
65 fn on_upload_complete(&self, _chat_id: &str, _pending_id: &str, _attachment_id: &str, _url: &str) {}
67 fn on_upload_stage(&self, _pending_id: &str, _stage: &str, _pct: Option<u8>) {}
70
71 fn on_sent(&self, _chat_id: &str, _old_id: &str, _msg: &Message) {}
74
75 fn on_failed(&self, _chat_id: &str, _old_id: &str, _msg: &Message) {}
77
78 fn on_persist(&self, _chat_id: &str, _msg: &Message) {}
81}
82
83pub struct NoOpSendCallback;
85impl SendCallback for NoOpSendCallback {}
86
87pub struct SendConfig {
93 pub max_send_attempts: u32,
95 pub retry_delay: std::time::Duration,
97 pub self_send: bool,
99 pub cancel_token: Option<Arc<AtomicBool>>,
101 pub upload_retries: u32,
103 pub upload_retry_delay: std::time::Duration,
105 pub expiration: Option<u64>,
109 pub self_destruct_secs: Option<u64>,
113}
114
115impl Default for SendConfig {
116 fn default() -> Self {
117 Self {
118 max_send_attempts: 1,
119 retry_delay: std::time::Duration::from_secs(5),
120 self_send: false,
121 cancel_token: None,
122 upload_retries: 3,
123 upload_retry_delay: std::time::Duration::from_secs(2),
124 expiration: None,
125 self_destruct_secs: None,
126 }
127 }
128}
129
130impl SendConfig {
131 pub fn gui() -> Self {
133 Self {
134 max_send_attempts: 12,
135 self_send: true,
136 ..Default::default()
137 }
138 }
139
140 pub fn headless() -> Self {
142 Self {
143 max_send_attempts: 3,
144 self_send: true,
145 ..Default::default()
146 }
147 }
148}
149
150#[derive(serde::Serialize, Clone, Debug)]
156pub struct SendResult {
157 pub pending_id: String,
159 pub event_id: Option<String>,
161 pub chat_id: String,
163}
164
165struct WrapConfirm {
182 wrap_id: EventId,
183 chat_id: String,
184 pending_id: String,
185 rumor_event_id: String,
187 rumor: UnsignedEvent,
188 callback: Arc<dyn SendCallback>,
189 self_send: bool,
190 confirmed: AtomicBool,
191 rescued: AtomicBool,
194 loop_exited: AtomicBool,
197 notify: tokio::sync::Notify,
198 session: std::sync::Arc<crate::db::Session>,
199 registered_at: web_time::Instant,
200}
201
202struct WrapConfirms;
205
206fn wrap_confirms() -> Arc<std::sync::Mutex<std::collections::HashMap<EventId, Arc<WrapConfirm>>>> {
207 crate::db::current_session().scoped::<WrapConfirms, _>()
208}
209
210const WRAP_CONFIRM_TTL: std::time::Duration = std::time::Duration::from_secs(15 * 60);
213
214fn register_wrap_confirm(entry: Arc<WrapConfirm>) {
215 let owner = wrap_confirms();
216 let mut map = owner.lock().unwrap();
217 map.retain(|_, e| e.registered_at.elapsed() < WRAP_CONFIRM_TTL);
218 map.insert(entry.wrap_id, entry);
219}
220
221fn remove_wrap_confirm(wrap_id: &EventId) {
222 wrap_confirms().lock().unwrap().remove(wrap_id);
223}
224
225pub fn note_relay_ok(event_id: &EventId, accepted: bool) {
231 if !accepted {
232 return;
233 }
234 let entry = {
235 let owner = wrap_confirms();
236 let map = owner.lock().unwrap();
237 map.get(event_id).cloned()
238 };
239 let Some(entry) = entry else { return };
240 entry.confirmed.store(true, Ordering::SeqCst);
241 entry.notify.notify_one();
242 if !entry.loop_exited.load(Ordering::SeqCst)
243 || entry.rescued.swap(true, Ordering::SeqCst)
244 {
245 return;
246 }
247 if !entry.session.is_live() {
248 remove_wrap_confirm(&entry.wrap_id);
249 return;
250 }
251 crate::db::spawn_bound(async move {
252 rescue_failed_as_sent(&entry).await;
253 remove_wrap_confirm(&entry.wrap_id);
254 });
255}
256
257async fn rescue_failed_as_sent(entry: &WrapConfirm) {
260 let finalized = {
261 let mut state = STATE.lock().await;
262 state.update_message(&entry.pending_id, |msg| {
263 msg.set_failed(false);
264 });
265 state.finalize_pending_message(&entry.chat_id, &entry.pending_id, &entry.rumor_event_id)
266 };
267 let Some((_old_id, ref msg)) = finalized else { return };
268 crate::log_info!(
269 "[Send] late relay OK confirmed wrap {} — message {} rescued to sent",
270 entry.wrap_id,
271 entry.rumor_event_id,
272 );
273 entry.callback.on_sent(&entry.chat_id, &entry.pending_id, msg);
274 entry.callback.on_persist(&entry.chat_id, msg);
275 if let Some(rid) = entry.rumor.id {
277 let _ = crate::db::nip17_keys::clear_resend_payload(&rid);
278 }
279 if entry.self_send {
280 if let (Some(client), Some(my_pk)) = (nostr_client(), my_public_key()) {
281 spawn_self_send(client, my_pk, entry.rumor.clone());
282 }
283 }
284}
285
286fn spawn_self_send(client: Client, my_pk: PublicKey, rumor: UnsignedEvent) {
288 let rid_for_self = rumor.id;
289 crate::db::spawn_bound(async move {
290 match crate::inbox_relays::send_gift_wrap_retained(
291 &client, &my_pk, rumor, [],
292 ).await {
293 Ok(self_outcome) if !self_outcome.output.success.is_empty() => {
294 if let Some(rid) = rid_for_self {
295 if let Err(e) = crate::db::nip17_keys::store_wrap_key(
296 &self_outcome.wrap_event_id,
297 &rid,
298 &my_pk,
299 crate::db::nip17_keys::WrapRole::SelfSend,
300 &self_outcome.wrap_secret,
301 &self_outcome.targeted_relays,
302 ) {
303 eprintln!("[NIP-17] failed to persist self-wrap key: {}", e);
304 }
305 }
306 }
307 _ => {}
308 }
309 });
310}
311
312#[allow(clippy::too_many_arguments)]
327async fn retry_send_gift_wrap(
328 client: &Client,
329 receiver: &PublicKey,
330 receiver_npub: &str,
331 pending_id: &str,
332 rumor: UnsignedEvent,
333 event_id: &str,
334 config: &SendConfig,
335 callback: Arc<dyn SendCallback>,
336 prebuilt: Option<crate::inbox_relays::BuiltGiftWrap>,
340) -> Result<SendResult, String> {
341 let my_pk = my_public_key().ok_or("Public key not set")?;
342 let inner_rumor_id = rumor.id;
343
344 let is_resend = prebuilt.is_some();
347 let mut built: Option<crate::inbox_relays::BuiltGiftWrap> = prebuilt;
350 let mut targets: Option<crate::inbox_relays::GiftWrapTargets> = None;
353 let mut confirm: Option<Arc<WrapConfirm>> = None;
354 let mut last_error: Option<String> = None;
355
356 let max_attempts = config.max_send_attempts.max(1);
357
358 for attempt in 0..max_attempts {
359 if built.is_none() {
360 let wrap_extra: Vec<Tag> = rumor.tags.iter()
363 .find(|t| t.as_slice().first().map(|k| k.as_str() == "expiration").unwrap_or(false))
364 .cloned()
365 .into_iter()
366 .collect();
367 match crate::inbox_relays::build_gift_wrap_retained(
368 client, receiver, rumor.clone(), wrap_extra,
369 ).await {
370 Ok(b) => built = Some(b),
371 Err(e) => {
372 crate::log_warn!(
373 "[Send] attempt {}/{} — building gift-wrap failed: {}",
374 attempt + 1, max_attempts, e,
375 );
376 last_error = Some(e);
377 if attempt + 1 < max_attempts {
378 crate::rt::time::sleep(config.retry_delay).await;
379 }
380 continue;
381 }
382 }
383 }
384 let wrap = built.as_ref().unwrap();
385
386 if confirm.is_none() {
387 let entry = Arc::new(WrapConfirm {
388 wrap_id: wrap.event.id,
389 chat_id: receiver_npub.to_string(),
390 pending_id: pending_id.to_string(),
391 rumor_event_id: event_id.to_string(),
392 rumor: rumor.clone(),
393 callback: callback.clone(),
394 self_send: config.self_send,
395 confirmed: AtomicBool::new(false),
396 rescued: AtomicBool::new(false),
397 loop_exited: AtomicBool::new(false),
398 notify: tokio::sync::Notify::new(),
399 session: crate::db::current_session(),
400 registered_at: web_time::Instant::now(),
401 });
402 register_wrap_confirm(entry.clone());
403 confirm = Some(entry);
404 }
405 let confirm_ref = confirm.as_ref().unwrap();
406
407 if let Some(t) = targets.as_ref() {
408 crate::inbox_relays::reconnect_gift_wrap_targets(t).await;
409 } else {
410 let t = crate::inbox_relays::resolve_gift_wrap_targets(client, receiver).await;
411 if !is_resend {
415 if let Some(rid) = inner_rumor_id {
416 if let Err(e) = crate::db::nip17_keys::store_wrap_key(
417 &wrap.event.id,
418 &rid,
419 receiver,
420 crate::db::nip17_keys::WrapRole::Recipient,
421 &wrap.secret,
422 &t.targeted_relays,
423 ) {
424 eprintln!("[NIP-17] failed to persist wrap key: {}", e);
425 }
426 if let Err(e) = crate::db::nip17_keys::stash_resend_payload(
427 &wrap.event.id, pending_id, &wrap.event, &rumor,
428 ) {
429 eprintln!("[NIP-17] failed to retain resend payload: {}", e);
430 }
431 }
432 }
433 targets = Some(t);
434 }
435 let targets_ref = targets.as_ref().unwrap();
436
437 match crate::inbox_relays::publish_gift_wrap_to_targets(
438 client, targets_ref, &wrap.event,
439 ).await {
440 Ok(output) if !output.success.is_empty() => {
441 return Ok(finalize_gift_wrap_sent(
442 client, my_pk, receiver_npub, pending_id, event_id,
443 &rumor, config, &callback, confirm_ref, targets_ref,
444 ).await);
445 }
446 Ok(output) => {
447 let failures: Vec<String> = output.failed.iter()
452 .map(|(url, err)| format!("{}: {}", url, err))
453 .collect();
454 crate::log_warn!(
455 "[Send] attempt {}/{} — 0 of {} relays accepted (targeted: {}). Per-relay errors: {}",
456 attempt + 1,
457 max_attempts,
458 targets_ref.targeted_relays.len(),
459 targets_ref.targeted_relays.join(", "),
460 if failures.is_empty() {
461 "(none reported — likely all timed out before responding)".to_string()
462 } else {
463 failures.join(" | ")
464 },
465 );
466 last_error = None;
467 }
468 Err(e) => {
469 crate::log_warn!(
470 "[Send] attempt {}/{} — publish errored: {}",
471 attempt + 1, max_attempts, e,
472 );
473 last_error = Some(e);
474 }
475 }
476
477 if confirm_ref.confirmed.load(Ordering::SeqCst) {
480 return Ok(finalize_gift_wrap_sent(
481 client, my_pk, receiver_npub, pending_id, event_id,
482 &rumor, config, &callback, confirm_ref, targets_ref,
483 ).await);
484 }
485
486 if attempt + 1 < max_attempts {
487 let _ = crate::rt::time::timeout(
491 config.retry_delay,
492 confirm_ref.notify.notified(),
493 ).await;
494 if confirm_ref.confirmed.load(Ordering::SeqCst) {
495 return Ok(finalize_gift_wrap_sent(
496 client, my_pk, receiver_npub, pending_id, event_id,
497 &rumor, config, &callback, confirm_ref, targets_ref,
498 ).await);
499 }
500 }
501 }
502
503 if let Some(t) = targets.as_ref() {
507 crate::inbox_relays::teardown_gift_wrap_targets(client, t).await;
508 }
509 let failed_msg = {
510 let mut state = STATE.lock().await;
511 state.update_message(pending_id, |msg| {
512 msg.set_failed(true);
513 msg.set_pending(false);
514 })
515 };
516 if let Some((_chat_id, ref msg)) = failed_msg {
517 callback.on_failed(receiver_npub, pending_id, msg);
518 callback.on_persist(receiver_npub, msg);
519 }
520 if let Some(entry) = confirm.as_ref() {
521 entry.loop_exited.store(true, Ordering::SeqCst);
522 if entry.confirmed.load(Ordering::SeqCst)
525 && !entry.rescued.swap(true, Ordering::SeqCst)
526 {
527 rescue_failed_as_sent(entry).await;
528 remove_wrap_confirm(&entry.wrap_id);
529 return Ok(SendResult {
530 pending_id: pending_id.to_string(),
531 event_id: Some(event_id.to_string()),
532 chat_id: receiver_npub.to_string(),
533 });
534 }
535 }
536 match last_error {
537 Some(e) => Err(format!("Failed to send DM after {} attempts: {}", max_attempts, e)),
538 None => Err(format!(
539 "Failed to send DM after {} attempts (no relays accepted the gift-wrap)",
540 max_attempts
541 )),
542 }
543}
544
545#[allow(clippy::too_many_arguments)]
548async fn finalize_gift_wrap_sent(
549 client: &Client,
550 my_pk: PublicKey,
551 receiver_npub: &str,
552 pending_id: &str,
553 event_id: &str,
554 rumor: &UnsignedEvent,
555 config: &SendConfig,
556 callback: &Arc<dyn SendCallback>,
557 confirm: &Arc<WrapConfirm>,
558 targets: &crate::inbox_relays::GiftWrapTargets,
559) -> SendResult {
560 remove_wrap_confirm(&confirm.wrap_id);
561 crate::inbox_relays::teardown_gift_wrap_targets(client, targets).await;
562
563 let finalized = {
564 let mut state = STATE.lock().await;
565 state.finalize_pending_message(receiver_npub, pending_id, event_id)
566 };
567 if let Some((_old_id, ref finalized_msg)) = finalized {
568 callback.on_sent(receiver_npub, pending_id, finalized_msg);
569 callback.on_persist(receiver_npub, finalized_msg);
570 }
571
572 if let Some(rid) = rumor.id {
575 let _ = crate::db::nip17_keys::clear_resend_payload(&rid);
576 }
577
578 if config.self_send {
579 spawn_self_send(client.clone(), my_pk, rumor.clone());
580 }
581
582 SendResult {
583 pending_id: pending_id.to_string(),
584 event_id: Some(event_id.to_string()),
585 chat_id: receiver_npub.to_string(),
586 }
587}
588
589pub async fn send_dm(
598 receiver_npub: &str,
599 content: &str,
600 reply_to: Option<&str>,
601 config: &SendConfig,
602 callback: Arc<dyn SendCallback>,
603) -> Result<SendResult, String> {
604 let client = nostr_client().ok_or("Not logged in")?;
605 let my_pk = my_public_key().ok_or("Public key not set")?;
606
607 let now = web_time::SystemTime::now()
608 .duration_since(web_time::UNIX_EPOCH).unwrap();
609 let pending_id = format!("pending-{}", now.as_nanos());
610
611 let receiver = PublicKey::from_bech32(receiver_npub)
612 .map_err(|e| format!("Invalid npub: {}", e))?;
613
614 let emoji_tags = crate::emoji_packs::resolve_outbound_emoji_tags(content);
619
620 let mut msg = Message {
622 id: pending_id.clone(),
623 content: content.to_string(),
624 replied_to: reply_to.unwrap_or("").to_string(),
625 at: now.as_millis() as u64,
626 pending: true,
627 mine: true,
628 npub: my_pk.to_bech32().ok(),
629 emoji_tags: emoji_tags.clone(),
630 expiration: config.expiration,
631 ..Default::default()
632 };
633 let _ = crate::db::events::populate_reply_context(&mut msg).await;
635
636 {
637 let mut state = STATE.lock().await;
638 state.add_message_to_participant(receiver_npub, &msg);
639 }
640
641 callback.on_pending(receiver_npub, &msg);
642
643 let milliseconds = now.as_millis() % 1000;
645 let mut rumor = EventBuilder::new(Kind::PrivateDirectMessage, content)
647 .tag(Tag::public_key(receiver));
648
649 if let Some(reply_id) = reply_to {
650 if !reply_id.is_empty() {
651 rumor = rumor.tag(Tag::custom(
652 "e",
653 [reply_id.to_string(), String::new(), "reply".to_string()],
654 ));
655 }
656 }
657
658 let mut rumor = rumor.tag(Tag::custom("ms", [milliseconds.to_string()]));
659 for et in &emoji_tags {
660 rumor = rumor.tag(Tag::custom(
661 "emoji",
662 [et.shortcode.clone(), et.url.clone()],
663 ));
664 }
665 if let Some(exp) = config.expiration {
668 rumor = rumor.tag(Tag::expiration(Timestamp::from_secs(exp)));
669 }
670 let built_rumor = rumor.finalize_unsigned_with_id(my_pk);
671 let event_id = built_rumor.id.ok_or("Rumor has no id")?.to_hex();
672
673 retry_send_gift_wrap(
675 &client, &receiver, receiver_npub, &pending_id,
676 built_rumor, &event_id, config, callback, None,
677 ).await
678}
679
680pub async fn send_rumor_dm(
688 receiver_npub: &str,
689 pending_id: &str,
690 rumor: UnsignedEvent,
691 config: &SendConfig,
692 callback: Arc<dyn SendCallback>,
693) -> Result<SendResult, String> {
694 let client = nostr_client().ok_or("Not logged in")?;
695
696 let receiver = PublicKey::from_bech32(receiver_npub)
697 .map_err(|e| format!("Invalid npub: {}", e))?;
698
699 let event_id = rumor.id.ok_or("Rumor has no id")?.to_hex();
700
701 retry_send_gift_wrap(
702 &client, &receiver, receiver_npub, pending_id,
703 rumor, &event_id, config, callback, None,
704 ).await
705}
706
707pub async fn resend_failed_dm(
720 receiver_npub: &str,
721 failed_msg_id: &str,
722 config: &SendConfig,
723 callback: Arc<dyn SendCallback>,
724) -> Result<bool, String> {
725 let payload = match crate::db::nip17_keys::get_resend_payload_by_pending(failed_msg_id)? {
730 Some(p) => p,
731 None => return Ok(false),
732 };
733 let client = nostr_client().ok_or("Not logged in")?;
734 let receiver = PublicKey::from_bech32(receiver_npub)
735 .map_err(|e| format!("Invalid npub: {}", e))?;
736
737 let repending = {
740 let mut state = STATE.lock().await;
741 state.update_message(failed_msg_id, |msg| {
742 msg.set_failed(false);
743 msg.set_pending(true);
744 })
745 };
746 if let Some((_chat_id, ref msg)) = repending {
747 callback.on_pending(receiver_npub, msg);
748 }
749
750 let event_id = payload.rumor.id.ok_or("Retained rumor has no id")?.to_hex();
751 let built = crate::inbox_relays::BuiltGiftWrap {
752 event: payload.wrap_event,
753 secret: payload.secret,
754 };
755 if let Err(e) = retry_send_gift_wrap(
757 &client, &receiver, receiver_npub, failed_msg_id,
758 payload.rumor, &event_id, config, callback, Some(built),
759 )
760 .await
761 {
762 crate::log_warn!("[Send] idempotent resend of {} did not land: {}", failed_msg_id, e);
766 }
767 Ok(true)
768}
769
770#[allow(clippy::too_many_arguments)]
778pub async fn send_file_dm(
779 receiver_npub: &str,
780 file_bytes: Arc<Vec<u8>>,
781 filename: &str,
782 extension: &str,
783 content: Option<&str>,
784 reply_to: Option<&str>,
785 config: &SendConfig,
786 callback: Arc<dyn SendCallback>,
787) -> Result<SendResult, String> {
788 send_file_dm_with_meta(receiver_npub, file_bytes, filename, extension, None, content, reply_to, config, callback).await
789}
790
791#[allow(clippy::too_many_arguments)]
794pub async fn send_file_dm_with_meta(
795 receiver_npub: &str,
796 file_bytes: Arc<Vec<u8>>,
797 filename: &str,
798 extension: &str,
799 img_meta: Option<crate::types::ImageMetadata>,
800 content: Option<&str>,
801 reply_to: Option<&str>,
802 config: &SendConfig,
803 callback: Arc<dyn SendCallback>,
804) -> Result<SendResult, String> {
805 send_file_dm_from(receiver_npub, FileSource::Bytes(file_bytes), filename, extension, img_meta, content, reply_to, config, callback).await
806}
807
808pub enum FileSource {
810 Bytes(Arc<Vec<u8>>),
812 Path(std::path::PathBuf),
815}
816
817pub struct TempFile(std::path::PathBuf);
819
820impl Drop for TempFile {
821 fn drop(&mut self) {
822 let _ = std::fs::remove_file(&self.0);
823 }
824}
825
826pub enum Sealed {
828 Reused(crate::db::attachments::ReusableUpload),
830 Body(crate::blossom::UploadBody, Option<TempFile>),
832}
833
834pub async fn seal_or_reuse(
837 plain: FileSource,
838 hash: &str,
839 key: &str,
840 nonce: &str,
841 pending_id: &str,
842 callback: Arc<dyn SendCallback>,
843 cancel: Option<Arc<AtomicBool>>,
844) -> Result<Sealed, String> {
845 if let Ok(Some(r)) = crate::db::attachments::find_reusable_by_hash(hash) {
848 if crate::blossom::blob_is_served(&r.url, std::time::Duration::from_secs(5)).await {
849 crate::log_info!("[SmartForward] {} reused ({}, {} KB) — no upload", &hash[..8.min(hash.len())], if r.mine { "own blob" } else { "foreign blob, mirroring in background" }, r.size / 1024);
850 return Ok(Sealed::Reused(r));
851 }
852 }
853
854 let (key, nonce) = (key.to_string(), nonce.to_string());
855 let outgoing = crate::db::current_account_dir()
857 .map(|d| d.join("outgoing"))
858 .unwrap_or_else(|_| std::env::temp_dir());
859 callback.on_upload_stage(pending_id, "encrypting", matches!(plain, FileSource::Path(_)).then_some(0));
861 let sealed = match plain {
862 FileSource::Bytes(bytes) => {
863 let sealed = crate::rt::spawn_blocking(move || {
864 crypto::encrypt_data(&bytes, &crypto::EncryptionParams { key, nonce })
865 })
866 .await
867 .map_err(|e| format!("Encryption failed: {}", e))??;
868 Sealed::Body(crate::blossom::UploadBody::Memory(Arc::new(sealed)), None)
869 }
870 FileSource::Path(path) => {
871 let _ = std::fs::create_dir_all(&outgoing);
872 let out = outgoing.join(format!("{}-{}.enc", hash, pending_id));
873 let guard = TempFile(out.clone());
874 let target = out.clone();
875 let (stage_cb, pid) = (callback.clone(), pending_id.to_string());
876 let (sealed_hash, len) = crate::rt::spawn_blocking(move || {
877 crypto::stream::encrypt_file_with_progress(&path, &target, &key, &nonce, &mut |pct| {
878 stage_cb.on_upload_stage(&pid, "encrypting", Some(pct));
879 !cancel.as_ref().is_some_and(|c| c.load(Ordering::Relaxed))
881 })
882 })
883 .await
884 .map_err(|e| format!("Encryption failed: {}", e))??;
885 Sealed::Body(crate::blossom::UploadBody::file(out, len, &sealed_hash)?, Some(guard))
886 }
887 };
888 callback.on_upload_stage(pending_id, "starting", None);
889 Ok(sealed)
890}
891
892async fn mark_send_failed(callback: &Arc<dyn SendCallback>, receiver_npub: &str, pending_id: &str, err: String) -> String {
894 let failed_msg = {
895 let mut state = STATE.lock().await;
896 state.update_message(pending_id, |msg| {
897 msg.set_failed(true);
898 msg.set_pending(false);
899 })
900 };
901 if let Some((_chat_id, ref msg)) = failed_msg {
902 callback.on_failed(receiver_npub, pending_id, msg);
903 callback.on_persist(receiver_npub, msg);
904 }
905 err
906}
907
908#[allow(clippy::too_many_arguments)]
910pub async fn send_file_dm_from(
911 receiver_npub: &str,
912 source: FileSource,
913 filename: &str,
914 extension: &str,
915 img_meta: Option<crate::types::ImageMetadata>,
916 content: Option<&str>,
917 reply_to: Option<&str>,
918 config: &SendConfig,
919 callback: Arc<dyn SendCallback>,
920) -> Result<SendResult, String> {
921 let client = nostr_client().ok_or("Not logged in")?;
922 let my_pk = my_public_key().ok_or("Public key not set")?;
923 let reply_to = reply_to.filter(|r| !r.is_empty());
924 let signer = crate::signer::active_signer()
928 .map_err(|e| format!("Signer unavailable: {}", e))?;
929
930 let now = web_time::SystemTime::now()
931 .duration_since(web_time::UNIX_EPOCH).unwrap();
932 let pending_id = format!("pending-{}", now.as_nanos());
933 let milliseconds = now.as_millis() % 1000;
934
935 let receiver = PublicKey::from_bech32(receiver_npub)
936 .map_err(|e| format!("Invalid npub: {}", e))?;
937
938 let mime_type = crypto::mime_from_extension(extension);
939 let download_dir = crate::db::get_download_dir();
940
941 #[cfg(target_arch = "wasm32")]
943 let (file_hash, local_path_str, img_meta, plain_len) = {
944 let FileSource::Bytes(bytes) = &source else {
945 return Err("Vector Web sends files from memory".into());
946 };
947 let file_hash = crypto::sha256_hex(bytes);
948 let local_path = download_dir.join(format!("{file_hash}.{extension}"));
949 crate::webfiles::write(&local_path, bytes).await?;
950 let img_meta = img_meta.or_else(|| crypto::generate_image_metadata(bytes));
951 (file_hash, local_path.to_string_lossy().to_string(), img_meta, bytes.len() as u64)
952 };
953 #[cfg(not(target_arch = "wasm32"))]
957 let (file_hash, local_path_str, img_meta, plain_len) = {
958 let source = match &source {
959 FileSource::Bytes(bytes) => FileSource::Bytes(Arc::clone(bytes)),
960 FileSource::Path(path) => FileSource::Path(path.clone()),
961 };
962 let (filename, extension) = (filename.to_string(), extension.to_string());
963 let tmp_tag = pending_id.clone();
964 crate::rt::spawn_blocking(move || -> Result<_, String> {
965 let (file_hash, plain_len) = match &source {
966 FileSource::Bytes(bytes) => (crypto::sha256_hex(bytes), bytes.len() as u64),
967 FileSource::Path(path) => crypto::stream::hash_file(path)?,
968 };
969 let _ = std::fs::create_dir_all(&download_dir);
970 let local_name = if filename.is_empty() {
976 format!("{}.{}", &file_hash, extension)
977 } else {
978 let stem = filename.rsplit_once('.').map(|(s, _)| s).unwrap_or(&filename);
979 format!("{}.{}", stem, extension)
980 };
981 let local_path = crypto::resolve_unique_filename(&download_dir, &local_name);
983 let tmp = download_dir.join(format!(".{}.{}.tmp", &file_hash, &tmp_tag));
987 let img_meta = match &source {
988 FileSource::Bytes(bytes) => {
989 std::fs::write(&tmp, &**bytes).map_err(|e| format!("Failed to save the file: {e}"))?;
990 img_meta.or_else(|| crypto::generate_image_metadata(bytes))
991 }
992 FileSource::Path(path) => {
993 std::fs::copy(path, &tmp).map_err(|e| format!("Failed to save the file: {e}"))?;
994 img_meta
995 }
996 };
997 if let Err(e) = std::fs::rename(&tmp, &local_path) {
998 let _ = std::fs::remove_file(&tmp);
999 return Err(format!("Failed to save the file: {e}"));
1000 }
1001 Ok((file_hash, local_path.to_string_lossy().to_string(), img_meta, plain_len))
1002 })
1003 .await
1004 .map_err(|e| format!("Attachment prep failed: {}", e))??
1005 };
1006
1007 let webxdc_topic = (extension.eq_ignore_ascii_case("xdc"))
1012 .then(|| crate::webxdc::mint_topic_id(&file_hash, &my_pk.to_hex()));
1013
1014 let params = crypto::generate_encryption_params();
1017 let attachment = Attachment {
1018 id: file_hash.clone(), key: params.key.clone(), nonce: params.nonce.clone(),
1019 extension: extension.to_string(), name: filename.to_string(),
1020 url: String::new(), path: local_path_str.clone(), size: plain_len + 16,
1021 img_meta: img_meta.clone(), downloading: false, downloaded: true,
1022 webxdc_topic: webxdc_topic.clone(),
1023 ..Default::default()
1024 };
1025 let mut msg = Message {
1026 id: pending_id.clone(), content: content.unwrap_or("").to_string(),
1027 replied_to: reply_to.unwrap_or("").to_string(),
1028 at: now.as_millis() as u64, pending: true, mine: true,
1029 npub: my_pk.to_bech32().ok(), attachments: vec![attachment.clone()],
1030 expiration: if config.self_destruct_secs.is_some() { None } else { config.expiration },
1032 ..Default::default()
1033 };
1034 let _ = crate::db::events::populate_reply_context(&mut msg).await;
1035 {
1036 let mut state = STATE.lock().await;
1037 state.add_message_to_participant(receiver_npub, &msg);
1038 }
1039 callback.on_pending(receiver_npub, &msg);
1040 let cancel = config.cancel_token.clone().or_else(|| callback.cancel_token(&pending_id));
1041
1042 let plain = match source {
1045 FileSource::Bytes(bytes) => FileSource::Bytes(bytes),
1046 FileSource::Path(_) => FileSource::Path(std::path::PathBuf::from(&local_path_str)),
1047 };
1048 let sealed = match seal_or_reuse(plain, &file_hash, ¶ms.key, ¶ms.nonce, &pending_id, callback.clone(), cancel.clone()).await {
1049 Ok(sealed) => sealed,
1050 Err(e) => return Err(mark_send_failed(&callback, receiver_npub, &pending_id, e).await),
1052 };
1053 let (reused, encrypted, _ciphertext_file) = match sealed {
1055 Sealed::Reused(r) => (Some(r), None, None),
1056 Sealed::Body(body, guard) => (None, Some(body), guard),
1057 };
1058 let (att_key, att_nonce, att_url, encrypted_size) = match (&reused, &encrypted) {
1059 (Some(r), _) => {
1060 let adopted = crate::compact::CompactAttachment::from_attachment(&Attachment {
1061 key: r.key.clone(), nonce: r.nonce.clone(), url: r.url.clone(), size: r.size,
1062 ..attachment.clone()
1063 });
1064 let mut state = STATE.lock().await;
1065 state.update_message(&pending_id, |msg| {
1066 if let Some(att) = msg.attachments.last_mut() {
1067 *att = adopted.clone();
1068 }
1069 });
1070 (r.key.clone(), r.nonce.clone(), r.url.clone(), r.size)
1071 }
1072 (None, body) => (params.key.clone(), params.nonce.clone(), String::new(), body.as_ref().map_or(0, |b| b.len())),
1073 };
1074
1075 let servers = crate::state::get_blossom_servers();
1077 let cb_for_progress = callback.clone();
1078 let pid_for_progress = pending_id.clone();
1079 let progress_cb: crate::blossom::ProgressCallback = Arc::new(move |percentage, bytes| {
1080 cb_for_progress.on_upload_progress(
1081 &pid_for_progress,
1082 percentage.unwrap_or(0),
1083 bytes.unwrap_or(0),
1084 )
1085 });
1086
1087 let (upload_url, mirror_urls) = match encrypted {
1091 None => {
1094 callback.on_upload_complete(receiver_npub, &pending_id, &file_hash, &att_url);
1095 if reused.as_ref().is_some_and(|r| !r.mine) {
1099 let signer_bg = signer.clone();
1100 let url_bg = att_url.clone();
1101 crate::db::spawn_bound(async move {
1102 let _ = crate::blossom::mirror_blob_to_servers(
1103 signer_bg, &url_bg, crate::state::get_blossom_servers(),
1104 1, std::time::Duration::from_secs(8), &[],
1105 ).await;
1106 });
1107 }
1108 (att_url.clone(), Vec::new())
1109 }
1110 Some(encrypted) => {
1111 let accepted = match crate::blossom::upload_body_with_progress_and_failover(
1112 signer.clone(), servers, encrypted, Some(mime_type),
1113 true,
1114 progress_cb, Some(config.upload_retries), Some(config.upload_retry_delay),
1115 cancel.clone(),
1116 ).await {
1117 Ok(accepted) => accepted,
1118 Err(e) => return Err(mark_send_failed(&callback, receiver_npub, &pending_id, format!("Upload failed: {}", e)).await),
1119 };
1120
1121 let upload_url = accepted.url.clone();
1122 {
1123 let mut state = STATE.lock().await;
1124 state.update_message(&pending_id, |msg| {
1125 if let Some(att) = msg.attachments.last_mut() {
1126 att.url = upload_url.clone().into_boxed_str();
1127 }
1128 });
1129 }
1130 callback.on_upload_complete(receiver_npub, &pending_id, &file_hash, &upload_url);
1131
1132 let mirror_urls = crate::blossom::mirror_blob_to_servers(
1136 signer.clone(),
1137 &upload_url,
1138 crate::state::get_blossom_servers(),
1139 2,
1140 std::time::Duration::from_secs(5),
1141 std::slice::from_ref(&accepted.server),
1142 ).await;
1143 if !mirror_urls.is_empty() {
1144 let mut state = STATE.lock().await;
1145 state.update_message(&pending_id, |msg| {
1146 if let Some(att) = msg.attachments.last_mut() {
1147 att.fallback_urls = Some(mirror_urls.iter().map(|s| s.as_str().into()).collect());
1148 }
1149 });
1150 }
1151 (upload_url, mirror_urls)
1152 }
1153 };
1154
1155 if cancel.as_ref().is_some_and(|c| c.load(Ordering::Relaxed)) {
1158 return Err("Upload cancelled".to_string());
1159 }
1160
1161 let mut file_rumor = EventBuilder::new(Kind::from_u16(15), &upload_url)
1163 .tag(Tag::public_key(receiver))
1164 .tag(Tag::custom("file-type", [mime_type]))
1165 .tag(Tag::custom("size", [encrypted_size.to_string()]))
1166 .tag(Tag::custom("encryption-algorithm", ["aes-gcm"]))
1167 .tag(Tag::custom("decryption-key", [att_key.as_str()]))
1168 .tag(Tag::custom("decryption-nonce", [att_nonce.as_str()]))
1169 .tag(Tag::custom("ox", [file_hash.clone()]));
1170 if let Some(reply_id) = reply_to {
1171 file_rumor = file_rumor.tag(Tag::custom(
1172 "e",
1173 [reply_id.to_string(), String::new(), "reply".to_string()],
1174 ));
1175 }
1176 for fb in &mirror_urls {
1177 file_rumor = file_rumor.tag(Tag::custom("fallback", [fb.as_str()]));
1178 }
1179 if !filename.is_empty() {
1180 file_rumor = file_rumor.tag(Tag::custom("name", [filename]));
1181 }
1182 if let Some(ref topic) = webxdc_topic {
1183 file_rumor = file_rumor.tag(Tag::custom("webxdc-topic", [topic.as_str()]));
1184 }
1185 if let Some(ref meta) = img_meta {
1187 if !meta.thumbhash.is_empty() {
1188 file_rumor = file_rumor.tag(Tag::custom("thumbhash", [meta.thumbhash.as_str()]));
1191 }
1192 file_rumor = file_rumor.tag(Tag::custom("dim", [format!("{}x{}", meta.width, meta.height)]));
1193 }
1194 file_rumor = file_rumor.tag(Tag::custom("ms", [milliseconds.to_string()]));
1195 let expiration = config
1198 .self_destruct_secs
1199 .and_then(crate::self_destruct::expiry_after)
1200 .or(config.expiration);
1201 if let Some(exp) = expiration {
1202 file_rumor = file_rumor.tag(Tag::expiration(Timestamp::from_secs(exp)));
1203 let mut state = STATE.lock().await;
1204 state.update_message(&pending_id, |msg| { msg.expiration_secs = exp as u32; });
1205 }
1206
1207 let built_rumor = file_rumor.finalize_unsigned_with_id(my_pk);
1208 let event_id = built_rumor.id.ok_or("Rumor has no id")?.to_hex();
1209
1210 retry_send_gift_wrap(
1211 &client, &receiver, receiver_npub, &pending_id,
1212 built_rumor, &event_id, config, callback, None,
1213 ).await
1214}
1215
1216#[cfg(test)]
1221mod tests {
1222 use super::*;
1223 use std::sync::Mutex;
1224
1225 #[derive(Debug, Clone, PartialEq)]
1226 enum CbEvent {
1227 Pending(String),
1228 UploadProgress(String, u8, u64),
1229 UploadComplete(String, String),
1230 Sent(String, String),
1231 Failed(String, String),
1232 Persist(String),
1233 }
1234
1235 struct MockCallback {
1236 events: Mutex<Vec<CbEvent>>,
1237 cancel_at: Option<u8>,
1238 }
1239
1240 impl MockCallback {
1241 fn new() -> Self { Self { events: Mutex::new(vec![]), cancel_at: None } }
1242 fn with_cancel(pct: u8) -> Self { Self { events: Mutex::new(vec![]), cancel_at: Some(pct) } }
1243 fn events(&self) -> Vec<CbEvent> { self.events.lock().unwrap().clone() }
1244 }
1245
1246 impl SendCallback for MockCallback {
1247 fn on_pending(&self, cid: &str, _: &Message) {
1248 self.events.lock().unwrap().push(CbEvent::Pending(cid.into()));
1249 }
1250 fn on_upload_progress(&self, pid: &str, pct: u8, bytes: u64) -> Result<(), String> {
1251 self.events.lock().unwrap().push(CbEvent::UploadProgress(pid.into(), pct, bytes));
1252 if self.cancel_at.map_or(false, |c| pct >= c) { return Err("Cancelled".into()); }
1253 Ok(())
1254 }
1255 fn on_upload_complete(&self, cid: &str, _: &str, _: &str, url: &str) {
1256 self.events.lock().unwrap().push(CbEvent::UploadComplete(cid.into(), url.into()));
1257 }
1258 fn on_sent(&self, cid: &str, old: &str, _: &Message) {
1259 self.events.lock().unwrap().push(CbEvent::Sent(cid.into(), old.into()));
1260 }
1261 fn on_failed(&self, cid: &str, old: &str, _: &Message) {
1262 self.events.lock().unwrap().push(CbEvent::Failed(cid.into(), old.into()));
1263 }
1264 fn on_persist(&self, cid: &str, _: &Message) {
1265 self.events.lock().unwrap().push(CbEvent::Persist(cid.into()));
1266 }
1267 }
1268
1269 #[test]
1270 fn config_default() {
1271 let c = SendConfig::default();
1272 assert_eq!(c.max_send_attempts, 1);
1273 assert!(!c.self_send);
1274 assert!(c.cancel_token.is_none());
1275 assert_eq!(c.upload_retries, 3);
1276 }
1277
1278 #[test]
1279 fn config_gui() {
1280 let c = SendConfig::gui();
1281 assert_eq!(c.max_send_attempts, 12);
1282 assert!(c.self_send);
1283 }
1284
1285 #[test]
1286 fn config_headless() {
1287 let c = SendConfig::headless();
1288 assert_eq!(c.max_send_attempts, 3);
1289 assert!(c.self_send);
1290 }
1291
1292 #[test]
1293 fn config_custom_override() {
1294 let c = SendConfig { max_send_attempts: 5, ..SendConfig::gui() };
1295 assert_eq!(c.max_send_attempts, 5);
1296 assert!(c.self_send);
1297 }
1298
1299 #[test]
1300 fn noop_callback_all_methods() {
1301 let cb = NoOpSendCallback;
1302 let msg = Message::default();
1303 cb.on_pending("c", &msg);
1304
1305 assert!(cb.on_upload_progress("p", 50, 1024).is_ok());
1306 cb.on_upload_complete("c", "p", "a", "url");
1307 cb.on_sent("c", "o", &msg);
1308 cb.on_failed("c", "o", &msg);
1309 cb.on_persist("c", &msg);
1310 }
1311
1312 #[test]
1313 fn text_dm_sequence() {
1314 let cb = MockCallback::new();
1315 let msg = Message::default();
1316 cb.on_pending("r", &msg);
1317 cb.on_sent("r", "p-1", &msg);
1318 cb.on_persist("r", &msg);
1319 assert_eq!(cb.events(), vec![
1320 CbEvent::Pending("r".into()),
1321 CbEvent::Sent("r".into(), "p-1".into()),
1322 CbEvent::Persist("r".into()),
1323 ]);
1324 }
1325
1326 #[test]
1327 fn file_dm_sequence() {
1328 let cb = MockCallback::new();
1329 let msg = Message::default();
1330 cb.on_pending("r", &msg);
1331
1332 cb.on_upload_progress("p", 0, 0).ok();
1333 cb.on_upload_progress("p", 50, 5000).ok();
1334 cb.on_upload_progress("p", 100, 10000).ok();
1335 cb.on_upload_complete("r", "p", "h", "https://blossom/h");
1336 cb.on_sent("r", "p", &msg);
1337 cb.on_persist("r", &msg);
1338 let e = cb.events();
1339 assert_eq!(e.len(), 7);
1340 assert!(matches!(&e[4], CbEvent::UploadComplete(_, url) if url.contains("blossom")));
1341 }
1342
1343 #[test]
1344 fn failed_sequence() {
1345 let cb = MockCallback::new();
1346 let msg = Message::default();
1347 cb.on_pending("r", &msg);
1348 cb.on_failed("r", "p", &msg);
1349 cb.on_persist("r", &msg);
1350 assert_eq!(cb.events(), vec![
1351 CbEvent::Pending("r".into()),
1352 CbEvent::Failed("r".into(), "p".into()),
1353 CbEvent::Persist("r".into()),
1354 ]);
1355 }
1356
1357 #[test]
1358 fn cancel_upload_at_threshold() {
1359 let cb = MockCallback::with_cancel(50);
1360 assert!(cb.on_upload_progress("p", 25, 512).is_ok());
1361 assert!(cb.on_upload_progress("p", 50, 1024).is_err());
1362 assert_eq!(cb.events().len(), 2);
1363 }
1364
1365 #[test]
1366 fn cancel_triggers_failed() {
1367 let cb = MockCallback::with_cancel(30);
1368 let msg = Message::default();
1369 cb.on_pending("r", &msg);
1370
1371 cb.on_upload_progress("p", 10, 1000).ok();
1372 assert!(cb.on_upload_progress("p", 30, 3000).is_err());
1373 cb.on_failed("r", "p", &msg);
1374 assert!(matches!(cb.events().last(), Some(CbEvent::Failed(..))));
1375 }
1376
1377 #[test]
1378 fn send_result_serialize() {
1379 let r = SendResult { pending_id: "p".into(), event_id: Some("e".into()), chat_id: "c".into() };
1380 let j = serde_json::to_string(&r).unwrap();
1381 assert!(j.contains("\"pending_id\":\"p\""));
1382 }
1383
1384 #[test]
1385 fn send_result_none_event() {
1386 let r = SendResult { pending_id: "p".into(), event_id: None, chat_id: "c".into() };
1387 let j = serde_json::to_string(&r).unwrap();
1388 assert!(j.contains("null"));
1389 }
1390
1391 #[test]
1396 fn file_dm_fresh_upload_full_sequence() {
1397 let cb = MockCallback::new();
1398 let msg = Message::default();
1399
1400 cb.on_pending("npub1recv", &msg);
1402 cb.on_upload_progress("pending-42", 0, 0).unwrap();
1406 cb.on_upload_progress("pending-42", 25, 2500).unwrap();
1407 cb.on_upload_progress("pending-42", 50, 5000).unwrap();
1408 cb.on_upload_progress("pending-42", 75, 7500).unwrap();
1409 cb.on_upload_progress("pending-42", 100, 10000).unwrap();
1410 cb.on_upload_complete("npub1recv", "pending-42", "deadbeef", "https://blossom.example/deadbeef");
1412 cb.on_sent("npub1recv", "pending-42", &msg);
1414 cb.on_persist("npub1recv", &msg);
1416
1417 let e = cb.events();
1418 assert_eq!(e.len(), 9);
1419 assert!(matches!(&e[0], CbEvent::Pending(c) if c == "npub1recv"));
1420 assert!(matches!(&e[1], CbEvent::UploadProgress(_, 0, 0)));
1421 assert!(matches!(&e[5], CbEvent::UploadProgress(_, 100, 10000)));
1422 assert!(matches!(&e[6], CbEvent::UploadComplete(_, url) if url.contains("deadbeef")));
1423 assert!(matches!(&e[7], CbEvent::Sent(..)));
1424 assert!(matches!(&e[8], CbEvent::Persist(..)));
1425 }
1426
1427 #[test]
1428 fn file_dm_skip_upload_sequence() {
1429 let cb = MockCallback::new();
1430 let msg = Message::default();
1431
1432 cb.on_pending("npub1recv", &msg);
1435 cb.on_upload_complete("npub1recv", "pending-99", "existinghash", "https://blossom.example/existing");
1439 cb.on_sent("npub1recv", "pending-99", &msg);
1441 cb.on_persist("npub1recv", &msg);
1443
1444 let e = cb.events();
1445 assert_eq!(e.len(), 4);
1446 assert!(!e.iter().any(|ev| matches!(ev, CbEvent::UploadProgress(..))));
1448 assert!(matches!(&e[1], CbEvent::UploadComplete(..)));
1449 }
1450
1451 #[test]
1452 fn file_dm_upload_cancelled_at_30pct() {
1453 let cb = MockCallback::with_cancel(30);
1454 let msg = Message::default();
1455
1456 cb.on_pending("npub1recv", &msg);
1457
1458 assert!(cb.on_upload_progress("p", 10, 1000).is_ok());
1459 assert!(cb.on_upload_progress("p", 20, 2000).is_ok());
1460 let err = cb.on_upload_progress("p", 30, 3000);
1462 assert!(err.is_err());
1463 assert!(err.unwrap_err().contains("Cancelled"));
1464 cb.on_failed("npub1recv", "p", &msg);
1466
1467 let e = cb.events();
1468 assert_eq!(e.len(), 5);
1469 assert!(!e.iter().any(|ev| matches!(ev, CbEvent::Sent(..))));
1471 assert!(matches!(e.last(), Some(CbEvent::Failed(..))));
1472 }
1473
1474 #[test]
1475 fn file_dm_upload_fails_marks_failed() {
1476 let cb = MockCallback::new();
1477 let msg = Message::default();
1478
1479 cb.on_pending("npub1recv", &msg);
1480
1481 cb.on_upload_progress("p", 0, 0).ok();
1482 cb.on_upload_progress("p", 10, 500).ok();
1483 cb.on_failed("npub1recv", "p", &msg);
1485 cb.on_persist("npub1recv", &msg);
1486
1487 let e = cb.events();
1488 assert_eq!(e.len(), 5);
1489 assert!(matches!(&e[3], CbEvent::Failed(..)));
1490 assert!(matches!(&e[4], CbEvent::Persist(..)));
1491 assert!(!e.iter().any(|ev| matches!(ev, CbEvent::UploadComplete(..))));
1493 assert!(!e.iter().any(|ev| matches!(ev, CbEvent::Sent(..))));
1494 }
1495
1496 #[test]
1497 fn file_dm_gift_wrap_fails_after_upload() {
1498 let cb = MockCallback::new();
1499 let msg = Message::default();
1500
1501 cb.on_pending("npub1recv", &msg);
1503
1504 cb.on_upload_progress("p", 100, 10000).ok();
1505 cb.on_upload_complete("npub1recv", "p", "hash", "https://blossom/hash");
1506 cb.on_failed("npub1recv", "p", &msg);
1508 cb.on_persist("npub1recv", &msg);
1509
1510 let e = cb.events();
1511 assert_eq!(e.len(), 5);
1512 assert!(matches!(&e[2], CbEvent::UploadComplete(..)));
1514 assert!(matches!(&e[3], CbEvent::Failed(..)));
1515 }
1516
1517 #[test]
1518 fn file_dm_with_image_metadata_sequence() {
1519 let cb = MockCallback::new();
1520 let msg = Message::default();
1521
1522 cb.on_pending("npub1recv", &msg);
1524
1525 cb.on_upload_progress("p", 0, 0).ok();
1526 cb.on_upload_progress("p", 50, 50000).ok();
1527 cb.on_upload_progress("p", 100, 100000).ok();
1528 cb.on_upload_complete("npub1recv", "p", "imghash", "https://blossom/imghash.jpg");
1529 cb.on_sent("npub1recv", "p", &msg);
1530 cb.on_persist("npub1recv", &msg);
1531
1532 let e = cb.events();
1533 assert_eq!(e.len(), 7);
1534 assert!(matches!(&e[0], CbEvent::Pending(..)));
1536 assert!(matches!(&e[4], CbEvent::UploadComplete(_, url) if url.ends_with(".jpg")));
1537 assert!(matches!(&e[5], CbEvent::Sent(..)));
1538 }
1539
1540 #[test]
1541 fn cancel_token_config_with_upload() {
1542 let token = Arc::new(std::sync::atomic::AtomicBool::new(false));
1543 let c = SendConfig {
1544 cancel_token: Some(token.clone()),
1545 ..SendConfig::gui()
1546 };
1547 assert!(c.cancel_token.is_some());
1548 assert!(!token.load(std::sync::atomic::Ordering::Relaxed));
1549
1550 token.store(true, std::sync::atomic::Ordering::Relaxed);
1552 assert!(c.cancel_token.as_ref().unwrap().load(std::sync::atomic::Ordering::Relaxed));
1553 }
1554}
1555
1556#[cfg(test)]
1557mod seal_tests {
1558 use super::*;
1559 use std::sync::Mutex;
1560
1561 #[derive(Default)]
1562 struct Stages(Mutex<Vec<(String, Option<u8>)>>);
1563 impl SendCallback for Stages {
1564 fn on_upload_stage(&self, _: &str, stage: &str, pct: Option<u8>) {
1565 self.0.lock().unwrap().push((stage.to_string(), pct));
1566 }
1567 }
1568
1569 #[tokio::test]
1570 async fn bytes_and_files_seal_to_what_their_key_opens() {
1571 let plain: Vec<u8> = (0..300_000u32).map(|i| (i * 7 % 251) as u8).collect();
1572 let params = crypto::generate_encryption_params();
1573 let hash = crypto::sha256_hex(&plain);
1574 let src = std::env::temp_dir().join(format!("vector-seal-{}.bin", std::process::id()));
1575 std::fs::write(&src, &plain).unwrap();
1576
1577 for source in [FileSource::Bytes(Arc::new(plain.clone())), FileSource::Path(src.clone())] {
1578 let from_file = matches!(source, FileSource::Path(_));
1579 let stages = Arc::new(Stages::default());
1580 let sealed = seal_or_reuse(source, &hash, ¶ms.key, ¶ms.nonce, "pending-1", stages.clone(), None).await.unwrap();
1581 let Sealed::Body(body, guard) = sealed else { panic!("nothing to reuse without a database") };
1582 let ciphertext = match &body {
1583 crate::blossom::UploadBody::Memory(b) => b.to_vec(),
1584 crate::blossom::UploadBody::File { path, .. } => std::fs::read(path).unwrap(),
1585 };
1586 assert_eq!(body.len(), plain.len() as u64 + 16);
1587 assert_eq!(crypto::decrypt_data(&ciphertext, ¶ms.key, ¶ms.nonce).unwrap(), plain);
1588 let stages = stages.0.lock().unwrap().clone();
1589 assert_eq!(stages.first().unwrap().0, "encrypting");
1590 assert_eq!(stages.last().unwrap(), &("starting".to_string(), None));
1591 assert_eq!(guard.is_some(), from_file);
1592 if let crate::blossom::UploadBody::File { path, .. } = &body {
1593 drop(guard);
1594 assert!(!path.exists(), "the ciphertext goes with its guard");
1595 }
1596 }
1597 let _ = std::fs::remove_file(&src);
1598 }
1599}