notedeck

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

app.rs (21664B)


      1 use crate::{
      2     args::ColumnsArgs,
      3     column::Columns,
      4     decks::{Decks, DecksCache, FALLBACK_PUBKEY},
      5     draft::Drafts,
      6     nav, storage,
      7     subscriptions::{SubKind, Subscriptions},
      8     support::Support,
      9     timeline::{self, TimelineCache},
     10     ui::{self, DesktopSidePanel},
     11     unknowns,
     12     view_state::ViewState,
     13     Result,
     14 };
     15 
     16 use notedeck::{Accounts, AppContext, DataPath, DataPathType, FilterState, ImageCache, UnknownIds};
     17 
     18 use enostr::{ClientMessage, Keypair, PoolRelay, Pubkey, RelayEvent, RelayMessage, RelayPool};
     19 use uuid::Uuid;
     20 
     21 use egui_extras::{Size, StripBuilder};
     22 
     23 use nostrdb::{Ndb, Transaction};
     24 
     25 use std::collections::HashMap;
     26 use std::path::Path;
     27 use std::time::Duration;
     28 use tracing::{error, info, trace, warn};
     29 
     30 #[derive(Debug, Eq, PartialEq, Clone)]
     31 pub enum DamusState {
     32     Initializing,
     33     Initialized,
     34 }
     35 
     36 /// We derive Deserialize/Serialize so we can persist app state on shutdown.
     37 pub struct Damus {
     38     state: DamusState,
     39     pub decks_cache: DecksCache,
     40     pub view_state: ViewState,
     41     pub drafts: Drafts,
     42     pub timeline_cache: TimelineCache,
     43     pub subscriptions: Subscriptions,
     44     pub support: Support,
     45 
     46     //frame_history: crate::frame_history::FrameHistory,
     47 
     48     // TODO: make these bitflags
     49     /// Were columns loaded from the commandline? If so disable persistence.
     50     pub tmp_columns: bool,
     51     pub debug: bool,
     52     pub since_optimize: bool,
     53     pub textmode: bool,
     54 }
     55 
     56 fn handle_key_events(input: &egui::InputState, columns: &mut Columns) {
     57     for event in &input.raw.events {
     58         if let egui::Event::Key {
     59             key, pressed: true, ..
     60         } = event
     61         {
     62             match key {
     63                 egui::Key::J => {
     64                     columns.select_down();
     65                 }
     66                 egui::Key::K => {
     67                     columns.select_up();
     68                 }
     69                 egui::Key::H => {
     70                     columns.select_left();
     71                 }
     72                 egui::Key::L => {
     73                     columns.select_left();
     74                 }
     75                 _ => {}
     76             }
     77         }
     78     }
     79 }
     80 
     81 fn try_process_event(
     82     damus: &mut Damus,
     83     app_ctx: &mut AppContext<'_>,
     84     ctx: &egui::Context,
     85 ) -> Result<()> {
     86     let current_columns = get_active_columns_mut(app_ctx.accounts, &mut damus.decks_cache);
     87     ctx.input(|i| handle_key_events(i, current_columns));
     88 
     89     let ctx2 = ctx.clone();
     90     let wakeup = move || {
     91         ctx2.request_repaint();
     92     };
     93 
     94     app_ctx.pool.keepalive_ping(wakeup);
     95 
     96     // NOTE: we don't use the while let loop due to borrow issues
     97     #[allow(clippy::while_let_loop)]
     98     loop {
     99         let ev = if let Some(ev) = app_ctx.pool.try_recv() {
    100             ev.into_owned()
    101         } else {
    102             break;
    103         };
    104 
    105         match (&ev.event).into() {
    106             RelayEvent::Opened => {
    107                 app_ctx
    108                     .accounts
    109                     .send_initial_filters(app_ctx.pool, &ev.relay);
    110 
    111                 timeline::send_initial_timeline_filters(
    112                     app_ctx.ndb,
    113                     damus.since_optimize,
    114                     get_active_columns_mut(app_ctx.accounts, &mut damus.decks_cache),
    115                     &mut damus.subscriptions,
    116                     app_ctx.pool,
    117                     &ev.relay,
    118                 );
    119             }
    120             // TODO: handle reconnects
    121             RelayEvent::Closed => warn!("{} connection closed", &ev.relay),
    122             RelayEvent::Error(e) => error!("{}: {}", &ev.relay, e),
    123             RelayEvent::Other(msg) => trace!("other event {:?}", &msg),
    124             RelayEvent::Message(msg) => {
    125                 process_message(damus, app_ctx, &ev.relay, &msg);
    126             }
    127         }
    128     }
    129 
    130     let current_columns = get_active_columns_mut(app_ctx.accounts, &mut damus.decks_cache);
    131     let n_timelines = current_columns.timelines().len();
    132     for timeline_ind in 0..n_timelines {
    133         let is_ready = {
    134             let timeline = &mut current_columns.timelines[timeline_ind];
    135             timeline::is_timeline_ready(
    136                 app_ctx.ndb,
    137                 app_ctx.pool,
    138                 app_ctx.note_cache,
    139                 timeline,
    140                 app_ctx
    141                     .accounts
    142                     .get_selected_account()
    143                     .as_ref()
    144                     .map(|sa| &sa.pubkey),
    145             )
    146         };
    147 
    148         if is_ready {
    149             let txn = Transaction::new(app_ctx.ndb).expect("txn");
    150             // only thread timelines are reversed
    151             let reversed = false;
    152 
    153             if let Err(err) = current_columns.timelines_mut()[timeline_ind].poll_notes_into_view(
    154                 app_ctx.ndb,
    155                 &txn,
    156                 app_ctx.unknown_ids,
    157                 app_ctx.note_cache,
    158                 reversed,
    159             ) {
    160                 error!("poll_notes_into_view: {err}");
    161             }
    162         } else {
    163             // TODO: show loading?
    164         }
    165     }
    166 
    167     if app_ctx.unknown_ids.ready_to_send() {
    168         unknown_id_send(app_ctx.unknown_ids, app_ctx.pool);
    169     }
    170 
    171     Ok(())
    172 }
    173 
    174 fn unknown_id_send(unknown_ids: &mut UnknownIds, pool: &mut RelayPool) {
    175     let filter = unknown_ids.filter().expect("filter");
    176     info!(
    177         "Getting {} unknown ids from relays",
    178         unknown_ids.ids().len()
    179     );
    180     let msg = ClientMessage::req("unknownids".to_string(), filter);
    181     unknown_ids.clear();
    182     pool.send(&msg);
    183 }
    184 
    185 fn update_damus(damus: &mut Damus, app_ctx: &mut AppContext<'_>, ctx: &egui::Context) {
    186     match damus.state {
    187         DamusState::Initializing => {
    188             damus.state = DamusState::Initialized;
    189             // this lets our eose handler know to close unknownids right away
    190             damus
    191                 .subscriptions()
    192                 .insert("unknownids".to_string(), SubKind::OneShot);
    193             if let Err(err) = timeline::setup_initial_nostrdb_subs(
    194                 app_ctx.ndb,
    195                 app_ctx.note_cache,
    196                 &mut damus.decks_cache,
    197             ) {
    198                 warn!("update_damus init: {err}");
    199             }
    200         }
    201 
    202         DamusState::Initialized => (),
    203     };
    204 
    205     if let Err(err) = try_process_event(damus, app_ctx, ctx) {
    206         error!("error processing event: {}", err);
    207     }
    208 }
    209 
    210 fn handle_eose(
    211     damus: &mut Damus,
    212     ctx: &mut AppContext<'_>,
    213     subid: &str,
    214     relay_url: &str,
    215 ) -> Result<()> {
    216     let sub_kind = if let Some(sub_kind) = damus.subscriptions().get(subid) {
    217         sub_kind
    218     } else {
    219         let n_subids = damus.subscriptions().len();
    220         warn!(
    221             "got unknown eose subid {}, {} tracked subscriptions",
    222             subid, n_subids
    223         );
    224         return Ok(());
    225     };
    226 
    227     match *sub_kind {
    228         SubKind::Timeline(_) => {
    229             // eose on timeline? whatevs
    230         }
    231         SubKind::Initial => {
    232             let txn = Transaction::new(ctx.ndb)?;
    233             unknowns::update_from_columns(
    234                 &txn,
    235                 ctx.unknown_ids,
    236                 get_active_columns(ctx.accounts, &damus.decks_cache),
    237                 ctx.ndb,
    238                 ctx.note_cache,
    239             );
    240             // this is possible if this is the first time
    241             if ctx.unknown_ids.ready_to_send() {
    242                 unknown_id_send(ctx.unknown_ids, ctx.pool);
    243             }
    244         }
    245 
    246         // oneshot subs just close when they're done
    247         SubKind::OneShot => {
    248             let msg = ClientMessage::close(subid.to_string());
    249             ctx.pool.send_to(&msg, relay_url);
    250         }
    251 
    252         SubKind::FetchingContactList(timeline_uid) => {
    253             let timeline = if let Some(tl) =
    254                 get_active_columns_mut(ctx.accounts, &mut damus.decks_cache)
    255                     .find_timeline_mut(timeline_uid)
    256             {
    257                 tl
    258             } else {
    259                 error!(
    260                     "timeline uid:{} not found for FetchingContactList",
    261                     timeline_uid
    262                 );
    263                 return Ok(());
    264             };
    265 
    266             let filter_state = timeline.filter.get(relay_url);
    267 
    268             // If this request was fetching a contact list, our filter
    269             // state should be "FetchingRemote". We look at the local
    270             // subscription for that filter state and get the subscription id
    271             let local_sub = if let FilterState::FetchingRemote(unisub) = filter_state {
    272                 unisub.local
    273             } else {
    274                 // TODO: we could have multiple contact list results, we need
    275                 // to check to see if this one is newer and use that instead
    276                 warn!(
    277                     "Expected timeline to have FetchingRemote state but was {:?}",
    278                     timeline.filter
    279                 );
    280                 return Ok(());
    281             };
    282 
    283             info!(
    284                 "got contact list from {}, updating filter_state to got_remote",
    285                 relay_url
    286             );
    287 
    288             // We take the subscription id and pass it to the new state of
    289             // "GotRemote". This will let future frames know that it can try
    290             // to look for the contact list in nostrdb.
    291             timeline
    292                 .filter
    293                 .set_relay_state(relay_url.to_string(), FilterState::got_remote(local_sub));
    294         }
    295     }
    296 
    297     Ok(())
    298 }
    299 
    300 fn process_message(damus: &mut Damus, ctx: &mut AppContext<'_>, relay: &str, msg: &RelayMessage) {
    301     match msg {
    302         RelayMessage::Event(_subid, ev) => {
    303             let relay = if let Some(relay) = ctx.pool.relays.iter().find(|r| r.url() == relay) {
    304                 relay
    305             } else {
    306                 error!("couldn't find relay {} for note processing!?", relay);
    307                 return;
    308             };
    309 
    310             match relay {
    311                 PoolRelay::Websocket(_) => {
    312                     //info!("processing event {}", event);
    313                     if let Err(err) = ctx.ndb.process_event(ev) {
    314                         error!("error processing event {ev}: {err}");
    315                     }
    316                 }
    317                 PoolRelay::Multicast(_) => {
    318                     // multicast events are client events
    319                     if let Err(err) = ctx.ndb.process_client_event(ev) {
    320                         error!("error processing multicast event {ev}: {err}");
    321                     }
    322                 }
    323             }
    324         }
    325         RelayMessage::Notice(msg) => warn!("Notice from {}: {}", relay, msg),
    326         RelayMessage::OK(cr) => info!("OK {:?}", cr),
    327         RelayMessage::Eose(sid) => {
    328             if let Err(err) = handle_eose(damus, ctx, sid, relay) {
    329                 error!("error handling eose: {}", err);
    330             }
    331         }
    332     }
    333 }
    334 
    335 fn render_damus(damus: &mut Damus, app_ctx: &mut AppContext<'_>, ui: &mut egui::Ui) {
    336     if notedeck::ui::is_narrow(ui.ctx()) {
    337         render_damus_mobile(damus, app_ctx, ui);
    338     } else {
    339         render_damus_desktop(damus, app_ctx, ui);
    340     }
    341 
    342     // We use this for keeping timestamps and things up to date
    343     ui.ctx().request_repaint_after(Duration::from_secs(1));
    344 }
    345 
    346 /*
    347 fn determine_key_storage_type() -> KeyStorageType {
    348     #[cfg(target_os = "macos")]
    349     {
    350         KeyStorageType::MacOS
    351     }
    352 
    353     #[cfg(target_os = "linux")]
    354     {
    355         KeyStorageType::Linux
    356     }
    357 
    358     #[cfg(not(any(target_os = "macos", target_os = "linux")))]
    359     {
    360         KeyStorageType::None
    361     }
    362 }
    363 */
    364 
    365 impl Damus {
    366     /// Called once before the first frame.
    367     pub fn new(ctx: &mut AppContext<'_>, args: &[String]) -> Self {
    368         // arg parsing
    369 
    370         let parsed_args = ColumnsArgs::parse(args);
    371         let account = ctx
    372             .accounts
    373             .get_selected_account()
    374             .as_ref()
    375             .map(|a| a.pubkey.bytes());
    376 
    377         let tmp_columns = !parsed_args.columns.is_empty();
    378         let decks_cache = if tmp_columns {
    379             info!("DecksCache: loading from command line arguments");
    380             let mut columns: Columns = Columns::new();
    381             for col in parsed_args.columns {
    382                 if let Some(timeline) = col.into_timeline(ctx.ndb, account) {
    383                     columns.add_new_timeline_column(timeline);
    384                 }
    385             }
    386 
    387             columns_to_decks_cache(columns, account)
    388         } else if let Some(decks_cache) = crate::storage::load_decks_cache(ctx.path, ctx.ndb) {
    389             info!(
    390                 "DecksCache: loading from disk {}",
    391                 crate::storage::DECKS_CACHE_FILE
    392             );
    393             decks_cache
    394         } else if let Some(cols) = storage::deserialize_columns(ctx.path, ctx.ndb, account) {
    395             info!(
    396                 "DecksCache: loading from disk at depreciated location {}",
    397                 crate::storage::COLUMNS_FILE
    398             );
    399             columns_to_decks_cache(cols, account)
    400         } else {
    401             info!("DecksCache: creating new with demo configuration");
    402             let mut cache = DecksCache::new_with_demo_config(ctx.ndb);
    403             for account in ctx.accounts.get_accounts() {
    404                 cache.add_deck_default(account.pubkey);
    405             }
    406             set_demo(&mut cache, ctx.ndb, ctx.accounts, ctx.unknown_ids);
    407 
    408             cache
    409         };
    410 
    411         let debug = ctx.args.debug;
    412         let support = Support::new(ctx.path);
    413 
    414         Self {
    415             subscriptions: Subscriptions::default(),
    416             since_optimize: parsed_args.since_optimize,
    417             timeline_cache: TimelineCache::default(),
    418             drafts: Drafts::default(),
    419             state: DamusState::Initializing,
    420             textmode: parsed_args.textmode,
    421             //frame_history: FrameHistory::default(),
    422             view_state: ViewState::default(),
    423             tmp_columns,
    424             support,
    425             decks_cache,
    426             debug,
    427         }
    428     }
    429 
    430     pub fn columns_mut(&mut self, accounts: &Accounts) -> &mut Columns {
    431         get_active_columns_mut(accounts, &mut self.decks_cache)
    432     }
    433 
    434     pub fn columns(&self, accounts: &Accounts) -> &Columns {
    435         get_active_columns(accounts, &self.decks_cache)
    436     }
    437 
    438     pub fn gen_subid(&self, kind: &SubKind) -> String {
    439         if self.debug {
    440             format!("{:?}", kind)
    441         } else {
    442             Uuid::new_v4().to_string()
    443         }
    444     }
    445 
    446     pub fn mock<P: AsRef<Path>>(data_path: P) -> Self {
    447         let decks_cache = DecksCache::default();
    448 
    449         let path = DataPath::new(&data_path);
    450         let imgcache_dir = path.path(DataPathType::Cache).join(ImageCache::rel_dir());
    451         let _ = std::fs::create_dir_all(imgcache_dir.clone());
    452         let debug = true;
    453 
    454         let support = Support::new(&path);
    455 
    456         Self {
    457             debug,
    458             subscriptions: Subscriptions::default(),
    459             since_optimize: true,
    460             timeline_cache: TimelineCache::default(),
    461             drafts: Drafts::default(),
    462             state: DamusState::Initializing,
    463             textmode: false,
    464             tmp_columns: true,
    465             //frame_history: FrameHistory::default(),
    466             view_state: ViewState::default(),
    467             support,
    468             decks_cache,
    469         }
    470     }
    471 
    472     pub fn subscriptions(&mut self) -> &mut HashMap<String, SubKind> {
    473         &mut self.subscriptions.subs
    474     }
    475 }
    476 
    477 /*
    478 fn circle_icon(ui: &mut egui::Ui, openness: f32, response: &egui::Response) {
    479     let stroke = ui.style().interact(&response).fg_stroke;
    480     let radius = egui::lerp(2.0..=3.0, openness);
    481     ui.painter()
    482         .circle_filled(response.rect.center(), radius, stroke.color);
    483 }
    484 */
    485 
    486 fn render_damus_mobile(app: &mut Damus, app_ctx: &mut AppContext<'_>, ui: &mut egui::Ui) {
    487     #[cfg(feature = "profiling")]
    488     puffin::profile_function!();
    489 
    490     //let routes = app.timelines[0].routes.clone();
    491 
    492     if !app.columns(app_ctx.accounts).columns().is_empty()
    493         && nav::render_nav(0, app, app_ctx, ui).process_render_nav_response(app, app_ctx)
    494         && !app.tmp_columns
    495     {
    496         storage::save_decks_cache(app_ctx.path, &app.decks_cache);
    497     }
    498 }
    499 
    500 fn render_damus_desktop(app: &mut Damus, app_ctx: &mut AppContext<'_>, ui: &mut egui::Ui) {
    501     #[cfg(feature = "profiling")]
    502     puffin::profile_function!();
    503 
    504     let screen_size = ui.ctx().screen_rect().width();
    505     let calc_panel_width = (screen_size
    506         / get_active_columns(app_ctx.accounts, &app.decks_cache).num_columns() as f32)
    507         - 30.0;
    508     let min_width = 320.0;
    509     let need_scroll = calc_panel_width < min_width;
    510     let panel_sizes = if need_scroll {
    511         Size::exact(min_width)
    512     } else {
    513         Size::remainder()
    514     };
    515 
    516     ui.spacing_mut().item_spacing.x = 0.0;
    517     if need_scroll {
    518         egui::ScrollArea::horizontal().show(ui, |ui| {
    519             timelines_view(ui, panel_sizes, app, app_ctx);
    520         });
    521     } else {
    522         timelines_view(ui, panel_sizes, app, app_ctx);
    523     }
    524 }
    525 
    526 fn timelines_view(ui: &mut egui::Ui, sizes: Size, app: &mut Damus, ctx: &mut AppContext<'_>) {
    527     StripBuilder::new(ui)
    528         .size(Size::exact(ui::side_panel::SIDE_PANEL_WIDTH))
    529         .sizes(
    530             sizes,
    531             get_active_columns(ctx.accounts, &app.decks_cache).num_columns(),
    532         )
    533         .clip(true)
    534         .horizontal(|mut strip| {
    535             let mut side_panel_action: Option<nav::SwitchingAction> = None;
    536             strip.cell(|ui| {
    537                 let rect = ui.available_rect_before_wrap();
    538                 let side_panel = DesktopSidePanel::new(
    539                     ctx.ndb,
    540                     ctx.img_cache,
    541                     ctx.accounts.get_selected_account(),
    542                     &app.decks_cache,
    543                 )
    544                 .show(ui);
    545 
    546                 if side_panel.response.clicked() || side_panel.response.secondary_clicked() {
    547                     if let Some(action) = DesktopSidePanel::perform_action(
    548                         &mut app.decks_cache,
    549                         ctx.accounts,
    550                         &mut app.support,
    551                         ctx.theme,
    552                         side_panel.action,
    553                     ) {
    554                         side_panel_action = Some(action);
    555                     }
    556                 }
    557 
    558                 // vertical sidebar line
    559                 ui.painter().vline(
    560                     rect.right(),
    561                     rect.y_range(),
    562                     ui.visuals().widgets.noninteractive.bg_stroke,
    563                 );
    564             });
    565 
    566             let mut save_cols = false;
    567             if let Some(action) = side_panel_action {
    568                 save_cols = save_cols || action.process(&mut app.decks_cache, ctx);
    569             }
    570 
    571             let num_cols = app.columns(ctx.accounts).num_columns();
    572             let mut responses = Vec::with_capacity(num_cols);
    573             for col_index in 0..num_cols {
    574                 strip.cell(|ui| {
    575                     let rect = ui.available_rect_before_wrap();
    576                     responses.push(nav::render_nav(col_index, app, ctx, ui));
    577 
    578                     // vertical line
    579                     ui.painter().vline(
    580                         rect.right(),
    581                         rect.y_range(),
    582                         ui.visuals().widgets.noninteractive.bg_stroke,
    583                     );
    584                 });
    585 
    586                 //strip.cell(|ui| timeline::timeline_view(ui, app, timeline_ind));
    587             }
    588 
    589             for response in responses {
    590                 let save = response.process_render_nav_response(app, ctx);
    591                 save_cols = save_cols || save;
    592             }
    593 
    594             if app.tmp_columns {
    595                 save_cols = false;
    596             }
    597 
    598             if save_cols {
    599                 storage::save_decks_cache(ctx.path, &app.decks_cache);
    600             }
    601         });
    602 }
    603 
    604 impl notedeck::App for Damus {
    605     fn update(&mut self, ctx: &mut AppContext<'_>, ui: &mut egui::Ui) {
    606         /*
    607         self.app
    608             .frame_history
    609             .on_new_frame(ctx.input(|i| i.time), frame.info().cpu_usage);
    610         */
    611 
    612         update_damus(self, ctx, ui.ctx());
    613         render_damus(self, ctx, ui);
    614     }
    615 }
    616 
    617 pub fn get_active_columns<'a>(accounts: &Accounts, decks_cache: &'a DecksCache) -> &'a Columns {
    618     get_decks(accounts, decks_cache).active().columns()
    619 }
    620 
    621 pub fn get_decks<'a>(accounts: &Accounts, decks_cache: &'a DecksCache) -> &'a Decks {
    622     let key = if let Some(acc) = accounts.get_selected_account() {
    623         &acc.pubkey
    624     } else {
    625         decks_cache.get_fallback_pubkey()
    626     };
    627     decks_cache.decks(key)
    628 }
    629 
    630 pub fn get_active_columns_mut<'a>(
    631     accounts: &Accounts,
    632     decks_cache: &'a mut DecksCache,
    633 ) -> &'a mut Columns {
    634     get_decks_mut(accounts, decks_cache)
    635         .active_mut()
    636         .columns_mut()
    637 }
    638 
    639 pub fn get_decks_mut<'a>(accounts: &Accounts, decks_cache: &'a mut DecksCache) -> &'a mut Decks {
    640     if let Some(acc) = accounts.get_selected_account() {
    641         decks_cache.decks_mut(&acc.pubkey)
    642     } else {
    643         decks_cache.fallback_mut()
    644     }
    645 }
    646 
    647 pub fn set_demo(
    648     decks_cache: &mut DecksCache,
    649     ndb: &Ndb,
    650     accounts: &mut Accounts,
    651     unk_ids: &mut UnknownIds,
    652 ) {
    653     let txn = Transaction::new(ndb).expect("txn");
    654     accounts
    655         .add_account(Keypair::only_pubkey(*decks_cache.get_fallback_pubkey()))
    656         .process_action(unk_ids, ndb, &txn);
    657     accounts.select_account(accounts.num_accounts() - 1);
    658 }
    659 
    660 fn columns_to_decks_cache(cols: Columns, key: Option<&[u8; 32]>) -> DecksCache {
    661     let mut account_to_decks: HashMap<Pubkey, Decks> = Default::default();
    662     let decks = Decks::new(crate::decks::Deck::new_with_columns(
    663         crate::decks::Deck::default().icon,
    664         "My Deck".to_owned(),
    665         cols,
    666     ));
    667 
    668     let account = if let Some(key) = key {
    669         Pubkey::new(*key)
    670     } else {
    671         FALLBACK_PUBKEY()
    672     };
    673     account_to_decks.insert(account, decks);
    674     DecksCache::new(account_to_decks)
    675 }