notedeck

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

mod.rs (22806B)


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