notedeck

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

app.rs (25286B)


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