notedeck

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

add_column.rs (29785B)


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