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