notedeck

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

mod.rs (24373B)


      1 pub mod contents;
      2 pub mod context;
      3 pub mod options;
      4 pub mod post;
      5 pub mod quote_repost;
      6 pub mod reply;
      7 
      8 pub use contents::NoteContents;
      9 pub use context::{NoteContextButton, NoteContextSelection};
     10 pub use options::NoteOptions;
     11 pub use post::{PostAction, PostResponse, PostType, PostView};
     12 pub use quote_repost::QuoteRepostView;
     13 pub use reply::PostReplyView;
     14 
     15 use crate::{
     16     actionbar::NoteAction,
     17     app_style::NotedeckTextStyle,
     18     colors,
     19     imgcache::ImageCache,
     20     notecache::{CachedNote, NoteCache},
     21     ui::{self, View},
     22 };
     23 use egui::emath::{pos2, Vec2};
     24 use egui::{Id, Label, Pos2, Rect, Response, RichText, Sense};
     25 use enostr::{NoteId, Pubkey};
     26 use nostrdb::{Ndb, Note, NoteKey, NoteReply, Transaction};
     27 
     28 use super::profile::preview::{get_display_name, one_line_display_name_widget};
     29 
     30 pub struct NoteView<'a> {
     31     ndb: &'a Ndb,
     32     note_cache: &'a mut NoteCache,
     33     img_cache: &'a mut ImageCache,
     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 context_selection: Option<NoteContextSelection>,
     42     pub action: Option<NoteAction>,
     43 }
     44 
     45 impl NoteResponse {
     46     pub fn new(response: egui::Response) -> Self {
     47         Self {
     48             response,
     49             context_selection: None,
     50             action: 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 select_option(mut self, context_selection: Option<NoteContextSelection>) -> Self {
     60         self.context_selection = context_selection;
     61         self
     62     }
     63 }
     64 
     65 impl View for NoteView<'_> {
     66     fn ui(&mut self, ui: &mut egui::Ui) {
     67         self.show(ui);
     68     }
     69 }
     70 
     71 fn reply_desc(
     72     ui: &mut egui::Ui,
     73     txn: &Transaction,
     74     note_reply: &NoteReply,
     75     ndb: &Ndb,
     76     img_cache: &mut ImageCache,
     77 ) {
     78     #[cfg(feature = "profiling")]
     79     puffin::profile_function!();
     80 
     81     let size = 10.0;
     82     let selectable = false;
     83 
     84     ui.add(
     85         Label::new(
     86             RichText::new("replying to")
     87                 .size(size)
     88                 .color(colors::GRAY_SECONDARY),
     89         )
     90         .selectable(selectable),
     91     );
     92 
     93     let reply = if let Some(reply) = note_reply.reply() {
     94         reply
     95     } else {
     96         return;
     97     };
     98 
     99     let reply_note = if let Ok(reply_note) = ndb.get_note_by_id(txn, reply.id) {
    100         reply_note
    101     } else {
    102         ui.add(
    103             Label::new(
    104                 RichText::new("a note")
    105                     .size(size)
    106                     .color(colors::GRAY_SECONDARY),
    107             )
    108             .selectable(selectable),
    109         );
    110         return;
    111     };
    112 
    113     if note_reply.is_reply_to_root() {
    114         // We're replying to the root, let's show this
    115         ui.add(
    116             ui::Mention::new(ndb, img_cache, txn, reply_note.pubkey())
    117                 .size(size)
    118                 .selectable(selectable),
    119         );
    120         ui.add(
    121             Label::new(
    122                 RichText::new("'s note")
    123                     .size(size)
    124                     .color(colors::GRAY_SECONDARY),
    125             )
    126             .selectable(selectable),
    127         );
    128     } else if let Some(root) = note_reply.root() {
    129         // replying to another post in a thread, not the root
    130 
    131         if let Ok(root_note) = ndb.get_note_by_id(txn, root.id) {
    132             if root_note.pubkey() == reply_note.pubkey() {
    133                 // simply "replying to bob's note" when replying to bob in his thread
    134                 ui.add(
    135                     ui::Mention::new(ndb, img_cache, txn, reply_note.pubkey())
    136                         .size(size)
    137                         .selectable(selectable),
    138                 );
    139                 ui.add(
    140                     Label::new(
    141                         RichText::new("'s note")
    142                             .size(size)
    143                             .color(colors::GRAY_SECONDARY),
    144                     )
    145                     .selectable(selectable),
    146                 );
    147             } else {
    148                 // replying to bob in alice's thread
    149 
    150                 ui.add(
    151                     ui::Mention::new(ndb, img_cache, txn, reply_note.pubkey())
    152                         .size(size)
    153                         .selectable(selectable),
    154                 );
    155                 ui.add(
    156                     Label::new(RichText::new("in").size(size).color(colors::GRAY_SECONDARY))
    157                         .selectable(selectable),
    158                 );
    159                 ui.add(
    160                     ui::Mention::new(ndb, img_cache, txn, root_note.pubkey())
    161                         .size(size)
    162                         .selectable(selectable),
    163                 );
    164                 ui.add(
    165                     Label::new(
    166                         RichText::new("'s thread")
    167                             .size(size)
    168                             .color(colors::GRAY_SECONDARY),
    169                     )
    170                     .selectable(selectable),
    171                 );
    172             }
    173         } else {
    174             ui.add(
    175                 ui::Mention::new(ndb, img_cache, txn, reply_note.pubkey())
    176                     .size(size)
    177                     .selectable(selectable),
    178             );
    179             ui.add(
    180                 Label::new(
    181                     RichText::new("in someone's thread")
    182                         .size(size)
    183                         .color(colors::GRAY_SECONDARY),
    184                 )
    185                 .selectable(selectable),
    186             );
    187         }
    188     }
    189 }
    190 
    191 impl<'a> NoteView<'a> {
    192     pub fn new(
    193         ndb: &'a Ndb,
    194         note_cache: &'a mut NoteCache,
    195         img_cache: &'a mut ImageCache,
    196         note: &'a nostrdb::Note<'a>,
    197     ) -> Self {
    198         let flags = NoteOptions::actionbar | NoteOptions::note_previews;
    199         let parent: Option<NoteKey> = None;
    200         Self {
    201             ndb,
    202             note_cache,
    203             img_cache,
    204             parent,
    205             note,
    206             flags,
    207         }
    208     }
    209 
    210     pub fn note_options(mut self, options: NoteOptions) -> Self {
    211         *self.options_mut() = options;
    212         self
    213     }
    214 
    215     pub fn textmode(mut self, enable: bool) -> Self {
    216         self.options_mut().set_textmode(enable);
    217         self
    218     }
    219 
    220     pub fn actionbar(mut self, enable: bool) -> Self {
    221         self.options_mut().set_actionbar(enable);
    222         self
    223     }
    224 
    225     pub fn small_pfp(mut self, enable: bool) -> Self {
    226         self.options_mut().set_small_pfp(enable);
    227         self
    228     }
    229 
    230     pub fn medium_pfp(mut self, enable: bool) -> Self {
    231         self.options_mut().set_medium_pfp(enable);
    232         self
    233     }
    234 
    235     pub fn note_previews(mut self, enable: bool) -> Self {
    236         self.options_mut().set_note_previews(enable);
    237         self
    238     }
    239 
    240     pub fn selectable_text(mut self, enable: bool) -> Self {
    241         self.options_mut().set_selectable_text(enable);
    242         self
    243     }
    244 
    245     pub fn wide(mut self, enable: bool) -> Self {
    246         self.options_mut().set_wide(enable);
    247         self
    248     }
    249 
    250     pub fn options_button(mut self, enable: bool) -> Self {
    251         self.options_mut().set_options_button(enable);
    252         self
    253     }
    254 
    255     pub fn options(&self) -> NoteOptions {
    256         self.flags
    257     }
    258 
    259     pub fn options_mut(&mut self) -> &mut NoteOptions {
    260         &mut self.flags
    261     }
    262 
    263     pub fn parent(mut self, parent: NoteKey) -> Self {
    264         self.parent = Some(parent);
    265         self
    266     }
    267 
    268     fn textmode_ui(&mut self, ui: &mut egui::Ui) -> egui::Response {
    269         let note_key = self.note.key().expect("todo: implement non-db notes");
    270         let txn = self.note.txn().expect("todo: implement non-db notes");
    271 
    272         ui.with_layout(egui::Layout::left_to_right(egui::Align::TOP), |ui| {
    273             let profile = self.ndb.get_profile_by_pubkey(txn, self.note.pubkey());
    274 
    275             //ui.horizontal(|ui| {
    276             ui.spacing_mut().item_spacing.x = 2.0;
    277 
    278             let cached_note = self
    279                 .note_cache
    280                 .cached_note_or_insert_mut(note_key, self.note);
    281 
    282             let (_id, rect) = ui.allocate_space(egui::vec2(50.0, 20.0));
    283             ui.allocate_rect(rect, Sense::hover());
    284             ui.put(rect, |ui: &mut egui::Ui| {
    285                 render_reltime(ui, cached_note, false).response
    286             });
    287             let (_id, rect) = ui.allocate_space(egui::vec2(150.0, 20.0));
    288             ui.allocate_rect(rect, Sense::hover());
    289             ui.put(rect, |ui: &mut egui::Ui| {
    290                 ui.add(
    291                     ui::Username::new(profile.as_ref().ok(), self.note.pubkey())
    292                         .abbreviated(6)
    293                         .pk_colored(true),
    294                 )
    295             });
    296 
    297             ui.add(&mut NoteContents::new(
    298                 self.ndb,
    299                 self.img_cache,
    300                 self.note_cache,
    301                 txn,
    302                 self.note,
    303                 note_key,
    304                 self.flags,
    305             ));
    306             //});
    307         })
    308         .response
    309     }
    310 
    311     pub fn expand_size() -> f32 {
    312         5.0
    313     }
    314 
    315     fn pfp(
    316         &mut self,
    317         note_key: NoteKey,
    318         profile: &Result<nostrdb::ProfileRecord<'_>, nostrdb::Error>,
    319         ui: &mut egui::Ui,
    320     ) -> egui::Response {
    321         if !self.options().has_wide() {
    322             ui.spacing_mut().item_spacing.x = 16.0;
    323         } else {
    324             ui.spacing_mut().item_spacing.x = 4.0;
    325         }
    326 
    327         let pfp_size = self.options().pfp_size();
    328 
    329         let sense = Sense::click();
    330         match profile
    331             .as_ref()
    332             .ok()
    333             .and_then(|p| p.record().profile()?.picture())
    334         {
    335             // these have different lifetimes and types,
    336             // so the calls must be separate
    337             Some(pic) => {
    338                 let anim_speed = 0.05;
    339                 let profile_key = profile.as_ref().unwrap().record().note_key();
    340                 let note_key = note_key.as_u64();
    341 
    342                 let (rect, size, resp) = ui::anim::hover_expand(
    343                     ui,
    344                     egui::Id::new((profile_key, note_key)),
    345                     pfp_size,
    346                     ui::NoteView::expand_size(),
    347                     anim_speed,
    348                 );
    349 
    350                 ui.put(rect, ui::ProfilePic::new(self.img_cache, pic).size(size))
    351                     .on_hover_ui_at_pointer(|ui| {
    352                         ui.set_max_width(300.0);
    353                         ui.add(ui::ProfilePreview::new(
    354                             profile.as_ref().unwrap(),
    355                             self.img_cache,
    356                         ));
    357                     });
    358                 resp
    359             }
    360             None => ui
    361                 .add(
    362                     ui::ProfilePic::new(self.img_cache, ui::ProfilePic::no_pfp_url())
    363                         .size(pfp_size),
    364                 )
    365                 .interact(sense),
    366         }
    367     }
    368 
    369     pub fn show(&mut self, ui: &mut egui::Ui) -> NoteResponse {
    370         if self.options().has_textmode() {
    371             NoteResponse::new(self.textmode_ui(ui))
    372         } else {
    373             let txn = self.note.txn().expect("txn");
    374             if let Some(note_to_repost) = get_reposted_note(self.ndb, txn, self.note) {
    375                 let profile = self.ndb.get_profile_by_pubkey(txn, self.note.pubkey());
    376 
    377                 let style = NotedeckTextStyle::Small;
    378                 ui.horizontal(|ui| {
    379                     ui.vertical(|ui| {
    380                         ui.add_space(2.0);
    381                         ui.add_sized([20.0, 20.0], repost_icon(ui.visuals().dark_mode));
    382                     });
    383                     ui.add_space(6.0);
    384                     let resp = ui.add(one_line_display_name_widget(
    385                         get_display_name(profile.as_ref().ok()),
    386                         style,
    387                     ));
    388                     if let Ok(rec) = &profile {
    389                         resp.on_hover_ui_at_pointer(|ui| {
    390                             ui.set_max_width(300.0);
    391                             ui.add(ui::ProfilePreview::new(rec, self.img_cache));
    392                         });
    393                     }
    394                     ui.add_space(4.0);
    395                     ui.label(
    396                         RichText::new("Reposted")
    397                             .color(colors::GRAY_SECONDARY)
    398                             .text_style(style.text_style()),
    399                     );
    400                 });
    401                 NoteView::new(self.ndb, self.note_cache, self.img_cache, &note_to_repost).show(ui)
    402             } else {
    403                 self.show_standard(ui)
    404             }
    405         }
    406     }
    407 
    408     fn note_header(
    409         ui: &mut egui::Ui,
    410         note_cache: &mut NoteCache,
    411         note: &Note,
    412         profile: &Result<nostrdb::ProfileRecord<'_>, nostrdb::Error>,
    413         options: NoteOptions,
    414         container_right: Pos2,
    415     ) -> NoteResponse {
    416         #[cfg(feature = "profiling")]
    417         puffin::profile_function!();
    418 
    419         let note_key = note.key().unwrap();
    420 
    421         let inner_response = ui.horizontal(|ui| {
    422             ui.spacing_mut().item_spacing.x = 2.0;
    423             ui.add(ui::Username::new(profile.as_ref().ok(), note.pubkey()).abbreviated(20));
    424 
    425             let cached_note = note_cache.cached_note_or_insert_mut(note_key, note);
    426             render_reltime(ui, cached_note, true);
    427 
    428             if options.has_options_button() {
    429                 let context_pos = {
    430                     let size = NoteContextButton::max_width();
    431                     let min = Pos2::new(container_right.x - size, container_right.y);
    432                     Rect::from_min_size(min, egui::vec2(size, size))
    433                 };
    434 
    435                 let resp = ui.add(NoteContextButton::new(note_key).place_at(context_pos));
    436                 NoteContextButton::menu(ui, resp.clone())
    437             } else {
    438                 None
    439             }
    440         });
    441 
    442         NoteResponse::new(inner_response.response).select_option(inner_response.inner)
    443     }
    444 
    445     fn show_standard(&mut self, ui: &mut egui::Ui) -> NoteResponse {
    446         #[cfg(feature = "profiling")]
    447         puffin::profile_function!();
    448         let note_key = self.note.key().expect("todo: support non-db notes");
    449         let txn = self.note.txn().expect("todo: support non-db notes");
    450 
    451         let mut note_action: Option<NoteAction> = None;
    452         let mut selected_option: Option<NoteContextSelection> = None;
    453 
    454         let hitbox_id = note_hitbox_id(note_key, self.options(), self.parent);
    455         let profile = self.ndb.get_profile_by_pubkey(txn, self.note.pubkey());
    456         let maybe_hitbox = maybe_note_hitbox(ui, hitbox_id);
    457         let container_right = {
    458             let r = ui.available_rect_before_wrap();
    459             let x = r.max.x;
    460             let y = r.min.y;
    461             Pos2::new(x, y)
    462         };
    463 
    464         // wide design
    465         let response = if self.options().has_wide() {
    466             ui.vertical(|ui| {
    467                 ui.horizontal(|ui| {
    468                     if self.pfp(note_key, &profile, ui).clicked() {
    469                         note_action =
    470                             Some(NoteAction::OpenProfile(Pubkey::new(*self.note.pubkey())));
    471                     };
    472 
    473                     let size = ui.available_size();
    474                     ui.vertical(|ui| {
    475                         ui.add_sized([size.x, self.options().pfp_size()], |ui: &mut egui::Ui| {
    476                             ui.horizontal_centered(|ui| {
    477                                 selected_option = NoteView::note_header(
    478                                     ui,
    479                                     self.note_cache,
    480                                     self.note,
    481                                     &profile,
    482                                     self.options(),
    483                                     container_right,
    484                                 )
    485                                 .context_selection;
    486                             })
    487                             .response
    488                         });
    489 
    490                         let note_reply = self
    491                             .note_cache
    492                             .cached_note_or_insert_mut(note_key, self.note)
    493                             .reply
    494                             .borrow(self.note.tags());
    495 
    496                         if note_reply.reply().is_some() {
    497                             ui.horizontal(|ui| {
    498                                 reply_desc(ui, txn, &note_reply, self.ndb, self.img_cache);
    499                             });
    500                         }
    501                     });
    502                 });
    503 
    504                 let mut contents = NoteContents::new(
    505                     self.ndb,
    506                     self.img_cache,
    507                     self.note_cache,
    508                     txn,
    509                     self.note,
    510                     note_key,
    511                     self.options(),
    512                 );
    513 
    514                 ui.add(&mut contents);
    515 
    516                 if let Some(action) = contents.action() {
    517                     note_action = Some(*action);
    518                 }
    519 
    520                 if self.options().has_actionbar() {
    521                     if let Some(action) = render_note_actionbar(ui, self.note.id(), note_key).inner
    522                     {
    523                         note_action = Some(action);
    524                     }
    525                 }
    526             })
    527             .response
    528         } else {
    529             // main design
    530             ui.with_layout(egui::Layout::left_to_right(egui::Align::TOP), |ui| {
    531                 if self.pfp(note_key, &profile, ui).clicked() {
    532                     note_action = Some(NoteAction::OpenProfile(Pubkey::new(*self.note.pubkey())));
    533                 };
    534 
    535                 ui.with_layout(egui::Layout::top_down(egui::Align::LEFT), |ui| {
    536                     selected_option = NoteView::note_header(
    537                         ui,
    538                         self.note_cache,
    539                         self.note,
    540                         &profile,
    541                         self.options(),
    542                         container_right,
    543                     )
    544                     .context_selection;
    545                     ui.horizontal(|ui| {
    546                         ui.spacing_mut().item_spacing.x = 2.0;
    547 
    548                         let note_reply = self
    549                             .note_cache
    550                             .cached_note_or_insert_mut(note_key, self.note)
    551                             .reply
    552                             .borrow(self.note.tags());
    553 
    554                         if note_reply.reply().is_some() {
    555                             reply_desc(ui, txn, &note_reply, self.ndb, self.img_cache);
    556                         }
    557                     });
    558 
    559                     let mut contents = NoteContents::new(
    560                         self.ndb,
    561                         self.img_cache,
    562                         self.note_cache,
    563                         txn,
    564                         self.note,
    565                         note_key,
    566                         self.options(),
    567                     );
    568                     ui.add(&mut contents);
    569 
    570                     if let Some(action) = contents.action() {
    571                         note_action = Some(*action);
    572                     }
    573 
    574                     if self.options().has_actionbar() {
    575                         if let Some(action) =
    576                             render_note_actionbar(ui, self.note.id(), note_key).inner
    577                         {
    578                             note_action = Some(action);
    579                         }
    580                     }
    581                 });
    582             })
    583             .response
    584         };
    585 
    586         let note_action = if note_hitbox_clicked(ui, hitbox_id, &response.rect, maybe_hitbox) {
    587             Some(NoteAction::OpenThread(NoteId::new(*self.note.id())))
    588         } else {
    589             note_action
    590         };
    591 
    592         NoteResponse::new(response)
    593             .with_action(note_action)
    594             .select_option(selected_option)
    595     }
    596 }
    597 
    598 fn get_reposted_note<'a>(ndb: &Ndb, txn: &'a Transaction, note: &Note) -> Option<Note<'a>> {
    599     let new_note_id: &[u8; 32] = if note.kind() == 6 {
    600         let mut res = None;
    601         for tag in note.tags().iter() {
    602             if tag.count() == 0 {
    603                 continue;
    604             }
    605 
    606             if let Some("e") = tag.get(0).and_then(|t| t.variant().str()) {
    607                 if let Some(note_id) = tag.get(1).and_then(|f| f.variant().id()) {
    608                     res = Some(note_id);
    609                     break;
    610                 }
    611             }
    612         }
    613         res?
    614     } else {
    615         return None;
    616     };
    617 
    618     let note = ndb.get_note_by_id(txn, new_note_id).ok();
    619     note.filter(|note| note.kind() == 1)
    620 }
    621 
    622 fn note_hitbox_id(
    623     note_key: NoteKey,
    624     note_options: NoteOptions,
    625     parent: Option<NoteKey>,
    626 ) -> egui::Id {
    627     Id::new(("note_size", note_key, note_options, parent))
    628 }
    629 
    630 fn maybe_note_hitbox(ui: &mut egui::Ui, hitbox_id: egui::Id) -> Option<Response> {
    631     ui.ctx()
    632         .data_mut(|d| d.get_persisted(hitbox_id))
    633         .map(|note_size: Vec2| {
    634             // The hitbox should extend the entire width of the
    635             // container.  The hitbox height was cached last layout.
    636             let container_rect = ui.max_rect();
    637             let rect = Rect {
    638                 min: pos2(container_rect.min.x, container_rect.min.y),
    639                 max: pos2(container_rect.max.x, container_rect.min.y + note_size.y),
    640             };
    641 
    642             let response = ui.interact(rect, ui.id().with(hitbox_id), egui::Sense::click());
    643 
    644             response
    645                 .widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Other, true, "hitbox"));
    646 
    647             response
    648         })
    649 }
    650 
    651 fn note_hitbox_clicked(
    652     ui: &mut egui::Ui,
    653     hitbox_id: egui::Id,
    654     note_rect: &Rect,
    655     maybe_hitbox: Option<Response>,
    656 ) -> bool {
    657     // Stash the dimensions of the note content so we can render the
    658     // hitbox in the next frame
    659     ui.ctx().data_mut(|d| {
    660         d.insert_persisted(hitbox_id, note_rect.size());
    661     });
    662 
    663     // If there was an hitbox and it was clicked open the thread
    664     match maybe_hitbox {
    665         Some(hitbox) => hitbox.clicked(),
    666         _ => false,
    667     }
    668 }
    669 
    670 fn render_note_actionbar(
    671     ui: &mut egui::Ui,
    672     note_id: &[u8; 32],
    673     note_key: NoteKey,
    674 ) -> egui::InnerResponse<Option<NoteAction>> {
    675     #[cfg(feature = "profiling")]
    676     puffin::profile_function!();
    677 
    678     ui.horizontal(|ui| {
    679         let reply_resp = reply_button(ui, note_key);
    680         let quote_resp = quote_repost_button(ui, note_key);
    681 
    682         if reply_resp.clicked() {
    683             Some(NoteAction::Reply(NoteId::new(*note_id)))
    684         } else if quote_resp.clicked() {
    685             Some(NoteAction::Quote(NoteId::new(*note_id)))
    686         } else {
    687             None
    688         }
    689     })
    690 }
    691 
    692 fn secondary_label(ui: &mut egui::Ui, s: impl Into<String>) {
    693     ui.add(Label::new(
    694         RichText::new(s).size(10.0).color(colors::GRAY_SECONDARY),
    695     ));
    696 }
    697 
    698 fn render_reltime(
    699     ui: &mut egui::Ui,
    700     note_cache: &mut CachedNote,
    701     before: bool,
    702 ) -> egui::InnerResponse<()> {
    703     #[cfg(feature = "profiling")]
    704     puffin::profile_function!();
    705 
    706     ui.horizontal(|ui| {
    707         if before {
    708             secondary_label(ui, "⋅");
    709         }
    710 
    711         secondary_label(ui, note_cache.reltime_str_mut());
    712 
    713         if !before {
    714             secondary_label(ui, "⋅");
    715         }
    716     })
    717 }
    718 
    719 fn reply_button(ui: &mut egui::Ui, note_key: NoteKey) -> egui::Response {
    720     let img_data = if ui.style().visuals.dark_mode {
    721         egui::include_image!("../../../assets/icons/reply.png")
    722     } else {
    723         egui::include_image!("../../../assets/icons/reply-dark.png")
    724     };
    725 
    726     let (rect, size, resp) =
    727         ui::anim::hover_expand_small(ui, ui.id().with(("reply_anim", note_key)));
    728 
    729     // align rect to note contents
    730     let expand_size = 5.0; // from hover_expand_small
    731     let rect = rect.translate(egui::vec2(-(expand_size / 2.0), 0.0));
    732 
    733     let put_resp = ui.put(rect, egui::Image::new(img_data).max_width(size));
    734 
    735     resp.union(put_resp)
    736 }
    737 
    738 fn repost_icon(dark_mode: bool) -> egui::Image<'static> {
    739     let img_data = if dark_mode {
    740         egui::include_image!("../../../assets/icons/repost_icon_4x.png")
    741     } else {
    742         egui::include_image!("../../../assets/icons/repost_light_4x.png")
    743     };
    744     egui::Image::new(img_data)
    745 }
    746 
    747 fn quote_repost_button(ui: &mut egui::Ui, note_key: NoteKey) -> egui::Response {
    748     let (rect, size, resp) =
    749         ui::anim::hover_expand_small(ui, ui.id().with(("repost_anim", note_key)));
    750 
    751     let expand_size = 5.0;
    752     let rect = rect.translate(egui::vec2(-(expand_size / 2.0), 0.0));
    753 
    754     let put_resp = ui.put(rect, repost_icon(ui.visuals().dark_mode).max_width(size));
    755 
    756     resp.union(put_resp)
    757 }