Skip to main content

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    expiry_after(chat_duration_secs(chat_id)?)
45}
46
47/// The absolute NIP-40 expiry for a lifespan that starts now. A file send calls this
48/// after its upload, so the clock starts at publish, not at the moment the send was asked
49/// for.
50pub fn expiry_after(duration_secs: u64) -> Option<u64> {
51    let now = web_time::SystemTime::now()
52        .duration_since(web_time::UNIX_EPOCH)
53        .ok()?
54        .as_secs();
55    Some(now + duration_secs)
56}
57
58/// Reset the in-flight flag whatever exit `sweep_expired` takes.
59struct SweepGuard;
60impl Drop for SweepGuard {
61    fn drop(&mut self) {
62        SWEEP_RUNNING.store(false, Ordering::Release);
63    }
64}
65static SWEEP_RUNNING: AtomicBool = AtomicBool::new(false);
66
67/// Purge every message whose NIP-40 expiry has passed: drop it from STATE and
68/// the DB, remove cached attachment files no sibling still needs, and — for OUR
69/// OWN file messages — issue a Blossom blob delete (blobs carry no self-expiry).
70/// Emits `message_removed` with reason "self-destruct" per purged row so the UI
71/// can derez it.
72///
73/// Local-only: every client honors the same NIP-40 tag independently, so no
74/// delete is broadcast. Safe to call repeatedly (a ticker + a boot catch-up).
75pub async fn sweep_expired() -> Option<u64> {
76    if SWEEP_RUNNING.swap(true, Ordering::AcqRel) {
77        return None; // a sweep is already in flight
78    }
79    let _guard = SweepGuard;
80
81    let now = match web_time::SystemTime::now().duration_since(web_time::UNIX_EPOCH) {
82        Ok(d) => d.as_secs(),
83        Err(_) => return None,
84    };
85
86    // Pass 1 — scan under the lock: collect the expired, and track the soonest
87    // still-pending expiry so the loop can time the next sweep to the second.
88    let mut soonest: Option<u64> = None;
89    let expired_ids: Vec<String> = {
90        let state = crate::state::STATE.lock().await;
91        let mut ids = Vec::new();
92        for chat in &state.chats {
93            for msg in chat.messages.iter() {
94                let exp = msg.expiration_secs;
95                if exp == 0 {
96                    continue;
97                }
98                let exp = exp as u64;
99                if exp <= now {
100                    ids.push(msg.id_hex());
101                } else {
102                    soonest = Some(soonest.map_or(exp, |s| s.min(exp)));
103                }
104            }
105        }
106        ids
107    };
108    if expired_ids.is_empty() {
109        return soonest;
110    }
111
112    // Pass 2 — purge each. Re-lock per id so the sweep never holds STATE across
113    // an await (DB delete, blob delete).
114    for id in expired_ids {
115        purge_one(&id, None).await;
116    }
117
118    soonest
119}
120
121/// Purge one expired message everywhere it lives: STATE (emitting the derez
122/// event when it was resident), cached attachment files, our own Blossom
123/// blobs, and the DB row. `db_fallback` supplies (attachments, mine) for rows
124/// that never hydrated into STATE (a DB load caught them first); `None` keeps
125/// the sweep's contract of skipping ids something else already removed.
126pub(crate) async fn purge_one(id: &str, db_fallback: Option<(Vec<crate::types::Attachment>, bool)>) {
127    let removed = {
128        let mut state = crate::state::STATE.lock().await;
129        state.remove_message(id)
130    };
131    let (attachments, mine, resident_chat) = match removed {
132        Some((chat_id, msg)) => (msg.attachments, msg.mine, Some(chat_id)),
133        None => match db_fallback {
134            Some((attachments, mine)) => (attachments, mine, None),
135            None => return, // already gone (client-side derez or a prior sweep)
136        },
137    };
138
139    if !attachments.is_empty() {
140        // Refcount filter: keep files/blobs a sibling message still points at.
141        let unique = crate::deletion::filter_unreferenced_attachments(id, attachments).await;
142        crate::deletion::delete_cached_attachment_files_pub(&unique);
143
144        // Our own, now-unreferenced blob → wipe it network-side too, minus any
145        // url the ledger says another message still rides (smart-forward).
146        if mine {
147            let urls: Vec<String> = crate::deletion::scrub_safe_urls(&unique, id);
148            if !urls.is_empty() && crate::state::nostr_client().is_some() {
149                if let Ok(signer) = crate::signer::active_signer() {
150                    crate::blossom::delete_blobs_best_effort(signer, urls);
151                }
152            }
153        }
154    }
155
156    let _ = crate::db::events::delete_event(id).await;
157
158    // Only a STATE-resident message can be on screen — DB-only rows were
159    // caught before rendering and vanish silently.
160    if let Some(chat_id) = resident_chat {
161        crate::traits::emit_event(
162            "message_removed",
163            &serde_json::json!({ "id": id, "chat_id": chat_id, "reason": "self-destruct" }),
164        );
165    }
166}
167
168/// Drop already-expired messages from a freshly-loaded DB window, purging
169/// their remnants in the background. Rows that expired while the app was
170/// closed (or while their chat was out of STATE) must NEVER render — not
171/// even for the frame between hydration and the next sweep tick.
172pub fn strip_expired(messages: &mut Vec<crate::types::Message>) {
173    let now = match web_time::SystemTime::now().duration_since(web_time::UNIX_EPOCH) {
174        Ok(d) => d.as_secs(),
175        Err(_) => return,
176    };
177    let mut expired: Vec<(String, Vec<crate::types::Attachment>, bool)> = Vec::new();
178    messages.retain(|m| match m.expiration {
179        Some(exp) if exp <= now => {
180            expired.push((m.id.clone(), m.attachments.clone(), m.mine));
181            false
182        }
183        _ => true,
184    });
185    if expired.is_empty() {
186        return;
187    }
188    crate::db::spawn_bound(async move {
189        for (id, attachments, mine) in expired {
190            purge_one(&id, Some((attachments, mine))).await;
191        }
192    });
193}
194
195/// Time until the next sweep: sleep exactly until the soonest pending expiry
196/// (down to a 1s floor) so the final stretch purges in real time, but never
197/// longer than SWEEP_MAX_SECS so a newly-arrived message is noticed promptly.
198fn next_sweep_delay(soonest: Option<u64>) -> std::time::Duration {
199    let now = web_time::SystemTime::now()
200        .duration_since(web_time::UNIX_EPOCH)
201        .map(|d| d.as_secs())
202        .unwrap_or(0);
203    let secs = match soonest {
204        Some(exp) => exp.saturating_sub(now).clamp(1, SWEEP_MAX_SECS),
205        None => SWEEP_MAX_SECS,
206    };
207    std::time::Duration::from_secs(secs)
208}
209
210/// The self-destruct sweep loop: purge, then sleep until the next expiry is due
211/// (adaptive — 1s-tight near a deadline, up to SWEEP_MAX_SECS when idle). The
212/// immediate first pass catches anything that expired while offline. Hosts with
213/// their own async runtime (e.g. Tauri) should spawn this directly.
214pub async fn run_sweeper_loop() {
215    loop {
216        let soonest = sweep_expired().await;
217        crate::rt::time::sleep(next_sweep_delay(soonest)).await;
218    }
219}
220
221/// Convenience for tokio-native hosts (CLI/SDK): spawn `run_sweeper_loop` once.
222/// Idempotent — a second call is a no-op.
223pub fn start_sweeper() {
224    static STARTED: AtomicBool = AtomicBool::new(false);
225    if STARTED.swap(true, Ordering::AcqRel) {
226        return;
227    }
228    // NOT bound: this loop runs for the process lifetime and must sweep
229    // whichever account is live. Binding would pin it to whoever logged in
230    // first, and every later account would silently stop expiring messages.
231    // spawn-detached: the sweeper must expire messages for whichever account is live; see above.
232    crate::rt::spawn(run_sweeper_loop());
233}