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