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