1use 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
38const RAIL_DIRTY_KEY: &str = "synced_rail_dirty";
42
43const MAX_ENTRIES: usize = 2048;
48
49const FETCH_TIMEOUT_SECS: u64 = 10;
50
51#[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#[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#[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#[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 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 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
183pub enum Pref {
184 Blocks,
185 Mutes,
186 Nicknames,
187 Notify,
188 Rail,
189}
190
191pub 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 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
228struct 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
237pub fn is_hydrated(pref: Pref) -> bool {
245 hydrated_set().lock().map(|h| h.contains(pref.d_tag())).unwrap_or(false)
246}
247
248pub fn mark_hydrated(pref: Pref) {
252 if let Ok(mut h) = hydrated_set().lock() {
253 h.insert(pref.d_tag());
254 }
255}
256
257pub 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 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 None => mark_hydrated(pref),
285 }
286 }
287 applied
288}
289
290pub 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
334pub 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
350pub 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
368pub 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
386const RAIL_PUBLISH_IDLE: std::time::Duration = std::time::Duration::from_millis(2500);
397
398struct RailPublishGen;
402
403fn rail_gen() -> std::sync::Arc<std::sync::atomic::AtomicU64> {
404 crate::db::current_session().scoped::<RailPublishGen, _>()
405}
406
407pub 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
423pub 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
445pub 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 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 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 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 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}