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