mod.rs (35297B)
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 if let Some(action) = NoteContextButton::menu(ui, self.note_context.i18n, resp.clone()) 645 { 646 note_action = Some(NoteAction::Context(ContextSelection { note_key, action })); 647 } 648 } 649 650 note_action = note_hitbox_clicked(ui, hitbox_id, &response.response.rect, maybe_hitbox) 651 .then_some(NoteAction::note(NoteId::new(*self.note.id()))) 652 .or(note_action); 653 654 let mut resp = NoteResponse::new(response.response).with_action(note_action); 655 if let Some(pfp_rect) = note_ui_resp.pfp_rect { 656 resp = resp.with_pfp(pfp_rect); 657 } 658 659 resp 660 } 661 } 662 663 fn get_zapper<'a>( 664 accounts: &'a Accounts, 665 global_wallet: &'a GlobalWallet, 666 zaps: &'a Zaps, 667 ) -> Option<Zapper<'a>> { 668 let has_wallet = get_current_wallet(accounts, global_wallet).is_some(); 669 let cur_acc = accounts.get_selected_account(); 670 671 has_wallet.then_some(Zapper { 672 zaps, 673 cur_acc: cur_acc.keypair(), 674 }) 675 } 676 677 pub fn get_reposted_note<'a>(ndb: &Ndb, txn: &'a Transaction, note: &Note) -> Option<Note<'a>> { 678 if note.kind() != 6 { 679 return None; 680 } 681 682 let new_note_id: &[u8; 32] = { 683 let mut res = None; 684 for tag in note.tags().iter() { 685 if tag.count() == 0 { 686 continue; 687 } 688 689 if let Some("e") = tag.get(0).and_then(|t| t.variant().str()) { 690 if let Some(note_id) = tag.get(1).and_then(|f| f.variant().id()) { 691 res = Some(note_id); 692 break; 693 } 694 } 695 } 696 res? 697 }; 698 699 let note = ndb.get_note_by_id(txn, new_note_id).ok(); 700 note.filter(|note| note.kind() == 1) 701 } 702 703 struct NoteUiResponse { 704 action: Option<NoteAction>, 705 pfp_rect: Option<egui::Rect>, 706 } 707 708 struct PfpResponse { 709 action: Option<MediaAction>, 710 response: egui::Response, 711 bounding_rect: egui::Rect, 712 } 713 714 impl PfpResponse { 715 fn into_action(self, note_pk: &[u8; 32]) -> Option<NoteAction> { 716 if self.response.clicked() { 717 return Some(NoteAction::Profile(Pubkey::new(*note_pk))); 718 } 719 720 self.action.map(NoteAction::Media) 721 } 722 } 723 724 fn show_actual_pfp( 725 ui: &mut egui::Ui, 726 images: &mut Images, 727 jobs: &MediaJobSender, 728 pic: &str, 729 pfp_size: i8, 730 note_key: NoteKey, 731 profile: &Result<nostrdb::ProfileRecord<'_>, nostrdb::Error>, 732 ) -> PfpResponse { 733 let anim_speed = 0.05; 734 let profile_key = profile.as_ref().unwrap().record().note_key(); 735 let note_key = note_key.as_u64(); 736 737 let (rect, size, resp) = crate::anim::hover_expand( 738 ui, 739 egui::Id::new((profile_key, note_key)), 740 pfp_size as f32, 741 NoteView::expand_size() as f32, 742 anim_speed, 743 ); 744 745 let resp = resp.on_hover_cursor(egui::CursorIcon::PointingHand); 746 747 let mut pfp = ProfilePic::new(images, jobs, pic).size(size); 748 let pfp_resp = ui.put(rect, &mut pfp); 749 let action = pfp.action; 750 751 pfp_resp.on_hover_ui_at_pointer(|ui| { 752 ui.set_max_width(300.0); 753 ui.add(ProfilePreview::new(profile.as_ref().unwrap(), images, jobs)); 754 }); 755 756 PfpResponse { 757 response: resp, 758 action, 759 bounding_rect: rect.shrink((rect.width() - size) / 2.0), 760 } 761 } 762 763 fn show_fallback_pfp( 764 ui: &mut egui::Ui, 765 images: &mut Images, 766 jobs: &MediaJobSender, 767 pfp_size: i8, 768 ) -> PfpResponse { 769 let sense = Sense::click(); 770 // This has to match the expand size from the above case to 771 // prevent bounciness 772 let size = (pfp_size + NoteView::expand_size()) as f32; 773 let (rect, _response) = ui.allocate_exact_size(egui::vec2(size, size), sense); 774 775 let mut pfp = 776 ProfilePic::new(images, jobs, notedeck::profile::no_pfp_url()).size(pfp_size as f32); 777 let response = ui.put(rect, &mut pfp).interact(sense); 778 779 PfpResponse { 780 action: pfp.action, 781 response, 782 bounding_rect: rect.shrink((rect.width() - size) / 2.0), 783 } 784 } 785 786 fn note_hitbox_id( 787 note_key: NoteKey, 788 note_options: NoteOptions, 789 parent: Option<NoteKey>, 790 ) -> egui::Id { 791 Id::new(("note_size", note_key, note_options, parent)) 792 } 793 794 fn maybe_note_hitbox(ui: &mut egui::Ui, hitbox_id: egui::Id) -> Option<Response> { 795 ui.ctx() 796 .data_mut(|d| d.get_temp(hitbox_id)) 797 .map(|note_size: Vec2| { 798 // The hitbox should extend the entire width of the 799 // container. The hitbox height was cached last layout. 800 let container_rect = ui.max_rect(); 801 let rect = Rect { 802 min: pos2(container_rect.min.x, container_rect.min.y), 803 max: pos2(container_rect.max.x, container_rect.min.y + note_size.y), 804 }; 805 806 let response = ui.interact(rect, ui.id().with(hitbox_id), egui::Sense::click()); 807 808 response 809 .widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Other, true, "hitbox")); 810 811 response 812 }) 813 } 814 815 fn note_hitbox_clicked( 816 ui: &mut egui::Ui, 817 hitbox_id: egui::Id, 818 note_rect: &Rect, 819 maybe_hitbox: Option<Response>, 820 ) -> bool { 821 // Stash the dimensions of the note content so we can render the 822 // hitbox in the next frame 823 ui.ctx().data_mut(|d| { 824 d.insert_temp(hitbox_id, note_rect.size()); 825 }); 826 827 // If there was an hitbox and it was clicked open the thread 828 match maybe_hitbox { 829 Some(hitbox) => hitbox.clicked(), 830 _ => false, 831 } 832 } 833 834 struct Zapper<'a> { 835 zaps: &'a Zaps, 836 cur_acc: KeypairUnowned<'a>, 837 } 838 839 fn zap_actionbar_button( 840 ui: &mut egui::Ui, 841 note_id: &[u8; 32], 842 note_pubkey: &[u8; 32], 843 zapper: Option<Zapper<'_>>, 844 i18n: &mut Localization, 845 ) -> Option<NoteAction> { 846 let mut action: Option<NoteAction> = None; 847 let Zapper { zaps, cur_acc } = zapper?; 848 849 let zap_target = ZapTarget::Note(NoteZapTarget { 850 note_id, 851 zap_recipient: note_pubkey, 852 }); 853 854 let zap_state = zaps.any_zap_state_for(cur_acc.pubkey.bytes(), zap_target); 855 856 let target = NoteZapTargetOwned { 857 note_id: NoteId::new(*note_id), 858 zap_recipient: Pubkey::new(*note_pubkey), 859 }; 860 861 cur_acc.secret_key.as_ref()?; 862 863 match zap_state { 864 Ok(any_zap_state) => { 865 let zap_resp = ui.add(zap_button(i18n, any_zap_state, note_id)); 866 867 if zap_resp.secondary_clicked() { 868 action = Some(NoteAction::Zap(ZapAction::CustomizeAmount(target.clone()))); 869 } 870 871 if zap_resp.clicked() { 872 action = Some(NoteAction::Zap(ZapAction::Send(ZapTargetAmount { 873 target, 874 specified_msats: None, 875 }))); 876 } 877 878 zap_resp 879 } 880 Err(err) => { 881 let (rect, _) = ui.allocate_at_least(egui::vec2(10.0, 10.0), egui::Sense::click()); 882 let x_button = ui.add(x_button(rect)).on_hover_text(err.to_string()); 883 884 if x_button.clicked() { 885 action = Some(NoteAction::Zap(ZapAction::ClearError(target.clone()))); 886 } 887 x_button 888 } 889 } 890 .on_hover_cursor(egui::CursorIcon::PointingHand); 891 892 action 893 } 894 895 fn is_root_note(note: &Note) -> bool { 896 for tag in note.tags() { 897 if tag.count() < 2 { 898 continue; 899 } 900 901 // any reference to an e tag is a non-root note 902 if tag.get_str(0) == Some("e") { 903 return false; 904 } 905 } 906 907 true 908 } 909 910 #[profiling::function] 911 fn actionbar_ui( 912 ui: &mut egui::Ui, 913 counts: Option<nostrdb::CountsEntry<'_>>, 914 zapper: Option<Zapper<'_>>, 915 note: &Note, 916 current_user_pubkey: &Pubkey, 917 note_key: NoteKey, 918 i18n: &mut Localization, 919 ) -> Option<NoteAction> { 920 let mut action = None; 921 let spacing = 24.0; 922 923 ui.spacing_mut().item_spacing.x = 2.0; 924 ui.set_min_height(26.0); 925 926 let reply_resp = 927 reply_button(ui, i18n, note_key).on_hover_cursor(egui::CursorIcon::PointingHand); 928 929 if let Some(c) = &counts { 930 let count = if is_root_note(note) { 931 c.thread_replies() 932 } else { 933 c.direct_replies() as u32 934 }; 935 936 if count > 0 { 937 //ui.weak(format!("{}", count)); 938 crate::anim::rolling_number(ui, egui::Id::new((note_key, "replies")), count); 939 } 940 } 941 942 ui.add_space(spacing); 943 944 let filled = ui 945 .ctx() 946 .data(|d| d.get_temp(reaction_sent_id(current_user_pubkey, note.id()))) 947 == Some(true); 948 949 let like_resp = 950 like_button(ui, i18n, note_key, filled).on_hover_cursor(egui::CursorIcon::PointingHand); 951 952 if let Some(c) = &counts { 953 let count = c.reactions(); 954 if count > 0 { 955 crate::anim::rolling_number(ui, egui::Id::new((note_key, "likes")), count); 956 } 957 } 958 959 ui.add_space(spacing); 960 961 let quote_resp = 962 quote_repost_button(ui, i18n, note_key).on_hover_cursor(egui::CursorIcon::PointingHand); 963 964 if let Some(c) = &counts { 965 let count = c.quotes() + c.reposts(); 966 if count > 0 { 967 crate::anim::rolling_number(ui, egui::Id::new((note_key, "quotes")), count as u32); 968 } 969 } 970 971 ui.add_space(spacing); 972 973 if reply_resp.clicked() { 974 action = Some(NoteAction::Reply(NoteId::new(*note.id()))); 975 } 976 977 if like_resp.clicked() { 978 action = Some(NoteAction::React(ReactAction::new( 979 NoteId::new(*note.id()), 980 "🤙🏻", 981 ))); 982 } 983 984 if quote_resp.clicked() { 985 action = Some(NoteAction::Repost(NoteId::new(*note.id()))); 986 } 987 988 action = zap_actionbar_button(ui, note.id(), note.pubkey(), zapper, i18n).or(action); 989 990 action 991 } 992 993 #[profiling::function] 994 fn render_notetime( 995 ui: &mut egui::Ui, 996 i18n: &mut Localization, 997 created_at: u64, 998 before: bool, 999 ) -> Response { 1000 if before { 1001 secondary_label( 1002 ui, 1003 format!(" ⋅ {}", notedeck::time_ago_since(i18n, created_at)), 1004 ) 1005 } else { 1006 secondary_label( 1007 ui, 1008 format!("{} ⋅ ", notedeck::time_ago_since(i18n, created_at)), 1009 ) 1010 } 1011 } 1012 1013 fn reply_button(ui: &mut egui::Ui, i18n: &mut Localization, note_key: NoteKey) -> egui::Response { 1014 let img = if ui.style().visuals.dark_mode { 1015 app_images::reply_dark_image() 1016 } else { 1017 app_images::reply_light_image() 1018 }; 1019 1020 let (rect, size, resp) = 1021 crate::anim::hover_expand_small(ui, ui.id().with(("reply_anim", note_key))); 1022 1023 // align rect to note contents 1024 let expand_size = 5.0; // from hover_expand_small 1025 let rect = rect.translate(egui::vec2(-(expand_size / 2.0), 0.0)); 1026 1027 let put_resp = ui.put(rect, img.max_width(size)).on_hover_text(tr!( 1028 i18n, 1029 "Reply to this note", 1030 "Hover text for reply button" 1031 )); 1032 1033 resp.union(put_resp) 1034 } 1035 1036 fn like_button( 1037 ui: &mut egui::Ui, 1038 i18n: &mut Localization, 1039 note_key: NoteKey, 1040 filled: bool, 1041 ) -> egui::Response { 1042 let img = { 1043 let img = if filled { 1044 app_images::like_image_filled() 1045 } else { 1046 app_images::like_image() 1047 }; 1048 1049 if ui.visuals().dark_mode { 1050 img.tint(ui.visuals().text_color()) 1051 } else { 1052 img 1053 } 1054 }; 1055 1056 let (rect, size, resp) = 1057 crate::anim::hover_expand_small(ui, ui.id().with(("like_anim", note_key))); 1058 1059 // align rect to note contents 1060 let expand_size = 5.0; // from hover_expand_small 1061 let rect = rect.translate(egui::vec2(-(expand_size / 2.0), 0.0)); 1062 1063 let put_resp = ui.put(rect, img.max_width(size)).on_hover_text(tr!( 1064 i18n, 1065 "Like this note", 1066 "Hover text for like button" 1067 )); 1068 1069 resp.union(put_resp) 1070 } 1071 1072 fn repost_icon(dark_mode: bool) -> egui::Image<'static> { 1073 if dark_mode { 1074 app_images::repost_dark_image() 1075 } else { 1076 app_images::repost_light_image() 1077 } 1078 } 1079 1080 fn quote_repost_button( 1081 ui: &mut egui::Ui, 1082 i18n: &mut Localization, 1083 note_key: NoteKey, 1084 ) -> egui::Response { 1085 let size = crate::anim::hover_small_size() + 4.0; 1086 let expand_size = 5.0; 1087 let anim_speed = 0.05; 1088 let id = ui.id().with(("repost_anim", note_key)); 1089 1090 let (rect, size, resp) = crate::anim::hover_expand(ui, id, size, expand_size, anim_speed); 1091 1092 let rect = rect.translate(egui::vec2(-(expand_size / 2.0), -1.0)); 1093 1094 let put_resp = ui 1095 .put(rect, repost_icon(ui.visuals().dark_mode).max_width(size)) 1096 .on_hover_text(tr!( 1097 i18n, 1098 "Repost this note", 1099 "Hover text for repost button" 1100 )); 1101 1102 resp.union(put_resp) 1103 } 1104 1105 fn zap_button<'a>( 1106 i18n: &'a mut Localization, 1107 state: AnyZapState, 1108 noteid: &'a [u8; 32], 1109 ) -> impl egui::Widget + use<'a> { 1110 move |ui: &mut egui::Ui| -> egui::Response { 1111 let (rect, size, resp) = crate::anim::hover_expand_small(ui, ui.id().with("zap")); 1112 1113 let mut img = app_images::zap_dark_image().max_width(size); 1114 let id = ui.id().with(("pulse", noteid)); 1115 let ctx = ui.ctx().clone(); 1116 1117 match state { 1118 AnyZapState::None => { 1119 if !ui.visuals().dark_mode { 1120 img = app_images::zap_light_image(); 1121 } 1122 } 1123 AnyZapState::Pending => { 1124 let alpha_min = if ui.visuals().dark_mode { 50 } else { 180 }; 1125 let cur_alpha = PulseAlpha::new(&ctx, id, alpha_min, 255) 1126 .with_speed(0.35) 1127 .animate(); 1128 1129 let cur_color = egui::Color32::from_rgba_unmultiplied(0xFF, 0xB7, 0x57, cur_alpha); 1130 img = img.tint(cur_color); 1131 } 1132 AnyZapState::LocalOnly => { 1133 img = img.tint(egui::Color32::from_rgb(0xFF, 0xB7, 0x57)); 1134 } 1135 AnyZapState::Confirmed => {} 1136 } 1137 1138 // align rect to note contents 1139 let expand_size = 5.0; // from hover_expand_small 1140 let rect = rect.translate(egui::vec2(-(expand_size / 2.0), 0.0)); 1141 1142 let put_resp = ui.put(rect, img).on_hover_text(tr!( 1143 i18n, 1144 "Zap this note", 1145 "Hover text for zap button" 1146 )); 1147 1148 resp.union(put_resp) 1149 } 1150 }