Skip to main content

hashtree_cli/socialgraph/
local_lists.rs

1use std::fs;
2use std::path::Path;
3use std::time::{SystemTime, UNIX_EPOCH};
4
5use anyhow::{Context, Result};
6use nostr::{Event, EventBuilder, Keys, Kind, PublicKey, Tag, Timestamp};
7use serde::Deserialize;
8
9use super::SocialGraphBackend;
10
11#[derive(Debug, Clone, Default, PartialEq, Eq)]
12pub struct LocalListFileState {
13    pub contacts_modified: Option<SystemTime>,
14    pub mutes_modified: Option<SystemTime>,
15}
16
17#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
18pub struct LocalListSyncOutcome {
19    pub contacts_changed: bool,
20    pub mutes_changed: bool,
21}
22
23#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
24struct StoredMuteEntry {
25    pubkey: String,
26    #[serde(default)]
27    reason: Option<String>,
28}
29
30pub fn read_local_list_file_state(data_dir: &Path) -> Result<LocalListFileState> {
31    Ok(LocalListFileState {
32        contacts_modified: file_modified(data_dir.join("contacts.json"))?,
33        mutes_modified: file_modified(data_dir.join("mutes.json"))?,
34    })
35}
36
37pub fn sync_local_list_files_force(
38    backend: &(impl SocialGraphBackend + ?Sized),
39    data_dir: &Path,
40    keys: &Keys,
41) -> Result<LocalListFileState> {
42    let state = read_local_list_file_state(data_dir)?;
43    let _ = sync_local_list_files_between(
44        backend,
45        data_dir,
46        keys,
47        &LocalListFileState::default(),
48        &state,
49        true,
50    )?;
51    Ok(state)
52}
53
54pub fn sync_local_list_files_if_changed(
55    backend: &(impl SocialGraphBackend + ?Sized),
56    data_dir: &Path,
57    keys: &Keys,
58    previous_state: &mut LocalListFileState,
59) -> Result<LocalListSyncOutcome> {
60    let current_state = read_local_list_file_state(data_dir)?;
61    let outcome = sync_local_list_files_between(
62        backend,
63        data_dir,
64        keys,
65        previous_state,
66        &current_state,
67        false,
68    )?;
69    *previous_state = current_state;
70    Ok(outcome)
71}
72
73fn sync_local_list_files_between(
74    backend: &(impl SocialGraphBackend + ?Sized),
75    data_dir: &Path,
76    keys: &Keys,
77    previous_state: &LocalListFileState,
78    current_state: &LocalListFileState,
79    force_existing: bool,
80) -> Result<LocalListSyncOutcome> {
81    let contacts_changed = should_sync_list(
82        previous_state.contacts_modified,
83        current_state.contacts_modified,
84        force_existing,
85    );
86    if contacts_changed {
87        let event = build_contact_list_event(
88            &load_contacts(data_dir.join("contacts.json"))?,
89            keys,
90            list_timestamp(
91                current_state.contacts_modified,
92                previous_state.contacts_modified,
93            ),
94        )?;
95        backend.ingest_event(&event)?;
96    }
97
98    let mutes_changed = should_sync_list(
99        previous_state.mutes_modified,
100        current_state.mutes_modified,
101        force_existing,
102    );
103    if mutes_changed {
104        let event = build_mute_list_event(
105            &load_mutes(data_dir.join("mutes.json"))?,
106            keys,
107            list_timestamp(current_state.mutes_modified, previous_state.mutes_modified),
108        )?;
109        backend.ingest_event(&event)?;
110    }
111
112    Ok(LocalListSyncOutcome {
113        contacts_changed,
114        mutes_changed,
115    })
116}
117
118fn should_sync_list(
119    previous: Option<SystemTime>,
120    current: Option<SystemTime>,
121    force_existing: bool,
122) -> bool {
123    if previous != current {
124        return previous.is_some() || current.is_some();
125    }
126
127    force_existing && current.is_some()
128}
129
130fn file_modified(path: impl AsRef<Path>) -> Result<Option<SystemTime>> {
131    let path = path.as_ref();
132    if !path.exists() {
133        return Ok(None);
134    }
135    let metadata = fs::metadata(path)?;
136    Ok(metadata.modified().ok())
137}
138
139fn list_timestamp(current: Option<SystemTime>, previous: Option<SystemTime>) -> Timestamp {
140    if let Some(current) = current {
141        return Timestamp::from_secs(system_time_secs(current));
142    }
143    if previous.is_some() {
144        return Timestamp::now();
145    }
146    Timestamp::now()
147}
148
149fn system_time_secs(time: SystemTime) -> u64 {
150    time.duration_since(UNIX_EPOCH)
151        .map(|duration| duration.as_secs())
152        .unwrap_or_default()
153}
154
155fn load_contacts(path: impl AsRef<Path>) -> Result<Vec<String>> {
156    let path = path.as_ref();
157    if !path.exists() {
158        return Ok(Vec::new());
159    }
160    let data = fs::read_to_string(path)?;
161    let contacts = serde_json::from_str::<Vec<String>>(&data).unwrap_or_default();
162    Ok(contacts)
163}
164
165fn load_mutes(path: impl AsRef<Path>) -> Result<Vec<StoredMuteEntry>> {
166    let path = path.as_ref();
167    if !path.exists() {
168        return Ok(Vec::new());
169    }
170
171    let data = fs::read_to_string(path)?;
172    let value: serde_json::Value = serde_json::from_str(&data).unwrap_or_default();
173    let Some(items) = value.as_array() else {
174        return Ok(Vec::new());
175    };
176
177    let mut entries = Vec::new();
178    for item in items {
179        match item {
180            serde_json::Value::String(pubkey) => entries.push(StoredMuteEntry {
181                pubkey: pubkey.clone(),
182                reason: None,
183            }),
184            serde_json::Value::Object(_) => {
185                if let Ok(entry) = serde_json::from_value::<StoredMuteEntry>(item.clone()) {
186                    entries.push(entry);
187                }
188            }
189            _ => {}
190        }
191    }
192
193    Ok(entries)
194}
195
196fn build_contact_list_event(
197    pubkeys: &[String],
198    keys: &Keys,
199    created_at: Timestamp,
200) -> Result<Event> {
201    let tags = pubkeys
202        .iter()
203        .filter_map(|pubkey| PublicKey::from_hex(pubkey).ok())
204        .map(Tag::public_key)
205        .collect::<Vec<_>>();
206    EventBuilder::new(Kind::ContactList, "", tags)
207        .custom_created_at(created_at)
208        .to_event(keys)
209        .context("sign local contact list event")
210}
211
212fn build_mute_list_event(
213    entries: &[StoredMuteEntry],
214    keys: &Keys,
215    created_at: Timestamp,
216) -> Result<Event> {
217    let mut tags = Vec::new();
218    for entry in entries {
219        let Ok(pubkey) = PublicKey::from_hex(&entry.pubkey) else {
220            continue;
221        };
222        if let Some(reason) = entry
223            .reason
224            .as_deref()
225            .map(str::trim)
226            .filter(|value| !value.is_empty())
227        {
228            tags.push(Tag::parse(&["p", &pubkey.to_hex(), reason])?);
229        } else {
230            tags.push(Tag::public_key(pubkey));
231        }
232    }
233    EventBuilder::new(Kind::MuteList, "", tags)
234        .custom_created_at(created_at)
235        .to_event(keys)
236        .context("sign local mute list event")
237}