notedeck

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

mod.rs (21950B)


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