diff --git a/assets/icons/wallet.svg b/assets/icons/wallet.svg
new file mode 100644
index 00000000..4c79e855
--- /dev/null
+++ b/assets/icons/wallet.svg
@@ -0,0 +1,3 @@
+
diff --git a/crates/nip60_ui/src/wallet.rs b/crates/nip60_ui/src/wallet.rs
index 7026f4cd..c9acabd3 100644
--- a/crates/nip60_ui/src/wallet.rs
+++ b/crates/nip60_ui/src/wallet.rs
@@ -1,9 +1,12 @@
-use ::nip60::{HistoryEntry, MintQuoteEntry, WalletEvent, WalletRegistry};
+use std::rc::Rc;
+
+use ::nip60::{HistoryEntry, WalletEvent, WalletRegistry};
use common::TimestampExt;
use gpui::prelude::FluentBuilder;
use gpui::{
- App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable, IntoElement,
- ParentElement, Render, SharedString, Styled, Subscription, Window, div,
+ AnyElement, App, AppContext, ClickEvent, Context, Entity, EventEmitter, FocusHandle, Focusable,
+ IntoElement, ParentElement, Render, SharedString, Styled, Subscription, Window, div, px,
+ uniform_list,
};
use gpui_kit::assets::IconName;
use gpui_kit::base::StyledExt as _;
@@ -12,40 +15,27 @@ use gpui_kit::component::clipboard::Clipboard;
use gpui_kit::component::dock::{Panel, PanelEvent};
use gpui_kit::component::input::{Input, InputState};
use gpui_kit::component::notification::Notification;
-use gpui_kit::component::scroll::ScrollableElement as _;
use gpui_kit::component::separator::Separator;
use gpui_kit::component::{ActiveTheme, Icon, Sizable, WindowExt, h_flex, v_flex};
use nostr_sdk::prelude::*;
use smallvec::SmallVec;
+
pub fn init(window: &mut Window, cx: &mut App) -> Entity {
cx.new(|cx| WalletPanel::new(window, cx))
}
+type SubmitFn = Rc bool>;
+
pub struct WalletPanel {
name: SharedString,
focus_handle: FocusHandle,
-
- /// Mint URL input for the setup flow
- mint_input: Entity,
- /// Amount input for sending a cashu token
- send_input: Entity,
- /// Amount input for the funding invoice
- invoice_input: Entity,
- /// Pasted token input for receiving ecash
- token_input: Entity,
/// The token produced by the last send, shown for copying
sent_token: Option,
-
_subscriptions: SmallVec<[Subscription; 1]>,
}
impl WalletPanel {
fn new(window: &mut Window, cx: &mut Context) -> Self {
- let mint_input = cx.new(|cx| InputState::new(window, cx).placeholder("https://mint…"));
- let send_input = cx.new(|cx| InputState::new(window, cx).placeholder("Amount (sats)"));
- let invoice_input = cx.new(|cx| InputState::new(window, cx).placeholder("Amount (sats)"));
- let token_input = cx.new(|cx| InputState::new(window, cx).placeholder("cashu…"));
-
let wallet = WalletRegistry::global(cx);
let mut subscriptions = SmallVec::new();
subscriptions.push(
@@ -68,83 +58,20 @@ impl WalletPanel {
Self {
name: "Wallet".into(),
focus_handle: cx.focus_handle(),
- mint_input,
- send_input,
- invoice_input,
- token_input,
sent_token: None,
_subscriptions: subscriptions,
}
}
- fn parse_amount(&self, input: &Entity, cx: &Context) -> Option {
- match input.read(cx).value().trim().parse::() {
- Ok(amount) if amount > 0 => Some(amount),
- _ => None,
- }
- }
-
- fn setup(&mut self, window: &mut Window, cx: &mut Context) {
- let mint = self.mint_input.read(cx).value().trim().to_string();
- if mint.is_empty() {
- window.push_notification(Notification::error("Enter a mint URL"), cx);
- return;
- }
-
- WalletRegistry::global(cx).update(cx, |registry, cx| {
- registry.setup(mint, cx);
- });
- }
-
- fn send_token(&mut self, window: &mut Window, cx: &mut Context) {
- let Some(amount) = self.parse_amount(&self.send_input, cx) else {
- window.push_notification(Notification::error("Enter an amount in sats"), cx);
- return;
- };
-
- WalletRegistry::global(cx).update(cx, |registry, cx| {
- registry.send_token(amount, cx);
- });
- }
-
- fn create_invoice(&mut self, window: &mut Window, cx: &mut Context) {
- let Some(amount) = self.parse_amount(&self.invoice_input, cx) else {
- window.push_notification(Notification::error("Enter an amount in sats"), cx);
- return;
- };
-
- WalletRegistry::global(cx).update(cx, |registry, cx| {
- registry.create_mint_quote(amount, cx);
- });
- }
-
- fn claim_quote(&mut self, quote_id: String, cx: &mut Context) {
- WalletRegistry::global(cx).update(cx, |registry, cx| {
- registry.claim_mint_quote(quote_id, cx);
- });
- }
-
- fn receive_token(&mut self, window: &mut Window, cx: &mut Context) {
- let token = self.token_input.read(cx).value().trim().to_string();
- if token.is_empty() {
- window.push_notification(Notification::error("Paste a cashu token"), cx);
- return;
- }
-
- WalletRegistry::global(cx).update(cx, |registry, cx| {
- registry.receive_token(token, cx);
- });
- }
-
fn render_balance(&self, cx: &Context) -> impl IntoElement {
let balance = WalletRegistry::global(cx).read(cx).balance();
- h_flex()
- .gap_2()
- .items_end()
+ v_flex()
+ .items_center()
+ .gap_1()
.child(
div()
- .text_2xl()
+ .text_xl()
.child(SharedString::from(balance.to_string())),
)
.child(
@@ -155,41 +82,24 @@ impl WalletPanel {
)
}
- fn render_setup(&self, cx: &mut Context) -> impl IntoElement {
- let has_wallet = WalletRegistry::global(cx).read(cx).has_wallet();
-
- v_flex()
- .gap_1p5()
- .w_full()
+ fn render_actions(&self) -> impl IntoElement {
+ h_flex()
+ .justify_center()
+ .gap_2()
.child(
- div()
- .text_xs()
- .font_semibold()
- .text_color(cx.theme().muted_foreground)
- .child(SharedString::from(if has_wallet {
- "Add a mint:"
- } else {
- "Set up your wallet with a mint:"
- })),
+ Button::new("send")
+ .label("Send")
+ .primary()
+ .on_click(|_, window, cx| open_send_dialog(window, cx)),
)
.child(
- h_flex()
- .gap_1()
- .w_full()
- .child(Input::new(&self.mint_input).cleanable(true))
- .child(
- Button::new("setup")
- .label(if has_wallet { "Add" } else { "Set up" })
- .primary()
- .small()
- .on_click(cx.listener(move |this, _ev, window, cx| {
- this.setup(window, cx);
- })),
- ),
+ Button::new("receive")
+ .label("Receive")
+ .on_click(|_, window, cx| open_receive_dialog(window, cx)),
)
}
- fn render_send(&self, cx: &Context) -> impl IntoElement {
+ fn render_sent_token(&self, cx: &Context) -> impl IntoElement {
v_flex()
.gap_1p5()
.w_full()
@@ -198,21 +108,7 @@ impl WalletPanel {
.text_xs()
.font_semibold()
.text_color(cx.theme().muted_foreground)
- .child("Send ecash:"),
- )
- .child(
- h_flex()
- .gap_1()
- .w_full()
- .child(Input::new(&self.send_input).cleanable(true))
- .child(
- Button::new("send")
- .label("Send")
- .small()
- .on_click(cx.listener(move |this, _ev, window, cx| {
- this.send_token(window, cx);
- })),
- ),
+ .child("Sent token:"),
)
.when_some(self.sent_token.as_ref(), |this, token| {
this.child(
@@ -239,110 +135,43 @@ impl WalletPanel {
})
}
- fn render_receive(&self, cx: &Context) -> impl IntoElement {
- let quotes = WalletRegistry::global(cx).read(cx).mint_quotes().to_vec();
-
- let mut section =
- v_flex()
- .gap_1p5()
- .w_full()
- .child(
- div()
- .text_xs()
- .font_semibold()
- .text_color(cx.theme().muted_foreground)
- .child("Receive:"),
- )
- .child(
- h_flex()
- .gap_1()
- .w_full()
- .child(Input::new(&self.invoice_input).cleanable(true))
- .child(Button::new("invoice").label("Invoice").small().on_click(
- cx.listener(move |this, _ev, window, cx| {
- this.create_invoice(window, cx);
- }),
- )),
- );
-
- for quote in quotes {
- section = section.child(self.render_quote("e, cx));
- }
-
- section.child(
- h_flex()
- .gap_1()
- .w_full()
- .child(Input::new(&self.token_input).cleanable(true))
- .child(
- Button::new("receive")
- .label("Redeem")
- .small()
- .on_click(cx.listener(move |this, _ev, window, cx| {
- this.receive_token(window, cx);
- })),
- ),
- )
- }
-
- fn render_quote(&self, quote: &MintQuoteEntry, cx: &Context) -> impl IntoElement {
- h_flex()
- .gap_1()
- .w_full()
- .child(
- div()
- .flex_1()
- .min_w_0()
- .truncate()
- .text_color(cx.theme().muted_foreground)
- .child(SharedString::from(quote.request.clone())),
- )
- .child(
- Clipboard::new(SharedString::from(format!("copy_{}", quote.quote_id)))
- .accessibility_label("Copy lightning invoice")
- .value_fn({
- let request = SharedString::from(quote.request.clone());
- move |_, _| request.clone()
- }),
- )
- .child(
- Button::new(SharedString::from(format!("claim_{}", quote.quote_id)))
- .label("Claim")
- .small()
- .on_click({
- let quote_id = quote.quote_id.clone();
- cx.listener(move |this, _ev, _window, cx| {
- this.claim_quote(quote_id.clone(), cx);
- })
- }),
- )
- }
-
- fn render_history(&self, cx: &Context) -> impl IntoElement {
- let history = WalletRegistry::global(cx).read(cx).history().to_vec();
-
- let mut section = v_flex().gap_1().w_full().child(
- div()
- .text_xs()
- .font_semibold()
- .text_color(cx.theme().muted_foreground)
- .child("History:"),
- );
-
+ fn render_history(
+ &mut self,
+ history: Vec,
+ cx: &mut Context,
+ ) -> impl IntoElement {
if history.is_empty() {
- section = section.child(
- div()
- .text_xs()
- .text_color(cx.theme().muted_foreground)
- .child("No transactions yet."),
- );
+ return div()
+ .text_xs()
+ .text_color(cx.theme().muted_foreground)
+ .child("No transactions yet.")
+ .into_any_element();
}
- for entry in history {
- section = section.child(self.render_history_entry(&entry, cx));
- }
+ uniform_list(
+ "wallet-history",
+ history.len(),
+ cx.processor(move |this, range, _window, cx| {
+ this.render_history_rows(range, &history, cx)
+ }),
+ )
+ .flex_1()
+ .min_h_0()
+ .into_any_element()
+ }
- section
+ fn render_history_rows(
+ &self,
+ range: std::ops::Range,
+ history: &[HistoryEntry],
+ cx: &Context,
+ ) -> Vec {
+ range
+ .map(|ix| {
+ self.render_history_entry(&history[ix], cx)
+ .into_any_element()
+ })
+ .collect()
}
fn render_history_entry(&self, entry: &HistoryEntry, cx: &Context) -> impl IntoElement {
@@ -370,6 +199,202 @@ impl WalletPanel {
}
}
+/// Ask for an amount and send ecash.
+fn open_send_dialog(window: &mut Window, cx: &mut App) {
+ let registry = WalletRegistry::global(cx);
+ let receiver_input = cx.new(|cx| InputState::new(window, cx).placeholder("npub… (optional)"));
+ let amount_input = cx.new(|cx| InputState::new(window, cx).placeholder("Amount (sats)"));
+
+ let send: SubmitFn = {
+ let receiver_input = receiver_input.clone();
+ let amount_input = amount_input.clone();
+
+ Rc::new(move |_, window, cx| {
+ let Some(amount) = parse_amount(&amount_input, cx) else {
+ window.push_notification(Notification::error("Enter an amount in sats"), cx);
+ return false;
+ };
+
+ let receiver = receiver_input.read(cx).value().trim().to_string();
+
+ let recipient = match receiver.as_str() {
+ "" => None,
+ value => match PublicKey::parse(value) {
+ Ok(recipient) => Some(recipient),
+ Err(_) => {
+ window.push_notification(
+ Notification::error("Receiver must be an npub or a public key"),
+ cx,
+ );
+ return false;
+ }
+ },
+ };
+
+ match recipient {
+ Some(recipient) => registry.update(cx, |registry, cx| {
+ registry.send_nutzap(recipient, amount, None, None, cx);
+ }),
+ None => registry.update(cx, |registry, cx| {
+ registry.send_token(amount, cx);
+ }),
+ }
+
+ true
+ })
+ };
+
+ window.open_dialog(cx, move |this, _window, _cx| {
+ this.width(px(380.))
+ .title("Send eCash")
+ .on_ok({
+ let send = send.clone();
+ move |event, window, cx| send(event, window, cx)
+ })
+ .child(
+ v_flex()
+ .gap_3()
+ .child(
+ v_flex()
+ .gap_1p5()
+ .child(
+ div()
+ .text_xs()
+ .font_semibold()
+ .child("To (npub, optional):"),
+ )
+ .child(Input::new(&receiver_input).cleanable(true)),
+ )
+ .child(Input::new(&amount_input).cleanable(true))
+ .child(
+ h_flex()
+ .gap_1()
+ .justify_end()
+ .child(
+ Button::new("cancel")
+ .label("Cancel")
+ .ghost()
+ .on_click(|_, window, cx| window.close_dialog(cx)),
+ )
+ .child(Button::new("send").label("Send").primary().on_click({
+ let send = send.clone();
+ move |event, window, cx| {
+ if send(event, window, cx) {
+ window.close_dialog(cx);
+ }
+ }
+ })),
+ ),
+ )
+ });
+}
+
+/// Create a lightning invoice or redeem a pasted cashu token
+fn open_receive_dialog(window: &mut Window, cx: &mut App) {
+ let amount_input = cx.new(|cx| InputState::new(window, cx).placeholder("Amount (sats)"));
+ let token_input = cx.new(|cx| InputState::new(window, cx).placeholder("cashu…"));
+
+ let create_invoice: SubmitFn = {
+ let input = amount_input.clone();
+ Rc::new(move |_, window, cx| {
+ let Some(amount) = parse_amount(&input, cx) else {
+ window.push_notification(Notification::error("Enter an amount in sats"), cx);
+ return false;
+ };
+
+ WalletRegistry::global(cx).update(cx, |registry, cx| {
+ registry.create_mint_quote(amount, cx);
+ });
+
+ true
+ })
+ };
+
+ let redeem: SubmitFn = {
+ let input = token_input.clone();
+ Rc::new(move |_, window, cx| {
+ let token = input.read(cx).value().trim().to_string();
+ if token.is_empty() {
+ window.push_notification(Notification::error("Paste a cashu token"), cx);
+ return false;
+ }
+
+ WalletRegistry::global(cx).update(cx, |registry, cx| {
+ registry.receive_token(token, cx);
+ });
+
+ true
+ })
+ };
+
+ window.open_dialog(cx, move |this, _window, _cx| {
+ this.width(px(420.))
+ .title("Receive")
+ .on_ok({
+ let token_input = token_input.clone();
+ let create_invoice = create_invoice.clone();
+ let redeem = redeem.clone();
+ move |_event, window, cx| {
+ if !token_input.read(cx).value().trim().is_empty() {
+ redeem(_event, window, cx)
+ } else {
+ create_invoice(_event, window, cx)
+ }
+ }
+ })
+ .child(
+ v_flex()
+ .gap_3()
+ .child(
+ v_flex()
+ .gap_1p5()
+ .child(div().text_xs().font_semibold().child("Lightning invoice:"))
+ .child(
+ h_flex()
+ .gap_1()
+ .w_full()
+ .child(Input::new(&amount_input).cleanable(true))
+ .child(Button::new("invoice").label("Invoice").on_click({
+ let create_invoice = create_invoice.clone();
+ move |event, window, cx| {
+ if create_invoice(event, window, cx) {
+ window.close_dialog(cx);
+ }
+ }
+ })),
+ ),
+ )
+ .child(Separator::horizontal())
+ .child(
+ v_flex()
+ .gap_1p5()
+ .child(div().text_xs().font_semibold().child("Cashu token:"))
+ .child(
+ h_flex()
+ .gap_1()
+ .w_full()
+ .child(Input::new(&token_input).cleanable(true))
+ .child(Button::new("redeem").label("Redeem").on_click({
+ let redeem = redeem.clone();
+ move |event, window, cx| {
+ if redeem(event, window, cx) {
+ window.close_dialog(cx);
+ }
+ }
+ })),
+ ),
+ ),
+ )
+ });
+}
+
+fn parse_amount(input: &Entity, cx: &App) -> Option {
+ match input.read(cx).value().trim().parse::() {
+ Ok(amount) if amount > 0 => Some(amount),
+ _ => None,
+ }
+}
+
impl gpui_kit::component::dock::BasePanel for WalletPanel {
fn panel_name(&self) -> &'static str {
"Wallet"
@@ -400,23 +425,26 @@ impl Focusable for WalletPanel {
impl Render for WalletPanel {
fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement {
- let has_wallet = WalletRegistry::global(cx).read(cx).has_wallet();
+ let history = WalletRegistry::global(cx).read(cx).history().to_vec();
v_flex()
+ .size_full()
.p_3()
.gap_3()
- .w_full()
- .overflow_y_scrollbar()
.text_xs()
- .when(!has_wallet, |this| this.child(self.render_setup(cx)))
- .when(has_wallet, |this| {
- this.child(self.render_balance(cx))
- .child(Separator::horizontal())
- .child(self.render_send(cx))
- .child(self.render_receive(cx))
- .child(Separator::horizontal())
- .child(self.render_history(cx))
- .child(self.render_setup(cx))
+ .child(self.render_balance(cx))
+ .child(self.render_actions())
+ .when_some(self.sent_token.as_ref(), |this, _token| {
+ this.child(self.render_sent_token(cx))
})
+ .child(Separator::horizontal())
+ .child(
+ div()
+ .text_xs()
+ .font_semibold()
+ .text_color(cx.theme().muted_foreground)
+ .child("History:"),
+ )
+ .child(self.render_history(history, cx))
}
}
diff --git a/crates/workspace/src/dialogs/settings.rs b/crates/workspace/src/dialogs/settings.rs
index 4800a987..eb3adb5f 100644
--- a/crates/workspace/src/dialogs/settings.rs
+++ b/crates/workspace/src/dialogs/settings.rs
@@ -41,6 +41,8 @@ pub struct Preferences {
radius_input: Entity,
radius_lg_input: Entity,
#[cfg(not(target_arch = "wasm32"))]
+ mint_input: Entity,
+ #[cfg(not(target_arch = "wasm32"))]
_speech_poll: Task<()>,
/// Keeps the control subscriptions alive while the dialog is open
_subscriptions: Vec,
@@ -90,6 +92,9 @@ impl Preferences {
.max(RADIUS_RANGE.1 as f64)
});
+ #[cfg(not(target_arch = "wasm32"))]
+ let mint_input = cx.new(|cx| InputState::new(window, cx).placeholder("https://mint…"));
+
let subscriptions = vec![
Self::wire_number_input(&font_size_input, FONT_SIZE_RANGE, cx, |value, cx| {
if let Some(font_size) = value {
@@ -140,6 +145,8 @@ impl Preferences {
radius_input,
radius_lg_input,
#[cfg(not(target_arch = "wasm32"))]
+ mint_input,
+ #[cfg(not(target_arch = "wasm32"))]
_speech_poll,
_subscriptions: subscriptions,
}
@@ -185,6 +192,56 @@ impl Preferences {
}
}
+ /// Create the NIP-60 wallet or add a mint to it
+ #[cfg(not(target_arch = "wasm32"))]
+ fn setup_mint(&mut self, window: &mut Window, cx: &mut Context) {
+ let mint = self.mint_input.read(cx).value().trim().to_string();
+ if mint.is_empty() {
+ window.push_notification(Notification::error("Enter a mint URL"), cx);
+ return;
+ }
+
+ nip60::WalletRegistry::global(cx).update(cx, |registry, cx| {
+ registry.setup(mint, cx);
+ });
+ }
+
+ /// The NIP-60 wallet block; absent from the web build
+ fn render_wallet(
+ &mut self,
+ #[cfg_attr(target_arch = "wasm32", allow(unused_variables))] cx: &mut Context,
+ ) -> AnyElement {
+ #[cfg(target_arch = "wasm32")]
+ {
+ return div().into_any_element();
+ }
+
+ #[cfg(not(target_arch = "wasm32"))]
+ {
+ const WALLET: &str = "Cashu mint for your NIP-60 wallet. Set up creates the wallet, Add extends it with another mint.";
+ let has_wallet = nip60::WalletRegistry::global(cx).read(cx).has_wallet();
+
+ settings_block(
+ "Wallet",
+ WALLET,
+ h_flex()
+ .gap_1()
+ .child(div().flex_1().child(Input::new(&self.mint_input)))
+ .child(
+ Button::new("setup-mint")
+ .label(if has_wallet { "Add" } else { "Set up" })
+ .ghost()
+ .size_8()
+ .on_click(cx.listener(move |this, _ev, window, cx| {
+ this.setup_mint(window, cx);
+ })),
+ ),
+ cx,
+ )
+ .into_any_element()
+ }
+ }
+
/// Set the appearance mode (system, light or dark)
fn set_appearance_mode(mode: AppearanceMode, cx: &mut App) {
AppSettings::update_appearance_mode(mode, cx);
@@ -546,5 +603,7 @@ impl Render for Preferences {
),
cx,
))
+ .child(Separator::horizontal())
+ .child(self.render_wallet(cx))
}
}
diff --git a/crates/workspace/src/lib.rs b/crates/workspace/src/lib.rs
index eed3b675..cc9a72b1 100644
--- a/crates/workspace/src/lib.rs
+++ b/crates/workspace/src/lib.rs
@@ -61,7 +61,6 @@ enum Command {
ShowContactList,
ShowInbox,
ShowBrowse,
- #[cfg(not(target_arch = "wasm32"))]
ShowWallet,
NewChat,
NewGroup,
@@ -333,10 +332,12 @@ impl Workspace {
let panel = browse::init(self.dock.downgrade(), window, cx);
self.add_panel_to_dock(panel, DockPlacement::Center, window, cx);
}
- #[cfg(not(target_arch = "wasm32"))]
Command::ShowWallet => {
- let panel = nip60_ui::wallet::init(window, cx);
- self.add_panel_to_dock(panel, DockPlacement::Center, window, cx);
+ #[cfg(not(target_arch = "wasm32"))]
+ {
+ let panel = nip60_ui::wallet::init(window, cx);
+ self.add_panel_to_dock(panel, DockPlacement::Center, window, cx);
+ }
}
Command::NewChat => {
new_chat::open(self.dock.downgrade(), window, cx);
diff --git a/crates/workspace/src/sidebar/list.rs b/crates/workspace/src/sidebar/list.rs
index 8b3b5d0b..ec846d49 100644
--- a/crates/workspace/src/sidebar/list.rs
+++ b/crates/workspace/src/sidebar/list.rs
@@ -39,23 +39,9 @@ impl Sidebar {
let requests_open = self.requests_open;
let contacts_open = self.contacts_open && !requests_open;
let rows = Rc::new(self.rows_for(cx));
-
// Whether to show the create button for the contact picker
let create_button = contacts_open && !self.selected_contacts.is_empty();
- #[cfg(not(target_arch = "wasm32"))]
- let wallet_row = Some(
- Nav::new("wallet")
- .prefix(Icon::new(gpui_kit::assets::IconName::Wallet).small())
- .label("Wallet")
- .text_sm()
- .on_click(|_, window, cx| {
- window.dispatch_action(Box::new(crate::Command::ShowWallet), cx)
- }),
- );
- #[cfg(target_arch = "wasm32")]
- let wallet_row: Option