mod.rs (21950B)
1 pub mod contents; 2 pub mod context; 3 pub mod options; 4 pub mod post; 5 pub mod quote_repost; 6 pub mod reply; 7 pub mod reply_description; 8 9 pub use contents::NoteContents; 10 pub use context::{NoteContextButton, NoteContextSelection}; 11 pub use options::NoteOptions; 12 pub use post::{PostAction, PostResponse, PostType, PostView}; 13 pub use quote_repost::QuoteRepostView; 14 pub use reply::PostReplyView; 15 pub use reply_description::reply_desc; 16 17 use crate::{ 18 actionbar::NoteAction, 19 ui::{self, View}, 20 }; 21 22 use egui::emath::{pos2, Vec2}; 23 use egui::{Id, Label, Pos2, Rect, Response, RichText, Sense}; 24 use enostr::{NoteId, Pubkey}; 25 use nostrdb::{Ndb, Note, NoteKey, Transaction}; 26 use notedeck::{CachedNote, ImageCache, NoteCache, NotedeckTextStyle}; 27 28 use super::profile::preview::{get_display_name, one_line_display_name_widget}; 29 30 pub struct NoteView<'a> { 31 ndb: &'a Ndb, 32 note_cache: &'a mut NoteCache, 33 img_cache: &'a mut ImageCache, 34 parent: Option<NoteKey>, 35 note: &'a nostrdb::Note<'a>, 36 flags: NoteOptions, 37 } 38 39 pub struct NoteResponse { 40 pub response: egui::Response, 41 pub context_selection: Option<NoteContextSelection>, 42 pub action: Option<NoteAction>, 43 } 44 45 impl NoteResponse { 46 pub fn new(response: egui::Response) -> Self { 47 Self { 48 response, 49 context_selection: None, 50 action: None, 51 } 52 } 53 54 pub fn with_action(mut self, action: Option<NoteAction>) -> Self { 55 self.action = action; 56 self 57 } 58 59 pub fn select_option(mut self, context_selection: Option<NoteContextSelection>) -> Self { 60 self.context_selection = context_selection; 61 self 62 } 63 } 64 65 impl View for NoteView<'_> { 66 fn ui(&mut self, ui: &mut egui::Ui) { 67 self.show(ui); 68 } 69 } 70 71 impl<'a> NoteView<'a> { 72 pub fn new( 73 ndb: &'a Ndb, 74 note_cache: &'a mut NoteCache, 75 img_cache: &'a mut ImageCache, 76 note: &'a nostrdb::Note<'a>, 77 ) -> Self { 78 let flags = NoteOptions::actionbar | NoteOptions::note_previews; 79 let parent: Option<NoteKey> = None; 80 Self { 81 ndb, 82 note_cache, 83 img_cache, 84 parent, 85 note, 86 flags, 87 } 88 } 89 90 pub fn note_options(mut self, options: NoteOptions) -> Self { 91 *self.options_mut() = options; 92 self 93 } 94 95 pub fn textmode(mut self, enable: bool) -> Self { 96 self.options_mut().set_textmode(enable); 97 self 98 } 99 100 pub fn actionbar(mut self, enable: bool) -> Self { 101 self.options_mut().set_actionbar(enable); 102 self 103 } 104 105 pub fn small_pfp(mut self, enable: bool) -> Self { 106 self.options_mut().set_small_pfp(enable); 107 self 108 } 109 110 pub fn medium_pfp(mut self, enable: bool) -> Self { 111 self.options_mut().set_medium_pfp(enable); 112 self 113 } 114 115 pub fn note_previews(mut self, enable: bool) -> Self { 116 self.options_mut().set_note_previews(enable); 117 self 118 } 119 120 pub fn selectable_text(mut self, enable: bool) -> Self { 121 self.options_mut().set_selectable_text(enable); 122 self 123 } 124 125 pub fn wide(mut self, enable: bool) -> Self { 126 self.options_mut().set_wide(enable); 127 self 128 } 129 130 pub fn options_button(mut self, enable: bool) -> Self { 131 self.options_mut().set_options_button(enable); 132 self 133 } 134 135 pub fn options(&self) -> NoteOptions { 136 self.flags 137 } 138 139 pub fn options_mut(&mut self) -> &mut NoteOptions { 140 &mut self.flags 141 } 142 143 pub fn parent(mut self, parent: NoteKey) -> Self { 144 self.parent = Some(parent); 145 self 146 } 147 148 fn textmode_ui(&mut self, ui: &mut egui::Ui) -> egui::Response { 149 let note_key = self.note.key().expect("todo: implement non-db notes"); 150 let txn = self.note.txn().expect("todo: implement non-db notes"); 151 152 ui.with_layout(egui::Layout::left_to_right(egui::Align::TOP), |ui| { 153 let profile = self.ndb.get_profile_by_pubkey(txn, self.note.pubkey()); 154 155 //ui.horizontal(|ui| { 156 ui.spacing_mut().item_spacing.x = 2.0; 157 158 let cached_note = self 159 .note_cache 160 .cached_note_or_insert_mut(note_key, self.note); 161 162 let (_id, rect) = ui.allocate_space(egui::vec2(50.0, 20.0)); 163 ui.allocate_rect(rect, Sense::hover()); 164 ui.put(rect, |ui: &mut egui::Ui| { 165 render_reltime(ui, cached_note, false).response 166 }); 167 let (_id, rect) = ui.allocate_space(egui::vec2(150.0, 20.0)); 168 ui.allocate_rect(rect, Sense::hover()); 169 ui.put(rect, |ui: &mut egui::Ui| { 170 ui.add( 171 ui::Username::new(profile.as_ref().ok(), self.note.pubkey()) 172 .abbreviated(6) 173 .pk_colored(true), 174 ) 175 }); 176 177 ui.add(&mut NoteContents::new( 178 self.ndb, 179 self.img_cache, 180 self.note_cache, 181 txn, 182 self.note, 183 note_key, 184 self.flags, 185 )); 186 //}); 187 }) 188 .response 189 } 190 191 pub fn expand_size() -> f32 { 192 5.0 193 } 194 195 fn pfp( 196 &mut self, 197 note_key: NoteKey, 198 profile: &Result<nostrdb::ProfileRecord<'_>, nostrdb::Error>, 199 ui: &mut egui::Ui, 200 ) -> egui::Response { 201 if !self.options().has_wide() { 202 ui.spacing_mut().item_spacing.x = 16.0; 203 } else { 204 ui.spacing_mut().item_spacing.x = 4.0; 205 } 206 207 let pfp_size = self.options().pfp_size(); 208 209 let sense = Sense::click(); 210 match profile 211 .as_ref() 212 .ok() 213 .and_then(|p| p.record().profile()?.picture()) 214 { 215 // these have different lifetimes and types, 216 // so the calls must be separate 217 Some(pic) => { 218 let anim_speed = 0.05; 219 let profile_key = profile.as_ref().unwrap().record().note_key(); 220 let note_key = note_key.as_u64(); 221 222 let (rect, size, resp) = ui::anim::hover_expand( 223 ui, 224 egui::Id::new((profile_key, note_key)), 225 pfp_size, 226 ui::NoteView::expand_size(), 227 anim_speed, 228 ); 229 230 ui.put(rect, ui::ProfilePic::new(self.img_cache, pic).size(size)) 231 .on_hover_ui_at_pointer(|ui| { 232 ui.set_max_width(300.0); 233 ui.add(ui::ProfilePreview::new( 234 profile.as_ref().unwrap(), 235 self.img_cache, 236 )); 237 }); 238 239 if resp.hovered() || resp.clicked() { 240 ui::show_pointer(ui); 241 } 242 243 resp 244 } 245 None => ui 246 .add( 247 ui::ProfilePic::new(self.img_cache, ui::ProfilePic::no_pfp_url()) 248 .size(pfp_size), 249 ) 250 .interact(sense), 251 } 252 } 253 254 pub fn show(&mut self, ui: &mut egui::Ui) -> NoteResponse { 255 if self.options().has_textmode() { 256 NoteResponse::new(self.textmode_ui(ui)) 257 } else { 258 let txn = self.note.txn().expect("txn"); 259 if let Some(note_to_repost) = get_reposted_note(self.ndb, txn, self.note) { 260 let profile = self.ndb.get_profile_by_pubkey(txn, self.note.pubkey()); 261 262 let style = NotedeckTextStyle::Small; 263 ui.horizontal(|ui| { 264 ui.vertical(|ui| { 265 ui.add_space(2.0); 266 ui.add_sized([20.0, 20.0], repost_icon(ui.visuals().dark_mode)); 267 }); 268 ui.add_space(6.0); 269 let resp = ui.add(one_line_display_name_widget( 270 ui.visuals(), 271 get_display_name(profile.as_ref().ok()), 272 style, 273 )); 274 if let Ok(rec) = &profile { 275 resp.on_hover_ui_at_pointer(|ui| { 276 ui.set_max_width(300.0); 277 ui.add(ui::ProfilePreview::new(rec, self.img_cache)); 278 }); 279 } 280 let color = ui.style().visuals.noninteractive().fg_stroke.color; 281 ui.add_space(4.0); 282 ui.label( 283 RichText::new("Reposted") 284 .color(color) 285 .text_style(style.text_style()), 286 ); 287 }); 288 NoteView::new(self.ndb, self.note_cache, self.img_cache, ¬e_to_repost).show(ui) 289 } else { 290 self.show_standard(ui) 291 } 292 } 293 } 294 295 fn note_header( 296 ui: &mut egui::Ui, 297 note_cache: &mut NoteCache, 298 note: &Note, 299 profile: &Result<nostrdb::ProfileRecord<'_>, nostrdb::Error>, 300 options: NoteOptions, 301 container_right: Pos2, 302 ) -> NoteResponse { 303 #[cfg(feature = "profiling")] 304 puffin::profile_function!(); 305 306 let note_key = note.key().unwrap(); 307 308 let inner_response = ui.horizontal(|ui| { 309 ui.spacing_mut().item_spacing.x = 2.0; 310 ui.add(ui::Username::new(profile.as_ref().ok(), note.pubkey()).abbreviated(20)); 311 312 let cached_note = note_cache.cached_note_or_insert_mut(note_key, note); 313 render_reltime(ui, cached_note, true); 314 315 if options.has_options_button() { 316 let context_pos = { 317 let size = NoteContextButton::max_width(); 318 let min = Pos2::new(container_right.x - size, container_right.y); 319 Rect::from_min_size(min, egui::vec2(size, size)) 320 }; 321 322 let resp = ui.add(NoteContextButton::new(note_key).place_at(context_pos)); 323 NoteContextButton::menu(ui, resp.clone()) 324 } else { 325 None 326 } 327 }); 328 329 NoteResponse::new(inner_response.response).select_option(inner_response.inner) 330 } 331 332 fn show_standard(&mut self, ui: &mut egui::Ui) -> NoteResponse { 333 #[cfg(feature = "profiling")] 334 puffin::profile_function!(); 335 let note_key = self.note.key().expect("todo: support non-db notes"); 336 let txn = self.note.txn().expect("todo: support non-db notes"); 337 338 let mut note_action: Option<NoteAction> = None; 339 let mut selected_option: Option<NoteContextSelection> = None; 340 341 let hitbox_id = note_hitbox_id(note_key, self.options(), self.parent); 342 let profile = self.ndb.get_profile_by_pubkey(txn, self.note.pubkey()); 343 let maybe_hitbox = maybe_note_hitbox(ui, hitbox_id); 344 let container_right = { 345 let r = ui.available_rect_before_wrap(); 346 let x = r.max.x; 347 let y = r.min.y; 348 Pos2::new(x, y) 349 }; 350 351 // wide design 352 let response = if self.options().has_wide() { 353 ui.vertical(|ui| { 354 ui.horizontal(|ui| { 355 if self.pfp(note_key, &profile, ui).clicked() { 356 note_action = 357 Some(NoteAction::OpenProfile(Pubkey::new(*self.note.pubkey()))); 358 }; 359 360 let size = ui.available_size(); 361 ui.vertical(|ui| { 362 ui.add_sized([size.x, self.options().pfp_size()], |ui: &mut egui::Ui| { 363 ui.horizontal_centered(|ui| { 364 selected_option = NoteView::note_header( 365 ui, 366 self.note_cache, 367 self.note, 368 &profile, 369 self.options(), 370 container_right, 371 ) 372 .context_selection; 373 }) 374 .response 375 }); 376 377 let note_reply = self 378 .note_cache 379 .cached_note_or_insert_mut(note_key, self.note) 380 .reply 381 .borrow(self.note.tags()); 382 383 if note_reply.reply().is_some() { 384 let action = ui 385 .horizontal(|ui| { 386 reply_desc( 387 ui, 388 txn, 389 ¬e_reply, 390 self.ndb, 391 self.img_cache, 392 self.note_cache, 393 ) 394 }) 395 .inner; 396 397 if action.is_some() { 398 note_action = action; 399 } 400 } 401 }); 402 }); 403 404 let mut contents = NoteContents::new( 405 self.ndb, 406 self.img_cache, 407 self.note_cache, 408 txn, 409 self.note, 410 note_key, 411 self.options(), 412 ); 413 414 ui.add(&mut contents); 415 416 if let Some(action) = contents.action() { 417 note_action = Some(*action); 418 } 419 420 if self.options().has_actionbar() { 421 if let Some(action) = render_note_actionbar(ui, self.note.id(), note_key).inner 422 { 423 note_action = Some(action); 424 } 425 } 426 }) 427 .response 428 } else { 429 // main design 430 ui.with_layout(egui::Layout::left_to_right(egui::Align::TOP), |ui| { 431 if self.pfp(note_key, &profile, ui).clicked() { 432 note_action = Some(NoteAction::OpenProfile(Pubkey::new(*self.note.pubkey()))); 433 }; 434 435 ui.with_layout(egui::Layout::top_down(egui::Align::LEFT), |ui| { 436 selected_option = NoteView::note_header( 437 ui, 438 self.note_cache, 439 self.note, 440 &profile, 441 self.options(), 442 container_right, 443 ) 444 .context_selection; 445 ui.horizontal(|ui| { 446 ui.spacing_mut().item_spacing.x = 2.0; 447 448 let note_reply = self 449 .note_cache 450 .cached_note_or_insert_mut(note_key, self.note) 451 .reply 452 .borrow(self.note.tags()); 453 454 if note_reply.reply().is_some() { 455 let action = reply_desc( 456 ui, 457 txn, 458 ¬e_reply, 459 self.ndb, 460 self.img_cache, 461 self.note_cache, 462 ); 463 464 if action.is_some() { 465 note_action = action; 466 } 467 } 468 }); 469 470 let mut contents = NoteContents::new( 471 self.ndb, 472 self.img_cache, 473 self.note_cache, 474 txn, 475 self.note, 476 note_key, 477 self.options(), 478 ); 479 ui.add(&mut contents); 480 481 if let Some(action) = contents.action() { 482 note_action = Some(*action); 483 } 484 485 if self.options().has_actionbar() { 486 if let Some(action) = 487 render_note_actionbar(ui, self.note.id(), note_key).inner 488 { 489 note_action = Some(action); 490 } 491 } 492 }); 493 }) 494 .response 495 }; 496 497 let note_action = if note_hitbox_clicked(ui, hitbox_id, &response.rect, maybe_hitbox) { 498 Some(NoteAction::OpenThread(NoteId::new(*self.note.id()))) 499 } else { 500 note_action 501 }; 502 503 NoteResponse::new(response) 504 .with_action(note_action) 505 .select_option(selected_option) 506 } 507 } 508 509 fn get_reposted_note<'a>(ndb: &Ndb, txn: &'a Transaction, note: &Note) -> Option<Note<'a>> { 510 let new_note_id: &[u8; 32] = if note.kind() == 6 { 511 let mut res = None; 512 for tag in note.tags().iter() { 513 if tag.count() == 0 { 514 continue; 515 } 516 517 if let Some("e") = tag.get(0).and_then(|t| t.variant().str()) { 518 if let Some(note_id) = tag.get(1).and_then(|f| f.variant().id()) { 519 res = Some(note_id); 520 break; 521 } 522 } 523 } 524 res? 525 } else { 526 return None; 527 }; 528 529 let note = ndb.get_note_by_id(txn, new_note_id).ok(); 530 note.filter(|note| note.kind() == 1) 531 } 532 533 fn note_hitbox_id( 534 note_key: NoteKey, 535 note_options: NoteOptions, 536 parent: Option<NoteKey>, 537 ) -> egui::Id { 538 Id::new(("note_size", note_key, note_options, parent)) 539 } 540 541 fn maybe_note_hitbox(ui: &mut egui::Ui, hitbox_id: egui::Id) -> Option<Response> { 542 ui.ctx() 543 .data_mut(|d| d.get_persisted(hitbox_id)) 544 .map(|note_size: Vec2| { 545 // The hitbox should extend the entire width of the 546 // container. The hitbox height was cached last layout. 547 let container_rect = ui.max_rect(); 548 let rect = Rect { 549 min: pos2(container_rect.min.x, container_rect.min.y), 550 max: pos2(container_rect.max.x, container_rect.min.y + note_size.y), 551 }; 552 553 let response = ui.interact(rect, ui.id().with(hitbox_id), egui::Sense::click()); 554 555 response 556 .widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Other, true, "hitbox")); 557 558 response 559 }) 560 } 561 562 fn note_hitbox_clicked( 563 ui: &mut egui::Ui, 564 hitbox_id: egui::Id, 565 note_rect: &Rect, 566 maybe_hitbox: Option<Response>, 567 ) -> bool { 568 // Stash the dimensions of the note content so we can render the 569 // hitbox in the next frame 570 ui.ctx().data_mut(|d| { 571 d.insert_persisted(hitbox_id, note_rect.size()); 572 }); 573 574 // If there was an hitbox and it was clicked open the thread 575 match maybe_hitbox { 576 Some(hitbox) => hitbox.clicked(), 577 _ => false, 578 } 579 } 580 581 fn render_note_actionbar( 582 ui: &mut egui::Ui, 583 note_id: &[u8; 32], 584 note_key: NoteKey, 585 ) -> egui::InnerResponse<Option<NoteAction>> { 586 #[cfg(feature = "profiling")] 587 puffin::profile_function!(); 588 589 ui.horizontal(|ui| { 590 let reply_resp = reply_button(ui, note_key); 591 let quote_resp = quote_repost_button(ui, note_key); 592 593 if reply_resp.clicked() { 594 Some(NoteAction::Reply(NoteId::new(*note_id))) 595 } else if quote_resp.clicked() { 596 Some(NoteAction::Quote(NoteId::new(*note_id))) 597 } else { 598 None 599 } 600 }) 601 } 602 603 fn secondary_label(ui: &mut egui::Ui, s: impl Into<String>) { 604 let color = ui.style().visuals.noninteractive().fg_stroke.color; 605 ui.add(Label::new(RichText::new(s).size(10.0).color(color))); 606 } 607 608 fn render_reltime( 609 ui: &mut egui::Ui, 610 note_cache: &mut CachedNote, 611 before: bool, 612 ) -> egui::InnerResponse<()> { 613 #[cfg(feature = "profiling")] 614 puffin::profile_function!(); 615 616 ui.horizontal(|ui| { 617 if before { 618 secondary_label(ui, "⋅"); 619 } 620 621 secondary_label(ui, note_cache.reltime_str_mut()); 622 623 if !before { 624 secondary_label(ui, "⋅"); 625 } 626 }) 627 } 628 629 fn reply_button(ui: &mut egui::Ui, note_key: NoteKey) -> egui::Response { 630 let img_data = if ui.style().visuals.dark_mode { 631 egui::include_image!("../../../../../assets/icons/reply.png") 632 } else { 633 egui::include_image!("../../../../../assets/icons/reply-dark.png") 634 }; 635 636 let (rect, size, resp) = 637 ui::anim::hover_expand_small(ui, ui.id().with(("reply_anim", note_key))); 638 639 // align rect to note contents 640 let expand_size = 5.0; // from hover_expand_small 641 let rect = rect.translate(egui::vec2(-(expand_size / 2.0), 0.0)); 642 643 let put_resp = ui.put(rect, egui::Image::new(img_data).max_width(size)); 644 645 resp.union(put_resp) 646 } 647 648 fn repost_icon(dark_mode: bool) -> egui::Image<'static> { 649 let img_data = if dark_mode { 650 egui::include_image!("../../../../../assets/icons/repost_icon_4x.png") 651 } else { 652 egui::include_image!("../../../../../assets/icons/repost_light_4x.png") 653 }; 654 egui::Image::new(img_data) 655 } 656 657 fn quote_repost_button(ui: &mut egui::Ui, note_key: NoteKey) -> egui::Response { 658 let size = 14.0; 659 let expand_size = 5.0; 660 let anim_speed = 0.05; 661 let id = ui.id().with(("repost_anim", note_key)); 662 663 let (rect, size, resp) = ui::anim::hover_expand(ui, id, size, expand_size, anim_speed); 664 665 let rect = rect.translate(egui::vec2(-(expand_size / 2.0), -1.0)); 666 667 let put_resp = ui.put(rect, repost_icon(ui.visuals().dark_mode).max_width(size)); 668 669 resp.union(put_resp) 670 }