notedeck

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

app.rs (35665B)


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