Skip to content

Commit f7bd124

Browse files
JSKittyclaude
andcommitted
feat: redesign wallpaper edit mode + honor Background Wallpaper toggle
Reworks the wallpaper preview into a profile-style Cancel/Save header overlay with icon-only blur/brightness sliders (voice-preview styling) and a trash button that discards edits. Adds wallpaper removal: a new remove_wallpaper command publishes a kind-30078 tombstone that clears the wallpaper on both sides (new WallpaperRemoved system event), reached from a "Remove Wallpaper" chat-menu item with a confirm prompt. User wallpapers now respect the global Background Wallpaper display toggle (previews stay live; committed wallpapers hide when off, with an info prompt). Fixes blur(0px) washing the layer white by building the filter inline and omitting blur at zero. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 7ee5a0b commit f7bd124

12 files changed

Lines changed: 474 additions & 150 deletions

File tree

crates/vector-core/src/rumor.rs

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -713,20 +713,24 @@ fn process_app_specific(
713713
// ref; the caller decides whether this beats the chat's current
714714
// `wallpaper_ts` and runs the download+decrypt step.
715715
if is_wallpaper_change(&rumor) {
716+
// A wallpaper rumor with no `url` is a removal tombstone — the sender
717+
// cleared their wallpaper. The url/key/nonce are absent in that case,
718+
// so they're optional here; the apply step treats an empty url as
719+
// "revert to default theme".
716720
let url = rumor.tags
717721
.find(TagKind::Custom(Cow::Borrowed("url")))
718722
.and_then(|tag| tag.content())
719-
.ok_or("Wallpaper rumor missing url tag")?
723+
.unwrap_or_default()
720724
.to_string();
721725
let decryption_key = rumor.tags
722726
.find(TagKind::Custom(Cow::Borrowed("decryption-key")))
723727
.and_then(|tag| tag.content())
724-
.ok_or("Wallpaper rumor missing decryption-key tag")?
728+
.unwrap_or_default()
725729
.to_string();
726730
let decryption_nonce = rumor.tags
727731
.find(TagKind::Custom(Cow::Borrowed("decryption-nonce")))
728732
.and_then(|tag| tag.content())
729-
.ok_or("Wallpaper rumor missing decryption-nonce tag")?
733+
.unwrap_or_default()
730734
.to_string();
731735
let plaintext_hash = rumor.tags
732736
.find(TagKind::Custom(Cow::Borrowed("x")))

crates/vector-core/src/stored_event.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ pub enum SystemEventType {
7373
MemberJoined = 1,
7474
MemberRemoved = 2,
7575
WallpaperChanged = 3,
76+
WallpaperRemoved = 4,
7677
}
7778

7879
impl SystemEventType {
@@ -82,6 +83,7 @@ impl SystemEventType {
8283
SystemEventType::MemberJoined => format!("{} has joined", display_name),
8384
SystemEventType::MemberRemoved => format!("{} was removed", display_name),
8485
SystemEventType::WallpaperChanged => format!("{} changed the wallpaper", display_name),
86+
SystemEventType::WallpaperRemoved => format!("{} removed the wallpaper", display_name),
8587
}
8688
}
8789

crates/vector-core/src/wallpaper.rs

Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -568,6 +568,37 @@ pub async fn apply_received_wallpaper(
568568
}
569569
}
570570

571+
// Removal tombstone — sender cleared their wallpaper. No blob to fetch;
572+
// wipe the local active file + STATE/DB so the default theme returns.
573+
if url.is_empty() {
574+
clean_chat_files(chat_npub, FileKind::Active, None)?;
575+
let (slim, prev_url, prev_uploader) = {
576+
let mut state = crate::state::STATE.lock().await;
577+
let prev = state.get_chat(chat_npub).map(|c| {
578+
(c.wallpaper_url.clone(), c.wallpaper_uploader.clone())
579+
});
580+
if let Some(chat) = state.get_chat_mut(chat_npub) {
581+
chat.wallpaper_path = String::new();
582+
chat.wallpaper_url = String::new();
583+
chat.wallpaper_uploader = String::new();
584+
chat.wallpaper_ts = created_at;
585+
chat.wallpaper_blur = blur;
586+
chat.wallpaper_dim = dim;
587+
}
588+
let slim = state
589+
.get_chat(chat_npub)
590+
.map(|c| crate::db::chats::SlimChatDB::from_chat(c, &state.interner));
591+
let (pu, puploader) = prev.unwrap_or_default();
592+
(slim, pu, puploader)
593+
};
594+
if let Some(slim) = slim {
595+
let _ = crate::db::chats::save_slim_chat(&slim);
596+
}
597+
delete_prior_blob_if_ours(&prev_url, &prev_uploader).await;
598+
emit_wallpaper_removed(chat_npub, sender_npub, created_at, rumor_event_id).await;
599+
return Ok(());
600+
}
601+
571602
let mime_str = mime.unwrap_or("image/png").to_string();
572603
let extension = crypto::extension_from_mime(&mime_str);
573604

@@ -728,3 +759,168 @@ pub async fn apply_received_wallpaper(
728759

729760
Ok(())
730761
}
762+
763+
/// Fire-and-forget DELETE of a prior Blossom blob, but only if WE uploaded
764+
/// it (the server's auth challenge rejects deletes from anyone else). The
765+
/// uploader check is on the npub, so multi-device replaces still fire.
766+
async fn delete_prior_blob_if_ours(prev_url: &str, prev_uploader: &str) {
767+
if prev_url.is_empty() {
768+
return;
769+
}
770+
let me_npub = crate::state::my_public_key()
771+
.and_then(|pk| pk.to_bech32().ok())
772+
.unwrap_or_default();
773+
if me_npub.is_empty() || prev_uploader != me_npub {
774+
return;
775+
}
776+
if let Some(client) = crate::state::nostr_client() {
777+
if let Ok(signer) = client.signer().await {
778+
let prev_url = prev_url.to_string();
779+
tokio::spawn(async move {
780+
if let Err(e) = crate::blossom::delete_blob_by_url(signer, &prev_url).await {
781+
log_warn!("[Wallpaper] DELETE prev blob {} failed: {}", prev_url, e);
782+
}
783+
});
784+
}
785+
}
786+
}
787+
788+
/// Save the WallpaperChanged system event for a removal + emit the frontend
789+
/// events that revert the chat to the default theme.
790+
async fn emit_wallpaper_removed(
791+
chat_npub: &str,
792+
by_npub: &str,
793+
created_at: u64,
794+
event_id: &str,
795+
) {
796+
let me_npub = crate::state::my_public_key()
797+
.and_then(|pk| pk.to_bech32().ok())
798+
.unwrap_or_default();
799+
let display = if by_npub == me_npub {
800+
"You".to_string()
801+
} else {
802+
let state = crate::state::STATE.lock().await;
803+
state
804+
.get_profile(by_npub)
805+
.and_then(|p| {
806+
if !p.nickname.is_empty() {
807+
Some(p.nickname.to_string())
808+
} else if !p.name.is_empty() {
809+
Some(p.name.to_string())
810+
} else {
811+
None
812+
}
813+
})
814+
.unwrap_or_else(|| by_npub.to_string())
815+
};
816+
let inserted = crate::db::events::save_system_event_by_id(
817+
event_id,
818+
chat_npub,
819+
crate::stored_event::SystemEventType::WallpaperRemoved,
820+
by_npub,
821+
Some(&display),
822+
)
823+
.await
824+
.unwrap_or(false);
825+
if inserted {
826+
crate::traits::emit_event("system_event", &serde_json::json!({
827+
"conversation_id": chat_npub,
828+
"event_id": event_id,
829+
"event_type": crate::stored_event::SystemEventType::WallpaperRemoved.as_u8(),
830+
"member_pubkey": by_npub,
831+
"member_name": display,
832+
}));
833+
}
834+
crate::traits::emit_event(
835+
"wallpaper_updated",
836+
&serde_json::json!({
837+
"chat_id": chat_npub,
838+
"path": "",
839+
"ts": created_at,
840+
"blur": 0,
841+
"dim": 50,
842+
"by_npub": by_npub,
843+
"event_id": event_id,
844+
}),
845+
);
846+
}
847+
848+
/// Remove the chat's wallpaper, reverting both sides to the default theme.
849+
/// Publishes a kind-30078 `vector-wallpaper` tombstone (no `url` tag) so the
850+
/// recipient and our other devices clear it too (latest-write-wins by
851+
/// `created_at`), then DELETEs our blob and wipes local STATE/DB.
852+
pub async fn remove_wallpaper(chat_npub: &str) -> Result<(), String> {
853+
let session = crate::state::SessionGuard::capture();
854+
855+
let my_pk = crate::state::my_public_key().ok_or("Public key not set")?;
856+
let recipient_pk = PublicKey::from_bech32(chat_npub)
857+
.map_err(|e| format!("Invalid chat npub: {}", e))?;
858+
859+
let created_at = std::time::SystemTime::now()
860+
.duration_since(std::time::UNIX_EPOCH)
861+
.unwrap()
862+
.as_secs();
863+
// Tombstone: same d-tag + recipient p-tag as a set, but no url/key/nonce.
864+
let rumor = EventBuilder::new(Kind::Custom(event_kind::APPLICATION_SPECIFIC), "")
865+
.tag(Tag::identifier(WALLPAPER_DTAG_VALUE))
866+
.tag(Tag::public_key(recipient_pk))
867+
.custom_created_at(Timestamp::from(created_at))
868+
.build(my_pk);
869+
870+
let pending_id = format!("pending-wallpaper-rm-{}", created_at);
871+
let send_config = crate::sending::SendConfig {
872+
max_send_attempts: 3,
873+
retry_delay: std::time::Duration::from_secs(2),
874+
self_send: true,
875+
..Default::default()
876+
};
877+
let send_callback: Arc<dyn crate::sending::SendCallback> =
878+
Arc::new(crate::sending::NoOpSendCallback);
879+
if let Err(e) = crate::sending::send_rumor_dm(
880+
chat_npub, &pending_id, rumor.clone(), &send_config, send_callback,
881+
).await {
882+
log_warn!("[Wallpaper] removal send to {} failed: {}", chat_npub, e);
883+
return Err(format!(
884+
"Couldn't remove the wallpaper. Check that the relays you and your contact share are reachable, then try again. ({})",
885+
e
886+
));
887+
}
888+
889+
// Account swapped mid-send — the tombstone already went out, but the
890+
// local commit below would land in the new account's storage. Skip it.
891+
if !session.is_valid() {
892+
return Ok(());
893+
}
894+
895+
clean_chat_files(chat_npub, FileKind::Active, None)?;
896+
let me_npub = my_pk.to_bech32().unwrap_or_default();
897+
let (slim, prev_url, prev_uploader) = {
898+
let mut state = crate::state::STATE.lock().await;
899+
let prev = state.get_chat(chat_npub).map(|c| {
900+
(c.wallpaper_url.clone(), c.wallpaper_uploader.clone())
901+
});
902+
if let Some(chat) = state.get_chat_mut(chat_npub) {
903+
chat.wallpaper_path = String::new();
904+
chat.wallpaper_url = String::new();
905+
chat.wallpaper_uploader = String::new();
906+
chat.wallpaper_ts = created_at;
907+
}
908+
let slim = state
909+
.get_chat(chat_npub)
910+
.map(|c| crate::db::chats::SlimChatDB::from_chat(c, &state.interner));
911+
let (pu, puploader) = prev.unwrap_or_default();
912+
(slim, pu, puploader)
913+
};
914+
if let Some(slim) = slim {
915+
if let Err(e) = crate::db::chats::save_slim_chat(&slim) {
916+
log_warn!("[Wallpaper] save_slim_chat (removal) failed for {}: {}", chat_npub, e);
917+
}
918+
}
919+
920+
delete_prior_blob_if_ours(&prev_url, &prev_uploader).await;
921+
922+
let event_id = rumor.id.ok_or("Rumor missing id")?.to_hex();
923+
emit_wallpaper_removed(chat_npub, &me_npub, created_at, &event_id).await;
924+
925+
Ok(())
926+
}

src-tauri/capabilities/default.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,7 @@
101101
"allow-preview-wallpaper",
102102
"allow-publish-wallpaper",
103103
"allow-cancel-wallpaper-preview",
104+
"allow-remove-wallpaper",
104105
"allow-setup-encryption",
105106
"allow-skip-encryption",
106107
"allow-notifs",
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
# Automatically generated - DO NOT EDIT!
2+
3+
[[permission]]
4+
identifier = "allow-remove-wallpaper"
5+
description = "Enables the remove_wallpaper command without any pre-configured scope."
6+
commands.allow = ["remove_wallpaper"]
7+
8+
[[permission]]
9+
identifier = "deny-remove-wallpaper"
10+
description = "Denies the remove_wallpaper command without any pre-configured scope."
11+
commands.deny = ["remove_wallpaper"]

src-tauri/src/commands/wallpaper.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,3 +65,11 @@ pub async fn publish_wallpaper(
6565
pub async fn cancel_wallpaper_preview(chat_id: String) -> Result<(), String> {
6666
wallpaper::cancel_wallpaper_preview(&chat_id)
6767
}
68+
69+
/// Remove the chat's wallpaper, reverting both sides to the default theme.
70+
/// Publishes a removal tombstone so the recipient + our other devices clear
71+
/// it too, then DELETEs our blob and wipes local state.
72+
#[tauri::command]
73+
pub async fn remove_wallpaper(chat_id: String) -> Result<(), String> {
74+
wallpaper::remove_wallpaper(&chat_id).await
75+
}

src-tauri/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -725,6 +725,7 @@ pub fn run() {
725725
commands::wallpaper::preview_wallpaper,
726726
commands::wallpaper::publish_wallpaper,
727727
commands::wallpaper::cancel_wallpaper_preview,
728+
commands::wallpaper::remove_wallpaper,
728729
#[cfg(debug_assertions)]
729730
commands::account::debug_hot_reload_sync,
730731
commands::account::logout,

src/icons/bulb.svg

Lines changed: 3 additions & 0 deletions
Loading

src/index.html

Lines changed: 20 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -940,6 +940,19 @@ <h3 id="chat-contact" class="cutoff chat-contact-with-status btn"></h3>
940940
<div id="chat-menu-btn" class="btn nav-menu-btn" title="Chat options">
941941
<span class="icon icon-dots-horizontal nav-icon"></span>
942942
</div>
943+
<!-- Wallpaper edit-mode overlay — mirrors the Profile edit bar,
944+
sits over the chat header while a wallpaper preview is staged. -->
945+
<div id="wallpaper-edit-bar" style="display: none;">
946+
<div id="wallpaper-edit-cancel-btn">
947+
<span class="icon icon-edit-x"></span>
948+
<span>Cancel</span>
949+
</div>
950+
<span id="wallpaper-edit-mode-label">Edit Mode is enabled.</span>
951+
<div id="wallpaper-edit-save-btn">
952+
<span class="icon icon-save"></span>
953+
<span>Save</span>
954+
</div>
955+
</div>
943956
</div>
944957
<div id="msg-top-fade" class="fadeout-top-msgs" style="top: 60px;"></div>
945958
<div id="chat-messages" class="chat-messages">
@@ -952,21 +965,18 @@ <h3 id="chat-contact" class="cutoff chat-contact-with-status btn"></h3>
952965
tunes the blur + brightness sliders. -->
953966
<div id="wallpaper-preview-bar" class="wallpaper-preview-bar" style="display: none;">
954967
<div class="wallpaper-preview-sliders">
955-
<label class="wallpaper-slider">
956-
<span class="wallpaper-slider-label">Blur</span>
968+
<label class="wallpaper-slider" title="Blur">
969+
<span class="icon icon-eye-off wallpaper-slider-icon"></span>
957970
<input type="range" id="wallpaper-blur-slider" min="0" max="30" step="1" value="0">
958-
<span class="wallpaper-slider-value" id="wallpaper-blur-value">0</span>
959971
</label>
960-
<label class="wallpaper-slider">
961-
<span class="wallpaper-slider-label">Brightness</span>
972+
<label class="wallpaper-slider" title="Brightness">
973+
<span class="icon icon-bulb wallpaper-slider-icon"></span>
962974
<input type="range" id="wallpaper-dim-slider" min="10" max="100" step="1" value="50">
963-
<span class="wallpaper-slider-value" id="wallpaper-dim-value">50</span>
964975
</label>
965976
</div>
966-
<div class="wallpaper-preview-actions">
967-
<button id="wallpaper-preview-cancel" class="wallpaper-preview-btn wallpaper-preview-btn-cancel">Cancel</button>
968-
<button id="wallpaper-preview-confirm" class="wallpaper-preview-btn wallpaper-preview-btn-confirm">Set Wallpaper</button>
969-
</div>
977+
<button id="wallpaper-remove-btn" class="wallpaper-remove-btn" title="Discard edits">
978+
<span class="icon icon-trash"></span>
979+
</button>
970980
</div>
971981

972982
<div class="row input-box" id="chat-box">

src/js/settings.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2429,6 +2429,8 @@ async function initSettings() {
24292429
document.body.classList.add('chat-bg-disabled');
24302430
await saveChatBgEnabled(false);
24312431
}
2432+
// Re-evaluate the open chat's wallpaper against the new toggle state.
2433+
refreshChatWallpaper();
24322434
});
24332435
}
24342436

0 commit comments

Comments
 (0)