diff --git a/assets/icons/new_deck_icon_4x_dark.png b/assets/icons/new_deck_icon_4x_dark.png new file mode 100644 index 00000000..38c628a2 Binary files /dev/null and b/assets/icons/new_deck_icon_4x_dark.png differ diff --git a/src/accounts/mod.rs b/src/accounts/mod.rs index 37634a52..3b21cf2b 100644 --- a/src/accounts/mod.rs +++ b/src/accounts/mod.rs @@ -8,12 +8,13 @@ use uuid::Uuid; use enostr::{ClientMessage, FilledKeypair, FullKeypair, Keypair, RelayPool}; use nostrdb::{Filter, Ndb, Note, NoteKey, Subscription, Transaction}; +use crate::app::get_active_columns_mut; +use crate::decks::DecksCache; use crate::{ - column::Columns, imgcache::ImageCache, login_manager::AcquireKeyState, muted::Muted, - route::{Route, Router}, + route::Route, storage::{KeyStorageResponse, KeyStorageType}, ui::{ account_login_view::{AccountLoginResponse, AccountLoginView}, @@ -27,7 +28,7 @@ use tracing::{debug, error, info}; mod route; -pub use route::{AccountsRoute, AccountsRouteResponse}; +pub use route::{AccountsAction, AccountsRoute, AccountsRouteResponse}; pub struct AccountRelayData { filter: Filter, @@ -224,19 +225,34 @@ pub struct Accounts { needs_relay_config: bool, } +#[must_use = "You must call process_login_action on this to handle unknown ids"] +pub struct RenderAccountAction { + pub accounts_action: Option, + pub unk_id_action: SingleUnkIdAction, +} + +impl RenderAccountAction { + // Simple wrapper around processing the unknown action to expose too + // much internal logic. This allows us to have a must_use on our + // LoginAction type, otherwise the SingleUnkIdAction's must_use will + // be lost when returned in the login action + pub fn process_action(&mut self, ids: &mut UnknownIds, ndb: &Ndb, txn: &Transaction) { + self.unk_id_action.process_action(ids, ndb, txn); + } +} + /// Render account management views from a route #[allow(clippy::too_many_arguments)] pub fn render_accounts_route( ui: &mut egui::Ui, ndb: &Ndb, col: usize, - columns: &mut Columns, img_cache: &mut ImageCache, accounts: &mut Accounts, + decks: &mut DecksCache, login_state: &mut AcquireKeyState, route: AccountsRoute, -) -> SingleUnkIdAction { - let router = columns.column_mut(col).router_mut(); +) -> RenderAccountAction { let resp = match route { AccountsRoute::Accounts => AccountsView::new(ndb, accounts, img_cache) .ui(ui) @@ -252,37 +268,57 @@ pub fn render_accounts_route( if let Some(resp) = resp { match resp { AccountsRouteResponse::Accounts(response) => { - process_accounts_view_response(accounts, response, router); - SingleUnkIdAction::no_action() + let action = process_accounts_view_response(accounts, decks, col, response); + RenderAccountAction { + accounts_action: action, + unk_id_action: SingleUnkIdAction::no_action(), + } } AccountsRouteResponse::AddAccount(response) => { - let action = process_login_view_response(accounts, response); + let action = process_login_view_response(accounts, decks, response); *login_state = Default::default(); + let router = get_active_columns_mut(accounts, decks) + .column_mut(col) + .router_mut(); router.go_back(); action } } } else { - SingleUnkIdAction::no_action() + RenderAccountAction { + accounts_action: None, + unk_id_action: SingleUnkIdAction::no_action(), + } } } pub fn process_accounts_view_response( - manager: &mut Accounts, + accounts: &mut Accounts, + decks: &mut DecksCache, + col: usize, response: AccountsViewResponse, - router: &mut Router, -) { +) -> Option { + let router = get_active_columns_mut(accounts, decks) + .column_mut(col) + .router_mut(); + let mut selection = None; match response { AccountsViewResponse::RemoveAccount(index) => { - manager.remove_account(index); + let acc_sel = AccountsAction::Remove(index); + info!("account selection: {:?}", acc_sel); + selection = Some(acc_sel); } AccountsViewResponse::SelectAccount(index) => { - manager.select_account(index); + let acc_sel = AccountsAction::Switch(index); + info!("account selection: {:?}", acc_sel); + selection = Some(acc_sel); } AccountsViewResponse::RouteToLogin => { router.route_to(Route::add_account()); } } + + selection } impl Accounts { @@ -375,7 +411,7 @@ impl Accounts { } #[must_use = "UnknownIdAction's must be handled. Use .process_unknown_id_action()"] - pub fn add_account(&mut self, account: Keypair) -> LoginAction { + pub fn add_account(&mut self, account: Keypair) -> RenderAccountAction { let pubkey = account.pubkey; let switch_to_index = if let Some(contains_acc) = self.contains_account(pubkey.bytes()) { if account.secret_key.is_some() && !contains_acc.has_nsec { @@ -397,9 +433,9 @@ impl Accounts { self.accounts.len() - 1 }; - LoginAction { - unk: SingleUnkIdAction::pubkey(pubkey), - switch_to_index, + RenderAccountAction { + accounts_action: Some(AccountsAction::Switch(switch_to_index)), + unk_id_action: SingleUnkIdAction::pubkey(pubkey), } } @@ -619,32 +655,24 @@ fn get_selected_index(accounts: &[UserAccount], keystore: &KeyStorageType) -> Op pub fn process_login_view_response( manager: &mut Accounts, + decks: &mut DecksCache, response: AccountLoginResponse, -) -> SingleUnkIdAction { - let login_action = match response { +) -> RenderAccountAction { + let (r, pubkey) = match response { AccountLoginResponse::CreateNew => { - manager.add_account(FullKeypair::generate().to_keypair()) + let kp = FullKeypair::generate().to_keypair(); + let pubkey = kp.pubkey; + (manager.add_account(kp), pubkey) + } + AccountLoginResponse::LoginWith(keypair) => { + let pubkey = keypair.pubkey; + (manager.add_account(keypair), pubkey) } - AccountLoginResponse::LoginWith(keypair) => manager.add_account(keypair), }; - manager.select_account(login_action.switch_to_index); - login_action.unk -} -#[must_use = "You must call process_login_action on this to handle unknown ids"] -pub struct LoginAction { - unk: SingleUnkIdAction, - pub switch_to_index: usize, -} + decks.add_deck_default(pubkey); -impl LoginAction { - // Simple wrapper around processing the unknown action to expose too - // much internal logic. This allows us to have a must_use on our - // LoginAction type, otherwise the SingleUnkIdAction's must_use will - // be lost when returned in the login action - pub fn process_action(&mut self, ids: &mut UnknownIds, ndb: &Ndb, txn: &Transaction) { - self.unk.process_action(ids, ndb, txn); - } + r } #[derive(Default)] diff --git a/src/accounts/route.rs b/src/accounts/route.rs index 69ce1279..b446d463 100644 --- a/src/accounts/route.rs +++ b/src/accounts/route.rs @@ -11,3 +11,9 @@ pub enum AccountsRoute { Accounts, AddAccount, } + +#[derive(Debug)] +pub enum AccountsAction { + Switch(usize), + Remove(usize), +} diff --git a/src/app.rs b/src/app.rs index 8512890b..f856ea77 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,9 +1,10 @@ use crate::{ - accounts::{Accounts, AccountsRoute}, + accounts::Accounts, app_creation::setup_cc, app_size_handler::AppSizeHandler, args::Args, - column::{Column, Columns}, + column::Columns, + decks::{Decks, DecksCache, FALLBACK_PUBKEY}, draft::Drafts, filter::FilterState, frame_history::FrameHistory, @@ -12,13 +13,12 @@ use crate::{ notecache::NoteCache, notes_holder::NotesHolderStorage, profile::Profile, - route::Route, storage::{self, DataPath, DataPathType, Directory, FileKeyStorage, KeyStorageType}, subscriptions::{SubKind, Subscriptions}, support::Support, thread::Thread, - timeline::{self, Timeline, TimelineKind}, - ui::{self, add_column::AddColumnRoute, DesktopSidePanel}, + timeline::{self, Timeline}, + ui::{self, DesktopSidePanel}, unknowns::UnknownIds, view_state::ViewState, Result, @@ -30,7 +30,7 @@ use uuid::Uuid; use egui::{Context, Frame, Style}; use egui_extras::{Size, StripBuilder}; -use nostrdb::{Config, Filter, Ndb, Transaction}; +use nostrdb::{Config, Ndb, Transaction}; use std::collections::HashMap; use std::path::Path; @@ -49,7 +49,7 @@ pub struct Damus { pub note_cache: NoteCache, pub pool: RelayPool, - pub columns: Columns, + pub decks_cache: DecksCache, pub ndb: Ndb, pub view_state: ViewState, pub unknown_ids: UnknownIds, @@ -98,7 +98,8 @@ fn handle_key_events(input: &egui::InputState, _pixels_per_point: f32, columns: fn try_process_event(damus: &mut Damus, ctx: &egui::Context) -> Result<()> { let ppp = ctx.pixels_per_point(); - ctx.input(|i| handle_key_events(i, ppp, &mut damus.columns)); + let current_columns = get_active_columns_mut(&damus.accounts, &mut damus.decks_cache); + ctx.input(|i| handle_key_events(i, ppp, current_columns)); let ctx2 = ctx.clone(); let wakeup = move || { @@ -124,7 +125,7 @@ fn try_process_event(damus: &mut Damus, ctx: &egui::Context) -> Result<()> { timeline::send_initial_timeline_filters( &damus.ndb, damus.since_optimize, - &mut damus.columns, + get_active_columns_mut(&damus.accounts, &mut damus.decks_cache), &mut damus.subscriptions, &mut damus.pool, &ev.relay, @@ -138,10 +139,11 @@ fn try_process_event(damus: &mut Damus, ctx: &egui::Context) -> Result<()> { } } - let n_timelines = damus.columns.timelines().len(); + let current_columns = get_active_columns_mut(&damus.accounts, &mut damus.decks_cache); + let n_timelines = current_columns.timelines().len(); for timeline_ind in 0..n_timelines { let is_ready = { - let timeline = &mut damus.columns.timelines[timeline_ind]; + let timeline = &mut current_columns.timelines[timeline_ind]; timeline::is_timeline_ready( &damus.ndb, &mut damus.pool, @@ -156,7 +158,7 @@ fn try_process_event(damus: &mut Damus, ctx: &egui::Context) -> Result<()> { if let Err(err) = Timeline::poll_notes_into_view( timeline_ind, - damus.columns.timelines_mut(), + current_columns.timelines_mut(), &damus.ndb, &txn, &mut damus.unknown_ids, @@ -209,7 +211,7 @@ fn update_damus(damus: &mut Damus, ctx: &egui::Context) { if let Err(err) = timeline::setup_initial_nostrdb_subs( &damus.ndb, &mut damus.note_cache, - &mut damus.columns, + &mut damus.decks_cache, &damus.accounts.mutefun(), ) { warn!("update_damus init: {err}"); @@ -257,7 +259,7 @@ fn handle_eose(damus: &mut Damus, subid: &str, relay_url: &str) -> Result<()> { UnknownIds::update( &txn, &mut damus.unknown_ids, - &damus.columns, + get_active_columns(&damus.accounts, &damus.decks_cache), &damus.ndb, &mut damus.note_cache, ); @@ -274,7 +276,10 @@ fn handle_eose(damus: &mut Damus, subid: &str, relay_url: &str) -> Result<()> { } SubKind::FetchingContactList(timeline_uid) => { - let timeline = if let Some(tl) = damus.columns.find_timeline_mut(timeline_uid) { + let timeline = if let Some(tl) = + get_active_columns_mut(&damus.accounts, &mut damus.decks_cache) + .find_timeline_mut(timeline_uid) + { tl } else { error!( @@ -445,19 +450,8 @@ impl Damus { .as_ref() .map(|a| a.pubkey.bytes()); - let mut columns = if parsed_args.columns.is_empty() { - if let Some(serializable_columns) = storage::load_columns(&path) { - info!("Using columns from disk"); - serializable_columns.into_columns(&ndb, account) - } else { - info!("Could not load columns from disk"); - Columns::new() - } - } else { - info!( - "Using columns from command line arguments: {:?}", - parsed_args.columns - ); + let decks_cache = if !parsed_args.columns.is_empty() { + info!("DecksCache: loading from command line arguments"); let mut columns: Columns = Columns::new(); for col in parsed_args.columns { if let Some(timeline) = col.into_timeline(&ndb, account) { @@ -465,19 +459,32 @@ impl Damus { } } - columns + columns_to_decks_cache(columns, account) + } else if let Some(decks_cache) = storage::load_decks_cache(&path, &ndb) { + info!( + "DecksCache: loading from disk {}", + crate::storage::DECKS_CACHE_FILE + ); + decks_cache + } else if let Some(cols) = storage::deserialize_columns(&path, &ndb, account) { + info!( + "DecksCache: loading from disk at depreciated location {}", + crate::storage::COLUMNS_FILE + ); + columns_to_decks_cache(cols, account) + } else { + info!("DecksCache: creating new with demo configuration"); + let mut cache = DecksCache::new_with_demo_config(&ndb); + for account in accounts.get_accounts() { + cache.add_deck_default(account.pubkey); + } + set_demo(&mut cache, &ndb, &mut accounts, &mut unknown_ids); + + cache }; let debug = parsed_args.debug; - if columns.columns().is_empty() { - if accounts.get_accounts().is_empty() { - set_demo(&path, &ndb, &mut accounts, &mut columns, &mut unknown_ids); - } else { - columns.new_column_picker(); - } - } - let app_rect_handler = AppSizeHandler::new(&path); let support = Support::new(&path); @@ -493,7 +500,6 @@ impl Damus { state: DamusState::Initializing, img_cache: ImageCache::new(imgcache_dir), note_cache: NoteCache::default(), - columns, textmode: parsed_args.textmode, ndb, accounts, @@ -502,6 +508,7 @@ impl Damus { path, app_rect_handler, support, + decks_cache, } } @@ -534,11 +541,11 @@ impl Damus { } pub fn columns_mut(&mut self) -> &mut Columns { - &mut self.columns + get_active_columns_mut(&self.accounts, &mut self.decks_cache) } pub fn columns(&self) -> &Columns { - &self.columns + get_active_columns(&self.accounts, &self.decks_cache) } pub fn gen_subid(&self, kind: &SubKind) -> String { @@ -550,12 +557,7 @@ impl Damus { } pub fn mock>(data_path: P) -> Self { - let mut columns = Columns::new(); - let filter = Filter::from_json(include_str!("../queries/global.json")).unwrap(); - - let timeline = Timeline::new(TimelineKind::Universe, FilterState::ready(vec![filter])); - - columns.add_new_timeline_column(timeline); + let decks_cache = DecksCache::default(); let path = DataPath::new(&data_path); let imgcache_dir = path.path(DataPathType::Cache).join(ImageCache::rel_dir()); @@ -579,7 +581,6 @@ impl Damus { pool: RelayPool::new(), img_cache: ImageCache::new(imgcache_dir), note_cache: NoteCache::default(), - columns, textmode: false, ndb: Ndb::new( path.path(DataPathType::Db) @@ -591,10 +592,10 @@ impl Damus { accounts: Accounts::new(KeyStorageType::None, vec![]), frame_history: FrameHistory::default(), view_state: ViewState::default(), - path, app_rect_handler, support, + decks_cache, } } @@ -639,10 +640,10 @@ fn render_damus_mobile(ctx: &egui::Context, app: &mut Damus) { //let routes = app.timelines[0].routes.clone(); main_panel(&ctx.style(), ui::is_narrow(ctx)).show(ctx, |ui| { - if !app.columns.columns().is_empty() + if !app.columns().columns().is_empty() && nav::render_nav(0, app, ui).process_render_nav_response(app) { - storage::save_columns(&app.path, app.columns().as_serializable_columns()); + storage::save_decks_cache(&app.path, &app.decks_cache); } }); } @@ -666,7 +667,9 @@ fn render_damus_desktop(ctx: &egui::Context, app: &mut Damus) { puffin::profile_function!(); let screen_size = ctx.screen_rect().width(); - let calc_panel_width = (screen_size / app.columns.num_columns() as f32) - 30.0; + let calc_panel_width = (screen_size + / get_active_columns(&app.accounts, &app.decks_cache).num_columns() as f32) + - 30.0; let min_width = 320.0; let need_scroll = calc_panel_width < min_width; let panel_sizes = if need_scroll { @@ -690,24 +693,32 @@ fn render_damus_desktop(ctx: &egui::Context, app: &mut Damus) { fn timelines_view(ui: &mut egui::Ui, sizes: Size, app: &mut Damus) { StripBuilder::new(ui) .size(Size::exact(ui::side_panel::SIDE_PANEL_WIDTH)) - .sizes(sizes, app.columns.num_columns()) + .sizes( + sizes, + get_active_columns(&app.accounts, &app.decks_cache).num_columns(), + ) .clip(true) .horizontal(|mut strip| { + let mut side_panel_action: Option = None; strip.cell(|ui| { let rect = ui.available_rect_before_wrap(); let side_panel = DesktopSidePanel::new( &app.ndb, &mut app.img_cache, app.accounts.get_selected_account(), + &app.decks_cache, ) .show(ui); - if side_panel.response.clicked() { - DesktopSidePanel::perform_action( - &mut app.columns, + if side_panel.response.clicked() || side_panel.response.secondary_clicked() { + if let Some(action) = DesktopSidePanel::perform_action( + &mut app.decks_cache, + &app.accounts, &mut app.support, side_panel.action, - ); + ) { + side_panel_action = Some(action); + } } // vertical sidebar line @@ -718,8 +729,14 @@ fn timelines_view(ui: &mut egui::Ui, sizes: Size, app: &mut Damus) { ); }); - let mut responses = Vec::with_capacity(app.columns.num_columns()); - for col_index in 0..app.columns.num_columns() { + let mut save_cols = false; + if let Some(action) = side_panel_action { + save_cols = save_cols || action.process(app); + } + + let num_cols = app.columns().num_columns(); + let mut responses = Vec::with_capacity(num_cols); + for col_index in 0..num_cols { strip.cell(|ui| { let rect = ui.available_rect_before_wrap(); responses.push(nav::render_nav(col_index, app, ui)); @@ -735,14 +752,13 @@ fn timelines_view(ui: &mut egui::Ui, sizes: Size, app: &mut Damus) { //strip.cell(|ui| timeline::timeline_view(ui, app, timeline_ind)); } - let mut save_cols = false; for response in responses { let save = response.process_render_nav_response(app); save_cols = save_cols || save; } if save_cols { - storage::save_columns(&app.path, app.columns().as_serializable_columns()); + storage::save_decks_cache(&app.path, &app.decks_cache); } }); } @@ -766,37 +782,62 @@ impl eframe::App for Damus { } } -fn set_demo( - data_path: &DataPath, +pub fn get_active_columns<'a>(accounts: &Accounts, decks_cache: &'a DecksCache) -> &'a Columns { + get_decks(accounts, decks_cache).active().columns() +} + +pub fn get_decks<'a>(accounts: &Accounts, decks_cache: &'a DecksCache) -> &'a Decks { + let key = if let Some(acc) = accounts.get_selected_account() { + &acc.pubkey + } else { + decks_cache.get_fallback_pubkey() + }; + decks_cache.decks(key) +} + +pub fn get_active_columns_mut<'a>( + accounts: &Accounts, + decks_cache: &'a mut DecksCache, +) -> &'a mut Columns { + get_decks_mut(accounts, decks_cache) + .active_mut() + .columns_mut() +} + +pub fn get_decks_mut<'a>(accounts: &Accounts, decks_cache: &'a mut DecksCache) -> &'a mut Decks { + if let Some(acc) = accounts.get_selected_account() { + decks_cache.decks_mut(&acc.pubkey) + } else { + decks_cache.fallback_mut() + } +} + +pub fn set_demo( + decks_cache: &mut DecksCache, ndb: &Ndb, accounts: &mut Accounts, - columns: &mut Columns, unk_ids: &mut UnknownIds, ) { - let demo_pubkey = - Pubkey::from_hex("aa733081e4f0f79dd43023d8983265593f2b41a988671cfcef3f489b91ad93fe") - .unwrap(); - { - let txn = Transaction::new(ndb).expect("txn"); - accounts - .add_account(Keypair::only_pubkey(demo_pubkey)) - .process_action(unk_ids, ndb, &txn); - accounts.select_account(0); - } - - columns.add_column(Column::new(vec![ - Route::AddColumn(AddColumnRoute::Base), - Route::Accounts(AccountsRoute::Accounts), - ])); - - if let Some(timeline) = - TimelineKind::contact_list(timeline::PubkeySource::Explicit(demo_pubkey)) - .into_timeline(ndb, Some(demo_pubkey.bytes())) - { - columns.add_new_timeline_column(timeline); - } + let txn = Transaction::new(ndb).expect("txn"); + accounts + .add_account(Keypair::only_pubkey(*decks_cache.get_fallback_pubkey())) + .process_action(unk_ids, ndb, &txn); + accounts.select_account(accounts.num_accounts() - 1); +} - columns.add_new_timeline_column(Timeline::hashtag("introductions".to_string())); +fn columns_to_decks_cache(cols: Columns, key: Option<&[u8; 32]>) -> DecksCache { + let mut account_to_decks: HashMap = Default::default(); + let decks = Decks::new(crate::decks::Deck::new_with_columns( + crate::decks::Deck::default().icon, + "My Deck".to_owned(), + cols, + )); - storage::save_columns(data_path, columns.as_serializable_columns()); + let account = if let Some(key) = key { + Pubkey::new(*key) + } else { + FALLBACK_PUBKEY() + }; + account_to_decks.insert(account, decks); + DecksCache::new(account_to_decks) } diff --git a/src/app_style.rs b/src/app_style.rs index 86fc85d9..b80c12ac 100644 --- a/src/app_style.rs +++ b/src/app_style.rs @@ -229,7 +229,7 @@ pub fn create_themed_visuals(theme: ColorTheme, default: Visuals) -> Visuals { } } -//pub static DECK_ICON_SIZE: f32 = 24.0; +pub static DECK_ICON_SIZE: f32 = 24.0; pub fn deck_icon_font_sized(size: f32) -> FontId { egui::FontId::new(size, emoji_font_family()) diff --git a/src/args.rs b/src/args.rs index 99d8630e..43a39f1f 100644 --- a/src/args.rs +++ b/src/args.rs @@ -306,15 +306,15 @@ mod tests { let ctx = egui::Context::default(); let app = Damus::new(&ctx, &tmpdir, args); - assert_eq!(app.columns.columns().len(), 2); + assert_eq!(app.columns().columns().len(), 2); - let tl1 = app.columns.column(0).router().top().timeline_id(); - let tl2 = app.columns.column(1).router().top().timeline_id(); + let tl1 = app.columns().column(0).router().top().timeline_id(); + let tl2 = app.columns().column(1).router().top().timeline_id(); assert_eq!(tl1.is_some(), true); assert_eq!(tl2.is_some(), true); - let timelines = app.columns.timelines(); + let timelines = app.columns().timelines(); assert!(timelines[0].kind.is_notifications()); assert!(timelines[1].kind.is_contacts()); diff --git a/src/column.rs b/src/column.rs index 042f283b..97564174 100644 --- a/src/column.rs +++ b/src/column.rs @@ -1,11 +1,9 @@ use crate::route::{Route, Router}; -use crate::timeline::{SerializableTimeline, Timeline, TimelineId, TimelineRoute}; +use crate::timeline::{Timeline, TimelineId}; use indexmap::IndexMap; -use nostrdb::Ndb; -use serde::{Deserialize, Deserializer, Serialize}; use std::iter::Iterator; use std::sync::atomic::{AtomicU32, Ordering}; -use tracing::{error, warn}; +use tracing::warn; #[derive(Clone)] pub struct Column { @@ -27,28 +25,6 @@ impl Column { } } -impl serde::Serialize for Column { - fn serialize(&self, serializer: S) -> Result - where - S: serde::Serializer, - { - self.router.routes().serialize(serializer) - } -} - -impl<'de> Deserialize<'de> for Column { - fn deserialize(deserializer: D) -> Result - where - D: Deserializer<'de>, - { - let routes = Vec::::deserialize(deserializer)?; - - Ok(Column { - router: Router::new(routes), - }) - } -} - #[derive(Default)] pub struct Columns { /// Columns are simply routers into settings, timelines, etc @@ -90,6 +66,24 @@ impl Columns { )])); } + pub fn insert_intermediary_routes(&mut self, intermediary_routes: Vec) { + let id = Self::get_new_id(); + + let routes = intermediary_routes + .into_iter() + .map(|r| match r { + IntermediaryRoute::Timeline(timeline) => { + let route = Route::timeline(timeline.id); + self.timelines.insert(id, timeline); + route + } + IntermediaryRoute::Route(route) => route, + }) + .collect(); + + self.columns.insert(id, Column::new(routes)); + } + fn get_new_id() -> u32 { UIDS.fetch_add(1, Ordering::Relaxed) } @@ -217,56 +211,14 @@ impl Columns { self.new_column_picker(); } } - - pub fn as_serializable_columns(&self) -> SerializableColumns { - SerializableColumns { - columns: self.columns.values().cloned().collect(), - timelines: self - .timelines - .values() - .map(|t| t.as_serializable_timeline()) - .collect(), - } - } } -#[derive(Serialize, Deserialize)] -pub struct SerializableColumns { - pub columns: Vec, - pub timelines: Vec, +pub enum IntermediaryRoute { + Timeline(Timeline), + Route(Route), } -impl SerializableColumns { - pub fn into_columns(self, ndb: &Ndb, deck_pubkey: Option<&[u8; 32]>) -> Columns { - let mut columns = Columns::default(); - - for column in self.columns { - let id = Columns::get_new_id(); - let mut routes = Vec::new(); - for route in column.router.routes() { - match route { - Route::Timeline(TimelineRoute::Timeline(timeline_id)) => { - if let Some(serializable_tl) = - self.timelines.iter().find(|tl| tl.id == *timeline_id) - { - let tl = serializable_tl.clone().into_timeline(ndb, deck_pubkey); - if let Some(tl) = tl { - routes.push(Route::Timeline(TimelineRoute::Timeline(tl.id))); - columns.timelines.insert(id, tl); - } else { - error!("Problem deserializing timeline {:?}", serializable_tl); - } - } - } - Route::Timeline(TimelineRoute::Thread(_thread)) => { - // TODO: open thread before pushing route - } - _ => routes.push(*route), - } - } - columns.add_column_at(Column::new(routes), id); - } - - columns - } +pub enum ColumnsAction { + // Switch(usize), TODO: could use for keyboard selection + Remove(usize), } diff --git a/src/deck_state.rs b/src/deck_state.rs index ccca2d9c..b4837697 100644 --- a/src/deck_state.rs +++ b/src/deck_state.rs @@ -4,7 +4,6 @@ use crate::{app_style::emoji_font_family, decks::Deck}; pub struct DeckState { pub deck_name: String, pub selected_glyph: Option, - pub deleting: bool, pub selecting_glyph: bool, pub warn_no_title: bool, pub warn_no_icon: bool, @@ -42,7 +41,6 @@ impl Default for DeckState { Self { deck_name: Default::default(), selected_glyph: Default::default(), - deleting: Default::default(), selecting_glyph: true, warn_no_icon: Default::default(), warn_no_title: Default::default(), diff --git a/src/decks.rs b/src/decks.rs index 4597bbaf..1d832bd9 100644 --- a/src/decks.rs +++ b/src/decks.rs @@ -1,4 +1,4 @@ -use std::collections::HashMap; +use std::collections::{hash_map::ValuesMut, HashMap}; use enostr::Pubkey; use nostrdb::Ndb; @@ -12,29 +12,31 @@ use crate::{ ui::{add_column::AddColumnRoute, configure_deck::ConfigureDeckResponse}, }; -static FALLBACK_PUBKEY: &str = "aa733081e4f0f79dd43023d8983265593f2b41a988671cfcef3f489b91ad93fe"; +pub static FALLBACK_PUBKEY: fn() -> Pubkey = || { + Pubkey::from_hex("aa733081e4f0f79dd43023d8983265593f2b41a988671cfcef3f489b91ad93fe").unwrap() +}; -//pub enum DecksAction { -// Switch(usize), -// Removing(usize), -//} +pub enum DecksAction { + Switch(usize), + Removing(usize), +} pub struct DecksCache { - pub account_to_decks: HashMap, - pub fallback_pubkey: Pubkey, + account_to_decks: HashMap, + fallback_pubkey: Pubkey, } impl Default for DecksCache { fn default() -> Self { let mut account_to_decks: HashMap = Default::default(); - account_to_decks.insert(Pubkey::from_hex(FALLBACK_PUBKEY).unwrap(), Decks::default()); + account_to_decks.insert(FALLBACK_PUBKEY(), Decks::default()); DecksCache::new(account_to_decks) } } impl DecksCache { pub fn new(account_to_decks: HashMap) -> Self { - let fallback_pubkey = Pubkey::from_hex(FALLBACK_PUBKEY).unwrap(); + let fallback_pubkey = FALLBACK_PUBKEY(); Self { account_to_decks, @@ -44,7 +46,7 @@ impl DecksCache { pub fn new_with_demo_config(ndb: &Ndb) -> Self { let mut account_to_decks: HashMap = Default::default(); - let fallback_pubkey = Pubkey::from_hex(FALLBACK_PUBKEY).unwrap(); + let fallback_pubkey = FALLBACK_PUBKEY(); account_to_decks.insert(fallback_pubkey, demo_decks(fallback_pubkey, ndb)); DecksCache::new(account_to_decks) } @@ -52,13 +54,17 @@ impl DecksCache { pub fn decks(&self, key: &Pubkey) -> &Decks { self.account_to_decks .get(key) - .unwrap_or_else(|| panic!("{:?} not found", key)) + .unwrap_or_else(|| self.fallback()) } pub fn decks_mut(&mut self, key: &Pubkey) -> &mut Decks { + self.account_to_decks.entry(*key).or_default() + } + + pub fn fallback(&self) -> &Decks { self.account_to_decks - .get_mut(key) - .unwrap_or_else(|| panic!("{:?} not found", key)) + .get(&self.fallback_pubkey) + .unwrap_or_else(|| panic!("fallback deck not found")) } pub fn fallback_mut(&mut self) -> &mut Decks { @@ -107,6 +113,18 @@ impl DecksCache { info!("Removing decks for {:?}", key); self.account_to_decks.remove(key); } + + pub fn get_fallback_pubkey(&self) -> &Pubkey { + &self.fallback_pubkey + } + + pub fn get_all_decks_mut(&mut self) -> ValuesMut { + self.account_to_decks.values_mut() + } + + pub fn get_mapping(&self) -> &HashMap { + &self.account_to_decks + } } pub struct Decks { diff --git a/src/nav.rs b/src/nav.rs index 714686de..eb788e01 100644 --- a/src/nav.rs +++ b/src/nav.rs @@ -1,6 +1,10 @@ use crate::{ - accounts::render_accounts_route, + accounts::{render_accounts_route, AccountsAction}, actionbar::NoteAction, + app::{get_active_columns, get_active_columns_mut, get_decks_mut}, + column::ColumnsAction, + deck_state::DeckState, + decks::{Deck, DecksAction}, notes_holder::NotesHolder, profile::Profile, relay_pool_manager::RelayPoolManager, @@ -14,6 +18,8 @@ use crate::{ self, add_column::render_add_column_routes, column::NavTitle, + configure_deck::ConfigureDeckView, + edit_deck::{EditDeckResponse, EditDeckView}, note::{PostAction, PostType}, support::SupportView, RelayView, View, @@ -25,11 +31,45 @@ use egui_nav::{Nav, NavAction, NavResponse, NavUiType}; use nostrdb::{Ndb, Transaction}; use tracing::{error, info}; +#[allow(clippy::enum_variant_names)] pub enum RenderNavAction { Back, RemoveColumn, PostAction(PostAction), NoteAction(NoteAction), + SwitchingAction(SwitchingAction), +} + +pub enum SwitchingAction { + Accounts(AccountsAction), + Columns(ColumnsAction), + Decks(crate::decks::DecksAction), +} + +impl SwitchingAction { + /// process the action, and return whether switching occured + pub fn process(&self, app: &mut Damus) -> bool { + match &self { + SwitchingAction::Accounts(account_action) => match *account_action { + AccountsAction::Switch(index) => app.accounts.select_account(index), + AccountsAction::Remove(index) => app.accounts.remove_account(index), + }, + SwitchingAction::Columns(columns_action) => match *columns_action { + ColumnsAction::Remove(index) => { + get_active_columns_mut(&app.accounts, &mut app.decks_cache).delete_column(index) + } + }, + SwitchingAction::Decks(decks_action) => match *decks_action { + DecksAction::Switch(index) => { + get_decks_mut(&app.accounts, &mut app.decks_cache).set_active(index) + } + DecksAction::Removing(index) => { + get_decks_mut(&app.accounts, &mut app.decks_cache).remove_deck(index) + } + }, + } + true + } } impl From for RenderNavAction { @@ -59,7 +99,7 @@ impl RenderNavResponse { #[must_use = "Make sure to save columns if result is true"] pub fn process_render_nav_response(&self, app: &mut Damus) -> bool { - let mut col_changed: bool = false; + let mut switching_occured: bool = false; let col = self.column; if let Some(action) = self @@ -81,13 +121,16 @@ impl RenderNavResponse { } app.columns_mut().delete_column(col); - col_changed = true; + switching_occured = true; } RenderNavAction::PostAction(post_action) => { let txn = Transaction::new(&app.ndb).expect("txn"); let _ = post_action.execute(&app.ndb, &txn, &mut app.pool, &mut app.drafts); - app.columns_mut().column_mut(col).router_mut().go_back(); + get_active_columns_mut(&app.accounts, &mut app.decks_cache) + .column_mut(col) + .router_mut() + .go_back(); } RenderNavAction::NoteAction(note_action) => { @@ -95,7 +138,7 @@ impl RenderNavResponse { note_action.execute_and_process_result( &app.ndb, - &mut app.columns, + get_active_columns_mut(&app.accounts, &mut app.decks_cache), col, &mut app.threads, &mut app.profiles, @@ -105,6 +148,10 @@ impl RenderNavResponse { &app.accounts.mutefun(), ); } + + RenderNavAction::SwitchingAction(switching_action) => { + switching_occured = switching_action.process(app); + } } } @@ -144,7 +191,8 @@ impl RenderNavResponse { &app.accounts.mutefun(), ); } - col_changed = true; + + switching_occured = true; } NavAction::Navigated => { @@ -153,7 +201,7 @@ impl RenderNavResponse { if cur_router.is_replacing() { cur_router.remove_previous_routes(); } - col_changed = true; + switching_occured = true; } NavAction::Dragging => {} @@ -163,7 +211,7 @@ impl RenderNavResponse { } } - col_changed + switching_occured } } @@ -176,7 +224,7 @@ fn render_nav_body( match top { Route::Timeline(tlr) => render_timeline_route( &app.ndb, - &mut app.columns, + get_active_columns_mut(&app.accounts, &mut app.decks_cache), &mut app.drafts, &mut app.img_cache, &mut app.unknown_ids, @@ -190,19 +238,21 @@ fn render_nav_body( ui, ), Route::Accounts(amr) => { - let action = render_accounts_route( + let mut action = render_accounts_route( ui, &app.ndb, col, - &mut app.columns, &mut app.img_cache, &mut app.accounts, + &mut app.decks_cache, &mut app.view_state.login, *amr, ); let txn = Transaction::new(&app.ndb).expect("txn"); action.process_action(&mut app.unknown_ids, &app.ndb, &txn); - None + action + .accounts_action + .map(|f| RenderNavAction::SwitchingAction(SwitchingAction::Accounts(f))) } Route::Relays => { let manager = RelayPoolManager::new(app.pool_mut()); @@ -231,17 +281,78 @@ fn render_nav_body( None } - Route::Support => { SupportView::new(&mut app.support).show(ui); None } + Route::NewDeck => { + let id = ui.id().with("new-deck"); + let new_deck_state = app.view_state.id_to_deck_state.entry(id).or_default(); + let mut resp = None; + if let Some(config_resp) = ConfigureDeckView::new(new_deck_state).ui(ui) { + if let Some(cur_acc) = app.accounts.get_selected_account() { + app.decks_cache.add_deck( + cur_acc.pubkey, + Deck::new(config_resp.icon, config_resp.name), + ); + + // set new deck as active + let cur_index = get_decks_mut(&app.accounts, &mut app.decks_cache) + .decks() + .len() + - 1; + resp = Some(RenderNavAction::SwitchingAction(SwitchingAction::Decks( + DecksAction::Switch(cur_index), + ))); + } + + new_deck_state.clear(); + get_active_columns_mut(&app.accounts, &mut app.decks_cache) + .get_first_router() + .go_back(); + } + resp + } + Route::EditDeck(index) => { + let mut action = None; + let cur_deck = get_decks_mut(&app.accounts, &mut app.decks_cache) + .decks_mut() + .get_mut(*index) + .expect("index wasn't valid"); + let id = ui.id().with(( + "edit-deck", + app.accounts.get_selected_account().map(|k| k.pubkey), + index, + )); + let deck_state = app + .view_state + .id_to_deck_state + .entry(id) + .or_insert_with(|| DeckState::from_deck(cur_deck)); + if let Some(resp) = EditDeckView::new(deck_state).ui(ui) { + match resp { + EditDeckResponse::Edit(configure_deck_response) => { + cur_deck.edit(configure_deck_response); + } + EditDeckResponse::Delete => { + action = Some(RenderNavAction::SwitchingAction(SwitchingAction::Decks( + DecksAction::Removing(*index), + ))); + } + } + get_active_columns_mut(&app.accounts, &mut app.decks_cache) + .get_first_router() + .go_back(); + } + + action + } } } #[must_use = "RenderNavResponse must be handled by calling .process_render_nav_response(..)"] pub fn render_nav(col: usize, app: &mut Damus, ui: &mut egui::Ui) -> RenderNavResponse { - let col_id = app.columns.get_column_id_at_index(col); + let col_id = get_active_columns(&app.accounts, &app.decks_cache).get_column_id_at_index(col); // TODO(jb55): clean up this router_mut mess by using Router in egui-nav directly let nav_response = Nav::new(&app.columns().column(col).router().routes().clone()) @@ -252,7 +363,7 @@ pub fn render_nav(col: usize, app: &mut Damus, ui: &mut egui::Ui) -> RenderNavRe NavUiType::Title => NavTitle::new( &app.ndb, &mut app.img_cache, - &app.columns, + get_active_columns_mut(&app.accounts, &mut app.decks_cache), app.accounts.get_selected_account().map(|a| &a.pubkey), nav.routes(), ) diff --git a/src/route.rs b/src/route.rs index ed13f68f..4b7d1124 100644 --- a/src/route.rs +++ b/src/route.rs @@ -1,5 +1,4 @@ use enostr::{NoteId, Pubkey}; -use serde::{Deserialize, Serialize}; use std::{ borrow::Cow, fmt::{self}, @@ -13,7 +12,7 @@ use crate::{ }; /// App routing. These describe different places you can go inside Notedeck. -#[derive(Clone, Copy, Eq, PartialEq, Debug, Serialize, Deserialize)] +#[derive(Clone, Copy, Eq, PartialEq, Debug)] pub enum Route { Timeline(TimelineRoute), Accounts(AccountsRoute), @@ -21,6 +20,8 @@ pub enum Route { ComposeNote, AddColumn(AddColumnRoute), Support, + NewDeck, + EditDeck(usize), } impl Route { @@ -95,6 +96,8 @@ impl Route { AddColumnRoute::Hashtag => Cow::Borrowed("Add Hashtag Column"), }, Route::Support => Cow::Borrowed("Damus Support"), + Route::NewDeck => Cow::Borrowed("Add Deck"), + Route::EditDeck(_) => Cow::Borrowed("Edit Deck"), } } } @@ -204,6 +207,8 @@ impl fmt::Display for Route { Route::AddColumn(_) => write!(f, "Add Column"), Route::Support => write!(f, "Support"), + Route::NewDeck => write!(f, "Add Deck"), + Route::EditDeck(_) => write!(f, "Edit Deck"), } } } diff --git a/src/storage/columns.rs b/src/storage/columns.rs deleted file mode 100644 index 1b325c8c..00000000 --- a/src/storage/columns.rs +++ /dev/null @@ -1,48 +0,0 @@ -use tracing::{error, info}; - -use crate::column::SerializableColumns; - -use super::{write_file, DataPath, DataPathType, Directory}; - -static COLUMNS_FILE: &str = "columns.json"; - -pub fn save_columns(path: &DataPath, columns: SerializableColumns) { - let serialized_columns = match serde_json::to_string(&columns) { - Ok(s) => s, - Err(e) => { - error!("Could not serialize columns: {}", e); - return; - } - }; - - let data_path = path.path(DataPathType::Setting); - - if let Err(e) = write_file(&data_path, COLUMNS_FILE.to_string(), &serialized_columns) { - error!("Could not write columns to file {}: {}", COLUMNS_FILE, e); - } else { - info!("Successfully wrote columns to {}", COLUMNS_FILE); - } -} - -pub fn load_columns(path: &DataPath) -> Option { - let data_path = path.path(DataPathType::Setting); - - let columns_string = match Directory::new(data_path).get_file(COLUMNS_FILE.to_owned()) { - Ok(s) => s, - Err(e) => { - error!("Could not read columns from file {}: {}", COLUMNS_FILE, e); - return None; - } - }; - - match serde_json::from_str::(&columns_string) { - Ok(s) => { - info!("Successfully loaded columns from {}", COLUMNS_FILE); - Some(s) - } - Err(e) => { - error!("Could not deserialize columns: {}", e); - None - } - } -} diff --git a/src/storage/decks.rs b/src/storage/decks.rs new file mode 100644 index 00000000..60344681 --- /dev/null +++ b/src/storage/decks.rs @@ -0,0 +1,799 @@ +use std::{collections::HashMap, fmt, str::FromStr}; + +use enostr::{NoteId, Pubkey}; +use nostrdb::Ndb; +use serde::{Deserialize, Serialize}; +use tracing::{error, info}; + +use crate::{ + accounts::AccountsRoute, + column::{Columns, IntermediaryRoute}, + decks::{Deck, Decks, DecksCache}, + route::Route, + timeline::{kind::ListKind, PubkeySource, TimelineKind, TimelineRoute}, + ui::add_column::AddColumnRoute, + Error, +}; + +use super::{write_file, DataPath, DataPathType, Directory}; + +pub static DECKS_CACHE_FILE: &str = "decks_cache.json"; + +pub fn load_decks_cache(path: &DataPath, ndb: &Ndb) -> Option { + let data_path = path.path(DataPathType::Setting); + + let decks_cache_str = match Directory::new(data_path).get_file(DECKS_CACHE_FILE.to_owned()) { + Ok(s) => s, + Err(e) => { + error!( + "Could not read decks cache from file {}: {}", + DECKS_CACHE_FILE, e + ); + return None; + } + }; + + let serializable_decks_cache = + serde_json::from_str::(&decks_cache_str).ok()?; + + serializable_decks_cache.decks_cache(ndb).ok() +} + +pub fn save_decks_cache(path: &DataPath, decks_cache: &DecksCache) { + let serialized_decks_cache = + match serde_json::to_string(&SerializableDecksCache::to_serializable(decks_cache)) { + Ok(s) => s, + Err(e) => { + error!("Could not serialize decks cache: {}", e); + return; + } + }; + + let data_path = path.path(DataPathType::Setting); + + if let Err(e) = write_file( + &data_path, + DECKS_CACHE_FILE.to_string(), + &serialized_decks_cache, + ) { + error!( + "Could not write decks cache to file {}: {}", + DECKS_CACHE_FILE, e + ); + } else { + info!("Successfully wrote decks cache to {}", DECKS_CACHE_FILE); + } +} + +#[derive(Serialize, Deserialize)] +struct SerializableDecksCache { + #[serde(serialize_with = "serialize_map", deserialize_with = "deserialize_map")] + decks_cache: HashMap, +} + +impl SerializableDecksCache { + fn to_serializable(decks_cache: &DecksCache) -> Self { + SerializableDecksCache { + decks_cache: decks_cache + .get_mapping() + .iter() + .map(|(k, v)| (*k, SerializableDecks::from_decks(v))) + .collect(), + } + } + + pub fn decks_cache(self, ndb: &Ndb) -> Result { + let account_to_decks = self + .decks_cache + .into_iter() + .map(|(pubkey, serializable_decks)| { + let deck_key = pubkey.bytes(); + serializable_decks + .decks(ndb, deck_key) + .map(|decks| (pubkey, decks)) + }) + .collect::, Error>>()?; + + Ok(DecksCache::new(account_to_decks)) + } +} + +fn serialize_map( + map: &HashMap, + serializer: S, +) -> Result +where + S: serde::Serializer, +{ + let stringified_map: HashMap = + map.iter().map(|(k, v)| (k.hex(), v)).collect(); + stringified_map.serialize(serializer) +} + +fn deserialize_map<'de, D>(deserializer: D) -> Result, D::Error> +where + D: serde::Deserializer<'de>, +{ + let stringified_map: HashMap = HashMap::deserialize(deserializer)?; + + stringified_map + .into_iter() + .map(|(k, v)| { + let key = Pubkey::from_hex(&k).map_err(serde::de::Error::custom)?; + Ok((key, v)) + }) + .collect() +} + +#[derive(Serialize, Deserialize)] +struct SerializableDecks { + active_deck: usize, + decks: Vec, +} + +impl SerializableDecks { + pub fn from_decks(decks: &Decks) -> Self { + Self { + active_deck: decks.active_index(), + decks: decks + .decks() + .iter() + .map(SerializableDeck::from_deck) + .collect(), + } + } + + fn decks(self, ndb: &Ndb, deck_key: &[u8; 32]) -> Result { + Ok(Decks::from_decks( + self.active_deck, + self.decks + .into_iter() + .map(|d| d.deck(ndb, deck_key)) + .collect::>()?, + )) + } +} + +#[derive(Serialize, Deserialize)] +struct SerializableDeck { + metadata: Vec, + columns: Vec>, +} + +#[derive(PartialEq, Clone)] +enum MetadataKeyword { + Icon, + Name, +} + +impl MetadataKeyword { + const MAPPING: &'static [(&'static str, MetadataKeyword)] = &[ + ("icon", MetadataKeyword::Icon), + ("name", MetadataKeyword::Name), + ]; +} +impl fmt::Display for MetadataKeyword { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let name = MetadataKeyword::MAPPING + .iter() + .find(|(_, keyword)| keyword == self) + .map(|(name, _)| *name) + .expect("MAPPING is incorrect"); + + write!(f, "{}", name) + } +} + +impl FromStr for MetadataKeyword { + type Err = Error; + + fn from_str(serialized: &str) -> Result { + MetadataKeyword::MAPPING + .iter() + .find(|(name, _)| *name == serialized) + .map(|(_, keyword)| keyword.clone()) + .ok_or(Error::Generic( + "Could not convert string to Keyword enum".to_owned(), + )) + } +} + +struct MetadataPayload { + keyword: MetadataKeyword, + value: String, +} + +impl MetadataPayload { + fn new(keyword: MetadataKeyword, value: String) -> Self { + Self { keyword, value } + } +} + +fn serialize_metadata(payloads: Vec) -> Vec { + payloads + .into_iter() + .map(|payload| format!("{}:{}", payload.keyword, payload.value)) + .collect() +} + +fn deserialize_metadata(serialized_metadatas: Vec) -> Option> { + let mut payloads = Vec::new(); + for serialized_metadata in serialized_metadatas { + let cur_split: Vec<&str> = serialized_metadata.split(':').collect(); + if cur_split.len() != 2 { + continue; + } + + if let Ok(keyword) = MetadataKeyword::from_str(cur_split.first().unwrap()) { + payloads.push(MetadataPayload { + keyword, + value: cur_split.get(1).unwrap().to_string(), + }); + } + } + + if payloads.is_empty() { + None + } else { + Some(payloads) + } +} + +impl SerializableDeck { + pub fn from_deck(deck: &Deck) -> Self { + let columns = serialize_columns(deck.columns()); + + let metadata = serialize_metadata(vec![ + MetadataPayload::new(MetadataKeyword::Icon, deck.icon.to_string()), + MetadataPayload::new(MetadataKeyword::Name, deck.name.clone()), + ]); + + SerializableDeck { metadata, columns } + } + + pub fn deck(self, ndb: &Ndb, deck_user: &[u8; 32]) -> Result { + let columns = deserialize_columns(ndb, deck_user, self.columns); + let deserialized_metadata = deserialize_metadata(self.metadata) + .ok_or(Error::Generic("Could not deserialize metadata".to_owned()))?; + + let icon = deserialized_metadata + .iter() + .find(|p| p.keyword == MetadataKeyword::Icon) + .map_or_else(|| "🇩", |f| &f.value); + let name = deserialized_metadata + .iter() + .find(|p| p.keyword == MetadataKeyword::Name) + .map_or_else(|| "Deck", |f| &f.value) + .to_string(); + + Ok(Deck::new_with_columns( + icon.parse::() + .map_err(|_| Error::Generic("could not convert String -> char".to_owned()))?, + name, + columns, + )) + } +} + +fn serialize_columns(columns: &Columns) -> Vec> { + let mut cols_serialized: Vec> = Vec::new(); + + for column in columns.columns() { + let mut column_routes = Vec::new(); + for route in column.router().routes() { + if let Some(route_str) = serialize_route(route, columns) { + column_routes.push(route_str); + } + } + cols_serialized.push(column_routes); + } + + cols_serialized +} + +fn deserialize_columns(ndb: &Ndb, deck_user: &[u8; 32], serialized: Vec>) -> Columns { + let mut cols = Columns::new(); + for serialized_routes in serialized { + let mut cur_routes = Vec::new(); + for serialized_route in serialized_routes { + let selections = Selection::from_serialized(&serialized_route); + if let Some(route_intermediary) = selections_to_route(selections.clone()) { + if let Some(ir) = route_intermediary.intermediary_route(ndb, Some(deck_user)) { + match &ir { + IntermediaryRoute::Route(Route::Timeline(TimelineRoute::Thread(_))) + | IntermediaryRoute::Route(Route::Timeline(TimelineRoute::Profile(_))) => { + // Do nothing. Threads & Profiles not yet supported for deserialization + } + IntermediaryRoute::Timeline(tl) + if matches!(tl.kind, TimelineKind::Profile(_)) => + { + // Do nothing. Profiles aren't yet supported for deserialization + } + _ => cur_routes.push(ir), + } + } + } else { + error!( + "could not turn selections to RouteIntermediary: {:?}", + selections + ); + } + } + + if !cur_routes.is_empty() { + cols.insert_intermediary_routes(cur_routes); + } + } + + cols +} + +#[derive(Clone, Debug)] +enum Selection { + Keyword(Keyword), + Payload(String), +} + +#[derive(Clone, PartialEq, Debug)] +enum Keyword { + Notifs, + Universe, + Contact, + Explicit, + DeckAuthor, + Profile, + Hashtag, + Generic, + Thread, + Reply, + Quote, + Account, + Show, + New, + Relay, + Compose, + Column, + NotificationSelection, + ExternalNotifSelection, + HashtagSelection, + Support, + Deck, + Edit, +} + +impl Keyword { + const MAPPING: &'static [(&'static str, Keyword, bool)] = &[ + ("notifs", Keyword::Notifs, false), + ("universe", Keyword::Universe, false), + ("contact", Keyword::Contact, false), + ("explicit", Keyword::Explicit, true), + ("deck_author", Keyword::DeckAuthor, false), + ("profile", Keyword::Profile, true), + ("hashtag", Keyword::Hashtag, true), + ("generic", Keyword::Generic, false), + ("thread", Keyword::Thread, true), + ("reply", Keyword::Reply, true), + ("quote", Keyword::Quote, true), + ("account", Keyword::Account, false), + ("show", Keyword::Show, false), + ("new", Keyword::New, false), + ("relay", Keyword::Relay, false), + ("compose", Keyword::Compose, false), + ("column", Keyword::Column, false), + ( + "notification_selection", + Keyword::NotificationSelection, + false, + ), + ( + "external_notif_selection", + Keyword::ExternalNotifSelection, + false, + ), + ("hashtag_selection", Keyword::HashtagSelection, false), + ("support", Keyword::Support, false), + ("deck", Keyword::Deck, false), + ("edit", Keyword::Edit, true), + ]; + + fn has_payload(&self) -> bool { + Keyword::MAPPING + .iter() + .find(|(_, keyword, _)| keyword == self) + .map(|(_, _, has_payload)| *has_payload) + .unwrap_or(false) + } +} + +impl fmt::Display for Keyword { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let name = Keyword::MAPPING + .iter() + .find(|(_, keyword, _)| keyword == self) + .map(|(name, _, _)| *name) + .expect("MAPPING is incorrect"); + + write!(f, "{}", name) + } +} + +impl FromStr for Keyword { + type Err = Error; + + fn from_str(serialized: &str) -> Result { + Keyword::MAPPING + .iter() + .find(|(name, _, _)| *name == serialized) + .map(|(_, keyword, _)| keyword.clone()) + .ok_or(Error::Generic( + "Could not convert string to Keyword enum".to_owned(), + )) + } +} + +enum CleanIntermediaryRoute { + ToTimeline(TimelineKind), + ToRoute(Route), +} + +impl CleanIntermediaryRoute { + fn intermediary_route(self, ndb: &Ndb, user: Option<&[u8; 32]>) -> Option { + match self { + CleanIntermediaryRoute::ToTimeline(timeline_kind) => Some(IntermediaryRoute::Timeline( + timeline_kind.into_timeline(ndb, user)?, + )), + CleanIntermediaryRoute::ToRoute(route) => Some(IntermediaryRoute::Route(route)), + } + } +} + +// TODO: The public-accessible version will be a subset of this +fn serialize_route(route: &Route, columns: &Columns) -> Option { + let mut selections: Vec = Vec::new(); + match route { + Route::Timeline(timeline_route) => match timeline_route { + TimelineRoute::Timeline(timeline_id) => { + if let Some(timeline) = columns.find_timeline(*timeline_id) { + match &timeline.kind { + TimelineKind::List(list_kind) => match list_kind { + ListKind::Contact(pubkey_source) => { + selections.push(Selection::Keyword(Keyword::Contact)); + selections.extend(generate_pubkey_selections(pubkey_source)); + } + }, + TimelineKind::Notifications(pubkey_source) => { + selections.push(Selection::Keyword(Keyword::Notifs)); + selections.extend(generate_pubkey_selections(pubkey_source)); + } + TimelineKind::Profile(pubkey_source) => { + selections.push(Selection::Keyword(Keyword::Profile)); + selections.extend(generate_pubkey_selections(pubkey_source)); + } + TimelineKind::Universe => { + selections.push(Selection::Keyword(Keyword::Universe)) + } + TimelineKind::Generic => { + selections.push(Selection::Keyword(Keyword::Generic)) + } + TimelineKind::Hashtag(hashtag) => { + selections.push(Selection::Keyword(Keyword::Hashtag)); + selections.push(Selection::Payload(hashtag.to_string())); + } + } + } + } + TimelineRoute::Thread(note_id) => { + selections.push(Selection::Keyword(Keyword::Thread)); + selections.push(Selection::Payload(note_id.hex())); + } + TimelineRoute::Profile(pubkey) => { + selections.push(Selection::Keyword(Keyword::Profile)); + selections.push(Selection::Keyword(Keyword::Explicit)); + selections.push(Selection::Payload(pubkey.hex())); + } + TimelineRoute::Reply(note_id) => { + selections.push(Selection::Keyword(Keyword::Reply)); + selections.push(Selection::Payload(note_id.hex())); + } + TimelineRoute::Quote(note_id) => { + selections.push(Selection::Keyword(Keyword::Quote)); + selections.push(Selection::Payload(note_id.hex())); + } + }, + Route::Accounts(accounts_route) => { + selections.push(Selection::Keyword(Keyword::Account)); + match accounts_route { + AccountsRoute::Accounts => selections.push(Selection::Keyword(Keyword::Show)), + AccountsRoute::AddAccount => selections.push(Selection::Keyword(Keyword::New)), + } + } + Route::Relays => selections.push(Selection::Keyword(Keyword::Relay)), + Route::ComposeNote => selections.push(Selection::Keyword(Keyword::Compose)), + Route::AddColumn(add_column_route) => { + selections.push(Selection::Keyword(Keyword::Column)); + match add_column_route { + AddColumnRoute::Base => (), + AddColumnRoute::UndecidedNotification => { + selections.push(Selection::Keyword(Keyword::NotificationSelection)) + } + AddColumnRoute::ExternalNotification => { + selections.push(Selection::Keyword(Keyword::ExternalNotifSelection)) + } + AddColumnRoute::Hashtag => { + selections.push(Selection::Keyword(Keyword::HashtagSelection)) + } + } + } + Route::Support => selections.push(Selection::Keyword(Keyword::Support)), + Route::NewDeck => { + selections.push(Selection::Keyword(Keyword::Deck)); + selections.push(Selection::Keyword(Keyword::New)); + } + Route::EditDeck(index) => { + selections.push(Selection::Keyword(Keyword::Deck)); + selections.push(Selection::Keyword(Keyword::Edit)); + selections.push(Selection::Payload(index.to_string())); + } + } + + if selections.is_empty() { + None + } else { + Some( + selections + .iter() + .map(|k| k.to_string()) + .collect::>() + .join(":"), + ) + } +} + +fn generate_pubkey_selections(source: &PubkeySource) -> Vec { + let mut selections = Vec::new(); + match source { + PubkeySource::Explicit(pubkey) => { + selections.push(Selection::Keyword(Keyword::Explicit)); + selections.push(Selection::Payload(pubkey.hex())); + } + PubkeySource::DeckAuthor => { + selections.push(Selection::Keyword(Keyword::DeckAuthor)); + } + } + selections +} + +impl Selection { + fn from_serialized(serialized: &str) -> Vec { + let mut selections = Vec::new(); + let seperator = ":"; + + let mut serialized_copy = serialized.to_string(); + let mut buffer = serialized_copy.as_mut(); + + let mut next_is_payload = false; + while let Some(index) = buffer.find(seperator) { + if let Ok(keyword) = Keyword::from_str(&buffer[..index]) { + selections.push(Selection::Keyword(keyword.clone())); + if keyword.has_payload() { + next_is_payload = true; + } + } + + buffer = &mut buffer[index + seperator.len()..]; + } + + if next_is_payload { + selections.push(Selection::Payload(buffer.to_string())); + } else if let Ok(keyword) = Keyword::from_str(buffer) { + selections.push(Selection::Keyword(keyword.clone())); + } + + selections + } +} + +fn selections_to_route(selections: Vec) -> Option { + match selections.first()? { + Selection::Keyword(Keyword::Contact) => match selections.get(1)? { + Selection::Keyword(Keyword::Explicit) => { + if let Selection::Payload(hex) = selections.get(2)? { + Some(CleanIntermediaryRoute::ToTimeline( + TimelineKind::contact_list(PubkeySource::Explicit( + Pubkey::from_hex(hex.as_str()).ok()?, + )), + )) + } else { + None + } + } + Selection::Keyword(Keyword::DeckAuthor) => Some(CleanIntermediaryRoute::ToTimeline( + TimelineKind::contact_list(PubkeySource::DeckAuthor), + )), + _ => None, + }, + Selection::Keyword(Keyword::Notifs) => match selections.get(1)? { + Selection::Keyword(Keyword::Explicit) => { + if let Selection::Payload(hex) = selections.get(2)? { + Some(CleanIntermediaryRoute::ToTimeline( + TimelineKind::notifications(PubkeySource::Explicit( + Pubkey::from_hex(hex.as_str()).ok()?, + )), + )) + } else { + None + } + } + Selection::Keyword(Keyword::DeckAuthor) => Some(CleanIntermediaryRoute::ToTimeline( + TimelineKind::notifications(PubkeySource::DeckAuthor), + )), + _ => None, + }, + Selection::Keyword(Keyword::Profile) => match selections.get(1)? { + Selection::Keyword(Keyword::Explicit) => { + if let Selection::Payload(hex) = selections.get(2)? { + Some(CleanIntermediaryRoute::ToTimeline(TimelineKind::profile( + PubkeySource::Explicit(Pubkey::from_hex(hex.as_str()).ok()?), + ))) + } else { + None + } + } + Selection::Keyword(Keyword::DeckAuthor) => Some(CleanIntermediaryRoute::ToTimeline( + TimelineKind::profile(PubkeySource::DeckAuthor), + )), + _ => None, + }, + Selection::Keyword(Keyword::Universe) => { + Some(CleanIntermediaryRoute::ToTimeline(TimelineKind::Universe)) + } + Selection::Keyword(Keyword::Hashtag) => { + if let Selection::Payload(hashtag) = selections.get(1)? { + Some(CleanIntermediaryRoute::ToTimeline(TimelineKind::Hashtag( + hashtag.to_string(), + ))) + } else { + None + } + } + Selection::Keyword(Keyword::Generic) => { + Some(CleanIntermediaryRoute::ToTimeline(TimelineKind::Generic)) + } + Selection::Keyword(Keyword::Thread) => { + if let Selection::Payload(hex) = selections.get(1)? { + Some(CleanIntermediaryRoute::ToRoute(Route::thread( + NoteId::from_hex(hex.as_str()).ok()?, + ))) + } else { + None + } + } + Selection::Keyword(Keyword::Reply) => { + if let Selection::Payload(hex) = selections.get(1)? { + Some(CleanIntermediaryRoute::ToRoute(Route::reply( + NoteId::from_hex(hex.as_str()).ok()?, + ))) + } else { + None + } + } + Selection::Keyword(Keyword::Quote) => { + if let Selection::Payload(hex) = selections.get(1)? { + Some(CleanIntermediaryRoute::ToRoute(Route::quote( + NoteId::from_hex(hex.as_str()).ok()?, + ))) + } else { + None + } + } + Selection::Keyword(Keyword::Account) => match selections.get(1)? { + Selection::Keyword(Keyword::Show) => Some(CleanIntermediaryRoute::ToRoute( + Route::Accounts(AccountsRoute::Accounts), + )), + Selection::Keyword(Keyword::New) => Some(CleanIntermediaryRoute::ToRoute( + Route::Accounts(AccountsRoute::AddAccount), + )), + _ => None, + }, + Selection::Keyword(Keyword::Relay) => Some(CleanIntermediaryRoute::ToRoute(Route::Relays)), + Selection::Keyword(Keyword::Compose) => { + Some(CleanIntermediaryRoute::ToRoute(Route::ComposeNote)) + } + Selection::Keyword(Keyword::Column) => match selections.get(1)? { + Selection::Keyword(Keyword::NotificationSelection) => { + Some(CleanIntermediaryRoute::ToRoute(Route::AddColumn( + AddColumnRoute::UndecidedNotification, + ))) + } + Selection::Keyword(Keyword::ExternalNotifSelection) => { + Some(CleanIntermediaryRoute::ToRoute(Route::AddColumn( + AddColumnRoute::ExternalNotification, + ))) + } + Selection::Keyword(Keyword::HashtagSelection) => Some(CleanIntermediaryRoute::ToRoute( + Route::AddColumn(AddColumnRoute::Hashtag), + )), + _ => None, + }, + Selection::Keyword(Keyword::Support) => { + Some(CleanIntermediaryRoute::ToRoute(Route::Support)) + } + Selection::Keyword(Keyword::Deck) => match selections.get(1)? { + Selection::Keyword(Keyword::New) => { + Some(CleanIntermediaryRoute::ToRoute(Route::NewDeck)) + } + Selection::Keyword(Keyword::Edit) => { + if let Selection::Payload(index_str) = selections.get(2)? { + let parsed_index = index_str.parse::().ok()?; + Some(CleanIntermediaryRoute::ToRoute(Route::EditDeck( + parsed_index, + ))) + } else { + None + } + } + _ => None, + }, + Selection::Payload(_) + | Selection::Keyword(Keyword::Explicit) + | Selection::Keyword(Keyword::New) + | Selection::Keyword(Keyword::DeckAuthor) + | Selection::Keyword(Keyword::Show) + | Selection::Keyword(Keyword::NotificationSelection) + | Selection::Keyword(Keyword::ExternalNotifSelection) + | Selection::Keyword(Keyword::HashtagSelection) + | Selection::Keyword(Keyword::Edit) => None, + } +} + +impl fmt::Display for Selection { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Selection::Keyword(keyword) => write!(f, "{}", keyword), + Selection::Payload(payload) => write!(f, "{}", payload), + } + } +} + +#[cfg(test)] +mod tests { + use enostr::Pubkey; + + use crate::{route::Route, test_data::test_app, timeline::TimelineRoute}; + + use super::deserialize_columns; + + #[test] + fn test_deserialize_columns() { + let serialized = vec![ + vec!["universe".to_owned()], + vec![ + "notifs:explicit:aa733081e4f0f79dd43023d8983265593f2b41a988671cfcef3f489b91ad93fe" + .to_owned(), + ], + ]; + + let user = + Pubkey::from_hex("aa733081e4f0f79dd43023d8983265593f2b41a988671cfcef3f489b91ad93fe") + .unwrap(); + + let app = test_app(); + let cols = deserialize_columns(&app.ndb, user.bytes(), serialized); + + assert_eq!(cols.columns().len(), 2); + let router = cols.column(0).router(); + assert_eq!(router.routes().len(), 1); + + if let Route::Timeline(TimelineRoute::Timeline(_)) = router.routes().first().unwrap() { + } else { + panic!("The first router route is not a TimelineRoute::Timeline variant"); + } + + let router = cols.column(1).router(); + assert_eq!(router.routes().len(), 1); + if let Route::Timeline(TimelineRoute::Timeline(_)) = router.routes().first().unwrap() { + } else { + panic!("The second router route is not a TimelineRoute::Timeline variant"); + } + } +} diff --git a/src/storage/migration.rs b/src/storage/migration.rs new file mode 100644 index 00000000..733d6ad1 --- /dev/null +++ b/src/storage/migration.rs @@ -0,0 +1,695 @@ +use enostr::{NoteId, Pubkey}; +use nostrdb::Ndb; +use serde::{Deserialize, Deserializer}; +use tracing::error; + +use crate::{ + accounts::AccountsRoute, + column::{Columns, IntermediaryRoute}, + route::Route, + timeline::{kind::ListKind, PubkeySource, Timeline, TimelineId, TimelineKind, TimelineRoute}, + ui::add_column::AddColumnRoute, + Error, +}; + +use super::{DataPath, DataPathType, Directory}; + +pub static COLUMNS_FILE: &str = "columns.json"; + +fn columns_json(path: &DataPath) -> Option { + let data_path = path.path(DataPathType::Setting); + Directory::new(data_path) + .get_file(COLUMNS_FILE.to_string()) + .ok() +} + +#[derive(Deserialize, Debug, PartialEq)] +enum MigrationTimelineRoute { + Timeline(u32), + Thread(String), + Profile(String), + Reply(String), + Quote(String), +} + +impl MigrationTimelineRoute { + fn timeline_route(self) -> Option { + match self { + MigrationTimelineRoute::Timeline(id) => { + Some(TimelineRoute::Timeline(TimelineId::new(id))) + } + MigrationTimelineRoute::Thread(note_id_hex) => { + Some(TimelineRoute::Thread(NoteId::from_hex(¬e_id_hex).ok()?)) + } + MigrationTimelineRoute::Profile(pubkey_hex) => { + Some(TimelineRoute::Profile(Pubkey::from_hex(&pubkey_hex).ok()?)) + } + MigrationTimelineRoute::Reply(note_id_hex) => { + Some(TimelineRoute::Reply(NoteId::from_hex(¬e_id_hex).ok()?)) + } + MigrationTimelineRoute::Quote(note_id_hex) => { + Some(TimelineRoute::Quote(NoteId::from_hex(¬e_id_hex).ok()?)) + } + } + } +} + +#[derive(Deserialize, Debug, PartialEq)] +enum MigrationRoute { + Timeline(MigrationTimelineRoute), + Accounts(MigrationAccountsRoute), + Relays, + ComposeNote, + AddColumn(MigrationAddColumnRoute), + Support, +} + +impl MigrationRoute { + fn route(self) -> Option { + match self { + MigrationRoute::Timeline(migration_timeline_route) => { + Some(Route::Timeline(migration_timeline_route.timeline_route()?)) + } + MigrationRoute::Accounts(migration_accounts_route) => { + Some(Route::Accounts(migration_accounts_route.accounts_route())) + } + MigrationRoute::Relays => Some(Route::Relays), + MigrationRoute::ComposeNote => Some(Route::ComposeNote), + MigrationRoute::AddColumn(migration_add_column_route) => Some(Route::AddColumn( + migration_add_column_route.add_column_route(), + )), + MigrationRoute::Support => Some(Route::Support), + } + } +} + +#[derive(Deserialize, Debug, PartialEq)] +enum MigrationAccountsRoute { + Accounts, + AddAccount, +} + +impl MigrationAccountsRoute { + fn accounts_route(self) -> AccountsRoute { + match self { + MigrationAccountsRoute::Accounts => AccountsRoute::Accounts, + MigrationAccountsRoute::AddAccount => AccountsRoute::AddAccount, + } + } +} + +#[derive(Deserialize, Debug, PartialEq)] +enum MigrationAddColumnRoute { + Base, + UndecidedNotification, + ExternalNotification, + Hashtag, +} + +impl MigrationAddColumnRoute { + fn add_column_route(self) -> AddColumnRoute { + match self { + MigrationAddColumnRoute::Base => AddColumnRoute::Base, + MigrationAddColumnRoute::UndecidedNotification => AddColumnRoute::UndecidedNotification, + MigrationAddColumnRoute::ExternalNotification => AddColumnRoute::ExternalNotification, + MigrationAddColumnRoute::Hashtag => AddColumnRoute::Hashtag, + } + } +} + +#[derive(Debug, PartialEq)] +struct MigrationColumn { + routes: Vec, +} + +impl<'de> Deserialize<'de> for MigrationColumn { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let routes = Vec::::deserialize(deserializer)?; + + Ok(MigrationColumn { routes }) + } +} + +#[derive(Deserialize, Debug)] +struct MigrationColumns { + columns: Vec, + timelines: Vec, +} + +#[derive(Deserialize, Debug, Clone, PartialEq)] +struct MigrationTimeline { + id: u32, + kind: MigrationTimelineKind, +} + +impl MigrationTimeline { + fn into_timeline(self, ndb: &Ndb, deck_user_pubkey: Option<&[u8; 32]>) -> Option { + self.kind + .into_timeline_kind()? + .into_timeline(ndb, deck_user_pubkey) + } +} + +#[derive(Deserialize, Clone, Debug, PartialEq)] +enum MigrationListKind { + Contact(MigrationPubkeySource), +} + +impl MigrationListKind { + fn list_kind(self) -> Option { + match self { + MigrationListKind::Contact(migration_pubkey_source) => { + Some(ListKind::Contact(migration_pubkey_source.pubkey_source()?)) + } + } + } +} + +#[derive(Deserialize, Clone, Debug, PartialEq)] +enum MigrationPubkeySource { + Explicit(String), + DeckAuthor, +} + +impl MigrationPubkeySource { + fn pubkey_source(self) -> Option { + match self { + MigrationPubkeySource::Explicit(hex) => { + Some(PubkeySource::Explicit(Pubkey::from_hex(hex.as_str()).ok()?)) + } + MigrationPubkeySource::DeckAuthor => Some(PubkeySource::DeckAuthor), + } + } +} + +#[derive(Deserialize, Clone, Debug, PartialEq)] +enum MigrationTimelineKind { + List(MigrationListKind), + Notifications(MigrationPubkeySource), + Profile(MigrationPubkeySource), + Universe, + Generic, + Hashtag(String), +} + +impl MigrationTimelineKind { + fn into_timeline_kind(self) -> Option { + match self { + MigrationTimelineKind::List(migration_list_kind) => { + Some(TimelineKind::List(migration_list_kind.list_kind()?)) + } + MigrationTimelineKind::Notifications(migration_pubkey_source) => Some( + TimelineKind::Notifications(migration_pubkey_source.pubkey_source()?), + ), + MigrationTimelineKind::Profile(migration_pubkey_source) => Some(TimelineKind::Profile( + migration_pubkey_source.pubkey_source()?, + )), + MigrationTimelineKind::Universe => Some(TimelineKind::Universe), + MigrationTimelineKind::Generic => Some(TimelineKind::Generic), + MigrationTimelineKind::Hashtag(hashtag) => Some(TimelineKind::Hashtag(hashtag)), + } + } +} + +impl MigrationColumns { + fn into_columns(self, ndb: &Ndb, deck_pubkey: Option<&[u8; 32]>) -> Columns { + let mut columns = Columns::default(); + + for column in self.columns { + let mut cur_routes = Vec::new(); + for route in column.routes { + match route { + MigrationRoute::Timeline(MigrationTimelineRoute::Timeline(timeline_id)) => { + if let Some(migration_tl) = + self.timelines.iter().find(|tl| tl.id == timeline_id) + { + let tl = migration_tl.clone().into_timeline(ndb, deck_pubkey); + if let Some(tl) = tl { + cur_routes.push(IntermediaryRoute::Timeline(tl)); + } else { + error!("Problem deserializing timeline {:?}", migration_tl); + } + } + } + MigrationRoute::Timeline(MigrationTimelineRoute::Thread(_thread)) => {} + _ => { + if let Some(route) = route.route() { + cur_routes.push(IntermediaryRoute::Route(route)); + } + } + } + } + if !cur_routes.is_empty() { + columns.insert_intermediary_routes(cur_routes); + } + } + columns + } +} + +fn string_to_columns( + serialized_columns: String, + ndb: &Ndb, + user: Option<&[u8; 32]>, +) -> Option { + Some( + deserialize_columns_string(serialized_columns) + .ok()? + .into_columns(ndb, user), + ) +} + +pub fn deserialize_columns(path: &DataPath, ndb: &Ndb, user: Option<&[u8; 32]>) -> Option { + string_to_columns(columns_json(path)?, ndb, user) +} + +fn deserialize_columns_string(serialized_columns: String) -> Result { + serde_json::from_str::(&serialized_columns) + .map_err(|e| Error::Generic(e.to_string())) +} + +#[cfg(test)] +mod tests { + use crate::storage::migration::{ + MigrationColumn, MigrationListKind, MigrationPubkeySource, MigrationRoute, + MigrationTimeline, MigrationTimelineKind, MigrationTimelineRoute, + }; + + impl MigrationColumn { + fn from_route(route: MigrationRoute) -> Self { + Self { + routes: vec![route], + } + } + + fn from_routes(routes: Vec) -> Self { + Self { routes } + } + } + + impl MigrationTimeline { + fn new(id: u32, kind: MigrationTimelineKind) -> Self { + Self { id, kind } + } + } + + use super::*; + + #[test] + fn multi_column() { + let route = r#"{"columns":[[{"Timeline":{"Timeline":2}}],[{"Timeline":{"Timeline":0}}],[{"Timeline":{"Timeline":1}}]],"timelines":[{"id":0,"kind":{"List":{"Contact":{"Explicit":"aa733081e4f0f79dd43023d8983265593f2b41a988671cfcef3f489b91ad93fe"}}}},{"id":1,"kind":{"Hashtag":"introductions"}},{"id":2,"kind":"Universe"}]}"#; // Multi-column + + let deserialized_columns = deserialize_columns_string(route.to_string()); + assert!(deserialized_columns.is_ok()); + + let migration_cols = deserialized_columns.unwrap(); + + assert_eq!(migration_cols.columns.len(), 3); + assert_eq!( + *migration_cols.columns.first().unwrap(), + MigrationColumn::from_route(MigrationRoute::Timeline( + MigrationTimelineRoute::Timeline(2) + )) + ); + + assert_eq!( + *migration_cols.columns.get(1).unwrap(), + MigrationColumn::from_route(MigrationRoute::Timeline( + MigrationTimelineRoute::Timeline(0) + )) + ); + + assert_eq!( + *migration_cols.columns.get(2).unwrap(), + MigrationColumn::from_route(MigrationRoute::Timeline( + MigrationTimelineRoute::Timeline(1) + )) + ); + + assert_eq!(migration_cols.timelines.len(), 3); + assert_eq!( + *migration_cols.timelines.first().unwrap(), + MigrationTimeline::new( + 0, + MigrationTimelineKind::List(MigrationListKind::Contact( + MigrationPubkeySource::Explicit( + "aa733081e4f0f79dd43023d8983265593f2b41a988671cfcef3f489b91ad93fe" + .to_owned() + ) + )) + ) + ); + assert_eq!( + *migration_cols.timelines.get(1).unwrap(), + MigrationTimeline::new( + 1, + MigrationTimelineKind::Hashtag("introductions".to_owned()) + ) + ); + + assert_eq!( + *migration_cols.timelines.get(2).unwrap(), + MigrationTimeline::new(2, MigrationTimelineKind::Universe) + ) + } + + #[test] + fn base() { + let route = r#"{"columns":[[{"AddColumn":"Base"}]],"timelines":[]}"#; + + let deserialized_columns = deserialize_columns_string(route.to_string()); + assert!(deserialized_columns.is_ok()); + + let migration_cols = deserialized_columns.unwrap(); + assert_eq!(migration_cols.columns.len(), 1); + assert_eq!( + *migration_cols.columns.first().unwrap(), + MigrationColumn::from_route(MigrationRoute::AddColumn(MigrationAddColumnRoute::Base)) + ); + + assert!(migration_cols.timelines.is_empty()); + } + + #[test] + fn universe() { + let route = r#"{"columns":[[{"Timeline":{"Timeline":0}}]],"timelines":[{"id":0,"kind":"Universe"}]}"#; + let deserialized_columns = deserialize_columns_string(route.to_string()); + assert!(deserialized_columns.is_ok()); + + let migration_cols = deserialized_columns.unwrap(); + assert_eq!(migration_cols.columns.len(), 1); + assert_eq!( + *migration_cols.columns.first().unwrap(), + MigrationColumn::from_route(MigrationRoute::Timeline( + MigrationTimelineRoute::Timeline(0) + )) + ); + + assert_eq!(migration_cols.timelines.len(), 1); + assert_eq!( + *migration_cols.timelines.first().unwrap(), + MigrationTimeline::new(0, MigrationTimelineKind::Universe) + ) + } + + #[test] + fn home() { + let route = r#"{"columns":[[{"Timeline":{"Timeline":2}}]],"timelines":[{"id":2,"kind":{"List":{"Contact":{"Explicit":"aa733081e4f0f79dd43023d8983265593f2b41a988671cfcef3f489b91ad93fe"}}}}]}"#; + + let deserialized_columns = deserialize_columns_string(route.to_string()); + assert!(deserialized_columns.is_ok()); + + let migration_cols = deserialized_columns.unwrap(); + assert_eq!(migration_cols.columns.len(), 1); + assert_eq!( + *migration_cols.columns.first().unwrap(), + MigrationColumn::from_route(MigrationRoute::Timeline( + MigrationTimelineRoute::Timeline(2) + )) + ); + + assert_eq!(migration_cols.timelines.len(), 1); + assert_eq!( + *migration_cols.timelines.first().unwrap(), + MigrationTimeline::new( + 2, + MigrationTimelineKind::List(MigrationListKind::Contact( + MigrationPubkeySource::Explicit( + "aa733081e4f0f79dd43023d8983265593f2b41a988671cfcef3f489b91ad93fe" + .to_owned() + ) + )) + ) + ) + } + + #[test] + fn thread() { + let route = r#"{"columns":[[{"Timeline":{"Timeline":7}},{"Timeline":{"Thread":"fb9b0c62bc91bbe28ca428fc85e310ae38795b94fb910e0f4e12962ced971f25"}}]],"timelines":[{"id":7,"kind":{"List":{"Contact":{"Explicit":"4a0510f26880d40e432f4865cb5714d9d3c200ca6ebb16b418ae6c555f574967"}}}}]}"#; + + let deserialized_columns = deserialize_columns_string(route.to_string()); + assert!(deserialized_columns.is_ok()); + + let migration_cols = deserialized_columns.unwrap(); + assert_eq!(migration_cols.columns.len(), 1); + assert_eq!( + *migration_cols.columns.first().unwrap(), + MigrationColumn::from_routes(vec![ + MigrationRoute::Timeline(MigrationTimelineRoute::Timeline(7),), + MigrationRoute::Timeline(MigrationTimelineRoute::Thread( + "fb9b0c62bc91bbe28ca428fc85e310ae38795b94fb910e0f4e12962ced971f25".to_owned() + )), + ]) + ); + + assert_eq!(migration_cols.timelines.len(), 1); + assert_eq!( + *migration_cols.timelines.first().unwrap(), + MigrationTimeline::new( + 7, + MigrationTimelineKind::List(MigrationListKind::Contact( + MigrationPubkeySource::Explicit( + "4a0510f26880d40e432f4865cb5714d9d3c200ca6ebb16b418ae6c555f574967" + .to_owned() + ) + )) + ) + ) + } + + #[test] + fn profile() { + let route = r#"{"columns":[[{"Timeline":{"Timeline":7}},{"Timeline":{"Profile":"32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245"}}]],"timelines":[{"id":7,"kind":{"List":{"Contact":{"Explicit":"4a0510f26880d40e432f4865cb5714d9d3c200ca6ebb16b418ae6c555f574967"}}}}]}"#; + + let deserialized_columns = deserialize_columns_string(route.to_string()); + assert!(deserialized_columns.is_ok()); + + let migration_cols = deserialized_columns.unwrap(); + assert_eq!(migration_cols.columns.len(), 1); + assert_eq!( + *migration_cols.columns.first().unwrap(), + MigrationColumn::from_routes(vec![ + MigrationRoute::Timeline(MigrationTimelineRoute::Timeline(7),), + MigrationRoute::Timeline(MigrationTimelineRoute::Profile( + "32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245".to_owned() + )), + ]) + ); + + assert_eq!(migration_cols.timelines.len(), 1); + assert_eq!( + *migration_cols.timelines.first().unwrap(), + MigrationTimeline::new( + 7, + MigrationTimelineKind::List(MigrationListKind::Contact( + MigrationPubkeySource::Explicit( + "4a0510f26880d40e432f4865cb5714d9d3c200ca6ebb16b418ae6c555f574967" + .to_owned() + ) + )) + ) + ) + } + + #[test] + fn your_notifs() { + let route = r#"{"columns":[[{"Timeline":{"Timeline":5}}]],"timelines":[{"id":5,"kind":{"Notifications":"DeckAuthor"}}]}"#; + + let deserialized_columns = deserialize_columns_string(route.to_string()); + assert!(deserialized_columns.is_ok()); + + let migration_cols = deserialized_columns.unwrap(); + assert_eq!(migration_cols.columns.len(), 1); + assert_eq!( + *migration_cols.columns.first().unwrap(), + MigrationColumn::from_route(MigrationRoute::Timeline( + MigrationTimelineRoute::Timeline(5) + )) + ); + + assert_eq!(migration_cols.timelines.len(), 1); + assert_eq!( + *migration_cols.timelines.first().unwrap(), + MigrationTimeline::new( + 5, + MigrationTimelineKind::Notifications(MigrationPubkeySource::DeckAuthor) + ) + ) + } + + #[test] + fn undecided_notifs() { + let route = r#"{"columns":[[{"AddColumn":"Base"},{"AddColumn":"UndecidedNotification"}]],"timelines":[]}"#; + + let deserialized_columns = deserialize_columns_string(route.to_string()); + assert!(deserialized_columns.is_ok()); + + let migration_cols = deserialized_columns.unwrap(); + assert_eq!(migration_cols.columns.len(), 1); + assert_eq!( + *migration_cols.columns.first().unwrap(), + MigrationColumn::from_routes(vec![ + MigrationRoute::AddColumn(MigrationAddColumnRoute::Base), + MigrationRoute::AddColumn(MigrationAddColumnRoute::UndecidedNotification), + ]) + ); + + assert!(migration_cols.timelines.is_empty()); + } + + #[test] + fn extern_notifs() { + let route = r#"{"columns":[[{"Timeline":{"Timeline":4}}]],"timelines":[{"id":4,"kind":{"Notifications":{"Explicit":"32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245"}}}]}"#; + + let deserialized_columns = deserialize_columns_string(route.to_string()); + assert!(deserialized_columns.is_ok()); + + let migration_cols = deserialized_columns.unwrap(); + assert_eq!(migration_cols.columns.len(), 1); + assert_eq!( + *migration_cols.columns.first().unwrap(), + MigrationColumn::from_route(MigrationRoute::Timeline( + MigrationTimelineRoute::Timeline(4) + )) + ); + + assert_eq!(migration_cols.timelines.len(), 1); + assert_eq!( + *migration_cols.timelines.first().unwrap(), + MigrationTimeline::new( + 4, + MigrationTimelineKind::Notifications(MigrationPubkeySource::Explicit( + "32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245".to_owned() + )) + ) + ) + } + + #[test] + fn hashtag() { + let route = r#"{"columns":[[{"Timeline":{"Timeline":6}}]],"timelines":[{"id":6,"kind":{"Hashtag":"notedeck"}}]}"#; + + let deserialized_columns = deserialize_columns_string(route.to_string()); + assert!(deserialized_columns.is_ok()); + + let migration_cols = deserialized_columns.unwrap(); + assert_eq!(migration_cols.columns.len(), 1); + assert_eq!( + *migration_cols.columns.first().unwrap(), + MigrationColumn::from_route(MigrationRoute::Timeline( + MigrationTimelineRoute::Timeline(6) + )) + ); + + assert_eq!(migration_cols.timelines.len(), 1); + assert_eq!( + *migration_cols.timelines.first().unwrap(), + MigrationTimeline::new(6, MigrationTimelineKind::Hashtag("notedeck".to_owned())) + ) + } + + #[test] + fn support() { + let route = r#"{"columns":[[{"AddColumn":"Base"},"Support"]],"timelines":[]}"#; + + let deserialized_columns = deserialize_columns_string(route.to_string()); + assert!(deserialized_columns.is_ok()); + + let migration_cols = deserialized_columns.unwrap(); + assert_eq!(migration_cols.columns.len(), 1); + assert_eq!( + *migration_cols.columns.first().unwrap(), + MigrationColumn::from_routes(vec![ + MigrationRoute::AddColumn(MigrationAddColumnRoute::Base), + MigrationRoute::Support + ]) + ); + + assert!(migration_cols.timelines.is_empty()); + } + + #[test] + fn post() { + let route = r#"{"columns":[[{"AddColumn":"Base"},"ComposeNote"]],"timelines":[]}"#; + + let deserialized_columns = deserialize_columns_string(route.to_string()); + assert!(deserialized_columns.is_ok()); + + let migration_cols = deserialized_columns.unwrap(); + assert_eq!(migration_cols.columns.len(), 1); + assert_eq!( + *migration_cols.columns.first().unwrap(), + MigrationColumn::from_routes(vec![ + MigrationRoute::AddColumn(MigrationAddColumnRoute::Base), + MigrationRoute::ComposeNote + ]) + ); + + assert!(migration_cols.timelines.is_empty()); + } + + #[test] + fn relay() { + let route = r#"{"columns":[[{"AddColumn":"Base"},"Relays"]],"timelines":[]}"#; + + let deserialized_columns = deserialize_columns_string(route.to_string()); + assert!(deserialized_columns.is_ok()); + + let migration_cols = deserialized_columns.unwrap(); + assert_eq!(migration_cols.columns.len(), 1); + assert_eq!( + *migration_cols.columns.first().unwrap(), + MigrationColumn::from_routes(vec![ + MigrationRoute::AddColumn(MigrationAddColumnRoute::Base), + MigrationRoute::Relays + ]) + ); + + assert!(migration_cols.timelines.is_empty()); + } + + #[test] + fn accounts() { + let route = + r#"{"columns":[[{"AddColumn":"Base"},{"Accounts":"Accounts"}]],"timelines":[]}"#; + + let deserialized_columns = deserialize_columns_string(route.to_string()); + assert!(deserialized_columns.is_ok()); + + let migration_cols = deserialized_columns.unwrap(); + assert_eq!(migration_cols.columns.len(), 1); + assert_eq!( + *migration_cols.columns.first().unwrap(), + MigrationColumn::from_routes(vec![ + MigrationRoute::AddColumn(MigrationAddColumnRoute::Base), + MigrationRoute::Accounts(MigrationAccountsRoute::Accounts), + ]) + ); + + assert!(migration_cols.timelines.is_empty()); + } + + #[test] + fn login() { + let route = r#"{"columns":[[{"AddColumn":"Base"},{"Accounts":"Accounts"},{"Accounts":"AddAccount"}]],"timelines":[]}"#; + + let deserialized_columns = deserialize_columns_string(route.to_string()); + assert!(deserialized_columns.is_ok()); + + let migration_cols = deserialized_columns.unwrap(); + assert_eq!(migration_cols.columns.len(), 1); + assert_eq!( + *migration_cols.columns.first().unwrap(), + MigrationColumn::from_routes(vec![ + MigrationRoute::AddColumn(MigrationAddColumnRoute::Base), + MigrationRoute::Accounts(MigrationAccountsRoute::Accounts), + MigrationRoute::Accounts(MigrationAccountsRoute::AddAccount), + ]) + ); + + assert!(migration_cols.timelines.is_empty()); + } +} diff --git a/src/storage/mod.rs b/src/storage/mod.rs index 005902ed..a64772de 100644 --- a/src/storage/mod.rs +++ b/src/storage/mod.rs @@ -1,10 +1,12 @@ -mod columns; +mod decks; mod file_key_storage; mod file_storage; +mod migration; -pub use columns::{load_columns, save_columns}; +pub use decks::{load_decks_cache, save_decks_cache, DECKS_CACHE_FILE}; pub use file_key_storage::FileKeyStorage; pub use file_storage::{delete_file, write_file, DataPath, DataPathType, Directory}; +pub use migration::{deserialize_columns, COLUMNS_FILE}; #[cfg(target_os = "macos")] mod security_framework_key_storage; diff --git a/src/timeline/kind.rs b/src/timeline/kind.rs index 1b60a483..94d22335 100644 --- a/src/timeline/kind.rs +++ b/src/timeline/kind.rs @@ -14,7 +14,7 @@ pub enum PubkeySource { DeckAuthor, } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq)] pub enum ListKind { Contact(PubkeySource), } @@ -44,7 +44,7 @@ impl ListKind { /// - filter /// - ... etc /// -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq)] pub enum TimelineKind { List(ListKind), diff --git a/src/timeline/mod.rs b/src/timeline/mod.rs index 0b26ceb8..b11c3d2f 100644 --- a/src/timeline/mod.rs +++ b/src/timeline/mod.rs @@ -1,5 +1,6 @@ use crate::{ column::Columns, + decks::DecksCache, error::{Error, FilterError}, filter::{self, FilterState, FilterStates}, muted::MuteFun, @@ -16,7 +17,6 @@ use std::sync::atomic::{AtomicU32, Ordering}; use egui_virtual_list::VirtualList; use enostr::{Relay, RelayPool}; use nostrdb::{Filter, Ndb, Note, Subscription, Transaction}; -use serde::{Deserialize, Serialize}; use std::cell::RefCell; use std::hash::Hash; use std::rc::Rc; @@ -29,7 +29,7 @@ pub mod route; pub use kind::{PubkeySource, TimelineKind}; pub use route::TimelineRoute; -#[derive(Debug, Hash, Copy, Clone, Eq, PartialEq, serde::Serialize, serde::Deserialize)] +#[derive(Debug, Hash, Copy, Clone, Eq, PartialEq)] pub struct TimelineId(u32); impl TimelineId { @@ -185,18 +185,6 @@ pub struct Timeline { pub subscription: Option, } -#[derive(Serialize, Deserialize, Clone, Debug)] -pub struct SerializableTimeline { - pub id: TimelineId, - pub kind: TimelineKind, -} - -impl SerializableTimeline { - pub fn into_timeline(self, ndb: &Ndb, deck_user_pubkey: Option<&[u8; 32]>) -> Option { - self.kind.into_timeline(ndb, deck_user_pubkey) - } -} - impl Timeline { /// Create a timeline from a contact list pub fn contact_list(contact_list: &Note, pk_src: PubkeySource) -> Result { @@ -350,13 +338,6 @@ impl Timeline { Ok(()) } - - pub fn as_serializable_timeline(&self) -> SerializableTimeline { - SerializableTimeline { - id: self.id, - kind: self.kind.clone(), - } - } } pub enum MergeKind { @@ -601,12 +582,16 @@ pub fn copy_notes_into_timeline( pub fn setup_initial_nostrdb_subs( ndb: &Ndb, note_cache: &mut NoteCache, - columns: &mut Columns, + decks_cache: &mut DecksCache, is_muted: &MuteFun, ) -> Result<()> { - for timeline in columns.timelines_mut() { - if let Err(err) = setup_timeline_nostrdb_sub(ndb, note_cache, timeline, is_muted) { - error!("setup_initial_nostrdb_subs: {err}"); + for decks in decks_cache.get_all_decks_mut() { + for deck in decks.decks_mut() { + for timeline in deck.columns_mut().timelines_mut() { + if let Err(err) = setup_timeline_nostrdb_sub(ndb, note_cache, timeline, is_muted) { + error!("setup_initial_nostrdb_subs: {err}"); + } + } } } diff --git a/src/timeline/route.rs b/src/timeline/route.rs index 1575db2b..5ccca2d6 100644 --- a/src/timeline/route.rs +++ b/src/timeline/route.rs @@ -21,7 +21,7 @@ use crate::{ use enostr::{NoteId, Pubkey}; use nostrdb::{Ndb, Transaction}; -#[derive(Debug, Eq, PartialEq, Clone, Copy, serde::Serialize, serde::Deserialize)] +#[derive(Debug, Eq, PartialEq, Clone, Copy)] pub enum TimelineRoute { Timeline(TimelineId), Thread(NoteId), diff --git a/src/ui/accounts.rs b/src/ui/accounts.rs index 531f446b..7e7bc0f2 100644 --- a/src/ui/accounts.rs +++ b/src/ui/accounts.rs @@ -2,7 +2,6 @@ use crate::colors::PINK; use crate::imgcache::ImageCache; use crate::{ accounts::Accounts, - route::{Route, Router}, ui::{Preview, PreviewConfig, View}, Damus, }; @@ -205,15 +204,12 @@ mod preview { pub struct AccountsPreview { app: Damus, - router: Router, } impl AccountsPreview { fn new() -> Self { let app = test_data::test_app(); - let router = Router::new(vec![Route::accounts()]); - - AccountsPreview { app, router } + AccountsPreview { app } } } @@ -226,7 +222,12 @@ mod preview { .ui(ui) .inner { - process_accounts_view_response(self.app.accounts_mut(), response, &mut self.router); + process_accounts_view_response( + &mut self.app.accounts, + &mut self.app.decks_cache, + 0, + response, + ); } } } diff --git a/src/ui/add_column.rs b/src/ui/add_column.rs index b0acf580..a636b47f 100644 --- a/src/ui/add_column.rs +++ b/src/ui/add_column.rs @@ -1,5 +1,4 @@ use core::f32; -use serde::{Deserialize, Serialize}; use std::collections::HashMap; use egui::{ @@ -43,7 +42,7 @@ enum AddColumnOption { Hashtag(String), } -#[derive(Clone, Copy, Eq, PartialEq, Debug, Serialize, Deserialize)] +#[derive(Clone, Copy, Eq, PartialEq, Debug)] pub enum AddColumnRoute { Base, UndecidedNotification, @@ -285,7 +284,11 @@ impl<'a> AddColumnView<'a> { }); if let Some(acc) = self.cur_account { - let source = PubkeySource::Explicit(acc.pubkey); + let source = if acc.secret_key.is_some() { + PubkeySource::DeckAuthor + } else { + PubkeySource::Explicit(acc.pubkey) + }; vec.push(ColumnOptionData { title: "Home timeline", diff --git a/src/ui/column/header.rs b/src/ui/column/header.rs index 6e81de94..f621ee11 100644 --- a/src/ui/column/header.rs +++ b/src/ui/column/header.rs @@ -205,6 +205,8 @@ impl<'a> NavTitle<'a> { Route::AddColumn(_add_col_route) => {} Route::Support => {} Route::Relays => {} + Route::NewDeck => {} + Route::EditDeck(_) => {} } } diff --git a/src/ui/configure_deck.rs b/src/ui/configure_deck.rs index f19231fc..4794fc1e 100644 --- a/src/ui/configure_deck.rs +++ b/src/ui/configure_deck.rs @@ -216,13 +216,13 @@ fn glyph_icon_max_size(ui: &egui::Ui, glyph: &char, font_size: f32) -> egui::Vec glyph_galley.rect.size() } -fn glyph_icon(glyph: char, font_size: f32, max_size: egui::Vec2) -> impl Widget { +fn glyph_icon(glyph: char, font_size: f32, max_size: egui::Vec2, color: Color32) -> impl Widget { move |ui: &mut egui::Ui| { let helper = AnimationHelper::new(ui, ("glyph", glyph), max_size); let painter = ui.painter_at(helper.get_animation_rect()); let font = deck_icon_font_sized(helper.scale_1d_pos(font_size)); - let glyph_galley = painter.layout_no_wrap(glyph.to_string(), font, Color32::WHITE); + let glyph_galley = painter.layout_no_wrap(glyph.to_string(), font, color); let top_left = { let mut glyph_rect = glyph_galley.rect; @@ -280,7 +280,15 @@ fn paint_row(ui: &mut egui::Ui, row_glyphs: &[char], font_size: f32) -> Option { ndb: &'a nostrdb::Ndb, img_cache: &'a mut ImageCache, selected_account: Option<&'a UserAccount>, + decks_cache: &'a DecksCache, } impl View for DesktopSidePanel<'_> { @@ -45,6 +52,9 @@ pub enum SidePanelAction { Search, ExpandSidePanel, Support, + NewDeck, + SwitchDeck(usize), + EditDeck(usize), } pub struct SidePanelResponse { @@ -63,11 +73,13 @@ impl<'a> DesktopSidePanel<'a> { ndb: &'a nostrdb::Ndb, img_cache: &'a mut ImageCache, selected_account: Option<&'a UserAccount>, + decks_cache: &'a DecksCache, ) -> Self { Self { ndb, img_cache, selected_account, + decks_cache, } } @@ -112,6 +124,21 @@ impl<'a> DesktopSidePanel<'a> { ui.add(Separator::default().horizontal().spacing(8.0).shrink(4.0)); + ui.add_space(8.0); + ui.add(egui::Label::new( + RichText::new("DECKS") + .size(11.0) + .color(ui.visuals().noninteractive().fg_stroke.color), + )); + ui.add_space(8.0); + let add_deck_resp = ui.add(add_deck_button()); + + let decks_inner = ScrollArea::vertical() + .max_height(ui.available_height() - (3.0 * (ICON_WIDTH + 12.0))) + .show(ui, |ui| { + show_decks(ui, self.decks_cache, self.selected_account) + }) + .inner; if expand_resp.clicked() { Some(InnerResponse::new( SidePanelAction::ExpandSidePanel, @@ -126,12 +153,34 @@ impl<'a> DesktopSidePanel<'a> { // Some(InnerResponse::new(SidePanelAction::Search, search_resp)) } else if column_resp.clicked() { Some(InnerResponse::new(SidePanelAction::Columns, column_resp)) + } else if add_deck_resp.clicked() { + Some(InnerResponse::new(SidePanelAction::NewDeck, add_deck_resp)) + } else if decks_inner.response.secondary_clicked() { + info!("decks inner secondary click"); + if let Some(clicked_index) = decks_inner.inner { + Some(InnerResponse::new( + SidePanelAction::EditDeck(clicked_index), + decks_inner.response, + )) + } else { + None + } + } else if decks_inner.response.clicked() { + if let Some(clicked_index) = decks_inner.inner { + Some(InnerResponse::new( + SidePanelAction::SwitchDeck(clicked_index), + decks_inner.response, + )) + } else { + None + } } else { None } }) .inner; + ui.add(Separator::default().horizontal().spacing(8.0).shrink(4.0)); let (pfp_resp, bottom_resp) = ui .with_layout(Layout::bottom_up(egui::Align::Center), |ui| { let pfp_resp = self.pfp_button(ui); @@ -200,8 +249,14 @@ impl<'a> DesktopSidePanel<'a> { helper.take_animation_response() } - pub fn perform_action(columns: &mut Columns, support: &mut Support, action: SidePanelAction) { - let router = columns.get_first_router(); + pub fn perform_action( + decks_cache: &mut DecksCache, + accounts: &Accounts, + support: &mut Support, + action: SidePanelAction, + ) -> Option { + let router = get_active_columns_mut(accounts, decks_cache).get_first_router(); + let mut switching_response = None; match action { SidePanelAction::Panel => {} // TODO SidePanelAction::Account => { @@ -232,7 +287,7 @@ impl<'a> DesktopSidePanel<'a> { { router.go_back(); } else { - columns.new_column_picker(); + get_active_columns_mut(accounts, decks_cache).new_column_picker(); } } SidePanelAction::ComposeNote => { @@ -258,7 +313,40 @@ impl<'a> DesktopSidePanel<'a> { router.route_to(Route::Support); } } + SidePanelAction::NewDeck => { + if router.routes().iter().any(|&r| r == Route::NewDeck) { + router.go_back(); + } else { + router.route_to(Route::NewDeck); + } + } + SidePanelAction::SwitchDeck(index) => { + switching_response = Some(crate::nav::SwitchingAction::Decks(DecksAction::Switch( + index, + ))) + } + SidePanelAction::EditDeck(index) => { + if router.routes().iter().any(|&r| r == Route::EditDeck(index)) { + router.go_back(); + } else { + switching_response = Some(crate::nav::SwitchingAction::Decks( + DecksAction::Switch(index), + )); + if let Some(edit_deck) = get_decks_mut(accounts, decks_cache) + .decks_mut() + .get_mut(index) + { + edit_deck + .columns_mut() + .get_first_router() + .route_to(Route::EditDeck(index)); + } else { + error!("Cannot push EditDeck route to index {}", index); + } + } + } } + switching_response } } @@ -445,6 +533,64 @@ fn support_button() -> impl Widget { } } +fn add_deck_button() -> impl Widget { + |ui: &mut egui::Ui| -> egui::Response { + let img_size = 40.0; + + let max_size = ICON_WIDTH * ICON_EXPANSION_MULTIPLE; // max size of the widget + let img_data = egui::include_image!("../../assets/icons/new_deck_icon_4x_dark.png"); + let img = egui::Image::new(img_data).max_width(img_size); + + let helper = AnimationHelper::new(ui, "new-deck-icon", vec2(max_size, max_size)); + + let cur_img_size = helper.scale_1d_pos(img_size); + img.paint_at( + ui, + helper + .get_animation_rect() + .shrink((max_size - cur_img_size) / 2.0), + ); + + helper.take_animation_response() + } +} + +fn show_decks<'a>( + ui: &mut egui::Ui, + decks_cache: &'a DecksCache, + selected_account: Option<&'a UserAccount>, +) -> InnerResponse> { + let show_decks_id = ui.id().with("show-decks"); + let account_id = if let Some(acc) = selected_account { + acc.pubkey + } else { + *decks_cache.get_fallback_pubkey() + }; + let (cur_decks, account_id) = ( + decks_cache.decks(&account_id), + show_decks_id.with(account_id), + ); + let active_index = cur_decks.active_index(); + + let (_, mut resp) = ui.allocate_exact_size(vec2(0.0, 0.0), egui::Sense::click()); + let mut clicked_index = None; + for (index, deck) in cur_decks.decks().iter().enumerate() { + let highlight = index == active_index; + let deck_icon_resp = ui.add(deck_icon( + account_id.with(index), + Some(deck.icon), + DECK_ICON_SIZE, + 40.0, + highlight, + )); + if deck_icon_resp.clicked() || deck_icon_resp.secondary_clicked() { + clicked_index = Some(index); + } + resp = resp.union(deck_icon_resp); + } + InnerResponse::new(clicked_index, resp) +} + fn milestone_name() -> impl Widget { |ui: &mut egui::Ui| -> egui::Response { ui.vertical_centered(|ui| { @@ -470,6 +616,7 @@ mod preview { use egui_extras::{Size, StripBuilder}; use crate::{ + app::get_active_columns_mut, test_data, ui::{Preview, PreviewConfig}, }; @@ -483,7 +630,8 @@ mod preview { impl DesktopSidePanelPreview { fn new() -> Self { let mut app = test_data::test_app(); - app.columns.add_column(Column::new(vec![Route::accounts()])); + get_active_columns_mut(&app.accounts, &mut app.decks_cache) + .add_column(Column::new(vec![Route::accounts()])); DesktopSidePanelPreview { app } } } @@ -500,11 +648,13 @@ mod preview { &self.app.ndb, &mut self.app.img_cache, self.app.accounts.get_selected_account(), + &self.app.decks_cache, ); let response = panel.show(ui); DesktopSidePanel::perform_action( - &mut self.app.columns, + &mut self.app.decks_cache, + &self.app.accounts, &mut self.app.support, response.action, ); diff --git a/src/view_state.rs b/src/view_state.rs index 17aec246..c0a20b2c 100644 --- a/src/view_state.rs +++ b/src/view_state.rs @@ -1,11 +1,13 @@ use std::collections::HashMap; +use crate::deck_state::DeckState; use crate::login_manager::AcquireKeyState; /// Various state for views #[derive(Default)] pub struct ViewState { pub login: AcquireKeyState, + pub id_to_deck_state: HashMap, pub id_state_map: HashMap, pub id_string_map: HashMap, }