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