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