notedeck

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

wallet.rs (15057B)


      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         input_context(
    241             &ui.add(text_edit),
    242             clipboard,
    243             &mut state.buf,
    244             PasteBehavior::Clear,
    245         );
    246 
    247         let Some(error_msg) = &state.error_msg else {
    248             return;
    249         };
    250 
    251         let error_str = match error_msg {
    252             WalletError::InvalidURI => tr!(
    253                 i18n,
    254                 "Invalid NWC URI",
    255                 "Error message for invalid Nostr Wallet Connect URI"
    256             ),
    257             WalletError::NoWallet => tr!(
    258                 i18n,
    259                 "Add a wallet to continue",
    260                 "Error message for missing wallet"
    261             ),
    262         };
    263         ui.colored_label(ui.visuals().warn_fg_color, error_str);
    264     });
    265 
    266     ui.add_space(8.0);
    267 
    268     if show_local_only {
    269         ui.checkbox(
    270             &mut state.for_local_only,
    271             tr!(
    272                 i18n,
    273                 "Use this wallet for the current account only",
    274                 "Checkbox label for using wallet only for current account"
    275             ),
    276         );
    277         ui.add_space(8.0);
    278     }
    279 
    280     ui.with_layout(Layout::top_down(egui::Align::Center), |ui| {
    281         ui.add(styled_button(
    282             tr!(i18n, "Add Wallet", "Button label to add a wallet").as_str(),
    283             notedeck_ui::colors::PINK,
    284         ))
    285         .clicked()
    286         .then_some(WalletAction::SaveURI)
    287     })
    288     .inner
    289 }
    290 
    291 fn show_with_wallet(
    292     ui: &mut egui::Ui,
    293     i18n: &mut Localization,
    294     wallet: &mut Wallet,
    295     default_zap_state: &mut DefaultZapState,
    296     can_create_local_wallet: bool,
    297 ) -> Option<WalletAction> {
    298     ui.horizontal_wrapped(|ui| {
    299         let balance = wallet.get_balance();
    300 
    301         if let Some(balance) = balance {
    302             match balance {
    303                 Ok(msats) => show_balance(ui, *msats),
    304                 Err(e) => ui.colored_label(egui::Color32::RED, format!("error: {e}")),
    305             }
    306         } else {
    307             ui.with_layout(Layout::top_down(egui::Align::Center), |ui| {
    308                 ui.add(egui::Spinner::new().size(48.0))
    309             })
    310             .inner
    311         }
    312     });
    313 
    314     let mut action = show_default_zap(ui, i18n, default_zap_state);
    315 
    316     ui.with_layout(Layout::bottom_up(egui::Align::Min), |ui| 's: {
    317         if ui
    318             .add(styled_button(
    319                 tr!(i18n, "Delete Wallet", "Button label to delete a wallet").as_str(),
    320                 ui.visuals().window_fill,
    321             ))
    322             .clicked()
    323         {
    324             action = Some(WalletAction::Delete);
    325             break 's;
    326         }
    327 
    328         ui.add_space(12.0);
    329         if can_create_local_wallet
    330             && ui
    331                 .checkbox(
    332                     &mut false,
    333                     tr!(
    334                         i18n,
    335                         "Add a different wallet that will only be used for this account",
    336                         "Button label to add a different wallet"
    337                     ),
    338                 )
    339                 .clicked()
    340         {
    341             action = Some(WalletAction::AddLocalOnly);
    342         }
    343     });
    344 
    345     action
    346 }
    347 
    348 fn show_balance(ui: &mut egui::Ui, msats: u64) -> egui::Response {
    349     let sats = human_format::Formatter::new()
    350         .with_decimals(2)
    351         .format(msats as f64 / 1000.0);
    352 
    353     ui.with_layout(Layout::top_down(egui::Align::Center), |ui| {
    354         ui.label(egui::RichText::new(format!("{sats} sats")).size(48.0))
    355     })
    356     .inner
    357 }
    358 
    359 fn show_default_zap(
    360     ui: &mut egui::Ui,
    361     i18n: &mut Localization,
    362     state: &mut DefaultZapState,
    363 ) -> Option<WalletAction> {
    364     let mut action = None;
    365     ui.allocate_ui_with_layout(
    366         vec2(ui.available_width(), 50.0),
    367         egui::Layout::left_to_right(egui::Align::Center).with_main_wrap(true),
    368         |ui| {
    369             ui.label(tr!(i18n, "Default amount per zap: ", "Label for default zap amount input"));
    370             match state {
    371                 DefaultZapState::Pending(pending_default_zap_state) => {
    372                     let text = &mut pending_default_zap_state.amount_sats;
    373 
    374                     let font = NotedeckTextStyle::Body.get_font_id(ui.ctx());
    375                     let desired_width = {
    376                         let painter = ui.painter();
    377                         let galley = painter.layout_no_wrap(
    378                             text.clone(),
    379                             font.clone(),
    380                             ui.visuals().text_color(),
    381                         );
    382                         let rect_width = galley.rect.width();
    383                         if rect_width < 5.0 {
    384                             10.0
    385                         } else {
    386                             rect_width
    387                         }
    388                     };
    389 
    390                     let id = ui.id().with("default_zap_amount");
    391                     ui.add(
    392                         egui::TextEdit::singleline(text)
    393                             .desired_width(desired_width)
    394                             .margin(egui::Margin::same(8))
    395                             .font(font)
    396                             .id(id),
    397                     );
    398 
    399                     ui.memory_mut(|m| m.request_focus(id));
    400 
    401                     ui.label(tr!(i18n, "sats", "Unit label for satoshis (Bitcoin unit) for configuring default zap amount in wallet settings."));
    402 
    403                     if ui
    404                         .add(styled_button(tr!(i18n, "Save", "Button to save default zap amount").as_str(), ui.visuals().widgets.active.bg_fill))
    405                         .clicked()
    406                     {
    407                         action = Some(WalletAction::SetDefaultZapSats(text.to_string()));
    408                     }
    409                 }
    410                 DefaultZapState::Valid(msats) => {
    411                     if let Some(wallet_action) = show_valid_msats(ui, i18n, **msats) {
    412                         action = Some(wallet_action);
    413                     }
    414                     ui.label(tr!(i18n, "sats", "Unit label for satoshis (Bitcoin unit) for configuring default zap amount in wallet settings."));
    415                 }
    416             }
    417 
    418             if let DefaultZapState::Pending(pending) = state {
    419                 if let Some(error_message) = &pending.error_message {
    420                     let msg_str = match error_message {
    421                         notedeck::DefaultZapError::InvalidUserInput => tr!(i18n, "Invalid amount", "Error message for invalid zap amount"),
    422                     };
    423 
    424                     ui.colored_label(ui.visuals().warn_fg_color, msg_str);
    425                 }
    426             }
    427         },
    428     );
    429 
    430     action
    431 }
    432 
    433 fn show_valid_msats(
    434     ui: &mut egui::Ui,
    435     i18n: &mut Localization,
    436     msats: u64,
    437 ) -> Option<WalletAction> {
    438     let galley = {
    439         let painter = ui.painter();
    440 
    441         let sats_str = (msats / 1000).to_string();
    442         painter.layout_no_wrap(
    443             sats_str,
    444             NotedeckTextStyle::Body.get_font_id(ui.ctx()),
    445             ui.visuals().text_color(),
    446         )
    447     };
    448 
    449     let (rect, resp) = ui.allocate_exact_size(galley.rect.expand(8.0).size(), egui::Sense::click());
    450 
    451     let resp = resp
    452         .on_hover_cursor(egui::CursorIcon::PointingHand)
    453         .on_hover_text_at_pointer(tr!(
    454             i18n,
    455             "Click to edit",
    456             "Hover text for editable zap amount"
    457         ));
    458 
    459     let painter = ui.painter_at(resp.rect);
    460 
    461     painter.rect_filled(
    462         rect,
    463         CornerRadius::same(8),
    464         ui.visuals().noninteractive().bg_fill,
    465     );
    466 
    467     let galley_pos = {
    468         let mut next_pos = rect.left_top();
    469         next_pos.x += 8.0;
    470         next_pos.y += 8.0;
    471         next_pos
    472     };
    473 
    474     painter.galley(galley_pos, galley, notedeck_ui::colors::MID_GRAY);
    475 
    476     if resp.clicked() {
    477         Some(WalletAction::EditDefaultZaps)
    478     } else {
    479         None
    480     }
    481 }