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