mod.rs (35765B)
1 pub mod contents; 2 pub mod context; 3 pub mod media; 4 pub mod options; 5 pub mod reply_description; 6 7 use crate::{app_images, secondary_label}; 8 use crate::{widgets::x_button, ProfilePic, ProfilePreview, PulseAlpha, Username}; 9 10 pub use contents::{render_note_preview, NoteContents}; 11 pub use context::NoteContextButton; 12 use notedeck::note::{reaction_sent_id, ZapTargetAmount}; 13 use notedeck::ui::is_narrow; 14 use notedeck::Accounts; 15 use notedeck::GlobalWallet; 16 use notedeck::Images; 17 use notedeck::Localization; 18 use notedeck::MediaAction; 19 use notedeck::{get_current_wallet, MediaJobSender}; 20 pub use options::NoteOptions; 21 pub use reply_description::reply_desc; 22 23 use egui::emath::{pos2, Vec2}; 24 use egui::{Id, Pos2, Rect, Response, Sense}; 25 use enostr::{KeypairUnowned, NoteId, Pubkey}; 26 use nostrdb::{Ndb, Note, NoteKey, ProfileRecord, Transaction}; 27 use notedeck::{ 28 note::{NoteAction, NoteContext, ReactAction, ZapAction}, 29 tr, AnyZapState, ContextSelection, NoteZapTarget, NoteZapTargetOwned, ZapTarget, Zaps, 30 }; 31 32 pub struct NoteView<'a, 'd> { 33 note_context: &'a mut NoteContext<'d>, 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 action: Option<NoteAction>, 42 pub pfp_rect: Option<egui::Rect>, 43 } 44 45 impl NoteResponse { 46 pub fn new(response: egui::Response) -> Self { 47 Self { 48 response, 49 action: None, 50 pfp_rect: 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 with_pfp(mut self, pfp_rect: egui::Rect) -> Self { 60 self.pfp_rect = Some(pfp_rect); 61 self 62 } 63 } 64 65 /* 66 impl View for NoteView<'_, '_> { 67 fn ui(&mut self, ui: &mut egui::Ui) { 68 self.show(ui); 69 } 70 } 71 */ 72 73 impl egui::Widget for &mut NoteView<'_, '_> { 74 fn ui(self, ui: &mut egui::Ui) -> egui::Response { 75 self.show(ui).response 76 } 77 } 78 79 impl<'a, 'd> NoteView<'a, 'd> { 80 pub fn new( 81 note_context: &'a mut NoteContext<'d>, 82 note: &'a nostrdb::Note<'a>, 83 flags: NoteOptions, 84 ) -> Self { 85 let parent: Option<NoteKey> = None; 86 87 Self { 88 note_context, 89 parent, 90 note, 91 flags, 92 } 93 } 94 95 pub fn preview_style(self) -> Self { 96 self.actionbar(false) 97 .small_pfp(true) 98 .frame(true) 99 .wide(true) 100 .note_previews(false) 101 .options_button(true) 102 .is_preview(true) 103 .full_date(false) 104 .client_name(false) 105 } 106 107 pub fn selected_style(self, selected: bool) -> Self { 108 self.wide(selected) 109 .full_date(selected) 110 .client_name(selected) 111 } 112 113 #[inline] 114 pub fn textmode(mut self, enable: bool) -> Self { 115 self.options_mut().set(NoteOptions::Textmode, enable); 116 self 117 } 118 119 #[inline] 120 pub fn client_name(mut self, enable: bool) -> Self { 121 self.options_mut().set(NoteOptions::ClientName, enable); 122 self 123 } 124 125 #[inline] 126 pub fn full_date(mut self, enable: bool) -> Self { 127 self.options_mut().set(NoteOptions::FullCreatedDate, enable); 128 self 129 } 130 131 #[inline] 132 pub fn actionbar(mut self, enable: bool) -> Self { 133 self.options_mut().set(NoteOptions::ActionBar, enable); 134 self 135 } 136 137 #[inline] 138 pub fn hide_media(mut self, enable: bool) -> Self { 139 self.options_mut().set(NoteOptions::HideMedia, enable); 140 self 141 } 142 143 #[inline] 144 pub fn frame(mut self, enable: bool) -> Self { 145 self.options_mut().set(NoteOptions::Framed, enable); 146 self 147 } 148 149 #[inline] 150 pub fn truncate(mut self, enable: bool) -> Self { 151 self.options_mut().set(NoteOptions::Truncate, enable); 152 self 153 } 154 155 #[inline] 156 pub fn small_pfp(mut self, enable: bool) -> Self { 157 self.options_mut().set(NoteOptions::SmallPfp, enable); 158 self 159 } 160 161 #[inline] 162 pub fn medium_pfp(mut self, enable: bool) -> Self { 163 self.options_mut().set(NoteOptions::MediumPfp, enable); 164 self 165 } 166 167 #[inline] 168 pub fn note_previews(mut self, enable: bool) -> Self { 169 self.options_mut().set(NoteOptions::HasNotePreviews, enable); 170 self 171 } 172 173 #[inline] 174 pub fn selectable_text(mut self, enable: bool) -> Self { 175 self.options_mut().set(NoteOptions::SelectableText, enable); 176 self 177 } 178 179 #[inline] 180 pub fn wide(mut self, enable: bool) -> Self { 181 self.options_mut().set(NoteOptions::Wide, enable); 182 self 183 } 184 185 #[inline] 186 pub fn options_button(mut self, enable: bool) -> Self { 187 self.options_mut().set(NoteOptions::OptionsButton, enable); 188 self 189 } 190 191 #[inline] 192 pub fn unread_indicator(mut self, enable: bool) -> Self { 193 self.options_mut().set(NoteOptions::UnreadIndicator, enable); 194 self 195 } 196 197 #[inline] 198 pub fn options(&self) -> NoteOptions { 199 self.flags 200 } 201 202 #[inline] 203 pub fn options_mut(&mut self) -> &mut NoteOptions { 204 &mut self.flags 205 } 206 207 #[inline] 208 pub fn parent(mut self, parent: NoteKey) -> Self { 209 self.parent = Some(parent); 210 self 211 } 212 213 #[inline] 214 pub fn is_preview(mut self, is_preview: bool) -> Self { 215 self.options_mut().set(NoteOptions::IsPreview, is_preview); 216 self 217 } 218 219 fn textmode_ui(&mut self, ui: &mut egui::Ui) -> egui::Response { 220 let txn = self.note.txn().expect("todo: implement non-db notes"); 221 222 ui.with_layout(egui::Layout::left_to_right(egui::Align::TOP), |ui| { 223 let profile = self 224 .note_context 225 .ndb 226 .get_profile_by_pubkey(txn, self.note.pubkey()); 227 228 //ui.horizontal(|ui| { 229 ui.spacing_mut().item_spacing.x = 2.0; 230 231 let (_id, rect) = ui.allocate_space(egui::vec2(50.0, 20.0)); 232 ui.allocate_rect(rect, Sense::hover()); 233 ui.put(rect, |ui: &mut egui::Ui| { 234 render_notetime(ui, self.note_context.i18n, self.note.created_at(), false) 235 }); 236 let (_id, rect) = ui.allocate_space(egui::vec2(150.0, 20.0)); 237 ui.allocate_rect(rect, Sense::hover()); 238 ui.put(rect, |ui: &mut egui::Ui| { 239 ui.add( 240 Username::new( 241 self.note_context.i18n, 242 profile.as_ref().ok(), 243 self.note.pubkey(), 244 ) 245 .abbreviated(6) 246 .pk_colored(true), 247 ) 248 }); 249 250 ui.add(&mut NoteContents::new( 251 self.note_context, 252 txn, 253 self.note, 254 self.flags, 255 )); 256 //}); 257 }) 258 .response 259 } 260 261 pub fn expand_size() -> i8 { 262 5 263 } 264 265 fn pfp( 266 &mut self, 267 note_key: NoteKey, 268 profile: &Result<nostrdb::ProfileRecord<'_>, nostrdb::Error>, 269 ui: &mut egui::Ui, 270 ) -> PfpResponse { 271 if !self.options().contains(NoteOptions::Wide) { 272 ui.spacing_mut().item_spacing.x = 16.0; 273 } else { 274 ui.spacing_mut().item_spacing.x = 4.0; 275 } 276 277 let pfp_size = self.options().pfp_size(); 278 279 match profile 280 .as_ref() 281 .ok() 282 .and_then(|p| p.record().profile()?.picture()) 283 { 284 // these have different lifetimes and types, 285 // so the calls must be separate 286 Some(pic) => show_actual_pfp( 287 ui, 288 self.note_context.img_cache, 289 self.note_context.jobs, 290 pic, 291 pfp_size, 292 note_key, 293 profile, 294 ), 295 296 None => show_fallback_pfp( 297 ui, 298 self.note_context.img_cache, 299 self.note_context.jobs, 300 pfp_size, 301 ), 302 } 303 } 304 305 pub fn show(&mut self, ui: &mut egui::Ui) -> NoteResponse { 306 if !self.flags.contains(NoteOptions::TrustMedia) { 307 let acc = self.note_context.accounts.get_selected_account(); 308 if self.note.pubkey() == acc.key.pubkey.bytes() 309 || matches!( 310 acc.is_following(self.note.pubkey()), 311 notedeck::IsFollowing::Yes 312 ) 313 { 314 self.flags = self.flags.union(NoteOptions::TrustMedia); 315 } 316 } 317 318 if self.options().contains(NoteOptions::Textmode) { 319 NoteResponse::new(self.textmode_ui(ui)) 320 } else if self.options().contains(NoteOptions::Framed) { 321 egui::Frame::new() 322 .fill(ui.visuals().noninteractive().weak_bg_fill) 323 .inner_margin(egui::Margin::same(8)) 324 .outer_margin(egui::Margin::symmetric(0, 8)) 325 .corner_radius(egui::CornerRadius::same(10)) 326 .stroke(egui::Stroke::new( 327 1.0, 328 ui.visuals().noninteractive().bg_stroke.color, 329 )) 330 .show(ui, |ui| { 331 if is_narrow(ui.ctx()) { 332 ui.set_width(ui.available_width()); 333 } 334 self.show_standard(ui) 335 }) 336 .inner 337 } else { 338 self.show_standard(ui) 339 } 340 } 341 342 #[profiling::function] 343 fn note_header( 344 ui: &mut egui::Ui, 345 txn: &Transaction, 346 note_context: &mut NoteContext, 347 note: &Note, 348 profile: &Result<nostrdb::ProfileRecord<'_>, nostrdb::Error>, 349 flags: NoteOptions, 350 ) { 351 let horiz_resp = ui 352 .horizontal_wrapped(|ui| { 353 ui.spacing_mut().item_spacing.x = if is_narrow(ui.ctx()) { 1.0 } else { 2.0 }; 354 let response = ui.add( 355 Username::new(note_context.i18n, profile.as_ref().ok(), note.pubkey()) 356 .abbreviated(20), 357 ); 358 if !flags.contains(NoteOptions::FullCreatedDate) { 359 return render_notetime(ui, note_context.i18n, note.created_at(), true); 360 } 361 response 362 }) 363 .response; 364 365 if flags.contains(NoteOptions::UnreadIndicator) { 366 let radius = 4.0; 367 let circle_center = { 368 let mut center = horiz_resp.rect.right_center(); 369 center.x += radius + 4.0; 370 center 371 }; 372 373 ui.painter() 374 .circle_filled(circle_center, radius, crate::colors::PINK); 375 } 376 377 if note.is_rumor() { 378 ui.horizontal_wrapped(|ui| { 379 ui.spacing_mut().item_spacing.x = if is_narrow(ui.ctx()) { 1.0 } else { 2.0 }; 380 381 secondary_label(ui, "encrypted privately to"); 382 383 crate::Mention::new( 384 note_context.ndb, 385 note_context.img_cache, 386 note_context.jobs, 387 txn, 388 note.rumor_receiver_pubkey().expect("expected pubkey"), 389 ) 390 .size(10.0) 391 .selectable(true) 392 .show(ui); 393 }); 394 } 395 } 396 397 fn wide_ui( 398 &mut self, 399 ui: &mut egui::Ui, 400 txn: &Transaction, 401 note_key: NoteKey, 402 profile: &Result<ProfileRecord, nostrdb::Error>, 403 ) -> egui::InnerResponse<NoteUiResponse> { 404 ui.with_layout(egui::Layout::top_down(egui::Align::LEFT), |ui| { 405 let mut note_action: Option<NoteAction> = None; 406 let mut pfp_rect = None; 407 408 if !self.flags.contains(NoteOptions::NotificationPreview) { 409 ui.horizontal(|ui| { 410 let pfp_resp = self.pfp(note_key, profile, ui); 411 pfp_rect = Some(pfp_resp.bounding_rect); 412 note_action = pfp_resp 413 .into_action(self.note.pubkey()) 414 .or(note_action.take()); 415 416 let size = ui.available_size(); 417 418 ui.vertical(|ui| { 419 ui.add_sized( 420 [size.x, self.options().pfp_size() as f32], 421 |ui: &mut egui::Ui| { 422 ui.horizontal_centered(|ui| { 423 NoteView::note_header( 424 ui, 425 txn, 426 self.note_context, 427 self.note, 428 profile, 429 self.flags, 430 ); 431 }) 432 .response 433 }, 434 ); 435 436 let note_reply = self 437 .note_context 438 .note_cache 439 .cached_note_or_insert_mut(note_key, self.note) 440 .reply 441 .borrow(self.note.tags()); 442 443 if note_reply.reply().is_none() { 444 return; 445 } 446 447 ui.horizontal_wrapped(|ui| { 448 ui.spacing_mut().item_spacing.x = 0.0; 449 450 note_action = 451 reply_desc(ui, txn, ¬e_reply, self.note_context, self.flags) 452 .or(note_action.take()); 453 }); 454 }); 455 }); 456 } 457 458 let mut contents = NoteContents::new(self.note_context, txn, self.note, self.flags); 459 460 ui.add(&mut contents); 461 462 note_action = contents.action.or(note_action); 463 464 if self.options().contains(NoteOptions::ActionBar) { 465 note_action = ui 466 .horizontal_wrapped(|ui| { 467 // NOTE(jb55): without this we get a weird artifact where 468 // there subsequent lines start sinking leftward off the screen. 469 // question: WTF? question 2: WHY? 470 ui.allocate_space(egui::vec2(0.0, 0.0)); 471 472 let counts = self 473 .note_context 474 .ndb 475 .get_note_metadata(txn, self.note.id()) 476 .ok() 477 .and_then(|md| { 478 md.into_iter().find_map(|e| { 479 if let nostrdb::NoteMetadataEntryVariant::Counts(ce) = e { 480 Some(ce) 481 } else { 482 None 483 } 484 }) 485 }); 486 487 actionbar_ui( 488 ui, 489 counts, 490 get_zapper( 491 self.note_context.accounts, 492 self.note_context.global_wallet, 493 self.note_context.zaps, 494 ), 495 self.note, 496 self.note_context.accounts.selected_account_pubkey(), 497 note_key, 498 self.note_context.i18n, 499 ) 500 }) 501 .inner 502 .or(note_action); 503 } 504 505 NoteUiResponse { 506 action: note_action, 507 pfp_rect, 508 } 509 }) 510 } 511 512 fn standard_ui( 513 &mut self, 514 ui: &mut egui::Ui, 515 txn: &Transaction, 516 note_key: NoteKey, 517 profile: &Result<ProfileRecord, nostrdb::Error>, 518 ) -> egui::InnerResponse<NoteUiResponse> { 519 // main design 520 ui.with_layout(egui::Layout::left_to_right(egui::Align::TOP), |ui| { 521 let (mut note_action, pfp_rect) = 522 if self.flags.contains(NoteOptions::NotificationPreview) { 523 // do not render pfp 524 (None, None) 525 } else { 526 let pfp_resp = self.pfp(note_key, profile, ui); 527 let pfp_rect = pfp_resp.bounding_rect; 528 (pfp_resp.into_action(self.note.pubkey()), Some(pfp_rect)) 529 }; 530 531 ui.with_layout(egui::Layout::top_down(egui::Align::LEFT), |ui| { 532 if !self.flags.contains(NoteOptions::NotificationPreview) { 533 NoteView::note_header( 534 ui, 535 txn, 536 self.note_context, 537 self.note, 538 profile, 539 self.flags, 540 ); 541 542 ui.horizontal_wrapped(|ui| { 543 ui.spacing_mut().item_spacing.x = 1.0; 544 545 let note_reply = self 546 .note_context 547 .note_cache 548 .cached_note_or_insert_mut(note_key, self.note) 549 .reply 550 .borrow(self.note.tags()); 551 552 if note_reply.reply().is_none() { 553 return; 554 } 555 556 note_action = 557 reply_desc(ui, txn, ¬e_reply, self.note_context, self.flags) 558 .or(note_action.take()); 559 }); 560 } 561 562 let mut contents = NoteContents::new(self.note_context, txn, self.note, self.flags); 563 ui.add(&mut contents); 564 565 note_action = contents.action.or(note_action); 566 567 if self.options().contains(NoteOptions::ActionBar) { 568 let counts = self 569 .note_context 570 .ndb 571 .get_note_metadata(txn, self.note.id()) 572 .ok() 573 .and_then(|md| { 574 md.into_iter().find_map(|e| { 575 if let nostrdb::NoteMetadataEntryVariant::Counts(ce) = e { 576 Some(ce) 577 } else { 578 None 579 } 580 }) 581 }); 582 583 note_action = ui 584 .horizontal_wrapped(|ui| { 585 actionbar_ui( 586 ui, 587 counts, 588 get_zapper( 589 self.note_context.accounts, 590 self.note_context.global_wallet, 591 self.note_context.zaps, 592 ), 593 self.note, 594 self.note_context.accounts.selected_account_pubkey(), 595 note_key, 596 self.note_context.i18n, 597 ) 598 }) 599 .inner 600 .or(note_action); 601 } 602 603 NoteUiResponse { 604 action: note_action, 605 pfp_rect, 606 } 607 }) 608 .inner 609 }) 610 } 611 612 #[profiling::function] 613 fn show_standard(&mut self, ui: &mut egui::Ui) -> NoteResponse { 614 let note_key = self.note.key().expect("todo: support non-db notes"); 615 let txn = self.note.txn().expect("todo: support non-db notes"); 616 617 let profile = self 618 .note_context 619 .ndb 620 .get_profile_by_pubkey(txn, self.note.pubkey()); 621 622 let hitbox_id = note_hitbox_id(note_key, self.options(), self.parent); 623 let maybe_hitbox = maybe_note_hitbox(ui, hitbox_id); 624 625 // wide design 626 let response = if self.options().contains(NoteOptions::Wide) { 627 self.wide_ui(ui, txn, note_key, &profile) 628 } else { 629 self.standard_ui(ui, txn, note_key, &profile) 630 }; 631 632 let note_ui_resp = response.inner; 633 let mut note_action = note_ui_resp.action; 634 635 if self.options().contains(NoteOptions::OptionsButton) { 636 let context_pos = { 637 let size = NoteContextButton::max_width(); 638 let top_right = response.response.rect.right_top(); 639 let min = Pos2::new(top_right.x - size, top_right.y); 640 Rect::from_min_size(min, egui::vec2(size, size)) 641 }; 642 643 let resp = ui.add(NoteContextButton::new(note_key).place_at(context_pos)); 644 let can_sign = self 645 .note_context 646 .accounts 647 .get_selected_account() 648 .key 649 .secret_key 650 .is_some(); 651 let is_muted = self 652 .note_context 653 .accounts 654 .mute() 655 .is_pk_muted(self.note.pubkey()); 656 let note_id = NoteId::new(*self.note.id()); 657 if let Some(action) = NoteContextButton::menu( 658 ui, 659 self.note_context.i18n, 660 resp.clone(), 661 note_id, 662 can_sign, 663 is_muted, 664 ) { 665 note_action = Some(NoteAction::Context(ContextSelection { note_key, action })); 666 } 667 } 668 669 note_action = note_hitbox_clicked(ui, hitbox_id, &response.response.rect, maybe_hitbox) 670 .then_some(NoteAction::note(NoteId::new(*self.note.id()))) 671 .or(note_action); 672 673 let mut resp = NoteResponse::new(response.response).with_action(note_action); 674 if let Some(pfp_rect) = note_ui_resp.pfp_rect { 675 resp = resp.with_pfp(pfp_rect); 676 } 677 678 resp 679 } 680 } 681 682 fn get_zapper<'a>( 683 accounts: &'a Accounts, 684 global_wallet: &'a GlobalWallet, 685 zaps: &'a Zaps, 686 ) -> Option<Zapper<'a>> { 687 let has_wallet = get_current_wallet(accounts, global_wallet).is_some(); 688 let cur_acc = accounts.get_selected_account(); 689 690 has_wallet.then_some(Zapper { 691 zaps, 692 cur_acc: cur_acc.keypair(), 693 }) 694 } 695 696 pub fn get_reposted_note<'a>(ndb: &Ndb, txn: &'a Transaction, note: &Note) -> Option<Note<'a>> { 697 if note.kind() != 6 { 698 return None; 699 } 700 701 let new_note_id: &[u8; 32] = { 702 let mut res = None; 703 for tag in note.tags().iter() { 704 if tag.count() == 0 { 705 continue; 706 } 707 708 if let Some("e") = tag.get(0).and_then(|t| t.variant().str()) { 709 if let Some(note_id) = tag.get(1).and_then(|f| f.variant().id()) { 710 res = Some(note_id); 711 break; 712 } 713 } 714 } 715 res? 716 }; 717 718 let note = ndb.get_note_by_id(txn, new_note_id).ok(); 719 note.filter(|note| note.kind() == 1) 720 } 721 722 struct NoteUiResponse { 723 action: Option<NoteAction>, 724 pfp_rect: Option<egui::Rect>, 725 } 726 727 struct PfpResponse { 728 action: Option<MediaAction>, 729 response: egui::Response, 730 bounding_rect: egui::Rect, 731 } 732 733 impl PfpResponse { 734 fn into_action(self, note_pk: &[u8; 32]) -> Option<NoteAction> { 735 if self.response.clicked() { 736 return Some(NoteAction::Profile(Pubkey::new(*note_pk))); 737 } 738 739 self.action.map(NoteAction::Media) 740 } 741 } 742 743 fn show_actual_pfp( 744 ui: &mut egui::Ui, 745 images: &mut Images, 746 jobs: &MediaJobSender, 747 pic: &str, 748 pfp_size: i8, 749 note_key: NoteKey, 750 profile: &Result<nostrdb::ProfileRecord<'_>, nostrdb::Error>, 751 ) -> PfpResponse { 752 let anim_speed = 0.05; 753 let profile_key = profile.as_ref().unwrap().record().note_key(); 754 let note_key = note_key.as_u64(); 755 756 let (rect, size, resp) = crate::anim::hover_expand( 757 ui, 758 egui::Id::new((profile_key, note_key)), 759 pfp_size as f32, 760 NoteView::expand_size() as f32, 761 anim_speed, 762 ); 763 764 let resp = resp.on_hover_cursor(egui::CursorIcon::PointingHand); 765 766 let mut pfp = ProfilePic::new(images, jobs, pic).size(size); 767 let pfp_resp = ui.put(rect, &mut pfp); 768 let action = pfp.action; 769 770 pfp_resp.on_hover_ui_at_pointer(|ui| { 771 ui.set_max_width(300.0); 772 ui.add(ProfilePreview::new(profile.as_ref().unwrap(), images, jobs)); 773 }); 774 775 PfpResponse { 776 response: resp, 777 action, 778 bounding_rect: rect.shrink((rect.width() - size) / 2.0), 779 } 780 } 781 782 fn show_fallback_pfp( 783 ui: &mut egui::Ui, 784 images: &mut Images, 785 jobs: &MediaJobSender, 786 pfp_size: i8, 787 ) -> PfpResponse { 788 let sense = Sense::click(); 789 // This has to match the expand size from the above case to 790 // prevent bounciness 791 let size = (pfp_size + NoteView::expand_size()) as f32; 792 let (rect, _response) = ui.allocate_exact_size(egui::vec2(size, size), sense); 793 794 let mut pfp = 795 ProfilePic::new(images, jobs, notedeck::profile::no_pfp_url()).size(pfp_size as f32); 796 let response = ui.put(rect, &mut pfp).interact(sense); 797 798 PfpResponse { 799 action: pfp.action, 800 response, 801 bounding_rect: rect.shrink((rect.width() - size) / 2.0), 802 } 803 } 804 805 fn note_hitbox_id( 806 note_key: NoteKey, 807 note_options: NoteOptions, 808 parent: Option<NoteKey>, 809 ) -> egui::Id { 810 Id::new(("note_size", note_key, note_options, parent)) 811 } 812 813 fn maybe_note_hitbox(ui: &mut egui::Ui, hitbox_id: egui::Id) -> Option<Response> { 814 ui.ctx() 815 .data_mut(|d| d.get_temp(hitbox_id)) 816 .map(|note_size: Vec2| { 817 // The hitbox should extend the entire width of the 818 // container. The hitbox height was cached last layout. 819 let container_rect = ui.max_rect(); 820 let rect = Rect { 821 min: pos2(container_rect.min.x, container_rect.min.y), 822 max: pos2(container_rect.max.x, container_rect.min.y + note_size.y), 823 }; 824 825 let response = ui.interact(rect, ui.id().with(hitbox_id), egui::Sense::click()); 826 827 response 828 .widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Other, true, "hitbox")); 829 830 response 831 }) 832 } 833 834 fn note_hitbox_clicked( 835 ui: &mut egui::Ui, 836 hitbox_id: egui::Id, 837 note_rect: &Rect, 838 maybe_hitbox: Option<Response>, 839 ) -> bool { 840 // Stash the dimensions of the note content so we can render the 841 // hitbox in the next frame 842 ui.ctx().data_mut(|d| { 843 d.insert_temp(hitbox_id, note_rect.size()); 844 }); 845 846 // If there was an hitbox and it was clicked open the thread 847 match maybe_hitbox { 848 Some(hitbox) => hitbox.clicked(), 849 _ => false, 850 } 851 } 852 853 struct Zapper<'a> { 854 zaps: &'a Zaps, 855 cur_acc: KeypairUnowned<'a>, 856 } 857 858 fn zap_actionbar_button( 859 ui: &mut egui::Ui, 860 note_id: &[u8; 32], 861 note_pubkey: &[u8; 32], 862 zapper: Option<Zapper<'_>>, 863 i18n: &mut Localization, 864 ) -> Option<NoteAction> { 865 let mut action: Option<NoteAction> = None; 866 let Zapper { zaps, cur_acc } = zapper?; 867 868 let zap_target = ZapTarget::Note(NoteZapTarget { 869 note_id, 870 zap_recipient: note_pubkey, 871 }); 872 873 let zap_state = zaps.any_zap_state_for(cur_acc.pubkey.bytes(), zap_target); 874 875 let target = NoteZapTargetOwned { 876 note_id: NoteId::new(*note_id), 877 zap_recipient: Pubkey::new(*note_pubkey), 878 }; 879 880 cur_acc.secret_key.as_ref()?; 881 882 match zap_state { 883 Ok(any_zap_state) => { 884 let zap_resp = ui.add(zap_button(i18n, any_zap_state, note_id)); 885 886 if zap_resp.secondary_clicked() { 887 action = Some(NoteAction::Zap(ZapAction::CustomizeAmount(target.clone()))); 888 } 889 890 if zap_resp.clicked() { 891 action = Some(NoteAction::Zap(ZapAction::Send(ZapTargetAmount { 892 target, 893 specified_msats: None, 894 }))); 895 } 896 897 zap_resp 898 } 899 Err(err) => { 900 let (rect, _) = ui.allocate_at_least(egui::vec2(10.0, 10.0), egui::Sense::click()); 901 let x_button = ui.add(x_button(rect)).on_hover_text(err.to_string()); 902 903 if x_button.clicked() { 904 action = Some(NoteAction::Zap(ZapAction::ClearError(target.clone()))); 905 } 906 x_button 907 } 908 } 909 .on_hover_cursor(egui::CursorIcon::PointingHand); 910 911 action 912 } 913 914 fn is_root_note(note: &Note) -> bool { 915 for tag in note.tags() { 916 if tag.count() < 2 { 917 continue; 918 } 919 920 // any reference to an e tag is a non-root note 921 if tag.get_str(0) == Some("e") { 922 return false; 923 } 924 } 925 926 true 927 } 928 929 #[profiling::function] 930 fn actionbar_ui( 931 ui: &mut egui::Ui, 932 counts: Option<nostrdb::CountsEntry<'_>>, 933 zapper: Option<Zapper<'_>>, 934 note: &Note, 935 current_user_pubkey: &Pubkey, 936 note_key: NoteKey, 937 i18n: &mut Localization, 938 ) -> Option<NoteAction> { 939 let mut action = None; 940 let spacing = 24.0; 941 942 ui.spacing_mut().item_spacing.x = 2.0; 943 ui.set_min_height(26.0); 944 945 let reply_resp = 946 reply_button(ui, i18n, note_key).on_hover_cursor(egui::CursorIcon::PointingHand); 947 948 if let Some(c) = &counts { 949 let count = if is_root_note(note) { 950 c.thread_replies() 951 } else { 952 c.direct_replies() as u32 953 }; 954 955 if count > 0 { 956 //ui.weak(format!("{}", count)); 957 crate::anim::rolling_number(ui, egui::Id::new((note_key, "replies")), count); 958 } 959 } 960 961 ui.add_space(spacing); 962 963 let filled = ui 964 .ctx() 965 .data(|d| d.get_temp(reaction_sent_id(current_user_pubkey, note.id()))) 966 == Some(true); 967 968 let like_resp = 969 like_button(ui, i18n, note_key, filled).on_hover_cursor(egui::CursorIcon::PointingHand); 970 971 if let Some(c) = &counts { 972 let count = c.reactions(); 973 if count > 0 { 974 crate::anim::rolling_number(ui, egui::Id::new((note_key, "likes")), count); 975 } 976 } 977 978 ui.add_space(spacing); 979 980 let quote_resp = 981 quote_repost_button(ui, i18n, note_key).on_hover_cursor(egui::CursorIcon::PointingHand); 982 983 if let Some(c) = &counts { 984 let count = c.quotes() + c.reposts(); 985 if count > 0 { 986 crate::anim::rolling_number(ui, egui::Id::new((note_key, "quotes")), count as u32); 987 } 988 } 989 990 ui.add_space(spacing); 991 992 if reply_resp.clicked() { 993 action = Some(NoteAction::Reply(NoteId::new(*note.id()))); 994 } 995 996 if like_resp.clicked() { 997 action = Some(NoteAction::React(ReactAction::new( 998 NoteId::new(*note.id()), 999 "🤙🏻", 1000 ))); 1001 } 1002 1003 if quote_resp.clicked() { 1004 action = Some(NoteAction::Repost(NoteId::new(*note.id()))); 1005 } 1006 1007 action = zap_actionbar_button(ui, note.id(), note.pubkey(), zapper, i18n).or(action); 1008 1009 action 1010 } 1011 1012 #[profiling::function] 1013 fn render_notetime( 1014 ui: &mut egui::Ui, 1015 i18n: &mut Localization, 1016 created_at: u64, 1017 before: bool, 1018 ) -> Response { 1019 if before { 1020 secondary_label( 1021 ui, 1022 format!(" ⋅ {}", notedeck::time_ago_since(i18n, created_at)), 1023 ) 1024 } else { 1025 secondary_label( 1026 ui, 1027 format!("{} ⋅ ", notedeck::time_ago_since(i18n, created_at)), 1028 ) 1029 } 1030 } 1031 1032 fn reply_button(ui: &mut egui::Ui, i18n: &mut Localization, note_key: NoteKey) -> egui::Response { 1033 let img = if ui.style().visuals.dark_mode { 1034 app_images::reply_dark_image() 1035 } else { 1036 app_images::reply_light_image() 1037 }; 1038 1039 let (rect, size, resp) = 1040 crate::anim::hover_expand_small(ui, ui.id().with(("reply_anim", note_key))); 1041 1042 // align rect to note contents 1043 let expand_size = 5.0; // from hover_expand_small 1044 let rect = rect.translate(egui::vec2(-(expand_size / 2.0), 0.0)); 1045 1046 let put_resp = ui.put(rect, img.max_width(size)).on_hover_text(tr!( 1047 i18n, 1048 "Reply to this note", 1049 "Hover text for reply button" 1050 )); 1051 1052 resp.union(put_resp) 1053 } 1054 1055 fn like_button( 1056 ui: &mut egui::Ui, 1057 i18n: &mut Localization, 1058 note_key: NoteKey, 1059 filled: bool, 1060 ) -> egui::Response { 1061 let img = { 1062 let img = if filled { 1063 app_images::like_image_filled() 1064 } else { 1065 app_images::like_image() 1066 }; 1067 1068 img.tint(ui.visuals().text_color()) 1069 }; 1070 1071 let (rect, size, resp) = 1072 crate::anim::hover_expand_small(ui, ui.id().with(("like_anim", note_key))); 1073 1074 // align rect to note contents 1075 let expand_size = 5.0; // from hover_expand_small 1076 let rect = rect.translate(egui::vec2(-(expand_size / 2.0), 0.0)); 1077 1078 let put_resp = ui.put(rect, img.max_width(size)).on_hover_text(tr!( 1079 i18n, 1080 "Like this note", 1081 "Hover text for like button" 1082 )); 1083 1084 resp.union(put_resp) 1085 } 1086 1087 fn repost_icon(dark_mode: bool) -> egui::Image<'static> { 1088 if dark_mode { 1089 app_images::repost_dark_image() 1090 } else { 1091 app_images::repost_light_image() 1092 } 1093 } 1094 1095 fn quote_repost_button( 1096 ui: &mut egui::Ui, 1097 i18n: &mut Localization, 1098 note_key: NoteKey, 1099 ) -> egui::Response { 1100 let size = crate::anim::hover_small_size() + 4.0; 1101 let expand_size = 5.0; 1102 let anim_speed = 0.05; 1103 let id = ui.id().with(("repost_anim", note_key)); 1104 1105 let (rect, size, resp) = crate::anim::hover_expand(ui, id, size, expand_size, anim_speed); 1106 1107 let rect = rect.translate(egui::vec2(-(expand_size / 2.0), -1.0)); 1108 1109 let put_resp = ui 1110 .put(rect, repost_icon(ui.visuals().dark_mode).max_width(size)) 1111 .on_hover_text(tr!( 1112 i18n, 1113 "Repost this note", 1114 "Hover text for repost button" 1115 )); 1116 1117 resp.union(put_resp) 1118 } 1119 1120 fn zap_button<'a>( 1121 i18n: &'a mut Localization, 1122 state: AnyZapState, 1123 noteid: &'a [u8; 32], 1124 ) -> impl egui::Widget + use<'a> { 1125 move |ui: &mut egui::Ui| -> egui::Response { 1126 let (rect, size, resp) = crate::anim::hover_expand_small(ui, ui.id().with("zap")); 1127 1128 let mut img = app_images::zap_dark_image().max_width(size); 1129 let id = ui.id().with(("pulse", noteid)); 1130 let ctx = ui.ctx().clone(); 1131 1132 match state { 1133 AnyZapState::None => { 1134 if !ui.visuals().dark_mode { 1135 img = app_images::zap_light_image(); 1136 } 1137 } 1138 AnyZapState::Pending => { 1139 let alpha_min = if ui.visuals().dark_mode { 50 } else { 180 }; 1140 let cur_alpha = PulseAlpha::new(&ctx, id, alpha_min, 255) 1141 .with_speed(0.35) 1142 .animate(); 1143 1144 let cur_color = egui::Color32::from_rgba_unmultiplied(0xFF, 0xB7, 0x57, cur_alpha); 1145 img = img.tint(cur_color); 1146 } 1147 AnyZapState::LocalOnly => { 1148 img = img.tint(egui::Color32::from_rgb(0xFF, 0xB7, 0x57)); 1149 } 1150 AnyZapState::Confirmed => {} 1151 } 1152 1153 // align rect to note contents 1154 let expand_size = 5.0; // from hover_expand_small 1155 let rect = rect.translate(egui::vec2(-(expand_size / 2.0), 0.0)); 1156 1157 let put_resp = ui.put(rect, img).on_hover_text(tr!( 1158 i18n, 1159 "Zap this note", 1160 "Hover text for zap button" 1161 )); 1162 1163 resp.union(put_resp) 1164 } 1165 }