notedeck

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

mod.rs (14550B)


      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, PubkeyRef};
      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, TimelineCacheKey},
     19     ui::{
     20         note::NoteOptions,
     21         timeline::{tabs_ui, TimelineTabView},
     22     },
     23     NostrName,
     24 };
     25 
     26 use notedeck::{Accounts, ImageCache, 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 ImageCache,
     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 ImageCache,
     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                         TimelineCacheKey::Profile(PubkeyRef::new(self.pubkey.bytes())),
     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))).size(size),
    153                     );
    154 
    155                     if ui.add(copy_key_widget(&pfp_rect)).clicked() {
    156                         ui.output_mut(|w| {
    157                             w.copied_text = if let Some(bech) = self.pubkey.to_bech() {
    158                                 bech
    159                             } else {
    160                                 error!("Could not convert Pubkey to bech");
    161                                 String::new()
    162                             }
    163                         });
    164                     }
    165 
    166                     if self.accounts.contains_full_kp(self.pubkey) {
    167                         ui.with_layout(Layout::right_to_left(egui::Align::Max), |ui| {
    168                             if ui.add(edit_profile_button()).clicked() {
    169                                 action = true;
    170                             }
    171                         });
    172                     }
    173                 });
    174 
    175                 ui.add_space(18.0);
    176 
    177                 ui.add(display_name_widget(get_display_name(Some(&profile)), false));
    178 
    179                 ui.add_space(8.0);
    180 
    181                 ui.add(about_section_widget(&profile));
    182 
    183                 ui.horizontal_wrapped(|ui| {
    184                     if let Some(website_url) = profile
    185                         .record()
    186                         .profile()
    187                         .and_then(|p| p.website())
    188                         .filter(|s| !s.is_empty())
    189                     {
    190                         handle_link(ui, website_url);
    191                     }
    192 
    193                     if let Some(lud16) = profile
    194                         .record()
    195                         .profile()
    196                         .and_then(|p| p.lud16())
    197                         .filter(|s| !s.is_empty())
    198                     {
    199                         handle_lud16(ui, lud16);
    200                     }
    201                 });
    202             });
    203         });
    204 
    205         action
    206     }
    207 }
    208 
    209 fn handle_link(ui: &mut egui::Ui, website_url: &str) {
    210     ui.image(egui::include_image!(
    211         "../../../../../assets/icons/links_4x.png"
    212     ));
    213     if ui
    214         .label(RichText::new(website_url).color(colors::PINK))
    215         .on_hover_cursor(egui::CursorIcon::PointingHand)
    216         .interact(Sense::click())
    217         .clicked()
    218     {
    219         if let Err(e) = open::that(website_url) {
    220             error!("Failed to open URL {} because: {}", website_url, e);
    221         };
    222     }
    223 }
    224 
    225 fn handle_lud16(ui: &mut egui::Ui, lud16: &str) {
    226     ui.image(egui::include_image!(
    227         "../../../../../assets/icons/zap_4x.png"
    228     ));
    229 
    230     let _ = ui.label(RichText::new(lud16).color(colors::PINK));
    231 }
    232 
    233 fn copy_key_widget(pfp_rect: &egui::Rect) -> impl egui::Widget + '_ {
    234     |ui: &mut egui::Ui| -> egui::Response {
    235         let painter = ui.painter();
    236         let copy_key_rect = painter.round_rect_to_pixels(egui::Rect::from_center_size(
    237             pfp_rect.center_bottom(),
    238             egui::vec2(48.0, 28.0),
    239         ));
    240         let resp = ui.interact(
    241             copy_key_rect,
    242             ui.id().with("custom_painter"),
    243             Sense::click(),
    244         );
    245 
    246         let copy_key_rounding = Rounding::same(100.0);
    247         let fill_color = if resp.hovered() {
    248             ui.visuals().widgets.inactive.weak_bg_fill
    249         } else {
    250             ui.visuals().noninteractive().bg_stroke.color
    251         };
    252         painter.rect_filled(copy_key_rect, copy_key_rounding, fill_color);
    253 
    254         let stroke_color = ui.visuals().widgets.inactive.weak_bg_fill;
    255         painter.rect_stroke(
    256             copy_key_rect.shrink(1.0),
    257             copy_key_rounding,
    258             Stroke::new(1.0, stroke_color),
    259         );
    260         egui::Image::new(egui::include_image!(
    261             "../../../../../assets/icons/key_4x.png"
    262         ))
    263         .paint_at(
    264             ui,
    265             painter.round_rect_to_pixels(egui::Rect::from_center_size(
    266                 copy_key_rect.center(),
    267                 egui::vec2(16.0, 16.0),
    268             )),
    269         );
    270 
    271         resp
    272     }
    273 }
    274 
    275 fn edit_profile_button() -> impl egui::Widget + 'static {
    276     |ui: &mut egui::Ui| -> egui::Response {
    277         let (rect, resp) = ui.allocate_exact_size(vec2(124.0, 32.0), Sense::click());
    278         let painter = ui.painter_at(rect);
    279         let rect = painter.round_rect_to_pixels(rect);
    280 
    281         painter.rect_filled(
    282             rect,
    283             Rounding::same(8.0),
    284             if resp.hovered() {
    285                 ui.visuals().widgets.active.bg_fill
    286             } else {
    287                 ui.visuals().widgets.inactive.bg_fill
    288             },
    289         );
    290         painter.rect_stroke(
    291             rect.shrink(1.0),
    292             Rounding::same(8.0),
    293             if resp.hovered() {
    294                 ui.visuals().widgets.active.bg_stroke
    295             } else {
    296                 ui.visuals().widgets.inactive.bg_stroke
    297             },
    298         );
    299 
    300         let edit_icon_size = vec2(16.0, 16.0);
    301         let galley = painter.layout(
    302             "Edit Profile".to_owned(),
    303             NotedeckTextStyle::Button.get_font_id(ui.ctx()),
    304             ui.visuals().text_color(),
    305             rect.width(),
    306         );
    307 
    308         let space_between_icon_galley = 8.0;
    309         let half_icon_size = edit_icon_size.x / 2.0;
    310         let galley_rect = {
    311             let galley_rect = Rect::from_center_size(rect.center(), galley.rect.size());
    312             galley_rect.translate(vec2(half_icon_size + space_between_icon_galley / 2.0, 0.0))
    313         };
    314 
    315         let edit_icon_rect = {
    316             let mut center = galley_rect.left_center();
    317             center.x -= half_icon_size + space_between_icon_galley;
    318             painter.round_rect_to_pixels(Rect::from_center_size(
    319                 painter.round_pos_to_pixel_center(center),
    320                 edit_icon_size,
    321             ))
    322         };
    323 
    324         painter.galley(galley_rect.left_top(), galley, Color32::WHITE);
    325 
    326         egui::Image::new(egui::include_image!(
    327             "../../../../../assets/icons/edit_icon_4x_dark.png"
    328         ))
    329         .paint_at(ui, edit_icon_rect);
    330 
    331         resp
    332     }
    333 }
    334 
    335 fn display_name_widget(name: NostrName<'_>, add_placeholder_space: bool) -> impl egui::Widget + '_ {
    336     move |ui: &mut egui::Ui| -> egui::Response {
    337         let disp_resp = name.display_name.map(|disp_name| {
    338             ui.add(
    339                 Label::new(
    340                     RichText::new(disp_name).text_style(NotedeckTextStyle::Heading3.text_style()),
    341                 )
    342                 .selectable(false),
    343             )
    344         });
    345 
    346         let (username_resp, nip05_resp) = ui
    347             .horizontal(|ui| {
    348                 let username_resp = name.username.map(|username| {
    349                     ui.add(
    350                         Label::new(
    351                             RichText::new(format!("@{}", username))
    352                                 .size(16.0)
    353                                 .color(colors::MID_GRAY),
    354                         )
    355                         .selectable(false),
    356                     )
    357                 });
    358 
    359                 let nip05_resp = name.nip05.map(|nip05| {
    360                     ui.image(egui::include_image!(
    361                         "../../../../../assets/icons/verified_4x.png"
    362                     ));
    363                     ui.add(Label::new(
    364                         RichText::new(nip05).size(16.0).color(colors::TEAL),
    365                     ))
    366                 });
    367 
    368                 (username_resp, nip05_resp)
    369             })
    370             .inner;
    371 
    372         let resp = match (disp_resp, username_resp, nip05_resp) {
    373             (Some(disp), Some(username), Some(nip05)) => disp.union(username).union(nip05),
    374             (Some(disp), Some(username), None) => disp.union(username),
    375             (Some(disp), None, None) => disp,
    376             (None, Some(username), Some(nip05)) => username.union(nip05),
    377             (None, Some(username), None) => username,
    378             _ => ui.add(Label::new(RichText::new(name.name()))),
    379         };
    380 
    381         if add_placeholder_space {
    382             ui.add_space(16.0);
    383         }
    384 
    385         resp
    386     }
    387 }
    388 
    389 pub fn get_profile_url<'a>(profile: Option<&ProfileRecord<'a>>) -> &'a str {
    390     unwrap_profile_url(profile.and_then(|pr| pr.record().profile().and_then(|p| p.picture())))
    391 }
    392 
    393 pub fn unwrap_profile_url(maybe_url: Option<&str>) -> &str {
    394     if let Some(url) = maybe_url {
    395         url
    396     } else {
    397         ProfilePic::no_pfp_url()
    398     }
    399 }
    400 
    401 fn about_section_widget<'a, 'b>(profile: &'b ProfileRecord<'a>) -> impl egui::Widget + 'b
    402 where
    403     'b: 'a,
    404 {
    405     move |ui: &mut egui::Ui| {
    406         if let Some(about) = profile.record().profile().and_then(|p| p.about()) {
    407             let resp = ui.label(about);
    408             ui.add_space(8.0);
    409             resp
    410         } else {
    411             // need any Response so we dont need an Option
    412             ui.allocate_response(egui::Vec2::ZERO, egui::Sense::hover())
    413         }
    414     }
    415 }
    416 
    417 fn banner_texture(ui: &mut egui::Ui, banner_url: &str) -> Option<egui::load::SizedTexture> {
    418     // TODO: cache banner
    419     if !banner_url.is_empty() {
    420         let texture_load_res =
    421             egui::Image::new(banner_url).load_for_size(ui.ctx(), ui.available_size());
    422         if let Ok(texture_poll) = texture_load_res {
    423             match texture_poll {
    424                 TexturePoll::Pending { .. } => {}
    425                 TexturePoll::Ready { texture, .. } => return Some(texture),
    426             }
    427         }
    428     }
    429 
    430     None
    431 }
    432 
    433 fn banner(ui: &mut egui::Ui, banner_url: Option<&str>, height: f32) -> egui::Response {
    434     ui.add_sized([ui.available_size().x, height], |ui: &mut egui::Ui| {
    435         banner_url
    436             .and_then(|url| banner_texture(ui, url))
    437             .map(|texture| {
    438                 images::aspect_fill(
    439                     ui,
    440                     Sense::hover(),
    441                     texture.id,
    442                     texture.size.x / texture.size.y,
    443                 )
    444             })
    445             .unwrap_or_else(|| ui.label(""))
    446     })
    447 }