Merge pull request #3 from chakanysystems/rewrite

Rewrite
This commit is contained in:
Jack Chakany 2024-08-29 22:10:28 -04:00 committed by GitHub
commit 692638221a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 1906 additions and 212 deletions

820
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -5,18 +5,26 @@ authors = ["Jack Chakany <jack@chakany.systems>"]
edition = "2021"
publish = false
[features]
profiling = ["dep:puffin", "dep:puffin_http", "eframe/puffin", "egui_extras/puffin"]
[dependencies]
eframe = { version = "0.27.2", features = ["default", "puffin"] }
eframe = { version = "0.27.2", features = ["default", "persistence"] }
egui_extras = { version = "0.27.2", features = [
"file",
"image",
"svg",
"puffin",
] }
egui_tabs = { git = "https://github.com/damus-io/egui-tabs", rev = "120971fc43db6ba0b6f194f4bd4a66f7e00a4e22" }
image = { version = "0.25.1", features = ["jpeg", "png"] }
tracing = "0.1.40"
tracing-appender = "0.2.3"
tracing-subscriber = "0.3.18"
yandk = { path = "../yandk", features = ["coordinator"] }
puffin = "0.19.0"
puffin_http = "0.16.0"
puffin = { version = "0.19.0", optional = true }
puffin_http = { version = "0.16.0", optional = true }
ewebsock = { version = "0.6.0", features = ["tls"] }
nostrdb = { git = "https://github.com/damus-io/nostrdb-rs", rev = "ee8afeeb0b6695fca6d27dd0b74a8dc159e37b95" }
rand = "0.8.5"
nostr = { version = "0.32.1", features = ["std"] }
serde = "1.0.204"
serde_json = "1.0.121"

2
Makefile Normal file
View file

@ -0,0 +1,2 @@
dev:
RUST_BACKTRACE=1 cargo run --features profiling

63
src/account_manager.rs Normal file
View file

@ -0,0 +1,63 @@
use crate::keystorage::{Error, KeyStorage, KeyStorageType};
use nostr::Keys;
pub struct AccountManager {
pub loaded_keys: Vec<Keys>,
}
impl AccountManager {
pub fn new() -> Self {
Self {
loaded_keys: Vec::new(),
}
}
pub fn generate_keys(&mut self) -> Result<Keys, Error> {
let new_keypair = Keys::generate();
self.loaded_keys.push(new_keypair.clone());
Ok(new_keypair)
}
pub fn load_keys(&mut self) -> Result<Vec<Keys>, Error> {
let mut keys = self.get_keys()?;
keys.extend(self.loaded_keys.drain(..));
keys.dedup();
self.loaded_keys = keys.clone();
Ok(keys)
}
pub fn delete_key(&mut self, key: &Keys) -> Result<(), Error> {
self.remove_key(key)?;
if let Some(index) = self.loaded_keys.iter().position(|k| k == key) {
self.loaded_keys.remove(index);
}
Ok(())
}
fn get_platform_keystorage() -> KeyStorageType {
#[cfg(target_os = "linux")]
{
return KeyStorageType::Linux;
}
#[cfg(not(target_os = "linux"))]
KeyStorageType::None
}
}
impl KeyStorage for AccountManager {
fn get_keys(&self) -> Result<Vec<Keys>, Error> {
Self::get_platform_keystorage().get_keys()
}
fn add_key(&self, key: &Keys) -> Result<(), Error> {
Self::get_platform_keystorage().add_key(key)
}
fn remove_key(&self, key: &Keys) -> Result<(), Error> {
Self::get_platform_keystorage().remove_key(key)
}
}

6
src/error.rs Normal file
View file

@ -0,0 +1,6 @@
#[derive(Debug)]
pub enum Error {
RelayNotConnected,
}
pub type Result<T> = core::result::Result<T, Error>;

118
src/keystorage/linux.rs Normal file
View file

@ -0,0 +1,118 @@
use super::{Error, KeyStorage};
use nostr::Keys;
pub struct LinuxKeyStorage {}
impl LinuxKeyStorage {
pub fn new() -> Self {
Self {}
}
}
impl KeyStorage for LinuxKeyStorage {
fn get_keys(&self) -> Result<Vec<Keys>, Error> {
let bfs = BasicFileStorage::new().get_keys()?;
Ok(bfs)
}
fn add_key(&self, key: &Keys) -> Result<(), Error> {
BasicFileStorage::new().add_key(key)?;
Ok(())
}
fn remove_key(&self, key: &Keys) -> Result<(), Error> {
BasicFileStorage::new().remove_key(key)?;
Ok(())
}
}
struct BasicFileStorage {
credentials_dir: String,
}
impl BasicFileStorage {
pub fn new() -> Self {
BasicFileStorage {
credentials_dir: ".credentials".to_string(),
}
}
fn write_private_key(&self, keypair: &Keys) -> Result<(), Error> {
use std::fs::{self, File};
use std::io::Write;
use std::os::unix::fs::PermissionsExt;
use std::path::Path;
fs::create_dir_all(&self.credentials_dir)?;
let public_key = keypair.public_key().to_hex();
let private_key = keypair.secret_key().unwrap().to_secret_hex();
let file_path = Path::new(&self.credentials_dir).join(&public_key);
let mut file = File::create(&file_path)?;
file.write_all(private_key.as_bytes())?;
let mut perms = file.metadata()?.permissions();
perms.set_mode(0o600);
fs::set_permissions(&file_path, perms)?;
Ok(())
}
fn read_private_keys(&self) -> Result<Vec<Keys>, Error> {
use std::fs::{self, File};
use std::io::Read;
use std::path::Path;
let mut keypairs: Vec<Keys> = Vec::new();
let dir_path = Path::new(&self.credentials_dir);
for entry in fs::read_dir(dir_path)? {
let entry = entry?;
let path = entry.path();
if path.is_file() {
let mut file = File::open(&path)?;
let mut contents = String::new();
file.read_to_string(&mut contents)?;
if let Ok(keypair) = Keys::parse(contents) {
keypairs.push(keypair);
}
}
}
Ok(keypairs)
}
fn remove_keypair(&self, keypair: &Keys) -> Result<(), Error> {
use std::fs;
use std::path::Path;
let public_key = keypair.public_key().to_string();
let file_path = Path::new(&self.credentials_dir).join(&public_key);
if file_path.exists() {
fs::remove_file(file_path)?;
Ok(())
} else {
Err(Error::IOError(std::io::Error::new(
std::io::ErrorKind::NotFound,
"Key file not found",
)))
}
}
}
impl KeyStorage for BasicFileStorage {
fn get_keys(&self) -> Result<Vec<Keys>, Error> {
let mmt = self.read_private_keys()?;
Ok(mmt)
}
fn add_key(&self, key: &Keys) -> Result<(), Error> {
self.write_private_key(key)?;
Ok(())
}
fn remove_key(&self, key: &Keys) -> Result<(), Error> {
self.remove_keypair(key)?;
Ok(())
}
}

68
src/keystorage/mod.rs Normal file
View file

@ -0,0 +1,68 @@
use nostr::Keys;
mod linux;
use linux::LinuxKeyStorage;
pub enum Error {
IOError(std::io::Error),
}
impl From<std::io::Error> for Error {
fn from(value: std::io::Error) -> Self {
Error::IOError(value)
}
}
impl std::fmt::Debug for Error {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Error::IOError(err) => write!(f, "IOError: {:?}", err),
}
}
}
impl std::fmt::Display for Error {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Error::IOError(err) => write!(f, "IO error: {}", err),
}
}
}
pub enum KeyStorageType {
None,
#[cfg(target_os = "linux")]
Linux,
}
pub trait KeyStorage {
fn get_keys(&self) -> Result<Vec<Keys>, Error>;
fn add_key(&self, key: &Keys) -> Result<(), Error>;
fn remove_key(&self, key: &Keys) -> Result<(), Error>;
}
impl KeyStorage for KeyStorageType {
fn add_key(&self, key: &Keys) -> Result<(), Error> {
match self {
Self::None => Ok(()),
#[cfg(target_os = "linux")]
Self::Linux => LinuxKeyStorage::new().add_key(key),
}
}
fn get_keys(&self) -> Result<Vec<Keys>, Error> {
match self {
Self::None => Ok(Vec::new()),
#[cfg(target_os = "linux")]
Self::Linux => LinuxKeyStorage::new().get_keys(),
}
}
fn remove_key(&self, key: &Keys) -> Result<(), Error> {
match self {
Self::None => Ok(()),
#[cfg(target_os = "linux")]
Self::Linux => LinuxKeyStorage::new().remove_key(key),
}
}
}

View file

@ -7,6 +7,12 @@ use egui::{Align, FontId, Layout};
use egui_extras::{Column, TableBuilder};
use tracing::{debug, error, info, Level};
mod account_manager;
mod error;
mod keystorage;
mod relay;
mod ui;
fn main() -> Result<(), eframe::Error> {
let (non_blocking, _guard) = tracing_appender::non_blocking(std::io::stdout()); // add log files in prod one day
tracing_subscriber::fmt()
@ -14,6 +20,7 @@ fn main() -> Result<(), eframe::Error> {
.with_max_level(Level::DEBUG)
.init();
#[cfg(feature = "profiling")]
start_puffin_server();
let options = eframe::NativeOptions {
@ -40,26 +47,40 @@ fn main() -> Result<(), eframe::Error> {
let _ = &cc
.egui_ctx
.style_mut(|style| style.visuals.dark_mode = false);
Box::<Hoot>::default()
Box::new(Hoot::new(cc))
}),
)
}
#[derive(Debug, PartialEq)]
enum Page {
pub enum Page {
Inbox,
Drafts,
Starred,
Archived,
Trash,
Post,
Settings,
// TODO: fix this mess
Onboarding,
OnboardingNew,
OnboardingNewShowKey,
OnboardingReturning,
}
struct Hoot {
current_page: Page,
// for storing the state of different components and such.
#[derive(Default)]
pub struct HootState {
pub onboarding: ui::onboarding::OnboardingState,
pub settings: ui::settings::SettingsState,
}
pub struct Hoot {
pub page: Page,
focused_post: String,
status: HootStatus,
nostr: yandk::coordinator::Coordinator,
state: HootState,
relays: relay::RelayPool,
ndb: nostrdb::Ndb,
events: Vec<nostr::Event>,
pub windows: Vec<Box<ui::compose_window::ComposeWindow>>,
account_manager: account_manager::AccountManager,
}
#[derive(Debug, PartialEq)]
@ -68,100 +89,128 @@ enum HootStatus {
Ready,
}
impl Default for Hoot {
fn default() -> Self {
Self::new()
}
}
fn update_app(app: &mut Hoot, ctx: &egui::Context) {
#[cfg(feature = "profiling")]
puffin::profile_function!();
impl Hoot {
fn new() -> Self {
let coordinator = yandk::coordinator::Coordinator::new();
Self {
nostr: coordinator,
current_page: Page::Inbox,
focused_post: "".into(),
status: HootStatus::Initalizing,
if app.status == HootStatus::Initalizing {
info!("Initalizing Hoot...");
let ctx = ctx.clone();
let wake_up = move || {
ctx.request_repaint();
};
match app.account_manager.load_keys() {
Ok(..) => {}
Err(v) => error!("something went wrong trying to load keys: {}", v),
}
app.relays
.add_url("wss://relay.damus.io".to_string(), wake_up.clone());
app.relays
.add_url("wss://relay-dev.hoot.sh".to_string(), wake_up);
app.status = HootStatus::Ready;
info!("Hoot Ready");
}
}
impl eframe::App for Hoot {
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
match self.status {
HootStatus::Initalizing => {
info!("Initalizing Hoot...");
self.status = HootStatus::Ready;
let cloned_ctx = ctx.clone();
let refresh_func = move || {
cloned_ctx.request_repaint();
};
let _ = self.nostr.add_relay("".to_string(), refresh_func);
let new_val = app.relays.try_recv();
if new_val.is_some() {
info!("{:?}", new_val.clone());
use relay::RelayMessage;
let deserialized: RelayMessage =
serde_json::from_str(new_val.unwrap().as_str()).expect("relay sent us bad json");
use RelayMessage::*;
match deserialized {
Event {
subscription_id,
event,
} => {
app.events.push(event);
}
_ => {
// who cares rn
}
HootStatus::Ready => self.nostr.try_recv(), // we want to recieve events now
}
}
}
egui::SidePanel::left("sidebar").show(ctx, |ui| {
ui.heading("Hoot");
ui.vertical(|ui| {
ui.style_mut()
.text_styles
.insert(Button, FontId::new(20.0, Proportional));
ui.selectable_value(&mut self.current_page, Page::Inbox, "Inbox");
ui.selectable_value(&mut self.current_page, Page::Drafts, "Drafts");
ui.selectable_value(&mut self.current_page, Page::Starred, "Starred");
ui.selectable_value(&mut self.current_page, Page::Archived, "Archived");
ui.selectable_value(&mut self.current_page, Page::Trash, "Trash");
fn render_app(app: &mut Hoot, ctx: &egui::Context) {
#[cfg(feature = "profiling")]
puffin::profile_function!();
ui.with_layout(Layout::bottom_up(Align::LEFT), |ui| {
let my_key = yandk::Pubkey::from_hex(
"c5fb6ecc876e0458e3eca9918e370cbcd376901c58460512fe537a46e58c38bb",
)
.unwrap();
let maybe_profile = match self.nostr.get_profile(my_key.bytes()) {
Ok(p) => p,
Err(e) => {
error!("error when getting profile: {}", e);
ui.label("Loading...");
ui.label("Loading...");
None
}
};
if let Some(p) = maybe_profile {
let record = p.record();
if let Some(profile) = record.profile() {
if let Some(nip_05) = profile.nip05() {
ui.label(nip_05);
} else {
ui.label("No Nostr Address");
}
if let Some(display_name) = profile.display_name() {
ui.label(display_name);
} else if let Some(name) = profile.name() {
ui.label(format!("@{}", name));
} else {
let hex = my_key.hex();
ui.label(hex);
}
}
} else {
ui.label("Loading...");
ui.label("Loading...");
}
ui.separator();
});
});
if app.page == Page::Onboarding
|| app.page == Page::OnboardingNew
|| app.page == Page::OnboardingNewShowKey
|| app.page == Page::OnboardingReturning
{
egui::CentralPanel::default().show(ctx, |ui| {
ui::onboarding::OnboardingScreen::ui(app, ui);
});
egui::TopBottomPanel::top("search").show(ctx, |ui| {
} else {
egui::SidePanel::left("Side Navbar").show(ctx, |ui| {
ui.heading("Hoot");
if ui.button("Inbox").clicked() {
app.page = Page::Inbox;
}
if ui.button("Drafts").clicked() {
app.page = Page::Drafts;
}
if ui.button("Settings").clicked() {
app.page = Page::Settings;
}
if ui.button("Onboarding").clicked() {
app.page = Page::Onboarding;
}
});
egui::TopBottomPanel::top("Search").show(ctx, |ui| {
ui.heading("Search");
});
egui::CentralPanel::default().show(ctx, |ui| match self.current_page {
Page::Inbox => {
ui.horizontal(|ui| {
ui.checkbox(&mut false, "");
ui.heading("Inbox");
});
egui::CentralPanel::default().show(ctx, |ui| {
// todo: fix
for window in &mut app.windows {
window.show(ui);
}
if app.page == Page::Inbox {
ui.label("hello there!");
if ui.button("Compose").clicked() {
let mut new_window = Box::new(ui::compose_window::ComposeWindow::new());
new_window.show(ui);
app.windows.push(new_window);
}
if ui.button("Send Test Event").clicked() {
let temp_keys = nostr::Keys::generate();
// todo: lmao
let new_event = nostr::EventBuilder::text_note("GFY!", [])
.to_event(&temp_keys)
.unwrap();
let event_json = crate::relay::ClientMessage::Event { event: new_event };
let _ = &app
.relays
.send(ewebsock::WsMessage::Text(
serde_json::to_string(&event_json).unwrap(),
))
.unwrap();
}
if ui.button("Get kind 1 notes").clicked() {
let mut filter = nostr::types::Filter::new();
filter = filter.kind(nostr::Kind::TextNote);
let mut sub = crate::relay::Subscription::default();
sub.filter(filter);
let c_msg = crate::relay::ClientMessage::from(sub);
let _ = &app
.relays
.send(ewebsock::WsMessage::Text(
serde_json::to_string(&c_msg).unwrap(),
))
.unwrap();
}
TableBuilder::new(ui)
.column(Column::auto())
.column(Column::auto())
@ -169,11 +218,47 @@ impl eframe::App for Hoot {
.column(Column::remainder())
.column(Column::remainder())
.striped(true)
.auto_shrink(Vec2b { x: false, y: false })
.sense(Sense::click())
.auto_shrink(Vec2b { x: false, y: false })
.header(20.0, |_header| {})
.body(|mut body| {
puffin::profile_scope!("table rendering");
for event in app.events.clone() {
body.row(30.0, |mut row| {
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| {
ui.label(event.content.clone());
});
row.col(|ui| {
ui.label("2 minutes ago");
});
});
}
body.row(30.0, |mut row| {
row.col(|ui| {
ui.checkbox(&mut false, "");
});
row.col(|ui| {
ui.checkbox(&mut false, "");
});
row.col(|ui| {
ui.label("Elon Musk");
});
row.col(|ui| {
ui.label("Second Test Message");
});
row.col(|ui| {
ui.label("2 minutes ago");
});
});
body.row(30.0, |mut row| {
row.col(|ui| {
ui.checkbox(&mut false, "");
@ -185,59 +270,51 @@ impl eframe::App for Hoot {
ui.label("Jack Chakany");
});
row.col(|ui| {
ui.label("Hello! Just checking in...");
ui.label("Message Content");
});
row.col(|ui| {
ui.label("5 min ago");
ui.label("5 minutes ago");
});
if row.response().clicked() {
self.current_page = Page::Post;
}
});
body.row(30.0, |mut row| {
row.col(|ui| {
ui.checkbox(&mut false, "");
});
row.col(|ui| {
ui.checkbox(&mut false, "");
});
row.col(|ui| {
ui.label("Karnage");
});
row.col(|ui| {
ui.label("New designs!");
});
row.col(|ui| {
ui.label("10 min ago");
});
if row.response().clicked() {
self.current_page = Page::Post;
}
});
});
}
Page::Drafts => {
ui.heading("Drafts");
}
Page::Starred => {
ui.heading("Starred");
}
Page::Archived => {
ui.heading("Archived");
}
Page::Trash => {
ui.heading("Trash");
}
Page::Post => {
// used for viewing messages duh
ui.heading("Message");
ui.label(format!("{}", self.focused_post));
} else if app.page == Page::Settings {
ui.heading("Settings");
ui::settings::SettingsScreen::ui(app, ui);
}
});
}
}
impl Hoot {
fn new(cc: &eframe::CreationContext<'_>) -> Self {
let storage_dir = eframe::storage_dir("Hoot").unwrap();
let mut ndb_config = nostrdb::Config::new();
ndb_config.set_ingester_threads(3);
let ndb = nostrdb::Ndb::new(storage_dir.to_str().unwrap(), &ndb_config)
.expect("could not load nostrdb");
Self {
page: Page::Inbox,
focused_post: "".into(),
status: HootStatus::Initalizing,
state: Default::default(),
relays: relay::RelayPool::new(),
ndb,
events: Vec::new(),
windows: Vec::new(),
account_manager: account_manager::AccountManager::new(),
}
}
}
impl eframe::App for Hoot {
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
update_app(self, ctx);
render_app(self, ctx);
}
}
#[cfg(feature = "profiling")]
fn start_puffin_server() {
puffin::set_scopes_on(true); // tell puffin to collect data

195
src/relay/message.rs Normal file
View file

@ -0,0 +1,195 @@
use ewebsock::WsMessage;
use nostr::types::Filter;
use nostr::Event;
use serde::de::{SeqAccess, Visitor};
use serde::ser::SerializeSeq;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use std::fmt::{self};
/// Messages that are client <- relay.
#[derive(Debug, Clone)]
pub enum RelayMessage {
Event {
subscription_id: String,
event: Event,
},
Ok {
event_id: String,
accepted: bool,
message: String,
},
Eose {
subscription_id: String,
},
Closed {
subscription_id: String,
message: String,
},
Notice {
message: String,
},
}
impl From<WsMessage> for RelayMessage {
fn from(value: WsMessage) -> Self {
match value {
WsMessage::Text(text) => {
let parsed: RelayMessage = match serde_json::from_str(&text) {
Ok(p) => p,
Err(e) => {
panic!("could not parse message: {}", e);
}
};
return parsed;
}
_ => {
panic!("Cannot parse anything but text into a RelayMessage");
}
}
}
}
impl<'de> Deserialize<'de> for RelayMessage {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
struct RelayMessageVisitor;
impl<'de> Visitor<'de> for RelayMessageVisitor {
type Value = RelayMessage;
fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
formatter.write_str("a sequence starting with 'EVENT' or 'OK'")
}
fn visit_seq<A>(self, mut seq: A) -> Result<Self::Value, A::Error>
where
A: SeqAccess<'de>,
{
let tag: &str = seq
.next_element()?
.ok_or_else(|| serde::de::Error::invalid_length(0, &self))?;
match tag {
"EVENT" => {
let subscription_id: String = seq
.next_element()?
.ok_or_else(|| serde::de::Error::invalid_length(1, &self))?;
let event: Event = seq
.next_element()?
.ok_or_else(|| serde::de::Error::invalid_length(2, &self))?;
Ok(RelayMessage::Event {
subscription_id,
event,
})
}
"OK" => {
let event_id: String = seq
.next_element()?
.ok_or_else(|| serde::de::Error::invalid_length(1, &self))?;
let accepted: bool = seq
.next_element()?
.ok_or_else(|| serde::de::Error::invalid_length(2, &self))?;
let message: String = seq
.next_element()?
.ok_or_else(|| serde::de::Error::invalid_length(3, &self))?;
Ok(RelayMessage::Ok {
event_id,
accepted,
message,
})
}
"EOSE" => {
let subscription_id: String = seq
.next_element()?
.ok_or_else(|| serde::de::Error::invalid_length(1, &self))?;
Ok(RelayMessage::Eose { subscription_id })
}
"CLOSED" => {
let subscription_id: String = seq
.next_element()?
.ok_or_else(|| serde::de::Error::invalid_length(1, &self))?;
let message: String = seq
.next_element()?
.ok_or_else(|| serde::de::Error::invalid_length(2, &self))?;
Ok(RelayMessage::Closed {
subscription_id,
message,
})
}
"NOTICE" => {
let message: String = seq
.next_element()?
.ok_or_else(|| serde::de::Error::invalid_length(1, &self))?;
Ok(RelayMessage::Notice { message })
}
_ => Err(serde::de::Error::invalid_value(
serde::de::Unexpected::Str(&tag),
&self,
)),
}
}
}
deserializer.deserialize_seq(RelayMessageVisitor)
}
}
/// Messages that are client -> relay.
#[derive(Debug, Clone)]
pub enum ClientMessage {
Event {
event: Event,
},
Req {
subscription_id: String,
filters: Vec<Filter>,
},
Close {
subscription_id: String,
},
}
impl From<super::Subscription> for ClientMessage {
fn from(value: super::Subscription) -> Self {
Self::Req {
subscription_id: value.id,
filters: value.filters,
}
}
}
impl Serialize for ClientMessage {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
match self {
ClientMessage::Event { event } => {
let mut seq = serializer.serialize_seq(Some(2))?;
seq.serialize_element("EVENT")?;
seq.serialize_element(event)?;
seq.end()
}
ClientMessage::Req {
subscription_id,
filters,
} => {
let mut seq = serializer.serialize_seq(Some(2 + filters.len()))?;
seq.serialize_element("REQ")?;
seq.serialize_element(subscription_id)?;
for filter in filters {
seq.serialize_element(filter)?;
}
seq.end()
}
ClientMessage::Close { subscription_id } => {
let mut seq = serializer.serialize_seq(Some(2))?;
seq.serialize_element("CLOSE")?;
seq.serialize_element(subscription_id)?;
seq.end()
}
}
}
}

120
src/relay/mod.rs Normal file
View file

@ -0,0 +1,120 @@
use ewebsock::{WsEvent, WsMessage};
use tracing::{error, info};
use crate::error::{Error, Result};
mod pool;
pub use pool::RelayPool;
mod message;
pub use message::{ClientMessage, RelayMessage};
mod subscription;
pub use subscription::Subscription;
#[derive(PartialEq, Clone, Copy)]
pub enum RelayStatus {
Connecting,
Connected,
Disconnected,
}
pub struct Relay {
pub url: String,
reader: ewebsock::WsReceiver,
writer: ewebsock::WsSender,
pub status: RelayStatus,
}
impl Relay {
pub fn new_with_wakeup(
url: impl Into<String>,
wake_up: impl Fn() + Send + Sync + 'static,
) -> Self {
let new_url: String = url.into();
let (sender, reciever) =
ewebsock::connect_with_wakeup(new_url.clone(), ewebsock::Options::default(), wake_up)
.unwrap();
let mut relay = Self {
url: new_url,
reader: reciever,
writer: sender,
status: RelayStatus::Connecting,
};
relay.ping();
relay
}
pub fn send(&mut self, message: WsMessage) -> Result<()> {
if self.status != RelayStatus::Connected {
return Err(Error::RelayNotConnected);
}
self.writer.send(message);
Ok(())
}
pub fn try_recv(&mut self) -> Option<String> {
if let Some(event) = self.reader.try_recv() {
use WsEvent::*;
match event {
Message(message) => {
return self.handle_message(message);
}
Opened => {
self.status = RelayStatus::Connected;
}
Error(error) => {
error!("error in websocket connection to {}: {}", self.url, error);
}
Closed => {
info!("connection to {} closed", self.url);
self.status = RelayStatus::Disconnected;
}
}
}
None
}
fn handle_message(&mut self, message: WsMessage) -> Option<String> {
use WsMessage::*;
match message {
Text(txt) => {
return Some(txt);
}
Binary(..) => {
error!("recived binary messsage, your move semisol");
}
Ping(d) => {
let pong_msg = WsMessage::Pong(d);
match self.send(pong_msg) {
Ok(_) => {}
Err(e) => error!("error when sending websocket message {:?}", e),
}
}
_ => {
// who cares
}
}
None
}
pub fn ping(&mut self) {
let ping_msg = WsMessage::Ping(Vec::new());
match self.send(ping_msg) {
Ok(_) => {
info!("Ping sent to {}", self.url);
self.status = RelayStatus::Connected;
}
Err(e) => {
error!("Error sending ping to {}: {:?}", self.url, e);
self.status = RelayStatus::Disconnected;
}
}
}
}

49
src/relay/pool.rs Normal file
View file

@ -0,0 +1,49 @@
use crate::error::Result;
use crate::relay::{Relay, RelayStatus};
use std::collections::HashMap;
pub struct RelayPool {
pub relays: HashMap<String, Relay>,
}
impl RelayPool {
pub fn new() -> Self {
Self {
relays: HashMap::new(),
}
}
pub fn add_url(&mut self, url: String, wake_up: impl Fn() + Send + Sync + 'static) {
let relay = Relay::new_with_wakeup(url.clone(), wake_up);
self.relays.insert(url, relay);
}
pub fn remove_url(&mut self, url: &str) -> Option<Relay> {
self.relays.remove(url)
}
pub fn try_recv(&mut self) -> Option<String> {
for relay in self.relays.values_mut() {
if let Some(message) = relay.try_recv() {
return Some(message);
}
}
None
}
pub fn send(&mut self, message: ewebsock::WsMessage) -> Result<()> {
for relay in self.relays.values_mut() {
if relay.status == RelayStatus::Connected {
relay.send(message.clone())?;
}
}
Ok(())
}
pub fn ping_all(&mut self) -> Result<()> {
for relay in self.relays.values_mut() {
relay.ping();
}
Ok(())
}
}

29
src/relay/subscription.rs Normal file
View file

@ -0,0 +1,29 @@
use nostr::types::Filter;
use rand::{distributions::Alphanumeric, Rng};
#[derive(Debug, PartialEq, Eq, Clone)]
pub struct Subscription {
pub id: String,
pub filters: Vec<Filter>,
}
impl Default for Subscription {
fn default() -> Self {
let s: String = rand::thread_rng()
.sample_iter(&Alphanumeric)
.take(7)
.map(char::from)
.collect();
Self::new(s, vec![])
}
}
impl Subscription {
pub fn new(id: String, filters: Vec<Filter>) -> Self {
Self { id, filters }
}
pub fn filter(&mut self, filter: Filter) {
self.filters.push(filter)
}
}

39
src/ui/compose_window.rs Normal file
View file

@ -0,0 +1,39 @@
use crate::Hoot;
use eframe::egui::{self, RichText};
use rand::random;
pub struct ComposeWindow {
title: Option<RichText>,
id: egui::Id,
subject: String,
to_field: String,
content: String,
}
impl ComposeWindow {
pub fn new() -> Self {
Self {
title: None,
id: egui::Id::new(random::<u32>()),
subject: String::from("New Message"),
to_field: String::new(),
content: String::new(),
}
}
pub fn show(&mut self, ui: &mut egui::Ui) {
egui::Window::new(&self.subject)
.id(self.id)
.show(ui.ctx(), |ui| {
ui.label("Hello!");
ui.vertical(|ui| {
ui.text_edit_singleline(&mut self.to_field);
ui.text_edit_singleline(&mut self.subject);
ui.add_sized(
ui.available_size(),
egui::TextEdit::multiline(&mut self.content),
);
});
});
}
}

9
src/ui/mod.rs Normal file
View file

@ -0,0 +1,9 @@
use eframe::egui;
pub mod compose_window;
pub mod onboarding;
pub mod settings;
pub trait View {
fn ui(&mut self, ui: &mut egui::Ui);
}

95
src/ui/onboarding.rs Normal file
View file

@ -0,0 +1,95 @@
use crate::{Hoot, Page};
use eframe::egui;
use tracing::error;
#[derive(Default)]
pub struct OnboardingState {
// for nsecs, etc.
pub secret_input: String,
}
pub struct OnboardingScreen {}
impl OnboardingScreen {
pub fn ui(app: &mut Hoot, ui: &mut egui::Ui) {
ui.heading("Welcome to Hoot Mail!");
match app.page {
Page::Onboarding => Self::onboarding_home(app, ui),
Page::OnboardingNew => Self::onboarding_new(app, ui),
Page::OnboardingNewShowKey => Self::onboarding_new_keypair_generated(app, ui),
Page::OnboardingReturning => Self::onboarding_returning(app, ui),
_ => error!("OnboardingScreen should not be displayed when page is not Onboarding!"),
}
}
fn onboarding_home(app: &mut Hoot, ui: &mut egui::Ui) {
if ui.button("I am new to Hoot Mail").clicked() {
app.page = Page::OnboardingNew;
}
if ui.button("I have used Hoot Mail before.").clicked() {
app.page = Page::OnboardingReturning;
}
}
fn onboarding_new(app: &mut Hoot, ui: &mut egui::Ui) {
if ui.button("Go Back").clicked() {
app.page = Page::Onboarding;
}
ui.label("To setup Hoot Mail, you need a nostr identity.");
if ui.button("Create new keypair").clicked() {
let _ = app.account_manager.generate_keys();
app.page = Page::OnboardingNewShowKey;
}
}
fn onboarding_new_keypair_generated(app: &mut Hoot, ui: &mut egui::Ui) {
use crate::keystorage::KeyStorage;
use nostr::ToBech32;
let first_key = app.account_manager.loaded_keys[0].clone();
ui.label(format!(
"New identity: {}",
first_key.public_key().to_bech32().unwrap()
));
if ui.button("OK, Save!").clicked() {
app.account_manager
.add_key(&first_key)
.expect("could not write key");
app.page = Page::Inbox;
}
}
fn onboarding_returning(app: &mut Hoot, ui: &mut egui::Ui) {
if ui.button("Go Back").clicked() {
app.page = Page::Onboarding;
}
ui.label("Welcome Back!");
let parsed_secret_key = nostr::SecretKey::parse(&app.state.onboarding.secret_input);
let valid_key = parsed_secret_key.is_ok();
ui.horizontal(|ui| {
ui.label("Please enter your nsec here:");
ui.text_edit_singleline(&mut app.state.onboarding.secret_input);
match valid_key {
true => ui.colored_label(egui::Color32::LIGHT_GREEN, "✔ Key Valid"),
false => ui.colored_label(egui::Color32::RED, "⊗ Key Invalid"),
}
});
if ui
.add_enabled(valid_key, egui::Button::new("Save"))
.clicked()
{
use crate::keystorage::KeyStorage;
let keypair = nostr::Keys::new(parsed_secret_key.unwrap());
let _ = app.account_manager.add_key(&keypair);
let _ = app.account_manager.load_keys();
app.page = Page::Inbox;
}
}
}

134
src/ui/settings.rs Normal file
View file

@ -0,0 +1,134 @@
use crate::Hoot;
use eframe::egui::{self, Color32, Direction, Layout, Sense, Ui, Vec2};
use egui_tabs::Tabs;
use tracing::error;
#[derive(Default)]
pub struct SettingsState {
pub new_relay_url: String,
}
enum Tab {
Profile = 0,
Relays = 1,
Identity = 2,
}
impl From<i32> for Tab {
fn from(value: i32) -> Self {
match value {
0 => Tab::Profile,
1 => Tab::Relays,
2 => Tab::Identity,
_ => Tab::Profile, // Default to Profile for invalid values
}
}
}
impl From<Tab> for i32 {
fn from(tab: Tab) -> Self {
tab as i32
}
}
pub struct SettingsScreen {}
impl SettingsScreen {
pub fn ui(app: &mut Hoot, ui: &mut Ui) {
let tabs_response = Tabs::new(3)
.height(16.0)
.selected(0)
.layout(Layout::centered_and_justified(Direction::TopDown))
.show(ui, |ui, state| {
let current_tab = Tab::from(state.index());
use Tab::*;
let tab_label = match current_tab {
Profile => "My Profile",
Relays => "Relays",
Identity => "Keys",
};
ui.add(egui::Label::new(tab_label).selectable(false));
});
let current_tab: Tab = tabs_response.selected().unwrap().into();
use Tab::*;
match current_tab {
Profile => Self::profile(app, ui),
Relays => Self::relays(app, ui),
Identity => Self::identity(app, ui),
}
}
fn profile(app: &mut Hoot, ui: &mut Ui) {
ui.label("Your profile.");
}
fn relays(app: &mut Hoot, ui: &mut Ui) {
ui.heading("Relays");
ui.small("A relay is a server that Hoot connects with to send & receive messages.");
ui.label("Add New Relay:");
ui.horizontal(|ui| {
let new_relay = &mut app.state.settings.new_relay_url;
ui.text_edit_singleline(new_relay);
if ui.button("Add Relay").clicked() && !new_relay.is_empty() {
let ctx = ui.ctx().clone();
let wake_up = move || {
ctx.request_repaint();
};
app.relays.add_url(new_relay.clone(), wake_up);
app.state.settings.new_relay_url = String::new(); // clears field
}
});
ui.add_space(10.0);
ui.label("Your Relays:");
ui.vertical(|ui| {
let mut relay_to_remove: Option<String> = None;
for (url, relay) in app.relays.relays.iter() {
ui.horizontal(|ui| {
use crate::relay::RelayStatus::*;
let conn_fill: Color32 = match relay.status {
Connecting => Color32::YELLOW,
Connected => Color32::LIGHT_GREEN,
Disconnected => Color32::RED,
};
let size = Vec2::splat(12.0);
let (response, painter) = ui.allocate_painter(size, Sense::hover());
let rect = response.rect;
let c = rect.center();
let r = rect.width() / 2.0 - 1.0;
painter.circle_filled(c, r, conn_fill);
ui.label(url);
if ui.button("Remove Relay").clicked() {
relay_to_remove = Some(url.to_string());
}
});
}
if relay_to_remove.is_some() {
app.relays.remove_url(&relay_to_remove.unwrap());
}
});
}
fn identity(app: &mut Hoot, ui: &mut Ui) {
ui.vertical(|ui| {
use nostr::ToBech32;
for key in app.account_manager.loaded_keys.clone() {
ui.horizontal(|ui| {
ui.label(format!("Key ID: {}", key.public_key().to_bech32().unwrap()));
if ui.button("Remove Key").clicked() {
match app.account_manager.delete_key(&key) {
Ok(..) => {}
Err(v) => error!("couldn't remove key: {}", v),
}
}
});
}
});
}
}