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    Ok(crate::db::account_dir(&npub)?.join("wallpapers"))
59}
60
61#[derive(Debug, Clone, Serialize, Deserialize)]
62pub struct WallpaperPreview {
63    /// Local cached path the frontend should render.
64    pub path: String,
65    /// True iff the picker had to extract a frame from an animated source.
66    /// The UI uses this to surface a one-line "static-only" notice.
67    pub was_animated: bool,
68    /// Suggested initial brightness (0..=100) derived from the image's
69    /// average luma — bright images get more dimming so text stays
70    /// readable, dark images keep more of the original. User can still
71    /// override with the slider before confirming.
72    pub recommended_dim: u8,
73}
74
75/// Validate + prepare a picked image: enforce the 5 MB cap, image-only
76/// mime, extract first frame for animated formats, write to the per-chat
77/// preview slot. The returned path is what the chat background should
78/// switch to while the Confirm/Cancel bar is showing.
79pub async fn prepare_wallpaper_preview(
80    chat_npub: &str,
81    file_path: &str,
82) -> Result<WallpaperPreview, String> {
83    let bytes = crate::files::read(Path::new(file_path))
84        .await
85        .map_err(|e| format!("Failed to read image: {}", e))?;
86
87    if bytes.len() > MAX_WALLPAPER_BYTES {
88        return Err(format!(
89            "Image is too large ({} MB). Wallpapers max out at {} MB.",
90            bytes.len() / (1024 * 1024),
91            MAX_WALLPAPER_BYTES / (1024 * 1024),
92        ));
93    }
94
95    let mime = crypto::mime_from_magic_bytes(&bytes).to_string();
96    if !mime.starts_with("image/") {
97        return Err("Wallpapers must be image files.".to_string());
98    }
99
100    // Single decode: normalize (strip + resize + re-encode) and the brightness
101    // estimate share the same decoded pixels — no second decode. Off the runtime:
102    // a full-size decode would stall every task queued behind it.
103    let (final_bytes, final_extension, was_animated, recommended_dim) =
104        crate::rt::spawn_blocking(move || normalize_wallpaper_image(&bytes, &mime))
105            .await
106            .map_err(|e| format!("Image processing task failed: {e}"))??;
107
108    // Clear any prior preview for this chat (different format or stale).
109    clean_chat_files(chat_npub, FileKind::Preview, None).await?;
110
111    let preview = wallpapers_dir()?.join(format!("{}.preview.{}", chat_npub, final_extension));
112    crate::files::write(&preview, &final_bytes)
113        .await
114        .map_err(|e| format!("Failed to stage preview file: {}", e))?;
115
116    Ok(WallpaperPreview {
117        path: preview.to_string_lossy().to_string(),
118        was_animated,
119        recommended_dim,
120    })
121}
122
123/// Pick a starting brightness percent such that white chat text stays
124/// readable against the image. Down-samples to 64×64 for speed, averages
125/// the Rec. 709 luma, then maps `(avg_luma 0..=255)` to a brightness
126/// percent — bright images get dimmer defaults, dark images stay bright.
127/// Operates on the already-decoded wallpaper (no re-decode); falls back to
128/// the 50% default for a degenerate (zero-size) image.
129fn estimate_brightness_for_white_text(img: &::image::DynamicImage) -> u8 {
130    use ::image::GenericImageView;
131
132    let thumb = img.thumbnail(64, 64);
133    let rgb = thumb.to_rgb8();
134    let (w, h) = thumb.dimensions();
135    if w == 0 || h == 0 {
136        return 50;
137    }
138    let mut sum: u64 = 0;
139    let mut count: u64 = 0;
140    for pixel in rgb.pixels() {
141        let r = pixel[0] as f32;
142        let g = pixel[1] as f32;
143        let b = pixel[2] as f32;
144        let y = 0.2126 * r + 0.7152 * g + 0.0722 * b;
145        sum += y as u64;
146        count += 1;
147    }
148    if count == 0 {
149        return 50;
150    }
151    let avg = (sum / count) as f32; // 0..=255
152    // Map luma → brightness with an aggressive curve, then halve to land
153    // most photographs in the 15..30% range where white chat text is
154    // clearly legible against the underlying image:
155    //   white  (255) → ~12
156    //   bright (200) → ~17
157    //   mid    (128) → ~22
158    //   dark   ( 60) → ~38
159    //   black  (  0) → ~47
160    let brightness = (95.0 - (avg / 255.0) * 70.0) / 2.0;
161    brightness.clamp(10.0, 50.0) as u8
162}
163
164/// Delete the preview file (user cancelled before publishing).
165pub async fn cancel_wallpaper_preview(chat_npub: &str) -> Result<(), String> {
166    clean_chat_files(chat_npub, FileKind::Preview, None).await
167}
168
169/// Returns `(bytes, extension, was_animated, recommended_dim)`. Every wallpaper
170/// is decoded ONCE (baking EXIF orientation into pixels) and re-encoded to a
171/// still: this both flattens any animation (wallpapers never animate) AND drops
172/// all metadata. The wallpaper is gift-wrapped to the other participant, so
173/// their copy must not carry our EXIF/GPS. Opaque images become JPEG (small);
174/// images with real transparency stay PNG. The brightness estimate reuses the
175/// same decoded pixels rather than decoding again.
176///
177/// Downscaling uses Triangle (bilinear): a wallpaper is shown behind blur + dim,
178/// so the extra sharpness of a wide-kernel filter is invisible and not worth the
179/// cost on slow devices.
180fn normalize_wallpaper_image(
181    src: &[u8],
182    mime: &str,
183) -> Result<(Vec<u8>, String, bool, u8), String> {
184    let was_animated = mime == "image/gif" || mime == "image/webp";
185
186    let mut img = crate::crypto::decode_image_bounded(src)?;
187    if img.width() > MAX_WALLPAPER_DIMENSION || img.height() > MAX_WALLPAPER_DIMENSION {
188        img = img.resize(
189            MAX_WALLPAPER_DIMENSION,
190            MAX_WALLPAPER_DIMENSION,
191            ::image::imageops::FilterType::Triangle,
192        );
193    }
194
195    let recommended_dim = estimate_brightness_for_white_text(&img);
196    let (bytes, ext) = encode_wallpaper_still(&img)?;
197    Ok((bytes, ext, was_animated, recommended_dim))
198}
199
200/// Re-encode a decoded wallpaper as a metadata-free still: PNG when real
201/// transparency is present, otherwise JPEG (much smaller for photos).
202fn encode_wallpaper_still(img: &::image::DynamicImage) -> Result<(Vec<u8>, String), String> {
203    use ::image::{ExtendedColorType, ImageEncoder};
204    use std::io::Cursor;
205
206    let mut out = Vec::new();
207    if img.color().has_alpha() {
208        let rgba = img.to_rgba8();
209        if rgba.pixels().any(|p| p.0[3] != 255) {
210            ::image::codecs::png::PngEncoder::new(Cursor::new(&mut out))
211                .write_image(rgba.as_raw(), rgba.width(), rgba.height(), ExtendedColorType::Rgba8)
212                .map_err(|e| format!("Failed to encode wallpaper: {}", e))?;
213            return Ok((out, "png".to_string()));
214        }
215    }
216    let rgb = img.to_rgb8();
217    ::image::codecs::jpeg::JpegEncoder::new_with_quality(Cursor::new(&mut out), 88)
218        .write_image(rgb.as_raw(), rgb.width(), rgb.height(), ExtendedColorType::Rgb8)
219        .map_err(|e| format!("Failed to encode wallpaper: {}", e))?;
220    Ok((out, "jpg".to_string()))
221}
222
223#[derive(Copy, Clone)]
224enum FileKind {
225    Preview,
226    Active,
227}
228
229/// Remove every wallpaper artifact for `chat_npub` of the given kind. When
230/// `extension_to_skip` is `Some`, files matching that extension are kept
231/// (used to overwrite-in-place during same-format rewrites).
232async fn clean_chat_files(
233    chat_npub: &str,
234    kind: FileKind,
235    extension_to_skip: Option<&str>,
236) -> Result<(), String> {
237    let dir = wallpapers_dir()?;
238
239    let preview_prefix = format!("{}.preview.", chat_npub);
240    let active_prefix = format!("{}.", chat_npub);
241
242    for path in crate::files::list(&dir).await {
243        let name = match path.file_name().and_then(|n| n.to_str()) {
244            Some(n) => n.to_string(),
245            None => continue,
246        };
247        let matches = match kind {
248            FileKind::Preview => name.starts_with(&preview_prefix),
249            // Active = chat-prefixed but NOT preview-prefixed.
250            FileKind::Active => name.starts_with(&active_prefix) && !name.starts_with(&preview_prefix),
251        };
252        if !matches {
253            continue;
254        }
255        if let Some(ext) = extension_to_skip {
256            let suffix = format!(".{}", ext);
257            if name.ends_with(&suffix) {
258                continue;
259            }
260        }
261        crate::files::remove(&path).await;
262    }
263    Ok(())
264}
265
266/// Publish the current preview file as the chat's wallpaper. Encrypts +
267/// uploads to Blossom, builds the kind-30078 rumor, sends to the
268/// counterparty (the gift-wrap helper fans out to self for cross-device
269/// sync), promotes the preview file to the active slot, updates STATE +
270/// DB, drops a `WallpaperChanged` system event, and emits
271/// `wallpaper_updated` to the frontend.
272///
273/// `blur` and `dim` are the customisation knobs the user set on the
274/// preview slider — clamped here to safe ranges and carried as optional
275/// tags on the rumor so older clients without slider support still get a
276/// usable wallpaper (falling back to their own defaults).
277pub async fn publish_wallpaper(chat_npub: &str, blur: u8, dim: u8) -> Result<(), String> {
278    // Capture session at entry — the upload + gift-wrap send below take
279    // seconds, and a mid-publish account swap must not write this
280    // wallpaper into the new account (re-checked before the STATE/DB write).
281
282    let blur = blur.min(30);
283    let dim = dim.min(100);
284    // Find the preview file (we don't know its extension ahead of time).
285    let dir = wallpapers_dir()?;
286    let prefix = format!("{}.preview.", chat_npub);
287    let mut preview_path: Option<PathBuf> = None;
288    for p in crate::files::list(&dir).await {
289        let n = p
290            .file_name()
291            .and_then(|n| n.to_str())
292            .unwrap_or("")
293            .to_string();
294        if n.starts_with(&prefix) {
295            preview_path = Some(p);
296            break;
297        }
298    }
299    let preview = preview_path
300        .ok_or_else(|| "No wallpaper preview to publish. Pick an image first.".to_string())?;
301    let bytes = crate::files::read(&preview)
302        .await
303        .map_err(|e| format!("Failed to read preview file: {}", e))?;
304
305    let extension = preview
306        .extension()
307        .and_then(|e| e.to_str())
308        .unwrap_or("png")
309        .to_string();
310    let mime = crypto::mime_from_extension(&extension).to_string();
311    let plaintext_hash = crypto::sha256_hex(&bytes);
312
313    let params = crypto::generate_encryption_params();
314    let encrypted = crypto::encrypt_data(&bytes, &params)?;
315
316    let _client = crate::state::nostr_client().ok_or("Not logged in")?;
317    let signer = crate::signer::active_signer()
318        .map_err(|e| format!("Signer: {}", e))?;
319    let my_pk = crate::state::my_public_key().ok_or("Public key not set")?;
320    // The chat the wallpaper belongs to, tagged on the rumor below. Without it,
321    // the self-send copy (for multi-device sync) has no recipient, so the
322    // inbound handler attributes it to our self-chat (Notes) instead of this
323    // chat — a wallpaper set in any chat would also reskin Notes.
324    let recipient_pk = PublicKey::from_bech32(chat_npub)
325        .map_err(|e| format!("Invalid chat npub: {}", e))?;
326
327    let servers = crate::state::get_blossom_servers();
328
329    // Bridge Blossom upload progress to the frontend so the Set Wallpaper
330    // button can render a real ring instead of an opaque disabled state.
331    let chat_npub_for_progress = chat_npub.to_string();
332    let progress_cb: crate::blossom::ProgressCallback = Arc::new(move |percentage, bytes| {
333        crate::traits::emit_event(
334            "wallpaper_upload_progress",
335            &serde_json::json!({
336                "chat_id": chat_npub_for_progress,
337                "progress": percentage.unwrap_or(0),
338                "bytes": bytes.unwrap_or(0),
339            }),
340        );
341        Ok(())
342    });
343
344    let upload_url = crate::blossom::upload_blob_with_progress_and_failover(
345        signer.clone(),
346        servers,
347        Arc::new(encrypted.clone()),
348        Some(&mime),
349        /* is_encrypted */ true,
350        progress_cb,
351        None, // default retry count
352        None, // default retry spacing
353        None, // no cancel flag (the picker flow doesn't expose cancel mid-upload)
354    )
355    .await
356    .map_err(|e| format!("Wallpaper upload failed: {}", e))?
357    // This flow never mirrors, so only the descriptor matters.
358    .url;
359
360    let created_at = web_time::SystemTime::now()
361        .duration_since(web_time::UNIX_EPOCH)
362        .unwrap()
363        .as_secs();
364    let rumor = EventBuilder::new(Kind::Custom(event_kind::APPLICATION_SPECIFIC), "")
365        .tag(Tag::identifier(WALLPAPER_DTAG_VALUE))
366        // Recipient tag — identifies which chat this wallpaper is for. The
367        // inbound handler reads it to attribute self-sent (multi-device) copies
368        // to the correct chat rather than defaulting to our self-chat.
369        .tag(Tag::public_key(recipient_pk))
370        .tag(Tag::custom(
371            "url",
372            vec![upload_url.clone()],
373        ))
374        .tag(Tag::custom(
375            "decryption-key",
376            vec![params.key.clone()],
377        ))
378        .tag(Tag::custom(
379            "decryption-nonce",
380            vec![params.nonce.clone()],
381        ))
382        .tag(Tag::custom(
383            "x",
384            vec![plaintext_hash.clone()],
385        ))
386        .tag(Tag::custom(
387            "m",
388            vec![mime.clone()],
389        ))
390        .tag(Tag::custom(
391            "size",
392            vec![encrypted.len().to_string()],
393        ))
394        .tag(Tag::custom(
395            "blur",
396            vec![blur.to_string()],
397        ))
398        .tag(Tag::custom(
399            "dim",
400            vec![dim.to_string()],
401        ))
402        .custom_created_at(Timestamp::from(created_at))
403        .finalize_unsigned_with_id(my_pk);
404
405    // SEND FIRST, commit on success. Wallpaper is a sync feature — if the
406    // recipient (and our other devices) can't see it, there's no value in
407    // locally applying it.
408    //
409    // 3 attempts with 2s spacing (~6s max wait) keeps the dialog
410    // responsive. self_send=true so other devices of ours pick it up via
411    // their own NIP-17 inbox subscription.
412    let pending_id = format!("pending-wallpaper-{}", created_at);
413    let send_config = crate::sending::SendConfig {
414        max_send_attempts: 3,
415        retry_delay: std::time::Duration::from_secs(2),
416        self_send: true,
417        ..Default::default()
418    };
419    let send_callback: Arc<dyn crate::sending::SendCallback> =
420        Arc::new(crate::sending::NoOpSendCallback);
421    if let Err(e) = crate::sending::send_rumor_dm(
422        chat_npub, &pending_id, rumor.clone(), &send_config, send_callback,
423    ).await {
424        log_warn!("[Wallpaper] send_rumor_dm to {} failed: {}", chat_npub, e);
425        return Err(format!(
426            "Couldn't send the wallpaper. Check that the relays you and your contact share are reachable, then try again. ({})",
427            e
428        ));
429    }
430    log_info!("[Wallpaper] rumor delivered to {}", chat_npub);
431
432    // Account swapped during upload/send: the rumor already went to the
433    // original recipient, but the local commit below (preview promotion,
434    // STATE, DB) would land in the new account's storage. Skip it.
435
436    // Send succeeded — promote the preview file to the active slot.
437    let active = wallpapers_dir()?.join(format!("{}.{}", chat_npub, extension));
438    clean_chat_files(chat_npub, FileKind::Active, None).await?;
439    crate::files::rename(&preview, &active)
440        .await
441        .map_err(|e| format!("Failed to promote preview: {}", e))?;
442    let active_str = active.to_string_lossy().to_string();
443
444    let me_npub = my_pk.to_bech32().unwrap_or_default();
445
446    // Persist to STATE + DB. Capture the previous Blossom URL + uploader
447    // under the same lock so we can clean it up only if we owned it.
448    let (slim, prev_url, prev_uploader) = {
449        let mut state = crate::state::STATE.lock().await;
450        let prev = state.get_chat(chat_npub).map(|c| {
451            (c.wallpaper_url.clone(), c.wallpaper_uploader.clone())
452        });
453        if let Some(chat) = state.get_chat_mut(chat_npub) {
454            chat.wallpaper_path = active_str.clone();
455            chat.wallpaper_ts = created_at;
456            chat.wallpaper_blur = blur;
457            chat.wallpaper_dim = dim;
458            chat.wallpaper_url = upload_url.clone();
459            chat.wallpaper_uploader = me_npub.clone();
460        }
461        let slim = state
462            .get_chat(chat_npub)
463            .map(|c| crate::db::chats::SlimChatDB::from_chat(c, &state.interner));
464        let (pu, puploader) = prev.unwrap_or_default();
465        (slim, pu, puploader)
466    };
467    if let Some(slim) = slim {
468        if let Err(e) = crate::db::chats::save_slim_chat(&slim) {
469            log_warn!("[Wallpaper] save_slim_chat failed for {}: {}", chat_npub, e);
470        }
471    }
472
473    // Fire-and-forget DELETE of the previous blob, only if we uploaded
474    // it (server's auth challenge would reject otherwise). Multi-device
475    // case: this runs on the device that does the replace, which may not
476    // be the device that uploaded the previous one — the uploader check
477    // is on the npub, not the device, so it still fires correctly.
478    if !prev_url.is_empty() && prev_uploader == me_npub {
479        let signer_clone = signer.clone();
480        let prev_url_clone = prev_url.clone();
481        crate::db::spawn_bound(async move {
482            if let Err(e) =
483                crate::blossom::delete_blob_by_url(signer_clone, &prev_url_clone).await
484            {
485                log_warn!(
486                    "[Wallpaper] DELETE prev blob {} failed: {}",
487                    prev_url_clone, e
488                );
489            }
490        });
491    }
492
493    let event_id = rumor.id.ok_or("Rumor missing id")?.to_hex();
494    let inserted = match crate::db::events::save_system_event_by_id(
495        &event_id,
496        chat_npub,
497        crate::stored_event::SystemEventType::WallpaperChanged,
498        &me_npub,
499        Some("You"),
500    )
501    .await
502    {
503        Ok(b) => b,
504        Err(e) => {
505            log_warn!("[Wallpaper] save_system_event_by_id failed for {}: {}", event_id, e);
506            false
507        }
508    };
509    if inserted {
510        crate::traits::emit_event("system_event", &serde_json::json!({
511            "conversation_id": chat_npub,
512            "event_id": event_id,
513            "event_type": crate::stored_event::SystemEventType::WallpaperChanged.as_u8(),
514            "member_pubkey": me_npub,
515            "member_name": "You",
516        }));
517    } else {
518        log_warn!("[Wallpaper] system event {} was not inserted (already exists or save failed)", event_id);
519    }
520
521    crate::traits::emit_event(
522        "wallpaper_updated",
523        &serde_json::json!({
524            "chat_id": chat_npub,
525            "path": active_str,
526            "ts": created_at,
527            "blur": blur,
528            "dim": dim,
529            "by_npub": me_npub,
530            "event_id": event_id,
531        }),
532    );
533
534    Ok(())
535}
536
537/// Apply a received wallpaper rumor. Drops the rumor if its timestamp is
538/// not newer than the chat's current `wallpaper_ts` (latest-write-wins).
539/// On a fresh rumor: downloads + decrypts the Blossom blob, caches it
540/// locally, updates STATE + DB, saves a `WallpaperChanged` system event,
541/// and emits `wallpaper_updated` to the frontend.
542#[allow(clippy::too_many_arguments)]
543pub async fn apply_received_wallpaper(
544    chat_npub: &str,
545    sender_npub: &str,
546    created_at: u64,
547    url: &str,
548    decryption_key: &str,
549    decryption_nonce: &str,
550    plaintext_hash: Option<&str>,
551    mime: Option<&str>,
552    blur: Option<u8>,
553    dim: Option<u8>,
554    rumor_event_id: &str,
555) -> Result<(), String> {
556    // Capture session NOW — the download below can take seconds, and a
557    // mid-fetch account swap must not let us write account A's wallpaper
558    // into account B's STATE/DB (re-checked before every write below).
559
560    let blur = blur.unwrap_or(0).min(30);
561    let dim = dim.unwrap_or(50).min(100);
562    // Latest-write-wins. Drop the rumor if we've already applied a newer
563    // (or equal) one — typical during negentropy backfill.
564    {
565        let state = crate::state::STATE.lock().await;
566        if let Some(chat) = state.get_chat(chat_npub) {
567            if chat.wallpaper_ts >= created_at {
568                return Ok(());
569            }
570        }
571    }
572
573    // Removal tombstone — sender cleared their wallpaper. No blob to fetch;
574    // wipe the local active file + STATE/DB so the default theme returns.
575    if url.is_empty() {
576        clean_chat_files(chat_npub, FileKind::Active, None).await?;
577        let (slim, prev_url, prev_uploader) = {
578            let mut state = crate::state::STATE.lock().await;
579            let prev = state.get_chat(chat_npub).map(|c| {
580                (c.wallpaper_url.clone(), c.wallpaper_uploader.clone())
581            });
582            if let Some(chat) = state.get_chat_mut(chat_npub) {
583                chat.wallpaper_path = String::new();
584                chat.wallpaper_url = String::new();
585                chat.wallpaper_uploader = String::new();
586                chat.wallpaper_ts = created_at;
587                chat.wallpaper_blur = blur;
588                chat.wallpaper_dim = dim;
589            }
590            let slim = state
591                .get_chat(chat_npub)
592                .map(|c| crate::db::chats::SlimChatDB::from_chat(c, &state.interner));
593            let (pu, puploader) = prev.unwrap_or_default();
594            (slim, pu, puploader)
595        };
596        if let Some(slim) = slim {
597            let _ = crate::db::chats::save_slim_chat(&slim);
598        }
599        delete_prior_blob_if_ours(&prev_url, &prev_uploader).await;
600        emit_wallpaper_removed(chat_npub, sender_npub, created_at, rumor_event_id).await;
601        return Ok(());
602    }
603
604    let mime_str = mime.unwrap_or("image/png").to_string();
605    let extension = crypto::extension_from_mime(&mime_str);
606
607    // SSRF guard: the URL is attacker-controlled (it arrives in a rumor),
608    // and this fetch is zero-interaction. Block private/internal targets.
609    crate::net::validate_url_not_private(url)?;
610
611    let http = crate::net::build_http_client(Duration::from_secs(30))?;
612    let resp = crate::net::proxied_request(&http, reqwest::Method::GET, url)
613        .await
614        .send()
615        .await
616        .map_err(|e| format!("Wallpaper download failed: {}", e))?;
617    if !resp.status().is_success() {
618        return Err(format!("Wallpaper HTTP {}", resp.status()));
619    }
620    // Reject by advertised length first (cheap), then enforce a hard cap
621    // while streaming so a server that omits Content-Length can't OOM us.
622    if let Some(len) = resp.content_length() {
623        if len > MAX_WALLPAPER_DOWNLOAD_BYTES {
624            return Err("Wallpaper too large".to_string());
625        }
626    }
627    let mut bytes: Vec<u8> = Vec::new();
628    let mut body = resp.bytes_stream();
629    while let Some(chunk) = futures_util::StreamExt::next(&mut body)
630        .await
631        .transpose()
632        .map_err(|e| format!("Read body: {}", e))?
633    {
634        bytes.extend_from_slice(&chunk);
635        if bytes.len() as u64 > MAX_WALLPAPER_DOWNLOAD_BYTES {
636            return Err("Wallpaper too large".to_string());
637        }
638    }
639
640    let plaintext = crypto::decrypt_data_owned(bytes, decryption_key, decryption_nonce)?;
641
642    if let Some(want_hash) = plaintext_hash {
643        let got_hash = crypto::sha256_hex(&plaintext);
644        if !got_hash.eq_ignore_ascii_case(want_hash) {
645            return Err("Wallpaper integrity check failed".to_string());
646        }
647    }
648
649    // Account swap during the download invalidates everything below — the
650    // chat npub, the DB pool, and the per-account wallpapers dir all belong
651
652    let active = wallpapers_dir()?.join(format!("{}.{}", chat_npub, extension));
653    clean_chat_files(chat_npub, FileKind::Active, None).await?;
654    crate::files::write(&active, &plaintext).await.map_err(|e| format!("Write wallpaper: {}", e))?;
655    let active_str = active.to_string_lossy().to_string();
656
657    // Capture previous URL + uploader under the same lock — only DELETE
658    // the prior blob if WE were the uploader (covers multi-device sync
659    // where a different device of ours uploaded the previous wallpaper).
660    let (slim, prev_url, prev_uploader) = {
661        let mut state = crate::state::STATE.lock().await;
662        let prev = state.get_chat(chat_npub).map(|c| {
663            (c.wallpaper_url.clone(), c.wallpaper_uploader.clone())
664        });
665        if let Some(chat) = state.get_chat_mut(chat_npub) {
666            chat.wallpaper_path = active_str.clone();
667            chat.wallpaper_ts = created_at;
668            chat.wallpaper_blur = blur;
669            chat.wallpaper_dim = dim;
670            chat.wallpaper_url = url.to_string();
671            chat.wallpaper_uploader = sender_npub.to_string();
672        }
673        let slim = state
674            .get_chat(chat_npub)
675            .map(|c| crate::db::chats::SlimChatDB::from_chat(c, &state.interner));
676        let (pu, puploader) = prev.unwrap_or_default();
677        (slim, pu, puploader)
678    };
679    if let Some(slim) = slim {
680        let _ = crate::db::chats::save_slim_chat(&slim);
681    }
682
683    if !prev_url.is_empty() {
684        let me_npub = crate::state::my_public_key()
685            .and_then(|pk| pk.to_bech32().ok())
686            .unwrap_or_default();
687        if !me_npub.is_empty() && prev_uploader == me_npub {
688            if let Some(_client) = crate::state::nostr_client() {
689                if let Ok(signer) = crate::signer::active_signer() {
690                    let prev_url_clone = prev_url.clone();
691                    crate::db::spawn_bound(async move {
692                        if let Err(e) =
693                            crate::blossom::delete_blob_by_url(signer, &prev_url_clone).await
694                        {
695                            log_warn!(
696                                "[Wallpaper] DELETE prev blob {} failed: {}",
697                                prev_url_clone, e
698                            );
699                        }
700                    });
701                }
702            }
703        }
704    }
705
706    // Resolve a display name for the system event. Fall back to the npub
707    // (truncated by the frontend's formatter) when we don't know the peer
708    // yet — the row still tells the user what happened.
709    let sender_display = {
710        let state = crate::state::STATE.lock().await;
711        state
712            .get_profile(sender_npub)
713            .and_then(|p| {
714                if !p.nickname().is_empty() {
715                    Some(p.nickname().to_string())
716                } else if !p.name.is_empty() {
717                    Some(p.name.to_string())
718                } else {
719                    None
720                }
721            })
722            .unwrap_or_else(|| sender_npub.to_string())
723    };
724    let inserted = crate::db::events::save_system_event_by_id(
725        rumor_event_id,
726        chat_npub,
727        crate::stored_event::SystemEventType::WallpaperChanged,
728        sender_npub,
729        Some(&sender_display),
730    )
731    .await
732    .unwrap_or(false);
733    if inserted {
734        crate::traits::emit_event("system_event", &serde_json::json!({
735            "conversation_id": chat_npub,
736            "event_id": rumor_event_id,
737            "event_type": crate::stored_event::SystemEventType::WallpaperChanged.as_u8(),
738            "member_pubkey": sender_npub,
739            "member_name": sender_display,
740        }));
741    }
742
743    crate::traits::emit_event(
744        "wallpaper_updated",
745        &serde_json::json!({
746            "chat_id": chat_npub,
747            "path": active_str,
748            "ts": created_at,
749            "blur": blur,
750            "dim": dim,
751            "by_npub": sender_npub,
752            "event_id": rumor_event_id,
753        }),
754    );
755
756    Ok(())
757}
758
759/// Fire-and-forget DELETE of a prior Blossom blob, but only if WE uploaded
760/// it (the server's auth challenge rejects deletes from anyone else). The
761/// uploader check is on the npub, so multi-device replaces still fire.
762async fn delete_prior_blob_if_ours(prev_url: &str, prev_uploader: &str) {
763    if prev_url.is_empty() {
764        return;
765    }
766    let me_npub = crate::state::my_public_key()
767        .and_then(|pk| pk.to_bech32().ok())
768        .unwrap_or_default();
769    if me_npub.is_empty() || prev_uploader != me_npub {
770        return;
771    }
772    if let Some(_client) = crate::state::nostr_client() {
773        if let Ok(signer) = crate::signer::active_signer() {
774            let prev_url = prev_url.to_string();
775            crate::db::spawn_bound(async move {
776                if let Err(e) = crate::blossom::delete_blob_by_url(signer, &prev_url).await {
777                    log_warn!("[Wallpaper] DELETE prev blob {} failed: {}", prev_url, e);
778                }
779            });
780        }
781    }
782}
783
784/// Save the WallpaperChanged system event for a removal + emit the frontend
785/// events that revert the chat to the default theme.
786async fn emit_wallpaper_removed(
787    chat_npub: &str,
788    by_npub: &str,
789    created_at: u64,
790    event_id: &str,
791) {
792    let me_npub = crate::state::my_public_key()
793        .and_then(|pk| pk.to_bech32().ok())
794        .unwrap_or_default();
795    let display = if by_npub == me_npub {
796        "You".to_string()
797    } else {
798        let state = crate::state::STATE.lock().await;
799        state
800            .get_profile(by_npub)
801            .and_then(|p| {
802                if !p.nickname().is_empty() {
803                    Some(p.nickname().to_string())
804                } else if !p.name.is_empty() {
805                    Some(p.name.to_string())
806                } else {
807                    None
808                }
809            })
810            .unwrap_or_else(|| by_npub.to_string())
811    };
812    let inserted = crate::db::events::save_system_event_by_id(
813        event_id,
814        chat_npub,
815        crate::stored_event::SystemEventType::WallpaperRemoved,
816        by_npub,
817        Some(&display),
818    )
819    .await
820    .unwrap_or(false);
821    if inserted {
822        crate::traits::emit_event("system_event", &serde_json::json!({
823            "conversation_id": chat_npub,
824            "event_id": event_id,
825            "event_type": crate::stored_event::SystemEventType::WallpaperRemoved.as_u8(),
826            "member_pubkey": by_npub,
827            "member_name": display,
828        }));
829    }
830    crate::traits::emit_event(
831        "wallpaper_updated",
832        &serde_json::json!({
833            "chat_id": chat_npub,
834            "path": "",
835            "ts": created_at,
836            "blur": 0,
837            "dim": 50,
838            "by_npub": by_npub,
839            "event_id": event_id,
840        }),
841    );
842}
843
844/// Remove the chat's wallpaper, reverting both sides to the default theme.
845/// Publishes a kind-30078 `vector-wallpaper` tombstone (no `url` tag) so the
846/// recipient and our other devices clear it too (latest-write-wins by
847/// `created_at`), then DELETEs our blob and wipes local STATE/DB.
848pub async fn remove_wallpaper(chat_npub: &str) -> Result<(), String> {
849
850    let my_pk = crate::state::my_public_key().ok_or("Public key not set")?;
851    let recipient_pk = PublicKey::from_bech32(chat_npub)
852        .map_err(|e| format!("Invalid chat npub: {}", e))?;
853
854    let created_at = web_time::SystemTime::now()
855        .duration_since(web_time::UNIX_EPOCH)
856        .unwrap()
857        .as_secs();
858    // Tombstone: same d-tag + recipient p-tag as a set, but no url/key/nonce.
859    let rumor = EventBuilder::new(Kind::Custom(event_kind::APPLICATION_SPECIFIC), "")
860        .tag(Tag::identifier(WALLPAPER_DTAG_VALUE))
861        .tag(Tag::public_key(recipient_pk))
862        .custom_created_at(Timestamp::from(created_at))
863        .finalize_unsigned_with_id(my_pk);
864
865    let pending_id = format!("pending-wallpaper-rm-{}", created_at);
866    let send_config = crate::sending::SendConfig {
867        max_send_attempts: 3,
868        retry_delay: std::time::Duration::from_secs(2),
869        self_send: true,
870        ..Default::default()
871    };
872    let send_callback: Arc<dyn crate::sending::SendCallback> =
873        Arc::new(crate::sending::NoOpSendCallback);
874    if let Err(e) = crate::sending::send_rumor_dm(
875        chat_npub, &pending_id, rumor.clone(), &send_config, send_callback,
876    ).await {
877        log_warn!("[Wallpaper] removal send to {} failed: {}", chat_npub, e);
878        return Err(format!(
879            "Couldn't remove the wallpaper. Check that the relays you and your contact share are reachable, then try again. ({})",
880            e
881        ));
882    }
883
884    // Account swapped mid-send — the tombstone already went out, but the
885    // local commit below would land in the new account's storage. Skip it.
886
887    clean_chat_files(chat_npub, FileKind::Active, None).await?;
888    let me_npub = my_pk.to_bech32().unwrap_or_default();
889    let (slim, prev_url, prev_uploader) = {
890        let mut state = crate::state::STATE.lock().await;
891        let prev = state.get_chat(chat_npub).map(|c| {
892            (c.wallpaper_url.clone(), c.wallpaper_uploader.clone())
893        });
894        if let Some(chat) = state.get_chat_mut(chat_npub) {
895            chat.wallpaper_path = String::new();
896            chat.wallpaper_url = String::new();
897            chat.wallpaper_uploader = String::new();
898            chat.wallpaper_ts = created_at;
899        }
900        let slim = state
901            .get_chat(chat_npub)
902            .map(|c| crate::db::chats::SlimChatDB::from_chat(c, &state.interner));
903        let (pu, puploader) = prev.unwrap_or_default();
904        (slim, pu, puploader)
905    };
906    if let Some(slim) = slim {
907        if let Err(e) = crate::db::chats::save_slim_chat(&slim) {
908            log_warn!("[Wallpaper] save_slim_chat (removal) failed for {}: {}", chat_npub, e);
909        }
910    }
911
912    delete_prior_blob_if_ours(&prev_url, &prev_uploader).await;
913
914    let event_id = rumor.id.ok_or("Rumor missing id")?.to_hex();
915    emit_wallpaper_removed(chat_npub, &me_npub, created_at, &event_id).await;
916
917    Ok(())
918}
919
920#[cfg(test)]
921mod wallpaper_strip_tests {
922    use super::*;
923    use ::image::{DynamicImage, ImageFormat, Rgb, RgbImage, Rgba, RgbaImage};
924    use std::io::Cursor;
925
926    fn png_bytes(img: &DynamicImage) -> Vec<u8> {
927        let mut out = Vec::new();
928        img.write_to(&mut Cursor::new(&mut out), ImageFormat::Png).unwrap();
929        out
930    }
931
932    #[test]
933    fn opaque_wallpaper_becomes_jpeg_and_is_capped() {
934        let img = DynamicImage::ImageRgb8(RgbImage::from_fn(3000, 1000, |x, y| {
935            Rgb([(x % 256) as u8, (y % 256) as u8, 128])
936        }));
937        let (bytes, ext, was_animated, dim) =
938            normalize_wallpaper_image(&png_bytes(&img), "image/png").unwrap();
939        assert_eq!(ext, "jpg");
940        assert!(!was_animated);
941        assert!((10..=50).contains(&dim), "brightness out of range: {dim}");
942        let dec = ::image::load_from_memory(&bytes).unwrap();
943        assert!(dec.width() <= MAX_WALLPAPER_DIMENSION && dec.height() <= MAX_WALLPAPER_DIMENSION);
944    }
945
946    #[test]
947    fn transparent_wallpaper_stays_png() {
948        let img = DynamicImage::ImageRgba8(RgbaImage::from_fn(64, 64, |x, _| {
949            Rgba([10, 20, 30, if x < 32 { 0 } else { 255 }])
950        }));
951        let (bytes, ext, _, _) =
952            normalize_wallpaper_image(&png_bytes(&img), "image/png").unwrap();
953        assert_eq!(ext, "png");
954        assert!(::image::load_from_memory(&bytes).unwrap().color().has_alpha());
955    }
956
957    #[test]
958    fn animated_mime_flags_was_animated_but_flattens_to_a_still() {
959        let img = DynamicImage::ImageRgb8(RgbImage::from_fn(10, 10, |_, _| Rgb([1, 2, 3])));
960        let (_bytes, ext, was_animated, _dim) =
961            normalize_wallpaper_image(&png_bytes(&img), "image/gif").unwrap();
962        assert!(was_animated);
963        assert_eq!(ext, "jpg");
964    }
965}