diff --git a/liana-gui/src/app/message.rs b/liana-gui/src/app/message.rs index 6abcd912..b019b44b 100644 --- a/liana-gui/src/app/message.rs +++ b/liana-gui/src/app/message.rs @@ -26,6 +26,11 @@ pub enum Message { LoadWallet(Wallet), Info(Result), ReceiveAddress(Result<(Address, ChildNumber), Error>), + /// Revealed addresses. The second element contains the start index used for the request. + RevealedAddresses( + Result, + Option, // start_index + ), Coins(Result, Error>), /// When we want both coins and tip height together. CoinsTipHeight(Result, Error>, Result), diff --git a/liana-gui/src/app/state/receive.rs b/liana-gui/src/app/state/receive.rs index 08e25b9c..2135f5eb 100644 --- a/liana-gui/src/app/state/receive.rs +++ b/liana-gui/src/app/state/receive.rs @@ -28,6 +28,8 @@ use crate::daemon::{ Daemon, }; +const PREV_ADDRESSES_PAGE_SIZE: usize = 20; + pub enum Modal { VerifyAddress(VerifyAddressModal), ShowQrCode(ShowQrCodeModal), @@ -41,6 +43,12 @@ pub struct Addresses { labels: HashMap, } +impl Addresses { + pub fn is_empty(&self) -> bool { + self.list.is_empty() && self.derivation_indexes.is_empty() && self.labels.is_empty() + } +} + impl Labelled for Addresses { fn labelled(&self) -> Vec { self.list @@ -57,9 +65,14 @@ pub struct ReceivePanel { data_dir: LianaDirectory, wallet: Arc, addresses: Addresses, + prev_addresses: Addresses, + prev_continue_from: Option, + show_prev_addresses: bool, + selected: HashSet
, labels_edited: LabelsEdited, modal: Modal, warning: Option, + processing: bool, } impl ReceivePanel { @@ -68,9 +81,34 @@ impl ReceivePanel { data_dir, wallet, addresses: Addresses::default(), + prev_addresses: Addresses::default(), + prev_continue_from: None, + show_prev_addresses: false, + selected: HashSet::new(), labels_edited: LabelsEdited::default(), modal: Modal::None, warning: None, + processing: false, + } + } + + pub fn address(&self, i: usize) -> Option<&Address> { + if i < self.addresses.list.len() { + self.addresses.list.get(i) + } else { + // i >= self.addresses.list.len() + self.prev_addresses.list.get(i - self.addresses.list.len()) + } + } + + pub fn derivation_index(&self, i: usize) -> Option<&ChildNumber> { + if i < self.addresses.list.len() { + self.addresses.derivation_indexes.get(i) + } else { + // i >= self.addresses.list.len() + self.prev_addresses + .derivation_indexes + .get(i - self.addresses.list.len()) } } } @@ -84,7 +122,13 @@ impl State for ReceivePanel { view::receive::receive( &self.addresses.list, &self.addresses.labels, + &self.prev_addresses.list, + &self.prev_addresses.labels, + self.show_prev_addresses, + &self.selected, self.labels_edited.cache(), + self.prev_continue_from.is_none(), + self.processing, ), ); @@ -118,7 +162,9 @@ impl State for ReceivePanel { match self.labels_edited.update( daemon, message, - std::iter::once(&mut self.addresses).map(|a| a as &mut dyn LabelsLoader), + std::iter::once(&mut self.addresses) + .chain(std::iter::once(&mut self.prev_addresses)) + .map(|a| a as &mut dyn LabelsLoader), ) { Ok(cmd) => cmd, Err(e) => { @@ -143,16 +189,16 @@ impl State for ReceivePanel { Task::none() } Message::View(view::Message::Select(i)) => { + let (address, index) = ( + self.address(i).expect("Must be present"), + self.derivation_index(i).expect("Must be present"), + ); self.modal = Modal::VerifyAddress(VerifyAddressModal::new( self.data_dir.clone(), self.wallet.clone(), cache.network, - self.addresses.list.get(i).expect("Must be present").clone(), - *self - .addresses - .derivation_indexes - .get(i) - .expect("Must be present"), + address.clone(), + *index, )); Task::none() } @@ -169,11 +215,74 @@ impl State for ReceivePanel { Message::ReceiveAddress, ) } + Message::View(view::Message::ToggleShowPreviousAddresses) => { + self.show_prev_addresses = !self.show_prev_addresses; + Task::none() + } + Message::View(view::Message::SelectAddress(addr)) => { + if self.selected.contains(&addr) { + self.selected.remove(&addr); + } else { + self.selected.insert(addr); + } + Task::none() + } + Message::RevealedAddresses(res, start_index) => { + self.processing = false; + match res { + Ok(revealed) => { + self.warning = None; + // Make sure these results are for the expected continuation. + // The start index can only be None for the first request when there are no prev addresses saved. + if self.prev_continue_from == start_index + && (start_index.is_some() || self.prev_addresses.is_empty()) + { + for entry in revealed.addresses.iter() { + self.prev_addresses.list.push(entry.address.clone()); + self.prev_addresses.derivation_indexes.push(entry.index); + if let Some(label) = &entry.label { + self.prev_addresses.labels.insert( + LabelItem::from(entry.address.clone()).to_string(), + label.clone(), + ); + } + } + self.prev_continue_from = revealed.continue_from; + } + } + Err(e) => { + self.warning = Some(e); + } + }; + Task::none() + } + Message::View(view::Message::Next) => { + if self.prev_continue_from.is_some() { + self.processing = true; + let start_index = self.prev_continue_from; + Task::perform( + async move { + ( + daemon + .list_revealed_addresses( + false, + true, + PREV_ADDRESSES_PAGE_SIZE, + start_index, + ) + .await + .map_err(|e| e.into()), + start_index, + ) + }, + |(res, start_index)| Message::RevealedAddresses(res, start_index), + ) + } else { + Task::none() + } + } Message::View(view::Message::ShowQrCode(i)) => { - if let (Some(address), Some(index)) = ( - self.addresses.list.get(i), - self.addresses.derivation_indexes.get(i), - ) { + if let (Some(address), Some(index)) = (self.address(i), self.derivation_index(i)) { if let Some(modal) = ShowQrCodeModal::new(address, *index) { self.modal = Modal::ShowQrCode(modal); } @@ -192,12 +301,20 @@ impl State for ReceivePanel { fn reload( &mut self, - _daemon: Arc, + daemon: Arc, wallet: Arc, ) -> Task { - self.wallet = wallet; - self.addresses = Addresses::default(); - Task::none() + let data_dir = self.data_dir.clone(); + *self = Self::new(data_dir, wallet); + Task::perform( + async move { + daemon + .list_revealed_addresses(false, true, PREV_ADDRESSES_PAGE_SIZE, None) + .await + .map_err(|e| e.into()) + }, + |res| Message::RevealedAddresses(res, None), + ) } } @@ -348,13 +465,24 @@ mod tests { Address::from_str("tb1qkldgvljmjpxrjq2ev5qxe8dvhn0dph9q85pwtfkjeanmwdue2akqj4twxj") .unwrap() .assume_checked(); - let daemon = Daemon::new(vec![( - Some(json!({"method": "getnewaddress", "params": Option::::None})), - Ok(json!(GetAddressResult::new( - addr.clone(), - ChildNumber::from_normal_idx(0).unwrap() - ))), - )]); + let daemon = Daemon::new(vec![ + ( + Some( + json!({"method": "listrevealedaddresses", "params": [false, true, 20, Option::::None]}), + ), + Ok(json!(ListRevealedAddressesResult { + addresses: vec![], + continue_from: None, + })), + ), + ( + Some(json!({"method": "getnewaddress", "params": Option::::None})), + Ok(json!(GetAddressResult::new( + addr.clone(), + ChildNumber::from_normal_idx(0).unwrap() + ))), + ), + ]); let wallet = Arc::new(Wallet::new(LianaDescriptor::from_str(DESC).unwrap())); let sandbox: Sandbox = Sandbox::new(ReceivePanel::new( LianaDirectory::new(PathBuf::new()), diff --git a/liana-gui/src/app/view/message.rs b/liana-gui/src/app/view/message.rs index af822046..4d85450f 100644 --- a/liana-gui/src/app/view/message.rs +++ b/liana-gui/src/app/view/message.rs @@ -1,5 +1,5 @@ use crate::{app::menu::Menu, export::ImportExportMessage, node::bitcoind::RpcAuthType}; -use liana::miniscript::bitcoin::{bip32::Fingerprint, OutPoint}; +use liana::miniscript::bitcoin::{bip32::Fingerprint, Address, OutPoint}; pub trait Close { fn close() -> Self; @@ -15,6 +15,8 @@ pub enum Message { SelectPayment(OutPoint), Label(Vec, LabelMessage), NextReceiveAddress, + ToggleShowPreviousAddresses, + SelectAddress(Address), Settings(SettingsMessage), CreateSpend(CreateSpendMessage), ImportSpend(ImportSpendMessage), diff --git a/liana-gui/src/app/view/receive.rs b/liana-gui/src/app/view/receive.rs index d517789d..8b76314a 100644 --- a/liana-gui/src/app/view/receive.rs +++ b/liana-gui/src/app/view/receive.rs @@ -1,9 +1,10 @@ use std::collections::{HashMap, HashSet}; use iced::{ + alignment::Horizontal, widget::{ qr_code::{self, QRCode}, - scrollable, Space, + scrollable, Button, Space, }, Alignment, Length, }; @@ -89,11 +90,19 @@ fn address_card<'a>( ) } +#[allow(clippy::too_many_arguments)] pub fn receive<'a>( addresses: &'a [bitcoin::Address], labels: &'a HashMap, + prev_addresses: &'a [bitcoin::Address], + prev_labels: &'a HashMap, + show_prev_addresses: bool, + selected: &'a HashSet, labels_editing: &'a HashMap>, + is_last_page: bool, + processing: bool, ) -> Element<'a, Message> { + let mut addresses_count = 0; // for counting number of new addresses generated Column::new() .push( Row::new() @@ -104,15 +113,143 @@ pub fn receive<'a>( .on_press(Message::NextReceiveAddress), ), ) - .push(p1_bold("New and never used reception addresses")) + .push(text("Always generate a new address for each deposit.")) .push( Row::new() .spacing(10) .push(addresses.iter().enumerate().rev().fold( + // iterate starting from most recently generated Column::new().spacing(10).width(Length::Fill), - |col, (i, address)| col.push(address_card(i, address, labels, labels_editing)), + |col, (i, address)| { + addresses_count += 1; + col.push(address_card(i, address, labels, labels_editing)) + }, )), ) + .push( + Container::new( + Button::new( + Row::new() + .align_y(Alignment::Center) + .push( + p1_bold("Previously generated addresses still awaiting deposit") + .width(Length::Fill), + ) + .push(if show_prev_addresses { + icon::collapsed_icon() + } else { + icon::collapse_icon() + }), + ) + .on_press(Message::ToggleShowPreviousAddresses) + .padding(20) + .width(Length::Fill) + .style(theme::button::transparent_border), + ) + .style(theme::card::simple), + ) + .push_maybe(show_prev_addresses.then_some(Row::new().spacing(10).push( + prev_addresses.iter().enumerate().fold( + // prev addresses are already ordered in descending order + Column::new().spacing(10).width(Length::Fill), + |col, (i, address)| { + col.push(if !selected.contains(address) { + Button::new( + Row::new() + .spacing(30) + .push( + Container::new( + scrollable( + Column::new() + .push(Space::with_height(Length::Fixed(10.0))) + .push( + p2_regular(address) + .small() + .style(theme::text::secondary), + ) + // Space between the address and the scrollbar + .push(Space::with_height(Length::Fixed(10.0))), + ) + .direction( + scrollable::Direction::Horizontal( + scrollable::Scrollbar::new() + .width(2) + .scroller_width(2), + ), + ), + ) + .padding(10) + .width(Length::FillPortion(13)), + ) + .push( + Container::new( + scrollable( + Column::new() + .push(Space::with_height(Length::Fixed(10.0))) + .push( + text( + prev_labels + .get(&address.to_string()) + .cloned() + .unwrap_or_default(), + ) + .small() + .style(theme::text::secondary), + ) + // Space between the label and the scrollbar + .push(Space::with_height(Length::Fixed(10.0))), + ) + .direction( + scrollable::Direction::Horizontal( + scrollable::Scrollbar::new() + .width(2) + .scroller_width(2), + ), + ), + ) + .padding(10) + .width(Length::FillPortion(7)), + ) + .align_y(Alignment::Center), + ) + .on_press(Message::SelectAddress(address.clone())) + .style(theme::button::secondary) + } else { + // Continue the row index from those of generated addresses above. + Button::new(address_card( + addresses_count + i, + address, + prev_labels, + labels_editing, + )) + .padding(0) // so that button & card borders match + .on_press(Message::SelectAddress(address.clone())) + .style(theme::button::transparent_border) + }) + }, + ), + ))) + .push_maybe( + (!is_last_page && show_prev_addresses).then_some( + Container::new( + Button::new( + text(if processing { + "Fetching ..." + } else { + "See more" + }) + .width(Length::Fill) + .align_x(Horizontal::Center), + ) + .width(Length::Fill) + .padding(15) + .style(theme::button::transparent_border) + .on_press_maybe((!processing).then_some(Message::Next)), + ) + .width(Length::Fill) + .style(theme::card::simple), + ), + ) .spacing(20) .into() }