notedeck

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

commit 2fde5addebcbb3c4cc75bb071e9943ce5dde9378
parent f77e7898b6a9cdddca193df562b5d01b312b431c
Author: William Casarin <jb55@jb55.com>
Date:   Sun, 10 Aug 2025 17:46:09 -0700

clndash: zap rendering

Signed-off-by: William Casarin <jb55@jb55.com>

Diffstat:
MCargo.lock | 6++++++
MCargo.toml | 2+-
Mcrates/notedeck_clndash/Cargo.toml | 3+++
Mcrates/notedeck_clndash/src/lib.rs | 132+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------
4 files changed, 129 insertions(+), 14 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -2370,6 +2370,9 @@ name = "hex" version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +dependencies = [ + "serde", +] [[package]] name = "hex-conservative" @@ -3590,9 +3593,12 @@ dependencies = [ "eframe", "egui", "egui_extras", + "hex", "lightning-invoice", "lnsocket", + "nostrdb", "notedeck", + "notedeck_ui", "serde", "serde_json", "tokio", diff --git a/Cargo.toml b/Cargo.toml @@ -37,7 +37,7 @@ ewebsock = { version = "0.2.0", features = ["tls"] } fluent = "0.17.0" fluent-resmgr = "0.0.8" fluent-langneg = "0.13" -hex = "0.4.3" +hex = { version = "0.4.3", features = ["serde"] } image = { version = "0.25", features = ["jpeg", "png", "webp"] } indexmap = "2.6.0" log = "0.4.17" diff --git a/crates/notedeck_clndash/Cargo.toml b/crates/notedeck_clndash/Cargo.toml @@ -14,5 +14,8 @@ tokio = { workspace = true } serde = { workspace = true } egui_extras = { workspace = true } lightning-invoice = { workspace = true } +hex = { workspace = true } +nostrdb = { workspace = true } +notedeck_ui = { workspace = true } lnsocket = "0.5.1" diff --git a/crates/notedeck_clndash/src/lib.rs b/crates/notedeck_clndash/src/lib.rs @@ -7,11 +7,13 @@ use crate::event::ListPeerChannel; use crate::event::Request; use crate::watch::fetch_paid_invoices; -use egui::{Color32, Label, RichText}; +use egui::{Color32, Label, RichText, Widget}; use lnsocket::bitcoin::secp256k1::{PublicKey, SecretKey, rand}; use lnsocket::{CommandoClient, LNSocket}; +use nostrdb::Ndb; use notedeck::{AppAction, AppContext}; use serde_json::json; +use std::collections::HashMap; use std::str::FromStr; use std::sync::Arc; use tokio::sync::mpsc::{UnboundedReceiver, UnboundedSender, unbounded_channel}; @@ -65,9 +67,17 @@ pub struct ClnDash { summary: LoadingState<Summary, lnsocket::Error>, get_info: LoadingState<String, lnsocket::Error>, channels: LoadingState<Channels, lnsocket::Error>, - channel: Option<CommChannel>, invoices: LoadingState<Vec<Invoice>, lnsocket::Error>, + channel: Option<CommChannel>, last_summary: Option<Summary>, + // invoice label to zapreq id + invoice_zap_reqs: HashMap<String, [u8; 32]>, +} + +#[derive(serde::Deserialize)] +pub struct ZapReqId { + #[serde(with = "hex::serde")] + id: [u8; 32], } impl Default for ConnectionState { @@ -88,7 +98,7 @@ enum ConnectionState { } impl notedeck::App for ClnDash { - fn update(&mut self, _ctx: &mut AppContext<'_>, ui: &mut egui::Ui) -> Option<AppAction> { + fn update(&mut self, ctx: &mut AppContext<'_>, ui: &mut egui::Ui) -> Option<AppAction> { if !self.initialized { self.connection_state = ConnectionState::Connecting; @@ -96,9 +106,9 @@ impl notedeck::App for ClnDash { self.initialized = true; } - self.process_events(); + self.process_events(ctx.ndb); - self.show(ui); + self.show(ui, ctx); None } @@ -144,14 +154,14 @@ fn summary_ui( } impl ClnDash { - fn show(&mut self, ui: &mut egui::Ui) { + fn show(&mut self, ui: &mut egui::Ui, ctx: &mut AppContext) { egui::Frame::new() .inner_margin(egui::Margin::same(20)) .show(ui, |ui| { egui::ScrollArea::vertical().show(ui, |ui| { connection_state_ui(ui, &self.connection_state); summary_ui(ui, self.last_summary.as_ref(), &self.summary); - invoices_ui(ui, &self.invoices); + invoices_ui(ui, &self.invoice_zap_reqs, ctx, &self.invoices); channels_ui(ui, &self.channels); get_info_ui(ui, &self.get_info); }); @@ -251,7 +261,7 @@ impl ClnDash { }); } - fn process_events(&mut self) { + fn process_events(&mut self, ndb: &Ndb) { let Some(channel) = &mut self.channel else { return; }; @@ -289,6 +299,23 @@ impl ClnDash { } ClnResponse::PaidInvoices(invoices) => { + // process zap requests + + if let Ok(invoices) = &invoices { + for invoice in invoices { + let zap_req_id: Option<ZapReqId> = + serde_json::from_str(&invoice.description).ok(); + if let Some(zap_req_id) = zap_req_id { + self.invoice_zap_reqs + .insert(invoice.label.clone(), zap_req_id.id); + let _ = ndb.process_event(&format!( + "[\"EVENT\",\"a\",{}]", + &invoice.description + )); + } + } + } + self.invoices = LoadingState::from_result(invoices); } }, @@ -600,7 +627,12 @@ fn delta_str(new: i64, old: i64) -> String { } } -fn invoices_ui(ui: &mut egui::Ui, invoices: &LoadingState<Vec<Invoice>, lnsocket::Error>) { +fn invoices_ui( + ui: &mut egui::Ui, + invoice_notes: &HashMap<String, [u8; 32]>, + ctx: &mut AppContext, + invoices: &LoadingState<Vec<Invoice>, lnsocket::Error>, +) { match invoices { LoadingState::Loading => { ui.label("loading invoices..."); @@ -618,17 +650,23 @@ fn invoices_ui(ui: &mut egui::Ui, invoices: &LoadingState<Vec<Invoice>, lnsocket .column(Column::remainder()) .header(20.0, |mut header| { header.col(|ui| { - ui.heading("Description"); + ui.strong("description"); }); header.col(|ui| { - ui.heading("Amount"); + ui.strong("amount"); }); }) .body(|mut body| { for invoice in invoices { - body.row(30.0, |mut row| { + body.row(20.0, |mut row| { row.col(|ui| { - ui.label(invoice.description.clone()); + if invoice.description.starts_with("{") { + ui.label("Zap!").on_hover_ui_at_pointer(|ui| { + note_hover_ui(ui, &invoice.label, ctx, invoice_notes); + }); + } else { + ui.label(&invoice.description); + } }); row.col(|ui| match invoice.bolt11.amount_milli_satoshis() { None => { @@ -644,3 +682,71 @@ fn invoices_ui(ui: &mut egui::Ui, invoices: &LoadingState<Vec<Invoice>, lnsocket } } } + +fn note_hover_ui( + ui: &mut egui::Ui, + label: &str, + ctx: &mut AppContext, + invoice_notes: &HashMap<String, [u8; 32]>, +) -> Option<notedeck::NoteAction> { + let zap_req_id = invoice_notes.get(label)?; + + let Ok(txn) = nostrdb::Transaction::new(ctx.ndb) else { + return None; + }; + + let Ok(zapreq_note) = ctx.ndb.get_note_by_id(&txn, zap_req_id) else { + return None; + }; + + for tag in zapreq_note.tags() { + let Some("e") = tag.get_str(0) else { + continue; + }; + + let Some(target_id) = tag.get_id(1) else { + continue; + }; + + let Ok(note) = ctx.ndb.get_note_by_id(&txn, target_id) else { + return None; + }; + + let author = ctx + .ndb + .get_profile_by_pubkey(&txn, zapreq_note.pubkey()) + .ok(); + + // TODO(jb55): make this less horrible + let mut note_context = notedeck::NoteContext { + ndb: ctx.ndb, + accounts: ctx.accounts, + img_cache: ctx.img_cache, + note_cache: ctx.note_cache, + zaps: ctx.zaps, + pool: ctx.pool, + job_pool: ctx.job_pool, + unknown_ids: ctx.unknown_ids, + clipboard: ctx.clipboard, + i18n: ctx.i18n, + global_wallet: ctx.global_wallet, + }; + + let mut jobs = notedeck::JobsCache::default(); + let options = notedeck_ui::NoteOptions::default(); + + notedeck_ui::ProfilePic::from_profile_or_default(note_context.img_cache, author.as_ref()) + .ui(ui); + + let nostr_name = notedeck::name::get_display_name(author.as_ref()); + ui.label(format!("{} zapped you", nostr_name.name())); + + return notedeck_ui::NoteView::new(&mut note_context, &note, options, &mut jobs) + .preview_style() + .hide_media(true) + .show(ui) + .action; + } + + None +}