notedeck

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

app.rs (35295B)


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