Skip to main content

dialog_lib/
lib.rs

1use nostr_sdk::prelude::*;
2use std::path::PathBuf;
3use thiserror::Error;
4
5pub mod note;
6pub mod query;
7pub mod watch;
8
9pub use note::Note;
10
11#[derive(Error, Debug)]
12pub enum DialogError {
13    #[error("Nostr error: {0}")]
14    Nostr(#[from] nostr_sdk::client::Error),
15    #[error("Keys error: {0}")]
16    Keys(#[from] nostr_sdk::key::Error),
17    #[error("NIP-44 error: {0}")]
18    Nip44(#[from] nostr_sdk::nips::nip44::Error),
19    #[error("IO error: {0}")]
20    Io(#[from] std::io::Error),
21    #[error("Database error: {0}")]
22    Database(String),
23    #[error("Event builder error: {0}")]
24    EventBuilder(#[from] nostr_sdk::event::builder::Error),
25    #[error("Failed to get project directories")]
26    ProjectDirs,
27}
28
29pub type Result<T> = std::result::Result<T, DialogError>;
30
31pub struct Dialog {
32    pub client: Client,
33    pub keys: Keys,
34}
35
36impl Dialog {
37    pub async fn new(nsec: &str) -> Result<Self> {
38        let keys = Keys::parse(nsec)?;
39
40        // Use pubkey in path for isolation
41        let db_path = get_data_dir(&keys.public_key().to_hex())?;
42        let database = NdbDatabase::open(db_path.to_string_lossy())
43            .map_err(|e| DialogError::Database(e.to_string()))?;
44
45        let client = Client::builder()
46            .signer(keys.clone())
47            .database(database)
48            .build();
49
50        Ok(Self { client, keys })
51    }
52
53    pub async fn new_with_relay(nsec: &str, relay_url: &str) -> Result<Self> {
54        let dialog = Self::new(nsec).await?;
55        dialog.connect_relay(relay_url).await?;
56        Ok(dialog)
57    }
58
59    pub async fn connect_relay(&self, url: &str) -> Result<()> {
60        eprintln!("[lib] connect_relay: adding {url}");
61        self.client.add_relay(url).await?;
62        eprintln!("[lib] connect_relay: connecting");
63        self.client.connect().await;
64        eprintln!("[lib] connect_relay: connected");
65        Ok(())
66    }
67
68    pub fn public_key(&self) -> PublicKey {
69        self.keys.public_key()
70    }
71}
72
73fn get_data_dir(pubkey: &str) -> Result<PathBuf> {
74    // 1) CI / user override
75    if let Ok(p) = std::env::var("DIALOG_DATA_DIR") {
76        let p = PathBuf::from(p).join(pubkey);
77        std::fs::create_dir_all(&p)?;
78        return Ok(p.join("nostrdb"));
79    }
80
81    // 2) OS-correct per-app location
82    if let Some(dirs) = directories::ProjectDirs::from("", "", "dialog") {
83        let data_dir = dirs.data_dir().join(pubkey);
84        std::fs::create_dir_all(&data_dir)?;
85        return Ok(data_dir.join("nostrdb"));
86    }
87
88    // 3) Last-resort fallback (containers without HOME, etc.)
89    let p = std::env::temp_dir().join("dialog").join(pubkey);
90    std::fs::create_dir_all(&p)?;
91    Ok(p.join("nostrdb"))
92}
93
94pub fn clean_test_storage(pubkey: &str) -> Result<()> {
95    // Use same resolution as get_data_dir but just get parent
96    let data_dir = if let Ok(p) = std::env::var("DIALOG_DATA_DIR") {
97        PathBuf::from(p).join(pubkey)
98    } else if let Some(dirs) = directories::ProjectDirs::from("", "", "dialog") {
99        dirs.data_dir().join(pubkey)
100    } else {
101        std::env::temp_dir().join("dialog").join(pubkey)
102    };
103
104    if data_dir.exists() {
105        std::fs::remove_dir_all(data_dir)?;
106    }
107    Ok(())
108}
109
110pub fn validate_nsec(nsec: &str) -> Result<()> {
111    Keys::parse(nsec)?;
112    Ok(())
113}
114
115impl Dialog {
116    /// Mark a note as read - stores in local database only
117    pub async fn mark_as_read(&self, note_id: &EventId) -> Result<()> {
118        // Create a local-only Kind 30078 event for app data
119        let content = serde_json::json!({
120            "type": "read_status",
121            "note_id": note_id.to_hex(),
122            "is_read": true,
123            "timestamp": Timestamp::now().as_u64()
124        })
125        .to_string();
126
127        let event = EventBuilder::new(Kind::from(30078), content)
128            .tag(Tag::custom(
129                TagKind::SingleLetter(SingleLetterTag::lowercase(Alphabet::D)),
130                vec!["dialog_local_state"],
131            ))
132            .sign(&self.keys)
133            .await?;
134
135        // Save to database but NEVER publish to relays
136        self.client
137            .database()
138            .save_event(&event)
139            .await
140            .map_err(|e| DialogError::Database(e.to_string()))?;
141
142        Ok(())
143    }
144
145    /// Get read status for a note from local state
146    pub async fn get_read_status(&self, note_id: &EventId) -> bool {
147        // Query local database for read status
148        let filter = Filter::new()
149            .author(self.keys.public_key())
150            .kind(Kind::from(30078))
151            .custom_tag(
152                SingleLetterTag::lowercase(Alphabet::D),
153                vec!["dialog_local_state"],
154            )
155            .limit(1000); // Get recent local state events
156
157        if let Ok(events) = self.client.database().query(vec![filter]).await {
158            // Convert to vec first to use rev()
159            let events_vec: Vec<_> = events.into_iter().collect();
160            // Look through events for this note's read status (most recent first)
161            for event in events_vec.iter().rev() {
162                // Parse content to check if it's for this note
163                if let Ok(data) = serde_json::from_str::<serde_json::Value>(&event.content) {
164                    if data["type"] == "read_status" && data["note_id"] == note_id.to_hex() {
165                        return data["is_read"].as_bool().unwrap_or(false);
166                    }
167                }
168            }
169        }
170
171        false // Default to unread
172    }
173
174    /// Mark a note as synced locally
175    pub async fn mark_as_synced(&self, note_id: &EventId) -> Result<()> {
176        let content = serde_json::json!({
177            "type": "sync_status",
178            "note_id": note_id.to_hex(),
179            "is_synced": true,
180            "timestamp": Timestamp::now().as_u64()
181        })
182        .to_string();
183
184        let event = EventBuilder::new(Kind::from(30078), content)
185            .tag(Tag::custom(
186                TagKind::SingleLetter(SingleLetterTag::lowercase(Alphabet::D)),
187                vec!["dialog_local_state"],
188            ))
189            .sign(&self.keys)
190            .await?;
191
192        self.client
193            .database()
194            .save_event(&event)
195            .await
196            .map_err(|e| DialogError::Database(e.to_string()))?;
197
198        Ok(())
199    }
200}