pub mod contents; pub mod context; pub mod media; pub mod options; pub mod reply_description; use crate::{app_images, secondary_label}; use crate::{widgets::x_button, ProfilePic, ProfilePreview, PulseAlpha, Username}; pub use contents::{render_note_preview, NoteContents}; pub use context::NoteContextButton; use notedeck::get_current_wallet; use notedeck::note::{reaction_sent_id, ZapTargetAmount}; use notedeck::ui::is_narrow; use notedeck::Accounts; use notedeck::GlobalWallet; use notedeck::Images; use notedeck::JobsCache; use notedeck::Localization; use notedeck::MediaAction; pub use options::NoteOptions; pub use reply_description::reply_desc; use egui::emath::{pos2, Vec2}; use egui::{Id, Pos2, Rect, Response, Sense}; use enostr::{KeypairUnowned, NoteId, Pubkey}; use nostrdb::{Ndb, Note, NoteKey, ProfileRecord, Transaction}; use notedeck::{ note::{NoteAction, NoteContext, ReactAction, ZapAction}, tr, AnyZapState, ContextSelection, NoteZapTarget, NoteZapTargetOwned, ZapTarget, Zaps, }; pub struct NoteView<'a, 'd> { note_context: &'a mut NoteContext<'d>, parent: Option, note: &'a nostrdb::Note<'a>, flags: NoteOptions, jobs: &'a mut JobsCache, } pub struct NoteResponse { pub response: egui::Response, pub action: Option, pub pfp_rect: Option, } impl NoteResponse { pub fn new(response: egui::Response) -> Self { Self { response, action: None, pfp_rect: None, } } pub fn with_action(mut self, action: Option) -> Self { self.action = action; self } pub fn with_pfp(mut self, pfp_rect: egui::Rect) -> Self { self.pfp_rect = Some(pfp_rect); self } } /* impl View for NoteView<'_, '_> { fn ui(&mut self, ui: &mut egui::Ui) { self.show(ui); } } */ impl egui::Widget for &mut NoteView<'_, '_> { fn ui(self, ui: &mut egui::Ui) -> egui::Response { self.show(ui).response } } impl<'a, 'd> NoteView<'a, 'd> { pub fn new( note_context: &'a mut NoteContext<'d>, note: &'a nostrdb::Note<'a>, flags: NoteOptions, jobs: &'a mut JobsCache, ) -> Self { let parent: Option = None; Self { note_context, parent, note, flags, jobs, } } pub fn preview_style(self) -> Self { self.actionbar(false) .small_pfp(true) .frame(true) .wide(true) .note_previews(false) .options_button(true) .is_preview(true) .full_date(false) .client_name(false) } pub fn selected_style(self, selected: bool) -> Self { self.wide(selected) .full_date(selected) .client_name(selected) } #[inline] pub fn textmode(mut self, enable: bool) -> Self { self.options_mut().set(NoteOptions::Textmode, enable); self } #[inline] pub fn client_name(mut self, enable: bool) -> Self { self.options_mut().set(NoteOptions::ClientName, enable); self } #[inline] pub fn full_date(mut self, enable: bool) -> Self { self.options_mut().set(NoteOptions::FullCreatedDate, enable); self } #[inline] pub fn actionbar(mut self, enable: bool) -> Self { self.options_mut().set(NoteOptions::ActionBar, enable); self } #[inline] pub fn hide_media(mut self, enable: bool) -> Self { self.options_mut().set(NoteOptions::HideMedia, enable); self } #[inline] pub fn frame(mut self, enable: bool) -> Self { self.options_mut().set(NoteOptions::Framed, enable); self } #[inline] pub fn truncate(mut self, enable: bool) -> Self { self.options_mut().set(NoteOptions::Truncate, enable); self } #[inline] pub fn small_pfp(mut self, enable: bool) -> Self { self.options_mut().set(NoteOptions::SmallPfp, enable); self } #[inline] pub fn medium_pfp(mut self, enable: bool) -> Self { self.options_mut().set(NoteOptions::MediumPfp, enable); self } #[inline] pub fn note_previews(mut self, enable: bool) -> Self { self.options_mut().set(NoteOptions::HasNotePreviews, enable); self } #[inline] pub fn selectable_text(mut self, enable: bool) -> Self { self.options_mut().set(NoteOptions::SelectableText, enable); self } #[inline] pub fn wide(mut self, enable: bool) -> Self { self.options_mut().set(NoteOptions::Wide, enable); self } #[inline] pub fn options_button(mut self, enable: bool) -> Self { self.options_mut().set(NoteOptions::OptionsButton, enable); self } #[inline] pub fn unread_indicator(mut self, enable: bool) -> Self { self.options_mut().set(NoteOptions::UnreadIndicator, enable); self } #[inline] pub fn options(&self) -> NoteOptions { self.flags } #[inline] pub fn options_mut(&mut self) -> &mut NoteOptions { &mut self.flags } #[inline] pub fn parent(mut self, parent: NoteKey) -> Self { self.parent = Some(parent); self } #[inline] pub fn is_preview(mut self, is_preview: bool) -> Self { self.options_mut().set(NoteOptions::IsPreview, is_preview); self } fn textmode_ui(&mut self, ui: &mut egui::Ui) -> egui::Response { let txn = self.note.txn().expect("todo: implement non-db notes"); ui.with_layout(egui::Layout::left_to_right(egui::Align::TOP), |ui| { let profile = self .note_context .ndb .get_profile_by_pubkey(txn, self.note.pubkey()); //ui.horizontal(|ui| { ui.spacing_mut().item_spacing.x = 2.0; let (_id, rect) = ui.allocate_space(egui::vec2(50.0, 20.0)); ui.allocate_rect(rect, Sense::hover()); ui.put(rect, |ui: &mut egui::Ui| { render_notetime(ui, self.note_context.i18n, self.note.created_at(), false) }); let (_id, rect) = ui.allocate_space(egui::vec2(150.0, 20.0)); ui.allocate_rect(rect, Sense::hover()); ui.put(rect, |ui: &mut egui::Ui| { ui.add( Username::new( self.note_context.i18n, profile.as_ref().ok(), self.note.pubkey(), ) .abbreviated(6) .pk_colored(true), ) }); ui.add(&mut NoteContents::new( self.note_context, txn, self.note, self.flags, self.jobs, )); //}); }) .response } pub fn expand_size() -> i8 { 5 } fn pfp( &mut self, note_key: NoteKey, profile: &Result, nostrdb::Error>, ui: &mut egui::Ui, ) -> PfpResponse { if !self.options().contains(NoteOptions::Wide) { ui.spacing_mut().item_spacing.x = 16.0; } else { ui.spacing_mut().item_spacing.x = 4.0; } let pfp_size = self.options().pfp_size(); match profile .as_ref() .ok() .and_then(|p| p.record().profile()?.picture()) { // these have different lifetimes and types, // so the calls must be separate Some(pic) => show_actual_pfp( ui, self.note_context.img_cache, pic, pfp_size, note_key, profile, ), None => show_fallback_pfp(ui, self.note_context.img_cache, pfp_size), } } pub fn show(&mut self, ui: &mut egui::Ui) -> NoteResponse { if !self.flags.contains(NoteOptions::TrustMedia) { let acc = self.note_context.accounts.get_selected_account(); if self.note.pubkey() == acc.key.pubkey.bytes() || matches!( acc.is_following(self.note.pubkey()), notedeck::IsFollowing::Yes ) { self.flags = self.flags.union(NoteOptions::TrustMedia); } } if self.options().contains(NoteOptions::Textmode) { NoteResponse::new(self.textmode_ui(ui)) } else if self.options().contains(NoteOptions::Framed) { egui::Frame::new() .fill(ui.visuals().noninteractive().weak_bg_fill) .inner_margin(egui::Margin::same(8)) .outer_margin(egui::Margin::symmetric(0, 8)) .corner_radius(egui::CornerRadius::same(10)) .stroke(egui::Stroke::new( 1.0, ui.visuals().noninteractive().bg_stroke.color, )) .show(ui, |ui| { if is_narrow(ui.ctx()) { ui.set_width(ui.available_width()); } self.show_standard(ui) }) .inner } else { self.show_standard(ui) } } #[profiling::function] fn note_header( ui: &mut egui::Ui, i18n: &mut Localization, note: &Note, profile: &Result, nostrdb::Error>, flags: NoteOptions, ) { let horiz_resp = ui .horizontal_wrapped(|ui| { ui.spacing_mut().item_spacing.x = if is_narrow(ui.ctx()) { 1.0 } else { 2.0 }; let response = ui .add(Username::new(i18n, profile.as_ref().ok(), note.pubkey()).abbreviated(20)); if !flags.contains(NoteOptions::FullCreatedDate) { return render_notetime(ui, i18n, note.created_at(), true); } response }) .response; if flags.contains(NoteOptions::UnreadIndicator) { let radius = 4.0; let circle_center = { let mut center = horiz_resp.rect.right_center(); center.x += radius + 4.0; center }; ui.painter() .circle_filled(circle_center, radius, crate::colors::PINK); } } fn wide_ui( &mut self, ui: &mut egui::Ui, txn: &Transaction, note_key: NoteKey, profile: &Result, ) -> egui::InnerResponse { ui.with_layout(egui::Layout::top_down(egui::Align::LEFT), |ui| { let mut note_action: Option = None; let mut pfp_rect = None; if !self.flags.contains(NoteOptions::NotificationPreview) { ui.horizontal(|ui| { let pfp_resp = self.pfp(note_key, profile, ui); pfp_rect = Some(pfp_resp.bounding_rect); note_action = pfp_resp .into_action(self.note.pubkey()) .or(note_action.take()); let size = ui.available_size(); ui.vertical(|ui| { ui.add_sized( [size.x, self.options().pfp_size() as f32], |ui: &mut egui::Ui| { ui.horizontal_centered(|ui| { NoteView::note_header( ui, self.note_context.i18n, self.note, profile, self.flags, ); }) .response }, ); let note_reply = self .note_context .note_cache .cached_note_or_insert_mut(note_key, self.note) .reply .borrow(self.note.tags()); if note_reply.reply().is_none() { return; } ui.horizontal_wrapped(|ui| { ui.spacing_mut().item_spacing.x = 0.0; note_action = reply_desc( ui, txn, ¬e_reply, self.note_context, self.flags, self.jobs, ) .or(note_action.take()); }); }); }); } let mut contents = NoteContents::new(self.note_context, txn, self.note, self.flags, self.jobs); ui.add(&mut contents); note_action = contents.action.or(note_action); if self.options().contains(NoteOptions::ActionBar) { note_action = ui .horizontal_wrapped(|ui| { // NOTE(jb55): without this we get a weird artifact where // there subsequent lines start sinking leftward off the screen. // question: WTF? question 2: WHY? ui.allocate_space(egui::vec2(0.0, 0.0)); let counts = self .note_context .ndb .get_note_metadata(txn, self.note.id()) .ok() .and_then(|md| { md.into_iter().find_map(|e| { if let nostrdb::NoteMetadataEntryVariant::Counts(ce) = e { Some(ce) } else { None } }) }); render_note_actionbar( ui, counts, get_zapper( self.note_context.accounts, self.note_context.global_wallet, self.note_context.zaps, ), self.note, self.note_context.accounts.selected_account_pubkey(), note_key, self.note_context.i18n, ) }) .inner .or(note_action); } NoteUiResponse { action: note_action, pfp_rect, } }) } fn standard_ui( &mut self, ui: &mut egui::Ui, txn: &Transaction, note_key: NoteKey, profile: &Result, ) -> egui::InnerResponse { // main design ui.with_layout(egui::Layout::left_to_right(egui::Align::TOP), |ui| { let (mut note_action, pfp_rect) = if self.flags.contains(NoteOptions::NotificationPreview) { // do not render pfp (None, None) } else { let pfp_resp = self.pfp(note_key, profile, ui); let pfp_rect = pfp_resp.bounding_rect; (pfp_resp.into_action(self.note.pubkey()), Some(pfp_rect)) }; ui.with_layout(egui::Layout::top_down(egui::Align::LEFT), |ui| { if !self.flags.contains(NoteOptions::NotificationPreview) { NoteView::note_header( ui, self.note_context.i18n, self.note, profile, self.flags, ); ui.horizontal_wrapped(|ui| { ui.spacing_mut().item_spacing.x = 1.0; let note_reply = self .note_context .note_cache .cached_note_or_insert_mut(note_key, self.note) .reply .borrow(self.note.tags()); if note_reply.reply().is_none() { return; } note_action = reply_desc( ui, txn, ¬e_reply, self.note_context, self.flags, self.jobs, ) .or(note_action.take()); }); } let mut contents = NoteContents::new(self.note_context, txn, self.note, self.flags, self.jobs); ui.add(&mut contents); note_action = contents.action.or(note_action); if self.options().contains(NoteOptions::ActionBar) { let counts = self .note_context .ndb .get_note_metadata(txn, self.note.id()) .ok() .and_then(|md| { md.into_iter().find_map(|e| { if let nostrdb::NoteMetadataEntryVariant::Counts(ce) = e { Some(ce) } else { None } }) }); note_action = ui .horizontal_wrapped(|ui| { render_note_actionbar( ui, counts, get_zapper( self.note_context.accounts, self.note_context.global_wallet, self.note_context.zaps, ), self.note, self.note_context.accounts.selected_account_pubkey(), note_key, self.note_context.i18n, ) }) .inner .or(note_action); } NoteUiResponse { action: note_action, pfp_rect, } }) .inner }) } #[profiling::function] fn show_standard(&mut self, ui: &mut egui::Ui) -> NoteResponse { let note_key = self.note.key().expect("todo: support non-db notes"); let txn = self.note.txn().expect("todo: support non-db notes"); let profile = self .note_context .ndb .get_profile_by_pubkey(txn, self.note.pubkey()); let hitbox_id = note_hitbox_id(note_key, self.options(), self.parent); let maybe_hitbox = maybe_note_hitbox(ui, hitbox_id); // wide design let response = if self.options().contains(NoteOptions::Wide) { self.wide_ui(ui, txn, note_key, &profile) } else { self.standard_ui(ui, txn, note_key, &profile) }; let note_ui_resp = response.inner; let mut note_action = note_ui_resp.action; if self.options().contains(NoteOptions::OptionsButton) { let context_pos = { let size = NoteContextButton::max_width(); let top_right = response.response.rect.right_top(); let min = Pos2::new(top_right.x - size, top_right.y); Rect::from_min_size(min, egui::vec2(size, size)) }; let resp = ui.add(NoteContextButton::new(note_key).place_at(context_pos)); if let Some(action) = NoteContextButton::menu(ui, self.note_context.i18n, resp.clone()) { note_action = Some(NoteAction::Context(ContextSelection { note_key, action })); } } note_action = note_hitbox_clicked(ui, hitbox_id, &response.response.rect, maybe_hitbox) .then_some(NoteAction::note(NoteId::new(*self.note.id()))) .or(note_action); let mut resp = NoteResponse::new(response.response).with_action(note_action); if let Some(pfp_rect) = note_ui_resp.pfp_rect { resp = resp.with_pfp(pfp_rect); } resp } } fn get_zapper<'a>( accounts: &'a Accounts, global_wallet: &'a GlobalWallet, zaps: &'a Zaps, ) -> Option> { let has_wallet = get_current_wallet(accounts, global_wallet).is_some(); let cur_acc = accounts.get_selected_account(); has_wallet.then_some(Zapper { zaps, cur_acc: cur_acc.keypair(), }) } pub fn get_reposted_note<'a>(ndb: &Ndb, txn: &'a Transaction, note: &Note) -> Option> { if note.kind() != 6 { return None; } let new_note_id: &[u8; 32] = { let mut res = None; for tag in note.tags().iter() { if tag.count() == 0 { continue; } if let Some("e") = tag.get(0).and_then(|t| t.variant().str()) { if let Some(note_id) = tag.get(1).and_then(|f| f.variant().id()) { res = Some(note_id); break; } } } res? }; let note = ndb.get_note_by_id(txn, new_note_id).ok(); note.filter(|note| note.kind() == 1) } struct NoteUiResponse { action: Option, pfp_rect: Option, } struct PfpResponse { action: Option, response: egui::Response, bounding_rect: egui::Rect, } impl PfpResponse { fn into_action(self, note_pk: &[u8; 32]) -> Option { if self.response.clicked() { return Some(NoteAction::Profile(Pubkey::new(*note_pk))); } self.action.map(NoteAction::Media) } } fn show_actual_pfp( ui: &mut egui::Ui, images: &mut Images, pic: &str, pfp_size: i8, note_key: NoteKey, profile: &Result, nostrdb::Error>, ) -> PfpResponse { let anim_speed = 0.05; let profile_key = profile.as_ref().unwrap().record().note_key(); let note_key = note_key.as_u64(); let (rect, size, resp) = crate::anim::hover_expand( ui, egui::Id::new((profile_key, note_key)), pfp_size as f32, NoteView::expand_size() as f32, anim_speed, ); let resp = resp.on_hover_cursor(egui::CursorIcon::PointingHand); let mut pfp = ProfilePic::new(images, pic).size(size); let pfp_resp = ui.put(rect, &mut pfp); let action = pfp.action; pfp_resp.on_hover_ui_at_pointer(|ui| { ui.set_max_width(300.0); ui.add(ProfilePreview::new(profile.as_ref().unwrap(), images)); }); PfpResponse { response: resp, action, bounding_rect: rect.shrink((rect.width() - size) / 2.0), } } fn show_fallback_pfp(ui: &mut egui::Ui, images: &mut Images, pfp_size: i8) -> PfpResponse { let sense = Sense::click(); // This has to match the expand size from the above case to // prevent bounciness let size = (pfp_size + NoteView::expand_size()) as f32; let (rect, _response) = ui.allocate_exact_size(egui::vec2(size, size), sense); let mut pfp = ProfilePic::new(images, notedeck::profile::no_pfp_url()).size(pfp_size as f32); let response = ui.put(rect, &mut pfp).interact(sense); PfpResponse { action: pfp.action, response, bounding_rect: rect.shrink((rect.width() - size) / 2.0), } } fn note_hitbox_id( note_key: NoteKey, note_options: NoteOptions, parent: Option, ) -> egui::Id { Id::new(("note_size", note_key, note_options, parent)) } fn maybe_note_hitbox(ui: &mut egui::Ui, hitbox_id: egui::Id) -> Option { ui.ctx() .data_mut(|d| d.get_temp(hitbox_id)) .map(|note_size: Vec2| { // The hitbox should extend the entire width of the // container. The hitbox height was cached last layout. let container_rect = ui.max_rect(); let rect = Rect { min: pos2(container_rect.min.x, container_rect.min.y), max: pos2(container_rect.max.x, container_rect.min.y + note_size.y), }; let response = ui.interact(rect, ui.id().with(hitbox_id), egui::Sense::click()); response .widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Other, true, "hitbox")); response }) } fn note_hitbox_clicked( ui: &mut egui::Ui, hitbox_id: egui::Id, note_rect: &Rect, maybe_hitbox: Option, ) -> bool { // Stash the dimensions of the note content so we can render the // hitbox in the next frame ui.ctx().data_mut(|d| { d.insert_temp(hitbox_id, note_rect.size()); }); // If there was an hitbox and it was clicked open the thread match maybe_hitbox { Some(hitbox) => hitbox.clicked(), _ => false, } } struct Zapper<'a> { zaps: &'a Zaps, cur_acc: KeypairUnowned<'a>, } fn zap_actionbar_button( ui: &mut egui::Ui, note_id: &[u8; 32], note_pubkey: &[u8; 32], zapper: Option>, i18n: &mut Localization, ) -> Option { let mut action: Option = None; let Zapper { zaps, cur_acc } = zapper?; let zap_target = ZapTarget::Note(NoteZapTarget { note_id, zap_recipient: note_pubkey, }); let zap_state = zaps.any_zap_state_for(cur_acc.pubkey.bytes(), zap_target); let target = NoteZapTargetOwned { note_id: NoteId::new(*note_id), zap_recipient: Pubkey::new(*note_pubkey), }; cur_acc.secret_key.as_ref()?; match zap_state { Ok(any_zap_state) => { let zap_resp = ui.add(zap_button(i18n, any_zap_state, note_id)); if zap_resp.secondary_clicked() { action = Some(NoteAction::Zap(ZapAction::CustomizeAmount(target.clone()))); } if zap_resp.clicked() { action = Some(NoteAction::Zap(ZapAction::Send(ZapTargetAmount { target, specified_msats: None, }))); } zap_resp } Err(err) => { let (rect, _) = ui.allocate_at_least(egui::vec2(10.0, 10.0), egui::Sense::click()); let x_button = ui.add(x_button(rect)).on_hover_text(err.to_string()); if x_button.clicked() { action = Some(NoteAction::Zap(ZapAction::ClearError(target.clone()))); } x_button } } .on_hover_cursor(egui::CursorIcon::PointingHand); action } fn is_root_note(note: &Note) -> bool { for tag in note.tags() { if tag.count() < 2 { continue; } // any reference to an e tag is a non-root note if tag.get_str(0) == Some("e") { return false; } } true } #[profiling::function] fn render_note_actionbar( ui: &mut egui::Ui, counts: Option>, zapper: Option>, note: &Note, current_user_pubkey: &Pubkey, note_key: NoteKey, i18n: &mut Localization, ) -> Option { let mut action = None; let spacing = 24.0; ui.spacing_mut().item_spacing.x = 2.0; ui.set_min_height(26.0); let reply_resp = reply_button(ui, i18n, note_key).on_hover_cursor(egui::CursorIcon::PointingHand); if let Some(c) = &counts { let count = if is_root_note(note) { c.thread_replies() } else { c.direct_replies() as u32 }; if count > 0 { ui.weak(format!("{}", count)); } } ui.add_space(spacing); let filled = ui .ctx() .data(|d| d.get_temp(reaction_sent_id(current_user_pubkey, note.id()))) == Some(true); let like_resp = like_button(ui, i18n, note_key, filled).on_hover_cursor(egui::CursorIcon::PointingHand); if let Some(c) = &counts { let count = c.reactions(); if count > 0 { ui.weak(format!("{}", count)); } } ui.add_space(spacing); let quote_resp = quote_repost_button(ui, i18n, note_key).on_hover_cursor(egui::CursorIcon::PointingHand); if let Some(c) = &counts { let count = c.quotes() + c.reposts(); if count > 0 { ui.weak(format!("{}", count)); } } ui.add_space(spacing); if reply_resp.clicked() { action = Some(NoteAction::Reply(NoteId::new(*note.id()))); } if like_resp.clicked() { action = Some(NoteAction::React(ReactAction::new( NoteId::new(*note.id()), "🤙🏻", ))); } if quote_resp.clicked() { action = Some(NoteAction::Repost(NoteId::new(*note.id()))); } action = zap_actionbar_button(ui, note.id(), note.pubkey(), zapper, i18n).or(action); action } #[profiling::function] fn render_notetime( ui: &mut egui::Ui, i18n: &mut Localization, created_at: u64, before: bool, ) -> Response { if before { secondary_label( ui, format!(" ⋅ {}", notedeck::time_ago_since(i18n, created_at)), ) } else { secondary_label( ui, format!("{} ⋅ ", notedeck::time_ago_since(i18n, created_at)), ) } } fn reply_button(ui: &mut egui::Ui, i18n: &mut Localization, note_key: NoteKey) -> egui::Response { let img = if ui.style().visuals.dark_mode { app_images::reply_dark_image() } else { app_images::reply_light_image() }; let (rect, size, resp) = crate::anim::hover_expand_small(ui, ui.id().with(("reply_anim", note_key))); // align rect to note contents let expand_size = 5.0; // from hover_expand_small let rect = rect.translate(egui::vec2(-(expand_size / 2.0), 0.0)); let put_resp = ui.put(rect, img.max_width(size)).on_hover_text(tr!( i18n, "Reply to this note", "Hover text for reply button" )); resp.union(put_resp) } fn like_button( ui: &mut egui::Ui, i18n: &mut Localization, note_key: NoteKey, filled: bool, ) -> egui::Response { let img = { let img = if filled { app_images::like_image_filled() } else { app_images::like_image() }; if ui.visuals().dark_mode { img.tint(ui.visuals().text_color()) } else { img } }; let (rect, size, resp) = crate::anim::hover_expand_small(ui, ui.id().with(("like_anim", note_key))); // align rect to note contents let expand_size = 5.0; // from hover_expand_small let rect = rect.translate(egui::vec2(-(expand_size / 2.0), 0.0)); let put_resp = ui.put(rect, img.max_width(size)).on_hover_text(tr!( i18n, "Like this note", "Hover text for like button" )); resp.union(put_resp) } fn repost_icon(dark_mode: bool) -> egui::Image<'static> { if dark_mode { app_images::repost_dark_image() } else { app_images::repost_light_image() } } fn quote_repost_button( ui: &mut egui::Ui, i18n: &mut Localization, note_key: NoteKey, ) -> egui::Response { let size = crate::anim::hover_small_size() + 4.0; let expand_size = 5.0; let anim_speed = 0.05; let id = ui.id().with(("repost_anim", note_key)); let (rect, size, resp) = crate::anim::hover_expand(ui, id, size, expand_size, anim_speed); let rect = rect.translate(egui::vec2(-(expand_size / 2.0), -1.0)); let put_resp = ui .put(rect, repost_icon(ui.visuals().dark_mode).max_width(size)) .on_hover_text(tr!( i18n, "Repost this note", "Hover text for repost button" )); resp.union(put_resp) } fn zap_button<'a>( i18n: &'a mut Localization, state: AnyZapState, noteid: &'a [u8; 32], ) -> impl egui::Widget + use<'a> { move |ui: &mut egui::Ui| -> egui::Response { let (rect, size, resp) = crate::anim::hover_expand_small(ui, ui.id().with("zap")); let mut img = app_images::zap_dark_image().max_width(size); let id = ui.id().with(("pulse", noteid)); let ctx = ui.ctx().clone(); match state { AnyZapState::None => { if !ui.visuals().dark_mode { img = app_images::zap_light_image(); } } AnyZapState::Pending => { let alpha_min = if ui.visuals().dark_mode { 50 } else { 180 }; let cur_alpha = PulseAlpha::new(&ctx, id, alpha_min, 255) .with_speed(0.35) .animate(); let cur_color = egui::Color32::from_rgba_unmultiplied(0xFF, 0xB7, 0x57, cur_alpha); img = img.tint(cur_color); } AnyZapState::LocalOnly => { img = img.tint(egui::Color32::from_rgb(0xFF, 0xB7, 0x57)); } AnyZapState::Confirmed => {} } // align rect to note contents let expand_size = 5.0; // from hover_expand_small let rect = rect.translate(egui::vec2(-(expand_size / 2.0), 0.0)); let put_resp = ui.put(rect, img).on_hover_text(tr!( i18n, "Zap this note", "Hover text for zap button" )); resp.union(put_resp) } }