diff --git a/Cargo.lock b/Cargo.lock index af7a0b33..6449febc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6148,7 +6148,11 @@ dependencies = [ "gpui-pre", "nip60", "nostr-sdk", + "person", + "settings", "smallvec", + "state", + "ui", ] [[package]] diff --git a/crates/nip60_ui/Cargo.toml b/crates/nip60_ui/Cargo.toml index b049fcda..33266d0d 100644 --- a/crates/nip60_ui/Cargo.toml +++ b/crates/nip60_ui/Cargo.toml @@ -7,6 +7,10 @@ publish.workspace = true [dependencies] common = { path = "../common" } nip60 = { path = "../nip60" } +person = { path = "../person" } +settings = { path = "../settings" } +state = { path = "../state" } +ui = { path = "../ui" } gpui.workspace = true gpui-kit.workspace = true diff --git a/crates/nip60_ui/src/wallet.rs b/crates/nip60_ui/src/wallet.rs index b3d17a12..38d1a6b0 100644 --- a/crates/nip60_ui/src/wallet.rs +++ b/crates/nip60_ui/src/wallet.rs @@ -1,3 +1,4 @@ +use std::ops::Range; use std::rc::Rc; use ::nip60::{HistoryEntry, WalletEvent, WalletRegistry}; @@ -5,20 +6,26 @@ use common::TimestampExt; use gpui::prelude::FluentBuilder; use gpui::{ AnyElement, App, AppContext, ClickEvent, Context, Entity, EventEmitter, FocusHandle, Focusable, - IntoElement, ParentElement, Render, SharedString, Styled, Subscription, Window, div, px, - relative, uniform_list, + InteractiveElement as _, IntoElement, ParentElement, Render, SharedString, + StatefulInteractiveElement as _, Styled, Subscription, Task, Window, div, px, relative, + uniform_list, }; use gpui_kit::assets::IconName; use gpui_kit::base::StyledExt 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::input::{Input, InputState}; +use gpui_kit::component::input::{Input, InputEvent, InputState, NumberInput}; use gpui_kit::component::notification::Notification; 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 person::Directory; +use settings::AppSettings; use smallvec::SmallVec; +use state::NostrRegistry; +use ui::Avatar; pub fn init(window: &mut Window, cx: &mut App) -> Entity { cx.new(|cx| WalletPanel::new(window, cx)) @@ -29,8 +36,6 @@ type SubmitFn = Rc bool>; pub struct WalletPanel { name: SharedString, focus_handle: FocusHandle, - /// The token produced by the last send, shown for copying - sent_token: Option, _subscriptions: SmallVec<[Subscription; 1]>, } @@ -38,27 +43,21 @@ impl WalletPanel { fn new(window: &mut Window, cx: &mut Context) -> Self { let wallet = WalletRegistry::global(cx); let mut subscriptions = SmallVec::new(); - subscriptions.push( - cx.subscribe_in( - &wallet, - window, - |this, _wallet, event, window, cx| match event { - WalletEvent::Updated => cx.notify(), - WalletEvent::Error(error) => { - window.push_notification(Notification::error(error.clone()), cx); - } - WalletEvent::TokenCreated(token) => { - this.sent_token = Some(token.clone().into()); - cx.notify(); - } - }, - ), - ); + subscriptions.push(cx.subscribe_in( + &wallet, + window, + |_this, _wallet, event, window, cx| match event { + WalletEvent::Updated => cx.notify(), + WalletEvent::Error(error) => { + window.push_notification(Notification::error(error.clone()), cx); + } + WalletEvent::TokenCreated(_) => {} + }, + )); Self { name: "Wallet".into(), focus_handle: cx.focus_handle(), - sent_token: None, _subscriptions: subscriptions, } } @@ -109,42 +108,6 @@ impl WalletPanel { ) } - fn render_sent_token(&self, cx: &Context) -> 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( &mut self, history: Vec, @@ -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) { - 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 view = cx.new(|cx| SendDialog::new(window, cx)); - let send: SubmitFn = { - let receiver_input = receiver_input.clone(); - let amount_input = amount_input.clone(); + window.open_dialog(cx, { + let view = view.clone(); + move |this, _window, _cx| this.width(px(420.)).title("Send eCash").child(view.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; - }; +struct SendDialog { + /// Search input, filters contacts or accepts an npub / NIP-05 address + input: Entity, + amount_input: Entity, + /// Current user's contacts + contacts: Option>, + /// Person directory for contact names and avatars + directory: Entity, + /// The contact chosen to receive the nutzap + selected: Option, + /// Whether a NIP-05 address is being resolved + resolving: bool, + error: Option, + tasks: Vec>>, + _subscriptions: Vec, +} - let receiver = receiver_input.read(cx).value().trim().to_string(); +impl SendDialog { + fn new(window: &mut Window, cx: &mut Context) -> 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() { - "" => 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; + let subscriptions = vec![ + cx.subscribe_in(&input, window, |this, _input, event, window, cx| { + if let InputEvent::PressEnter { .. } = event { + this.submit(window, cx); + } + }), + cx.subscribe_in(&amount_input, window, |this, _input, event, window, cx| { + if let InputEvent::PressEnter { .. } = event { + this.send(window, cx); + } + }), + ]; + + // 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) { + 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, anyhow::Error>> = cx.background_spawn(async move { + let filter = Filter::new() + .author(current_user) + .kind(Kind::ContactList) + .limit(1); + + let contacts: Vec = 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 { - 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); - }), + Ok(()) + })); + } + + /// The contacts matching the current input, or every contact when empty. + fn filtered_contacts(&self, cx: &Context) -> Vec { + 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) { + 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) { + let nostr = NostrRegistry::global(cx); - 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) + if nostr.read(cx).current_user() == Some(public_key) { + self.set_error("You cannot send to yourself", cx); + return; + } + + 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) { + 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) -> 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.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.input.update(cx, |this, cx| { + this.set_loading(resolving, window, cx); + }); + self.resolving = resolving; + cx.notify(); + } + + fn set_error(&mut self, message: impl Into, cx: &mut Context) { + self.error = Some(message.into()); + cx.notify(); + } + + fn render_contacts( + &self, + range: Range, + contacts: &[PublicKey], + cx: &mut Context, + ) -> Vec { + 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) -> 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) -> 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( - 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)), + uniform_list( + "send-contacts", + contact_count, + cx.processor(move |this, range, _window, cx| { + this.render_contacts(range, contacts.as_slice(), cx) + }), + ) + .h_64(), + ) + .map(|this| { + if let Some(error) = self.error.clone() { + 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( - h_flex() - .gap_1() - .justify_end() - .child( - Button::new("cancel") - .label("Cancel") - .secondary() - .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); - } + Button::new("cancel") + .label("Cancel") + .secondary() + .on_click(|_, window, cx| window.close_dialog(cx)), + ) + .child( + Button::new("send") + .label("Send") + .primary() + .on_click(cx.listener(move |this, _event, window, cx| { + if this.send(window, cx) { + window.close_dialog(cx); } })), ), ) - }); + } } /// Create a lightning invoice or redeem a pasted cashu token @@ -467,9 +771,6 @@ impl Render for WalletPanel { .text_xs() .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( v_flex() .gap_2()