app.rs (35665B)
1 use crate::{ 2 args::{ColumnsArgs, ColumnsFlag}, 3 column::Columns, 4 decks::{Decks, DecksCache}, 5 draft::Drafts, 6 nav::{self, ProcessNavResult}, 7 onboarding::Onboarding, 8 options::AppOptions, 9 route::Route, 10 storage, 11 support::Support, 12 timeline::{self, kind::ListKind, thread::Threads, TimelineCache, TimelineKind}, 13 timeline_loader::{TimelineLoader, TimelineLoaderMsg}, 14 ui::{self, DesktopSidePanel, SidePanelAction}, 15 view_state::ViewState, 16 Result, 17 }; 18 use egui_extras::{Size, StripBuilder}; 19 use enostr::Pubkey; 20 use nostrdb::Transaction; 21 use notedeck::{ 22 tr, ui::is_compiled_as_mobile, ui::is_narrow, Accounts, AppAction, AppContext, AppResponse, 23 DataPath, DataPathType, FilterState, Images, Localization, MediaJobSender, NotedeckOptions, 24 SettingsHandler, 25 }; 26 use notedeck_ui::{ 27 media::{MediaViewer, MediaViewerFlags, MediaViewerState}, 28 NoteOptions, 29 }; 30 use std::collections::{BTreeSet, HashMap, HashSet}; 31 use std::path::Path; 32 use tracing::{error, info, warn}; 33 34 /// Max timeline loader messages to process per frame to avoid UI stalls. 35 const MAX_TIMELINE_LOADER_MSGS_PER_FRAME: usize = 8; 36 37 #[derive(Debug, Eq, PartialEq, Clone)] 38 pub enum DamusState { 39 Initializing, 40 Initialized, 41 } 42 43 /// We derive Deserialize/Serialize so we can persist app state on shutdown. 44 pub struct Damus { 45 state: DamusState, 46 47 pub decks_cache: DecksCache, 48 pub view_state: ViewState, 49 pub drafts: Drafts, 50 pub timeline_cache: TimelineCache, 51 pub support: Support, 52 pub threads: Threads, 53 /// Background loader for initial timeline scans. 54 timeline_loader: TimelineLoader, 55 /// Timelines currently loading initial notes. 56 pub inflight_timeline_loads: HashSet<TimelineKind>, 57 /// Timelines that have completed their initial load. 58 pub loaded_timeline_loads: HashSet<TimelineKind>, 59 60 //frame_history: crate::frame_history::FrameHistory, 61 62 // TODO: make these bitflags 63 /// Were columns loaded from the commandline? If so disable persistence. 64 pub options: AppOptions, 65 pub note_options: NoteOptions, 66 67 pub unrecognized_args: BTreeSet<String>, 68 69 /// keep track of follow packs 70 pub onboarding: Onboarding, 71 72 /// Track which column is hovered for mouse back/forward navigation 73 hovered_column: Option<usize>, 74 } 75 76 #[profiling::function] 77 fn handle_egui_events( 78 input: &egui::InputState, 79 columns: &mut Columns, 80 hovered_column: Option<usize>, 81 wants_keyboard_input: bool, 82 ) { 83 for event in &input.raw.events { 84 match event { 85 egui::Event::Key { 86 key, 87 pressed, 88 modifiers, 89 .. 90 } if *pressed => { 91 // Browser-like navigation: Cmd+Arrow (macOS) / Ctrl+Arrow (others) 92 if !wants_keyboard_input 93 && (modifiers.ctrl || modifiers.command) 94 && !modifiers.shift 95 && !modifiers.alt 96 { 97 match key { 98 egui::Key::ArrowLeft | egui::Key::H => { 99 columns.get_selected_router().go_back(); 100 continue; 101 } 102 egui::Key::ArrowRight | egui::Key::L => { 103 columns.get_selected_router().go_forward(); 104 continue; 105 } 106 _ => {} 107 } 108 } 109 110 match key { 111 egui::Key::J => { 112 //columns.select_down(); 113 {} 114 } 115 /* 116 egui::Key::K => { 117 columns.select_up(); 118 } 119 egui::Key::H => { 120 columns.select_left(); 121 } 122 egui::Key::L => { 123 columns.select_left(); 124 } 125 */ 126 egui::Key::BrowserBack => { 127 columns.get_selected_router().go_back(); 128 } 129 _ => {} 130 } 131 } 132 133 egui::Event::PointerButton { 134 button: egui::PointerButton::Extra1, 135 pressed: true, 136 .. 137 } => { 138 if let Some(col_idx) = hovered_column { 139 columns.column_mut(col_idx).router_mut().go_back(); 140 } else { 141 columns.get_selected_router().go_back(); 142 } 143 } 144 145 egui::Event::PointerButton { 146 button: egui::PointerButton::Extra2, 147 pressed: true, 148 .. 149 } => { 150 if let Some(col_idx) = hovered_column { 151 columns.column_mut(col_idx).router_mut().go_forward(); 152 } else { 153 columns.get_selected_router().go_forward(); 154 } 155 } 156 157 egui::Event::InsetsChanged => { 158 tracing::debug!("insets have changed!"); 159 } 160 161 _ => {} 162 } 163 } 164 } 165 166 #[profiling::function] 167 fn try_process_event( 168 damus: &mut Damus, 169 app_ctx: &mut AppContext<'_>, 170 ctx: &egui::Context, 171 ) -> Result<()> { 172 let current_columns = 173 get_active_columns_mut(app_ctx.i18n, app_ctx.accounts, &mut damus.decks_cache); 174 let wants_keyboard_input = ctx.wants_keyboard_input(); 175 ctx.input(|i| { 176 handle_egui_events( 177 i, 178 current_columns, 179 damus.hovered_column, 180 wants_keyboard_input, 181 ) 182 }); 183 184 // Handle Escape separately: only consume the key if there's a route to go back to, 185 // otherwise let Chrome handle it (e.g. to open the side menu) 186 let can_go_back = current_columns.get_selected_router().routes().len() > 1; 187 if can_go_back && ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape)) { 188 current_columns.get_selected_router().go_back(); 189 } 190 191 let selected_account_pk = *app_ctx.accounts.selected_account_pubkey(); 192 for (kind, timeline) in &mut damus.timeline_cache { 193 if timeline.subscription.dependers(&selected_account_pk) == 0 { 194 continue; 195 } 196 197 if let FilterState::Ready(filter) = &timeline.filter { 198 if timeline.kind.should_subscribe_locally() 199 && timeline 200 .subscription 201 .get_local(&selected_account_pk) 202 .is_none() 203 { 204 timeline 205 .subscription 206 .try_add_local(selected_account_pk, app_ctx.ndb, filter); 207 } 208 } 209 210 let is_ready = { 211 let mut scoped_subs = app_ctx.remote.scoped_subs(app_ctx.accounts); 212 timeline::is_timeline_ready(app_ctx.ndb, &mut scoped_subs, timeline, app_ctx.accounts) 213 }; 214 215 if is_ready { 216 schedule_timeline_load( 217 &damus.timeline_loader, 218 &mut damus.inflight_timeline_loads, 219 &damus.loaded_timeline_loads, 220 app_ctx.ndb, 221 kind, 222 timeline, 223 app_ctx.accounts.selected_account_pubkey(), 224 ); 225 let txn = Transaction::new(app_ctx.ndb).expect("txn"); 226 // only thread timelines are reversed 227 let reversed = false; 228 229 if let Err(err) = timeline.poll_notes_into_view( 230 &selected_account_pk, 231 app_ctx.ndb, 232 &txn, 233 app_ctx.unknown_ids, 234 app_ctx.note_cache, 235 reversed, 236 ) { 237 error!("poll_notes_into_view: {err}"); 238 } 239 } else { 240 // TODO: show loading? 241 match kind { 242 TimelineKind::List(ListKind::Contact(_)) 243 | TimelineKind::Algo(timeline::kind::AlgoTimeline::LastPerPubkey( 244 ListKind::Contact(_), 245 )) => { 246 timeline::fetch_contact_list(timeline, app_ctx.accounts); 247 } 248 TimelineKind::List(ListKind::PeopleList(_)) 249 | TimelineKind::Algo(timeline::kind::AlgoTimeline::LastPerPubkey( 250 ListKind::PeopleList(_), 251 )) => { 252 let txn = Transaction::new(app_ctx.ndb).expect("txn"); 253 timeline::fetch_people_list(app_ctx.ndb, &txn, timeline); 254 } 255 _ => {} 256 } 257 } 258 259 if let Some(follow_packs) = damus.onboarding.get_follow_packs_mut() { 260 follow_packs.poll_for_notes(app_ctx.ndb, app_ctx.unknown_ids); 261 } 262 } 263 264 Ok(()) 265 } 266 267 /// Schedule an initial timeline load if it is not already in-flight or complete. 268 fn schedule_timeline_load( 269 loader: &TimelineLoader, 270 inflight: &mut HashSet<TimelineKind>, 271 loaded: &HashSet<TimelineKind>, 272 ndb: &nostrdb::Ndb, 273 kind: &TimelineKind, 274 timeline: &mut timeline::Timeline, 275 account_pk: &Pubkey, 276 ) { 277 if loaded.contains(kind) || inflight.contains(kind) { 278 return; 279 } 280 281 let FilterState::Ready(filter) = timeline.filter.clone() else { 282 return; 283 }; 284 285 if timeline.kind.should_subscribe_locally() { 286 timeline 287 .subscription 288 .try_add_local(*account_pk, ndb, &filter); 289 } 290 291 loader.load_timeline(kind.clone()); 292 inflight.insert(kind.clone()); 293 } 294 295 /// Drain timeline loader messages and apply them to the timeline cache. 296 #[profiling::function] 297 fn handle_timeline_loader_messages(damus: &mut Damus, app_ctx: &mut AppContext<'_>) { 298 let mut handled = 0; 299 while handled < MAX_TIMELINE_LOADER_MSGS_PER_FRAME { 300 let Some(msg) = damus.timeline_loader.try_recv() else { 301 break; 302 }; 303 handled += 1; 304 305 match msg { 306 TimelineLoaderMsg::TimelineBatch { kind, notes } => { 307 let Some(timeline) = damus.timeline_cache.get_mut(&kind) else { 308 warn!("timeline loader batch for missing timeline {:?}", kind); 309 continue; 310 }; 311 let txn = Transaction::new(app_ctx.ndb).expect("txn"); 312 if let Some(pks) = 313 timeline.insert_new(&txn, app_ctx.ndb, app_ctx.note_cache, ¬es) 314 { 315 pks.process(app_ctx.ndb, &txn, app_ctx.unknown_ids); 316 } 317 } 318 TimelineLoaderMsg::TimelineFinished { kind } => { 319 damus.inflight_timeline_loads.remove(&kind); 320 damus.loaded_timeline_loads.insert(kind); 321 } 322 TimelineLoaderMsg::Failed { kind, error } => { 323 warn!("timeline loader failed for {:?}: {}", kind, error); 324 damus.inflight_timeline_loads.remove(&kind); 325 } 326 } 327 } 328 } 329 330 #[profiling::function] 331 fn update_damus(damus: &mut Damus, app_ctx: &mut AppContext<'_>, ctx: &egui::Context) { 332 app_ctx.img_cache.urls.cache.handle_io(); 333 334 damus 335 .timeline_loader 336 .start(ctx.clone(), app_ctx.ndb.clone()); 337 338 if damus.columns(app_ctx.accounts).columns().is_empty() { 339 damus 340 .columns_mut(app_ctx.i18n, app_ctx.accounts) 341 .new_column_picker(); 342 } 343 344 match damus.state { 345 DamusState::Initializing => { 346 damus.state = DamusState::Initialized; 347 setup_selected_account_timeline_subs(&mut damus.timeline_cache, app_ctx); 348 349 if !app_ctx.settings.welcome_completed() { 350 let split = 351 egui_nav::Split::PercentFromTop(egui_nav::Percent::new(40).expect("40 <= 100")); 352 if let Some(col) = damus 353 .decks_cache 354 .selected_column_mut(app_ctx.i18n, app_ctx.accounts) 355 { 356 col.sheet_router.route_to(Route::Welcome, split); 357 } 358 } else if is_compiled_as_mobile() && !app_ctx.settings.tos_accepted() { 359 damus 360 .columns_mut(app_ctx.i18n, app_ctx.accounts) 361 .get_selected_router() 362 .route_to(Route::TosAcceptance); 363 } 364 } 365 366 DamusState::Initialized => (), 367 }; 368 369 handle_timeline_loader_messages(damus, app_ctx); 370 371 if let Err(err) = try_process_event(damus, app_ctx, ctx) { 372 error!("error processing event: {}", err); 373 } 374 } 375 376 pub(crate) fn setup_selected_account_timeline_subs( 377 timeline_cache: &mut TimelineCache, 378 app_ctx: &mut AppContext<'_>, 379 ) { 380 if let Err(err) = timeline::setup_initial_nostrdb_subs( 381 app_ctx.ndb, 382 timeline_cache, 383 *app_ctx.accounts.selected_account_pubkey(), 384 ) { 385 warn!("update_damus init: {err}"); 386 } 387 } 388 389 fn render_damus(damus: &mut Damus, app_ctx: &mut AppContext<'_>, ui: &mut egui::Ui) -> AppResponse { 390 damus 391 .note_options 392 .set(NoteOptions::Wide, is_narrow(ui.ctx())); 393 394 let app_resp = if notedeck::ui::is_narrow(ui.ctx()) { 395 render_damus_mobile(damus, app_ctx, ui) 396 } else { 397 render_damus_desktop(damus, app_ctx, ui) 398 }; 399 400 fullscreen_media_viewer_ui( 401 ui, 402 &mut damus.view_state.media_viewer, 403 app_ctx.img_cache, 404 app_ctx.media_jobs.sender(), 405 ); 406 407 // We use this for keeping timestamps and things up to date 408 //ui.ctx().request_repaint_after(Duration::from_secs(5)); 409 410 app_resp 411 } 412 413 /// Present a fullscreen media viewer if the FullscreenMedia AppOptions flag is set. This is 414 /// typically set by image carousels using a MediaAction's on_view_media callback when 415 /// an image is clicked 416 fn fullscreen_media_viewer_ui( 417 ui: &mut egui::Ui, 418 state: &mut MediaViewerState, 419 img_cache: &mut Images, 420 jobs: &MediaJobSender, 421 ) { 422 if !state.should_show(ui) { 423 if state.scene_rect.is_some() { 424 // if we shouldn't show yet we will have a scene 425 // rect, then we should clear it for next time 426 tracing::debug!("fullscreen_media_viewer_ui: resetting scene rect"); 427 state.scene_rect = None; 428 } 429 return; 430 } 431 432 let resp = MediaViewer::new(state) 433 .fullscreen(true) 434 .ui(img_cache, jobs, ui); 435 436 if resp.clicked() 437 || ui 438 .ctx() 439 .input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape)) 440 { 441 fullscreen_media_close(state); 442 } 443 } 444 445 /// Close the fullscreen media player. This also resets the scene_rect state 446 fn fullscreen_media_close(state: &mut MediaViewerState) { 447 state.flags.set(MediaViewerFlags::Open, false); 448 } 449 450 /* 451 fn determine_key_storage_type() -> KeyStorageType { 452 #[cfg(target_os = "macos")] 453 { 454 KeyStorageType::MacOS 455 } 456 457 #[cfg(target_os = "linux")] 458 { 459 KeyStorageType::Linux 460 } 461 462 #[cfg(not(any(target_os = "macos", target_os = "linux")))] 463 { 464 KeyStorageType::None 465 } 466 } 467 */ 468 469 impl Damus { 470 /// Called once before the first frame. 471 pub fn new(app_context: &mut AppContext<'_>, args: &[String]) -> Self { 472 // arg parsing 473 474 let (parsed_args, unrecognized_args) = 475 ColumnsArgs::parse(args, Some(app_context.accounts.selected_account_pubkey())); 476 477 let account = app_context.accounts.selected_account_pubkey_bytes(); 478 479 let mut timeline_cache = TimelineCache::default(); 480 let mut options = AppOptions::default(); 481 let tmp_columns = !parsed_args.columns.is_empty(); 482 options.set(AppOptions::TmpColumns, tmp_columns); 483 options.set( 484 AppOptions::Debug, 485 app_context.args.options.contains(NotedeckOptions::Debug), 486 ); 487 options.set( 488 AppOptions::SinceOptimize, 489 parsed_args.is_flag_set(ColumnsFlag::SinceOptimize), 490 ); 491 492 let decks_cache = if tmp_columns { 493 info!("DecksCache: loading from command line arguments"); 494 let mut columns: Columns = Columns::new(); 495 let txn = Transaction::new(app_context.ndb).unwrap(); 496 for col in &parsed_args.columns { 497 let timeline_kind = col.clone().into_timeline_kind(); 498 let mut scoped_subs = app_context.remote.scoped_subs(app_context.accounts); 499 if let Some(add_result) = columns.add_new_timeline_column( 500 &mut timeline_cache, 501 &txn, 502 app_context.ndb, 503 app_context.note_cache, 504 &mut scoped_subs, 505 &timeline_kind, 506 *app_context.accounts.selected_account_pubkey(), 507 ) { 508 add_result.process( 509 app_context.ndb, 510 app_context.note_cache, 511 &txn, 512 &mut timeline_cache, 513 app_context.unknown_ids, 514 ); 515 } 516 } 517 518 columns_to_decks_cache(app_context.i18n, columns, account) 519 } else if let Some(decks_cache) = crate::storage::load_decks_cache( 520 app_context.path, 521 app_context.ndb, 522 &mut timeline_cache, 523 app_context.i18n, 524 ) { 525 info!( 526 "DecksCache: loading from disk {}", 527 crate::storage::DECKS_CACHE_FILE 528 ); 529 decks_cache 530 } else { 531 info!("DecksCache: creating new with demo configuration"); 532 DecksCache::new_with_demo_config(&mut timeline_cache, app_context) 533 //for (pk, _) in &app_context.accounts.cache { 534 // cache.add_deck_default(*pk); 535 //} 536 }; 537 538 let support = Support::new(app_context.path); 539 let note_options = get_note_options(parsed_args, app_context.settings); 540 let threads = Threads::default(); 541 542 Self { 543 timeline_cache, 544 drafts: Drafts::default(), 545 state: DamusState::Initializing, 546 note_options, 547 options, 548 //frame_history: FrameHistory::default(), 549 view_state: ViewState::default(), 550 support, 551 decks_cache, 552 unrecognized_args, 553 threads, 554 onboarding: Onboarding::default(), 555 hovered_column: None, 556 timeline_loader: TimelineLoader::default(), 557 inflight_timeline_loads: HashSet::new(), 558 loaded_timeline_loads: HashSet::new(), 559 } 560 } 561 562 /// Scroll to the top of the currently selected column. This is called 563 /// by the chrome when you click the toolbar 564 pub fn scroll_to_top(&mut self) { 565 self.options.insert(AppOptions::ScrollToTop) 566 } 567 568 pub fn columns_mut(&mut self, i18n: &mut Localization, accounts: &Accounts) -> &mut Columns { 569 get_active_columns_mut(i18n, accounts, &mut self.decks_cache) 570 } 571 572 pub fn columns(&self, accounts: &Accounts) -> &Columns { 573 get_active_columns(accounts, &self.decks_cache) 574 } 575 576 pub fn mock<P: AsRef<Path>>(data_path: P) -> Self { 577 let mut i18n = Localization::default(); 578 let decks_cache = DecksCache::default_decks_cache(&mut i18n); 579 580 let path = DataPath::new(&data_path); 581 let imgcache_dir = path.path(DataPathType::Cache); 582 let _ = std::fs::create_dir_all(imgcache_dir.clone()); 583 let options = AppOptions::default() | AppOptions::Debug | AppOptions::TmpColumns; 584 585 let support = Support::new(&path); 586 587 Self { 588 timeline_cache: TimelineCache::default(), 589 drafts: Drafts::default(), 590 state: DamusState::Initializing, 591 note_options: NoteOptions::default(), 592 //frame_history: FrameHistory::default(), 593 view_state: ViewState::default(), 594 support, 595 options, 596 decks_cache, 597 unrecognized_args: BTreeSet::default(), 598 threads: Threads::default(), 599 onboarding: Onboarding::default(), 600 hovered_column: None, 601 timeline_loader: TimelineLoader::default(), 602 inflight_timeline_loads: HashSet::new(), 603 loaded_timeline_loads: HashSet::new(), 604 } 605 } 606 607 pub fn unrecognized_args(&self) -> &BTreeSet<String> { 608 &self.unrecognized_args 609 } 610 611 /// Navigate to the Home (contact list) timeline. 612 pub fn navigate_home(&mut self, ctx: &mut AppContext) { 613 crate::toolbar::ToolbarAction::Home.process(self, ctx); 614 } 615 616 /// Navigate to the Search view. 617 pub fn navigate_search(&mut self, ctx: &mut AppContext) { 618 crate::toolbar::ToolbarAction::Search.process(self, ctx); 619 } 620 621 /// Navigate to the Notifications timeline. 622 pub fn navigate_notifications(&mut self, ctx: &mut AppContext) { 623 crate::toolbar::ToolbarAction::Notifications.process(self, ctx); 624 } 625 626 /// Check if there are unseen notifications. 627 pub fn has_unseen_notifications(&mut self, accounts: ¬edeck::Accounts) -> bool { 628 let active_col = self.columns(accounts).selected as usize; 629 crate::toolbar::unseen_notification(self, accounts, active_col) 630 } 631 632 /// Returns which toolbar tab matches the current active route. 633 /// 0=Home, 1=Search, 2=Notifications, None=no match 634 pub fn active_toolbar_tab(&self, accounts: ¬edeck::Accounts) -> Option<u8> { 635 use crate::timeline::kind::ListKind; 636 use crate::timeline::TimelineKind; 637 638 let cols = self.columns(accounts); 639 let active_col = cols.selected as usize; 640 let top = cols.column(active_col).router().top(); 641 let pk = accounts.get_selected_account().keypair().pubkey; 642 643 match top { 644 Route::Timeline(TimelineKind::List(ListKind::Contact(contact_pk))) 645 if contact_pk == pk => 646 { 647 Some(0) 648 } 649 Route::Search => Some(1), 650 Route::Timeline(TimelineKind::Notifications(notif_pk)) if notif_pk == pk => Some(2), 651 _ => None, 652 } 653 } 654 } 655 656 fn get_note_options(args: ColumnsArgs, settings_handler: &mut SettingsHandler) -> NoteOptions { 657 let mut note_options = NoteOptions::default(); 658 659 note_options.set( 660 NoteOptions::Textmode, 661 args.is_flag_set(ColumnsFlag::Textmode), 662 ); 663 note_options.set( 664 NoteOptions::ScrambleText, 665 args.is_flag_set(ColumnsFlag::Scramble), 666 ); 667 note_options.set( 668 NoteOptions::HideMedia, 669 args.is_flag_set(ColumnsFlag::NoMedia), 670 ); 671 note_options.set( 672 NoteOptions::RepliesNewestFirst, 673 settings_handler.show_replies_newest_first(), 674 ); 675 note_options 676 } 677 678 /* 679 fn circle_icon(ui: &mut egui::Ui, openness: f32, response: &egui::Response) { 680 let stroke = ui.style().interact(&response).fg_stroke; 681 let radius = egui::lerp(2.0..=3.0, openness); 682 ui.painter() 683 .circle_filled(response.rect.center(), radius, stroke.color); 684 } 685 */ 686 687 #[profiling::function] 688 fn render_damus_mobile( 689 app: &mut Damus, 690 app_ctx: &mut AppContext<'_>, 691 ui: &mut egui::Ui, 692 ) -> AppResponse { 693 let mut can_take_drag_from = Vec::new(); 694 let active_col = app.columns_mut(app_ctx.i18n, app_ctx.accounts).selected as usize; 695 let mut app_action: Option<AppAction> = None; 696 697 let rect = ui.available_rect_before_wrap(); 698 if !app.columns(app_ctx.accounts).columns().is_empty() { 699 let resp = nav::render_nav( 700 active_col, 701 ui.available_rect_before_wrap(), 702 app, 703 app_ctx, 704 ui, 705 ); 706 707 can_take_drag_from.extend(resp.can_take_drag_from()); 708 709 let r = resp.process_render_nav_response(app, app_ctx, ui); 710 if let Some(r) = r { 711 match r { 712 ProcessNavResult::SwitchOccurred => { 713 if !app.options.contains(AppOptions::TmpColumns) { 714 storage::save_decks_cache(app_ctx.path, &app.decks_cache); 715 } 716 } 717 718 ProcessNavResult::PfpClicked => { 719 app_action = Some(AppAction::ToggleChrome); 720 } 721 722 ProcessNavResult::SwitchAccount(pubkey) => { 723 // Add as pubkey-only account if not already present 724 let kp = enostr::Keypair::only_pubkey(pubkey); 725 let _ = app_ctx.accounts.add_account(kp); 726 727 app_ctx.select_account(&pubkey); 728 setup_selected_account_timeline_subs(&mut app.timeline_cache, app_ctx); 729 } 730 731 ProcessNavResult::ExternalNoteAction(note_action) => { 732 app_action = Some(AppAction::Note(note_action)); 733 } 734 } 735 } 736 } 737 738 hovering_post_button(ui, app, app_ctx, rect); 739 740 AppResponse::action(app_action).drag(can_take_drag_from) 741 } 742 743 fn hovering_post_button( 744 ui: &mut egui::Ui, 745 app: &mut Damus, 746 app_ctx: &mut AppContext, 747 mut rect: egui::Rect, 748 ) { 749 let should_show_compose = should_show_compose_button(&app.decks_cache, app_ctx.accounts); 750 let btn_id = ui.id().with("hover_post_btn"); 751 let button_y = ui 752 .ctx() 753 .animate_bool_responsive(btn_id, should_show_compose); 754 755 rect.min.x = rect.max.x - (if is_narrow(ui.ctx()) { 60.0 } else { 100.0 } * button_y); 756 rect.min.y = rect.max.y - 100.0; 757 rect.max.x += 48.0 * (1.0 - button_y); 758 759 let darkmode = ui.ctx().style().visuals.dark_mode; 760 761 // only show the compose button on profile pages and on home 762 let compose_resp = ui 763 .put(rect, ui::post::compose_note_button(darkmode)) 764 .on_hover_cursor(egui::CursorIcon::PointingHand); 765 if compose_resp.clicked() && !app.columns(app_ctx.accounts).columns().is_empty() { 766 // just use the some side panel logic as the desktop 767 DesktopSidePanel::perform_action( 768 &mut app.decks_cache, 769 app_ctx.accounts, 770 SidePanelAction::ComposeNote, 771 app_ctx.i18n, 772 ); 773 } 774 } 775 776 /// Should we show the compose button? When in threads we should hide it, etc 777 fn should_show_compose_button(decks: &DecksCache, accounts: &Accounts) -> bool { 778 let Some(col) = decks.selected_column(accounts) else { 779 return false; 780 }; 781 782 match col.router().top() { 783 Route::Timeline(timeline_kind) => { 784 match timeline_kind { 785 TimelineKind::List(list_kind) => match list_kind { 786 ListKind::Contact(_pk) => true, 787 ListKind::PeopleList(_) => true, 788 }, 789 790 TimelineKind::Algo(_pk) => true, 791 TimelineKind::Profile(_pk) => true, 792 TimelineKind::Universe => true, 793 TimelineKind::Generic(_) => true, 794 TimelineKind::Hashtag(_) => true, 795 796 // no! 797 TimelineKind::Search(_) => false, 798 TimelineKind::Notifications(_) => false, 799 } 800 } 801 802 Route::Thread(_) => false, 803 Route::Accounts(_) => false, 804 Route::Reply(_) => false, 805 Route::Quote(_) => false, 806 Route::Relays => false, 807 Route::Settings => false, 808 Route::ComposeNote => false, 809 Route::AddColumn(_) => false, 810 Route::EditProfile(_) => false, 811 Route::Support => false, 812 Route::NewDeck => false, 813 Route::Search => false, 814 Route::EditDeck(_) => false, 815 Route::Wallet(_) => false, 816 Route::CustomizeZapAmount(_) => false, 817 Route::RepostDecision(_) => false, 818 Route::Following(_) => false, 819 Route::FollowedBy(_) => false, 820 Route::TosAcceptance => false, 821 Route::Welcome => false, 822 Route::Report(_) => false, 823 } 824 } 825 826 #[profiling::function] 827 fn render_damus_desktop( 828 app: &mut Damus, 829 app_ctx: &mut AppContext<'_>, 830 ui: &mut egui::Ui, 831 ) -> AppResponse { 832 let screen_size = ui.ctx().screen_rect().width(); 833 let calc_panel_width = (screen_size 834 / get_active_columns(app_ctx.accounts, &app.decks_cache).num_columns() as f32) 835 - 30.0; 836 let min_width = 320.0; 837 let need_scroll = calc_panel_width < min_width; 838 let panel_sizes = if need_scroll { 839 Size::exact(min_width) 840 } else { 841 Size::remainder() 842 }; 843 844 ui.spacing_mut().item_spacing.x = 0.0; 845 846 if need_scroll { 847 egui::ScrollArea::horizontal() 848 .show(ui, |ui| timelines_view(ui, panel_sizes, app, app_ctx)) 849 .inner 850 } else { 851 timelines_view(ui, panel_sizes, app, app_ctx) 852 } 853 } 854 855 fn timelines_view( 856 ui: &mut egui::Ui, 857 sizes: Size, 858 app: &mut Damus, 859 ctx: &mut AppContext<'_>, 860 ) -> AppResponse { 861 let num_cols = get_active_columns(ctx.accounts, &app.decks_cache).num_columns(); 862 let mut side_panel_action: Option<nav::SwitchingAction> = None; 863 let mut responses = Vec::with_capacity(num_cols); 864 865 let mut can_take_drag_from = Vec::new(); 866 867 StripBuilder::new(ui) 868 .size(Size::exact(ui::side_panel::SIDE_PANEL_WIDTH)) 869 .sizes(sizes, num_cols) 870 .clip(true) 871 .horizontal(|mut strip| { 872 strip.cell(|ui| { 873 let rect = ui.available_rect_before_wrap(); 874 // Clone the route to avoid holding a borrow on app.decks_cache 875 let current_route = get_active_columns(ctx.accounts, &app.decks_cache) 876 .selected() 877 .map(|col| col.router().top().clone()); 878 let side_panel = DesktopSidePanel::new( 879 ctx.accounts.get_selected_account(), 880 &app.decks_cache, 881 ctx.i18n, 882 ctx.ndb, 883 ctx.img_cache, 884 ctx.media_jobs.sender(), 885 current_route.as_ref(), 886 ctx.remote.relay_inspect(), 887 ) 888 .show(ui); 889 890 if let Some(side_panel) = side_panel { 891 if side_panel.response.clicked() || side_panel.response.secondary_clicked() { 892 if let Some(action) = DesktopSidePanel::perform_action( 893 &mut app.decks_cache, 894 ctx.accounts, 895 side_panel.action, 896 ctx.i18n, 897 ) { 898 side_panel_action = Some(action); 899 } 900 } 901 } 902 903 // debug 904 /* 905 ui.painter().rect( 906 rect, 907 0, 908 egui::Color32::RED, 909 egui::Stroke::new(1.0, egui::Color32::BLUE), 910 egui::StrokeKind::Inside, 911 ); 912 */ 913 914 // vertical sidebar line 915 ui.painter().vline( 916 rect.right(), 917 rect.y_range(), 918 ui.visuals().widgets.noninteractive.bg_stroke, 919 ); 920 }); 921 922 app.hovered_column = None; 923 924 for col_index in 0..num_cols { 925 strip.cell(|ui| { 926 let rect = ui.available_rect_before_wrap(); 927 let v_line_stroke = ui.visuals().widgets.noninteractive.bg_stroke; 928 let inner_rect = { 929 let mut inner = rect; 930 inner.set_right(rect.right() - v_line_stroke.width); 931 inner 932 }; 933 let resp = nav::render_nav(col_index, inner_rect, app, ctx, ui); 934 can_take_drag_from.extend(resp.can_take_drag_from()); 935 responses.push(resp); 936 937 // Track hovered column for mouse back/forward navigation 938 if ui.rect_contains_pointer(rect) { 939 app.hovered_column = Some(col_index); 940 } 941 942 // vertical line 943 ui.painter() 944 .vline(rect.right(), rect.y_range(), v_line_stroke); 945 946 // we need borrow ui context for processing, so proces 947 // responses in the last cell 948 949 if col_index == num_cols - 1 {} 950 }); 951 952 //strip.cell(|ui| timeline::timeline_view(ui, app, timeline_ind)); 953 } 954 }); 955 956 // process the side panel action after so we don't change the number of columns during 957 // StripBuilder rendering 958 let mut save_cols = false; 959 if let Some(action) = side_panel_action { 960 save_cols = save_cols || action.process(&mut app.timeline_cache, &mut app.decks_cache, ctx); 961 } 962 963 let mut app_action: Option<AppAction> = None; 964 965 for response in responses { 966 let nav_result = response.process_render_nav_response(app, ctx, ui); 967 968 if let Some(nr) = nav_result { 969 match nr { 970 ProcessNavResult::SwitchOccurred => save_cols = true, 971 972 ProcessNavResult::PfpClicked => { 973 app_action = Some(AppAction::ToggleChrome); 974 } 975 976 ProcessNavResult::SwitchAccount(pubkey) => { 977 // Add as pubkey-only account if not already present 978 let kp = enostr::Keypair::only_pubkey(pubkey); 979 let _ = ctx.accounts.add_account(kp); 980 981 ctx.select_account(&pubkey); 982 setup_selected_account_timeline_subs(&mut app.timeline_cache, ctx); 983 } 984 985 ProcessNavResult::ExternalNoteAction(note_action) => { 986 app_action = Some(AppAction::Note(note_action)); 987 } 988 } 989 } 990 } 991 992 if app.options.contains(AppOptions::TmpColumns) { 993 save_cols = false; 994 } 995 996 if save_cols { 997 storage::save_decks_cache(ctx.path, &app.decks_cache); 998 } 999 1000 AppResponse::action(app_action).drag(can_take_drag_from) 1001 } 1002 1003 impl notedeck::App for Damus { 1004 #[profiling::function] 1005 fn update(&mut self, ctx: &mut AppContext<'_>, egui_ctx: &egui::Context) { 1006 update_damus(self, ctx, egui_ctx); 1007 } 1008 1009 #[profiling::function] 1010 fn render(&mut self, ctx: &mut AppContext<'_>, ui: &mut egui::Ui) -> AppResponse { 1011 render_damus(self, ctx, ui) 1012 } 1013 } 1014 1015 pub fn get_active_columns<'a>(accounts: &Accounts, decks_cache: &'a DecksCache) -> &'a Columns { 1016 get_decks(accounts, decks_cache).active().columns() 1017 } 1018 1019 pub fn get_decks<'a>(accounts: &Accounts, decks_cache: &'a DecksCache) -> &'a Decks { 1020 let key = accounts.selected_account_pubkey(); 1021 decks_cache.decks(key) 1022 } 1023 1024 pub fn get_active_columns_mut<'a>( 1025 i18n: &mut Localization, 1026 accounts: &Accounts, 1027 decks_cache: &'a mut DecksCache, 1028 ) -> &'a mut Columns { 1029 get_decks_mut(i18n, accounts, decks_cache) 1030 .active_mut() 1031 .columns_mut() 1032 } 1033 1034 pub fn get_decks_mut<'a>( 1035 i18n: &mut Localization, 1036 accounts: &Accounts, 1037 decks_cache: &'a mut DecksCache, 1038 ) -> &'a mut Decks { 1039 decks_cache.decks_mut(i18n, accounts.selected_account_pubkey()) 1040 } 1041 1042 fn columns_to_decks_cache(i18n: &mut Localization, cols: Columns, key: &[u8; 32]) -> DecksCache { 1043 let mut account_to_decks: HashMap<Pubkey, Decks> = Default::default(); 1044 let decks = Decks::new(crate::decks::Deck::new_with_columns( 1045 crate::decks::Deck::default_icon(), 1046 tr!(i18n, "My Deck", "Title for the user's deck"), 1047 cols, 1048 )); 1049 1050 let account = Pubkey::new(*key); 1051 account_to_decks.insert(account, decks); 1052 DecksCache::new(account_to_decks, i18n) 1053 }