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, ¬e, 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 }