notedeck

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

app.rs (30263B)


      1 use crate::{
      2     args::{ColumnsArgs, ColumnsFlag},
      3     column::Columns,
      4     decks::{Decks, DecksCache},
      5     draft::Drafts,
      6     nav::{self, ProcessNavResult},
      7     options::AppOptions,
      8     route::Route,
      9     storage,
     10     subscriptions::{SubKind, Subscriptions},
     11     support::Support,
     12     timeline::{self, kind::ListKind, thread::Threads, TimelineCache, TimelineKind},
     13     ui::{self, DesktopSidePanel, ShowSourceClientOption, SidePanelAction},
     14     view_state::ViewState,
     15     Result,
     16 };
     17 use egui_extras::{Size, StripBuilder};
     18 use enostr::{ClientMessage, PoolRelay, Pubkey, RelayEvent, RelayMessage, RelayPool};
     19 use nostrdb::Transaction;
     20 use notedeck::{
     21     tr, ui::is_narrow, Accounts, AppAction, AppContext, DataPath, DataPathType, FilterState,
     22     Images, JobsCache, Localization, NotedeckOptions, SettingsHandler, UnknownIds,
     23 };
     24 use notedeck_ui::{
     25     media::{MediaViewer, MediaViewerFlags, MediaViewerState},
     26     NoteOptions,
     27 };
     28 use std::collections::{BTreeSet, HashMap};
     29 use std::path::Path;
     30 use std::time::Duration;
     31 use tracing::{debug, error, info, trace, warn};
     32 use uuid::Uuid;
     33 
     34 #[derive(Debug, Eq, PartialEq, Clone)]
     35 pub enum DamusState {
     36     Initializing,
     37     Initialized,
     38 }
     39 
     40 /// We derive Deserialize/Serialize so we can persist app state on shutdown.
     41 pub struct Damus {
     42     state: DamusState,
     43 
     44     pub decks_cache: DecksCache,
     45     pub view_state: ViewState,
     46     pub drafts: Drafts,
     47     pub timeline_cache: TimelineCache,
     48     pub subscriptions: Subscriptions,
     49     pub support: Support,
     50     pub jobs: JobsCache,
     51     pub threads: Threads,
     52 
     53     //frame_history: crate::frame_history::FrameHistory,
     54 
     55     // TODO: make these bitflags
     56     /// Were columns loaded from the commandline? If so disable persistence.
     57     pub options: AppOptions,
     58     pub note_options: NoteOptions,
     59 
     60     pub unrecognized_args: BTreeSet<String>,
     61 }
     62 
     63 fn handle_key_events(input: &egui::InputState, columns: &mut Columns) {
     64     for event in &input.raw.events {
     65         if let egui::Event::Key {
     66             key, pressed: true, ..
     67         } = event
     68         {
     69             match key {
     70                 egui::Key::J => {
     71                     columns.select_down();
     72                 }
     73                 egui::Key::K => {
     74                     columns.select_up();
     75                 }
     76                 egui::Key::H => {
     77                     columns.select_left();
     78                 }
     79                 egui::Key::L => {
     80                     columns.select_left();
     81                 }
     82                 egui::Key::BrowserBack | egui::Key::Escape => {
     83                     columns.get_selected_router().go_back();
     84                 }
     85                 _ => {}
     86             }
     87         }
     88     }
     89 }
     90 
     91 fn try_process_event(
     92     damus: &mut Damus,
     93     app_ctx: &mut AppContext<'_>,
     94     ctx: &egui::Context,
     95 ) -> Result<()> {
     96     let current_columns =
     97         get_active_columns_mut(app_ctx.i18n, app_ctx.accounts, &mut damus.decks_cache);
     98     ctx.input(|i| handle_key_events(i, current_columns));
     99 
    100     let ctx2 = ctx.clone();
    101     let wakeup = move || {
    102         ctx2.request_repaint();
    103     };
    104 
    105     app_ctx.pool.keepalive_ping(wakeup);
    106 
    107     // NOTE: we don't use the while let loop due to borrow issues
    108     #[allow(clippy::while_let_loop)]
    109     loop {
    110         let ev = if let Some(ev) = app_ctx.pool.try_recv() {
    111             ev.into_owned()
    112         } else {
    113             break;
    114         };
    115 
    116         match (&ev.event).into() {
    117             RelayEvent::Opened => {
    118                 app_ctx
    119                     .accounts
    120                     .send_initial_filters(app_ctx.pool, &ev.relay);
    121 
    122                 timeline::send_initial_timeline_filters(
    123                     damus.options.contains(AppOptions::SinceOptimize),
    124                     &mut damus.timeline_cache,
    125                     &mut damus.subscriptions,
    126                     app_ctx.pool,
    127                     &ev.relay,
    128                     app_ctx.accounts,
    129                 );
    130             }
    131             // TODO: handle reconnects
    132             RelayEvent::Closed => warn!("{} connection closed", &ev.relay),
    133             RelayEvent::Error(e) => error!("{}: {}", &ev.relay, e),
    134             RelayEvent::Other(msg) => trace!("other event {:?}", &msg),
    135             RelayEvent::Message(msg) => {
    136                 process_message(damus, app_ctx, &ev.relay, &msg);
    137             }
    138         }
    139     }
    140 
    141     for (_kind, timeline) in &mut damus.timeline_cache {
    142         let is_ready = timeline::is_timeline_ready(
    143             app_ctx.ndb,
    144             app_ctx.pool,
    145             app_ctx.note_cache,
    146             timeline,
    147             app_ctx.accounts,
    148         );
    149 
    150         if is_ready {
    151             let txn = Transaction::new(app_ctx.ndb).expect("txn");
    152             // only thread timelines are reversed
    153             let reversed = false;
    154 
    155             if let Err(err) = timeline.poll_notes_into_view(
    156                 app_ctx.ndb,
    157                 &txn,
    158                 app_ctx.unknown_ids,
    159                 app_ctx.note_cache,
    160                 reversed,
    161             ) {
    162                 error!("poll_notes_into_view: {err}");
    163             }
    164         } else {
    165             // TODO: show loading?
    166         }
    167     }
    168 
    169     if app_ctx.unknown_ids.ready_to_send() {
    170         unknown_id_send(app_ctx.unknown_ids, app_ctx.pool);
    171     }
    172 
    173     Ok(())
    174 }
    175 
    176 fn unknown_id_send(unknown_ids: &mut UnknownIds, pool: &mut RelayPool) {
    177     debug!("unknown_id_send called on: {:?}", &unknown_ids);
    178     let filter = unknown_ids.filter().expect("filter");
    179     debug!(
    180         "Getting {} unknown ids from relays",
    181         unknown_ids.ids_iter().len()
    182     );
    183     let msg = ClientMessage::req("unknownids".to_string(), filter);
    184     unknown_ids.clear();
    185     pool.send(&msg);
    186 }
    187 
    188 fn update_damus(damus: &mut Damus, app_ctx: &mut AppContext<'_>, ctx: &egui::Context) {
    189     app_ctx.img_cache.urls.cache.handle_io();
    190 
    191     if damus.columns(app_ctx.accounts).columns().is_empty() {
    192         damus
    193             .columns_mut(app_ctx.i18n, app_ctx.accounts)
    194             .new_column_picker();
    195     }
    196 
    197     match damus.state {
    198         DamusState::Initializing => {
    199             damus.state = DamusState::Initialized;
    200             // this lets our eose handler know to close unknownids right away
    201             damus
    202                 .subscriptions()
    203                 .insert("unknownids".to_string(), SubKind::OneShot);
    204             if let Err(err) = timeline::setup_initial_nostrdb_subs(
    205                 app_ctx.ndb,
    206                 app_ctx.note_cache,
    207                 &mut damus.timeline_cache,
    208             ) {
    209                 warn!("update_damus init: {err}");
    210             }
    211         }
    212 
    213         DamusState::Initialized => (),
    214     };
    215 
    216     if let Err(err) = try_process_event(damus, app_ctx, ctx) {
    217         error!("error processing event: {}", err);
    218     }
    219 }
    220 
    221 fn handle_eose(
    222     subscriptions: &Subscriptions,
    223     timeline_cache: &mut TimelineCache,
    224     ctx: &mut AppContext<'_>,
    225     subid: &str,
    226     relay_url: &str,
    227 ) -> Result<()> {
    228     let sub_kind = if let Some(sub_kind) = subscriptions.subs.get(subid) {
    229         sub_kind
    230     } else {
    231         let n_subids = subscriptions.subs.len();
    232         warn!(
    233             "got unknown eose subid {}, {} tracked subscriptions",
    234             subid, n_subids
    235         );
    236         return Ok(());
    237     };
    238 
    239     match sub_kind {
    240         SubKind::Timeline(_) => {
    241             // eose on timeline? whatevs
    242         }
    243         SubKind::Initial => {
    244             //let txn = Transaction::new(ctx.ndb)?;
    245             //unknowns::update_from_columns(
    246             //    &txn,
    247             //    ctx.unknown_ids,
    248             //    timeline_cache,
    249             //    ctx.ndb,
    250             //    ctx.note_cache,
    251             //);
    252             //// this is possible if this is the first time
    253             //if ctx.unknown_ids.ready_to_send() {
    254             //    unknown_id_send(ctx.unknown_ids, ctx.pool);
    255             //}
    256         }
    257 
    258         // oneshot subs just close when they're done
    259         SubKind::OneShot => {
    260             let msg = ClientMessage::close(subid.to_string());
    261             ctx.pool.send_to(&msg, relay_url);
    262         }
    263 
    264         SubKind::FetchingContactList(timeline_uid) => {
    265             let timeline = if let Some(tl) = timeline_cache.get_mut(timeline_uid) {
    266                 tl
    267             } else {
    268                 error!(
    269                     "timeline uid:{:?} not found for FetchingContactList",
    270                     timeline_uid
    271                 );
    272                 return Ok(());
    273             };
    274 
    275             let filter_state = timeline.filter.get_mut(relay_url);
    276 
    277             let FilterState::FetchingRemote(fetching_remote_type) = filter_state else {
    278                 // TODO: we could have multiple contact list results, we need
    279                 // to check to see if this one is newer and use that instead
    280                 warn!(
    281                     "Expected timeline to have FetchingRemote state but was {:?}",
    282                     timeline.filter
    283                 );
    284                 return Ok(());
    285             };
    286 
    287             let new_filter_state = match fetching_remote_type {
    288                 notedeck::filter::FetchingRemoteType::Normal(unified_subscription) => {
    289                     FilterState::got_remote(unified_subscription.local)
    290                 }
    291                 notedeck::filter::FetchingRemoteType::Contact => {
    292                     FilterState::GotRemote(notedeck::filter::GotRemoteType::Contact)
    293                 }
    294             };
    295 
    296             // We take the subscription id and pass it to the new state of
    297             // "GotRemote". This will let future frames know that it can try
    298             // to look for the contact list in nostrdb.
    299             timeline
    300                 .filter
    301                 .set_relay_state(relay_url.to_string(), new_filter_state);
    302         }
    303     }
    304 
    305     Ok(())
    306 }
    307 
    308 fn process_message(damus: &mut Damus, ctx: &mut AppContext<'_>, relay: &str, msg: &RelayMessage) {
    309     match msg {
    310         RelayMessage::Event(_subid, ev) => {
    311             let relay = if let Some(relay) = ctx.pool.relays.iter().find(|r| r.url() == relay) {
    312                 relay
    313             } else {
    314                 error!("couldn't find relay {} for note processing!?", relay);
    315                 return;
    316             };
    317 
    318             match relay {
    319                 PoolRelay::Websocket(_) => {
    320                     //info!("processing event {}", event);
    321                     if let Err(err) = ctx.ndb.process_event_with(
    322                         ev,
    323                         nostrdb::IngestMetadata::new()
    324                             .client(false)
    325                             .relay(relay.url()),
    326                     ) {
    327                         error!("error processing event {ev}: {err}");
    328                     }
    329                 }
    330                 PoolRelay::Multicast(_) => {
    331                     // multicast events are client events
    332                     if let Err(err) = ctx.ndb.process_event_with(
    333                         ev,
    334                         nostrdb::IngestMetadata::new()
    335                             .client(true)
    336                             .relay(relay.url()),
    337                     ) {
    338                         error!("error processing multicast event {ev}: {err}");
    339                     }
    340                 }
    341             }
    342         }
    343         RelayMessage::Notice(msg) => warn!("Notice from {}: {}", relay, msg),
    344         RelayMessage::OK(cr) => info!("OK {:?}", cr),
    345         RelayMessage::Eose(sid) => {
    346             if let Err(err) = handle_eose(
    347                 &damus.subscriptions,
    348                 &mut damus.timeline_cache,
    349                 ctx,
    350                 sid,
    351                 relay,
    352             ) {
    353                 error!("error handling eose: {}", err);
    354             }
    355         }
    356     }
    357 }
    358 
    359 fn render_damus(
    360     damus: &mut Damus,
    361     app_ctx: &mut AppContext<'_>,
    362     ui: &mut egui::Ui,
    363 ) -> Option<AppAction> {
    364     damus
    365         .note_options
    366         .set(NoteOptions::Wide, is_narrow(ui.ctx()));
    367 
    368     let app_action = if notedeck::ui::is_narrow(ui.ctx()) {
    369         render_damus_mobile(damus, app_ctx, ui)
    370     } else {
    371         render_damus_desktop(damus, app_ctx, ui)
    372     };
    373 
    374     fullscreen_media_viewer_ui(ui, &mut damus.view_state.media_viewer, app_ctx.img_cache);
    375 
    376     // We use this for keeping timestamps and things up to date
    377     ui.ctx().request_repaint_after(Duration::from_secs(5));
    378 
    379     app_action
    380 }
    381 
    382 /// Present a fullscreen media viewer if the FullscreenMedia AppOptions flag is set. This is
    383 /// typically set by image carousels using a MediaAction's on_view_media callback when
    384 /// an image is clicked
    385 fn fullscreen_media_viewer_ui(
    386     ui: &mut egui::Ui,
    387     state: &mut MediaViewerState,
    388     img_cache: &mut Images,
    389 ) {
    390     if !state.should_show(ui) {
    391         if state.scene_rect.is_some() {
    392             // if we shouldn't show yet we will have a scene
    393             // rect, then we should clear it for next time
    394             tracing::debug!("fullscreen_media_viewer_ui: resetting scene rect");
    395             state.scene_rect = None;
    396         }
    397         return;
    398     }
    399 
    400     let resp = MediaViewer::new(state).fullscreen(true).ui(img_cache, ui);
    401 
    402     if resp.clicked() || ui.input(|i| i.key_pressed(egui::Key::Escape)) {
    403         fullscreen_media_close(state);
    404     }
    405 }
    406 
    407 /// Close the fullscreen media player. This also resets the scene_rect state
    408 fn fullscreen_media_close(state: &mut MediaViewerState) {
    409     state.flags.set(MediaViewerFlags::Open, false);
    410 }
    411 
    412 /*
    413 fn determine_key_storage_type() -> KeyStorageType {
    414     #[cfg(target_os = "macos")]
    415     {
    416         KeyStorageType::MacOS
    417     }
    418 
    419     #[cfg(target_os = "linux")]
    420     {
    421         KeyStorageType::Linux
    422     }
    423 
    424     #[cfg(not(any(target_os = "macos", target_os = "linux")))]
    425     {
    426         KeyStorageType::None
    427     }
    428 }
    429 */
    430 
    431 impl Damus {
    432     /// Called once before the first frame.
    433     pub fn new(app_context: &mut AppContext<'_>, args: &[String]) -> Self {
    434         // arg parsing
    435 
    436         let (parsed_args, unrecognized_args) =
    437             ColumnsArgs::parse(args, Some(app_context.accounts.selected_account_pubkey()));
    438 
    439         let account = app_context.accounts.selected_account_pubkey_bytes();
    440 
    441         let mut timeline_cache = TimelineCache::default();
    442         let mut options = AppOptions::default();
    443         let tmp_columns = !parsed_args.columns.is_empty();
    444         options.set(AppOptions::TmpColumns, tmp_columns);
    445         options.set(
    446             AppOptions::Debug,
    447             app_context.args.options.contains(NotedeckOptions::Debug),
    448         );
    449         options.set(
    450             AppOptions::SinceOptimize,
    451             parsed_args.is_flag_set(ColumnsFlag::SinceOptimize),
    452         );
    453 
    454         let decks_cache = if tmp_columns {
    455             info!("DecksCache: loading from command line arguments");
    456             let mut columns: Columns = Columns::new();
    457             let txn = Transaction::new(app_context.ndb).unwrap();
    458             for col in &parsed_args.columns {
    459                 let timeline_kind = col.clone().into_timeline_kind();
    460                 if let Some(add_result) = columns.add_new_timeline_column(
    461                     &mut timeline_cache,
    462                     &txn,
    463                     app_context.ndb,
    464                     app_context.note_cache,
    465                     app_context.pool,
    466                     &timeline_kind,
    467                 ) {
    468                     add_result.process(
    469                         app_context.ndb,
    470                         app_context.note_cache,
    471                         &txn,
    472                         &mut timeline_cache,
    473                         app_context.unknown_ids,
    474                     );
    475                 }
    476             }
    477 
    478             columns_to_decks_cache(app_context.i18n, columns, account)
    479         } else if let Some(decks_cache) = crate::storage::load_decks_cache(
    480             app_context.path,
    481             app_context.ndb,
    482             &mut timeline_cache,
    483             app_context.i18n,
    484         ) {
    485             info!(
    486                 "DecksCache: loading from disk {}",
    487                 crate::storage::DECKS_CACHE_FILE
    488             );
    489             decks_cache
    490         } else {
    491             info!("DecksCache: creating new with demo configuration");
    492             DecksCache::new_with_demo_config(&mut timeline_cache, app_context)
    493             //for (pk, _) in &app_context.accounts.cache {
    494             //    cache.add_deck_default(*pk);
    495             //}
    496         };
    497 
    498         let support = Support::new(app_context.path);
    499         let note_options = get_note_options(parsed_args, app_context.settings);
    500         let jobs = JobsCache::default();
    501         let threads = Threads::default();
    502 
    503         Self {
    504             subscriptions: Subscriptions::default(),
    505             timeline_cache,
    506             drafts: Drafts::default(),
    507             state: DamusState::Initializing,
    508             note_options,
    509             options,
    510             //frame_history: FrameHistory::default(),
    511             view_state: ViewState::default(),
    512             support,
    513             decks_cache,
    514             unrecognized_args,
    515             jobs,
    516             threads,
    517         }
    518     }
    519 
    520     /// Scroll to the top of the currently selected column. This is called
    521     /// by the chrome when you click the toolbar
    522     pub fn scroll_to_top(&mut self) {
    523         self.options.insert(AppOptions::ScrollToTop)
    524     }
    525 
    526     pub fn columns_mut(&mut self, i18n: &mut Localization, accounts: &Accounts) -> &mut Columns {
    527         get_active_columns_mut(i18n, accounts, &mut self.decks_cache)
    528     }
    529 
    530     pub fn columns(&self, accounts: &Accounts) -> &Columns {
    531         get_active_columns(accounts, &self.decks_cache)
    532     }
    533 
    534     pub fn gen_subid(&self, kind: &SubKind) -> String {
    535         if self.options.contains(AppOptions::Debug) {
    536             format!("{kind:?}")
    537         } else {
    538             Uuid::new_v4().to_string()
    539         }
    540     }
    541 
    542     pub fn mock<P: AsRef<Path>>(data_path: P) -> Self {
    543         let mut i18n = Localization::default();
    544         let decks_cache = DecksCache::default_decks_cache(&mut i18n);
    545 
    546         let path = DataPath::new(&data_path);
    547         let imgcache_dir = path.path(DataPathType::Cache);
    548         let _ = std::fs::create_dir_all(imgcache_dir.clone());
    549         let options = AppOptions::default() | AppOptions::Debug | AppOptions::TmpColumns;
    550 
    551         let support = Support::new(&path);
    552 
    553         Self {
    554             subscriptions: Subscriptions::default(),
    555             timeline_cache: TimelineCache::default(),
    556             drafts: Drafts::default(),
    557             state: DamusState::Initializing,
    558             note_options: NoteOptions::default(),
    559             //frame_history: FrameHistory::default(),
    560             view_state: ViewState::default(),
    561             support,
    562             options,
    563             decks_cache,
    564             unrecognized_args: BTreeSet::default(),
    565             jobs: JobsCache::default(),
    566             threads: Threads::default(),
    567         }
    568     }
    569 
    570     pub fn subscriptions(&mut self) -> &mut HashMap<String, SubKind> {
    571         &mut self.subscriptions.subs
    572     }
    573 
    574     pub fn unrecognized_args(&self) -> &BTreeSet<String> {
    575         &self.unrecognized_args
    576     }
    577 }
    578 
    579 fn get_note_options(args: ColumnsArgs, settings_handler: &mut SettingsHandler) -> NoteOptions {
    580     let mut note_options = NoteOptions::default();
    581 
    582     note_options.set(
    583         NoteOptions::Textmode,
    584         args.is_flag_set(ColumnsFlag::Textmode),
    585     );
    586     note_options.set(
    587         NoteOptions::ScrambleText,
    588         args.is_flag_set(ColumnsFlag::Scramble),
    589     );
    590     note_options.set(
    591         NoteOptions::HideMedia,
    592         args.is_flag_set(ColumnsFlag::NoMedia),
    593     );
    594     note_options.set(
    595         NoteOptions::ClientNameTop,
    596         ShowSourceClientOption::Top == settings_handler.show_source_client().into()
    597             || args.is_flag_set(ColumnsFlag::ShowNoteClientTop),
    598     );
    599     note_options.set(
    600         NoteOptions::ClientNameBottom,
    601         ShowSourceClientOption::Bottom == settings_handler.show_source_client().into()
    602             || args.is_flag_set(ColumnsFlag::ShowNoteClientBottom),
    603     );
    604 
    605     note_options.set(
    606         NoteOptions::RepliesNewestFirst,
    607         settings_handler.show_replies_newest_first(),
    608     );
    609     note_options
    610 }
    611 
    612 /*
    613 fn circle_icon(ui: &mut egui::Ui, openness: f32, response: &egui::Response) {
    614     let stroke = ui.style().interact(&response).fg_stroke;
    615     let radius = egui::lerp(2.0..=3.0, openness);
    616     ui.painter()
    617         .circle_filled(response.rect.center(), radius, stroke.color);
    618 }
    619 */
    620 
    621 #[profiling::function]
    622 fn render_damus_mobile(
    623     app: &mut Damus,
    624     app_ctx: &mut AppContext<'_>,
    625     ui: &mut egui::Ui,
    626 ) -> Option<AppAction> {
    627     //let routes = app.timelines[0].routes.clone();
    628 
    629     let rect = ui.available_rect_before_wrap();
    630     let mut app_action: Option<AppAction> = None;
    631 
    632     let active_col = app.columns_mut(app_ctx.i18n, app_ctx.accounts).selected as usize;
    633 
    634     if !app.columns(app_ctx.accounts).columns().is_empty() {
    635         let r = nav::render_nav(
    636             active_col,
    637             ui.available_rect_before_wrap(),
    638             app,
    639             app_ctx,
    640             ui,
    641         )
    642         .process_render_nav_response(app, app_ctx, ui);
    643         if let Some(r) = &r {
    644             match r {
    645                 ProcessNavResult::SwitchOccurred => {
    646                     if !app.options.contains(AppOptions::TmpColumns) {
    647                         storage::save_decks_cache(app_ctx.path, &app.decks_cache);
    648                     }
    649                 }
    650 
    651                 ProcessNavResult::PfpClicked => {
    652                     app_action = Some(AppAction::ToggleChrome);
    653                 }
    654             }
    655         }
    656     }
    657 
    658     hovering_post_button(ui, app, app_ctx, rect);
    659 
    660     app_action
    661 }
    662 
    663 fn hovering_post_button(
    664     ui: &mut egui::Ui,
    665     app: &mut Damus,
    666     app_ctx: &mut AppContext,
    667     mut rect: egui::Rect,
    668 ) {
    669     let should_show_compose = should_show_compose_button(&app.decks_cache, app_ctx.accounts);
    670     let btn_id = ui.id().with("hover_post_btn");
    671     let button_y = ui
    672         .ctx()
    673         .animate_bool_responsive(btn_id, should_show_compose);
    674     rect.min.x = rect.max.x - if is_narrow(ui.ctx()) { 60.0 } else { 100.0 };
    675     rect.min.y = rect.max.y - 100.0 * button_y;
    676 
    677     let darkmode = ui.ctx().style().visuals.dark_mode;
    678 
    679     // only show the compose button on profile pages and on home
    680     let compose_resp = ui
    681         .put(rect, ui::post::compose_note_button(darkmode))
    682         .on_hover_cursor(egui::CursorIcon::PointingHand);
    683     if compose_resp.clicked() && !app.columns(app_ctx.accounts).columns().is_empty() {
    684         // just use the some side panel logic as the desktop
    685         DesktopSidePanel::perform_action(
    686             &mut app.decks_cache,
    687             app_ctx.accounts,
    688             SidePanelAction::ComposeNote,
    689             app_ctx.i18n,
    690         );
    691     }
    692 }
    693 
    694 /// Should we show the compose button? When in threads we should hide it, etc
    695 fn should_show_compose_button(decks: &DecksCache, accounts: &Accounts) -> bool {
    696     let Some(col) = decks.selected_column(accounts) else {
    697         return false;
    698     };
    699 
    700     match col.router().top() {
    701         Route::Timeline(timeline_kind) => {
    702             match timeline_kind {
    703                 TimelineKind::List(list_kind) => match list_kind {
    704                     ListKind::Contact(_pk) => true,
    705                 },
    706 
    707                 TimelineKind::Algo(_pk) => true,
    708                 TimelineKind::Profile(_pk) => true,
    709                 TimelineKind::Universe => true,
    710                 TimelineKind::Generic(_) => true,
    711                 TimelineKind::Hashtag(_) => true,
    712 
    713                 // no!
    714                 TimelineKind::Search(_) => false,
    715                 TimelineKind::Notifications(_) => false,
    716             }
    717         }
    718 
    719         Route::Thread(_) => false,
    720         Route::Accounts(_) => false,
    721         Route::Reply(_) => false,
    722         Route::Quote(_) => false,
    723         Route::Relays => false,
    724         Route::Settings => false,
    725         Route::ComposeNote => false,
    726         Route::AddColumn(_) => false,
    727         Route::EditProfile(_) => false,
    728         Route::Support => false,
    729         Route::NewDeck => false,
    730         Route::Search => false,
    731         Route::EditDeck(_) => false,
    732         Route::Wallet(_) => false,
    733         Route::CustomizeZapAmount(_) => false,
    734     }
    735 }
    736 
    737 #[profiling::function]
    738 fn render_damus_desktop(
    739     app: &mut Damus,
    740     app_ctx: &mut AppContext<'_>,
    741     ui: &mut egui::Ui,
    742 ) -> Option<AppAction> {
    743     let screen_size = ui.ctx().screen_rect().width();
    744     let calc_panel_width = (screen_size
    745         / get_active_columns(app_ctx.accounts, &app.decks_cache).num_columns() as f32)
    746         - 30.0;
    747     let min_width = 320.0;
    748     let need_scroll = calc_panel_width < min_width;
    749     let panel_sizes = if need_scroll {
    750         Size::exact(min_width)
    751     } else {
    752         Size::remainder()
    753     };
    754 
    755     ui.spacing_mut().item_spacing.x = 0.0;
    756 
    757     if need_scroll {
    758         egui::ScrollArea::horizontal()
    759             .show(ui, |ui| timelines_view(ui, panel_sizes, app, app_ctx))
    760             .inner
    761     } else {
    762         timelines_view(ui, panel_sizes, app, app_ctx)
    763     }
    764 }
    765 
    766 fn timelines_view(
    767     ui: &mut egui::Ui,
    768     sizes: Size,
    769     app: &mut Damus,
    770     ctx: &mut AppContext<'_>,
    771 ) -> Option<AppAction> {
    772     let num_cols = get_active_columns(ctx.accounts, &app.decks_cache).num_columns();
    773     let mut side_panel_action: Option<nav::SwitchingAction> = None;
    774     let mut responses = Vec::with_capacity(num_cols);
    775 
    776     StripBuilder::new(ui)
    777         .size(Size::exact(ui::side_panel::SIDE_PANEL_WIDTH))
    778         .sizes(sizes, num_cols)
    779         .clip(true)
    780         .horizontal(|mut strip| {
    781             strip.cell(|ui| {
    782                 let rect = ui.available_rect_before_wrap();
    783                 let side_panel = DesktopSidePanel::new(
    784                     ctx.accounts.get_selected_account(),
    785                     &app.decks_cache,
    786                     ctx.i18n,
    787                 )
    788                 .show(ui);
    789 
    790                 if let Some(side_panel) = side_panel {
    791                     if side_panel.response.clicked() || side_panel.response.secondary_clicked() {
    792                         if let Some(action) = DesktopSidePanel::perform_action(
    793                             &mut app.decks_cache,
    794                             ctx.accounts,
    795                             side_panel.action,
    796                             ctx.i18n,
    797                         ) {
    798                             side_panel_action = Some(action);
    799                         }
    800                     }
    801                 }
    802 
    803                 // debug
    804                 /*
    805                 ui.painter().rect(
    806                     rect,
    807                     0,
    808                     egui::Color32::RED,
    809                     egui::Stroke::new(1.0, egui::Color32::BLUE),
    810                     egui::StrokeKind::Inside,
    811                 );
    812                 */
    813 
    814                 // vertical sidebar line
    815                 ui.painter().vline(
    816                     rect.right(),
    817                     rect.y_range(),
    818                     ui.visuals().widgets.noninteractive.bg_stroke,
    819                 );
    820             });
    821 
    822             for col_index in 0..num_cols {
    823                 strip.cell(|ui| {
    824                     let rect = ui.available_rect_before_wrap();
    825                     let v_line_stroke = ui.visuals().widgets.noninteractive.bg_stroke;
    826                     let inner_rect = {
    827                         let mut inner = rect;
    828                         inner.set_right(rect.right() - v_line_stroke.width);
    829                         inner
    830                     };
    831                     responses.push(nav::render_nav(col_index, inner_rect, app, ctx, ui));
    832 
    833                     // vertical line
    834                     ui.painter()
    835                         .vline(rect.right(), rect.y_range(), v_line_stroke);
    836 
    837                     // we need borrow ui context for processing, so proces
    838                     // responses in the last cell
    839 
    840                     if col_index == num_cols - 1 {}
    841                 });
    842 
    843                 //strip.cell(|ui| timeline::timeline_view(ui, app, timeline_ind));
    844             }
    845         });
    846 
    847     // process the side panel action after so we don't change the number of columns during
    848     // StripBuilder rendering
    849     let mut save_cols = false;
    850     if let Some(action) = side_panel_action {
    851         save_cols = save_cols
    852             || action.process(&mut app.timeline_cache, &mut app.decks_cache, ctx, ui.ctx());
    853     }
    854 
    855     let mut app_action: Option<AppAction> = None;
    856 
    857     for response in responses {
    858         let nav_result = response.process_render_nav_response(app, ctx, ui);
    859 
    860         if let Some(nr) = &nav_result {
    861             match nr {
    862                 ProcessNavResult::SwitchOccurred => save_cols = true,
    863 
    864                 ProcessNavResult::PfpClicked => {
    865                     app_action = Some(AppAction::ToggleChrome);
    866                 }
    867             }
    868         }
    869     }
    870 
    871     if app.options.contains(AppOptions::TmpColumns) {
    872         save_cols = false;
    873     }
    874 
    875     if save_cols {
    876         storage::save_decks_cache(ctx.path, &app.decks_cache);
    877     }
    878 
    879     app_action
    880 }
    881 
    882 impl notedeck::App for Damus {
    883     fn update(&mut self, ctx: &mut AppContext<'_>, ui: &mut egui::Ui) -> Option<AppAction> {
    884         /*
    885         self.app
    886             .frame_history
    887             .on_new_frame(ctx.input(|i| i.time), frame.info().cpu_usage);
    888         */
    889 
    890         update_damus(self, ctx, ui.ctx());
    891         render_damus(self, ctx, ui)
    892     }
    893 }
    894 
    895 pub fn get_active_columns<'a>(accounts: &Accounts, decks_cache: &'a DecksCache) -> &'a Columns {
    896     get_decks(accounts, decks_cache).active().columns()
    897 }
    898 
    899 pub fn get_decks<'a>(accounts: &Accounts, decks_cache: &'a DecksCache) -> &'a Decks {
    900     let key = accounts.selected_account_pubkey();
    901     decks_cache.decks(key)
    902 }
    903 
    904 pub fn get_active_columns_mut<'a>(
    905     i18n: &mut Localization,
    906     accounts: &Accounts,
    907     decks_cache: &'a mut DecksCache,
    908 ) -> &'a mut Columns {
    909     get_decks_mut(i18n, accounts, decks_cache)
    910         .active_mut()
    911         .columns_mut()
    912 }
    913 
    914 pub fn get_decks_mut<'a>(
    915     i18n: &mut Localization,
    916     accounts: &Accounts,
    917     decks_cache: &'a mut DecksCache,
    918 ) -> &'a mut Decks {
    919     decks_cache.decks_mut(i18n, accounts.selected_account_pubkey())
    920 }
    921 
    922 fn columns_to_decks_cache(i18n: &mut Localization, cols: Columns, key: &[u8; 32]) -> DecksCache {
    923     let mut account_to_decks: HashMap<Pubkey, Decks> = Default::default();
    924     let decks = Decks::new(crate::decks::Deck::new_with_columns(
    925         crate::decks::Deck::default_icon(),
    926         tr!(i18n, "My Deck", "Title for the user's deck"),
    927         cols,
    928     ));
    929 
    930     let account = Pubkey::new(*key);
    931     account_to_decks.insert(account, decks);
    932     DecksCache::new(account_to_decks, i18n)
    933 }