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