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";
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
34const MAX_ENTRIES: usize = 2048;
39
40const FETCH_TIMEOUT_SECS: u64 = 10;
41
42#[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#[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 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 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#[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 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
155struct 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
164pub fn is_hydrated(pref: Pref) -> bool {
172 hydrated_set().lock().map(|h| h.contains(pref.d_tag())).unwrap_or(false)
173}
174
175pub fn mark_hydrated(pref: Pref) {
179 if let Ok(mut h) = hydrated_set().lock() {
180 h.insert(pref.d_tag());
181 }
182}
183
184pub 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 None => mark_hydrated(pref),
204 }
205 }
206 applied
207}
208
209pub 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
245pub 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
261pub 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
279pub 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 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 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 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 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}