diff --git a/gui/Cargo.lock b/gui/Cargo.lock index 90aa09e18..ad984f3ca 100644 --- a/gui/Cargo.lock +++ b/gui/Cargo.lock @@ -2112,8 +2112,8 @@ dependencies = [ [[package]] name = "liana" -version = "1.0.0" -source = "git+https://github.com/wizardsardine/liana?branch=master#85d470dd8dd67e6726118fe6dd86f9b4c8d3b0ef" +version = "2.0.0" +source = "git+https://github.com/wizardsardine/liana?branch=master#605a13d4bab662f832b8fcb0d915eb17d0360c1f" dependencies = [ "backtrace", "bip39", diff --git a/gui/src/app/message.rs b/gui/src/app/message.rs index 23a1352f7..44bbd27ee 100644 --- a/gui/src/app/message.rs +++ b/gui/src/app/message.rs @@ -1,3 +1,4 @@ +use std::collections::HashMap; use std::sync::Arc; use liana::{ @@ -22,6 +23,7 @@ pub enum Message { Info(Result), ReceiveAddress(Result), Coins(Result, Error>), + Labels(Result, Error>), SpendTxs(Result, Error>), Psbt(Result), Recovery(Result), @@ -33,4 +35,5 @@ pub enum Message { ConnectedHardwareWallets(Vec), HistoryTransactions(Result, Error>), PendingTransactions(Result, Error>), + LabelsUpdated(Result, Error>), } diff --git a/gui/src/app/mod.rs b/gui/src/app/mod.rs index 25c9c86d3..78f9a62df 100644 --- a/gui/src/app/mod.rs +++ b/gui/src/app/mod.rs @@ -97,6 +97,7 @@ impl App { self.wallet.clone(), &self.cache.coins, self.cache.blockheight as u32, + self.cache.network, ) .into(), menu::Menu::RefreshCoins(preselected) => CreateSpendPanel::new_self_send( @@ -104,6 +105,7 @@ impl App { &self.cache.coins, self.cache.blockheight as u32, preselected, + self.cache.network, ) .into(), }; diff --git a/gui/src/app/settings.rs b/gui/src/app/settings.rs index 2d62d9e55..ea82ea63c 100644 --- a/gui/src/app/settings.rs +++ b/gui/src/app/settings.rs @@ -1,3 +1,5 @@ +//! Settings is the module to handle the GUI settings file. +//! The settings file is used by the GUI to store useful information. use std::collections::HashMap; use std::fs::OpenOptions; use std::io::Write; @@ -8,8 +10,6 @@ use serde::{Deserialize, Serialize}; use crate::{app::wallet::Wallet, hw::HardwareWalletConfig}; -///! Settings is the module to handle the GUI settings file. -///! The settings file is used by the GUI to store useful information. pub const DEFAULT_FILE_NAME: &str = "settings.json"; #[derive(Debug, Clone, Deserialize, Serialize)] diff --git a/gui/src/app/state/coins.rs b/gui/src/app/state/coins.rs index e01d4f096..23490db51 100644 --- a/gui/src/app/state/coins.rs +++ b/gui/src/app/state/coins.rs @@ -1,18 +1,48 @@ -use std::cmp::Ordering; +use std::collections::HashMap; use std::sync::Arc; +use std::{cmp::Ordering, collections::HashSet}; use iced::Command; use liana_ui::widget::Element; use crate::{ - app::{cache::Cache, error::Error, menu::Menu, message::Message, state::State, view}, - daemon::{model::Coin, Daemon}, + app::{ + cache::Cache, + error::Error, + menu::Menu, + message::Message, + state::{label::LabelsEdited, State}, + view, + }, + daemon::{ + model::{Coin, LabelItem, Labelled}, + Daemon, + }, }; +#[derive(Debug, Default)] +pub struct Coins { + list: Vec, + labels: HashMap, +} + +impl Labelled for Coins { + fn labelled(&self) -> Vec { + self.list + .iter() + .map(|a| LabelItem::OutPoint(a.outpoint)) + .collect() + } + fn labels(&mut self) -> &mut HashMap { + &mut self.labels + } +} + pub struct CoinsPanel { - coins: Vec, + coins: Coins, selected: Vec, + labels_edited: LabelsEdited, warning: Option, /// timelock value to pass for the heir to consume a coin. timelock: u16, @@ -21,7 +51,8 @@ pub struct CoinsPanel { impl CoinsPanel { pub fn new(coins: &[Coin], timelock: u16) -> Self { let mut panel = Self { - coins: Vec::new(), + labels_edited: LabelsEdited::default(), + coins: Coins::default(), selected: Vec::new(), warning: None, timelock, @@ -31,18 +62,14 @@ impl CoinsPanel { } fn update_coins(&mut self, coins: &[Coin]) { - self.coins = coins + self.coins.list = coins .iter() - .filter_map(|coin| { - if coin.spend_info.is_none() { - Some(coin.clone()) - } else { - None - } - }) + .filter(|coin| coin.spend_info.is_none()) + .cloned() .collect(); self.coins + .list .sort_by(|a, b| match (a.block_height, b.block_height) { (Some(a_height), Some(b_height)) => { if a_height == b_height { @@ -64,13 +91,20 @@ impl State for CoinsPanel { &Menu::Coins, cache, self.warning.as_ref(), - view::coins::coins_view(cache, &self.coins, self.timelock, &self.selected), + view::coins::coins_view( + cache, + &self.coins.list, + self.timelock, + &self.selected, + &self.coins.labels, + self.labels_edited.cache(), + ), ) } fn update( &mut self, - _daemon: Arc, + daemon: Arc, _cache: &Cache, message: Message, ) -> Command { @@ -83,6 +117,24 @@ impl State for CoinsPanel { self.update_coins(&coins); } }, + Message::Labels(res) => match res { + Err(e) => self.warning = Some(e), + Ok(labels) => { + self.coins.labels = labels; + } + }, + Message::View(view::Message::Label(_, _)) | Message::LabelsUpdated(_) => { + match self.labels_edited.update( + daemon, + message, + std::iter::once(&mut self.coins).map(|a| a as &mut dyn Labelled), + ) { + Ok(cmd) => return cmd, + Err(e) => { + self.warning = Some(e); + } + } + } Message::View(view::Message::Select(i)) => { if let Some(position) = self.selected.iter().position(|j| *j == i) { self.selected.remove(position); @@ -96,16 +148,34 @@ impl State for CoinsPanel { } fn load(&self, daemon: Arc) -> Command { - let daemon = daemon.clone(); - Command::perform( - async move { - daemon - .list_coins() - .map(|res| res.coins) - .map_err(|e| e.into()) - }, - Message::Coins, - ) + let daemon1 = daemon.clone(); + let daemon2 = daemon.clone(); + Command::batch(vec![ + Command::perform( + async move { + daemon1 + .list_coins() + .map(|res| res.coins) + .map_err(|e| e.into()) + }, + Message::Coins, + ), + Command::perform( + async move { + let coins = daemon2 + .list_coins() + .map(|res| res.coins) + .map_err(Error::from)?; + let mut targets = HashSet::::new(); + for coin in coins { + targets.insert(LabelItem::OutPoint(coin.outpoint)); + targets.insert(LabelItem::Address(coin.address)); + } + daemon2.get_labels(&targets).map_err(|e| e.into()) + }, + Message::Labels, + ), + ]) } } @@ -172,6 +242,7 @@ mod tests { assert_eq!( panel .coins + .list .iter() .map(|c| c.outpoint) .collect::>(), diff --git a/gui/src/app/state/label.rs b/gui/src/app/state/label.rs new file mode 100644 index 000000000..a8d435f05 --- /dev/null +++ b/gui/src/app/state/label.rs @@ -0,0 +1,88 @@ +use liana::miniscript::bitcoin; +use std::str::FromStr; +use std::{collections::HashMap, iter::IntoIterator, sync::Arc}; + +use crate::{ + app::{error::Error, message::Message, view}, + daemon::{ + model::{LabelItem, Labelled}, + Daemon, + }, +}; +use iced::Command; +use liana_ui::component::form; + +#[derive(Default)] +pub struct LabelsEdited(HashMap>); + +impl LabelsEdited { + pub fn cache(&self) -> &HashMap> { + &self.0 + } + pub fn update<'a, T: IntoIterator>( + &mut self, + daemon: Arc, + message: Message, + targets: T, + ) -> Result, Error> { + match message { + Message::View(view::Message::Label(labelled, msg)) => match msg { + view::LabelMessage::Edited(value) => { + let valid = value.len() <= 100; + if let Some(label) = self.0.get_mut(&labelled) { + label.valid = valid; + label.value = value; + } else { + self.0.insert(labelled, form::Value { valid, value }); + } + } + view::LabelMessage::Cancel => { + self.0.remove(&labelled); + } + view::LabelMessage::Confirm => { + if let Some(label) = self.0.get(&labelled).cloned() { + return Ok(Command::perform( + async move { + if let Some(item) = label_item_from_str(&labelled) { + daemon.update_labels(&HashMap::from([( + item, + label.value.clone(), + )]))?; + } + Ok(HashMap::from([(labelled, label.value)])) + }, + Message::LabelsUpdated, + )); + } + } + }, + Message::LabelsUpdated(res) => match res { + Ok(new_labels) => { + for target in targets { + target.load_labels(&new_labels); + } + for (labelled, _) in new_labels { + self.0.remove(&labelled); + } + } + Err(e) => { + return Err(e); + } + }, + _ => {} + }; + Ok(Command::none()) + } +} + +pub fn label_item_from_str(s: &str) -> Option { + if let Ok(addr) = bitcoin::Address::from_str(s) { + Some(LabelItem::Address(addr.assume_checked())) + } else if let Ok(txid) = bitcoin::Txid::from_str(s) { + Some(LabelItem::Txid(txid)) + } else if let Ok(outpoint) = bitcoin::OutPoint::from_str(s) { + Some(LabelItem::OutPoint(outpoint)) + } else { + None + } +} diff --git a/gui/src/app/state/mod.rs b/gui/src/app/state/mod.rs index 53b5d360d..b6292e066 100644 --- a/gui/src/app/state/mod.rs +++ b/gui/src/app/state/mod.rs @@ -1,4 +1,5 @@ mod coins; +mod label; mod psbt; mod psbts; mod recovery; @@ -6,6 +7,7 @@ mod settings; mod spend; mod transactions; +use std::collections::HashMap; use std::convert::TryInto; use std::sync::Arc; use std::time::{SystemTime, UNIX_EPOCH}; @@ -17,10 +19,11 @@ use liana_ui::widget::*; use super::{cache::Cache, error::Error, menu::Menu, message::Message, view, wallet::Wallet}; use crate::daemon::{ - model::{remaining_sequence, Coin, HistoryTransaction}, + model::{remaining_sequence, Coin, HistoryTransaction, LabelItem, Labelled}, Daemon, }; pub use coins::CoinsPanel; +use label::LabelsEdited; pub use psbts::PsbtsPanel; pub use recovery::RecoveryPanel; pub use settings::SettingsState; @@ -54,6 +57,7 @@ pub struct Home { pending_events: Vec, events: Vec, selected_event: Option<(usize, usize)>, + labels_edited: LabelsEdited, warning: Option, } @@ -80,6 +84,7 @@ impl Home { selected_event: None, events: Vec::new(), pending_events: Vec::new(), + labels_edited: LabelsEdited::default(), warning: None, } } @@ -93,7 +98,13 @@ impl State for Home { } else { &self.events[i - self.pending_events.len()] }; - view::home::payment_view(cache, event, output_index, self.warning.as_ref()) + view::home::payment_view( + cache, + event, + output_index, + self.labels_edited.cache(), + self.warning.as_ref(), + ) } else { view::dashboard( &Menu::Home, @@ -174,6 +185,23 @@ impl State for Home { } } }, + Message::View(view::Message::Label(_, _)) | Message::LabelsUpdated(_) => { + match self.labels_edited.update( + daemon, + message, + self.pending_events + .iter_mut() + .map(|tx| tx as &mut dyn Labelled) + .chain(self.events.iter_mut().map(|tx| tx as &mut dyn Labelled)), + ) { + Ok(cmd) => { + return cmd; + } + Err(e) => { + self.warning = Some(e); + } + }; + } Message::View(view::Message::Close) => { self.selected_event = None; } @@ -265,9 +293,28 @@ impl From for Box { } } +#[derive(Debug, Default)] +pub struct Addresses { + list: Vec
, + labels: HashMap, +} + +impl Labelled for Addresses { + fn labelled(&self) -> Vec { + self.list + .iter() + .map(|a| LabelItem::Address(a.clone())) + .collect() + } + fn labels(&mut self) -> &mut HashMap { + &mut self.labels + } +} + #[derive(Default)] pub struct ReceivePanel { - addresses: Vec
, + addresses: Addresses, + labels_edited: LabelsEdited, qr_code: Option, warning: Option, } @@ -278,7 +325,12 @@ impl State for ReceivePanel { &Menu::Receive, cache, self.warning.as_ref(), - view::receive::receive(&self.addresses, self.qr_code.as_ref()), + view::receive::receive( + &self.addresses.list, + self.qr_code.as_ref(), + &self.addresses.labels, + self.labels_edited.cache(), + ), ) } fn update( @@ -288,12 +340,25 @@ impl State for ReceivePanel { message: Message, ) -> Command { match message { + Message::View(view::Message::Label(_, _)) | Message::LabelsUpdated(_) => { + match self.labels_edited.update( + daemon, + message, + std::iter::once(&mut self.addresses).map(|a| a as &mut dyn Labelled), + ) { + Ok(cmd) => cmd, + Err(e) => { + self.warning = Some(e); + Command::none() + } + } + } Message::ReceiveAddress(res) => { match res { Ok(address) => { self.warning = None; self.qr_code = Some(qr_code::State::new(address.to_qr_uri()).unwrap()); - self.addresses.push(address); + self.addresses.list.push(address); } Err(e) => self.warning = Some(e), } @@ -363,6 +428,6 @@ mod tests { let sandbox = sandbox.load(client, &Cache::default()).await; let panel = sandbox.state(); - assert_eq!(panel.addresses, vec![addr]); + assert_eq!(panel.addresses.list, vec![addr]); } } diff --git a/gui/src/app/state/psbt.rs b/gui/src/app/state/psbt.rs index c927ec382..6d27bc71a 100644 --- a/gui/src/app/state/psbt.rs +++ b/gui/src/app/state/psbt.rs @@ -1,4 +1,4 @@ -use std::collections::HashSet; +use std::collections::{HashMap, HashSet}; use std::sync::Arc; use iced::Command; @@ -17,11 +17,12 @@ use crate::{ cache::Cache, error::Error, message::Message, + state::label::{label_item_from_str, LabelsEdited}, view, wallet::{Wallet, WalletError}, }, daemon::{ - model::{SpendStatus, SpendTx}, + model::{LabelItem, Labelled, SpendStatus, SpendTx}, Daemon, }, hw::{list_hardware_wallets, HardwareWallet}, @@ -50,6 +51,8 @@ pub struct PsbtState { pub desc_policy: LianaPolicy, pub tx: SpendTx, pub saved: bool, + pub warning: Option, + pub labels_edited: LabelsEdited, pub action: Option>, } @@ -58,6 +61,8 @@ impl PsbtState { Self { desc_policy: wallet.main_descriptor.policy(), wallet, + labels_edited: LabelsEdited::default(), + warning: None, action: None, tx, saved, @@ -84,7 +89,7 @@ impl PsbtState { self.action = None; } view::SpendTxMessage::Delete => { - self.action = Some(Box::new(DeleteAction::default())); + self.action = Some(Box::::default()); } view::SpendTxMessage::Sign => { let action = SignAction::new(self.tx.signers(), self.wallet.clone()); @@ -99,10 +104,10 @@ impl PsbtState { return cmd; } view::SpendTxMessage::Broadcast => { - self.action = Some(Box::new(BroadcastAction::default())); + self.action = Some(Box::::default()); } view::SpendTxMessage::Save => { - self.action = Some(Box::new(SaveAction::default())); + self.action = Some(Box::::default()); } _ => { if let Some(action) = self.action.as_mut() { @@ -110,6 +115,20 @@ impl PsbtState { } } }, + Message::View(view::Message::Label(_, _)) | Message::LabelsUpdated(_) => { + match self.labels_edited.update( + daemon, + message, + std::iter::once(&mut self.tx).map(|tx| tx as &mut dyn Labelled), + ) { + Ok(cmd) => { + return cmd; + } + Err(e) => { + self.warning = Some(e); + } + }; + } Message::Updated(Ok(_)) => { self.saved = true; if let Some(action) = self.action.as_mut() { @@ -132,7 +151,9 @@ impl PsbtState { self.saved, &self.desc_policy, &self.wallet.keys_aliases, + self.labels_edited.cache(), cache.network, + self.warning.as_ref(), ); if let Some(action) = &self.action { modal::Modal::new(content, action.view()) @@ -161,8 +182,18 @@ impl Action for SaveAction { Message::View(view::Message::Spend(view::SpendTxMessage::Confirm)) => { let daemon = daemon.clone(); let psbt = tx.psbt.clone(); + let mut labels = HashMap::::new(); + for (item, label) in tx.labels() { + labels.insert( + label_item_from_str(item).expect("Must be a LabelItem"), + label.clone(), + ); + } return Command::perform( - async move { daemon.update_spend_tx(&psbt).map_err(|e| e.into()) }, + async move { + daemon.update_spend_tx(&psbt)?; + daemon.update_labels(&labels).map_err(|e| e.into()) + }, Message::Updated, ); } diff --git a/gui/src/app/state/recovery.rs b/gui/src/app/state/recovery.rs index dbe904e96..a8113118d 100644 --- a/gui/src/app/state/recovery.rs +++ b/gui/src/app/state/recovery.rs @@ -140,22 +140,29 @@ impl State for RecoveryPanel { .recovery_paths .get(self.selected_path.expect("A path must be selected")) .map(|p| p.sequence); + let network = cache.network; return Command::perform( async move { let psbt = daemon.create_recovery(address, feerate_vb, sequence)?; let coins = daemon.list_coins().map(|res| res.coins)?; let coins = coins - .iter() + .into_iter() .filter(|coin| { psbt.unsigned_tx .input .iter() .any(|input| input.previous_output == coin.outpoint) }) - .cloned() .collect(); let sigs = desc.partial_spend_info(&psbt).unwrap(); - Ok(SpendTx::new(None, psbt, coins, sigs, desc.max_sat_vbytes())) + Ok(SpendTx::new( + None, + psbt, + coins, + sigs, + desc.max_sat_vbytes(), + network, + )) }, Message::Recovery, ); diff --git a/gui/src/app/state/spend/mod.rs b/gui/src/app/state/spend/mod.rs index 67587efd9..31a585a5a 100644 --- a/gui/src/app/state/spend/mod.rs +++ b/gui/src/app/state/spend/mod.rs @@ -1,15 +1,20 @@ mod step; + +use std::collections::HashSet; use std::sync::Arc; use iced::Command; -use liana::miniscript::bitcoin::OutPoint; +use liana::miniscript::bitcoin::{Network, OutPoint}; use liana_ui::widget::Element; use super::{redirect, State}; use crate::{ - app::{cache::Cache, menu::Menu, message::Message, view, wallet::Wallet}, - daemon::{model::Coin, Daemon}, + app::{cache::Cache, error::Error, menu::Menu, message::Message, view, wallet::Wallet}, + daemon::{ + model::{Coin, LabelItem}, + Daemon, + }, }; pub struct CreateSpendPanel { @@ -19,11 +24,11 @@ pub struct CreateSpendPanel { } impl CreateSpendPanel { - pub fn new(wallet: Arc, coins: &[Coin], blockheight: u32) -> Self { + pub fn new(wallet: Arc, coins: &[Coin], blockheight: u32, network: Network) -> Self { let descriptor = wallet.main_descriptor.clone(); let timelock = descriptor.first_timelock_value(); Self { - draft: step::TransactionDraft::default(), + draft: step::TransactionDraft::new(network), current: 0, steps: vec![ Box::new( @@ -40,11 +45,12 @@ impl CreateSpendPanel { coins: &[Coin], blockheight: u32, preselected_coins: &[OutPoint], + network: Network, ) -> Self { let descriptor = wallet.main_descriptor.clone(); let timelock = descriptor.first_timelock_value(); Self { - draft: step::TransactionDraft::default(), + draft: step::TransactionDraft::new(network), current: 0, steps: vec![ Box::new( @@ -99,16 +105,33 @@ impl State for CreateSpendPanel { } fn load(&self, daemon: Arc) -> Command { - let daemon = daemon.clone(); - Command::perform( - async move { - daemon - .list_coins() - .map(|res| res.coins) - .map_err(|e| e.into()) - }, - Message::Coins, - ) + let daemon1 = daemon.clone(); + let daemon2 = daemon.clone(); + Command::batch(vec![ + Command::perform( + async move { + daemon1 + .list_coins() + .map(|res| res.coins) + .map_err(|e| e.into()) + }, + Message::Coins, + ), + Command::perform( + async move { + let coins = daemon + .list_coins() + .map(|res| res.coins) + .map_err(Error::from)?; + let mut targets = HashSet::::new(); + for coin in coins { + targets.insert(LabelItem::OutPoint(coin.outpoint)); + } + daemon2.get_labels(&targets).map_err(|e| e.into()) + }, + Message::Labels, + ), + ]) } } diff --git a/gui/src/app/state/spend/step.rs b/gui/src/app/state/spend/step.rs index dcbe53e1f..b43f8092e 100644 --- a/gui/src/app/state/spend/step.rs +++ b/gui/src/app/state/spend/step.rs @@ -26,10 +26,27 @@ use crate::{ /// See: https://github.com/wizardsardine/liana/blob/master/src/commands/mod.rs#L32 const DUST_OUTPUT_SATS: u64 = 5_000; -#[derive(Default, Clone)] +#[derive(Clone)] pub struct TransactionDraft { + network: Network, inputs: Vec, + recipients: Vec, generated: Option, + batch_label: Option, + labels: HashMap, +} + +impl TransactionDraft { + pub fn new(network: Network) -> Self { + Self { + network, + inputs: Vec::new(), + recipients: Vec::new(), + generated: None, + batch_label: None, + labels: HashMap::new(), + } + } } pub trait Step { @@ -53,6 +70,8 @@ pub struct DefineSpend { descriptor: LianaDescriptor, timelock: u16, coins: Vec<(Coin, bool)>, + coins_labels: HashMap, + batch_label: form::Value, amount_left_to_select: Option, feerate: form::Value, generated: Option, @@ -88,6 +107,8 @@ impl DefineSpend { timelock, generated: None, coins, + coins_labels: HashMap::new(), + batch_label: form::Value::default(), recipients: vec![Recipient::default()], is_valid: false, is_duplicate: false, @@ -128,7 +149,9 @@ impl DefineSpend { } fn check_valid(&mut self) { - self.is_valid = self.feerate.valid && !self.feerate.value.is_empty(); + self.is_valid = self.feerate.valid + && !self.feerate.value.is_empty() + && (self.batch_label.valid || self.recipients.len() < 2); self.is_duplicate = false; if !self.coins.iter().any(|(_, selected)| *selected) { self.is_valid = false; @@ -216,94 +239,146 @@ impl Step for DefineSpend { cache: &Cache, message: Message, ) -> Command { - if let Message::View(view::Message::CreateSpend(msg)) = message { - match msg { - view::CreateSpendMessage::AddRecipient => { - self.recipients.push(Recipient::default()); - } - view::CreateSpendMessage::DeleteRecipient(i) => { - self.recipients.remove(i); - } - view::CreateSpendMessage::RecipientEdited(i, _, _) => { - self.recipients - .get_mut(i) - .unwrap() - .update(cache.network, msg); - } + match message { + Message::View(view::Message::CreateSpend(msg)) => { + match msg { + view::CreateSpendMessage::BatchLabelEdited(label) => { + self.batch_label.valid = label.len() <= 100; + self.batch_label.value = label; + } + view::CreateSpendMessage::AddRecipient => { + self.recipients.push(Recipient::default()); + } + view::CreateSpendMessage::DeleteRecipient(i) => { + self.recipients.remove(i); + if self.recipients.len() < 2 { + self.batch_label.valid = true; + self.batch_label.value = "".to_string(); + } + } + view::CreateSpendMessage::RecipientEdited(i, _, _) => { + self.recipients + .get_mut(i) + .unwrap() + .update(cache.network, msg); + } - view::CreateSpendMessage::FeerateEdited(s) => { - if let Ok(value) = s.parse::() { - self.feerate.value = s; - self.feerate.valid = value != 0; - self.amount_left_to_select(); - } else if s.is_empty() { - self.feerate.value = "".to_string(); - self.feerate.valid = true; - self.amount_left_to_select = None; - } else { - self.feerate.valid = false; - self.amount_left_to_select = None; + view::CreateSpendMessage::FeerateEdited(s) => { + if let Ok(value) = s.parse::() { + self.feerate.value = s; + self.feerate.valid = value != 0; + self.amount_left_to_select(); + } else if s.is_empty() { + self.feerate.value = "".to_string(); + self.feerate.valid = true; + self.amount_left_to_select = None; + } else { + self.feerate.valid = false; + self.amount_left_to_select = None; + } + self.warning = None; } - self.warning = None; - } - view::CreateSpendMessage::Generate => { - let inputs: Vec = self - .coins - .iter() - .filter_map( - |(coin, selected)| if *selected { Some(coin.outpoint) } else { None }, - ) - .collect(); - let mut outputs: HashMap, u64> = - HashMap::new(); - for recipient in &self.recipients { - outputs.insert( - Address::from_str(&recipient.address.value).expect("Checked before"), - recipient.amount().expect("Checked before"), + view::CreateSpendMessage::Generate => { + let inputs: Vec = self + .coins + .iter() + .filter_map( + |(coin, selected)| { + if *selected { + Some(coin.outpoint) + } else { + None + } + }, + ) + .collect(); + let mut outputs: HashMap, u64> = + HashMap::new(); + for recipient in &self.recipients { + outputs.insert( + Address::from_str(&recipient.address.value) + .expect("Checked before"), + recipient.amount().expect("Checked before"), + ); + } + let feerate_vb = self.feerate.value.parse::().unwrap_or(0); + self.warning = None; + return Command::perform( + async move { + daemon + .create_spend_tx(&inputs, &outputs, feerate_vb) + .map(|res| res.psbt) + .map_err(|e| e.into()) + }, + Message::Psbt, ); } - let feerate_vb = self.feerate.value.parse::().unwrap_or(0); - self.warning = None; - return Command::perform( - async move { - daemon - .create_spend_tx(&inputs, &outputs, feerate_vb) - .map(|res| res.psbt) - .map_err(|e| e.into()) - }, - Message::Psbt, - ); - } - view::CreateSpendMessage::SelectCoin(i) => { - if let Some(coin) = self.coins.get_mut(i) { - coin.1 = !coin.1; - self.amount_left_to_select(); + view::CreateSpendMessage::SelectCoin(i) => { + if let Some(coin) = self.coins.get_mut(i) { + coin.1 = !coin.1; + self.amount_left_to_select(); + } } + _ => {} } - _ => {} + self.check_valid(); } - self.check_valid(); - Command::none() - } else { - if let Message::Psbt(res) = message { - match res { - Ok(psbt) => { - self.generated = Some(psbt); - return Command::perform(async {}, |_| Message::View(view::Message::Next)); - } - Err(e) => self.warning = Some(e), + Message::Psbt(res) => match res { + Ok(psbt) => { + self.generated = Some(psbt); + return Command::perform(async {}, |_| Message::View(view::Message::Next)); } - } - Command::none() - } + Err(e) => self.warning = Some(e), + }, + Message::Labels(res) => match res { + Ok(labels) => { + self.coins_labels = labels; + } + Err(e) => self.warning = Some(e), + }, + _ => {} + }; + Command::none() } fn apply(&self, draft: &mut TransactionDraft) { draft.inputs = self .coins .iter() - .filter_map(|(coin, selected)| if *selected { Some(coin.clone()) } else { None }) + .filter_map(|(coin, selected)| if *selected { Some(coin) } else { None }) + .cloned() .collect(); + if let Some(psbt) = &self.generated { + draft.labels = self.coins_labels.clone(); + for (i, output) in psbt.unsigned_tx.output.iter().enumerate() { + if let Some(label) = self + .recipients + .iter() + .find(|recipient| { + !recipient.label.value.is_empty() + && Address::from_str(&recipient.address.value) + .unwrap() + .payload + .matches_script_pubkey(&output.script_pubkey) + && output.value == recipient.amount().unwrap() + }) + .map(|recipient| recipient.label.value.to_string()) + { + draft.labels.insert( + OutPoint { + txid: psbt.unsigned_tx.txid(), + vout: i as u32, + } + .to_string(), + label, + ); + } + } + } + draft.recipients = self.recipients.clone(); + if self.recipients.len() > 1 { + draft.batch_label = Some(self.batch_label.value.clone()); + } draft.generated = self.generated.clone(); } @@ -326,6 +401,8 @@ impl Step for DefineSpend { self.is_duplicate, self.timelock, &self.coins, + &self.coins_labels, + &self.batch_label, self.amount_left_to_select.as_ref(), &self.feerate, self.warning.as_ref(), @@ -333,8 +410,9 @@ impl Step for DefineSpend { } } -#[derive(Default)] +#[derive(Default, Clone)] struct Recipient { + label: form::Value, address: form::Value, amount: form::Value, } @@ -372,6 +450,7 @@ impl Recipient { && self.address.valid && !self.amount.value.is_empty() && self.amount.valid + && self.label.valid } fn update(&mut self, network: Network, message: view::CreateSpendMessage) { @@ -399,12 +478,16 @@ impl Recipient { self.amount.valid = true; } } + view::CreateSpendMessage::RecipientEdited(_, "label", label) => { + self.label.valid = label.len() <= 100; + self.label.value = label; + } _ => {} }; } fn view(&self, i: usize) -> Element { - view::spend::recipient_view(i, &self.address, &self.amount) + view::spend::recipient_view(i, &self.address, &self.amount, &self.label) } } @@ -430,17 +513,49 @@ impl Step for SaveSpend { .main_descriptor .partial_spend_info(&psbt) .unwrap(); - self.spend = Some(psbt::PsbtState::new( - self.wallet.clone(), - SpendTx::new( - None, - psbt, - draft.inputs.clone(), - sigs, - self.wallet.main_descriptor.max_sat_vbytes(), - ), - false, - )); + + let mut tx = SpendTx::new( + None, + psbt, + draft.inputs.clone(), + sigs, + self.wallet.main_descriptor.max_sat_vbytes(), + draft.network, + ); + tx.labels = draft.labels.clone(); + + if tx.is_batch() { + if let Some(label) = &draft.batch_label { + tx.labels + .insert(tx.psbt.unsigned_tx.txid().to_string(), label.clone()); + for (i, output) in tx.psbt.unsigned_tx.output.iter().enumerate() { + let address_str = Address::from_script(&output.script_pubkey, tx.network) + .unwrap() + .to_string(); + if tx.change_indexes.contains(&i) && tx.labels.contains_key(&address_str) { + tx.labels + .insert(address_str, format!("Change of {}", label.clone())); + } + } + } + } else if let Some(recipient) = draft.recipients.first() { + if !recipient.label.value.is_empty() { + let label = recipient.label.value.clone(); + tx.labels + .insert(tx.psbt.unsigned_tx.txid().to_string(), label.clone()); + for (i, output) in tx.psbt.unsigned_tx.output.iter().enumerate() { + let address_str = Address::from_script(&output.script_pubkey, tx.network) + .unwrap() + .to_string(); + if tx.change_indexes.contains(&i) && tx.labels.contains_key(&address_str) { + tx.labels + .insert(address_str, format!("Change of {}", label.clone())); + } + } + } + } + + self.spend = Some(psbt::PsbtState::new(self.wallet.clone(), tx, false)); } fn update( @@ -464,7 +579,9 @@ impl Step for SaveSpend { spend.saved, &spend.desc_policy, &spend.wallet.keys_aliases, + spend.labels_edited.cache(), cache.network, + spend.warning.as_ref(), ); if let Some(action) = &spend.action { modal::Modal::new(content, action.view()) diff --git a/gui/src/app/state/transactions.rs b/gui/src/app/state/transactions.rs index c61043795..4bcb37b34 100644 --- a/gui/src/app/state/transactions.rs +++ b/gui/src/app/state/transactions.rs @@ -1,18 +1,30 @@ -use std::convert::TryInto; -use std::sync::Arc; -use std::time::{SystemTime, UNIX_EPOCH}; +use std::{ + convert::TryInto, + sync::Arc, + time::{SystemTime, UNIX_EPOCH}, +}; use iced::Command; use liana_ui::widget::*; -use crate::app::{cache::Cache, error::Error, message::Message, view, State}; +use crate::app::{ + cache::Cache, + error::Error, + message::Message, + state::{label::LabelsEdited, State}, + view, +}; -use crate::daemon::{model::HistoryTransaction, Daemon}; +use crate::daemon::{ + model::{HistoryTransaction, Labelled}, + Daemon, +}; #[derive(Default)] pub struct TransactionsPanel { pending_txs: Vec, txs: Vec, + labels_edited: LabelsEdited, selected_tx: Option, warning: Option, } @@ -23,6 +35,7 @@ impl TransactionsPanel { selected_tx: None, txs: Vec::new(), pending_txs: Vec::new(), + labels_edited: LabelsEdited::default(), warning: None, } } @@ -36,7 +49,12 @@ impl State for TransactionsPanel { } else { &self.txs[i - self.pending_txs.len()] }; - view::transactions::tx_view(cache, tx, self.warning.as_ref()) + view::transactions::tx_view( + cache, + tx, + self.labels_edited.cache(), + self.warning.as_ref(), + ) } else { view::transactions::transactions_view( cache, @@ -82,6 +100,23 @@ impl State for TransactionsPanel { Message::View(view::Message::Select(i)) => { self.selected_tx = Some(i); } + Message::View(view::Message::Label(_, _)) | Message::LabelsUpdated(_) => { + match self.labels_edited.update( + daemon, + message, + self.pending_txs + .iter_mut() + .map(|tx| tx as &mut dyn Labelled) + .chain(self.txs.iter_mut().map(|tx| tx as &mut dyn Labelled)), + ) { + Ok(cmd) => { + return cmd; + } + Err(e) => { + self.warning = Some(e); + } + }; + } Message::View(view::Message::Next) => { if let Some(last) = self.txs.last() { let daemon = daemon.clone(); diff --git a/gui/src/app/view/coins.rs b/gui/src/app/view/coins.rs index 484e4d9e3..1bab2fecc 100644 --- a/gui/src/app/view/coins.rs +++ b/gui/src/app/view/coins.rs @@ -1,15 +1,21 @@ +use std::collections::HashMap; + use iced::{widget::Space, Alignment, Length}; use liana_ui::{ color, - component::{amount::*, badge, button, text::*}, + component::{amount::*, badge, button, form, text::*}, icon, theme, util::Collection, widget::*, }; use crate::{ - app::{cache::Cache, menu::Menu, view::message::Message}, + app::{ + cache::Cache, + menu::Menu, + view::{label, message::Message}, + }, daemon::model::{remaining_sequence, Coin}, }; @@ -18,6 +24,8 @@ pub fn coins_view<'a>( coins: &'a [Coin], timelock: u16, selected: &[usize], + labels: &'a HashMap, + labels_editing: &'a HashMap>, ) -> Element<'a, Message> { Column::new() .push(Container::new(h3("Coins")).width(Length::Fill)) @@ -33,6 +41,8 @@ pub fn coins_view<'a>( cache.blockheight as u32, i, selected.contains(&i), + labels, + labels_editing, )) }, )), @@ -43,13 +53,17 @@ pub fn coins_view<'a>( } #[allow(clippy::collapsible_else_if)] -fn coin_list_view( - coin: &Coin, +fn coin_list_view<'a>( + coin: &'a Coin, timelock: u16, blockheight: u32, index: usize, collapsed: bool, -) -> Container { + labels: &'a HashMap, + labels_editing: &'a HashMap>, +) -> Container<'a, Message> { + let outpoint = coin.outpoint.to_string(); + let address = coin.address.to_string(); Container::new( Column::new() .push( @@ -58,6 +72,17 @@ fn coin_list_view( .push( Row::new() .push(badge::coin()) + .push(if !collapsed { + if let Some(label) = labels.get(&outpoint) { + Container::new(p1_bold(label)).width(Length::Fill) + } else { + Container::new(Space::with_width(Length::Fill)) + .width(Length::Fill) + } + } else { + Container::new(Space::with_width(Length::Fill)) + .width(Length::Fill) + }) .push(if coin.spend_info.is_some() { badge::spent() } else if coin.block_height.is_none() { @@ -83,6 +108,18 @@ fn coin_list_view( Column::new() .padding(10) .spacing(5) + .push( + Container::new(if let Some(label) = labels_editing.get(&outpoint) { + label::label_editing(outpoint.clone(), label, P1_SIZE) + } else { + label::label_editable( + outpoint.clone(), + labels.get(&outpoint), + P1_SIZE, + ) + }) + .width(Length::Fill), + ) .push_maybe(if coin.spend_info.is_none() { if let Some(b) = coin.block_height { if blockheight > b as u32 + timelock as u32 { @@ -104,6 +141,42 @@ fn coin_list_view( }) .push( Column::new() + .push( + Row::new() + .align_items(Alignment::Center) + .push(p2_regular("Address:").bold().style(color::GREY_2)) + .push( + Row::new() + .align_items(Alignment::Center) + .push( + p2_regular(address.clone()) + .style(color::GREY_2), + ) + .push( + Button::new(icon::clipboard_icon()) + .on_press(Message::Clipboard( + address.clone(), + )) + .style(theme::Button::TransparentBorder), + ), + ) + .spacing(5), + ) + .push( + Row::new() + .align_items(Alignment::Center) + .push( + p2_regular("Address label:") + .bold() + .style(color::GREY_2), + ) + .push(if let Some(label) = labels.get(&address) { + p2_regular(label).style(color::GREY_2) + } else { + p2_regular("No label").style(color::GREY_2) + }) + .spacing(5), + ) .push( Row::new() .align_items(Alignment::Center) @@ -186,7 +259,7 @@ pub fn coin_sequence_label<'a, T: 'a>(seq: u32, timelock: u32) -> Container<'a, ) .padding(10) .style(theme::Container::Pill(theme::Pill::Warning)) - } else if seq < timelock as u32 * 10 / 100 { + } else if seq < timelock * 10 / 100 { Container::new( Row::new() .spacing(5) diff --git a/gui/src/app/view/home.rs b/gui/src/app/view/home.rs index 0a1a28cd1..131a9f8e7 100644 --- a/gui/src/app/view/home.rs +++ b/gui/src/app/view/home.rs @@ -1,11 +1,12 @@ use chrono::NaiveDateTime; +use std::collections::HashMap; -use iced::{alignment, Alignment, Length}; +use iced::{alignment, widget::Space, Alignment, Length}; use liana::miniscript::bitcoin; use liana_ui::{ color, - component::{amount::*, button, card, event, text::*}, + component::{amount::*, button, card, event, form, text::*}, icon, theme, util::Collection, widget::*, @@ -16,7 +17,7 @@ use crate::{ cache::Cache, error::Error, menu::Menu, - view::{coins, dashboard, message::Message}, + view::{coins, dashboard, label, message::Message}, }, daemon::model::HistoryTransaction, }; @@ -28,8 +29,8 @@ pub fn home_view<'a>( unconfirmed_balance: &'a bitcoin::Amount, remaining_sequence: &Option, expiring_coins: &Vec, - pending_events: &[HistoryTransaction], - events: &Vec, + pending_events: &'a [HistoryTransaction], + events: &'a Vec, ) -> Element<'a, Message> { Column::new() .push(h3("Balance")) @@ -145,21 +146,41 @@ pub fn home_view<'a>( .into() } -fn event_list_view<'a>(i: usize, event: &HistoryTransaction) -> Column<'a, Message> { +fn event_list_view(i: usize, event: &HistoryTransaction) -> Column<'_, Message> { event.tx.output.iter().enumerate().fold( Column::new().spacing(10), |col, (output_index, output)| { + let label = if let Some(label) = event.labels.get( + &bitcoin::OutPoint { + txid: event.tx.txid(), + vout: output_index as u32, + } + .to_string(), + ) { + Some(p1_bold(label)) + } else { + event + .labels + .get( + &bitcoin::Address::from_script(&output.script_pubkey, event.network) + .unwrap() + .to_string(), + ) + .map(|label| p1_bold(format!("address label: {}", label)).style(color::GREY_3)) + }; if event.is_external() { if !event.change_indexes.contains(&output_index) { col } else if let Some(t) = event.time { col.push(event::confirmed_incoming_event( + label, NaiveDateTime::from_timestamp_opt(t as i64, 0).unwrap(), &Amount::from_sat(output.value), Message::SelectSub(i, output_index), )) } else { col.push(event::unconfirmed_incoming_event( + label, &Amount::from_sat(output.value), Message::SelectSub(i, output_index), )) @@ -168,12 +189,14 @@ fn event_list_view<'a>(i: usize, event: &HistoryTransaction) -> Column<'a, Messa col } else if let Some(t) = event.time { col.push(event::confirmed_outgoing_event( + label, NaiveDateTime::from_timestamp_opt(t as i64, 0).unwrap(), &Amount::from_sat(output.value), Message::SelectSub(i, output_index), )) } else { col.push(event::unconfirmed_outgoing_event( + label, &Amount::from_sat(output.value), Message::SelectSub(i, output_index), )) @@ -186,8 +209,15 @@ pub fn payment_view<'a>( cache: &'a Cache, tx: &'a HistoryTransaction, output_index: usize, + labels_editing: &'a HashMap>, warning: Option<&'a Error>, ) -> Element<'a, Message> { + let txid = tx.tx.txid().to_string(); + let outpoint = bitcoin::OutPoint { + txid: tx.tx.txid(), + vout: output_index as u32, + } + .to_string(); dashboard( &Menu::Home, cache, @@ -200,11 +230,22 @@ pub fn payment_view<'a>( } else { Container::new(h3("Outgoing payment")).width(Length::Fill) }) + .push(if let Some(label) = labels_editing.get(&outpoint) { + label::label_editing(outpoint.clone(), label, H3_SIZE) + } else { + label::label_editable(outpoint.clone(), tx.labels.get(&outpoint), H1_SIZE) + }) .push(Container::new(amount_with_size( &Amount::from_sat(tx.tx.output[output_index].value), H1_SIZE, ))) + .push(Space::with_height(H3_SIZE)) .push(Container::new(h3("Transaction")).width(Length::Fill)) + .push(if let Some(label) = labels_editing.get(&txid) { + label::label_editing(txid.clone(), label, H3_SIZE) + } else { + label::label_editable(txid.clone(), tx.labels.get(&txid), H3_SIZE) + }) .push_maybe(tx.fee_amount.map(|fee_amount| { Row::new() .align_items(Alignment::Center) @@ -259,11 +300,8 @@ pub fn payment_view<'a>( } else { Some(tx.change_indexes.clone()) }, - if tx.is_external() { - Some(tx.change_indexes.clone()) - } else { - None - }, + &tx.labels, + labels_editing, )) .spacing(20), ) diff --git a/gui/src/app/view/label.rs b/gui/src/app/view/label.rs new file mode 100644 index 000000000..c7d2dd710 --- /dev/null +++ b/gui/src/app/view/label.rs @@ -0,0 +1,71 @@ +use iced::{widget::row, Alignment}; + +use liana_ui::{ + color, + component::{button, form}, + font, icon, + widget::*, +}; + +use crate::app::view; + +pub fn label_editable( + labelled: String, + label: Option<&String>, + size: u16, +) -> Element<'_, view::Message> { + if let Some(label) = label { + if !label.is_empty() { + return Container::new( + row!( + iced::widget::Text::new(label).size(size).font(font::BOLD), + button::primary(Some(icon::pencil_icon()), "Edit").on_press( + view::Message::Label( + labelled, + view::message::LabelMessage::Edited(label.to_string()) + ) + ) + ) + .spacing(5) + .align_items(Alignment::Center), + ) + .into(); + } + } + Container::new( + row!( + iced::widget::Text::new("Add Label") + .size(size) + .font(font::BOLD) + .style(color::GREY_3), + button::primary(Some(icon::pencil_icon()), "Edit").on_press(view::Message::Label( + labelled, + view::message::LabelMessage::Edited(String::default()) + )) + ) + .spacing(5) + .align_items(Alignment::Center), + ) + .into() +} + +pub fn label_editing( + labelled: String, + label: &form::Value, + size: u16, +) -> Element { + let e: Element = Container::new( + row!( + form::Form::new("Label", label, view::LabelMessage::Edited) + .warning("Invalid label length, cannot be superior to 100") + .size(size) + .padding(10), + button::primary(None, "Save").on_press(view::message::LabelMessage::Confirm), + button::primary(None, "Cancel").on_press(view::message::LabelMessage::Cancel) + ) + .spacing(5) + .align_items(Alignment::Center), + ) + .into(); + e.map(move |msg| view::Message::Label(labelled.clone(), msg)) +} diff --git a/gui/src/app/view/message.rs b/gui/src/app/view/message.rs index a5eeaffa7..3d634eca0 100644 --- a/gui/src/app/view/message.rs +++ b/gui/src/app/view/message.rs @@ -9,6 +9,7 @@ pub enum Message { Close, Select(usize), SelectSub(usize, usize), + Label(String, LabelMessage), Settings(SettingsMessage), CreateSpend(CreateSpendMessage), ImportSpend(ImportSpendMessage), @@ -18,9 +19,17 @@ pub enum Message { SelectHardwareWallet(usize), } +#[derive(Debug, Clone)] +pub enum LabelMessage { + Edited(String), + Cancel, + Confirm, +} + #[derive(Debug, Clone)] pub enum CreateSpendMessage { AddRecipient, + BatchLabelEdited(String), DeleteRecipient(usize), SelectCoin(usize), RecipientEdited(usize, &'static str, String), diff --git a/gui/src/app/view/mod.rs b/gui/src/app/view/mod.rs index 8d606e24a..73887fa43 100644 --- a/gui/src/app/view/mod.rs +++ b/gui/src/app/view/mod.rs @@ -1,3 +1,4 @@ +mod label; mod message; mod warning; diff --git a/gui/src/app/view/psbt.rs b/gui/src/app/view/psbt.rs index 587d17e8e..b0bad4cef 100644 --- a/gui/src/app/view/psbt.rs +++ b/gui/src/app/view/psbt.rs @@ -9,14 +9,19 @@ use liana::{ descriptors::{LianaPolicy, PathInfo, PathSpendInfo}, miniscript::bitcoin::{ bip32::{DerivationPath, Fingerprint}, - Address, Amount, Network, Transaction, + blockdata::transaction::TxOut, + Address, Amount, Network, OutPoint, Transaction, Txid, }, }; use liana_ui::{ color, component::{ - amount::*, badge, button, card, collapse::Collapse, form, hw, separation, text::*, + amount::*, + badge, button, card, + collapse::Collapse, + form, hw, separation, + text::{self, *}, }, icon, theme, util::Collection, @@ -28,24 +33,27 @@ use crate::{ cache::Cache, error::Error, menu::Menu, - view::{dashboard, hw::hw_list_view, message::*, warning::warn}, + view::{dashboard, hw::hw_list_view, label, message::*, warning::warn}, }, daemon::model::{Coin, SpendStatus, SpendTx}, hw::HardwareWallet, }; +#[allow(clippy::too_many_arguments)] pub fn psbt_view<'a>( cache: &'a Cache, tx: &'a SpendTx, saved: bool, desc_info: &'a LianaPolicy, key_aliases: &'a HashMap, + labels_editing: &'a HashMap>, network: Network, + warning: Option<&Error>, ) -> Element<'a, Message> { dashboard( &Menu::PSBTs, cache, - None, + warning, Column::new() .spacing(20) .push( @@ -65,14 +73,15 @@ pub fn psbt_view<'a>( _ => None, }), ) - .push(spend_header(tx)) + .push(spend_header(tx, labels_editing)) .push(spend_overview_view(tx, desc_info, key_aliases)) .push(inputs_and_outputs_view( &tx.coins, &tx.psbt.unsigned_tx, network, Some(tx.change_indexes.clone()), - None, + &tx.labels, + labels_editing, )) .push(if saved { Row::new() @@ -182,9 +191,18 @@ pub fn delete_action<'a>(warning: Option<&Error>, deleted: bool) -> Element<'a, } } -pub fn spend_header<'a>(tx: &SpendTx) -> Element<'a, Message> { +pub fn spend_header<'a>( + tx: &'a SpendTx, + labels_editing: &'a HashMap>, +) -> Element<'a, Message> { + let txid = tx.psbt.unsigned_tx.txid().to_string(); Column::new() .spacing(20) + .push(if let Some(label) = labels_editing.get(&txid) { + label::label_editing(txid.clone(), label, H3_SIZE) + } else { + label::label_editable(txid.clone(), tx.labels.get(&txid), H1_SIZE) + }) .push( Column::new() .push(if tx.is_self_send() { @@ -504,8 +522,10 @@ pub fn inputs_and_outputs_view<'a>( tx: &'a Transaction, network: Network, change_indexes: Option>, - receive_indexes: Option>, + labels: &'a HashMap, + labels_editing: &'a HashMap>, ) -> Element<'a, Message> { + let change_indexes_copy = change_indexes.clone(); Column::new() .spacing(20) .push_maybe(if !coins.is_empty() { @@ -551,29 +571,9 @@ pub fn inputs_and_outputs_view<'a>( coins .iter() .fold( - Column::new().padding(20), + Column::new().spacing(10).padding(20), |col: Column<'a, Message>, coin| { - col.push( - Row::new() - .align_items(Alignment::Center) - .width(Length::Fill) - .push( - Row::new() - .width(Length::Fill) - .align_items(Alignment::Center) - .push(p2_regular(coin.outpoint.to_string()).style(color::GREY_3)) - .push( - Button::new(icon::clipboard_icon().style(color::GREY_3)) - .on_press(Message::Clipboard( - coin.outpoint.to_string(), - )) - .style( - theme::Button::TransparentBorder, - ), - ), - ) - .push(amount(&coin.amount)), - ) + col.push(input_view(coin, labels, labels_editing)) }, ) .into() @@ -584,107 +584,373 @@ pub fn inputs_and_outputs_view<'a>( } else { None }) - .push( - Container::new(Collapse::new( - move || { - Button::new( - Row::new() - .align_items(Alignment::Center) - .push( - h4_bold(format!( - "{} recipient{}", - tx.output.len(), - if tx.output.len() == 1 { "" } else { "s" } - )) - .width(Length::Fill), + .push({ + let count = tx + .output + .iter() + .enumerate() + .filter(|(i, _)| { + if let Some(indexes) = change_indexes_copy.as_ref() { + !indexes.contains(i) + } else { + true + } + }) + .count(); + if count > 0 { + Container::new(Collapse::new( + move || { + Button::new( + Row::new() + .align_items(Alignment::Center) + .push( + h4_bold(format!( + "{} payment{}", + count, + if count == 1 { "" } else { "s" } + )) + .width(Length::Fill), + ) + .push(icon::collapse_icon()), + ) + .padding(20) + .width(Length::Fill) + .style(theme::Button::TransparentBorder) + }, + move || { + Button::new( + Row::new() + .align_items(Alignment::Center) + .push( + h4_bold(format!( + "{} payment{}", + count, + if count == 1 { "" } else { "s" } + )) + .width(Length::Fill), + ) + .push(icon::collapsed_icon()), + ) + .padding(20) + .width(Length::Fill) + .style(theme::Button::TransparentBorder) + }, + move || { + tx.output + .iter() + .enumerate() + .filter(|(i, _)| { + if let Some(indexes) = change_indexes_copy.as_ref() { + !indexes.contains(i) + } else { + true + } + }) + .fold( + Column::new().padding(20), + |col: Column<'a, Message>, (i, output)| { + col.spacing(10).push(payment_view( + i, + tx.txid(), + output, + network, + labels, + labels_editing, + )) + }, ) - .push(icon::collapse_icon()), - ) + .into() + }, + )) + .style(theme::Container::Card(theme::Card::Simple)) + } else { + Container::new(h4_bold("0 payment")) .padding(20) .width(Length::Fill) - .style(theme::Button::TransparentBorder) - }, - move || { - Button::new( - Row::new() - .align_items(Alignment::Center) - .push( - h4_bold(format!( - "{} recipient{}", - tx.output.len(), - if tx.output.len() == 1 { "" } else { "s" } - )) - .width(Length::Fill), + .style(theme::Container::Card(theme::Card::Simple)) + } + }) + .push_maybe( + if change_indexes + .as_ref() + .map(|indexes| !indexes.is_empty()) + .unwrap_or(false) + { + Some( + Container::new(Collapse::new( + move || { + Button::new( + Row::new() + .align_items(Alignment::Center) + .push(h4_bold("Change").width(Length::Fill)) + .push(icon::collapse_icon()), ) - .push(icon::collapsed_icon()), - ) - .padding(20) - .width(Length::Fill) - .style(theme::Button::TransparentBorder) - }, - move || { - tx.output - .iter() - .enumerate() - .fold( - Column::new().padding(20), - |col: Column<'a, Message>, (i, output)| { - let addr = - Address::from_script(&output.script_pubkey, network).unwrap(); - col.push( - Column::new() - .width(Length::Fill) - .spacing(5) - .push( - Row::new() - .align_items(Alignment::Center) - .width(Length::Fill) - .push( - Row::new() - .align_items(Alignment::Center) - .width(Length::Fill) - .push(p2_regular(addr.to_string()).style(color::GREY_3)) - .push( - Button::new(icon::clipboard_icon().style(color::GREY_3)) - .on_press(Message::Clipboard( - addr.to_string(), - )) - .style( - theme::Button::TransparentBorder, - ), - ), - ) - .push(amount(&Amount::from_sat(output.value))), - ) - .push_maybe(if let Some(indexes) = change_indexes.as_ref() { - if indexes.contains(&i) { - Some(Container::new(text("Change")).padding(5).style( - theme::Container::Pill(theme::Pill::Success), - )) - } else { - None - } - } else { - None - }) - .push_maybe(if let Some(indexes) = receive_indexes.as_ref() { - if indexes.contains(&i) { - Some(Container::new(text("Deposit")).padding(5).style( - theme::Container::Pill(theme::Pill::Success), - )) - } else { - None - } - } else { - None - }), + .padding(20) + .width(Length::Fill) + .style(theme::Button::TransparentBorder) + }, + move || { + Button::new( + Row::new() + .align_items(Alignment::Center) + .push(h4_bold("Change").width(Length::Fill)) + .push(icon::collapsed_icon()), ) - }, + .padding(20) + .width(Length::Fill) + .style(theme::Button::TransparentBorder) + }, + move || { + tx.output + .iter() + .enumerate() + .filter(|(i, _)| change_indexes.as_ref().unwrap().contains(i)) + .fold( + Column::new().padding(20), + |col: Column<'a, Message>, (i, output)| { + col.spacing(10).push(change_view( + i, + tx.txid(), + output, + network, + labels, + labels_editing, + )) + }, + ) + .into() + }, + )) + .style(theme::Container::Card(theme::Card::Simple)), + ) + } else { + None + }, + ) + .into() +} + +fn input_view<'a>( + coin: &'a Coin, + labels: &'a HashMap, + labels_editing: &'a HashMap>, +) -> Element<'a, Message> { + let outpoint = coin.outpoint.to_string(); + let addr = coin.address.to_string(); + Column::new() + .width(Length::Fill) + .push( + Row::new() + .spacing(5) + .align_items(Alignment::Center) + .push( + Container::new(if let Some(label) = labels_editing.get(&outpoint) { + label::label_editing(outpoint.clone(), label, text::P1_SIZE) + } else { + label::label_editable( + outpoint.clone(), + labels.get(&outpoint), + text::P1_SIZE, ) - .into() - }, - )) - .style(theme::Container::Card(theme::Card::Simple)), + }) + .width(Length::Fill), + ) + .push(amount(&coin.amount)), + ) + .push( + Column::new() + .push( + Row::new() + .align_items(Alignment::Center) + .spacing(5) + .push(p1_bold("Outpoint:").style(color::GREY_3)) + .push(p2_regular(outpoint.clone()).style(color::GREY_3)) + .push( + Button::new(icon::clipboard_icon().style(color::GREY_3)) + .on_press(Message::Clipboard(coin.outpoint.to_string())) + .style(theme::Button::TransparentBorder), + ), + ) + .push( + Row::new() + .align_items(Alignment::Center) + .width(Length::Fill) + .push( + Row::new() + .align_items(Alignment::Center) + .width(Length::Fill) + .spacing(5) + .push(p1_bold("Address:").style(color::GREY_3)) + .push(p2_regular(addr.clone()).style(color::GREY_3)) + .push( + Button::new(icon::clipboard_icon().style(color::GREY_3)) + .on_press(Message::Clipboard(addr.clone())) + .style(theme::Button::TransparentBorder), + ), + ), + ) + .push_maybe(labels.get(&addr).map(|label| { + Row::new() + .align_items(Alignment::Center) + .width(Length::Fill) + .push( + Row::new() + .align_items(Alignment::Center) + .width(Length::Fill) + .spacing(5) + .push(p1_bold("Address label:").style(color::GREY_3)) + .push(p2_regular(label).style(color::GREY_3)), + ) + })), + ) + .spacing(5) + .into() +} + +fn payment_view<'a>( + i: usize, + txid: Txid, + output: &'a TxOut, + network: Network, + labels: &'a HashMap, + labels_editing: &'a HashMap>, +) -> Element<'a, Message> { + let addr = Address::from_script(&output.script_pubkey, network) + .unwrap() + .to_string(); + let outpoint = OutPoint { + txid, + vout: i as u32, + } + .to_string(); + Column::new() + .width(Length::Fill) + .spacing(5) + .push( + Row::new() + .spacing(5) + .align_items(Alignment::Center) + .push( + Container::new(if let Some(label) = labels_editing.get(&outpoint) { + label::label_editing(outpoint.clone(), label, text::P1_SIZE) + } else { + label::label_editable( + outpoint.clone(), + labels.get(&outpoint), + text::P1_SIZE, + ) + }) + .width(Length::Fill), + ) + .push(amount(&Amount::from_sat(output.value))), + ) + .push( + Column::new() + .push( + Row::new() + .align_items(Alignment::Center) + .width(Length::Fill) + .push( + Row::new() + .align_items(Alignment::Center) + .width(Length::Fill) + .spacing(5) + .push(p1_bold("Address:").style(color::GREY_3)) + .push(p2_regular(addr.clone()).style(color::GREY_3)) + .push( + Button::new(icon::clipboard_icon().style(color::GREY_3)) + .on_press(Message::Clipboard(addr.clone())) + .style(theme::Button::TransparentBorder), + ), + ), + ) + .push_maybe(labels.get(&addr).map(|label| { + Row::new() + .align_items(Alignment::Center) + .width(Length::Fill) + .push( + Row::new() + .align_items(Alignment::Center) + .width(Length::Fill) + .spacing(5) + .push(p1_bold("Address label:").style(color::GREY_3)) + .push(p2_regular(label).style(color::GREY_3)), + ) + })), + ) + .into() +} + +fn change_view<'a>( + i: usize, + txid: Txid, + output: &'a TxOut, + network: Network, + labels: &'a HashMap, + labels_editing: &'a HashMap>, +) -> Element<'a, Message> { + let addr = Address::from_script(&output.script_pubkey, network) + .unwrap() + .to_string(); + let outpoint = OutPoint { + txid, + vout: i as u32, + } + .to_string(); + Column::new() + .width(Length::Fill) + .spacing(5) + .push( + Row::new() + .spacing(5) + .align_items(Alignment::Center) + .push( + Container::new(if let Some(label) = labels_editing.get(&outpoint) { + label::label_editing(outpoint.clone(), label, text::P1_SIZE) + } else { + label::label_editable( + outpoint.clone(), + labels.get(&outpoint), + text::P1_SIZE, + ) + }) + .width(Length::Fill), + ) + .push(amount(&Amount::from_sat(output.value))), + ) + .push( + Column::new() + .push( + Row::new() + .align_items(Alignment::Center) + .width(Length::Fill) + .push( + Row::new() + .align_items(Alignment::Center) + .width(Length::Fill) + .spacing(5) + .push(p1_bold("Address:").style(color::GREY_3)) + .push(p2_regular(addr.clone()).style(color::GREY_3)) + .push( + Button::new(icon::clipboard_icon().style(color::GREY_3)) + .on_press(Message::Clipboard(addr.clone())) + .style(theme::Button::TransparentBorder), + ), + ), + ) + .push_maybe(labels.get(&addr).map(|label| { + Row::new() + .align_items(Alignment::Center) + .width(Length::Fill) + .push( + Row::new() + .align_items(Alignment::Center) + .width(Length::Fill) + .spacing(5) + .push(p1_bold("Address label:").style(color::GREY_3)) + .push(p2_regular(label).style(color::GREY_3)), + ) + })), ) .into() } diff --git a/gui/src/app/view/psbts.rs b/gui/src/app/view/psbts.rs index cf49d4883..1cff99c97 100644 --- a/gui/src/app/view/psbts.rs +++ b/gui/src/app/view/psbts.rs @@ -59,7 +59,7 @@ pub fn import_psbt_success_view<'a>() -> Element<'a, Message> { .into() } -pub fn psbts_view<'a>(spend_txs: &[SpendTx]) -> Element<'a, Message> { +pub fn psbts_view(spend_txs: &[SpendTx]) -> Element<'_, Message> { Column::new() .push( Row::new() @@ -90,7 +90,7 @@ pub fn psbts_view<'a>(spend_txs: &[SpendTx]) -> Element<'a, Message> { .into() } -fn spend_tx_list_view<'a>(i: usize, tx: &SpendTx) -> Element<'a, Message> { +fn spend_tx_list_view(i: usize, tx: &SpendTx) -> Element<'_, Message> { Container::new( Button::new( Row::new() @@ -124,6 +124,11 @@ fn spend_tx_list_view<'a>(i: usize, tx: &SpendTx) -> Element<'a, Message> { .push(icon::key_icon().style(color::GREY_3)), ) }) + .push_maybe( + tx.labels + .get(&tx.psbt.unsigned_tx.txid().to_string()) + .map(p1_bold), + ) .spacing(10) .align_items(Alignment::Center) .width(Length::Fill), @@ -134,6 +139,11 @@ fn spend_tx_list_view<'a>(i: usize, tx: &SpendTx) -> Element<'a, Message> { SpendStatus::Spent => Some(badge::spent()), _ => None, }) + .push_maybe(if tx.is_batch() { + Some(badge::batch()) + } else { + None + }) .push( Column::new() .align_items(Alignment::End) diff --git a/gui/src/app/view/receive.rs b/gui/src/app/view/receive.rs index e0f5cb2c6..fcc5ae69b 100644 --- a/gui/src/app/view/receive.rs +++ b/gui/src/app/view/receive.rs @@ -1,3 +1,5 @@ +use std::collections::HashMap; + use iced::{ widget::{ qr_code::{self, QRCode}, @@ -10,16 +12,23 @@ use liana::miniscript::bitcoin; use liana_ui::{ color, - component::{button, card, text::*}, + component::{ + button, card, form, + text::{self, *}, + }, icon, theme, widget::*, }; +use crate::app::view::label; + use super::message::Message; pub fn receive<'a>( addresses: &'a [bitcoin::Address], qr: Option<&'a qr_code::State>, + labels: &'a HashMap, + labels_editing: &'a HashMap>, ) -> Element<'a, Message> { Column::new() .push( @@ -38,34 +47,54 @@ pub fn receive<'a>( .push(addresses.iter().rev().fold( Column::new().spacing(10).width(Length::Fill), |col, address| { + let addr = address.to_string(); col.push( card::simple( - Row::new() + Column::new() + .push(if let Some(label) = labels_editing.get(&addr) { + label::label_editing(addr.clone(), label, text::P1_SIZE) + } else { + label::label_editable( + addr.clone(), + labels.get(&addr), + text::P1_SIZE, + ) + }) .push( - Container::new( - scrollable( - Column::new() - .push(Space::with_height(Length::Fixed(10.0))) - .push( - p2_regular(address.to_string()) - .small() - .style(color::GREY_3), + Row::new() + .push( + Container::new( + scrollable( + Column::new() + .push(Space::with_height( + Length::Fixed(10.0), + )) + .push( + p2_regular(addr) + .small() + .style(color::GREY_3), + ) + // Space between the address and the scrollbar + .push(Space::with_height( + Length::Fixed(10.0), + )), ) - // Space between the address and the scrollbar - .push(Space::with_height(Length::Fixed(10.0))), + .horizontal_scroll( + scrollable::Properties::new() + .scroller_width(5), + ), + ) + .width(Length::Fill), ) - .horizontal_scroll( - scrollable::Properties::new().scroller_width(5), - ), - ) - .width(Length::Fill), - ) - .push( - Button::new(icon::clipboard_icon().style(color::GREY_3)) - .on_press(Message::Clipboard(address.to_string())) - .style(theme::Button::TransparentBorder), - ) - .align_items(Alignment::Center), + .push( + Button::new( + icon::clipboard_icon().style(color::GREY_3), + ) + .on_press(Message::Clipboard(address.to_string())) + .style(theme::Button::TransparentBorder), + ) + .align_items(Alignment::Center), + ), ) .padding(20), ) diff --git a/gui/src/app/view/spend/mod.rs b/gui/src/app/view/spend/mod.rs index 1a4cdee82..8c34ad5e2 100644 --- a/gui/src/app/view/spend/mod.rs +++ b/gui/src/app/view/spend/mod.rs @@ -29,29 +29,33 @@ use crate::{ daemon::model::{remaining_sequence, Coin, SpendTx}, }; +#[allow(clippy::too_many_arguments)] pub fn spend_view<'a>( cache: &'a Cache, tx: &'a SpendTx, saved: bool, desc_info: &'a LianaPolicy, key_aliases: &'a HashMap, + labels_editing: &'a HashMap>, network: Network, + warning: Option<&Error>, ) -> Element<'a, Message> { dashboard( &Menu::CreateSpendTx, cache, - None, + warning, Column::new() .spacing(20) .push(Container::new(h3("Send")).width(Length::Fill)) - .push(psbt::spend_header(tx)) + .push(psbt::spend_header(tx, labels_editing)) .push(psbt::spend_overview_view(tx, desc_info, key_aliases)) .push(psbt::inputs_and_outputs_view( &tx.coins, &tx.psbt.unsigned_tx, network, Some(tx.change_indexes.clone()), - None, + &tx.labels, + labels_editing, )) .push(if saved { Row::new() @@ -89,6 +93,8 @@ pub fn create_spend_tx<'a>( duplicate: bool, timelock: u16, coins: &[(Coin, bool)], + coins_labels: &'a HashMap, + batch_label: &form::Value, amount_left: Option<&Amount>, feerate: &form::Value, error: Option<&Error>, @@ -104,6 +110,18 @@ pub fn create_spend_tx<'a>( } else { "Send" })) + .push_maybe(if recipients.len() > 1 { + Some( + form::Form::new("Batch label", batch_label, |s| { + Message::CreateSpend(CreateSpendMessage::BatchLabelEdited(s)) + }) + .warning("Invalid label length, cannot be superior to 100") + .size(30) + .padding(10), + ) + } else { + None + }) .push( Column::new() .push(Column::with_children(recipients).spacing(10)) @@ -112,7 +130,7 @@ pub fn create_spend_tx<'a>( .push_maybe(if duplicate { Some( Container::new( - text("Two recipient addresses are the same") + text("Two payment addresses are the same") .style(color::RED), ) .padding(10), @@ -125,7 +143,7 @@ pub fn create_spend_tx<'a>( None } else { Some( - button::secondary(Some(icon::plus_icon()), "Add recipient") + button::secondary(Some(icon::plus_icon()), "Add payment") .on_press(Message::CreateSpend( CreateSpendMessage::AddRecipient, )), @@ -201,6 +219,7 @@ pub fn create_spend_tx<'a>( col.push(coin_list_view( i, coin, + coins_labels, timelock, cache.blockheight as u32, *selected, @@ -246,6 +265,7 @@ pub fn recipient_view<'a>( index: usize, address: &form::Value, amount: &form::Value, + label: &form::Value, ) -> Element<'a, CreateSpendMessage> { Container::new( Column::new() @@ -263,10 +283,10 @@ pub fn recipient_view<'a>( .align_items(Alignment::Start) .spacing(10) .push( - Container::new(p1_bold("Pay to")) + Container::new(p1_bold("Address")) .align_x(alignment::Horizontal::Right) .padding(10) - .width(Length::Fixed(80.0)), + .width(Length::Fixed(110.0)), ) .push( form::Form::new_trimmed("Address", address, move |msg| { @@ -277,6 +297,25 @@ pub fn recipient_view<'a>( .padding(10), ), ) + .push( + Row::new() + .align_items(Alignment::Start) + .spacing(10) + .push( + Container::new(p1_bold("Description")) + .align_x(alignment::Horizontal::Right) + .padding(10) + .width(Length::Fixed(110.0)), + ) + .push( + form::Form::new("Payment label", label, move |msg| { + CreateSpendMessage::RecipientEdited(index, "label", msg) + }) + .warning("Label length is too long (> 100 char)") + .size(20) + .padding(10), + ), + ) .push( Row::new() .align_items(Alignment::Start) @@ -285,7 +324,7 @@ pub fn recipient_view<'a>( Container::new(p1_bold("Amount")) .padding(10) .align_x(alignment::Horizontal::Right) - .width(Length::Fixed(80.0)), + .width(Length::Fixed(110.0)), ) .push( form::Form::new_trimmed("0.001 (in BTC)", amount, move |msg| { @@ -308,6 +347,7 @@ pub fn recipient_view<'a>( fn coin_list_view<'a>( i: usize, coin: &Coin, + coins_labels: &'a HashMap, timelock: u16, blockheight: u32, selected: bool, @@ -318,6 +358,13 @@ fn coin_list_view<'a>( .push(checkbox("", selected, move |_| { Message::CreateSpend(CreateSpendMessage::SelectCoin(i)) })) + .push( + if let Some(label) = coins_labels.get(&coin.outpoint.to_string()) { + Container::new(p1_bold(label)).width(Length::Fill) + } else { + Container::new(p1_bold("")).width(Length::Fill) + }, + ) .push(if coin.spend_info.is_some() { badge::spent() } else if coin.block_height.is_none() { diff --git a/gui/src/app/view/transactions.rs b/gui/src/app/view/transactions.rs index 204910803..838afe44e 100644 --- a/gui/src/app/view/transactions.rs +++ b/gui/src/app/view/transactions.rs @@ -1,10 +1,11 @@ use chrono::NaiveDateTime; +use std::collections::HashMap; use iced::{alignment, Alignment, Length}; use liana_ui::{ color, - component::{amount::*, badge, card, text::*}, + component::{amount::*, badge, card, form, text::*}, icon, theme, util::Collection, widget::*, @@ -15,7 +16,7 @@ use crate::{ cache::Cache, error::Error, menu::Menu, - view::{dashboard, message::Message}, + view::{dashboard, label, message::Message}, }, daemon::model::HistoryTransaction, }; @@ -24,8 +25,8 @@ pub const HISTORY_EVENT_PAGE_SIZE: u64 = 20; pub fn transactions_view<'a>( cache: &'a Cache, - pending_txs: &[HistoryTransaction], - txs: &Vec, + pending_txs: &'a [HistoryTransaction], + txs: &'a Vec, warning: Option<&'a Error>, ) -> Element<'a, Message> { dashboard( @@ -83,7 +84,7 @@ pub fn transactions_view<'a>( ) } -fn tx_list_view<'a>(i: usize, tx: &HistoryTransaction) -> Element<'a, Message> { +fn tx_list_view(i: usize, tx: &HistoryTransaction) -> Element<'_, Message> { Container::new( Button::new( Row::new() @@ -96,23 +97,36 @@ fn tx_list_view<'a>(i: usize, tx: &HistoryTransaction) -> Element<'a, Message> { } else { badge::spend() }) - .push(if let Some(t) = tx.time { - Container::new( - text(format!( - "{}", - NaiveDateTime::from_timestamp_opt(t as i64, 0) - .unwrap() - .format("%b. %d, %Y - %T"), - )) - .small(), - ) - } else { - badge::unconfirmed() - }) + .push( + Column::new() + .push_maybe(tx.labels.get(&tx.tx.txid().to_string()).map(p1_bold)) + .push_maybe(tx.time.map(|t| { + Container::new( + text(format!( + "{}", + NaiveDateTime::from_timestamp_opt(t as i64, 0) + .unwrap() + .format("%b. %d, %Y - %T"), + )) + .style(color::GREY_3) + .small(), + ) + })), + ) .spacing(10) .align_items(Alignment::Center) .width(Length::Fill), ) + .push_maybe(if tx.time.is_none() { + Some(badge::unconfirmed()) + } else { + None + }) + .push_maybe(if tx.is_batch() { + Some(badge::batch()) + } else { + None + }) .push(if tx.is_external() { Row::new() .spacing(5) @@ -142,8 +156,10 @@ fn tx_list_view<'a>(i: usize, tx: &HistoryTransaction) -> Element<'a, Message> { pub fn tx_view<'a>( cache: &'a Cache, tx: &'a HistoryTransaction, + labels_editing: &'a HashMap>, warning: Option<&'a Error>, ) -> Element<'a, Message> { + let txid = tx.tx.txid().to_string(); dashboard( &Menu::Transactions, cache, @@ -156,6 +172,11 @@ pub fn tx_view<'a>( } else { Container::new(h3("Outgoing transaction")).width(Length::Fill) }) + .push(if let Some(label) = labels_editing.get(&txid) { + label::label_editing(txid.clone(), label, H3_SIZE) + } else { + label::label_editable(txid.clone(), tx.labels.get(&txid), H1_SIZE) + }) .push( Column::new().spacing(20).push( Column::new() @@ -202,10 +223,10 @@ pub fn tx_view<'a>( .push( Row::new() .align_items(Alignment::Center) - .push(Container::new(text(format!("{}", tx.tx.txid())).small())) + .push(Container::new(text(txid.clone()).small())) .push( Button::new(icon::clipboard_icon()) - .on_press(Message::Clipboard(tx.tx.txid().to_string())) + .on_press(Message::Clipboard(txid.clone())) .style(theme::Button::TransparentBorder), ) .width(Length::Shrink), @@ -222,11 +243,8 @@ pub fn tx_view<'a>( } else { Some(tx.change_indexes.clone()) }, - if tx.is_external() { - Some(tx.change_indexes.clone()) - } else { - None - }, + &tx.labels, + labels_editing, )) .spacing(20), ) diff --git a/gui/src/daemon/client/mod.rs b/gui/src/daemon/client/mod.rs index 6f4e11963..185dfb2a4 100644 --- a/gui/src/daemon/client/mod.rs +++ b/gui/src/daemon/client/mod.rs @@ -1,5 +1,6 @@ -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::fmt::Debug; +use std::iter::FromIterator; use serde::de::DeserializeOwned; use serde::{Deserialize, Serialize}; @@ -10,6 +11,7 @@ pub mod error; pub mod jsonrpc; use liana::{ + commands::LabelItem, config::Config, miniscript::bitcoin::{address, psbt::Psbt, Address, OutPoint, Txid}, }; @@ -145,6 +147,22 @@ impl Daemon for Lianad { )?; Ok(res.psbt) } + + fn get_labels( + &self, + items: &HashSet, + ) -> Result, DaemonError> { + let items = items.iter().map(|a| a.to_string()).collect::>(); + let res: GetLabelsResult = self.call("getlabels", Some(vec![items]))?; + Ok(res.labels) + } + + fn update_labels(&self, items: &HashMap) -> Result<(), DaemonError> { + let labels: HashMap = + HashMap::from_iter(items.iter().map(|(a, l)| (a.to_string(), l.clone()))); + let _res: serde_json::value::Value = self.call("updatelabels", Some(vec![labels]))?; + Ok(()) + } } #[derive(Debug, Clone, Deserialize, Serialize)] diff --git a/gui/src/daemon/embedded.rs b/gui/src/daemon/embedded.rs index 686bd966e..5103932cf 100644 --- a/gui/src/daemon/embedded.rs +++ b/gui/src/daemon/embedded.rs @@ -1,7 +1,8 @@ -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use super::{model::*, Daemon, DaemonError}; use liana::{ + commands::LabelItem, config::Config, miniscript::bitcoin::{address, psbt::Psbt, Address, OutPoint, Txid}, DaemonControl, DaemonHandle, @@ -59,7 +60,7 @@ impl Daemon for EmbeddedDaemon { } fn list_coins(&self) -> Result { - Ok(self.control()?.list_coins()) + Ok(self.control()?.list_coins(&[], &[])) } fn list_spend_txs(&self) -> Result { @@ -126,4 +127,16 @@ impl Daemon for EmbeddedDaemon { .map_err(|e| DaemonError::Unexpected(e.to_string())) .map(|res| res.psbt) } + + fn get_labels( + &self, + items: &HashSet, + ) -> Result, DaemonError> { + Ok(self.handle.control.get_labels(items).labels) + } + + fn update_labels(&self, items: &HashMap) -> Result<(), DaemonError> { + self.handle.control.update_labels(items); + Ok(()) + } } diff --git a/gui/src/daemon/mod.rs b/gui/src/daemon/mod.rs index 1c4e975fa..6d5b1743a 100644 --- a/gui/src/daemon/mod.rs +++ b/gui/src/daemon/mod.rs @@ -2,11 +2,12 @@ pub mod client; pub mod embedded; pub mod model; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::fmt::Debug; use std::io::ErrorKind; use liana::{ + commands::LabelItem, config::Config, miniscript::bitcoin::{address, psbt::Psbt, Address, OutPoint, Txid}, StartupError, @@ -75,6 +76,11 @@ pub trait Daemon: Debug { sequence: Option, ) -> Result; fn list_txs(&self, txid: &[Txid]) -> Result; + fn get_labels( + &self, + labels: &HashSet, + ) -> Result, DaemonError>; + fn update_labels(&self, labels: &HashMap) -> Result<(), DaemonError>; fn list_spend_transactions(&self) -> Result, DaemonError> { let info = self.get_info()?; @@ -103,8 +109,10 @@ pub trait Daemon: Debug { coins, sigs, info.descriptors.main.max_sat_vbytes(), + info.network, )) } + load_labels(self, &mut spend_txs)?; spend_txs.sort_by(|a, b| { if a.status == b.status { // last updated first @@ -123,9 +131,10 @@ pub trait Daemon: Debug { end: u32, limit: u64, ) -> Result, DaemonError> { + let info = self.get_info()?; let coins = self.list_coins()?.coins; let txs = self.list_confirmed_txs(start, end, limit)?.transactions; - Ok(txs + let mut txs = txs .into_iter() .map(|tx| { let mut tx_coins = Vec::new(); @@ -142,12 +151,22 @@ pub trait Daemon: Debug { tx_coins.push(coin.clone()); } } - model::HistoryTransaction::new(tx.tx, tx.height, tx.time, tx_coins, change_indexes) + model::HistoryTransaction::new( + tx.tx, + tx.height, + tx.time, + tx_coins, + change_indexes, + info.network, + ) }) - .collect()) + .collect(); + load_labels(self, &mut txs)?; + Ok(txs) } fn list_pending_txs(&self) -> Result, DaemonError> { + let info = self.get_info()?; let coins = self.list_coins()?.coins; let mut txids: Vec = Vec::new(); for coin in &coins { @@ -163,7 +182,7 @@ pub trait Daemon: Debug { } let txs = self.list_txs(&txids)?.transactions; - Ok(txs + let mut txs = txs .into_iter() .map(|tx| { let mut tx_coins = Vec::new(); @@ -180,8 +199,38 @@ pub trait Daemon: Debug { tx_coins.push(coin.clone()); } } - model::HistoryTransaction::new(tx.tx, tx.height, tx.time, tx_coins, change_indexes) + model::HistoryTransaction::new( + tx.tx, + tx.height, + tx.time, + tx_coins, + change_indexes, + info.network, + ) }) - .collect()) + .collect(); + + load_labels(self, &mut txs)?; + Ok(txs) + } +} + +fn load_labels( + daemon: &D, + targets: &mut Vec, +) -> Result<(), DaemonError> { + if targets.is_empty() { + return Ok(()); + } + let mut items = HashSet::::new(); + for target in &*targets { + for item in target.labelled() { + items.insert(item); + } + } + let labels = daemon.get_labels(&items)?; + for target in targets { + target.load_labels(&labels); } + Ok(()) } diff --git a/gui/src/daemon/model.rs b/gui/src/daemon/model.rs index b36d9e457..ae5c36e78 100644 --- a/gui/src/daemon/model.rs +++ b/gui/src/daemon/model.rs @@ -1,12 +1,15 @@ -use std::collections::HashSet; +use std::collections::{HashMap, HashSet}; pub use liana::{ commands::{ - CreateSpendResult, GetAddressResult, GetInfoResult, ListCoinsEntry, ListCoinsResult, - ListSpendEntry, ListSpendResult, ListTransactionsResult, TransactionInfo, + CreateSpendResult, GetAddressResult, GetInfoResult, GetLabelsResult, LabelItem, + ListCoinsEntry, ListCoinsResult, ListSpendEntry, ListSpendResult, ListTransactionsResult, + TransactionInfo, }, descriptors::{PartialSpendInfo, PathSpendInfo}, - miniscript::bitcoin::{bip32::Fingerprint, psbt::Psbt, Amount, Transaction}, + miniscript::bitcoin::{ + bip32::Fingerprint, psbt::Psbt, Address, Amount, Network, OutPoint, Transaction, Txid, + }, }; pub type Coin = ListCoinsEntry; @@ -25,7 +28,9 @@ pub fn remaining_sequence(coin: &Coin, blockheight: u32, timelock: u16) -> u32 { #[derive(Debug, Clone)] pub struct SpendTx { + pub network: Network, pub coins: Vec, + pub labels: HashMap, pub psbt: Psbt, pub change_indexes: Vec, pub spend_amount: Amount, @@ -53,6 +58,7 @@ impl SpendTx { coins: Vec, sigs: PartialSpendInfo, max_sat_vbytes: usize, + network: Network, ) -> Self { let mut change_indexes = Vec::new(); let (change_amount, spend_amount) = psbt.unsigned_tx.output.iter().enumerate().fold( @@ -85,6 +91,7 @@ impl SpendTx { } Self { + labels: HashMap::new(), updated_at, coins, psbt, @@ -94,6 +101,7 @@ impl SpendTx { max_sat_vbytes, status, sigs, + network, } } @@ -135,10 +143,48 @@ impl SpendTx { self.psbt.unsigned_tx.vsize() + (self.max_sat_vbytes * self.psbt.inputs.len()); self.fee_amount.to_sat() / max_tx_vbytes as u64 } + + pub fn is_batch(&self) -> bool { + self.psbt + .unsigned_tx + .output + .iter() + .enumerate() + .filter(|(i, _)| !self.change_indexes.contains(i)) + .count() + > 1 + } +} + +impl Labelled for SpendTx { + fn labels(&mut self) -> &mut HashMap { + &mut self.labels + } + fn labelled(&self) -> Vec { + let mut items = Vec::new(); + let txid = self.psbt.unsigned_tx.txid(); + items.push(LabelItem::Txid(txid)); + for coin in &self.coins { + items.push(LabelItem::Address(coin.address.clone())); + items.push(LabelItem::OutPoint(coin.outpoint)); + } + for (vout, output) in self.psbt.unsigned_tx.output.iter().enumerate() { + items.push(LabelItem::OutPoint(OutPoint { + txid, + vout: vout as u32, + })); + items.push(LabelItem::Address( + Address::from_script(&output.script_pubkey, self.network).unwrap(), + )); + } + items + } } #[derive(Debug, Clone)] pub struct HistoryTransaction { + pub network: Network, + pub labels: HashMap, pub coins: Vec, pub change_indexes: Vec, pub tx: Transaction, @@ -156,6 +202,7 @@ impl HistoryTransaction { time: Option, coins: Vec, change_indexes: Vec, + network: Network, ) -> Self { let (incoming_amount, outgoing_amount) = tx.output.iter().enumerate().fold( (Amount::from_sat(0), Amount::from_sat(0)), @@ -180,6 +227,7 @@ impl HistoryTransaction { }; Self { + labels: HashMap::new(), tx, coins, change_indexes, @@ -188,6 +236,7 @@ impl HistoryTransaction { fee_amount, height, time, + network, } } @@ -198,4 +247,54 @@ impl HistoryTransaction { pub fn is_self_send(&self) -> bool { !self.coins.is_empty() && self.outgoing_amount == Amount::from_sat(0) } + + pub fn is_batch(&self) -> bool { + self.tx + .output + .iter() + .enumerate() + .filter(|(i, _)| !self.change_indexes.contains(i)) + .count() + > 1 + } +} + +impl Labelled for HistoryTransaction { + fn labels(&mut self) -> &mut HashMap { + &mut self.labels + } + fn labelled(&self) -> Vec { + let mut items = Vec::new(); + let txid = self.tx.txid(); + items.push(LabelItem::Txid(txid)); + for coin in &self.coins { + items.push(LabelItem::Address(coin.address.clone())); + items.push(LabelItem::OutPoint(coin.outpoint)); + } + for (vout, output) in self.tx.output.iter().enumerate() { + items.push(LabelItem::OutPoint(OutPoint { + txid, + vout: vout as u32, + })); + items.push(LabelItem::Address( + Address::from_script(&output.script_pubkey, self.network).unwrap(), + )); + } + items + } +} + +pub trait Labelled { + fn labelled(&self) -> Vec; + fn labels(&mut self) -> &mut HashMap; + fn load_labels(&mut self, new_labels: &HashMap) { + let items = self.labelled(); + let labels = self.labels(); + for item in items { + let item_str = item.to_string(); + if let Some(l) = new_labels.get(&item_str) { + labels.insert(item_str, l.to_string()); + } + } + } } diff --git a/gui/ui/src/component/badge.rs b/gui/ui/src/component/badge.rs index 199dc165e..e64f70f1f 100644 --- a/gui/ui/src/component/badge.rs +++ b/gui/ui/src/component/badge.rs @@ -100,6 +100,19 @@ pub fn unconfirmed<'a, T: 'a>() -> Container<'a, T> { ) } +pub fn batch<'a, T: 'a>() -> Container<'a, T> { + Container::new( + tooltip::Tooltip::new( + Container::new(text::p2_regular(" Batch ")) + .padding(10) + .style(theme::Container::Pill(theme::Pill::Simple)), + "This transaction contains multiple payments", + tooltip::Position::Top, + ) + .style(theme::Container::Card(theme::Card::Simple)), + ) +} + pub fn deprecated<'a, T: 'a>() -> Container<'a, T> { Container::new( tooltip::Tooltip::new( diff --git a/gui/ui/src/component/event.rs b/gui/ui/src/component/event.rs index b97e4d78e..2544996ec 100644 --- a/gui/ui/src/component/event.rs +++ b/gui/ui/src/component/event.rs @@ -1,6 +1,8 @@ use crate::{ + color, component::{amount, badge, text}, theme, + util::Collection, widget::*, }; use bitcoin::Amount; @@ -9,14 +11,19 @@ use iced::{ Alignment, Length, }; -pub fn unconfirmed_outgoing_event<'a, T: Clone + 'a>(amount: &Amount, msg: T) -> Container<'a, T> { +pub fn unconfirmed_outgoing_event<'a, T: Clone + 'a>( + label: Option>>, + amount: &Amount, + msg: T, +) -> Container<'a, T> { Container::new( button( row!( - row!(badge::spend(), badge::unconfirmed()) + row!(badge::spend(), Column::new().push_maybe(label),) .spacing(10) .align_items(Alignment::Center) .width(Length::Fill), + badge::unconfirmed(), row!(text::p1_regular("-"), amount::amount(amount)) .spacing(5) .align_items(Alignment::Center), @@ -32,6 +39,7 @@ pub fn unconfirmed_outgoing_event<'a, T: Clone + 'a>(amount: &Amount, msg: T) -> } pub fn confirmed_outgoing_event<'a, T: Clone + 'a>( + label: Option>>, date: chrono::NaiveDateTime, amount: &Amount, msg: T, @@ -41,7 +49,10 @@ pub fn confirmed_outgoing_event<'a, T: Clone + 'a>( row!( row!( badge::spend(), - text::p2_regular(date.format("%b. %d, %Y - %T").to_string()) + Column::new().push_maybe(label).push( + text::p2_regular(date.format("%b. %d, %Y - %T").to_string()) + .style(color::GREY_3) + ) ) .spacing(10) .align_items(Alignment::Center) @@ -60,14 +71,19 @@ pub fn confirmed_outgoing_event<'a, T: Clone + 'a>( .style(theme::Container::Card(theme::Card::Simple)) } -pub fn unconfirmed_incoming_event<'a, T: Clone + 'a>(amount: &Amount, msg: T) -> Container<'a, T> { +pub fn unconfirmed_incoming_event<'a, T: Clone + 'a>( + label: Option>>, + amount: &Amount, + msg: T, +) -> Container<'a, T> { Container::new( button( row!( - row!(badge::receive(), badge::unconfirmed()) + row!(badge::receive(), Column::new().push_maybe(label)) .spacing(10) .align_items(Alignment::Center) .width(Length::Fill), + badge::unconfirmed(), row!(text::p1_regular("+"), amount::amount(amount)) .spacing(5) .align_items(Alignment::Center), @@ -83,6 +99,7 @@ pub fn unconfirmed_incoming_event<'a, T: Clone + 'a>(amount: &Amount, msg: T) -> } pub fn confirmed_incoming_event<'a, T: Clone + 'a>( + label: Option>>, date: chrono::NaiveDateTime, amount: &Amount, msg: T, @@ -92,7 +109,10 @@ pub fn confirmed_incoming_event<'a, T: Clone + 'a>( row!( row!( badge::receive(), - text::p2_regular(date.format("%b. %d, %Y - %T").to_string()) + Column::new().push_maybe(label).push( + text::p2_regular(date.format("%b. %d, %Y - %T").to_string()) + .style(color::GREY_3) + ) ) .spacing(10) .align_items(Alignment::Center)