notedeck

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

add_column.rs (16254B)


      1 use core::f32;
      2 use serde::{Deserialize, Serialize};
      3 use std::collections::HashMap;
      4 
      5 use egui::{
      6     pos2, vec2, Align, Button, Color32, FontId, Id, ImageSource, Margin, Pos2, Rect, RichText,
      7     Separator, Ui, Vec2,
      8 };
      9 use nostrdb::Ndb;
     10 use tracing::error;
     11 
     12 use crate::{
     13     app_style::{get_font_size, NotedeckTextStyle},
     14     login_manager::AcquireKeyState,
     15     timeline::{PubkeySource, Timeline, TimelineKind},
     16     ui::anim::ICON_EXPANSION_MULTIPLE,
     17     user_account::UserAccount,
     18     Damus,
     19 };
     20 
     21 use super::{anim::AnimationHelper, padding};
     22 
     23 pub enum AddColumnResponse {
     24     Timeline(Timeline),
     25     UndecidedNotification,
     26     ExternalNotification,
     27     Hashtag,
     28 }
     29 
     30 pub enum NotificationColumnType {
     31     Home,
     32     External,
     33 }
     34 
     35 #[derive(Clone, Debug)]
     36 enum AddColumnOption {
     37     Universe,
     38     UndecidedNotification,
     39     ExternalNotification,
     40     Notification(PubkeySource),
     41     Home(PubkeySource),
     42     UndecidedHashtag,
     43     Hashtag(String),
     44 }
     45 
     46 #[derive(Clone, Copy, Eq, PartialEq, Debug, Serialize, Deserialize)]
     47 pub enum AddColumnRoute {
     48     Base,
     49     UndecidedNotification,
     50     ExternalNotification,
     51     Hashtag,
     52 }
     53 
     54 impl AddColumnOption {
     55     pub fn take_as_response(
     56         self,
     57         ndb: &Ndb,
     58         cur_account: Option<&UserAccount>,
     59     ) -> Option<AddColumnResponse> {
     60         match self {
     61             AddColumnOption::Universe => TimelineKind::Universe
     62                 .into_timeline(ndb, None)
     63                 .map(AddColumnResponse::Timeline),
     64             AddColumnOption::Notification(pubkey) => TimelineKind::Notifications(pubkey)
     65                 .into_timeline(ndb, cur_account.map(|a| a.pubkey.bytes()))
     66                 .map(AddColumnResponse::Timeline),
     67             AddColumnOption::UndecidedNotification => {
     68                 Some(AddColumnResponse::UndecidedNotification)
     69             }
     70             AddColumnOption::Home(pubkey) => {
     71                 let tlk = TimelineKind::contact_list(pubkey);
     72                 tlk.into_timeline(ndb, cur_account.map(|a| a.pubkey.bytes()))
     73                     .map(AddColumnResponse::Timeline)
     74             }
     75             AddColumnOption::ExternalNotification => Some(AddColumnResponse::ExternalNotification),
     76             AddColumnOption::UndecidedHashtag => Some(AddColumnResponse::Hashtag),
     77             AddColumnOption::Hashtag(hashtag) => TimelineKind::Hashtag(hashtag)
     78                 .into_timeline(ndb, None)
     79                 .map(AddColumnResponse::Timeline),
     80         }
     81     }
     82 }
     83 
     84 pub struct AddColumnView<'a> {
     85     key_state_map: &'a mut HashMap<Id, AcquireKeyState>,
     86     ndb: &'a Ndb,
     87     cur_account: Option<&'a UserAccount>,
     88 }
     89 
     90 impl<'a> AddColumnView<'a> {
     91     pub fn new(
     92         key_state_map: &'a mut HashMap<Id, AcquireKeyState>,
     93         ndb: &'a Ndb,
     94         cur_account: Option<&'a UserAccount>,
     95     ) -> Self {
     96         Self {
     97             key_state_map,
     98             ndb,
     99             cur_account,
    100         }
    101     }
    102 
    103     pub fn ui(&mut self, ui: &mut Ui) -> Option<AddColumnResponse> {
    104         let mut selected_option: Option<AddColumnResponse> = None;
    105         for column_option_data in self.get_base_options() {
    106             let option = column_option_data.option.clone();
    107             if self.column_option_ui(ui, column_option_data).clicked() {
    108                 selected_option = option.take_as_response(self.ndb, self.cur_account);
    109             }
    110 
    111             ui.add(Separator::default().spacing(0.0));
    112         }
    113 
    114         selected_option
    115     }
    116 
    117     fn notifications_ui(&mut self, ui: &mut Ui) -> Option<AddColumnResponse> {
    118         let mut selected_option: Option<AddColumnResponse> = None;
    119         for column_option_data in self.get_notifications_options() {
    120             let option = column_option_data.option.clone();
    121             if self.column_option_ui(ui, column_option_data).clicked() {
    122                 selected_option = option.take_as_response(self.ndb, self.cur_account);
    123             }
    124 
    125             ui.add(Separator::default().spacing(0.0));
    126         }
    127 
    128         selected_option
    129     }
    130 
    131     fn external_notification_ui(&mut self, ui: &mut Ui) -> Option<AddColumnResponse> {
    132         padding(16.0, ui, |ui| {
    133             let id = ui.id().with("external_notif");
    134             let key_state = self.key_state_map.entry(id).or_default();
    135 
    136             let text_edit = key_state.get_acquire_textedit(|text| {
    137                 egui::TextEdit::singleline(text)
    138                     .hint_text(
    139                         RichText::new("Enter the user's key (npub, hex, nip05) here...")
    140                             .text_style(NotedeckTextStyle::Body.text_style()),
    141                     )
    142                     .vertical_align(Align::Center)
    143                     .desired_width(f32::INFINITY)
    144                     .min_size(Vec2::new(0.0, 40.0))
    145                     .margin(Margin::same(12.0))
    146             });
    147 
    148             ui.add(text_edit);
    149 
    150             if ui.button("Add").clicked() {
    151                 key_state.apply_acquire();
    152             }
    153 
    154             if key_state.is_awaiting_network() {
    155                 ui.spinner();
    156             }
    157 
    158             if let Some(error) = key_state.check_for_error() {
    159                 error!("acquire key error: {}", error);
    160                 ui.colored_label(
    161                     Color32::RED,
    162                     "Please enter a valid npub, public hex key or nip05",
    163                 );
    164             }
    165 
    166             if let Some(keypair) = key_state.check_for_successful_login() {
    167                 key_state.should_create_new();
    168                 AddColumnOption::Notification(PubkeySource::Explicit(keypair.pubkey))
    169                     .take_as_response(self.ndb, self.cur_account)
    170             } else {
    171                 None
    172             }
    173         })
    174         .inner
    175     }
    176 
    177     fn column_option_ui(&mut self, ui: &mut Ui, data: ColumnOptionData) -> egui::Response {
    178         let icon_padding = 8.0;
    179         let min_icon_width = 32.0;
    180         let height_padding = 12.0;
    181         let max_width = ui.available_width();
    182         let title_style = NotedeckTextStyle::Body;
    183         let desc_style = NotedeckTextStyle::Button;
    184         let title_min_font_size = get_font_size(ui.ctx(), &title_style);
    185         let desc_min_font_size = get_font_size(ui.ctx(), &desc_style);
    186 
    187         let max_height = {
    188             let max_wrap_width =
    189                 max_width - ((icon_padding * 2.0) + (min_icon_width * ICON_EXPANSION_MULTIPLE));
    190             let title_max_font = FontId::new(
    191                 title_min_font_size * ICON_EXPANSION_MULTIPLE,
    192                 title_style.font_family(),
    193             );
    194             let desc_max_font = FontId::new(
    195                 desc_min_font_size * ICON_EXPANSION_MULTIPLE,
    196                 desc_style.font_family(),
    197             );
    198             let max_desc_galley = ui.fonts(|f| {
    199                 f.layout(
    200                     data.description.to_string(),
    201                     desc_max_font,
    202                     Color32::WHITE,
    203                     max_wrap_width,
    204                 )
    205             });
    206 
    207             let max_title_galley = ui.fonts(|f| {
    208                 f.layout(
    209                     data.title.to_string(),
    210                     title_max_font,
    211                     Color32::WHITE,
    212                     max_wrap_width,
    213                 )
    214             });
    215 
    216             let desc_font_max_size = max_desc_galley.rect.height();
    217             let title_font_max_size = max_title_galley.rect.height();
    218             title_font_max_size + desc_font_max_size + (2.0 * height_padding)
    219         };
    220 
    221         let helper = AnimationHelper::new(ui, data.title, vec2(max_width, max_height));
    222         let animation_rect = helper.get_animation_rect();
    223 
    224         let cur_icon_width = helper.scale_1d_pos(min_icon_width);
    225         let painter = ui.painter_at(animation_rect);
    226 
    227         let cur_icon_size = vec2(cur_icon_width, cur_icon_width);
    228         let cur_icon_x_pos = animation_rect.left() + (icon_padding) + (cur_icon_width / 2.0);
    229 
    230         let title_cur_font = FontId::new(
    231             helper.scale_1d_pos(title_min_font_size),
    232             title_style.font_family(),
    233         );
    234 
    235         let desc_cur_font = FontId::new(
    236             helper.scale_1d_pos(desc_min_font_size),
    237             desc_style.font_family(),
    238         );
    239 
    240         let wrap_width = max_width - (cur_icon_width + (icon_padding * 2.0));
    241         let text_color = ui.ctx().style().visuals.text_color();
    242         let fallback_color = ui.ctx().style().visuals.weak_text_color();
    243 
    244         let title_galley = painter.layout(
    245             data.title.to_string(),
    246             title_cur_font,
    247             text_color,
    248             wrap_width,
    249         );
    250         let desc_galley = painter.layout(
    251             data.description.to_string(),
    252             desc_cur_font,
    253             text_color,
    254             wrap_width,
    255         );
    256 
    257         let galley_heights = title_galley.rect.height() + desc_galley.rect.height();
    258 
    259         let cur_height_padding = (animation_rect.height() - galley_heights) / 2.0;
    260         let corner_x_pos = cur_icon_x_pos + (cur_icon_width / 2.0) + icon_padding;
    261         let title_corner_pos = Pos2::new(corner_x_pos, animation_rect.top() + cur_height_padding);
    262         let desc_corner_pos = Pos2::new(
    263             corner_x_pos,
    264             title_corner_pos.y + title_galley.rect.height(),
    265         );
    266 
    267         let icon_cur_y = animation_rect.top() + cur_height_padding + (galley_heights / 2.0);
    268         let icon_img = egui::Image::new(data.icon).fit_to_exact_size(cur_icon_size);
    269         let icon_rect = Rect::from_center_size(pos2(cur_icon_x_pos, icon_cur_y), cur_icon_size);
    270 
    271         icon_img.paint_at(ui, icon_rect);
    272         painter.galley(title_corner_pos, title_galley, fallback_color);
    273         painter.galley(desc_corner_pos, desc_galley, fallback_color);
    274 
    275         helper.take_animation_response()
    276     }
    277 
    278     fn get_base_options(&self) -> Vec<ColumnOptionData> {
    279         let mut vec = Vec::new();
    280         vec.push(ColumnOptionData {
    281             title: "Universe",
    282             description: "See the whole nostr universe",
    283             icon: egui::include_image!("../../assets/icons/universe_icon_dark_4x.png"),
    284             option: AddColumnOption::Universe,
    285         });
    286 
    287         if let Some(acc) = self.cur_account {
    288             let source = PubkeySource::Explicit(acc.pubkey);
    289 
    290             vec.push(ColumnOptionData {
    291                 title: "Home timeline",
    292                 description: "See recommended notes first",
    293                 icon: egui::include_image!("../../assets/icons/home_icon_dark_4x.png"),
    294                 option: AddColumnOption::Home(source.clone()),
    295             });
    296         }
    297         vec.push(ColumnOptionData {
    298             title: "Notifications",
    299             description: "Stay up to date with notifications and mentions",
    300             icon: egui::include_image!("../../assets/icons/notifications_icon_dark_4x.png"),
    301             option: AddColumnOption::UndecidedNotification,
    302         });
    303         vec.push(ColumnOptionData {
    304             title: "Hashtag",
    305             description: "Stay up to date with a certain hashtag",
    306             icon: egui::include_image!("../../assets/icons/notifications_icon_dark_4x.png"),
    307             option: AddColumnOption::UndecidedHashtag,
    308         });
    309 
    310         vec
    311     }
    312 
    313     fn get_notifications_options(&self) -> Vec<ColumnOptionData> {
    314         let mut vec = Vec::new();
    315 
    316         if let Some(acc) = self.cur_account {
    317             let source = if acc.secret_key.is_some() {
    318                 PubkeySource::DeckAuthor
    319             } else {
    320                 PubkeySource::Explicit(acc.pubkey)
    321             };
    322 
    323             vec.push(ColumnOptionData {
    324                 title: "Your Notifications",
    325                 description: "Stay up to date with your notifications and mentions",
    326                 icon: egui::include_image!("../../assets/icons/notifications_icon_dark_4x.png"),
    327                 option: AddColumnOption::Notification(source),
    328             });
    329         }
    330 
    331         vec.push(ColumnOptionData {
    332             title: "Someone else's Notifications",
    333             description: "Stay up to date with someone else's notifications and mentions",
    334             icon: egui::include_image!("../../assets/icons/notifications_icon_dark_4x.png"),
    335             option: AddColumnOption::ExternalNotification,
    336         });
    337 
    338         vec
    339     }
    340 }
    341 
    342 struct ColumnOptionData {
    343     title: &'static str,
    344     description: &'static str,
    345     icon: ImageSource<'static>,
    346     option: AddColumnOption,
    347 }
    348 
    349 pub fn render_add_column_routes(
    350     ui: &mut egui::Ui,
    351     app: &mut Damus,
    352     col: usize,
    353     route: &AddColumnRoute,
    354 ) {
    355     let mut add_column_view = AddColumnView::new(
    356         &mut app.view_state.id_state_map,
    357         &app.ndb,
    358         app.accounts.get_selected_account(),
    359     );
    360     let resp = match route {
    361         AddColumnRoute::Base => add_column_view.ui(ui),
    362         AddColumnRoute::UndecidedNotification => add_column_view.notifications_ui(ui),
    363         AddColumnRoute::ExternalNotification => add_column_view.external_notification_ui(ui),
    364         AddColumnRoute::Hashtag => hashtag_ui(ui, &app.ndb, &mut app.view_state.id_string_map),
    365     };
    366 
    367     if let Some(resp) = resp {
    368         match resp {
    369             AddColumnResponse::Timeline(mut timeline) => {
    370                 crate::timeline::setup_new_timeline(
    371                     &mut timeline,
    372                     &app.ndb,
    373                     &mut app.subscriptions,
    374                     &mut app.pool,
    375                     &mut app.note_cache,
    376                     app.since_optimize,
    377                     &app.accounts.mutefun(),
    378                 );
    379                 app.columns_mut().add_timeline_to_column(col, timeline);
    380             }
    381             AddColumnResponse::UndecidedNotification => {
    382                 app.columns_mut().column_mut(col).router_mut().route_to(
    383                     crate::route::Route::AddColumn(AddColumnRoute::UndecidedNotification),
    384                 );
    385             }
    386             AddColumnResponse::ExternalNotification => {
    387                 app.columns_mut().column_mut(col).router_mut().route_to(
    388                     crate::route::Route::AddColumn(AddColumnRoute::ExternalNotification),
    389                 );
    390             }
    391             AddColumnResponse::Hashtag => {
    392                 app.columns_mut()
    393                     .column_mut(col)
    394                     .router_mut()
    395                     .route_to(crate::route::Route::AddColumn(AddColumnRoute::Hashtag));
    396             }
    397         };
    398     }
    399 }
    400 
    401 pub fn hashtag_ui(
    402     ui: &mut Ui,
    403     ndb: &Ndb,
    404     id_string_map: &mut HashMap<Id, String>,
    405 ) -> Option<AddColumnResponse> {
    406     padding(16.0, ui, |ui| {
    407         let id = ui.id().with("hashtag");
    408         let text_buffer = id_string_map.entry(id).or_default();
    409 
    410         let text_edit = egui::TextEdit::singleline(text_buffer)
    411             .hint_text(
    412                 RichText::new("Enter the desired hashtag here")
    413                     .text_style(NotedeckTextStyle::Body.text_style()),
    414             )
    415             .vertical_align(Align::Center)
    416             .desired_width(f32::INFINITY)
    417             .min_size(Vec2::new(0.0, 40.0))
    418             .margin(Margin::same(12.0));
    419         ui.add(text_edit);
    420 
    421         ui.add_space(8.0);
    422         if ui
    423             .add_sized(
    424                 egui::vec2(50.0, 40.0),
    425                 Button::new("Add").rounding(8.0).fill(crate::colors::PINK),
    426             )
    427             .clicked()
    428         {
    429             let resp = AddColumnOption::Hashtag(text_buffer.to_owned()).take_as_response(ndb, None);
    430             id_string_map.remove(&id);
    431             resp
    432         } else {
    433             None
    434         }
    435     })
    436     .inner
    437 }
    438 
    439 mod preview {
    440     use crate::{
    441         test_data,
    442         ui::{Preview, PreviewConfig, View},
    443         Damus,
    444     };
    445 
    446     use super::AddColumnView;
    447 
    448     pub struct AddColumnPreview {
    449         app: Damus,
    450     }
    451 
    452     impl AddColumnPreview {
    453         fn new() -> Self {
    454             let app = test_data::test_app();
    455 
    456             AddColumnPreview { app }
    457         }
    458     }
    459 
    460     impl View for AddColumnPreview {
    461         fn ui(&mut self, ui: &mut egui::Ui) {
    462             AddColumnView::new(
    463                 &mut self.app.view_state.id_state_map,
    464                 &self.app.ndb,
    465                 self.app.accounts.get_selected_account(),
    466             )
    467             .ui(ui);
    468         }
    469     }
    470 
    471     impl Preview for AddColumnView<'_> {
    472         type Prev = AddColumnPreview;
    473 
    474         fn preview(_cfg: PreviewConfig) -> Self::Prev {
    475             AddColumnPreview::new()
    476         }
    477     }
    478 }