notedeck

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

add_column.rs (21072B)


      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 
     11 use crate::{
     12     login_manager::AcquireKeyState,
     13     timeline::{PubkeySource, Timeline, TimelineKind},
     14     ui::anim::ICON_EXPANSION_MULTIPLE,
     15     Damus,
     16 };
     17 
     18 use notedeck::{AppContext, ImageCache, NotedeckTextStyle, UserAccount};
     19 
     20 use super::{anim::AnimationHelper, padding, ProfilePreview};
     21 
     22 pub enum AddColumnResponse {
     23     Timeline(Timeline),
     24     UndecidedNotification,
     25     ExternalNotification,
     26     Hashtag,
     27     UndecidedIndividual,
     28     ExternalIndividual,
     29 }
     30 
     31 pub enum NotificationColumnType {
     32     Home,
     33     External,
     34 }
     35 
     36 #[derive(Clone, Debug)]
     37 enum AddColumnOption {
     38     Universe,
     39     UndecidedNotification,
     40     ExternalNotification,
     41     Notification(PubkeySource),
     42     Home(PubkeySource),
     43     UndecidedHashtag,
     44     Hashtag(String),
     45     UndecidedIndividual,
     46     ExternalIndividual,
     47     Individual(PubkeySource),
     48 }
     49 
     50 #[derive(Clone, Copy, Eq, PartialEq, Debug)]
     51 pub enum AddColumnRoute {
     52     Base,
     53     UndecidedNotification,
     54     ExternalNotification,
     55     Hashtag,
     56     UndecidedIndividual,
     57     ExternalIndividual,
     58 }
     59 
     60 impl AddColumnOption {
     61     pub fn take_as_response(
     62         self,
     63         ndb: &Ndb,
     64         cur_account: Option<&UserAccount>,
     65     ) -> Option<AddColumnResponse> {
     66         match self {
     67             AddColumnOption::Universe => TimelineKind::Universe
     68                 .into_timeline(ndb, None)
     69                 .map(AddColumnResponse::Timeline),
     70             AddColumnOption::Notification(pubkey) => TimelineKind::Notifications(pubkey)
     71                 .into_timeline(ndb, cur_account.map(|a| a.pubkey.bytes()))
     72                 .map(AddColumnResponse::Timeline),
     73             AddColumnOption::UndecidedNotification => {
     74                 Some(AddColumnResponse::UndecidedNotification)
     75             }
     76             AddColumnOption::Home(pubkey) => {
     77                 let tlk = TimelineKind::contact_list(pubkey);
     78                 tlk.into_timeline(ndb, cur_account.map(|a| a.pubkey.bytes()))
     79                     .map(AddColumnResponse::Timeline)
     80             }
     81             AddColumnOption::ExternalNotification => Some(AddColumnResponse::ExternalNotification),
     82             AddColumnOption::UndecidedHashtag => Some(AddColumnResponse::Hashtag),
     83             AddColumnOption::Hashtag(hashtag) => TimelineKind::Hashtag(hashtag)
     84                 .into_timeline(ndb, None)
     85                 .map(AddColumnResponse::Timeline),
     86             AddColumnOption::UndecidedIndividual => Some(AddColumnResponse::UndecidedIndividual),
     87             AddColumnOption::ExternalIndividual => Some(AddColumnResponse::ExternalIndividual),
     88             AddColumnOption::Individual(pubkey_source) => {
     89                 let tlk = TimelineKind::profile(pubkey_source);
     90                 tlk.into_timeline(ndb, cur_account.map(|a| a.pubkey.bytes()))
     91                     .map(AddColumnResponse::Timeline)
     92             }
     93         }
     94     }
     95 }
     96 
     97 pub struct AddColumnView<'a> {
     98     key_state_map: &'a mut HashMap<Id, AcquireKeyState>,
     99     ndb: &'a Ndb,
    100     img_cache: &'a mut ImageCache,
    101     cur_account: Option<&'a UserAccount>,
    102 }
    103 
    104 impl<'a> AddColumnView<'a> {
    105     pub fn new(
    106         key_state_map: &'a mut HashMap<Id, AcquireKeyState>,
    107         ndb: &'a Ndb,
    108         img_cache: &'a mut ImageCache,
    109         cur_account: Option<&'a UserAccount>,
    110     ) -> Self {
    111         Self {
    112             key_state_map,
    113             ndb,
    114             img_cache,
    115             cur_account,
    116         }
    117     }
    118 
    119     pub fn ui(&mut self, ui: &mut Ui) -> Option<AddColumnResponse> {
    120         let mut selected_option: Option<AddColumnResponse> = None;
    121         for column_option_data in self.get_base_options() {
    122             let option = column_option_data.option.clone();
    123             if self.column_option_ui(ui, column_option_data).clicked() {
    124                 selected_option = option.take_as_response(self.ndb, self.cur_account);
    125             }
    126 
    127             ui.add(Separator::default().spacing(0.0));
    128         }
    129 
    130         selected_option
    131     }
    132 
    133     fn notifications_ui(&mut self, ui: &mut Ui) -> Option<AddColumnResponse> {
    134         let mut selected_option: Option<AddColumnResponse> = None;
    135         for column_option_data in self.get_notifications_options() {
    136             let option = column_option_data.option.clone();
    137             if self.column_option_ui(ui, column_option_data).clicked() {
    138                 selected_option = option.take_as_response(self.ndb, self.cur_account);
    139             }
    140 
    141             ui.add(Separator::default().spacing(0.0));
    142         }
    143 
    144         selected_option
    145     }
    146 
    147     fn external_notification_ui(&mut self, ui: &mut Ui) -> Option<AddColumnResponse> {
    148         let id = ui.id().with("external_notif");
    149         self.external_ui(ui, id, |pubkey| {
    150             AddColumnOption::Notification(PubkeySource::Explicit(pubkey))
    151         })
    152     }
    153 
    154     fn individual_ui(&mut self, ui: &mut Ui) -> Option<AddColumnResponse> {
    155         let mut selected_option: Option<AddColumnResponse> = None;
    156         for column_option_data in self.get_individual_options() {
    157             let option = column_option_data.option.clone();
    158             if self.column_option_ui(ui, column_option_data).clicked() {
    159                 selected_option = option.take_as_response(self.ndb, self.cur_account);
    160             }
    161 
    162             ui.add(Separator::default().spacing(0.0));
    163         }
    164 
    165         selected_option
    166     }
    167 
    168     fn external_individual_ui(&mut self, ui: &mut Ui) -> Option<AddColumnResponse> {
    169         let id = ui.id().with("external_individual");
    170 
    171         self.external_ui(ui, id, |pubkey| {
    172             AddColumnOption::Individual(PubkeySource::Explicit(pubkey))
    173         })
    174     }
    175 
    176     fn external_ui(
    177         &mut self,
    178         ui: &mut Ui,
    179         id: egui::Id,
    180         to_option: fn(Pubkey) -> AddColumnOption,
    181     ) -> Option<AddColumnResponse> {
    182         padding(16.0, ui, |ui| {
    183             let key_state = self.key_state_map.entry(id).or_default();
    184 
    185             let text_edit = key_state.get_acquire_textedit(|text| {
    186                 egui::TextEdit::singleline(text)
    187                     .hint_text(
    188                         RichText::new("Enter the user's key (npub, hex, nip05) here...")
    189                             .text_style(NotedeckTextStyle::Body.text_style()),
    190                     )
    191                     .vertical_align(Align::Center)
    192                     .desired_width(f32::INFINITY)
    193                     .min_size(Vec2::new(0.0, 40.0))
    194                     .margin(Margin::same(12.0))
    195             });
    196 
    197             ui.add(text_edit);
    198 
    199             key_state.handle_input_change_after_acquire();
    200             key_state.loading_and_error_ui(ui);
    201 
    202             if key_state.get_login_keypair().is_none() && ui.add(find_user_button()).clicked() {
    203                 key_state.apply_acquire();
    204             }
    205 
    206             let resp = if let Some(keypair) = key_state.get_login_keypair() {
    207                 let txn = Transaction::new(self.ndb).expect("txn");
    208                 if let Ok(profile) = self.ndb.get_profile_by_pubkey(&txn, keypair.pubkey.bytes()) {
    209                     egui::Frame::window(ui.style())
    210                         .outer_margin(Margin {
    211                             left: 4.0,
    212                             right: 4.0,
    213                             top: 12.0,
    214                             bottom: 32.0,
    215                         })
    216                         .show(ui, |ui| {
    217                             ProfilePreview::new(&profile, self.img_cache).ui(ui);
    218                         });
    219                 }
    220 
    221                 if ui.add(add_column_button()).clicked() {
    222                     to_option(keypair.pubkey).take_as_response(self.ndb, self.cur_account)
    223                 } else {
    224                     None
    225                 }
    226             } else {
    227                 None
    228             };
    229             if resp.is_some() {
    230                 self.key_state_map.remove(&id);
    231             };
    232             resp
    233         })
    234         .inner
    235     }
    236 
    237     fn column_option_ui(&mut self, ui: &mut Ui, data: ColumnOptionData) -> egui::Response {
    238         let icon_padding = 8.0;
    239         let min_icon_width = 32.0;
    240         let height_padding = 12.0;
    241         let max_width = ui.available_width();
    242         let title_style = NotedeckTextStyle::Body;
    243         let desc_style = NotedeckTextStyle::Button;
    244         let title_min_font_size = notedeck::fonts::get_font_size(ui.ctx(), &title_style);
    245         let desc_min_font_size = notedeck::fonts::get_font_size(ui.ctx(), &desc_style);
    246 
    247         let max_height = {
    248             let max_wrap_width =
    249                 max_width - ((icon_padding * 2.0) + (min_icon_width * ICON_EXPANSION_MULTIPLE));
    250             let title_max_font = FontId::new(
    251                 title_min_font_size * ICON_EXPANSION_MULTIPLE,
    252                 title_style.font_family(),
    253             );
    254             let desc_max_font = FontId::new(
    255                 desc_min_font_size * ICON_EXPANSION_MULTIPLE,
    256                 desc_style.font_family(),
    257             );
    258             let max_desc_galley = ui.fonts(|f| {
    259                 f.layout(
    260                     data.description.to_string(),
    261                     desc_max_font,
    262                     Color32::WHITE,
    263                     max_wrap_width,
    264                 )
    265             });
    266 
    267             let max_title_galley = ui.fonts(|f| {
    268                 f.layout(
    269                     data.title.to_string(),
    270                     title_max_font,
    271                     Color32::WHITE,
    272                     max_wrap_width,
    273                 )
    274             });
    275 
    276             let desc_font_max_size = max_desc_galley.rect.height();
    277             let title_font_max_size = max_title_galley.rect.height();
    278             title_font_max_size + desc_font_max_size + (2.0 * height_padding)
    279         };
    280 
    281         let helper = AnimationHelper::new(ui, data.title, vec2(max_width, max_height));
    282         let animation_rect = helper.get_animation_rect();
    283 
    284         let cur_icon_width = helper.scale_1d_pos(min_icon_width);
    285         let painter = ui.painter_at(animation_rect);
    286 
    287         let cur_icon_size = vec2(cur_icon_width, cur_icon_width);
    288         let cur_icon_x_pos = animation_rect.left() + (icon_padding) + (cur_icon_width / 2.0);
    289 
    290         let title_cur_font = FontId::new(
    291             helper.scale_1d_pos(title_min_font_size),
    292             title_style.font_family(),
    293         );
    294 
    295         let desc_cur_font = FontId::new(
    296             helper.scale_1d_pos(desc_min_font_size),
    297             desc_style.font_family(),
    298         );
    299 
    300         let wrap_width = max_width - (cur_icon_width + (icon_padding * 2.0));
    301         let text_color = ui.ctx().style().visuals.text_color();
    302         let fallback_color = ui.ctx().style().visuals.weak_text_color();
    303 
    304         let title_galley = painter.layout(
    305             data.title.to_string(),
    306             title_cur_font,
    307             text_color,
    308             wrap_width,
    309         );
    310         let desc_galley = painter.layout(
    311             data.description.to_string(),
    312             desc_cur_font,
    313             text_color,
    314             wrap_width,
    315         );
    316 
    317         let galley_heights = title_galley.rect.height() + desc_galley.rect.height();
    318 
    319         let cur_height_padding = (animation_rect.height() - galley_heights) / 2.0;
    320         let corner_x_pos = cur_icon_x_pos + (cur_icon_width / 2.0) + icon_padding;
    321         let title_corner_pos = Pos2::new(corner_x_pos, animation_rect.top() + cur_height_padding);
    322         let desc_corner_pos = Pos2::new(
    323             corner_x_pos,
    324             title_corner_pos.y + title_galley.rect.height(),
    325         );
    326 
    327         let icon_cur_y = animation_rect.top() + cur_height_padding + (galley_heights / 2.0);
    328         let icon_img = egui::Image::new(data.icon).fit_to_exact_size(cur_icon_size);
    329         let icon_rect = Rect::from_center_size(pos2(cur_icon_x_pos, icon_cur_y), cur_icon_size);
    330 
    331         icon_img.paint_at(ui, icon_rect);
    332         painter.galley(title_corner_pos, title_galley, fallback_color);
    333         painter.galley(desc_corner_pos, desc_galley, fallback_color);
    334 
    335         helper.take_animation_response()
    336     }
    337 
    338     fn get_base_options(&self) -> Vec<ColumnOptionData> {
    339         let mut vec = Vec::new();
    340         vec.push(ColumnOptionData {
    341             title: "Universe",
    342             description: "See the whole nostr universe",
    343             icon: egui::include_image!("../../../../assets/icons/universe_icon_dark_4x.png"),
    344             option: AddColumnOption::Universe,
    345         });
    346 
    347         if let Some(acc) = self.cur_account {
    348             let source = if acc.secret_key.is_some() {
    349                 PubkeySource::DeckAuthor
    350             } else {
    351                 PubkeySource::Explicit(acc.pubkey)
    352             };
    353 
    354             vec.push(ColumnOptionData {
    355                 title: "Home timeline",
    356                 description: "See recommended notes first",
    357                 icon: egui::include_image!("../../../../assets/icons/home_icon_dark_4x.png"),
    358                 option: AddColumnOption::Home(source.clone()),
    359             });
    360         }
    361         vec.push(ColumnOptionData {
    362             title: "Notifications",
    363             description: "Stay up to date with notifications and mentions",
    364             icon: egui::include_image!("../../../../assets/icons/notifications_icon_dark_4x.png"),
    365             option: AddColumnOption::UndecidedNotification,
    366         });
    367         vec.push(ColumnOptionData {
    368             title: "Hashtag",
    369             description: "Stay up to date with a certain hashtag",
    370             icon: egui::include_image!("../../../../assets/icons/notifications_icon_dark_4x.png"),
    371             option: AddColumnOption::UndecidedHashtag,
    372         });
    373         vec.push(ColumnOptionData {
    374             title: "Individual",
    375             description: "Stay up to date with someone's notes & replies",
    376             icon: egui::include_image!("../../../../assets/icons/profile_icon_4x.png"),
    377             option: AddColumnOption::UndecidedIndividual,
    378         });
    379 
    380         vec
    381     }
    382 
    383     fn get_notifications_options(&self) -> Vec<ColumnOptionData> {
    384         let mut vec = Vec::new();
    385 
    386         if let Some(acc) = self.cur_account {
    387             let source = if acc.secret_key.is_some() {
    388                 PubkeySource::DeckAuthor
    389             } else {
    390                 PubkeySource::Explicit(acc.pubkey)
    391             };
    392 
    393             vec.push(ColumnOptionData {
    394                 title: "Your Notifications",
    395                 description: "Stay up to date with your notifications and mentions",
    396                 icon: egui::include_image!(
    397                     "../../../../assets/icons/notifications_icon_dark_4x.png"
    398                 ),
    399                 option: AddColumnOption::Notification(source),
    400             });
    401         }
    402 
    403         vec.push(ColumnOptionData {
    404             title: "Someone else's Notifications",
    405             description: "Stay up to date with someone else's notifications and mentions",
    406             icon: egui::include_image!("../../../../assets/icons/notifications_icon_dark_4x.png"),
    407             option: AddColumnOption::ExternalNotification,
    408         });
    409 
    410         vec
    411     }
    412 
    413     fn get_individual_options(&self) -> Vec<ColumnOptionData> {
    414         let mut vec = Vec::new();
    415 
    416         if let Some(acc) = self.cur_account {
    417             let source = if acc.secret_key.is_some() {
    418                 PubkeySource::DeckAuthor
    419             } else {
    420                 PubkeySource::Explicit(acc.pubkey)
    421             };
    422 
    423             vec.push(ColumnOptionData {
    424                 title: "Your Notes",
    425                 description: "Keep track of your notes & replies",
    426                 icon: egui::include_image!("../../../../assets/icons/profile_icon_4x.png"),
    427                 option: AddColumnOption::Individual(source),
    428             });
    429         }
    430 
    431         vec.push(ColumnOptionData {
    432             title: "Someone else's Notes",
    433             description: "Stay up to date with someone else's notes & replies",
    434             icon: egui::include_image!("../../../../assets/icons/profile_icon_4x.png"),
    435             option: AddColumnOption::ExternalIndividual,
    436         });
    437 
    438         vec
    439     }
    440 }
    441 
    442 fn find_user_button() -> impl Widget {
    443     sized_button("Find User")
    444 }
    445 
    446 fn add_column_button() -> impl Widget {
    447     sized_button("Add")
    448 }
    449 
    450 fn sized_button(text: &str) -> impl Widget + '_ {
    451     move |ui: &mut egui::Ui| -> egui::Response {
    452         let painter = ui.painter();
    453         let galley = painter.layout(
    454             text.to_owned(),
    455             NotedeckTextStyle::Body.get_font_id(ui.ctx()),
    456             Color32::WHITE,
    457             ui.available_width(),
    458         );
    459 
    460         ui.add_sized(
    461             galley.rect.expand2(vec2(16.0, 8.0)).size(),
    462             Button::new(galley).rounding(8.0).fill(crate::colors::PINK),
    463         )
    464     }
    465 }
    466 
    467 struct ColumnOptionData {
    468     title: &'static str,
    469     description: &'static str,
    470     icon: ImageSource<'static>,
    471     option: AddColumnOption,
    472 }
    473 
    474 pub fn render_add_column_routes(
    475     ui: &mut egui::Ui,
    476     app: &mut Damus,
    477     ctx: &mut AppContext<'_>,
    478     col: usize,
    479     route: &AddColumnRoute,
    480 ) {
    481     let mut add_column_view = AddColumnView::new(
    482         &mut app.view_state.id_state_map,
    483         ctx.ndb,
    484         ctx.img_cache,
    485         ctx.accounts.get_selected_account(),
    486     );
    487     let resp = match route {
    488         AddColumnRoute::Base => add_column_view.ui(ui),
    489         AddColumnRoute::UndecidedNotification => add_column_view.notifications_ui(ui),
    490         AddColumnRoute::ExternalNotification => add_column_view.external_notification_ui(ui),
    491         AddColumnRoute::Hashtag => hashtag_ui(ui, ctx.ndb, &mut app.view_state.id_string_map),
    492         AddColumnRoute::UndecidedIndividual => add_column_view.individual_ui(ui),
    493         AddColumnRoute::ExternalIndividual => add_column_view.external_individual_ui(ui),
    494     };
    495 
    496     if let Some(resp) = resp {
    497         match resp {
    498             AddColumnResponse::Timeline(mut timeline) => {
    499                 crate::timeline::setup_new_timeline(
    500                     &mut timeline,
    501                     ctx.ndb,
    502                     &mut app.subscriptions,
    503                     ctx.pool,
    504                     ctx.note_cache,
    505                     app.since_optimize,
    506                     &ctx.accounts.mutefun(),
    507                     ctx.accounts
    508                         .get_selected_account()
    509                         .as_ref()
    510                         .map(|sa| &sa.pubkey),
    511                 );
    512                 app.columns_mut(ctx.accounts)
    513                     .add_timeline_to_column(col, timeline);
    514             }
    515             AddColumnResponse::UndecidedNotification => {
    516                 app.columns_mut(ctx.accounts)
    517                     .column_mut(col)
    518                     .router_mut()
    519                     .route_to(crate::route::Route::AddColumn(
    520                         AddColumnRoute::UndecidedNotification,
    521                     ));
    522             }
    523             AddColumnResponse::ExternalNotification => {
    524                 app.columns_mut(ctx.accounts)
    525                     .column_mut(col)
    526                     .router_mut()
    527                     .route_to(crate::route::Route::AddColumn(
    528                         AddColumnRoute::ExternalNotification,
    529                     ));
    530             }
    531             AddColumnResponse::Hashtag => {
    532                 app.columns_mut(ctx.accounts)
    533                     .column_mut(col)
    534                     .router_mut()
    535                     .route_to(crate::route::Route::AddColumn(AddColumnRoute::Hashtag));
    536             }
    537             AddColumnResponse::UndecidedIndividual => {
    538                 app.columns_mut(ctx.accounts)
    539                     .column_mut(col)
    540                     .router_mut()
    541                     .route_to(crate::route::Route::AddColumn(
    542                         AddColumnRoute::UndecidedIndividual,
    543                     ));
    544             }
    545             AddColumnResponse::ExternalIndividual => {
    546                 app.columns_mut(ctx.accounts)
    547                     .column_mut(col)
    548                     .router_mut()
    549                     .route_to(crate::route::Route::AddColumn(
    550                         AddColumnRoute::ExternalIndividual,
    551                     ));
    552             }
    553         };
    554     }
    555 }
    556 
    557 pub fn hashtag_ui(
    558     ui: &mut Ui,
    559     ndb: &Ndb,
    560     id_string_map: &mut HashMap<Id, String>,
    561 ) -> Option<AddColumnResponse> {
    562     padding(16.0, ui, |ui| {
    563         let id = ui.id().with("hashtag)");
    564         let text_buffer = id_string_map.entry(id).or_default();
    565 
    566         let text_edit = egui::TextEdit::singleline(text_buffer)
    567             .hint_text(
    568                 RichText::new("Enter the desired hashtag here")
    569                     .text_style(NotedeckTextStyle::Body.text_style()),
    570             )
    571             .vertical_align(Align::Center)
    572             .desired_width(f32::INFINITY)
    573             .min_size(Vec2::new(0.0, 40.0))
    574             .margin(Margin::same(12.0));
    575         ui.add(text_edit);
    576 
    577         ui.add_space(8.0);
    578         if ui
    579             .add_sized(egui::vec2(50.0, 40.0), add_column_button())
    580             .clicked()
    581         {
    582             let resp = AddColumnOption::Hashtag(text_buffer.to_owned()).take_as_response(ndb, None);
    583             id_string_map.remove(&id);
    584             resp
    585         } else {
    586             None
    587         }
    588     })
    589     .inner
    590 }