notedeck

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

contents.rs (13639B)


      1 use super::media::image_carousel;
      2 use crate::{
      3     note::{NoteAction, NoteOptions, NoteResponse, NoteView},
      4     secondary_label,
      5 };
      6 use egui::{Color32, Hyperlink, Label, RichText};
      7 use nostrdb::{BlockType, Mention, Note, NoteKey, Transaction};
      8 use notedeck::Localization;
      9 use notedeck::RenderableMedia;
     10 use notedeck::{time_format, update_imeta_blurhashes, NoteCache, NoteContext, NotedeckTextStyle};
     11 use tracing::warn;
     12 
     13 pub struct NoteContents<'a, 'd> {
     14     note_context: &'a mut NoteContext<'d>,
     15     txn: &'a Transaction,
     16     note: &'a Note<'a>,
     17     options: NoteOptions,
     18     pub action: Option<NoteAction>,
     19 }
     20 
     21 impl<'a, 'd> NoteContents<'a, 'd> {
     22     #[allow(clippy::too_many_arguments)]
     23     pub fn new(
     24         note_context: &'a mut NoteContext<'d>,
     25         txn: &'a Transaction,
     26         note: &'a Note,
     27         options: NoteOptions,
     28     ) -> Self {
     29         NoteContents {
     30             note_context,
     31             txn,
     32             note,
     33             options,
     34             action: None,
     35         }
     36     }
     37 }
     38 
     39 impl egui::Widget for &mut NoteContents<'_, '_> {
     40     fn ui(self, ui: &mut egui::Ui) -> egui::Response {
     41         let result = render_note_contents(ui, self.note_context, self.txn, self.note, self.options);
     42         self.action = result.action;
     43         result.response
     44     }
     45 }
     46 
     47 fn render_client_name(ui: &mut egui::Ui, note_cache: &mut NoteCache, note: &Note, before: bool) {
     48     let cached_note = note_cache.cached_note_or_insert_mut(note.key().unwrap(), note);
     49 
     50     let Some(client) = cached_note.client.as_ref() else {
     51         return;
     52     };
     53 
     54     if client.is_empty() {
     55         return;
     56     }
     57 
     58     if before {
     59         secondary_label(ui, "⋅");
     60     }
     61 
     62     secondary_label(ui, format!("via {client}"));
     63 }
     64 
     65 /// Render an inline note preview with a border. These are used when
     66 /// notes are references within a note
     67 #[allow(clippy::too_many_arguments)]
     68 #[profiling::function]
     69 pub fn render_note_preview(
     70     ui: &mut egui::Ui,
     71     note_context: &mut NoteContext,
     72     txn: &Transaction,
     73     id: &[u8; 32],
     74     parent: NoteKey,
     75     note_options: NoteOptions,
     76 ) -> NoteResponse {
     77     let note = if let Ok(note) = note_context.ndb.get_note_by_id(txn, id) {
     78         // TODO: support other preview kinds
     79         if note.kind() == 1 {
     80             note
     81         } else {
     82             return NoteResponse::new(ui.colored_label(
     83                 Color32::RED,
     84                 format!("TODO: can't preview kind {}", note.kind()),
     85             ));
     86         }
     87     } else {
     88         note_context
     89             .unknown_ids
     90             .add_note_id_if_missing(note_context.ndb, txn, id);
     91 
     92         return NoteResponse::new(ui.colored_label(Color32::RED, "TODO: COULD NOT LOAD"));
     93         /*
     94         return ui
     95             .horizontal(|ui| {
     96                 ui.spacing_mut().item_spacing.x = 0.0;
     97                 ui.colored_label(link_color, "@");
     98                 ui.colored_label(link_color, &id_str[4..16]);
     99             })
    100             .response;
    101             */
    102     };
    103 
    104     NoteView::new(note_context, &note, note_options)
    105         .preview_style()
    106         .parent(parent)
    107         .show(ui)
    108 }
    109 
    110 /// Render note contents and surrounding info (client name, full date timestamp)
    111 fn render_note_contents(
    112     ui: &mut egui::Ui,
    113     note_context: &mut NoteContext,
    114     txn: &Transaction,
    115     note: &Note,
    116     options: NoteOptions,
    117 ) -> NoteResponse {
    118     let response = render_undecorated_note_contents(ui, note_context, txn, note, options);
    119 
    120     ui.horizontal_wrapped(|ui| {
    121         note_bottom_metadata_ui(
    122             ui,
    123             note_context.i18n,
    124             note_context.note_cache,
    125             note,
    126             options,
    127         );
    128     });
    129 
    130     response
    131 }
    132 
    133 /// Client name, full timestamp, etc
    134 fn note_bottom_metadata_ui(
    135     ui: &mut egui::Ui,
    136     i18n: &mut Localization,
    137     note_cache: &mut NoteCache,
    138     note: &Note,
    139     options: NoteOptions,
    140 ) {
    141     let show_full_date = options.contains(NoteOptions::FullCreatedDate);
    142 
    143     if show_full_date {
    144         secondary_label(ui, time_format(i18n, note.created_at()));
    145     }
    146 
    147     if options.contains(NoteOptions::ClientName) {
    148         render_client_name(ui, note_cache, note, show_full_date);
    149     }
    150 }
    151 
    152 #[allow(clippy::too_many_arguments)]
    153 #[profiling::function]
    154 fn render_undecorated_note_contents<'a>(
    155     ui: &mut egui::Ui,
    156     note_context: &mut NoteContext,
    157     txn: &Transaction,
    158     note: &'a Note,
    159     options: NoteOptions,
    160 ) -> NoteResponse {
    161     let note_key = note.key().expect("todo: implement non-db notes");
    162     let selectable = options.contains(NoteOptions::SelectableText);
    163     let mut note_action: Option<NoteAction> = None;
    164     let mut inline_note: Option<(&[u8; 32], &str)> = None;
    165     let hide_media = options.contains(NoteOptions::HideMedia);
    166     let link_color = ui.visuals().hyperlink_color;
    167 
    168     // The current length of the rendered blocks. Used in trucation logic
    169     let mut current_len: usize = 0;
    170     let truncate_len = 280;
    171 
    172     if !options.contains(NoteOptions::IsPreview) {
    173         // need this for the rect to take the full width of the column
    174         let _ = ui.allocate_at_least(egui::vec2(ui.available_width(), 0.0), egui::Sense::click());
    175     }
    176 
    177     let mut supported_medias: Vec<RenderableMedia> = vec![];
    178 
    179     let response = ui.horizontal_wrapped(|ui| {
    180         ui.spacing_mut().item_spacing.x = 1.0;
    181 
    182         let blocks = if let Ok(blocks) = note_context.ndb.get_blocks_by_key(txn, note_key) {
    183             blocks
    184         } else {
    185             warn!("missing note content blocks? '{}'", note.content());
    186             ui.weak(note.content());
    187             return;
    188         };
    189 
    190         for block in blocks.iter(note) {
    191             match block.blocktype() {
    192                 BlockType::MentionBech32 => match block.as_mention().unwrap() {
    193                     Mention::Profile(profile) => {
    194                         profiling::scope!("profile-block");
    195                         let act = crate::Mention::new(
    196                             note_context.ndb,
    197                             note_context.img_cache,
    198                             note_context.jobs,
    199                             txn,
    200                             profile.pubkey(),
    201                         )
    202                         .show(ui);
    203 
    204                         if act.is_some() {
    205                             note_action = act;
    206                         }
    207                     }
    208 
    209                     Mention::Pubkey(npub) => {
    210                         profiling::scope!("pubkey-block");
    211                         let act = crate::Mention::new(
    212                             note_context.ndb,
    213                             note_context.img_cache,
    214                             note_context.jobs,
    215                             txn,
    216                             npub.pubkey(),
    217                         )
    218                         .show(ui);
    219 
    220                         if act.is_some() {
    221                             note_action = act;
    222                         }
    223                     }
    224 
    225                     Mention::Note(note) if options.contains(NoteOptions::HasNotePreviews) => {
    226                         inline_note = Some((note.id(), block.as_str()));
    227                     }
    228 
    229                     Mention::Event(note) if options.contains(NoteOptions::HasNotePreviews) => {
    230                         inline_note = Some((note.id(), block.as_str()));
    231                     }
    232 
    233                     _ => {
    234                         ui.colored_label(
    235                             link_color,
    236                             RichText::new(format!("@{}", &block.as_str()[..16]))
    237                                 .text_style(NotedeckTextStyle::NoteBody.text_style()),
    238                         );
    239                     }
    240                 },
    241 
    242                 BlockType::Hashtag => {
    243                     profiling::scope!("hashtag-block");
    244                     if block.as_str().trim().is_empty() {
    245                         continue;
    246                     }
    247                     let resp = ui
    248                         .colored_label(
    249                             link_color,
    250                             RichText::new(format!("#{}", block.as_str()))
    251                                 .text_style(NotedeckTextStyle::NoteBody.text_style()),
    252                         )
    253                         .on_hover_cursor(egui::CursorIcon::PointingHand);
    254 
    255                     if resp.clicked() {
    256                         note_action = Some(NoteAction::Hashtag(block.as_str().to_string()));
    257                     }
    258                 }
    259 
    260                 BlockType::Url => {
    261                     profiling::scope!("url-block");
    262                     let mut found_supported = || -> bool {
    263                         let url = block.as_str();
    264 
    265                         if !note_context.img_cache.metadata.contains_key(url) {
    266                             update_imeta_blurhashes(note, &mut note_context.img_cache.metadata);
    267                         }
    268 
    269                         let Some(media) = note_context.img_cache.get_renderable_media(url) else {
    270                             return false;
    271                         };
    272 
    273                         supported_medias.push(media);
    274                         true
    275                     };
    276 
    277                     if hide_media || !found_supported() {
    278                         if block.as_str().trim().is_empty() {
    279                             continue;
    280                         }
    281                         ui.add(Hyperlink::from_label_and_url(
    282                             RichText::new(block.as_str())
    283                                 .color(link_color)
    284                                 .text_style(NotedeckTextStyle::NoteBody.text_style()),
    285                             block.as_str(),
    286                         ));
    287                     }
    288                 }
    289 
    290                 BlockType::Text => {
    291                     profiling::scope!("text-block");
    292                     // truncate logic
    293                     let mut truncate = false;
    294                     let block_str = if options.contains(NoteOptions::Truncate)
    295                         && (current_len + block.as_str().len() > truncate_len)
    296                     {
    297                         truncate = true;
    298                         // The current block goes over the truncate length,
    299                         // we'll need to truncate this block
    300                         let block_str = block.as_str();
    301                         let closest = notedeck::abbrev::floor_char_boundary(
    302                             block_str,
    303                             truncate_len - current_len,
    304                         );
    305                         &(block_str[..closest].to_string() + "…")
    306                     } else {
    307                         let block_str = block.as_str();
    308                         current_len += block_str.len();
    309                         block_str
    310                     };
    311                     if block_str.trim().is_empty() {
    312                         continue;
    313                     }
    314                     if options.contains(NoteOptions::ScrambleText) {
    315                         ui.add(
    316                             Label::new(
    317                                 RichText::new(rot13(block_str))
    318                                     .text_style(NotedeckTextStyle::NoteBody.text_style()),
    319                             )
    320                             .wrap()
    321                             .selectable(selectable),
    322                         );
    323                     } else {
    324                         let mut richtext = RichText::new(block_str)
    325                             .text_style(NotedeckTextStyle::NoteBody.text_style());
    326 
    327                         if options.contains(NoteOptions::NotificationPreview) {
    328                             richtext = richtext.color(egui::Color32::from_rgb(0x87, 0x87, 0x8D));
    329                         }
    330 
    331                         ui.add(Label::new(richtext).wrap().selectable(selectable));
    332                     }
    333                     // don't render any more blocks
    334                     if truncate {
    335                         break;
    336                     }
    337                 }
    338 
    339                 _ => {
    340                     ui.colored_label(link_color, block.as_str());
    341                 }
    342             }
    343         }
    344     });
    345 
    346     let preview_note_action = inline_note.and_then(|(id, _)| {
    347         render_note_preview(ui, note_context, txn, id, note_key, options)
    348             .action
    349             .map(|a| match a {
    350                 NoteAction::Note { note_id, .. } => NoteAction::Note {
    351                     note_id,
    352                     preview: true,
    353                     scroll_offset: 0.0,
    354                 },
    355                 other => other,
    356             })
    357     });
    358 
    359     let mut media_action = None;
    360     if !supported_medias.is_empty() && !options.contains(NoteOptions::Textmode) {
    361         ui.add_space(2.0);
    362         let carousel_id = egui::Id::new(("carousel", note.key().expect("expected tx note")));
    363 
    364         media_action = image_carousel(
    365             ui,
    366             note_context.img_cache,
    367             note_context.jobs,
    368             &supported_medias,
    369             carousel_id,
    370             note_context.i18n,
    371             options,
    372         );
    373         ui.add_space(2.0);
    374     }
    375 
    376     let note_action = preview_note_action
    377         .or(note_action)
    378         .or(media_action.map(NoteAction::Media));
    379 
    380     NoteResponse::new(response.response).with_action(note_action)
    381 }
    382 
    383 fn rot13(input: &str) -> String {
    384     input
    385         .chars()
    386         .map(|c| {
    387             if c.is_ascii_lowercase() {
    388                 // Rotate lowercase letters
    389                 (((c as u8 - b'a' + 13) % 26) + b'a') as char
    390             } else if c.is_ascii_uppercase() {
    391                 // Rotate uppercase letters
    392                 (((c as u8 - b'A' + 13) % 26) + b'A') as char
    393             } else {
    394                 // Leave other characters unchanged
    395                 c
    396             }
    397         })
    398         .collect()
    399 }