notedeck

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

mod.rs (29961B)


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