hashtree_cli/socialgraph/
local_lists.rs1use 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 ¤t_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}