notedeck

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

contents.rs (12043B)


      1 use crate::gif::{handle_repaint, retrieve_latest_texture};
      2 use crate::ui::images::render_images;
      3 use crate::ui::{
      4     self,
      5     note::{NoteOptions, NoteResponse},
      6 };
      7 use crate::{actionbar::NoteAction, images::ImageType, timeline::TimelineKind};
      8 use egui::{Color32, Hyperlink, Image, RichText};
      9 use nostrdb::{BlockType, Mention, Ndb, Note, NoteKey, Transaction};
     10 use tracing::warn;
     11 
     12 use notedeck::{supported_mime_hosted_at_url, Images, MediaCacheType, NoteCache};
     13 
     14 /// Aggregates dependencies to reduce the number of parameters
     15 /// passed to inner UI elements, minimizing prop drilling.
     16 pub struct NoteContext<'d> {
     17     pub ndb: &'d Ndb,
     18     pub img_cache: &'d mut Images,
     19     pub note_cache: &'d mut NoteCache,
     20 }
     21 
     22 pub struct NoteContents<'a, 'd> {
     23     note_context: &'a mut NoteContext<'d>,
     24     txn: &'a Transaction,
     25     note: &'a Note<'a>,
     26     options: NoteOptions,
     27     action: Option<NoteAction>,
     28 }
     29 
     30 impl<'a, 'd> NoteContents<'a, 'd> {
     31     #[allow(clippy::too_many_arguments)]
     32     pub fn new(
     33         note_context: &'a mut NoteContext<'d>,
     34         txn: &'a Transaction,
     35         note: &'a Note,
     36         options: ui::note::NoteOptions,
     37     ) -> Self {
     38         NoteContents {
     39             note_context,
     40             txn,
     41             note,
     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(ui, self.note_context, self.txn, self.note, self.options);
     55         self.action = result.action;
     56         result.response
     57     }
     58 }
     59 
     60 /// Render an inline note preview with a border. These are used when
     61 /// notes are references within a note
     62 #[allow(clippy::too_many_arguments)]
     63 pub fn render_note_preview(
     64     ui: &mut egui::Ui,
     65     note_context: &mut NoteContext,
     66     txn: &Transaction,
     67     id: &[u8; 32],
     68     parent: NoteKey,
     69     note_options: NoteOptions,
     70 ) -> NoteResponse {
     71     #[cfg(feature = "profiling")]
     72     puffin::profile_function!();
     73 
     74     let note = if let Ok(note) = note_context.ndb.get_note_by_id(txn, id) {
     75         // TODO: support other preview kinds
     76         if note.kind() == 1 {
     77             note
     78         } else {
     79             return NoteResponse::new(ui.colored_label(
     80                 Color32::RED,
     81                 format!("TODO: can't preview kind {}", note.kind()),
     82             ));
     83         }
     84     } else {
     85         return NoteResponse::new(ui.colored_label(Color32::RED, "TODO: COULD NOT LOAD"));
     86         /*
     87         return ui
     88             .horizontal(|ui| {
     89                 ui.spacing_mut().item_spacing.x = 0.0;
     90                 ui.colored_label(link_color, "@");
     91                 ui.colored_label(link_color, &id_str[4..16]);
     92             })
     93             .response;
     94             */
     95     };
     96 
     97     egui::Frame::new()
     98         .fill(ui.visuals().noninteractive().weak_bg_fill)
     99         .inner_margin(egui::Margin::same(8))
    100         .outer_margin(egui::Margin::symmetric(0, 8))
    101         .rounding(egui::Rounding::same(10))
    102         .stroke(egui::Stroke::new(
    103             1.0,
    104             ui.visuals().noninteractive().bg_stroke.color,
    105         ))
    106         .show(ui, |ui| {
    107             ui::NoteView::new(note_context, &note, note_options)
    108                 .actionbar(false)
    109                 .small_pfp(true)
    110                 .wide(true)
    111                 .note_previews(false)
    112                 .options_button(true)
    113                 .parent(parent)
    114                 .is_preview(true)
    115                 .show(ui)
    116         })
    117         .inner
    118 }
    119 
    120 #[allow(clippy::too_many_arguments)]
    121 fn render_note_contents(
    122     ui: &mut egui::Ui,
    123     note_context: &mut NoteContext,
    124     txn: &Transaction,
    125     note: &Note,
    126     options: NoteOptions,
    127 ) -> NoteResponse {
    128     #[cfg(feature = "profiling")]
    129     puffin::profile_function!();
    130 
    131     let note_key = note.key().expect("todo: implement non-db notes");
    132     let selectable = options.has_selectable_text();
    133     let mut images: Vec<(String, MediaCacheType)> = vec![];
    134     let mut note_action: Option<NoteAction> = None;
    135     let mut inline_note: Option<(&[u8; 32], &str)> = None;
    136     let hide_media = options.has_hide_media();
    137     let link_color = ui.visuals().hyperlink_color;
    138 
    139     if !options.has_is_preview() {
    140         // need this for the rect to take the full width of the column
    141         let _ = ui.allocate_at_least(egui::vec2(ui.available_width(), 0.0), egui::Sense::click());
    142     }
    143 
    144     let response = ui.horizontal_wrapped(|ui| {
    145         let blocks = if let Ok(blocks) = note_context.ndb.get_blocks_by_key(txn, note_key) {
    146             blocks
    147         } else {
    148             warn!("missing note content blocks? '{}'", note.content());
    149             ui.weak(note.content());
    150             return;
    151         };
    152 
    153         ui.spacing_mut().item_spacing.x = 0.0;
    154 
    155         for block in blocks.iter(note) {
    156             match block.blocktype() {
    157                 BlockType::MentionBech32 => match block.as_mention().unwrap() {
    158                     Mention::Profile(profile) => {
    159                         let act = ui::Mention::new(
    160                             note_context.ndb,
    161                             note_context.img_cache,
    162                             txn,
    163                             profile.pubkey(),
    164                         )
    165                         .show(ui)
    166                         .inner;
    167                         if act.is_some() {
    168                             note_action = act;
    169                         }
    170                     }
    171 
    172                     Mention::Pubkey(npub) => {
    173                         let act = ui::Mention::new(
    174                             note_context.ndb,
    175                             note_context.img_cache,
    176                             txn,
    177                             npub.pubkey(),
    178                         )
    179                         .show(ui)
    180                         .inner;
    181                         if act.is_some() {
    182                             note_action = act;
    183                         }
    184                     }
    185 
    186                     Mention::Note(note) if options.has_note_previews() => {
    187                         inline_note = Some((note.id(), block.as_str()));
    188                     }
    189 
    190                     Mention::Event(note) if options.has_note_previews() => {
    191                         inline_note = Some((note.id(), block.as_str()));
    192                     }
    193 
    194                     _ => {
    195                         ui.colored_label(link_color, format!("@{}", &block.as_str()[4..16]));
    196                     }
    197                 },
    198 
    199                 BlockType::Hashtag => {
    200                     #[cfg(feature = "profiling")]
    201                     puffin::profile_scope!("hashtag contents");
    202                     let resp = ui.colored_label(link_color, format!("#{}", block.as_str()));
    203 
    204                     if resp.clicked() {
    205                         note_action = Some(NoteAction::OpenTimeline(TimelineKind::Hashtag(
    206                             block.as_str().to_string(),
    207                         )));
    208                     } else if resp.hovered() {
    209                         ui::show_pointer(ui);
    210                     }
    211                 }
    212 
    213                 BlockType::Url => {
    214                     let mut found_supported = || -> bool {
    215                         let url = block.as_str();
    216                         if let Some(cache_type) =
    217                             supported_mime_hosted_at_url(&mut note_context.img_cache.urls, url)
    218                         {
    219                             images.push((url.to_string(), cache_type));
    220                             true
    221                         } else {
    222                             false
    223                         }
    224                     };
    225                     if hide_media || !found_supported() {
    226                         #[cfg(feature = "profiling")]
    227                         puffin::profile_scope!("url contents");
    228                         ui.add(Hyperlink::from_label_and_url(
    229                             RichText::new(block.as_str()).color(link_color),
    230                             block.as_str(),
    231                         ));
    232                     }
    233                 }
    234 
    235                 BlockType::Text => {
    236                     #[cfg(feature = "profiling")]
    237                     puffin::profile_scope!("text contents");
    238                     if options.has_scramble_text() {
    239                         ui.add(egui::Label::new(rot13(block.as_str())).selectable(selectable));
    240                     } else {
    241                         ui.add(egui::Label::new(block.as_str()).selectable(selectable));
    242                     }
    243                 }
    244 
    245                 _ => {
    246                     ui.colored_label(link_color, block.as_str());
    247                 }
    248             }
    249         }
    250     });
    251 
    252     let preview_note_action = if let Some((id, _block_str)) = inline_note {
    253         render_note_preview(ui, note_context, txn, id, note_key, options).action
    254     } else {
    255         None
    256     };
    257 
    258     if !images.is_empty() && !options.has_textmode() {
    259         ui.add_space(2.0);
    260         let carousel_id = egui::Id::new(("carousel", note.key().expect("expected tx note")));
    261         image_carousel(ui, note_context.img_cache, images, carousel_id);
    262         ui.add_space(2.0);
    263     }
    264 
    265     let note_action = preview_note_action.or(note_action);
    266 
    267     NoteResponse::new(response.response).with_action(note_action)
    268 }
    269 
    270 fn rot13(input: &str) -> String {
    271     input
    272         .chars()
    273         .map(|c| {
    274             if c.is_ascii_lowercase() {
    275                 // Rotate lowercase letters
    276                 (((c as u8 - b'a' + 13) % 26) + b'a') as char
    277             } else if c.is_ascii_uppercase() {
    278                 // Rotate uppercase letters
    279                 (((c as u8 - b'A' + 13) % 26) + b'A') as char
    280             } else {
    281                 // Leave other characters unchanged
    282                 c
    283             }
    284         })
    285         .collect()
    286 }
    287 
    288 fn image_carousel(
    289     ui: &mut egui::Ui,
    290     img_cache: &mut Images,
    291     images: Vec<(String, MediaCacheType)>,
    292     carousel_id: egui::Id,
    293 ) {
    294     // let's make sure everything is within our area
    295 
    296     let height = 360.0;
    297     let width = ui.available_size().x;
    298     let spinsz = if height > width { width } else { height };
    299 
    300     ui.add_sized([width, height], |ui: &mut egui::Ui| {
    301         egui::ScrollArea::horizontal()
    302             .id_salt(carousel_id)
    303             .show(ui, |ui| {
    304                 ui.horizontal(|ui| {
    305                     for (image, cache_type) in images {
    306                         render_images(
    307                             ui,
    308                             img_cache,
    309                             &image,
    310                             ImageType::Content(width.round() as u32, height.round() as u32),
    311                             cache_type,
    312                             |ui| {
    313                                 ui.allocate_space(egui::vec2(spinsz, spinsz));
    314                             },
    315                             |ui, _| {
    316                                 ui.allocate_space(egui::vec2(spinsz, spinsz));
    317                             },
    318                             |ui, url, renderable_media, gifs| {
    319                                 let texture = handle_repaint(
    320                                     ui,
    321                                     retrieve_latest_texture(&image, gifs, renderable_media),
    322                                 );
    323                                 let img_resp = ui.add(
    324                                     Image::new(texture)
    325                                         .max_height(height)
    326                                         .rounding(5.0)
    327                                         .fit_to_original_size(1.0),
    328                                 );
    329 
    330                                 img_resp.context_menu(|ui| {
    331                                     if ui.button("Copy Link").clicked() {
    332                                         ui.ctx().copy_text(url.to_owned());
    333                                         ui.close_menu();
    334                                     }
    335                                 });
    336                             },
    337                         );
    338                     }
    339                 })
    340                 .response
    341             })
    342             .inner
    343     });
    344 }