Skip to main content

vector_core/
wallpaper.rs

1//! Per-DM wallpaper feature.
2//!
3//! Wallpapers are static images attached to a 1:1 DM conversation. Either
4//! party may set one; latest-write-wins by rumor `created_at`.
5//!
6//! On the wire: a NIP-17 gift-wrapped rumor with kind 30078 and the d tag
7//! `vector-wallpaper`. The wallpaper bytes themselves are AES-256-GCM
8//! encrypted onto Blossom — same crypto path Vector uses for normal file
9//! attachments. The decryption key + nonce live in the rumor's tags; that's
10//! safe because the rumor is already sealed inside the NIP-17 envelope
11//! addressed only to the two participants.
12//!
13//! Rumor shape:
14//! ```text
15//!   kind:       30078 (APPLICATION_SPECIFIC)
16//!   created_at: now (latest-write-wins tiebreaker)
17//!   tags:
18//!     ["d",                "vector-wallpaper"]
19//!     ["url",              <blossom URL>]
20//!     ["decryption-key",   <hex>]
21//!     ["decryption-nonce", <hex>]
22//!     ["x",                <plaintext sha256>]
23//!     ["m",                "image/png"]   (optional)
24//!     ["size",             <encrypted size in bytes>]
25//!   content: "" (unused; metadata lives in tags)
26//! ```
27
28use 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
41/// Hard ceiling on a received (encrypted) wallpaper download. The send side
42/// caps plaintext at 5 MB; 10 MB leaves headroom for encryption overhead
43/// while still bounding memory against a malicious/oversized blob.
44const MAX_WALLPAPER_DOWNLOAD_BYTES: u64 = 10 * 1024 * 1024;
45
46/// Maximum allowed source-image size (pre-encryption). Matches the user-
47/// facing cap; enforced both at preview prep and at the picker UI.
48pub const MAX_WALLPAPER_BYTES: usize = 5 * 1024 * 1024;
49
50/// Longest-side cap for the re-encoded wallpaper — ample for any display, and
51/// keeps a huge source from bloating every gift-wrapped send.
52const MAX_WALLPAPER_DIMENSION: u32 = 2560;
53
54/// Per-account directory for cached wallpaper files. One active file per
55/// chat + at most one preview-staging file per chat.
56fn 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    /// Local cached path the frontend should render.
67    pub path: String,
68    /// True iff the picker had to extract a frame from an animated source.
69    /// The UI uses this to surface a one-line "static-only" notice.
70    pub was_animated: bool,
71    /// Suggested initial brightness (0..=100) derived from the image's
72    /// average luma — bright images get more dimming so text stays
73    /// readable, dark images keep more of the original. User can still
74    /// override with the slider before confirming.
75    pub recommended_dim: u8,
76}
77
78/// Validate + prepare a picked image: enforce the 5 MB cap, image-only
79/// mime, extract first frame for animated formats, write to the per-chat
80/// preview slot. The returned path is what the chat background should
81/// switch to while the Confirm/Cancel bar is showing.
82pub 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    // Single decode: normalize (strip + resize + re-encode) and the brightness
104    // estimate share the same decoded pixels — no second decode.
105    let (final_bytes, final_extension, was_animated, recommended_dim) =
106        normalize_wallpaper_image(&bytes, &mime)?;
107
108    // Clear any prior preview for this chat (different format or stale).
109    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
125/// Pick a starting brightness percent such that white chat text stays
126/// readable against the image. Down-samples to 64×64 for speed, averages
127/// the Rec. 709 luma, then maps `(avg_luma 0..=255)` to a brightness
128/// percent — bright images get dimmer defaults, dark images stay bright.
129/// Operates on the already-decoded wallpaper (no re-decode); falls back to
130/// the 50% default for a degenerate (zero-size) image.
131fn 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; // 0..=255
154    // Map luma → brightness with an aggressive curve, then halve to land
155    // most photographs in the 15..30% range where white chat text is
156    // clearly legible against the underlying image:
157    //   white  (255) → ~12
158    //   bright (200) → ~17
159    //   mid    (128) → ~22
160    //   dark   ( 60) → ~38
161    //   black  (  0) → ~47
162    let brightness = (95.0 - (avg / 255.0) * 70.0) / 2.0;
163    brightness.clamp(10.0, 50.0) as u8
164}
165
166/// Delete the preview file (user cancelled before publishing).
167pub fn cancel_wallpaper_preview(chat_npub: &str) -> Result<(), String> {
168    clean_chat_files(chat_npub, FileKind::Preview, None)
169}
170
171/// Returns `(bytes, extension, was_animated, recommended_dim)`. Every wallpaper
172/// is decoded ONCE (baking EXIF orientation into pixels) and re-encoded to a
173/// still: this both flattens any animation (wallpapers never animate) AND drops
174/// all metadata. The wallpaper is gift-wrapped to the other participant, so
175/// their copy must not carry our EXIF/GPS. Opaque images become JPEG (small);
176/// images with real transparency stay PNG. The brightness estimate reuses the
177/// same decoded pixels rather than decoding again.
178///
179/// Downscaling uses Triangle (bilinear): a wallpaper is shown behind blur + dim,
180/// so the extra sharpness of a wide-kernel filter is invisible and not worth the
181/// cost on slow devices.
182fn 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
202/// Re-encode a decoded wallpaper as a metadata-free still: PNG when real
203/// transparency is present, otherwise JPEG (much smaller for photos).
204fn 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
231/// Remove every wallpaper artifact for `chat_npub` of the given kind. When
232/// `extension_to_skip` is `Some`, files matching that extension are kept
233/// (used to overwrite-in-place during same-format rewrites).
234fn 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            // Active = chat-prefixed but NOT preview-prefixed.
257            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
273/// Publish the current preview file as the chat's wallpaper. Encrypts +
274/// uploads to Blossom, builds the kind-30078 rumor, sends to the
275/// counterparty (the gift-wrap helper fans out to self for cross-device
276/// sync), promotes the preview file to the active slot, updates STATE +
277/// DB, drops a `WallpaperChanged` system event, and emits
278/// `wallpaper_updated` to the frontend.
279///
280/// `blur` and `dim` are the customisation knobs the user set on the
281/// preview slider — clamped here to safe ranges and carried as optional
282/// tags on the rumor so older clients without slider support still get a
283/// usable wallpaper (falling back to their own defaults).
284pub async fn publish_wallpaper(chat_npub: &str, blur: u8, dim: u8) -> Result<(), String> {
285    // Capture session at entry — the upload + gift-wrap send below take
286    // seconds, and a mid-publish account swap must not write this
287    // wallpaper into the new account (re-checked before the STATE/DB write).
288    let session = crate::state::SessionGuard::capture();
289
290    let blur = blur.min(30);
291    let dim = dim.min(100);
292    // Find the preview file (we don't know its extension ahead of time).
293    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, &params)?;
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    // The chat the wallpaper belongs to, tagged on the rumor below. Without it,
332    // the self-send copy (for multi-device sync) has no recipient, so the
333    // inbound handler attributes it to our self-chat (Notes) instead of this
334    // chat — a wallpaper set in any chat would also reskin Notes.
335    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    // Bridge Blossom upload progress to the frontend so the Set Wallpaper
341    // button can render a real ring instead of an opaque disabled state.
342    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        /* is_encrypted */ true,
361        progress_cb,
362        None, // default retry count
363        None, // default retry spacing
364        None, // no cancel flag (the picker flow doesn't expose cancel mid-upload)
365    )
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        // Recipient tag — identifies which chat this wallpaper is for. The
376        // inbound handler reads it to attribute self-sent (multi-device) copies
377        // to the correct chat rather than defaulting to our self-chat.
378        .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    // SEND FIRST, commit on success. Wallpaper is a sync feature — if the
415    // recipient (and our other devices) can't see it, there's no value in
416    // locally applying it.
417    //
418    // 3 attempts with 2s spacing (~6s max wait) keeps the dialog
419    // responsive. self_send=true so other devices of ours pick it up via
420    // their own NIP-17 inbox subscription.
421    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    // Account swapped during upload/send: the rumor already went to the
442    // original recipient, but the local commit below (preview promotion,
443    // STATE, DB) would land in the new account's storage. Skip it.
444    if !session.is_valid() {
445        return Ok(());
446    }
447
448    // Send succeeded — promote the preview file to the active slot.
449    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    // Persist to STATE + DB. Capture the previous Blossom URL + uploader
458    // under the same lock so we can clean it up only if we owned it.
459    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    // Fire-and-forget DELETE of the previous blob, only if we uploaded
485    // it (server's auth challenge would reject otherwise). Multi-device
486    // case: this runs on the device that does the replace, which may not
487    // be the device that uploaded the previous one — the uploader check
488    // is on the npub, not the device, so it still fires correctly.
489    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/// Apply a received wallpaper rumor. Drops the rumor if its timestamp is
549/// not newer than the chat's current `wallpaper_ts` (latest-write-wins).
550/// On a fresh rumor: downloads + decrypts the Blossom blob, caches it
551/// locally, updates STATE + DB, saves a `WallpaperChanged` system event,
552/// and emits `wallpaper_updated` to the frontend.
553#[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    // Capture session NOW — the download below can take seconds, and a
568    // mid-fetch account swap must not let us write account A's wallpaper
569    // into account B's STATE/DB (re-checked before every write below).
570    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    // Latest-write-wins. Drop the rumor if we've already applied a newer
575    // (or equal) one — typical during negentropy backfill.
576    {
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    // Removal tombstone — sender cleared their wallpaper. No blob to fetch;
586    // wipe the local active file + STATE/DB so the default theme returns.
587    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    // SSRF guard: the URL is attacker-controlled (it arrives in a rumor),
620    // and this fetch is zero-interaction. Block private/internal targets.
621    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    // Reject by advertised length first (cheap), then enforce a hard cap
633    // while streaming so a server that omits Content-Length can't OOM us.
634    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    // Account swap during the download invalidates everything below — the
662    // chat npub, the DB pool, and the per-account wallpapers dir all belong
663    // to a session that may no longer be active. Bail before any write.
664    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    // Capture previous URL + uploader under the same lock — only DELETE
676    // the prior blob if WE were the uploader (covers multi-device sync
677    // where a different device of ours uploaded the previous wallpaper).
678    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    // Resolve a display name for the system event. Fall back to the npub
725    // (truncated by the frontend's formatter) when we don't know the peer
726    // yet — the row still tells the user what happened.
727    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
777/// Fire-and-forget DELETE of a prior Blossom blob, but only if WE uploaded
778/// it (the server's auth challenge rejects deletes from anyone else). The
779/// uploader check is on the npub, so multi-device replaces still fire.
780async 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
802/// Save the WallpaperChanged system event for a removal + emit the frontend
803/// events that revert the chat to the default theme.
804async 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
862/// Remove the chat's wallpaper, reverting both sides to the default theme.
863/// Publishes a kind-30078 `vector-wallpaper` tombstone (no `url` tag) so the
864/// recipient and our other devices clear it too (latest-write-wins by
865/// `created_at`), then DELETEs our blob and wipes local STATE/DB.
866pub 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    // Tombstone: same d-tag + recipient p-tag as a set, but no url/key/nonce.
878    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    // Account swapped mid-send — the tombstone already went out, but the
904    // local commit below would land in the new account's storage. Skip it.
905    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}