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::