diff --git a/assets/icons/close.svg b/assets/icons/close.svg new file mode 100644 index 0000000..8c71124 --- /dev/null +++ b/assets/icons/close.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/app.rs b/src/app.rs index d64c3a3..9bcddab 100644 --- a/src/app.rs +++ b/src/app.rs @@ -3,7 +3,7 @@ use std::{collections::BTreeMap, sync::Arc}; use fedimint_core::config::FederationId; use iced::{ futures::StreamExt, - widget::{column, container, row, scrollable}, + widget::{column, container, row, scrollable, stack}, Element, Length, Task, }; use nip_55::nip_46::{Nip46OverNip55ServerStream, Nip46RequestApproval}; @@ -14,7 +14,7 @@ use crate::{ fedimint::{FederationView, Wallet}, nostr::{NostrModuleMessage, NostrState}, routes::{self, bitcoin_wallet, unlock, Loadable, Route, RouteName}, - ui_components::sidebar, + ui_components::{sidebar, Toast, ToastManager, ToastStatus}, }; #[derive(Debug, Clone)] @@ -39,16 +39,21 @@ pub enum Message { ), ApproveFirstIncomingNip46Request, RejectFirstIncomingNip46Request, + + AddToast(Toast), + CloseToast(usize), } pub struct App { pub page: Route, + toasts: Vec, } impl Default for App { fn default() -> Self { Self { page: Route::new_locked(), + toasts: Vec::new(), } } } @@ -97,10 +102,18 @@ impl App { Task::none() } Message::CopyStringToClipboard(text) => { - // TODO: Display a toast stating whether the copy succeeded or failed. - let _ = arboard::Clipboard::new().map(|mut clipboard| clipboard.set_text(text)); - - Task::none() + match arboard::Clipboard::new().map(|mut clipboard| clipboard.set_text(text)) { + Ok(_) => Task::done(Message::AddToast(Toast { + title: "Copied to clipboard".to_string(), + body: "The text has been copied to your clipboard.".to_string(), + status: ToastStatus::Good, + })), + Err(e) => Task::done(Message::AddToast(Toast { + title: "Failed to copy to clipboard".to_string(), + body: e.to_string(), + status: ToastStatus::Bad, + })), + } } Message::IncomingNip46Request(data) => { if let Some(connected_state) = self.page.get_connected_state_mut() { @@ -127,6 +140,16 @@ impl App { } } + Task::none() + } + Message::AddToast(toast) => { + self.toasts.push(toast); + + Task::none() + } + Message::CloseToast(index) => { + self.toasts.remove(index); + Task::none() } } @@ -143,7 +166,11 @@ impl App { content = Element::new(row![sidebar(self), content]); }; - container(content).center_y(Length::Fill).into() + let content: Element<_, _, _> = container(content).center_y(Length::Fill).into(); + let toast_manager: Element<_, _, _> = + ToastManager::new(&self.toasts, Message::CloseToast).into(); + + stack![content, toast_manager].into() } pub fn subscription(&self) -> iced::Subscription { diff --git a/src/routes/bitcoin_wallet/send.rs b/src/routes/bitcoin_wallet/send.rs index 3ca312a..855f19f 100644 --- a/src/routes/bitcoin_wallet/send.rs +++ b/src/routes/bitcoin_wallet/send.rs @@ -11,7 +11,7 @@ use crate::{ app, fedimint::{FederationView, Wallet}, routes::{self, container, Loadable, RouteName}, - ui_components::{icon_button, PaletteColor, SvgIcon}, + ui_components::{icon_button, PaletteColor, SvgIcon, Toast, ToastStatus}, }; use super::{ConnectedState, SubrouteName}; @@ -25,7 +25,7 @@ pub enum Message { // Payment actions. PayInvoice(Bolt11Invoice, FederationId), PayInvoiceSucceeded(Bolt11Invoice), - PayInvoiceFailed(Bolt11Invoice), + PayInvoiceFailed((Bolt11Invoice, Arc)), UpdateFederationViews(BTreeMap), } @@ -79,9 +79,11 @@ impl Page { Ok(()) => app::Message::Routes(routes::Message::BitcoinWalletPage( super::Message::Send(Message::PayInvoiceSucceeded(invoice)), )), - // TODO: Display error to user. Probably a toast. - Err(_err) => app::Message::Routes(routes::Message::BitcoinWalletPage( - super::Message::Send(Message::PayInvoiceFailed(invoice)), + Err(err) => app::Message::Routes(routes::Message::BitcoinWalletPage( + super::Message::Send(Message::PayInvoiceFailed(( + invoice, + Arc::from(err), + ))), )), } }) @@ -93,16 +95,24 @@ impl Page { self.loadable_invoice_payment_or = Some(Loadable::Loaded(())); } - Task::none() + Task::done(app::Message::AddToast(Toast { + title: "Payment succeeded".to_string(), + body: "Invoice was successfully paid".to_string(), + status: ToastStatus::Good, + })) } - Message::PayInvoiceFailed(invoice) => { + Message::PayInvoiceFailed((invoice, err)) => { let invoice_or = Bolt11Invoice::from_str(&self.lightning_invoice_input).ok(); if Some(invoice) == invoice_or { self.loadable_invoice_payment_or = Some(Loadable::Failed); } - Task::none() + Task::done(app::Message::AddToast(Toast { + title: "Payment failed".to_string(), + body: format!("Failed to pay invoice: {err}"), + status: ToastStatus::Bad, + })) } Message::UpdateFederationViews(federation_views) => { self.federation_combo_box_selected_federation = self diff --git a/src/routes/nostr_keypairs.rs b/src/routes/nostr_keypairs.rs index c28341f..cdf4d7f 100644 --- a/src/routes/nostr_keypairs.rs +++ b/src/routes/nostr_keypairs.rs @@ -12,7 +12,7 @@ use secp256k1::Secp256k1; use crate::{ app, - ui_components::{icon_button, PaletteColor, SvgIcon}, + ui_components::{icon_button, PaletteColor, SvgIcon, Toast, ToastStatus}, util::truncate_text, }; @@ -33,12 +33,18 @@ pub struct Page { impl Page { pub fn update(&mut self, msg: Message) -> Task { match msg { - Message::SaveKeypair(keypair) => { - // TODO: Surface this error to the UI. - let _ = self.connected_state.db.save_keypair(&keypair); - - Task::none() - } + Message::SaveKeypair(keypair) => match self.connected_state.db.save_keypair(&keypair) { + Ok(()) => Task::done(app::Message::AddToast(Toast { + title: "Saved keypair".to_string(), + body: "The keypair was successfully saved.".to_string(), + status: ToastStatus::Good, + })), + Err(_err) => Task::done(app::Message::AddToast(Toast { + title: "Failed to save keypair".to_string(), + body: "The keypair was not saved.".to_string(), + status: ToastStatus::Bad, + })), + }, Message::SaveKeypairNsecInputChanged(new_nsec) => { if let Subroute::Add(Add { nsec, keypair_or, .. @@ -55,10 +61,18 @@ impl Page { Task::none() } Message::DeleteKeypair { public_key } => { - // TODO: Surface this error to the UI. - _ = self.connected_state.db.remove_keypair(&public_key); - - Task::none() + match self.connected_state.db.remove_keypair(&public_key) { + Ok(()) => Task::done(app::Message::AddToast(Toast { + title: "Deleted keypair".to_string(), + body: "The keypair was successfully deleted.".to_string(), + status: ToastStatus::Good, + })), + Err(_err) => Task::done(app::Message::AddToast(Toast { + title: "Failed to delete keypair".to_string(), + body: "The keypair was not deleted.".to_string(), + status: ToastStatus::Bad, + })), + } } } } diff --git a/src/routes/nostr_relays.rs b/src/routes/nostr_relays.rs index 1eac1df..f6d7ce6 100644 --- a/src/routes/nostr_relays.rs +++ b/src/routes/nostr_relays.rs @@ -10,7 +10,7 @@ use nostr_sdk::Url; use crate::{ app, nostr::NostrModuleMessage, - ui_components::{icon_button, PaletteColor, SvgIcon}, + ui_components::{icon_button, PaletteColor, SvgIcon, Toast, ToastStatus}, util::truncate_text, }; @@ -32,13 +32,24 @@ impl Page { pub fn update(&mut self, msg: Message) -> Task { match msg { Message::SaveRelay { websocket_url } => { - // TODO: Surface this error to the UI. - let _ = self.connected_state.db.save_relay(websocket_url.clone()); + let task = match self.connected_state.db.save_relay(websocket_url.clone()) { + Ok(()) => Task::done(app::Message::AddToast(Toast { + title: "Saved relay".to_string(), + body: "The relay was successfully saved.".to_string(), + status: ToastStatus::Good, + })), + Err(_err) => Task::done(app::Message::AddToast(Toast { + title: "Failed to save relay".to_string(), + body: "The relay was not saved.".to_string(), + status: ToastStatus::Bad, + })), + }; + self.connected_state .nostr_module .update(NostrModuleMessage::ConnectToRelay(websocket_url)); - Task::none() + task } Message::SaveRelayWebsocketUrlInputChanged(new_websocket_url) => { if let Subroute::Add(Add { websocket_url }) = &mut self.subroute { @@ -48,13 +59,24 @@ impl Page { Task::none() } Message::DeleteRelay { websocket_url } => { - // TODO: Surface this error to the UI. - _ = self.connected_state.db.remove_relay(&websocket_url); + let task = match self.connected_state.db.remove_relay(&websocket_url) { + Ok(()) => Task::done(app::Message::AddToast(Toast { + title: "Deleted relay".to_string(), + body: "The relay was successfully deleted.".to_string(), + status: ToastStatus::Good, + })), + Err(_err) => Task::done(app::Message::AddToast(Toast { + title: "Failed to delete relay".to_string(), + body: "The relay was not deleted.".to_string(), + status: ToastStatus::Bad, + })), + }; + self.connected_state .nostr_module .update(NostrModuleMessage::DisconnectFromRelay(websocket_url)); - Task::none() + task } } } diff --git a/src/routes/settings.rs b/src/routes/settings.rs index 2c78359..34dea84 100644 --- a/src/routes/settings.rs +++ b/src/routes/settings.rs @@ -5,7 +5,7 @@ use iced::{ use crate::{ app, - ui_components::{icon_button, PaletteColor, SvgIcon}, + ui_components::{icon_button, PaletteColor, SvgIcon, Toast, ToastStatus}, }; use super::{container, ConnectedState, RouteName}; @@ -56,20 +56,24 @@ impl Page { current_password, new_password, } => { - if self + match self .connected_state .db .change_password(¤t_password, &new_password) - .is_ok() { - // TODO: Show success in UI. - - Task::done(app::Message::Routes(super::Message::Navigate( + Ok(()) => Task::done(app::Message::Routes(super::Message::Navigate( RouteName::Settings(SubrouteName::Main), ))) - } else { - // TODO: Show error in UI. - Task::none() + .chain(Task::done(app::Message::AddToast(Toast { + title: "Password changed".to_string(), + body: "Your password has been changed.".to_string(), + status: ToastStatus::Good, + }))), + Err(_err) => Task::done(app::Message::AddToast(Toast { + title: "Failed to change password".to_string(), + body: "Check that you entered your current password correctly.".to_string(), + status: ToastStatus::Bad, + })), } } } diff --git a/src/ui_components/button.rs b/src/ui_components/button.rs index d99e48f..8808bf3 100644 --- a/src/ui_components/button.rs +++ b/src/ui_components/button.rs @@ -14,6 +14,49 @@ use crate::{ use super::{PaletteColor, SvgIcon}; +pub fn mini_icon_button_no_text<'a>( + icon: SvgIcon, + palette_color: PaletteColor, +) -> Button<'a, app::Message, Theme> { + // TODO: Find a way to darken the icon color when the button is disabled. + let svg = icon.view(16.0, 16.0, Color::WHITE); + + Button::new(svg) + .style(move |theme, status| { + let border = Border { + color: iced::Color::WHITE, + width: 0.0, + radius: (8.0).into(), + }; + + let mut bg_color = palette_color.to_color(theme); + + if palette_color == PaletteColor::Background { + bg_color = darken(bg_color, 0.05); + } + + bg_color = match status { + Status::Active => bg_color, + Status::Hovered => lighten(bg_color, 0.05), + Status::Pressed => lighten(bg_color, 0.1), + Status::Disabled => darken(bg_color, 0.5), + }; + + let mut text_color = Color::WHITE; + if status == Status::Disabled { + text_color = darken(text_color, 0.5); + } + + button::Style { + background: Some(bg_color.into()), + text_color, + border, + shadow: Shadow::default(), + } + }) + .padding(6) +} + pub fn icon_button( text_str: &str, icon: SvgIcon, diff --git a/src/ui_components/icon.rs b/src/ui_components/icon.rs index 79c0879..09efada 100644 --- a/src/ui_components/icon.rs +++ b/src/ui_components/icon.rs @@ -17,6 +17,7 @@ pub enum SvgIcon { Casino, ChevronRight, Circle, + Close, ContentCopy, CurrencyBitcoin, Delete, @@ -54,6 +55,7 @@ impl SvgIcon { Self::Casino => icon_handle!("casino.svg"), Self::ChevronRight => icon_handle!("chevron_right.svg"), Self::Circle => Svg::new(Handle::from_memory(CIRCLE_SVG_BYTES)), + Self::Close => icon_handle!("close.svg"), Self::ContentCopy => icon_handle!("content_copy.svg"), Self::CurrencyBitcoin => icon_handle!("currency_bitcoin.svg"), Self::Delete => icon_handle!("delete.svg"), diff --git a/src/ui_components/mod.rs b/src/ui_components/mod.rs index cc54ecc..f492f00 100644 --- a/src/ui_components/mod.rs +++ b/src/ui_components/mod.rs @@ -8,6 +8,9 @@ pub use icon::*; mod sidebar; pub use sidebar::*; +mod toast; +pub use toast::*; + // TODO: Remove this allow unused. #[allow(unused)] #[derive(PartialEq, Eq)] diff --git a/src/ui_components/toast.rs b/src/ui_components/toast.rs new file mode 100644 index 0000000..b216a2b --- /dev/null +++ b/src/ui_components/toast.rs @@ -0,0 +1,303 @@ +use std::time::{Duration, Instant}; + +use crate::app; +use crate::util::lighten; +use iced::advanced::layout::{self, Layout, Limits}; +use iced::advanced::renderer; +use iced::advanced::widget::{self, Tree}; +use iced::advanced::{Clipboard, Shell, Widget}; +use iced::event::{self, Event}; +use iced::widget::{column, container, horizontal_space, row, text}; +use iced::Border; +use iced::{mouse, Color, Font}; +use iced::{window, Shadow}; +use iced::{Alignment, Element, Length, Rectangle, Renderer, Size, Theme, Vector}; + +use super::{mini_icon_button_no_text, PaletteColor, SvgIcon}; + +const DEFAULT_TIMEOUT: u64 = 5; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ToastStatus { + Neutral, + Good, + Bad, +} + +impl ToastStatus { + fn get_style(self, theme: &Theme) -> container::Style { + let gray = lighten(theme.palette().background, 0.1); + + let border_color = match self { + Self::Neutral => gray, + Self::Good => theme.palette().success, + Self::Bad => theme.palette().danger, + }; + + container::Style { + background: Some(gray.into()), + text_color: Color::WHITE.into(), + border: Border { + color: border_color, + width: 1., + radius: (4.).into(), + }, + shadow: Shadow { + color: Color::from_rgba8(0, 0, 0, 0.25), + offset: Vector::new(-2., -2.), + blur_radius: 4., + }, + } + } +} + +#[derive(Debug, Clone)] +pub struct Toast { + pub title: String, + pub body: String, + pub status: ToastStatus, +} + +pub struct ToastManager<'a> { + toasts: Vec>, + timeout_secs: u64, + on_close: Box app::Message + 'a>, +} + +impl<'a> ToastManager<'a> { + pub fn new(toasts: &'a [Toast], on_close: impl Fn(usize) -> app::Message + 'a) -> Self { + let toasts = toasts + .iter() + .enumerate() + .map(|(index, toast)| { + let close_button = + mini_icon_button_no_text(SvgIcon::Close, PaletteColor::Background); + + container(column![container(column![ + row![ + text(toast.title.as_str()).font(Font { + family: iced::font::Family::default(), + weight: iced::font::Weight::Bold, + stretch: iced::font::Stretch::Normal, + style: iced::font::Style::Normal, + }), + horizontal_space(), + close_button.on_press((on_close)(index)) + ] + .align_y(Alignment::Center), + text(toast.body.as_str()) + ]) + .width(Length::Fill) + .padding(16) + .style(|theme| toast.status.get_style(theme))]) + .max_width(256) + .into() + }) + .collect(); + + Self { + toasts, + timeout_secs: DEFAULT_TIMEOUT, + on_close: Box::new(on_close), + } + } +} + +impl<'a> Widget for ToastManager<'a> { + fn size(&self) -> Size { + Size::new(Length::Fill, Length::Fill) + } + + fn layout(&self, tree: &mut Tree, renderer: &Renderer, limits: &Limits) -> layout::Node { + layout::flex::resolve( + layout::flex::Axis::Vertical, + renderer, + limits, + Length::Fill, + Length::Fill, + 10.into(), + 10.0, + Alignment::End, + &self.toasts, + &mut tree.children, + ) + } + + fn draw( + &self, + tree: &Tree, + renderer: &mut Renderer, + theme: &Theme, + style: &renderer::Style, + layout: Layout<'_>, + cursor: mouse::Cursor, + viewport: &Rectangle, + ) { + for ((child, state), layout) in self + .toasts + .iter() + .zip(tree.children.iter()) + .zip(layout.children()) + { + child + .as_widget() + .draw(state, renderer, theme, style, layout, cursor, viewport); + } + } + + fn tag(&self) -> widget::tree::Tag { + struct Marker; + widget::tree::Tag::of::() + } + + fn state(&self) -> widget::tree::State { + widget::tree::State::new(Vec::>::new()) + } + + fn children(&self) -> Vec { + self.toasts.iter().map(Tree::new).collect() + } + + fn diff(&self, tree: &mut Tree) { + let instants = tree.state.downcast_mut::>>(); + + // Invalidating removed instants to None allows us to remove + // them here so that diffing for removed / new toast instants + // is accurate + instants.retain(Option::is_some); + + match (instants.len(), self.toasts.len()) { + (old, new) if old > new => { + instants.truncate(new); + } + (old, new) if old < new => { + instants.extend(std::iter::repeat(Some(Instant::now())).take(new - old)); + } + _ => {} + } + + tree.diff_children(&self.toasts.iter().collect::>()); + } + + fn operate( + &self, + state: &mut Tree, + layout: Layout<'_>, + renderer: &Renderer, + operation: &mut dyn widget::Operation<()>, + ) { + operation.container(None, layout.bounds(), &mut |operation| { + self.toasts + .iter() + .zip(state.children.iter_mut()) + .zip(layout.children()) + .for_each(|((child, state), layout)| { + child + .as_widget() + .operate(state, layout, renderer, operation); + }); + }); + } + + fn on_event( + &mut self, + state: &mut Tree, + event: Event, + layout: Layout<'_>, + cursor: mouse::Cursor, + renderer: &Renderer, + clipboard: &mut dyn Clipboard, + shell: &mut Shell<'_, app::Message>, + _viewport: &Rectangle, + ) -> event::Status { + let instants = state.state.downcast_mut::>>(); + + if let Event::Window(window::Event::RedrawRequested(now)) = &event { + let mut next_redraw: Option = None; + + instants + .iter_mut() + .enumerate() + .for_each(|(index, maybe_instant)| { + if let Some(instant) = maybe_instant.as_mut() { + let remaining = Duration::from_secs(self.timeout_secs) + .saturating_sub(instant.elapsed()); + + if remaining == Duration::ZERO { + maybe_instant.take(); + shell.publish((self.on_close)(index)); + next_redraw = Some(window::RedrawRequest::NextFrame); + } else { + let redraw_at = window::RedrawRequest::At(*now + remaining); + next_redraw = next_redraw + .map(|redraw| redraw.min(redraw_at)) + .or(Some(redraw_at)); + } + } + }); + + if let Some(redraw) = next_redraw { + shell.request_redraw(redraw); + } + } + + let viewport = layout.bounds(); + + self.toasts + .iter_mut() + .zip(state.children.iter_mut()) + .zip(layout.children()) + .zip(instants.iter_mut()) + .map(|(((child, state), layout), instant)| { + let mut local_messages = vec![]; + let mut local_shell = Shell::new(&mut local_messages); + + let status = child.as_widget_mut().on_event( + state, + event.clone(), + layout, + cursor, + renderer, + clipboard, + &mut local_shell, + &viewport, + ); + + if !local_shell.is_empty() { + instant.take(); + } + + shell.merge(local_shell, std::convert::identity); + + status + }) + .fold(event::Status::Ignored, event::Status::merge) + } + + fn mouse_interaction( + &self, + state: &Tree, + layout: Layout<'_>, + cursor: mouse::Cursor, + viewport: &Rectangle, + renderer: &Renderer, + ) -> mouse::Interaction { + self.toasts + .iter() + .zip(state.children.iter()) + .zip(layout.children()) + .map(|((child, state), layout)| { + child + .as_widget() + .mouse_interaction(state, layout, cursor, viewport, renderer) + }) + .max() + .unwrap_or_default() + } +} + +impl<'a> From> for Element<'a, app::Message> { + fn from(manager: ToastManager<'a>) -> Self { + Element::new(manager) + } +}