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