Skip to main content

vector_core/
synced_prefs.rs

1//! Account preferences that follow you between your own devices: the block
2//! list, the mute list, and nicknames.
3//!
4//! Each is a private, parameterized-replaceable kind 30078 with its own d tag,
5//! NIP-44 self-encrypted, riding the SAME self-sync subscription as the
6//! Community, Invite and Pinned lists — so each inherits boot sync, reconnect
7//! re-sync and live cross-device edits with no new plumbing.
8//!
9//! **Vector's own lists, deliberately not NIP-51.** Social clients disagree on
10//! what a mute is — several treat it as a soft block — so round-tripping
11//! through the shared kind-10000 would blur the mute/block separation Vector
12//! draws on purpose. Isolation costs interop and buys exactness.
13//!
14//! **Newest wins, whole list.** These are one person's settings edited from one
15//! device at a time, so the replaceable event's own last-write-wins is the
16//! merge. A device that was offline can therefore republish over a change it
17//! never saw; the pre-publish fetch below shrinks that window, and these are
18//! deliberate, infrequent actions rather than latency-sensitive ones.
19
20use nostr_sdk::prelude::*;
21use serde::{Deserialize, Serialize};
22use std::collections::BTreeMap;
23
24use crate::stored_event::event_kind;
25
26pub const BLOCKS_D_TAG: &str = "vector/blocks";
27pub const MUTES_D_TAG: &str = "vector/mutes";
28pub const NICKNAMES_D_TAG: &str = "vector/nicknames";
29pub const NOTIFY_D_TAG: &str = "vector/notify";
30pub const RAIL_D_TAG: &str = "vector/rail";
31
32const BLOCKS_LOCAL_KEY: &str = "synced_blocks_local";
33const MUTES_LOCAL_KEY: &str = "synced_mutes_local";
34const NICKNAMES_LOCAL_KEY: &str = "synced_nicknames_local";
35const NOTIFY_LOCAL_KEY: &str = "synced_notify_local";
36const RAIL_LOCAL_KEY: &str = "synced_rail_local";
37
38/// Set when a list has local edits the relays have not seen, cleared once they
39/// have. Persisted, so a quit during the rail's publish debounce is recoverable
40/// at the next boot instead of being silently lost.
41const RAIL_DIRTY_KEY: &str = "synced_rail_dirty";
42
43/// One NIP-44 event holds the whole list, so it inherits the same ~65KB
44/// plaintext ceiling as the Community List. Blocks and nicknames scale with
45/// contacts rather than being capped like pins, so the write path refuses to
46/// grow a list past this rather than publishing something no reader can open.
47const MAX_ENTRIES: usize = 2048;
48
49const FETCH_TIMEOUT_SECS: u64 = 10;
50
51/// A set of ids (npubs for blocks, chat ids for mutes).
52#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
53pub struct IdList {
54    #[serde(default = "one")]
55    pub v: u32,
56    #[serde(default)]
57    pub ids: Vec<String>,
58}
59
60/// npub → nickname. A map rather than a list so a rename replaces rather than
61/// duplicates, and so the wire form stays stable under reordering.
62#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
63pub struct NicknameMap {
64    #[serde(default = "one")]
65    pub v: u32,
66    #[serde(default)]
67    pub names: BTreeMap<String, String>,
68}
69
70/// One scope's notification settings on the wire. Level is spelled out rather
71/// than numbered so a future rung does not silently become an old one, and an
72/// absent field means inherit, exactly as it does on disk.
73#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
74pub struct NotifyEntry {
75    #[serde(default, skip_serializing_if = "Option::is_none")]
76    pub level: Option<String>,
77    #[serde(default, skip_serializing_if = "is_zero")]
78    pub mute_until: i64,
79    #[serde(default, skip_serializing_if = "Option::is_none")]
80    pub suppress_everyone: Option<bool>,
81}
82
83fn is_zero(v: &i64) -> bool {
84    *v == 0
85}
86
87/// scope id → settings, for communities, channels and DMs alike.
88#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
89pub struct NotifyMap {
90    #[serde(default = "one")]
91    pub v: u32,
92    #[serde(default)]
93    pub scopes: BTreeMap<String, NotifyEntry>,
94}
95
96impl NotifyMap {
97    pub fn from_json(s: &str) -> Self {
98        serde_json::from_str(s).unwrap_or_default()
99    }
100    pub fn to_json(&self) -> String {
101        serde_json::to_string(self).unwrap_or_else(|_| "{\"v\":1,\"scopes\":{}}".to_string())
102    }
103    /// Refuses to grow past the ceiling rather than publishing a list no
104    /// reader can open, the same rule the id lists follow.
105    pub fn set(&mut self, scope_id: &str, entry: NotifyEntry) -> Result<(), String> {
106        if scope_id.trim().is_empty() {
107            return Err("empty scope id".to_string());
108        }
109        if entry == NotifyEntry::default() {
110            self.scopes.remove(scope_id);
111            return Ok(());
112        }
113        if !self.scopes.contains_key(scope_id) && self.scopes.len() >= MAX_ENTRIES {
114            return Err(format!("this list is full ({MAX_ENTRIES} entries)"));
115        }
116        self.scopes.insert(scope_id.to_string(), entry);
117        Ok(())
118    }
119}
120
121fn one() -> u32 {
122    1
123}
124
125impl IdList {
126    /// Tolerant parse: a malformed payload degrades to empty rather than
127    /// erroring, so one bad event can never wedge a sync.
128    pub fn from_json(s: &str) -> Self {
129        serde_json::from_str(s).unwrap_or_default()
130    }
131    pub fn to_json(&self) -> String {
132        serde_json::to_string(self).unwrap_or_else(|_| "{\"v\":1,\"ids\":[]}".to_string())
133    }
134    pub fn contains(&self, id: &str) -> bool {
135        self.ids.iter().any(|i| i == id)
136    }
137    pub fn add(&mut self, id: &str) -> Result<(), String> {
138        if id.trim().is_empty() {
139            return Err("empty id".to_string());
140        }
141        if self.contains(id) {
142            return Ok(());
143        }
144        if self.ids.len() >= MAX_ENTRIES {
145            return Err(format!("this list is full ({MAX_ENTRIES} entries)"));
146        }
147        self.ids.push(id.to_string());
148        Ok(())
149    }
150    pub fn remove(&mut self, id: &str) {
151        self.ids.retain(|i| i != id);
152    }
153}
154
155impl NicknameMap {
156    pub fn from_json(s: &str) -> Self {
157        serde_json::from_str(s).unwrap_or_default()
158    }
159    pub fn to_json(&self) -> String {
160        serde_json::to_string(self).unwrap_or_else(|_| "{\"v\":1,\"names\":{}}".to_string())
161    }
162    /// An empty nickname CLEARS the entry — that is how the UI expresses
163    /// "remove this nickname", and keeping a blank would republish it forever.
164    pub fn set(&mut self, npub: &str, name: &str) -> Result<(), String> {
165        if npub.trim().is_empty() {
166            return Err("empty npub".to_string());
167        }
168        if name.trim().is_empty() {
169            self.names.remove(npub);
170            return Ok(());
171        }
172        if !self.names.contains_key(npub) && self.names.len() >= MAX_ENTRIES {
173            return Err(format!("nickname list is full ({MAX_ENTRIES} entries)"));
174        }
175        self.names.insert(npub.to_string(), name.to_string());
176        Ok(())
177    }
178}
179
180/// Which list a call refers to. Keeps one set of network/storage plumbing for
181/// all three rather than three near-identical copies that can drift.
182#[derive(Debug, Clone, Copy, PartialEq, Eq)]
183pub enum Pref {
184    Blocks,
185    Mutes,
186    Nicknames,
187    Notify,
188    Rail,
189}
190
191/// Every list, in the order hydration walks them. `Notify` comes after `Mutes`
192/// so a device holding both applies the richer one last and wins the overlap.
193pub const ALL_PREFS: [Pref; 5] =
194    [Pref::Blocks, Pref::Mutes, Pref::Nicknames, Pref::Notify, Pref::Rail];
195
196impl Pref {
197    pub fn d_tag(self) -> &'static str {
198        match self {
199            Pref::Blocks => BLOCKS_D_TAG,
200            Pref::Mutes => MUTES_D_TAG,
201            Pref::Nicknames => NICKNAMES_D_TAG,
202            Pref::Notify => NOTIFY_D_TAG,
203            Pref::Rail => RAIL_D_TAG,
204        }
205    }
206    fn local_key(self) -> &'static str {
207        match self {
208            Pref::Blocks => BLOCKS_LOCAL_KEY,
209            Pref::Mutes => MUTES_LOCAL_KEY,
210            Pref::Nicknames => NICKNAMES_LOCAL_KEY,
211            Pref::Notify => NOTIFY_LOCAL_KEY,
212            Pref::Rail => RAIL_LOCAL_KEY,
213        }
214    }
215    /// The d-tag → list routing used by the self-sync handler.
216    pub fn from_d_tag(d: &str) -> Option<Self> {
217        match d {
218            BLOCKS_D_TAG => Some(Pref::Blocks),
219            MUTES_D_TAG => Some(Pref::Mutes),
220            NICKNAMES_D_TAG => Some(Pref::Nicknames),
221            NOTIFY_D_TAG => Some(Pref::Notify),
222            RAIL_D_TAG => Some(Pref::Rail),
223            _ => None,
224        }
225    }
226}
227
228/// Lists this account has reconciled with the relays this session, keyed by
229/// d-tag. Per-account by construction: it lives on the Session, so a swap drops
230/// it and the next account re-hydrates rather than inheriting this one's.
231struct Hydrated;
232
233fn hydrated_set() -> std::sync::Arc<std::sync::Mutex<std::collections::HashSet<&'static str>>> {
234    crate::db::current_session().scoped::<Hydrated, _>()
235}
236
237/// Has `pref` been reconciled with the relays yet this session?
238///
239/// **The publish gate.** These lists are whole-list newest-wins projections of
240/// local state, so publishing one before the relay copy has been applied would
241/// overwrite another device's prefs with this device's emptier view — a fresh
242/// login that mutes one chat before the subscription replay lands would erase
243/// every block, mute and nickname set elsewhere. Reconcile, THEN publish.
244pub fn is_hydrated(pref: Pref) -> bool {
245    hydrated_set().lock().map(|h| h.contains(pref.d_tag())).unwrap_or(false)
246}
247
248/// Mark `pref` reconciled. Called when a copy is applied AND when the relays
249/// confirm none exists — "there is nothing to preserve" is just as reconciled
250/// as having read it, and without that a first-ever account could never publish.
251pub fn mark_hydrated(pref: Pref) {
252    if let Ok(mut h) = hydrated_set().lock() {
253        h.insert(pref.d_tag());
254    }
255}
256
257/// Pull every list once at login and apply it, so this device is reconciled
258/// before the user can touch anything. The live subscription also delivers
259/// these, but it races the user; this does not.
260///
261/// A list whose fetch FAILS stays un-hydrated, so it stays unpublishable — far
262/// better to leave prefs un-synced for a session than to overwrite prefs we
263/// could not read.
264pub async fn hydrate_all(client: &Client) -> Vec<(Pref, String)> {
265    let Some(my_pk) = crate::state::my_public_key() else { return Vec::new() };
266    let mut applied = Vec::new();
267    for pref in ALL_PREFS {
268        // The rail is the one list that can hold edits the relays have never
269        // seen: its publish is debounced, so a quit mid-drag leaves the newer
270        // copy here. Applying the relay's older one would undo it.
271        if pref == Pref::Rail && rail_is_dirty() {
272            mark_hydrated(pref);
273            schedule_rail_publish();
274            continue;
275        }
276        match fetch_raw(client, my_pk, pref).await {
277            Some(json) => {
278                if save_local_raw(pref, &json).is_ok() {
279                    mark_hydrated(pref);
280                    applied.push((pref, json));
281                }
282            }
283            // No stored copy: nothing to preserve, so this device may publish.
284            None => mark_hydrated(pref),
285        }
286    }
287    applied
288}
289
290/// Raw JSON of a list's local mirror. Callers parse into whichever shape the
291/// list uses; the storage layer stays shape-agnostic.
292pub fn load_local_raw(pref: Pref) -> Option<String> {
293    crate::db::settings::get_sql_setting(pref.local_key().to_string())
294        .ok()
295        .flatten()
296}
297
298pub fn save_local_raw(pref: Pref, json: &str) -> Result<(), String> {
299    crate::db::settings::set_sql_setting(pref.local_key().to_string(), json.to_string())
300}
301
302pub fn load_blocks() -> IdList {
303    load_local_raw(Pref::Blocks).map(|s| IdList::from_json(&s)).unwrap_or_default()
304}
305pub fn load_mutes() -> IdList {
306    load_local_raw(Pref::Mutes).map(|s| IdList::from_json(&s)).unwrap_or_default()
307}
308pub fn load_nicknames() -> NicknameMap {
309    load_local_raw(Pref::Nicknames).map(|s| NicknameMap::from_json(&s)).unwrap_or_default()
310}
311pub fn load_notify() -> NotifyMap {
312    load_local_raw(Pref::Notify).map(|s| NotifyMap::from_json(&s)).unwrap_or_default()
313}
314pub fn load_rail() -> crate::rail_layout::RailLayout {
315    load_local_raw(Pref::Rail)
316        .map(|s| crate::rail_layout::RailLayout::from_json(&s))
317        .unwrap_or_default()
318}
319
320async fn decrypt_event(my_pk: &PublicKey, event: &Event) -> Option<String> {
321    if event.content.is_empty() {
322        return None;
323    }
324    let signer = crate::signer::active_signer().ok()?;
325    match signer.nip44_decrypt_async(my_pk, &event.content).await {
326        Ok(plaintext) => Some(plaintext),
327        Err(e) => {
328            crate::log_warn!("[SyncedPrefs] decrypt {} failed: {}", event.kind.as_u16(), e);
329            None
330        }
331    }
332}
333
334/// Fetch a list's relay copy as raw JSON, or `None` when the relays hold none.
335pub async fn fetch_raw(client: &Client, my_pk: PublicKey, pref: Pref) -> Option<String> {
336    let filter = Filter::new()
337        .author(my_pk)
338        .kind(Kind::Custom(event_kind::APPLICATION_SPECIFIC))
339        .identifier(pref.d_tag())
340        .limit(1);
341    let events = client
342        .fetch_events(filter)
343        .timeout(std::time::Duration::from_secs(FETCH_TIMEOUT_SECS))
344        .await
345        .ok()?;
346    let event = events.into_iter().next()?;
347    decrypt_event(&my_pk, &event).await
348}
349
350/// Persist locally, then publish self-encrypted.
351pub async fn publish_raw(client: &Client, pref: Pref, json: &str) -> Result<(), String> {
352    let my_pk = crate::state::my_public_key().ok_or_else(|| "Not logged in".to_string())?;
353    save_local_raw(pref, json)?;
354
355    let signer = crate::signer::active_signer().map_err(|e| format!("Signer unavailable: {e}"))?;
356    let content = signer
357        .nip44_encrypt_async(&my_pk, json)
358        .await
359        .map_err(|e| format!("nip44 encrypt {}: {e}", pref.d_tag()))?;
360    let builder = EventBuilder::new(Kind::Custom(event_kind::APPLICATION_SPECIFIC), content)
361        .tag(Tag::identifier(pref.d_tag()));
362    crate::sign_and_send(client, builder)
363        .await
364        .map_err(|e| format!("publish {}: {e}", pref.d_tag()))?;
365    Ok(())
366}
367
368/// Consume a sibling device's update. Never republishes — the relay echoes our
369/// own publishes back on this same subscription, and answering an echo with a
370/// publish loops forever.
371pub async fn ingest_remote(my_pk: &PublicKey, event: &Event) -> Option<(Pref, String)> {
372    let d = event.tags.identifier().unwrap_or_default().to_string();
373    let pref = Pref::from_d_tag(&d)?;
374    let json = decrypt_event(my_pk, event).await?;
375    if let Err(e) = save_local_raw(pref, &json) {
376        crate::log_warn!("[SyncedPrefs] persisting {} failed: {e}", pref.d_tag());
377        return None;
378    }
379    mark_hydrated(pref);
380    if pref == Pref::Rail {
381        set_rail_dirty(false);
382    }
383    Some((pref, json))
384}
385
386// ============================================================================
387// The rail's debounced publish
388// ============================================================================
389
390/// How long the rail must sit still before its arrangement goes out.
391///
392/// Rearranging is burst activity: a minute of dragging communities in and out
393/// of folders is ONE document at the end of it, not forty along the way. The
394/// local mirror already holds every intermediate state, so nothing is at risk
395/// while the timer runs.
396const RAIL_PUBLISH_IDLE: std::time::Duration = std::time::Duration::from_millis(2500);
397
398/// Bumped by every rail edit. A sleeping publish that wakes to find a newer
399/// generation was superseded mid-drag and simply stops, which is what collapses
400/// a burst into one write.
401struct RailPublishGen;
402
403fn rail_gen() -> std::sync::Arc<std::sync::atomic::AtomicU64> {
404    crate::db::current_session().scoped::<RailPublishGen, _>()
405}
406
407/// Whether the rail holds edits the relays have not seen.
408pub fn rail_is_dirty() -> bool {
409    crate::db::settings::get_sql_setting(RAIL_DIRTY_KEY.to_string())
410        .ok()
411        .flatten()
412        .as_deref()
413        == Some("1")
414}
415
416fn set_rail_dirty(dirty: bool) {
417    let _ = crate::db::settings::set_sql_setting(
418        RAIL_DIRTY_KEY.to_string(),
419        if dirty { "1" } else { "0" }.to_string(),
420    );
421}
422
423/// Commit an arrangement: on disk now, on the relays once the dragging stops.
424///
425/// The local mirror is the truth the rail paints from, so the UI never waits on
426/// a relay to show a drag landing.
427pub fn save_rail_debounced(layout: &crate::rail_layout::RailLayout) -> Result<(), String> {
428    save_local_raw(Pref::Rail, &layout.to_json())?;
429    set_rail_dirty(true);
430    schedule_rail_publish();
431    Ok(())
432}
433
434fn schedule_rail_publish() {
435    let generation = rail_gen().fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1;
436    crate::db::spawn_bound(async move {
437        crate::rt::time::sleep(RAIL_PUBLISH_IDLE).await;
438        if rail_gen().load(std::sync::atomic::Ordering::Relaxed) != generation {
439            return;
440        }
441        flush_rail().await;
442    });
443}
444
445/// Publish the arrangement if it has unsent edits.
446///
447/// A failure leaves the dirty flag set rather than retrying in a loop: the next
448/// edit reschedules, and failing that the next boot does, because the flag is
449/// persisted. That is also what covers a quit inside the debounce window — the
450/// publish never ran, and the arrangement is not lost.
451pub async fn flush_rail() {
452    if !rail_is_dirty() || !is_hydrated(Pref::Rail) {
453        return;
454    }
455    let Some(client) = crate::state::nostr_client() else { return };
456    let Some(json) = load_local_raw(Pref::Rail) else { return };
457    match publish_raw(&client, Pref::Rail, &json).await {
458        Ok(()) => set_rail_dirty(false),
459        Err(e) => crate::log_warn!("[SyncedPrefs] rail publish deferred: {e}"),
460    }
461}
462
463#[cfg(test)]
464mod tests {
465    use super::*;
466
467    #[test]
468    fn d_tags_round_trip_and_are_distinct() {
469        for p in ALL_PREFS {
470            assert_eq!(Pref::from_d_tag(p.d_tag()), Some(p));
471        }
472        // A tag belonging to another 30078 list must not resolve here, or the
473        // self-sync router would hand a Community List to the block ingest.
474        assert_eq!(Pref::from_d_tag("vector/communities"), None);
475        assert_eq!(Pref::from_d_tag("vector/pinned"), None);
476        assert_eq!(Pref::from_d_tag(""), None);
477    }
478
479    #[test]
480    fn id_lists_add_idempotently_and_remove_tolerantly() {
481        let mut l = IdList::default();
482        l.add("npub1a").unwrap();
483        l.add("npub1a").unwrap();
484        assert_eq!(l.ids.len(), 1, "a second add is not a second entry");
485        l.remove("never-present");
486        l.remove("npub1a");
487        assert!(l.ids.is_empty());
488        assert!(l.add("  ").is_err(), "an empty id is refused, not stored");
489    }
490
491    #[test]
492    fn an_empty_nickname_clears_rather_than_storing_a_blank() {
493        let mut n = NicknameMap::default();
494        n.set("npub1a", "Landlord").unwrap();
495        assert_eq!(n.names.get("npub1a").map(String::as_str), Some("Landlord"));
496        n.set("npub1a", "").unwrap();
497        assert!(!n.names.contains_key("npub1a"), "clearing removes the key, not blanks it");
498    }
499
500    #[test]
501    fn malformed_payloads_degrade_to_empty_instead_of_erroring() {
502        assert!(IdList::from_json("not json").ids.is_empty());
503        assert!(IdList::from_json("{}").ids.is_empty());
504        assert!(NicknameMap::from_json("[]").names.is_empty());
505        // An unknown version still yields its entries rather than being dropped.
506        let future = IdList::from_json("{\"v\":99,\"ids\":[\"a\"],\"extra\":1}");
507        assert_eq!(future.ids, vec!["a".to_string()]);
508    }
509
510    #[test]
511    fn lists_refuse_to_grow_past_the_event_ceiling() {
512        let mut l = IdList::default();
513        for i in 0..MAX_ENTRIES {
514            l.add(&format!("id{i}")).unwrap();
515        }
516        assert!(l.add("one-too-many").is_err(), "a list that cannot be opened is worse than a refusal");
517        // Removing frees a slot again.
518        l.remove("id0");
519        assert!(l.add("one-too-many").is_ok());
520    }
521
522    #[test]
523    fn nickname_order_is_stable_across_a_round_trip() {
524        // BTreeMap, so two devices building the same set emit identical bytes —
525        // no spurious republish churn from map iteration order.
526        let mut a = NicknameMap::default();
527        a.set("npub1z", "Zed").unwrap();
528        a.set("npub1a", "Ann").unwrap();
529        let mut b = NicknameMap::default();
530        b.set("npub1a", "Ann").unwrap();
531        b.set("npub1z", "Zed").unwrap();
532        assert_eq!(a.to_json(), b.to_json(), "insertion order must not change the wire form");
533    }
534}