notedeck

One damus client to rule them all
git clone git://jb55.com/notedeck
Log | Files | Refs | README | LICENSE

commit e9c35960675f22006d2e2c670e4f7a0ddaaf6a26
parent 93800e0d0472c678e418aaf8ff45d2ba21507d92
Author: kernelkind <kernelkind@gmail.com>
Date:   Thu,  9 May 2024 15:21:02 -0400

AccountManagementView

View used to add and remove accounts from the app

Signed-off-by: kernelkind <kernelkind@gmail.com>
Signed-off-by: William Casarin <jb55@jb55.com>

Diffstat:
Asrc/account_manager.rs | 107+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/key_storage.rs | 48++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/lib.rs | 4++++
Asrc/relay_generation.rs | 54++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/relay_pool_manager.rs | 2+-
Asrc/ui/account_management.rs | 328+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/ui/mod.rs | 2++
Msrc/ui_preview/main.rs | 7+++++--
Asrc/user_account.rs | 6++++++
9 files changed, 555 insertions(+), 3 deletions(-)

diff --git a/src/account_manager.rs b/src/account_manager.rs @@ -0,0 +1,107 @@ +use nostr_sdk::Keys; +use nostrdb::{Ndb, Transaction}; + +pub use crate::user_account::UserAccount; +use crate::{ + imgcache::ImageCache, key_storage::KeyStorage, relay_generation::RelayGenerator, + ui::profile::preview::SimpleProfilePreview, +}; + +pub struct SimpleProfilePreviewController<'a> { + ndb: &'a Ndb, + img_cache: &'a mut ImageCache, +} + +impl<'a> SimpleProfilePreviewController<'a> { + pub fn new(ndb: &'a Ndb, img_cache: &'a mut ImageCache) -> Self { + SimpleProfilePreviewController { ndb, img_cache } + } + + pub fn set_profile_previews( + &mut self, + account_manager: &AccountManager<'a>, + ui: &mut egui::Ui, + edit_mode: bool, + add_preview_ui: fn( + ui: &mut egui::Ui, + preview: SimpleProfilePreview, + edit_mode: bool, + ) -> bool, + ) -> Option<Vec<usize>> { + let mut to_remove: Option<Vec<usize>> = None; + + for i in 0..account_manager.num_accounts() { + if let Some(account) = account_manager.get_account(i) { + if let Ok(txn) = Transaction::new(self.ndb) { + let profile = self + .ndb + .get_profile_by_pubkey(&txn, &account.key.public_key().to_bytes()); + + if let Ok(profile) = profile { + let preview = SimpleProfilePreview::new(&profile, self.img_cache); + + if add_preview_ui(ui, preview, edit_mode) { + if to_remove.is_none() { + to_remove = Some(Vec::new()); + } + to_remove.as_mut().unwrap().push(i); + } + }; + } + } + } + + to_remove + } +} + +/// The interface for managing the user's accounts. +/// Represents all user-facing operations related to account management. +pub struct AccountManager<'a> { + accounts: &'a mut Vec<UserAccount>, + key_store: KeyStorage, + relay_generator: RelayGenerator, +} + +impl<'a> AccountManager<'a> { + pub fn new( + accounts: &'a mut Vec<UserAccount>, + key_store: KeyStorage, + relay_generator: RelayGenerator, + ) -> Self { + AccountManager { + accounts, + key_store, + relay_generator, + } + } + + pub fn get_accounts(&'a self) -> &'a Vec<UserAccount> { + self.accounts + } + + pub fn get_account(&'a self, index: usize) -> Option<&'a UserAccount> { + self.accounts.get(index) + } + + pub fn remove_account(&mut self, index: usize) { + if let Some(account) = self.accounts.get(index) { + self.key_store.remove_key(&account.key); + } + if index < self.accounts.len() { + self.accounts.remove(index); + } + } + + pub fn add_account(&'a mut self, key: Keys, ctx: &egui::Context) { + self.key_store.add_key(&key); + let relays = self.relay_generator.generate_relays_for(&key, ctx); + let account = UserAccount { key, relays }; + + self.accounts.push(account) + } + + pub fn num_accounts(&self) -> usize { + self.accounts.len() + } +} diff --git a/src/key_storage.rs b/src/key_storage.rs @@ -0,0 +1,48 @@ +use nostr_sdk::Keys; + +pub enum KeyStorage { + None, + // TODO: + // Linux, + // Windows, + // Android, +} + +impl KeyStorage { + pub fn get_keys(&self) -> Result<Vec<Keys>, KeyStorageError> { + match self { + Self::None => Ok(Vec::new()), + } + } + + pub fn add_key(&self, key: &Keys) -> Result<(), KeyStorageError> { + match self { + Self::None => Ok(()), + } + } + + pub fn remove_key(&self, key: &Keys) -> Result<(), KeyStorageError> { + match self { + Self::None => Ok(()), + } + } +} + +#[derive(Debug, PartialEq)] +pub enum KeyStorageError<'a> { + Retrieval, + Addition(&'a Keys), + Removal(&'a Keys), +} + +impl std::fmt::Display for KeyStorageError<'_> { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + match self { + Self::Retrieval => write!(f, "Failed to retrieve keys."), + Self::Addition(key) => write!(f, "Failed to add key: {:?}", key.public_key()), + Self::Removal(key) => write!(f, "Failed to remove key: {:?}", key.public_key()), + } + } +} + +impl std::error::Error for KeyStorageError<'_> {} diff --git a/src/lib.rs b/src/lib.rs @@ -4,6 +4,7 @@ mod error; //mod note; //mod block; mod abbrev; +pub mod account_manager; pub mod app_creation; mod app_style; mod colors; @@ -13,9 +14,11 @@ mod frame_history; mod images; mod imgcache; mod key_parsing; +mod key_storage; pub mod login_manager; mod notecache; mod profile; +mod relay_generation; pub mod relay_pool_manager; mod result; mod test_data; @@ -23,6 +26,7 @@ mod time; mod timecache; mod timeline; pub mod ui; +mod user_account; #[cfg(test)] #[macro_use] diff --git a/src/relay_generation.rs b/src/relay_generation.rs @@ -0,0 +1,54 @@ +use crate::relay_pool_manager::create_wakeup; +use enostr::RelayPool; +use nostr_sdk::Keys; +use tracing::error; + +pub enum RelayGenerator { + GossipModel, + Nip65, + Constant, +} + +impl RelayGenerator { + pub fn generate_relays_for(&self, key: &Keys, ctx: &egui::Context) -> RelayPool { + match self { + Self::GossipModel => generate_relays_gossip(key, ctx), + Self::Nip65 => generate_relays_nip65(key, ctx), + Self::Constant => generate_constant_relays(ctx), + } + } +} + +fn generate_relays_gossip(key: &Keys, ctx: &egui::Context) -> RelayPool { + todo!() +} + +fn generate_relays_nip65(key: &Keys, ctx: &egui::Context) -> RelayPool { + todo!() +} + +fn generate_constant_relays(ctx: &egui::Context) -> RelayPool { + let mut pool = RelayPool::new(); + let wakeup = create_wakeup(ctx); + + if let Err(e) = pool.add_url("ws://localhost:8080".to_string(), wakeup.clone()) { + error!("{:?}", e) + } + if let Err(e) = pool.add_url("wss://relay.damus.io".to_string(), wakeup.clone()) { + error!("{:?}", e) + } + if let Err(e) = pool.add_url("wss://pyramid.fiatjaf.com".to_string(), wakeup.clone()) { + error!("{:?}", e) + } + if let Err(e) = pool.add_url("wss://nos.lol".to_string(), wakeup.clone()) { + error!("{:?}", e) + } + if let Err(e) = pool.add_url("wss://nostr.wine".to_string(), wakeup.clone()) { + error!("{:?}", e) + } + if let Err(e) = pool.add_url("wss://purplepag.es".to_string(), wakeup) { + error!("{:?}", e) + } + + pool +} diff --git a/src/relay_pool_manager.rs b/src/relay_pool_manager.rs @@ -46,7 +46,7 @@ impl<'a> RelayPoolManager<'a> { } } -fn create_wakeup(ctx: &egui::Context) -> impl Fn() + Send + Sync + Clone + 'static { +pub fn create_wakeup(ctx: &egui::Context) -> impl Fn() + Send + Sync + Clone + 'static { let ctx = ctx.clone(); move || { ctx.request_repaint(); diff --git a/src/ui/account_management.rs b/src/ui/account_management.rs @@ -0,0 +1,328 @@ +use egui::{Align, Align2, Button, Frame, Layout, Margin, RichText, ScrollArea, Vec2, Window}; + +use crate::{ + account_manager::{AccountManager, SimpleProfilePreviewController}, + app_style::NotedeckTextStyle, + ui::{self, Preview, View}, +}; + +pub struct AccountManagementView<'a> { + account_manager: AccountManager<'a>, + simple_preview_controller: SimpleProfilePreviewController<'a>, + edit_mode: &'a mut bool, +} + +impl<'a> View for AccountManagementView<'a> { + fn ui(&mut self, ui: &mut egui::Ui) { + if ui::is_mobile(ui.ctx()) { + self.show_mobile(ui); + } else { + self.show(ui); + } + } +} + +impl<'a> AccountManagementView<'a> { + pub fn new( + account_manager: AccountManager<'a>, + simple_preview_controller: SimpleProfilePreviewController<'a>, + edit_mode: &'a mut bool, + ) -> Self { + AccountManagementView { + account_manager, + simple_preview_controller, + edit_mode, + } + } + + fn show(&mut self, ui: &mut egui::Ui) { + ui.add_space(24.0); + let screen_size = ui.ctx().screen_rect(); + let margin_amt = 128.0; + let window_size = Vec2::new( + screen_size.width() - margin_amt, + screen_size.height() - margin_amt, + ); + + Window::new("Account Management") + .frame(Frame::window(ui.style())) + .collapsible(false) + .anchor(Align2::CENTER_CENTER, [0.0, 0.0]) + .resizable(false) + .title_bar(false) + .default_size(window_size) + .show(ui.ctx(), |ui| { + ui.add(title()); + ui.add(self.buttons_widget()); + ui.add_space(8.0); + self.show_accounts(ui); + }); + } + + fn show_accounts(&mut self, ui: &mut egui::Ui) { + scroll_area().show(ui, |ui| { + ui.horizontal_wrapped(|ui| { + let maybe_remove = self.simple_preview_controller.set_profile_previews( + &self.account_manager, + ui, + *self.edit_mode, + |ui, preview, edit_mode| { + let mut should_remove = false; + + ui.add_sized(preview.dimensions(), |ui: &mut egui::Ui| { + simple_preview_frame(ui) + .show(ui, |ui| { + ui.vertical_centered(|ui| { + ui.add(preview); + if edit_mode { + should_remove = ui + .add(delete_button(ui.visuals().dark_mode)) + .clicked(); + } + }); + }) + .response + }); + should_remove + }, + ); + + self.maybe_remove_accounts(maybe_remove); + }); + }); + } + + fn show_accounts_mobile(&mut self, ui: &mut egui::Ui) { + scroll_area().show(ui, |ui| { + ui.allocate_ui_with_layout( + Vec2::new(ui.available_size_before_wrap().x, 32.0), + Layout::top_down(egui::Align::Min), + |ui| { + let maybe_remove = self.simple_preview_controller.set_profile_previews( + &self.account_manager, + ui, + *self.edit_mode, + |ui, preview, edit_mode| { + let mut should_remove = false; + + ui.add_sized( + Vec2::new(ui.available_width(), 50.0), + |ui: &mut egui::Ui| { + Frame::none() + .show(ui, |ui| { + ui.horizontal(|ui| { + ui.add(preview); + if edit_mode { + ui.with_layout( + Layout::right_to_left(Align::Center), + |ui| { + should_remove = ui + .add(delete_button( + ui.visuals().dark_mode, + )) + .clicked(); + }, + ); + } + }); + }) + .response + }, + ); + ui.add_space(16.0); + should_remove + }, + ); + + self.maybe_remove_accounts(maybe_remove); + }, + ); + }); + } + + fn maybe_remove_accounts(&mut self, account_indices: Option<Vec<usize>>) { + if let Some(to_remove) = account_indices { + to_remove + .iter() + .for_each(|index| self.account_manager.remove_account(*index)); + } + } + + fn show_mobile(&mut self, ui: &mut egui::Ui) -> egui::Response { + egui::CentralPanel::default() + .show(ui.ctx(), |ui| { + ui.add(title()); + ui.add(self.buttons_widget()); + ui.add_space(8.0); + self.show_accounts_mobile(ui); + }) + .response + } + + fn buttons_widget(&mut self) -> impl egui::Widget + '_ { + |ui: &mut egui::Ui| { + ui.horizontal(|ui| { + ui.allocate_ui_with_layout( + Vec2::new(ui.available_size_before_wrap().x, 32.0), + Layout::left_to_right(egui::Align::Center), + |ui| { + if *self.edit_mode { + if ui.add(done_account_button()).clicked() { + *self.edit_mode = false; + } + } else if ui.add(edit_account_button()).clicked() { + *self.edit_mode = true; + } + }, + ); + + ui.allocate_ui_with_layout( + Vec2::new(ui.available_size_before_wrap().x, 32.0), + Layout::right_to_left(egui::Align::Center), + |ui| { + if ui.add(add_account_button()).clicked() { + // TODO: route to AccountLoginView + } + }, + ); + }) + .response + } + } +} + +fn simple_preview_frame(ui: &mut egui::Ui) -> Frame { + Frame::none() + .rounding(ui.visuals().window_rounding) + .fill(ui.visuals().window_fill) + .stroke(ui.visuals().window_stroke) + .outer_margin(Margin::same(2.0)) + .inner_margin(12.0) +} + +fn title() -> impl egui::Widget { + |ui: &mut egui::Ui| { + ui.vertical_centered(|ui| { + ui.label( + RichText::new("Accounts") + .text_style(NotedeckTextStyle::Heading2.text_style()) + .strong(), + ); + }) + .response + } +} + +fn scroll_area() -> ScrollArea { + egui::ScrollArea::vertical() + .scroll_bar_visibility(egui::scroll_area::ScrollBarVisibility::AlwaysHidden) + .auto_shrink([false; 2]) +} + +fn add_account_button() -> Button<'static> { + Button::new("Add Account").min_size(Vec2::new(0.0, 32.0)) +} + +fn edit_account_button() -> Button<'static> { + Button::new("Edit").min_size(Vec2::new(0.0, 32.0)) +} + +fn done_account_button() -> Button<'static> { + Button::new("Done").min_size(Vec2::new(0.0, 32.0)) +} + +fn delete_button(_dark_mode: bool) -> egui::Button<'static> { + let img_data = egui::include_image!("../../assets/icons/delete_icon_4x.png"); + + egui::Button::image(egui::Image::new(img_data).max_width(30.0)).frame(true) +} + +// PREVIEWS + +mod preview { + use nostr_sdk::{Keys, PublicKey}; + use nostrdb::{Config, Ndb}; + + use super::*; + use crate::key_storage::KeyStorage; + use crate::relay_generation::RelayGenerator; + use crate::{account_manager::UserAccount, imgcache::ImageCache, test_data}; + use std::path::Path; + + pub struct AccountManagementPreview { + accounts: Vec<UserAccount>, + ndb: Ndb, + img_cache: ImageCache, + edit_mode: bool, + } + + impl AccountManagementPreview { + fn new() -> Self { + let account_hexes = [ + "3efdaebb1d8923ebd99c9e7ace3b4194ab45512e2be79c1b7d68d9243e0d2681", + "32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245", + "bd1e19980e2c91e6dc657e92c25762ca882eb9272d2579e221f037f93788de91", + "5c10ed0678805156d39ef1ef6d46110fe1e7e590ae04986ccf48ba1299cb53e2", + "4c96d763eb2fe01910f7e7220b7c7ecdbe1a70057f344b9f79c28af080c3ee30", + "edf16b1dd61eab353a83af470cc13557029bff6827b4cb9b7fc9bdb632a2b8e6", + "3efdaebb1d8923ebd99c9e7ace3b4194ab45512e2be79c1b7d68d9243e0d2681", + "32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245", + "32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245", + "32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245", + ]; + + let accounts: Vec<UserAccount> = account_hexes + .iter() + .map(|account_hex| { + let key = Keys::from_public_key(PublicKey::from_hex(account_hex).unwrap()); + + UserAccount { + key, + relays: test_data::sample_pool(), + } + }) + .collect(); + + let mut config = Config::new(); + config.set_ingester_threads(2); + + let db_dir = Path::new("."); + let path = db_dir.to_str().unwrap(); + let ndb = Ndb::new(path, &config).expect("ndb"); + let imgcache_dir = db_dir.join("cache/img"); + let img_cache = ImageCache::new(imgcache_dir); + + AccountManagementPreview { + accounts, + ndb, + img_cache, + edit_mode: false, + } + } + } + + impl View for AccountManagementPreview { + fn ui(&mut self, ui: &mut egui::Ui) { + let account_manager = AccountManager::new( + &mut self.accounts, + KeyStorage::None, + RelayGenerator::Constant, + ); + + AccountManagementView::new( + account_manager, + SimpleProfilePreviewController::new(&self.ndb, &mut self.img_cache), + &mut self.edit_mode, + ) + .ui(ui); + } + } + + impl<'a> Preview for AccountManagementView<'a> { + type Prev = AccountManagementPreview; + + fn preview() -> Self::Prev { + AccountManagementPreview::new() + } + } +} diff --git a/src/ui/mod.rs b/src/ui/mod.rs @@ -1,4 +1,5 @@ pub mod account_login_view; +pub mod account_management; pub mod anim; pub mod mention; pub mod note; @@ -7,6 +8,7 @@ pub mod profile; pub mod relay; pub mod username; +pub use account_management::AccountManagementView; pub use mention::Mention; pub use note::Note; pub use preview::{Preview, PreviewApp}; diff --git a/src/ui_preview/main.rs b/src/ui_preview/main.rs @@ -2,7 +2,9 @@ use notedeck::app_creation::{ generate_mobile_emulator_native_options, generate_native_options, setup_cc, }; use notedeck::ui::account_login_view::AccountLoginView; -use notedeck::ui::{Preview, PreviewApp, ProfilePic, ProfilePreview, RelayView}; +use notedeck::ui::{ + AccountManagementView, Preview, PreviewApp, ProfilePic, ProfilePreview, RelayView, +}; use std::env; struct PreviewRunner { @@ -82,6 +84,7 @@ async fn main() { RelayView, AccountLoginView, ProfilePreview, - ProfilePic + ProfilePic, + AccountManagementView, ); } diff --git a/src/user_account.rs b/src/user_account.rs @@ -0,0 +1,6 @@ +use enostr::RelayPool; + +pub struct UserAccount { + pub key: nostr_sdk::Keys, + pub relays: RelayPool, +}