vector_core/self_destruct.rs
1//! Self-Destruct Timer — per-chat NIP-40 message expiry ("disappearing
2//! messages"). The per-chat lifespan is a DURATION stored in the account
3//! settings KV; each outgoing DM in that chat is stamped with an absolute
4//! NIP-40 expiry so relays drop the gift-wrap and every compliant client
5//! purges its local copy on schedule. Purge is local-only per client — the
6//! expiry tag travels with the message, so no delete broadcast is needed.
7
8use std::sync::atomic::{AtomicBool, Ordering};
9
10const KEY_PREFIX: &str = "self_destruct:";
11
12/// Longest the sweeper sleeps when nothing is near expiry — bounds how quickly
13/// a newly-arrived self-destruct message is first noticed (it's then scheduled
14/// precisely). Kept below the shortest offered timer (10s) so even a fresh
15/// short-fused message is always seen before it expires. Near an expiry the
16/// loop sleeps exactly until it, down to a 1s floor.
17const SWEEP_MAX_SECS: u64 = 8;
18
19/// Configured self-destruct DURATION in seconds for a chat, or `None` when the
20/// chat keeps messages permanently. Stored per-account, so it naturally follows
21/// the active account's DB pool.
22pub fn chat_duration_secs(chat_id: &str) -> Option<u64> {
23 crate::db::settings::get_sql_setting(format!("{KEY_PREFIX}{chat_id}"))
24 .ok()
25 .flatten()
26 .and_then(|v| v.parse::<u64>().ok())
27 .filter(|d| *d > 0)
28}
29
30/// Set the self-destruct duration for a chat. `None` (or 0) clears it back to
31/// permanent by removing the key.
32pub fn set_chat_duration_secs(chat_id: &str, secs: Option<u64>) -> Result<(), String> {
33 let key = format!("{KEY_PREFIX}{chat_id}");
34 match secs {
35 Some(d) if d > 0 => crate::db::settings::set_sql_setting(key, d.to_string()),
36 _ => crate::db::settings::remove_setting(&key),
37 }
38}
39
40/// Resolve the absolute NIP-40 expiry (unix seconds) to stamp on a NEW message
41/// sent to `chat_id`, honoring the chat's configured lifespan. `None` when the
42/// chat is permanent.
43pub fn resolve_send_expiry(chat_id: &str) -> Option<u64> {
44 let duration = chat_duration_secs(chat_id)?;
45 let now = std::time::SystemTime::now()
46 .duration_since(std::time::UNIX_EPOCH)
47 .ok()?
48 .as_secs();
49 Some(now + duration)
50}
51
52/// Reset the in-flight flag whatever exit `sweep_expired` takes.
53struct SweepGuard;
54impl Drop for SweepGuard {
55 fn drop(&mut self) {
56 SWEEP_RUNNING.store(false, Ordering::Release);
57 }
58}
59static SWEEP_RUNNING: AtomicBool = AtomicBool::new(false);
60
61/// Purge every message whose NIP-40 expiry has passed: drop it from STATE and
62/// the DB, remove cached attachment files no sibling still needs, and — for OUR
63/// OWN file messages — issue a Blossom blob delete (blobs carry no self-expiry).
64/// Emits `message_removed` with reason "self-destruct" per purged row so the UI
65/// can derez it.
66///
67/// Local-only: every client honors the same NIP-40 tag independently, so no
68/// delete is broadcast. Safe to call repeatedly (a ticker + a boot catch-up).
69pub async fn sweep_expired() -> Option<u64> {
70 if SWEEP_RUNNING.swap(true, Ordering::AcqRel) {
71 return None; // a sweep is already in flight
72 }
73 let _guard = SweepGuard;
74
75 // Snapshot the session so a mid-sweep account swap can't purge account A's
76 // rows against account B's DB (see SessionGuard contract).
77 let session = crate::state::SessionGuard::capture();
78
79 let now = match std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH) {
80 Ok(d) => d.as_secs(),
81 Err(_) => return None,
82 };
83
84 // Pass 1 — scan under the lock: collect the expired, and track the soonest
85 // still-pending expiry so the loop can time the next sweep to the second.
86 let mut soonest: Option<u64> = None;
87 let expired_ids: Vec<String> = {
88 let state = crate::state::STATE.lock().await;
89 let mut ids = Vec::new();
90 for chat in &state.chats {
91 for msg in chat.messages.iter() {
92 let exp = msg.expiration_secs;
93 if exp == 0 {
94 continue;
95 }
96 let exp = exp as u64;
97 if exp <= now {
98 ids.push(msg.id_hex());
99 } else {
100 soonest = Some(soonest.map_or(exp, |s| s.min(exp)));
101 }
102 }
103 }
104 ids
105 };
106 if !session.is_valid() {
107 return None;
108 }
109 if expired_ids.is_empty() {
110 return soonest;
111 }
112
113 let client = crate::state::nostr_client();
114
115 // Pass 2 — purge each. Re-lock per id so the sweep never holds STATE across
116 // an await (DB delete, blob delete).
117 for id in expired_ids {
118 let removed = {
119 let mut state = crate::state::STATE.lock().await;
120 state.remove_message(&id)
121 };
122 let (chat_id, msg) = match removed {
123 Some(pair) => pair,
124 None => continue, // already gone (client-side derez or a prior sweep)
125 };
126
127 if !msg.attachments.is_empty() {
128 let mine = msg.mine;
129 // Refcount filter: keep files/blobs a sibling message still points at.
130 let unique = crate::deletion::filter_unreferenced_attachments(&id, msg.attachments).await;
131 crate::deletion::delete_cached_attachment_files_pub(&unique);
132
133 // Our own, now-unreferenced blob → wipe it network-side too.
134 if mine {
135 let urls: Vec<String> = unique
136 .iter()
137 .map(|a| a.url.to_string())
138 .filter(|u| !u.is_empty())
139 .collect();
140 if !urls.is_empty() {
141 if let Some(ref client) = client {
142 if let Ok(signer) = client.signer().await {
143 crate::blossom::delete_blobs_best_effort(signer, urls);
144 }
145 }
146 }
147 }
148 }
149
150 let _ = crate::db::events::delete_event(&id).await;
151
152 crate::traits::emit_event(
153 "message_removed",
154 &serde_json::json!({ "id": id, "chat_id": chat_id, "reason": "self-destruct" }),
155 );
156 }
157
158 soonest
159}
160
161/// Time until the next sweep: sleep exactly until the soonest pending expiry
162/// (down to a 1s floor) so the final stretch purges in real time, but never
163/// longer than SWEEP_MAX_SECS so a newly-arrived message is noticed promptly.
164fn next_sweep_delay(soonest: Option<u64>) -> std::time::Duration {
165 let now = std::time::SystemTime::now()
166 .duration_since(std::time::UNIX_EPOCH)
167 .map(|d| d.as_secs())
168 .unwrap_or(0);
169 let secs = match soonest {
170 Some(exp) => exp.saturating_sub(now).clamp(1, SWEEP_MAX_SECS),
171 None => SWEEP_MAX_SECS,
172 };
173 std::time::Duration::from_secs(secs)
174}
175
176/// The self-destruct sweep loop: purge, then sleep until the next expiry is due
177/// (adaptive — 1s-tight near a deadline, up to SWEEP_MAX_SECS when idle). The
178/// immediate first pass catches anything that expired while offline. Hosts with
179/// their own async runtime (e.g. Tauri) should spawn this directly.
180pub async fn run_sweeper_loop() {
181 loop {
182 let soonest = sweep_expired().await;
183 tokio::time::sleep(next_sweep_delay(soonest)).await;
184 }
185}
186
187/// Convenience for tokio-native hosts (CLI/SDK): spawn `run_sweeper_loop` once.
188/// Idempotent — a second call is a no-op.
189pub fn start_sweeper() {
190 static STARTED: AtomicBool = AtomicBool::new(false);
191 if STARTED.swap(true, Ordering::AcqRel) {
192 return;
193 }
194 tokio::spawn(run_sweeper_loop());
195}