notedeck

One damus client to rule them all
git clone git://jb55.com/notedeck
Log | Files | Refs | README | LICENSE

wallet.rs (14597B)


      1 use egui::{vec2, CornerRadius, Layout};
      2 use notedeck::{
      3     get_current_wallet_mut, tr, Accounts, DefaultZapMsats, GlobalWallet, Localization,
      4     NotedeckTextStyle, PendingDefaultZapState, Wallet, WalletError, WalletUIState, ZapWallet,
      5 };
      6 
      7 use crate::{nav::RouterAction, route::Route};
      8 
      9 use super::widgets::styled_button;
     10 
     11 #[derive(Debug)]
     12 pub enum WalletState<'a> {
     13     Wallet {
     14         wallet: &'a mut Wallet,
     15         default_zap_state: DefaultZapState<'a>,
     16         can_create_local_wallet: bool,
     17     },
     18     NoWallet {
     19         state: &'a mut WalletUIState,
     20         show_local_only: bool,
     21     },
     22 }
     23 
     24 type Msats = u64;
     25 
     26 #[derive(Debug)]
     27 pub enum DefaultZapState<'a> {
     28     Pending(&'a mut PendingDefaultZapState), // User input
     29     Valid(&'a Msats),                        // in milisats
     30 }
     31 
     32 pub fn get_default_zap_state(default_zap: &mut DefaultZapMsats) -> DefaultZapState {
     33     if default_zap.pending.is_rewriting {
     34         return DefaultZapState::Pending(&mut default_zap.pending);
     35     }
     36 
     37     if let Some(user_selection) = &default_zap.msats {
     38         DefaultZapState::Valid(user_selection)
     39     } else {
     40         DefaultZapState::Pending(&mut default_zap.pending)
     41     }
     42 }
     43 
     44 #[derive(Debug)]
     45 pub enum WalletAction {
     46     SaveURI,
     47     AddLocalOnly,
     48     Delete,
     49     SetDefaultZapSats(String), // in sats
     50     EditDefaultZaps,
     51 }
     52 
     53 impl WalletAction {
     54     pub fn process(
     55         &self,
     56         accounts: &mut Accounts,
     57         global_wallet: &mut GlobalWallet,
     58     ) -> Option<RouterAction> {
     59         let mut action = None;
     60 
     61         match &self {
     62             WalletAction::SaveURI => {
     63                 let ui_state = &mut global_wallet.ui_state;
     64                 if ui_state.for_local_only {
     65                     ui_state.for_local_only = false;
     66 
     67                     if accounts.get_selected_wallet_mut().is_some() {
     68                         return None;
     69                     }
     70 
     71                     let wallet = try_create_wallet(ui_state)?;
     72 
     73                     accounts.update_current_account(move |acc| {
     74                         acc.wallet = Some(wallet.into());
     75                     });
     76                 } else {
     77                     if global_wallet.wallet.is_some() {
     78                         return None;
     79                     }
     80 
     81                     let wallet = try_create_wallet(ui_state)?;
     82 
     83                     global_wallet.wallet = Some(wallet.into());
     84                     global_wallet.save_wallet();
     85                 }
     86             }
     87             WalletAction::AddLocalOnly => {
     88                 action = Some(RouterAction::route_to(Route::Wallet(
     89                     notedeck::WalletType::Local,
     90                 )));
     91                 global_wallet.ui_state.for_local_only = true;
     92             }
     93             WalletAction::Delete => {
     94                 if accounts.get_selected_account().wallet.is_some() {
     95                     accounts.update_current_account(|acc| {
     96                         acc.wallet = None;
     97                     });
     98                     return None;
     99                 }
    100 
    101                 global_wallet.wallet = None;
    102                 global_wallet.save_wallet();
    103             }
    104             WalletAction::SetDefaultZapSats(new_default) => 's: {
    105                 let sats = {
    106                     let Some(wallet) = get_current_wallet_mut(accounts, global_wallet) else {
    107                         break 's;
    108                     };
    109 
    110                     let Ok(sats) = new_default.parse::<u64>() else {
    111                         wallet.default_zap.pending.error_message =
    112                             Some(notedeck::DefaultZapError::InvalidUserInput);
    113                         break 's;
    114                     };
    115                     sats
    116                 };
    117 
    118                 let update_wallet = |wallet: &mut ZapWallet| {
    119                     wallet.default_zap.set_user_selection(sats * 1000);
    120                     wallet.default_zap.pending = PendingDefaultZapState::default();
    121                 };
    122 
    123                 if accounts.selected_account_has_wallet()
    124                     && accounts.update_current_account(|acc| {
    125                         if let Some(wallet) = &mut acc.wallet {
    126                             update_wallet(wallet);
    127                         }
    128                     })
    129                 {
    130                     break 's;
    131                 }
    132 
    133                 let Some(wallet) = &mut global_wallet.wallet else {
    134                     break 's;
    135                 };
    136 
    137                 update_wallet(wallet);
    138                 global_wallet.save_wallet();
    139             }
    140             WalletAction::EditDefaultZaps => 's: {
    141                 let Some(wallet) = get_current_wallet_mut(accounts, global_wallet) else {
    142                     break 's;
    143                 };
    144 
    145                 wallet.default_zap.pending.is_rewriting = true;
    146                 wallet.default_zap.pending.amount_sats =
    147                     (wallet.default_zap.get_default_zap_msats() / 1000).to_string();
    148             }
    149         }
    150         action
    151     }
    152 }
    153 
    154 pub struct WalletView<'a> {
    155     state: WalletState<'a>,
    156     i18n: &'a mut Localization,
    157 }
    158 
    159 impl<'a> WalletView<'a> {
    160     pub fn new(state: WalletState<'a>, i18n: &'a mut Localization) -> Self {
    161         Self { state, i18n }
    162     }
    163 
    164     pub fn ui(&mut self, ui: &mut egui::Ui) -> Option<WalletAction> {
    165         egui::Frame::NONE
    166             .inner_margin(egui::Margin::same(8))
    167             .show(ui, |ui| self.inner_ui(ui))
    168             .inner
    169     }
    170 
    171     fn inner_ui(&mut self, ui: &mut egui::Ui) -> Option<WalletAction> {
    172         match &mut self.state {
    173             WalletState::Wallet {
    174                 wallet,
    175                 default_zap_state,
    176                 can_create_local_wallet,
    177             } => show_with_wallet(
    178                 ui,
    179                 self.i18n,
    180                 wallet,
    181                 default_zap_state,
    182                 *can_create_local_wallet,
    183             ),
    184             WalletState::NoWallet {
    185                 state,
    186                 show_local_only,
    187             } => show_no_wallet(ui, self.i18n, state, *show_local_only),
    188         }
    189     }
    190 }
    191 
    192 fn try_create_wallet(state: &mut WalletUIState) -> Option<Wallet> {
    193     let uri = &state.buf;
    194 
    195     let Ok(wallet) = Wallet::new(uri.to_owned()) else {
    196         state.error_msg = Some(WalletError::InvalidURI);
    197         return None;
    198     };
    199 
    200     *state = WalletUIState::default();
    201     Some(wallet)
    202 }
    203 
    204 fn show_no_wallet(
    205     ui: &mut egui::Ui,
    206     i18n: &mut Localization,
    207     state: &mut WalletUIState,
    208     show_local_only: bool,
    209 ) -> Option<WalletAction> {
    210     ui.horizontal_wrapped(|ui| 's: {
    211         let text_edit = egui::TextEdit::singleline(&mut state.buf)
    212             .hint_text(
    213                 egui::RichText::new(tr!(
    214                     i18n,
    215                     "Paste your NWC URI here...",
    216                     "Placeholder text for NWC URI input"
    217                 ))
    218                 .text_style(notedeck::NotedeckTextStyle::Body.text_style()),
    219             )
    220             .vertical_align(egui::Align::Center)
    221             .desired_width(f32::INFINITY)
    222             .min_size(egui::Vec2::new(0.0, 40.0))
    223             .margin(egui::Margin::same(12))
    224             .password(true);
    225 
    226         ui.add(text_edit);
    227 
    228         let Some(error_msg) = &state.error_msg else {
    229             break 's;
    230         };
    231 
    232         let error_str = match error_msg {
    233             WalletError::InvalidURI => tr!(
    234                 i18n,
    235                 "Invalid NWC URI",
    236                 "Error message for invalid Nostr Wallet Connect URI"
    237             ),
    238             WalletError::NoWallet => tr!(
    239                 i18n,
    240                 "Add a wallet to continue",
    241                 "Error message for missing wallet"
    242             ),
    243         };
    244         ui.colored_label(ui.visuals().warn_fg_color, error_str);
    245     });
    246 
    247     ui.add_space(8.0);
    248 
    249     if show_local_only {
    250         ui.checkbox(
    251             &mut state.for_local_only,
    252             tr!(
    253                 i18n,
    254                 "Use this wallet for the current account only",
    255                 "Checkbox label for using wallet only for current account"
    256             ),
    257         );
    258         ui.add_space(8.0);
    259     }
    260 
    261     ui.with_layout(Layout::top_down(egui::Align::Center), |ui| {
    262         ui.add(styled_button(
    263             tr!(i18n, "Add Wallet", "Button label to add a wallet").as_str(),
    264             notedeck_ui::colors::PINK,
    265         ))
    266         .clicked()
    267         .then_some(WalletAction::SaveURI)
    268     })
    269     .inner
    270 }
    271 
    272 fn show_with_wallet(
    273     ui: &mut egui::Ui,
    274     i18n: &mut Localization,
    275     wallet: &mut Wallet,
    276     default_zap_state: &mut DefaultZapState,
    277     can_create_local_wallet: bool,
    278 ) -> Option<WalletAction> {
    279     ui.horizontal_wrapped(|ui| {
    280         let balance = wallet.get_balance();
    281 
    282         if let Some(balance) = balance {
    283             match balance {
    284                 Ok(msats) => show_balance(ui, *msats),
    285                 Err(e) => ui.colored_label(egui::Color32::RED, format!("error: {e}")),
    286             }
    287         } else {
    288             ui.with_layout(Layout::top_down(egui::Align::Center), |ui| {
    289                 ui.add(egui::Spinner::new().size(48.0))
    290             })
    291             .inner
    292         }
    293     });
    294 
    295     let mut action = show_default_zap(ui, i18n, default_zap_state);
    296 
    297     ui.with_layout(Layout::bottom_up(egui::Align::Min), |ui| 's: {
    298         if ui
    299             .add(styled_button(
    300                 tr!(i18n, "Delete Wallet", "Button label to delete a wallet").as_str(),
    301                 ui.visuals().window_fill,
    302             ))
    303             .clicked()
    304         {
    305             action = Some(WalletAction::Delete);
    306             break 's;
    307         }
    308 
    309         ui.add_space(12.0);
    310         if can_create_local_wallet
    311             && ui
    312                 .checkbox(
    313                     &mut false,
    314                     tr!(
    315                         i18n,
    316                         "Add a different wallet that will only be used for this account",
    317                         "Button label to add a different wallet"
    318                     ),
    319                 )
    320                 .clicked()
    321         {
    322             action = Some(WalletAction::AddLocalOnly);
    323         }
    324     });
    325 
    326     action
    327 }
    328 
    329 fn show_balance(ui: &mut egui::Ui, msats: u64) -> egui::Response {
    330     let sats = human_format::Formatter::new()
    331         .with_decimals(2)
    332         .format(msats as f64 / 1000.0);
    333 
    334     ui.with_layout(Layout::top_down(egui::Align::Center), |ui| {
    335         ui.label(egui::RichText::new(format!("{sats} sats")).size(48.0))
    336     })
    337     .inner
    338 }
    339 
    340 fn show_default_zap(
    341     ui: &mut egui::Ui,
    342     i18n: &mut Localization,
    343     state: &mut DefaultZapState,
    344 ) -> Option<WalletAction> {
    345     let mut action = None;
    346     ui.allocate_ui_with_layout(
    347         vec2(ui.available_width(), 50.0),
    348         egui::Layout::left_to_right(egui::Align::Center).with_main_wrap(true),
    349         |ui| {
    350             ui.label(tr!(i18n, "Default amount per zap: ", "Label for default zap amount input"));
    351             match state {
    352                 DefaultZapState::Pending(pending_default_zap_state) => {
    353                     let text = &mut pending_default_zap_state.amount_sats;
    354 
    355                     let font = NotedeckTextStyle::Body.get_font_id(ui.ctx());
    356                     let desired_width = {
    357                         let painter = ui.painter();
    358                         let galley = painter.layout_no_wrap(
    359                             text.clone(),
    360                             font.clone(),
    361                             ui.visuals().text_color(),
    362                         );
    363                         let rect_width = galley.rect.width();
    364                         if rect_width < 5.0 {
    365                             10.0
    366                         } else {
    367                             rect_width
    368                         }
    369                     };
    370 
    371                     let id = ui.id().with("default_zap_amount");
    372                     ui.add(
    373                         egui::TextEdit::singleline(text)
    374                             .desired_width(desired_width)
    375                             .margin(egui::Margin::same(8))
    376                             .font(font)
    377                             .id(id),
    378                     );
    379 
    380                     ui.memory_mut(|m| m.request_focus(id));
    381 
    382                     ui.label(tr!(i18n, "sats", "Unit label for satoshis (Bitcoin unit) for configuring default zap amount in wallet settings."));
    383 
    384                     if ui
    385                         .add(styled_button(tr!(i18n, "Save", "Button to save default zap amount").as_str(), ui.visuals().widgets.active.bg_fill))
    386                         .clicked()
    387                     {
    388                         action = Some(WalletAction::SetDefaultZapSats(text.to_string()));
    389                     }
    390                 }
    391                 DefaultZapState::Valid(msats) => {
    392                     if let Some(wallet_action) = show_valid_msats(ui, i18n, **msats) {
    393                         action = Some(wallet_action);
    394                     }
    395                     ui.label(tr!(i18n, "sats", "Unit label for satoshis (Bitcoin unit) for configuring default zap amount in wallet settings."));
    396                 }
    397             }
    398 
    399             if let DefaultZapState::Pending(pending) = state {
    400                 if let Some(error_message) = &pending.error_message {
    401                     let msg_str = match error_message {
    402                         notedeck::DefaultZapError::InvalidUserInput => tr!(i18n, "Invalid amount", "Error message for invalid zap amount"),
    403                     };
    404 
    405                     ui.colored_label(ui.visuals().warn_fg_color, msg_str);
    406                 }
    407             }
    408         },
    409     );
    410 
    411     action
    412 }
    413 
    414 fn show_valid_msats(
    415     ui: &mut egui::Ui,
    416     i18n: &mut Localization,
    417     msats: u64,
    418 ) -> Option<WalletAction> {
    419     let galley = {
    420         let painter = ui.painter();
    421 
    422         let sats_str = (msats / 1000).to_string();
    423         painter.layout_no_wrap(
    424             sats_str,
    425             NotedeckTextStyle::Body.get_font_id(ui.ctx()),
    426             ui.visuals().text_color(),
    427         )
    428     };
    429 
    430     let (rect, resp) = ui.allocate_exact_size(galley.rect.expand(8.0).size(), egui::Sense::click());
    431 
    432     let resp = resp
    433         .on_hover_cursor(egui::CursorIcon::PointingHand)
    434         .on_hover_text_at_pointer(tr!(
    435             i18n,
    436             "Click to edit",
    437             "Hover text for editable zap amount"
    438         ));
    439 
    440     let painter = ui.painter_at(resp.rect);
    441 
    442     painter.rect_filled(
    443         rect,
    444         CornerRadius::same(8),
    445         ui.visuals().noninteractive().bg_fill,
    446     );
    447 
    448     let galley_pos = {
    449         let mut next_pos = rect.left_top();
    450         next_pos.x += 8.0;
    451         next_pos.y += 8.0;
    452         next_pos
    453     };
    454 
    455     painter.galley(galley_pos, galley, notedeck_ui::colors::MID_GRAY);
    456 
    457     if resp.clicked() {
    458         Some(WalletAction::EditDefaultZaps)
    459     } else {
    460         None
    461     }
    462 }