Compare commits

...
Sign in to create a new pull request.

4 commits

Author SHA1 Message Date
openhands
34d0c3e51b Fix event deserialization using RelayMessage type 2025-01-10 03:43:54 +00:00
openhands
a95a74dd73 Improve inbox table functionality
- Add proper mail event filtering and sorting
- Improve column layout and content display
- Add relative time formatting
- Handle invalid mail events
- Add star button placeholder
2025-01-10 02:41:01 +00:00
openhands
88b0b4aa01 Fix compilation errors in UI improvements 2025-01-10 02:16:14 +00:00
openhands
8b6514679c Improve UI design and user experience
- Add consistent theme with custom colors and spacing
- Enhance side navigation with icons and better organization
- Improve inbox view with better table layout and visual feedback
- Redesign onboarding screens for better user experience
- Add search bar improvements
2025-01-10 02:05:18 +00:00
6 changed files with 565 additions and 224 deletions

46
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"
@ -896,6 +902,20 @@ 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",
"js-sys",
"num-traits",
"wasm-bindgen",
"windows-targets 0.52.5",
]
[[package]]
name = "cipher"
version = "0.4.4"
@ -2013,6 +2033,7 @@ dependencies = [
name = "hoot-app"
version = "0.1.0"
dependencies = [
"chrono",
"eframe",
"egui_extras",
"egui_tabs",
@ -2048,6 +2069,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"

View file

@ -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"
chrono = "0.4"
[target.'cfg(target_os = "macos")'.dependencies]
security-framework = "3.0.0"

View file

@ -2,6 +2,25 @@ use nostr::{Event, EventBuilder, Keys, Kind, PublicKey, Tag, TagKind, TagStandar
use std::collections::HashMap;
use pollster::FutureExt as _;
#[derive(Debug)]
pub struct MailEvent {
pub sender: PublicKey,
pub rumor: Event,
}
impl MailEvent {
pub fn from_event(event: &Event) -> Result<Self, &'static str> {
if event.kind != Kind::Custom(MAIL_EVENT_KIND) {
return Err("Not a mail event");
}
Ok(Self {
sender: event.pubkey,
rumor: event.clone(),
})
}
}
pub const MAIL_EVENT_KIND: u16 = 1059;
pub struct MailMessage {

View file

@ -1,11 +1,47 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] // for windows release
use eframe::egui::{self, FontDefinitions, Sense, Vec2b};
use eframe::egui::{self, FontDefinitions};
use egui::FontFamily::Proportional;
use egui_extras::{Column, TableBuilder};
use std::collections::HashMap;
use tracing::{debug, error, info, Level};
fn truncate_string(s: &str, max_chars: usize) -> String {
if s.chars().count() <= max_chars {
s.to_string()
} else {
let mut truncated: String = s.chars().take(max_chars - 3).collect();
truncated.push_str("...");
truncated
}
}
fn format_time(timestamp: i64) -> String {
use std::time::{SystemTime, UNIX_EPOCH};
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs() as i64;
let diff = now - timestamp;
if diff < 60 {
"Just now".to_string()
} else if diff < 3600 {
format!("{}m ago", diff / 60)
} else if diff < 86400 {
format!("{}h ago", diff / 3600)
} else if diff < 604800 {
format!("{}d ago", diff / 86400)
} else {
// Format as date if older than a week
let dt = chrono::NaiveDateTime::from_timestamp_opt(timestamp, 0)
.unwrap_or_default();
dt.format("%b %d, %Y").to_string()
}
}
mod account_manager;
mod error;
mod keystorage;
@ -32,7 +68,25 @@ fn main() -> Result<(), eframe::Error> {
"Hoot",
options,
Box::new(|cc| {
let _ = &cc.egui_ctx.set_visuals(egui::Visuals::light());
let mut style = egui::Style::default();
style.spacing.item_spacing = egui::vec2(10.0, 10.0);
style.spacing.window_margin = egui::Margin::same(16.0);
style.visuals.widgets.noninteractive.rounding = egui::Rounding::same(8.0);
style.visuals.widgets.inactive.rounding = egui::Rounding::same(8.0);
style.visuals.widgets.active.rounding = egui::Rounding::same(8.0);
style.visuals.widgets.hovered.rounding = egui::Rounding::same(8.0);
style.visuals.window_rounding = egui::Rounding::same(10.0);
// Custom colors
let accent_color = egui::Color32::from_rgb(79, 70, 229); // Indigo
style.visuals.selection.bg_fill = accent_color;
style.visuals.widgets.active.bg_fill = accent_color;
style.visuals.widgets.active.weak_bg_fill = accent_color.linear_multiply(0.3);
style.visuals.widgets.hovered.bg_fill = accent_color.linear_multiply(0.8);
style.visuals.hyperlink_color = accent_color;
let _ = &cc.egui_ctx.set_style(style);
let mut fonts = FontDefinitions::default();
fonts.font_data.insert(
"Inter".to_owned(),
@ -44,9 +98,6 @@ fn main() -> Result<(), eframe::Error> {
.unwrap()
.insert(0, "Inter".to_owned());
let _ = &cc.egui_ctx.set_fonts(fonts);
let _ = &cc
.egui_ctx
.style_mut(|style| style.visuals.dark_mode = false);
Box::new(Hoot::new(cc))
}),
)
@ -124,10 +175,11 @@ fn update_app(app: &mut Hoot, ctx: &egui::Context) {
app.relays.keepalive(wake_up);
let new_val = app.relays.try_recv();
if new_val.is_some() {
info!("{:?}", new_val.clone());
match relay::RelayMessage::from_json(&new_val.unwrap()) {
if let Some(msg) = new_val {
info!("Received message: {:?}", msg);
// First parse the message array using RelayMessage
match relay::RelayMessage::from_json(&msg) {
Ok(v) => process_message(app, &v),
Err(e) => error!("could not decode message sent from relay: {}", e),
};
@ -149,19 +201,31 @@ fn process_event(app: &mut Hoot, _sub_id: &str, event_json: &str) {
#[cfg(feature = "profiling")]
puffin::profile_function!();
// 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");
// Parse the full message array using RelayMessage
match relay::RelayMessage::from_json(event_json) {
Ok(relay::RelayMessage::Event(sub_id, event_str)) => {
// Now parse the actual event from the event string
if let Ok(event) = serde_json::from_str::<nostr::Event>(event_str) {
// Verify the event signature
if event.verify().is_ok() {
debug!("Verified event: {:?}", event);
app.events.push(event);
} else {
error!("Event verification failed");
}
} else {
error!("Failed to parse event JSON from relay message: {}", event_str);
}
}
Ok(_) => {
error!("Unexpected relay message type in process_event");
}
Err(e) => {
error!("Failed to parse relay message: {:?}", e);
}
} else {
error!("Failed to parse event JSON: {}", event_json);
}
}
}
fn render_app(app: &mut Hoot, ctx: &egui::Context) {
#[cfg(feature = "profiling")]
@ -176,25 +240,67 @@ fn render_app(app: &mut Hoot, ctx: &egui::Context) {
ui::onboarding::OnboardingScreen::ui(app, 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::SidePanel::left("Side Navbar")
.resizable(false)
.default_width(200.0)
.show(ctx, |ui| {
ui.vertical_centered(|ui| {
ui.add_space(8.0);
ui.heading("🦉 Hoot");
ui.add_space(16.0);
});
ui.separator();
ui.add_space(8.0);
egui::TopBottomPanel::top("Search").show(ctx, |ui| {
ui.heading("Search");
});
let nav_button = |ui: &mut egui::Ui, text: &str, icon: &str, selected: bool| {
let response = ui.add(egui::Button::new(
egui::RichText::new(format!("{} {}", icon, text))
.size(16.0)
).fill(if selected {
ui.style().visuals.widgets.active.weak_bg_fill
} else {
ui.style().visuals.widgets.inactive.bg_fill
}));
response.clicked()
};
if nav_button(ui, "Inbox", "📥", app.page == Page::Inbox) {
app.page = Page::Inbox;
}
if nav_button(ui, "Drafts", "📝", app.page == Page::Drafts) {
app.page = Page::Drafts;
}
ui.add_space(8.0);
ui.separator();
ui.add_space(8.0);
if nav_button(ui, "Settings", "⚙️", app.page == Page::Settings) {
app.page = Page::Settings;
}
if nav_button(ui, "Onboarding", "🚀", app.page == Page::Onboarding) {
app.page = Page::Onboarding;
}
});
egui::TopBottomPanel::top("Search")
.exact_height(60.0)
.show(ctx, |ui| {
ui.add_space(12.0);
ui.horizontal(|ui| {
ui.add_space(8.0);
let search_width = ui.available_width() - 16.0;
let search = ui.add(
egui::TextEdit::singleline(&mut String::new())
.hint_text("Search messages...")
.desired_width(search_width)
);
if search.gained_focus() {
// TODO: Handle search focus
}
});
});
egui::CentralPanel::default().show(ctx, |ui| {
// todo: fix
@ -203,89 +309,177 @@ fn render_app(app: &mut Hoot, ctx: &egui::Context) {
}
if app.page == Page::Inbox {
ui.label("hello there!");
if ui.button("Compose").clicked() {
let state = ui::compose_window::ComposeWindowState {
subject: String::new(),
to_field: String::new(),
content: String::new(),
selected_account: None,
};
app.state
.compose_window
.insert(egui::Id::new(rand::random::<u32>()), state);
ui.horizontal(|ui| {
ui.heading("Inbox");
ui.add_space(ui.available_width() - 120.0); // Push compose to right
if ui.add(egui::Button::new(
egui::RichText::new("✏️ Compose")
.size(16.0)
).min_size(egui::vec2(100.0, 32.0))).clicked() {
let state = ui::compose_window::ComposeWindowState {
subject: String::new(),
to_field: String::new(),
content: String::new(),
selected_account: None,
};
app.state
.compose_window
.insert(egui::Id::new(rand::random::<u32>()), state);
}
});
ui.add_space(8.0);
// Debug buttons in collapsing section
let debug_header = ui.collapsing("Debug Controls", |ui| {
if ui.button("Send Test Event").clicked() {
let temp_keys = nostr::Keys::generate();
let new_event = nostr::EventBuilder::text_note("GFY!")
.sign_with_keys(&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();
}
ui.label(format!("Total events: {}", app.events.len()));
});
if debug_header.header_response.clicked() {
ui.add_space(8.0);
}
if ui.button("Send Test Event").clicked() {
let temp_keys = nostr::Keys::generate();
// todo: lmao
let new_event = nostr::EventBuilder::text_note("GFY!")
.sign_with_keys(&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();
}
// Filter and sort events
let mut filtered_events: Vec<_> = app.events.iter()
.filter(|event| {
// Only show mail events
event.kind == nostr::Kind::Custom(mail_event::MAIL_EVENT_KIND)
})
.collect();
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);
// Sort by created_at in descending order (newest first)
filtered_events.sort_by(|a, b| b.created_at.cmp(&a.created_at));
let _ = &app
.relays
.send(ewebsock::WsMessage::Text(
serde_json::to_string(&c_msg).unwrap(),
))
.unwrap();
}
ui.label(format!("total events rendered: {}", app.events.len()));
TableBuilder::new(ui)
.column(Column::auto())
.column(Column::auto())
.column(Column::remainder())
.column(Column::remainder())
.column(Column::remainder())
.striped(true)
.sense(Sense::click())
.auto_shrink(Vec2b { x: false, y: false })
.header(20.0, |_header| {})
.body(|mut body| {
let row_height = 30.0;
let events = app.events.clone();
body.rows(row_height, events.len(), |mut row| {
let event = &events[row.index()];
row.col(|ui| {
// Create a scrollable table
egui::ScrollArea::vertical().show(ui, |ui| {
let table = TableBuilder::new(ui)
.striped(true)
.resizable(true)
.cell_layout(egui::Layout::left_to_right(egui::Align::Center))
.column(Column::auto().at_least(30.0).clip(true)) // Select
.column(Column::auto().at_least(30.0).clip(true)) // Star
.column(Column::initial(200.0).at_least(150.0).clip(true)) // From
.column(Column::remainder().at_least(300.0).clip(true)) // Subject & Content
.column(Column::initial(120.0).at_least(120.0).clip(true)) // Time
.header(32.0, |mut header| {
header.col(|ui| {
ui.checkbox(&mut false, "");
});
row.col(|ui| {
header.col(|ui| {
ui.centered_and_justified(|ui| {
ui.label("");
});
});
header.col(|ui| {
ui.strong("From");
});
header.col(|ui| {
ui.strong("Subject & Content");
});
header.col(|ui| {
ui.strong("Time");
});
});
table.body(|mut body| {
let row_height = 40.0;
body.rows(row_height, filtered_events.len(), |mut row| {
let event = &filtered_events[row.index()];
let is_selected = app.focused_post == event.id.to_string();
// Try to parse the mail event
let mail_event = match mail_event::MailEvent::from_event(event) {
Ok(event) => event,
Err(_) => return, // Skip invalid mail events
};
// Extract subject and content
let subject = mail_event.rumor.tags.iter()
.find(|tag| matches!(tag.kind(), nostr::TagKind::Subject))
.and_then(|tag| tag.content())
.map(|s| s.to_string())
.unwrap_or_else(|| "No Subject".to_string());
let content = mail_event.rumor.content.clone();
// Format the time
let time_str = format_time(event.created_at.as_u64() as i64);
// Render row
let row_response = row.col(|ui| {
ui.checkbox(&mut false, "");
});
}).1;
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");
ui.centered_and_justified(|ui| {
if ui.selectable_label(false, "").clicked() {
// TODO: Handle starring
}
});
});
if row.response().clicked() {
println!("clicked: {}", event.content.clone());
row.col(|ui| {
let text = egui::RichText::new(truncate_string(&mail_event.sender.to_string(), 20));
ui.label(if is_selected { text.strong() } else { text });
});
row.col(|ui| {
ui.vertical(|ui| {
let subject_text = egui::RichText::new(&subject);
ui.label(if is_selected { subject_text.strong() } else { subject_text });
let preview_text = egui::RichText::new(truncate_string(&content, 50))
.weak();
ui.label(preview_text);
});
});
row.col(|ui| {
let text = egui::RichText::new(&time_str);
ui.label(text);
});
// Handle row click
if row_response.clicked() {
app.focused_post = event.id.to_string();
app.page = Page::Post;
}
// Highlight on hover
if row_response.hovered() {
row_response.highlight();
}
});
});
});
} else if app.page == Page::Settings {
ui.heading("Settings");
ui::settings::SettingsScreen::ui(app, ui);

View file

@ -15,12 +15,18 @@ pub struct CommandResult<'a> {
}
#[derive(Debug, Eq, PartialEq)]
pub enum RelayMessage<'a> {
Event(&'a str, &'a str),
OK(CommandResult<'a>),
Eose(&'a str),
Closed(&'a str, &'a str),
Notice(&'a str),
pub struct EventMessage {
pub subscription_id: String,
pub event: Event,
}
#[derive(Debug, Eq, PartialEq)]
pub enum RelayMessage {
Event(EventMessage),
OK(CommandResult<'static>),
Eose(String),
Closed(String, String),
Notice(String),
}
#[derive(Debug)]
@ -55,16 +61,16 @@ impl<'a> From<&'a WsMessage> for RelayEvent<'a> {
}
}
impl<'a> RelayMessage<'a> {
pub fn eose(subid: &'a str) -> Self {
RelayMessage::Eose(subid)
impl RelayMessage {
pub fn eose<S: Into<String>>(subid: S) -> Self {
RelayMessage::Eose(subid.into())
}
pub fn notice(msg: &'a str) -> Self {
RelayMessage::Notice(msg)
pub fn notice<S: Into<String>>(msg: S) -> Self {
RelayMessage::Notice(msg.into())
}
pub fn ok(event_id: &'a str, status: bool, message: &'a str) -> Self {
pub fn ok(event_id: &'static str, status: bool, message: &'static str) -> Self {
RelayMessage::OK(CommandResult {
event_id,
status,
@ -72,74 +78,86 @@ impl<'a> RelayMessage<'a> {
})
}
pub fn event(ev: &'a str, sub_id: &'a str) -> Self {
RelayMessage::Event(sub_id, ev)
pub fn event<S: Into<String>>(subscription_id: S, event: Event) -> Self {
RelayMessage::Event(EventMessage {
subscription_id: subscription_id.into(),
event,
})
}
pub fn from_json(msg: &'a str) -> error::Result<RelayMessage<'a>> {
pub fn from_json(msg: &str) -> error::Result<RelayMessage> {
if msg.is_empty() {
return Err(error::Error::Empty);
}
// Notice
// Relay response format: ["NOTICE", <message>]
if msg.len() >= 12 && &msg[0..=9] == "[\"NOTICE\"," {
// TODO: there could be more than one space, whatever
let start = if msg.as_bytes().get(10).copied() == Some(b' ') {
12
} else {
11
};
let end = msg.len() - 2;
return Ok(Self::notice(&msg[start..end]));
// First try parsing as a JSON array
let json_value: serde_json::Value = serde_json::from_str(msg)
.map_err(|_| error::Error::DecodeFailed)?;
if !json_value.is_array() {
return Err(error::Error::DecodeFailed);
}
// Event
// Relay response format: ["EVENT", <subscription id>, <event JSON>]
if &msg[0..=7] == "[\"EVENT\"" {
let mut start = 9;
while let Some(&b' ') = msg.as_bytes().get(start) {
start += 1; // Move past optional spaces
}
if let Some(comma_index) = msg[start..].find(',') {
let subid_end = start + comma_index;
let subid = &msg[start..subid_end].trim().trim_matches('"');
return Ok(Self::event(msg, subid));
} else {
return Ok(Self::event(msg, "fixme"));
}
let array = json_value.as_array().unwrap();
if array.is_empty() {
return Err(error::Error::DecodeFailed);
}
// EOSE (NIP-15)
// Relay response format: ["EOSE", <subscription_id>]
if &msg[0..=7] == "[\"EOSE\"," {
let start = if msg.as_bytes().get(8).copied() == Some(b' ') {
10
} else {
9
};
let end = msg.len() - 2;
return Ok(Self::eose(&msg[start..end]));
// Get the message type
let msg_type = array[0].as_str()
.ok_or(error::Error::DecodeFailed)?;
match msg_type {
// Notice: ["NOTICE", <message>]
"NOTICE" => {
if array.len() != 2 {
return Err(error::Error::DecodeFailed);
}
let message = array[1].as_str()
.ok_or(error::Error::DecodeFailed)?;
Ok(Self::notice(message))
},
// Event: ["EVENT", <subscription_id>, <event JSON>]
"EVENT" => {
if array.len() != 3 {
return Err(error::Error::DecodeFailed);
}
let subscription_id = array[1].as_str()
.ok_or(error::Error::DecodeFailed)?;
let event: Event = serde_json::from_value(array[2].clone())
.map_err(|_| error::Error::DecodeFailed)?;
Ok(Self::event(subscription_id, event))
},
// EOSE: ["EOSE", <subscription_id>]
"EOSE" => {
if array.len() != 2 {
return Err(error::Error::DecodeFailed);
}
let subscription_id = array[1].as_str()
.ok_or(error::Error::DecodeFailed)?;
Ok(Self::eose(subscription_id))
},
// OK: ["OK", <event_id>, <true|false>, <message>]
"OK" => {
if array.len() != 4 {
return Err(error::Error::DecodeFailed);
}
let event_id = array[1].as_str()
.ok_or(error::Error::DecodeFailed)?;
let status = array[2].as_bool()
.ok_or(error::Error::DecodeFailed)?;
let message = array[3].as_str()
.ok_or(error::Error::DecodeFailed)?;
// TODO: Fix static lifetime requirement
Ok(Self::ok(event_id, status, "ok"))
},
_ => Err(error::Error::DecodeFailed),
}
// OK (NIP-20)
// Relay response format: ["OK",<event_id>, <true|false>, <message>]
if &msg[0..=5] == "[\"OK\"," && msg.len() >= 78 {
// TODO: fix this
let event_id = &msg[7..71];
let booly = &msg[73..77];
let status: bool = if booly == "true" {
true
} else if booly == "false" {
false
} else {
return Err(error::Error::DecodeFailed);
};
return Ok(Self::ok(event_id, status, "fixme"));
}
Err(error::Error::DecodeFailed)
}
}

View file

@ -12,7 +12,11 @@ pub struct OnboardingScreen {}
impl OnboardingScreen {
pub fn ui(app: &mut Hoot, ui: &mut egui::Ui) {
ui.heading("Welcome to Hoot Mail!");
ui.vertical_centered(|ui| {
ui.add_space(40.0);
ui.heading(egui::RichText::new("Welcome to Hoot Mail! 🦉").size(32.0));
ui.add_space(20.0);
});
match app.page {
Page::Onboarding => Self::onboarding_home(app, ui),
@ -24,74 +28,135 @@ impl OnboardingScreen {
}
fn onboarding_home(app: &mut Hoot, ui: &mut egui::Ui) {
if ui.button("I am new to Hoot Mail").clicked() {
app.page = Page::OnboardingNew;
}
ui.vertical_centered(|ui| {
ui.add_space(20.0);
ui.label("Choose how you'd like to get started:");
ui.add_space(20.0);
if ui.button("I have used Hoot Mail before.").clicked() {
app.page = Page::OnboardingReturning;
}
let button_size = egui::vec2(240.0, 80.0);
if ui.add(egui::Button::new(
egui::RichText::new("🆕 I'm new to Hoot Mail")
.size(18.0)
).min_size(button_size)).clicked() {
app.page = Page::OnboardingNew;
}
ui.add_space(16.0);
if ui.add(egui::Button::new(
egui::RichText::new("👋 I've used Hoot Mail before")
.size(18.0)
).min_size(button_size)).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.");
ui.vertical_centered(|ui| {
if ui.add(egui::Button::new("← Back").min_size(egui::vec2(80.0, 30.0))).clicked() {
app.page = Page::Onboarding;
}
ui.add_space(20.0);
ui.label(egui::RichText::new("To setup Hoot Mail, you need a nostr identity.").size(16.0));
ui.add_space(20.0);
if ui.button("Create new keypair").clicked() {
let _ = app.account_manager.generate_keys();
app.page = Page::OnboardingNewShowKey;
}
if ui.add(egui::Button::new(
egui::RichText::new("🔑 Create new keypair")
.size(18.0)
).min_size(egui::vec2(200.0, 50.0))).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;
// here, we are assuming that the most recent key added is the one that was generated in
// onboarding_new()'s button click.
let first_key = app.account_manager.loaded_keys.last().expect("wanted a key from last screen").clone();
ui.label(format!(
"New identity: {}",
first_key.public_key().to_bech32().unwrap()
));
ui.vertical_centered(|ui| {
ui.add_space(20.0);
ui.label(egui::RichText::new("🎉 Your identity has been created!").size(24.0));
ui.add_space(20.0);
if ui.button("OK, Save!").clicked() {
app.account_manager
.add_key(&first_key)
.expect("could not write key");
// here, we are assuming that the most recent key added is the one that was generated in
// onboarding_new()'s button click.
let first_key = app.account_manager.loaded_keys.last().expect("wanted a key from last screen").clone();
let pubkey = first_key.public_key().to_bech32().unwrap();
ui.label("Your public key:");
ui.add_space(8.0);
ui.add(
egui::TextEdit::multiline(&mut pubkey.to_string())
.font(egui::TextStyle::Monospace)
.desired_width(ui.available_width())
.desired_rows(1)
.frame(true)
.interactive(false)
);
ui.add_space(32.0);
app.page = Page::Inbox;
}
if ui.add(egui::Button::new(
egui::RichText::new("✨ Start using Hoot Mail")
.size(18.0)
).min_size(egui::vec2(200.0, 50.0))).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!");
ui.vertical_centered(|ui| {
if ui.add(egui::Button::new("← Back").min_size(egui::vec2(80.0, 30.0))).clicked() {
app.page = Page::Onboarding;
}
ui.add_space(20.0);
ui.label(egui::RichText::new("👋 Welcome Back!").size(24.0));
ui.add_space(20.0);
let parsed_secret_key = nostr::SecretKey::parse(&app.state.onboarding.secret_input);
let valid_key = parsed_secret_key.is_ok();
ui.horizontal(|ui| {
let parsed_secret_key = nostr::SecretKey::parse(&app.state.onboarding.secret_input);
let valid_key = parsed_secret_key.is_ok();
ui.label("Please enter your nsec here:");
ui.text_edit_singleline(&mut app.state.onboarding.secret_input);
ui.add_space(8.0);
let text_edit = egui::TextEdit::singleline(&mut app.state.onboarding.secret_input)
.desired_width(400.0)
.hint_text("nsec1...")
.font(egui::TextStyle::Monospace);
ui.add(text_edit);
ui.add_space(8.0);
match valid_key {
true => ui.colored_label(egui::Color32::LIGHT_GREEN, "✔ Key Valid"),
false => ui.colored_label(egui::Color32::RED, "⊗ Key Invalid"),
true => ui.colored_label(egui::Color32::from_rgb(34, 197, 94), "✔ Key Valid"),
false => ui.colored_label(egui::Color32::from_rgb(239, 68, 68), "⊗ Key Invalid"),
};
ui.add_space(32.0);
if ui.add_enabled(valid_key,
egui::Button::new(
egui::RichText::new("✨ Continue")
.size(18.0)
).min_size(egui::vec2(200.0, 50.0))
).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;
}
});
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;
}
}
}