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