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