notedeck

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

contents.rs (11805B)


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