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 std::path::{Path, PathBuf};
29use std::sync::Arc;
30use std::time::Duration;
31
32use nostr_sdk::prelude::*;
33use serde::{Deserialize, Serialize};
34
35use crate::crypto;
36use crate::stored_event::event_kind;
37
38const WALLPAPER_DTAG_VALUE: &str = "vector-wallpaper";
39
40/// Hard ceiling on a received (encrypted) wallpaper download. The send side
41/// caps plaintext at 5 MB; 10 MB leaves headroom for encryption overhead
42/// while still bounding memory against a malicious/oversized blob.
43const MAX_WALLPAPER_DOWNLOAD_BYTES: u64 = 10 * 1024 * 1024;
44
45/// Maximum allowed source-image size (pre-encryption). Matches the user-
46/// facing cap; enforced both at preview prep and at the picker UI.
47pub const MAX_WALLPAPER_BYTES: usize = 5 * 1024 * 1024;
48
49/// Per-account directory for cached wallpaper files. One active file per
50/// chat + at most one preview-staging file per chat.
51fn wallpapers_dir() -> Result<PathBuf, String> {
52    let npub = crate::db::get_current_account()?;
53    let dir = crate::db::account_dir(&npub)?.join("wallpapers");
54    std::fs::create_dir_all(&dir)
55        .map_err(|e| format!("Failed to create wallpapers dir: {}", e))?;
56    Ok(dir)
57}
58
59#[derive(Debug, Clone, Serialize, Deserialize)]
60pub struct WallpaperPreview {
61    /// Local cached path the frontend should render.
62    pub path: String,
63    /// True iff the picker had to extract a frame from an animated source.
64    /// The UI uses this to surface a one-line "static-only" notice.
65    pub was_animated: bool,
66    /// Suggested initial brightness (0..=100) derived from the image's
67    /// average luma — bright images get more dimming so text stays
68    /// readable, dark images keep more of the original. User can still
69    /// override with the slider before confirming.
70    pub recommended_dim: u8,
71}
72
73/// Validate + prepare a picked image: enforce the 5 MB cap, image-only
74/// mime, extract first frame for animated formats, write to the per-chat
75/// preview slot. The returned path is what the chat background should
76/// switch to while the Confirm/Cancel bar is showing.
77pub fn prepare_wallpaper_preview(
78    chat_npub: &str,
79    file_path: &str,
80) -> Result<WallpaperPreview, String> {
81    let src = Path::new(file_path);
82    let bytes = std::fs::read(src)
83        .map_err(|e| format!("Failed to read image: {}", e))?;
84
85    if bytes.len() > MAX_WALLPAPER_BYTES {
86        return Err(format!(
87            "Image is too large ({} MB). Wallpapers max out at {} MB.",
88            bytes.len() / (1024 * 1024),
89            MAX_WALLPAPER_BYTES / (1024 * 1024),
90        ));
91    }
92
93    let mime = crypto::mime_from_magic_bytes(&bytes).to_string();
94    if !mime.starts_with("image/") {
95        return Err("Wallpapers must be image files.".to_string());
96    }
97
98    let (final_bytes, final_extension, was_animated) =
99        extract_first_frame_if_animated(&bytes, &mime)?;
100
101    // Sample luma so the slider lands somewhere readable instead of the
102    // fixed 50% default. Cheap (downsamples first); failures fall back
103    // to the static default so a weird-encoded image never blocks the
104    // preview flow.
105    let recommended_dim = estimate_brightness_for_white_text(&final_bytes).unwrap_or(50);
106
107    // Clear any prior preview for this chat (different format or stale).
108    clean_chat_files(chat_npub, FileKind::Preview, None)?;
109
110    let preview = wallpapers_dir()?.join(format!("{}.preview.{}", chat_npub, final_extension));
111    let tmp = preview.with_file_name(format!("{}.preview.{}.tmp", chat_npub, final_extension));
112    std::fs::write(&tmp, &final_bytes)
113        .map_err(|e| format!("Failed to stage preview file: {}", e))?;
114    std::fs::rename(&tmp, &preview)
115        .map_err(|e| format!("Failed to commit preview file: {}", e))?;
116
117    Ok(WallpaperPreview {
118        path: preview.to_string_lossy().to_string(),
119        was_animated,
120        recommended_dim,
121    })
122}
123
124/// Pick a starting brightness percent such that white chat text stays
125/// readable against the image. Down-samples to 64×64 for speed, averages
126/// the Rec. 709 luma, then maps `(avg_luma 0..=255)` to a brightness
127/// percent in the `[25, 95]` range — bright images get dimmer defaults,
128/// dark images stay bright. Returns `None` if the bytes can't be decoded.
129fn estimate_brightness_for_white_text(bytes: &[u8]) -> Option<u8> {
130    use ::image::{GenericImageView, ImageReader};
131    use std::io::Cursor;
132
133    let mut reader = ImageReader::new(Cursor::new(bytes))
134        .with_guessed_format()
135        .ok()?;
136    reader.limits(crate::crypto::bounded_image_limits());
137    let img = reader.decode().ok()?;
138    let thumb = img.thumbnail(64, 64);
139    let rgb = thumb.to_rgb8();
140    let (w, h) = thumb.dimensions();
141    if w == 0 || h == 0 {
142        return None;
143    }
144    let mut sum: u64 = 0;
145    let mut count: u64 = 0;
146    for pixel in rgb.pixels() {
147        let r = pixel[0] as f32;
148        let g = pixel[1] as f32;
149        let b = pixel[2] as f32;
150        let y = 0.2126 * r + 0.7152 * g + 0.0722 * b;
151        sum += y as u64;
152        count += 1;
153    }
154    if count == 0 {
155        return None;
156    }
157    let avg = (sum / count) as f32; // 0..=255
158    // Map luma → brightness with an aggressive curve, then halve to land
159    // most photographs in the 15..30% range where white chat text is
160    // clearly legible against the underlying image:
161    //   white  (255) → ~12
162    //   bright (200) → ~17
163    //   mid    (128) → ~22
164    //   dark   ( 60) → ~38
165    //   black  (  0) → ~47
166    let brightness = (95.0 - (avg / 255.0) * 70.0) / 2.0;
167    Some(brightness.clamp(10.0, 50.0) as u8)
168}
169
170/// Delete the preview file (user cancelled before publishing).
171pub fn cancel_wallpaper_preview(chat_npub: &str) -> Result<(), String> {
172    clean_chat_files(chat_npub, FileKind::Preview, None)
173}
174
175/// Returns `(bytes, extension, was_animated)`. For non-animated formats,
176/// passes the source through unchanged. For GIF / WebP the first frame is
177/// re-encoded as PNG so it can be rendered in `background-image` without
178/// animation.
179fn extract_first_frame_if_animated(
180    src: &[u8],
181    mime: &str,
182) -> Result<(Vec<u8>, String, bool), String> {
183    use ::image::{ImageFormat, ImageReader};
184    use std::io::Cursor;
185
186    let is_gif = mime == "image/gif";
187    let is_webp = mime == "image/webp";
188
189    if !is_gif && !is_webp {
190        let ext = crypto::extension_from_mime(mime);
191        return Ok((src.to_vec(), ext, false));
192    }
193
194    // `image::ImageReader::decode()` yields frame 0 for animated GIF / WebP.
195    let mut reader = ImageReader::with_format(
196        Cursor::new(src),
197        if is_gif { ImageFormat::Gif } else { ImageFormat::WebP },
198    );
199    reader.limits(crate::crypto::bounded_image_limits());
200    let img = reader
201        .decode()
202        .map_err(|e| format!("Failed to decode image: {}", e))?;
203
204    let mut out: Vec<u8> = Vec::new();
205    img.write_to(&mut Cursor::new(&mut out), ImageFormat::Png)
206        .map_err(|e| format!("Failed to re-encode wallpaper: {}", e))?;
207
208    Ok((out, "png".to_string(), true))
209}
210
211#[derive(Copy, Clone)]
212enum FileKind {
213    Preview,
214    Active,
215}
216
217/// Remove every wallpaper artifact for `chat_npub` of the given kind. When
218/// `extension_to_skip` is `Some`, files matching that extension are kept
219/// (used to overwrite-in-place during same-format rewrites).
220fn clean_chat_files(
221    chat_npub: &str,
222    kind: FileKind,
223    extension_to_skip: Option<&str>,
224) -> Result<(), String> {
225    let dir = wallpapers_dir()?;
226    let entries = match std::fs::read_dir(&dir) {
227        Ok(e) => e,
228        Err(_) => return Ok(()),
229    };
230
231    let preview_prefix = format!("{}.preview.", chat_npub);
232    let active_prefix = format!("{}.", chat_npub);
233
234    for entry in entries.flatten() {
235        let path = entry.path();
236        let name = match path.file_name().and_then(|n| n.to_str()) {
237            Some(n) => n.to_string(),
238            None => continue,
239        };
240        let matches = match kind {
241            FileKind::Preview => name.starts_with(&preview_prefix),
242            // Active = chat-prefixed but NOT preview-prefixed.
243            FileKind::Active => name.starts_with(&active_prefix) && !name.starts_with(&preview_prefix),
244        };
245        if !matches {
246            continue;
247        }
248        if let Some(ext) = extension_to_skip {
249            let suffix = format!(".{}", ext);
250            if name.ends_with(&suffix) {
251                continue;
252            }
253        }
254        let _ = std::fs::remove_file(&path);
255    }
256    Ok(())
257}
258
259/// Publish the current preview file as the chat's wallpaper. Encrypts +
260/// uploads to Blossom, builds the kind-30078 rumor, sends to the
261/// counterparty (the gift-wrap helper fans out to self for cross-device
262/// sync), promotes the preview file to the active slot, updates STATE +
263/// DB, drops a `WallpaperChanged` system event, and emits
264/// `wallpaper_updated` to the frontend.
265///
266/// `blur` and `dim` are the customisation knobs the user set on the
267/// preview slider — clamped here to safe ranges and carried as optional
268/// tags on the rumor so older clients without slider support still get a
269/// usable wallpaper (falling back to their own defaults).
270pub async fn publish_wallpaper(chat_npub: &str, blur: u8, dim: u8) -> Result<(), String> {
271    // Capture session at entry — the upload + gift-wrap send below take
272    // seconds, and a mid-publish account swap must not write this
273    // wallpaper into the new account (re-checked before the STATE/DB write).
274    let session = crate::state::SessionGuard::capture();
275
276    let blur = blur.min(30);
277    let dim = dim.min(100);
278    // Find the preview file (we don't know its extension ahead of time).
279    let dir = wallpapers_dir()?;
280    let prefix = format!("{}.preview.", chat_npub);
281    let mut preview_path: Option<PathBuf> = None;
282    for entry in std::fs::read_dir(&dir)
283        .map_err(|e| format!("Wallpapers dir: {}", e))?
284        .flatten()
285    {
286        let p = entry.path();
287        let n = p
288            .file_name()
289            .and_then(|n| n.to_str())
290            .unwrap_or("")
291            .to_string();
292        if n.starts_with(&prefix) {
293            preview_path = Some(p);
294            break;
295        }
296    }
297    let preview = preview_path
298        .ok_or_else(|| "No wallpaper preview to publish. Pick an image first.".to_string())?;
299    let bytes = std::fs::read(&preview)
300        .map_err(|e| format!("Failed to read preview file: {}", e))?;
301
302    let extension = preview
303        .extension()
304        .and_then(|e| e.to_str())
305        .unwrap_or("png")
306        .to_string();
307    let mime = crypto::mime_from_extension(&extension).to_string();
308    let plaintext_hash = crypto::sha256_hex(&bytes);
309
310    let params = crypto::generate_encryption_params();
311    let encrypted = crypto::encrypt_data(&bytes, &params)?;
312
313    let client = crate::state::nostr_client().ok_or("Not logged in")?;
314    let signer = client
315        .signer()
316        .await
317        .map_err(|e| format!("Signer: {}", e))?;
318    let my_pk = crate::state::my_public_key().ok_or("Public key not set")?;
319    // The chat the wallpaper belongs to, tagged on the rumor below. Without it,
320    // the self-send copy (for multi-device sync) has no recipient, so the
321    // inbound handler attributes it to our self-chat (Notes) instead of this
322    // chat — a wallpaper set in any chat would also reskin Notes.
323    let recipient_pk = PublicKey::from_bech32(chat_npub)
324        .map_err(|e| format!("Invalid chat npub: {}", e))?;
325
326    let servers = crate::state::get_blossom_servers();
327
328    // Bridge Blossom upload progress to the frontend so the Set Wallpaper
329    // button can render a real ring instead of an opaque disabled state.
330    let chat_npub_for_progress = chat_npub.to_string();
331    let progress_cb: crate::blossom::ProgressCallback = Arc::new(move |percentage, bytes| {
332        crate::traits::emit_event(
333            "wallpaper_upload_progress",
334            &serde_json::json!({
335                "chat_id": chat_npub_for_progress,
336                "progress": percentage.unwrap_or(0),
337                "bytes": bytes.unwrap_or(0),
338            }),
339        );
340        Ok(())
341    });
342
343    let upload_url = crate::blossom::upload_blob_with_progress_and_failover(
344        signer.clone(),
345        servers,
346        Arc::new(encrypted.clone()),
347        Some(&mime),
348        /* is_encrypted */ true,
349        progress_cb,
350        None, // default retry count
351        None, // default retry spacing
352        None, // no cancel flag (the picker flow doesn't expose cancel mid-upload)
353    )
354    .await
355    .map_err(|e| format!("Wallpaper upload failed: {}", e))?;
356
357    let created_at = std::time::SystemTime::now()
358        .duration_since(std::time::UNIX_EPOCH)
359        .unwrap()
360        .as_secs();
361    let rumor = EventBuilder::new(Kind::Custom(event_kind::APPLICATION_SPECIFIC), "")
362        .tag(Tag::identifier(WALLPAPER_DTAG_VALUE))
363        // Recipient tag — identifies which chat this wallpaper is for. The
364        // inbound handler reads it to attribute self-sent (multi-device) copies
365        // to the correct chat rather than defaulting to our self-chat.
366        .tag(Tag::public_key(recipient_pk))
367        .tag(Tag::custom(
368            TagKind::Custom("url".into()),
369            vec![upload_url.clone()],
370        ))
371        .tag(Tag::custom(
372            TagKind::Custom("decryption-key".into()),
373            vec![params.key.clone()],
374        ))
375        .tag(Tag::custom(
376            TagKind::Custom("decryption-nonce".into()),
377            vec![params.nonce.clone()],
378        ))
379        .tag(Tag::custom(
380            TagKind::Custom("x".into()),
381            vec![plaintext_hash.clone()],
382        ))
383        .tag(Tag::custom(
384            TagKind::Custom("m".into()),
385            vec![mime.clone()],
386        ))
387        .tag(Tag::custom(
388            TagKind::Custom("size".into()),
389            vec![encrypted.len().to_string()],
390        ))
391        .tag(Tag::custom(
392            TagKind::Custom("blur".into()),
393            vec![blur.to_string()],
394        ))
395        .tag(Tag::custom(
396            TagKind::Custom("dim".into()),
397            vec![dim.to_string()],
398        ))
399        .custom_created_at(Timestamp::from(created_at))
400        .build(my_pk);
401
402    // SEND FIRST, commit on success. Wallpaper is a sync feature — if the
403    // recipient (and our other devices) can't see it, there's no value in
404    // locally applying it.
405    //
406    // 3 attempts with 2s spacing (~6s max wait) keeps the dialog
407    // responsive. self_send=true so other devices of ours pick it up via
408    // their own NIP-17 inbox subscription.
409    let pending_id = format!("pending-wallpaper-{}", created_at);
410    let send_config = crate::sending::SendConfig {
411        max_send_attempts: 3,
412        retry_delay: std::time::Duration::from_secs(2),
413        self_send: true,
414        ..Default::default()
415    };
416    let send_callback: Arc<dyn crate::sending::SendCallback> =
417        Arc::new(crate::sending::NoOpSendCallback);
418    if let Err(e) = crate::sending::send_rumor_dm(
419        chat_npub, &pending_id, rumor.clone(), &send_config, send_callback,
420    ).await {
421        log_warn!("[Wallpaper] send_rumor_dm to {} failed: {}", chat_npub, e);
422        return Err(format!(
423            "Couldn't send the wallpaper. Check that the relays you and your contact share are reachable, then try again. ({})",
424            e
425        ));
426    }
427    log_info!("[Wallpaper] rumor delivered to {}", chat_npub);
428
429    // Account swapped during upload/send: the rumor already went to the
430    // original recipient, but the local commit below (preview promotion,
431    // STATE, DB) would land in the new account's storage. Skip it.
432    if !session.is_valid() {
433        return Ok(());
434    }
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)?;
439    std::fs::rename(&preview, &active)
440        .map_err(|e| format!("Failed to promote preview: {}", e))?;
441    let active_str = active.to_string_lossy().to_string();
442
443    let me_npub = my_pk.to_bech32().unwrap_or_default();
444
445    // Persist to STATE + DB. Capture the previous Blossom URL + uploader
446    // under the same lock so we can clean it up only if we owned it.
447    let (slim, prev_url, prev_uploader) = {
448        let mut state = crate::state::STATE.lock().await;
449        let prev = state.get_chat(chat_npub).map(|c| {
450            (c.wallpaper_url.clone(), c.wallpaper_uploader.clone())
451        });
452        if let Some(chat) = state.get_chat_mut(chat_npub) {
453            chat.wallpaper_path = active_str.clone();
454            chat.wallpaper_ts = created_at;
455            chat.wallpaper_blur = blur;
456            chat.wallpaper_dim = dim;
457            chat.wallpaper_url = upload_url.clone();
458            chat.wallpaper_uploader = me_npub.clone();
459        }
460        let slim = state
461            .get_chat(chat_npub)
462            .map(|c| crate::db::chats::SlimChatDB::from_chat(c, &state.interner));
463        let (pu, puploader) = prev.unwrap_or_default();
464        (slim, pu, puploader)
465    };
466    if let Some(slim) = slim {
467        if let Err(e) = crate::db::chats::save_slim_chat(&slim) {
468            log_warn!("[Wallpaper] save_slim_chat failed for {}: {}", chat_npub, e);
469        }
470    }
471
472    // Fire-and-forget DELETE of the previous blob, only if we uploaded
473    // it (server's auth challenge would reject otherwise). Multi-device
474    // case: this runs on the device that does the replace, which may not
475    // be the device that uploaded the previous one — the uploader check
476    // is on the npub, not the device, so it still fires correctly.
477    if !prev_url.is_empty() && prev_uploader == me_npub {
478        let signer_clone = signer.clone();
479        let prev_url_clone = prev_url.clone();
480        tokio::spawn(async move {
481            if let Err(e) =
482                crate::blossom::delete_blob_by_url(signer_clone, &prev_url_clone).await
483            {
484                log_warn!(
485                    "[Wallpaper] DELETE prev blob {} failed: {}",
486                    prev_url_clone, e
487                );
488            }
489        });
490    }
491
492    let event_id = rumor.id.ok_or("Rumor missing id")?.to_hex();
493    let inserted = match crate::db::events::save_system_event_by_id(
494        &event_id,
495        chat_npub,
496        crate::stored_event::SystemEventType::WallpaperChanged,
497        &me_npub,
498        Some("You"),
499    )
500    .await
501    {
502        Ok(b) => b,
503        Err(e) => {
504            log_warn!("[Wallpaper] save_system_event_by_id failed for {}: {}", event_id, e);
505            false
506        }
507    };
508    if inserted {
509        crate::traits::emit_event("system_event", &serde_json::json!({
510            "conversation_id": chat_npub,
511            "event_id": event_id,
512            "event_type": crate::stored_event::SystemEventType::WallpaperChanged.as_u8(),
513            "member_pubkey": me_npub,
514            "member_name": "You",
515        }));
516    } else {
517        log_warn!("[Wallpaper] system event {} was not inserted (already exists or save failed)", event_id);
518    }
519
520    crate::traits::emit_event(
521        "wallpaper_updated",
522        &serde_json::json!({
523            "chat_id": chat_npub,
524            "path": active_str,
525            "ts": created_at,
526            "blur": blur,
527            "dim": dim,
528            "by_npub": me_npub,
529            "event_id": event_id,
530        }),
531    );
532
533    Ok(())
534}
535
536/// Apply a received wallpaper rumor. Drops the rumor if its timestamp is
537/// not newer than the chat's current `wallpaper_ts` (latest-write-wins).
538/// On a fresh rumor: downloads + decrypts the Blossom blob, caches it
539/// locally, updates STATE + DB, saves a `WallpaperChanged` system event,
540/// and emits `wallpaper_updated` to the frontend.
541#[allow(clippy::too_many_arguments)]
542pub async fn apply_received_wallpaper(
543    chat_npub: &str,
544    sender_npub: &str,
545    created_at: u64,
546    url: &str,
547    decryption_key: &str,
548    decryption_nonce: &str,
549    plaintext_hash: Option<&str>,
550    mime: Option<&str>,
551    blur: Option<u8>,
552    dim: Option<u8>,
553    rumor_event_id: &str,
554) -> Result<(), String> {
555    // Capture session NOW — the download below can take seconds, and a
556    // mid-fetch account swap must not let us write account A's wallpaper
557    // into account B's STATE/DB (re-checked before every write below).
558    let session = crate::state::SessionGuard::capture();
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)?;
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 = http
613        .get(url)
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 resp = resp;
628    let mut bytes: Vec<u8> = Vec::new();
629    while let Some(chunk) = resp
630        .chunk()
631        .await
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(&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    // to a session that may no longer be active. Bail before any write.
652    if !session.is_valid() {
653        return Ok(());
654    }
655
656    let active = wallpapers_dir()?.join(format!("{}.{}", chat_npub, extension));
657    clean_chat_files(chat_npub, FileKind::Active, None)?;
658    let tmp = active.with_file_name(format!("{}.{}.tmp", chat_npub, extension));
659    std::fs::write(&tmp, &plaintext).map_err(|e| format!("Write wallpaper: {}", e))?;
660    std::fs::rename(&tmp, &active).map_err(|e| format!("Commit wallpaper: {}", e))?;
661    let active_str = active.to_string_lossy().to_string();
662
663    // Capture previous URL + uploader under the same lock — only DELETE
664    // the prior blob if WE were the uploader (covers multi-device sync
665    // where a different device of ours uploaded the previous wallpaper).
666    let (slim, prev_url, prev_uploader) = {
667        let mut state = crate::state::STATE.lock().await;
668        let prev = state.get_chat(chat_npub).map(|c| {
669            (c.wallpaper_url.clone(), c.wallpaper_uploader.clone())
670        });
671        if let Some(chat) = state.get_chat_mut(chat_npub) {
672            chat.wallpaper_path = active_str.clone();
673            chat.wallpaper_ts = created_at;
674            chat.wallpaper_blur = blur;
675            chat.wallpaper_dim = dim;
676            chat.wallpaper_url = url.to_string();
677            chat.wallpaper_uploader = sender_npub.to_string();
678        }
679        let slim = state
680            .get_chat(chat_npub)
681            .map(|c| crate::db::chats::SlimChatDB::from_chat(c, &state.interner));
682        let (pu, puploader) = prev.unwrap_or_default();
683        (slim, pu, puploader)
684    };
685    if let Some(slim) = slim {
686        let _ = crate::db::chats::save_slim_chat(&slim);
687    }
688
689    if !prev_url.is_empty() {
690        let me_npub = crate::state::my_public_key()
691            .and_then(|pk| pk.to_bech32().ok())
692            .unwrap_or_default();
693        if !me_npub.is_empty() && prev_uploader == me_npub {
694            if let Some(client) = crate::state::nostr_client() {
695                if let Ok(signer) = client.signer().await {
696                    let prev_url_clone = prev_url.clone();
697                    tokio::spawn(async move {
698                        if let Err(e) =
699                            crate::blossom::delete_blob_by_url(signer, &prev_url_clone).await
700                        {
701                            log_warn!(
702                                "[Wallpaper] DELETE prev blob {} failed: {}",
703                                prev_url_clone, e
704                            );
705                        }
706                    });
707                }
708            }
709        }
710    }
711
712    // Resolve a display name for the system event. Fall back to the npub
713    // (truncated by the frontend's formatter) when we don't know the peer
714    // yet — the row still tells the user what happened.
715    let sender_display = {
716        let state = crate::state::STATE.lock().await;
717        state
718            .get_profile(sender_npub)
719            .and_then(|p| {
720                if !p.nickname.is_empty() {
721                    Some(p.nickname.to_string())
722                } else if !p.name.is_empty() {
723                    Some(p.name.to_string())
724                } else {
725                    None
726                }
727            })
728            .unwrap_or_else(|| sender_npub.to_string())
729    };
730    let inserted = crate::db::events::save_system_event_by_id(
731        rumor_event_id,
732        chat_npub,
733        crate::stored_event::SystemEventType::WallpaperChanged,
734        sender_npub,
735        Some(&sender_display),
736    )
737    .await
738    .unwrap_or(false);
739    if inserted {
740        crate::traits::emit_event("system_event", &serde_json::json!({
741            "conversation_id": chat_npub,
742            "event_id": rumor_event_id,
743            "event_type": crate::stored_event::SystemEventType::WallpaperChanged.as_u8(),
744            "member_pubkey": sender_npub,
745            "member_name": sender_display,
746        }));
747    }
748
749    crate::traits::emit_event(
750        "wallpaper_updated",
751        &serde_json::json!({
752            "chat_id": chat_npub,
753            "path": active_str,
754            "ts": created_at,
755            "blur": blur,
756            "dim": dim,
757            "by_npub": sender_npub,
758            "event_id": rumor_event_id,
759        }),
760    );
761
762    Ok(())
763}
764
765/// Fire-and-forget DELETE of a prior Blossom blob, but only if WE uploaded
766/// it (the server's auth challenge rejects deletes from anyone else). The
767/// uploader check is on the npub, so multi-device replaces still fire.
768async fn delete_prior_blob_if_ours(prev_url: &str, prev_uploader: &str) {
769    if prev_url.is_empty() {
770        return;
771    }
772    let me_npub = crate::state::my_public_key()
773        .and_then(|pk| pk.to_bech32().ok())
774        .unwrap_or_default();
775    if me_npub.is_empty() || prev_uploader != me_npub {
776        return;
777    }
778    if let Some(client) = crate::state::nostr_client() {
779        if let Ok(signer) = client.signer().await {
780            let prev_url = prev_url.to_string();
781            tokio::spawn(async move {
782                if let Err(e) = crate::blossom::delete_blob_by_url(signer, &prev_url).await {
783                    log_warn!("[Wallpaper] DELETE prev blob {} failed: {}", prev_url, e);
784                }
785            });
786        }
787    }
788}
789
790/// Save the WallpaperChanged system event for a removal + emit the frontend
791/// events that revert the chat to the default theme.
792async fn emit_wallpaper_removed(
793    chat_npub: &str,
794    by_npub: &str,
795    created_at: u64,
796    event_id: &str,
797) {
798    let me_npub = crate::state::my_public_key()
799        .and_then(|pk| pk.to_bech32().ok())
800        .unwrap_or_default();
801    let display = if by_npub == me_npub {
802        "You".to_string()
803    } else {
804        let state = crate::state::STATE.lock().await;
805        state
806            .get_profile(by_npub)
807            .and_then(|p| {
808                if !p.nickname.is_empty() {
809                    Some(p.nickname.to_string())
810                } else if !p.name.is_empty() {
811                    Some(p.name.to_string())
812                } else {
813                    None
814                }
815            })
816            .unwrap_or_else(|| by_npub.to_string())
817    };
818    let inserted = crate::db::events::save_system_event_by_id(
819        event_id,
820        chat_npub,
821        crate::stored_event::SystemEventType::WallpaperRemoved,
822        by_npub,
823        Some(&display),
824    )
825    .await
826    .unwrap_or(false);
827    if inserted {
828        crate::traits::emit_event("system_event", &serde_json::json!({
829            "conversation_id": chat_npub,
830            "event_id": event_id,
831            "event_type": crate::stored_event::SystemEventType::WallpaperRemoved.as_u8(),
832            "member_pubkey": by_npub,
833            "member_name": display,
834        }));
835    }
836    crate::traits::emit_event(
837        "wallpaper_updated",
838        &serde_json::json!({
839            "chat_id": chat_npub,
840            "path": "",
841            "ts": created_at,
842            "blur": 0,
843            "dim": 50,
844            "by_npub": by_npub,
845            "event_id": event_id,
846        }),
847    );
848}
849
850/// Remove the chat's wallpaper, reverting both sides to the default theme.
851/// Publishes a kind-30078 `vector-wallpaper` tombstone (no `url` tag) so the
852/// recipient and our other devices clear it too (latest-write-wins by
853/// `created_at`), then DELETEs our blob and wipes local STATE/DB.
854pub async fn remove_wallpaper(chat_npub: &str) -> Result<(), String> {
855    let session = crate::state::SessionGuard::capture();
856
857    let my_pk = crate::state::my_public_key().ok_or("Public key not set")?;
858    let recipient_pk = PublicKey::from_bech32(chat_npub)
859        .map_err(|e| format!("Invalid chat npub: {}", e))?;
860
861    let created_at = std::time::SystemTime::now()
862        .duration_since(std::time::UNIX_EPOCH)
863        .unwrap()
864        .as_secs();
865    // Tombstone: same d-tag + recipient p-tag as a set, but no url/key/nonce.
866    let rumor = EventBuilder::new(Kind::Custom(event_kind::APPLICATION_SPECIFIC), "")
867        .tag(Tag::identifier(WALLPAPER_DTAG_VALUE))
868        .tag(Tag::public_key(recipient_pk))
869        .custom_created_at(Timestamp::from(created_at))
870        .build(my_pk);
871
872    let pending_id = format!("pending-wallpaper-rm-{}", created_at);
873    let send_config = crate::sending::SendConfig {
874        max_send_attempts: 3,
875        retry_delay: std::time::Duration::from_secs(2),
876        self_send: true,
877        ..Default::default()
878    };
879    let send_callback: Arc<dyn crate::sending::SendCallback> =
880        Arc::new(crate::sending::NoOpSendCallback);
881    if let Err(e) = crate::sending::send_rumor_dm(
882        chat_npub, &pending_id, rumor.clone(), &send_config, send_callback,
883    ).await {
884        log_warn!("[Wallpaper] removal send to {} failed: {}", chat_npub, e);
885        return Err(format!(
886            "Couldn't remove the wallpaper. Check that the relays you and your contact share are reachable, then try again. ({})",
887            e
888        ));
889    }
890
891    // Account swapped mid-send — the tombstone already went out, but the
892    // local commit below would land in the new account's storage. Skip it.
893    if !session.is_valid() {
894        return Ok(());
895    }
896
897    clean_chat_files(chat_npub, FileKind::Active, None)?;
898    let me_npub = my_pk.to_bech32().unwrap_or_default();
899    let (slim, prev_url, prev_uploader) = {
900        let mut state = crate::state::STATE.lock().await;
901        let prev = state.get_chat(chat_npub).map(|c| {
902            (c.wallpaper_url.clone(), c.wallpaper_uploader.clone())
903        });
904        if let Some(chat) = state.get_chat_mut(chat_npub) {
905            chat.wallpaper_path = String::new();
906            chat.wallpaper_url = String::new();
907            chat.wallpaper_uploader = String::new();
908            chat.wallpaper_ts = created_at;
909        }
910        let slim = state
911            .get_chat(chat_npub)
912            .map(|c| crate::db::chats::SlimChatDB::from_chat(c, &state.interner));
913        let (pu, puploader) = prev.unwrap_or_default();
914        (slim, pu, puploader)
915    };
916    if let Some(slim) = slim {
917        if let Err(e) = crate::db::chats::save_slim_chat(&slim) {
918            log_warn!("[Wallpaper] save_slim_chat (removal) failed for {}: {}", chat_npub, e);
919        }
920    }
921
922    delete_prior_blob_if_ours(&prev_url, &prev_uploader).await;
923
924    let event_id = rumor.id.ok_or("Rumor missing id")?.to_hex();
925    emit_wallpaper_removed(chat_npub, &me_npub, created_at, &event_id).await;
926
927    Ok(())
928}