notedeck

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

mod.rs (21512B)


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