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