Compare commits
4 commits
main
...
improve-ui
Author | SHA1 | Date | |
---|---|---|---|
![]() |
34d0c3e51b | ||
![]() |
a95a74dd73 | ||
![]() |
88b0b4aa01 | ||
![]() |
8b6514679c |
6 changed files with 565 additions and 224 deletions
46
Cargo.lock
generated
46
Cargo.lock
generated
|
@ -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"
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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 {
|
||||
|
|
404
src/main.rs
404
src/main.rs
|
@ -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);
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Add table
Reference in a new issue