notedeck

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

wallet.rs (15417B)


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