1use crate::event_ext::FinalizeUnsignedWithId;
29use std::path::{Path, PathBuf};
30use std::sync::Arc;
31use std::time::Duration;
32
33use nostr_sdk::prelude::*;
34use serde::{Deserialize, Serialize};
35
36use crate::crypto;
37use crate::stored_event::event_kind;
38
39const WALLPAPER_DTAG_VALUE: &str = "vector-wallpaper";
40
41const MAX_WALLPAPER_DOWNLOAD_BYTES: u64 = 10 * 1024 * 1024;
45
46pub const MAX_WALLPAPER_BYTES: usize = 5 * 1024 * 1024;
49
50const MAX_WALLPAPER_DIMENSION: u32 = 2560;
53
54fn wallpapers_dir() -> Result<PathBuf, String> {
57 let npub = crate::db::get_current_account()?;
58 Ok(crate::db::account_dir(&npub)?.join("wallpapers"))
59}
60
61#[derive(Debug, Clone, Serialize, Deserialize)]
62pub struct WallpaperPreview {
63 pub path: String,
65 pub was_animated: bool,
68 pub recommended_dim: u8,
73}
74
75pub async fn prepare_wallpaper_preview(
80 chat_npub: &str,
81 file_path: &str,
82) -> Result<WallpaperPreview, String> {
83 let bytes = crate::files::read(Path::new(file_path))
84 .await
85 .map_err(|e| format!("Failed to read image: {}", e))?;
86
87 if bytes.len() > MAX_WALLPAPER_BYTES {
88 return Err(format!(
89 "Image is too large ({} MB). Wallpapers max out at {} MB.",
90 bytes.len() / (1024 * 1024),
91 MAX_WALLPAPER_BYTES / (1024 * 1024),
92 ));
93 }
94
95 let mime = crypto::mime_from_magic_bytes(&bytes).to_string();
96 if !mime.starts_with("image/") {
97 return Err("Wallpapers must be image files.".to_string());
98 }
99
100 let (final_bytes, final_extension, was_animated, recommended_dim) =
104 crate::rt::spawn_blocking(move || normalize_wallpaper_image(&bytes, &mime))
105 .await
106 .map_err(|e| format!("Image processing task failed: {e}"))??;
107
108 clean_chat_files(chat_npub, FileKind::Preview, None).await?;
110
111 let preview = wallpapers_dir()?.join(format!("{}.preview.{}", chat_npub, final_extension));
112 crate::files::write(&preview, &final_bytes)
113 .await
114 .map_err(|e| format!("Failed to stage preview file: {}", e))?;
115
116 Ok(WallpaperPreview {
117 path: preview.to_string_lossy().to_string(),
118 was_animated,
119 recommended_dim,
120 })
121}
122
123fn estimate_brightness_for_white_text(img: &::image::DynamicImage) -> u8 {
130 use ::image::GenericImageView;
131
132 let thumb = img.thumbnail(64, 64);
133 let rgb = thumb.to_rgb8();
134 let (w, h) = thumb.dimensions();
135 if w == 0 || h == 0 {
136 return 50;
137 }
138 let mut sum: u64 = 0;
139 let mut count: u64 = 0;
140 for pixel in rgb.pixels() {
141 let r = pixel[0] as f32;
142 let g = pixel[1] as f32;
143 let b = pixel[2] as f32;
144 let y = 0.2126 * r + 0.7152 * g + 0.0722 * b;
145 sum += y as u64;
146 count += 1;
147 }
148 if count == 0 {
149 return 50;
150 }
151 let avg = (sum / count) as f32; let brightness = (95.0 - (avg / 255.0) * 70.0) / 2.0;
161 brightness.clamp(10.0, 50.0) as u8
162}
163
164pub async fn cancel_wallpaper_preview(chat_npub: &str) -> Result<(), String> {
166 clean_chat_files(chat_npub, FileKind::Preview, None).await
167}
168
169fn normalize_wallpaper_image(
181 src: &[u8],
182 mime: &str,
183) -> Result<(Vec<u8>, String, bool, u8), String> {
184 let was_animated = mime == "image/gif" || mime == "image/webp";
185
186 let mut img = crate::crypto::decode_image_bounded(src)?;
187 if img.width() > MAX_WALLPAPER_DIMENSION || img.height() > MAX_WALLPAPER_DIMENSION {
188 img = img.resize(
189 MAX_WALLPAPER_DIMENSION,
190 MAX_WALLPAPER_DIMENSION,
191 ::image::imageops::FilterType::Triangle,
192 );
193 }
194
195 let recommended_dim = estimate_brightness_for_white_text(&img);
196 let (bytes, ext) = encode_wallpaper_still(&img)?;
197 Ok((bytes, ext, was_animated, recommended_dim))
198}
199
200fn encode_wallpaper_still(img: &::image::DynamicImage) -> Result<(Vec<u8>, String), String> {
203 use ::image::{ExtendedColorType, ImageEncoder};
204 use std::io::Cursor;
205
206 let mut out = Vec::new();
207 if img.color().has_alpha() {
208 let rgba = img.to_rgba8();
209 if rgba.pixels().any(|p| p.0[3] != 255) {
210 ::image::codecs::png::PngEncoder::new(Cursor::new(&mut out))
211 .write_image(rgba.as_raw(), rgba.width(), rgba.height(), ExtendedColorType::Rgba8)
212 .map_err(|e| format!("Failed to encode wallpaper: {}", e))?;
213 return Ok((out, "png".to_string()));
214 }
215 }
216 let rgb = img.to_rgb8();
217 ::image::codecs::jpeg::JpegEncoder::new_with_quality(Cursor::new(&mut out), 88)
218 .write_image(rgb.as_raw(), rgb.width(), rgb.height(), ExtendedColorType::Rgb8)
219 .map_err(|e| format!("Failed to encode wallpaper: {}", e))?;
220 Ok((out, "jpg".to_string()))
221}
222
223#[derive(Copy, Clone)]
224enum FileKind {
225 Preview,
226 Active,
227}
228
229async fn clean_chat_files(
233 chat_npub: &str,
234 kind: FileKind,
235 extension_to_skip: Option<&str>,
236) -> Result<(), String> {
237 let dir = wallpapers_dir()?;
238
239 let preview_prefix = format!("{}.preview.", chat_npub);
240 let active_prefix = format!("{}.", chat_npub);
241
242 for path in crate::files::list(&dir).await {
243 let name = match path.file_name().and_then(|n| n.to_str()) {
244 Some(n) => n.to_string(),
245 None => continue,
246 };
247 let matches = match kind {
248 FileKind::Preview => name.starts_with(&preview_prefix),
249 FileKind::Active => name.starts_with(&active_prefix) && !name.starts_with(&preview_prefix),
251 };
252 if !matches {
253 continue;
254 }
255 if let Some(ext) = extension_to_skip {
256 let suffix = format!(".{}", ext);
257 if name.ends_with(&suffix) {
258 continue;
259 }
260 }
261 crate::files::remove(&path).await;
262 }
263 Ok(())
264}
265
266pub async fn publish_wallpaper(chat_npub: &str, blur: u8, dim: u8) -> Result<(), String> {
278 let blur = blur.min(30);
283 let dim = dim.min(100);
284 let dir = wallpapers_dir()?;
286 let prefix = format!("{}.preview.", chat_npub);
287 let mut preview_path: Option<PathBuf> = None;
288 for p in crate::files::list(&dir).await {
289 let n = p
290 .file_name()
291 .and_then(|n| n.to_str())
292 .unwrap_or("")
293 .to_string();
294 if n.starts_with(&prefix) {
295 preview_path = Some(p);
296 break;
297 }
298 }
299 let preview = preview_path
300 .ok_or_else(|| "No wallpaper preview to publish. Pick an image first.".to_string())?;
301 let bytes = crate::files::read(&preview)
302 .await
303 .map_err(|e| format!("Failed to read preview file: {}", e))?;
304
305 let extension = preview
306 .extension()
307 .and_then(|e| e.to_str())
308 .unwrap_or("png")
309 .to_string();
310 let mime = crypto::mime_from_extension(&extension).to_string();
311 let plaintext_hash = crypto::sha256_hex(&bytes);
312
313 let params = crypto::generate_encryption_params();
314 let encrypted = crypto::encrypt_data(&bytes, ¶ms)?;
315
316 let _client = crate::state::nostr_client().ok_or("Not logged in")?;
317 let signer = crate::signer::active_signer()
318 .map_err(|e| format!("Signer: {}", e))?;
319 let my_pk = crate::state::my_public_key().ok_or("Public key not set")?;
320 let recipient_pk = PublicKey::from_bech32(chat_npub)
325 .map_err(|e| format!("Invalid chat npub: {}", e))?;
326
327 let servers = crate::state::get_blossom_servers();
328
329 let chat_npub_for_progress = chat_npub.to_string();
332 let progress_cb: crate::blossom::ProgressCallback = Arc::new(move |percentage, bytes| {
333 crate::traits::emit_event(
334 "wallpaper_upload_progress",
335 &serde_json::json!({
336 "chat_id": chat_npub_for_progress,
337 "progress": percentage.unwrap_or(0),
338 "bytes": bytes.unwrap_or(0),
339 }),
340 );
341 Ok(())
342 });
343
344 let upload_url = crate::blossom::upload_blob_with_progress_and_failover(
345 signer.clone(),
346 servers,
347 Arc::new(encrypted.clone()),
348 Some(&mime),
349 true,
350 progress_cb,
351 None, None, None, )
355 .await
356 .map_err(|e| format!("Wallpaper upload failed: {}", e))?
357 .url;
359
360 let created_at = web_time::SystemTime::now()
361 .duration_since(web_time::UNIX_EPOCH)
362 .unwrap()
363 .as_secs();
364 let rumor = EventBuilder::new(Kind::Custom(event_kind::APPLICATION_SPECIFIC), "")
365 .tag(Tag::identifier(WALLPAPER_DTAG_VALUE))
366 .tag(Tag::public_key(recipient_pk))
370 .tag(Tag::custom(
371 "url",
372 vec![upload_url.clone()],
373 ))
374 .tag(Tag::custom(
375 "decryption-key",
376 vec![params.key.clone()],
377 ))
378 .tag(Tag::custom(
379 "decryption-nonce",
380 vec![params.nonce.clone()],
381 ))
382 .tag(Tag::custom(
383 "x",
384 vec![plaintext_hash.clone()],
385 ))
386 .tag(Tag::custom(
387 "m",
388 vec![mime.clone()],
389 ))
390 .tag(Tag::custom(
391 "size",
392 vec![encrypted.len().to_string()],
393 ))
394 .tag(Tag::custom(
395 "blur",
396 vec![blur.to_string()],
397 ))
398 .tag(Tag::custom(
399 "dim",
400 vec![dim.to_string()],
401 ))
402 .custom_created_at(Timestamp::from(created_at))
403 .finalize_unsigned_with_id(my_pk);
404
405 let pending_id = format!("pending-wallpaper-{}", created_at);
413 let send_config = crate::sending::SendConfig {
414 max_send_attempts: 3,
415 retry_delay: std::time::Duration::from_secs(2),
416 self_send: true,
417 ..Default::default()
418 };
419 let send_callback: Arc<dyn crate::sending::SendCallback> =
420 Arc::new(crate::sending::NoOpSendCallback);
421 if let Err(e) = crate::sending::send_rumor_dm(
422 chat_npub, &pending_id, rumor.clone(), &send_config, send_callback,
423 ).await {
424 log_warn!("[Wallpaper] send_rumor_dm to {} failed: {}", chat_npub, e);
425 return Err(format!(
426 "Couldn't send the wallpaper. Check that the relays you and your contact share are reachable, then try again. ({})",
427 e
428 ));
429 }
430 log_info!("[Wallpaper] rumor delivered to {}", chat_npub);
431
432 let active = wallpapers_dir()?.join(format!("{}.{}", chat_npub, extension));
438 clean_chat_files(chat_npub, FileKind::Active, None).await?;
439 crate::files::rename(&preview, &active)
440 .await
441 .map_err(|e| format!("Failed to promote preview: {}", e))?;
442 let active_str = active.to_string_lossy().to_string();
443
444 let me_npub = my_pk.to_bech32().unwrap_or_default();
445
446 let (slim, prev_url, prev_uploader) = {
449 let mut state = crate::state::STATE.lock().await;
450 let prev = state.get_chat(chat_npub).map(|c| {
451 (c.wallpaper_url.clone(), c.wallpaper_uploader.clone())
452 });
453 if let Some(chat) = state.get_chat_mut(chat_npub) {
454 chat.wallpaper_path = active_str.clone();
455 chat.wallpaper_ts = created_at;
456 chat.wallpaper_blur = blur;
457 chat.wallpaper_dim = dim;
458 chat.wallpaper_url = upload_url.clone();
459 chat.wallpaper_uploader = me_npub.clone();
460 }
461 let slim = state
462 .get_chat(chat_npub)
463 .map(|c| crate::db::chats::SlimChatDB::from_chat(c, &state.interner));
464 let (pu, puploader) = prev.unwrap_or_default();
465 (slim, pu, puploader)
466 };
467 if let Some(slim) = slim {
468 if let Err(e) = crate::db::chats::save_slim_chat(&slim) {
469 log_warn!("[Wallpaper] save_slim_chat failed for {}: {}", chat_npub, e);
470 }
471 }
472
473 if !prev_url.is_empty() && prev_uploader == me_npub {
479 let signer_clone = signer.clone();
480 let prev_url_clone = prev_url.clone();
481 crate::db::spawn_bound(async move {
482 if let Err(e) =
483 crate::blossom::delete_blob_by_url(signer_clone, &prev_url_clone).await
484 {
485 log_warn!(
486 "[Wallpaper] DELETE prev blob {} failed: {}",
487 prev_url_clone, e
488 );
489 }
490 });
491 }
492
493 let event_id = rumor.id.ok_or("Rumor missing id")?.to_hex();
494 let inserted = match crate::db::events::save_system_event_by_id(
495 &event_id,
496 chat_npub,
497 crate::stored_event::SystemEventType::WallpaperChanged,
498 &me_npub,
499 Some("You"),
500 )
501 .await
502 {
503 Ok(b) => b,
504 Err(e) => {
505 log_warn!("[Wallpaper] save_system_event_by_id failed for {}: {}", event_id, e);
506 false
507 }
508 };
509 if inserted {
510 crate::traits::emit_event("system_event", &serde_json::json!({
511 "conversation_id": chat_npub,
512 "event_id": event_id,
513 "event_type": crate::stored_event::SystemEventType::WallpaperChanged.as_u8(),
514 "member_pubkey": me_npub,
515 "member_name": "You",
516 }));
517 } else {
518 log_warn!("[Wallpaper] system event {} was not inserted (already exists or save failed)", event_id);
519 }
520
521 crate::traits::emit_event(
522 "wallpaper_updated",
523 &serde_json::json!({
524 "chat_id": chat_npub,
525 "path": active_str,
526 "ts": created_at,
527 "blur": blur,
528 "dim": dim,
529 "by_npub": me_npub,
530 "event_id": event_id,
531 }),
532 );
533
534 Ok(())
535}
536
537#[allow(clippy::too_many_arguments)]
543pub async fn apply_received_wallpaper(
544 chat_npub: &str,
545 sender_npub: &str,
546 created_at: u64,
547 url: &str,
548 decryption_key: &str,
549 decryption_nonce: &str,
550 plaintext_hash: Option<&str>,
551 mime: Option<&str>,
552 blur: Option<u8>,
553 dim: Option<u8>,
554 rumor_event_id: &str,
555) -> Result<(), String> {
556 let blur = blur.unwrap_or(0).min(30);
561 let dim = dim.unwrap_or(50).min(100);
562 {
565 let state = crate::state::STATE.lock().await;
566 if let Some(chat) = state.get_chat(chat_npub) {
567 if chat.wallpaper_ts >= created_at {
568 return Ok(());
569 }
570 }
571 }
572
573 if url.is_empty() {
576 clean_chat_files(chat_npub, FileKind::Active, None).await?;
577 let (slim, prev_url, prev_uploader) = {
578 let mut state = crate::state::STATE.lock().await;
579 let prev = state.get_chat(chat_npub).map(|c| {
580 (c.wallpaper_url.clone(), c.wallpaper_uploader.clone())
581 });
582 if let Some(chat) = state.get_chat_mut(chat_npub) {
583 chat.wallpaper_path = String::new();
584 chat.wallpaper_url = String::new();
585 chat.wallpaper_uploader = String::new();
586 chat.wallpaper_ts = created_at;
587 chat.wallpaper_blur = blur;
588 chat.wallpaper_dim = dim;
589 }
590 let slim = state
591 .get_chat(chat_npub)
592 .map(|c| crate::db::chats::SlimChatDB::from_chat(c, &state.interner));
593 let (pu, puploader) = prev.unwrap_or_default();
594 (slim, pu, puploader)
595 };
596 if let Some(slim) = slim {
597 let _ = crate::db::chats::save_slim_chat(&slim);
598 }
599 delete_prior_blob_if_ours(&prev_url, &prev_uploader).await;
600 emit_wallpaper_removed(chat_npub, sender_npub, created_at, rumor_event_id).await;
601 return Ok(());
602 }
603
604 let mime_str = mime.unwrap_or("image/png").to_string();
605 let extension = crypto::extension_from_mime(&mime_str);
606
607 crate::net::validate_url_not_private(url)?;
610
611 let http = crate::net::build_http_client(Duration::from_secs(30))?;
612 let resp = crate::net::proxied_request(&http, reqwest::Method::GET, url)
613 .await
614 .send()
615 .await
616 .map_err(|e| format!("Wallpaper download failed: {}", e))?;
617 if !resp.status().is_success() {
618 return Err(format!("Wallpaper HTTP {}", resp.status()));
619 }
620 if let Some(len) = resp.content_length() {
623 if len > MAX_WALLPAPER_DOWNLOAD_BYTES {
624 return Err("Wallpaper too large".to_string());
625 }
626 }
627 let mut bytes: Vec<u8> = Vec::new();
628 let mut body = resp.bytes_stream();
629 while let Some(chunk) = futures_util::StreamExt::next(&mut body)
630 .await
631 .transpose()
632 .map_err(|e| format!("Read body: {}", e))?
633 {
634 bytes.extend_from_slice(&chunk);
635 if bytes.len() as u64 > MAX_WALLPAPER_DOWNLOAD_BYTES {
636 return Err("Wallpaper too large".to_string());
637 }
638 }
639
640 let plaintext = crypto::decrypt_data_owned(bytes, decryption_key, decryption_nonce)?;
641
642 if let Some(want_hash) = plaintext_hash {
643 let got_hash = crypto::sha256_hex(&plaintext);
644 if !got_hash.eq_ignore_ascii_case(want_hash) {
645 return Err("Wallpaper integrity check failed".to_string());
646 }
647 }
648
649 let active = wallpapers_dir()?.join(format!("{}.{}", chat_npub, extension));
653 clean_chat_files(chat_npub, FileKind::Active, None).await?;
654 crate::files::write(&active, &plaintext).await.map_err(|e| format!("Write wallpaper: {}", e))?;
655 let active_str = active.to_string_lossy().to_string();
656
657 let (slim, prev_url, prev_uploader) = {
661 let mut state = crate::state::STATE.lock().await;
662 let prev = state.get_chat(chat_npub).map(|c| {
663 (c.wallpaper_url.clone(), c.wallpaper_uploader.clone())
664 });
665 if let Some(chat) = state.get_chat_mut(chat_npub) {
666 chat.wallpaper_path = active_str.clone();
667 chat.wallpaper_ts = created_at;
668 chat.wallpaper_blur = blur;
669 chat.wallpaper_dim = dim;
670 chat.wallpaper_url = url.to_string();
671 chat.wallpaper_uploader = sender_npub.to_string();
672 }
673 let slim = state
674 .get_chat(chat_npub)
675 .map(|c| crate::db::chats::SlimChatDB::from_chat(c, &state.interner));
676 let (pu, puploader) = prev.unwrap_or_default();
677 (slim, pu, puploader)
678 };
679 if let Some(slim) = slim {
680 let _ = crate::db::chats::save_slim_chat(&slim);
681 }
682
683 if !prev_url.is_empty() {
684 let me_npub = crate::state::my_public_key()
685 .and_then(|pk| pk.to_bech32().ok())
686 .unwrap_or_default();
687 if !me_npub.is_empty() && prev_uploader == me_npub {
688 if let Some(_client) = crate::state::nostr_client() {
689 if let Ok(signer) = crate::signer::active_signer() {
690 let prev_url_clone = prev_url.clone();
691 crate::db::spawn_bound(async move {
692 if let Err(e) =
693 crate::blossom::delete_blob_by_url(signer, &prev_url_clone).await
694 {
695 log_warn!(
696 "[Wallpaper] DELETE prev blob {} failed: {}",
697 prev_url_clone, e
698 );
699 }
700 });
701 }
702 }
703 }
704 }
705
706 let sender_display = {
710 let state = crate::state::STATE.lock().await;
711 state
712 .get_profile(sender_npub)
713 .and_then(|p| {
714 if !p.nickname().is_empty() {
715 Some(p.nickname().to_string())
716 } else if !p.name.is_empty() {
717 Some(p.name.to_string())
718 } else {
719 None
720 }
721 })
722 .unwrap_or_else(|| sender_npub.to_string())
723 };
724 let inserted = crate::db::events::save_system_event_by_id(
725 rumor_event_id,
726 chat_npub,
727 crate::stored_event::SystemEventType::WallpaperChanged,
728 sender_npub,
729 Some(&sender_display),
730 )
731 .await
732 .unwrap_or(false);
733 if inserted {
734 crate::traits::emit_event("system_event", &serde_json::json!({
735 "conversation_id": chat_npub,
736 "event_id": rumor_event_id,
737 "event_type": crate::stored_event::SystemEventType::WallpaperChanged.as_u8(),
738 "member_pubkey": sender_npub,
739 "member_name": sender_display,
740 }));
741 }
742
743 crate::traits::emit_event(
744 "wallpaper_updated",
745 &serde_json::json!({
746 "chat_id": chat_npub,
747 "path": active_str,
748 "ts": created_at,
749 "blur": blur,
750 "dim": dim,
751 "by_npub": sender_npub,
752 "event_id": rumor_event_id,
753 }),
754 );
755
756 Ok(())
757}
758
759async fn delete_prior_blob_if_ours(prev_url: &str, prev_uploader: &str) {
763 if prev_url.is_empty() {
764 return;
765 }
766 let me_npub = crate::state::my_public_key()
767 .and_then(|pk| pk.to_bech32().ok())
768 .unwrap_or_default();
769 if me_npub.is_empty() || prev_uploader != me_npub {
770 return;
771 }
772 if let Some(_client) = crate::state::nostr_client() {
773 if let Ok(signer) = crate::signer::active_signer() {
774 let prev_url = prev_url.to_string();
775 crate::db::spawn_bound(async move {
776 if let Err(e) = crate::blossom::delete_blob_by_url(signer, &prev_url).await {
777 log_warn!("[Wallpaper] DELETE prev blob {} failed: {}", prev_url, e);
778 }
779 });
780 }
781 }
782}
783
784async fn emit_wallpaper_removed(
787 chat_npub: &str,
788 by_npub: &str,
789 created_at: u64,
790 event_id: &str,
791) {
792 let me_npub = crate::state::my_public_key()
793 .and_then(|pk| pk.to_bech32().ok())
794 .unwrap_or_default();
795 let display = if by_npub == me_npub {
796 "You".to_string()
797 } else {
798 let state = crate::state::STATE.lock().await;
799 state
800 .get_profile(by_npub)
801 .and_then(|p| {
802 if !p.nickname().is_empty() {
803 Some(p.nickname().to_string())
804 } else if !p.name.is_empty() {
805 Some(p.name.to_string())
806 } else {
807 None
808 }
809 })
810 .unwrap_or_else(|| by_npub.to_string())
811 };
812 let inserted = crate::db::events::save_system_event_by_id(
813 event_id,
814 chat_npub,
815 crate::stored_event::SystemEventType::WallpaperRemoved,
816 by_npub,
817 Some(&display),
818 )
819 .await
820 .unwrap_or(false);
821 if inserted {
822 crate::traits::emit_event("system_event", &serde_json::json!({
823 "conversation_id": chat_npub,
824 "event_id": event_id,
825 "event_type": crate::stored_event::SystemEventType::WallpaperRemoved.as_u8(),
826 "member_pubkey": by_npub,
827 "member_name": display,
828 }));
829 }
830 crate::traits::emit_event(
831 "wallpaper_updated",
832 &serde_json::json!({
833 "chat_id": chat_npub,
834 "path": "",
835 "ts": created_at,
836 "blur": 0,
837 "dim": 50,
838 "by_npub": by_npub,
839 "event_id": event_id,
840 }),
841 );
842}
843
844pub async fn remove_wallpaper(chat_npub: &str) -> Result<(), String> {
849
850 let my_pk = crate::state::my_public_key().ok_or("Public key not set")?;
851 let recipient_pk = PublicKey::from_bech32(chat_npub)
852 .map_err(|e| format!("Invalid chat npub: {}", e))?;
853
854 let created_at = web_time::SystemTime::now()
855 .duration_since(web_time::UNIX_EPOCH)
856 .unwrap()
857 .as_secs();
858 let rumor = EventBuilder::new(Kind::Custom(event_kind::APPLICATION_SPECIFIC), "")
860 .tag(Tag::identifier(WALLPAPER_DTAG_VALUE))
861 .tag(Tag::public_key(recipient_pk))
862 .custom_created_at(Timestamp::from(created_at))
863 .finalize_unsigned_with_id(my_pk);
864
865 let pending_id = format!("pending-wallpaper-rm-{}", created_at);
866 let send_config = crate::sending::SendConfig {
867 max_send_attempts: 3,
868 retry_delay: std::time::Duration::from_secs(2),
869 self_send: true,
870 ..Default::default()
871 };
872 let send_callback: Arc<dyn crate::sending::SendCallback> =
873 Arc::new(crate::sending::NoOpSendCallback);
874 if let Err(e) = crate::sending::send_rumor_dm(
875 chat_npub, &pending_id, rumor.clone(), &send_config, send_callback,
876 ).await {
877 log_warn!("[Wallpaper] removal send to {} failed: {}", chat_npub, e);
878 return Err(format!(
879 "Couldn't remove the wallpaper. Check that the relays you and your contact share are reachable, then try again. ({})",
880 e
881 ));
882 }
883
884 clean_chat_files(chat_npub, FileKind::Active, None).await?;
888 let me_npub = my_pk.to_bech32().unwrap_or_default();
889 let (slim, prev_url, prev_uploader) = {
890 let mut state = crate::state::STATE.lock().await;
891 let prev = state.get_chat(chat_npub).map(|c| {
892 (c.wallpaper_url.clone(), c.wallpaper_uploader.clone())
893 });
894 if let Some(chat) = state.get_chat_mut(chat_npub) {
895 chat.wallpaper_path = String::new();
896 chat.wallpaper_url = String::new();
897 chat.wallpaper_uploader = String::new();
898 chat.wallpaper_ts = created_at;
899 }
900 let slim = state
901 .get_chat(chat_npub)
902 .map(|c| crate::db::chats::SlimChatDB::from_chat(c, &state.interner));
903 let (pu, puploader) = prev.unwrap_or_default();
904 (slim, pu, puploader)
905 };
906 if let Some(slim) = slim {
907 if let Err(e) = crate::db::chats::save_slim_chat(&slim) {
908 log_warn!("[Wallpaper] save_slim_chat (removal) failed for {}: {}", chat_npub, e);
909 }
910 }
911
912 delete_prior_blob_if_ours(&prev_url, &prev_uploader).await;
913
914 let event_id = rumor.id.ok_or("Rumor missing id")?.to_hex();
915 emit_wallpaper_removed(chat_npub, &me_npub, created_at, &event_id).await;
916
917 Ok(())
918}
919
920#[cfg(test)]
921mod wallpaper_strip_tests {
922 use super::*;
923 use ::image::{DynamicImage, ImageFormat, Rgb, RgbImage, Rgba, RgbaImage};
924 use std::io::Cursor;
925
926 fn png_bytes(img: &DynamicImage) -> Vec<u8> {
927 let mut out = Vec::new();
928 img.write_to(&mut Cursor::new(&mut out), ImageFormat::Png).unwrap();
929 out
930 }
931
932 #[test]
933 fn opaque_wallpaper_becomes_jpeg_and_is_capped() {
934 let img = DynamicImage::ImageRgb8(RgbImage::from_fn(3000, 1000, |x, y| {
935 Rgb([(x % 256) as u8, (y % 256) as u8, 128])
936 }));
937 let (bytes, ext, was_animated, dim) =
938 normalize_wallpaper_image(&png_bytes(&img), "image/png").unwrap();
939 assert_eq!(ext, "jpg");
940 assert!(!was_animated);
941 assert!((10..=50).contains(&dim), "brightness out of range: {dim}");
942 let dec = ::image::load_from_memory(&bytes).unwrap();
943 assert!(dec.width() <= MAX_WALLPAPER_DIMENSION && dec.height() <= MAX_WALLPAPER_DIMENSION);
944 }
945
946 #[test]
947 fn transparent_wallpaper_stays_png() {
948 let img = DynamicImage::ImageRgba8(RgbaImage::from_fn(64, 64, |x, _| {
949 Rgba([10, 20, 30, if x < 32 { 0 } else { 255 }])
950 }));
951 let (bytes, ext, _, _) =
952 normalize_wallpaper_image(&png_bytes(&img), "image/png").unwrap();
953 assert_eq!(ext, "png");
954 assert!(::image::load_from_memory(&bytes).unwrap().color().has_alpha());
955 }
956
957 #[test]
958 fn animated_mime_flags_was_animated_but_flattens_to_a_still() {
959 let img = DynamicImage::ImageRgb8(RgbImage::from_fn(10, 10, |_, _| Rgb([1, 2, 3])));
960 let (_bytes, ext, was_animated, _dim) =
961 normalize_wallpaper_image(&png_bytes(&img), "image/gif").unwrap();
962 assert!(was_animated);
963 assert_eq!(ext, "jpg");
964 }
965}