notedeck

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

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(&note_id.hex());
    106             }
    107             Route::Quote(note_id) => {
    108                 writer.write_token("quote");
    109                 writer.write_token(&note_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(&note_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 }