From 196b8cc3e91fcbbe9f17a7e0945010dc38dae4fb Mon Sep 17 00:00:00 2001 From: edouard Date: Wed, 19 Apr 2023 11:50:24 +0200 Subject: [PATCH] gui: new transactions panel close #437 --- gui/src/app/menu.rs | 1 + gui/src/app/mod.rs | 6 +- gui/src/app/state/mod.rs | 4 +- gui/src/app/state/transactions.rs | 171 ++++++++++++++++++++++++++ gui/src/app/view/home.rs | 78 +----------- gui/src/app/view/mod.rs | 30 +++++ gui/src/app/view/psbts.rs | 1 + gui/src/app/view/transactions.rs | 191 ++++++++++++++++++++++++++++++ 8 files changed, 404 insertions(+), 78 deletions(-) create mode 100644 gui/src/app/state/transactions.rs create mode 100644 gui/src/app/view/transactions.rs diff --git a/gui/src/app/menu.rs b/gui/src/app/menu.rs index 08995e63..8d0f1857 100644 --- a/gui/src/app/menu.rs +++ b/gui/src/app/menu.rs @@ -3,6 +3,7 @@ pub enum Menu { Home, Receive, PSBTs, + Transactions, Settings, Coins, CreateSpendTx, diff --git a/gui/src/app/mod.rs b/gui/src/app/mod.rs index 0c7085b0..d9553fa8 100644 --- a/gui/src/app/mod.rs +++ b/gui/src/app/mod.rs @@ -24,7 +24,10 @@ use liana_ui::widget::Element; pub use config::Config; pub use message::Message; -use state::{CoinsPanel, CreateSpendPanel, Home, PsbtsPanel, ReceivePanel, RecoveryPanel, State}; +use state::{ + CoinsPanel, CreateSpendPanel, Home, PsbtsPanel, ReceivePanel, RecoveryPanel, State, + TransactionsPanel, +}; use crate::{ app::{cache::Cache, error::Error, menu::Menu, wallet::Wallet}, @@ -81,6 +84,7 @@ impl App { ) .into(), menu::Menu::Receive => ReceivePanel::default().into(), + menu::Menu::Transactions => TransactionsPanel::new().into(), menu::Menu::PSBTs => PsbtsPanel::new(self.wallet.clone(), &self.cache.spend_txs).into(), menu::Menu::CreateSpendTx => CreateSpendPanel::new( self.wallet.clone(), diff --git a/gui/src/app/state/mod.rs b/gui/src/app/state/mod.rs index ebbabfae..0bb43a10 100644 --- a/gui/src/app/state/mod.rs +++ b/gui/src/app/state/mod.rs @@ -3,6 +3,7 @@ mod psbts; mod recovery; mod settings; mod spend; +mod transactions; use std::convert::TryInto; use std::sync::Arc; @@ -23,6 +24,7 @@ pub use psbts::PsbtsPanel; pub use recovery::RecoveryPanel; pub use settings::SettingsState; pub use spend::CreateSpendPanel; +pub use transactions::TransactionsPanel; pub trait State { fn view<'a>(&'a self, cache: &'a Cache) -> Element<'a, view::Message>; @@ -91,7 +93,7 @@ impl State for Home { return view::modal( false, self.warning.as_ref(), - view::home::event_view(cache, event), + view::transactions::tx_view(cache, event), None::>, ); } diff --git a/gui/src/app/state/transactions.rs b/gui/src/app/state/transactions.rs new file mode 100644 index 00000000..a61740c1 --- /dev/null +++ b/gui/src/app/state/transactions.rs @@ -0,0 +1,171 @@ +use std::convert::TryInto; +use std::sync::Arc; +use std::time::{SystemTime, UNIX_EPOCH}; + +use iced::Command; +use liana_ui::widget::*; + +use crate::app::{cache::Cache, error::Error, menu::Menu, message::Message, view, State}; + +use crate::daemon::{model::HistoryTransaction, Daemon}; + +#[derive(Default)] +pub struct TransactionsPanel { + pending_txs: Vec, + txs: Vec, + selected_tx: Option, + warning: Option, +} + +impl TransactionsPanel { + pub fn new() -> Self { + Self { + selected_tx: None, + txs: Vec::new(), + pending_txs: Vec::new(), + warning: None, + } + } +} + +impl State for TransactionsPanel { + fn view<'a>(&'a self, cache: &'a Cache) -> Element<'a, view::Message> { + if let Some(i) = self.selected_tx { + let tx = if i < self.pending_txs.len() { + &self.pending_txs[i] + } else { + &self.txs[i - self.pending_txs.len()] + }; + return view::modal( + false, + self.warning.as_ref(), + view::transactions::tx_view(cache, tx), + None::>, + ); + } + view::dashboard( + &Menu::Transactions, + cache, + None, + view::transactions::transactions_view(&self.pending_txs, &self.txs), + ) + } + + fn update( + &mut self, + daemon: Arc, + _cache: &Cache, + message: Message, + ) -> Command { + match message { + Message::HistoryTransactions(res) => match res { + Err(e) => self.warning = Some(e), + Ok(txs) => { + self.warning = None; + for tx in txs { + if !self.txs.iter().any(|other| other.tx == tx.tx) { + self.txs.push(tx); + } + } + } + }, + Message::PendingTransactions(res) => match res { + Err(e) => self.warning = Some(e), + Ok(txs) => { + self.warning = None; + for tx in txs { + if !self.pending_txs.iter().any(|other| other.tx == tx.tx) { + self.pending_txs.push(tx); + } + } + } + }, + Message::View(view::Message::Close) => { + self.selected_tx = None; + } + Message::View(view::Message::Select(i)) => { + self.selected_tx = Some(i); + } + Message::View(view::Message::Next) => { + if let Some(last) = self.txs.last() { + let daemon = daemon.clone(); + let last_tx_date = last.time.unwrap(); + return Command::perform( + async move { + let mut limit = view::home::HISTORY_EVENT_PAGE_SIZE; + let mut txs = daemon.list_history_txs(0_u32, last_tx_date, limit)?; + + // because gethistory cursor is inclusive and use blocktime + // multiple txs can occur in the same block. + // If there is more tx in the same block that the + // HISTORY_EVENT_PAGE_SIZE they can not be retrieved by changing + // the cursor value (blocktime) but by increasing the limit. + // + // 1. Check if the txs retrieved have all the same blocktime + let blocktime = if let Some(tx) = txs.first() { + tx.time + } else { + return Ok(txs); + }; + + // 2. Retrieve a larger batch of tx with the same cursor but + // a larger limit. + while !txs.iter().any(|evt| evt.time != blocktime) + && txs.len() as u64 == limit + { + // increments of the equivalent of one page more. + limit += view::home::HISTORY_EVENT_PAGE_SIZE; + txs = daemon.list_history_txs(0, last_tx_date, limit)?; + } + Ok(txs) + }, + Message::HistoryTransactions, + ); + } + } + _ => {} + }; + Command::none() + } + + fn load(&self, daemon: Arc) -> Command { + let daemon1 = daemon.clone(); + let daemon2 = daemon.clone(); + let daemon3 = daemon.clone(); + let now: u32 = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs() + .try_into() + .unwrap(); + Command::batch(vec![ + Command::perform( + async move { daemon3.list_pending_txs().map_err(|e| e.into()) }, + Message::PendingTransactions, + ), + Command::perform( + async move { + daemon1 + .list_history_txs(0, now, view::home::HISTORY_EVENT_PAGE_SIZE) + .map_err(|e| e.into()) + }, + Message::HistoryTransactions, + ), + Command::perform( + async move { + daemon2 + .list_coins() + .map(|res| res.coins) + .map_err(|e| e.into()) + }, + Message::Coins, + ), + ]) + } +} + +impl From for Box { + fn from(s: TransactionsPanel) -> Box { + Box::new(s) + } +} diff --git a/gui/src/app/view/home.rs b/gui/src/app/view/home.rs index c546bb99..438e5966 100644 --- a/gui/src/app/view/home.rs +++ b/gui/src/app/view/home.rs @@ -5,17 +5,14 @@ use iced::{alignment, Alignment, Length}; use liana::miniscript::bitcoin; use liana_ui::{ color, - component::{badge, card, text::*}, + component::{badge, text::*}, icon, theme, util::Collection, widget::*, }; use crate::{ - app::{ - cache::Cache, - view::{message::Message, util::*}, - }, + app::view::{message::Message, util::*}, daemon::model::HistoryTransaction, }; @@ -158,74 +155,3 @@ fn event_list_view<'a>(i: usize, event: &HistoryTransaction) -> Element<'a, Mess .style(theme::Container::Card(theme::Card::Simple)) .into() } - -pub fn event_view<'a>(cache: &Cache, event: &'a HistoryTransaction) -> Element<'a, Message> { - Column::new() - .push( - Row::new() - .push(if event.is_external() { - badge::receive() - } else { - badge::spend() - }) - .spacing(10) - .align_items(Alignment::Center), - ) - .push(if event.is_external() { - amount_with_size(&event.incoming_amount, 50) - } else { - amount_with_size(&event.outgoing_amount, 50) - }) - .push_maybe( - event - .fee_amount - .map(|fee| Row::new().push(text("Miner Fee: ")).push(amount(&fee))), - ) - .push(card::simple( - Column::new() - .push_maybe(event.time.map(|t| { - let date = NaiveDateTime::from_timestamp_opt(t as i64, 0).unwrap(); - Row::new() - .width(Length::Fill) - .push(Container::new(text("Date:").bold()).width(Length::Fill)) - .push(Container::new(text(format!("{}", date))).width(Length::Shrink)) - })) - .push( - Row::new() - .width(Length::Fill) - .align_items(Alignment::Center) - .push(Container::new(text("Txid:").bold()).width(Length::Fill)) - .push( - Row::new() - .align_items(Alignment::Center) - .push(Container::new(text(format!("{}", event.tx.txid())).small())) - .push( - Button::new(icon::clipboard_icon()) - .on_press(Message::Clipboard(event.tx.txid().to_string())) - .style(theme::Button::TransparentBorder), - ) - .width(Length::Shrink), - ), - ) - .spacing(5), - )) - .push(super::spend::detail::inputs_and_outputs_view( - &event.coins, - &event.tx, - cache.network, - if event.is_external() { - None - } else { - Some(event.change_indexes.clone()) - }, - if event.is_external() { - Some(event.change_indexes.clone()) - } else { - None - }, - )) - .align_items(Alignment::Center) - .spacing(20) - .max_width(800) - .into() -} diff --git a/gui/src/app/view/mod.rs b/gui/src/app/view/mod.rs index 19d92876..f4c99cd4 100644 --- a/gui/src/app/view/mod.rs +++ b/gui/src/app/view/mod.rs @@ -10,6 +10,7 @@ pub mod receive; pub mod recovery; pub mod settings; pub mod spend; +pub mod transactions; pub use message::*; use warning::warn; @@ -41,6 +42,34 @@ pub fn sidebar<'a>(menu: &Menu, cache: &'a Cache) -> Container<'a, Message> { .width(iced::Length::Fill) }; + let transactions_button = if *menu == Menu::Transactions { + Button::new( + row!( + history_icon().width(Length::Units(20)), + text("Transactions") + ) + .spacing(10) + .padding(10) + .align_items(iced::Alignment::Center), + ) + .style(theme::Button::Menu(true)) + .on_press(Message::Reload) + .width(iced::Length::Fill) + } else { + Button::new( + row!( + history_icon().width(Length::Units(20)), + text("Transactions") + ) + .spacing(10) + .padding(10) + .align_items(iced::Alignment::Center), + ) + .style(theme::Button::Menu(false)) + .on_press(Message::Menu(Menu::Transactions)) + .width(iced::Length::Fill) + }; + let coins_button = if *menu == Menu::Coins { Button::new( Container::new( @@ -266,6 +295,7 @@ pub fn sidebar<'a>(menu: &Menu, cache: &'a Cache) -> Container<'a, Message> { .push(receive_button) .push(coins_button) .push(psbt_button) + .push(transactions_button) .spacing(15) .height(Length::Fill), ) diff --git a/gui/src/app/view/psbts.rs b/gui/src/app/view/psbts.rs index 53cc6cac..ea97d11b 100644 --- a/gui/src/app/view/psbts.rs +++ b/gui/src/app/view/psbts.rs @@ -63,6 +63,7 @@ pub fn psbts_view<'a>(spend_txs: &[SpendTx]) -> Element<'a, Message> { Column::new() .push( Row::new() + .align_items(Alignment::Center) .spacing(10) .push(Container::new(h3("PSBTs")).width(Length::Fill)) .push( diff --git a/gui/src/app/view/transactions.rs b/gui/src/app/view/transactions.rs new file mode 100644 index 00000000..adac858c --- /dev/null +++ b/gui/src/app/view/transactions.rs @@ -0,0 +1,191 @@ +use chrono::NaiveDateTime; + +use iced::{alignment, Alignment, Length}; + +use liana_ui::{ + component::{badge, card, text::*}, + icon, theme, + util::Collection, + widget::*, +}; + +use crate::{ + app::{ + cache::Cache, + view::{message::Message, util::*}, + }, + daemon::model::HistoryTransaction, +}; + +pub const HISTORY_EVENT_PAGE_SIZE: u64 = 20; + +pub fn transactions_view<'a>( + pending_txs: &[HistoryTransaction], + txs: &Vec, +) -> Element<'a, Message> { + Column::new() + .push(Container::new(h3("Transactions")).width(Length::Fill)) + .push( + Column::new() + .spacing(10) + .push( + pending_txs + .iter() + .enumerate() + .fold(Column::new().spacing(10), |col, (i, tx)| { + col.push(tx_list_view(i, tx)) + }), + ) + .push( + txs.iter() + .enumerate() + .fold(Column::new().spacing(10), |col, (i, tx)| { + col.push(tx_list_view(i + pending_txs.len(), tx)) + }), + ) + .push_maybe( + if txs.len() % HISTORY_EVENT_PAGE_SIZE as usize == 0 && !txs.is_empty() { + Some( + Container::new( + Button::new( + text("See more") + .width(Length::Fill) + .horizontal_alignment(alignment::Horizontal::Center), + ) + .width(Length::Fill) + .padding(15) + .style(theme::Button::TransparentBorder) + .on_press(Message::Next), + ) + .width(Length::Fill) + .style(theme::Container::Card(theme::Card::Simple)), + ) + } else { + None + }, + ), + ) + .align_items(Alignment::Center) + .spacing(20) + .into() +} + +fn tx_list_view<'a>(i: usize, tx: &HistoryTransaction) -> Element<'a, Message> { + Container::new( + Button::new( + Row::new() + .push( + Row::new() + .push(if tx.is_external() { + badge::receive() + } else { + badge::spend() + }) + .push(if let Some(t) = tx.time { + Container::new( + text(format!( + "{}", + NaiveDateTime::from_timestamp_opt(t as i64, 0).unwrap(), + )) + .small(), + ) + } else { + badge::unconfirmed() + }) + .spacing(10) + .align_items(Alignment::Center) + .width(Length::Fill), + ) + .push(if tx.is_external() { + Row::new() + .spacing(5) + .push(text("+")) + .push(amount(&tx.incoming_amount)) + .align_items(Alignment::Center) + } else { + Row::new() + .spacing(5) + .push(text("-")) + .push(amount(&tx.outgoing_amount)) + .align_items(Alignment::Center) + }) + .align_items(Alignment::Center) + .spacing(20), + ) + .padding(10) + .on_press(Message::Select(i)) + .style(theme::Button::TransparentBorder), + ) + .style(theme::Container::Card(theme::Card::Simple)) + .into() +} + +pub fn tx_view<'a>(cache: &Cache, tx: &'a HistoryTransaction) -> Element<'a, Message> { + Column::new() + .push( + Row::new() + .push(if tx.is_external() { + badge::receive() + } else { + badge::spend() + }) + .spacing(10) + .align_items(Alignment::Center), + ) + .push(if tx.is_external() { + amount_with_size(&tx.incoming_amount, 50) + } else { + amount_with_size(&tx.outgoing_amount, 50) + }) + .push_maybe( + tx.fee_amount + .map(|fee| Row::new().push(text("Miner Fee: ")).push(amount(&fee))), + ) + .push(card::simple( + Column::new() + .push_maybe(tx.time.map(|t| { + let date = NaiveDateTime::from_timestamp_opt(t as i64, 0).unwrap(); + Row::new() + .width(Length::Fill) + .push(Container::new(text("Date:").bold()).width(Length::Fill)) + .push(Container::new(text(format!("{}", date))).width(Length::Shrink)) + })) + .push( + Row::new() + .width(Length::Fill) + .align_items(Alignment::Center) + .push(Container::new(text("Txid:").bold()).width(Length::Fill)) + .push( + Row::new() + .align_items(Alignment::Center) + .push(Container::new(text(format!("{}", tx.tx.txid())).small())) + .push( + Button::new(icon::clipboard_icon()) + .on_press(Message::Clipboard(tx.tx.txid().to_string())) + .style(theme::Button::TransparentBorder), + ) + .width(Length::Shrink), + ), + ) + .spacing(5), + )) + .push(super::spend::detail::inputs_and_outputs_view( + &tx.coins, + &tx.tx, + cache.network, + if tx.is_external() { + None + } else { + Some(tx.change_indexes.clone()) + }, + if tx.is_external() { + Some(tx.change_indexes.clone()) + } else { + None + }, + )) + .align_items(Alignment::Center) + .spacing(20) + .max_width(800) + .into() +}