notedeck

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

mod.rs (13800B)


      1 pub mod edit;
      2 
      3 pub use edit::EditProfileView;
      4 use egui::{vec2, Color32, CornerRadius, Layout, Rect, RichText, ScrollArea, Sense, Stroke};
      5 use enostr::Pubkey;
      6 use nostrdb::{ProfileRecord, Transaction};
      7 use notedeck::{tr, Localization};
      8 use notedeck_ui::profile::follow_button;
      9 use robius_open::Uri;
     10 use tracing::error;
     11 
     12 use crate::{
     13     timeline::{TimelineCache, TimelineKind},
     14     ui::timeline::{tabs_ui, TimelineTabView},
     15 };
     16 use notedeck::{
     17     name::get_display_name, profile::get_profile_url, IsFollowing, JobsCache, NoteAction,
     18     NoteContext, NotedeckTextStyle,
     19 };
     20 use notedeck_ui::{
     21     app_images,
     22     profile::{about_section_widget, banner, display_name_widget},
     23     NoteOptions, ProfilePic,
     24 };
     25 
     26 pub struct ProfileView<'a, 'd> {
     27     pubkey: &'a Pubkey,
     28     col_id: usize,
     29     timeline_cache: &'a mut TimelineCache,
     30     note_options: NoteOptions,
     31     note_context: &'a mut NoteContext<'d>,
     32     jobs: &'a mut JobsCache,
     33 }
     34 
     35 pub enum ProfileViewAction {
     36     EditProfile,
     37     Note(NoteAction),
     38     Unfollow(Pubkey),
     39     Follow(Pubkey),
     40 }
     41 
     42 impl<'a, 'd> ProfileView<'a, 'd> {
     43     #[allow(clippy::too_many_arguments)]
     44     pub fn new(
     45         pubkey: &'a Pubkey,
     46         col_id: usize,
     47         timeline_cache: &'a mut TimelineCache,
     48         note_options: NoteOptions,
     49         note_context: &'a mut NoteContext<'d>,
     50         jobs: &'a mut JobsCache,
     51     ) -> Self {
     52         ProfileView {
     53             pubkey,
     54             col_id,
     55             timeline_cache,
     56             note_options,
     57             note_context,
     58             jobs,
     59         }
     60     }
     61 
     62     pub fn scroll_id(col_id: usize, profile_pubkey: &Pubkey) -> egui::Id {
     63         egui::Id::new(("profile_scroll", col_id, profile_pubkey))
     64     }
     65 
     66     pub fn ui(&mut self, ui: &mut egui::Ui) -> Option<ProfileViewAction> {
     67         let scroll_id = ProfileView::scroll_id(self.col_id, self.pubkey);
     68         let offset_id = scroll_id.with("scroll_offset");
     69 
     70         let mut scroll_area = ScrollArea::vertical().id_salt(scroll_id);
     71 
     72         if let Some(offset) = ui.data(|i| i.get_temp::<f32>(offset_id)) {
     73             scroll_area = scroll_area.vertical_scroll_offset(offset);
     74         }
     75 
     76         let output = scroll_area.show(ui, |ui| {
     77             let mut action = None;
     78             let txn = Transaction::new(self.note_context.ndb).expect("txn");
     79             let profile = self
     80                 .note_context
     81                 .ndb
     82                 .get_profile_by_pubkey(&txn, self.pubkey.bytes())
     83                 .ok();
     84 
     85             if let Some(profile_view_action) = self.profile_body(ui, profile.as_ref()) {
     86                 action = Some(profile_view_action);
     87             }
     88             let profile_timeline = self
     89                 .timeline_cache
     90                 .notes(
     91                     self.note_context.ndb,
     92                     self.note_context.note_cache,
     93                     &txn,
     94                     &TimelineKind::Profile(*self.pubkey),
     95                 )
     96                 .get_ptr();
     97 
     98             profile_timeline.selected_view = tabs_ui(
     99                 ui,
    100                 self.note_context.i18n,
    101                 profile_timeline.selected_view,
    102                 &profile_timeline.views,
    103             );
    104 
    105             let reversed = false;
    106             // poll for new notes and insert them into our existing notes
    107             if let Err(e) = profile_timeline.poll_notes_into_view(
    108                 self.note_context.ndb,
    109                 &txn,
    110                 self.note_context.unknown_ids,
    111                 self.note_context.note_cache,
    112                 reversed,
    113             ) {
    114                 error!("Profile::poll_notes_into_view: {e}");
    115             }
    116 
    117             if let Some(note_action) = TimelineTabView::new(
    118                 profile_timeline.current_view(),
    119                 reversed,
    120                 self.note_options,
    121                 &txn,
    122                 self.note_context,
    123                 self.jobs,
    124             )
    125             .show(ui)
    126             {
    127                 action = Some(ProfileViewAction::Note(note_action));
    128             }
    129 
    130             action
    131         });
    132 
    133         ui.data_mut(|d| d.insert_temp(offset_id, output.state.offset.y));
    134 
    135         output.inner
    136     }
    137 
    138     fn profile_body(
    139         &mut self,
    140         ui: &mut egui::Ui,
    141         profile: Option<&ProfileRecord<'_>>,
    142     ) -> Option<ProfileViewAction> {
    143         let mut action = None;
    144         ui.vertical(|ui| {
    145             banner(
    146                 ui,
    147                 profile
    148                     .map(|p| p.record().profile())
    149                     .and_then(|p| p.and_then(|p| p.banner())),
    150                 120.0,
    151             );
    152 
    153             let padding = 12.0;
    154             notedeck_ui::padding(padding, ui, |ui| {
    155                 let mut pfp_rect = ui.available_rect_before_wrap();
    156                 let size = 80.0;
    157                 pfp_rect.set_width(size);
    158                 pfp_rect.set_height(size);
    159                 let pfp_rect = pfp_rect.translate(egui::vec2(0.0, -(padding + 2.0 + (size / 2.0))));
    160 
    161                 ui.horizontal(|ui| {
    162                     ui.put(
    163                         pfp_rect,
    164                         &mut ProfilePic::new(self.note_context.img_cache, get_profile_url(profile))
    165                             .size(size)
    166                             .border(ProfilePic::border_stroke(ui)),
    167                     );
    168 
    169                     if ui.add(copy_key_widget(&pfp_rect)).clicked() {
    170                         let to_copy = if let Some(bech) = self.pubkey.npub() {
    171                             bech
    172                         } else {
    173                             error!("Could not convert Pubkey to bech");
    174                             String::new()
    175                         };
    176                         ui.ctx().copy_text(to_copy)
    177                     }
    178 
    179                     ui.with_layout(Layout::right_to_left(egui::Align::RIGHT), |ui| {
    180                         ui.add_space(24.0);
    181 
    182                         let target_key = self.pubkey;
    183                         let selected = self.note_context.accounts.get_selected_account();
    184 
    185                         let profile_type = if selected.key.secret_key.is_none() {
    186                             ProfileType::ReadOnly
    187                         } else if &selected.key.pubkey == self.pubkey {
    188                             ProfileType::MyProfile
    189                         } else {
    190                             ProfileType::Followable(selected.is_following(target_key.bytes()))
    191                         };
    192 
    193                         match profile_type {
    194                             ProfileType::MyProfile => {
    195                                 if ui
    196                                     .add(edit_profile_button(self.note_context.i18n))
    197                                     .clicked()
    198                                 {
    199                                     action = Some(ProfileViewAction::EditProfile);
    200                                 }
    201                             }
    202                             ProfileType::Followable(is_following) => {
    203                                 let follow_button = ui.add(follow_button(is_following));
    204 
    205                                 if follow_button.clicked() {
    206                                     action = match is_following {
    207                                         IsFollowing::Unknown => {
    208                                             // don't do anything, we don't have contact list
    209                                             None
    210                                         }
    211 
    212                                         IsFollowing::Yes => {
    213                                             Some(ProfileViewAction::Unfollow(target_key.to_owned()))
    214                                         }
    215 
    216                                         IsFollowing::No => {
    217                                             Some(ProfileViewAction::Follow(target_key.to_owned()))
    218                                         }
    219                                     };
    220                                 }
    221                             }
    222                             ProfileType::ReadOnly => {}
    223                         }
    224                     });
    225                 });
    226 
    227                 ui.add_space(18.0);
    228 
    229                 ui.add(display_name_widget(&get_display_name(profile), false));
    230 
    231                 ui.add_space(8.0);
    232 
    233                 ui.add(about_section_widget(profile));
    234 
    235                 ui.horizontal_wrapped(|ui| {
    236                     let website_url = profile
    237                         .as_ref()
    238                         .map(|p| p.record().profile())
    239                         .and_then(|p| p.and_then(|p| p.website()).filter(|s| !s.is_empty()));
    240 
    241                     let lud16 = profile
    242                         .as_ref()
    243                         .map(|p| p.record().profile())
    244                         .and_then(|p| p.and_then(|p| p.lud16()).filter(|s| !s.is_empty()));
    245 
    246                     if let Some(website_url) = website_url {
    247                         ui.horizontal(|ui| {
    248                             handle_link(ui, website_url);
    249                         });
    250                     }
    251 
    252                     if let Some(lud16) = lud16 {
    253                         if website_url.is_some() {
    254                             ui.end_row();
    255                         }
    256                         ui.horizontal(|ui| {
    257                             handle_lud16(ui, lud16);
    258                         });
    259                     }
    260                 });
    261             });
    262         });
    263 
    264         action
    265     }
    266 }
    267 
    268 enum ProfileType {
    269     MyProfile,
    270     ReadOnly,
    271     Followable(IsFollowing),
    272 }
    273 
    274 fn handle_link(ui: &mut egui::Ui, website_url: &str) {
    275     let img = if ui.visuals().dark_mode {
    276         app_images::link_dark_image()
    277     } else {
    278         app_images::link_light_image()
    279     };
    280 
    281     ui.add(img);
    282     if ui
    283         .label(RichText::new(website_url).color(notedeck_ui::colors::PINK))
    284         .on_hover_cursor(egui::CursorIcon::PointingHand)
    285         .on_hover_text(website_url)
    286         .interact(Sense::click())
    287         .clicked()
    288     {
    289         if let Err(e) = Uri::new(website_url).open() {
    290             error!("Failed to open URL {} because: {:?}", website_url, e);
    291         };
    292     }
    293 }
    294 
    295 fn handle_lud16(ui: &mut egui::Ui, lud16: &str) {
    296     ui.add(app_images::filled_zap_image());
    297 
    298     let _ = ui
    299         .label(RichText::new(lud16).color(notedeck_ui::colors::PINK))
    300         .on_hover_text(lud16);
    301 }
    302 
    303 fn copy_key_widget(pfp_rect: &egui::Rect) -> impl egui::Widget + '_ {
    304     |ui: &mut egui::Ui| -> egui::Response {
    305         let painter = ui.painter();
    306         #[allow(deprecated)]
    307         let copy_key_rect = painter.round_rect_to_pixels(egui::Rect::from_center_size(
    308             pfp_rect.center_bottom(),
    309             egui::vec2(48.0, 28.0),
    310         ));
    311         let resp = ui
    312             .interact(
    313                 copy_key_rect,
    314                 ui.id().with("custom_painter"),
    315                 Sense::click(),
    316             )
    317             .on_hover_text("Copy npub to clipboard");
    318 
    319         let copy_key_rounding = CornerRadius::same(100);
    320         let fill_color = if resp.hovered() {
    321             ui.visuals().widgets.inactive.weak_bg_fill
    322         } else {
    323             ui.visuals().noninteractive().bg_stroke.color
    324         };
    325         painter.rect_filled(copy_key_rect, copy_key_rounding, fill_color);
    326 
    327         let stroke_color = ui.visuals().widgets.inactive.weak_bg_fill;
    328         painter.rect_stroke(
    329             copy_key_rect.shrink(1.0),
    330             copy_key_rounding,
    331             Stroke::new(1.0, stroke_color),
    332             egui::StrokeKind::Outside,
    333         );
    334 
    335         app_images::key_image().paint_at(
    336             ui,
    337             #[allow(deprecated)]
    338             painter.round_rect_to_pixels(egui::Rect::from_center_size(
    339                 copy_key_rect.center(),
    340                 egui::vec2(16.0, 16.0),
    341             )),
    342         );
    343 
    344         resp
    345     }
    346 }
    347 
    348 fn edit_profile_button<'a>(i18n: &'a mut Localization) -> impl egui::Widget + 'a {
    349     |ui: &mut egui::Ui| -> egui::Response {
    350         let (rect, resp) = ui.allocate_exact_size(vec2(124.0, 32.0), Sense::click());
    351         let painter = ui.painter_at(rect);
    352         #[allow(deprecated)]
    353         let rect = painter.round_rect_to_pixels(rect);
    354 
    355         painter.rect_filled(
    356             rect,
    357             CornerRadius::same(8),
    358             if resp.hovered() {
    359                 ui.visuals().widgets.active.bg_fill
    360             } else {
    361                 ui.visuals().widgets.inactive.bg_fill
    362             },
    363         );
    364         painter.rect_stroke(
    365             rect.shrink(1.0),
    366             CornerRadius::same(8),
    367             if resp.hovered() {
    368                 ui.visuals().widgets.active.bg_stroke
    369             } else {
    370                 ui.visuals().widgets.inactive.bg_stroke
    371             },
    372             egui::StrokeKind::Outside,
    373         );
    374 
    375         let edit_icon_size = vec2(16.0, 16.0);
    376         let galley = painter.layout(
    377             tr!(i18n, "Edit Profile", "Button label to edit user profile"),
    378             NotedeckTextStyle::Button.get_font_id(ui.ctx()),
    379             ui.visuals().text_color(),
    380             rect.width(),
    381         );
    382 
    383         let space_between_icon_galley = 8.0;
    384         let half_icon_size = edit_icon_size.x / 2.0;
    385         let galley_rect = {
    386             let galley_rect = Rect::from_center_size(rect.center(), galley.rect.size());
    387             galley_rect.translate(vec2(half_icon_size + space_between_icon_galley / 2.0, 0.0))
    388         };
    389 
    390         let edit_icon_rect = {
    391             let mut center = galley_rect.left_center();
    392             center.x -= half_icon_size + space_between_icon_galley;
    393             #[allow(deprecated)]
    394             painter.round_rect_to_pixels(Rect::from_center_size(
    395                 painter.round_pos_to_pixel_center(center),
    396                 edit_icon_size,
    397             ))
    398         };
    399 
    400         painter.galley(galley_rect.left_top(), galley, Color32::WHITE);
    401 
    402         app_images::edit_dark_image()
    403             .tint(ui.visuals().text_color())
    404             .paint_at(ui, edit_icon_rect);
    405 
    406         resp
    407     }
    408 }