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"),