notedeck

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

contents.rs (11087B)


      1 use crate::actionbar::NoteAction;
      2 use crate::images::ImageType;
      3 use crate::ui::{
      4     self,
      5     note::{NoteOptions, NoteResponse},
      6     ProfilePic,
      7 };
      8 use egui::{Color32, Hyperlink, Image, RichText};
      9 use nostrdb::{BlockType, Mention, Ndb, Note, NoteKey, Transaction};
     10 use tracing::warn;
     11 
     12 use notedeck::{ImageCache, NoteCache};
     13 
     14 pub struct NoteContents<'a> {
     15     ndb: &'a Ndb,
     16     img_cache: &'a mut ImageCache,
     17     note_cache: &'a mut NoteCache,
     18     txn: &'a Transaction,
     19     note: &'a Note<'a>,
     20     note_key: NoteKey,
     21     options: NoteOptions,
     22     action: Option<NoteAction>,
     23 }
     24 
     25 impl<'a> NoteContents<'a> {
     26     pub fn new(
     27         ndb: &'a Ndb,
     28         img_cache: &'a mut ImageCache,
     29         note_cache: &'a mut NoteCache,
     30         txn: &'a Transaction,
     31         note: &'a Note,
     32         note_key: NoteKey,
     33         options: ui::note::NoteOptions,
     34     ) -> Self {
     35         NoteContents {
     36             ndb,
     37             img_cache,
     38             note_cache,
     39             txn,
     40             note,
     41             note_key,
     42             options,
     43             action: None,
     44         }
     45     }
     46 
     47     pub fn action(&self) -> &Option<NoteAction> {
     48         &self.action
     49     }
     50 }
     51 
     52 impl egui::Widget for &mut NoteContents<'_> {
     53     fn ui(self, ui: &mut egui::Ui) -> egui::Response {
     54         let result = render_note_contents(
     55             ui,
     56             self.ndb,
     57             self.img_cache,
     58             self.note_cache,
     59             self.txn,
     60             self.note,
     61             self.note_key,
     62             self.options,
     63         );
     64         self.action = result.action;
     65         result.response
     66     }
     67 }
     68 
     69 /// Render an inline note preview with a border. These are used when
     70 /// notes are references within a note
     71 pub fn render_note_preview(
     72     ui: &mut egui::Ui,
     73     ndb: &Ndb,
     74     note_cache: &mut NoteCache,
     75     img_cache: &mut ImageCache,
     76     txn: &Transaction,
     77     id: &[u8; 32],
     78     parent: NoteKey,
     79 ) -> NoteResponse {
     80     #[cfg(feature = "profiling")]
     81     puffin::profile_function!();
     82 
     83     let note = if let Ok(note) = ndb.get_note_by_id(txn, id) {
     84         // TODO: support other preview kinds
     85         if note.kind() == 1 {
     86             note
     87         } else {
     88             return NoteResponse::new(ui.colored_label(
     89                 Color32::RED,
     90                 format!("TODO: can't preview kind {}", note.kind()),
     91             ));
     92         }
     93     } else {
     94         return NoteResponse::new(ui.colored_label(Color32::RED, "TODO: COULD NOT LOAD"));
     95         /*
     96         return ui
     97             .horizontal(|ui| {
     98                 ui.spacing_mut().item_spacing.x = 0.0;
     99                 ui.colored_label(link_color, "@");
    100                 ui.colored_label(link_color, &id_str[4..16]);
    101             })
    102             .response;
    103             */
    104     };
    105 
    106     egui::Frame::none()
    107         .fill(ui.visuals().noninteractive().weak_bg_fill)
    108         .inner_margin(egui::Margin::same(8.0))
    109         .outer_margin(egui::Margin::symmetric(0.0, 8.0))
    110         .rounding(egui::Rounding::same(10.0))
    111         .stroke(egui::Stroke::new(
    112             1.0,
    113             ui.visuals().noninteractive().bg_stroke.color,
    114         ))
    115         .show(ui, |ui| {
    116             ui::NoteView::new(ndb, note_cache, img_cache, &note)
    117                 .actionbar(false)
    118                 .small_pfp(true)
    119                 .wide(true)
    120                 .note_previews(false)
    121                 .options_button(true)
    122                 .parent(parent)
    123                 .show(ui)
    124         })
    125         .inner
    126 }
    127 
    128 fn is_image_link(url: &str) -> bool {
    129     url.ends_with("png") || url.ends_with("jpg") || url.ends_with("jpeg")
    130 }
    131 
    132 #[allow(clippy::too_many_arguments)]
    133 fn render_note_contents(
    134     ui: &mut egui::Ui,
    135     ndb: &Ndb,
    136     img_cache: &mut ImageCache,
    137     note_cache: &mut NoteCache,
    138     txn: &Transaction,
    139     note: &Note,
    140     note_key: NoteKey,
    141     options: NoteOptions,
    142 ) -> NoteResponse {
    143     #[cfg(feature = "profiling")]
    144     puffin::profile_function!();
    145 
    146     let selectable = options.has_selectable_text();
    147     let mut images: Vec<String> = vec![];
    148     let mut note_action: Option<NoteAction> = None;
    149     let mut inline_note: Option<(&[u8; 32], &str)> = None;
    150     let hide_media = options.has_hide_media();
    151     let link_color = ui.visuals().hyperlink_color;
    152 
    153     let response = ui.horizontal_wrapped(|ui| {
    154         let blocks = if let Ok(blocks) = ndb.get_blocks_by_key(txn, note_key) {
    155             blocks
    156         } else {
    157             warn!("missing note content blocks? '{}'", note.content());
    158             ui.weak(note.content());
    159             return;
    160         };
    161 
    162         ui.spacing_mut().item_spacing.x = 0.0;
    163 
    164         for block in blocks.iter(note) {
    165             match block.blocktype() {
    166                 BlockType::MentionBech32 => match block.as_mention().unwrap() {
    167                     Mention::Profile(profile) => {
    168                         let act = ui::Mention::new(ndb, img_cache, txn, profile.pubkey())
    169                             .show(ui)
    170                             .inner;
    171                         if act.is_some() {
    172                             note_action = act;
    173                         }
    174                     }
    175 
    176                     Mention::Pubkey(npub) => {
    177                         let act = ui::Mention::new(ndb, img_cache, txn, npub.pubkey())
    178                             .show(ui)
    179                             .inner;
    180                         if act.is_some() {
    181                             note_action = act;
    182                         }
    183                     }
    184 
    185                     Mention::Note(note) if options.has_note_previews() => {
    186                         inline_note = Some((note.id(), block.as_str()));
    187                     }
    188 
    189                     Mention::Event(note) if options.has_note_previews() => {
    190                         inline_note = Some((note.id(), block.as_str()));
    191                     }
    192 
    193                     _ => {
    194                         ui.colored_label(link_color, format!("@{}", &block.as_str()[4..16]));
    195                     }
    196                 },
    197 
    198                 BlockType::Hashtag => {
    199                     #[cfg(feature = "profiling")]
    200                     puffin::profile_scope!("hashtag contents");
    201                     ui.colored_label(link_color, format!("#{}", block.as_str()));
    202                 }
    203 
    204                 BlockType::Url => {
    205                     let lower_url = block.as_str().to_lowercase();
    206                     if !hide_media && is_image_link(&lower_url) {
    207                         images.push(block.as_str().to_string());
    208                     } else {
    209                         #[cfg(feature = "profiling")]
    210                         puffin::profile_scope!("url contents");
    211                         ui.add(Hyperlink::from_label_and_url(
    212                             RichText::new(block.as_str()).color(link_color),
    213                             block.as_str(),
    214                         ));
    215                     }
    216                 }
    217 
    218                 BlockType::Text => {
    219                     #[cfg(feature = "profiling")]
    220                     puffin::profile_scope!("text contents");
    221                     ui.add(egui::Label::new(block.as_str()).selectable(selectable));
    222                 }
    223 
    224                 _ => {
    225                     ui.colored_label(link_color, block.as_str());
    226                 }
    227             }
    228         }
    229     });
    230 
    231     let preview_note_action = if let Some((id, _block_str)) = inline_note {
    232         render_note_preview(ui, ndb, note_cache, img_cache, txn, id, note_key).action
    233     } else {
    234         None
    235     };
    236 
    237     if !images.is_empty() && !options.has_textmode() {
    238         ui.add_space(2.0);
    239         let carousel_id = egui::Id::new(("carousel", note.key().expect("expected tx note")));
    240         image_carousel(ui, img_cache, images, carousel_id);
    241         ui.add_space(2.0);
    242     }
    243 
    244     let note_action = preview_note_action.or(note_action);
    245 
    246     NoteResponse::new(response.response).with_action(note_action)
    247 }
    248 
    249 fn image_carousel(
    250     ui: &mut egui::Ui,
    251     img_cache: &mut ImageCache,
    252     images: Vec<String>,
    253     carousel_id: egui::Id,
    254 ) {
    255     // let's make sure everything is within our area
    256 
    257     let height = 360.0;
    258     let width = ui.available_size().x;
    259     let spinsz = if height > width { width } else { height };
    260 
    261     ui.add_sized([width, height], |ui: &mut egui::Ui| {
    262         egui::ScrollArea::horizontal()
    263             .id_salt(carousel_id)
    264             .show(ui, |ui| {
    265                 ui.horizontal(|ui| {
    266                     for image in images {
    267                         // If the cache is empty, initiate the fetch
    268                         let m_cached_promise = img_cache.map().get(&image);
    269                         if m_cached_promise.is_none() {
    270                             let res = crate::images::fetch_img(
    271                                 img_cache,
    272                                 ui.ctx(),
    273                                 &image,
    274                                 ImageType::Content(width.round() as u32, height.round() as u32),
    275                             );
    276                             img_cache.map_mut().insert(image.to_owned(), res);
    277                         }
    278 
    279                         // What is the state of the fetch?
    280                         match img_cache.map()[&image].ready() {
    281                             // Still waiting
    282                             None => {
    283                                 ui.allocate_space(egui::vec2(spinsz, spinsz));
    284                                 //ui.add(egui::Spinner::new().size(spinsz));
    285                             }
    286                             // Failed to fetch image!
    287                             Some(Err(_err)) => {
    288                                 // FIXME - use content-specific error instead
    289                                 let no_pfp = crate::images::fetch_img(
    290                                     img_cache,
    291                                     ui.ctx(),
    292                                     ProfilePic::no_pfp_url(),
    293                                     ImageType::Profile(128),
    294                                 );
    295                                 img_cache.map_mut().insert(image.to_owned(), no_pfp);
    296                                 // spin until next pass
    297                                 ui.allocate_space(egui::vec2(spinsz, spinsz));
    298                                 //ui.add(egui::Spinner::new().size(spinsz));
    299                             }
    300                             // Use the previously resolved image
    301                             Some(Ok(img)) => {
    302                                 let img_resp = ui.add(
    303                                     Image::new(img)
    304                                         .max_height(height)
    305                                         .rounding(5.0)
    306                                         .fit_to_original_size(1.0),
    307                                 );
    308                                 img_resp.context_menu(|ui| {
    309                                     if ui.button("Copy Link").clicked() {
    310                                         ui.ctx().copy_text(image);
    311                                         ui.close_menu();
    312                                     }
    313                                 });
    314                             }
    315                         }
    316                     }
    317                 })
    318                 .response
    319             })
    320             .inner
    321     });
    322 }