contents.rs (10481B)
1 use crate::actionbar::NoteActionResponse; 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: NoteActionResponse, 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: NoteActionResponse::default(), 42 } 43 } 44 45 pub fn action(&self) -> &NoteActionResponse { 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 _id_str: &str, 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 .show(ui) 121 }) 122 .inner 123 } 124 125 #[allow(clippy::too_many_arguments)] 126 fn render_note_contents( 127 ui: &mut egui::Ui, 128 ndb: &Ndb, 129 img_cache: &mut ImageCache, 130 note_cache: &mut NoteCache, 131 txn: &Transaction, 132 note: &Note, 133 note_key: NoteKey, 134 options: NoteOptions, 135 ) -> NoteResponse { 136 #[cfg(feature = "profiling")] 137 puffin::profile_function!(); 138 139 let selectable = options.has_selectable_text(); 140 let mut images: Vec<String> = vec![]; 141 let mut inline_note: Option<(&[u8; 32], &str)> = None; 142 143 let response = ui.horizontal_wrapped(|ui| { 144 let blocks = if let Ok(blocks) = ndb.get_blocks_by_key(txn, note_key) { 145 blocks 146 } else { 147 warn!("missing note content blocks? '{}'", note.content()); 148 ui.weak(note.content()); 149 return; 150 }; 151 152 ui.spacing_mut().item_spacing.x = 0.0; 153 154 for block in blocks.iter(note) { 155 match block.blocktype() { 156 BlockType::MentionBech32 => match block.as_mention().unwrap() { 157 Mention::Profile(profile) => { 158 ui.add(ui::Mention::new(ndb, img_cache, txn, profile.pubkey())); 159 } 160 161 Mention::Pubkey(npub) => { 162 ui.add(ui::Mention::new(ndb, img_cache, txn, npub.pubkey())); 163 } 164 165 Mention::Note(note) if options.has_note_previews() => { 166 inline_note = Some((note.id(), block.as_str())); 167 } 168 169 Mention::Event(note) if options.has_note_previews() => { 170 inline_note = Some((note.id(), block.as_str())); 171 } 172 173 _ => { 174 ui.colored_label(colors::PURPLE, format!("@{}", &block.as_str()[4..16])); 175 } 176 }, 177 178 BlockType::Hashtag => { 179 #[cfg(feature = "profiling")] 180 puffin::profile_scope!("hashtag contents"); 181 ui.colored_label(colors::PURPLE, format!("#{}", block.as_str())); 182 } 183 184 BlockType::Url => { 185 let lower_url = block.as_str().to_lowercase(); 186 if lower_url.ends_with("png") || lower_url.ends_with("jpg") { 187 images.push(block.as_str().to_string()); 188 } else { 189 #[cfg(feature = "profiling")] 190 puffin::profile_scope!("url contents"); 191 ui.add(Hyperlink::from_label_and_url( 192 RichText::new(block.as_str()).color(colors::PURPLE), 193 block.as_str(), 194 )); 195 } 196 } 197 198 BlockType::Text => { 199 #[cfg(feature = "profiling")] 200 puffin::profile_scope!("text contents"); 201 ui.add(egui::Label::new(block.as_str()).selectable(selectable)); 202 } 203 204 _ => { 205 ui.colored_label(colors::PURPLE, block.as_str()); 206 } 207 } 208 } 209 }); 210 211 let note_action = if let Some((id, block_str)) = inline_note { 212 render_note_preview(ui, ndb, note_cache, img_cache, txn, id, block_str).action 213 } else { 214 NoteActionResponse::default() 215 }; 216 217 if !images.is_empty() && !options.has_textmode() { 218 ui.add_space(2.0); 219 let carousel_id = egui::Id::new(("carousel", note.key().expect("expected tx note"))); 220 image_carousel(ui, img_cache, images, carousel_id); 221 ui.add_space(2.0); 222 } 223 224 NoteResponse::new(response.response).with_action(note_action) 225 } 226 227 fn image_carousel( 228 ui: &mut egui::Ui, 229 img_cache: &mut ImageCache, 230 images: Vec<String>, 231 carousel_id: egui::Id, 232 ) { 233 // let's make sure everything is within our area 234 235 let height = 360.0; 236 let width = ui.available_size().x; 237 let spinsz = if height > width { width } else { height }; 238 239 ui.add_sized([width, height], |ui: &mut egui::Ui| { 240 egui::ScrollArea::horizontal() 241 .id_source(carousel_id) 242 .show(ui, |ui| { 243 ui.horizontal(|ui| { 244 for image in images { 245 // If the cache is empty, initiate the fetch 246 let m_cached_promise = img_cache.map().get(&image); 247 if m_cached_promise.is_none() { 248 let res = crate::images::fetch_img( 249 img_cache, 250 ui.ctx(), 251 &image, 252 ImageType::Content(width.round() as u32, height.round() as u32), 253 ); 254 img_cache.map_mut().insert(image.to_owned(), res); 255 } 256 257 // What is the state of the fetch? 258 match img_cache.map()[&image].ready() { 259 // Still waiting 260 None => { 261 ui.allocate_space(egui::vec2(spinsz, spinsz)); 262 //ui.add(egui::Spinner::new().size(spinsz)); 263 } 264 // Failed to fetch image! 265 Some(Err(_err)) => { 266 // FIXME - use content-specific error instead 267 let no_pfp = crate::images::fetch_img( 268 img_cache, 269 ui.ctx(), 270 ProfilePic::no_pfp_url(), 271 ImageType::Profile(128), 272 ); 273 img_cache.map_mut().insert(image.to_owned(), no_pfp); 274 // spin until next pass 275 ui.allocate_space(egui::vec2(spinsz, spinsz)); 276 //ui.add(egui::Spinner::new().size(spinsz)); 277 } 278 // Use the previously resolved image 279 Some(Ok(img)) => { 280 let img_resp = ui.add( 281 Image::new(img) 282 .max_height(height) 283 .rounding(5.0) 284 .fit_to_original_size(1.0), 285 ); 286 img_resp.context_menu(|ui| { 287 if ui.button("Copy Link").clicked() { 288 ui.ctx().copy_text(image); 289 ui.close_menu(); 290 } 291 }); 292 } 293 } 294 } 295 }) 296 .response 297 }) 298 .inner 299 }); 300 }