contents.rs (12043B)
1 use crate::gif::{handle_repaint, retrieve_latest_texture}; 2 use crate::ui::images::render_images; 3 use crate::ui::{ 4 self, 5 note::{NoteOptions, NoteResponse}, 6 }; 7 use crate::{actionbar::NoteAction, images::ImageType, timeline::TimelineKind}; 8 use egui::{Color32, Hyperlink, Image, RichText}; 9 use nostrdb::{BlockType, Mention, Ndb, Note, NoteKey, Transaction}; 10 use tracing::warn; 11 12 use notedeck::{supported_mime_hosted_at_url, Images, MediaCacheType, NoteCache}; 13 14 /// Aggregates dependencies to reduce the number of parameters 15 /// passed to inner UI elements, minimizing prop drilling. 16 pub struct NoteContext<'d> { 17 pub ndb: &'d Ndb, 18 pub img_cache: &'d mut Images, 19 pub note_cache: &'d mut NoteCache, 20 } 21 22 pub struct NoteContents<'a, 'd> { 23 note_context: &'a mut NoteContext<'d>, 24 txn: &'a Transaction, 25 note: &'a Note<'a>, 26 options: NoteOptions, 27 action: Option<NoteAction>, 28 } 29 30 impl<'a, 'd> NoteContents<'a, 'd> { 31 #[allow(clippy::too_many_arguments)] 32 pub fn new( 33 note_context: &'a mut NoteContext<'d>, 34 txn: &'a Transaction, 35 note: &'a Note, 36 options: ui::note::NoteOptions, 37 ) -> Self { 38 NoteContents { 39 note_context, 40 txn, 41 note, 42 options, 43 action: None, 44 } 45 } 46 47 pub fn action(&self) -> &Option<NoteAction> { 48 &self.action 49 } 50 } 51 52 impl egui::Widget for &mut NoteContents<'_, '_> { 53 fn ui(self, ui: &mut egui::Ui) -> egui::Response { 54 let result = render_note_contents(ui, self.note_context, self.txn, self.note, self.options); 55 self.action = result.action; 56 result.response 57 } 58 } 59 60 /// Render an inline note preview with a border. These are used when 61 /// notes are references within a note 62 #[allow(clippy::too_many_arguments)] 63 pub fn render_note_preview( 64 ui: &mut egui::Ui, 65 note_context: &mut NoteContext, 66 txn: &Transaction, 67 id: &[u8; 32], 68 parent: NoteKey, 69 note_options: NoteOptions, 70 ) -> NoteResponse { 71 #[cfg(feature = "profiling")] 72 puffin::profile_function!(); 73 74 let note = if let Ok(note) = note_context.ndb.get_note_by_id(txn, id) { 75 // TODO: support other preview kinds 76 if note.kind() == 1 { 77 note 78 } else { 79 return NoteResponse::new(ui.colored_label( 80 Color32::RED, 81 format!("TODO: can't preview kind {}", note.kind()), 82 )); 83 } 84 } else { 85 return NoteResponse::new(ui.colored_label(Color32::RED, "TODO: COULD NOT LOAD")); 86 /* 87 return ui 88 .horizontal(|ui| { 89 ui.spacing_mut().item_spacing.x = 0.0; 90 ui.colored_label(link_color, "@"); 91 ui.colored_label(link_color, &id_str[4..16]); 92 }) 93 .response; 94 */ 95 }; 96 97 egui::Frame::new() 98 .fill(ui.visuals().noninteractive().weak_bg_fill) 99 .inner_margin(egui::Margin::same(8)) 100 .outer_margin(egui::Margin::symmetric(0, 8)) 101 .rounding(egui::Rounding::same(10)) 102 .stroke(egui::Stroke::new( 103 1.0, 104 ui.visuals().noninteractive().bg_stroke.color, 105 )) 106 .show(ui, |ui| { 107 ui::NoteView::new(note_context, ¬e, note_options) 108 .actionbar(false) 109 .small_pfp(true) 110 .wide(true) 111 .note_previews(false) 112 .options_button(true) 113 .parent(parent) 114 .is_preview(true) 115 .show(ui) 116 }) 117 .inner 118 } 119 120 #[allow(clippy::too_many_arguments)] 121 fn render_note_contents( 122 ui: &mut egui::Ui, 123 note_context: &mut NoteContext, 124 txn: &Transaction, 125 note: &Note, 126 options: NoteOptions, 127 ) -> NoteResponse { 128 #[cfg(feature = "profiling")] 129 puffin::profile_function!(); 130 131 let note_key = note.key().expect("todo: implement non-db notes"); 132 let selectable = options.has_selectable_text(); 133 let mut images: Vec<(String, MediaCacheType)> = vec![]; 134 let mut note_action: Option<NoteAction> = None; 135 let mut inline_note: Option<(&[u8; 32], &str)> = None; 136 let hide_media = options.has_hide_media(); 137 let link_color = ui.visuals().hyperlink_color; 138 139 if !options.has_is_preview() { 140 // need this for the rect to take the full width of the column 141 let _ = ui.allocate_at_least(egui::vec2(ui.available_width(), 0.0), egui::Sense::click()); 142 } 143 144 let response = ui.horizontal_wrapped(|ui| { 145 let blocks = if let Ok(blocks) = note_context.ndb.get_blocks_by_key(txn, note_key) { 146 blocks 147 } else { 148 warn!("missing note content blocks? '{}'", note.content()); 149 ui.weak(note.content()); 150 return; 151 }; 152 153 ui.spacing_mut().item_spacing.x = 0.0; 154 155 for block in blocks.iter(note) { 156 match block.blocktype() { 157 BlockType::MentionBech32 => match block.as_mention().unwrap() { 158 Mention::Profile(profile) => { 159 let act = ui::Mention::new( 160 note_context.ndb, 161 note_context.img_cache, 162 txn, 163 profile.pubkey(), 164 ) 165 .show(ui) 166 .inner; 167 if act.is_some() { 168 note_action = act; 169 } 170 } 171 172 Mention::Pubkey(npub) => { 173 let act = ui::Mention::new( 174 note_context.ndb, 175 note_context.img_cache, 176 txn, 177 npub.pubkey(), 178 ) 179 .show(ui) 180 .inner; 181 if act.is_some() { 182 note_action = act; 183 } 184 } 185 186 Mention::Note(note) if options.has_note_previews() => { 187 inline_note = Some((note.id(), block.as_str())); 188 } 189 190 Mention::Event(note) if options.has_note_previews() => { 191 inline_note = Some((note.id(), block.as_str())); 192 } 193 194 _ => { 195 ui.colored_label(link_color, format!("@{}", &block.as_str()[4..16])); 196 } 197 }, 198 199 BlockType::Hashtag => { 200 #[cfg(feature = "profiling")] 201 puffin::profile_scope!("hashtag contents"); 202 let resp = ui.colored_label(link_color, format!("#{}", block.as_str())); 203 204 if resp.clicked() { 205 note_action = Some(NoteAction::OpenTimeline(TimelineKind::Hashtag( 206 block.as_str().to_string(), 207 ))); 208 } else if resp.hovered() { 209 ui::show_pointer(ui); 210 } 211 } 212 213 BlockType::Url => { 214 let mut found_supported = || -> bool { 215 let url = block.as_str(); 216 if let Some(cache_type) = 217 supported_mime_hosted_at_url(&mut note_context.img_cache.urls, url) 218 { 219 images.push((url.to_string(), cache_type)); 220 true 221 } else { 222 false 223 } 224 }; 225 if hide_media || !found_supported() { 226 #[cfg(feature = "profiling")] 227 puffin::profile_scope!("url contents"); 228 ui.add(Hyperlink::from_label_and_url( 229 RichText::new(block.as_str()).color(link_color), 230 block.as_str(), 231 )); 232 } 233 } 234 235 BlockType::Text => { 236 #[cfg(feature = "profiling")] 237 puffin::profile_scope!("text contents"); 238 if options.has_scramble_text() { 239 ui.add(egui::Label::new(rot13(block.as_str())).selectable(selectable)); 240 } else { 241 ui.add(egui::Label::new(block.as_str()).selectable(selectable)); 242 } 243 } 244 245 _ => { 246 ui.colored_label(link_color, block.as_str()); 247 } 248 } 249 } 250 }); 251 252 let preview_note_action = if let Some((id, _block_str)) = inline_note { 253 render_note_preview(ui, note_context, txn, id, note_key, options).action 254 } else { 255 None 256 }; 257 258 if !images.is_empty() && !options.has_textmode() { 259 ui.add_space(2.0); 260 let carousel_id = egui::Id::new(("carousel", note.key().expect("expected tx note"))); 261 image_carousel(ui, note_context.img_cache, images, carousel_id); 262 ui.add_space(2.0); 263 } 264 265 let note_action = preview_note_action.or(note_action); 266 267 NoteResponse::new(response.response).with_action(note_action) 268 } 269 270 fn rot13(input: &str) -> String { 271 input 272 .chars() 273 .map(|c| { 274 if c.is_ascii_lowercase() { 275 // Rotate lowercase letters 276 (((c as u8 - b'a' + 13) % 26) + b'a') as char 277 } else if c.is_ascii_uppercase() { 278 // Rotate uppercase letters 279 (((c as u8 - b'A' + 13) % 26) + b'A') as char 280 } else { 281 // Leave other characters unchanged 282 c 283 } 284 }) 285 .collect() 286 } 287 288 fn image_carousel( 289 ui: &mut egui::Ui, 290 img_cache: &mut Images, 291 images: Vec<(String, MediaCacheType)>, 292 carousel_id: egui::Id, 293 ) { 294 // let's make sure everything is within our area 295 296 let height = 360.0; 297 let width = ui.available_size().x; 298 let spinsz = if height > width { width } else { height }; 299 300 ui.add_sized([width, height], |ui: &mut egui::Ui| { 301 egui::ScrollArea::horizontal() 302 .id_salt(carousel_id) 303 .show(ui, |ui| { 304 ui.horizontal(|ui| { 305 for (image, cache_type) in images { 306 render_images( 307 ui, 308 img_cache, 309 &image, 310 ImageType::Content(width.round() as u32, height.round() as u32), 311 cache_type, 312 |ui| { 313 ui.allocate_space(egui::vec2(spinsz, spinsz)); 314 }, 315 |ui, _| { 316 ui.allocate_space(egui::vec2(spinsz, spinsz)); 317 }, 318 |ui, url, renderable_media, gifs| { 319 let texture = handle_repaint( 320 ui, 321 retrieve_latest_texture(&image, gifs, renderable_media), 322 ); 323 let img_resp = ui.add( 324 Image::new(texture) 325 .max_height(height) 326 .rounding(5.0) 327 .fit_to_original_size(1.0), 328 ); 329 330 img_resp.context_menu(|ui| { 331 if ui.button("Copy Link").clicked() { 332 ui.ctx().copy_text(url.to_owned()); 333 ui.close_menu(); 334 } 335 }); 336 }, 337 ); 338 } 339 }) 340 .response 341 }) 342 .inner 343 }); 344 }