route.rs (30314B)
1 use egui_nav::{Percent, ReturnType}; 2 use enostr::{NoteId, Pubkey}; 3 use nostrdb::Ndb; 4 use notedeck::{ 5 tr, Localization, NoteZapTargetOwned, ReplacementType, ReportTarget, RootNoteIdBuf, Router, 6 ScopedSubApi, WalletType, 7 }; 8 use std::collections::HashSet; 9 use std::ops::Range; 10 11 use crate::{ 12 accounts::AccountsRoute, 13 onboarding::Onboarding, 14 scoped_sub_owner_keys::onboarding_owner_key, 15 timeline::{kind::ColumnTitle, thread::Threads, ThreadSelection, TimelineCache, TimelineKind}, 16 ui::add_column::{AddAlgoRoute, AddColumnRoute}, 17 view_state::ViewState, 18 }; 19 20 use tokenator::{ParseError, TokenParser, TokenSerializable, TokenWriter}; 21 22 /// App routing. These describe different places you can go inside Notedeck. 23 #[derive(Clone, Eq, PartialEq, Debug)] 24 pub enum Route { 25 Timeline(TimelineKind), 26 Thread(ThreadSelection), 27 Accounts(AccountsRoute), 28 Reply(NoteId), 29 Quote(NoteId), 30 RepostDecision(NoteId), 31 Relays, 32 Settings, 33 ComposeNote, 34 AddColumn(AddColumnRoute), 35 EditProfile(Pubkey), 36 Support, 37 NewDeck, 38 Search, 39 EditDeck(usize), 40 Wallet(WalletType), 41 CustomizeZapAmount(NoteZapTargetOwned), 42 Following(Pubkey), 43 FollowedBy(Pubkey), 44 TosAcceptance, 45 Welcome, 46 Report(ReportTarget), 47 } 48 49 impl Route { 50 pub fn timeline(timeline_kind: TimelineKind) -> Self { 51 Route::Timeline(timeline_kind) 52 } 53 54 pub fn timeline_id(&self) -> Option<&TimelineKind> { 55 if let Route::Timeline(tid) = self { 56 Some(tid) 57 } else { 58 None 59 } 60 } 61 62 pub fn relays() -> Self { 63 Route::Relays 64 } 65 66 pub fn settings() -> Self { 67 Route::Settings 68 } 69 70 pub fn thread(thread_selection: ThreadSelection) -> Self { 71 Route::Thread(thread_selection) 72 } 73 74 pub fn profile(pubkey: Pubkey) -> Self { 75 Route::Timeline(TimelineKind::profile(pubkey)) 76 } 77 78 pub fn reply(replying_to: NoteId) -> Self { 79 Route::Reply(replying_to) 80 } 81 82 pub fn quote(quoting: NoteId) -> Self { 83 Route::Quote(quoting) 84 } 85 86 pub fn accounts() -> Self { 87 Route::Accounts(AccountsRoute::Accounts) 88 } 89 90 pub fn add_account() -> Self { 91 Route::Accounts(AccountsRoute::AddAccount) 92 } 93 94 pub fn serialize_tokens(&self, writer: &mut TokenWriter) { 95 match self { 96 Route::Timeline(timeline_kind) => timeline_kind.serialize_tokens(writer), 97 Route::Thread(selection) => { 98 writer.write_token("thread"); 99 100 if let Some(reply) = selection.selected_note { 101 writer.write_token("root"); 102 writer.write_token(&NoteId::new(*selection.root_id.bytes()).hex()); 103 writer.write_token("reply"); 104 writer.write_token(&reply.hex()); 105 } else { 106 writer.write_token(&NoteId::new(*selection.root_id.bytes()).hex()); 107 } 108 } 109 Route::Accounts(routes) => routes.serialize_tokens(writer), 110 Route::AddColumn(routes) => routes.serialize_tokens(writer), 111 Route::Search => writer.write_token("search"), 112 Route::Reply(note_id) => { 113 writer.write_token("reply"); 114 writer.write_token(¬e_id.hex()); 115 } 116 Route::Quote(note_id) => { 117 writer.write_token("quote"); 118 writer.write_token(¬e_id.hex()); 119 } 120 Route::EditDeck(ind) => { 121 writer.write_token("deck"); 122 writer.write_token("edit"); 123 writer.write_token(&ind.to_string()); 124 } 125 Route::EditProfile(pubkey) => { 126 writer.write_token("profile"); 127 writer.write_token("edit"); 128 writer.write_token(&pubkey.hex()); 129 } 130 Route::Relays => { 131 writer.write_token("relay"); 132 } 133 Route::Settings => { 134 writer.write_token("settings"); 135 } 136 Route::ComposeNote => { 137 writer.write_token("compose"); 138 } 139 Route::Support => { 140 writer.write_token("support"); 141 } 142 Route::NewDeck => { 143 writer.write_token("deck"); 144 writer.write_token("new"); 145 } 146 Route::Wallet(_) => { 147 writer.write_token("wallet"); 148 } 149 Route::CustomizeZapAmount(_) => writer.write_token("customize zap amount"), 150 Route::RepostDecision(note_id) => { 151 writer.write_token("repost_decision"); 152 writer.write_token(¬e_id.hex()); 153 } 154 Route::Following(pubkey) => { 155 writer.write_token("following"); 156 writer.write_token(&pubkey.hex()); 157 } 158 Route::FollowedBy(pubkey) => { 159 writer.write_token("followed_by"); 160 writer.write_token(&pubkey.hex()); 161 } 162 Route::TosAcceptance => { 163 writer.write_token("tos"); 164 } 165 Route::Welcome => { 166 writer.write_token("welcome"); 167 } 168 Route::Report(target) => { 169 writer.write_token("report"); 170 writer.write_token(&target.pubkey.hex()); 171 if let Some(note_id) = &target.note_id { 172 writer.write_token(¬e_id.hex()); 173 } 174 } 175 } 176 } 177 178 pub fn parse<'a>( 179 parser: &mut TokenParser<'a>, 180 deck_author: &Pubkey, 181 ) -> Result<Self, ParseError<'a>> { 182 let tlkind = 183 parser.try_parse(|p| Ok(Route::Timeline(TimelineKind::parse(p, deck_author)?))); 184 185 if tlkind.is_ok() { 186 return tlkind; 187 } 188 189 TokenParser::alt( 190 parser, 191 &[ 192 |p| Ok(Route::Accounts(AccountsRoute::parse_from_tokens(p)?)), 193 |p| Ok(Route::AddColumn(AddColumnRoute::parse_from_tokens(p)?)), 194 |p| { 195 p.parse_all(|p| { 196 p.parse_token("deck")?; 197 p.parse_token("edit")?; 198 let ind_str = p.pull_token()?; 199 let parsed_index = ind_str 200 .parse::<usize>() 201 .map_err(|_| ParseError::DecodeFailed)?; 202 Ok(Route::EditDeck(parsed_index)) 203 }) 204 }, 205 |p| { 206 p.parse_all(|p| { 207 p.parse_token("profile")?; 208 p.parse_token("edit")?; 209 let pubkey = Pubkey::from_hex(p.pull_token()?) 210 .map_err(|_| ParseError::HexDecodeFailed)?; 211 Ok(Route::EditProfile(pubkey)) 212 }) 213 }, 214 |p| { 215 p.parse_all(|p| { 216 p.parse_token("relay")?; 217 Ok(Route::Relays) 218 }) 219 }, 220 |p| { 221 p.parse_all(|p| { 222 p.parse_token("settings")?; 223 Ok(Route::Settings) 224 }) 225 }, 226 |p| { 227 p.parse_all(|p| { 228 p.parse_token("repost_decision")?; 229 let note_id = NoteId::from_hex(p.pull_token()?) 230 .map_err(|_| ParseError::HexDecodeFailed)?; 231 Ok(Route::RepostDecision(note_id)) 232 }) 233 }, 234 |p| { 235 p.parse_all(|p| { 236 p.parse_token("quote")?; 237 Ok(Route::Quote(NoteId::new(tokenator::parse_hex_id(p)?))) 238 }) 239 }, 240 |p| { 241 p.parse_all(|p| { 242 p.parse_token("reply")?; 243 Ok(Route::Reply(NoteId::new(tokenator::parse_hex_id(p)?))) 244 }) 245 }, 246 |p| { 247 p.parse_all(|p| { 248 p.parse_token("compose")?; 249 Ok(Route::ComposeNote) 250 }) 251 }, 252 |p| { 253 p.parse_all(|p| { 254 p.parse_token("support")?; 255 Ok(Route::Support) 256 }) 257 }, 258 |p| { 259 p.parse_all(|p| { 260 p.parse_token("deck")?; 261 p.parse_token("new")?; 262 Ok(Route::NewDeck) 263 }) 264 }, 265 |p| { 266 p.parse_all(|p| { 267 p.parse_token("search")?; 268 Ok(Route::Search) 269 }) 270 }, 271 |p| { 272 p.parse_all(|p| { 273 p.parse_token("thread")?; 274 p.parse_token("root")?; 275 276 let root = tokenator::parse_hex_id(p)?; 277 278 p.parse_token("reply")?; 279 280 let selected = tokenator::parse_hex_id(p)?; 281 282 Ok(Route::Thread(ThreadSelection { 283 root_id: RootNoteIdBuf::new_unsafe(root), 284 selected_note: Some(NoteId::new(selected)), 285 })) 286 }) 287 }, 288 |p| { 289 p.parse_all(|p| { 290 p.parse_token("thread")?; 291 Ok(Route::Thread(ThreadSelection::from_root_id( 292 RootNoteIdBuf::new_unsafe(tokenator::parse_hex_id(p)?), 293 ))) 294 }) 295 }, 296 |p| { 297 p.parse_all(|p| { 298 p.parse_token("following")?; 299 let pubkey = Pubkey::from_hex(p.pull_token()?) 300 .map_err(|_| ParseError::HexDecodeFailed)?; 301 Ok(Route::Following(pubkey)) 302 }) 303 }, 304 |p| { 305 p.parse_all(|p| { 306 p.parse_token("followed_by")?; 307 let pubkey = Pubkey::from_hex(p.pull_token()?) 308 .map_err(|_| ParseError::HexDecodeFailed)?; 309 Ok(Route::FollowedBy(pubkey)) 310 }) 311 }, 312 |p| { 313 p.parse_all(|p| { 314 p.parse_token("tos")?; 315 Ok(Route::TosAcceptance) 316 }) 317 }, 318 |p| { 319 p.parse_all(|p| { 320 p.parse_token("welcome")?; 321 Ok(Route::Welcome) 322 }) 323 }, 324 |p| { 325 p.parse_all(|p| { 326 p.parse_token("report")?; 327 let pubkey = Pubkey::from_hex(p.pull_token()?) 328 .map_err(|_| ParseError::HexDecodeFailed)?; 329 let note_id = p.pull_token().ok().and_then(|t| NoteId::from_hex(t).ok()); 330 Ok(Route::Report(ReportTarget { pubkey, note_id })) 331 }) 332 }, 333 ], 334 ) 335 } 336 337 pub fn title(&self, i18n: &mut Localization) -> ColumnTitle<'_> { 338 match self { 339 Route::Timeline(kind) => kind.to_title(i18n), 340 Route::Thread(_) => { 341 ColumnTitle::formatted(tr!(i18n, "Thread", "Column title for note thread view")) 342 } 343 Route::Reply(_id) => { 344 ColumnTitle::formatted(tr!(i18n, "Reply", "Column title for reply composition")) 345 } 346 Route::Quote(_id) => { 347 ColumnTitle::formatted(tr!(i18n, "Quote", "Column title for quote composition")) 348 } 349 Route::Relays => { 350 ColumnTitle::formatted(tr!(i18n, "Relays", "Column title for relay management")) 351 } 352 Route::Settings => { 353 ColumnTitle::formatted(tr!(i18n, "Settings", "Column title for app settings")) 354 } 355 Route::Accounts(amr) => match amr { 356 AccountsRoute::Accounts => ColumnTitle::formatted(tr!( 357 i18n, 358 "Accounts", 359 "Column title for account management" 360 )), 361 AccountsRoute::AddAccount => ColumnTitle::formatted(tr!( 362 i18n, 363 "Add Account", 364 "Column title for adding new account" 365 )), 366 AccountsRoute::Onboarding => ColumnTitle::formatted(tr!( 367 i18n, 368 "Onboarding", 369 "Column title for finding users to follow" 370 )), 371 }, 372 Route::ComposeNote => ColumnTitle::formatted(tr!( 373 i18n, 374 "Compose Note", 375 "Column title for note composition" 376 )), 377 Route::AddColumn(c) => match c { 378 AddColumnRoute::Base => ColumnTitle::formatted(tr!( 379 i18n, 380 "Add Column", 381 "Column title for adding new column" 382 )), 383 AddColumnRoute::Algo(r) => match r { 384 AddAlgoRoute::Base => ColumnTitle::formatted(tr!( 385 i18n, 386 "Add Algo Column", 387 "Column title for adding algorithm column" 388 )), 389 AddAlgoRoute::LastPerPubkey => ColumnTitle::formatted(tr!( 390 i18n, 391 "Add Last Notes Column", 392 "Column title for adding last notes column" 393 )), 394 }, 395 AddColumnRoute::UndecidedNotification => ColumnTitle::formatted(tr!( 396 i18n, 397 "Add Notifications Column", 398 "Column title for adding notifications column" 399 )), 400 AddColumnRoute::ExternalNotification => ColumnTitle::formatted(tr!( 401 i18n, 402 "Add External Notifications Column", 403 "Column title for adding external notifications column" 404 )), 405 AddColumnRoute::Hashtag => ColumnTitle::formatted(tr!( 406 i18n, 407 "Add Hashtag Column", 408 "Column title for adding hashtag column" 409 )), 410 AddColumnRoute::UndecidedIndividual => ColumnTitle::formatted(tr!( 411 i18n, 412 "Subscribe to someone's notes", 413 "Column title for subscribing to individual user" 414 )), 415 AddColumnRoute::ExternalIndividual => ColumnTitle::formatted(tr!( 416 i18n, 417 "Subscribe to someone else's notes", 418 "Column title for subscribing to external user" 419 )), 420 AddColumnRoute::PeopleList => ColumnTitle::formatted(tr!( 421 i18n, 422 "Select a People List", 423 "Column title for selecting a people list" 424 )), 425 AddColumnRoute::CreatePeopleList => ColumnTitle::formatted(tr!( 426 i18n, 427 "Create People List", 428 "Column title for creating a people list" 429 )), 430 }, 431 Route::Support => { 432 ColumnTitle::formatted(tr!(i18n, "Damus Support", "Column title for support page")) 433 } 434 Route::NewDeck => { 435 ColumnTitle::formatted(tr!(i18n, "Add Deck", "Column title for adding new deck")) 436 } 437 Route::EditDeck(_) => { 438 ColumnTitle::formatted(tr!(i18n, "Edit Deck", "Column title for editing deck")) 439 } 440 Route::EditProfile(_) => ColumnTitle::formatted(tr!( 441 i18n, 442 "Edit Profile", 443 "Column title for profile editing" 444 )), 445 Route::Search => { 446 ColumnTitle::formatted(tr!(i18n, "Search", "Column title for search page")) 447 } 448 Route::Wallet(_) => { 449 ColumnTitle::formatted(tr!(i18n, "Wallet", "Column title for wallet management")) 450 } 451 Route::CustomizeZapAmount(_) => ColumnTitle::formatted(tr!( 452 i18n, 453 "Customize Zap Amount", 454 "Column title for zap amount customization" 455 )), 456 Route::RepostDecision(_) => ColumnTitle::formatted(tr!( 457 i18n, 458 "Repost", 459 "Column title for deciding the type of repost" 460 )), 461 Route::Following(_) => ColumnTitle::formatted(tr!( 462 i18n, 463 "Following", 464 "Column title for users being followed" 465 )), 466 Route::FollowedBy(_) => { 467 ColumnTitle::formatted(tr!(i18n, "Followed by", "Column title for followers")) 468 } 469 Route::TosAcceptance => ColumnTitle::formatted(tr!( 470 i18n, 471 "Terms of Service", 472 "Column title for TOS acceptance screen" 473 )), 474 Route::Welcome => { 475 ColumnTitle::formatted(tr!(i18n, "Welcome", "Column title for welcome screen")) 476 } 477 Route::Report(_) => { 478 ColumnTitle::formatted(tr!(i18n, "Report", "Column title for report screen")) 479 } 480 } 481 } 482 } 483 484 // TODO: add this to egui-nav so we don't have to deal with returning 485 // and navigating headaches 486 #[derive(Clone, Debug)] 487 pub struct ColumnsRouter<R: Clone> { 488 router_internal: Router<R>, 489 forward_stack: Vec<R>, 490 491 // An overlay captures a range of routes where only one will persist when going back, the most recent added 492 overlay_ranges: Vec<Range<usize>>, 493 } 494 495 impl<R: Clone> ColumnsRouter<R> { 496 pub fn new(routes: Vec<R>) -> Self { 497 if routes.is_empty() { 498 panic!("routes can't be empty") 499 } 500 let router_internal = Router::new(routes); 501 ColumnsRouter { 502 router_internal, 503 forward_stack: Vec::new(), 504 overlay_ranges: Vec::new(), 505 } 506 } 507 508 pub fn route_to(&mut self, route: R) { 509 self.router_internal.route_to(route); 510 self.forward_stack.clear(); 511 } 512 513 pub fn route_to_overlaid(&mut self, route: R) { 514 self.route_to(route); 515 self.set_overlaying(); 516 } 517 518 pub fn route_to_overlaid_new(&mut self, route: R) { 519 self.route_to(route); 520 self.new_overlay(); 521 } 522 523 // Route to R. Then when it is successfully placed, should call `remove_previous_routes` to remove all previous routes 524 pub fn route_to_replaced(&mut self, route: R) { 525 self.router_internal 526 .route_to_replaced(route, ReplacementType::All); 527 } 528 529 /// Go back, start the returning process 530 pub fn go_back(&mut self) -> Option<R> { 531 if self.router_internal.returning || self.router_internal.len() == 1 { 532 return None; 533 } 534 535 if let Some(range) = self.overlay_ranges.pop() { 536 tracing::debug!("Going back, found overlay: {:?}", range); 537 self.remove_overlay(range); 538 } else { 539 tracing::debug!("Going back, no overlay"); 540 } 541 542 self.router_internal.go_back() 543 } 544 545 pub fn go_forward(&mut self) -> bool { 546 if let Some(route) = self.forward_stack.pop() { 547 self.router_internal.route_to(route); 548 true 549 } else { 550 false 551 } 552 } 553 554 /// Pop a route, should only be called on a NavRespose::Returned reseponse 555 pub fn pop(&mut self) -> Option<R> { 556 if self.router_internal.len() == 1 { 557 return None; 558 } 559 560 let is_overlay = 's: { 561 let Some(last_range) = self.overlay_ranges.last_mut() else { 562 break 's false; 563 }; 564 565 if last_range.end != self.router_internal.len() { 566 break 's false; 567 } 568 569 if last_range.end - 1 <= last_range.start { 570 self.overlay_ranges.pop(); 571 } else { 572 last_range.end -= 1; 573 } 574 575 true 576 }; 577 578 let popped = self.router_internal.pop()?; 579 if !is_overlay { 580 self.forward_stack.push(popped.clone()); 581 } 582 Some(popped) 583 } 584 585 pub fn remove_previous_routes(&mut self) { 586 self.router_internal.complete_replacement(); 587 } 588 589 /// Removes all routes in the overlay besides the last 590 fn remove_overlay(&mut self, overlay_range: Range<usize>) { 591 let num_routes = self.router_internal.routes.len(); 592 if num_routes <= 1 { 593 return; 594 } 595 596 if overlay_range.len() <= 1 { 597 return; 598 } 599 600 self.router_internal 601 .routes 602 .drain(overlay_range.start..overlay_range.end - 1); 603 } 604 605 pub fn is_replacing(&self) -> bool { 606 self.router_internal.is_replacing() 607 } 608 609 fn set_overlaying(&mut self) { 610 let mut overlaying_active = None; 611 let mut binding = self.overlay_ranges.last_mut(); 612 if let Some(range) = &mut binding { 613 if range.end == self.router_internal.len() - 1 { 614 overlaying_active = Some(range); 615 } 616 }; 617 618 if let Some(range) = overlaying_active { 619 range.end = self.router_internal.len(); 620 } else { 621 let new_range = self.router_internal.len() - 1..self.router_internal.len(); 622 self.overlay_ranges.push(new_range); 623 } 624 } 625 626 fn new_overlay(&mut self) { 627 let new_range = self.router_internal.len() - 1..self.router_internal.len(); 628 self.overlay_ranges.push(new_range); 629 } 630 631 pub fn routes(&self) -> &Vec<R> { 632 self.router_internal.routes() 633 } 634 635 pub fn navigating(&self) -> bool { 636 self.router_internal.navigating 637 } 638 639 pub fn navigating_mut(&mut self, new: bool) { 640 self.router_internal.navigating = new; 641 } 642 643 pub fn returning(&self) -> bool { 644 self.router_internal.returning 645 } 646 647 pub fn returning_mut(&mut self, new: bool) { 648 self.router_internal.returning = new; 649 } 650 651 pub fn top(&self) -> &R { 652 self.router_internal.top() 653 } 654 655 pub fn prev(&self) -> Option<&R> { 656 self.router_internal.prev() 657 } 658 } 659 660 /* 661 impl fmt::Display for Route { 662 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 663 match self { 664 Route::Timeline(kind) => match kind { 665 TimelineKind::List(ListKind::Contact(_pk)) => { 666 write!(f, "{}", i18n, "Home", "Display name for home feed")) 667 } 668 TimelineKind::Algo(AlgoTimeline::LastPerPubkey(ListKind::Contact(_))) => { 669 write!( 670 f, 671 "{}", 672 tr!( 673 "Last Per Pubkey (Contact)", 674 "Display name for last notes per contact" 675 ) 676 ) 677 } 678 TimelineKind::Notifications(_) => write!( 679 f, 680 "{}", 681 tr!("Notifications", "Display name for notifications") 682 ), 683 TimelineKind::Universe => { 684 write!(f, "{}", tr!("Universe", "Display name for universe feed")) 685 } 686 TimelineKind::Generic(_) => { 687 write!(f, "{}", tr!("Custom", "Display name for custom timelines")) 688 } 689 TimelineKind::Search(_) => { 690 write!(f, "{}", tr!("Search", "Display name for search results")) 691 } 692 TimelineKind::Hashtag(ht) => write!( 693 f, 694 "{} ({})", 695 tr!("Hashtags", "Display name for hashtag feeds"), 696 ht.join(" ") 697 ), 698 TimelineKind::Profile(_id) => { 699 write!(f, "{}", tr!("Profile", "Display name for user profiles")) 700 } 701 }, 702 Route::Thread(_) => write!(f, "{}", tr!("Thread", "Display name for thread view")), 703 Route::Reply(_id) => { 704 write!(f, "{}", tr!("Reply", "Display name for reply composition")) 705 } 706 Route::Quote(_id) => { 707 write!(f, "{}", tr!("Quote", "Display name for quote composition")) 708 } 709 Route::Relays => write!(f, "{}", tr!("Relays", "Display name for relay management")), 710 Route::Settings => write!(f, "{}", tr!("Settings", "Display name for settings management")), 711 Route::Accounts(amr) => match amr { 712 AccountsRoute::Accounts => write!( 713 f, 714 "{}", 715 tr!("Accounts", "Display name for account management") 716 ), 717 AccountsRoute::AddAccount => write!( 718 f, 719 "{}", 720 tr!("Add Account", "Display name for adding account") 721 ), 722 }, 723 Route::ComposeNote => write!( 724 f, 725 "{}", 726 tr!("Compose Note", "Display name for note composition") 727 ), 728 Route::AddColumn(_) => { 729 write!(f, "{}", tr!("Add Column", "Display name for adding column")) 730 } 731 Route::Support => write!(f, "{}", tr!("Support", "Display name for support page")), 732 Route::NewDeck => write!(f, "{}", tr!("Add Deck", "Display name for adding deck")), 733 Route::EditDeck(_) => { 734 write!(f, "{}", tr!("Edit Deck", "Display name for editing deck")) 735 } 736 Route::EditProfile(_) => write!( 737 f, 738 "{}", 739 tr!("Edit Profile", "Display name for profile editing") 740 ), 741 Route::Search => write!(f, "{}", tr!("Search", "Display name for search page")), 742 Route::Wallet(_) => { 743 write!(f, "{}", tr!("Wallet", "Display name for wallet management")) 744 } 745 Route::CustomizeZapAmount(_) => write!( 746 f, 747 "{}", 748 tr!("Customize Zap Amount", "Display name for zap customization") 749 ), 750 } 751 } 752 } 753 */ 754 755 #[derive(Clone, Debug)] 756 pub struct SingletonRouter<R: Clone> { 757 route: Option<R>, 758 pub returning: bool, 759 pub navigating: bool, 760 pub after_action: Option<R>, 761 pub split: egui_nav::Split, 762 } 763 764 impl<R: Clone> SingletonRouter<R> { 765 pub fn route_to(&mut self, route: R, split: egui_nav::Split) { 766 self.navigating = true; 767 self.route = Some(route); 768 self.split = split; 769 } 770 771 pub fn go_back(&mut self) { 772 self.returning = true; 773 } 774 775 pub fn clear(&mut self) { 776 *self = Self::default(); 777 } 778 779 pub fn route(&self) -> &Option<R> { 780 &self.route 781 } 782 } 783 784 impl<R: Clone> Default for SingletonRouter<R> { 785 fn default() -> Self { 786 Self { 787 route: None, 788 returning: false, 789 navigating: false, 790 after_action: None, 791 split: egui_nav::Split::PercentFromTop(Percent::new(35).expect("35 <= 100")), 792 } 793 } 794 } 795 796 /// Centralized resource cleanup for popped routes. 797 /// This handles cleanup for Timeline, Thread, and EditProfile routes. 798 #[allow(clippy::too_many_arguments)] 799 pub fn cleanup_popped_route( 800 route: &Route, 801 timeline_cache: &mut TimelineCache, 802 threads: &mut Threads, 803 onboarding: &mut Onboarding, 804 view_state: &mut ViewState, 805 ndb: &mut Ndb, 806 scoped_subs: &mut ScopedSubApi, 807 loaded_timeline_loads: &mut HashSet<TimelineKind>, 808 inflight_timeline_loads: &mut HashSet<TimelineKind>, 809 return_type: ReturnType, 810 col_index: usize, 811 ) { 812 match route { 813 Route::Timeline(kind) => { 814 if let Err(err) = timeline_cache.pop(kind, ndb, scoped_subs) { 815 tracing::error!("popping timeline had an error: {err} for {:?}", kind); 816 } 817 // Allow the async loader to re-populate this timeline if 818 // it is opened again later. 819 loaded_timeline_loads.remove(kind); 820 inflight_timeline_loads.remove(kind); 821 } 822 Route::Thread(selection) => { 823 threads.close(ndb, scoped_subs, selection, return_type, col_index); 824 } 825 Route::EditProfile(pk) => { 826 view_state.pubkey_to_profile_state.remove(pk); 827 } 828 Route::Accounts(AccountsRoute::Onboarding) => { 829 onboarding.end_onboarding(ndb); 830 let _ = scoped_subs.drop_owner(onboarding_owner_key(col_index)); 831 } 832 _ => {} 833 } 834 } 835 836 #[cfg(test)] 837 mod tests { 838 use enostr::NoteId; 839 use tokenator::{TokenParser, TokenWriter}; 840 841 use crate::{timeline::ThreadSelection, Route}; 842 use enostr::Pubkey; 843 use notedeck::RootNoteIdBuf; 844 845 #[test] 846 fn test_thread_route_serialize() { 847 let note_id_hex = "1c54e5b0c386425f7e017d9e068ddef8962eb2ce1bb08ed27e24b93411c12e60"; 848 let note_id = NoteId::from_hex(note_id_hex).unwrap(); 849 let data_str = format!("thread:{}", note_id_hex); 850 let data = &data_str.split(":").collect::<Vec<&str>>(); 851 let mut token_writer = TokenWriter::default(); 852 let mut parser = TokenParser::new(&data); 853 let parsed = Route::parse(&mut parser, &Pubkey::new(*note_id.bytes())).unwrap(); 854 let expected = Route::Thread(ThreadSelection::from_root_id(RootNoteIdBuf::new_unsafe( 855 *note_id.bytes(), 856 ))); 857 parsed.serialize_tokens(&mut token_writer); 858 assert_eq!(expected, parsed); 859 assert_eq!(token_writer.str(), data_str); 860 } 861 }