notedeck

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

add_column.rs (33412B)


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