Merge pull request 'Add sqlite' (#4) from sqlite into main
Some checks failed
Rust CI / build-and-test (macOS-latest) (push) Has been cancelled
Rust CI / build-and-test (ubuntu-latest) (push) Has been cancelled
Rust CI / build-and-test (windows-latest) (push) Has been cancelled
Rust CI / cross-compile (macOS-latest, x86_64-pc-windows-gnu) (push) Has been cancelled
Rust CI / cross-compile (macOS-latest, x86_64-unknown-linux-gnu) (push) Has been cancelled
Rust CI / cross-compile (ubuntu-latest, x86_64-pc-windows-gnu) (push) Has been cancelled

Reviewed-on: #4
This commit is contained in:
Jack Chakany 2025-03-14 10:11:34 -04:00
commit 4df83c9bc2
7 changed files with 463 additions and 158 deletions

170
Cargo.lock generated
View file

@ -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"
@ -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"
@ -182,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"
@ -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"
@ -1937,6 +1967,15 @@ dependencies = [
"allocator-api2",
]
[[package]]
name = "hashlink"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af"
dependencies = [
"hashbrown",
]
[[package]]
name = "hassle-rs"
version = "0.11.0"
@ -2013,16 +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",
@ -2048,6 +2091,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"
@ -2127,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"
@ -2338,6 +2423,18 @@ dependencies = [
"libc",
]
[[package]]
name = "libsqlite3-sys"
version = "0.30.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149"
dependencies = [
"cc",
"openssl-sys",
"pkg-config",
"vcpkg",
]
[[package]]
name = "linux-raw-sys"
version = "0.3.8"
@ -2803,6 +2900,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 +3479,33 @@ version = "0.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3cd14fd5e3b777a7422cca79358c57a8f6e3a703d9ac187448d0daf220c2407f"
[[package]]
name = "rusqlite"
version = "0.32.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e"
dependencies = [
"bitflags 2.6.0",
"chrono",
"fallible-iterator",
"fallible-streaming-iterator",
"hashlink",
"libsqlite3-sys",
"serde_json",
"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"
@ -3613,6 +3759,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 +4429,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"

View file

@ -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,6 +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.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"

View file

@ -0,0 +1,14 @@
CREATE TABLE IF NOT EXISTS events (
id TEXT PRIMARY KEY,
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);

109
src/db.rs Normal file
View file

@ -0,0 +1,109 @@
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 })
}
pub fn store_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();
if store_unwrapped {
let unwrapped = account_manager.unwrap_gift_wrap(event).unwrap();
let mut rumor = unwrapped.rumor.clone();
rumor.ensure_id();
let id = rumor.id.expect("Invalid Gift Wrapped Event: There is no ID!").to_hex();
let raw = json!(rumor).to_string();
self.connection.execute(
"INSERT INTO events (id, raw)
VALUES (?1, ?2)",
(id, raw),
)?;
} else {
let id = event.id.to_string();
let raw = json!(event).to_string();
self.connection.execute(
"INSERT INTO events (id, raw)
VALUES (?1, ?2)",
(id, raw),
)?;
}
Ok(())
}
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)
}
}
/// Check if an event is a gift wrap
fn is_gift_wrap(event: &Event) -> bool {
event.kind == Kind::GiftWrap
}

View file

@ -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);
}

View file

@ -5,9 +5,11 @@ 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;
mod db;
mod error;
mod keystorage;
mod mail_event;
@ -84,6 +86,7 @@ pub struct Hoot {
relays: relay::RelayPool,
events: Vec<nostr::Event>,
account_manager: account_manager::AccountManager,
db: db::Db,
}
#[derive(Debug, PartialEq)]
@ -113,7 +116,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 +167,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_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 +212,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 +230,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 +245,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 +295,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 +333,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 +369,25 @@ 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)
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()));
.unwrap_or_else(|| "No Subject".to_string());
// Message header section
ui.add_space(8.0);
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")
.num_columns(2)
@ -341,11 +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();
});
@ -363,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
@ -387,8 +449,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 +457,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 +484,7 @@ impl Hoot {
relays: relay::RelayPool::new(),
events: Vec::new(),
account_manager: account_manager::AccountManager::new(),
db,
}
}
}

View file

@ -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