contents.rs (10601B)
1 use crate::actionbar::NoteAction; 2 use crate::images::ImageType; 3 use crate::imgcache::ImageCache; 4 use crate::notecache::NoteCache; 5 use crate::ui::note::{NoteOptions, NoteResponse}; 6 use crate::ui::ProfilePic; 7 use crate::{colors, ui}; 8 use egui::{Color32, Hyperlink, Image, RichText}; 9 use nostrdb::{BlockType, Mention, Ndb, Note, NoteKey, Transaction}; 10 use tracing::warn; 11 12 pub struct NoteContents<'a> { 13 ndb: &'a Ndb, 14 img_cache: &'a mut ImageCache, 15 note_cache: &'a mut NoteCache, 16 txn: &'a Transaction, 17 note: &'a Note<'a>, 18 note_key: NoteKey, 19 options: NoteOptions, 20 action: Option<NoteAction>, 21 } 22 23 impl<'a> NoteContents<'a> { 24 pub fn new( 25 ndb: &'a Ndb, 26 img_cache: &'a mut ImageCache, 27 note_cache: &'a mut NoteCache, 28 txn: &'a Transaction, 29 note: &'a Note, 30 note_key: NoteKey, 31 options: ui::note::NoteOptions, 32 ) -> Self { 33 NoteContents { 34 ndb, 35 img_cache, 36 note_cache, 37 txn, 38 note, 39 note_key, 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.note_key, 60 self.options, 61 ); 62 self.action = result.action; 63 result.response 64 } 65 } 66 67 /// Render an inline note preview with a border. These are used when 68 /// notes are references within a note 69 pub fn render_note_preview( 70 ui: &mut egui::Ui, 71 ndb: &Ndb, 72 note_cache: &mut NoteCache, 73 img_cache: &mut ImageCache, 74 txn: &Transaction, 75 id: &[u8; 32], 76 parent: NoteKey, 77 ) -> NoteResponse { 78 #[cfg(feature = "profiling")] 79 puffin::profile_function!(); 80 81 let note = if let Ok(note) = ndb.get_note_by_id(txn, id) { 82 // TODO: support other preview kinds 83 if note.kind() == 1 { 84 note 85 } else { 86 return NoteResponse::new(ui.colored_label( 87 Color32::RED, 88 format!("TODO: can't preview kind {}", note.kind()), 89 )); 90 } 91 } else { 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(colors::PURPLE, "@"); 98 ui.colored_label(colors::PURPLE, &id_str[4..16]); 99 }) 100 .response; 101 */ 102 }; 103 104 egui::Frame::none() 105 .fill(ui.visuals().noninteractive().weak_bg_fill) 106 .inner_margin(egui::Margin::same(8.0)) 107 .outer_margin(egui::Margin::symmetric(0.0, 8.0)) 108 .rounding(egui::Rounding::same(10.0)) 109 .stroke(egui::Stroke::new( 110 1.0, 111 ui.visuals().noninteractive().bg_stroke.color, 112 )) 113 .show(ui, |ui| { 114 ui::NoteView::new(ndb, note_cache, img_cache, ¬e) 115 .actionbar(false) 116 .small_pfp(true) 117 .wide(true) 118 .note_previews(false) 119 .options_button(true) 120 .parent(parent) 121 .show(ui) 122 }) 123 .inner 124 } 125 126 fn is_image_link(url: &str) -> bool { 127 url.ends_with("png") || url.ends_with("jpg") || url.ends_with("jpeg") 128 } 129 130 #[allow(clippy::too_many_arguments)] 131 fn render_note_contents( 132 ui: &mut egui::Ui, 133 ndb: &Ndb, 134 img_cache: &mut ImageCache, 135 note_cache: &mut NoteCache, 136 txn: &Transaction, 137 note: &Note, 138 note_key: NoteKey, 139 options: NoteOptions, 140 ) -> NoteResponse { 141 #[cfg(feature = "profiling")] 142 puffin::profile_function!(); 143 144 let selectable = options.has_selectable_text(); 145 let mut images: Vec<String> = vec![]; 146 let mut inline_note: Option<(&[u8; 32], &str)> = None; 147 let hide_media = options.has_hide_media(); 148 149 let response = ui.horizontal_wrapped(|ui| { 150 let blocks = if let Ok(blocks) = ndb.get_blocks_by_key(txn, note_key) { 151 blocks 152 } else { 153 warn!("missing note content blocks? '{}'", note.content()); 154 ui.weak(note.content()); 155 return; 156 }; 157 158 ui.spacing_mut().item_spacing.x = 0.0; 159 160 for block in blocks.iter(note) { 161 match block.blocktype() { 162 BlockType::MentionBech32 => match block.as_mention().unwrap() { 163 Mention::Profile(profile) => { 164 ui.add(ui::Mention::new(ndb, img_cache, txn, profile.pubkey())); 165 } 166 167 Mention::Pubkey(npub) => { 168 ui.add(ui::Mention::new(ndb, img_cache, txn, npub.pubkey())); 169 } 170 171 Mention::Note(note) if options.has_note_previews() => { 172 inline_note = Some((note.id(), block.as_str())); 173 } 174 175 Mention::Event(note) if options.has_note_previews() => { 176 inline_note = Some((note.id(), block.as_str())); 177 } 178 179 _ => { 180 ui.colored_label(colors::PURPLE, format!("@{}", &block.as_str()[4..16])); 181 } 182 }, 183 184 BlockType::Hashtag => { 185 #[cfg(feature = "profiling")] 186 puffin::profile_scope!("hashtag contents"); 187 ui.colored_label(colors::PURPLE, format!("#{}", block.as_str())); 188 } 189 190 BlockType::Url => { 191 let lower_url = block.as_str().to_lowercase(); 192 if !hide_media && is_image_link(&lower_url) { 193 images.push(block.as_str().to_string()); 194 } else { 195 #[cfg(feature = "profiling")] 196 puffin::profile_scope!("url contents"); 197 ui.add(Hyperlink::from_label_and_url( 198 RichText::new(block.as_str()).color(colors::PURPLE), 199 block.as_str(), 200 )); 201 } 202 } 203 204 BlockType::Text => { 205 #[cfg(feature = "profiling")] 206 puffin::profile_scope!("text contents"); 207 ui.add(egui::Label::new(block.as_str()).selectable(selectable)); 208 } 209 210 _ => { 211 ui.colored_label(colors::PURPLE, block.as_str()); 212 } 213 } 214 } 215 }); 216 217 let note_action = if let Some((id, _block_str)) = inline_note { 218 render_note_preview(ui, ndb, note_cache, img_cache, txn, id, note_key).action 219 } else { 220 None 221 }; 222 223 if !images.is_empty() && !options.has_textmode() { 224 ui.add_space(2.0); 225 let carousel_id = egui::Id::new(("carousel", note.key().expect("expected tx note"))); 226 image_carousel(ui, img_cache, images, carousel_id); 227 ui.add_space(2.0); 228 } 229 230 NoteResponse::new(response.response).with_action(note_action) 231 } 232 233 fn image_carousel( 234 ui: &mut egui::Ui, 235 img_cache: &mut ImageCache, 236 images: Vec<String>, 237 carousel_id: egui::Id, 238 ) { 239 // let's make sure everything is within our area 240 241 let height = 360.0; 242 let width = ui.available_size().x; 243 let spinsz = if height > width { width } else { height }; 244 245 ui.add_sized([width, height], |ui: &mut egui::Ui| { 246 egui::ScrollArea::horizontal() 247 .id_salt(carousel_id) 248 .show(ui, |ui| { 249 ui.horizontal(|ui| { 250 for image in images { 251 // If the cache is empty, initiate the fetch 252 let m_cached_promise = img_cache.map().get(&image); 253 if m_cached_promise.is_none() { 254 let res = crate::images::fetch_img( 255 img_cache, 256 ui.ctx(), 257 &image, 258 ImageType::Content(width.round() as u32, height.round() as u32), 259 ); 260 img_cache.map_mut().insert(image.to_owned(), res); 261 } 262 263 // What is the state of the fetch? 264 match img_cache.map()[&image].ready() { 265 // Still waiting 266 None => { 267 ui.allocate_space(egui::vec2(spinsz, spinsz)); 268 //ui.add(egui::Spinner::new().size(spinsz)); 269 } 270 // Failed to fetch image! 271 Some(Err(_err)) => { 272 // FIXME - use content-specific error instead 273 let no_pfp = crate::images::fetch_img( 274 img_cache, 275 ui.ctx(), 276 ProfilePic::no_pfp_url(), 277 ImageType::Profile(128), 278 ); 279 img_cache.map_mut().insert(image.to_owned(), no_pfp); 280 // spin until next pass 281 ui.allocate_space(egui::vec2(spinsz, spinsz)); 282 //ui.add(egui::Spinner::new().size(spinsz)); 283 } 284 // Use the previously resolved image 285 Some(Ok(img)) => { 286 let img_resp = ui.add( 287 Image::new(img) 288 .max_height(height) 289 .rounding(5.0) 290 .fit_to_original_size(1.0), 291 ); 292 img_resp.context_menu(|ui| { 293 if ui.button("Copy Link").clicked() { 294 ui.ctx().copy_text(image); 295 ui.close_menu(); 296 } 297 }); 298 } 299 } 300 } 301 }) 302 .response 303 }) 304 .inner 305 }); 306 }