notedeck

One damus client to rule them all
git clone git://jb55.com/notedeck
Log | Files | Refs | README | LICENSE

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(&note_id.hex());
    115             }
    116             Route::Quote(note_id) => {
    117                 writer.write_token("quote");
    118                 writer.write_token(&note_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(&note_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(&note_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 }