notedeck

One damus client to rule them all
git clone git://jb55.com/notedeck
Log | Files | Refs | README | LICENSE

commit cfbd601196a5657c10a89cb4b183b1155d823236
parent 5917bc16fd0c03c6f73c0fabfea46e4304834d7b
Author: kernelkind <kernelkind@gmail.com>
Date:   Sun, 30 Mar 2025 15:11:52 -0400

note zap button

Signed-off-by: kernelkind <kernelkind@gmail.com>

Diffstat:
Massets/icons/zap_4x.png | 0
Mcrates/notedeck/src/lib.rs | 4++--
Mcrates/notedeck/src/zaps/mod.rs | 2+-
Mcrates/notedeck_columns/src/actionbar.rs | 69++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Mcrates/notedeck_columns/src/nav.rs | 3+++
Mcrates/notedeck_columns/src/ui/note/mod.rs | 115++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------
6 files changed, 175 insertions(+), 18 deletions(-)

diff --git a/assets/icons/zap_4x.png b/assets/icons/zap_4x.png Binary files differ. diff --git a/crates/notedeck/src/lib.rs b/crates/notedeck/src/lib.rs @@ -57,9 +57,10 @@ pub use user_account::UserAccount; pub use wallet::{ get_wallet_for_mut, GlobalWallet, Wallet, WalletError, WalletState, WalletType, WalletUIState, }; +pub use zaps::{AnyZapState, NoteZapTarget, NoteZapTargetOwned, ZapTarget, ZappingError}; // export libs pub use enostr; pub use nostrdb; -pub use zaps::Zaps; -\ No newline at end of file +pub use zaps::Zaps; diff --git a/crates/notedeck/src/zaps/mod.rs b/crates/notedeck/src/zaps/mod.rs @@ -2,4 +2,4 @@ mod cache; mod networking; mod zap; -pub use cache::Zaps; +pub use cache::{AnyZapState, NoteZapTarget, NoteZapTargetOwned, ZapTarget, ZappingError, Zaps}; diff --git a/crates/notedeck_columns/src/actionbar.rs b/crates/notedeck_columns/src/actionbar.rs @@ -4,9 +4,12 @@ use crate::{ timeline::{TimelineCache, TimelineKind}, }; -use enostr::{NoteId, RelayPool}; +use enostr::{NoteId, Pubkey, RelayPool}; use nostrdb::{Ndb, NoteKey, Transaction}; -use notedeck::{NoteCache, UnknownIds}; +use notedeck::{ + get_wallet_for_mut, Accounts, GlobalWallet, NoteCache, NoteZapTargetOwned, UnknownIds, + ZapTarget, ZappingError, Zaps, +}; use tracing::error; #[derive(Debug, Eq, PartialEq, Clone)] @@ -14,6 +17,13 @@ pub enum NoteAction { Reply(NoteId), Quote(NoteId), OpenTimeline(TimelineKind), + Zap(ZapAction), +} + +#[derive(Debug, Eq, PartialEq, Clone)] +pub enum ZapAction { + Send(NoteZapTargetOwned), + ClearError(NoteZapTargetOwned), } pub struct NewNotes { @@ -35,6 +45,9 @@ impl NoteAction { note_cache: &mut NoteCache, pool: &mut RelayPool, txn: &Transaction, + accounts: &mut Accounts, + global_wallet: &mut GlobalWallet, + zaps: &mut Zaps, ) -> Option<TimelineOpenResult> { match self { NoteAction::Reply(note_id) => { @@ -51,6 +64,31 @@ impl NoteAction { router.route_to(Route::quote(*note_id)); None } + + NoteAction::Zap(zap_action) => 's: { + let Some(cur_acc) = accounts.get_selected_account_mut() else { + break 's None; + }; + + let sender = cur_acc.key.pubkey; + + match zap_action { + ZapAction::Send(target) => { + if get_wallet_for_mut(accounts, global_wallet, sender.bytes()).is_some() { + send_zap(&sender, zaps, pool, target) + } else { + zaps.send_error( + sender.bytes(), + ZapTarget::Note(target.into()), + ZappingError::SenderNoWallet, + ); + } + } + ZapAction::ClearError(target) => clear_zap_error(&sender, zaps, target), + } + + None + } } } @@ -66,14 +104,39 @@ impl NoteAction { pool: &mut RelayPool, txn: &Transaction, unknown_ids: &mut UnknownIds, + accounts: &mut Accounts, + global_wallet: &mut GlobalWallet, + zaps: &mut Zaps, ) { let router = columns.column_mut(col).router_mut(); - if let Some(br) = self.execute(ndb, router, timeline_cache, note_cache, pool, txn) { + if let Some(br) = self.execute( + ndb, + router, + timeline_cache, + note_cache, + pool, + txn, + accounts, + global_wallet, + zaps, + ) { br.process(ndb, note_cache, txn, timeline_cache, unknown_ids); } } } +fn send_zap(sender: &Pubkey, zaps: &mut Zaps, pool: &RelayPool, target: &NoteZapTargetOwned) { + let default_zap_msats = 10_000; // TODO(kernelkind): allow the user to set this default + let zap_target = ZapTarget::Note(target.into()); + + let sender_relays: Vec<String> = pool.relays.iter().map(|r| r.url().to_string()).collect(); + zaps.send_zap(sender.bytes(), sender_relays, zap_target, default_zap_msats); +} + +fn clear_zap_error(sender: &Pubkey, zaps: &mut Zaps, target: &NoteZapTargetOwned) { + zaps.clear_error_for(sender.bytes(), ZapTarget::Note(target.into())); +} + impl TimelineOpenResult { pub fn new_notes(notes: Vec<NoteKey>, id: TimelineKind) -> Self { Self::NewNotes(NewNotes::new(notes, id)) diff --git a/crates/notedeck_columns/src/nav.rs b/crates/notedeck_columns/src/nav.rs @@ -176,6 +176,9 @@ impl RenderNavResponse { ctx.pool, &txn, ctx.unknown_ids, + ctx.accounts, + ctx.global_wallet, + ctx.zaps, ); } diff --git a/crates/notedeck_columns/src/ui/note/mod.rs b/crates/notedeck_columns/src/ui/note/mod.rs @@ -16,7 +16,7 @@ pub use reply::PostReplyView; pub use reply_description::reply_desc; use crate::{ - actionbar::NoteAction, + actionbar::{NoteAction, ZapAction}, profile::get_display_name, timeline::{ThreadSelection, TimelineKind}, ui::{self, View}, @@ -26,9 +26,12 @@ use egui::emath::{pos2, Vec2}; use egui::{Id, Label, Pos2, Rect, Response, RichText, Sense}; use enostr::{KeypairUnowned, NoteId, Pubkey}; use nostrdb::{Ndb, Note, NoteKey, Transaction}; -use notedeck::{CachedNote, NoteCache, NotedeckTextStyle}; +use notedeck::{ + AnyZapState, CachedNote, NoteCache, NoteZapTarget, NoteZapTargetOwned, NotedeckTextStyle, + ZapTarget, Zaps, +}; -use super::profile::preview::one_line_display_name_widget; +use super::{profile::preview::one_line_display_name_widget, widgets::x_button}; pub struct NoteView<'a, 'd> { note_context: &'a mut NoteContext<'d>, @@ -409,7 +412,15 @@ impl<'a, 'd> NoteView<'a, 'd> { } if self.options().has_actionbar() { - if let Some(action) = render_note_actionbar(ui, self.note.id(), note_key).inner + if let Some(action) = render_note_actionbar( + ui, + self.note_context.zaps, + self.cur_acc.as_ref(), + self.note.id(), + self.note.pubkey(), + note_key, + ) + .inner { note_action = Some(action); } @@ -467,8 +478,15 @@ impl<'a, 'd> NoteView<'a, 'd> { } if self.options().has_actionbar() { - if let Some(action) = - render_note_actionbar(ui, self.note.id(), note_key).inner + if let Some(action) = render_note_actionbar( + ui, + self.note_context.zaps, + self.cur_acc.as_ref(), + self.note.id(), + self.note.pubkey(), + note_key, + ) + .inner { note_action = Some(action); } @@ -586,20 +604,67 @@ fn note_hitbox_clicked( #[profiling::function] fn render_note_actionbar( ui: &mut egui::Ui, + zaps: &Zaps, + cur_acc: Option<&KeypairUnowned>, note_id: &[u8; 32], + note_pubkey: &[u8; 32], note_key: NoteKey, ) -> egui::InnerResponse<Option<NoteAction>> { - ui.horizontal(|ui| { + ui.horizontal(|ui| 's: { let reply_resp = reply_button(ui, note_key); let quote_resp = quote_repost_button(ui, note_key); + let zap_target = ZapTarget::Note(NoteZapTarget { + note_id, + zap_recipient: note_pubkey, + }); + + let zap_state = cur_acc.map_or_else( + || AnyZapState::None, + |kp| zaps.any_zap_state_for(kp.pubkey.bytes(), zap_target), + ); + let zap_resp = cur_acc + .filter(|k| k.secret_key.is_some()) + .map(|_| match &zap_state { + AnyZapState::None => ui.add(zap_button(false)), + AnyZapState::Pending => ui.spinner(), + AnyZapState::LocalOnly | AnyZapState::Confirmed => ui.add(zap_button(true)), + AnyZapState::Error(zapping_error) => { + let (rect, _) = + ui.allocate_at_least(egui::vec2(10.0, 10.0), egui::Sense::click()); + ui.add(x_button(rect)) + .on_hover_text(format!("{zapping_error}")) + } + }); + + let to_noteid = |id: &[u8; 32]| NoteId::new(*id); + if reply_resp.clicked() { - Some(NoteAction::Reply(NoteId::new(*note_id))) - } else if quote_resp.clicked() { - Some(NoteAction::Quote(NoteId::new(*note_id))) - } else { - None + break 's Some(NoteAction::Reply(to_noteid(note_id))); + } + + if quote_resp.clicked() { + break 's Some(NoteAction::Quote(to_noteid(note_id))); + } + + let Some(zap_resp) = zap_resp else { + break 's None; + }; + + if !zap_resp.clicked() { + break 's None; + } + + let target = NoteZapTargetOwned { + note_id: to_noteid(note_id), + zap_recipient: Pubkey::new(*note_pubkey), + }; + + if matches!(zap_state, AnyZapState::Error(_)) { + break 's Some(NoteAction::Zap(ZapAction::ClearError(target))); } + + Some(NoteAction::Zap(ZapAction::Send(target))) }) } @@ -669,3 +734,29 @@ fn quote_repost_button(ui: &mut egui::Ui, note_key: NoteKey) -> egui::Response { resp.union(put_resp) } + +fn zap_button(colored: bool) -> impl egui::Widget { + move |ui: &mut egui::Ui| -> egui::Response { + let img_data = egui::include_image!("../../../../../assets/icons/zap_4x.png"); + + let (rect, size, resp) = ui::anim::hover_expand_small(ui, ui.id().with("zap")); + + let mut img = egui::Image::new(img_data).max_width(size); + + if colored { + img = img.tint(egui::Color32::from_rgb(0xFF, 0xB7, 0x57)); + } + + if !colored && !ui.visuals().dark_mode { + img = img.tint(egui::Color32::BLACK); + } + + // 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); + + resp.union(put_resp) + } +}