notedeck

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

add_column.rs (32946B)


      1 use core::f32;
      2 use std::collections::HashMap;
      3 
      4 use egui::{
      5     pos2, vec2, Align, Color32, FontId, Id, Image, Margin, Pos2, Rect, RichText, ScrollArea,
      6     Separator, Ui, Vec2, Widget,
      7 };
      8 use enostr::Pubkey;
      9 use nostrdb::{Ndb, Transaction};
     10 use tracing::error;
     11 
     12 use crate::{
     13     login_manager::AcquireKeyState,
     14     options::AppOptions,
     15     route::Route,
     16     timeline::{kind::ListKind, PubkeySource, TimelineKind},
     17     Damus,
     18 };
     19 
     20 use notedeck::{tr, AppContext, Images, Localization, NotedeckTextStyle, UserAccount};
     21 use notedeck_ui::{anim::ICON_EXPANSION_MULTIPLE, app_images};
     22 use tokenator::{ParseError, TokenParser, TokenSerializable, TokenWriter};
     23 
     24 use crate::ui::widgets::styled_button;
     25 use notedeck_ui::{anim::AnimationHelper, padding, ProfilePreview};
     26 
     27 pub enum AddColumnResponse {
     28     Timeline(TimelineKind),
     29     UndecidedNotification,
     30     ExternalNotification,
     31     Hashtag,
     32     Algo(AlgoOption),
     33     UndecidedIndividual,
     34     ExternalIndividual,
     35 }
     36 
     37 pub enum NotificationColumnType {
     38     Contacts,
     39     External,
     40 }
     41 
     42 #[derive(Clone, Debug)]
     43 pub enum Decision<T> {
     44     Undecided,
     45     Decided(T),
     46 }
     47 
     48 #[derive(Clone, Debug)]
     49 pub enum AlgoOption {
     50     LastPerPubkey(Decision<ListKind>),
     51 }
     52 
     53 #[derive(Clone, Debug)]
     54 enum AddColumnOption {
     55     Universe,
     56     UndecidedNotification,
     57     ExternalNotification,
     58     Algo(AlgoOption),
     59     Notification(PubkeySource),
     60     Contacts(PubkeySource),
     61     UndecidedHashtag,
     62     UndecidedIndividual,
     63     ExternalIndividual,
     64     Individual(PubkeySource),
     65 }
     66 
     67 #[derive(Clone, Copy, Eq, PartialEq, Debug, Default)]
     68 pub enum AddAlgoRoute {
     69     #[default]
     70     Base,
     71     LastPerPubkey,
     72 }
     73 
     74 #[derive(Clone, Copy, Eq, PartialEq, Debug)]
     75 pub enum AddColumnRoute {
     76     Base,
     77     UndecidedNotification,
     78     ExternalNotification,
     79     Hashtag,
     80     Algo(AddAlgoRoute),
     81     UndecidedIndividual,
     82     ExternalIndividual,
     83 }
     84 
     85 // Parser for the common case without any payloads
     86 fn parse_column_route<'a>(
     87     parser: &mut TokenParser<'a>,
     88     route: AddColumnRoute,
     89 ) -> Result<AddColumnRoute, ParseError<'a>> {
     90     parser.parse_all(|p| {
     91         for token in route.tokens() {
     92             p.parse_token(token)?;
     93         }
     94         Ok(route)
     95     })
     96 }
     97 
     98 impl AddColumnRoute {
     99     /// Route tokens use in both serialization and deserialization
    100     fn tokens(&self) -> &'static [&'static str] {
    101         match self {
    102             Self::Base => &["column"],
    103             Self::UndecidedNotification => &["column", "notification_selection"],
    104             Self::ExternalNotification => &["column", "external_notif_selection"],
    105             Self::UndecidedIndividual => &["column", "individual_selection"],
    106             Self::ExternalIndividual => &["column", "external_individual_selection"],
    107             Self::Hashtag => &["column", "hashtag"],
    108             Self::Algo(AddAlgoRoute::Base) => &["column", "algo_selection"],
    109             Self::Algo(AddAlgoRoute::LastPerPubkey) => {
    110                 &["column", "algo_selection", "last_per_pubkey"]
    111             } // NOTE!!! When adding to this, update the parser for TokenSerializable below
    112         }
    113     }
    114 }
    115 
    116 impl TokenSerializable for AddColumnRoute {
    117     fn serialize_tokens(&self, writer: &mut TokenWriter) {
    118         for token in self.tokens() {
    119             writer.write_token(token);
    120         }
    121     }
    122 
    123     fn parse_from_tokens<'a>(parser: &mut TokenParser<'a>) -> Result<Self, ParseError<'a>> {
    124         parser.peek_parse_token("column")?;
    125 
    126         TokenParser::alt(
    127             parser,
    128             &[
    129                 |p| parse_column_route(p, AddColumnRoute::Base),
    130                 |p| parse_column_route(p, AddColumnRoute::UndecidedNotification),
    131                 |p| parse_column_route(p, AddColumnRoute::ExternalNotification),
    132                 |p| parse_column_route(p, AddColumnRoute::UndecidedIndividual),
    133                 |p| parse_column_route(p, AddColumnRoute::ExternalIndividual),
    134                 |p| parse_column_route(p, AddColumnRoute::Hashtag),
    135                 |p| parse_column_route(p, AddColumnRoute::Algo(AddAlgoRoute::Base)),
    136                 |p| parse_column_route(p, AddColumnRoute::Algo(AddAlgoRoute::LastPerPubkey)),
    137             ],
    138         )
    139     }
    140 }
    141 
    142 impl AddColumnOption {
    143     pub fn take_as_response(self, cur_account: &UserAccount) -> AddColumnResponse {
    144         match self {
    145             AddColumnOption::Algo(algo_option) => AddColumnResponse::Algo(algo_option),
    146             AddColumnOption::Universe => AddColumnResponse::Timeline(TimelineKind::Universe),
    147             AddColumnOption::Notification(pubkey) => AddColumnResponse::Timeline(
    148                 TimelineKind::Notifications(*pubkey.as_pubkey(&cur_account.key.pubkey)),
    149             ),
    150             AddColumnOption::UndecidedNotification => AddColumnResponse::UndecidedNotification,
    151             AddColumnOption::Contacts(pk_src) => AddColumnResponse::Timeline(
    152                 TimelineKind::contact_list(*pk_src.as_pubkey(&cur_account.key.pubkey)),
    153             ),
    154             AddColumnOption::ExternalNotification => AddColumnResponse::ExternalNotification,
    155             AddColumnOption::UndecidedHashtag => AddColumnResponse::Hashtag,
    156             AddColumnOption::UndecidedIndividual => AddColumnResponse::UndecidedIndividual,
    157             AddColumnOption::ExternalIndividual => AddColumnResponse::ExternalIndividual,
    158             AddColumnOption::Individual(pubkey_source) => AddColumnResponse::Timeline(
    159                 TimelineKind::profile(*pubkey_source.as_pubkey(&cur_account.key.pubkey)),
    160             ),
    161         }
    162     }
    163 }
    164 
    165 pub struct AddColumnView<'a> {
    166     key_state_map: &'a mut HashMap<Id, AcquireKeyState>,
    167     ndb: &'a Ndb,
    168     img_cache: &'a mut Images,
    169     cur_account: &'a UserAccount,
    170     i18n: &'a mut Localization,
    171 }
    172 
    173 impl<'a> AddColumnView<'a> {
    174     pub fn new(
    175         key_state_map: &'a mut HashMap<Id, AcquireKeyState>,
    176         ndb: &'a Ndb,
    177         img_cache: &'a mut Images,
    178         cur_account: &'a UserAccount,
    179         i18n: &'a mut Localization,
    180     ) -> Self {
    181         Self {
    182             key_state_map,
    183             ndb,
    184             img_cache,
    185             cur_account,
    186             i18n,
    187         }
    188     }
    189 
    190     pub fn ui(&mut self, ui: &mut Ui) -> Option<AddColumnResponse> {
    191         ScrollArea::vertical()
    192             .show(ui, |ui| {
    193                 let mut selected_option: Option<AddColumnResponse> = None;
    194                 for column_option_data in self.get_base_options(ui) {
    195                     let option = column_option_data.option.clone();
    196                     if self.column_option_ui(ui, column_option_data).clicked() {
    197                         selected_option = Some(option.take_as_response(self.cur_account));
    198                     }
    199 
    200                     ui.add(Separator::default().spacing(0.0));
    201                 }
    202 
    203                 selected_option
    204             })
    205             .inner
    206     }
    207 
    208     fn notifications_ui(&mut self, ui: &mut Ui) -> Option<AddColumnResponse> {
    209         let mut selected_option: Option<AddColumnResponse> = None;
    210         for column_option_data in self.get_notifications_options(ui) {
    211             let option = column_option_data.option.clone();
    212             if self.column_option_ui(ui, column_option_data).clicked() {
    213                 selected_option = Some(option.take_as_response(self.cur_account));
    214             }
    215 
    216             ui.add(Separator::default().spacing(0.0));
    217         }
    218 
    219         selected_option
    220     }
    221 
    222     fn external_notification_ui(&mut self, ui: &mut Ui) -> Option<AddColumnResponse> {
    223         let id = ui.id().with("external_notif");
    224         self.external_ui(ui, id, |pubkey| {
    225             AddColumnOption::Notification(PubkeySource::Explicit(pubkey))
    226         })
    227     }
    228 
    229     fn algo_last_per_pk_ui(
    230         &mut self,
    231         ui: &mut Ui,
    232         deck_author: Pubkey,
    233     ) -> Option<AddColumnResponse> {
    234         let algo_option = ColumnOptionData {
    235             title: tr!(self.i18n, "Contact List", "Title for contact list column"),
    236             description: tr!(
    237                 self.i18n,
    238                 "Source the last note for each user in your contact list",
    239                 "Description for contact list column"
    240             ),
    241             icon: app_images::home_image(),
    242             option: AddColumnOption::Algo(AlgoOption::LastPerPubkey(Decision::Decided(
    243                 ListKind::contact_list(deck_author),
    244             ))),
    245         };
    246 
    247         let option = algo_option.option.clone();
    248         self.column_option_ui(ui, algo_option)
    249             .clicked()
    250             .then(|| option.take_as_response(self.cur_account))
    251     }
    252 
    253     fn algo_ui(&mut self, ui: &mut Ui) -> Option<AddColumnResponse> {
    254         let algo_option = ColumnOptionData {
    255             title: tr!(
    256                 self.i18n,
    257                 "Last Note per User",
    258                 "Title for last note per user column"
    259             ),
    260             description: tr!(
    261                 self.i18n,
    262                 "Show the last note for each user from a list",
    263                 "Description for last note per user column"
    264             ),
    265             icon: app_images::algo_image(),
    266             option: AddColumnOption::Algo(AlgoOption::LastPerPubkey(Decision::Undecided)),
    267         };
    268 
    269         let option = algo_option.option.clone();
    270         self.column_option_ui(ui, algo_option)
    271             .clicked()
    272             .then(|| option.take_as_response(self.cur_account))
    273     }
    274 
    275     fn individual_ui(&mut self, ui: &mut Ui) -> Option<AddColumnResponse> {
    276         let mut selected_option: Option<AddColumnResponse> = None;
    277         for column_option_data in self.get_individual_options() {
    278             let option = column_option_data.option.clone();
    279             if self.column_option_ui(ui, column_option_data).clicked() {
    280                 selected_option = Some(option.take_as_response(self.cur_account));
    281             }
    282 
    283             ui.add(Separator::default().spacing(0.0));
    284         }
    285 
    286         selected_option
    287     }
    288 
    289     fn external_individual_ui(&mut self, ui: &mut Ui) -> Option<AddColumnResponse> {
    290         let id = ui.id().with("external_individual");
    291 
    292         self.external_ui(ui, id, |pubkey| {
    293             AddColumnOption::Individual(PubkeySource::Explicit(pubkey))
    294         })
    295     }
    296 
    297     fn external_ui(
    298         &mut self,
    299         ui: &mut Ui,
    300         id: egui::Id,
    301         to_option: fn(Pubkey) -> AddColumnOption,
    302     ) -> Option<AddColumnResponse> {
    303         padding(16.0, ui, |ui| {
    304             let key_state = self.key_state_map.entry(id).or_default();
    305 
    306             let text_edit = key_state.get_acquire_textedit(|text| {
    307                 egui::TextEdit::singleline(text)
    308                     .hint_text(
    309                         RichText::new(tr!(
    310                             self.i18n,
    311                             "Enter the user's key (npub, hex, nip05) here...",
    312                             "Hint text to prompt entering the user's public key."
    313                         ))
    314                         .text_style(NotedeckTextStyle::Body.text_style()),
    315                     )
    316                     .vertical_align(Align::Center)
    317                     .desired_width(f32::INFINITY)
    318                     .min_size(Vec2::new(0.0, 40.0))
    319                     .margin(Margin::same(12))
    320             });
    321 
    322             ui.add(text_edit);
    323 
    324             key_state.handle_input_change_after_acquire();
    325             key_state.loading_and_error_ui(ui, self.i18n);
    326 
    327             if key_state.get_login_keypair().is_none()
    328                 && ui.add(find_user_button(self.i18n)).clicked()
    329             {
    330                 key_state.apply_acquire();
    331             }
    332 
    333             let resp = if let Some(keypair) = key_state.get_login_keypair() {
    334                 {
    335                     let txn = Transaction::new(self.ndb).expect("txn");
    336                     if let Ok(profile) =
    337                         self.ndb.get_profile_by_pubkey(&txn, keypair.pubkey.bytes())
    338                     {
    339                         egui::Frame::window(ui.style())
    340                             .outer_margin(Margin {
    341                                 left: 4,
    342                                 right: 4,
    343                                 top: 12,
    344                                 bottom: 32,
    345                             })
    346                             .show(ui, |ui| {
    347                                 ProfilePreview::new(&profile, self.img_cache).ui(ui);
    348                             });
    349                     }
    350                 }
    351 
    352                 ui.add(add_column_button(self.i18n))
    353                     .clicked()
    354                     .then(|| to_option(keypair.pubkey).take_as_response(self.cur_account))
    355             } else {
    356                 None
    357             };
    358             if resp.is_some() {
    359                 self.key_state_map.remove(&id);
    360             };
    361             resp
    362         })
    363         .inner
    364     }
    365 
    366     fn column_option_ui(&mut self, ui: &mut Ui, data: ColumnOptionData) -> egui::Response {
    367         let icon_padding = 8.0;
    368         let min_icon_width = 32.0;
    369         let height_padding = 12.0;
    370         let inter_text_padding = 4.0; // Padding between title and description
    371         let max_width = ui.available_width();
    372         let title_style = NotedeckTextStyle::Body;
    373         let desc_style = NotedeckTextStyle::Button;
    374         let title_min_font_size = notedeck::fonts::get_font_size(ui.ctx(), &title_style);
    375         let desc_min_font_size = notedeck::fonts::get_font_size(ui.ctx(), &desc_style);
    376 
    377         let max_height = {
    378             let max_wrap_width =
    379                 max_width - ((icon_padding * 2.0) + (min_icon_width * ICON_EXPANSION_MULTIPLE));
    380             let title_max_font = FontId::new(
    381                 title_min_font_size * ICON_EXPANSION_MULTIPLE,
    382                 title_style.font_family(),
    383             );
    384             let desc_max_font = FontId::new(
    385                 desc_min_font_size * ICON_EXPANSION_MULTIPLE,
    386                 desc_style.font_family(),
    387             );
    388             let max_desc_galley = ui.fonts(|f| {
    389                 f.layout(
    390                     data.description.to_string(),
    391                     desc_max_font,
    392                     ui.style().visuals.noninteractive().fg_stroke.color,
    393                     max_wrap_width,
    394                 )
    395             });
    396             let max_title_galley = ui.fonts(|f| {
    397                 f.layout(
    398                     data.title.to_string(),
    399                     title_max_font,
    400                     Color32::WHITE,
    401                     max_wrap_width,
    402                 )
    403             });
    404 
    405             let desc_font_max_size = max_desc_galley.rect.height();
    406             let title_font_max_size = max_title_galley.rect.height();
    407             title_font_max_size + inter_text_padding + desc_font_max_size + (2.0 * height_padding)
    408         };
    409 
    410         let title = data.title.clone();
    411         let helper = AnimationHelper::new(ui, title.clone(), vec2(max_width, max_height));
    412         let animation_rect = helper.get_animation_rect();
    413 
    414         let cur_icon_width = helper.scale_1d_pos(min_icon_width);
    415         let painter = ui.painter_at(animation_rect);
    416 
    417         let cur_icon_size = vec2(cur_icon_width, cur_icon_width);
    418         let cur_icon_x_pos = animation_rect.left() + icon_padding + (cur_icon_width / 2.0);
    419 
    420         let title_cur_font = FontId::new(
    421             helper.scale_1d_pos(title_min_font_size),
    422             title_style.font_family(),
    423         );
    424         let desc_cur_font = FontId::new(
    425             helper.scale_1d_pos(desc_min_font_size),
    426             desc_style.font_family(),
    427         );
    428 
    429         let wrap_width = max_width - (cur_icon_width + (icon_padding * 2.0));
    430         let text_color = ui.style().visuals.text_color();
    431         let fallback_color = ui.style().visuals.noninteractive().fg_stroke.color;
    432 
    433         let title_galley = painter.layout(
    434             data.title.to_string(),
    435             title_cur_font,
    436             text_color,
    437             wrap_width,
    438         );
    439         let desc_galley = painter.layout(
    440             data.description.to_string(),
    441             desc_cur_font,
    442             fallback_color,
    443             wrap_width,
    444         );
    445 
    446         let total_content_height =
    447             title_galley.rect.height() + inter_text_padding + desc_galley.rect.height();
    448         let cur_height_padding = (animation_rect.height() - total_content_height) / 2.0;
    449         let corner_x_pos = cur_icon_x_pos + (cur_icon_width / 2.0) + icon_padding;
    450         let title_corner_pos = Pos2::new(corner_x_pos, animation_rect.top() + cur_height_padding);
    451         let desc_corner_pos = Pos2::new(
    452             corner_x_pos,
    453             title_corner_pos.y + title_galley.rect.height() + inter_text_padding,
    454         );
    455 
    456         let icon_cur_y = animation_rect.top() + cur_height_padding + (total_content_height / 2.0);
    457         let icon_img = data.icon.fit_to_exact_size(cur_icon_size);
    458         let icon_rect = Rect::from_center_size(pos2(cur_icon_x_pos, icon_cur_y), cur_icon_size);
    459 
    460         icon_img.paint_at(ui, icon_rect);
    461         painter.galley(title_corner_pos, title_galley, text_color);
    462         painter.galley(desc_corner_pos, desc_galley, fallback_color);
    463 
    464         helper.take_animation_response()
    465     }
    466 
    467     fn get_base_options(&mut self, ui: &mut Ui) -> Vec<ColumnOptionData> {
    468         let mut vec = Vec::new();
    469         vec.push(ColumnOptionData {
    470             title: tr!(self.i18n, "Home", "Title for Home column"),
    471             description: tr!(
    472                 self.i18n,
    473                 "See notes from your contacts",
    474                 "Description for Home column"
    475             ),
    476             icon: app_images::home_image(),
    477             option: AddColumnOption::Contacts(if self.cur_account.key.secret_key.is_some() {
    478                 PubkeySource::DeckAuthor
    479             } else {
    480                 PubkeySource::Explicit(self.cur_account.key.pubkey)
    481             }),
    482         });
    483         vec.push(ColumnOptionData {
    484             title: tr!(self.i18n, "Notifications", "Title for notifications column"),
    485             description: tr!(
    486                 self.i18n,
    487                 "Stay up to date with notifications and mentions",
    488                 "Description for notifications column"
    489             ),
    490             icon: app_images::notifications_image(ui.visuals().dark_mode),
    491             option: AddColumnOption::UndecidedNotification,
    492         });
    493         vec.push(ColumnOptionData {
    494             title: tr!(self.i18n, "Universe", "Title for universe column"),
    495             description: tr!(
    496                 self.i18n,
    497                 "See the whole nostr universe",
    498                 "Description for universe column"
    499             ),
    500             icon: app_images::universe_image(),
    501             option: AddColumnOption::Universe,
    502         });
    503         vec.push(ColumnOptionData {
    504             title: tr!(self.i18n, "Hashtags", "Title for hashtags column"),
    505             description: tr!(
    506                 self.i18n,
    507                 "Stay up to date with a certain hashtag",
    508                 "Description for hashtags column"
    509             ),
    510             icon: app_images::hashtag_image(),
    511             option: AddColumnOption::UndecidedHashtag,
    512         });
    513         vec.push(ColumnOptionData {
    514             title: tr!(self.i18n, "Individual", "Title for individual user column"),
    515             description: tr!(
    516                 self.i18n,
    517                 "Stay up to date with someone's notes & replies",
    518                 "Description for individual user column"
    519             ),
    520             icon: app_images::profile_image(),
    521             option: AddColumnOption::UndecidedIndividual,
    522         });
    523         vec.push(ColumnOptionData {
    524             title: tr!(self.i18n, "Algo", "Title for algorithmic feeds column"),
    525             description: tr!(
    526                 self.i18n,
    527                 "Algorithmic feeds to aid in note discovery",
    528                 "Description for algorithmic feeds column"
    529             ),
    530             icon: app_images::algo_image(),
    531             option: AddColumnOption::Algo(AlgoOption::LastPerPubkey(Decision::Undecided)),
    532         });
    533 
    534         vec
    535     }
    536 
    537     fn get_notifications_options(&mut self, ui: &mut Ui) -> Vec<ColumnOptionData> {
    538         let mut vec = Vec::new();
    539 
    540         let source = if self.cur_account.key.secret_key.is_some() {
    541             PubkeySource::DeckAuthor
    542         } else {
    543             PubkeySource::Explicit(self.cur_account.key.pubkey)
    544         };
    545 
    546         vec.push(ColumnOptionData {
    547             title: tr!(
    548                 self.i18n,
    549                 "Your Notifications",
    550                 "Title for your notifications column"
    551             ),
    552             description: tr!(
    553                 self.i18n,
    554                 "Stay up to date with your notifications and mentions",
    555                 "Description for your notifications column"
    556             ),
    557             icon: app_images::notifications_image(ui.visuals().dark_mode),
    558             option: AddColumnOption::Notification(source),
    559         });
    560 
    561         vec.push(ColumnOptionData {
    562             title: tr!(
    563                 self.i18n,
    564                 "Someone else's Notifications",
    565                 "Title for someone else's notifications column"
    566             ),
    567             description: tr!(
    568                 self.i18n,
    569                 "Stay up to date with someone else's notifications and mentions",
    570                 "Description for someone else's notifications column"
    571             ),
    572             icon: app_images::notifications_image(ui.visuals().dark_mode),
    573             option: AddColumnOption::ExternalNotification,
    574         });
    575 
    576         vec
    577     }
    578 
    579     fn get_individual_options(&mut self) -> Vec<ColumnOptionData> {
    580         let mut vec = Vec::new();
    581 
    582         let source = if self.cur_account.key.secret_key.is_some() {
    583             PubkeySource::DeckAuthor
    584         } else {
    585             PubkeySource::Explicit(self.cur_account.key.pubkey)
    586         };
    587 
    588         vec.push(ColumnOptionData {
    589             title: tr!(self.i18n, "Your Notes", "Title for your notes column"),
    590             description: tr!(
    591                 self.i18n,
    592                 "Keep track of your notes & replies",
    593                 "Description for your notes column"
    594             ),
    595             icon: app_images::profile_image(),
    596             option: AddColumnOption::Individual(source),
    597         });
    598 
    599         vec.push(ColumnOptionData {
    600             title: tr!(
    601                 self.i18n,
    602                 "Someone else's Notes",
    603                 "Title for someone else's notes column"
    604             ),
    605             description: tr!(
    606                 self.i18n,
    607                 "Stay up to date with someone else's notes & replies",
    608                 "Description for someone else's notes column"
    609             ),
    610             icon: app_images::profile_image(),
    611             option: AddColumnOption::ExternalIndividual,
    612         });
    613 
    614         vec
    615     }
    616 }
    617 
    618 fn find_user_button(i18n: &mut Localization) -> impl Widget {
    619     let label = tr!(i18n, "Find User", "Label for find user button");
    620     let color = notedeck_ui::colors::PINK;
    621     move |ui: &mut egui::Ui| styled_button(label.as_str(), color).ui(ui)
    622 }
    623 
    624 fn add_column_button(i18n: &mut Localization) -> impl Widget {
    625     let label = tr!(i18n, "Add", "Label for add column button");
    626     let color = notedeck_ui::colors::PINK;
    627     move |ui: &mut egui::Ui| styled_button(label.as_str(), color).ui(ui)
    628 }
    629 
    630 /*
    631 pub(crate) fn sized_button(text: &str) -> impl Widget + '_ {
    632     move |ui: &mut egui::Ui| -> egui::Response {
    633         let painter = ui.painter();
    634         let galley = painter.layout(
    635             text.to_owned(),
    636             NotedeckTextStyle::Body.get_font_id(ui.ctx()),
    637             Color32::WHITE,
    638             ui.available_width(),
    639         );
    640 
    641         ui.add_sized(
    642             galley.rect.expand2(vec2(16.0, 8.0)).size(),
    643             egui::Button::new(galley)
    644                 .corner_radius(8.0)
    645                 .fill(notedeck_ui::colors::PINK),
    646         )
    647     }
    648 }
    649 */
    650 
    651 struct ColumnOptionData {
    652     title: String,
    653     description: String,
    654     icon: Image<'static>,
    655     option: AddColumnOption,
    656 }
    657 
    658 pub fn render_add_column_routes(
    659     ui: &mut egui::Ui,
    660     app: &mut Damus,
    661     ctx: &mut AppContext<'_>,
    662     col: usize,
    663     route: &AddColumnRoute,
    664 ) {
    665     let mut add_column_view = AddColumnView::new(
    666         &mut app.view_state.id_state_map,
    667         ctx.ndb,
    668         ctx.img_cache,
    669         ctx.accounts.get_selected_account(),
    670         ctx.i18n,
    671     );
    672     let resp = match route {
    673         AddColumnRoute::Base => add_column_view.ui(ui),
    674         AddColumnRoute::Algo(r) => match r {
    675             AddAlgoRoute::Base => add_column_view.algo_ui(ui),
    676             AddAlgoRoute::LastPerPubkey => add_column_view
    677                 .algo_last_per_pk_ui(ui, ctx.accounts.get_selected_account().key.pubkey),
    678         },
    679         AddColumnRoute::UndecidedNotification => add_column_view.notifications_ui(ui),
    680         AddColumnRoute::ExternalNotification => add_column_view.external_notification_ui(ui),
    681         AddColumnRoute::Hashtag => hashtag_ui(ui, ctx.i18n, &mut app.view_state.id_string_map),
    682         AddColumnRoute::UndecidedIndividual => add_column_view.individual_ui(ui),
    683         AddColumnRoute::ExternalIndividual => add_column_view.external_individual_ui(ui),
    684     };
    685 
    686     if let Some(resp) = resp {
    687         match resp {
    688             AddColumnResponse::Timeline(timeline_kind) => 'leave: {
    689                 let txn = Transaction::new(ctx.ndb).unwrap();
    690                 let mut timeline =
    691                     if let Some(timeline) = timeline_kind.into_timeline(&txn, ctx.ndb) {
    692                         timeline
    693                     } else {
    694                         error!("Could not convert column response to timeline");
    695                         break 'leave;
    696                     };
    697 
    698                 crate::timeline::setup_new_timeline(
    699                     &mut timeline,
    700                     ctx.ndb,
    701                     &txn,
    702                     &mut app.subscriptions,
    703                     ctx.pool,
    704                     ctx.note_cache,
    705                     app.options.contains(AppOptions::SinceOptimize),
    706                     ctx.accounts,
    707                 );
    708 
    709                 app.columns_mut(ctx.i18n, ctx.accounts)
    710                     .column_mut(col)
    711                     .router_mut()
    712                     .route_to_replaced(Route::timeline(timeline.kind.clone()));
    713 
    714                 app.timeline_cache.insert(timeline.kind.clone(), timeline);
    715             }
    716 
    717             AddColumnResponse::Algo(algo_option) => match algo_option {
    718                 // If we are undecided, we simply route to the LastPerPubkey
    719                 // algo route selection
    720                 AlgoOption::LastPerPubkey(Decision::Undecided) => {
    721                     app.columns_mut(ctx.i18n, ctx.accounts)
    722                         .column_mut(col)
    723                         .router_mut()
    724                         .route_to(Route::AddColumn(AddColumnRoute::Algo(
    725                             AddAlgoRoute::LastPerPubkey,
    726                         )));
    727                 }
    728 
    729                 // We have a decision on where we want the last per pubkey
    730                 // source to be, so let's create a timeline from that and
    731                 // add it to our list of timelines
    732                 AlgoOption::LastPerPubkey(Decision::Decided(list_kind)) => {
    733                     let txn = Transaction::new(ctx.ndb).unwrap();
    734                     let maybe_timeline =
    735                         TimelineKind::last_per_pubkey(list_kind).into_timeline(&txn, ctx.ndb);
    736 
    737                     if let Some(mut timeline) = maybe_timeline {
    738                         crate::timeline::setup_new_timeline(
    739                             &mut timeline,
    740                             ctx.ndb,
    741                             &txn,
    742                             &mut app.subscriptions,
    743                             ctx.pool,
    744                             ctx.note_cache,
    745                             app.options.contains(AppOptions::SinceOptimize),
    746                             ctx.accounts,
    747                         );
    748 
    749                         app.columns_mut(ctx.i18n, ctx.accounts)
    750                             .column_mut(col)
    751                             .router_mut()
    752                             .route_to_replaced(Route::timeline(timeline.kind.clone()));
    753 
    754                         app.timeline_cache.insert(timeline.kind.clone(), timeline);
    755                     } else {
    756                         // we couldn't fetch the timeline yet... let's let
    757                         // the user know ?
    758 
    759                         // TODO: spin off the list search here instead
    760 
    761                         ui.label(format!("error: could not find {list_kind:?}"));
    762                     }
    763                 }
    764             },
    765 
    766             AddColumnResponse::UndecidedNotification => {
    767                 app.columns_mut(ctx.i18n, ctx.accounts)
    768                     .column_mut(col)
    769                     .router_mut()
    770                     .route_to(Route::AddColumn(AddColumnRoute::UndecidedNotification));
    771             }
    772             AddColumnResponse::ExternalNotification => {
    773                 app.columns_mut(ctx.i18n, ctx.accounts)
    774                     .column_mut(col)
    775                     .router_mut()
    776                     .route_to(crate::route::Route::AddColumn(
    777                         AddColumnRoute::ExternalNotification,
    778                     ));
    779             }
    780             AddColumnResponse::Hashtag => {
    781                 app.columns_mut(ctx.i18n, ctx.accounts)
    782                     .column_mut(col)
    783                     .router_mut()
    784                     .route_to(crate::route::Route::AddColumn(AddColumnRoute::Hashtag));
    785             }
    786             AddColumnResponse::UndecidedIndividual => {
    787                 app.columns_mut(ctx.i18n, ctx.accounts)
    788                     .column_mut(col)
    789                     .router_mut()
    790                     .route_to(crate::route::Route::AddColumn(
    791                         AddColumnRoute::UndecidedIndividual,
    792                     ));
    793             }
    794             AddColumnResponse::ExternalIndividual => {
    795                 app.columns_mut(ctx.i18n, ctx.accounts)
    796                     .column_mut(col)
    797                     .router_mut()
    798                     .route_to(crate::route::Route::AddColumn(
    799                         AddColumnRoute::ExternalIndividual,
    800                     ));
    801             }
    802         };
    803     }
    804 }
    805 
    806 pub fn hashtag_ui(
    807     ui: &mut Ui,
    808     i18n: &mut Localization,
    809     id_string_map: &mut HashMap<Id, String>,
    810 ) -> Option<AddColumnResponse> {
    811     padding(16.0, ui, |ui| {
    812         let id = ui.id().with("hashtag)");
    813         let text_buffer = id_string_map.entry(id).or_default();
    814 
    815         let text_edit = egui::TextEdit::singleline(text_buffer)
    816             .hint_text(
    817                 RichText::new(tr!(
    818                     i18n,
    819                     "Enter the desired hashtags here (for multiple space-separated)",
    820                     "Placeholder for hashtag input field"
    821                 ))
    822                 .text_style(NotedeckTextStyle::Body.text_style()),
    823             )
    824             .vertical_align(Align::Center)
    825             .desired_width(f32::INFINITY)
    826             .min_size(Vec2::new(0.0, 40.0))
    827             .margin(Margin::same(12));
    828         ui.add(text_edit);
    829 
    830         ui.add_space(8.0);
    831 
    832         let mut handle_user_input = false;
    833         if ui.input(|i| i.key_released(egui::Key::Enter))
    834             || ui
    835                 .add_sized(egui::vec2(50.0, 40.0), add_column_button(i18n))
    836                 .clicked()
    837         {
    838             handle_user_input = true;
    839         }
    840 
    841         if handle_user_input && !text_buffer.is_empty() {
    842             let resp = AddColumnResponse::Timeline(TimelineKind::Hashtag(
    843                 text_buffer
    844                     .split_whitespace()
    845                     .filter(|s| !s.is_empty())
    846                     .map(|s| sanitize_hashtag(s).to_lowercase().to_string())
    847                     .collect::<Vec<_>>(),
    848             ));
    849             id_string_map.remove(&id);
    850             Some(resp)
    851         } else {
    852             None
    853         }
    854     })
    855     .inner
    856 }
    857 
    858 fn sanitize_hashtag(raw_hashtag: &str) -> String {
    859     raw_hashtag
    860         .chars()
    861         .filter(|c| c.is_alphanumeric()) // keep letters and numbers only
    862         .collect()
    863 }
    864 
    865 #[cfg(test)]
    866 mod tests {
    867     use super::*;
    868 
    869     #[test]
    870     fn test_column_serialize() {
    871         use super::{AddAlgoRoute, AddColumnRoute};
    872 
    873         {
    874             let data_str = "column:algo_selection:last_per_pubkey";
    875             let data = &data_str.split(":").collect::<Vec<&str>>();
    876             let mut token_writer = TokenWriter::default();
    877             let mut parser = TokenParser::new(data);
    878             let parsed = AddColumnRoute::parse_from_tokens(&mut parser).unwrap();
    879             let expected = AddColumnRoute::Algo(AddAlgoRoute::LastPerPubkey);
    880             parsed.serialize_tokens(&mut token_writer);
    881             assert_eq!(expected, parsed);
    882             assert_eq!(token_writer.str(), data_str);
    883         }
    884 
    885         {
    886             let data_str = "column";
    887             let mut token_writer = TokenWriter::default();
    888             let data: &[&str] = &[data_str];
    889             let mut parser = TokenParser::new(data);
    890             let parsed = AddColumnRoute::parse_from_tokens(&mut parser).unwrap();
    891             let expected = AddColumnRoute::Base;
    892             parsed.serialize_tokens(&mut token_writer);
    893             assert_eq!(expected, parsed);
    894             assert_eq!(token_writer.str(), data_str);
    895         }
    896     }
    897 }