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
289    let blur = blur.min(30);
290    let dim = dim.min(100);
291    // Find the preview file (we don't know its extension ahead of time).
292    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, &params)?;
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    // The chat the wallpaper belongs to, tagged on the rumor below. Without it,
331    // the self-send copy (for multi-device sync) has no recipient, so the
332    // inbound handler attributes it to our self-chat (Notes) instead of this
333    // chat — a wallpaper set in any chat would also reskin Notes.
334    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    // Bridge Blossom upload progress to the frontend so the Set Wallpaper
340    // button can render a real ring instead of an opaque disabled state.
341    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        /* is_encrypted */ true,
360        progress_cb,
361        None, // default retry count
362        None, // default retry spacing
363        None, // no cancel flag (the picker flow doesn't expose cancel mid-upload)
364    )
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        // Recipient tag — identifies which chat this wallpaper is for. The
375        // inbound handler reads it to attribute self-sent (multi-device) copies
376        // to the correct chat rather than defaulting to our self-chat.
377        .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    // SEND FIRST, commit on success. Wallpaper is a sync feature — if the
414    // recipient (and our other devices) can't see it, there's no value in
415    // locally applying it.
416    //
417    // 3 attempts with 2s spacing (~6s max wait) keeps the dialog
418    // responsive. self_send=true so other devices of ours pick it up via
419    // their own NIP-17 inbox subscription.
420    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    // Account swapped during upload/send: the rumor already went to the
441    // original recipient, but the local commit below (preview promotion,
442    // STATE, DB) would land in the new account's storage. Skip it.
443
444    // Send succeeded — promote the preview file to the active slot.
445    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    // Persist to STATE + DB. Capture the previous Blossom URL + uploader
454    // under the same lock so we can clean it up only if we owned it.
455    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    // Fire-and-forget DELETE of the previous blob, only if we uploaded
481    // it (server's auth challenge would reject otherwise). Multi-device
482    // case: this runs on the device that does the replace, which may not
483    // be the device that uploaded the previous one — the uploader check
484    // is on the npub, not the device, so it still fires correctly.
485    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/// Apply a received wallpaper rumor. Drops the rumor if its timestamp is
545/// not newer than the chat's current `wallpaper_ts` (latest-write-wins).
546/// On a fresh rumor: downloads + decrypts the Blossom blob, caches it
547/// locally, updates STATE + DB, saves a `WallpaperChanged` system event,
548/// and emits `wallpaper_updated` to the frontend.
549#[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    // Capture session NOW — the download below can take seconds, and a
564    // mid-fetch account swap must not let us write account A's wallpaper
565    // into account B's STATE/DB (re-checked before every write below).
566
567    let blur = blur.unwrap_or(0).min(30);
568    let dim = dim.unwrap_or(50).min(100);
569    // Latest-write-wins. Drop the rumor if we've already applied a newer
570    // (or equal) one — typical during negentropy backfill.
571    {
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    // Removal tombstone — sender cleared their wallpaper. No blob to fetch;
581    // wipe the local active file + STATE/DB so the default theme returns.
582    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    // SSRF guard: the URL is attacker-controlled (it arrives in a rumor),
615    // and this fetch is zero-interaction. Block private/internal targets.
616    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    // Reject by advertised length first (cheap), then enforce a hard cap
628    // while streaming so a server that omits Content-Length can't OOM us.
629    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    // Account swap during the download invalidates everything below — the
657    // chat npub, the DB pool, and the per-account wallpapers dir all belong
658
659    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    // Capture previous URL + uploader under the same lock — only DELETE
667    // the prior blob if WE were the uploader (covers multi-device sync
668    // where a different device of ours uploaded the previous wallpaper).
669    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    // Resolve a display name for the system event. Fall back to the npub
716    // (truncated by the frontend's formatter) when we don't know the peer
717    // yet — the row still tells the user what happened.
718    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
768/// Fire-and-forget DELETE of a prior Blossom blob, but only if WE uploaded
769/// it (the server's auth challenge rejects deletes from anyone else). The
770/// uploader check is on the npub, so multi-device replaces still fire.
771async 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
793/// Save the WallpaperChanged system event for a removal + emit the frontend
794/// events that revert the chat to the default theme.
795async 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
853/// Remove the chat's wallpaper, reverting both sides to the default theme.
854/// Publishes a kind-30078 `vector-wallpaper` tombstone (no `url` tag) so the
855/// recipient and our other devices clear it too (latest-write-wins by
856/// `created_at`), then DELETEs our blob and wipes local STATE/DB.
857pub 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    // Tombstone: same d-tag + recipient p-tag as a set, but no url/key/nonce.
868    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    // Account swapped mid-send — the tombstone already went out, but the
894    // local commit below would land in the new account's storage. Skip it.
895
896    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}