update ui

This commit is contained in:
2026-11-07 10:52:08 +07:00
parent 53aa54a034
commit 0ec8499777
5 changed files with 350 additions and 261 deletions
+270 -242
View File
@@ -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<WalletPanel> {
cx.new(|cx| WalletPanel::new(window, cx))
}
type SubmitFn = Rc<dyn Fn(&ClickEvent, &mut Window, &mut App) -> bool>;
pub struct WalletPanel {
name: SharedString,
focus_handle: FocusHandle,
/// Mint URL input for the setup flow
mint_input: Entity<InputState>,
/// Amount input for sending a cashu token
send_input: Entity<InputState>,
/// Amount input for the funding invoice
invoice_input: Entity<InputState>,
/// Pasted token input for receiving ecash
token_input: Entity<InputState>,
/// The token produced by the last send, shown for copying
sent_token: Option<SharedString>,
_subscriptions: SmallVec<[Subscription; 1]>,
}
impl WalletPanel {
fn new(window: &mut Window, cx: &mut Context<Self>) -> 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<InputState>, cx: &Context<Self>) -> Option<u64> {
match input.read(cx).value().trim().parse::<u64>() {
Ok(amount) if amount > 0 => Some(amount),
_ => None,
}
}
fn setup(&mut self, window: &mut Window, cx: &mut Context<Self>) {
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<Self>) {
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<Self>) {
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<Self>) {
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<Self>) {
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<Self>) -> 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<Self>) -> 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<Self>) -> impl IntoElement {
fn render_sent_token(&self, cx: &Context<Self>) -> 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<Self>) -> 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(&quote, 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<Self>) -> 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<Self>) -> 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<HistoryEntry>,
cx: &mut Context<Self>,
) -> 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<usize>,
history: &[HistoryEntry],
cx: &Context<Self>,
) -> Vec<AnyElement> {
range
.map(|ix| {
self.render_history_entry(&history[ix], cx)
.into_any_element()
})
.collect()
}
fn render_history_entry(&self, entry: &HistoryEntry, cx: &Context<Self>) -> 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<InputState>, cx: &App) -> Option<u64> {
match input.read(cx).value().trim().parse::<u64>() {
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<Self>) -> 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))
}
}
+59
View File
@@ -41,6 +41,8 @@ pub struct Preferences {
radius_input: Entity<InputState>,
radius_lg_input: Entity<InputState>,
#[cfg(not(target_arch = "wasm32"))]
mint_input: Entity<InputState>,
#[cfg(not(target_arch = "wasm32"))]
_speech_poll: Task<()>,
/// Keeps the control subscriptions alive while the dialog is open
_subscriptions: Vec<Subscription>,
@@ -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<Self>) {
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<Self>,
) -> 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))
}
}
+5 -4
View File
@@ -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);
+13 -15
View File
@@ -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<Nav> = None;
let (list_id, title) = if contacts_open {
("sidebar-contacts", Some("Contacts"))
} else if requests_open {
@@ -73,12 +59,25 @@ impl Sidebar {
.flex_1()
.min_h_0()
.relative()
.border_l_1()
.border_color(cx.theme().sidebar_border)
.child(
v_flex()
.px_2()
.gap_1()
.items_start()
.justify_start()
.when(cfg!(not(target_arch = "wasm32")), |this| {
this.child(
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)
}),
)
})
.child(
Nav::new("browse")
.prefix(Icon::new(IconName::Compass).small())
@@ -88,7 +87,6 @@ impl Sidebar {
window.dispatch_action(Box::new(Command::ShowBrowse), cx)
}),
)
.when_some(wallet_row, |this, row| this.child(row))
.child(
Nav::new("reqs")
.prefix(Icon::new(IconName::Invite).small())