notedeck

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

mod.rs (31006B)


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