notedeck

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

contents.rs (14045B)


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