notedeck

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

mod.rs (17313B)


      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, DragResponse, Localization, ProfileContext};
      8 use notedeck_ui::profile::{context::ProfileContextWidget, 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, NoteAction, NoteContext,
     18     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 }
     33 
     34 pub enum ProfileViewAction {
     35     EditProfile,
     36     Note(NoteAction),
     37     Unfollow(Pubkey),
     38     Follow(Pubkey),
     39     Context(ProfileContext),
     40     ShowFollowing(Pubkey),
     41     ShowFollowers(Pubkey),
     42 }
     43 
     44 struct ProfileScrollResponse {
     45     body_end_pos: f32,
     46     action: Option<ProfileViewAction>,
     47 }
     48 
     49 impl<'a, 'd> ProfileView<'a, 'd> {
     50     #[allow(clippy::too_many_arguments)]
     51     pub fn new(
     52         pubkey: &'a Pubkey,
     53         col_id: usize,
     54         timeline_cache: &'a mut TimelineCache,
     55         note_options: NoteOptions,
     56         note_context: &'a mut NoteContext<'d>,
     57     ) -> Self {
     58         ProfileView {
     59             pubkey,
     60             col_id,
     61             timeline_cache,
     62             note_options,
     63             note_context,
     64         }
     65     }
     66 
     67     pub fn scroll_id(col_id: usize, profile_pubkey: &Pubkey) -> egui::Id {
     68         egui::Id::new(("profile_scroll", col_id, profile_pubkey))
     69     }
     70 
     71     pub fn ui(&mut self, ui: &mut egui::Ui) -> DragResponse<ProfileViewAction> {
     72         let scroll_id = ProfileView::scroll_id(self.col_id, self.pubkey);
     73         let scroll_area = ScrollArea::vertical().id_salt(scroll_id).animated(false);
     74 
     75         let Some(profile_timeline) = self
     76             .timeline_cache
     77             .get_mut(&TimelineKind::Profile(*self.pubkey))
     78         else {
     79             return DragResponse::none();
     80         };
     81 
     82         let output = scroll_area.show(ui, |ui| {
     83             let mut action = None;
     84             let txn = Transaction::new(self.note_context.ndb).expect("txn");
     85             let profile = self
     86                 .note_context
     87                 .ndb
     88                 .get_profile_by_pubkey(&txn, self.pubkey.bytes())
     89                 .ok();
     90 
     91             if let Some(profile_view_action) =
     92                 profile_body(ui, self.pubkey, self.note_context, profile.as_ref(), &txn)
     93             {
     94                 action = Some(profile_view_action);
     95             }
     96 
     97             let tabs_resp = tabs_ui(
     98                 ui,
     99                 self.note_context.i18n,
    100                 profile_timeline.selected_view,
    101                 &profile_timeline.views,
    102             );
    103             profile_timeline.selected_view = tabs_resp.inner;
    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                 self.note_options,
    120                 &txn,
    121                 self.note_context,
    122             )
    123             .show(ui)
    124             {
    125                 action = Some(ProfileViewAction::Note(note_action));
    126             }
    127 
    128             ProfileScrollResponse {
    129                 body_end_pos: tabs_resp.response.rect.bottom(),
    130                 action,
    131             }
    132         });
    133 
    134         // only allow front insert when the profile body is fully obstructed
    135         profile_timeline.enable_front_insert = output.inner.body_end_pos < ui.clip_rect().top();
    136 
    137         DragResponse::output(output.inner.action).scroll_raw(output.id)
    138     }
    139 }
    140 
    141 fn profile_body(
    142     ui: &mut egui::Ui,
    143     pubkey: &Pubkey,
    144     note_context: &mut NoteContext,
    145     profile: Option<&ProfileRecord<'_>>,
    146     txn: &Transaction,
    147 ) -> Option<ProfileViewAction> {
    148     let mut action = None;
    149     ui.vertical(|ui| {
    150         let banner_resp = banner(
    151             ui,
    152             profile
    153                 .map(|p| p.record().profile())
    154                 .and_then(|p| p.and_then(|p| p.banner())),
    155             120.0,
    156         );
    157 
    158         let place_context = {
    159             let mut rect = banner_resp.rect;
    160             let size = 24.0;
    161             rect.set_bottom(rect.top() + size);
    162             rect.set_left(rect.right() - size);
    163             rect.translate(vec2(-16.0, 16.0))
    164         };
    165 
    166         let context_resp = ProfileContextWidget::new(place_context).context_button(ui, pubkey);
    167         if let Some(selection) =
    168             ProfileContextWidget::context_menu(ui, note_context.i18n, context_resp)
    169         {
    170             action = Some(ProfileViewAction::Context(ProfileContext {
    171                 profile: *pubkey,
    172                 selection,
    173             }));
    174         }
    175 
    176         let padding = 12.0;
    177         notedeck_ui::padding(padding, ui, |ui| {
    178             let mut pfp_rect = ui.available_rect_before_wrap();
    179             let size = 80.0;
    180             pfp_rect.set_width(size);
    181             pfp_rect.set_height(size);
    182             let pfp_rect = pfp_rect.translate(egui::vec2(0.0, -(padding + 2.0 + (size / 2.0))));
    183 
    184             ui.horizontal(|ui| {
    185                 ui.put(
    186                     pfp_rect,
    187                     &mut ProfilePic::new(
    188                         note_context.img_cache,
    189                         note_context.jobs,
    190                         get_profile_url(profile),
    191                     )
    192                     .size(size)
    193                     .border(ProfilePic::border_stroke(ui)),
    194                 );
    195 
    196                 if ui
    197                     .add(copy_key_widget(&pfp_rect, note_context.i18n))
    198                     .clicked()
    199                 {
    200                     let to_copy = if let Some(bech) = pubkey.npub() {
    201                         bech
    202                     } else {
    203                         error!("Could not convert Pubkey to bech");
    204                         String::new()
    205                     };
    206                     ui.ctx().copy_text(to_copy)
    207                 }
    208 
    209                 ui.with_layout(Layout::right_to_left(egui::Align::RIGHT), |ui| {
    210                     ui.add_space(24.0);
    211 
    212                     let target_key = pubkey;
    213                     let selected = note_context.accounts.get_selected_account();
    214 
    215                     let profile_type = if selected.key.secret_key.is_none() {
    216                         ProfileType::ReadOnly
    217                     } else if &selected.key.pubkey == pubkey {
    218                         ProfileType::MyProfile
    219                     } else {
    220                         ProfileType::Followable(selected.is_following(target_key.bytes()))
    221                     };
    222 
    223                     match profile_type {
    224                         ProfileType::MyProfile => {
    225                             if ui.add(edit_profile_button(note_context.i18n)).clicked() {
    226                                 action = Some(ProfileViewAction::EditProfile);
    227                             }
    228                         }
    229                         ProfileType::Followable(is_following) => {
    230                             let follow_button = ui.add(follow_button(is_following));
    231 
    232                             if follow_button.clicked() {
    233                                 action = match is_following {
    234                                     IsFollowing::Unknown => {
    235                                         // don't do anything, we don't have contact list
    236                                         None
    237                                     }
    238 
    239                                     IsFollowing::Yes => {
    240                                         Some(ProfileViewAction::Unfollow(target_key.to_owned()))
    241                                     }
    242 
    243                                     IsFollowing::No => {
    244                                         Some(ProfileViewAction::Follow(target_key.to_owned()))
    245                                     }
    246                                 };
    247                             }
    248                         }
    249                         ProfileType::ReadOnly => {}
    250                     }
    251                 });
    252             });
    253 
    254             ui.add_space(18.0);
    255 
    256             ui.add(display_name_widget(&get_display_name(profile), false));
    257 
    258             ui.add_space(8.0);
    259 
    260             ui.add(about_section_widget(profile));
    261 
    262             ui.add_space(8.0);
    263 
    264             if let Some(stats_action) = profile_stats(ui, pubkey, note_context, txn) {
    265                 action = Some(stats_action);
    266             }
    267 
    268             ui.horizontal_wrapped(|ui| {
    269                 let website_url = profile
    270                     .as_ref()
    271                     .map(|p| p.record().profile())
    272                     .and_then(|p| p.and_then(|p| p.website()).filter(|s| !s.is_empty()));
    273 
    274                 let lud16 = profile
    275                     .as_ref()
    276                     .map(|p| p.record().profile())
    277                     .and_then(|p| p.and_then(|p| p.lud16()).filter(|s| !s.is_empty()));
    278 
    279                 if let Some(website_url) = website_url {
    280                     ui.horizontal_wrapped(|ui| {
    281                         handle_link(ui, website_url);
    282                     });
    283                 }
    284 
    285                 if let Some(lud16) = lud16 {
    286                     if website_url.is_some() {
    287                         ui.end_row();
    288                     }
    289                     ui.horizontal_wrapped(|ui| {
    290                         handle_lud16(ui, lud16);
    291                     });
    292                 }
    293             });
    294         });
    295     });
    296 
    297     action
    298 }
    299 
    300 enum ProfileType {
    301     MyProfile,
    302     ReadOnly,
    303     Followable(IsFollowing),
    304 }
    305 
    306 fn profile_stats(
    307     ui: &mut egui::Ui,
    308     pubkey: &Pubkey,
    309     note_context: &mut NoteContext,
    310     txn: &Transaction,
    311 ) -> Option<ProfileViewAction> {
    312     let mut action = None;
    313 
    314     let filter = nostrdb::Filter::new()
    315         .authors([pubkey.bytes()])
    316         .kinds([3])
    317         .limit(1)
    318         .build();
    319 
    320     let mut count = 0;
    321     let following_count = {
    322         if let Ok(results) = note_context.ndb.query(txn, &[filter], 1) {
    323             if let Some(result) = results.first() {
    324                 for tag in result.note.tags() {
    325                     if tag.count() >= 2 {
    326                         if let Some("p") = tag.get_str(0) {
    327                             if tag.get_id(1).is_some() {
    328                                 count += 1;
    329                             }
    330                         }
    331                     }
    332                 }
    333             }
    334         }
    335 
    336         count
    337     };
    338 
    339     ui.horizontal(|ui| {
    340         let resp = ui
    341             .label(
    342                 RichText::new(format!("{following_count} "))
    343                     .size(notedeck::fonts::get_font_size(
    344                         ui.ctx(),
    345                         &NotedeckTextStyle::Small,
    346                     ))
    347                     .color(ui.visuals().text_color()),
    348             )
    349             .on_hover_cursor(egui::CursorIcon::PointingHand);
    350 
    351         let resp2 = ui
    352             .label(
    353                 RichText::new(tr!(
    354                     note_context.i18n,
    355                     "following",
    356                     "Label for number of accounts being followed"
    357                 ))
    358                 .size(notedeck::fonts::get_font_size(
    359                     ui.ctx(),
    360                     &NotedeckTextStyle::Small,
    361                 ))
    362                 .color(ui.visuals().weak_text_color()),
    363             )
    364             .on_hover_cursor(egui::CursorIcon::PointingHand);
    365 
    366         if resp.clicked() || resp2.clicked() {
    367             action = Some(ProfileViewAction::ShowFollowing(*pubkey));
    368         }
    369 
    370         let selected = note_context.accounts.get_selected_account();
    371         if &selected.key.pubkey != pubkey
    372             && selected.is_following(pubkey.bytes()) == notedeck::IsFollowing::Yes
    373         {
    374             ui.add_space(8.0);
    375             ui.label(
    376                 RichText::new(tr!(
    377                     note_context.i18n,
    378                     "Follows you",
    379                     "Badge indicating user follows you"
    380                 ))
    381                 .size(notedeck::fonts::get_font_size(
    382                     ui.ctx(),
    383                     &NotedeckTextStyle::Tiny,
    384                 ))
    385                 .color(ui.visuals().weak_text_color()),
    386             );
    387         }
    388     });
    389 
    390     action
    391 }
    392 
    393 fn handle_link(ui: &mut egui::Ui, website_url: &str) {
    394     let img = if ui.visuals().dark_mode {
    395         app_images::link_dark_image()
    396     } else {
    397         app_images::link_light_image()
    398     };
    399 
    400     ui.add(img);
    401     if ui
    402         .label(RichText::new(website_url).color(notedeck_ui::colors::PINK))
    403         .on_hover_cursor(egui::CursorIcon::PointingHand)
    404         .on_hover_text(website_url)
    405         .interact(Sense::click())
    406         .clicked()
    407     {
    408         if let Err(e) = Uri::new(website_url).open() {
    409             error!("Failed to open URL {} because: {:?}", website_url, e);
    410         };
    411     }
    412 }
    413 
    414 fn handle_lud16(ui: &mut egui::Ui, lud16: &str) {
    415     ui.add(app_images::filled_zap_image());
    416 
    417     let _ = ui
    418         .label(RichText::new(lud16).color(notedeck_ui::colors::PINK))
    419         .on_hover_text(lud16);
    420 }
    421 
    422 fn copy_key_widget<'a>(
    423     pfp_rect: &'a egui::Rect,
    424     i18n: &'a mut Localization,
    425 ) -> impl egui::Widget + 'a {
    426     |ui: &mut egui::Ui| -> egui::Response {
    427         let painter = ui.painter();
    428         #[allow(deprecated)]
    429         let copy_key_rect = painter.round_rect_to_pixels(egui::Rect::from_center_size(
    430             pfp_rect.center_bottom(),
    431             egui::vec2(48.0, 28.0),
    432         ));
    433         let resp = ui
    434             .interact(
    435                 copy_key_rect,
    436                 ui.id().with("custom_painter"),
    437                 Sense::click(),
    438             )
    439             .on_hover_text(tr!(
    440                 i18n,
    441                 "Copy npub to clipboard",
    442                 "Tooltip text for copying npub to clipboard"
    443             ));
    444 
    445         let copy_key_rounding = CornerRadius::same(100);
    446         let fill_color = if resp.hovered() {
    447             ui.visuals().widgets.inactive.weak_bg_fill
    448         } else {
    449             ui.visuals().noninteractive().bg_stroke.color
    450         };
    451         painter.rect_filled(copy_key_rect, copy_key_rounding, fill_color);
    452 
    453         let stroke_color = ui.visuals().widgets.inactive.weak_bg_fill;
    454         painter.rect_stroke(
    455             copy_key_rect.shrink(1.0),
    456             copy_key_rounding,
    457             Stroke::new(1.0, stroke_color),
    458             egui::StrokeKind::Outside,
    459         );
    460 
    461         app_images::key_image().paint_at(
    462             ui,
    463             #[allow(deprecated)]
    464             painter.round_rect_to_pixels(egui::Rect::from_center_size(
    465                 copy_key_rect.center(),
    466                 egui::vec2(16.0, 16.0),
    467             )),
    468         );
    469 
    470         resp
    471     }
    472 }
    473 
    474 fn edit_profile_button<'a>(i18n: &'a mut Localization) -> impl egui::Widget + 'a {
    475     |ui: &mut egui::Ui| -> egui::Response {
    476         let (rect, resp) = ui.allocate_exact_size(vec2(124.0, 32.0), Sense::click());
    477         let painter = ui.painter_at(rect);
    478         #[allow(deprecated)]
    479         let rect = painter.round_rect_to_pixels(rect);
    480 
    481         painter.rect_filled(
    482             rect,
    483             CornerRadius::same(8),
    484             if resp.hovered() {
    485                 ui.visuals().widgets.active.bg_fill
    486             } else {
    487                 ui.visuals().widgets.inactive.bg_fill
    488             },
    489         );
    490         painter.rect_stroke(
    491             rect.shrink(1.0),
    492             CornerRadius::same(8),
    493             if resp.hovered() {
    494                 ui.visuals().widgets.active.bg_stroke
    495             } else {
    496                 ui.visuals().widgets.inactive.bg_stroke
    497             },
    498             egui::StrokeKind::Outside,
    499         );
    500 
    501         let edit_icon_size = vec2(16.0, 16.0);
    502         let galley = painter.layout(
    503             tr!(i18n, "Edit Profile", "Button label to edit user profile"),
    504             NotedeckTextStyle::Button.get_font_id(ui.ctx()),
    505             ui.visuals().text_color(),
    506             rect.width(),
    507         );
    508 
    509         let space_between_icon_galley = 8.0;
    510         let half_icon_size = edit_icon_size.x / 2.0;
    511         let galley_rect = {
    512             let galley_rect = Rect::from_center_size(rect.center(), galley.rect.size());
    513             galley_rect.translate(vec2(half_icon_size + space_between_icon_galley / 2.0, 0.0))
    514         };
    515 
    516         let edit_icon_rect = {
    517             let mut center = galley_rect.left_center();
    518             center.x -= half_icon_size + space_between_icon_galley;
    519             #[allow(deprecated)]
    520             painter.round_rect_to_pixels(Rect::from_center_size(
    521                 painter.round_pos_to_pixel_center(center),
    522                 edit_icon_size,
    523             ))
    524         };
    525 
    526         painter.galley(galley_rect.left_top(), galley, Color32::WHITE);
    527 
    528         app_images::edit_dark_image()
    529             .tint(ui.visuals().text_color())
    530             .paint_at(ui, edit_icon_rect);
    531 
    532         resp
    533     }
    534 }