use nostr_sdk::prelude::*;
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use crate::stored_event::event_kind;
pub const BLOCKS_D_TAG: &str = "vector/blocks";
pub const MUTES_D_TAG: &str = "vector/mutes";
pub const NICKNAMES_D_TAG: &str = "vector/nicknames";
const BLOCKS_LOCAL_KEY: &str = "synced_blocks_local";
const MUTES_LOCAL_KEY: &str = "synced_mutes_local";
const NICKNAMES_LOCAL_KEY: &str = "synced_nicknames_local";
const MAX_ENTRIES: usize = 2048;
const FETCH_TIMEOUT_SECS: u64 = 10;
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct IdList {
#[serde(default = "one")]
pub v: u32,
#[serde(default)]
pub ids: Vec<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct NicknameMap {
#[serde(default = "one")]
pub v: u32,
#[serde(default)]
pub names: BTreeMap<String, String>,
}
fn one() -> u32 {
1
}
impl IdList {
pub fn from_json(s: &str) -> Self {
serde_json::from_str(s).unwrap_or_default()
}
pub fn to_json(&self) -> String {
serde_json::to_string(self).unwrap_or_else(|_| "{\"v\":1,\"ids\":[]}".to_string())
}
pub fn contains(&self, id: &str) -> bool {
self.ids.iter().any(|i| i == id)
}
pub fn add(&mut self, id: &str) -> Result<(), String> {
if id.trim().is_empty() {
return Err("empty id".to_string());
}
if self.contains(id) {
return Ok(());
}
if self.ids.len() >= MAX_ENTRIES {
return Err(format!("this list is full ({MAX_ENTRIES} entries)"));
}
self.ids.push(id.to_string());
Ok(())
}
pub fn remove(&mut self, id: &str) {
self.ids.retain(|i| i != id);
}
}
impl NicknameMap {
pub fn from_json(s: &str) -> Self {
serde_json::from_str(s).unwrap_or_default()
}
pub fn to_json(&self) -> String {
serde_json::to_string(self).unwrap_or_else(|_| "{\"v\":1,\"names\":{}}".to_string())
}
pub fn set(&mut self, npub: &str, name: &str) -> Result<(), String> {
if npub.trim().is_empty() {
return Err("empty npub".to_string());
}
if name.trim().is_empty() {
self.names.remove(npub);
return Ok(());
}
if !self.names.contains_key(npub) && self.names.len() >= MAX_ENTRIES {
return Err(format!("nickname list is full ({MAX_ENTRIES} entries)"));
}
self.names.insert(npub.to_string(), name.to_string());
Ok(())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Pref {
Blocks,
Mutes,
Nicknames,
}
impl Pref {
pub fn d_tag(self) -> &'static str {
match self {
Pref::Blocks => BLOCKS_D_TAG,
Pref::Mutes => MUTES_D_TAG,
Pref::Nicknames => NICKNAMES_D_TAG,
}
}
fn local_key(self) -> &'static str {
match self {
Pref::Blocks => BLOCKS_LOCAL_KEY,
Pref::Mutes => MUTES_LOCAL_KEY,
Pref::Nicknames => NICKNAMES_LOCAL_KEY,
}
}
pub fn from_d_tag(d: &str) -> Option<Self> {
match d {
BLOCKS_D_TAG => Some(Pref::Blocks),
MUTES_D_TAG => Some(Pref::Mutes),
NICKNAMES_D_TAG => Some(Pref::Nicknames),
_ => None,
}
}
}
struct Hydrated;
fn hydrated_set() -> std::sync::Arc<std::sync::Mutex<std::collections::HashSet<&'static str>>> {
crate::db::current_session().scoped::<Hydrated, _>()
}
pub fn is_hydrated(pref: Pref) -> bool {
hydrated_set().lock().map(|h| h.contains(pref.d_tag())).unwrap_or(false)
}
pub fn mark_hydrated(pref: Pref) {
if let Ok(mut h) = hydrated_set().lock() {
h.insert(pref.d_tag());
}
}
pub async fn hydrate_all(client: &Client) -> Vec<(Pref, String)> {
let Some(my_pk) = crate::state::my_public_key() else { return Vec::new() };
let mut applied = Vec::new();
for pref in [Pref::Blocks, Pref::Mutes, Pref::Nicknames] {
match fetch_raw(client, my_pk, pref).await {
Some(json) => {
if save_local_raw(pref, &json).is_ok() {
mark_hydrated(pref);
applied.push((pref, json));
}
}
None => mark_hydrated(pref),
}
}
applied
}
pub fn load_local_raw(pref: Pref) -> Option<String> {
crate::db::settings::get_sql_setting(pref.local_key().to_string())
.ok()
.flatten()
}
pub fn save_local_raw(pref: Pref, json: &str) -> Result<(), String> {
crate::db::settings::set_sql_setting(pref.local_key().to_string(), json.to_string())
}
pub fn load_blocks() -> IdList {
load_local_raw(Pref::Blocks).map(|s| IdList::from_json(&s)).unwrap_or_default()
}
pub fn load_mutes() -> IdList {
load_local_raw(Pref::Mutes).map(|s| IdList::from_json(&s)).unwrap_or_default()
}
pub fn load_nicknames() -> NicknameMap {
load_local_raw(Pref::Nicknames).map(|s| NicknameMap::from_json(&s)).unwrap_or_default()
}
async fn decrypt_event(my_pk: &PublicKey, event: &Event) -> Option<String> {
if event.content.is_empty() {
return None;
}
let signer = crate::signer::active_signer().ok()?;
match signer.nip44_decrypt_async(my_pk, &event.content).await {
Ok(plaintext) => Some(plaintext),
Err(e) => {
crate::log_warn!("[SyncedPrefs] decrypt {} failed: {}", event.kind.as_u16(), e);
None
}
}
}
pub async fn fetch_raw(client: &Client, my_pk: PublicKey, pref: Pref) -> Option<String> {
let filter = Filter::new()
.author(my_pk)
.kind(Kind::Custom(event_kind::APPLICATION_SPECIFIC))
.identifier(pref.d_tag())
.limit(1);
let events = client
.fetch_events(filter)
.timeout(std::time::Duration::from_secs(FETCH_TIMEOUT_SECS))
.await
.ok()?;
let event = events.into_iter().next()?;
decrypt_event(&my_pk, &event).await
}
pub async fn publish_raw(client: &Client, pref: Pref, json: &str) -> Result<(), String> {
let my_pk = crate::state::my_public_key().ok_or_else(|| "Not logged in".to_string())?;
save_local_raw(pref, json)?;
let signer = crate::signer::active_signer().map_err(|e| format!("Signer unavailable: {e}"))?;
let content = signer
.nip44_encrypt_async(&my_pk, json)
.await
.map_err(|e| format!("nip44 encrypt {}: {e}", pref.d_tag()))?;
let builder = EventBuilder::new(Kind::Custom(event_kind::APPLICATION_SPECIFIC), content)
.tag(Tag::identifier(pref.d_tag()));
crate::sign_and_send(client, builder)
.await
.map_err(|e| format!("publish {}: {e}", pref.d_tag()))?;
Ok(())
}
pub async fn ingest_remote(my_pk: &PublicKey, event: &Event) -> Option<(Pref, String)> {
let d = event.tags.identifier().unwrap_or_default().to_string();
let pref = Pref::from_d_tag(&d)?;
let json = decrypt_event(my_pk, event).await?;
if let Err(e) = save_local_raw(pref, &json) {
crate::log_warn!("[SyncedPrefs] persisting {} failed: {e}", pref.d_tag());
return None;
}
mark_hydrated(pref);
Some((pref, json))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn d_tags_round_trip_and_are_distinct() {
for p in [Pref::Blocks, Pref::Mutes, Pref::Nicknames] {
assert_eq!(Pref::from_d_tag(p.d_tag()), Some(p));
}
assert_eq!(Pref::from_d_tag("vector/communities"), None);
assert_eq!(Pref::from_d_tag("vector/pinned"), None);
assert_eq!(Pref::from_d_tag(""), None);
}
#[test]
fn id_lists_add_idempotently_and_remove_tolerantly() {
let mut l = IdList::default();
l.add("npub1a").unwrap();
l.add("npub1a").unwrap();
assert_eq!(l.ids.len(), 1, "a second add is not a second entry");
l.remove("never-present");
l.remove("npub1a");
assert!(l.ids.is_empty());
assert!(l.add(" ").is_err(), "an empty id is refused, not stored");
}
#[test]
fn an_empty_nickname_clears_rather_than_storing_a_blank() {
let mut n = NicknameMap::default();
n.set("npub1a", "Landlord").unwrap();
assert_eq!(n.names.get("npub1a").map(String::as_str), Some("Landlord"));
n.set("npub1a", "").unwrap();
assert!(!n.names.contains_key("npub1a"), "clearing removes the key, not blanks it");
}
#[test]
fn malformed_payloads_degrade_to_empty_instead_of_erroring() {
assert!(IdList::from_json("not json").ids.is_empty());
assert!(IdList::from_json("{}").ids.is_empty());
assert!(NicknameMap::from_json("[]").names.is_empty());
let future = IdList::from_json("{\"v\":99,\"ids\":[\"a\"],\"extra\":1}");
assert_eq!(future.ids, vec!["a".to_string()]);
}
#[test]
fn lists_refuse_to_grow_past_the_event_ceiling() {
let mut l = IdList::default();
for i in 0..MAX_ENTRIES {
l.add(&format!("id{i}")).unwrap();
}
assert!(l.add("one-too-many").is_err(), "a list that cannot be opened is worse than a refusal");
l.remove("id0");
assert!(l.add("one-too-many").is_ok());
}
#[test]
fn nickname_order_is_stable_across_a_round_trip() {
let mut a = NicknameMap::default();
a.set("npub1z", "Zed").unwrap();
a.set("npub1a", "Ann").unwrap();
let mut b = NicknameMap::default();
b.set("npub1a", "Ann").unwrap();
b.set("npub1z", "Zed").unwrap();
assert_eq!(a.to_json(), b.to_json(), "insertion order must not change the wire form");
}
}