notedeck

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

mod.rs (23487B)


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