notedeck

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

mod.rs (18246B)


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