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