Merge pull request #7 from chakanysystems/sick-ui

Sick UI
This commit is contained in:
Jack Chakany 2025-01-15 10:34:19 -05:00 committed by GitHub
commit eefd019e9b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 407 additions and 223 deletions

View file

@ -3,6 +3,7 @@
use eframe::egui::{self, FontDefinitions, Sense, Vec2b};
use egui::FontFamily::Proportional;
use egui_extras::{Column, TableBuilder};
use relay::RelayMessage;
use std::collections::HashMap;
use tracing::{debug, error, info, Level};
@ -151,177 +152,244 @@ 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");
}
// 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: {}", event_json);
}
}
fn render_app(app: &mut Hoot, ctx: &egui::Context) {
#[cfg(feature = "profiling")]
puffin::profile_function!();
// Render compose windows if any are open - moved outside CentralPanel
for window_id in app.state.compose_window.clone().into_keys() {
ui::compose_window::ComposeWindow::show_window(app, ctx, window_id);
}
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);
});
} 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("left_panel")
.default_width(200.0)
.show(ctx, |ui| {
ui.vertical(|ui| {
ui.add_space(8.0);
// App title
ui.heading("Hoot");
ui.add_space(16.0);
egui::TopBottomPanel::top("Search").show(ctx, |ui| {
ui.heading("Search");
});
egui::CentralPanel::default().show(ctx, |ui| {
// todo: fix
for window_id in app.state.compose_window.clone().into_keys() {
ui::compose_window::ComposeWindow::show(app, ui, window_id);
}
if app.page == Page::Inbox {
ui.label("hello there!");
if ui.button("Compose").clicked() {
// Compose button
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(),
content: String::new(),
selected_account: None,
minimized: false,
};
app.state
.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()),
("🔄 Requests", Page::Post, 20),
("📝 Drafts", Page::Drafts, 3),
("⭐ Starred", Page::Post, 0),
("📁 Archived", Page::Post, 0),
("🗑️ Trash", Page::Post, 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();
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() }));
if response.clicked() {
app.page = page;
}
}
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 rendered: {}", app.events.len()));
// 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() {
app.page = Page::Settings;
}
if let Some(key) = app.account_manager.loaded_keys.first() {
ui.label(&key.public_key().to_string()[..8]);
}
});
});
});
});
egui::CentralPanel::default().show(ctx, |ui| {
match app.page {
Page::Inbox => {
// Top bar with search
ui.horizontal(|ui| {
ui.add_space(16.0);
let search_width = ui.available_width() - 100.0;
ui.add_sized(
[search_width, 32.0],
egui::TextEdit::singleline(&mut String::new())
.hint_text("Search")
.margin(egui::vec2(8.0, 4.0))
);
});
ui.add_space(8.0);
// Email list using TableBuilder
TableBuilder::new(ui)
.column(Column::auto())
.column(Column::auto())
.column(Column::remainder())
.column(Column::remainder())
.column(Column::remainder())
.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 })
.header(20.0, |_header| {})
.header(20.0, |mut header| {
header.col(|ui| {
ui.checkbox(&mut false, "");
});
header.col(|ui| {
ui.label("");
});
header.col(|ui| {
ui.label("From");
});
header.col(|ui| {
ui.label("Content");
});
header.col(|ui| {
ui.label("Time");
});
})
.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| {
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");
});
// 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() {
println!("clicked: {}", event.content.clone());
app.focused_post = event.id.to_string();
app.page = Page::Post;
if row.response().clicked() {
app.focused_post = event.id.to_string();
app.page = Page::Post;
}
}
});
});
} else if app.page == Page::Settings {
ui.heading("Settings");
ui::settings::SettingsScreen::ui(app, ui);
} else if app.page == Page::Post {
assert!(
!app.focused_post.is_empty(),
"focused_post should not be empty when Page::Post"
);
let gift_wrapped_event = app
.events
.iter()
.find(|&x| x.id.to_string() == app.focused_post)
.expect("event id should be present inside event list");
let event_to_display = app.account_manager.unwrap_gift_wrap(gift_wrapped_event).expect("we should be able to unwrap an event we recieved");
ui.heading("View Message");
ui.label(format!("Content: {}", event_to_display.rumor.content));
ui.label(match &event_to_display.rumor.tags.find(nostr::TagKind::Subject) {
Some(s) => match s.content() {
Some(c) => format!("Subject: {}", c.to_string()),
None => "Subject: None".to_string(),
},
None => "Subject: None".to_string(),
});
ui.label(match &event_to_display.rumor.id {
Some(id) => format!("ID: {}", id.to_string()),
None => "ID: None".to_string(),
});
ui.label(format!("Author: {}", event_to_display.sender.to_string()));
}
});
}
Page::Settings => {
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 Ok(unwrapped) = app.account_manager.unwrap_gift_wrap(event) {
// Message header section
ui.add_space(8.0);
ui.heading(&unwrapped.rumor.tags.find(nostr::TagKind::Subject)
.and_then(|s| s.content())
.map(|c| c.to_string())
.unwrap_or_else(|| "No Subject".to_string()));
// Metadata grid
egui::Grid::new("email_metadata")
.num_columns(2)
.spacing([8.0, 4.0])
.show(ui, |ui| {
ui.label("From");
ui.label(unwrapped.sender.to_string());
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.end_row();
});
ui.add_space(8.0);
// Action buttons
ui.horizontal(|ui| {
if ui.button("📎 Attach").clicked() {
// TODO: Handle attachment
}
if ui.button("📝 Edit").clicked() {
// TODO: Handle edit
}
if ui.button("🗑️ Delete").clicked() {
// TODO: Handle delete
}
if ui.button("↩️ Reply").clicked() {
// TODO: Handle reply
}
if ui.button("↪️ Forward").clicked() {
// TODO: Handle forward
}
if ui.button("⭐ Star").clicked() {
// TODO: Handle star
}
});
ui.add_space(16.0);
ui.separator();
ui.add_space(16.0);
// Message content
ui.label(&unwrapped.rumor.content);
}
}
}
Page::Drafts => {
ui.heading("Drafts");
ui.label("Your draft messages will appear here");
}
_ => {
ui.heading("Coming Soon");
ui.label("This feature is under development");
}
}
});
}
impl Hoot {

View file

@ -10,107 +10,223 @@ pub struct ComposeWindowState {
pub to_field: String,
pub content: String,
pub selected_account: Option<Keys>,
pub minimized: bool,
}
pub struct ComposeWindow {}
impl ComposeWindow {
pub fn show(app: &mut crate::Hoot, ui: &mut egui::Ui, id: egui::Id) {
pub fn show_window(app: &mut crate::Hoot, ctx: &egui::Context, id: egui::Id) {
let screen_rect = ctx.screen_rect();
let min_width = screen_rect.width().min(600.0);
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");
egui::Window::new(&state.subject)
.id(id)
.show(ui.ctx(), |ui| {
ui.label("Hello!");
ui.vertical(|ui| {
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| {
ui.label("To:");
ui.text_edit_singleline(&mut state.to_field);
});
{
// god this is such a fucking mess
let accounts = app.account_manager.loaded_keys.clone();
use nostr::ToBech32;
let mut formatted_key = String::new();
if state.selected_account.is_some() {
formatted_key = state
.selected_account
.clone()
.unwrap()
.public_key()
.to_bech32()
.unwrap();
if ui.button("📧").clicked() {
state.minimized = false;
}
egui::ComboBox::from_label("Select Keys to Send With")
.selected_text(format!("{}", formatted_key))
.show_ui(ui, |ui| {
for key in accounts {
ui.selectable_value(
&mut state.selected_account,
Some(key.clone()),
key.public_key().to_bech32().unwrap(),
);
}
});
}
ui.horizontal(|ui| {
ui.label("Subject:");
ui.text_edit_singleline(&mut state.subject);
});
ui.label("Body:");
ui.text_edit_multiline(&mut state.content);
if ui.button("Send").clicked() {
if state.selected_account.is_none() {
error!("No Account Selected!");
return;
}
// convert to field into PublicKey object
let to_field = state.to_field.clone();
let mut recipient_keys: Vec<PublicKey> = Vec::new();
for key_string in to_field.split_whitespace() {
use nostr::FromBech32;
match PublicKey::from_bech32(key_string) {
Ok(k) => recipient_keys.push(k),
Err(e) => debug!("could not parse public key as bech32: {}", e),
};
match PublicKey::from_hex(key_string) {
Ok(k) => recipient_keys.push(k),
Err(e) => debug!("could not parse public key as hex: {}", e),
};
}
let mut msg = MailMessage {
to: recipient_keys,
cc: vec![],
bcc: vec![],
subject: state.subject.clone(),
content: state.content.clone(),
let subject = state.subject.as_str();
let display_text = if subject.is_empty() {
"New Message"
} else {
subject.get(0..20).unwrap_or(subject)
};
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 }) {
Ok(v) => match app.relays.send(ewebsock::WsMessage::Text(v)) {
Ok(r) => r,
Err(e) => error!("could not send event to relays: {}", e),
},
Err(e) => error!("could not serialize event: {}", e),
};
}
}
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:");
ui.add_sized(
[ui.available_width(), 24.0],
egui::TextEdit::singleline(&mut state.to_field)
);
});
ui.horizontal(|ui| {
ui.label("Subject:");
ui.add_sized(
[ui.available_width(), 24.0],
egui::TextEdit::singleline(&mut state.subject)
);
});
// Toolbar
ui.horizontal(|ui| {
ui.style_mut().spacing.button_padding = egui::vec2(4.0, 4.0);
if ui.button("B").clicked() {}
if ui.button("I").clicked() {}
if ui.button("U").clicked() {}
ui.separator();
if ui.button("🔗").clicked() {}
if ui.button("📎").clicked() {}
if ui.button("😀").clicked() {}
ui.separator();
if ui.button("").clicked() {}
});
// Message content
let available_height = ui.available_height() - 40.0; // Reserve space for bottom bar
egui::ScrollArea::vertical()
.max_height(available_height)
.show(ui, |ui| {
ui.add_sized(
[ui.available_width(), available_height - 20.0],
egui::TextEdit::multiline(&mut state.content)
);
});
// Bottom bar with account selector and send button
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if ui.button("Send").clicked() {
if state.selected_account.is_none() {
error!("No Account Selected!");
return;
}
// convert to field into PublicKey object
let to_field = state.to_field.clone();
let mut recipient_keys: Vec<PublicKey> = Vec::new();
for key_string in to_field.split_whitespace() {
use nostr::FromBech32;
match PublicKey::from_bech32(key_string) {
Ok(k) => recipient_keys.push(k),
Err(e) => debug!("could not parse public key as bech32: {}", e),
};
match PublicKey::from_hex(key_string) {
Ok(k) => recipient_keys.push(k),
Err(e) => debug!("could not parse public key as hex: {}", e),
};
}
let mut msg = MailMessage {
to: recipient_keys,
cc: vec![],
bcc: vec![],
subject: state.subject.clone(),
content: state.content.clone(),
};
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 }) {
Ok(v) => match app.relays.send(ewebsock::WsMessage::Text(v)) {
Ok(r) => r,
Err(e) => error!("could not send event to relays: {}", e),
},
Err(e) => error!("could not serialize event: {}", e),
};
}
}
// Account selector
let accounts = app.account_manager.loaded_keys.clone();
use nostr::ToBech32;
let mut formatted_key = String::new();
if state.selected_account.is_some() {
formatted_key = state
.selected_account
.clone()
.unwrap()
.public_key()
.to_bech32()
.unwrap();
}
egui::ComboBox::from_id_source("account_selector")
.selected_text(format!("{}", formatted_key))
.show_ui(ui, |ui| {
for key in accounts {
ui.selectable_value(
&mut state.selected_account,
Some(key.clone()),
key.public_key().to_bech32().unwrap(),
);
}
});
});
});
});
}
if should_close {
app.state.compose_window.remove(&id);
}
}
// Keep the original show method for backward compatibility
pub fn show(app: &mut crate::Hoot, ui: &mut egui::Ui, id: egui::Id) {
Self::show_window(app, ui.ctx(), id);
}
}