From 21301cc60335a3944689a602a64fd40e5d0b8257 Mon Sep 17 00:00:00 2001 From: Jack Chakany <jack@chakany.systems> Date: Sat, 1 Mar 2025 13:34:09 -0500 Subject: [PATCH 1/5] add sqlite dependencies --- Cargo.lock | 150 +++++++++++++++++++++++++++++++++++++++++++++++++++-- Cargo.toml | 1 + 2 files changed, 146 insertions(+), 5 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 582512e..c8889c1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -171,6 +171,12 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc7eb209b1518d6bb87b283c20095f5228ecda460da70b44f0802523dea6da04" +[[package]] +name = "android-tzdata" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" + [[package]] name = "android_system_properties" version = "0.1.5" @@ -826,13 +832,13 @@ dependencies = [ [[package]] name = "cc" -version = "1.0.95" +version = "1.2.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d32a725bc159af97c3e629873bb9f88fb8cf8a4867175f76dc987815ea07c83b" +checksum = "0c3d1b2e905a3a7b00a6141adb0e4c0bb941d11caf55349d863942a1cc44e3c9" dependencies = [ "jobserver", "libc", - "once_cell", + "shlex", ] [[package]] @@ -896,6 +902,18 @@ dependencies = [ "zeroize", ] +[[package]] +name = "chrono" +version = "0.4.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e36cc9d416881d2e24f9a963be5fb1cd90966419ac844274161d10488b3e825" +dependencies = [ + "android-tzdata", + "iana-time-zone", + "num-traits", + "windows-targets 0.52.5", +] + [[package]] name = "cipher" version = "0.4.4" @@ -1575,6 +1593,18 @@ dependencies = [ "zune-inflate", ] +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + [[package]] name = "fastrand" version = "1.9.0" @@ -1630,6 +1660,12 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "foldhash" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0d2fde1f7b3d48b8395d5f2de76c18a528bd6a9cdde438df747bfcba3e05d6f" + [[package]] name = "foreign-types" version = "0.5.0" @@ -1905,7 +1941,7 @@ checksum = "cc11df1ace8e7e564511f53af41f3e42ddc95b56fd07b3f4445d2a6048bc682c" dependencies = [ "bitflags 2.6.0", "gpu-descriptor-types", - "hashbrown", + "hashbrown 0.14.3", ] [[package]] @@ -1937,6 +1973,24 @@ dependencies = [ "allocator-api2", ] +[[package]] +name = "hashbrown" +version = "0.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf151400ff0baff5465007dd2f3e717f3fe502074ca563069ce3a6629d07b289" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashlink" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" +dependencies = [ + "hashbrown 0.15.2", +] + [[package]] name = "hassle-rs" version = "0.11.0" @@ -2023,6 +2077,7 @@ dependencies = [ "puffin", "puffin_http", "rand", + "rusqlite", "security-framework", "serde", "serde_json", @@ -2048,6 +2103,29 @@ version = "1.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fcc0b4a115bf80b728eb8ea024ad5bd707b615bfed49e0665b6e0f86fd082d9" +[[package]] +name = "iana-time-zone" +version = "0.1.61" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "235e081f3925a06703c2d0117ea8b91f042756fd6e7a6e5d901e8ca1a996b220" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + [[package]] name = "icrate" version = "0.0.4" @@ -2134,7 +2212,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "168fb715dda47215e360912c096649d23d58bf392ac62f73919e831745e40f26" dependencies = [ "equivalent", - "hashbrown", + "hashbrown 0.14.3", ] [[package]] @@ -2338,6 +2416,18 @@ dependencies = [ "libc", ] +[[package]] +name = "libsqlite3-sys" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8935b44e7c13394a179a438e0cebba0fe08fe01b54f152e29a93b5cf993fd4" +dependencies = [ + "cc", + "openssl-sys", + "pkg-config", + "vcpkg", +] + [[package]] name = "linux-raw-sys" version = "0.3.8" @@ -2803,6 +2893,28 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" +[[package]] +name = "openssl-src" +version = "300.4.2+3.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "168ce4e058f975fe43e89d9ccf78ca668601887ae736090aacc23ae353c298e2" +dependencies = [ + "cc", +] + +[[package]] +name = "openssl-sys" +version = "0.9.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8bb61ea9811cc39e3c2069f40b8b8e2e70d8569b361f879786cc7ed48b777cdd" +dependencies = [ + "cc", + "libc", + "openssl-src", + "pkg-config", + "vcpkg", +] + [[package]] name = "orbclient" version = "0.3.47" @@ -3360,6 +3472,22 @@ version = "0.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3cd14fd5e3b777a7422cca79358c57a8f6e3a703d9ac187448d0daf220c2407f" +[[package]] +name = "rusqlite" +version = "0.33.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c6d5e5acb6f6129fe3f7ba0a7fc77bca1942cb568535e18e7bc40262baf3110" +dependencies = [ + "bitflags 2.6.0", + "chrono", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "serde_json", + "smallvec", +] + [[package]] name = "rustc-hash" version = "1.1.0" @@ -3613,6 +3741,12 @@ dependencies = [ "lazy_static", ] +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + [[package]] name = "signal-hook-registry" version = "1.4.2" @@ -4277,6 +4411,12 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "830b7e5d4d90034032940e4ace0d9a9a057e7a45cd94e6c007832e39edb82f6d" +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "version-compare" version = "0.2.0" diff --git a/Cargo.toml b/Cargo.toml index 989102f..60d030f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -28,6 +28,7 @@ nostr = { version = "0.37.0", features = ["std", "nip59"] } serde = "1.0.204" serde_json = "1.0.121" pollster = "0.4.0" +rusqlite = { version = "0.33.0", features = ["chrono", "serde_json", "bundled-sqlcipher-vendored-openssl"] } [target.'cfg(target_os = "macos")'.dependencies] security-framework = "3.0.0" From a2deb4145be0a8d4e97e57b354df9dc7910da4f1 Mon Sep 17 00:00:00 2001 From: Jack Chakany <jack@chakany.systems> Date: Sat, 1 Mar 2025 17:37:56 -0500 Subject: [PATCH 2/5] adding mail events into sqlite --- Cargo.lock | 72 ++++++---- Cargo.toml | 22 ++- migrations/001-nostr_events/up.sql | 9 ++ src/db.rs | 151 ++++++++++++++++++++ src/mail_event.rs | 20 ++- src/main.rs | 212 ++++++++++++++++++++--------- 6 files changed, 378 insertions(+), 108 deletions(-) create mode 100644 migrations/001-nostr_events/up.sql create mode 100644 src/db.rs diff --git a/Cargo.lock b/Cargo.lock index c8889c1..8dc9e07 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1,6 +1,6 @@ # This file is automatically @generated by Cargo. # It is not intended for manual editing. -version = 3 +version = 4 [[package]] name = "ab_glyph" @@ -188,9 +188,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.82" +version = "1.0.96" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f538837af36e6f6a9be0faa67f9a314f8119e4e4b5867c6ab40ed60360142519" +checksum = "6b964d184e89d9b6b67dd2715bc8e74cf3107fb2b529990c90cf517326150bf4" [[package]] name = "arbitrary" @@ -1660,12 +1660,6 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" -[[package]] -name = "foldhash" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0d2fde1f7b3d48b8395d5f2de76c18a528bd6a9cdde438df747bfcba3e05d6f" - [[package]] name = "foreign-types" version = "0.5.0" @@ -1941,7 +1935,7 @@ checksum = "cc11df1ace8e7e564511f53af41f3e42ddc95b56fd07b3f4445d2a6048bc682c" dependencies = [ "bitflags 2.6.0", "gpu-descriptor-types", - "hashbrown 0.14.3", + "hashbrown", ] [[package]] @@ -1973,22 +1967,13 @@ dependencies = [ "allocator-api2", ] -[[package]] -name = "hashbrown" -version = "0.15.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf151400ff0baff5465007dd2f3e717f3fe502074ca563069ce3a6629d07b289" -dependencies = [ - "foldhash", -] - [[package]] name = "hashlink" -version = "0.10.0" +version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" +checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" dependencies = [ - "hashbrown 0.15.2", + "hashbrown", ] [[package]] @@ -2067,17 +2052,20 @@ dependencies = [ name = "hoot-app" version = "0.1.0" dependencies = [ + "anyhow", "eframe", "egui_extras", "egui_tabs", "ewebsock", "image 0.25.1", + "include_dir", "nostr", "pollster", "puffin", "puffin_http", "rand", "rusqlite", + "rusqlite_migration", "security-framework", "serde", "serde_json", @@ -2205,6 +2193,25 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "44feda355f4159a7c757171a77de25daf6411e217b4cabd03bd6650690468126" +[[package]] +name = "include_dir" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "923d117408f1e49d914f1a379a309cffe4f18c05cf4e3d12e613a15fc81bd0dd" +dependencies = [ + "include_dir_macros", +] + +[[package]] +name = "include_dir_macros" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cab85a7ed0bd5f0e76d93846e0147172bed2e2d3f859bcc33a8d9699cad1a75" +dependencies = [ + "proc-macro2", + "quote", +] + [[package]] name = "indexmap" version = "2.2.6" @@ -2212,7 +2219,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "168fb715dda47215e360912c096649d23d58bf392ac62f73919e831745e40f26" dependencies = [ "equivalent", - "hashbrown 0.14.3", + "hashbrown", ] [[package]] @@ -2418,9 +2425,9 @@ dependencies = [ [[package]] name = "libsqlite3-sys" -version = "0.31.0" +version = "0.30.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ad8935b44e7c13394a179a438e0cebba0fe08fe01b54f152e29a93b5cf993fd4" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" dependencies = [ "cc", "openssl-sys", @@ -3474,9 +3481,9 @@ checksum = "3cd14fd5e3b777a7422cca79358c57a8f6e3a703d9ac187448d0daf220c2407f" [[package]] name = "rusqlite" -version = "0.33.0" +version = "0.32.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c6d5e5acb6f6129fe3f7ba0a7fc77bca1942cb568535e18e7bc40262baf3110" +checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e" dependencies = [ "bitflags 2.6.0", "chrono", @@ -3488,6 +3495,17 @@ dependencies = [ "smallvec", ] +[[package]] +name = "rusqlite_migration" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "923b42e802f7dc20a0a6b5e097ba7c83fe4289da07e49156fecf6af08aa9cd1c" +dependencies = [ + "include_dir", + "log", + "rusqlite", +] + [[package]] name = "rustc-hash" version = "1.1.0" diff --git a/Cargo.toml b/Cargo.toml index 60d030f..4e5d609 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,15 +6,16 @@ edition = "2021" publish = false [features] -profiling = ["dep:puffin", "dep:puffin_http", "eframe/puffin", "egui_extras/puffin"] +profiling = [ + "dep:puffin", + "dep:puffin_http", + "eframe/puffin", + "egui_extras/puffin", +] [dependencies] eframe = { version = "0.27.2", features = ["default", "persistence"] } -egui_extras = { version = "0.27.2", features = [ - "file", - "image", - "svg", -] } +egui_extras = { version = "0.27.2", features = ["file", "image", "svg"] } egui_tabs = { git = "https://github.com/damus-io/egui-tabs", rev = "120971fc43db6ba0b6f194f4bd4a66f7e00a4e22" } image = { version = "0.25.1", features = ["jpeg", "png"] } tracing = "0.1.40" @@ -28,7 +29,14 @@ nostr = { version = "0.37.0", features = ["std", "nip59"] } serde = "1.0.204" serde_json = "1.0.121" pollster = "0.4.0" -rusqlite = { version = "0.33.0", features = ["chrono", "serde_json", "bundled-sqlcipher-vendored-openssl"] } +rusqlite = { version = "0.32.1", features = [ + "chrono", + "serde_json", + "bundled-sqlcipher-vendored-openssl", +] } +rusqlite_migration = { version = "1.3.1", features = ["from-directory"] } +anyhow = "1.0.96" +include_dir = "0.7.4" [target.'cfg(target_os = "macos")'.dependencies] security-framework = "3.0.0" diff --git a/migrations/001-nostr_events/up.sql b/migrations/001-nostr_events/up.sql new file mode 100644 index 0000000..d1852ec --- /dev/null +++ b/migrations/001-nostr_events/up.sql @@ -0,0 +1,9 @@ +CREATE TABLE IF NOT EXISTS events ( + id TEXT PRIMARY KEY, + pubkey TEXT NOT NULL, + created_at INTEGER NOT NULL, + kind INTEGER NOT NULL, + tags TEXT NOT NULL CHECK (json_valid (tags)), + content TEXT NOT NULL, + sig TEXT +); diff --git a/src/db.rs b/src/db.rs new file mode 100644 index 0000000..dc47ca5 --- /dev/null +++ b/src/db.rs @@ -0,0 +1,151 @@ +use std::path::PathBuf; +use std::sync::LazyLock; + +use anyhow::Result; +use include_dir::{include_dir, Dir}; +use nostr::{Event, Kind}; +use rusqlite::Connection; +use rusqlite_migration::{Migrations, M}; +use serde_json::json; + +use crate::account_manager::AccountManager; +use crate::mail_event::MAIL_EVENT_KIND; + +static MIGRATIONS_DIR: Dir = include_dir!("$CARGO_MANIFEST_DIR/migrations"); + +static MIGRATIONS: LazyLock<Migrations<'static>> = + LazyLock::new(|| Migrations::from_directory(&MIGRATIONS_DIR).unwrap()); + +pub struct Db { + connection: Connection, +} + +impl Db { + pub fn new(path: PathBuf) -> Result<Self> { + let mut conn = Connection::open(path)?; + + // Apply migrations + MIGRATIONS.to_latest(&mut conn)?; + + Ok(Self { connection: conn }) + } + + /// Store a mail event in the database + /// + /// This function first attempts to unwrap the gift wrap if necessary, + /// and then stores the event in the database. + pub fn store_mail_event( + &self, + event: &Event, + account_manager: &mut AccountManager, + ) -> Result<()> { + // Try to unwrap the gift wrap if this event is a gift wrap + let store_unwrapped = + is_gift_wrap(event) && account_manager.unwrap_gift_wrap(event).is_ok(); + + // Determine what event to store + if store_unwrapped { + // Unwrap succeeded, store the unwrapped event + let unwrapped = account_manager.unwrap_gift_wrap(event).unwrap(); + + // Get event details from the unwrapped gift + let id = match unwrapped.rumor.id { + Some(id) => id.to_string(), + None => "unknown".to_string(), + }; + let pubkey = unwrapped.rumor.pubkey.to_string(); + let created_at = unwrapped.rumor.created_at.as_u64(); + let kind = unwrapped.rumor.kind.as_u16() as u32; + let tags_json = json!(unwrapped.rumor.tags).to_string(); + let content = unwrapped.rumor.content.clone(); + let sig = unwrapped.sender.to_string(); // Use sender pubkey as signature reference + + // Store the unwrapped event in the database + self.connection.execute( + "INSERT OR REPLACE INTO events (id, pubkey, created_at, kind, tags, content, sig) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", + (id, pubkey, created_at, kind, tags_json, content, sig), + )?; + } else { + // Use original event + // Convert tags to JSON string for storage + let tags_json = json!(event.tags).to_string(); + + // Get event details + let id = event.id.to_string(); + let pubkey = event.pubkey.to_string(); + let created_at = event.created_at.as_u64(); + let kind = event.kind.as_u16() as u32; + let sig = event.sig.to_string(); + + // Store the event in the database + self.connection.execute( + "INSERT OR REPLACE INTO events (id, pubkey, created_at, kind, tags, content, sig) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", + (id, pubkey, created_at, kind, tags_json, &event.content, sig), + )?; + } + + Ok(()) + } + + /// Check if the database contains an event with the given ID + pub fn has_event(&self, event_id: &str) -> Result<bool> { + let count: i64 = self.connection.query_row( + "SELECT COUNT(*) FROM events WHERE id = ?", + [event_id], + |row| row.get(0), + )?; + + Ok(count > 0) + } + + /// Get all event IDs for mail events + pub fn get_mail_event_ids(&self) -> Result<Vec<String>> { + let mut stmt = self + .connection + .prepare("SELECT id FROM events WHERE kind = ?")?; + + let mail_kind = u32::from(MAIL_EVENT_KIND as u16); + + let id_iter = stmt.query_map([mail_kind], |row| { + let id: String = row.get(0)?; + Ok(id) + })?; + + let mut ids = Vec::new(); + for id_result in id_iter { + match id_result { + Ok(id) => ids.push(id), + Err(e) => { + tracing::error!("Error loading mail event ID: {}", e); + } + } + } + + Ok(ids) + } + + /// Get the JSON representation of an event by its ID + pub fn get_event_json(&self, event_id: &str) -> Result<Option<String>> { + let result = self.connection.query_row( + "SELECT json_object('id', id, 'pubkey', pubkey, 'created_at', created_at, + 'kind', kind, 'tags', json(tags), 'content', content, + 'sig', sig) + FROM events WHERE id = ?", + [event_id], + |row| row.get::<_, String>(0), + ); + + match result { + Ok(json) => Ok(Some(json)), + Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), + Err(e) => Err(e.into()), + } + } +} + +/// Check if an event is a gift wrap +fn is_gift_wrap(event: &Event) -> bool { + event.kind == Kind::GiftWrap +} diff --git a/src/mail_event.rs b/src/mail_event.rs index 21e8e47..bfc2142 100644 --- a/src/mail_event.rs +++ b/src/mail_event.rs @@ -1,8 +1,8 @@ use nostr::{Event, EventBuilder, Keys, Kind, PublicKey, Tag, TagKind, TagStandard}; -use std::collections::HashMap; use pollster::FutureExt as _; +use std::collections::HashMap; -pub const MAIL_EVENT_KIND: u16 = 1059; +pub const MAIL_EVENT_KIND: u16 = 2024; pub struct MailMessage { pub to: Vec<PublicKey>, @@ -23,19 +23,25 @@ impl MailMessage { } for pubkey in &self.cc { - tags.push(Tag::custom(TagKind::p(), vec![pubkey.to_hex().as_str(), "cc"])); + tags.push(Tag::custom( + TagKind::p(), + vec![pubkey.to_hex().as_str(), "cc"], + )); pubkeys_to_send_to.push(*pubkey); } - tags.push(Tag::from_standardized(TagStandard::Subject(self.subject.clone()))); + tags.push(Tag::from_standardized(TagStandard::Subject( + self.subject.clone(), + ))); - let base_event = EventBuilder::new(Kind::Custom(MAIL_EVENT_KIND), &self.content) - .tags(tags); + let base_event = EventBuilder::new(Kind::Custom(MAIL_EVENT_KIND), &self.content).tags(tags); let mut event_list: HashMap<PublicKey, Event> = HashMap::new(); for pubkey in pubkeys_to_send_to { let wrapped_event = - EventBuilder::gift_wrap(sending_keys, &pubkey, base_event.clone(), None).block_on().unwrap(); + EventBuilder::gift_wrap(sending_keys, &pubkey, base_event.clone(), None) + .block_on() + .unwrap(); event_list.insert(pubkey, wrapped_event); } diff --git a/src/main.rs b/src/main.rs index 671baec..0cfee52 100644 --- a/src/main.rs +++ b/src/main.rs @@ -8,6 +8,7 @@ use std::collections::HashMap; use tracing::{debug, error, info, Level}; mod account_manager; +mod db; mod error; mod keystorage; mod mail_event; @@ -84,6 +85,7 @@ pub struct Hoot { relays: relay::RelayPool, events: Vec<nostr::Event>, account_manager: account_manager::AccountManager, + db: db::Db, } #[derive(Debug, PartialEq)] @@ -113,7 +115,17 @@ fn update_app(app: &mut Hoot, ctx: &egui::Context) { if app.account_manager.loaded_keys.len() > 0 { let mut gw_sub = relay::Subscription::default(); - let filter = nostr::Filter::new().kind(nostr::Kind::Custom(mail_event::MAIL_EVENT_KIND)).custom_tag(nostr::SingleLetterTag { character: nostr::Alphabet::P, uppercase: false }, app.account_manager.loaded_keys.clone().into_iter().map(|keys| keys.public_key())); + let filter = nostr::Filter::new().kind(nostr::Kind::GiftWrap).custom_tag( + nostr::SingleLetterTag { + character: nostr::Alphabet::P, + uppercase: false, + }, + app.account_manager + .loaded_keys + .clone() + .into_iter() + .map(|keys| keys.public_key()), + ); gw_sub.filter(filter); // TODO: fix error handling @@ -154,13 +166,30 @@ fn process_event(app: &mut Hoot, _sub_id: &str, event_json: &str) { // Parse the event using the RelayMessage type which handles the ["EVENT", subscription_id, event_json] format if let Ok(event) = serde_json::from_str::<nostr::Event>(event_json) { - // Verify the event signature - if event.verify().is_ok() { - debug!("Verified event: {:?}", event); - app.events.push(event); - } else { - error!("Event verification failed"); + // Check if we already have this event + if let Ok(has_event) = app.db.has_event(&event.id.to_string()) { + if has_event { + debug!("Skipping already stored event: {}", event.id); + return; } + } + + // Verify the event signature + if event.verify().is_ok() { + debug!("Verified event: {:?}", event); + + // Store the event in memory + app.events.push(event.clone()); + + // Store the event in the database + if let Err(e) = app.db.store_mail_event(&event, &mut app.account_manager) { + error!("Failed to store event in database: {}", e); + } else { + debug!("Successfully stored event with id {} in database", event.id); + } + } else { + error!("Event verification failed for event: {}", event.id); + } } else { error!("Failed to parse event JSON: {}", event_json); } @@ -182,10 +211,13 @@ fn render_app(app: &mut Hoot, ctx: &egui::Context) { ui.add_space(16.0); // Compose button - if ui.add_sized( - [180.0, 36.0], - egui::Button::new("â Compose").fill(egui::Color32::from_rgb(149, 117, 205)), - ).clicked() { + if ui + .add_sized( + [180.0, 36.0], + egui::Button::new("â Compose").fill(egui::Color32::from_rgb(149, 117, 205)), + ) + .clicked() + { let state = ui::compose_window::ComposeWindowState { subject: String::new(), to_field: String::new(), @@ -197,9 +229,9 @@ fn render_app(app: &mut Hoot, ctx: &egui::Context) { .compose_window .insert(egui::Id::new(rand::random::<u32>()), state); } - + ui.add_space(16.0); - + // Navigation items let nav_items = [ ("đĨ Inbox", Page::Inbox, app.events.len()), @@ -212,18 +244,35 @@ fn render_app(app: &mut Hoot, ctx: &egui::Context) { for (label, page, count) in nav_items { let is_selected = app.page == page; - let response = ui.selectable_label(is_selected, format!("{} {}", label, if count > 0 { count.to_string() } else { String::new() })); + let response = ui.selectable_label( + is_selected, + format!( + "{} {}", + label, + if count > 0 { + count.to_string() + } else { + String::new() + } + ), + ); if response.clicked() { app.page = page; } } + if ui.button("onboarding").clicked() { + app.page = Page::OnboardingNew; + } // Add flexible space to push profile to bottom ui.with_layout(egui::Layout::bottom_up(egui::Align::Center), |ui| { ui.add_space(8.0); // Profile section ui.horizontal(|ui| { - if ui.add_sized([32.0, 32.0], egui::Button::new("đ¤")).clicked() { + if ui + .add_sized([32.0, 32.0], egui::Button::new("đ¤")) + .clicked() + { app.page = Page::Settings; } if let Some(key) = app.account_manager.loaded_keys.first() { @@ -245,19 +294,19 @@ fn render_app(app: &mut Hoot, ctx: &egui::Context) { [search_width, 32.0], egui::TextEdit::singleline(&mut String::new()) .hint_text("Search") - .margin(egui::vec2(8.0, 4.0)) + .margin(egui::vec2(8.0, 4.0)), ); }); - + ui.add_space(8.0); - + // Email list using TableBuilder TableBuilder::new(ui) - .column(Column::auto()) // Checkbox - .column(Column::auto()) // Star - .column(Column::remainder()) // Sender - .column(Column::remainder()) // Content - .column(Column::remainder()) // Time + .column(Column::auto()) // Checkbox + .column(Column::auto()) // Star + .column(Column::remainder()) // Sender + .column(Column::remainder()) // Content + .column(Column::remainder()) // Time .striped(true) .sense(Sense::click()) .auto_shrink(Vec2b { x: false, y: false }) @@ -283,37 +332,34 @@ fn render_app(app: &mut Hoot, ctx: &egui::Context) { let events = app.events.clone(); body.rows(row_height, events.len(), |mut row| { let event = &events[row.index()]; - - // Try to unwrap the gift wrap - if let Ok(unwrapped) = app.account_manager.unwrap_gift_wrap(event) { - row.col(|ui| { - ui.checkbox(&mut false, ""); - }); - row.col(|ui| { - ui.checkbox(&mut false, ""); - }); - row.col(|ui| { - ui.label(unwrapped.sender.to_string()); - }); - row.col(|ui| { - // Try to get subject from tags - let subject = match &unwrapped.rumor.tags.find(nostr::TagKind::Subject) { - Some(s) => match s.content() { - Some(c) => format!("{}: {}", c.to_string(), unwrapped.rumor.content), - None => unwrapped.rumor.content.clone(), - }, - None => unwrapped.rumor.content.clone(), - }; - ui.label(subject); - }); - row.col(|ui| { - ui.label("2 minutes ago"); - }); - if row.response().clicked() { - app.focused_post = event.id.to_string(); - app.page = Page::Post; - } + row.col(|ui| { + ui.checkbox(&mut false, ""); + }); + row.col(|ui| { + ui.checkbox(&mut false, ""); + }); + row.col(|ui| { + ui.label(event.pubkey.to_string()); + }); + row.col(|ui| { + // Try to get subject from tags + let subject = match &event.tags.find(nostr::TagKind::Subject) { + Some(s) => match s.content() { + Some(c) => format!("{}: {}", c.to_string(), event.content), + None => event.content.clone(), + }, + None => event.content.clone(), + }; + ui.label(subject); + }); + row.col(|ui| { + ui.label("2 minutes ago"); + }); + + if row.response().clicked() { + app.focused_post = event.id.to_string(); + app.page = Page::Post; } }); }); @@ -322,15 +368,24 @@ fn render_app(app: &mut Hoot, ctx: &egui::Context) { ui::settings::SettingsScreen::ui(app, ui); } Page::Post => { - if let Some(event) = app.events.iter().find(|e| e.id.to_string() == app.focused_post) { + if let Some(event) = app + .events + .iter() + .find(|e| e.id.to_string() == app.focused_post) + { if let Ok(unwrapped) = app.account_manager.unwrap_gift_wrap(event) { // Message header section ui.add_space(8.0); - ui.heading(&unwrapped.rumor.tags.find(nostr::TagKind::Subject) - .and_then(|s| s.content()) - .map(|c| c.to_string()) - .unwrap_or_else(|| "No Subject".to_string())); - + ui.heading( + &unwrapped + .rumor + .tags + .find(nostr::TagKind::Subject) + .and_then(|s| s.content()) + .map(|c| c.to_string()) + .unwrap_or_else(|| "No Subject".to_string()), + ); + // Metadata grid egui::Grid::new("email_metadata") .num_columns(2) @@ -341,11 +396,16 @@ fn render_app(app: &mut Hoot, ctx: &egui::Context) { ui.end_row(); ui.label("To"); - ui.label(unwrapped.rumor.tags.iter() - .filter_map(|tag| tag.content()) - .next() - .unwrap_or_else(|| "Unknown") - .to_string()); + ui.label( + unwrapped + .rumor + .tags + .iter() + .filter_map(|tag| tag.content()) + .next() + .unwrap_or_else(|| "Unknown") + .to_string(), + ); ui.end_row(); }); @@ -387,8 +447,7 @@ fn render_app(app: &mut Hoot, ctx: &egui::Context) { ui.label("Your draft messages will appear here"); } _ => { - ui.heading("Coming Soon"); - ui.label("This feature is under development"); + ui::onboarding::OnboardingScreen::ui(app, ui); } } }); @@ -396,7 +455,25 @@ fn render_app(app: &mut Hoot, ctx: &egui::Context) { impl Hoot { fn new(cc: &eframe::CreationContext<'_>) -> Self { - let storage_dir = eframe::storage_dir("Hoot").unwrap(); + // Create storage directory if it doesn't exist + let storage_dir = eframe::storage_dir("hoot").unwrap(); + std::fs::create_dir_all(&storage_dir).unwrap(); + + // Create the database file path + let db_path = storage_dir.join("hoot.db"); + + // Initialize the database + let db = match db::Db::new(db_path) { + Ok(db) => { + info!("Database initialized successfully"); + db + } + Err(e) => { + error!("Failed to initialize database: {}", e); + panic!("Database initialization failed: {}", e); + } + }; + Self { page: Page::Inbox, focused_post: "".into(), @@ -405,6 +482,7 @@ impl Hoot { relays: relay::RelayPool::new(), events: Vec::new(), account_manager: account_manager::AccountManager::new(), + db, } } } From 9814f9277a40dbdc622cef9e50756ab8bf7c0950 Mon Sep 17 00:00:00 2001 From: Jack Chakany <jack@chakany.systems> Date: Wed, 12 Mar 2025 11:15:23 -0400 Subject: [PATCH 3/5] change all cols in events to virtual ones that are generated on read, additionally refactor the insert event function --- migrations/001-nostr_events/up.sql | 17 +++++--- src/db.rs | 66 ++++++------------------------ src/main.rs | 2 +- 3 files changed, 24 insertions(+), 61 deletions(-) diff --git a/migrations/001-nostr_events/up.sql b/migrations/001-nostr_events/up.sql index d1852ec..bb45ba2 100644 --- a/migrations/001-nostr_events/up.sql +++ b/migrations/001-nostr_events/up.sql @@ -1,9 +1,14 @@ CREATE TABLE IF NOT EXISTS events ( id TEXT PRIMARY KEY, - pubkey TEXT NOT NULL, - created_at INTEGER NOT NULL, - kind INTEGER NOT NULL, - tags TEXT NOT NULL CHECK (json_valid (tags)), - content TEXT NOT NULL, - sig TEXT + pubkey TEXT NOT NULL GENERATED ALWAYS AS (jsonb_extract (raw, '$.pubkey')), + created_at INTEGER NOT NULL GENERATED ALWAYS AS (jsonb_extract (raw, '$.created_at')) VIRTUAL, + kind INTEGER NOT NULL GENERATED ALWAYS AS (jsonb_extract (raw, '$.kind')) VIRTUAL, + tags BLOB NOT NULL GENERATED ALWAYS AS (jsonb_extract (raw, '$.tags')) VIRTUAL, + content TEXT NOT NULL GENERATED ALWAYS AS (jsonb_extract (raw, '$.content')) VIRTUAL, + sig TEXT GENERATED ALWAYS AS (jsonb_extract (raw, '$.sig')) VIRTUAL, + raw BLOB NOT NULL ); + +-- indexes +CREATE INDEX idx_events_pubkey ON events (pubkey); +CREATE INDEX idx_events_kind ON events (created_at); diff --git a/src/db.rs b/src/db.rs index dc47ca5..420f397 100644 --- a/src/db.rs +++ b/src/db.rs @@ -30,11 +30,7 @@ impl Db { Ok(Self { connection: conn }) } - /// Store a mail event in the database - /// - /// This function first attempts to unwrap the gift wrap if necessary, - /// and then stores the event in the database. - pub fn store_mail_event( + pub fn store_event( &self, event: &Event, account_manager: &mut AccountManager, @@ -43,53 +39,33 @@ impl Db { let store_unwrapped = is_gift_wrap(event) && account_manager.unwrap_gift_wrap(event).is_ok(); - // Determine what event to store if store_unwrapped { - // Unwrap succeeded, store the unwrapped event let unwrapped = account_manager.unwrap_gift_wrap(event).unwrap(); + let mut rumor = unwrapped.rumor.clone(); + rumor.ensure_id(); - // Get event details from the unwrapped gift - let id = match unwrapped.rumor.id { - Some(id) => id.to_string(), - None => "unknown".to_string(), - }; - let pubkey = unwrapped.rumor.pubkey.to_string(); - let created_at = unwrapped.rumor.created_at.as_u64(); - let kind = unwrapped.rumor.kind.as_u16() as u32; - let tags_json = json!(unwrapped.rumor.tags).to_string(); - let content = unwrapped.rumor.content.clone(); - let sig = unwrapped.sender.to_string(); // Use sender pubkey as signature reference + let id = rumor.id.expect("Invalid Gift Wrapped Event: There is no ID!").to_hex(); + let raw = json!(rumor).to_string(); - // Store the unwrapped event in the database self.connection.execute( - "INSERT OR REPLACE INTO events (id, pubkey, created_at, kind, tags, content, sig) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", - (id, pubkey, created_at, kind, tags_json, content, sig), + "INSERT INTO events (id, raw) + VALUES (?1, ?2)", + (id, raw), )?; } else { - // Use original event - // Convert tags to JSON string for storage - let tags_json = json!(event.tags).to_string(); - - // Get event details let id = event.id.to_string(); - let pubkey = event.pubkey.to_string(); - let created_at = event.created_at.as_u64(); - let kind = event.kind.as_u16() as u32; - let sig = event.sig.to_string(); + let raw = json!(event).to_string(); - // Store the event in the database self.connection.execute( - "INSERT OR REPLACE INTO events (id, pubkey, created_at, kind, tags, content, sig) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", - (id, pubkey, created_at, kind, tags_json, &event.content, sig), + "INSERT INTO events (id, raw) + VALUES (?1, ?2)", + (id, raw), )?; } Ok(()) } - /// Check if the database contains an event with the given ID pub fn has_event(&self, event_id: &str) -> Result<bool> { let count: i64 = self.connection.query_row( "SELECT COUNT(*) FROM events WHERE id = ?", @@ -125,24 +101,6 @@ impl Db { Ok(ids) } - - /// Get the JSON representation of an event by its ID - pub fn get_event_json(&self, event_id: &str) -> Result<Option<String>> { - let result = self.connection.query_row( - "SELECT json_object('id', id, 'pubkey', pubkey, 'created_at', created_at, - 'kind', kind, 'tags', json(tags), 'content', content, - 'sig', sig) - FROM events WHERE id = ?", - [event_id], - |row| row.get::<_, String>(0), - ); - - match result { - Ok(json) => Ok(Some(json)), - Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), - Err(e) => Err(e.into()), - } - } } /// Check if an event is a gift wrap diff --git a/src/main.rs b/src/main.rs index 0cfee52..ba95caa 100644 --- a/src/main.rs +++ b/src/main.rs @@ -182,7 +182,7 @@ fn process_event(app: &mut Hoot, _sub_id: &str, event_json: &str) { app.events.push(event.clone()); // Store the event in the database - if let Err(e) = app.db.store_mail_event(&event, &mut app.account_manager) { + if let Err(e) = app.db.store_event(&event, &mut app.account_manager) { error!("Failed to store event in database: {}", e); } else { debug!("Successfully stored event with id {} in database", event.id); From 837c96e3469657174dc80df900b7225a5b666a54 Mon Sep 17 00:00:00 2001 From: Jack Chakany <jack@chakany.systems> Date: Fri, 14 Mar 2025 09:42:34 -0400 Subject: [PATCH 4/5] make compose window less stupid --- src/ui/compose_window.rs | 71 ---------------------------------------- 1 file changed, 71 deletions(-) diff --git a/src/ui/compose_window.rs b/src/ui/compose_window.rs index 9bcc4b9..5fd9a76 100644 --- a/src/ui/compose_window.rs +++ b/src/ui/compose_window.rs @@ -22,85 +22,20 @@ impl ComposeWindow { let min_height = screen_rect.height().min(400.0); // First collect all window IDs and their minimized state - let window_states: Vec<_> = app.state.compose_window.iter() - .map(|(id, state)| (*id, state.minimized)) - .collect(); - - let mut should_close = false; - let state = app .state .compose_window .get_mut(&id) .expect("no state found for id"); - if state.minimized { - // Count how many minimized windows are before this one - let minimized_index = window_states.iter() - .filter(|(other_id, is_minimized)| { - *is_minimized && other_id.value() < id.value() - }) - .count(); - - let minimized_height = 32.0; - let minimized_width = 200.0; - let minimized_spacing = 2.0; - let stack_on_left = screen_rect.width() < min_width + minimized_width + 40.0; - - let window_y_offset = -(minimized_height + minimized_spacing) * (minimized_index as f32 + 1.0); - - let (anchor, anchor_pos) = if stack_on_left { - (egui::Align2::LEFT_BOTTOM, [20.0, window_y_offset]) - } else { - (egui::Align2::RIGHT_BOTTOM, [-20.0, window_y_offset]) - }; - - egui::Window::new("đ§") - .id(id) - .fixed_size([minimized_width, minimized_height]) - .anchor(anchor, anchor_pos) - .title_bar(true) - .frame(egui::Frame::window(&ctx.style()).multiply_with_opacity(0.95)) - .show(ctx, |ui| { - ui.horizontal(|ui| { - if ui.button("đ§").clicked() { - state.minimized = false; - } - let subject = state.subject.as_str(); - let display_text = if subject.is_empty() { - "New Message" - } else { - subject.get(0..20).unwrap_or(subject) - }; - ui.label(RichText::new(display_text).size(11.0)); - }); - }); - return; - } else { egui::Window::new("New Message") .id(id) .default_size([min_width, min_height]) .min_width(300.0) .min_height(200.0) - .anchor(egui::Align2::RIGHT_BOTTOM, [-20.0, -20.0]) .default_pos([screen_rect.right() - min_width - 20.0, screen_rect.bottom() - min_height - 20.0]) - .collapsible(false) - .resizable(true) .show(ctx, |ui| { ui.vertical(|ui| { - // Window controls at the top - ui.horizontal(|ui| { - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - if ui.button("â").clicked() { - should_close = true; - } - if ui.button("đ").clicked() { - state.minimized = true; - } - ui.add_space(ui.available_width() - 50.0); // Push buttons to the right - }); - }); - // Header section ui.horizontal(|ui| { ui.label("To:"); @@ -177,7 +112,6 @@ impl ComposeWindow { let events_to_send = msg.to_events(&state.selected_account.clone().unwrap()); - info!("new events! {:?}", events_to_send); // send over wire for event in events_to_send { match serde_json::to_string(&ClientMessage::Event { event: event.1 }) { @@ -218,11 +152,6 @@ impl ComposeWindow { }); }); }); - } - - if should_close { - app.state.compose_window.remove(&id); - } } // Keep the original show method for backward compatibility From 0d8afd7340d46e0c2c7b4a65166548e75b439b84 Mon Sep 17 00:00:00 2001 From: Jack Chakany <jack@chakany.systems> Date: Fri, 14 Mar 2025 10:10:18 -0400 Subject: [PATCH 5/5] make replies work --- src/main.rs | 42 ++++++++++++++++++++++-------------------- 1 file changed, 22 insertions(+), 20 deletions(-) diff --git a/src/main.rs b/src/main.rs index ba95caa..8b26f43 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,6 +5,7 @@ use egui::FontFamily::Proportional; use egui_extras::{Column, TableBuilder}; use relay::RelayMessage; use std::collections::HashMap; +use nostr::{SingleLetterTag, TagKind}; use tracing::{debug, error, info, Level}; mod account_manager; @@ -374,17 +375,18 @@ fn render_app(app: &mut Hoot, ctx: &egui::Context) { .find(|e| e.id.to_string() == app.focused_post) { if let Ok(unwrapped) = app.account_manager.unwrap_gift_wrap(event) { + let subject = &unwrapped + .rumor + .tags + .find(nostr::TagKind::Subject) + .and_then(|s| s.content()) + .map(|c| c.to_string()) + .unwrap_or_else(|| "No Subject".to_string()); // Message header section ui.add_space(8.0); - ui.heading( - &unwrapped - .rumor - .tags - .find(nostr::TagKind::Subject) - .and_then(|s| s.content()) - .map(|c| c.to_string()) - .unwrap_or_else(|| "No Subject".to_string()), - ); + ui.heading(subject); + let destination: Vec<&nostr::PublicKey> = unwrapped.rumor.tags.public_keys().collect(); + let destination_stringed = destination.iter().map(|v| v.to_string()).collect::<Vec<_>>().join(" "); // Metadata grid egui::Grid::new("email_metadata") @@ -396,16 +398,7 @@ fn render_app(app: &mut Hoot, ctx: &egui::Context) { ui.end_row(); ui.label("To"); - ui.label( - unwrapped - .rumor - .tags - .iter() - .filter_map(|tag| tag.content()) - .next() - .unwrap_or_else(|| "Unknown") - .to_string(), - ); + ui.label(destination_stringed.clone()); ui.end_row(); }); @@ -423,7 +416,16 @@ fn render_app(app: &mut Hoot, ctx: &egui::Context) { // TODO: Handle delete } if ui.button("âŠī¸ Reply").clicked() { - // TODO: Handle reply + let state = ui::compose_window::ComposeWindowState { + subject: format!("Re: {}", subject), + to_field: unwrapped.sender.to_string(), + content: String::new(), + selected_account: None, + minimized: false, + }; + app.state + .compose_window + .insert(egui::Id::new(rand::random::<u32>()), state); } if ui.button("âĒī¸ Forward").clicked() { // TODO: Handle forward