@@ -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+ }
0 commit comments