notedeck

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

app.rs (22698B)


      1 use crate::{
      2     args::ColumnsArgs,
      3     column::Columns,
      4     decks::{Decks, DecksCache, FALLBACK_PUBKEY},
      5     draft::Drafts,
      6     nav,
      7     notes_holder::NotesHolderStorage,
      8     profile::Profile,
      9     storage,
     10     subscriptions::{SubKind, Subscriptions},
     11     support::Support,
     12     thread::Thread,
     13     timeline::{self, Timeline},
     14     ui::{self, DesktopSidePanel},
     15     unknowns,
     16     view_state::ViewState,
     17     Result,
     18 };
     19 
     20 use notedeck::{Accounts, AppContext, DataPath, DataPathType, FilterState, ImageCache, UnknownIds};
     21 
     22 use enostr::{ClientMessage, Keypair, Pubkey, RelayEvent, RelayMessage, RelayPool};
     23 use uuid::Uuid;
     24 
     25 use egui::{Frame, Style};
     26 use egui_extras::{Size, StripBuilder};
     27 
     28 use nostrdb::{Ndb, Transaction};
     29 
     30 use std::collections::HashMap;
     31 use std::path::Path;
     32 use std::time::Duration;
     33 use tracing::{error, info, trace, warn};
     34 
     35 #[derive(Debug, Eq, PartialEq, Clone)]
     36 pub enum DamusState {
     37     Initializing,
     38     Initialized,
     39 }
     40 
     41 /// We derive Deserialize/Serialize so we can persist app state on shutdown.
     42 pub struct Damus {
     43     state: DamusState,
     44     pub decks_cache: DecksCache,
     45     pub view_state: ViewState,
     46     pub drafts: Drafts,
     47     pub threads: NotesHolderStorage<Thread>,
     48     pub profiles: NotesHolderStorage<Profile>,
     49     pub subscriptions: Subscriptions,
     50     pub support: Support,
     51 
     52     //frame_history: crate::frame_history::FrameHistory,
     53 
     54     // TODO: make these bitflags
     55     pub debug: bool,
     56     pub since_optimize: bool,
     57     pub textmode: bool,
     58 }
     59 
     60 fn handle_key_events(input: &egui::InputState, columns: &mut Columns) {
     61     for event in &input.raw.events {
     62         if let egui::Event::Key {
     63             key, pressed: true, ..
     64         } = event
     65         {
     66             match key {
     67                 egui::Key::J => {
     68                     columns.select_down();
     69                 }
     70                 egui::Key::K => {
     71                     columns.select_up();
     72                 }
     73                 egui::Key::H => {
     74                     columns.select_left();
     75                 }
     76                 egui::Key::L => {
     77                     columns.select_left();
     78                 }
     79                 _ => {}
     80             }
     81         }
     82     }
     83 }
     84 
     85 fn try_process_event(
     86     damus: &mut Damus,
     87     app_ctx: &mut AppContext<'_>,
     88     ctx: &egui::Context,
     89 ) -> Result<()> {
     90     let current_columns = get_active_columns_mut(app_ctx.accounts, &mut damus.decks_cache);
     91     ctx.input(|i| handle_key_events(i, current_columns));
     92 
     93     let ctx2 = ctx.clone();
     94     let wakeup = move || {
     95         ctx2.request_repaint();
     96     };
     97 
     98     app_ctx.pool.keepalive_ping(wakeup);
     99 
    100     // NOTE: we don't use the while let loop due to borrow issues
    101     #[allow(clippy::while_let_loop)]
    102     loop {
    103         let ev = if let Some(ev) = app_ctx.pool.try_recv() {
    104             ev.into_owned()
    105         } else {
    106             break;
    107         };
    108 
    109         match (&ev.event).into() {
    110             RelayEvent::Opened => {
    111                 app_ctx
    112                     .accounts
    113                     .send_initial_filters(app_ctx.pool, &ev.relay);
    114 
    115                 timeline::send_initial_timeline_filters(
    116                     app_ctx.ndb,
    117                     damus.since_optimize,
    118                     get_active_columns_mut(app_ctx.accounts, &mut damus.decks_cache),
    119                     &mut damus.subscriptions,
    120                     app_ctx.pool,
    121                     &ev.relay,
    122                 );
    123             }
    124             // TODO: handle reconnects
    125             RelayEvent::Closed => warn!("{} connection closed", &ev.relay),
    126             RelayEvent::Error(e) => error!("{}: {}", &ev.relay, e),
    127             RelayEvent::Other(msg) => trace!("other event {:?}", &msg),
    128             RelayEvent::Message(msg) => process_message(damus, app_ctx, &ev.relay, &msg),
    129         }
    130     }
    131 
    132     let current_columns = get_active_columns_mut(app_ctx.accounts, &mut damus.decks_cache);
    133     let n_timelines = current_columns.timelines().len();
    134     for timeline_ind in 0..n_timelines {
    135         let is_ready = {
    136             let timeline = &mut current_columns.timelines[timeline_ind];
    137             timeline::is_timeline_ready(
    138                 app_ctx.ndb,
    139                 app_ctx.pool,
    140                 app_ctx.note_cache,
    141                 timeline,
    142                 &app_ctx.accounts.mutefun(),
    143                 app_ctx
    144                     .accounts
    145                     .get_selected_account()
    146                     .as_ref()
    147                     .map(|sa| &sa.pubkey),
    148             )
    149         };
    150 
    151         if is_ready {
    152             let txn = Transaction::new(app_ctx.ndb).expect("txn");
    153 
    154             if let Err(err) = Timeline::poll_notes_into_view(
    155                 timeline_ind,
    156                 current_columns.timelines_mut(),
    157                 app_ctx.ndb,
    158                 &txn,
    159                 app_ctx.unknown_ids,
    160                 app_ctx.note_cache,
    161                 &app_ctx.accounts.mutefun(),
    162             ) {
    163                 error!("poll_notes_into_view: {err}");
    164             }
    165         } else {
    166             // TODO: show loading?
    167         }
    168     }
    169 
    170     if app_ctx.unknown_ids.ready_to_send() {
    171         unknown_id_send(app_ctx.unknown_ids, app_ctx.pool);
    172     }
    173 
    174     Ok(())
    175 }
    176 
    177 fn unknown_id_send(unknown_ids: &mut UnknownIds, pool: &mut RelayPool) {
    178     let filter = unknown_ids.filter().expect("filter");
    179     info!(
    180         "Getting {} unknown ids from relays",
    181         unknown_ids.ids().len()
    182     );
    183     let msg = ClientMessage::req("unknownids".to_string(), filter);
    184     unknown_ids.clear();
    185     pool.send(&msg);
    186 }
    187 
    188 #[cfg(feature = "profiling")]
    189 fn setup_profiling() {
    190     puffin::set_scopes_on(true); // tell puffin to collect data
    191 }
    192 
    193 fn update_damus(damus: &mut Damus, app_ctx: &mut AppContext<'_>) {
    194     let _ctx = app_ctx.egui.clone();
    195     let ctx = &_ctx;
    196 
    197     app_ctx.accounts.update(app_ctx.ndb, app_ctx.pool, ctx); // update user relay and mute lists
    198 
    199     match damus.state {
    200         DamusState::Initializing => {
    201             #[cfg(feature = "profiling")]
    202             setup_profiling();
    203 
    204             damus.state = DamusState::Initialized;
    205             // this lets our eose handler know to close unknownids right away
    206             damus
    207                 .subscriptions()
    208                 .insert("unknownids".to_string(), SubKind::OneShot);
    209             if let Err(err) = timeline::setup_initial_nostrdb_subs(
    210                 app_ctx.ndb,
    211                 app_ctx.note_cache,
    212                 &mut damus.decks_cache,
    213                 &app_ctx.accounts.mutefun(),
    214             ) {
    215                 warn!("update_damus init: {err}");
    216             }
    217         }
    218 
    219         DamusState::Initialized => (),
    220     };
    221 
    222     if let Err(err) = try_process_event(damus, app_ctx, ctx) {
    223         error!("error processing event: {}", err);
    224     }
    225 }
    226 
    227 fn process_event(ndb: &Ndb, _subid: &str, event: &str) {
    228     #[cfg(feature = "profiling")]
    229     puffin::profile_function!();
    230 
    231     //info!("processing event {}", event);
    232     if let Err(_err) = ndb.process_event(event) {
    233         error!("error processing event {}", event);
    234     }
    235 }
    236 
    237 fn handle_eose(
    238     damus: &mut Damus,
    239     ctx: &mut AppContext<'_>,
    240     subid: &str,
    241     relay_url: &str,
    242 ) -> Result<()> {
    243     let sub_kind = if let Some(sub_kind) = damus.subscriptions().get(subid) {
    244         sub_kind
    245     } else {
    246         let n_subids = damus.subscriptions().len();
    247         warn!(
    248             "got unknown eose subid {}, {} tracked subscriptions",
    249             subid, n_subids
    250         );
    251         return Ok(());
    252     };
    253 
    254     match *sub_kind {
    255         SubKind::Timeline(_) => {
    256             // eose on timeline? whatevs
    257         }
    258         SubKind::Initial => {
    259             let txn = Transaction::new(ctx.ndb)?;
    260             unknowns::update_from_columns(
    261                 &txn,
    262                 ctx.unknown_ids,
    263                 get_active_columns(ctx.accounts, &damus.decks_cache),
    264                 ctx.ndb,
    265                 ctx.note_cache,
    266             );
    267             // this is possible if this is the first time
    268             if ctx.unknown_ids.ready_to_send() {
    269                 unknown_id_send(ctx.unknown_ids, ctx.pool);
    270             }
    271         }
    272 
    273         // oneshot subs just close when they're done
    274         SubKind::OneShot => {
    275             let msg = ClientMessage::close(subid.to_string());
    276             ctx.pool.send_to(&msg, relay_url);
    277         }
    278 
    279         SubKind::FetchingContactList(timeline_uid) => {
    280             let timeline = if let Some(tl) =
    281                 get_active_columns_mut(ctx.accounts, &mut damus.decks_cache)
    282                     .find_timeline_mut(timeline_uid)
    283             {
    284                 tl
    285             } else {
    286                 error!(
    287                     "timeline uid:{} not found for FetchingContactList",
    288                     timeline_uid
    289                 );
    290                 return Ok(());
    291             };
    292 
    293             let filter_state = timeline.filter.get(relay_url);
    294 
    295             // If this request was fetching a contact list, our filter
    296             // state should be "FetchingRemote". We look at the local
    297             // subscription for that filter state and get the subscription id
    298             let local_sub = if let FilterState::FetchingRemote(unisub) = filter_state {
    299                 unisub.local
    300             } else {
    301                 // TODO: we could have multiple contact list results, we need
    302                 // to check to see if this one is newer and use that instead
    303                 warn!(
    304                     "Expected timeline to have FetchingRemote state but was {:?}",
    305                     timeline.filter
    306                 );
    307                 return Ok(());
    308             };
    309 
    310             info!(
    311                 "got contact list from {}, updating filter_state to got_remote",
    312                 relay_url
    313             );
    314 
    315             // We take the subscription id and pass it to the new state of
    316             // "GotRemote". This will let future frames know that it can try
    317             // to look for the contact list in nostrdb.
    318             timeline
    319                 .filter
    320                 .set_relay_state(relay_url.to_string(), FilterState::got_remote(local_sub));
    321         }
    322     }
    323 
    324     Ok(())
    325 }
    326 
    327 fn process_message(damus: &mut Damus, ctx: &mut AppContext<'_>, relay: &str, msg: &RelayMessage) {
    328     match msg {
    329         RelayMessage::Event(subid, ev) => process_event(ctx.ndb, subid, ev),
    330         RelayMessage::Notice(msg) => warn!("Notice from {}: {}", relay, msg),
    331         RelayMessage::OK(cr) => info!("OK {:?}", cr),
    332         RelayMessage::Eose(sid) => {
    333             if let Err(err) = handle_eose(damus, ctx, sid, relay) {
    334                 error!("error handling eose: {}", err);
    335             }
    336         }
    337     }
    338 }
    339 
    340 fn render_damus(damus: &mut Damus, app_ctx: &mut AppContext<'_>) {
    341     if notedeck::ui::is_narrow(app_ctx.egui) {
    342         render_damus_mobile(damus, app_ctx);
    343     } else {
    344         render_damus_desktop(damus, app_ctx);
    345     }
    346 
    347     // We use this for keeping timestamps and things up to date
    348     app_ctx.egui.request_repaint_after(Duration::from_secs(1));
    349 
    350     #[cfg(feature = "profiling")]
    351     puffin_egui::profiler_window(ctx);
    352 }
    353 
    354 /*
    355 fn determine_key_storage_type() -> KeyStorageType {
    356     #[cfg(target_os = "macos")]
    357     {
    358         KeyStorageType::MacOS
    359     }
    360 
    361     #[cfg(target_os = "linux")]
    362     {
    363         KeyStorageType::Linux
    364     }
    365 
    366     #[cfg(not(any(target_os = "macos", target_os = "linux")))]
    367     {
    368         KeyStorageType::None
    369     }
    370 }
    371 */
    372 
    373 impl Damus {
    374     /// Called once before the first frame.
    375     pub fn new(ctx: &mut AppContext<'_>, args: &[String]) -> Self {
    376         // arg parsing
    377 
    378         let parsed_args = ColumnsArgs::parse(args);
    379         let account = ctx
    380             .accounts
    381             .get_selected_account()
    382             .as_ref()
    383             .map(|a| a.pubkey.bytes());
    384 
    385         let decks_cache = if !parsed_args.columns.is_empty() {
    386             info!("DecksCache: loading from command line arguments");
    387             let mut columns: Columns = Columns::new();
    388             for col in parsed_args.columns {
    389                 if let Some(timeline) = col.into_timeline(ctx.ndb, account) {
    390                     columns.add_new_timeline_column(timeline);
    391                 }
    392             }
    393 
    394             columns_to_decks_cache(columns, account)
    395         } else if let Some(decks_cache) = crate::storage::load_decks_cache(ctx.path, ctx.ndb) {
    396             info!(
    397                 "DecksCache: loading from disk {}",
    398                 crate::storage::DECKS_CACHE_FILE
    399             );
    400             decks_cache
    401         } else if let Some(cols) = storage::deserialize_columns(ctx.path, ctx.ndb, account) {
    402             info!(
    403                 "DecksCache: loading from disk at depreciated location {}",
    404                 crate::storage::COLUMNS_FILE
    405             );
    406             columns_to_decks_cache(cols, account)
    407         } else {
    408             info!("DecksCache: creating new with demo configuration");
    409             let mut cache = DecksCache::new_with_demo_config(ctx.ndb);
    410             for account in ctx.accounts.get_accounts() {
    411                 cache.add_deck_default(account.pubkey);
    412             }
    413             set_demo(&mut cache, ctx.ndb, ctx.accounts, ctx.unknown_ids);
    414 
    415             cache
    416         };
    417 
    418         let debug = ctx.args.debug;
    419         let support = Support::new(ctx.path);
    420 
    421         Self {
    422             subscriptions: Subscriptions::default(),
    423             since_optimize: parsed_args.since_optimize,
    424             threads: NotesHolderStorage::default(),
    425             profiles: NotesHolderStorage::default(),
    426             drafts: Drafts::default(),
    427             state: DamusState::Initializing,
    428             textmode: parsed_args.textmode,
    429             //frame_history: FrameHistory::default(),
    430             view_state: ViewState::default(),
    431             support,
    432             decks_cache,
    433             debug,
    434         }
    435     }
    436 
    437     pub fn columns_mut(&mut self, accounts: &Accounts) -> &mut Columns {
    438         get_active_columns_mut(accounts, &mut self.decks_cache)
    439     }
    440 
    441     pub fn columns(&self, accounts: &Accounts) -> &Columns {
    442         get_active_columns(accounts, &self.decks_cache)
    443     }
    444 
    445     pub fn gen_subid(&self, kind: &SubKind) -> String {
    446         if self.debug {
    447             format!("{:?}", kind)
    448         } else {
    449             Uuid::new_v4().to_string()
    450         }
    451     }
    452 
    453     pub fn mock<P: AsRef<Path>>(data_path: P) -> Self {
    454         let decks_cache = DecksCache::default();
    455 
    456         let path = DataPath::new(&data_path);
    457         let imgcache_dir = path.path(DataPathType::Cache).join(ImageCache::rel_dir());
    458         let _ = std::fs::create_dir_all(imgcache_dir.clone());
    459         let debug = true;
    460 
    461         let support = Support::new(&path);
    462 
    463         Self {
    464             debug,
    465             subscriptions: Subscriptions::default(),
    466             since_optimize: true,
    467             threads: NotesHolderStorage::default(),
    468             profiles: NotesHolderStorage::default(),
    469             drafts: Drafts::default(),
    470             state: DamusState::Initializing,
    471             textmode: false,
    472             //frame_history: FrameHistory::default(),
    473             view_state: ViewState::default(),
    474             support,
    475             decks_cache,
    476         }
    477     }
    478 
    479     pub fn subscriptions(&mut self) -> &mut HashMap<String, SubKind> {
    480         &mut self.subscriptions.subs
    481     }
    482 
    483     pub fn threads(&self) -> &NotesHolderStorage<Thread> {
    484         &self.threads
    485     }
    486 
    487     pub fn threads_mut(&mut self) -> &mut NotesHolderStorage<Thread> {
    488         &mut self.threads
    489     }
    490 }
    491 
    492 /*
    493 fn circle_icon(ui: &mut egui::Ui, openness: f32, response: &egui::Response) {
    494     let stroke = ui.style().interact(&response).fg_stroke;
    495     let radius = egui::lerp(2.0..=3.0, openness);
    496     ui.painter()
    497         .circle_filled(response.rect.center(), radius, stroke.color);
    498 }
    499 */
    500 
    501 fn render_damus_mobile(app: &mut Damus, app_ctx: &mut AppContext<'_>) {
    502     let _ctx = app_ctx.egui.clone();
    503     let ctx = &_ctx;
    504 
    505     #[cfg(feature = "profiling")]
    506     puffin::profile_function!();
    507 
    508     //let routes = app.timelines[0].routes.clone();
    509 
    510     main_panel(&ctx.style(), notedeck::ui::is_narrow(ctx)).show(ctx, |ui| {
    511         if !app.columns(app_ctx.accounts).columns().is_empty()
    512             && nav::render_nav(0, app, app_ctx, ui).process_render_nav_response(app, app_ctx)
    513         {
    514             storage::save_decks_cache(app_ctx.path, &app.decks_cache);
    515         }
    516     });
    517 }
    518 
    519 fn margin_top(narrow: bool) -> f32 {
    520     #[cfg(target_os = "android")]
    521     {
    522         // FIXME - query the system bar height and adjust more precisely
    523         let _ = narrow; // suppress compiler warning on android
    524         40.0
    525     }
    526     #[cfg(not(target_os = "android"))]
    527     {
    528         if narrow {
    529             50.0
    530         } else {
    531             0.0
    532         }
    533     }
    534 }
    535 
    536 fn main_panel(style: &Style, narrow: bool) -> egui::CentralPanel {
    537     let inner_margin = egui::Margin {
    538         top: margin_top(narrow),
    539         left: 0.0,
    540         right: 0.0,
    541         bottom: 0.0,
    542     };
    543     egui::CentralPanel::default().frame(Frame {
    544         inner_margin,
    545         fill: style.visuals.panel_fill,
    546         ..Default::default()
    547     })
    548 }
    549 
    550 fn render_damus_desktop(app: &mut Damus, app_ctx: &mut AppContext<'_>) {
    551     let _ctx = app_ctx.egui.clone();
    552     let ctx = &_ctx;
    553 
    554     #[cfg(feature = "profiling")]
    555     puffin::profile_function!();
    556 
    557     let screen_size = ctx.screen_rect().width();
    558     let calc_panel_width = (screen_size
    559         / get_active_columns(app_ctx.accounts, &app.decks_cache).num_columns() as f32)
    560         - 30.0;
    561     let min_width = 320.0;
    562     let need_scroll = calc_panel_width < min_width;
    563     let panel_sizes = if need_scroll {
    564         Size::exact(min_width)
    565     } else {
    566         Size::remainder()
    567     };
    568 
    569     main_panel(&ctx.style(), notedeck::ui::is_narrow(ctx)).show(ctx, |ui| {
    570         ui.spacing_mut().item_spacing.x = 0.0;
    571         if need_scroll {
    572             egui::ScrollArea::horizontal().show(ui, |ui| {
    573                 timelines_view(ui, panel_sizes, app, app_ctx);
    574             });
    575         } else {
    576             timelines_view(ui, panel_sizes, app, app_ctx);
    577         }
    578     });
    579 }
    580 
    581 fn timelines_view(ui: &mut egui::Ui, sizes: Size, app: &mut Damus, ctx: &mut AppContext<'_>) {
    582     StripBuilder::new(ui)
    583         .size(Size::exact(ui::side_panel::SIDE_PANEL_WIDTH))
    584         .sizes(
    585             sizes,
    586             get_active_columns(ctx.accounts, &app.decks_cache).num_columns(),
    587         )
    588         .clip(true)
    589         .horizontal(|mut strip| {
    590             let mut side_panel_action: Option<nav::SwitchingAction> = None;
    591             strip.cell(|ui| {
    592                 let rect = ui.available_rect_before_wrap();
    593                 let side_panel = DesktopSidePanel::new(
    594                     ctx.ndb,
    595                     ctx.img_cache,
    596                     ctx.accounts.get_selected_account(),
    597                     &app.decks_cache,
    598                 )
    599                 .show(ui);
    600 
    601                 if side_panel.response.clicked() || side_panel.response.secondary_clicked() {
    602                     if let Some(action) = DesktopSidePanel::perform_action(
    603                         &mut app.decks_cache,
    604                         ctx.accounts,
    605                         &mut app.support,
    606                         ctx.theme,
    607                         side_panel.action,
    608                     ) {
    609                         side_panel_action = Some(action);
    610                     }
    611                 }
    612 
    613                 // vertical sidebar line
    614                 ui.painter().vline(
    615                     rect.right(),
    616                     rect.y_range(),
    617                     ui.visuals().widgets.noninteractive.bg_stroke,
    618                 );
    619             });
    620 
    621             let mut save_cols = false;
    622             if let Some(action) = side_panel_action {
    623                 save_cols = save_cols || action.process(app, ctx);
    624             }
    625 
    626             let num_cols = app.columns(ctx.accounts).num_columns();
    627             let mut responses = Vec::with_capacity(num_cols);
    628             for col_index in 0..num_cols {
    629                 strip.cell(|ui| {
    630                     let rect = ui.available_rect_before_wrap();
    631                     responses.push(nav::render_nav(col_index, app, ctx, ui));
    632 
    633                     // vertical line
    634                     ui.painter().vline(
    635                         rect.right(),
    636                         rect.y_range(),
    637                         ui.visuals().widgets.noninteractive.bg_stroke,
    638                     );
    639                 });
    640 
    641                 //strip.cell(|ui| timeline::timeline_view(ui, app, timeline_ind));
    642             }
    643 
    644             for response in responses {
    645                 let save = response.process_render_nav_response(app, ctx);
    646                 save_cols = save_cols || save;
    647             }
    648 
    649             if save_cols {
    650                 storage::save_decks_cache(ctx.path, &app.decks_cache);
    651             }
    652         });
    653 }
    654 
    655 impl notedeck::App for Damus {
    656     fn update(&mut self, ctx: &mut AppContext<'_>) {
    657         /*
    658         self.app
    659             .frame_history
    660             .on_new_frame(ctx.input(|i| i.time), frame.info().cpu_usage);
    661         */
    662 
    663         #[cfg(feature = "profiling")]
    664         puffin::GlobalProfiler::lock().new_frame();
    665         update_damus(self, ctx);
    666         render_damus(self, ctx);
    667     }
    668 }
    669 
    670 pub fn get_active_columns<'a>(accounts: &Accounts, decks_cache: &'a DecksCache) -> &'a Columns {
    671     get_decks(accounts, decks_cache).active().columns()
    672 }
    673 
    674 pub fn get_decks<'a>(accounts: &Accounts, decks_cache: &'a DecksCache) -> &'a Decks {
    675     let key = if let Some(acc) = accounts.get_selected_account() {
    676         &acc.pubkey
    677     } else {
    678         decks_cache.get_fallback_pubkey()
    679     };
    680     decks_cache.decks(key)
    681 }
    682 
    683 pub fn get_active_columns_mut<'a>(
    684     accounts: &Accounts,
    685     decks_cache: &'a mut DecksCache,
    686 ) -> &'a mut Columns {
    687     get_decks_mut(accounts, decks_cache)
    688         .active_mut()
    689         .columns_mut()
    690 }
    691 
    692 pub fn get_decks_mut<'a>(accounts: &Accounts, decks_cache: &'a mut DecksCache) -> &'a mut Decks {
    693     if let Some(acc) = accounts.get_selected_account() {
    694         decks_cache.decks_mut(&acc.pubkey)
    695     } else {
    696         decks_cache.fallback_mut()
    697     }
    698 }
    699 
    700 pub fn set_demo(
    701     decks_cache: &mut DecksCache,
    702     ndb: &Ndb,
    703     accounts: &mut Accounts,
    704     unk_ids: &mut UnknownIds,
    705 ) {
    706     let txn = Transaction::new(ndb).expect("txn");
    707     accounts
    708         .add_account(Keypair::only_pubkey(*decks_cache.get_fallback_pubkey()))
    709         .process_action(unk_ids, ndb, &txn);
    710     accounts.select_account(accounts.num_accounts() - 1);
    711 }
    712 
    713 fn columns_to_decks_cache(cols: Columns, key: Option<&[u8; 32]>) -> DecksCache {
    714     let mut account_to_decks: HashMap<Pubkey, Decks> = Default::default();
    715     let decks = Decks::new(crate::decks::Deck::new_with_columns(
    716         crate::decks::Deck::default().icon,
    717         "My Deck".to_owned(),
    718         cols,
    719     ));
    720 
    721     let account = if let Some(key) = key {
    722         Pubkey::new(*key)
    723     } else {
    724         FALLBACK_PUBKEY()
    725     };
    726     account_to_decks.insert(account, decks);
    727     DecksCache::new(account_to_decks)
    728 }