app.rs (36965B)
1 use crate::account_manager::AccountManager; 2 use crate::actionbar::BarResult; 3 use crate::app_creation::setup_cc; 4 use crate::app_style::user_requested_visuals_change; 5 use crate::args::Args; 6 use crate::column::ColumnKind; 7 use crate::draft::Drafts; 8 use crate::error::{Error, FilterError}; 9 use crate::filter::FilterState; 10 use crate::frame_history::FrameHistory; 11 use crate::imgcache::ImageCache; 12 use crate::key_storage::KeyStorageType; 13 use crate::note::NoteRef; 14 use crate::notecache::{CachedNote, NoteCache}; 15 use crate::relay_pool_manager::RelayPoolManager; 16 use crate::route::Route; 17 use crate::subscriptions::{SubKind, Subscriptions}; 18 use crate::thread::{DecrementResult, Threads}; 19 use crate::timeline::{Timeline, TimelineSource, ViewFilter}; 20 use crate::ui::note::PostAction; 21 use crate::ui::{self, AccountSelectionWidget, DesktopGlobalPopup}; 22 use crate::ui::{DesktopSidePanel, RelayView, View}; 23 use crate::unknowns::UnknownIds; 24 use crate::{filter, Result}; 25 use egui_nav::{Nav, NavAction}; 26 use enostr::{ClientMessage, RelayEvent, RelayMessage, RelayPool}; 27 use std::cell::RefCell; 28 use std::rc::Rc; 29 use uuid::Uuid; 30 31 use egui::{Context, Frame, Style}; 32 use egui_extras::{Size, StripBuilder}; 33 34 use nostrdb::{Config, Filter, Ndb, Note, Transaction}; 35 36 use std::collections::HashMap; 37 use std::path::Path; 38 use std::time::Duration; 39 use tracing::{debug, error, info, trace, warn}; 40 41 #[derive(Debug, Eq, PartialEq, Clone)] 42 pub enum DamusState { 43 Initializing, 44 Initialized, 45 } 46 47 /// We derive Deserialize/Serialize so we can persist app state on shutdown. 48 pub struct Damus { 49 state: DamusState, 50 note_cache: NoteCache, 51 pub pool: RelayPool, 52 53 /// global navigation for account management popups, etc. 54 pub global_nav: Vec<Route>, 55 56 pub timelines: Vec<Timeline>, 57 pub selected_timeline: i32, 58 59 pub ndb: Ndb, 60 pub unknown_ids: UnknownIds, 61 pub drafts: Drafts, 62 pub threads: Threads, 63 pub img_cache: ImageCache, 64 pub account_manager: AccountManager, 65 pub subscriptions: Subscriptions, 66 67 frame_history: crate::frame_history::FrameHistory, 68 69 // TODO: make these flags 70 is_mobile: bool, 71 pub debug: bool, 72 pub since_optimize: bool, 73 pub textmode: bool, 74 pub show_account_switcher: bool, 75 pub show_global_popup: bool, 76 } 77 78 fn relay_setup(pool: &mut RelayPool, ctx: &egui::Context) { 79 let ctx = ctx.clone(); 80 let wakeup = move || { 81 ctx.request_repaint(); 82 }; 83 if let Err(e) = pool.add_url("ws://localhost:8080".to_string(), wakeup.clone()) { 84 error!("{:?}", e) 85 } 86 if let Err(e) = pool.add_url("wss://relay.damus.io".to_string(), wakeup.clone()) { 87 error!("{:?}", e) 88 } 89 //if let Err(e) = pool.add_url("wss://pyramid.fiatjaf.com".to_string(), wakeup.clone()) { 90 //error!("{:?}", e) 91 //} 92 if let Err(e) = pool.add_url("wss://nos.lol".to_string(), wakeup.clone()) { 93 error!("{:?}", e) 94 } 95 if let Err(e) = pool.add_url("wss://nostr.wine".to_string(), wakeup.clone()) { 96 error!("{:?}", e) 97 } 98 if let Err(e) = pool.add_url("wss://purplepag.es".to_string(), wakeup) { 99 error!("{:?}", e) 100 } 101 } 102 103 fn send_initial_timeline_filter(damus: &mut Damus, timeline: usize, to: &str) { 104 let can_since_optimize = damus.since_optimize; 105 106 let filter_state = damus.timelines[timeline].filter.clone(); 107 108 match filter_state { 109 FilterState::Broken(err) => { 110 error!( 111 "FetchingRemote state in broken state when sending initial timeline filter? {err}" 112 ); 113 } 114 115 FilterState::FetchingRemote(_unisub) => { 116 error!("FetchingRemote state when sending initial timeline filter?"); 117 } 118 119 FilterState::GotRemote(_sub) => { 120 error!("GotRemote state when sending initial timeline filter?"); 121 } 122 123 FilterState::Ready(filter) => { 124 let filter = filter.to_owned(); 125 let new_filters = filter.into_iter().map(|f| { 126 // limit the size of remote filters 127 let default_limit = filter::default_remote_limit(); 128 let mut lim = f.limit().unwrap_or(default_limit); 129 let mut filter = f; 130 if lim > default_limit { 131 lim = default_limit; 132 filter = filter.limit_mut(lim); 133 } 134 135 let notes = damus.timelines[timeline].notes(ViewFilter::NotesAndReplies); 136 137 // Should we since optimize? Not always. For example 138 // if we only have a few notes locally. One way to 139 // determine this is by looking at the current filter 140 // and seeing what its limit is. If we have less 141 // notes than the limit, we might want to backfill 142 // older notes 143 if can_since_optimize && filter::should_since_optimize(lim, notes.len()) { 144 filter = filter::since_optimize_filter(filter, notes); 145 } else { 146 warn!("Skipping since optimization for {:?}: number of local notes is less than limit, attempting to backfill.", filter); 147 } 148 149 filter 150 }).collect(); 151 152 let sub_id = damus.gen_subid(&SubKind::Initial); 153 damus 154 .subscriptions() 155 .insert(sub_id.clone(), SubKind::Initial); 156 157 let cmd = ClientMessage::req(sub_id, new_filters); 158 damus.pool.send_to(&cmd, to); 159 } 160 161 // we need some data first 162 FilterState::NeedsRemote(filter) => { 163 let uid = damus.timelines[timeline].uid; 164 let sub_kind = SubKind::FetchingContactList(uid); 165 let sub_id = damus.gen_subid(&sub_kind); 166 let local_sub = damus.ndb.subscribe(&filter).expect("sub"); 167 168 damus.timelines[timeline].filter = 169 FilterState::fetching_remote(sub_id.clone(), local_sub); 170 171 damus.subscriptions().insert(sub_id.clone(), sub_kind); 172 173 damus.pool.subscribe(sub_id, filter.to_owned()); 174 } 175 } 176 } 177 178 fn send_initial_filters(damus: &mut Damus, relay_url: &str) { 179 info!("Sending initial filters to {}", relay_url); 180 let timelines = damus.timelines.len(); 181 182 for i in 0..timelines { 183 send_initial_timeline_filter(damus, i, relay_url); 184 } 185 } 186 187 enum ContextAction { 188 SetPixelsPerPoint(f32), 189 } 190 191 fn handle_key_events( 192 input: &egui::InputState, 193 pixels_per_point: f32, 194 damus: &mut Damus, 195 ) -> Option<ContextAction> { 196 let amount = 0.2; 197 198 // We can't do things like setting the pixels_per_point when we are holding 199 // on to an locked InputState context, so we need to pass actions externally 200 let mut context_action: Option<ContextAction> = None; 201 202 for event in &input.raw.events { 203 if let egui::Event::Key { 204 key, pressed: true, .. 205 } = event 206 { 207 match key { 208 egui::Key::Equals => { 209 context_action = 210 Some(ContextAction::SetPixelsPerPoint(pixels_per_point + amount)); 211 } 212 egui::Key::Minus => { 213 context_action = 214 Some(ContextAction::SetPixelsPerPoint(pixels_per_point - amount)); 215 } 216 egui::Key::J => { 217 damus.select_down(); 218 } 219 egui::Key::K => { 220 damus.select_up(); 221 } 222 egui::Key::H => { 223 damus.select_left(); 224 } 225 egui::Key::L => { 226 damus.select_left(); 227 } 228 _ => {} 229 } 230 } 231 } 232 233 context_action 234 } 235 236 fn try_process_event(damus: &mut Damus, ctx: &egui::Context) -> Result<()> { 237 let ppp = ctx.pixels_per_point(); 238 let res = ctx.input(|i| handle_key_events(i, ppp, damus)); 239 if let Some(action) = res { 240 match action { 241 ContextAction::SetPixelsPerPoint(amt) => { 242 ctx.set_pixels_per_point(amt); 243 } 244 } 245 } 246 247 let ctx2 = ctx.clone(); 248 let wakeup = move || { 249 ctx2.request_repaint(); 250 }; 251 damus.pool.keepalive_ping(wakeup); 252 253 // pool stuff 254 while let Some(ev) = damus.pool.try_recv() { 255 let relay = ev.relay.to_owned(); 256 257 match (&ev.event).into() { 258 RelayEvent::Opened => send_initial_filters(damus, &relay), 259 // TODO: handle reconnects 260 RelayEvent::Closed => warn!("{} connection closed", &relay), 261 RelayEvent::Error(e) => error!("{}: {}", &relay, e), 262 RelayEvent::Other(msg) => trace!("other event {:?}", &msg), 263 RelayEvent::Message(msg) => process_message(damus, &relay, &msg), 264 } 265 } 266 267 for timeline in 0..damus.timelines.len() { 268 let src = TimelineSource::column(timeline); 269 270 if let Ok(true) = is_timeline_ready(damus, timeline) { 271 let txn = Transaction::new(&damus.ndb).expect("txn"); 272 if let Err(err) = src.poll_notes_into_view(&txn, damus) { 273 error!("poll_notes_into_view: {err}"); 274 } 275 } else { 276 // TODO: show loading? 277 } 278 } 279 280 if damus.unknown_ids.ready_to_send() { 281 unknown_id_send(damus); 282 } 283 284 Ok(()) 285 } 286 287 fn unknown_id_send(damus: &mut Damus) { 288 let filter = damus.unknown_ids.filter().expect("filter"); 289 info!( 290 "Getting {} unknown ids from relays", 291 damus.unknown_ids.ids().len() 292 ); 293 let msg = ClientMessage::req("unknownids".to_string(), filter); 294 damus.unknown_ids.clear(); 295 damus.pool.send(&msg); 296 } 297 298 /// Check our timeline filter and see if we have any filter data ready. 299 /// Our timelines may require additional data before it is functional. For 300 /// example, when we have to fetch a contact list before we do the actual 301 /// following list query. 302 fn is_timeline_ready(damus: &mut Damus, timeline: usize) -> Result<bool> { 303 let sub = match &damus.timelines[timeline].filter { 304 FilterState::GotRemote(sub) => *sub, 305 FilterState::Ready(_f) => return Ok(true), 306 _ => return Ok(false), 307 }; 308 309 // We got at least one eose for our filter request. Let's see 310 // if nostrdb is done processing it yet. 311 let res = damus.ndb.poll_for_notes(sub, 1); 312 if res.is_empty() { 313 debug!("check_timeline_filter_state: no notes found (yet?) for timeline {timeline}"); 314 return Ok(false); 315 } 316 317 info!("notes found for contact timeline after GotRemote!"); 318 319 let note_key = res[0]; 320 321 let filter = { 322 let txn = Transaction::new(&damus.ndb).expect("txn"); 323 let note = damus.ndb.get_note_by_key(&txn, note_key).expect("note"); 324 filter::filter_from_tags(¬e).map(|f| f.into_follow_filter()) 325 }; 326 327 // TODO: into_follow_filter is hardcoded to contact lists, let's generalize 328 match filter { 329 Err(Error::Filter(e)) => { 330 error!("got broken when building filter {e}"); 331 damus.timelines[timeline].filter = FilterState::broken(e); 332 } 333 Err(err) => { 334 error!("got broken when building filter {err}"); 335 damus.timelines[timeline].filter = FilterState::broken(FilterError::EmptyContactList); 336 return Err(err); 337 } 338 Ok(filter) => { 339 // we just switched to the ready state, we should send initial 340 // queries and setup the local subscription 341 info!("Found contact list! Setting up local and remote contact list query"); 342 setup_initial_timeline(damus, timeline, &filter).expect("setup init"); 343 damus.timelines[timeline].filter = FilterState::ready(filter.clone()); 344 345 let ck = &damus.timelines[timeline].kind; 346 let subid = damus.gen_subid(&SubKind::Column(ck.clone())); 347 damus.pool.subscribe(subid, filter) 348 } 349 } 350 351 Ok(true) 352 } 353 354 #[cfg(feature = "profiling")] 355 fn setup_profiling() { 356 puffin::set_scopes_on(true); // tell puffin to collect data 357 } 358 359 fn setup_initial_timeline(damus: &mut Damus, timeline: usize, filters: &[Filter]) -> Result<()> { 360 damus.timelines[timeline].subscription = Some(damus.ndb.subscribe(filters)?); 361 let txn = Transaction::new(&damus.ndb)?; 362 debug!( 363 "querying nostrdb sub {:?} {:?}", 364 damus.timelines[timeline].subscription, damus.timelines[timeline].filter 365 ); 366 let lim = filters[0].limit().unwrap_or(crate::filter::default_limit()) as i32; 367 let results = damus.ndb.query(&txn, filters, lim)?; 368 369 let filters = { 370 let views = &damus.timelines[timeline].views; 371 let filters: Vec<fn(&CachedNote, &Note) -> bool> = 372 views.iter().map(|v| v.filter.filter()).collect(); 373 filters 374 }; 375 376 for result in results { 377 for (view, filter) in filters.iter().enumerate() { 378 if filter( 379 damus 380 .note_cache_mut() 381 .cached_note_or_insert_mut(result.note_key, &result.note), 382 &result.note, 383 ) { 384 damus.timelines[timeline].views[view].notes.push(NoteRef { 385 key: result.note_key, 386 created_at: result.note.created_at(), 387 }) 388 } 389 } 390 } 391 392 Ok(()) 393 } 394 395 fn setup_initial_nostrdb_subs(damus: &mut Damus) -> Result<()> { 396 let timelines = damus.timelines.len(); 397 for i in 0..timelines { 398 let filter = damus.timelines[i].filter.clone(); 399 match filter { 400 FilterState::Ready(filters) => setup_initial_timeline(damus, i, &filters)?, 401 402 FilterState::Broken(err) => { 403 error!("FetchingRemote state broken in setup_initial_nostr_subs: {err}") 404 } 405 FilterState::FetchingRemote(_) => { 406 error!("FetchingRemote state in setup_initial_nostr_subs") 407 } 408 FilterState::GotRemote(_) => { 409 error!("GotRemote state in setup_initial_nostr_subs") 410 } 411 FilterState::NeedsRemote(_filters) => { 412 // can't do anything yet, we defer to first connect to send 413 // remote filters 414 } 415 } 416 } 417 418 Ok(()) 419 } 420 421 fn update_damus(damus: &mut Damus, ctx: &egui::Context) { 422 if damus.state == DamusState::Initializing { 423 #[cfg(feature = "profiling")] 424 setup_profiling(); 425 426 damus.state = DamusState::Initialized; 427 // this lets our eose handler know to close unknownids right away 428 damus 429 .subscriptions() 430 .insert("unknownids".to_string(), SubKind::OneShot); 431 setup_initial_nostrdb_subs(damus).expect("home subscription failed"); 432 } 433 434 if let Err(err) = try_process_event(damus, ctx) { 435 error!("error processing event: {}", err); 436 } 437 } 438 439 fn process_event(damus: &mut Damus, _subid: &str, event: &str) { 440 #[cfg(feature = "profiling")] 441 puffin::profile_function!(); 442 443 //info!("processing event {}", event); 444 if let Err(_err) = damus.ndb.process_event(event) { 445 error!("error processing event {}", event); 446 } 447 } 448 449 fn handle_eose(damus: &mut Damus, subid: &str, relay_url: &str) -> Result<()> { 450 let sub_kind = if let Some(sub_kind) = damus.subscriptions().get(subid) { 451 sub_kind 452 } else { 453 let n_subids = damus.subscriptions().len(); 454 warn!( 455 "got unknown eose subid {}, {} tracked subscriptions", 456 subid, n_subids 457 ); 458 return Ok(()); 459 }; 460 461 match *sub_kind { 462 SubKind::Column(_) => { 463 // eose on column? whatevs 464 } 465 SubKind::Initial => { 466 let txn = Transaction::new(&damus.ndb)?; 467 UnknownIds::update(&txn, damus); 468 // this is possible if this is the first time 469 if damus.unknown_ids.ready_to_send() { 470 unknown_id_send(damus); 471 } 472 } 473 474 // oneshot subs just close when they're done 475 SubKind::OneShot => { 476 let msg = ClientMessage::close(subid.to_string()); 477 damus.pool.send_to(&msg, relay_url); 478 } 479 480 SubKind::FetchingContactList(timeline_uid) => { 481 let timeline_ind = if let Some(i) = damus.find_timeline(timeline_uid) { 482 i 483 } else { 484 error!( 485 "timeline uid:{} not found for FetchingContactList", 486 timeline_uid 487 ); 488 return Ok(()); 489 }; 490 491 let local_sub = if let FilterState::FetchingRemote(unisub) = 492 &damus.timelines[timeline_ind].filter 493 { 494 unisub.local 495 } else { 496 // TODO: we could have multiple contact list results, we need 497 // to check to see if this one is newer and use that instead 498 warn!( 499 "Expected timeline to have FetchingRemote state but was {:?}", 500 damus.timelines[timeline_ind].filter 501 ); 502 return Ok(()); 503 }; 504 505 damus.timelines[timeline_ind].filter = FilterState::got_remote(local_sub); 506 507 /* 508 // see if we're fast enough to catch a processed contact list 509 let note_keys = damus.ndb.poll_for_notes(local_sub, 1); 510 if !note_keys.is_empty() { 511 debug!("fast! caught contact list from {relay_url} right away"); 512 let txn = Transaction::new(&damus.ndb)?; 513 let note_key = note_keys[0]; 514 let nr = damus.ndb.get_note_by_key(&txn, note_key)?; 515 let filter = filter::filter_from_tags(&nr)?.into_follow_filter(); 516 setup_initial_timeline(damus, timeline, &filter) 517 damus.timelines[timeline_ind].filter = FilterState::ready(filter); 518 } 519 */ 520 } 521 } 522 523 Ok(()) 524 } 525 526 fn process_message(damus: &mut Damus, relay: &str, msg: &RelayMessage) { 527 match msg { 528 RelayMessage::Event(subid, ev) => process_event(damus, subid, ev), 529 RelayMessage::Notice(msg) => warn!("Notice from {}: {}", relay, msg), 530 RelayMessage::OK(cr) => info!("OK {:?}", cr), 531 RelayMessage::Eose(sid) => { 532 if let Err(err) = handle_eose(damus, sid, relay) { 533 error!("error handling eose: {}", err); 534 } 535 } 536 } 537 } 538 539 fn render_damus(damus: &mut Damus, ctx: &Context) { 540 if damus.is_mobile() { 541 render_damus_mobile(ctx, damus); 542 } else { 543 render_damus_desktop(ctx, damus); 544 } 545 546 ctx.request_repaint_after(Duration::from_secs(1)); 547 548 #[cfg(feature = "profiling")] 549 puffin_egui::profiler_window(ctx); 550 } 551 552 /* 553 fn determine_key_storage_type() -> KeyStorageType { 554 #[cfg(target_os = "macos")] 555 { 556 KeyStorageType::MacOS 557 } 558 559 #[cfg(target_os = "linux")] 560 { 561 KeyStorageType::Linux 562 } 563 564 #[cfg(not(any(target_os = "macos", target_os = "linux")))] 565 { 566 KeyStorageType::None 567 } 568 } 569 */ 570 571 impl Damus { 572 /// Called once before the first frame. 573 pub fn new<P: AsRef<Path>>( 574 cc: &eframe::CreationContext<'_>, 575 data_path: P, 576 args: Vec<String>, 577 ) -> Self { 578 // arg parsing 579 let parsed_args = Args::parse(&args); 580 let is_mobile = parsed_args.is_mobile.unwrap_or(ui::is_compiled_as_mobile()); 581 582 setup_cc(cc, is_mobile, parsed_args.light); 583 584 let dbpath = parsed_args 585 .dbpath 586 .unwrap_or(data_path.as_ref().to_str().expect("db path ok").to_string()); 587 588 let _ = std::fs::create_dir_all(dbpath.clone()); 589 590 let imgcache_dir = data_path.as_ref().join(ImageCache::rel_datadir()); 591 let _ = std::fs::create_dir_all(imgcache_dir.clone()); 592 593 let mut config = Config::new(); 594 config.set_ingester_threads(4); 595 596 let mut account_manager = AccountManager::new( 597 // TODO: should pull this from settings 598 None, 599 // TODO: use correct KeyStorage mechanism for current OS arch 600 KeyStorageType::None, 601 ); 602 603 for key in parsed_args.keys { 604 info!("adding account: {}", key.pubkey); 605 account_manager.add_account(key); 606 } 607 608 // TODO: pull currently selected account from settings 609 if account_manager.num_accounts() > 0 { 610 account_manager.select_account(0); 611 } 612 613 // setup relays if we have them 614 let pool = if parsed_args.relays.is_empty() { 615 let mut pool = RelayPool::new(); 616 relay_setup(&mut pool, &cc.egui_ctx); 617 pool 618 } else { 619 let ctx = cc.egui_ctx.clone(); 620 let wakeup = move || { 621 ctx.request_repaint(); 622 }; 623 let mut pool = RelayPool::new(); 624 for relay in parsed_args.relays { 625 if let Err(e) = pool.add_url(relay.clone(), wakeup.clone()) { 626 error!("error adding relay {}: {}", relay, e); 627 } 628 } 629 pool 630 }; 631 632 let account = account_manager 633 .get_selected_account() 634 .as_ref() 635 .map(|a| a.pubkey.bytes()); 636 let ndb = Ndb::new(&dbpath, &config).expect("ndb"); 637 638 let mut timelines: Vec<Timeline> = Vec::with_capacity(parsed_args.columns.len()); 639 for col in parsed_args.columns { 640 if let Some(timeline) = col.into_timeline(&ndb, account) { 641 timelines.push(timeline); 642 } 643 } 644 645 let debug = parsed_args.debug; 646 647 Self { 648 pool, 649 debug, 650 is_mobile, 651 unknown_ids: UnknownIds::default(), 652 subscriptions: Subscriptions::default(), 653 since_optimize: parsed_args.since_optimize, 654 threads: Threads::default(), 655 drafts: Drafts::default(), 656 state: DamusState::Initializing, 657 img_cache: ImageCache::new(imgcache_dir), 658 note_cache: NoteCache::default(), 659 selected_timeline: 0, 660 timelines, 661 textmode: false, 662 ndb, 663 account_manager, 664 frame_history: FrameHistory::default(), 665 show_account_switcher: false, 666 show_global_popup: false, 667 global_nav: Vec::new(), 668 } 669 } 670 671 pub fn gen_subid(&self, kind: &SubKind) -> String { 672 if self.debug { 673 format!("{:?}", kind) 674 } else { 675 Uuid::new_v4().to_string() 676 } 677 } 678 679 pub fn mock<P: AsRef<Path>>(data_path: P, is_mobile: bool) -> Self { 680 let mut timelines: Vec<Timeline> = vec![]; 681 let filter = Filter::from_json(include_str!("../queries/global.json")).unwrap(); 682 timelines.push(Timeline::new( 683 ColumnKind::Universe, 684 FilterState::ready(vec![filter]), 685 )); 686 687 let imgcache_dir = data_path.as_ref().join(ImageCache::rel_datadir()); 688 let _ = std::fs::create_dir_all(imgcache_dir.clone()); 689 let debug = true; 690 691 let mut config = Config::new(); 692 config.set_ingester_threads(2); 693 Self { 694 is_mobile, 695 debug, 696 unknown_ids: UnknownIds::default(), 697 subscriptions: Subscriptions::default(), 698 since_optimize: true, 699 threads: Threads::default(), 700 drafts: Drafts::default(), 701 state: DamusState::Initializing, 702 pool: RelayPool::new(), 703 img_cache: ImageCache::new(imgcache_dir), 704 note_cache: NoteCache::default(), 705 selected_timeline: 0, 706 timelines, 707 textmode: false, 708 ndb: Ndb::new(data_path.as_ref().to_str().expect("db path ok"), &config).expect("ndb"), 709 account_manager: AccountManager::new(None, KeyStorageType::None), 710 frame_history: FrameHistory::default(), 711 show_account_switcher: false, 712 show_global_popup: true, 713 global_nav: Vec::new(), 714 } 715 } 716 717 pub fn find_timeline(&self, uid: u32) -> Option<usize> { 718 for (i, timeline) in self.timelines.iter().enumerate() { 719 if timeline.uid == uid { 720 return Some(i); 721 } 722 } 723 724 None 725 } 726 727 pub fn subscriptions(&mut self) -> &mut HashMap<String, SubKind> { 728 &mut self.subscriptions.subs 729 } 730 731 pub fn note_cache_mut(&mut self) -> &mut NoteCache { 732 &mut self.note_cache 733 } 734 735 pub fn note_cache(&self) -> &NoteCache { 736 &self.note_cache 737 } 738 739 pub fn selected_timeline(&mut self) -> &mut Timeline { 740 &mut self.timelines[self.selected_timeline as usize] 741 } 742 743 pub fn select_down(&mut self) { 744 self.selected_timeline().current_view_mut().select_down(); 745 } 746 747 pub fn select_up(&mut self) { 748 self.selected_timeline().current_view_mut().select_up(); 749 } 750 751 pub fn select_left(&mut self) { 752 if self.selected_timeline - 1 < 0 { 753 return; 754 } 755 self.selected_timeline -= 1; 756 } 757 758 pub fn select_right(&mut self) { 759 if self.selected_timeline + 1 >= self.timelines.len() as i32 { 760 return; 761 } 762 self.selected_timeline += 1; 763 } 764 765 pub fn is_mobile(&self) -> bool { 766 self.is_mobile 767 } 768 } 769 770 /* 771 fn circle_icon(ui: &mut egui::Ui, openness: f32, response: &egui::Response) { 772 let stroke = ui.style().interact(&response).fg_stroke; 773 let radius = egui::lerp(2.0..=3.0, openness); 774 ui.painter() 775 .circle_filled(response.rect.center(), radius, stroke.color); 776 } 777 */ 778 779 fn top_panel(ctx: &egui::Context) -> egui::TopBottomPanel { 780 let top_margin = egui::Margin { 781 top: 4.0, 782 left: 8.0, 783 right: 8.0, 784 ..Default::default() 785 }; 786 787 let frame = Frame { 788 inner_margin: top_margin, 789 fill: ctx.style().visuals.panel_fill, 790 ..Default::default() 791 }; 792 793 egui::TopBottomPanel::top("top_panel") 794 .frame(frame) 795 .show_separator_line(false) 796 } 797 798 fn render_panel(ctx: &egui::Context, app: &mut Damus, timeline_ind: usize) { 799 top_panel(ctx).show(ctx, |ui| { 800 ui.with_layout(egui::Layout::right_to_left(egui::Align::TOP), |ui| { 801 ui.visuals_mut().button_frame = false; 802 803 if let Some(new_visuals) = 804 user_requested_visuals_change(app.is_mobile(), ctx.style().visuals.dark_mode, ui) 805 { 806 ctx.set_visuals(new_visuals) 807 } 808 809 if ui 810 .add(egui::Button::new("A").frame(false)) 811 .on_hover_text("Text mode") 812 .clicked() 813 { 814 app.textmode = !app.textmode; 815 } 816 817 /* 818 if ui 819 .add(egui::Button::new("+").frame(false)) 820 .on_hover_text("Add Timeline") 821 .clicked() 822 { 823 app.n_panels += 1; 824 } 825 826 if app.n_panels != 1 827 && ui 828 .add(egui::Button::new("-").frame(false)) 829 .on_hover_text("Remove Timeline") 830 .clicked() 831 { 832 app.n_panels -= 1; 833 } 834 */ 835 836 //#[cfg(feature = "profiling")] 837 { 838 ui.weak(format!( 839 "FPS: {:.2}, {:10.1}ms", 840 app.frame_history.fps(), 841 app.frame_history.mean_frame_time() * 1e3 842 )); 843 844 if !app.timelines.is_empty() { 845 ui.weak(format!( 846 "{} notes", 847 &app.timelines[timeline_ind] 848 .notes(ViewFilter::NotesAndReplies) 849 .len() 850 )); 851 } 852 } 853 }); 854 }); 855 } 856 857 /// Local thread unsubscribe 858 fn thread_unsubscribe(app: &mut Damus, id: &[u8; 32]) { 859 let (unsubscribe, remote_subid) = { 860 let txn = Transaction::new(&app.ndb).expect("txn"); 861 let root_id = crate::note::root_note_id_from_selected_id(app, &txn, id); 862 863 let thread = app.threads.thread_mut(&app.ndb, &txn, root_id).get_ptr(); 864 let unsub = thread.decrement_sub(); 865 866 let mut remote_subid: Option<String> = None; 867 if let Ok(DecrementResult::LastSubscriber(_subid)) = unsub { 868 *thread.subscription_mut() = None; 869 remote_subid = thread.remote_subscription().to_owned(); 870 *thread.remote_subscription_mut() = None; 871 } 872 873 (unsub, remote_subid) 874 }; 875 876 match unsubscribe { 877 Ok(DecrementResult::LastSubscriber(sub)) => { 878 if let Err(e) = app.ndb.unsubscribe(sub) { 879 error!( 880 "failed to unsubscribe from thread: {e}, subid:{}, {} active subscriptions", 881 sub.id(), 882 app.ndb.subscription_count() 883 ); 884 } else { 885 info!( 886 "Unsubscribed from thread subid:{}. {} active subscriptions", 887 sub.id(), 888 app.ndb.subscription_count() 889 ); 890 } 891 892 // unsub from remote 893 if let Some(subid) = remote_subid { 894 app.pool.unsubscribe(subid); 895 } 896 } 897 898 Ok(DecrementResult::ActiveSubscribers) => { 899 info!( 900 "Keeping thread subscription. {} active subscriptions.", 901 app.ndb.subscription_count() 902 ); 903 // do nothing 904 } 905 906 Err(e) => { 907 // something is wrong! 908 error!( 909 "Thread unsubscribe error: {e}. {} active subsciptions.", 910 app.ndb.subscription_count() 911 ); 912 } 913 } 914 } 915 916 fn render_nav(routes: Vec<Route>, timeline_ind: usize, app: &mut Damus, ui: &mut egui::Ui) { 917 let navigating = app.timelines[timeline_ind].navigating; 918 let returning = app.timelines[timeline_ind].returning; 919 let app_ctx = Rc::new(RefCell::new(app)); 920 921 let nav_response = Nav::new(routes) 922 .navigating(navigating) 923 .returning(returning) 924 .title(false) 925 .show(ui, |ui, nav| match nav.top() { 926 Route::Timeline(_n) => { 927 let app = &mut app_ctx.borrow_mut(); 928 ui::TimelineView::new(app, timeline_ind).ui(ui); 929 None 930 } 931 932 Route::ManageAccount => { 933 ui.label("account management view"); 934 None 935 } 936 937 Route::Relays => { 938 let pool = &mut app_ctx.borrow_mut().pool; 939 let manager = RelayPoolManager::new(pool); 940 RelayView::new(manager).ui(ui); 941 None 942 } 943 944 Route::Thread(id) => { 945 let app = &mut app_ctx.borrow_mut(); 946 let result = ui::ThreadView::new(app, timeline_ind, id.bytes()).ui(ui); 947 948 if let Some(bar_result) = result { 949 match bar_result { 950 BarResult::NewThreadNotes(new_notes) => { 951 let thread = app.threads.thread_expected_mut(new_notes.root_id.bytes()); 952 new_notes.process(thread); 953 } 954 } 955 } 956 957 None 958 } 959 960 Route::Reply(id) => { 961 let mut app = app_ctx.borrow_mut(); 962 963 let txn = if let Ok(txn) = Transaction::new(&app.ndb) { 964 txn 965 } else { 966 ui.label("Reply to unknown note"); 967 return None; 968 }; 969 970 let note = if let Ok(note) = app.ndb.get_note_by_id(&txn, id.bytes()) { 971 note 972 } else { 973 ui.label("Reply to unknown note"); 974 return None; 975 }; 976 977 let id = egui::Id::new(("post", timeline_ind, note.key().unwrap())); 978 let response = egui::ScrollArea::vertical().show(ui, |ui| { 979 ui::PostReplyView::new(&mut app, ¬e) 980 .id_source(id) 981 .show(ui) 982 }); 983 984 Some(response) 985 } 986 }); 987 988 let mut app = app_ctx.borrow_mut(); 989 if let Some(reply_response) = nav_response.inner { 990 if let Some(PostAction::Post(_np)) = reply_response.inner.action { 991 app.timelines[timeline_ind].returning = true; 992 } 993 } 994 995 if let Some(NavAction::Returned) = nav_response.action { 996 let popped = app.timelines[timeline_ind].routes.pop(); 997 if let Some(Route::Thread(id)) = popped { 998 thread_unsubscribe(&mut app, id.bytes()); 999 } 1000 app.timelines[timeline_ind].returning = false; 1001 } else if let Some(NavAction::Navigated) = nav_response.action { 1002 app.timelines[timeline_ind].navigating = false; 1003 } 1004 } 1005 1006 fn render_damus_mobile(ctx: &egui::Context, app: &mut Damus) { 1007 //render_panel(ctx, app, 0); 1008 1009 #[cfg(feature = "profiling")] 1010 puffin::profile_function!(); 1011 1012 //let routes = app.timelines[0].routes.clone(); 1013 1014 main_panel(&ctx.style(), app.is_mobile()).show(ctx, |ui| { 1015 render_nav(app.timelines[0].routes.clone(), 0, app, ui); 1016 }); 1017 } 1018 1019 fn main_panel(style: &Style, mobile: bool) -> egui::CentralPanel { 1020 let inner_margin = egui::Margin { 1021 top: if mobile { 50.0 } else { 0.0 }, 1022 left: 0.0, 1023 right: 0.0, 1024 bottom: 0.0, 1025 }; 1026 egui::CentralPanel::default().frame(Frame { 1027 inner_margin, 1028 fill: style.visuals.panel_fill, 1029 ..Default::default() 1030 }) 1031 } 1032 1033 fn render_damus_desktop(ctx: &egui::Context, app: &mut Damus) { 1034 render_panel(ctx, app, 0); 1035 #[cfg(feature = "profiling")] 1036 puffin::profile_function!(); 1037 1038 let screen_size = ctx.screen_rect().width(); 1039 let calc_panel_width = (screen_size / app.timelines.len() as f32) - 30.0; 1040 let min_width = 320.0; 1041 let need_scroll = calc_panel_width < min_width; 1042 let panel_sizes = if need_scroll { 1043 Size::exact(min_width) 1044 } else { 1045 Size::remainder() 1046 }; 1047 1048 main_panel(&ctx.style(), app.is_mobile()).show(ctx, |ui| { 1049 ui.spacing_mut().item_spacing.x = 0.0; 1050 AccountSelectionWidget::ui(app, ui); 1051 DesktopGlobalPopup::show(app.global_nav.clone(), app, ui); 1052 if need_scroll { 1053 egui::ScrollArea::horizontal().show(ui, |ui| { 1054 timelines_view(ui, panel_sizes, app, app.timelines.len()); 1055 }); 1056 } else { 1057 timelines_view(ui, panel_sizes, app, app.timelines.len()); 1058 } 1059 }); 1060 } 1061 1062 fn timelines_view(ui: &mut egui::Ui, sizes: Size, app: &mut Damus, timelines: usize) { 1063 StripBuilder::new(ui) 1064 .size(Size::exact(40.0)) 1065 .sizes(sizes, timelines) 1066 .clip(true) 1067 .horizontal(|mut strip| { 1068 strip.cell(|ui| { 1069 let rect = ui.available_rect_before_wrap(); 1070 let side_panel = DesktopSidePanel::new(app).show(ui); 1071 1072 if side_panel.response.clicked() { 1073 info!("clicked {:?}", side_panel.action); 1074 } 1075 1076 DesktopSidePanel::perform_action(app, side_panel.action); 1077 1078 // vertical sidebar line 1079 ui.painter().vline( 1080 rect.right(), 1081 rect.y_range(), 1082 ui.visuals().widgets.noninteractive.bg_stroke, 1083 ); 1084 }); 1085 1086 for timeline_ind in 0..timelines { 1087 strip.cell(|ui| { 1088 let rect = ui.available_rect_before_wrap(); 1089 render_nav( 1090 app.timelines[timeline_ind].routes.clone(), 1091 timeline_ind, 1092 app, 1093 ui, 1094 ); 1095 1096 // vertical line 1097 ui.painter().vline( 1098 rect.right(), 1099 rect.y_range(), 1100 ui.visuals().widgets.noninteractive.bg_stroke, 1101 ); 1102 }); 1103 1104 //strip.cell(|ui| timeline::timeline_view(ui, app, timeline_ind)); 1105 } 1106 }); 1107 } 1108 1109 impl eframe::App for Damus { 1110 /// Called by the frame work to save state before shutdown. 1111 fn save(&mut self, _storage: &mut dyn eframe::Storage) { 1112 //eframe::set_value(storage, eframe::APP_KEY, self); 1113 } 1114 1115 /// Called each time the UI needs repainting, which may be many times per second. 1116 /// Put your widgets into a `SidePanel`, `TopPanel`, `CentralPanel`, `Window` or `Area`. 1117 fn update(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) { 1118 self.frame_history 1119 .on_new_frame(ctx.input(|i| i.time), frame.info().cpu_usage); 1120 1121 #[cfg(feature = "profiling")] 1122 puffin::GlobalProfiler::lock().new_frame(); 1123 update_damus(self, ctx); 1124 render_damus(self, ctx); 1125 } 1126 }