notedeck

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

app.rs (22571B)


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