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 }