notedeck

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

add_column.rs (33139B)


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