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 session = crate::state::SessionGuard::capture();
289
290 let blur = blur.min(30);
291 let dim = dim.min(100);
292 let dir = wallpapers_dir()?;
294 let prefix = format!("{}.preview.", chat_npub);
295 let mut preview_path: Option<PathBuf> = None;
296 for entry in std::fs::read_dir(&dir)
297 .map_err(|e| format!("Wallpapers dir: {}", e))?
298 .flatten()
299 {
300 let p = entry.path();
301 let n = p
302 .file_name()
303 .and_then(|n| n.to_str())
304 .unwrap_or("")
305 .to_string();
306 if n.starts_with(&prefix) {
307 preview_path = Some(p);
308 break;
309 }
310 }
311 let preview = preview_path
312 .ok_or_else(|| "No wallpaper preview to publish. Pick an image first.".to_string())?;
313 let bytes = std::fs::read(&preview)
314 .map_err(|e| format!("Failed to read preview file: {}", e))?;
315
316 let extension = preview
317 .extension()
318 .and_then(|e| e.to_str())
319 .unwrap_or("png")
320 .to_string();
321 let mime = crypto::mime_from_extension(&extension).to_string();
322 let plaintext_hash = crypto::sha256_hex(&bytes);
323
324 let params = crypto::generate_encryption_params();
325 let encrypted = crypto::encrypt_data(&bytes, ¶ms)?;
326
327 let _client = crate::state::nostr_client().ok_or("Not logged in")?;
328 let signer = crate::signer::active_signer()
329 .map_err(|e| format!("Signer: {}", e))?;
330 let my_pk = crate::state::my_public_key().ok_or("Public key not set")?;
331 let recipient_pk = PublicKey::from_bech32(chat_npub)
336 .map_err(|e| format!("Invalid chat npub: {}", e))?;
337
338 let servers = crate::state::get_blossom_servers();
339
340 let chat_npub_for_progress = chat_npub.to_string();
343 let progress_cb: crate::blossom::ProgressCallback = Arc::new(move |percentage, bytes| {
344 crate::traits::emit_event(
345 "wallpaper_upload_progress",
346 &serde_json::json!({
347 "chat_id": chat_npub_for_progress,
348 "progress": percentage.unwrap_or(0),
349 "bytes": bytes.unwrap_or(0),
350 }),
351 );
352 Ok(())
353 });
354
355 let upload_url = crate::blossom::upload_blob_with_progress_and_failover(
356 signer.clone(),
357 servers,
358 Arc::new(encrypted.clone()),
359 Some(&mime),
360 true,
361 progress_cb,
362 None, None, None, )
366 .await
367 .map_err(|e| format!("Wallpaper upload failed: {}", e))?;
368
369 let created_at = std::time::SystemTime::now()
370 .duration_since(std::time::UNIX_EPOCH)
371 .unwrap()
372 .as_secs();
373 let rumor = EventBuilder::new(Kind::Custom(event_kind::APPLICATION_SPECIFIC), "")
374 .tag(Tag::identifier(WALLPAPER_DTAG_VALUE))
375 .tag(Tag::public_key(recipient_pk))
379 .tag(Tag::custom(
380 "url",
381 vec![upload_url.clone()],
382 ))
383 .tag(Tag::custom(
384 "decryption-key",
385 vec![params.key.clone()],
386 ))
387 .tag(Tag::custom(
388 "decryption-nonce",
389 vec![params.nonce.clone()],
390 ))
391 .tag(Tag::custom(
392 "x",
393 vec![plaintext_hash.clone()],
394 ))
395 .tag(Tag::custom(
396 "m",
397 vec![mime.clone()],
398 ))
399 .tag(Tag::custom(
400 "size",
401 vec![encrypted.len().to_string()],
402 ))
403 .tag(Tag::custom(
404 "blur",
405 vec![blur.to_string()],
406 ))
407 .tag(Tag::custom(
408 "dim",
409 vec![dim.to_string()],
410 ))
411 .custom_created_at(Timestamp::from(created_at))
412 .finalize_unsigned_with_id(my_pk);
413
414 let pending_id = format!("pending-wallpaper-{}", created_at);
422 let send_config = crate::sending::SendConfig {
423 max_send_attempts: 3,
424 retry_delay: std::time::Duration::from_secs(2),
425 self_send: true,
426 ..Default::default()
427 };
428 let send_callback: Arc<dyn crate::sending::SendCallback> =
429 Arc::new(crate::sending::NoOpSendCallback);
430 if let Err(e) = crate::sending::send_rumor_dm(
431 chat_npub, &pending_id, rumor.clone(), &send_config, send_callback,
432 ).await {
433 log_warn!("[Wallpaper] send_rumor_dm to {} failed: {}", chat_npub, e);
434 return Err(format!(
435 "Couldn't send the wallpaper. Check that the relays you and your contact share are reachable, then try again. ({})",
436 e
437 ));
438 }
439 log_info!("[Wallpaper] rumor delivered to {}", chat_npub);
440
441 if !session.is_valid() {
445 return Ok(());
446 }
447
448 let active = wallpapers_dir()?.join(format!("{}.{}", chat_npub, extension));
450 clean_chat_files(chat_npub, FileKind::Active, None)?;
451 std::fs::rename(&preview, &active)
452 .map_err(|e| format!("Failed to promote preview: {}", e))?;
453 let active_str = active.to_string_lossy().to_string();
454
455 let me_npub = my_pk.to_bech32().unwrap_or_default();
456
457 let (slim, prev_url, prev_uploader) = {
460 let mut state = crate::state::STATE.lock().await;
461 let prev = state.get_chat(chat_npub).map(|c| {
462 (c.wallpaper_url.clone(), c.wallpaper_uploader.clone())
463 });
464 if let Some(chat) = state.get_chat_mut(chat_npub) {
465 chat.wallpaper_path = active_str.clone();
466 chat.wallpaper_ts = created_at;
467 chat.wallpaper_blur = blur;
468 chat.wallpaper_dim = dim;
469 chat.wallpaper_url = upload_url.clone();
470 chat.wallpaper_uploader = me_npub.clone();
471 }
472 let slim = state
473 .get_chat(chat_npub)
474 .map(|c| crate::db::chats::SlimChatDB::from_chat(c, &state.interner));
475 let (pu, puploader) = prev.unwrap_or_default();
476 (slim, pu, puploader)
477 };
478 if let Some(slim) = slim {
479 if let Err(e) = crate::db::chats::save_slim_chat(&slim) {
480 log_warn!("[Wallpaper] save_slim_chat failed for {}: {}", chat_npub, e);
481 }
482 }
483
484 if !prev_url.is_empty() && prev_uploader == me_npub {
490 let signer_clone = signer.clone();
491 let prev_url_clone = prev_url.clone();
492 tokio::spawn(async move {
493 if let Err(e) =
494 crate::blossom::delete_blob_by_url(signer_clone, &prev_url_clone).await
495 {
496 log_warn!(
497 "[Wallpaper] DELETE prev blob {} failed: {}",
498 prev_url_clone, e
499 );
500 }
501 });
502 }
503
504 let event_id = rumor.id.ok_or("Rumor missing id")?.to_hex();
505 let inserted = match crate::db::events::save_system_event_by_id(
506 &event_id,
507 chat_npub,
508 crate::stored_event::SystemEventType::WallpaperChanged,
509 &me_npub,
510 Some("You"),
511 )
512 .await
513 {
514 Ok(b) => b,
515 Err(e) => {
516 log_warn!("[Wallpaper] save_system_event_by_id failed for {}: {}", event_id, e);
517 false
518 }
519 };
520 if inserted {
521 crate::traits::emit_event("system_event", &serde_json::json!({
522 "conversation_id": chat_npub,
523 "event_id": event_id,
524 "event_type": crate::stored_event::SystemEventType::WallpaperChanged.as_u8(),
525 "member_pubkey": me_npub,
526 "member_name": "You",
527 }));
528 } else {
529 log_warn!("[Wallpaper] system event {} was not inserted (already exists or save failed)", event_id);
530 }
531
532 crate::traits::emit_event(
533 "wallpaper_updated",
534 &serde_json::json!({
535 "chat_id": chat_npub,
536 "path": active_str,
537 "ts": created_at,
538 "blur": blur,
539 "dim": dim,
540 "by_npub": me_npub,
541 "event_id": event_id,
542 }),
543 );
544
545 Ok(())
546}
547
548#[allow(clippy::too_many_arguments)]
554pub async fn apply_received_wallpaper(
555 chat_npub: &str,
556 sender_npub: &str,
557 created_at: u64,
558 url: &str,
559 decryption_key: &str,
560 decryption_nonce: &str,
561 plaintext_hash: Option<&str>,
562 mime: Option<&str>,
563 blur: Option<u8>,
564 dim: Option<u8>,
565 rumor_event_id: &str,
566) -> Result<(), String> {
567 let session = crate::state::SessionGuard::capture();
571
572 let blur = blur.unwrap_or(0).min(30);
573 let dim = dim.unwrap_or(50).min(100);
574 {
577 let state = crate::state::STATE.lock().await;
578 if let Some(chat) = state.get_chat(chat_npub) {
579 if chat.wallpaper_ts >= created_at {
580 return Ok(());
581 }
582 }
583 }
584
585 if url.is_empty() {
588 clean_chat_files(chat_npub, FileKind::Active, None)?;
589 let (slim, prev_url, prev_uploader) = {
590 let mut state = crate::state::STATE.lock().await;
591 let prev = state.get_chat(chat_npub).map(|c| {
592 (c.wallpaper_url.clone(), c.wallpaper_uploader.clone())
593 });
594 if let Some(chat) = state.get_chat_mut(chat_npub) {
595 chat.wallpaper_path = String::new();
596 chat.wallpaper_url = String::new();
597 chat.wallpaper_uploader = String::new();
598 chat.wallpaper_ts = created_at;
599 chat.wallpaper_blur = blur;
600 chat.wallpaper_dim = dim;
601 }
602 let slim = state
603 .get_chat(chat_npub)
604 .map(|c| crate::db::chats::SlimChatDB::from_chat(c, &state.interner));
605 let (pu, puploader) = prev.unwrap_or_default();
606 (slim, pu, puploader)
607 };
608 if let Some(slim) = slim {
609 let _ = crate::db::chats::save_slim_chat(&slim);
610 }
611 delete_prior_blob_if_ours(&prev_url, &prev_uploader).await;
612 emit_wallpaper_removed(chat_npub, sender_npub, created_at, rumor_event_id).await;
613 return Ok(());
614 }
615
616 let mime_str = mime.unwrap_or("image/png").to_string();
617 let extension = crypto::extension_from_mime(&mime_str);
618
619 crate::net::validate_url_not_private(url)?;
622
623 let http = crate::net::build_http_client(Duration::from_secs(30))?;
624 let resp = http
625 .get(url)
626 .send()
627 .await
628 .map_err(|e| format!("Wallpaper download failed: {}", e))?;
629 if !resp.status().is_success() {
630 return Err(format!("Wallpaper HTTP {}", resp.status()));
631 }
632 if let Some(len) = resp.content_length() {
635 if len > MAX_WALLPAPER_DOWNLOAD_BYTES {
636 return Err("Wallpaper too large".to_string());
637 }
638 }
639 let mut resp = resp;
640 let mut bytes: Vec<u8> = Vec::new();
641 while let Some(chunk) = resp
642 .chunk()
643 .await
644 .map_err(|e| format!("Read body: {}", e))?
645 {
646 bytes.extend_from_slice(&chunk);
647 if bytes.len() as u64 > MAX_WALLPAPER_DOWNLOAD_BYTES {
648 return Err("Wallpaper too large".to_string());
649 }
650 }
651
652 let plaintext = crypto::decrypt_data(&bytes, decryption_key, decryption_nonce)?;
653
654 if let Some(want_hash) = plaintext_hash {
655 let got_hash = crypto::sha256_hex(&plaintext);
656 if !got_hash.eq_ignore_ascii_case(want_hash) {
657 return Err("Wallpaper integrity check failed".to_string());
658 }
659 }
660
661 if !session.is_valid() {
665 return Ok(());
666 }
667
668 let active = wallpapers_dir()?.join(format!("{}.{}", chat_npub, extension));
669 clean_chat_files(chat_npub, FileKind::Active, None)?;
670 let tmp = active.with_file_name(format!("{}.{}.tmp", chat_npub, extension));
671 std::fs::write(&tmp, &plaintext).map_err(|e| format!("Write wallpaper: {}", e))?;
672 std::fs::rename(&tmp, &active).map_err(|e| format!("Commit wallpaper: {}", e))?;
673 let active_str = active.to_string_lossy().to_string();
674
675 let (slim, prev_url, prev_uploader) = {
679 let mut state = crate::state::STATE.lock().await;
680 let prev = state.get_chat(chat_npub).map(|c| {
681 (c.wallpaper_url.clone(), c.wallpaper_uploader.clone())
682 });
683 if let Some(chat) = state.get_chat_mut(chat_npub) {
684 chat.wallpaper_path = active_str.clone();
685 chat.wallpaper_ts = created_at;
686 chat.wallpaper_blur = blur;
687 chat.wallpaper_dim = dim;
688 chat.wallpaper_url = url.to_string();
689 chat.wallpaper_uploader = sender_npub.to_string();
690 }
691 let slim = state
692 .get_chat(chat_npub)
693 .map(|c| crate::db::chats::SlimChatDB::from_chat(c, &state.interner));
694 let (pu, puploader) = prev.unwrap_or_default();
695 (slim, pu, puploader)
696 };
697 if let Some(slim) = slim {
698 let _ = crate::db::chats::save_slim_chat(&slim);
699 }
700
701 if !prev_url.is_empty() {
702 let me_npub = crate::state::my_public_key()
703 .and_then(|pk| pk.to_bech32().ok())
704 .unwrap_or_default();
705 if !me_npub.is_empty() && prev_uploader == me_npub {
706 if let Some(_client) = crate::state::nostr_client() {
707 if let Ok(signer) = crate::signer::active_signer() {
708 let prev_url_clone = prev_url.clone();
709 tokio::spawn(async move {
710 if let Err(e) =
711 crate::blossom::delete_blob_by_url(signer, &prev_url_clone).await
712 {
713 log_warn!(
714 "[Wallpaper] DELETE prev blob {} failed: {}",
715 prev_url_clone, e
716 );
717 }
718 });
719 }
720 }
721 }
722 }
723
724 let sender_display = {
728 let state = crate::state::STATE.lock().await;
729 state
730 .get_profile(sender_npub)
731 .and_then(|p| {
732 if !p.nickname().is_empty() {
733 Some(p.nickname().to_string())
734 } else if !p.name.is_empty() {
735 Some(p.name.to_string())
736 } else {
737 None
738 }
739 })
740 .unwrap_or_else(|| sender_npub.to_string())
741 };
742 let inserted = crate::db::events::save_system_event_by_id(
743 rumor_event_id,
744 chat_npub,
745 crate::stored_event::SystemEventType::WallpaperChanged,
746 sender_npub,
747 Some(&sender_display),
748 )
749 .await
750 .unwrap_or(false);
751 if inserted {
752 crate::traits::emit_event("system_event", &serde_json::json!({
753 "conversation_id": chat_npub,
754 "event_id": rumor_event_id,
755 "event_type": crate::stored_event::SystemEventType::WallpaperChanged.as_u8(),
756 "member_pubkey": sender_npub,
757 "member_name": sender_display,
758 }));
759 }
760
761 crate::traits::emit_event(
762 "wallpaper_updated",
763 &serde_json::json!({
764 "chat_id": chat_npub,
765 "path": active_str,
766 "ts": created_at,
767 "blur": blur,
768 "dim": dim,
769 "by_npub": sender_npub,
770 "event_id": rumor_event_id,
771 }),
772 );
773
774 Ok(())
775}
776
777async fn delete_prior_blob_if_ours(prev_url: &str, prev_uploader: &str) {
781 if prev_url.is_empty() {
782 return;
783 }
784 let me_npub = crate::state::my_public_key()
785 .and_then(|pk| pk.to_bech32().ok())
786 .unwrap_or_default();
787 if me_npub.is_empty() || prev_uploader != me_npub {
788 return;
789 }
790 if let Some(_client) = crate::state::nostr_client() {
791 if let Ok(signer) = crate::signer::active_signer() {
792 let prev_url = prev_url.to_string();
793 tokio::spawn(async move {
794 if let Err(e) = crate::blossom::delete_blob_by_url(signer, &prev_url).await {
795 log_warn!("[Wallpaper] DELETE prev blob {} failed: {}", prev_url, e);
796 }
797 });
798 }
799 }
800}
801
802async fn emit_wallpaper_removed(
805 chat_npub: &str,
806 by_npub: &str,
807 created_at: u64,
808 event_id: &str,
809) {
810 let me_npub = crate::state::my_public_key()
811 .and_then(|pk| pk.to_bech32().ok())
812 .unwrap_or_default();
813 let display = if by_npub == me_npub {
814 "You".to_string()
815 } else {
816 let state = crate::state::STATE.lock().await;
817 state
818 .get_profile(by_npub)
819 .and_then(|p| {
820 if !p.nickname().is_empty() {
821 Some(p.nickname().to_string())
822 } else if !p.name.is_empty() {
823 Some(p.name.to_string())
824 } else {
825 None
826 }
827 })
828 .unwrap_or_else(|| by_npub.to_string())
829 };
830 let inserted = crate::db::events::save_system_event_by_id(
831 event_id,
832 chat_npub,
833 crate::stored_event::SystemEventType::WallpaperRemoved,
834 by_npub,
835 Some(&display),
836 )
837 .await
838 .unwrap_or(false);
839 if inserted {
840 crate::traits::emit_event("system_event", &serde_json::json!({
841 "conversation_id": chat_npub,
842 "event_id": event_id,
843 "event_type": crate::stored_event::SystemEventType::WallpaperRemoved.as_u8(),
844 "member_pubkey": by_npub,
845 "member_name": display,
846 }));
847 }
848 crate::traits::emit_event(
849 "wallpaper_updated",
850 &serde_json::json!({
851 "chat_id": chat_npub,
852 "path": "",
853 "ts": created_at,
854 "blur": 0,
855 "dim": 50,
856 "by_npub": by_npub,
857 "event_id": event_id,
858 }),
859 );
860}
861
862pub async fn remove_wallpaper(chat_npub: &str) -> Result<(), String> {
867 let session = crate::state::SessionGuard::capture();
868
869 let my_pk = crate::state::my_public_key().ok_or("Public key not set")?;
870 let recipient_pk = PublicKey::from_bech32(chat_npub)
871 .map_err(|e| format!("Invalid chat npub: {}", e))?;
872
873 let created_at = std::time::SystemTime::now()
874 .duration_since(std::time::UNIX_EPOCH)
875 .unwrap()
876 .as_secs();
877 let rumor = EventBuilder::new(Kind::Custom(event_kind::APPLICATION_SPECIFIC), "")
879 .tag(Tag::identifier(WALLPAPER_DTAG_VALUE))
880 .tag(Tag::public_key(recipient_pk))
881 .custom_created_at(Timestamp::from(created_at))
882 .finalize_unsigned_with_id(my_pk);
883
884 let pending_id = format!("pending-wallpaper-rm-{}", created_at);
885 let send_config = crate::sending::SendConfig {
886 max_send_attempts: 3,
887 retry_delay: std::time::Duration::from_secs(2),
888 self_send: true,
889 ..Default::default()
890 };
891 let send_callback: Arc<dyn crate::sending::SendCallback> =
892 Arc::new(crate::sending::NoOpSendCallback);
893 if let Err(e) = crate::sending::send_rumor_dm(
894 chat_npub, &pending_id, rumor.clone(), &send_config, send_callback,
895 ).await {
896 log_warn!("[Wallpaper] removal send to {} failed: {}", chat_npub, e);
897 return Err(format!(
898 "Couldn't remove the wallpaper. Check that the relays you and your contact share are reachable, then try again. ({})",
899 e
900 ));
901 }
902
903 if !session.is_valid() {
906 return Ok(());
907 }
908
909 clean_chat_files(chat_npub, FileKind::Active, None)?;
910 let me_npub = my_pk.to_bech32().unwrap_or_default();
911 let (slim, prev_url, prev_uploader) = {
912 let mut state = crate::state::STATE.lock().await;
913 let prev = state.get_chat(chat_npub).map(|c| {
914 (c.wallpaper_url.clone(), c.wallpaper_uploader.clone())
915 });
916 if let Some(chat) = state.get_chat_mut(chat_npub) {
917 chat.wallpaper_path = String::new();
918 chat.wallpaper_url = String::new();
919 chat.wallpaper_uploader = String::new();
920 chat.wallpaper_ts = created_at;
921 }
922 let slim = state
923 .get_chat(chat_npub)
924 .map(|c| crate::db::chats::SlimChatDB::from_chat(c, &state.interner));
925 let (pu, puploader) = prev.unwrap_or_default();
926 (slim, pu, puploader)
927 };
928 if let Some(slim) = slim {
929 if let Err(e) = crate::db::chats::save_slim_chat(&slim) {
930 log_warn!("[Wallpaper] save_slim_chat (removal) failed for {}: {}", chat_npub, e);
931 }
932 }
933
934 delete_prior_blob_if_ours(&prev_url, &prev_uploader).await;
935
936 let event_id = rumor.id.ok_or("Rumor missing id")?.to_hex();
937 emit_wallpaper_removed(chat_npub, &me_npub, created_at, &event_id).await;
938
939 Ok(())
940}
941
942#[cfg(test)]
943mod wallpaper_strip_tests {
944 use super::*;
945 use ::image::{DynamicImage, ImageFormat, Rgb, RgbImage, Rgba, RgbaImage};
946 use std::io::Cursor;
947
948 fn png_bytes(img: &DynamicImage) -> Vec<u8> {
949 let mut out = Vec::new();
950 img.write_to(&mut Cursor::new(&mut out), ImageFormat::Png).unwrap();
951 out
952 }
953
954 #[test]
955 fn opaque_wallpaper_becomes_jpeg_and_is_capped() {
956 let img = DynamicImage::ImageRgb8(RgbImage::from_fn(3000, 1000, |x, y| {
957 Rgb([(x % 256) as u8, (y % 256) as u8, 128])
958 }));
959 let (bytes, ext, was_animated, dim) =
960 normalize_wallpaper_image(&png_bytes(&img), "image/png").unwrap();
961 assert_eq!(ext, "jpg");
962 assert!(!was_animated);
963 assert!((10..=50).contains(&dim), "brightness out of range: {dim}");
964 let dec = ::image::load_from_memory(&bytes).unwrap();
965 assert!(dec.width() <= MAX_WALLPAPER_DIMENSION && dec.height() <= MAX_WALLPAPER_DIMENSION);
966 }
967
968 #[test]
969 fn transparent_wallpaper_stays_png() {
970 let img = DynamicImage::ImageRgba8(RgbaImage::from_fn(64, 64, |x, _| {
971 Rgba([10, 20, 30, if x < 32 { 0 } else { 255 }])
972 }));
973 let (bytes, ext, _, _) =
974 normalize_wallpaper_image(&png_bytes(&img), "image/png").unwrap();
975 assert_eq!(ext, "png");
976 assert!(::image::load_from_memory(&bytes).unwrap().color().has_alpha());
977 }
978
979 #[test]
980 fn animated_mime_flags_was_animated_but_flattens_to_a_still() {
981 let img = DynamicImage::ImageRgb8(RgbImage::from_fn(10, 10, |_, _| Rgb([1, 2, 3])));
982 let (_bytes, ext, was_animated, _dim) =
983 normalize_wallpaper_image(&png_bytes(&img), "image/gif").unwrap();
984 assert!(was_animated);
985 assert_eq!(ext, "jpg");
986 }
987}