notedeck

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

mod.rs (35765B)


      1 pub mod contents;
      2 pub mod context;
      3 pub mod media;
      4 pub mod options;
      5 pub mod reply_description;
      6 
      7 use crate::{app_images, secondary_label};
      8 use crate::{widgets::x_button, ProfilePic, ProfilePreview, PulseAlpha, Username};
      9 
     10 pub use contents::{render_note_preview, NoteContents};
     11 pub use context::NoteContextButton;
     12 use notedeck::note::{reaction_sent_id, ZapTargetAmount};
     13 use notedeck::ui::is_narrow;
     14 use notedeck::Accounts;
     15 use notedeck::GlobalWallet;
     16 use notedeck::Images;
     17 use notedeck::Localization;
     18 use notedeck::MediaAction;
     19 use notedeck::{get_current_wallet, MediaJobSender};
     20 pub use options::NoteOptions;
     21 pub use reply_description::reply_desc;
     22 
     23 use egui::emath::{pos2, Vec2};
     24 use egui::{Id, Pos2, Rect, Response, Sense};
     25 use enostr::{KeypairUnowned, NoteId, Pubkey};
     26 use nostrdb::{Ndb, Note, NoteKey, ProfileRecord, Transaction};
     27 use notedeck::{
     28     note::{NoteAction, NoteContext, ReactAction, ZapAction},
     29     tr, AnyZapState, ContextSelection, NoteZapTarget, NoteZapTargetOwned, ZapTarget, Zaps,
     30 };
     31 
     32 pub struct NoteView<'a, 'd> {
     33     note_context: &'a mut NoteContext<'d>,
     34     parent: Option<NoteKey>,
     35     note: &'a nostrdb::Note<'a>,
     36     flags: NoteOptions,
     37 }
     38 
     39 pub struct NoteResponse {
     40     pub response: egui::Response,
     41     pub action: Option<NoteAction>,
     42     pub pfp_rect: Option<egui::Rect>,
     43 }
     44 
     45 impl NoteResponse {
     46     pub fn new(response: egui::Response) -> Self {
     47         Self {
     48             response,
     49             action: None,
     50             pfp_rect: None,
     51         }
     52     }
     53 
     54     pub fn with_action(mut self, action: Option<NoteAction>) -> Self {
     55         self.action = action;
     56         self
     57     }
     58 
     59     pub fn with_pfp(mut self, pfp_rect: egui::Rect) -> Self {
     60         self.pfp_rect = Some(pfp_rect);
     61         self
     62     }
     63 }
     64 
     65 /*
     66 impl View for NoteView<'_, '_> {
     67     fn ui(&mut self, ui: &mut egui::Ui) {
     68         self.show(ui);
     69     }
     70 }
     71 */
     72 
     73 impl egui::Widget for &mut NoteView<'_, '_> {
     74     fn ui(self, ui: &mut egui::Ui) -> egui::Response {
     75         self.show(ui).response
     76     }
     77 }
     78 
     79 impl<'a, 'd> NoteView<'a, 'd> {
     80     pub fn new(
     81         note_context: &'a mut NoteContext<'d>,
     82         note: &'a nostrdb::Note<'a>,
     83         flags: NoteOptions,
     84     ) -> Self {
     85         let parent: Option<NoteKey> = None;
     86 
     87         Self {
     88             note_context,
     89             parent,
     90             note,
     91             flags,
     92         }
     93     }
     94 
     95     pub fn preview_style(self) -> Self {
     96         self.actionbar(false)
     97             .small_pfp(true)
     98             .frame(true)
     99             .wide(true)
    100             .note_previews(false)
    101             .options_button(true)
    102             .is_preview(true)
    103             .full_date(false)
    104             .client_name(false)
    105     }
    106 
    107     pub fn selected_style(self, selected: bool) -> Self {
    108         self.wide(selected)
    109             .full_date(selected)
    110             .client_name(selected)
    111     }
    112 
    113     #[inline]
    114     pub fn textmode(mut self, enable: bool) -> Self {
    115         self.options_mut().set(NoteOptions::Textmode, enable);
    116         self
    117     }
    118 
    119     #[inline]
    120     pub fn client_name(mut self, enable: bool) -> Self {
    121         self.options_mut().set(NoteOptions::ClientName, enable);
    122         self
    123     }
    124 
    125     #[inline]
    126     pub fn full_date(mut self, enable: bool) -> Self {
    127         self.options_mut().set(NoteOptions::FullCreatedDate, enable);
    128         self
    129     }
    130 
    131     #[inline]
    132     pub fn actionbar(mut self, enable: bool) -> Self {
    133         self.options_mut().set(NoteOptions::ActionBar, enable);
    134         self
    135     }
    136 
    137     #[inline]
    138     pub fn hide_media(mut self, enable: bool) -> Self {
    139         self.options_mut().set(NoteOptions::HideMedia, enable);
    140         self
    141     }
    142 
    143     #[inline]
    144     pub fn frame(mut self, enable: bool) -> Self {
    145         self.options_mut().set(NoteOptions::Framed, enable);
    146         self
    147     }
    148 
    149     #[inline]
    150     pub fn truncate(mut self, enable: bool) -> Self {
    151         self.options_mut().set(NoteOptions::Truncate, enable);
    152         self
    153     }
    154 
    155     #[inline]
    156     pub fn small_pfp(mut self, enable: bool) -> Self {
    157         self.options_mut().set(NoteOptions::SmallPfp, enable);
    158         self
    159     }
    160 
    161     #[inline]
    162     pub fn medium_pfp(mut self, enable: bool) -> Self {
    163         self.options_mut().set(NoteOptions::MediumPfp, enable);
    164         self
    165     }
    166 
    167     #[inline]
    168     pub fn note_previews(mut self, enable: bool) -> Self {
    169         self.options_mut().set(NoteOptions::HasNotePreviews, enable);
    170         self
    171     }
    172 
    173     #[inline]
    174     pub fn selectable_text(mut self, enable: bool) -> Self {
    175         self.options_mut().set(NoteOptions::SelectableText, enable);
    176         self
    177     }
    178 
    179     #[inline]
    180     pub fn wide(mut self, enable: bool) -> Self {
    181         self.options_mut().set(NoteOptions::Wide, enable);
    182         self
    183     }
    184 
    185     #[inline]
    186     pub fn options_button(mut self, enable: bool) -> Self {
    187         self.options_mut().set(NoteOptions::OptionsButton, enable);
    188         self
    189     }
    190 
    191     #[inline]
    192     pub fn unread_indicator(mut self, enable: bool) -> Self {
    193         self.options_mut().set(NoteOptions::UnreadIndicator, enable);
    194         self
    195     }
    196 
    197     #[inline]
    198     pub fn options(&self) -> NoteOptions {
    199         self.flags
    200     }
    201 
    202     #[inline]
    203     pub fn options_mut(&mut self) -> &mut NoteOptions {
    204         &mut self.flags
    205     }
    206 
    207     #[inline]
    208     pub fn parent(mut self, parent: NoteKey) -> Self {
    209         self.parent = Some(parent);
    210         self
    211     }
    212 
    213     #[inline]
    214     pub fn is_preview(mut self, is_preview: bool) -> Self {
    215         self.options_mut().set(NoteOptions::IsPreview, is_preview);
    216         self
    217     }
    218 
    219     fn textmode_ui(&mut self, ui: &mut egui::Ui) -> egui::Response {
    220         let txn = self.note.txn().expect("todo: implement non-db notes");
    221 
    222         ui.with_layout(egui::Layout::left_to_right(egui::Align::TOP), |ui| {
    223             let profile = self
    224                 .note_context
    225                 .ndb
    226                 .get_profile_by_pubkey(txn, self.note.pubkey());
    227 
    228             //ui.horizontal(|ui| {
    229             ui.spacing_mut().item_spacing.x = 2.0;
    230 
    231             let (_id, rect) = ui.allocate_space(egui::vec2(50.0, 20.0));
    232             ui.allocate_rect(rect, Sense::hover());
    233             ui.put(rect, |ui: &mut egui::Ui| {
    234                 render_notetime(ui, self.note_context.i18n, self.note.created_at(), false)
    235             });
    236             let (_id, rect) = ui.allocate_space(egui::vec2(150.0, 20.0));
    237             ui.allocate_rect(rect, Sense::hover());
    238             ui.put(rect, |ui: &mut egui::Ui| {
    239                 ui.add(
    240                     Username::new(
    241                         self.note_context.i18n,
    242                         profile.as_ref().ok(),
    243                         self.note.pubkey(),
    244                     )
    245                     .abbreviated(6)
    246                     .pk_colored(true),
    247                 )
    248             });
    249 
    250             ui.add(&mut NoteContents::new(
    251                 self.note_context,
    252                 txn,
    253                 self.note,
    254                 self.flags,
    255             ));
    256             //});
    257         })
    258         .response
    259     }
    260 
    261     pub fn expand_size() -> i8 {
    262         5
    263     }
    264 
    265     fn pfp(
    266         &mut self,
    267         note_key: NoteKey,
    268         profile: &Result<nostrdb::ProfileRecord<'_>, nostrdb::Error>,
    269         ui: &mut egui::Ui,
    270     ) -> PfpResponse {
    271         if !self.options().contains(NoteOptions::Wide) {
    272             ui.spacing_mut().item_spacing.x = 16.0;
    273         } else {
    274             ui.spacing_mut().item_spacing.x = 4.0;
    275         }
    276 
    277         let pfp_size = self.options().pfp_size();
    278 
    279         match profile
    280             .as_ref()
    281             .ok()
    282             .and_then(|p| p.record().profile()?.picture())
    283         {
    284             // these have different lifetimes and types,
    285             // so the calls must be separate
    286             Some(pic) => show_actual_pfp(
    287                 ui,
    288                 self.note_context.img_cache,
    289                 self.note_context.jobs,
    290                 pic,
    291                 pfp_size,
    292                 note_key,
    293                 profile,
    294             ),
    295 
    296             None => show_fallback_pfp(
    297                 ui,
    298                 self.note_context.img_cache,
    299                 self.note_context.jobs,
    300                 pfp_size,
    301             ),
    302         }
    303     }
    304 
    305     pub fn show(&mut self, ui: &mut egui::Ui) -> NoteResponse {
    306         if !self.flags.contains(NoteOptions::TrustMedia) {
    307             let acc = self.note_context.accounts.get_selected_account();
    308             if self.note.pubkey() == acc.key.pubkey.bytes()
    309                 || matches!(
    310                     acc.is_following(self.note.pubkey()),
    311                     notedeck::IsFollowing::Yes
    312                 )
    313             {
    314                 self.flags = self.flags.union(NoteOptions::TrustMedia);
    315             }
    316         }
    317 
    318         if self.options().contains(NoteOptions::Textmode) {
    319             NoteResponse::new(self.textmode_ui(ui))
    320         } else if self.options().contains(NoteOptions::Framed) {
    321             egui::Frame::new()
    322                 .fill(ui.visuals().noninteractive().weak_bg_fill)
    323                 .inner_margin(egui::Margin::same(8))
    324                 .outer_margin(egui::Margin::symmetric(0, 8))
    325                 .corner_radius(egui::CornerRadius::same(10))
    326                 .stroke(egui::Stroke::new(
    327                     1.0,
    328                     ui.visuals().noninteractive().bg_stroke.color,
    329                 ))
    330                 .show(ui, |ui| {
    331                     if is_narrow(ui.ctx()) {
    332                         ui.set_width(ui.available_width());
    333                     }
    334                     self.show_standard(ui)
    335                 })
    336                 .inner
    337         } else {
    338             self.show_standard(ui)
    339         }
    340     }
    341 
    342     #[profiling::function]
    343     fn note_header(
    344         ui: &mut egui::Ui,
    345         txn: &Transaction,
    346         note_context: &mut NoteContext,
    347         note: &Note,
    348         profile: &Result<nostrdb::ProfileRecord<'_>, nostrdb::Error>,
    349         flags: NoteOptions,
    350     ) {
    351         let horiz_resp = ui
    352             .horizontal_wrapped(|ui| {
    353                 ui.spacing_mut().item_spacing.x = if is_narrow(ui.ctx()) { 1.0 } else { 2.0 };
    354                 let response = ui.add(
    355                     Username::new(note_context.i18n, profile.as_ref().ok(), note.pubkey())
    356                         .abbreviated(20),
    357                 );
    358                 if !flags.contains(NoteOptions::FullCreatedDate) {
    359                     return render_notetime(ui, note_context.i18n, note.created_at(), true);
    360                 }
    361                 response
    362             })
    363             .response;
    364 
    365         if flags.contains(NoteOptions::UnreadIndicator) {
    366             let radius = 4.0;
    367             let circle_center = {
    368                 let mut center = horiz_resp.rect.right_center();
    369                 center.x += radius + 4.0;
    370                 center
    371             };
    372 
    373             ui.painter()
    374                 .circle_filled(circle_center, radius, crate::colors::PINK);
    375         }
    376 
    377         if note.is_rumor() {
    378             ui.horizontal_wrapped(|ui| {
    379                 ui.spacing_mut().item_spacing.x = if is_narrow(ui.ctx()) { 1.0 } else { 2.0 };
    380 
    381                 secondary_label(ui, "encrypted privately to");
    382 
    383                 crate::Mention::new(
    384                     note_context.ndb,
    385                     note_context.img_cache,
    386                     note_context.jobs,
    387                     txn,
    388                     note.rumor_receiver_pubkey().expect("expected pubkey"),
    389                 )
    390                 .size(10.0)
    391                 .selectable(true)
    392                 .show(ui);
    393             });
    394         }
    395     }
    396 
    397     fn wide_ui(
    398         &mut self,
    399         ui: &mut egui::Ui,
    400         txn: &Transaction,
    401         note_key: NoteKey,
    402         profile: &Result<ProfileRecord, nostrdb::Error>,
    403     ) -> egui::InnerResponse<NoteUiResponse> {
    404         ui.with_layout(egui::Layout::top_down(egui::Align::LEFT), |ui| {
    405             let mut note_action: Option<NoteAction> = None;
    406             let mut pfp_rect = None;
    407 
    408             if !self.flags.contains(NoteOptions::NotificationPreview) {
    409                 ui.horizontal(|ui| {
    410                     let pfp_resp = self.pfp(note_key, profile, ui);
    411                     pfp_rect = Some(pfp_resp.bounding_rect);
    412                     note_action = pfp_resp
    413                         .into_action(self.note.pubkey())
    414                         .or(note_action.take());
    415 
    416                     let size = ui.available_size();
    417 
    418                     ui.vertical(|ui| {
    419                         ui.add_sized(
    420                             [size.x, self.options().pfp_size() as f32],
    421                             |ui: &mut egui::Ui| {
    422                                 ui.horizontal_centered(|ui| {
    423                                     NoteView::note_header(
    424                                         ui,
    425                                         txn,
    426                                         self.note_context,
    427                                         self.note,
    428                                         profile,
    429                                         self.flags,
    430                                     );
    431                                 })
    432                                 .response
    433                             },
    434                         );
    435 
    436                         let note_reply = self
    437                             .note_context
    438                             .note_cache
    439                             .cached_note_or_insert_mut(note_key, self.note)
    440                             .reply
    441                             .borrow(self.note.tags());
    442 
    443                         if note_reply.reply().is_none() {
    444                             return;
    445                         }
    446 
    447                         ui.horizontal_wrapped(|ui| {
    448                             ui.spacing_mut().item_spacing.x = 0.0;
    449 
    450                             note_action =
    451                                 reply_desc(ui, txn, &note_reply, self.note_context, self.flags)
    452                                     .or(note_action.take());
    453                         });
    454                     });
    455                 });
    456             }
    457 
    458             let mut contents = NoteContents::new(self.note_context, txn, self.note, self.flags);
    459 
    460             ui.add(&mut contents);
    461 
    462             note_action = contents.action.or(note_action);
    463 
    464             if self.options().contains(NoteOptions::ActionBar) {
    465                 note_action = ui
    466                     .horizontal_wrapped(|ui| {
    467                         // NOTE(jb55): without this we get a weird artifact where
    468                         // there subsequent lines start sinking leftward off the screen.
    469                         // question: WTF? question 2: WHY?
    470                         ui.allocate_space(egui::vec2(0.0, 0.0));
    471 
    472                         let counts = self
    473                             .note_context
    474                             .ndb
    475                             .get_note_metadata(txn, self.note.id())
    476                             .ok()
    477                             .and_then(|md| {
    478                                 md.into_iter().find_map(|e| {
    479                                     if let nostrdb::NoteMetadataEntryVariant::Counts(ce) = e {
    480                                         Some(ce)
    481                                     } else {
    482                                         None
    483                                     }
    484                                 })
    485                             });
    486 
    487                         actionbar_ui(
    488                             ui,
    489                             counts,
    490                             get_zapper(
    491                                 self.note_context.accounts,
    492                                 self.note_context.global_wallet,
    493                                 self.note_context.zaps,
    494                             ),
    495                             self.note,
    496                             self.note_context.accounts.selected_account_pubkey(),
    497                             note_key,
    498                             self.note_context.i18n,
    499                         )
    500                     })
    501                     .inner
    502                     .or(note_action);
    503             }
    504 
    505             NoteUiResponse {
    506                 action: note_action,
    507                 pfp_rect,
    508             }
    509         })
    510     }
    511 
    512     fn standard_ui(
    513         &mut self,
    514         ui: &mut egui::Ui,
    515         txn: &Transaction,
    516         note_key: NoteKey,
    517         profile: &Result<ProfileRecord, nostrdb::Error>,
    518     ) -> egui::InnerResponse<NoteUiResponse> {
    519         // main design
    520         ui.with_layout(egui::Layout::left_to_right(egui::Align::TOP), |ui| {
    521             let (mut note_action, pfp_rect) =
    522                 if self.flags.contains(NoteOptions::NotificationPreview) {
    523                     // do not render pfp
    524                     (None, None)
    525                 } else {
    526                     let pfp_resp = self.pfp(note_key, profile, ui);
    527                     let pfp_rect = pfp_resp.bounding_rect;
    528                     (pfp_resp.into_action(self.note.pubkey()), Some(pfp_rect))
    529                 };
    530 
    531             ui.with_layout(egui::Layout::top_down(egui::Align::LEFT), |ui| {
    532                 if !self.flags.contains(NoteOptions::NotificationPreview) {
    533                     NoteView::note_header(
    534                         ui,
    535                         txn,
    536                         self.note_context,
    537                         self.note,
    538                         profile,
    539                         self.flags,
    540                     );
    541 
    542                     ui.horizontal_wrapped(|ui| {
    543                         ui.spacing_mut().item_spacing.x = 1.0;
    544 
    545                         let note_reply = self
    546                             .note_context
    547                             .note_cache
    548                             .cached_note_or_insert_mut(note_key, self.note)
    549                             .reply
    550                             .borrow(self.note.tags());
    551 
    552                         if note_reply.reply().is_none() {
    553                             return;
    554                         }
    555 
    556                         note_action =
    557                             reply_desc(ui, txn, &note_reply, self.note_context, self.flags)
    558                                 .or(note_action.take());
    559                     });
    560                 }
    561 
    562                 let mut contents = NoteContents::new(self.note_context, txn, self.note, self.flags);
    563                 ui.add(&mut contents);
    564 
    565                 note_action = contents.action.or(note_action);
    566 
    567                 if self.options().contains(NoteOptions::ActionBar) {
    568                     let counts = self
    569                         .note_context
    570                         .ndb
    571                         .get_note_metadata(txn, self.note.id())
    572                         .ok()
    573                         .and_then(|md| {
    574                             md.into_iter().find_map(|e| {
    575                                 if let nostrdb::NoteMetadataEntryVariant::Counts(ce) = e {
    576                                     Some(ce)
    577                                 } else {
    578                                     None
    579                                 }
    580                             })
    581                         });
    582 
    583                     note_action = ui
    584                         .horizontal_wrapped(|ui| {
    585                             actionbar_ui(
    586                                 ui,
    587                                 counts,
    588                                 get_zapper(
    589                                     self.note_context.accounts,
    590                                     self.note_context.global_wallet,
    591                                     self.note_context.zaps,
    592                                 ),
    593                                 self.note,
    594                                 self.note_context.accounts.selected_account_pubkey(),
    595                                 note_key,
    596                                 self.note_context.i18n,
    597                             )
    598                         })
    599                         .inner
    600                         .or(note_action);
    601                 }
    602 
    603                 NoteUiResponse {
    604                     action: note_action,
    605                     pfp_rect,
    606                 }
    607             })
    608             .inner
    609         })
    610     }
    611 
    612     #[profiling::function]
    613     fn show_standard(&mut self, ui: &mut egui::Ui) -> NoteResponse {
    614         let note_key = self.note.key().expect("todo: support non-db notes");
    615         let txn = self.note.txn().expect("todo: support non-db notes");
    616 
    617         let profile = self
    618             .note_context
    619             .ndb
    620             .get_profile_by_pubkey(txn, self.note.pubkey());
    621 
    622         let hitbox_id = note_hitbox_id(note_key, self.options(), self.parent);
    623         let maybe_hitbox = maybe_note_hitbox(ui, hitbox_id);
    624 
    625         // wide design
    626         let response = if self.options().contains(NoteOptions::Wide) {
    627             self.wide_ui(ui, txn, note_key, &profile)
    628         } else {
    629             self.standard_ui(ui, txn, note_key, &profile)
    630         };
    631 
    632         let note_ui_resp = response.inner;
    633         let mut note_action = note_ui_resp.action;
    634 
    635         if self.options().contains(NoteOptions::OptionsButton) {
    636             let context_pos = {
    637                 let size = NoteContextButton::max_width();
    638                 let top_right = response.response.rect.right_top();
    639                 let min = Pos2::new(top_right.x - size, top_right.y);
    640                 Rect::from_min_size(min, egui::vec2(size, size))
    641             };
    642 
    643             let resp = ui.add(NoteContextButton::new(note_key).place_at(context_pos));
    644             let can_sign = self
    645                 .note_context
    646                 .accounts
    647                 .get_selected_account()
    648                 .key
    649                 .secret_key
    650                 .is_some();
    651             let is_muted = self
    652                 .note_context
    653                 .accounts
    654                 .mute()
    655                 .is_pk_muted(self.note.pubkey());
    656             let note_id = NoteId::new(*self.note.id());
    657             if let Some(action) = NoteContextButton::menu(
    658                 ui,
    659                 self.note_context.i18n,
    660                 resp.clone(),
    661                 note_id,
    662                 can_sign,
    663                 is_muted,
    664             ) {
    665                 note_action = Some(NoteAction::Context(ContextSelection { note_key, action }));
    666             }
    667         }
    668 
    669         note_action = note_hitbox_clicked(ui, hitbox_id, &response.response.rect, maybe_hitbox)
    670             .then_some(NoteAction::note(NoteId::new(*self.note.id())))
    671             .or(note_action);
    672 
    673         let mut resp = NoteResponse::new(response.response).with_action(note_action);
    674         if let Some(pfp_rect) = note_ui_resp.pfp_rect {
    675             resp = resp.with_pfp(pfp_rect);
    676         }
    677 
    678         resp
    679     }
    680 }
    681 
    682 fn get_zapper<'a>(
    683     accounts: &'a Accounts,
    684     global_wallet: &'a GlobalWallet,
    685     zaps: &'a Zaps,
    686 ) -> Option<Zapper<'a>> {
    687     let has_wallet = get_current_wallet(accounts, global_wallet).is_some();
    688     let cur_acc = accounts.get_selected_account();
    689 
    690     has_wallet.then_some(Zapper {
    691         zaps,
    692         cur_acc: cur_acc.keypair(),
    693     })
    694 }
    695 
    696 pub fn get_reposted_note<'a>(ndb: &Ndb, txn: &'a Transaction, note: &Note) -> Option<Note<'a>> {
    697     if note.kind() != 6 {
    698         return None;
    699     }
    700 
    701     let new_note_id: &[u8; 32] = {
    702         let mut res = None;
    703         for tag in note.tags().iter() {
    704             if tag.count() == 0 {
    705                 continue;
    706             }
    707 
    708             if let Some("e") = tag.get(0).and_then(|t| t.variant().str()) {
    709                 if let Some(note_id) = tag.get(1).and_then(|f| f.variant().id()) {
    710                     res = Some(note_id);
    711                     break;
    712                 }
    713             }
    714         }
    715         res?
    716     };
    717 
    718     let note = ndb.get_note_by_id(txn, new_note_id).ok();
    719     note.filter(|note| note.kind() == 1)
    720 }
    721 
    722 struct NoteUiResponse {
    723     action: Option<NoteAction>,
    724     pfp_rect: Option<egui::Rect>,
    725 }
    726 
    727 struct PfpResponse {
    728     action: Option<MediaAction>,
    729     response: egui::Response,
    730     bounding_rect: egui::Rect,
    731 }
    732 
    733 impl PfpResponse {
    734     fn into_action(self, note_pk: &[u8; 32]) -> Option<NoteAction> {
    735         if self.response.clicked() {
    736             return Some(NoteAction::Profile(Pubkey::new(*note_pk)));
    737         }
    738 
    739         self.action.map(NoteAction::Media)
    740     }
    741 }
    742 
    743 fn show_actual_pfp(
    744     ui: &mut egui::Ui,
    745     images: &mut Images,
    746     jobs: &MediaJobSender,
    747     pic: &str,
    748     pfp_size: i8,
    749     note_key: NoteKey,
    750     profile: &Result<nostrdb::ProfileRecord<'_>, nostrdb::Error>,
    751 ) -> PfpResponse {
    752     let anim_speed = 0.05;
    753     let profile_key = profile.as_ref().unwrap().record().note_key();
    754     let note_key = note_key.as_u64();
    755 
    756     let (rect, size, resp) = crate::anim::hover_expand(
    757         ui,
    758         egui::Id::new((profile_key, note_key)),
    759         pfp_size as f32,
    760         NoteView::expand_size() as f32,
    761         anim_speed,
    762     );
    763 
    764     let resp = resp.on_hover_cursor(egui::CursorIcon::PointingHand);
    765 
    766     let mut pfp = ProfilePic::new(images, jobs, pic).size(size);
    767     let pfp_resp = ui.put(rect, &mut pfp);
    768     let action = pfp.action;
    769 
    770     pfp_resp.on_hover_ui_at_pointer(|ui| {
    771         ui.set_max_width(300.0);
    772         ui.add(ProfilePreview::new(profile.as_ref().unwrap(), images, jobs));
    773     });
    774 
    775     PfpResponse {
    776         response: resp,
    777         action,
    778         bounding_rect: rect.shrink((rect.width() - size) / 2.0),
    779     }
    780 }
    781 
    782 fn show_fallback_pfp(
    783     ui: &mut egui::Ui,
    784     images: &mut Images,
    785     jobs: &MediaJobSender,
    786     pfp_size: i8,
    787 ) -> PfpResponse {
    788     let sense = Sense::click();
    789     // This has to match the expand size from the above case to
    790     // prevent bounciness
    791     let size = (pfp_size + NoteView::expand_size()) as f32;
    792     let (rect, _response) = ui.allocate_exact_size(egui::vec2(size, size), sense);
    793 
    794     let mut pfp =
    795         ProfilePic::new(images, jobs, notedeck::profile::no_pfp_url()).size(pfp_size as f32);
    796     let response = ui.put(rect, &mut pfp).interact(sense);
    797 
    798     PfpResponse {
    799         action: pfp.action,
    800         response,
    801         bounding_rect: rect.shrink((rect.width() - size) / 2.0),
    802     }
    803 }
    804 
    805 fn note_hitbox_id(
    806     note_key: NoteKey,
    807     note_options: NoteOptions,
    808     parent: Option<NoteKey>,
    809 ) -> egui::Id {
    810     Id::new(("note_size", note_key, note_options, parent))
    811 }
    812 
    813 fn maybe_note_hitbox(ui: &mut egui::Ui, hitbox_id: egui::Id) -> Option<Response> {
    814     ui.ctx()
    815         .data_mut(|d| d.get_temp(hitbox_id))
    816         .map(|note_size: Vec2| {
    817             // The hitbox should extend the entire width of the
    818             // container.  The hitbox height was cached last layout.
    819             let container_rect = ui.max_rect();
    820             let rect = Rect {
    821                 min: pos2(container_rect.min.x, container_rect.min.y),
    822                 max: pos2(container_rect.max.x, container_rect.min.y + note_size.y),
    823             };
    824 
    825             let response = ui.interact(rect, ui.id().with(hitbox_id), egui::Sense::click());
    826 
    827             response
    828                 .widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Other, true, "hitbox"));
    829 
    830             response
    831         })
    832 }
    833 
    834 fn note_hitbox_clicked(
    835     ui: &mut egui::Ui,
    836     hitbox_id: egui::Id,
    837     note_rect: &Rect,
    838     maybe_hitbox: Option<Response>,
    839 ) -> bool {
    840     // Stash the dimensions of the note content so we can render the
    841     // hitbox in the next frame
    842     ui.ctx().data_mut(|d| {
    843         d.insert_temp(hitbox_id, note_rect.size());
    844     });
    845 
    846     // If there was an hitbox and it was clicked open the thread
    847     match maybe_hitbox {
    848         Some(hitbox) => hitbox.clicked(),
    849         _ => false,
    850     }
    851 }
    852 
    853 struct Zapper<'a> {
    854     zaps: &'a Zaps,
    855     cur_acc: KeypairUnowned<'a>,
    856 }
    857 
    858 fn zap_actionbar_button(
    859     ui: &mut egui::Ui,
    860     note_id: &[u8; 32],
    861     note_pubkey: &[u8; 32],
    862     zapper: Option<Zapper<'_>>,
    863     i18n: &mut Localization,
    864 ) -> Option<NoteAction> {
    865     let mut action: Option<NoteAction> = None;
    866     let Zapper { zaps, cur_acc } = zapper?;
    867 
    868     let zap_target = ZapTarget::Note(NoteZapTarget {
    869         note_id,
    870         zap_recipient: note_pubkey,
    871     });
    872 
    873     let zap_state = zaps.any_zap_state_for(cur_acc.pubkey.bytes(), zap_target);
    874 
    875     let target = NoteZapTargetOwned {
    876         note_id: NoteId::new(*note_id),
    877         zap_recipient: Pubkey::new(*note_pubkey),
    878     };
    879 
    880     cur_acc.secret_key.as_ref()?;
    881 
    882     match zap_state {
    883         Ok(any_zap_state) => {
    884             let zap_resp = ui.add(zap_button(i18n, any_zap_state, note_id));
    885 
    886             if zap_resp.secondary_clicked() {
    887                 action = Some(NoteAction::Zap(ZapAction::CustomizeAmount(target.clone())));
    888             }
    889 
    890             if zap_resp.clicked() {
    891                 action = Some(NoteAction::Zap(ZapAction::Send(ZapTargetAmount {
    892                     target,
    893                     specified_msats: None,
    894                 })));
    895             }
    896 
    897             zap_resp
    898         }
    899         Err(err) => {
    900             let (rect, _) = ui.allocate_at_least(egui::vec2(10.0, 10.0), egui::Sense::click());
    901             let x_button = ui.add(x_button(rect)).on_hover_text(err.to_string());
    902 
    903             if x_button.clicked() {
    904                 action = Some(NoteAction::Zap(ZapAction::ClearError(target.clone())));
    905             }
    906             x_button
    907         }
    908     }
    909     .on_hover_cursor(egui::CursorIcon::PointingHand);
    910 
    911     action
    912 }
    913 
    914 fn is_root_note(note: &Note) -> bool {
    915     for tag in note.tags() {
    916         if tag.count() < 2 {
    917             continue;
    918         }
    919 
    920         // any reference to an e tag is a non-root note
    921         if tag.get_str(0) == Some("e") {
    922             return false;
    923         }
    924     }
    925 
    926     true
    927 }
    928 
    929 #[profiling::function]
    930 fn actionbar_ui(
    931     ui: &mut egui::Ui,
    932     counts: Option<nostrdb::CountsEntry<'_>>,
    933     zapper: Option<Zapper<'_>>,
    934     note: &Note,
    935     current_user_pubkey: &Pubkey,
    936     note_key: NoteKey,
    937     i18n: &mut Localization,
    938 ) -> Option<NoteAction> {
    939     let mut action = None;
    940     let spacing = 24.0;
    941 
    942     ui.spacing_mut().item_spacing.x = 2.0;
    943     ui.set_min_height(26.0);
    944 
    945     let reply_resp =
    946         reply_button(ui, i18n, note_key).on_hover_cursor(egui::CursorIcon::PointingHand);
    947 
    948     if let Some(c) = &counts {
    949         let count = if is_root_note(note) {
    950             c.thread_replies()
    951         } else {
    952             c.direct_replies() as u32
    953         };
    954 
    955         if count > 0 {
    956             //ui.weak(format!("{}", count));
    957             crate::anim::rolling_number(ui, egui::Id::new((note_key, "replies")), count);
    958         }
    959     }
    960 
    961     ui.add_space(spacing);
    962 
    963     let filled = ui
    964         .ctx()
    965         .data(|d| d.get_temp(reaction_sent_id(current_user_pubkey, note.id())))
    966         == Some(true);
    967 
    968     let like_resp =
    969         like_button(ui, i18n, note_key, filled).on_hover_cursor(egui::CursorIcon::PointingHand);
    970 
    971     if let Some(c) = &counts {
    972         let count = c.reactions();
    973         if count > 0 {
    974             crate::anim::rolling_number(ui, egui::Id::new((note_key, "likes")), count);
    975         }
    976     }
    977 
    978     ui.add_space(spacing);
    979 
    980     let quote_resp =
    981         quote_repost_button(ui, i18n, note_key).on_hover_cursor(egui::CursorIcon::PointingHand);
    982 
    983     if let Some(c) = &counts {
    984         let count = c.quotes() + c.reposts();
    985         if count > 0 {
    986             crate::anim::rolling_number(ui, egui::Id::new((note_key, "quotes")), count as u32);
    987         }
    988     }
    989 
    990     ui.add_space(spacing);
    991 
    992     if reply_resp.clicked() {
    993         action = Some(NoteAction::Reply(NoteId::new(*note.id())));
    994     }
    995 
    996     if like_resp.clicked() {
    997         action = Some(NoteAction::React(ReactAction::new(
    998             NoteId::new(*note.id()),
    999             "🤙🏻",
   1000         )));
   1001     }
   1002 
   1003     if quote_resp.clicked() {
   1004         action = Some(NoteAction::Repost(NoteId::new(*note.id())));
   1005     }
   1006 
   1007     action = zap_actionbar_button(ui, note.id(), note.pubkey(), zapper, i18n).or(action);
   1008 
   1009     action
   1010 }
   1011 
   1012 #[profiling::function]
   1013 fn render_notetime(
   1014     ui: &mut egui::Ui,
   1015     i18n: &mut Localization,
   1016     created_at: u64,
   1017     before: bool,
   1018 ) -> Response {
   1019     if before {
   1020         secondary_label(
   1021             ui,
   1022             format!(" ⋅ {}", notedeck::time_ago_since(i18n, created_at)),
   1023         )
   1024     } else {
   1025         secondary_label(
   1026             ui,
   1027             format!("{} ⋅ ", notedeck::time_ago_since(i18n, created_at)),
   1028         )
   1029     }
   1030 }
   1031 
   1032 fn reply_button(ui: &mut egui::Ui, i18n: &mut Localization, note_key: NoteKey) -> egui::Response {
   1033     let img = if ui.style().visuals.dark_mode {
   1034         app_images::reply_dark_image()
   1035     } else {
   1036         app_images::reply_light_image()
   1037     };
   1038 
   1039     let (rect, size, resp) =
   1040         crate::anim::hover_expand_small(ui, ui.id().with(("reply_anim", note_key)));
   1041 
   1042     // align rect to note contents
   1043     let expand_size = 5.0; // from hover_expand_small
   1044     let rect = rect.translate(egui::vec2(-(expand_size / 2.0), 0.0));
   1045 
   1046     let put_resp = ui.put(rect, img.max_width(size)).on_hover_text(tr!(
   1047         i18n,
   1048         "Reply to this note",
   1049         "Hover text for reply button"
   1050     ));
   1051 
   1052     resp.union(put_resp)
   1053 }
   1054 
   1055 fn like_button(
   1056     ui: &mut egui::Ui,
   1057     i18n: &mut Localization,
   1058     note_key: NoteKey,
   1059     filled: bool,
   1060 ) -> egui::Response {
   1061     let img = {
   1062         let img = if filled {
   1063             app_images::like_image_filled()
   1064         } else {
   1065             app_images::like_image()
   1066         };
   1067 
   1068         img.tint(ui.visuals().text_color())
   1069     };
   1070 
   1071     let (rect, size, resp) =
   1072         crate::anim::hover_expand_small(ui, ui.id().with(("like_anim", note_key)));
   1073 
   1074     // align rect to note contents
   1075     let expand_size = 5.0; // from hover_expand_small
   1076     let rect = rect.translate(egui::vec2(-(expand_size / 2.0), 0.0));
   1077 
   1078     let put_resp = ui.put(rect, img.max_width(size)).on_hover_text(tr!(
   1079         i18n,
   1080         "Like this note",
   1081         "Hover text for like button"
   1082     ));
   1083 
   1084     resp.union(put_resp)
   1085 }
   1086 
   1087 fn repost_icon(dark_mode: bool) -> egui::Image<'static> {
   1088     if dark_mode {
   1089         app_images::repost_dark_image()
   1090     } else {
   1091         app_images::repost_light_image()
   1092     }
   1093 }
   1094 
   1095 fn quote_repost_button(
   1096     ui: &mut egui::Ui,
   1097     i18n: &mut Localization,
   1098     note_key: NoteKey,
   1099 ) -> egui::Response {
   1100     let size = crate::anim::hover_small_size() + 4.0;
   1101     let expand_size = 5.0;
   1102     let anim_speed = 0.05;
   1103     let id = ui.id().with(("repost_anim", note_key));
   1104 
   1105     let (rect, size, resp) = crate::anim::hover_expand(ui, id, size, expand_size, anim_speed);
   1106 
   1107     let rect = rect.translate(egui::vec2(-(expand_size / 2.0), -1.0));
   1108 
   1109     let put_resp = ui
   1110         .put(rect, repost_icon(ui.visuals().dark_mode).max_width(size))
   1111         .on_hover_text(tr!(
   1112             i18n,
   1113             "Repost this note",
   1114             "Hover text for repost button"
   1115         ));
   1116 
   1117     resp.union(put_resp)
   1118 }
   1119 
   1120 fn zap_button<'a>(
   1121     i18n: &'a mut Localization,
   1122     state: AnyZapState,
   1123     noteid: &'a [u8; 32],
   1124 ) -> impl egui::Widget + use<'a> {
   1125     move |ui: &mut egui::Ui| -> egui::Response {
   1126         let (rect, size, resp) = crate::anim::hover_expand_small(ui, ui.id().with("zap"));
   1127 
   1128         let mut img = app_images::zap_dark_image().max_width(size);
   1129         let id = ui.id().with(("pulse", noteid));
   1130         let ctx = ui.ctx().clone();
   1131 
   1132         match state {
   1133             AnyZapState::None => {
   1134                 if !ui.visuals().dark_mode {
   1135                     img = app_images::zap_light_image();
   1136                 }
   1137             }
   1138             AnyZapState::Pending => {
   1139                 let alpha_min = if ui.visuals().dark_mode { 50 } else { 180 };
   1140                 let cur_alpha = PulseAlpha::new(&ctx, id, alpha_min, 255)
   1141                     .with_speed(0.35)
   1142                     .animate();
   1143 
   1144                 let cur_color = egui::Color32::from_rgba_unmultiplied(0xFF, 0xB7, 0x57, cur_alpha);
   1145                 img = img.tint(cur_color);
   1146             }
   1147             AnyZapState::LocalOnly => {
   1148                 img = img.tint(egui::Color32::from_rgb(0xFF, 0xB7, 0x57));
   1149             }
   1150             AnyZapState::Confirmed => {}
   1151         }
   1152 
   1153         // align rect to note contents
   1154         let expand_size = 5.0; // from hover_expand_small
   1155         let rect = rect.translate(egui::vec2(-(expand_size / 2.0), 0.0));
   1156 
   1157         let put_resp = ui.put(rect, img).on_hover_text(tr!(
   1158             i18n,
   1159             "Zap this note",
   1160             "Hover text for zap button"
   1161         ));
   1162 
   1163         resp.union(put_resp)
   1164     }
   1165 }