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