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