update send dialog

This commit is contained in:
2026-11-09 08:11:50 +07:00
parent 73aec4a63d
commit e6ff231eaf
3 changed files with 442 additions and 133 deletions
Generated
+4
View File
@@ -6148,7 +6148,11 @@ dependencies = [
"gpui-pre", "gpui-pre",
"nip60", "nip60",
"nostr-sdk", "nostr-sdk",
"person",
"settings",
"smallvec", "smallvec",
"state",
"ui",
] ]
[[package]] [[package]]
+4
View File
@@ -7,6 +7,10 @@ publish.workspace = true
[dependencies] [dependencies]
common = { path = "../common" } common = { path = "../common" }
nip60 = { path = "../nip60" } nip60 = { path = "../nip60" }
person = { path = "../person" }
settings = { path = "../settings" }
state = { path = "../state" }
ui = { path = "../ui" }
gpui.workspace = true gpui.workspace = true
gpui-kit.workspace = true gpui-kit.workspace = true
+434 -133
View File
@@ -1,3 +1,4 @@
use std::ops::Range;
use std::rc::Rc; use std::rc::Rc;
use ::nip60::{HistoryEntry, WalletEvent, WalletRegistry}; use ::nip60::{HistoryEntry, WalletEvent, WalletRegistry};
@@ -5,20 +6,26 @@ use common::TimestampExt;
use gpui::prelude::FluentBuilder; use gpui::prelude::FluentBuilder;
use gpui::{ use gpui::{
AnyElement, App, AppContext, ClickEvent, Context, Entity, EventEmitter, FocusHandle, Focusable, AnyElement, App, AppContext, ClickEvent, Context, Entity, EventEmitter, FocusHandle, Focusable,
IntoElement, ParentElement, Render, SharedString, Styled, Subscription, Window, div, px, InteractiveElement as _, IntoElement, ParentElement, Render, SharedString,
relative, uniform_list, StatefulInteractiveElement as _, Styled, Subscription, Task, Window, div, px, relative,
uniform_list,
}; };
use gpui_kit::assets::IconName; use gpui_kit::assets::IconName;
use gpui_kit::base::StyledExt as _; use gpui_kit::base::StyledExt as _;
use gpui_kit::component::button::{Button, ButtonVariants as _}; use gpui_kit::component::button::{Button, ButtonVariants as _};
use gpui_kit::component::clipboard::Clipboard;
use gpui_kit::component::dock::{Panel, PanelEvent}; use gpui_kit::component::dock::{Panel, PanelEvent};
use gpui_kit::component::input::{Input, InputState}; use gpui_kit::component::input::{Input, InputEvent, InputState, NumberInput};
use gpui_kit::component::notification::Notification; use gpui_kit::component::notification::Notification;
use gpui_kit::component::separator::Separator; use gpui_kit::component::separator::Separator;
use gpui_kit::component::{ActiveTheme, Icon, Sizable, WindowExt, h_flex, v_flex}; use gpui_kit::component::{
ActiveTheme, Disableable as _, Icon, Sizable, WindowExt, h_flex, v_flex,
};
use nostr_sdk::prelude::*; use nostr_sdk::prelude::*;
use person::Directory;
use settings::AppSettings;
use smallvec::SmallVec; use smallvec::SmallVec;
use state::NostrRegistry;
use ui::Avatar;
pub fn init(window: &mut Window, cx: &mut App) -> Entity<WalletPanel> { pub fn init(window: &mut Window, cx: &mut App) -> Entity<WalletPanel> {
cx.new(|cx| WalletPanel::new(window, cx)) cx.new(|cx| WalletPanel::new(window, cx))
@@ -29,8 +36,6 @@ type SubmitFn = Rc<dyn Fn(&ClickEvent, &mut Window, &mut App) -> bool>;
pub struct WalletPanel { pub struct WalletPanel {
name: SharedString, name: SharedString,
focus_handle: FocusHandle, focus_handle: FocusHandle,
/// The token produced by the last send, shown for copying
sent_token: Option<SharedString>,
_subscriptions: SmallVec<[Subscription; 1]>, _subscriptions: SmallVec<[Subscription; 1]>,
} }
@@ -38,27 +43,21 @@ impl WalletPanel {
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self { fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
let wallet = WalletRegistry::global(cx); let wallet = WalletRegistry::global(cx);
let mut subscriptions = SmallVec::new(); let mut subscriptions = SmallVec::new();
subscriptions.push( subscriptions.push(cx.subscribe_in(
cx.subscribe_in( &wallet,
&wallet, window,
window, |_this, _wallet, event, window, cx| match event {
|this, _wallet, event, window, cx| match event { WalletEvent::Updated => cx.notify(),
WalletEvent::Updated => cx.notify(), WalletEvent::Error(error) => {
WalletEvent::Error(error) => { window.push_notification(Notification::error(error.clone()), cx);
window.push_notification(Notification::error(error.clone()), cx); }
} WalletEvent::TokenCreated(_) => {}
WalletEvent::TokenCreated(token) => { },
this.sent_token = Some(token.clone().into()); ));
cx.notify();
}
},
),
);
Self { Self {
name: "Wallet".into(), name: "Wallet".into(),
focus_handle: cx.focus_handle(), focus_handle: cx.focus_handle(),
sent_token: None,
_subscriptions: subscriptions, _subscriptions: subscriptions,
} }
} }
@@ -109,42 +108,6 @@ impl WalletPanel {
) )
} }
fn render_sent_token(&self, cx: &Context<Self>) -> impl IntoElement {
v_flex()
.gap_1p5()
.w_full()
.child(
div()
.text_xs()
.font_semibold()
.text_color(cx.theme().muted_foreground)
.child("Sent token:"),
)
.when_some(self.sent_token.as_ref(), |this, token| {
this.child(
h_flex()
.gap_1()
.w_full()
.child(
div()
.flex_1()
.min_w_0()
.truncate()
.text_color(cx.theme().muted_foreground)
.child(token.clone()),
)
.child(
Clipboard::new("copy-token")
.accessibility_label("Copy cashu token")
.value_fn({
let token = token.clone();
move |_, _| SharedString::from(token.to_string())
}),
),
)
})
}
fn render_history( fn render_history(
&mut self, &mut self,
history: Vec<HistoryEntry>, history: Vec<HistoryEntry>,
@@ -217,94 +180,435 @@ impl WalletPanel {
} }
} }
/// Ask for an amount and send ecash. /// Pick a contact and an amount, then send a NIP-61 nutzap
fn open_send_dialog(window: &mut Window, cx: &mut App) { fn open_send_dialog(window: &mut Window, cx: &mut App) {
let registry = WalletRegistry::global(cx); let view = cx.new(|cx| SendDialog::new(window, 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 = { window.open_dialog(cx, {
let receiver_input = receiver_input.clone(); let view = view.clone();
let amount_input = amount_input.clone(); move |this, _window, _cx| this.width(px(420.)).title("Send eCash").child(view.clone())
});
}
Rc::new(move |_, window, cx| { struct SendDialog {
let Some(amount) = parse_amount(&amount_input, cx) else { /// Search input, filters contacts or accepts an npub / NIP-05 address
window.push_notification(Notification::error("Enter an amount in sats"), cx); input: Entity<InputState>,
return false; amount_input: Entity<InputState>,
}; /// Current user's contacts
contacts: Option<Vec<PublicKey>>,
/// Person directory for contact names and avatars
directory: Entity<Directory>,
/// The contact chosen to receive the nutzap
selected: Option<PublicKey>,
/// Whether a NIP-05 address is being resolved
resolving: bool,
error: Option<SharedString>,
tasks: Vec<Task<Result<(), anyhow::Error>>>,
_subscriptions: Vec<Subscription>,
}
let receiver = receiver_input.read(cx).value().trim().to_string(); impl SendDialog {
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
let input = cx.new(|cx| {
InputState::new(window, cx).placeholder("Search or enter npub or user@address.me")
});
let amount_input = cx.new(|cx| {
InputState::new(window, cx)
.placeholder("Amount (sats)")
.step(1.)
.min(1.)
});
let recipient = match receiver.as_str() { let subscriptions = vec![
"" => None, cx.subscribe_in(&input, window, |this, _input, event, window, cx| {
value => match PublicKey::parse(value) { if let InputEvent::PressEnter { .. } = event {
Ok(recipient) => Some(recipient), this.submit(window, cx);
Err(_) => { }
window.push_notification( }),
Notification::error("Receiver must be an npub or a public key"), cx.subscribe_in(&amount_input, window, |this, _input, event, window, cx| {
cx, if let InputEvent::PressEnter { .. } = event {
); this.send(window, cx);
return false; }
}),
];
// Load the contacts once the view is mounted
cx.defer_in(window, |this, window, cx| {
this.load_contacts(window, cx);
});
Self {
input,
amount_input,
contacts: None,
directory: Directory::new(cx),
selected: None,
resolving: false,
error: None,
tasks: Vec::new(),
_subscriptions: subscriptions,
}
}
/// Load the signed-in user's contact list.
fn load_contacts(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if self.contacts.is_some() {
return;
}
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let Some(current_user) = nostr.read(cx).current_user() else {
return;
};
let task: Task<Result<Vec<PublicKey>, anyhow::Error>> = cx.background_spawn(async move {
let filter = Filter::new()
.author(current_user)
.kind(Kind::ContactList)
.limit(1);
let contacts: Vec<PublicKey> = client
.database()
.query(filter)
.await?
.into_iter()
.next()
.map(|event| event.tags.public_keys().collect())
.unwrap_or_default();
Ok(contacts)
});
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
let contacts = task.await;
this.update_in(cx, |this, _window, cx| {
match contacts {
Ok(contacts) => {
this.directory.update(cx, |directory, cx| {
directory.track(contacts.iter().copied(), cx)
});
this.contacts = Some(contacts);
} }
}, Err(error) => this.set_error(error.to_string(), cx),
}; }
cx.notify();
})?;
match recipient { Ok(())
Some(recipient) => registry.update(cx, |registry, cx| { }));
registry.send_nutzap(recipient, amount, None, None, cx); }
}),
None => registry.update(cx, |registry, cx| { /// The contacts matching the current input, or every contact when empty.
registry.send_token(amount, cx); fn filtered_contacts(&self, cx: &Context<Self>) -> Vec<PublicKey> {
}), let Some(contacts) = self.contacts.as_ref() else {
return Vec::new();
};
let query = self.input.read(cx).value().trim().to_lowercase();
if query.is_empty() {
return contacts.clone();
}
contacts
.iter()
.filter(|public_key| {
if public_key.to_hex().to_lowercase().contains(&query) {
return true;
}
let name = self.directory.read(cx).person(public_key, cx).name();
name.to_lowercase().contains(&query)
})
.cloned()
.collect()
}
/// Resolve the input into a contact selection.
fn submit(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if self.resolving {
return;
}
let query = self.input.read(cx).value().trim().to_string();
if query.is_empty() {
return;
}
// A raw public key or an npub can be selected directly.
if let Ok(public_key) = PublicKey::parse(&query) {
self.select(public_key, cx);
self.clear_input(window, cx);
return;
}
// A NIP-05 address is resolved before being selected.
if let Ok(address) = Nip05Address::parse(&query) {
let nostr = NostrRegistry::global(cx);
let task = nostr.read(cx).query_address(address, cx);
self.set_resolving(true, window, cx);
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
let result = task.await;
this.update_in(cx, |this, window, cx| {
this.set_resolving(false, window, cx);
match result {
Ok(public_key) => {
this.select(public_key, cx);
this.clear_input(window, cx);
}
Err(error) => this.set_error(error.to_string(), cx),
}
})?;
Ok(())
}));
return;
}
// Fall back to a local search by contact name.
let matches = self.filtered_contacts(cx);
match matches.len() {
0 => self.set_error("Enter a valid npub or NIP-05 address", cx),
1 => {
let public_key = matches[0];
self.select(public_key, cx);
self.clear_input(window, cx);
} }
_ => self.set_error("Multiple contacts match, pick one from the list", cx),
}
}
true /// Select a single recipient for the nutzap.
}) fn select(&mut self, public_key: PublicKey, cx: &mut Context<Self>) {
}; let nostr = NostrRegistry::global(cx);
window.open_dialog(cx, move |this, _window, _cx| { if nostr.read(cx).current_user() == Some(public_key) {
this.width(px(380.)) self.set_error("You cannot send to yourself", cx);
.title("Send eCash") return;
.on_ok({ }
let send = send.clone();
move |event, window, cx| send(event, window, cx) self.directory
.update(cx, |directory, cx| directory.track([public_key], cx));
self.selected = Some(public_key);
self.error = None;
cx.notify();
}
/// Toggle a contact's selection.
fn toggle_selection(&mut self, public_key: &PublicKey, cx: &mut Context<Self>) {
if self.selected == Some(*public_key) {
self.selected = None;
cx.notify();
} else {
self.select(*public_key, cx);
}
}
/// Validate the selection and amount, then schedule the nutzap.
fn send(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> bool {
let Some(recipient) = self.selected else {
self.set_error("Select a contact to send to", cx);
return false;
};
let Some(amount) = parse_amount(&self.amount_input, cx) else {
self.set_error("Enter an amount in sats", cx);
return false;
};
WalletRegistry::global(cx).update(cx, |registry, cx| {
registry.send_nutzap(recipient, amount, None, None, cx);
});
true
}
fn clear_input(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.input.update(cx, |this, cx| {
this.set_value("", window, cx);
});
}
/// Toggle the input's loading spinner while a NIP-05 address is resolved.
fn set_resolving(&mut self, resolving: bool, window: &mut Window, cx: &mut Context<Self>) {
self.input.update(cx, |this, cx| {
this.set_loading(resolving, window, cx);
});
self.resolving = resolving;
cx.notify();
}
fn set_error(&mut self, message: impl Into<SharedString>, cx: &mut Context<Self>) {
self.error = Some(message.into());
cx.notify();
}
fn render_contacts(
&self,
range: Range<usize>,
contacts: &[PublicKey],
cx: &mut Context<Self>,
) -> Vec<AnyElement> {
contacts
.get(range)
.into_iter()
.flatten()
.map(|public_key| {
let profile = self.directory.read(cx).person(public_key, cx);
let public_key = *public_key;
let selected = self.selected == Some(public_key);
let id = SharedString::from(format!("contact-{}", public_key.to_hex()));
h_flex()
.id(id)
.w_full()
.h_8()
.px_2()
.gap_2()
.rounded(cx.theme().radius)
.text_sm()
.cursor_pointer()
.hover(|this| this.bg(cx.theme().list_hover))
.when(selected, |this| this.bg(cx.theme().list_active))
.when(!AppSettings::get_hide_avatar(cx), |this| {
this.child(
Avatar::new(profile.avatar())
.seed(profile.avatar_seed())
.small(),
)
})
.child(div().min_w_0().truncate().child(profile.name()))
.on_click(cx.listener(move |this, _event, _window, cx| {
this.toggle_selection(&public_key, cx);
}))
.into_any_element()
})
.collect()
}
fn render_selected(&self, cx: &mut Context<Self>) -> impl IntoElement {
let Some(public_key) = self.selected else {
return div().into_any_element();
};
let profile = self.directory.read(cx).person(&public_key, cx);
let id = SharedString::from(format!("selected-{}", public_key.to_hex()));
h_flex()
.gap_1()
.child(
h_flex()
.id(id)
.gap_1()
.p_0p5()
.rounded_full()
.bg(cx.theme().secondary)
.hover(|this| this.bg(cx.theme().primary))
.child(
Avatar::new(profile.avatar())
.seed(profile.avatar_seed())
.xsmall(),
)
.child(
div()
.max_w_32()
.truncate()
.text_xs()
.line_height(relative(1.0))
.child(profile.name()),
)
.on_click(cx.listener(move |this, _event, _window, cx| {
this.selected = None;
cx.notify();
})),
)
.into_any_element()
}
}
impl Render for SendDialog {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
const NOTICE: &str = "Recipients need a NIP-60 wallet on a mint you both use.";
let contacts = Rc::new(self.filtered_contacts(cx));
let contact_count = contacts.len();
v_flex()
.gap_3()
.child(NumberInput::new(&self.amount_input))
.child(
h_flex()
.gap_1()
.w_full()
.child(Input::new(&self.input).flex_1().cleanable(true))
.child(
Button::new("add")
.icon(IconName::Plus)
.tooltip("Add person")
.ghost()
.large()
.disabled(self.resolving)
.on_click(cx.listener(|this, _event, window, cx| {
this.submit(window, cx);
})),
),
)
.when(self.selected.is_some(), |this| {
this.child(self.render_selected(cx))
}) })
.child( .child(
v_flex() uniform_list(
.gap_3() "send-contacts",
.child( contact_count,
v_flex() cx.processor(move |this, range, _window, cx| {
.gap_1p5() this.render_contacts(range, contacts.as_slice(), cx)
.child( }),
div() )
.text_xs() .h_64(),
.font_semibold() )
.child("To (npub, optional):"), .map(|this| {
) if let Some(error) = self.error.clone() {
.child(Input::new(&receiver_input).cleanable(true)), this.child(div().text_xs().text_color(cx.theme().danger).child(error))
} else {
this.child(
div()
.text_xs()
.text_color(cx.theme().muted_foreground)
.child(NOTICE),
) )
.child(Input::new(&amount_input).cleanable(true)) }
})
.child(
h_flex()
.gap_1()
.justify_end()
.child( .child(
h_flex() Button::new("cancel")
.gap_1() .label("Cancel")
.justify_end() .secondary()
.child( .on_click(|_, window, cx| window.close_dialog(cx)),
Button::new("cancel") )
.label("Cancel") .child(
.secondary() Button::new("send")
.on_click(|_, window, cx| window.close_dialog(cx)), .label("Send")
) .primary()
.child(Button::new("send").label("Send").primary().on_click({ .on_click(cx.listener(move |this, _event, window, cx| {
let send = send.clone(); if this.send(window, cx) {
move |event, window, cx| { window.close_dialog(cx);
if send(event, window, cx) {
window.close_dialog(cx);
}
} }
})), })),
), ),
) )
}); }
} }
/// Create a lightning invoice or redeem a pasted cashu token /// Create a lightning invoice or redeem a pasted cashu token
@@ -467,9 +771,6 @@ impl Render for WalletPanel {
.text_xs() .text_xs()
.child(self.render_balance(cx)) .child(self.render_balance(cx))
.child(self.render_actions()) .child(self.render_actions())
.when_some(self.sent_token.as_ref(), |this, _token| {
this.child(self.render_sent_token(cx))
})
.child( .child(
v_flex() v_flex()
.gap_2() .gap_2()