use super::media::image_carousel; use crate::{ note::{NoteAction, NoteOptions, NoteResponse, NoteView}, secondary_label, }; use egui::{Color32, Hyperlink, Label, RichText}; use nostrdb::{BlockType, Mention, Note, NoteKey, Transaction}; use notedeck::Localization; use notedeck::{ time_format, update_imeta_blurhashes, IsFollowing, NoteCache, NoteContext, NotedeckTextStyle, }; use notedeck::{JobsCache, RenderableMedia}; use tracing::warn; pub struct NoteContents<'a, 'd> { note_context: &'a mut NoteContext<'d>, txn: &'a Transaction, note: &'a Note<'a>, options: NoteOptions, pub action: Option, jobs: &'a mut JobsCache, } impl<'a, 'd> NoteContents<'a, 'd> { #[allow(clippy::too_many_arguments)] pub fn new( note_context: &'a mut NoteContext<'d>, txn: &'a Transaction, note: &'a Note, options: NoteOptions, jobs: &'a mut JobsCache, ) -> Self { NoteContents { note_context, txn, note, options, action: None, jobs, } } } impl egui::Widget for &mut NoteContents<'_, '_> { fn ui(self, ui: &mut egui::Ui) -> egui::Response { let result = render_note_contents( ui, self.note_context, self.txn, self.note, self.options, self.jobs, ); self.action = result.action; result.response } } fn render_client_name(ui: &mut egui::Ui, note_cache: &mut NoteCache, note: &Note, before: bool) { let cached_note = note_cache.cached_note_or_insert_mut(note.key().unwrap(), note); let Some(client) = cached_note.client.as_ref() else { return; }; if client.is_empty() { return; } if before { secondary_label(ui, "⋅"); } secondary_label(ui, format!("via {client}")); } /// Render an inline note preview with a border. These are used when /// notes are references within a note #[allow(clippy::too_many_arguments)] #[profiling::function] pub fn render_note_preview( ui: &mut egui::Ui, note_context: &mut NoteContext, txn: &Transaction, id: &[u8; 32], parent: NoteKey, note_options: NoteOptions, jobs: &mut JobsCache, ) -> NoteResponse { let note = if let Ok(note) = note_context.ndb.get_note_by_id(txn, id) { // TODO: support other preview kinds if note.kind() == 1 { note } else { return NoteResponse::new(ui.colored_label( Color32::RED, format!("TODO: can't preview kind {}", note.kind()), )); } } else { note_context .unknown_ids .add_note_id_if_missing(note_context.ndb, txn, id); return NoteResponse::new(ui.colored_label(Color32::RED, "TODO: COULD NOT LOAD")); /* return ui .horizontal(|ui| { ui.spacing_mut().item_spacing.x = 0.0; ui.colored_label(link_color, "@"); ui.colored_label(link_color, &id_str[4..16]); }) .response; */ }; NoteView::new(note_context, ¬e, note_options, jobs) .preview_style() .parent(parent) .show(ui) } /// Render note contents and surrounding info (client name, full date timestamp) fn render_note_contents( ui: &mut egui::Ui, note_context: &mut NoteContext, txn: &Transaction, note: &Note, options: NoteOptions, jobs: &mut JobsCache, ) -> NoteResponse { let response = render_undecorated_note_contents(ui, note_context, txn, note, options, jobs); ui.horizontal_wrapped(|ui| { note_bottom_metadata_ui( ui, note_context.i18n, note_context.note_cache, note, options, ); }); response } /// Client name, full timestamp, etc fn note_bottom_metadata_ui( ui: &mut egui::Ui, i18n: &mut Localization, note_cache: &mut NoteCache, note: &Note, options: NoteOptions, ) { let show_full_date = options.contains(NoteOptions::FullCreatedDate); if show_full_date { secondary_label(ui, time_format(i18n, note.created_at())); } if options.contains(NoteOptions::ClientName) { render_client_name(ui, note_cache, note, show_full_date); } } #[allow(clippy::too_many_arguments)] #[profiling::function] fn render_undecorated_note_contents<'a>( ui: &mut egui::Ui, note_context: &mut NoteContext, txn: &Transaction, note: &'a Note, options: NoteOptions, jobs: &mut JobsCache, ) -> NoteResponse { let note_key = note.key().expect("todo: implement non-db notes"); let selectable = options.contains(NoteOptions::SelectableText); let mut note_action: Option = None; let mut inline_note: Option<(&[u8; 32], &str)> = None; let hide_media = options.contains(NoteOptions::HideMedia); let link_color = ui.visuals().hyperlink_color; // The current length of the rendered blocks. Used in trucation logic let mut current_len: usize = 0; let truncate_len = 280; if !options.contains(NoteOptions::IsPreview) { // need this for the rect to take the full width of the column let _ = ui.allocate_at_least(egui::vec2(ui.available_width(), 0.0), egui::Sense::click()); } let mut supported_medias: Vec = vec![]; let response = ui.horizontal_wrapped(|ui| { ui.spacing_mut().item_spacing.x = 0.0; let blocks = if let Ok(blocks) = note_context.ndb.get_blocks_by_key(txn, note_key) { blocks } else { warn!("missing note content blocks? '{}'", note.content()); ui.weak(note.content()); return; }; for block in blocks.iter(note) { match block.blocktype() { BlockType::MentionBech32 => match block.as_mention().unwrap() { Mention::Profile(profile) => { let act = crate::Mention::new( note_context.ndb, note_context.img_cache, txn, profile.pubkey(), ) .show(ui); if act.is_some() { note_action = act; } } Mention::Pubkey(npub) => { let act = crate::Mention::new( note_context.ndb, note_context.img_cache, txn, npub.pubkey(), ) .show(ui); if act.is_some() { note_action = act; } } Mention::Note(note) if options.contains(NoteOptions::HasNotePreviews) => { inline_note = Some((note.id(), block.as_str())); } Mention::Event(note) if options.contains(NoteOptions::HasNotePreviews) => { inline_note = Some((note.id(), block.as_str())); } _ => { ui.colored_label( link_color, RichText::new(format!("@{}", &block.as_str()[..16])) .text_style(NotedeckTextStyle::NoteBody.text_style()), ); } }, BlockType::Hashtag => { if block.as_str().trim().is_empty() { continue; } let resp = ui .colored_label( link_color, RichText::new(format!("#{}", block.as_str())) .text_style(NotedeckTextStyle::NoteBody.text_style()), ) .on_hover_cursor(egui::CursorIcon::PointingHand); if resp.clicked() { note_action = Some(NoteAction::Hashtag(block.as_str().to_string())); } } BlockType::Url => { let mut found_supported = || -> bool { let url = block.as_str(); if !note_context.img_cache.metadata.contains_key(url) { update_imeta_blurhashes(note, &mut note_context.img_cache.metadata); } let Some(media) = note_context.img_cache.get_renderable_media(url) else { return false; }; supported_medias.push(media); true }; if hide_media || !found_supported() { if block.as_str().trim().is_empty() { continue; } ui.add(Hyperlink::from_label_and_url( RichText::new(block.as_str()) .color(link_color) .text_style(NotedeckTextStyle::NoteBody.text_style()), block.as_str(), )); } } BlockType::Text => { // truncate logic let mut truncate = false; let block_str = if options.contains(NoteOptions::Truncate) && (current_len + block.as_str().len() > truncate_len) { truncate = true; // The current block goes over the truncate length, // we'll need to truncate this block let block_str = block.as_str(); let closest = notedeck::abbrev::floor_char_boundary( block_str, truncate_len - current_len, ); &(block_str[..closest].to_string() + "…") } else { let block_str = block.as_str(); current_len += block_str.len(); block_str }; if block_str.trim().is_empty() { continue; } if options.contains(NoteOptions::ScrambleText) { ui.add( Label::new( RichText::new(rot13(block_str)) .text_style(NotedeckTextStyle::NoteBody.text_style()), ) .wrap() .selectable(selectable), ); } else { ui.add( Label::new( RichText::new(block_str) .text_style(NotedeckTextStyle::NoteBody.text_style()), ) .wrap() .selectable(selectable), ); } // don't render any more blocks if truncate { break; } } _ => { ui.colored_label(link_color, block.as_str()); } } } }); let preview_note_action = inline_note.and_then(|(id, _)| { render_note_preview(ui, note_context, txn, id, note_key, options, jobs) .action .map(|a| match a { NoteAction::Note { note_id, .. } => NoteAction::Note { note_id, preview: true, }, other => other, }) }); let mut media_action = None; if !supported_medias.is_empty() && !options.contains(NoteOptions::Textmode) { ui.add_space(2.0); let carousel_id = egui::Id::new(("carousel", note.key().expect("expected tx note"))); let is_self = note.pubkey() == note_context .accounts .get_selected_account() .key .pubkey .bytes(); let trusted_media = is_self || note_context .accounts .get_selected_account() .is_following(note.pubkey()) == IsFollowing::Yes; media_action = image_carousel( ui, note_context.img_cache, note_context.job_pool, jobs, &supported_medias, carousel_id, trusted_media, note_context.i18n, options, ); ui.add_space(2.0); } let note_action = preview_note_action .or(note_action) .or(media_action.map(NoteAction::Media)); NoteResponse::new(response.response).with_action(note_action) } fn rot13(input: &str) -> String { input .chars() .map(|c| { if c.is_ascii_lowercase() { // Rotate lowercase letters (((c as u8 - b'a' + 13) % 26) + b'a') as char } else if c.is_ascii_uppercase() { // Rotate uppercase letters (((c as u8 - b'A' + 13) % 26) + b'A') as char } else { // Leave other characters unchanged c } }) .collect() }