notedeck

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

commit 83eab711486aa38918ea67f3b23bf8eb6a1e0727
parent 2af44641db6e9c2c63ddf7b76938b8d3168f882f
Author: William Casarin <jb55@jb55.com>
Date:   Fri, 31 May 2024 01:01:05 -0500

Merge remote-tracking branch 'pr/80'

Diffstat:
Aassets/icons/add_account_icon_4x.png | 0
Aassets/icons/add_column_dark_4x.png | 0
Aassets/icons/plus_icon_4x.png | 0
Aassets/icons/select_icon_3x.png | 0
Aassets/icons/settings_dark_4x.png | 0
Aassets/icons/signout_icon_4x.png | 0
Menostr/src/keypair.rs | 6+++++-
Msrc/account_manager.rs | 166+++++++++++++++++++++++++++++--------------------------------------------------
Msrc/app.rs | 64+++++++++++++++++++++++++++++++++++++++++++++++++++++++---------
Msrc/colors.rs | 2++
Msrc/key_parsing.rs | 6+++---
Msrc/key_storage.rs | 10+++++-----
Msrc/lib.rs | 1+
Msrc/macos_key_storage.rs | 28+++++++++++++++++-----------
Msrc/relay_generation.rs | 34++--------------------------------
Asrc/route.rs | 7+++++++
Msrc/test_data.rs | 54++++++++++++++++++++++++++++++++++++++++++++++++++++--
Msrc/ui/account_management.rs | 464++++++++++++++++++++++++++-----------------------------------------------------
Asrc/ui/account_switcher.rs | 260+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/ui/mod.rs | 6+++++-
Msrc/ui/profile/mod.rs | 2++
Msrc/ui/profile/preview.rs | 10+++-------
Asrc/ui/profile/profile_preview_controller.rs | 168+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/ui/side_panel.rs | 172+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/ui_preview/main.rs | 5+++--
Msrc/user_account.rs | 13++++++++-----
26 files changed, 982 insertions(+), 496 deletions(-)

diff --git a/assets/icons/add_account_icon_4x.png b/assets/icons/add_account_icon_4x.png Binary files differ. diff --git a/assets/icons/add_column_dark_4x.png b/assets/icons/add_column_dark_4x.png Binary files differ. diff --git a/assets/icons/plus_icon_4x.png b/assets/icons/plus_icon_4x.png Binary files differ. diff --git a/assets/icons/select_icon_3x.png b/assets/icons/select_icon_3x.png Binary files differ. diff --git a/assets/icons/settings_dark_4x.png b/assets/icons/settings_dark_4x.png Binary files differ. diff --git a/assets/icons/signout_icon_4x.png b/assets/icons/signout_icon_4x.png Binary files differ. diff --git a/enostr/src/keypair.rs b/enostr/src/keypair.rs @@ -8,7 +8,7 @@ pub struct Keypair { } impl Keypair { - pub fn new(secret_key: SecretKey) -> Self { + pub fn from_secret(secret_key: SecretKey) -> Self { let cloned_secret_key = secret_key.clone(); let nostr_keys = nostr::Keys::new(secret_key); Keypair { @@ -17,6 +17,10 @@ impl Keypair { } } + pub fn new(pubkey: Pubkey, secret_key: Option<SecretKey>) -> Self { + Keypair { pubkey, secret_key } + } + pub fn only_pubkey(pubkey: Pubkey) -> Self { Keypair { pubkey, diff --git a/src/account_manager.rs b/src/account_manager.rs @@ -1,136 +1,92 @@ -use enostr::FullKeypair; -use nostrdb::{Ndb, Transaction}; +use std::cmp::Ordering; -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.pubkey.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 - } +use enostr::Keypair; - pub fn view_profile_previews( - &mut self, - account_manager: &'a AccountManager<'a>, - ui: &mut egui::Ui, - add_preview_ui: fn(ui: &mut egui::Ui, preview: SimpleProfilePreview, index: usize) -> bool, - ) -> Option<usize> { - let mut clicked_at: Option<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.pubkey.bytes()); - - if let Ok(profile) = profile { - let preview = SimpleProfilePreview::new(&profile, self.img_cache); - - if add_preview_ui(ui, preview, i) { - clicked_at = Some(i) - } - } - } - } - } - - clicked_at - } -} +use crate::key_storage::KeyStorage; +pub use crate::user_account::UserAccount; /// 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>, +pub struct AccountManager { + currently_selected_account: Option<usize>, + accounts: 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 { +impl AccountManager { + pub fn new(currently_selected_account: Option<usize>, key_store: KeyStorage) -> Self { + let accounts = key_store.get_keys().unwrap_or_default(); + AccountManager { + currently_selected_account, accounts, key_store, - relay_generator, } } - pub fn get_accounts(&'a self) -> &'a Vec<UserAccount> { - self.accounts + pub fn get_accounts(&self) -> &Vec<UserAccount> { + &self.accounts } - pub fn get_account(&'a self, index: usize) -> Option<&'a UserAccount> { - self.accounts.get(index) + pub fn get_account(&self, ind: usize) -> Option<&UserAccount> { + self.accounts.get(ind) + } + + pub fn find_account(&self, pk: &[u8; 32]) -> Option<&UserAccount> { + self.accounts.iter().find(|acc| acc.pubkey.bytes() == pk) } pub fn remove_account(&mut self, index: usize) { if let Some(account) = self.accounts.get(index) { - let _ = self.key_store.remove_key(&account.key); - } - if index < self.accounts.len() { + let _ = self.key_store.remove_key(account); self.accounts.remove(index); + + if let Some(selected_index) = self.currently_selected_account { + match selected_index.cmp(&index) { + Ordering::Greater => { + self.select_account(selected_index - 1); + } + Ordering::Equal => { + self.clear_selected_account(); + } + Ordering::Less => {} + } + } } } - pub fn add_account(&'a mut self, key: FullKeypair, ctx: &egui::Context) { - let _ = self.key_store.add_key(&key); - let relays = self.relay_generator.generate_relays_for(&key.pubkey, ctx); - let account = UserAccount { key, relays }; - + pub fn add_account(&mut self, account: Keypair) { + let _ = self.key_store.add_key(&account); self.accounts.push(account) } pub fn num_accounts(&self) -> usize { self.accounts.len() } + + pub fn get_selected_account_index(&self) -> Option<usize> { + self.currently_selected_account + } + + pub fn get_selected_account(&self) -> Option<&UserAccount> { + if let Some(account_index) = self.currently_selected_account { + if let Some(account) = self.get_account(account_index) { + Some(account) + } else { + None + } + } else { + None + } + } + + pub fn select_account(&mut self, index: usize) { + if self.accounts.get(index).is_some() { + self.currently_selected_account = Some(index) + } + } + + pub fn clear_selected_account(&mut self) { + self.currently_selected_account = None + } } diff --git a/src/app.rs b/src/app.rs @@ -1,12 +1,15 @@ +use crate::account_manager::AccountManager; use crate::app_creation::setup_cc; use crate::app_style::user_requested_visuals_change; use crate::error::Error; use crate::frame_history::FrameHistory; use crate::imgcache::ImageCache; use crate::notecache::{CachedNote, NoteCache}; +use crate::route::Route; use crate::timeline; use crate::timeline::{NoteRef, Timeline, ViewFilter}; -use crate::ui::is_mobile; +use crate::ui::profile::SimpleProfilePreviewController; +use crate::ui::{is_mobile, DesktopSidePanel}; use crate::Result; use egui::{Context, Frame, Style}; @@ -36,6 +39,8 @@ pub struct Damus { note_cache: NoteCache, pool: RelayPool, + /// global navigation for account management popups, etc. + nav: Vec<Route>, pub textmode: bool, pub timelines: Vec<Timeline>, @@ -43,6 +48,7 @@ pub struct Damus { pub img_cache: ImageCache, pub ndb: Ndb, + pub account_manager: AccountManager, frame_history: crate::frame_history::FrameHistory, } @@ -647,14 +653,47 @@ impl Damus { img_cache: ImageCache::new(imgcache_dir), note_cache: NoteCache::default(), selected_timeline: 0, + nav: Vec::with_capacity(6), timelines, textmode: false, ndb: Ndb::new(data_path.as_ref().to_str().expect("db path ok"), &config).expect("ndb"), + account_manager: AccountManager::new( + // TODO: should pull this from settings + None, + // TODO: use correct KeyStorage mechanism for current OS arch + crate::key_storage::KeyStorage::None, + ), //compose: "".to_string(), frame_history: FrameHistory::default(), } } + pub fn mock<P: AsRef<Path>>(data_path: P) -> Self { + let mut timelines: Vec<Timeline> = vec![]; + let _initial_limit = 100; + let filter = serde_json::from_str(include_str!("../queries/global.json")).unwrap(); + timelines.push(Timeline::new(filter)); + + let imgcache_dir = data_path.as_ref().join(ImageCache::rel_datadir()); + let _ = std::fs::create_dir_all(imgcache_dir.clone()); + + let mut config = Config::new(); + config.set_ingester_threads(2); + Self { + state: DamusState::Initializing, + pool: RelayPool::new(), + img_cache: ImageCache::new(imgcache_dir), + note_cache: NoteCache::default(), + selected_timeline: 0, + timelines, + nav: vec![], + textmode: false, + ndb: Ndb::new(data_path.as_ref().to_str().expect("db path ok"), &config).expect("ndb"), + account_manager: AccountManager::new(None, crate::key_storage::KeyStorage::None), + frame_history: FrameHistory::default(), + } + } + pub fn note_cache_mut(&mut self) -> &mut NoteCache { &mut self.note_cache } @@ -815,14 +854,6 @@ fn render_damus_desktop(ctx: &egui::Context, app: &mut Damus) { Size::remainder() }; - if app.timelines.len() == 1 { - main_panel(&ctx.style()).show(ctx, |ui| { - timeline::timeline_view(ui, app, 0); - }); - - return; - } - main_panel(&ctx.style()).show(ctx, |ui| { ui.spacing_mut().item_spacing.x = 0.0; if need_scroll { @@ -837,9 +868,24 @@ fn render_damus_desktop(ctx: &egui::Context, app: &mut Damus) { fn timelines_view(ui: &mut egui::Ui, sizes: Size, app: &mut Damus, timelines: usize) { StripBuilder::new(ui) + .size(Size::exact(40.0)) .sizes(sizes, timelines) .clip(true) .horizontal(|mut strip| { + strip.cell(|ui| { + let side_panel = DesktopSidePanel::new( + app.account_manager + .get_selected_account() + .map(|a| a.pubkey.bytes()), + SimpleProfilePreviewController::new(&app.ndb, &mut app.img_cache), + ) + .show(ui); + + if side_panel.response.clicked() { + info!("clicked {:?}", side_panel.action); + } + }); + for timeline_ind in 0..timelines { strip.cell(|ui| timeline::timeline_view(ui, app, timeline_ind)); } diff --git a/src/colors.rs b/src/colors.rs @@ -1,6 +1,8 @@ use egui::Color32; pub const PURPLE: Color32 = Color32::from_rgb(0xCC, 0x43, 0xC5); +// TODO: This should not be exposed publicly +pub const PINK: Color32 = Color32::from_rgb(0xE4, 0x5A, 0xC9); //pub const DARK_BG: Color32 = egui::Color32::from_rgb(40, 44, 52); pub const GRAY_SECONDARY: Color32 = Color32::from_rgb(0x8A, 0x8A, 0x8A); const BLACK: Color32 = Color32::from_rgb(0x00, 0x00, 0x00); diff --git a/src/key_parsing.rs b/src/key_parsing.rs @@ -125,7 +125,7 @@ pub async fn get_login_key(key: &str) -> Result<Keypair, LoginError> { } else if let Ok(pubkey) = Pubkey::try_from_hex_str_with_verify(tmp_key) { Ok(Keypair::only_pubkey(pubkey)) } else if let Ok(secret_key) = SecretKey::from_str(tmp_key) { - Ok(Keypair::new(secret_key)) + Ok(Keypair::from_secret(secret_key)) } else { Err(LoginError::InvalidKey) } @@ -181,7 +181,7 @@ mod tests { promise_assert!( assert_eq, - Ok(Keypair::new(expected_privkey)), + Ok(Keypair::from_secret(expected_privkey)), &login_key_result ); } @@ -194,7 +194,7 @@ mod tests { promise_assert!( assert_eq, - Ok(Keypair::new(expected_privkey)), + Ok(Keypair::from_secret(expected_privkey)), &login_key_result ); } diff --git a/src/key_storage.rs b/src/key_storage.rs @@ -1,4 +1,4 @@ -use enostr::FullKeypair; +use enostr::Keypair; #[cfg(target_os = "macos")] use crate::macos_key_storage::MacOSKeyStorage; @@ -17,15 +17,15 @@ pub enum KeyStorage { } impl KeyStorage { - pub fn get_keys(&self) -> Result<Vec<FullKeypair>, KeyStorageError> { + pub fn get_keys(&self) -> Result<Vec<Keypair>, KeyStorageError> { match self { Self::None => Ok(Vec::new()), #[cfg(target_os = "macos")] - Self::MacOS => Ok(MacOSKeyStorage::new(SERVICE_NAME).get_all_fullkeypairs()), + Self::MacOS => Ok(MacOSKeyStorage::new(SERVICE_NAME).get_all_keypairs()), } } - pub fn add_key(&self, key: &FullKeypair) -> Result<(), KeyStorageError> { + pub fn add_key(&self, key: &Keypair) -> Result<(), KeyStorageError> { let _ = key; match self { Self::None => Ok(()), @@ -34,7 +34,7 @@ impl KeyStorage { } } - pub fn remove_key(&self, key: &FullKeypair) -> Result<(), KeyStorageError> { + pub fn remove_key(&self, key: &Keypair) -> Result<(), KeyStorageError> { let _ = key; match self { Self::None => Ok(()), diff --git a/src/lib.rs b/src/lib.rs @@ -22,6 +22,7 @@ mod profile; mod relay_generation; pub mod relay_pool_manager; mod result; +mod route; mod test_data; mod time; mod timecache; diff --git a/src/macos_key_storage.rs b/src/macos_key_storage.rs @@ -1,6 +1,6 @@ #![cfg(target_os = "macos")] -use enostr::{FullKeypair, Pubkey, SecretKey}; +use enostr::{Keypair, Pubkey, SecretKey}; use security_framework::item::{ItemClass, ItemSearchOptions, Limit, SearchResult}; use security_framework::passwords::{delete_generic_password, set_generic_password}; @@ -16,11 +16,13 @@ impl<'a> MacOSKeyStorage<'a> { MacOSKeyStorage { service_name } } - pub fn add_key(&self, key: &FullKeypair) -> Result<(), KeyStorageError> { + pub fn add_key(&self, key: &Keypair) -> Result<(), KeyStorageError> { match set_generic_password( self.service_name, key.pubkey.hex().as_str(), - key.secret_key.as_secret_bytes(), + key.secret_key + .as_ref() + .map_or_else(|| &[] as &[u8], |sc| sc.as_secret_bytes()), ) { Ok(_) => Ok(()), Err(_) => Err(KeyStorageError::Addition(key.pubkey.hex())), @@ -82,12 +84,12 @@ impl<'a> MacOSKeyStorage<'a> { } } - pub fn get_all_fullkeypairs(&self) -> Vec<FullKeypair> { + pub fn get_all_keypairs(&self) -> Vec<Keypair> { self.get_pubkeys() .iter() - .filter_map(|pubkey| { + .map(|pubkey| { let maybe_secret = self.get_secret_key_for_pubkey(pubkey); - maybe_secret.map(|secret| FullKeypair::new(pubkey.clone(), secret)) + Keypair::new(pubkey.clone(), maybe_secret) }) .collect() } @@ -106,6 +108,8 @@ impl<'a> MacOSKeyStorage<'a> { #[cfg(test)] mod tests { use super::*; + use enostr::FullKeypair; + static TEST_SERVICE_NAME: &str = "NOTEDECKTEST"; static STORAGE: MacOSKeyStorage = MacOSKeyStorage { service_name: TEST_SERVICE_NAME, @@ -119,7 +123,7 @@ mod tests { fn add_and_remove_test_pubkey_only() { let num_keys_before_test = STORAGE.get_pubkeys().len(); - let keypair = FullKeypair::generate(); + let keypair = FullKeypair::generate().to_keypair(); let add_result = STORAGE.add_key(&keypair); assert_eq!(add_result, Ok(())); @@ -134,18 +138,20 @@ mod tests { } fn add_and_remove_full_n(n: usize) { - let num_keys_before_test = STORAGE.get_all_fullkeypairs().len(); + let num_keys_before_test = STORAGE.get_all_keypairs().len(); // there must be zero keys in storage for the test to work as intended assert_eq!(num_keys_before_test, 0); - let expected_keypairs: Vec<FullKeypair> = (0..n).map(|_| FullKeypair::generate()).collect(); + let expected_keypairs: Vec<Keypair> = (0..n) + .map(|_| FullKeypair::generate().to_keypair()) + .collect(); expected_keypairs.iter().for_each(|keypair| { let add_result = STORAGE.add_key(keypair); assert_eq!(add_result, Ok(())); }); - let asserted_keypairs = STORAGE.get_all_fullkeypairs(); + let asserted_keypairs = STORAGE.get_all_keypairs(); assert_eq!(expected_keypairs, asserted_keypairs); expected_keypairs.iter().for_each(|keypair| { @@ -153,7 +159,7 @@ mod tests { assert_eq!(remove_result, Ok(())); }); - let num_keys_after_test = STORAGE.get_all_fullkeypairs().len(); + let num_keys_after_test = STORAGE.get_all_keypairs().len(); assert_eq!(num_keys_after_test, 0); } diff --git a/src/relay_generation.rs b/src/relay_generation.rs @@ -1,38 +1,8 @@ -use crate::relay_pool_manager::create_wakeup; -use enostr::{Pubkey, RelayPool}; +use enostr::RelayPool; use tracing::error; -pub enum RelayGenerator { - GossipModel, - Nip65, - Constant, -} - -impl RelayGenerator { - pub fn generate_relays_for(&self, key: &Pubkey, 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: &Pubkey, ctx: &egui::Context) -> RelayPool { - let _ = ctx; - let _ = key; - todo!() -} - -fn generate_relays_nip65(key: &Pubkey, ctx: &egui::Context) -> RelayPool { - let _ = ctx; - let _ = key; - todo!() -} - -fn generate_constant_relays(ctx: &egui::Context) -> RelayPool { +fn test_relay_pool(wakeup: impl Fn() + Send + Sync + Clone + 'static) -> 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) diff --git a/src/route.rs b/src/route.rs @@ -0,0 +1,7 @@ +use nostrdb::NoteKey; + +/// App routing. These describe different places you can go inside Notedeck. +pub enum Route { + ManageAccount, + Thread(NoteKey), +} diff --git a/src/test_data.rs b/src/test_data.rs @@ -1,5 +1,13 @@ -use enostr::RelayPool; -use nostrdb::ProfileRecord; +use std::path::Path; + +use enostr::{FullKeypair, Pubkey, RelayPool}; +use nostrdb::{Config, Ndb, ProfileRecord}; + +use crate::{ + account_manager::{AccountManager, UserAccount}, + imgcache::ImageCache, + key_storage::KeyStorage, +}; #[allow(unused_must_use)] pub fn sample_pool() -> RelayPool { @@ -54,3 +62,45 @@ const TEST_PROFILE_DATA: [u8; 448] = [ pub fn test_profile_record() -> ProfileRecord<'static> { ProfileRecord::new_owned(&TEST_PROFILE_DATA).unwrap() } + +const TEN_ACCOUNT_HEXES: [&str; 10] = [ + "3efdaebb1d8923ebd99c9e7ace3b4194ab45512e2be79c1b7d68d9243e0d2681", + "32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245", + "bd1e19980e2c91e6dc657e92c25762ca882eb9272d2579e221f037f93788de91", + "5c10ed0678805156d39ef1ef6d46110fe1e7e590ae04986ccf48ba1299cb53e2", + "4c96d763eb2fe01910f7e7220b7c7ecdbe1a70057f344b9f79c28af080c3ee30", + "edf16b1dd61eab353a83af470cc13557029bff6827b4cb9b7fc9bdb632a2b8e6", + "3efdaebb1d8923ebd99c9e7ace3b4194ab45512e2be79c1b7d68d9243e0d2681", + "32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245", + "32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245", + "32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245", +]; + +pub fn get_test_accounts() -> Vec<UserAccount> { + TEN_ACCOUNT_HEXES + .iter() + .map(|account_hex| { + let mut kp = FullKeypair::generate().to_keypair(); + kp.pubkey = Pubkey::from_hex(account_hex).unwrap(); + kp + }) + .collect() +} + +pub fn get_accmgr_and_ndb_and_imgcache() -> (AccountManager, Ndb, ImageCache) { + let mut account_manager = AccountManager::new(None, KeyStorage::None); + let accounts = get_test_accounts(); + for account in accounts { + account_manager.add_account(account); + } + + 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); + (account_manager, ndb, img_cache) +} diff --git a/src/ui/account_management.rs b/src/ui/account_management.rs @@ -1,17 +1,17 @@ -use egui::{ - Align, Align2, Button, Frame, Id, Layout, Margin, RichText, ScrollArea, Sense, Vec2, Window, -}; - +use crate::colors::PINK; use crate::{ - account_manager::{AccountManager, SimpleProfilePreviewController, UserAccount}, + account_manager::AccountManager, app_style::NotedeckTextStyle, ui::{self, Preview, View}, }; +use egui::{Align, Button, Frame, Image, Layout, RichText, ScrollArea, Vec2}; + +use super::profile::preview::SimpleProfilePreview; +use super::profile::{ProfilePreviewOp, SimpleProfilePreviewController}; pub struct AccountManagementView<'a> { - account_manager: AccountManager<'a>, + account_manager: &'a mut AccountManager, simple_preview_controller: SimpleProfilePreviewController<'a>, - edit_mode: &'a mut bool, } impl<'a> View for AccountManagementView<'a> { @@ -26,120 +26,51 @@ impl<'a> View for AccountManagementView<'a> { impl<'a> AccountManagementView<'a> { pub fn new( - account_manager: AccountManager<'a>, + account_manager: &'a mut AccountManager, 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); + Frame::none().outer_margin(24.0).show(ui, |ui| { + self.top_section_buttons_widget(ui); + ui.add_space(8.0); + scroll_area().show(ui, |ui| { 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, + account_card_ui(), + ); + + self.maybe_remove_accounts(maybe_remove); + } + + fn show_accounts_mobile(&mut self, ui: &mut egui::Ui) { + ui.allocate_ui_with_layout( + Vec2::new(ui.available_size_before_wrap().x, 32.0), + Layout::top_down(egui::Align::Min), + |ui| { + // create all account 'cards' and get the indicies the user requested to remove let maybe_remove = self.simple_preview_controller.set_profile_previews( - &self.account_manager, + 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 - }, + account_card_ui(), // closure for creating an account 'card' ); + // remove all account indicies user requested 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>>) { @@ -153,66 +84,94 @@ impl<'a> AccountManagementView<'a> { 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()); + mobile_title(ui); + self.top_section_buttons_widget(ui); + ui.add_space(8.0); - self.show_accounts_mobile(ui); + scroll_area().show(ui, |ui| { + 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; - } - }, - ); + fn top_section_buttons_widget(&mut self, ui: &mut egui::Ui) -> egui::Response { + 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 ui.add(add_account_button()).clicked() { + // TODO: route to AccountLoginView + } + }, + ); - 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 - } + // UNCOMMENT FOR LOGOUTALL BUTTON + // 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(logout_all_button()).clicked() { + // for index in (0..self.account_manager.num_accounts()).rev() { + // self.account_manager.remove_account(index); + // } + // } + // }, + // ); + }) + .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 account_card_ui() -> fn( + ui: &mut egui::Ui, + preview: SimpleProfilePreview, + width: f32, + is_selected: bool, +) -> Option<ProfilePreviewOp> { + |ui, preview, width, is_selected| { + let mut op: Option<ProfilePreviewOp> = None; + + ui.add_sized(Vec2::new(width, 50.0), |ui: &mut egui::Ui| { + Frame::none() + .show(ui, |ui| { + ui.horizontal(|ui| { + ui.add(preview); + + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + if is_selected { + ui.add(selected_widget()); + } else { + if ui + .add(switch_button(ui.style().visuals.dark_mode)) + .clicked() + { + op = Some(ProfilePreviewOp::SwitchTo); + } + if ui.add(sign_out_button(ui)).clicked() { + op = Some(ProfilePreviewOp::RemoveAccount) + } + } + }); + }); + }) + .response + }); + ui.add_space(16.0); + op + } } -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 mobile_title(ui: &mut egui::Ui) -> egui::Response { + ui.vertical_centered(|ui| { + ui.label( + RichText::new("Account Management") + .text_style(NotedeckTextStyle::Heading2.text_style()) + .strong(), + ); + }) + .response } fn scroll_area() -> ScrollArea { @@ -222,160 +181,84 @@ fn scroll_area() -> ScrollArea { } fn add_account_button() -> Button<'static> { - Button::new("Add Account").min_size(Vec2::new(0.0, 32.0)) + let img_data = egui::include_image!("../../assets/icons/add_account_icon_4x.png"); + let img = Image::new(img_data).fit_to_exact_size(Vec2::new(48.0, 48.0)); + Button::image_and_text( + img, + RichText::new(" Add account") + .size(16.0) + // TODO: this color should not be hard coded. Find some way to add it to the visuals + .color(PINK), + ) + .frame(false) } -fn edit_account_button() -> Button<'static> { - Button::new("Edit").min_size(Vec2::new(0.0, 32.0)) -} +fn sign_out_button(ui: &egui::Ui) -> egui::Button<'static> { + let img_data = egui::include_image!("../../assets/icons/signout_icon_4x.png"); + let img = Image::new(img_data).fit_to_exact_size(Vec2::new(16.0, 16.0)); -fn done_account_button() -> Button<'static> { - Button::new("Done").min_size(Vec2::new(0.0, 32.0)) + egui::Button::image_and_text( + img, + RichText::new("Sign out").color(ui.visuals().noninteractive().fg_stroke.color), + ) + .frame(false) } -fn delete_button(_dark_mode: bool) -> egui::Button<'static> { - let img_data = egui::include_image!("../../assets/icons/delete_icon_4x.png"); +fn switch_button(dark_mode: bool) -> egui::Button<'static> { + let _ = dark_mode; - egui::Button::image(egui::Image::new(img_data).max_width(30.0)).frame(true) + egui::Button::new("Switch").min_size(Vec2::new(76.0, 32.0)) } -pub struct AccountSelectionWidget<'a> { - account_manager: AccountManager<'a>, - simple_preview_controller: SimpleProfilePreviewController<'a>, -} - -impl<'a> AccountSelectionWidget<'a> { - fn ui(&'a mut self, ui: &mut egui::Ui) -> Option<&'a UserAccount> { - let mut result: Option<&'a UserAccount> = None; - scroll_area().show(ui, |ui| { - ui.horizontal_wrapped(|ui| { - let clicked_at = self.simple_preview_controller.view_profile_previews( - &self.account_manager, - ui, - |ui, preview, index| { - let resp = ui.add_sized(preview.dimensions(), |ui: &mut egui::Ui| { - simple_preview_frame(ui) - .show(ui, |ui| { - ui.vertical_centered(|ui| { - ui.add(preview); - }); - }) - .response - }); - - ui.interact(resp.rect, Id::new(index), Sense::click()) - .clicked() - }, - ); - - if let Some(index) = clicked_at { - result = self.account_manager.get_account(index); - }; - }); - }); - result +fn selected_widget() -> impl egui::Widget { + |ui: &mut egui::Ui| { + Frame::none() + .show(ui, |ui| { + ui.label(RichText::new("Selected").size(13.0).color(PINK)); + let img_data = egui::include_image!("../../assets/icons/select_icon_3x.png"); + let img = Image::new(img_data).max_size(Vec2::new(16.0, 16.0)); + ui.add(img); + }) + .response } } -impl<'a> AccountSelectionWidget<'a> { - pub fn new( - account_manager: AccountManager<'a>, - simple_preview_controller: SimpleProfilePreviewController<'a>, - ) -> Self { - AccountSelectionWidget { - account_manager, - simple_preview_controller, - } - } -} +// fn logout_all_button() -> egui::Button<'static> { +// egui::Button::new("Logout all") +// } // PREVIEWS mod preview { - use enostr::{FullKeypair, Pubkey}; - use nostrdb::{Config, Ndb}; + use nostrdb::Ndb; use super::*; - use crate::key_storage::KeyStorage; - use crate::relay_generation::RelayGenerator; - use crate::{imgcache::ImageCache, test_data}; - use std::path::Path; - - const ACCOUNT_HEXES: [&str; 10] = [ - "3efdaebb1d8923ebd99c9e7ace3b4194ab45512e2be79c1b7d68d9243e0d2681", - "32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245", - "bd1e19980e2c91e6dc657e92c25762ca882eb9272d2579e221f037f93788de91", - "5c10ed0678805156d39ef1ef6d46110fe1e7e590ae04986ccf48ba1299cb53e2", - "4c96d763eb2fe01910f7e7220b7c7ecdbe1a70057f344b9f79c28af080c3ee30", - "edf16b1dd61eab353a83af470cc13557029bff6827b4cb9b7fc9bdb632a2b8e6", - "3efdaebb1d8923ebd99c9e7ace3b4194ab45512e2be79c1b7d68d9243e0d2681", - "32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245", - "32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245", - "32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245", - ]; + use crate::{imgcache::ImageCache, test_data::get_accmgr_and_ndb_and_imgcache}; pub struct AccountManagementPreview { - accounts: Vec<UserAccount>, + account_manager: AccountManager, ndb: Ndb, img_cache: ImageCache, - edit_mode: bool, - } - - fn get_accounts() -> Vec<UserAccount> { - ACCOUNT_HEXES - .iter() - .map(|account_hex| { - let key = FullKeypair::new( - Pubkey::from_hex(account_hex).unwrap(), - FullKeypair::generate().secret_key, - ); - - UserAccount { - key, - relays: test_data::sample_pool(), - } - }) - .collect() - } - - fn get_ndb_and_img_cache() -> (Ndb, ImageCache) { - 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); - (ndb, img_cache) } impl AccountManagementPreview { fn new() -> Self { - let accounts = get_accounts(); - let (ndb, img_cache) = get_ndb_and_img_cache(); + let (account_manager, ndb, img_cache) = get_accmgr_and_ndb_and_imgcache(); AccountManagementPreview { - accounts, + account_manager, 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, - ); - + ui.add_space(24.0); AccountManagementView::new( - account_manager, + &mut self.account_manager, SimpleProfilePreviewController::new(&self.ndb, &mut self.img_cache), - &mut self.edit_mode, ) .ui(ui); } @@ -388,49 +271,4 @@ mod preview { AccountManagementPreview::new() } } - - pub struct AccountSelectionPreview { - accounts: Vec<UserAccount>, - ndb: Ndb, - img_cache: ImageCache, - } - - impl AccountSelectionPreview { - fn new() -> Self { - let accounts = get_accounts(); - let (ndb, img_cache) = get_ndb_and_img_cache(); - AccountSelectionPreview { - accounts, - ndb, - img_cache, - } - } - } - - impl View for AccountSelectionPreview { - fn ui(&mut self, ui: &mut egui::Ui) { - let account_manager = AccountManager::new( - &mut self.accounts, - KeyStorage::None, - RelayGenerator::Constant, - ); - - let mut widget = AccountSelectionWidget::new( - account_manager, - SimpleProfilePreviewController::new(&self.ndb, &mut self.img_cache), - ); - - if let Some(account) = widget.ui(ui) { - println!("User made selection: {:?}", account.key); - } - } - } - - impl<'a> Preview for AccountSelectionWidget<'a> { - type Prev = AccountSelectionPreview; - - fn preview() -> Self::Prev { - AccountSelectionPreview::new() - } - } } diff --git a/src/ui/account_switcher.rs b/src/ui/account_switcher.rs @@ -0,0 +1,260 @@ +use crate::{ + account_manager::{AccountManager, UserAccount}, + colors::PINK, + profile::DisplayName, + ui, Result, +}; +use egui::{ + Align, Button, Color32, Frame, Id, Image, Layout, Margin, RichText, Rounding, ScrollArea, + Sense, Vec2, +}; + +use super::profile::{preview::SimpleProfilePreview, SimpleProfilePreviewController}; + +pub struct AccountSelectionWidget<'a> { + account_manager: &'a AccountManager, + simple_preview_controller: SimpleProfilePreviewController<'a>, +} + +enum AccountSelectAction { + RemoveAccount { index: usize }, + SelectAccount { index: usize }, + OpenAccountManagement, +} + +#[derive(Default)] +struct AccountSelectResponse { + action: Option<AccountSelectAction>, +} + +impl<'a> AccountSelectionWidget<'a> { + pub fn new( + account_manager: &'a AccountManager, + simple_preview_controller: SimpleProfilePreviewController<'a>, + ) -> Self { + AccountSelectionWidget { + account_manager, + simple_preview_controller, + } + } + + pub fn ui(&'a mut self, ui: &mut egui::Ui) { + if ui::is_mobile() { + self.show_mobile(ui); + } else { + self.show(ui); + } + } + + fn show(&mut self, ui: &mut egui::Ui) -> AccountSelectResponse { + let mut res = AccountSelectResponse::default(); + let mut selected_index = self.account_manager.get_selected_account_index(); + + Frame::none().outer_margin(8.0).show(ui, |ui| { + res = top_section_widget(ui); + + scroll_area().show(ui, |ui| { + if let Some(index) = self.show_accounts(ui) { + selected_index = Some(index); + res.action = Some(AccountSelectAction::SelectAccount { index }); + } + }); + ui.add_space(8.0); + ui.add(add_account_button()); + + if let Some(index) = selected_index { + if let Some(account) = self.account_manager.get_account(index) { + ui.add_space(8.0); + if self.handle_sign_out(ui, account) { + res.action = Some(AccountSelectAction::RemoveAccount { index }) + } + } + } + + ui.add_space(8.0); + }); + + res + } + + fn handle_sign_out(&mut self, ui: &mut egui::Ui, account: &UserAccount) -> bool { + if let Ok(response) = self.sign_out_button(ui, account) { + response.clicked() + } else { + false + } + } + + fn show_mobile(&mut self, ui: &mut egui::Ui) -> egui::Response { + let _ = ui; + todo!() + } + + fn show_accounts(&mut self, ui: &mut egui::Ui) -> Option<usize> { + self.simple_preview_controller.view_profile_previews( + self.account_manager, + ui, + account_switcher_card_ui(), + ) + } + + fn sign_out_button(&self, ui: &mut egui::Ui, account: &UserAccount) -> Result<egui::Response> { + self.simple_preview_controller.show_with_nickname( + ui, + account.pubkey.bytes(), + |ui: &mut egui::Ui, username: &DisplayName| { + let img_data = egui::include_image!("../../assets/icons/signout_icon_4x.png"); + let img = Image::new(img_data).fit_to_exact_size(Vec2::new(16.0, 16.0)); + let button = egui::Button::image_and_text( + img, + RichText::new(format!(" Sign out @{}", username.username())) + .color(PINK) + .size(16.0), + ) + .frame(false); + + ui.add(button) + }, + ) + } +} + +fn account_switcher_card_ui() -> fn( + ui: &mut egui::Ui, + preview: SimpleProfilePreview, + width: f32, + is_selected: bool, + index: usize, +) -> bool { + |ui, preview, width, is_selected, index| { + let resp = ui.add_sized(Vec2::new(width, 50.0), |ui: &mut egui::Ui| { + Frame::none() + .show(ui, |ui| { + ui.add_space(8.0); + ui.horizontal(|ui| { + if is_selected { + Frame::none() + .rounding(Rounding::same(8.0)) + .inner_margin(Margin::same(8.0)) + .fill(Color32::from_rgb(0x45, 0x1B, 0x59)) + .show(ui, |ui| { + ui.add(preview); + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + ui.add(selection_widget()); + }); + }); + } else { + ui.add_space(8.0); + ui.add(preview); + } + }); + }) + .response + }); + + ui.interact(resp.rect, Id::new(index), Sense::click()) + .clicked() + } +} + +fn selection_widget() -> impl egui::Widget { + |ui: &mut egui::Ui| { + let img_data: egui::ImageSource = + egui::include_image!("../../assets/icons/select_icon_3x.png"); + let img = Image::new(img_data).max_size(Vec2::new(16.0, 16.0)); + ui.add(img) + } +} + +fn top_section_widget(ui: &mut egui::Ui) -> AccountSelectResponse { + ui.horizontal(|ui| { + let mut resp = AccountSelectResponse::default(); + + ui.allocate_ui_with_layout( + Vec2::new(ui.available_size_before_wrap().x, 32.0), + Layout::left_to_right(egui::Align::Center), + |ui| ui.add(account_switcher_title()), + ); + + 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(manage_accounts_button()).clicked() { + resp.action = Some(AccountSelectAction::OpenAccountManagement); + } + }, + ); + + resp + }) + .inner +} + +fn manage_accounts_button() -> egui::Button<'static> { + Button::new(RichText::new("Manage").color(PINK).size(16.0)).frame(false) +} + +fn account_switcher_title() -> impl egui::Widget { + |ui: &mut egui::Ui| ui.label(RichText::new("Account switcher").size(20.0).strong()) +} + +fn scroll_area() -> ScrollArea { + egui::ScrollArea::vertical() + .scroll_bar_visibility(egui::scroll_area::ScrollBarVisibility::AlwaysHidden) + .auto_shrink([false; 2]) +} + +fn add_account_button() -> egui::Button<'static> { + let img_data = egui::include_image!("../../assets/icons/plus_icon_4x.png"); + let img = Image::new(img_data).fit_to_exact_size(Vec2::new(16.0, 16.0)); + Button::image_and_text(img, RichText::new(" Add account").size(16.0).color(PINK)).frame(false) +} + +mod previews { + use nostrdb::Ndb; + + use crate::{ + account_manager::AccountManager, + imgcache::ImageCache, + test_data, + ui::{profile::SimpleProfilePreviewController, Preview, View}, + }; + + use super::AccountSelectionWidget; + + pub struct AccountSelectionPreview { + account_manager: AccountManager, + ndb: Ndb, + img_cache: ImageCache, + } + + impl AccountSelectionPreview { + fn new() -> Self { + let (account_manager, ndb, img_cache) = test_data::get_accmgr_and_ndb_and_imgcache(); + AccountSelectionPreview { + account_manager, + ndb, + img_cache, + } + } + } + + impl View for AccountSelectionPreview { + fn ui(&mut self, ui: &mut egui::Ui) { + AccountSelectionWidget::new( + &self.account_manager, + SimpleProfilePreviewController::new(&self.ndb, &mut self.img_cache), + ) + .ui(ui); + } + } + + impl<'a> Preview for AccountSelectionWidget<'a> { + type Prev = AccountSelectionPreview; + + fn preview() -> Self::Prev { + AccountSelectionPreview::new() + } + } +} diff --git a/src/ui/mod.rs b/src/ui/mod.rs @@ -1,19 +1,23 @@ pub mod account_login_view; pub mod account_management; +pub mod account_switcher; pub mod anim; pub mod mention; pub mod note; pub mod preview; pub mod profile; pub mod relay; +pub mod side_panel; pub mod username; -pub use account_management::{AccountManagementView, AccountSelectionWidget}; +pub use account_management::AccountManagementView; +pub use account_switcher::AccountSelectionWidget; pub use mention::Mention; pub use note::Note; pub use preview::{Preview, PreviewApp}; pub use profile::{ProfilePic, ProfilePreview}; pub use relay::RelayView; +pub use side_panel::DesktopSidePanel; pub use username::Username; use egui::Margin; diff --git a/src/ui/profile/mod.rs b/src/ui/profile/mod.rs @@ -1,5 +1,7 @@ pub mod picture; pub mod preview; +mod profile_preview_controller; pub use picture::ProfilePic; pub use preview::ProfilePreview; +pub use profile_preview_controller::{ProfilePreviewOp, SimpleProfilePreviewController}; diff --git a/src/ui/profile/preview.rs b/src/ui/profile/preview.rs @@ -3,7 +3,7 @@ use crate::imgcache::ImageCache; use crate::ui::ProfilePic; use crate::{colors, images, DisplayName}; use egui::load::TexturePoll; -use egui::{Frame, RichText, Sense, Vec2, Widget}; +use egui::{Frame, RichText, Sense, Widget}; use egui_extras::Size; use nostrdb::ProfileRecord; @@ -93,10 +93,6 @@ impl<'a, 'cache> SimpleProfilePreview<'a, 'cache> { pub fn new(profile: &'a ProfileRecord<'a>, cache: &'cache mut ImageCache) -> Self { SimpleProfilePreview { profile, cache } } - - pub fn dimensions(&self) -> Vec2 { - Vec2::new(120.0, 150.0) - } } impl<'a, 'cache> egui::Widget for SimpleProfilePreview<'a, 'cache> { @@ -152,7 +148,7 @@ mod previews { } } -fn get_display_name<'a>(profile: &'a ProfileRecord<'a>) -> DisplayName<'a> { +pub fn get_display_name<'a>(profile: &'a ProfileRecord<'a>) -> DisplayName<'a> { if let Some(name) = crate::profile::get_profile_name(profile) { name } else { @@ -160,7 +156,7 @@ fn get_display_name<'a>(profile: &'a ProfileRecord<'a>) -> DisplayName<'a> { } } -fn get_profile_url<'a>(profile: &'a ProfileRecord<'a>) -> &'a str { +pub fn get_profile_url<'a>(profile: &'a ProfileRecord<'a>) -> &'a str { if let Some(url) = profile.record().profile().and_then(|p| p.picture()) { url } else { diff --git a/src/ui/profile/profile_preview_controller.rs b/src/ui/profile/profile_preview_controller.rs @@ -0,0 +1,168 @@ +use nostrdb::{Ndb, Transaction}; + +use crate::{account_manager::AccountManager, imgcache::ImageCache, DisplayName, Result}; + +use super::{ + preview::{get_display_name, get_profile_url, SimpleProfilePreview}, + ProfilePic, +}; + +pub struct SimpleProfilePreviewController<'a> { + ndb: &'a Ndb, + img_cache: &'a mut ImageCache, +} + +#[derive(Debug)] +pub enum ProfilePreviewOp { + RemoveAccount, + SwitchTo, +} + +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: &mut AccountManager, + ui: &mut egui::Ui, + add_preview_ui: fn( + ui: &mut egui::Ui, + preview: SimpleProfilePreview, + width: f32, + is_selected: bool, + ) -> Option<ProfilePreviewOp>, + ) -> Option<Vec<usize>> { + let mut to_remove: Option<Vec<usize>> = None; + + let width = ui.available_width(); + + let txn = if let Ok(txn) = Transaction::new(self.ndb) { + txn + } else { + return None; + }; + + for i in 0..account_manager.num_accounts() { + let account = if let Some(account) = account_manager.get_account(i) { + account + } else { + continue; + }; + + let profile = + if let Ok(profile) = self.ndb.get_profile_by_pubkey(&txn, account.pubkey.bytes()) { + profile + } else { + continue; + }; + + let preview = SimpleProfilePreview::new(&profile, self.img_cache); + + let is_selected = if let Some(selected) = account_manager.get_selected_account_index() { + i == selected + } else { + false + }; + + let op = if let Some(op) = add_preview_ui(ui, preview, width, is_selected) { + op + } else { + continue; + }; + + match op { + ProfilePreviewOp::RemoveAccount => { + if to_remove.is_none() { + to_remove = Some(Vec::new()); + } + to_remove.as_mut().unwrap().push(i); + } + ProfilePreviewOp::SwitchTo => account_manager.select_account(i), + } + } + + to_remove + } + + pub fn view_profile_previews( + &mut self, + account_manager: &AccountManager, + ui: &mut egui::Ui, + add_preview_ui: fn( + ui: &mut egui::Ui, + preview: SimpleProfilePreview, + width: f32, + is_selected: bool, + index: usize, + ) -> bool, + ) -> Option<usize> { + let width = ui.available_width(); + + let txn = if let Ok(txn) = Transaction::new(self.ndb) { + txn + } else { + return None; + }; + + for i in 0..account_manager.num_accounts() { + let account = if let Some(account) = account_manager.get_account(i) { + account + } else { + continue; + }; + + let profile = + if let Ok(profile) = self.ndb.get_profile_by_pubkey(&txn, account.pubkey.bytes()) { + profile + } else { + continue; + }; + + let preview = SimpleProfilePreview::new(&profile, self.img_cache); + + let is_selected = if let Some(selected) = account_manager.get_selected_account_index() { + i == selected + } else { + false + }; + + if add_preview_ui(ui, preview, width, is_selected, i) { + return Some(i); + } + } + + None + } + + pub fn show_with_nickname( + &self, + ui: &mut egui::Ui, + key: &[u8; 32], + ui_element: fn(ui: &mut egui::Ui, username: &DisplayName) -> egui::Response, + ) -> Result<egui::Response> { + let txn = Transaction::new(self.ndb)?; + let profile = self.ndb.get_profile_by_pubkey(&txn, key)?; + Ok(ui_element(ui, &get_display_name(&profile))) + } + + pub fn show_with_pfp( + self, + ui: &mut egui::Ui, + key: &[u8; 32], + ui_element: fn(ui: &mut egui::Ui, pfp: ProfilePic) -> egui::Response, + ) -> Option<egui::Response> { + if let Ok(txn) = Transaction::new(self.ndb) { + let profile = self.ndb.get_profile_by_pubkey(&txn, key); + + if let Ok(profile) = profile { + return Some(ui_element( + ui, + ProfilePic::new(self.img_cache, get_profile_url(&profile)), + )); + } + } + None + } +} diff --git a/src/ui/side_panel.rs b/src/ui/side_panel.rs @@ -0,0 +1,172 @@ +use egui::{Button, Layout, SidePanel, Vec2, Widget}; + +use crate::account_manager::AccountManager; + +use super::{profile::SimpleProfilePreviewController, ProfilePic, View}; + +pub struct DesktopSidePanel<'a> { + selected_account: Option<&'a [u8; 32]>, + simple_preview_controller: SimpleProfilePreviewController<'a>, +} + +#[derive(Debug, Eq, PartialEq, Clone, Copy)] +pub enum SidePanelAction { + Panel, + Account, + Settings, + Columns, +} + +pub struct SidePanelResponse { + pub response: egui::Response, + pub action: SidePanelAction, +} + +impl SidePanelResponse { + fn new(action: SidePanelAction, response: egui::Response) -> Self { + SidePanelResponse { action, response } + } +} + +impl<'a> Widget for DesktopSidePanel<'a> { + fn ui(self, ui: &mut egui::Ui) -> egui::Response { + self.show(ui).response + } +} + +impl<'a> DesktopSidePanel<'a> { + pub fn new( + selected_account: Option<&'a [u8; 32]>, + simple_preview_controller: SimpleProfilePreviewController<'a>, + ) -> Self { + DesktopSidePanel { + selected_account, + simple_preview_controller, + } + } + + pub fn panel() -> SidePanel { + egui::SidePanel::left("side_panel") + .resizable(false) + .exact_width(40.0) + } + + pub fn show(self, ui: &mut egui::Ui) -> SidePanelResponse { + let dark_mode = ui.ctx().style().visuals.dark_mode; + let spacing_amt = 16.0; + + let inner = ui + .with_layout(Layout::bottom_up(egui::Align::Center), |ui| { + ui.spacing_mut().item_spacing.y = spacing_amt; + let pfp_resp = self.pfp_button(ui); + let settings_resp = ui.add(settings_button(dark_mode)); + let column_resp = ui.add(add_column_button(dark_mode)); + + if pfp_resp.clicked() || pfp_resp.hovered() { + egui::InnerResponse::new(SidePanelAction::Account, pfp_resp) + } else if settings_resp.clicked() || settings_resp.hovered() { + egui::InnerResponse::new(SidePanelAction::Settings, settings_resp) + } else if column_resp.clicked() || column_resp.hovered() { + egui::InnerResponse::new(SidePanelAction::Columns, column_resp) + } else { + egui::InnerResponse::new(SidePanelAction::Panel, pfp_resp) + } + }) + .inner; + + SidePanelResponse::new(inner.inner, inner.response) + } + + fn pfp_button(self, ui: &mut egui::Ui) -> egui::Response { + if let Some(selected_account) = self.selected_account { + if let Some(response) = + self.simple_preview_controller + .show_with_pfp(ui, selected_account, show_pfp()) + { + return response; + } + } + + add_button_to_ui(ui, no_account_pfp()) + } +} + +fn show_pfp() -> fn(ui: &mut egui::Ui, pfp: ProfilePic) -> egui::Response { + |ui, pfp| { + let response = pfp.ui(ui); + ui.allocate_rect(response.rect, egui::Sense::click()) + } +} + +fn settings_button(dark_mode: bool) -> egui::Button<'static> { + let _ = dark_mode; + let img_data = egui::include_image!("../../assets/icons/settings_dark_4x.png"); + + egui::Button::image(egui::Image::new(img_data).max_width(32.0)).frame(false) +} + +fn add_button_to_ui(ui: &mut egui::Ui, button: Button) -> egui::Response { + ui.add_sized(Vec2::new(32.0, 32.0), button) +} + +fn no_account_pfp() -> Button<'static> { + Button::new("A") + .rounding(20.0) + .min_size(Vec2::new(38.0, 38.0)) +} + +fn add_column_button(dark_mode: bool) -> egui::Button<'static> { + let _ = dark_mode; + let img_data = egui::include_image!("../../assets/icons/add_column_dark_4x.png"); + + egui::Button::image(egui::Image::new(img_data).max_width(32.0)).frame(false) +} + +mod preview { + use nostrdb::Ndb; + + use crate::{imgcache::ImageCache, test_data, ui::Preview}; + + use super::*; + + pub struct DesktopSidePanelPreview { + account_manager: AccountManager, + ndb: Ndb, + img_cache: ImageCache, + } + + impl DesktopSidePanelPreview { + fn new() -> Self { + let (account_manager, ndb, img_cache) = test_data::get_accmgr_and_ndb_and_imgcache(); + DesktopSidePanelPreview { + account_manager, + ndb, + img_cache, + } + } + } + + impl View for DesktopSidePanelPreview { + fn ui(&mut self, ui: &mut egui::Ui) { + let selected_account = self + .account_manager + .get_selected_account() + .map(|x| x.pubkey.bytes()); + + let panel = DesktopSidePanel::new( + selected_account, + SimpleProfilePreviewController::new(&self.ndb, &mut self.img_cache), + ); + + DesktopSidePanel::panel().show(ui.ctx(), |ui| panel.ui(ui)); + } + } + + impl<'a> Preview for DesktopSidePanel<'a> { + type Prev = DesktopSidePanelPreview; + + fn preview() -> Self::Prev { + DesktopSidePanelPreview::new() + } + } +} diff --git a/src/ui_preview/main.rs b/src/ui_preview/main.rs @@ -3,8 +3,8 @@ use notedeck::app_creation::{ }; use notedeck::ui::account_login_view::AccountLoginView; use notedeck::ui::{ - AccountManagementView, AccountSelectionWidget, Preview, PreviewApp, ProfilePic, ProfilePreview, - RelayView, + AccountManagementView, AccountSelectionWidget, DesktopSidePanel, Preview, PreviewApp, + ProfilePic, ProfilePreview, RelayView, }; use std::env; @@ -88,5 +88,6 @@ async fn main() { ProfilePic, AccountManagementView, AccountSelectionWidget, + DesktopSidePanel, ); } diff --git a/src/user_account.rs b/src/user_account.rs @@ -1,6 +1,9 @@ -use enostr::{FullKeypair, RelayPool}; +use enostr::Keypair; -pub struct UserAccount { - pub key: FullKeypair, - pub relays: RelayPool, -} +//pub struct UserAccount { +//pub key: Keypair, +//pub relays: RelayPool, +//pub relays: Vec<String>, +//} + +pub type UserAccount = Keypair;