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