notedeck

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

mod.rs (13644B)


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