use crate::{ args::{ColumnsArgs, ColumnsFlag}, column::Columns, decks::{Decks, DecksCache}, draft::Drafts, nav::{self, ProcessNavResult}, onboarding::Onboarding, options::AppOptions, route::Route, storage, subscriptions::{SubKind, Subscriptions}, support::Support, timeline::{self, kind::ListKind, thread::Threads, TimelineCache, TimelineKind}, toolbar::unseen_notification, ui::{self, toolbar::toolbar, DesktopSidePanel, SidePanelAction}, view_state::ViewState, Result, }; use egui_extras::{Size, StripBuilder}; use enostr::{ClientMessage, PoolRelay, Pubkey, RelayEvent, RelayMessage, RelayPool}; use nostrdb::Transaction; use notedeck::{ tr, ui::is_narrow, Accounts, AppAction, AppContext, DataPath, DataPathType, FilterState, Images, JobsCache, Localization, NotedeckOptions, SettingsHandler, UnknownIds, }; use notedeck_ui::{ media::{MediaViewer, MediaViewerFlags, MediaViewerState}, NoteOptions, }; use std::collections::{BTreeSet, HashMap}; use std::path::Path; use tracing::{debug, error, info, trace, warn}; use uuid::Uuid; #[derive(Debug, Eq, PartialEq, Clone)] pub enum DamusState { Initializing, Initialized, } /// We derive Deserialize/Serialize so we can persist app state on shutdown. pub struct Damus { state: DamusState, pub decks_cache: DecksCache, pub view_state: ViewState, pub drafts: Drafts, pub timeline_cache: TimelineCache, pub subscriptions: Subscriptions, pub support: Support, pub jobs: JobsCache, pub threads: Threads, //frame_history: crate::frame_history::FrameHistory, // TODO: make these bitflags /// Were columns loaded from the commandline? If so disable persistence. pub options: AppOptions, pub note_options: NoteOptions, pub unrecognized_args: BTreeSet, /// keep track of follow packs pub onboarding: Onboarding, } fn handle_egui_events(input: &egui::InputState, columns: &mut Columns) { for event in &input.raw.events { match event { egui::Event::Key { key, pressed, .. } if *pressed => match key { egui::Key::J => { //columns.select_down(); {} } /* egui::Key::K => { columns.select_up(); } egui::Key::H => { columns.select_left(); } egui::Key::L => { columns.select_left(); } */ egui::Key::BrowserBack | egui::Key::Escape => { columns.get_selected_router().go_back(); } _ => {} }, egui::Event::InsetsChanged => { tracing::debug!("insets have changed!"); } _ => {} } } } fn try_process_event( damus: &mut Damus, app_ctx: &mut AppContext<'_>, ctx: &egui::Context, ) -> Result<()> { let current_columns = get_active_columns_mut(app_ctx.i18n, app_ctx.accounts, &mut damus.decks_cache); ctx.input(|i| handle_egui_events(i, current_columns)); let ctx2 = ctx.clone(); let wakeup = move || { ctx2.request_repaint(); }; app_ctx.pool.keepalive_ping(wakeup); // NOTE: we don't use the while let loop due to borrow issues #[allow(clippy::while_let_loop)] loop { let ev = if let Some(ev) = app_ctx.pool.try_recv() { ev.into_owned() } else { break; }; match (&ev.event).into() { RelayEvent::Opened => { app_ctx .accounts .send_initial_filters(app_ctx.pool, &ev.relay); timeline::send_initial_timeline_filters( damus.options.contains(AppOptions::SinceOptimize), &mut damus.timeline_cache, &mut damus.subscriptions, app_ctx.pool, &ev.relay, app_ctx.accounts, ); } // TODO: handle reconnects RelayEvent::Closed => warn!("{} connection closed", &ev.relay), RelayEvent::Error(e) => error!("{}: {}", &ev.relay, e), RelayEvent::Other(msg) => trace!("other event {:?}", &msg), RelayEvent::Message(msg) => { process_message(damus, app_ctx, &ev.relay, &msg); } } } for (kind, timeline) in &mut damus.timeline_cache { let is_ready = timeline::is_timeline_ready( app_ctx.ndb, app_ctx.pool, app_ctx.note_cache, timeline, app_ctx.accounts, app_ctx.unknown_ids, ); if is_ready { let txn = Transaction::new(app_ctx.ndb).expect("txn"); // only thread timelines are reversed let reversed = false; if let Err(err) = timeline.poll_notes_into_view( app_ctx.ndb, &txn, app_ctx.unknown_ids, app_ctx.note_cache, reversed, ) { error!("poll_notes_into_view: {err}"); } } else { // TODO: show loading? if matches!(kind, TimelineKind::List(ListKind::Contact(_))) { timeline::fetch_contact_list(&mut damus.subscriptions, timeline, app_ctx.accounts); } } } if let Some(follow_packs) = damus.onboarding.get_follow_packs_mut() { follow_packs.poll_for_notes(app_ctx.ndb, app_ctx.unknown_ids); } if app_ctx.unknown_ids.ready_to_send() { unknown_id_send(app_ctx.unknown_ids, app_ctx.pool); } Ok(()) } fn unknown_id_send(unknown_ids: &mut UnknownIds, pool: &mut RelayPool) { debug!("unknown_id_send called on: {:?}", &unknown_ids); let filter = unknown_ids.filter().expect("filter"); debug!( "Getting {} unknown ids from relays", unknown_ids.ids_iter().len() ); let msg = ClientMessage::req("unknownids".to_string(), filter); unknown_ids.clear(); pool.send(&msg); } fn update_damus(damus: &mut Damus, app_ctx: &mut AppContext<'_>, ctx: &egui::Context) { app_ctx.img_cache.urls.cache.handle_io(); if damus.columns(app_ctx.accounts).columns().is_empty() { damus .columns_mut(app_ctx.i18n, app_ctx.accounts) .new_column_picker(); } match damus.state { DamusState::Initializing => { damus.state = DamusState::Initialized; // this lets our eose handler know to close unknownids right away damus .subscriptions() .insert("unknownids".to_string(), SubKind::OneShot); if let Err(err) = timeline::setup_initial_nostrdb_subs( app_ctx.ndb, app_ctx.note_cache, &mut damus.timeline_cache, app_ctx.unknown_ids, ) { warn!("update_damus init: {err}"); } } DamusState::Initialized => (), }; if let Err(err) = try_process_event(damus, app_ctx, ctx) { error!("error processing event: {}", err); } } fn handle_eose( subscriptions: &Subscriptions, timeline_cache: &mut TimelineCache, ctx: &mut AppContext<'_>, subid: &str, relay_url: &str, ) -> Result<()> { let sub_kind = if let Some(sub_kind) = subscriptions.subs.get(subid) { sub_kind } else { let n_subids = subscriptions.subs.len(); warn!( "got unknown eose subid {}, {} tracked subscriptions", subid, n_subids ); return Ok(()); }; match sub_kind { SubKind::Timeline(_) => { // eose on timeline? whatevs } SubKind::Initial => { //let txn = Transaction::new(ctx.ndb)?; //unknowns::update_from_columns( // &txn, // ctx.unknown_ids, // timeline_cache, // ctx.ndb, // ctx.note_cache, //); //// this is possible if this is the first time //if ctx.unknown_ids.ready_to_send() { // unknown_id_send(ctx.unknown_ids, ctx.pool); //} } // oneshot subs just close when they're done SubKind::OneShot => { let msg = ClientMessage::close(subid.to_string()); ctx.pool.send_to(&msg, relay_url); } SubKind::FetchingContactList(timeline_uid) => { let timeline = if let Some(tl) = timeline_cache.get_mut(timeline_uid) { tl } else { error!( "timeline uid:{:?} not found for FetchingContactList", timeline_uid ); return Ok(()); }; let filter_state = timeline.filter.get_mut(relay_url); let FilterState::FetchingRemote(fetching_remote_type) = filter_state else { // TODO: we could have multiple contact list results, we need // to check to see if this one is newer and use that instead warn!( "Expected timeline to have FetchingRemote state but was {:?}", timeline.filter ); return Ok(()); }; let new_filter_state = match fetching_remote_type { notedeck::filter::FetchingRemoteType::Normal(unified_subscription) => { FilterState::got_remote(unified_subscription.local) } notedeck::filter::FetchingRemoteType::Contact => { FilterState::GotRemote(notedeck::filter::GotRemoteType::Contact) } }; // We take the subscription id and pass it to the new state of // "GotRemote". This will let future frames know that it can try // to look for the contact list in nostrdb. timeline .filter .set_relay_state(relay_url.to_string(), new_filter_state); } } Ok(()) } fn process_message(damus: &mut Damus, ctx: &mut AppContext<'_>, relay: &str, msg: &RelayMessage) { match msg { RelayMessage::Event(_subid, ev) => { let relay = if let Some(relay) = ctx.pool.relays.iter().find(|r| r.url() == relay) { relay } else { error!("couldn't find relay {} for note processing!?", relay); return; }; match relay { PoolRelay::Websocket(_) => { //info!("processing event {}", event); if let Err(err) = ctx.ndb.process_event_with( ev, nostrdb::IngestMetadata::new() .client(false) .relay(relay.url()), ) { error!("error processing event {ev}: {err}"); } } PoolRelay::Multicast(_) => { // multicast events are client events if let Err(err) = ctx.ndb.process_event_with( ev, nostrdb::IngestMetadata::new() .client(true) .relay(relay.url()), ) { error!("error processing multicast event {ev}: {err}"); } } } } RelayMessage::Notice(msg) => warn!("Notice from {}: {}", relay, msg), RelayMessage::OK(cr) => info!("OK {:?}", cr), RelayMessage::Eose(sid) => { if let Err(err) = handle_eose( &damus.subscriptions, &mut damus.timeline_cache, ctx, sid, relay, ) { error!("error handling eose: {}", err); } } } } fn render_damus( damus: &mut Damus, app_ctx: &mut AppContext<'_>, ui: &mut egui::Ui, ) -> Option { damus .note_options .set(NoteOptions::Wide, is_narrow(ui.ctx())); let app_action = if notedeck::ui::is_narrow(ui.ctx()) { render_damus_mobile(damus, app_ctx, ui) } else { render_damus_desktop(damus, app_ctx, ui) }; fullscreen_media_viewer_ui(ui, &mut damus.view_state.media_viewer, app_ctx.img_cache); // We use this for keeping timestamps and things up to date //ui.ctx().request_repaint_after(Duration::from_secs(5)); app_action } /// Present a fullscreen media viewer if the FullscreenMedia AppOptions flag is set. This is /// typically set by image carousels using a MediaAction's on_view_media callback when /// an image is clicked fn fullscreen_media_viewer_ui( ui: &mut egui::Ui, state: &mut MediaViewerState, img_cache: &mut Images, ) { if !state.should_show(ui) { if state.scene_rect.is_some() { // if we shouldn't show yet we will have a scene // rect, then we should clear it for next time tracing::debug!("fullscreen_media_viewer_ui: resetting scene rect"); state.scene_rect = None; } return; } let resp = MediaViewer::new(state).fullscreen(true).ui(img_cache, ui); if resp.clicked() || ui.input(|i| i.key_pressed(egui::Key::Escape)) { fullscreen_media_close(state); } } /// Close the fullscreen media player. This also resets the scene_rect state fn fullscreen_media_close(state: &mut MediaViewerState) { state.flags.set(MediaViewerFlags::Open, false); } /* fn determine_key_storage_type() -> KeyStorageType { #[cfg(target_os = "macos")] { KeyStorageType::MacOS } #[cfg(target_os = "linux")] { KeyStorageType::Linux } #[cfg(not(any(target_os = "macos", target_os = "linux")))] { KeyStorageType::None } } */ impl Damus { /// Called once before the first frame. pub fn new(app_context: &mut AppContext<'_>, args: &[String]) -> Self { // arg parsing let (parsed_args, unrecognized_args) = ColumnsArgs::parse(args, Some(app_context.accounts.selected_account_pubkey())); let account = app_context.accounts.selected_account_pubkey_bytes(); let mut timeline_cache = TimelineCache::default(); let mut options = AppOptions::default(); let tmp_columns = !parsed_args.columns.is_empty(); options.set(AppOptions::TmpColumns, tmp_columns); options.set( AppOptions::Debug, app_context.args.options.contains(NotedeckOptions::Debug), ); options.set( AppOptions::SinceOptimize, parsed_args.is_flag_set(ColumnsFlag::SinceOptimize), ); let decks_cache = if tmp_columns { info!("DecksCache: loading from command line arguments"); let mut columns: Columns = Columns::new(); let txn = Transaction::new(app_context.ndb).unwrap(); for col in &parsed_args.columns { let timeline_kind = col.clone().into_timeline_kind(); if let Some(add_result) = columns.add_new_timeline_column( &mut timeline_cache, &txn, app_context.ndb, app_context.note_cache, app_context.pool, &timeline_kind, ) { add_result.process( app_context.ndb, app_context.note_cache, &txn, &mut timeline_cache, app_context.unknown_ids, ); } } columns_to_decks_cache(app_context.i18n, columns, account) } else if let Some(decks_cache) = crate::storage::load_decks_cache( app_context.path, app_context.ndb, &mut timeline_cache, app_context.i18n, ) { info!( "DecksCache: loading from disk {}", crate::storage::DECKS_CACHE_FILE ); decks_cache } else { info!("DecksCache: creating new with demo configuration"); DecksCache::new_with_demo_config(&mut timeline_cache, app_context) //for (pk, _) in &app_context.accounts.cache { // cache.add_deck_default(*pk); //} }; let support = Support::new(app_context.path); let note_options = get_note_options(parsed_args, app_context.settings); let jobs = JobsCache::default(); let threads = Threads::default(); Self { subscriptions: Subscriptions::default(), timeline_cache, drafts: Drafts::default(), state: DamusState::Initializing, note_options, options, //frame_history: FrameHistory::default(), view_state: ViewState::default(), support, decks_cache, unrecognized_args, jobs, threads, onboarding: Onboarding::default(), } } /// Scroll to the top of the currently selected column. This is called /// by the chrome when you click the toolbar pub fn scroll_to_top(&mut self) { self.options.insert(AppOptions::ScrollToTop) } pub fn columns_mut(&mut self, i18n: &mut Localization, accounts: &Accounts) -> &mut Columns { get_active_columns_mut(i18n, accounts, &mut self.decks_cache) } pub fn columns(&self, accounts: &Accounts) -> &Columns { get_active_columns(accounts, &self.decks_cache) } pub fn gen_subid(&self, kind: &SubKind) -> String { if self.options.contains(AppOptions::Debug) { format!("{kind:?}") } else { Uuid::new_v4().to_string() } } pub fn mock>(data_path: P) -> Self { let mut i18n = Localization::default(); let decks_cache = DecksCache::default_decks_cache(&mut i18n); let path = DataPath::new(&data_path); let imgcache_dir = path.path(DataPathType::Cache); let _ = std::fs::create_dir_all(imgcache_dir.clone()); let options = AppOptions::default() | AppOptions::Debug | AppOptions::TmpColumns; let support = Support::new(&path); Self { subscriptions: Subscriptions::default(), timeline_cache: TimelineCache::default(), drafts: Drafts::default(), state: DamusState::Initializing, note_options: NoteOptions::default(), //frame_history: FrameHistory::default(), view_state: ViewState::default(), support, options, decks_cache, unrecognized_args: BTreeSet::default(), jobs: JobsCache::default(), threads: Threads::default(), onboarding: Onboarding::default(), } } pub fn subscriptions(&mut self) -> &mut HashMap { &mut self.subscriptions.subs } pub fn unrecognized_args(&self) -> &BTreeSet { &self.unrecognized_args } pub fn toolbar_height() -> f32 { 48.0 } pub fn initially_selected_toolbar_index() -> i32 { 0 } } fn get_note_options(args: ColumnsArgs, settings_handler: &mut SettingsHandler) -> NoteOptions { let mut note_options = NoteOptions::default(); note_options.set( NoteOptions::Textmode, args.is_flag_set(ColumnsFlag::Textmode), ); note_options.set( NoteOptions::ScrambleText, args.is_flag_set(ColumnsFlag::Scramble), ); note_options.set( NoteOptions::HideMedia, args.is_flag_set(ColumnsFlag::NoMedia), ); note_options.set( NoteOptions::RepliesNewestFirst, settings_handler.show_replies_newest_first(), ); note_options } /* fn circle_icon(ui: &mut egui::Ui, openness: f32, response: &egui::Response) { let stroke = ui.style().interact(&response).fg_stroke; let radius = egui::lerp(2.0..=3.0, openness); ui.painter() .circle_filled(response.rect.center(), radius, stroke.color); } */ #[profiling::function] fn render_damus_mobile( app: &mut Damus, app_ctx: &mut AppContext<'_>, ui: &mut egui::Ui, ) -> Option { //let routes = app.timelines[0].routes.clone(); let active_col = app.columns_mut(app_ctx.i18n, app_ctx.accounts).selected as usize; let mut app_action: Option = None; // don't show toolbar if soft keyboard is open let skb_rect = app_ctx.soft_keyboard_rect( ui.ctx().screen_rect(), notedeck::SoftKeyboardContext::platform(ui.ctx()), ); let toolbar_height = if skb_rect.is_none() { Damus::toolbar_height() } else { 0.0 }; StripBuilder::new(ui) .size(Size::remainder()) // top cell .size(Size::exact(toolbar_height)) // bottom cell .vertical(|mut strip| { strip.cell(|ui| { let rect = ui.available_rect_before_wrap(); if !app.columns(app_ctx.accounts).columns().is_empty() { let r = nav::render_nav( active_col, ui.available_rect_before_wrap(), app, app_ctx, ui, ) .process_render_nav_response(app, app_ctx, ui); if let Some(r) = &r { match r { ProcessNavResult::SwitchOccurred => { if !app.options.contains(AppOptions::TmpColumns) { storage::save_decks_cache(app_ctx.path, &app.decks_cache); } } ProcessNavResult::PfpClicked => { app_action = Some(AppAction::ToggleChrome); } } } } hovering_post_button(ui, app, app_ctx, rect); }); strip.cell(|ui| 'brk: { if toolbar_height <= 0.0 { break 'brk; } let unseen_notif = unseen_notification( app, app_ctx.ndb, app_ctx.accounts.get_selected_account().key.pubkey, ); if skb_rect.is_none() { let resp = toolbar(ui, unseen_notif); if let Some(action) = resp { action.process(app, app_ctx); } } }); }); app_action } fn hovering_post_button( ui: &mut egui::Ui, app: &mut Damus, app_ctx: &mut AppContext, mut rect: egui::Rect, ) { let should_show_compose = should_show_compose_button(&app.decks_cache, app_ctx.accounts); let btn_id = ui.id().with("hover_post_btn"); let button_y = ui .ctx() .animate_bool_responsive(btn_id, should_show_compose); rect.min.x = rect.max.x - (if is_narrow(ui.ctx()) { 60.0 } else { 100.0 } * button_y); rect.min.y = rect.max.y - 100.0; rect.max.x += 48.0 * (1.0 - button_y); let darkmode = ui.ctx().style().visuals.dark_mode; // only show the compose button on profile pages and on home let compose_resp = ui .put(rect, ui::post::compose_note_button(darkmode)) .on_hover_cursor(egui::CursorIcon::PointingHand); if compose_resp.clicked() && !app.columns(app_ctx.accounts).columns().is_empty() { // just use the some side panel logic as the desktop DesktopSidePanel::perform_action( &mut app.decks_cache, app_ctx.accounts, SidePanelAction::ComposeNote, app_ctx.i18n, ); } } /// Should we show the compose button? When in threads we should hide it, etc fn should_show_compose_button(decks: &DecksCache, accounts: &Accounts) -> bool { let Some(col) = decks.selected_column(accounts) else { return false; }; match col.router().top() { Route::Timeline(timeline_kind) => { match timeline_kind { TimelineKind::List(list_kind) => match list_kind { ListKind::Contact(_pk) => true, }, TimelineKind::Algo(_pk) => true, TimelineKind::Profile(_pk) => true, TimelineKind::Universe => true, TimelineKind::Generic(_) => true, TimelineKind::Hashtag(_) => true, // no! TimelineKind::Search(_) => false, TimelineKind::Notifications(_) => false, } } Route::Thread(_) => false, Route::Accounts(_) => false, Route::Reply(_) => false, Route::Quote(_) => false, Route::Relays => false, Route::Settings => false, Route::ComposeNote => false, Route::AddColumn(_) => false, Route::EditProfile(_) => false, Route::Support => false, Route::NewDeck => false, Route::Search => false, Route::EditDeck(_) => false, Route::Wallet(_) => false, Route::CustomizeZapAmount(_) => false, } } #[profiling::function] fn render_damus_desktop( app: &mut Damus, app_ctx: &mut AppContext<'_>, ui: &mut egui::Ui, ) -> Option { let screen_size = ui.ctx().screen_rect().width(); let calc_panel_width = (screen_size / get_active_columns(app_ctx.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 { Size::exact(min_width) } else { Size::remainder() }; ui.spacing_mut().item_spacing.x = 0.0; if need_scroll { egui::ScrollArea::horizontal() .show(ui, |ui| timelines_view(ui, panel_sizes, app, app_ctx)) .inner } else { timelines_view(ui, panel_sizes, app, app_ctx) } } fn timelines_view( ui: &mut egui::Ui, sizes: Size, app: &mut Damus, ctx: &mut AppContext<'_>, ) -> Option { let num_cols = get_active_columns(ctx.accounts, &app.decks_cache).num_columns(); let mut side_panel_action: Option = None; let mut responses = Vec::with_capacity(num_cols); StripBuilder::new(ui) .size(Size::exact(ui::side_panel::SIDE_PANEL_WIDTH)) .sizes(sizes, num_cols) .clip(true) .horizontal(|mut strip| { strip.cell(|ui| { let rect = ui.available_rect_before_wrap(); let side_panel = DesktopSidePanel::new( ctx.accounts.get_selected_account(), &app.decks_cache, ctx.i18n, ) .show(ui); if let Some(side_panel) = side_panel { if side_panel.response.clicked() || side_panel.response.secondary_clicked() { if let Some(action) = DesktopSidePanel::perform_action( &mut app.decks_cache, ctx.accounts, side_panel.action, ctx.i18n, ) { side_panel_action = Some(action); } } } // debug /* ui.painter().rect( rect, 0, egui::Color32::RED, egui::Stroke::new(1.0, egui::Color32::BLUE), egui::StrokeKind::Inside, ); */ // vertical sidebar line ui.painter().vline( rect.right(), rect.y_range(), ui.visuals().widgets.noninteractive.bg_stroke, ); }); for col_index in 0..num_cols { strip.cell(|ui| { let rect = ui.available_rect_before_wrap(); let v_line_stroke = ui.visuals().widgets.noninteractive.bg_stroke; let inner_rect = { let mut inner = rect; inner.set_right(rect.right() - v_line_stroke.width); inner }; responses.push(nav::render_nav(col_index, inner_rect, app, ctx, ui)); // vertical line ui.painter() .vline(rect.right(), rect.y_range(), v_line_stroke); // we need borrow ui context for processing, so proces // responses in the last cell if col_index == num_cols - 1 {} }); //strip.cell(|ui| timeline::timeline_view(ui, app, timeline_ind)); } }); // process the side panel action after so we don't change the number of columns during // StripBuilder rendering let mut save_cols = false; if let Some(action) = side_panel_action { save_cols = save_cols || action.process( &mut app.timeline_cache, &mut app.decks_cache, ctx, &mut app.subscriptions, ui.ctx(), ); } let mut app_action: Option = None; for response in responses { let nav_result = response.process_render_nav_response(app, ctx, ui); if let Some(nr) = &nav_result { match nr { ProcessNavResult::SwitchOccurred => save_cols = true, ProcessNavResult::PfpClicked => { app_action = Some(AppAction::ToggleChrome); } } } } if app.options.contains(AppOptions::TmpColumns) { save_cols = false; } if save_cols { storage::save_decks_cache(ctx.path, &app.decks_cache); } app_action } impl notedeck::App for Damus { fn update(&mut self, ctx: &mut AppContext<'_>, ui: &mut egui::Ui) -> Option { /* self.app .frame_history .on_new_frame(ctx.input(|i| i.time), frame.info().cpu_usage); */ update_damus(self, ctx, ui.ctx()); render_damus(self, ctx, ui) } } 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 = accounts.selected_account_pubkey(); decks_cache.decks(key) } pub fn get_active_columns_mut<'a>( i18n: &mut Localization, accounts: &Accounts, decks_cache: &'a mut DecksCache, ) -> &'a mut Columns { get_decks_mut(i18n, accounts, decks_cache) .active_mut() .columns_mut() } pub fn get_decks_mut<'a>( i18n: &mut Localization, accounts: &Accounts, decks_cache: &'a mut DecksCache, ) -> &'a mut Decks { decks_cache.decks_mut(i18n, accounts.selected_account_pubkey()) } fn columns_to_decks_cache(i18n: &mut Localization, cols: Columns, key: &[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(), tr!(i18n, "My Deck", "Title for the user's deck"), cols, )); let account = Pubkey::new(*key); account_to_decks.insert(account, decks); DecksCache::new(account_to_decks, i18n) }