notedeck

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

app.rs (26276B)


      1 use crate::{
      2     account_manager::AccountManager,
      3     app_creation::setup_cc,
      4     app_size_handler::AppSizeHandler,
      5     app_style::user_requested_visuals_change,
      6     args::Args,
      7     column::Columns,
      8     draft::Drafts,
      9     filter::FilterState,
     10     frame_history::FrameHistory,
     11     imgcache::ImageCache,
     12     nav,
     13     notecache::NoteCache,
     14     notes_holder::NotesHolderStorage,
     15     profile::Profile,
     16     storage::{self, DataPath, DataPathType, Directory, FileKeyStorage, KeyStorageType},
     17     subscriptions::{SubKind, Subscriptions},
     18     support::Support,
     19     thread::Thread,
     20     timeline::{self, Timeline, TimelineKind},
     21     ui::{self, DesktopSidePanel},
     22     unknowns::UnknownIds,
     23     view_state::ViewState,
     24     Result,
     25 };
     26 
     27 use enostr::{ClientMessage, RelayEvent, RelayMessage, RelayPool};
     28 use uuid::Uuid;
     29 
     30 use egui::{Context, Frame, Style};
     31 use egui_extras::{Size, StripBuilder};
     32 
     33 use nostrdb::{Config, Filter, Ndb, Transaction};
     34 
     35 use std::collections::HashMap;
     36 use std::path::Path;
     37 use std::time::Duration;
     38 use tracing::{error, info, trace, warn};
     39 
     40 #[derive(Debug, Eq, PartialEq, Clone)]
     41 pub enum DamusState {
     42     Initializing,
     43     Initialized,
     44 }
     45 
     46 /// We derive Deserialize/Serialize so we can persist app state on shutdown.
     47 pub struct Damus {
     48     state: DamusState,
     49     pub note_cache: NoteCache,
     50     pub pool: RelayPool,
     51 
     52     pub columns: Columns,
     53     pub ndb: Ndb,
     54     pub view_state: ViewState,
     55     pub unknown_ids: UnknownIds,
     56     pub drafts: Drafts,
     57     pub threads: NotesHolderStorage<Thread>,
     58     pub profiles: NotesHolderStorage<Profile>,
     59     pub img_cache: ImageCache,
     60     pub accounts: AccountManager,
     61     pub subscriptions: Subscriptions,
     62     pub app_rect_handler: AppSizeHandler,
     63     pub support: Support,
     64 
     65     frame_history: crate::frame_history::FrameHistory,
     66 
     67     pub path: DataPath,
     68     // TODO: make these bitflags
     69     pub debug: bool,
     70     pub since_optimize: bool,
     71     pub textmode: bool,
     72 }
     73 
     74 fn relay_setup(pool: &mut RelayPool, ctx: &egui::Context) {
     75     let ctx = ctx.clone();
     76     let wakeup = move || {
     77         ctx.request_repaint();
     78     };
     79     if let Err(e) = pool.add_url("ws://localhost:8080".to_string(), wakeup.clone()) {
     80         error!("{:?}", e)
     81     }
     82     if let Err(e) = pool.add_url("wss://relay.damus.io".to_string(), wakeup.clone()) {
     83         error!("{:?}", e)
     84     }
     85     //if let Err(e) = pool.add_url("wss://pyramid.fiatjaf.com".to_string(), wakeup.clone()) {
     86     //error!("{:?}", e)
     87     //}
     88     if let Err(e) = pool.add_url("wss://nos.lol".to_string(), wakeup.clone()) {
     89         error!("{:?}", e)
     90     }
     91     if let Err(e) = pool.add_url("wss://nostr.wine".to_string(), wakeup.clone()) {
     92         error!("{:?}", e)
     93     }
     94     if let Err(e) = pool.add_url("wss://purplepag.es".to_string(), wakeup) {
     95         error!("{:?}", e)
     96     }
     97 }
     98 
     99 fn handle_key_events(input: &egui::InputState, _pixels_per_point: f32, columns: &mut Columns) {
    100     for event in &input.raw.events {
    101         if let egui::Event::Key {
    102             key, pressed: true, ..
    103         } = event
    104         {
    105             match key {
    106                 egui::Key::J => {
    107                     columns.select_down();
    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             }
    120         }
    121     }
    122 }
    123 
    124 fn try_process_event(damus: &mut Damus, ctx: &egui::Context) -> Result<()> {
    125     let ppp = ctx.pixels_per_point();
    126     ctx.input(|i| handle_key_events(i, ppp, &mut damus.columns));
    127 
    128     let ctx2 = ctx.clone();
    129     let wakeup = move || {
    130         ctx2.request_repaint();
    131     };
    132     damus.pool.keepalive_ping(wakeup);
    133 
    134     // NOTE: we don't use the while let loop due to borrow issues
    135     #[allow(clippy::while_let_loop)]
    136     loop {
    137         let ev = if let Some(ev) = damus.pool.try_recv() {
    138             ev.into_owned()
    139         } else {
    140             break;
    141         };
    142 
    143         match (&ev.event).into() {
    144             RelayEvent::Opened => {
    145                 timeline::send_initial_timeline_filters(
    146                     &damus.ndb,
    147                     damus.since_optimize,
    148                     &mut damus.columns,
    149                     &mut damus.subscriptions,
    150                     &mut damus.pool,
    151                     &ev.relay,
    152                 );
    153             }
    154             // TODO: handle reconnects
    155             RelayEvent::Closed => warn!("{} connection closed", &ev.relay),
    156             RelayEvent::Error(e) => error!("{}: {}", &ev.relay, e),
    157             RelayEvent::Other(msg) => trace!("other event {:?}", &msg),
    158             RelayEvent::Message(msg) => process_message(damus, &ev.relay, &msg),
    159         }
    160     }
    161 
    162     let n_timelines = damus.columns.timelines().len();
    163     for timeline_ind in 0..n_timelines {
    164         let is_ready = {
    165             let timeline = &mut damus.columns.timelines[timeline_ind];
    166             timeline::is_timeline_ready(
    167                 &damus.ndb,
    168                 &mut damus.pool,
    169                 &mut damus.note_cache,
    170                 timeline,
    171             )
    172         };
    173 
    174         if is_ready {
    175             let txn = Transaction::new(&damus.ndb).expect("txn");
    176 
    177             if let Err(err) = Timeline::poll_notes_into_view(
    178                 timeline_ind,
    179                 damus.columns.timelines_mut(),
    180                 &damus.ndb,
    181                 &txn,
    182                 &mut damus.unknown_ids,
    183                 &mut damus.note_cache,
    184             ) {
    185                 error!("poll_notes_into_view: {err}");
    186             }
    187         } else {
    188             // TODO: show loading?
    189         }
    190     }
    191 
    192     if damus.unknown_ids.ready_to_send() {
    193         unknown_id_send(damus);
    194     }
    195 
    196     Ok(())
    197 }
    198 
    199 fn unknown_id_send(damus: &mut Damus) {
    200     let filter = damus.unknown_ids.filter().expect("filter");
    201     info!(
    202         "Getting {} unknown ids from relays",
    203         damus.unknown_ids.ids().len()
    204     );
    205     let msg = ClientMessage::req("unknownids".to_string(), filter);
    206     damus.unknown_ids.clear();
    207     damus.pool.send(&msg);
    208 }
    209 
    210 #[cfg(feature = "profiling")]
    211 fn setup_profiling() {
    212     puffin::set_scopes_on(true); // tell puffin to collect data
    213 }
    214 
    215 fn update_damus(damus: &mut Damus, ctx: &egui::Context) {
    216     match damus.state {
    217         DamusState::Initializing => {
    218             #[cfg(feature = "profiling")]
    219             setup_profiling();
    220 
    221             damus.state = DamusState::Initialized;
    222             // this lets our eose handler know to close unknownids right away
    223             damus
    224                 .subscriptions()
    225                 .insert("unknownids".to_string(), SubKind::OneShot);
    226             if let Err(err) = timeline::setup_initial_nostrdb_subs(
    227                 &damus.ndb,
    228                 &mut damus.note_cache,
    229                 &mut damus.columns,
    230             ) {
    231                 warn!("update_damus init: {err}");
    232             }
    233         }
    234 
    235         DamusState::Initialized => (),
    236     };
    237 
    238     if let Err(err) = try_process_event(damus, ctx) {
    239         error!("error processing event: {}", err);
    240     }
    241 
    242     damus.app_rect_handler.try_save_app_size(ctx);
    243 }
    244 
    245 fn process_event(damus: &mut Damus, _subid: &str, event: &str) {
    246     #[cfg(feature = "profiling")]
    247     puffin::profile_function!();
    248 
    249     //info!("processing event {}", event);
    250     if let Err(_err) = damus.ndb.process_event(event) {
    251         error!("error processing event {}", event);
    252     }
    253 }
    254 
    255 fn handle_eose(damus: &mut Damus, subid: &str, relay_url: &str) -> Result<()> {
    256     let sub_kind = if let Some(sub_kind) = damus.subscriptions().get(subid) {
    257         sub_kind
    258     } else {
    259         let n_subids = damus.subscriptions().len();
    260         warn!(
    261             "got unknown eose subid {}, {} tracked subscriptions",
    262             subid, n_subids
    263         );
    264         return Ok(());
    265     };
    266 
    267     match *sub_kind {
    268         SubKind::Timeline(_) => {
    269             // eose on timeline? whatevs
    270         }
    271         SubKind::Initial => {
    272             let txn = Transaction::new(&damus.ndb)?;
    273             UnknownIds::update(
    274                 &txn,
    275                 &mut damus.unknown_ids,
    276                 &damus.columns,
    277                 &damus.ndb,
    278                 &mut damus.note_cache,
    279             );
    280             // this is possible if this is the first time
    281             if damus.unknown_ids.ready_to_send() {
    282                 unknown_id_send(damus);
    283             }
    284         }
    285 
    286         // oneshot subs just close when they're done
    287         SubKind::OneShot => {
    288             let msg = ClientMessage::close(subid.to_string());
    289             damus.pool.send_to(&msg, relay_url);
    290         }
    291 
    292         SubKind::FetchingContactList(timeline_uid) => {
    293             let timeline = if let Some(tl) = damus.columns.find_timeline_mut(timeline_uid) {
    294                 tl
    295             } else {
    296                 error!(
    297                     "timeline uid:{} not found for FetchingContactList",
    298                     timeline_uid
    299                 );
    300                 return Ok(());
    301             };
    302 
    303             let filter_state = timeline.filter.get(relay_url);
    304 
    305             // If this request was fetching a contact list, our filter
    306             // state should be "FetchingRemote". We look at the local
    307             // subscription for that filter state and get the subscription id
    308             let local_sub = if let FilterState::FetchingRemote(unisub) = filter_state {
    309                 unisub.local
    310             } else {
    311                 // TODO: we could have multiple contact list results, we need
    312                 // to check to see if this one is newer and use that instead
    313                 warn!(
    314                     "Expected timeline to have FetchingRemote state but was {:?}",
    315                     timeline.filter
    316                 );
    317                 return Ok(());
    318             };
    319 
    320             info!(
    321                 "got contact list from {}, updating filter_state to got_remote",
    322                 relay_url
    323             );
    324 
    325             // We take the subscription id and pass it to the new state of
    326             // "GotRemote". This will let future frames know that it can try
    327             // to look for the contact list in nostrdb.
    328             timeline
    329                 .filter
    330                 .set_relay_state(relay_url.to_string(), FilterState::got_remote(local_sub));
    331         }
    332     }
    333 
    334     Ok(())
    335 }
    336 
    337 fn process_message(damus: &mut Damus, relay: &str, msg: &RelayMessage) {
    338     match msg {
    339         RelayMessage::Event(subid, ev) => process_event(damus, subid, ev),
    340         RelayMessage::Notice(msg) => warn!("Notice from {}: {}", relay, msg),
    341         RelayMessage::OK(cr) => info!("OK {:?}", cr),
    342         RelayMessage::Eose(sid) => {
    343             if let Err(err) = handle_eose(damus, sid, relay) {
    344                 error!("error handling eose: {}", err);
    345             }
    346         }
    347     }
    348 }
    349 
    350 fn render_damus(damus: &mut Damus, ctx: &Context) {
    351     if ui::is_narrow(ctx) {
    352         render_damus_mobile(ctx, damus);
    353     } else {
    354         render_damus_desktop(ctx, damus);
    355     }
    356 
    357     ctx.request_repaint_after(Duration::from_secs(1));
    358 
    359     #[cfg(feature = "profiling")]
    360     puffin_egui::profiler_window(ctx);
    361 }
    362 
    363 /*
    364 fn determine_key_storage_type() -> KeyStorageType {
    365     #[cfg(target_os = "macos")]
    366     {
    367         KeyStorageType::MacOS
    368     }
    369 
    370     #[cfg(target_os = "linux")]
    371     {
    372         KeyStorageType::Linux
    373     }
    374 
    375     #[cfg(not(any(target_os = "macos", target_os = "linux")))]
    376     {
    377         KeyStorageType::None
    378     }
    379 }
    380 */
    381 
    382 impl Damus {
    383     /// Called once before the first frame.
    384     pub fn new<P: AsRef<Path>>(ctx: &egui::Context, data_path: P, args: Vec<String>) -> Self {
    385         // arg parsing
    386         let parsed_args = Args::parse(&args);
    387         let is_mobile = parsed_args.is_mobile.unwrap_or(ui::is_compiled_as_mobile());
    388 
    389         setup_cc(ctx, is_mobile, parsed_args.light);
    390 
    391         let data_path = parsed_args
    392             .datapath
    393             .unwrap_or(data_path.as_ref().to_str().expect("db path ok").to_string());
    394         let path = DataPath::new(&data_path);
    395         let dbpath_str = parsed_args
    396             .dbpath
    397             .unwrap_or_else(|| path.path(DataPathType::Db).to_str().unwrap().to_string());
    398 
    399         let _ = std::fs::create_dir_all(&dbpath_str);
    400 
    401         let imgcache_dir = path.path(DataPathType::Cache).join(ImageCache::rel_dir());
    402         let _ = std::fs::create_dir_all(imgcache_dir.clone());
    403 
    404         let mut config = Config::new();
    405         config.set_ingester_threads(4);
    406 
    407         let keystore = if parsed_args.use_keystore {
    408             let keys_path = path.path(DataPathType::Keys);
    409             let selected_key_path = path.path(DataPathType::SelectedKey);
    410             KeyStorageType::FileSystem(FileKeyStorage::new(
    411                 Directory::new(keys_path),
    412                 Directory::new(selected_key_path),
    413             ))
    414         } else {
    415             KeyStorageType::None
    416         };
    417 
    418         let mut accounts = AccountManager::new(keystore);
    419 
    420         let num_keys = parsed_args.keys.len();
    421 
    422         let mut unknown_ids = UnknownIds::default();
    423         let ndb = Ndb::new(&dbpath_str, &config).expect("ndb");
    424 
    425         {
    426             let txn = Transaction::new(&ndb).expect("txn");
    427             for key in parsed_args.keys {
    428                 info!("adding account: {}", key.pubkey);
    429                 accounts
    430                     .add_account(key)
    431                     .process_action(&mut unknown_ids, &ndb, &txn);
    432             }
    433         }
    434 
    435         if num_keys != 0 {
    436             accounts.select_account(0);
    437         }
    438 
    439         // setup relays if we have them
    440         let pool = if parsed_args.relays.is_empty() {
    441             let mut pool = RelayPool::new();
    442             relay_setup(&mut pool, ctx);
    443             pool
    444         } else {
    445             let wakeup = {
    446                 let ctx = ctx.clone();
    447                 move || {
    448                     ctx.request_repaint();
    449                 }
    450             };
    451 
    452             let mut pool = RelayPool::new();
    453             for relay in parsed_args.relays {
    454                 if let Err(e) = pool.add_url(relay.clone(), wakeup.clone()) {
    455                     error!("error adding relay {}: {}", relay, e);
    456                 }
    457             }
    458             pool
    459         };
    460 
    461         let account = accounts
    462             .get_selected_account()
    463             .as_ref()
    464             .map(|a| a.pubkey.bytes());
    465 
    466         let mut columns = if parsed_args.columns.is_empty() {
    467             if let Some(serializable_columns) = storage::load_columns(&path) {
    468                 info!("Using columns from disk");
    469                 serializable_columns.into_columns(&ndb, account)
    470             } else {
    471                 info!("Could not load columns from disk");
    472                 Columns::new()
    473             }
    474         } else {
    475             info!(
    476                 "Using columns from command line arguments: {:?}",
    477                 parsed_args.columns
    478             );
    479             let mut columns: Columns = Columns::new();
    480             for col in parsed_args.columns {
    481                 if let Some(timeline) = col.into_timeline(&ndb, account) {
    482                     columns.add_new_timeline_column(timeline);
    483                 }
    484             }
    485 
    486             columns
    487         };
    488 
    489         let debug = parsed_args.debug;
    490 
    491         if columns.columns().is_empty() {
    492             columns.new_column_picker();
    493         }
    494 
    495         let app_rect_handler = AppSizeHandler::new(&path);
    496         let support = Support::new(&path);
    497 
    498         Self {
    499             pool,
    500             debug,
    501             unknown_ids,
    502             subscriptions: Subscriptions::default(),
    503             since_optimize: parsed_args.since_optimize,
    504             threads: NotesHolderStorage::default(),
    505             profiles: NotesHolderStorage::default(),
    506             drafts: Drafts::default(),
    507             state: DamusState::Initializing,
    508             img_cache: ImageCache::new(imgcache_dir),
    509             note_cache: NoteCache::default(),
    510             columns,
    511             textmode: parsed_args.textmode,
    512             ndb,
    513             accounts,
    514             frame_history: FrameHistory::default(),
    515             view_state: ViewState::default(),
    516             path,
    517             app_rect_handler,
    518             support,
    519         }
    520     }
    521 
    522     pub fn pool_mut(&mut self) -> &mut RelayPool {
    523         &mut self.pool
    524     }
    525 
    526     pub fn ndb(&self) -> &Ndb {
    527         &self.ndb
    528     }
    529 
    530     pub fn drafts_mut(&mut self) -> &mut Drafts {
    531         &mut self.drafts
    532     }
    533 
    534     pub fn img_cache_mut(&mut self) -> &mut ImageCache {
    535         &mut self.img_cache
    536     }
    537 
    538     pub fn accounts(&self) -> &AccountManager {
    539         &self.accounts
    540     }
    541 
    542     pub fn accounts_mut(&mut self) -> &mut AccountManager {
    543         &mut self.accounts
    544     }
    545 
    546     pub fn view_state_mut(&mut self) -> &mut ViewState {
    547         &mut self.view_state
    548     }
    549 
    550     pub fn columns_mut(&mut self) -> &mut Columns {
    551         &mut self.columns
    552     }
    553 
    554     pub fn columns(&self) -> &Columns {
    555         &self.columns
    556     }
    557 
    558     pub fn gen_subid(&self, kind: &SubKind) -> String {
    559         if self.debug {
    560             format!("{:?}", kind)
    561         } else {
    562             Uuid::new_v4().to_string()
    563         }
    564     }
    565 
    566     pub fn mock<P: AsRef<Path>>(data_path: P) -> Self {
    567         let mut columns = Columns::new();
    568         let filter = Filter::from_json(include_str!("../queries/global.json")).unwrap();
    569 
    570         let timeline = Timeline::new(TimelineKind::Universe, FilterState::ready(vec![filter]));
    571 
    572         columns.add_new_timeline_column(timeline);
    573 
    574         let path = DataPath::new(&data_path);
    575         let imgcache_dir = path.path(DataPathType::Cache).join(ImageCache::rel_dir());
    576         let _ = std::fs::create_dir_all(imgcache_dir.clone());
    577         let debug = true;
    578 
    579         let app_rect_handler = AppSizeHandler::new(&path);
    580         let support = Support::new(&path);
    581 
    582         let mut config = Config::new();
    583         config.set_ingester_threads(2);
    584 
    585         Self {
    586             debug,
    587             unknown_ids: UnknownIds::default(),
    588             subscriptions: Subscriptions::default(),
    589             since_optimize: true,
    590             threads: NotesHolderStorage::default(),
    591             profiles: NotesHolderStorage::default(),
    592             drafts: Drafts::default(),
    593             state: DamusState::Initializing,
    594             pool: RelayPool::new(),
    595             img_cache: ImageCache::new(imgcache_dir),
    596             note_cache: NoteCache::default(),
    597             columns,
    598             textmode: false,
    599             ndb: Ndb::new(
    600                 path.path(DataPathType::Db)
    601                     .to_str()
    602                     .expect("db path should be ok"),
    603                 &config,
    604             )
    605             .expect("ndb"),
    606             accounts: AccountManager::new(KeyStorageType::None),
    607             frame_history: FrameHistory::default(),
    608             view_state: ViewState::default(),
    609 
    610             path,
    611             app_rect_handler,
    612             support,
    613         }
    614     }
    615 
    616     pub fn subscriptions(&mut self) -> &mut HashMap<String, SubKind> {
    617         &mut self.subscriptions.subs
    618     }
    619 
    620     pub fn note_cache_mut(&mut self) -> &mut NoteCache {
    621         &mut self.note_cache
    622     }
    623 
    624     pub fn unknown_ids_mut(&mut self) -> &mut UnknownIds {
    625         &mut self.unknown_ids
    626     }
    627 
    628     pub fn threads(&self) -> &NotesHolderStorage<Thread> {
    629         &self.threads
    630     }
    631 
    632     pub fn threads_mut(&mut self) -> &mut NotesHolderStorage<Thread> {
    633         &mut self.threads
    634     }
    635 
    636     pub fn note_cache(&self) -> &NoteCache {
    637         &self.note_cache
    638     }
    639 }
    640 
    641 /*
    642 fn circle_icon(ui: &mut egui::Ui, openness: f32, response: &egui::Response) {
    643     let stroke = ui.style().interact(&response).fg_stroke;
    644     let radius = egui::lerp(2.0..=3.0, openness);
    645     ui.painter()
    646         .circle_filled(response.rect.center(), radius, stroke.color);
    647 }
    648 */
    649 
    650 fn top_panel(ctx: &egui::Context) -> egui::TopBottomPanel {
    651     let top_margin = egui::Margin {
    652         top: 4.0,
    653         left: 8.0,
    654         right: 8.0,
    655         ..Default::default()
    656     };
    657 
    658     let frame = Frame {
    659         inner_margin: top_margin,
    660         fill: ctx.style().visuals.panel_fill,
    661         ..Default::default()
    662     };
    663 
    664     egui::TopBottomPanel::top("top_panel")
    665         .frame(frame)
    666         .show_separator_line(false)
    667 }
    668 
    669 fn render_panel(ctx: &egui::Context, app: &mut Damus) {
    670     top_panel(ctx).show(ctx, |ui| {
    671         ui.with_layout(egui::Layout::right_to_left(egui::Align::TOP), |ui| {
    672             ui.visuals_mut().button_frame = false;
    673 
    674             if let Some(new_visuals) =
    675                 user_requested_visuals_change(ui::is_oled(), ctx.style().visuals.dark_mode, ui)
    676             {
    677                 ctx.set_visuals(new_visuals)
    678             }
    679 
    680             if ui
    681                 .add(egui::Button::new("A").frame(false))
    682                 .on_hover_text("Text mode")
    683                 .clicked()
    684             {
    685                 app.textmode = !app.textmode;
    686             }
    687 
    688             /*
    689             if ui
    690                 .add(egui::Button::new("+").frame(false))
    691                 .on_hover_text("Add Timeline")
    692                 .clicked()
    693             {
    694                 app.n_panels += 1;
    695             }
    696 
    697             if app.n_panels != 1
    698                 && ui
    699                     .add(egui::Button::new("-").frame(false))
    700                     .on_hover_text("Remove Timeline")
    701                     .clicked()
    702             {
    703                 app.n_panels -= 1;
    704             }
    705             */
    706 
    707             //#[cfg(feature = "profiling")]
    708             {
    709                 ui.weak(format!(
    710                     "FPS: {:.2}, {:10.1}ms",
    711                     app.frame_history.fps(),
    712                     app.frame_history.mean_frame_time() * 1e3
    713                 ));
    714 
    715                 /*
    716                 if !app.timelines().count().is_empty() {
    717                     ui.weak(format!(
    718                         "{} notes",
    719                         &app.timelines()
    720                             .notes(ViewFilter::NotesAndReplies)
    721                             .len()
    722                     ));
    723                 }
    724                 */
    725             }
    726         });
    727     });
    728 }
    729 
    730 fn render_damus_mobile(ctx: &egui::Context, app: &mut Damus) {
    731     //render_panel(ctx, app, 0);
    732 
    733     #[cfg(feature = "profiling")]
    734     puffin::profile_function!();
    735 
    736     //let routes = app.timelines[0].routes.clone();
    737 
    738     main_panel(&ctx.style(), ui::is_narrow(ctx)).show(ctx, |ui| {
    739         if !app.columns.columns().is_empty() {
    740             if let Some(r) = nav::render_nav(0, app, ui) {
    741                 r.process_nav_response(&app.path, &mut app.columns)
    742             }
    743         }
    744     });
    745 }
    746 
    747 fn main_panel(style: &Style, narrow: bool) -> egui::CentralPanel {
    748     let inner_margin = egui::Margin {
    749         top: if narrow { 50.0 } else { 0.0 },
    750         left: 0.0,
    751         right: 0.0,
    752         bottom: 0.0,
    753     };
    754     egui::CentralPanel::default().frame(Frame {
    755         inner_margin,
    756         fill: style.visuals.panel_fill,
    757         ..Default::default()
    758     })
    759 }
    760 
    761 fn render_damus_desktop(ctx: &egui::Context, app: &mut Damus) {
    762     render_panel(ctx, app);
    763     #[cfg(feature = "profiling")]
    764     puffin::profile_function!();
    765 
    766     let screen_size = ctx.screen_rect().width();
    767     let calc_panel_width = (screen_size / app.columns.num_columns() as f32) - 30.0;
    768     let min_width = 320.0;
    769     let need_scroll = calc_panel_width < min_width;
    770     let panel_sizes = if need_scroll {
    771         Size::exact(min_width)
    772     } else {
    773         Size::remainder()
    774     };
    775 
    776     main_panel(&ctx.style(), ui::is_narrow(ctx)).show(ctx, |ui| {
    777         ui.spacing_mut().item_spacing.x = 0.0;
    778         if need_scroll {
    779             egui::ScrollArea::horizontal().show(ui, |ui| {
    780                 timelines_view(ui, panel_sizes, app);
    781             });
    782         } else {
    783             timelines_view(ui, panel_sizes, app);
    784         }
    785     });
    786 }
    787 
    788 fn timelines_view(ui: &mut egui::Ui, sizes: Size, app: &mut Damus) {
    789     StripBuilder::new(ui)
    790         .size(Size::exact(ui::side_panel::SIDE_PANEL_WIDTH))
    791         .sizes(sizes, app.columns.num_columns())
    792         .clip(true)
    793         .horizontal(|mut strip| {
    794             strip.cell(|ui| {
    795                 let rect = ui.available_rect_before_wrap();
    796                 let side_panel = DesktopSidePanel::new(
    797                     &app.ndb,
    798                     &mut app.img_cache,
    799                     app.accounts.get_selected_account(),
    800                 )
    801                 .show(ui);
    802 
    803                 if side_panel.response.clicked() {
    804                     DesktopSidePanel::perform_action(
    805                         &mut app.columns,
    806                         &mut app.support,
    807                         side_panel.action,
    808                     );
    809                 }
    810 
    811                 // vertical sidebar line
    812                 ui.painter().vline(
    813                     rect.right(),
    814                     rect.y_range(),
    815                     ui.visuals().widgets.noninteractive.bg_stroke,
    816                 );
    817             });
    818 
    819             let mut nav_resp: Option<nav::RenderNavResponse> = None;
    820             for col_index in 0..app.columns.num_columns() {
    821                 strip.cell(|ui| {
    822                     let rect = ui.available_rect_before_wrap();
    823                     if let Some(r) = nav::render_nav(col_index, app, ui) {
    824                         nav_resp = Some(r);
    825                     }
    826 
    827                     // vertical line
    828                     ui.painter().vline(
    829                         rect.right(),
    830                         rect.y_range(),
    831                         ui.visuals().widgets.noninteractive.bg_stroke,
    832                     );
    833                 });
    834 
    835                 //strip.cell(|ui| timeline::timeline_view(ui, app, timeline_ind));
    836             }
    837 
    838             if let Some(r) = nav_resp {
    839                 r.process_nav_response(&app.path, &mut app.columns);
    840             }
    841         });
    842 }
    843 
    844 impl eframe::App for Damus {
    845     /// Called by the frame work to save state before shutdown.
    846     fn save(&mut self, _storage: &mut dyn eframe::Storage) {
    847         //eframe::set_value(storage, eframe::APP_KEY, self);
    848     }
    849 
    850     /// Called each time the UI needs repainting, which may be many times per second.
    851     /// Put your widgets into a `SidePanel`, `TopPanel`, `CentralPanel`, `Window` or `Area`.
    852     fn update(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) {
    853         self.frame_history
    854             .on_new_frame(ctx.input(|i| i.time), frame.info().cpu_usage);
    855 
    856         #[cfg(feature = "profiling")]
    857         puffin::GlobalProfiler::lock().new_frame();
    858         update_damus(self, ctx);
    859         render_damus(self, ctx);
    860     }
    861 }