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";
29
30const BLOCKS_LOCAL_KEY: &str = "synced_blocks_local";
31const MUTES_LOCAL_KEY: &str = "synced_mutes_local";
32const NICKNAMES_LOCAL_KEY: &str = "synced_nicknames_local";
33
34/// One NIP-44 event holds the whole list, so it inherits the same ~65KB
35/// plaintext ceiling as the Community List. Blocks and nicknames scale with
36/// contacts rather than being capped like pins, so the write path refuses to
37/// grow a list past this rather than publishing something no reader can open.
38const MAX_ENTRIES: usize = 2048;
39
40const FETCH_TIMEOUT_SECS: u64 = 10;
41
42/// A set of ids (npubs for blocks, chat ids for mutes).
43#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
44pub struct IdList {
45    #[serde(default = "one")]
46    pub v: u32,
47    #[serde(default)]
48    pub ids: Vec<String>,
49}
50
51/// npub → nickname. A map rather than a list so a rename replaces rather than
52/// duplicates, and so the wire form stays stable under reordering.
53#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
54pub struct NicknameMap {
55    #[serde(default = "one")]
56    pub v: u32,
57    #[serde(default)]
58    pub names: BTreeMap<String, String>,
59}
60
61fn one() -> u32 {
62    1
63}
64
65impl IdList {
66    /// Tolerant parse: a malformed payload degrades to empty rather than
67    /// erroring, so one bad event can never wedge a sync.
68    pub fn from_json(s: &str) -> Self {
69        serde_json::from_str(s).unwrap_or_default()
70    }
71    pub fn to_json(&self) -> String {
72        serde_json::to_string(self).unwrap_or_else(|_| "{\"v\":1,\"ids\":[]}".to_string())
73    }
74    pub fn contains(&self, id: &str) -> bool {
75        self.ids.iter().any(|i| i == id)
76    }
77    pub fn add(&mut self, id: &str) -> Result<(), String> {
78        if id.trim().is_empty() {
79            return Err("empty id".to_string());
80        }
81        if self.contains(id) {
82            return Ok(());
83        }
84        if self.ids.len() >= MAX_ENTRIES {
85            return Err(format!("this list is full ({MAX_ENTRIES} entries)"));
86        }
87        self.ids.push(id.to_string());
88        Ok(())
89    }
90    pub fn remove(&mut self, id: &str) {
91        self.ids.retain(|i| i != id);
92    }
93}
94
95impl NicknameMap {
96    pub fn from_json(s: &str) -> Self {
97        serde_json::from_str(s).unwrap_or_default()
98    }
99    pub fn to_json(&self) -> String {
100        serde_json::to_string(self).unwrap_or_else(|_| "{\"v\":1,\"names\":{}}".to_string())
101    }
102    /// An empty nickname CLEARS the entry — that is how the UI expresses
103    /// "remove this nickname", and keeping a blank would republish it forever.
104    pub fn set(&mut self, npub: &str, name: &str) -> Result<(), String> {
105        if npub.trim().is_empty() {
106            return Err("empty npub".to_string());
107        }
108        if name.trim().is_empty() {
109            self.names.remove(npub);
110            return Ok(());
111        }
112        if !self.names.contains_key(npub) && self.names.len() >= MAX_ENTRIES {
113            return Err(format!("nickname list is full ({MAX_ENTRIES} entries)"));
114        }
115        self.names.insert(npub.to_string(), name.to_string());
116        Ok(())
117    }
118}
119
120/// Which list a call refers to. Keeps one set of network/storage plumbing for
121/// all three rather than three near-identical copies that can drift.
122#[derive(Debug, Clone, Copy, PartialEq, Eq)]
123pub enum Pref {
124    Blocks,
125    Mutes,
126    Nicknames,
127}
128
129impl Pref {
130    pub fn d_tag(self) -> &'static str {
131        match self {
132            Pref::Blocks => BLOCKS_D_TAG,
133            Pref::Mutes => MUTES_D_TAG,
134            Pref::Nicknames => NICKNAMES_D_TAG,
135        }
136    }
137    fn local_key(self) -> &'static str {
138        match self {
139            Pref::Blocks => BLOCKS_LOCAL_KEY,
140            Pref::Mutes => MUTES_LOCAL_KEY,
141            Pref::Nicknames => NICKNAMES_LOCAL_KEY,
142        }
143    }
144    /// The d-tag → list routing used by the self-sync handler.
145    pub fn from_d_tag(d: &str) -> Option<Self> {
146        match d {
147            BLOCKS_D_TAG => Some(Pref::Blocks),
148            MUTES_D_TAG => Some(Pref::Mutes),
149            NICKNAMES_D_TAG => Some(Pref::Nicknames),
150            _ => None,
151        }
152    }
153}
154
155/// Lists this account has reconciled with the relays this session, keyed by
156/// d-tag. Per-account by construction: it lives on the Session, so a swap drops
157/// it and the next account re-hydrates rather than inheriting this one's.
158struct Hydrated;
159
160fn hydrated_set() -> std::sync::Arc<std::sync::Mutex<std::collections::HashSet<&'static str>>> {
161    crate::db::current_session().scoped::<Hydrated, _>()
162}
163
164/// Has `pref` been reconciled with the relays yet this session?
165///
166/// **The publish gate.** These lists are whole-list newest-wins projections of
167/// local state, so publishing one before the relay copy has been applied would
168/// overwrite another device's prefs with this device's emptier view — a fresh
169/// login that mutes one chat before the subscription replay lands would erase
170/// every block, mute and nickname set elsewhere. Reconcile, THEN publish.
171pub fn is_hydrated(pref: Pref) -> bool {
172    hydrated_set().lock().map(|h| h.contains(pref.d_tag())).unwrap_or(false)
173}
174
175/// Mark `pref` reconciled. Called when a copy is applied AND when the relays
176/// confirm none exists — "there is nothing to preserve" is just as reconciled
177/// as having read it, and without that a first-ever account could never publish.
178pub fn mark_hydrated(pref: Pref) {
179    if let Ok(mut h) = hydrated_set().lock() {
180        h.insert(pref.d_tag());
181    }
182}
183
184/// Pull every list once at login and apply it, so this device is reconciled
185/// before the user can touch anything. The live subscription also delivers
186/// these, but it races the user; this does not.
187///
188/// A list whose fetch FAILS stays un-hydrated, so it stays unpublishable — far
189/// better to leave prefs un-synced for a session than to overwrite prefs we
190/// could not read.
191pub async fn hydrate_all(client: &Client) -> Vec<(Pref, String)> {
192    let Some(my_pk) = crate::state::my_public_key() else { return Vec::new() };
193    let mut applied = Vec::new();
194    for pref in [Pref::Blocks, Pref::Mutes, Pref::Nicknames] {
195        match fetch_raw(client, my_pk, pref).await {
196            Some(json) => {
197                if save_local_raw(pref, &json).is_ok() {
198                    mark_hydrated(pref);
199                    applied.push((pref, json));
200                }
201            }
202            // No stored copy: nothing to preserve, so this device may publish.
203            None => mark_hydrated(pref),
204        }
205    }
206    applied
207}
208
209/// Raw JSON of a list's local mirror. Callers parse into whichever shape the
210/// list uses; the storage layer stays shape-agnostic.
211pub fn load_local_raw(pref: Pref) -> Option<String> {
212    crate::db::settings::get_sql_setting(pref.local_key().to_string())
213        .ok()
214        .flatten()
215}
216
217pub fn save_local_raw(pref: Pref, json: &str) -> Result<(), String> {
218    crate::db::settings::set_sql_setting(pref.local_key().to_string(), json.to_string())
219}
220
221pub fn load_blocks() -> IdList {
222    load_local_raw(Pref::Blocks).map(|s| IdList::from_json(&s)).unwrap_or_default()
223}
224pub fn load_mutes() -> IdList {
225    load_local_raw(Pref::Mutes).map(|s| IdList::from_json(&s)).unwrap_or_default()
226}
227pub fn load_nicknames() -> NicknameMap {
228    load_local_raw(Pref::Nicknames).map(|s| NicknameMap::from_json(&s)).unwrap_or_default()
229}
230
231async fn decrypt_event(my_pk: &PublicKey, event: &Event) -> Option<String> {
232    if event.content.is_empty() {
233        return None;
234    }
235    let signer = crate::signer::active_signer().ok()?;
236    match signer.nip44_decrypt_async(my_pk, &event.content).await {
237        Ok(plaintext) => Some(plaintext),
238        Err(e) => {
239            crate::log_warn!("[SyncedPrefs] decrypt {} failed: {}", event.kind.as_u16(), e);
240            None
241        }
242    }
243}
244
245/// Fetch a list's relay copy as raw JSON, or `None` when the relays hold none.
246pub async fn fetch_raw(client: &Client, my_pk: PublicKey, pref: Pref) -> Option<String> {
247    let filter = Filter::new()
248        .author(my_pk)
249        .kind(Kind::Custom(event_kind::APPLICATION_SPECIFIC))
250        .identifier(pref.d_tag())
251        .limit(1);
252    let events = client
253        .fetch_events(filter)
254        .timeout(std::time::Duration::from_secs(FETCH_TIMEOUT_SECS))
255        .await
256        .ok()?;
257    let event = events.into_iter().next()?;
258    decrypt_event(&my_pk, &event).await
259}
260
261/// Persist locally, then publish self-encrypted.
262pub async fn publish_raw(client: &Client, pref: Pref, json: &str) -> Result<(), String> {
263    let my_pk = crate::state::my_public_key().ok_or_else(|| "Not logged in".to_string())?;
264    save_local_raw(pref, json)?;
265
266    let signer = crate::signer::active_signer().map_err(|e| format!("Signer unavailable: {e}"))?;
267    let content = signer
268        .nip44_encrypt_async(&my_pk, json)
269        .await
270        .map_err(|e| format!("nip44 encrypt {}: {e}", pref.d_tag()))?;
271    let builder = EventBuilder::new(Kind::Custom(event_kind::APPLICATION_SPECIFIC), content)
272        .tag(Tag::identifier(pref.d_tag()));
273    crate::sign_and_send(client, builder)
274        .await
275        .map_err(|e| format!("publish {}: {e}", pref.d_tag()))?;
276    Ok(())
277}
278
279/// Consume a sibling device's update. Never republishes — the relay echoes our
280/// own publishes back on this same subscription, and answering an echo with a
281/// publish loops forever.
282pub async fn ingest_remote(my_pk: &PublicKey, event: &Event) -> Option<(Pref, String)> {
283    let d = event.tags.identifier().unwrap_or_default().to_string();
284    let pref = Pref::from_d_tag(&d)?;
285    let json = decrypt_event(my_pk, event).await?;
286    if let Err(e) = save_local_raw(pref, &json) {
287        crate::log_warn!("[SyncedPrefs] persisting {} failed: {e}", pref.d_tag());
288        return None;
289    }
290    mark_hydrated(pref);
291    Some((pref, json))
292}
293
294#[cfg(test)]
295mod tests {
296    use super::*;
297
298    #[test]
299    fn d_tags_round_trip_and_are_distinct() {
300        for p in [Pref::Blocks, Pref::Mutes, Pref::Nicknames] {
301            assert_eq!(Pref::from_d_tag(p.d_tag()), Some(p));
302        }
303        // A tag belonging to another 30078 list must not resolve here, or the
304        // self-sync router would hand a Community List to the block ingest.
305        assert_eq!(Pref::from_d_tag("vector/communities"), None);
306        assert_eq!(Pref::from_d_tag("vector/pinned"), None);
307        assert_eq!(Pref::from_d_tag(""), None);
308    }
309
310    #[test]
311    fn id_lists_add_idempotently_and_remove_tolerantly() {
312        let mut l = IdList::default();
313        l.add("npub1a").unwrap();
314        l.add("npub1a").unwrap();
315        assert_eq!(l.ids.len(), 1, "a second add is not a second entry");
316        l.remove("never-present");
317        l.remove("npub1a");
318        assert!(l.ids.is_empty());
319        assert!(l.add("  ").is_err(), "an empty id is refused, not stored");
320    }
321
322    #[test]
323    fn an_empty_nickname_clears_rather_than_storing_a_blank() {
324        let mut n = NicknameMap::default();
325        n.set("npub1a", "Landlord").unwrap();
326        assert_eq!(n.names.get("npub1a").map(String::as_str), Some("Landlord"));
327        n.set("npub1a", "").unwrap();
328        assert!(!n.names.contains_key("npub1a"), "clearing removes the key, not blanks it");
329    }
330
331    #[test]
332    fn malformed_payloads_degrade_to_empty_instead_of_erroring() {
333        assert!(IdList::from_json("not json").ids.is_empty());
334        assert!(IdList::from_json("{}").ids.is_empty());
335        assert!(NicknameMap::from_json("[]").names.is_empty());
336        // An unknown version still yields its entries rather than being dropped.
337        let future = IdList::from_json("{\"v\":99,\"ids\":[\"a\"],\"extra\":1}");
338        assert_eq!(future.ids, vec!["a".to_string()]);
339    }
340
341    #[test]
342    fn lists_refuse_to_grow_past_the_event_ceiling() {
343        let mut l = IdList::default();
344        for i in 0..MAX_ENTRIES {
345            l.add(&format!("id{i}")).unwrap();
346        }
347        assert!(l.add("one-too-many").is_err(), "a list that cannot be opened is worse than a refusal");
348        // Removing frees a slot again.
349        l.remove("id0");
350        assert!(l.add("one-too-many").is_ok());
351    }
352
353    #[test]
354    fn nickname_order_is_stable_across_a_round_trip() {
355        // BTreeMap, so two devices building the same set emit identical bytes —
356        // no spurious republish churn from map iteration order.
357        let mut a = NicknameMap::default();
358        a.set("npub1z", "Zed").unwrap();
359        a.set("npub1a", "Ann").unwrap();
360        let mut b = NicknameMap::default();
361        b.set("npub1a", "Ann").unwrap();
362        b.set("npub1z", "Zed").unwrap();
363        assert_eq!(a.to_json(), b.to_json(), "insertion order must not change the wire form");
364    }
365}