diff --git a/Cargo.lock b/Cargo.lock index 266379a..c56a836 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -170,6 +170,21 @@ dependencies = [ "syn 2.0.74", ] +[[package]] +name = "arboard" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb4009533e8ff8f1450a5bcbc30f4242a1d34442221f72314bea1f5dc9c7f89" +dependencies = [ + "clipboard-win", + "log", + "objc2", + "objc2-app-kit", + "objc2-foundation", + "parking_lot 0.12.3", + "x11rb", +] + [[package]] name = "argon2" version = "0.5.3" @@ -2057,6 +2072,12 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "98de4bbd547a563b716d8dfa9aad1cb19bfab00f4fa09a6a4ed21dbcf44ce9c4" +[[package]] +name = "float_next_after" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8bf7cc16383c4b8d58b9905a8509f02926ce3058053c056376248d958c9df1e8" + [[package]] name = "fnv" version = "1.0.7" @@ -2774,6 +2795,7 @@ dependencies = [ "iced_core", "iced_futures", "log", + "lyon_path", "once_cell", "raw-window-handle", "rustc-hash 1.1.0", @@ -2834,6 +2856,7 @@ dependencies = [ "guillotiere", "iced_graphics", "log", + "lyon", "once_cell", "resvg", "rustc-hash 1.1.0", @@ -2849,6 +2872,7 @@ dependencies = [ "iced_renderer", "iced_runtime", "num-traits", + "qrcode", "rustc-hash 1.1.0", "thiserror", "unicode-segmentation", @@ -3150,6 +3174,7 @@ name = "keystache" version = "0.0.0" dependencies = [ "anyhow", + "arboard", "async-stream", "async-trait", "chrono", @@ -3410,6 +3435,58 @@ dependencies = [ "hashbrown 0.14.5", ] +[[package]] +name = "lyon" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e7f9cda98b5430809e63ca5197b06c7d191bf7e26dfc467d5a3f0290e2a74f" +dependencies = [ + "lyon_algorithms", + "lyon_tessellation", +] + +[[package]] +name = "lyon_algorithms" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3bca95f9a4955b3e4a821fbbcd5edfbd9be2a9a50bb5758173e5358bfb4c623" +dependencies = [ + "lyon_path", + "num-traits", +] + +[[package]] +name = "lyon_geom" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edecfb8d234a2b0be031ab02ebcdd9f3b9ee418fb35e265f7a540a48d197bff9" +dependencies = [ + "arrayvec", + "euclid", + "num-traits", +] + +[[package]] +name = "lyon_path" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c08a606c7a59638d6c6aa18ac91a06aa9fb5f765a7efb27e6a4da58700740d7" +dependencies = [ + "lyon_geom", + "num-traits", +] + +[[package]] +name = "lyon_tessellation" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "579d42360a4b09846eff2feef28f538696c7d6c7439bfa65874ff3cbe0951b2c" +dependencies = [ + "float_next_after", + "lyon_path", + "num-traits", +] + [[package]] name = "lz4-sys" version = "1.10.0" @@ -3798,6 +3875,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", + "libm", ] [[package]] @@ -4528,6 +4606,12 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "43d84d1d7a6ac92673717f9f6d1518374ef257669c24ebc5ac25d5033828be58" +[[package]] +name = "qrcode" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "166f136dfdb199f98186f3649cf7a0536534a61417a1a30221b492b4fb60ce3f" + [[package]] name = "quick-xml" version = "0.34.0" diff --git a/Cargo.toml b/Cargo.toml index a614ebe..f44ddec 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,6 +13,7 @@ resources = ["assets/fonts/**/*.*"] [dependencies] anyhow = "1.0.80" +arboard = { version = "3.4.0", default-features = false } async-stream = "0.3.5" async-trait = "0.1.81" chrono = { version = "0.4.34", features = ["alloc"] } @@ -29,6 +30,7 @@ fedimint-mint-client = "0.4.0" fedimint-rocksdb = "0.4.0" iced = { git = "https://github.com/iced-rs/iced", rev = "e50aa03", features = [ "advanced", + "qr_code", "svg", "tokio", ] } diff --git a/assets/icons/arrow_downward.svg b/assets/icons/arrow_downward.svg new file mode 100644 index 0000000..e13fe3d --- /dev/null +++ b/assets/icons/arrow_downward.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/icons/arrow_upward.svg b/assets/icons/arrow_upward.svg new file mode 100644 index 0000000..b97886a --- /dev/null +++ b/assets/icons/arrow_upward.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/icons/content_copy.svg b/assets/icons/content_copy.svg new file mode 100644 index 0000000..77b6308 --- /dev/null +++ b/assets/icons/content_copy.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/icons/send.svg b/assets/icons/send.svg new file mode 100644 index 0000000..8eb8a01 --- /dev/null +++ b/assets/icons/send.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/fedimint.rs b/src/fedimint.rs index 26c2424..0d1a1b0 100644 --- a/src/fedimint.rs +++ b/src/fedimint.rs @@ -1,3 +1,4 @@ +use std::fmt::Display; use std::pin::Pin; use std::{ collections::{BTreeMap, HashMap}, @@ -29,6 +30,8 @@ use nostr_sdk::{ }; use secp256k1::rand::{seq::SliceRandom, thread_rng}; +use crate::util::format_amount; + const FEDIMINT_CLIENTS_DATA_DIR_NAME: &str = "fedimint_clients"; // TODO: Figure out if we even want this. If we do, it probably shouldn't live here. // It'd make more sense for it to live wherever the key is maintained elsewhere, and @@ -48,6 +51,19 @@ pub struct FederationView { pub gateways: Vec, } +impl Display for FederationView { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let name_or_id = self + .name_or + .clone() + .unwrap_or_else(|| self.federation_id.to_string()); + + let balance = format_amount(self.balance); + + write!(f, "{name_or_id} ({balance})") + } +} + pub struct Wallet { derivable_secret: DerivableSecret, clients: Arc>>, @@ -187,6 +203,32 @@ impl Wallet { state } + pub async fn pay_invoice( + &self, + invoice: Bolt11Invoice, + federation_id: FederationId, + ) -> anyhow::Result<()> { + let clients = self.clients.lock().await; + + let client = clients + .get(&federation_id) + .ok_or_else(|| anyhow::anyhow!("Client for federation {} not found", federation_id))?; + + let lightning_module = client.get_first_module::(); + + let gateways = lightning_module.list_gateways().await; + + let payment_info = lightning_module + .pay_bolt11_invoice(Self::select_gateway(&gateways), invoice, ()) + .await?; + + lightning_module + .wait_for_ln_payment(payment_info.payment_type, payment_info.contract_id, false) + .await?; + + Ok(()) + } + pub async fn receive_payment( &self, federation_id: FederationId, diff --git a/src/main.rs b/src/main.rs index 2d280f3..255275a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -107,7 +107,7 @@ impl Keystache { while let Some(views) = wallet_update_stream.next().await { output - .send(KeystacheMessage::FederationViewsUpdate { views }) + .send(KeystacheMessage::UpdateFederationViews { views }) .await .unwrap(); } @@ -159,10 +159,12 @@ enum KeystacheMessage { DbDeleteAllData, - FederationViewsUpdate { + UpdateFederationViews { views: BTreeMap, }, + CopyStringToClipboard(String), + IncomingNip46Request( Arc<( Vec, diff --git a/src/routes/bitcoin_wallet.rs b/src/routes/bitcoin_wallet.rs index e6b9191..e378a76 100644 --- a/src/routes/bitcoin_wallet.rs +++ b/src/routes/bitcoin_wallet.rs @@ -1,13 +1,13 @@ -use std::str::FromStr; +use std::{collections::BTreeMap, str::FromStr}; use fedimint_core::{ - config::{ClientConfig, META_FEDERATION_NAME_KEY}, + config::{ClientConfig, FederationId, META_FEDERATION_NAME_KEY}, invite_code::InviteCode, Amount, }; use iced::{ widget::{ - column, container::Style, horizontal_space, row, text_input, Column, Container, Text, + column, container::Style, horizontal_space, row, text_input, Column, Container, Space, Text, }, Border, Length, Shadow, Task, Theme, }; @@ -21,6 +21,9 @@ use crate::{ use super::{container, Loadable, RouteName}; +mod receive; +mod send; + #[derive(Debug, Clone)] pub enum Message { JoinFederationInviteCodeInputChanged(String), @@ -38,6 +41,11 @@ pub enum Message { JoinFedimintFederation(InviteCode), ConnectedToFederation, + + Send(send::Message), + Receive(receive::Message), + + UpdateFederationViews(BTreeMap), } pub struct Page { @@ -46,6 +54,8 @@ pub struct Page { } impl Page { + // TODO: Remove this clippy allow. + #[allow(clippy::too_many_lines)] pub fn update(&mut self, msg: Message) -> Task { match msg { Message::JoinFederationInviteCodeInputChanged(new_federation_invite_code) => { @@ -153,16 +163,46 @@ impl Page { Message::ConnectedToFederation => { // TODO: Do something here, or remove `ConnectedToFederation` message variant. + Task::none() + } + Message::Send(send_message) => { + if let Subroute::Send(send_page) = &mut self.subroute { + send_page.update(send_message) + } else { + Task::none() + } + } + Message::Receive(receive_message) => { + if let Subroute::Receive(receive_page) = &mut self.subroute { + receive_page.update(receive_message) + } else { + Task::none() + } + } + Message::UpdateFederationViews(federation_views) => { + match &mut self.subroute { + Subroute::Send(send_page) => { + send_page.update(send::Message::UpdateFederationViews(federation_views)); + } + Subroute::Receive(receive_page) => { + receive_page + .update(receive::Message::UpdateFederationViews(federation_views)); + } + _ => {} + } + Task::none() } } } - pub fn view<'a>(&self) -> Column<'a, KeystacheMessage> { + pub fn view(&self) -> Column { match &self.subroute { Subroute::List(list) => list.view(&self.connected_state), Subroute::FederationDetails(federation_details) => federation_details.view(), Subroute::Add(add) => add.view(), + Subroute::Send(send) => send.view(), + Subroute::Receive(receive) => receive.view(), } } } @@ -172,10 +212,12 @@ pub enum SubrouteName { List, FederationDetails(FederationView), Add, + Send, + Receive, } impl SubrouteName { - pub fn to_default_subroute(&self) -> Subroute { + pub fn to_default_subroute(&self, connected_state: &ConnectedState) -> Subroute { match self { Self::List => Subroute::List(List {}), Self::FederationDetails(federation_view) => { @@ -187,6 +229,8 @@ impl SubrouteName { federation_invite_code: String::new(), parsed_federation_invite_code_state_or: None, }), + Self::Send => Subroute::Send(send::Page::new(connected_state)), + Self::Receive => Subroute::Receive(receive::Page::new(connected_state)), } } } @@ -195,6 +239,8 @@ pub enum Subroute { List(List), FederationDetails(FederationDetails), Add(Add), + Send(send::Page), + Receive(receive::Page), } impl Subroute { @@ -205,6 +251,8 @@ impl Subroute { SubrouteName::FederationDetails(federation_details.view.clone()) } Self::Add(_) => SubrouteName::Add, + Self::Send(_) => SubrouteName::Send, + Self::Receive(_) => SubrouteName::Receive, } } } @@ -228,6 +276,18 @@ impl List { .map(|(_federation_id, view)| view.balance.msats) .sum::(), )))) + .push(row![ + icon_button("Send", SvgIcon::ArrowUpward, PaletteColor::Primary).on_press( + KeystacheMessage::Navigate(RouteName::BitcoinWallet( + SubrouteName::Send, + )) + ), + Space::with_width(10.0), + icon_button("Receive", SvgIcon::ArrowDownward, PaletteColor::Primary) + .on_press(KeystacheMessage::Navigate(RouteName::BitcoinWallet( + SubrouteName::Receive, + ))) + ]) .push(Text::new("Federations").size(25)); for view in views.values() { diff --git a/src/routes/bitcoin_wallet/receive.rs b/src/routes/bitcoin_wallet/receive.rs new file mode 100644 index 0000000..3dc8fa7 --- /dev/null +++ b/src/routes/bitcoin_wallet/receive.rs @@ -0,0 +1,282 @@ +use std::{collections::BTreeMap, sync::Arc}; + +use fedimint_core::{config::FederationId, Amount}; +use fedimint_ln_common::bitcoin::Denomination; +use iced::{ + widget::{combo_box, qr_code::Data, text_input, Column, QRCode, Text}, + Task, +}; +use lightning_invoice::Bolt11Invoice; + +use crate::{ + fedimint::{FederationView, LightningReceiveCompletion, Wallet}, + routes::{container, Loadable, RouteName}, + ui_components::{icon_button, PaletteColor, SvgIcon}, + ConnectedState, KeystacheMessage, +}; + +use super::SubrouteName; + +#[derive(Debug, Clone)] +pub enum Message { + // Invoice creation fields. + AmountInputChanged(String), + DenominationComboBoxSelected(Denomination), + FederationComboBoxSelected(FederationView), + + // Invoice creation and payment. + CreateInvoice(Amount, FederationId), + InvoiceCreated(Bolt11Invoice), + FailedToCreateInvoice, + PaymentSuccess(Bolt11Invoice), + PaymentFailure(Bolt11Invoice), + + UpdateFederationViews(BTreeMap), +} + +pub struct Page { + wallet: Arc, + amount_input: String, + denomination_combo_box_state: combo_box::State, + denomination_combo_box_selected_denomination: Option, + federation_combo_box_state: combo_box::State, + federation_combo_box_selected_federation: Option, + loadable_lightning_invoice_data_or: Option)>>, +} + +impl Page { + pub fn new(connected_state: &ConnectedState) -> Self { + Self { + wallet: connected_state.wallet.clone(), + amount_input: String::new(), + denomination_combo_box_state: combo_box::State::new(vec![ + Denomination::MilliSatoshi, + Denomination::Satoshi, + Denomination::Bitcoin, + ]), + denomination_combo_box_selected_denomination: Some(Denomination::Satoshi), + federation_combo_box_state: combo_box::State::new( + connected_state + .loadable_federation_views + .as_ref_option() + .cloned() + .unwrap_or_default() + .into_values() + .collect(), + ), + federation_combo_box_selected_federation: None, + loadable_lightning_invoice_data_or: None, + } + } + + pub fn update(&mut self, msg: Message) -> Task { + match msg { + Message::AmountInputChanged(new_amount_input) => { + self.amount_input = new_amount_input; + + Task::none() + } + Message::DenominationComboBoxSelected(denomination) => { + self.denomination_combo_box_selected_denomination = Some(denomination); + + Task::none() + } + Message::FederationComboBoxSelected(federation) => { + self.federation_combo_box_selected_federation = Some(federation); + + Task::none() + } + Message::CreateInvoice(amount, federation_id) => { + self.loadable_lightning_invoice_data_or = Some(Loadable::Loading); + + let wallet = self.wallet.clone(); + + Task::stream(async_stream::stream! { + match wallet + .receive_payment(federation_id, amount, String::new()) + .await + { + Ok((invoice, payment_completion_receiver)) => { + yield KeystacheMessage::BitcoinWalletPage(super::Message::Receive( + Message::InvoiceCreated( + invoice.clone(), + ))); + + match payment_completion_receiver.await { + Ok(lightning_receive_completion) => { + match lightning_receive_completion { + LightningReceiveCompletion::Success => { + yield KeystacheMessage::BitcoinWalletPage(super::Message::Receive( + Message::PaymentSuccess(invoice))); + } + LightningReceiveCompletion::Failure => { + yield KeystacheMessage::BitcoinWalletPage(super::Message::Receive( + Message::PaymentFailure(invoice))); + } + } + } + Err(_) => { + println!("Payment receive completion receiver was cancelled. This is a bug!"); + } + }; + } + Err(_) => { + yield KeystacheMessage::BitcoinWalletPage(super::Message::Receive( + Message::FailedToCreateInvoice)); + } + } + }) + } + Message::InvoiceCreated(invoice) => { + let new_qr_code_data = Data::new(invoice.to_string()).unwrap(); + + self.loadable_lightning_invoice_data_or = Some(Loadable::Loaded(( + invoice, + new_qr_code_data, + Loadable::Loading, + ))); + + Task::none() + } + Message::FailedToCreateInvoice => { + self.loadable_lightning_invoice_data_or = Some(Loadable::Failed); + + Task::none() + } + Message::PaymentSuccess(succeeded_invoice) => { + if let Some(Loadable::Loaded((invoice, _, loadable_invoice_payment))) = + &mut self.loadable_lightning_invoice_data_or + { + if invoice == &succeeded_invoice { + *loadable_invoice_payment = Loadable::Loaded(()); + } + } + + Task::none() + } + Message::PaymentFailure(failed_invoice) => { + if let Some(Loadable::Loaded((invoice, _, loadable_invoice_payment))) = + &mut self.loadable_lightning_invoice_data_or + { + if invoice == &failed_invoice { + *loadable_invoice_payment = Loadable::Failed; + } + } + + Task::none() + } + Message::UpdateFederationViews(federation_views) => { + self.federation_combo_box_selected_federation = self + .federation_combo_box_selected_federation + .as_ref() + .and_then(|selected_federation| { + federation_views + .get(&selected_federation.federation_id) + .cloned() + }); + + self.federation_combo_box_state = + combo_box::State::new(federation_views.into_values().collect()); + + Task::none() + } + } + } + + pub fn view(&self) -> Column { + let mut container = container("Receive"); + + let amount_or = self + .denomination_combo_box_selected_denomination + .and_then(|denomination| Amount::from_str_in(&self.amount_input, denomination).ok()); + + // If the inputted amount to receive is valid and a federation + // is selected, then we can proceed to pay the invoice. + let parsed_amount_and_selected_federation_id_or = amount_or.and_then(|invoice| { + self.federation_combo_box_selected_federation + .as_ref() + .map(|selected_federation| (invoice, selected_federation.federation_id)) + }); + + container = container + .push( + text_input("Amount to receive", &self.amount_input) + .on_input(|input| { + KeystacheMessage::BitcoinWalletPage(super::Message::Receive( + Message::AmountInputChanged(input), + )) + }) + .padding(10) + .size(30), + ) + .push(combo_box( + &self.denomination_combo_box_state, + "Denomination", + self.denomination_combo_box_selected_denomination.as_ref(), + Self::on_denomination_combo_box_change, + )) + .push(combo_box( + &self.federation_combo_box_state, + "Federation to receive to", + self.federation_combo_box_selected_federation.as_ref(), + Self::on_federation_combo_box_change, + )); + + container = if let Some(loadable_lightning_invoice_data) = + &self.loadable_lightning_invoice_data_or + { + match loadable_lightning_invoice_data { + Loadable::Loading => container.push(Text::new("Loading...")), + Loadable::Loaded((lightning_invoice, qr_code_data, is_paid)) => { + if is_paid == &Loadable::Loaded(()) { + container.push(Text::new("Payment successful!")) + } else { + container.push(QRCode::new(qr_code_data)).push( + icon_button( + "Copy Invoice", + SvgIcon::ContentCopy, + PaletteColor::Primary, + ) + .on_press( + KeystacheMessage::CopyStringToClipboard( + lightning_invoice.to_string(), + ), + ), + ) + } + } + Loadable::Failed => container.push(Text::new("Failed to create invoice")), + } + } else { + container.push( + icon_button("Create Invoice", SvgIcon::Send, PaletteColor::Primary).on_press_maybe( + parsed_amount_and_selected_federation_id_or.map(|(amount, federation_id)| { + KeystacheMessage::BitcoinWalletPage(super::Message::Receive( + Message::CreateInvoice(amount, federation_id), + )) + }), + ), + ) + }; + + container = container.push( + icon_button("Back", SvgIcon::ArrowBack, PaletteColor::Background).on_press( + KeystacheMessage::Navigate(RouteName::BitcoinWallet(SubrouteName::List)), + ), + ); + + container + } + + fn on_denomination_combo_box_change(denomination: Denomination) -> KeystacheMessage { + KeystacheMessage::BitcoinWalletPage(super::Message::Receive( + Message::DenominationComboBoxSelected(denomination), + )) + } + + fn on_federation_combo_box_change(federation_view: FederationView) -> KeystacheMessage { + KeystacheMessage::BitcoinWalletPage(super::Message::Receive( + Message::FederationComboBoxSelected(federation_view), + )) + } +} diff --git a/src/routes/bitcoin_wallet/send.rs b/src/routes/bitcoin_wallet/send.rs new file mode 100644 index 0000000..9967791 --- /dev/null +++ b/src/routes/bitcoin_wallet/send.rs @@ -0,0 +1,191 @@ +use std::{collections::BTreeMap, str::FromStr, sync::Arc}; + +use fedimint_core::config::FederationId; +use iced::{ + widget::{combo_box, text_input, Column, Text}, + Task, +}; +use lightning_invoice::Bolt11Invoice; + +use crate::{ + fedimint::{FederationView, Wallet}, + routes::{container, Loadable, RouteName}, + ui_components::{icon_button, PaletteColor, SvgIcon}, + ConnectedState, KeystacheMessage, +}; + +use super::SubrouteName; + +#[derive(Debug, Clone)] +pub enum Message { + // Payment input fields. + LightningInvoiceInputChanged(String), + FederationComboBoxSelected(FederationView), + + // Payment actions. + PayInvoice(Bolt11Invoice, FederationId), + PayInvoiceSucceeded(Bolt11Invoice), + PayInvoiceFailed(Bolt11Invoice), + + UpdateFederationViews(BTreeMap), +} + +pub struct Page { + wallet: Arc, + lightning_invoice_input: String, + federation_combo_box_state: combo_box::State, + federation_combo_box_selected_federation: Option, + loadable_invoice_payment_or: Option>, +} + +impl Page { + pub fn new(connected_state: &ConnectedState) -> Self { + Self { + wallet: connected_state.wallet.clone(), + lightning_invoice_input: String::new(), + federation_combo_box_state: combo_box::State::new( + connected_state + .loadable_federation_views + .as_ref_option() + .cloned() + .unwrap_or_default() + .into_values() + .collect(), + ), + federation_combo_box_selected_federation: None, + loadable_invoice_payment_or: None, + } + } + + pub fn update(&mut self, msg: Message) -> Task { + match msg { + Message::LightningInvoiceInputChanged(new_lightning_invoice_input) => { + self.lightning_invoice_input = new_lightning_invoice_input; + + Task::none() + } + Message::FederationComboBoxSelected(federation) => { + self.federation_combo_box_selected_federation = Some(federation); + + Task::none() + } + Message::PayInvoice(invoice, federation_id) => { + self.loadable_invoice_payment_or = Some(Loadable::Loading); + + let wallet = self.wallet.clone(); + + Task::future(async move { + match wallet.pay_invoice(invoice.clone(), federation_id).await { + Ok(()) => KeystacheMessage::BitcoinWalletPage(super::Message::Send( + Message::PayInvoiceSucceeded(invoice), + )), + // TODO: Display error to user. Probably a toast. + Err(_err) => KeystacheMessage::BitcoinWalletPage(super::Message::Send( + Message::PayInvoiceFailed(invoice), + )), + } + }) + } + Message::PayInvoiceSucceeded(invoice) => { + let invoice_or = Bolt11Invoice::from_str(&self.lightning_invoice_input).ok(); + + if Some(invoice) == invoice_or { + self.loadable_invoice_payment_or = Some(Loadable::Loaded(())); + } + + Task::none() + } + Message::PayInvoiceFailed(invoice) => { + let invoice_or = Bolt11Invoice::from_str(&self.lightning_invoice_input).ok(); + + if Some(invoice) == invoice_or { + self.loadable_invoice_payment_or = Some(Loadable::Failed); + } + + Task::none() + } + Message::UpdateFederationViews(federation_views) => { + self.federation_combo_box_selected_federation = self + .federation_combo_box_selected_federation + .as_ref() + .and_then(|selected_federation| { + federation_views + .get(&selected_federation.federation_id) + .cloned() + }); + + self.federation_combo_box_state = + combo_box::State::new(federation_views.into_values().collect()); + + Task::none() + } + } + } + + pub fn view(&self) -> Column { + let mut container = container("Send"); + + let invoice_or = Bolt11Invoice::from_str(&self.lightning_invoice_input).ok(); + + // If the inputted invoice is valid and a federation is + // selected, then we can proceed to pay the invoice. + let parsed_invoice_and_selected_federation_id_or = invoice_or.and_then(|invoice| { + self.federation_combo_box_selected_federation + .as_ref() + .map(|selected_federation| (invoice, selected_federation.federation_id)) + }); + + container = container + .push( + text_input("Lightning Invoice", &self.lightning_invoice_input) + .on_input(|input| { + KeystacheMessage::BitcoinWalletPage(super::Message::Send( + Message::LightningInvoiceInputChanged(input), + )) + }) + .padding(10) + .size(30), + ) + .push(combo_box( + &self.federation_combo_box_state, + "Federation to pay from", + self.federation_combo_box_selected_federation.as_ref(), + Self::on_combo_box_change, + )) + .push( + icon_button("Pay Invoice", SvgIcon::Send, PaletteColor::Primary).on_press_maybe( + parsed_invoice_and_selected_federation_id_or.map(|(invoice, federation_id)| { + KeystacheMessage::BitcoinWalletPage(super::Message::Send( + Message::PayInvoice(invoice, federation_id), + )) + }), + ), + ) + .push( + icon_button("Back", SvgIcon::ArrowBack, PaletteColor::Background).on_press( + KeystacheMessage::Navigate(RouteName::BitcoinWallet(SubrouteName::List)), + ), + ); + + match &self.loadable_invoice_payment_or { + Some(Loadable::Loading) => { + container = container.push(Text::new("Loading...")); + } + Some(Loadable::Loaded(())) => { + container = container.push(Text::new("Payment successful!")); + } + Some(Loadable::Failed) => { + container = container.push(Text::new("Payment failed")); + } + None => {} + } + + container + } + + fn on_combo_box_change(federation_view: FederationView) -> KeystacheMessage { + KeystacheMessage::BitcoinWalletPage(super::Message::Send( + Message::FederationComboBoxSelected(federation_view), + )) + } +} diff --git a/src/routes/mod.rs b/src/routes/mod.rs index b4bf81a..5050c9a 100644 --- a/src/routes/mod.rs +++ b/src/routes/mod.rs @@ -109,7 +109,7 @@ impl Route { self.get_connected_state().map(|connected_state| { Self::BitcoinWallet(bitcoin_wallet::Page { connected_state: connected_state.clone(), - subroute: subroute_name.to_default_subroute(), + subroute: subroute_name.to_default_subroute(connected_state), }) }) } @@ -187,13 +187,23 @@ impl Route { Task::none() } - KeystacheMessage::FederationViewsUpdate { views } => { + KeystacheMessage::UpdateFederationViews { views } => { if let Some(connected_state) = self.get_connected_state_mut() { - connected_state.loadable_federation_views = Loadable::Loaded(views); + connected_state.loadable_federation_views = Loadable::Loaded(views.clone()); + } + + if let Self::BitcoinWallet(bitcoin_wallet) = self { + bitcoin_wallet.update(bitcoin_wallet::Message::UpdateFederationViews(views)); } Task::none() } + KeystacheMessage::CopyStringToClipboard(text) => { + // TODO: Display a toast stating whether the copy succeeded or failed. + let _ = arboard::Clipboard::new().map(|mut clipboard| clipboard.set_text(text)); + + Task::none() + } KeystacheMessage::IncomingNip46Request(data) => { if let Some(connected_state) = self.get_connected_state_mut() { connected_state.in_flight_nip46_requests.push_back(data); @@ -295,13 +305,22 @@ impl Route { } } -#[derive(Debug, Clone)] +#[derive(Debug, Clone, PartialEq, Eq)] pub enum Loadable { Loading, Loaded(T), Failed, } +impl Loadable { + pub fn as_ref_option(&self) -> Option<&T> { + match self { + Self::Loaded(data) => Some(data), + _ => None, + } + } +} + fn container<'a>(title: &str) -> Column<'a, KeystacheMessage> { column![text(title.to_string()).size(35)] .spacing(20) diff --git a/src/ui_components/icon.rs b/src/ui_components/icon.rs index f1abdf3..071648d 100644 --- a/src/ui_components/icon.rs +++ b/src/ui_components/icon.rs @@ -10,8 +10,11 @@ use iced::{ pub enum SvgIcon { Add, ArrowBack, + ArrowDownward, + ArrowUpward, Casino, ChevronRight, + ContentCopy, CurrencyBitcoin, Delete, FileCopy, @@ -23,6 +26,7 @@ pub enum SvgIcon { Lock, LockOpen, Save, + Send, Settings, ThumbDown, ThumbUp, @@ -42,8 +46,11 @@ impl SvgIcon { match self { Self::Add => icon_handle!("add.svg"), Self::ArrowBack => icon_handle!("arrow_back.svg"), + Self::ArrowDownward => icon_handle!("arrow_downward.svg"), + Self::ArrowUpward => icon_handle!("arrow_upward.svg"), Self::Casino => icon_handle!("casino.svg"), Self::ChevronRight => icon_handle!("chevron_right.svg"), + Self::ContentCopy => icon_handle!("content_copy.svg"), Self::CurrencyBitcoin => icon_handle!("currency_bitcoin.svg"), Self::Delete => icon_handle!("delete.svg"), Self::FileCopy => icon_handle!("file_copy.svg"), @@ -55,6 +62,7 @@ impl SvgIcon { Self::Lock => icon_handle!("lock.svg"), Self::LockOpen => icon_handle!("lock_open.svg"), Self::Save => icon_handle!("save.svg"), + Self::Send => icon_handle!("send.svg"), Self::Settings => icon_handle!("settings.svg"), Self::ThumbDown => icon_handle!("thumb_down.svg"), Self::ThumbUp => icon_handle!("thumb_up.svg"),