notedeck

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

mod.rs (14591B)


      1 pub mod edit;
      2 pub mod picture;
      3 pub mod preview;
      4 
      5 pub use edit::EditProfileView;
      6 use egui::load::TexturePoll;
      7 use egui::{vec2, Color32, Label, Layout, Rect, RichText, Rounding, ScrollArea, Sense, Stroke};
      8 use enostr::Pubkey;
      9 use nostrdb::{Ndb, ProfileRecord, Transaction};
     10 pub use picture::ProfilePic;
     11 pub use preview::ProfilePreview;
     12 use tracing::error;
     13 
     14 use crate::{
     15     actionbar::NoteAction,
     16     colors, images,
     17     profile::get_display_name,
     18     timeline::{TimelineCache, TimelineKind},
     19     ui::{
     20         note::NoteOptions,
     21         timeline::{tabs_ui, TimelineTabView},
     22     },
     23     NostrName,
     24 };
     25 
     26 use notedeck::{Accounts, Images, MuteFun, NoteCache, NotedeckTextStyle, UnknownIds};
     27 
     28 pub struct ProfileView<'a> {
     29     pubkey: &'a Pubkey,
     30     accounts: &'a Accounts,
     31     col_id: usize,
     32     timeline_cache: &'a mut TimelineCache,
     33     note_options: NoteOptions,
     34     ndb: &'a Ndb,
     35     note_cache: &'a mut NoteCache,
     36     img_cache: &'a mut Images,
     37     unknown_ids: &'a mut UnknownIds,
     38     is_muted: &'a MuteFun,
     39 }
     40 
     41 pub enum ProfileViewAction {
     42     EditProfile,
     43     Note(NoteAction),
     44 }
     45 
     46 impl<'a> ProfileView<'a> {
     47     #[allow(clippy::too_many_arguments)]
     48     pub fn new(
     49         pubkey: &'a Pubkey,
     50         accounts: &'a Accounts,
     51         col_id: usize,
     52         timeline_cache: &'a mut TimelineCache,
     53         ndb: &'a Ndb,
     54         note_cache: &'a mut NoteCache,
     55         img_cache: &'a mut Images,
     56         unknown_ids: &'a mut UnknownIds,
     57         is_muted: &'a MuteFun,
     58         note_options: NoteOptions,
     59     ) -> Self {
     60         ProfileView {
     61             pubkey,
     62             accounts,
     63             col_id,
     64             timeline_cache,
     65             ndb,
     66             note_cache,
     67             img_cache,
     68             unknown_ids,
     69             note_options,
     70             is_muted,
     71         }
     72     }
     73 
     74     pub fn ui(&mut self, ui: &mut egui::Ui) -> Option<ProfileViewAction> {
     75         let scroll_id = egui::Id::new(("profile_scroll", self.col_id, self.pubkey));
     76 
     77         ScrollArea::vertical()
     78             .id_salt(scroll_id)
     79             .show(ui, |ui| {
     80                 let mut action = None;
     81                 let txn = Transaction::new(self.ndb).expect("txn");
     82                 if let Ok(profile) = self.ndb.get_profile_by_pubkey(&txn, self.pubkey.bytes()) {
     83                     if self.profile_body(ui, profile) {
     84                         action = Some(ProfileViewAction::EditProfile);
     85                     }
     86                 }
     87                 let profile_timeline = self
     88                     .timeline_cache
     89                     .notes(
     90                         self.ndb,
     91                         self.note_cache,
     92                         &txn,
     93                         &TimelineKind::Profile(*self.pubkey),
     94                     )
     95                     .get_ptr();
     96 
     97                 profile_timeline.selected_view =
     98                     tabs_ui(ui, profile_timeline.selected_view, &profile_timeline.views);
     99 
    100                 let reversed = false;
    101                 // poll for new notes and insert them into our existing notes
    102                 if let Err(e) = profile_timeline.poll_notes_into_view(
    103                     self.ndb,
    104                     &txn,
    105                     self.unknown_ids,
    106                     self.note_cache,
    107                     reversed,
    108                 ) {
    109                     error!("Profile::poll_notes_into_view: {e}");
    110                 }
    111 
    112                 if let Some(note_action) = TimelineTabView::new(
    113                     profile_timeline.current_view(),
    114                     reversed,
    115                     self.note_options,
    116                     &txn,
    117                     self.ndb,
    118                     self.note_cache,
    119                     self.img_cache,
    120                     self.is_muted,
    121                 )
    122                 .show(ui)
    123                 {
    124                     action = Some(ProfileViewAction::Note(note_action));
    125                 }
    126 
    127                 action
    128             })
    129             .inner
    130     }
    131 
    132     fn profile_body(&mut self, ui: &mut egui::Ui, profile: ProfileRecord<'_>) -> bool {
    133         let mut action = false;
    134         ui.vertical(|ui| {
    135             banner(
    136                 ui,
    137                 profile.record().profile().and_then(|p| p.banner()),
    138                 120.0,
    139             );
    140 
    141             let padding = 12.0;
    142             crate::ui::padding(padding, ui, |ui| {
    143                 let mut pfp_rect = ui.available_rect_before_wrap();
    144                 let size = 80.0;
    145                 pfp_rect.set_width(size);
    146                 pfp_rect.set_height(size);
    147                 let pfp_rect = pfp_rect.translate(egui::vec2(0.0, -(padding + 2.0 + (size / 2.0))));
    148 
    149                 ui.horizontal(|ui| {
    150                     ui.put(
    151                         pfp_rect,
    152                         ProfilePic::new(self.img_cache, get_profile_url(Some(&profile)))
    153                             .size(size)
    154                             .border(ProfilePic::border_stroke(ui)),
    155                     );
    156 
    157                     if ui.add(copy_key_widget(&pfp_rect)).clicked() {
    158                         ui.output_mut(|w| {
    159                             w.copied_text = if let Some(bech) = self.pubkey.to_bech() {
    160                                 bech
    161                             } else {
    162                                 error!("Could not convert Pubkey to bech");
    163                                 String::new()
    164                             }
    165                         });
    166                     }
    167 
    168                     if self.accounts.contains_full_kp(self.pubkey) {
    169                         ui.with_layout(Layout::right_to_left(egui::Align::Max), |ui| {
    170                             if ui.add(edit_profile_button()).clicked() {
    171                                 action = true;
    172                             }
    173                         });
    174                     }
    175                 });
    176 
    177                 ui.add_space(18.0);
    178 
    179                 ui.add(display_name_widget(get_display_name(Some(&profile)), false));
    180 
    181                 ui.add_space(8.0);
    182 
    183                 ui.add(about_section_widget(&profile));
    184 
    185                 ui.horizontal_wrapped(|ui| {
    186                     if let Some(website_url) = profile
    187                         .record()
    188                         .profile()
    189                         .and_then(|p| p.website())
    190                         .filter(|s| !s.is_empty())
    191                     {
    192                         handle_link(ui, website_url);
    193                     }
    194 
    195                     if let Some(lud16) = profile
    196                         .record()
    197                         .profile()
    198                         .and_then(|p| p.lud16())
    199                         .filter(|s| !s.is_empty())
    200                     {
    201                         handle_lud16(ui, lud16);
    202                     }
    203                 });
    204             });
    205         });
    206 
    207         action
    208     }
    209 }
    210 
    211 fn handle_link(ui: &mut egui::Ui, website_url: &str) {
    212     ui.image(egui::include_image!(
    213         "../../../../../assets/icons/links_4x.png"
    214     ));
    215     if ui
    216         .label(RichText::new(website_url).color(colors::PINK))
    217         .on_hover_cursor(egui::CursorIcon::PointingHand)
    218         .interact(Sense::click())
    219         .clicked()
    220     {
    221         if let Err(e) = open::that(website_url) {
    222             error!("Failed to open URL {} because: {}", website_url, e);
    223         };
    224     }
    225 }
    226 
    227 fn handle_lud16(ui: &mut egui::Ui, lud16: &str) {
    228     ui.image(egui::include_image!(
    229         "../../../../../assets/icons/zap_4x.png"
    230     ));
    231 
    232     let _ = ui.label(RichText::new(lud16).color(colors::PINK));
    233 }
    234 
    235 fn copy_key_widget(pfp_rect: &egui::Rect) -> impl egui::Widget + '_ {
    236     |ui: &mut egui::Ui| -> egui::Response {
    237         let painter = ui.painter();
    238         let copy_key_rect = painter.round_rect_to_pixels(egui::Rect::from_center_size(
    239             pfp_rect.center_bottom(),
    240             egui::vec2(48.0, 28.0),
    241         ));
    242         let resp = ui.interact(
    243             copy_key_rect,
    244             ui.id().with("custom_painter"),
    245             Sense::click(),
    246         );
    247 
    248         let copy_key_rounding = Rounding::same(100.0);
    249         let fill_color = if resp.hovered() {
    250             ui.visuals().widgets.inactive.weak_bg_fill
    251         } else {
    252             ui.visuals().noninteractive().bg_stroke.color
    253         };
    254         painter.rect_filled(copy_key_rect, copy_key_rounding, fill_color);
    255 
    256         let stroke_color = ui.visuals().widgets.inactive.weak_bg_fill;
    257         painter.rect_stroke(
    258             copy_key_rect.shrink(1.0),
    259             copy_key_rounding,
    260             Stroke::new(1.0, stroke_color),
    261         );
    262         egui::Image::new(egui::include_image!(
    263             "../../../../../assets/icons/key_4x.png"
    264         ))
    265         .paint_at(
    266             ui,
    267             painter.round_rect_to_pixels(egui::Rect::from_center_size(
    268                 copy_key_rect.center(),
    269                 egui::vec2(16.0, 16.0),
    270             )),
    271         );
    272 
    273         resp
    274     }
    275 }
    276 
    277 fn edit_profile_button() -> impl egui::Widget + 'static {
    278     |ui: &mut egui::Ui| -> egui::Response {
    279         let (rect, resp) = ui.allocate_exact_size(vec2(124.0, 32.0), Sense::click());
    280         let painter = ui.painter_at(rect);
    281         let rect = painter.round_rect_to_pixels(rect);
    282 
    283         painter.rect_filled(
    284             rect,
    285             Rounding::same(8.0),
    286             if resp.hovered() {
    287                 ui.visuals().widgets.active.bg_fill
    288             } else {
    289                 ui.visuals().widgets.inactive.bg_fill
    290             },
    291         );
    292         painter.rect_stroke(
    293             rect.shrink(1.0),
    294             Rounding::same(8.0),
    295             if resp.hovered() {
    296                 ui.visuals().widgets.active.bg_stroke
    297             } else {
    298                 ui.visuals().widgets.inactive.bg_stroke
    299             },
    300         );
    301 
    302         let edit_icon_size = vec2(16.0, 16.0);
    303         let galley = painter.layout(
    304             "Edit Profile".to_owned(),
    305             NotedeckTextStyle::Button.get_font_id(ui.ctx()),
    306             ui.visuals().text_color(),
    307             rect.width(),
    308         );
    309 
    310         let space_between_icon_galley = 8.0;
    311         let half_icon_size = edit_icon_size.x / 2.0;
    312         let galley_rect = {
    313             let galley_rect = Rect::from_center_size(rect.center(), galley.rect.size());
    314             galley_rect.translate(vec2(half_icon_size + space_between_icon_galley / 2.0, 0.0))
    315         };
    316 
    317         let edit_icon_rect = {
    318             let mut center = galley_rect.left_center();
    319             center.x -= half_icon_size + space_between_icon_galley;
    320             painter.round_rect_to_pixels(Rect::from_center_size(
    321                 painter.round_pos_to_pixel_center(center),
    322                 edit_icon_size,
    323             ))
    324         };
    325 
    326         painter.galley(galley_rect.left_top(), galley, Color32::WHITE);
    327 
    328         egui::Image::new(egui::include_image!(
    329             "../../../../../assets/icons/edit_icon_4x_dark.png"
    330         ))
    331         .paint_at(ui, edit_icon_rect);
    332 
    333         resp
    334     }
    335 }
    336 
    337 fn display_name_widget(name: NostrName<'_>, add_placeholder_space: bool) -> impl egui::Widget + '_ {
    338     move |ui: &mut egui::Ui| -> egui::Response {
    339         let disp_resp = name.display_name.map(|disp_name| {
    340             ui.add(
    341                 Label::new(
    342                     RichText::new(disp_name).text_style(NotedeckTextStyle::Heading3.text_style()),
    343                 )
    344                 .selectable(false),
    345             )
    346         });
    347 
    348         let (username_resp, nip05_resp) = ui
    349             .horizontal(|ui| {
    350                 let username_resp = name.username.map(|username| {
    351                     ui.add(
    352                         Label::new(
    353                             RichText::new(format!("@{}", username))
    354                                 .size(16.0)
    355                                 .color(colors::MID_GRAY),
    356                         )
    357                         .selectable(false),
    358                     )
    359                 });
    360 
    361                 let nip05_resp = name.nip05.map(|nip05| {
    362                     ui.image(egui::include_image!(
    363                         "../../../../../assets/icons/verified_4x.png"
    364                     ));
    365                     ui.add(Label::new(
    366                         RichText::new(nip05).size(16.0).color(colors::TEAL),
    367                     ))
    368                 });
    369 
    370                 (username_resp, nip05_resp)
    371             })
    372             .inner;
    373 
    374         let resp = match (disp_resp, username_resp, nip05_resp) {
    375             (Some(disp), Some(username), Some(nip05)) => disp.union(username).union(nip05),
    376             (Some(disp), Some(username), None) => disp.union(username),
    377             (Some(disp), None, None) => disp,
    378             (None, Some(username), Some(nip05)) => username.union(nip05),
    379             (None, Some(username), None) => username,
    380             _ => ui.add(Label::new(RichText::new(name.name()))),
    381         };
    382 
    383         if add_placeholder_space {
    384             ui.add_space(16.0);
    385         }
    386 
    387         resp
    388     }
    389 }
    390 
    391 pub fn get_profile_url<'a>(profile: Option<&ProfileRecord<'a>>) -> &'a str {
    392     unwrap_profile_url(profile.and_then(|pr| pr.record().profile().and_then(|p| p.picture())))
    393 }
    394 
    395 pub fn unwrap_profile_url(maybe_url: Option<&str>) -> &str {
    396     if let Some(url) = maybe_url {
    397         url
    398     } else {
    399         ProfilePic::no_pfp_url()
    400     }
    401 }
    402 
    403 fn about_section_widget<'a, 'b>(profile: &'b ProfileRecord<'a>) -> impl egui::Widget + 'b
    404 where
    405     'b: 'a,
    406 {
    407     move |ui: &mut egui::Ui| {
    408         if let Some(about) = profile.record().profile().and_then(|p| p.about()) {
    409             let resp = ui.label(about);
    410             ui.add_space(8.0);
    411             resp
    412         } else {
    413             // need any Response so we dont need an Option
    414             ui.allocate_response(egui::Vec2::ZERO, egui::Sense::hover())
    415         }
    416     }
    417 }
    418 
    419 fn banner_texture(ui: &mut egui::Ui, banner_url: &str) -> Option<egui::load::SizedTexture> {
    420     // TODO: cache banner
    421     if !banner_url.is_empty() {
    422         let texture_load_res =
    423             egui::Image::new(banner_url).load_for_size(ui.ctx(), ui.available_size());
    424         if let Ok(texture_poll) = texture_load_res {
    425             match texture_poll {
    426                 TexturePoll::Pending { .. } => {}
    427                 TexturePoll::Ready { texture, .. } => return Some(texture),
    428             }
    429         }
    430     }
    431 
    432     None
    433 }
    434 
    435 fn banner(ui: &mut egui::Ui, banner_url: Option<&str>, height: f32) -> egui::Response {
    436     ui.add_sized([ui.available_size().x, height], |ui: &mut egui::Ui| {
    437         banner_url
    438             .and_then(|url| banner_texture(ui, url))
    439             .map(|texture| {
    440                 images::aspect_fill(
    441                     ui,
    442                     Sense::hover(),
    443                     texture.id,
    444                     texture.size.x / texture.size.y,
    445                 )
    446             })
    447             .unwrap_or_else(|| ui.label(""))
    448     })
    449 }