notedeck

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

app.rs (33486B)


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