notedeck

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

mod.rs (21961B)


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