notedeck

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

mod.rs (14732B)


      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::{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::timeline::{tabs_ui, TimelineTabView},
     20     NostrName,
     21 };
     22 
     23 use notedeck::{Accounts, MuteFun, NotedeckTextStyle, UnknownIds};
     24 
     25 use super::note::contents::NoteContext;
     26 use super::note::NoteOptions;
     27 
     28 pub struct ProfileView<'a, 'd> {
     29     pubkey: &'a Pubkey,
     30     accounts: &'a Accounts,
     31     col_id: usize,
     32     timeline_cache: &'a mut TimelineCache,
     33     note_options: NoteOptions,
     34     unknown_ids: &'a mut UnknownIds,
     35     is_muted: &'a MuteFun,
     36     note_context: &'a mut NoteContext<'d>,
     37 }
     38 
     39 pub enum ProfileViewAction {
     40     EditProfile,
     41     Note(NoteAction),
     42 }
     43 
     44 impl<'a, 'd> ProfileView<'a, 'd> {
     45     #[allow(clippy::too_many_arguments)]
     46     pub fn new(
     47         pubkey: &'a Pubkey,
     48         accounts: &'a Accounts,
     49         col_id: usize,
     50         timeline_cache: &'a mut TimelineCache,
     51         note_options: NoteOptions,
     52         unknown_ids: &'a mut UnknownIds,
     53         is_muted: &'a MuteFun,
     54         note_context: &'a mut NoteContext<'d>,
     55     ) -> Self {
     56         ProfileView {
     57             pubkey,
     58             accounts,
     59             col_id,
     60             timeline_cache,
     61             note_options,
     62             unknown_ids,
     63             is_muted,
     64             note_context,
     65         }
     66     }
     67 
     68     pub fn ui(&mut self, ui: &mut egui::Ui) -> Option<ProfileViewAction> {
     69         let scroll_id = egui::Id::new(("profile_scroll", self.col_id, self.pubkey));
     70 
     71         ScrollArea::vertical()
     72             .id_salt(scroll_id)
     73             .show(ui, |ui| {
     74                 let mut action = None;
     75                 let txn = Transaction::new(self.note_context.ndb).expect("txn");
     76                 if let Ok(profile) = self
     77                     .note_context
     78                     .ndb
     79                     .get_profile_by_pubkey(&txn, self.pubkey.bytes())
     80                 {
     81                     if self.profile_body(ui, profile) {
     82                         action = Some(ProfileViewAction::EditProfile);
     83                     }
     84                 }
     85                 let profile_timeline = self
     86                     .timeline_cache
     87                     .notes(
     88                         self.note_context.ndb,
     89                         self.note_context.note_cache,
     90                         &txn,
     91                         &TimelineKind::Profile(*self.pubkey),
     92                     )
     93                     .get_ptr();
     94 
     95                 profile_timeline.selected_view =
     96                     tabs_ui(ui, profile_timeline.selected_view, &profile_timeline.views);
     97 
     98                 let reversed = false;
     99                 // poll for new notes and insert them into our existing notes
    100                 if let Err(e) = profile_timeline.poll_notes_into_view(
    101                     self.note_context.ndb,
    102                     &txn,
    103                     self.unknown_ids,
    104                     self.note_context.note_cache,
    105                     reversed,
    106                 ) {
    107                     error!("Profile::poll_notes_into_view: {e}");
    108                 }
    109 
    110                 if let Some(note_action) = TimelineTabView::new(
    111                     profile_timeline.current_view(),
    112                     reversed,
    113                     self.note_options,
    114                     &txn,
    115                     self.is_muted,
    116                     self.note_context,
    117                 )
    118                 .show(ui)
    119                 {
    120                     action = Some(ProfileViewAction::Note(note_action));
    121                 }
    122 
    123                 action
    124             })
    125             .inner
    126     }
    127 
    128     fn profile_body(&mut self, ui: &mut egui::Ui, profile: ProfileRecord<'_>) -> bool {
    129         let mut action = false;
    130         ui.vertical(|ui| {
    131             banner(
    132                 ui,
    133                 profile.record().profile().and_then(|p| p.banner()),
    134                 120.0,
    135             );
    136 
    137             let padding = 12.0;
    138             crate::ui::padding(padding, ui, |ui| {
    139                 let mut pfp_rect = ui.available_rect_before_wrap();
    140                 let size = 80.0;
    141                 pfp_rect.set_width(size);
    142                 pfp_rect.set_height(size);
    143                 let pfp_rect = pfp_rect.translate(egui::vec2(0.0, -(padding + 2.0 + (size / 2.0))));
    144 
    145                 ui.horizontal(|ui| {
    146                     ui.put(
    147                         pfp_rect,
    148                         ProfilePic::new(
    149                             self.note_context.img_cache,
    150                             get_profile_url(Some(&profile)),
    151                         )
    152                         .size(size)
    153                         .border(ProfilePic::border_stroke(ui)),
    154                     );
    155 
    156                     if ui.add(copy_key_widget(&pfp_rect)).clicked() {
    157                         let to_copy = 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                         ui.ctx().copy_text(to_copy)
    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         #[allow(deprecated)]
    237         let copy_key_rect = painter.round_rect_to_pixels(egui::Rect::from_center_size(
    238             pfp_rect.center_bottom(),
    239             egui::vec2(48.0, 28.0),
    240         ));
    241         let resp = ui.interact(
    242             copy_key_rect,
    243             ui.id().with("custom_painter"),
    244             Sense::click(),
    245         );
    246 
    247         let copy_key_rounding = Rounding::same(100);
    248         let fill_color = if resp.hovered() {
    249             ui.visuals().widgets.inactive.weak_bg_fill
    250         } else {
    251             ui.visuals().noninteractive().bg_stroke.color
    252         };
    253         painter.rect_filled(copy_key_rect, copy_key_rounding, fill_color);
    254 
    255         let stroke_color = ui.visuals().widgets.inactive.weak_bg_fill;
    256         painter.rect_stroke(
    257             copy_key_rect.shrink(1.0),
    258             copy_key_rounding,
    259             Stroke::new(1.0, stroke_color),
    260         );
    261         egui::Image::new(egui::include_image!(
    262             "../../../../../assets/icons/key_4x.png"
    263         ))
    264         .paint_at(
    265             ui,
    266             #[allow(deprecated)]
    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         #[allow(deprecated)]
    282         let rect = painter.round_rect_to_pixels(rect);
    283 
    284         painter.rect_filled(
    285             rect,
    286             Rounding::same(8),
    287             if resp.hovered() {
    288                 ui.visuals().widgets.active.bg_fill
    289             } else {
    290                 ui.visuals().widgets.inactive.bg_fill
    291             },
    292         );
    293         painter.rect_stroke(
    294             rect.shrink(1.0),
    295             Rounding::same(8),
    296             if resp.hovered() {
    297                 ui.visuals().widgets.active.bg_stroke
    298             } else {
    299                 ui.visuals().widgets.inactive.bg_stroke
    300             },
    301         );
    302 
    303         let edit_icon_size = vec2(16.0, 16.0);
    304         let galley = painter.layout(
    305             "Edit Profile".to_owned(),
    306             NotedeckTextStyle::Button.get_font_id(ui.ctx()),
    307             ui.visuals().text_color(),
    308             rect.width(),
    309         );
    310 
    311         let space_between_icon_galley = 8.0;
    312         let half_icon_size = edit_icon_size.x / 2.0;
    313         let galley_rect = {
    314             let galley_rect = Rect::from_center_size(rect.center(), galley.rect.size());
    315             galley_rect.translate(vec2(half_icon_size + space_between_icon_galley / 2.0, 0.0))
    316         };
    317 
    318         let edit_icon_rect = {
    319             let mut center = galley_rect.left_center();
    320             center.x -= half_icon_size + space_between_icon_galley;
    321             #[allow(deprecated)]
    322             painter.round_rect_to_pixels(Rect::from_center_size(
    323                 painter.round_pos_to_pixel_center(center),
    324                 edit_icon_size,
    325             ))
    326         };
    327 
    328         painter.galley(galley_rect.left_top(), galley, Color32::WHITE);
    329 
    330         egui::Image::new(egui::include_image!(
    331             "../../../../../assets/icons/edit_icon_4x_dark.png"
    332         ))
    333         .paint_at(ui, edit_icon_rect);
    334 
    335         resp
    336     }
    337 }
    338 
    339 fn display_name_widget(name: NostrName<'_>, add_placeholder_space: bool) -> impl egui::Widget + '_ {
    340     move |ui: &mut egui::Ui| -> egui::Response {
    341         let disp_resp = name.display_name.map(|disp_name| {
    342             ui.add(
    343                 Label::new(
    344                     RichText::new(disp_name).text_style(NotedeckTextStyle::Heading3.text_style()),
    345                 )
    346                 .selectable(false),
    347             )
    348         });
    349 
    350         let (username_resp, nip05_resp) = ui
    351             .horizontal(|ui| {
    352                 let username_resp = name.username.map(|username| {
    353                     ui.add(
    354                         Label::new(
    355                             RichText::new(format!("@{}", username))
    356                                 .size(16.0)
    357                                 .color(colors::MID_GRAY),
    358                         )
    359                         .selectable(false),
    360                     )
    361                 });
    362 
    363                 let nip05_resp = name.nip05.map(|nip05| {
    364                     ui.image(egui::include_image!(
    365                         "../../../../../assets/icons/verified_4x.png"
    366                     ));
    367                     ui.add(Label::new(
    368                         RichText::new(nip05).size(16.0).color(colors::TEAL),
    369                     ))
    370                 });
    371 
    372                 (username_resp, nip05_resp)
    373             })
    374             .inner;
    375 
    376         let resp = match (disp_resp, username_resp, nip05_resp) {
    377             (Some(disp), Some(username), Some(nip05)) => disp.union(username).union(nip05),
    378             (Some(disp), Some(username), None) => disp.union(username),
    379             (Some(disp), None, None) => disp,
    380             (None, Some(username), Some(nip05)) => username.union(nip05),
    381             (None, Some(username), None) => username,
    382             _ => ui.add(Label::new(RichText::new(name.name()))),
    383         };
    384 
    385         if add_placeholder_space {
    386             ui.add_space(16.0);
    387         }
    388 
    389         resp
    390     }
    391 }
    392 
    393 pub fn get_profile_url<'a>(profile: Option<&ProfileRecord<'a>>) -> &'a str {
    394     unwrap_profile_url(profile.and_then(|pr| pr.record().profile().and_then(|p| p.picture())))
    395 }
    396 
    397 pub fn unwrap_profile_url(maybe_url: Option<&str>) -> &str {
    398     if let Some(url) = maybe_url {
    399         url
    400     } else {
    401         ProfilePic::no_pfp_url()
    402     }
    403 }
    404 
    405 fn about_section_widget<'a, 'b>(profile: &'b ProfileRecord<'a>) -> impl egui::Widget + 'b
    406 where
    407     'b: 'a,
    408 {
    409     move |ui: &mut egui::Ui| {
    410         if let Some(about) = profile.record().profile().and_then(|p| p.about()) {
    411             let resp = ui.label(about);
    412             ui.add_space(8.0);
    413             resp
    414         } else {
    415             // need any Response so we dont need an Option
    416             ui.allocate_response(egui::Vec2::ZERO, egui::Sense::hover())
    417         }
    418     }
    419 }
    420 
    421 fn banner_texture(ui: &mut egui::Ui, banner_url: &str) -> Option<egui::load::SizedTexture> {
    422     // TODO: cache banner
    423     if !banner_url.is_empty() {
    424         let texture_load_res =
    425             egui::Image::new(banner_url).load_for_size(ui.ctx(), ui.available_size());
    426         if let Ok(texture_poll) = texture_load_res {
    427             match texture_poll {
    428                 TexturePoll::Pending { .. } => {}
    429                 TexturePoll::Ready { texture, .. } => return Some(texture),
    430             }
    431         }
    432     }
    433 
    434     None
    435 }
    436 
    437 fn banner(ui: &mut egui::Ui, banner_url: Option<&str>, height: f32) -> egui::Response {
    438     ui.add_sized([ui.available_size().x, height], |ui: &mut egui::Ui| {
    439         banner_url
    440             .and_then(|url| banner_texture(ui, url))
    441             .map(|texture| {
    442                 images::aspect_fill(
    443                     ui,
    444                     Sense::hover(),
    445                     texture.id,
    446                     texture.size.x / texture.size.y,
    447                 )
    448             })
    449             .unwrap_or_else(|| ui.label(""))
    450     })
    451 }