notedeck

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

mod.rs (35297B)


      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             if let Some(action) = NoteContextButton::menu(ui, self.note_context.i18n, resp.clone())
    645             {
    646                 note_action = Some(NoteAction::Context(ContextSelection { note_key, action }));
    647             }
    648         }
    649 
    650         note_action = note_hitbox_clicked(ui, hitbox_id, &response.response.rect, maybe_hitbox)
    651             .then_some(NoteAction::note(NoteId::new(*self.note.id())))
    652             .or(note_action);
    653 
    654         let mut resp = NoteResponse::new(response.response).with_action(note_action);
    655         if let Some(pfp_rect) = note_ui_resp.pfp_rect {
    656             resp = resp.with_pfp(pfp_rect);
    657         }
    658 
    659         resp
    660     }
    661 }
    662 
    663 fn get_zapper<'a>(
    664     accounts: &'a Accounts,
    665     global_wallet: &'a GlobalWallet,
    666     zaps: &'a Zaps,
    667 ) -> Option<Zapper<'a>> {
    668     let has_wallet = get_current_wallet(accounts, global_wallet).is_some();
    669     let cur_acc = accounts.get_selected_account();
    670 
    671     has_wallet.then_some(Zapper {
    672         zaps,
    673         cur_acc: cur_acc.keypair(),
    674     })
    675 }
    676 
    677 pub fn get_reposted_note<'a>(ndb: &Ndb, txn: &'a Transaction, note: &Note) -> Option<Note<'a>> {
    678     if note.kind() != 6 {
    679         return None;
    680     }
    681 
    682     let new_note_id: &[u8; 32] = {
    683         let mut res = None;
    684         for tag in note.tags().iter() {
    685             if tag.count() == 0 {
    686                 continue;
    687             }
    688 
    689             if let Some("e") = tag.get(0).and_then(|t| t.variant().str()) {
    690                 if let Some(note_id) = tag.get(1).and_then(|f| f.variant().id()) {
    691                     res = Some(note_id);
    692                     break;
    693                 }
    694             }
    695         }
    696         res?
    697     };
    698 
    699     let note = ndb.get_note_by_id(txn, new_note_id).ok();
    700     note.filter(|note| note.kind() == 1)
    701 }
    702 
    703 struct NoteUiResponse {
    704     action: Option<NoteAction>,
    705     pfp_rect: Option<egui::Rect>,
    706 }
    707 
    708 struct PfpResponse {
    709     action: Option<MediaAction>,
    710     response: egui::Response,
    711     bounding_rect: egui::Rect,
    712 }
    713 
    714 impl PfpResponse {
    715     fn into_action(self, note_pk: &[u8; 32]) -> Option<NoteAction> {
    716         if self.response.clicked() {
    717             return Some(NoteAction::Profile(Pubkey::new(*note_pk)));
    718         }
    719 
    720         self.action.map(NoteAction::Media)
    721     }
    722 }
    723 
    724 fn show_actual_pfp(
    725     ui: &mut egui::Ui,
    726     images: &mut Images,
    727     jobs: &MediaJobSender,
    728     pic: &str,
    729     pfp_size: i8,
    730     note_key: NoteKey,
    731     profile: &Result<nostrdb::ProfileRecord<'_>, nostrdb::Error>,
    732 ) -> PfpResponse {
    733     let anim_speed = 0.05;
    734     let profile_key = profile.as_ref().unwrap().record().note_key();
    735     let note_key = note_key.as_u64();
    736 
    737     let (rect, size, resp) = crate::anim::hover_expand(
    738         ui,
    739         egui::Id::new((profile_key, note_key)),
    740         pfp_size as f32,
    741         NoteView::expand_size() as f32,
    742         anim_speed,
    743     );
    744 
    745     let resp = resp.on_hover_cursor(egui::CursorIcon::PointingHand);
    746 
    747     let mut pfp = ProfilePic::new(images, jobs, pic).size(size);
    748     let pfp_resp = ui.put(rect, &mut pfp);
    749     let action = pfp.action;
    750 
    751     pfp_resp.on_hover_ui_at_pointer(|ui| {
    752         ui.set_max_width(300.0);
    753         ui.add(ProfilePreview::new(profile.as_ref().unwrap(), images, jobs));
    754     });
    755 
    756     PfpResponse {
    757         response: resp,
    758         action,
    759         bounding_rect: rect.shrink((rect.width() - size) / 2.0),
    760     }
    761 }
    762 
    763 fn show_fallback_pfp(
    764     ui: &mut egui::Ui,
    765     images: &mut Images,
    766     jobs: &MediaJobSender,
    767     pfp_size: i8,
    768 ) -> PfpResponse {
    769     let sense = Sense::click();
    770     // This has to match the expand size from the above case to
    771     // prevent bounciness
    772     let size = (pfp_size + NoteView::expand_size()) as f32;
    773     let (rect, _response) = ui.allocate_exact_size(egui::vec2(size, size), sense);
    774 
    775     let mut pfp =
    776         ProfilePic::new(images, jobs, notedeck::profile::no_pfp_url()).size(pfp_size as f32);
    777     let response = ui.put(rect, &mut pfp).interact(sense);
    778 
    779     PfpResponse {
    780         action: pfp.action,
    781         response,
    782         bounding_rect: rect.shrink((rect.width() - size) / 2.0),
    783     }
    784 }
    785 
    786 fn note_hitbox_id(
    787     note_key: NoteKey,
    788     note_options: NoteOptions,
    789     parent: Option<NoteKey>,
    790 ) -> egui::Id {
    791     Id::new(("note_size", note_key, note_options, parent))
    792 }
    793 
    794 fn maybe_note_hitbox(ui: &mut egui::Ui, hitbox_id: egui::Id) -> Option<Response> {
    795     ui.ctx()
    796         .data_mut(|d| d.get_temp(hitbox_id))
    797         .map(|note_size: Vec2| {
    798             // The hitbox should extend the entire width of the
    799             // container.  The hitbox height was cached last layout.
    800             let container_rect = ui.max_rect();
    801             let rect = Rect {
    802                 min: pos2(container_rect.min.x, container_rect.min.y),
    803                 max: pos2(container_rect.max.x, container_rect.min.y + note_size.y),
    804             };
    805 
    806             let response = ui.interact(rect, ui.id().with(hitbox_id), egui::Sense::click());
    807 
    808             response
    809                 .widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Other, true, "hitbox"));
    810 
    811             response
    812         })
    813 }
    814 
    815 fn note_hitbox_clicked(
    816     ui: &mut egui::Ui,
    817     hitbox_id: egui::Id,
    818     note_rect: &Rect,
    819     maybe_hitbox: Option<Response>,
    820 ) -> bool {
    821     // Stash the dimensions of the note content so we can render the
    822     // hitbox in the next frame
    823     ui.ctx().data_mut(|d| {
    824         d.insert_temp(hitbox_id, note_rect.size());
    825     });
    826 
    827     // If there was an hitbox and it was clicked open the thread
    828     match maybe_hitbox {
    829         Some(hitbox) => hitbox.clicked(),
    830         _ => false,
    831     }
    832 }
    833 
    834 struct Zapper<'a> {
    835     zaps: &'a Zaps,
    836     cur_acc: KeypairUnowned<'a>,
    837 }
    838 
    839 fn zap_actionbar_button(
    840     ui: &mut egui::Ui,
    841     note_id: &[u8; 32],
    842     note_pubkey: &[u8; 32],
    843     zapper: Option<Zapper<'_>>,
    844     i18n: &mut Localization,
    845 ) -> Option<NoteAction> {
    846     let mut action: Option<NoteAction> = None;
    847     let Zapper { zaps, cur_acc } = zapper?;
    848 
    849     let zap_target = ZapTarget::Note(NoteZapTarget {
    850         note_id,
    851         zap_recipient: note_pubkey,
    852     });
    853 
    854     let zap_state = zaps.any_zap_state_for(cur_acc.pubkey.bytes(), zap_target);
    855 
    856     let target = NoteZapTargetOwned {
    857         note_id: NoteId::new(*note_id),
    858         zap_recipient: Pubkey::new(*note_pubkey),
    859     };
    860 
    861     cur_acc.secret_key.as_ref()?;
    862 
    863     match zap_state {
    864         Ok(any_zap_state) => {
    865             let zap_resp = ui.add(zap_button(i18n, any_zap_state, note_id));
    866 
    867             if zap_resp.secondary_clicked() {
    868                 action = Some(NoteAction::Zap(ZapAction::CustomizeAmount(target.clone())));
    869             }
    870 
    871             if zap_resp.clicked() {
    872                 action = Some(NoteAction::Zap(ZapAction::Send(ZapTargetAmount {
    873                     target,
    874                     specified_msats: None,
    875                 })));
    876             }
    877 
    878             zap_resp
    879         }
    880         Err(err) => {
    881             let (rect, _) = ui.allocate_at_least(egui::vec2(10.0, 10.0), egui::Sense::click());
    882             let x_button = ui.add(x_button(rect)).on_hover_text(err.to_string());
    883 
    884             if x_button.clicked() {
    885                 action = Some(NoteAction::Zap(ZapAction::ClearError(target.clone())));
    886             }
    887             x_button
    888         }
    889     }
    890     .on_hover_cursor(egui::CursorIcon::PointingHand);
    891 
    892     action
    893 }
    894 
    895 fn is_root_note(note: &Note) -> bool {
    896     for tag in note.tags() {
    897         if tag.count() < 2 {
    898             continue;
    899         }
    900 
    901         // any reference to an e tag is a non-root note
    902         if tag.get_str(0) == Some("e") {
    903             return false;
    904         }
    905     }
    906 
    907     true
    908 }
    909 
    910 #[profiling::function]
    911 fn actionbar_ui(
    912     ui: &mut egui::Ui,
    913     counts: Option<nostrdb::CountsEntry<'_>>,
    914     zapper: Option<Zapper<'_>>,
    915     note: &Note,
    916     current_user_pubkey: &Pubkey,
    917     note_key: NoteKey,
    918     i18n: &mut Localization,
    919 ) -> Option<NoteAction> {
    920     let mut action = None;
    921     let spacing = 24.0;
    922 
    923     ui.spacing_mut().item_spacing.x = 2.0;
    924     ui.set_min_height(26.0);
    925 
    926     let reply_resp =
    927         reply_button(ui, i18n, note_key).on_hover_cursor(egui::CursorIcon::PointingHand);
    928 
    929     if let Some(c) = &counts {
    930         let count = if is_root_note(note) {
    931             c.thread_replies()
    932         } else {
    933             c.direct_replies() as u32
    934         };
    935 
    936         if count > 0 {
    937             //ui.weak(format!("{}", count));
    938             crate::anim::rolling_number(ui, egui::Id::new((note_key, "replies")), count);
    939         }
    940     }
    941 
    942     ui.add_space(spacing);
    943 
    944     let filled = ui
    945         .ctx()
    946         .data(|d| d.get_temp(reaction_sent_id(current_user_pubkey, note.id())))
    947         == Some(true);
    948 
    949     let like_resp =
    950         like_button(ui, i18n, note_key, filled).on_hover_cursor(egui::CursorIcon::PointingHand);
    951 
    952     if let Some(c) = &counts {
    953         let count = c.reactions();
    954         if count > 0 {
    955             crate::anim::rolling_number(ui, egui::Id::new((note_key, "likes")), count);
    956         }
    957     }
    958 
    959     ui.add_space(spacing);
    960 
    961     let quote_resp =
    962         quote_repost_button(ui, i18n, note_key).on_hover_cursor(egui::CursorIcon::PointingHand);
    963 
    964     if let Some(c) = &counts {
    965         let count = c.quotes() + c.reposts();
    966         if count > 0 {
    967             crate::anim::rolling_number(ui, egui::Id::new((note_key, "quotes")), count as u32);
    968         }
    969     }
    970 
    971     ui.add_space(spacing);
    972 
    973     if reply_resp.clicked() {
    974         action = Some(NoteAction::Reply(NoteId::new(*note.id())));
    975     }
    976 
    977     if like_resp.clicked() {
    978         action = Some(NoteAction::React(ReactAction::new(
    979             NoteId::new(*note.id()),
    980             "🤙🏻",
    981         )));
    982     }
    983 
    984     if quote_resp.clicked() {
    985         action = Some(NoteAction::Repost(NoteId::new(*note.id())));
    986     }
    987 
    988     action = zap_actionbar_button(ui, note.id(), note.pubkey(), zapper, i18n).or(action);
    989 
    990     action
    991 }
    992 
    993 #[profiling::function]
    994 fn render_notetime(
    995     ui: &mut egui::Ui,
    996     i18n: &mut Localization,
    997     created_at: u64,
    998     before: bool,
    999 ) -> Response {
   1000     if before {
   1001         secondary_label(
   1002             ui,
   1003             format!(" ⋅ {}", notedeck::time_ago_since(i18n, created_at)),
   1004         )
   1005     } else {
   1006         secondary_label(
   1007             ui,
   1008             format!("{} ⋅ ", notedeck::time_ago_since(i18n, created_at)),
   1009         )
   1010     }
   1011 }
   1012 
   1013 fn reply_button(ui: &mut egui::Ui, i18n: &mut Localization, note_key: NoteKey) -> egui::Response {
   1014     let img = if ui.style().visuals.dark_mode {
   1015         app_images::reply_dark_image()
   1016     } else {
   1017         app_images::reply_light_image()
   1018     };
   1019 
   1020     let (rect, size, resp) =
   1021         crate::anim::hover_expand_small(ui, ui.id().with(("reply_anim", note_key)));
   1022 
   1023     // align rect to note contents
   1024     let expand_size = 5.0; // from hover_expand_small
   1025     let rect = rect.translate(egui::vec2(-(expand_size / 2.0), 0.0));
   1026 
   1027     let put_resp = ui.put(rect, img.max_width(size)).on_hover_text(tr!(
   1028         i18n,
   1029         "Reply to this note",
   1030         "Hover text for reply button"
   1031     ));
   1032 
   1033     resp.union(put_resp)
   1034 }
   1035 
   1036 fn like_button(
   1037     ui: &mut egui::Ui,
   1038     i18n: &mut Localization,
   1039     note_key: NoteKey,
   1040     filled: bool,
   1041 ) -> egui::Response {
   1042     let img = {
   1043         let img = if filled {
   1044             app_images::like_image_filled()
   1045         } else {
   1046             app_images::like_image()
   1047         };
   1048 
   1049         if ui.visuals().dark_mode {
   1050             img.tint(ui.visuals().text_color())
   1051         } else {
   1052             img
   1053         }
   1054     };
   1055 
   1056     let (rect, size, resp) =
   1057         crate::anim::hover_expand_small(ui, ui.id().with(("like_anim", note_key)));
   1058 
   1059     // align rect to note contents
   1060     let expand_size = 5.0; // from hover_expand_small
   1061     let rect = rect.translate(egui::vec2(-(expand_size / 2.0), 0.0));
   1062 
   1063     let put_resp = ui.put(rect, img.max_width(size)).on_hover_text(tr!(
   1064         i18n,
   1065         "Like this note",
   1066         "Hover text for like button"
   1067     ));
   1068 
   1069     resp.union(put_resp)
   1070 }
   1071 
   1072 fn repost_icon(dark_mode: bool) -> egui::Image<'static> {
   1073     if dark_mode {
   1074         app_images::repost_dark_image()
   1075     } else {
   1076         app_images::repost_light_image()
   1077     }
   1078 }
   1079 
   1080 fn quote_repost_button(
   1081     ui: &mut egui::Ui,
   1082     i18n: &mut Localization,
   1083     note_key: NoteKey,
   1084 ) -> egui::Response {
   1085     let size = crate::anim::hover_small_size() + 4.0;
   1086     let expand_size = 5.0;
   1087     let anim_speed = 0.05;
   1088     let id = ui.id().with(("repost_anim", note_key));
   1089 
   1090     let (rect, size, resp) = crate::anim::hover_expand(ui, id, size, expand_size, anim_speed);
   1091 
   1092     let rect = rect.translate(egui::vec2(-(expand_size / 2.0), -1.0));
   1093 
   1094     let put_resp = ui
   1095         .put(rect, repost_icon(ui.visuals().dark_mode).max_width(size))
   1096         .on_hover_text(tr!(
   1097             i18n,
   1098             "Repost this note",
   1099             "Hover text for repost button"
   1100         ));
   1101 
   1102     resp.union(put_resp)
   1103 }
   1104 
   1105 fn zap_button<'a>(
   1106     i18n: &'a mut Localization,
   1107     state: AnyZapState,
   1108     noteid: &'a [u8; 32],
   1109 ) -> impl egui::Widget + use<'a> {
   1110     move |ui: &mut egui::Ui| -> egui::Response {
   1111         let (rect, size, resp) = crate::anim::hover_expand_small(ui, ui.id().with("zap"));
   1112 
   1113         let mut img = app_images::zap_dark_image().max_width(size);
   1114         let id = ui.id().with(("pulse", noteid));
   1115         let ctx = ui.ctx().clone();
   1116 
   1117         match state {
   1118             AnyZapState::None => {
   1119                 if !ui.visuals().dark_mode {
   1120                     img = app_images::zap_light_image();
   1121                 }
   1122             }
   1123             AnyZapState::Pending => {
   1124                 let alpha_min = if ui.visuals().dark_mode { 50 } else { 180 };
   1125                 let cur_alpha = PulseAlpha::new(&ctx, id, alpha_min, 255)
   1126                     .with_speed(0.35)
   1127                     .animate();
   1128 
   1129                 let cur_color = egui::Color32::from_rgba_unmultiplied(0xFF, 0xB7, 0x57, cur_alpha);
   1130                 img = img.tint(cur_color);
   1131             }
   1132             AnyZapState::LocalOnly => {
   1133                 img = img.tint(egui::Color32::from_rgb(0xFF, 0xB7, 0x57));
   1134             }
   1135             AnyZapState::Confirmed => {}
   1136         }
   1137 
   1138         // align rect to note contents
   1139         let expand_size = 5.0; // from hover_expand_small
   1140         let rect = rect.translate(egui::vec2(-(expand_size / 2.0), 0.0));
   1141 
   1142         let put_resp = ui.put(rect, img).on_hover_text(tr!(
   1143             i18n,
   1144             "Zap this note",
   1145             "Hover text for zap button"
   1146         ));
   1147 
   1148         resp.union(put_resp)
   1149     }
   1150 }