diff --git a/src/gui/views/goblin/mod.rs b/src/gui/views/goblin/mod.rs index 421d2be..105d89c 100644 --- a/src/gui/views/goblin/mod.rs +++ b/src/gui/views/goblin/mod.rs @@ -21,7 +21,7 @@ pub mod widgets; use eframe::epaint::{FontId, Stroke}; use egui::{Align, Layout, Margin, RichText, ScrollArea, Sense, Vec2}; -use crate::gui::icons::{CLOCK, COPY, SCAN, USER_CIRCLE, WALLET}; +use crate::gui::icons::{ARROW_DOWN, CLOCK, COPY, USER_CIRCLE, WALLET}; use crate::gui::platform::PlatformCallbacks; use crate::gui::theme::{self, fonts}; use crate::gui::views::{Content, View}; @@ -45,6 +45,12 @@ pub enum Tab { pub struct GoblinWalletView { tab: Tab, send: Option, + /// Request ids already approved this session (double-tap guard). + approving: std::collections::HashSet, + /// Identifier of the wallet this view is bound to (reset on change). + wallet_id: Option, + /// Inline username-claim state for the Me tab. + claim: Option, } impl Default for GoblinWalletView { @@ -52,6 +58,36 @@ impl Default for GoblinWalletView { Self { tab: Tab::Home, send: None, + approving: std::collections::HashSet::new(), + wallet_id: None, + claim: None, + } + } +} + +/// Inline username-claim widget state. +struct ClaimState { + input: String, + checking: bool, + available: Option, + result: std::sync::Arc>>, + message: Option, +} + +enum ClaimMsg { + Availability(bool), + Registered(String), + Error(String), +} + +impl Default for ClaimState { + fn default() -> Self { + Self { + input: String::new(), + checking: false, + available: None, + result: std::sync::Arc::new(std::sync::Mutex::new(None)), + message: None, } } } @@ -79,6 +115,17 @@ impl GoblinWalletView { pub fn ui(&mut self, ui: &mut egui::Ui, wallet: &Wallet, cb: &dyn PlatformCallbacks) { let t = theme::tokens(); + // Reset transient UI state when the bound wallet changes, so a + // half-filled send or claim never leaks across a wallet switch. + let id = wallet.identifier(); + if self.wallet_id.as_deref() != Some(id.as_str()) { + self.wallet_id = Some(id); + self.tab = Tab::Home; + self.send = None; + self.claim = None; + self.approving.clear(); + } + // Send flow takes the full surface when active. if let Some(send) = &mut self.send { let done = send.ui(ui, wallet, cb); @@ -137,7 +184,7 @@ impl GoblinWalletView { let tabs = [ (Tab::Home, WALLET, "Wallet", false), (Tab::Activity, CLOCK, "Activity", has_requests), - (Tab::Receive, SCAN, "Scan", false), + (Tab::Receive, ARROW_DOWN, "Receive", false), (Tab::Me, USER_CIRCLE, "Me", false), ]; ui.horizontal(|ui| { @@ -165,12 +212,7 @@ impl GoblinWalletView { .circle_filled(rect.center() + Vec2::new(12.0, -10.0), 4.0, t.neg); } if resp.clicked() { - if tab == Tab::Receive { - // "Scan" routes to receive screen for now. - self.tab = Tab::Receive; - } else { - self.tab = tab; - } + self.tab = tab; } } }); @@ -379,11 +421,17 @@ impl GoblinWalletView { Vec2::new(half, 44.0), )), |ui| { - if approve_button(ui) { - wallet.task(crate::wallet::types::WalletTask::NostrPayRequest( - req.rumor_id.clone(), - )); - } + let already = self.approving.contains(&req.rumor_id); + ui.add_enabled_ui(!already, |ui| { + if approve_button(ui) { + // Guard against double-tap: only enqueue the + // payment once per request id this session. + self.approving.insert(req.rumor_id.clone()); + wallet.task(crate::wallet::types::WalletTask::NostrPayRequest( + req.rumor_id.clone(), + )); + } + }); }, ); }); @@ -459,9 +507,11 @@ impl GoblinWalletView { Vec2::new(half, 56.0), )), |ui| { - if w::big_action(ui, "Slatepack", false).clicked() { - // Fall back to manual slatepack receive via legacy flow. - self.tab = Tab::Activity; + // Copy the grin1 slatepack address for manual/Tor exchange. + if w::big_action(ui, "Address", false).clicked() { + if let Some(addr) = wallet.slatepack_address() { + cb.copy_string_to_buffer(addr); + } } }, ); @@ -534,15 +584,62 @@ impl GoblinWalletView { settings_row(ui, "Auto-accept", accept_policy_label(wallet)); }); ui.add_space(16.0); - settings_group(ui, "Wallet", |ui| { - settings_row(ui, "Display unit", "ツ (grin)"); - settings_row(ui, "Relays", &relay_summary(wallet)); + + // Identity: claim a username, copy npub, back up the secret. + let is_anon = wallet + .nostr_service() + .map(|s| s.identity.read().anonymous) + .unwrap_or(true); + w::kicker(ui, "Identity"); + ui.add_space(8.0); + w::card(ui, |ui| { + if is_anon { + if settings_row_btn(ui, "Claim a username", USER_CIRCLE) { + self.claim = Some(ClaimState::default()); + } + } else { + settings_row(ui, "Username", &handle); + } if !npub.is_empty() { - if settings_row_btn(ui, "Backup nostr key", COPY) { + if settings_row_btn(ui, "Copy npub (public)", COPY) { cb.copy_string_to_buffer(npub.clone()); } + // A real backup is the SECRET key (nsec), not the npub. + if settings_row_btn(ui, "Back up secret key (nsec)", COPY) { + if let Some(nsec) = wallet.nostr_service().and_then(|s| s.nsec()) { + cb.copy_string_to_buffer(nsec); + } + } } }); + if self.claim.is_some() { + ui.add_space(8.0); + self.claim_ui(ui, wallet); + } + + ui.add_space(16.0); + settings_group(ui, "Wallet", |ui| { + settings_row(ui, "Display unit", "ツ (grin)"); + settings_row(ui, "Relays", &relay_summary(wallet)); + }); + + ui.add_space(16.0); + w::kicker(ui, "Archive"); + ui.add_space(8.0); + w::card(ui, |ui| { + if settings_row_btn(ui, "Export archive", COPY) { + if let Some(s) = wallet.nostr_service() { + let json = s.store.export_json(&s.npub()); + cb.copy_string_to_buffer(json); + } + } + if settings_row_btn(ui, "Wipe payment history", crate::gui::icons::X) { + if let Some(s) = wallet.nostr_service() { + s.store.wipe_archive(); + } + } + }); + ui.add_space(16.0); settings_group(ui, "About", |ui| { settings_row(ui, "Network", "Mimblewimble · no address on chain"); @@ -551,6 +648,180 @@ impl GoblinWalletView { ui.add_space(16.0); }); } + + /// Inline username-claim widget (availability check + register over Tor). + fn claim_ui(&mut self, ui: &mut egui::Ui, wallet: &Wallet) { + let t = theme::tokens(); + let claim = self.claim.as_mut().unwrap(); + // Poll the worker result. + if let Some(msg) = claim.result.lock().unwrap().take() { + claim.checking = false; + match msg { + ClaimMsg::Availability(ok) => { + claim.available = Some(ok); + claim.message = Some(if ok { + "Available!".into() + } else { + "Taken".into() + }); + } + ClaimMsg::Registered(nip05) => { + claim.message = Some(format!("Registered {}", nip05)); + // Persist nip05 on the identity and republish. + if let Some(s) = wallet.nostr_service() { + { + let mut id = s.identity.write(); + id.nip05 = Some(nip05.clone()); + id.anonymous = false; + } + s.save_identity(); + } + } + ClaimMsg::Error(e) => claim.message = Some(e), + } + } + w::card(ui, |ui| { + ui.label( + RichText::new("Pick a username") + .font(FontId::new(15.0, fonts::semibold())) + .color(t.text), + ); + ui.add_space(8.0); + ui.horizontal(|ui| { + ui.label(RichText::new("@").font(FontId::new(16.0, fonts::semibold()))); + let edit = egui::TextEdit::singleline(&mut claim.input) + .hint_text("yourname") + .desired_width(ui.available_width() - 20.0) + .frame(false); + if ui.add(edit).changed() { + claim.available = None; + claim.message = None; + } + }); + ui.add_space(4.0); + ui.label( + RichText::new("Shown as @you. Public on goblin.st. Payments stay encrypted.") + .font(FontId::new(12.0, fonts::regular())) + .color(t.text_mute), + ); + if let Some(msg) = &claim.message { + ui.add_space(6.0); + ui.label( + RichText::new(msg) + .font(FontId::new(13.0, fonts::regular())) + .color(if claim.available == Some(false) { + t.neg + } else { + t.pos + }), + ); + } + ui.add_space(10.0); + let name = claim.input.trim().to_lowercase(); + let valid = name.len() >= 3 && name.len() <= 30; + if claim.checking { + ui.horizontal(|ui| { + View::small_loading_spinner(ui); + ui.add_space(8.0); + ui.label(RichText::new("Working…").color(t.text_dim)); + }); + ui.ctx().request_repaint(); + } else { + ui.horizontal(|ui| { + let half = (ui.available_width() - 10.0) / 2.0; + ui.scope_builder( + egui::UiBuilder::new().max_rect(egui::Rect::from_min_size( + ui.cursor().min, + Vec2::new(half, 44.0), + )), + |ui| { + ui.add_enabled_ui(valid, |ui| { + if w::big_action(ui, "Check", true).clicked() { + start_claim_check(claim, &name, wallet); + } + }); + }, + ); + ui.add_space(10.0); + ui.scope_builder( + egui::UiBuilder::new().max_rect(egui::Rect::from_min_size( + ui.cursor().min, + Vec2::new(half, 44.0), + )), + |ui| { + ui.add_enabled_ui(valid && claim.available == Some(true), |ui| { + if w::big_action(ui, "Claim", false).clicked() { + start_claim_register(claim, &name, wallet); + } + }); + }, + ); + }); + } + }); + } +} + +/// Spawn the availability check on a worker thread. +fn start_claim_check(claim: &mut ClaimState, name: &str, wallet: &Wallet) { + let server = wallet + .nostr_service() + .map(|s| s.config.read().nip05_server()) + .unwrap_or_default(); + claim.checking = true; + let slot = claim.result.clone(); + let name = name.to_string(); + std::thread::spawn(move || { + let rt = match tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + { + Ok(rt) => rt, + Err(_) => return, + }; + let avail = rt.block_on(crate::nostr::nip05::check_availability(&server, &name)); + let msg = ClaimMsg::Availability(avail == crate::nostr::nip05::Availability::Available); + *slot.lock().unwrap() = Some(msg); + }); +} + +/// Spawn the NIP-98 registration on a worker thread. +fn start_claim_register(claim: &mut ClaimState, name: &str, wallet: &Wallet) { + let Some(service) = wallet.nostr_service() else { + return; + }; + let server = service.config.read().nip05_server(); + let keys = match service + .nsec() + .and_then(|nsec| nostr_sdk::Keys::parse(&nsec).ok()) + { + Some(k) => k, + None => return, + }; + claim.checking = true; + let slot = claim.result.clone(); + let name = name.to_string(); + std::thread::spawn(move || { + let rt = match tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + { + Ok(rt) => rt, + Err(_) => return, + }; + let res = rt.block_on(crate::nostr::nip05::register(&server, &name, &keys)); + let msg = match res { + crate::nostr::nip05::RegisterResult::Ok(nip05) => ClaimMsg::Registered(nip05), + crate::nostr::nip05::RegisterResult::Conflict(_) => { + ClaimMsg::Error("That username was just taken".into()) + } + crate::nostr::nip05::RegisterResult::Rejected(e) => ClaimMsg::Error(e), + crate::nostr::nip05::RegisterResult::Network => { + ClaimMsg::Error("Network error — try again".into()) + } + }; + *slot.lock().unwrap() = Some(msg); + }); } /// Draw the small Goblin mascot mark. diff --git a/src/gui/views/goblin/send.rs b/src/gui/views/goblin/send.rs index c40484d..da1105b 100644 --- a/src/gui/views/goblin/send.rs +++ b/src/gui/views/goblin/send.rs @@ -37,6 +37,7 @@ enum Stage { Review, Sending, Success, + Failed, } /// A resolved recipient. @@ -47,6 +48,9 @@ struct Recipient { hue: usize, } +/// Outcome of an async NIP-05 resolution: (pubkey hex, optional nip05 id). +type ResolveOutcome = Result<(String, Option), String>; + /// The send flow state. pub struct SendFlow { stage: Stage, @@ -56,6 +60,8 @@ pub struct SendFlow { note: String, hold: HoldToSend, error: Option, + /// Async NIP-05 resolution result, written by a worker thread. + resolve_slot: std::sync::Arc>>, /// Resolution in flight (bech32/nip05). resolving: bool, } @@ -70,6 +76,7 @@ impl Default for SendFlow { note: String::new(), hold: HoldToSend::default(), error: None, + resolve_slot: std::sync::Arc::new(std::sync::Mutex::new(None)), resolving: false, } } @@ -112,6 +119,7 @@ impl SendFlow { Stage::Review => done = self.review_ui(ui, wallet), Stage::Sending => self.sending_ui(ui, wallet), Stage::Success => done = self.success_ui(ui), + Stage::Failed => done = self.failed_ui(ui, wallet), }, ); }); @@ -177,9 +185,23 @@ impl SendFlow { View::horizontal_line(ui, t.line); ui.add_space(12.0); + // Poll the async resolution result (set by a worker thread). + self.poll_resolution(wallet); + // Resolve button when input looks like a handle/npub. if !self.search.trim().is_empty() { - if w::big_action(ui, "Find recipient", false).clicked() { + if self.resolving { + ui.horizontal(|ui| { + View::small_loading_spinner(ui); + ui.add_space(8.0); + ui.label( + RichText::new("Looking up handle…") + .font(FontId::new(14.0, fonts::regular())) + .color(t.text_dim), + ); + }); + ui.ctx().request_repaint(); + } else if w::big_action(ui, "Find recipient", false).clicked() { self.resolve_search(wallet); } if let Some(err) = &self.error { @@ -227,32 +249,48 @@ impl SendFlow { fn resolve_search(&mut self, wallet: &Wallet) { let input = self.search.trim().to_string(); - self.resolving = true; - // Try bech32 npub directly. + self.error = None; + // Instant cases (no network): bech32 npub or raw hex pubkey. use nostr_sdk::{FromBech32, PublicKey}; if let Ok(pk) = PublicKey::from_bech32(&input) { self.set_recipient_from_hex(wallet, &pk.to_hex(), None); - self.resolving = false; return; } if input.len() == 64 && input.chars().all(|c| c.is_ascii_hexdigit()) { self.set_recipient_from_hex(wallet, &input.to_lowercase(), None); - self.resolving = false; return; } - // Try NIP-05 resolution (user@domain or @user) over Tor. + // NIP-05 resolution over Tor: run on a worker thread, never block the + // UI. The result is written to `resolve_slot` and polled each frame. if let Some((name, domain)) = nip05::split_identifier(&input) { - match resolve_nip05_blocking(&name, &domain) { - Some(res) => { - let nip05_id = format!("{}@{}", name, domain); - self.set_recipient_from_hex(wallet, &res.pubkey.to_hex(), Some(nip05_id)); - } - None => self.error = Some("Couldn't find that handle".to_string()), - } + self.resolving = true; + *self.resolve_slot.lock().unwrap() = None; + let slot = self.resolve_slot.clone(); + std::thread::spawn(move || { + let outcome: ResolveOutcome = match resolve_nip05_blocking(&name, &domain) { + Some(res) => Ok((res.pubkey.to_hex(), Some(format!("{}@{}", name, domain)))), + None => Err("Couldn't find that handle".to_string()), + }; + *slot.lock().unwrap() = Some(outcome); + }); } else { self.error = Some("Enter an @handle, npub, or name".to_string()); } - self.resolving = false; + } + + /// Apply an async NIP-05 resolution result once the worker finishes. + fn poll_resolution(&mut self, wallet: &Wallet) { + if !self.resolving { + return; + } + let outcome = self.resolve_slot.lock().unwrap().take(); + if let Some(outcome) = outcome { + self.resolving = false; + match outcome { + Ok((hex, nip05)) => self.set_recipient_from_hex(wallet, &hex, nip05), + Err(e) => self.error = Some(e), + } + } } fn set_recipient_from_hex(&mut self, wallet: &Wallet, hex: &str, nip05: Option) { @@ -345,6 +383,7 @@ impl SendFlow { ui.add_space(16.0); // Note field. + let mut note_focused = false; w::card(ui, |ui| { ui.horizontal(|ui| { ui.label( @@ -357,7 +396,7 @@ impl SendFlow { .hint_text("Add a note…") .desired_width(ui.available_width()) .frame(false); - ui.add(edit); + note_focused = ui.add(edit).has_focus(); }); }); ui.add_space(12.0); @@ -365,7 +404,9 @@ impl SendFlow { // Numpad (mobile) — desktop also accepts keyboard via egui events. if !View::is_desktop() { w::numpad(ui, &mut self.amount); - } else { + } else if !note_focused { + // Only consume keystrokes for the amount when the note field is + // not focused, so typing a note doesn't also edit the amount. ui.input(|i| { for ev in &i.events { if let egui::Event::Text(txt) = ev { @@ -431,6 +472,7 @@ impl SendFlow { if !self.note.trim().is_empty() { w::info_row(ui, "Note", &format!("\u{201C}{}\u{201D}", self.note.trim())); } + w::info_row(ui, "Network fee", "deducted from your balance"); w::info_row(ui, "Privacy", "Mimblewimble"); w::info_row(ui, "Delivery", "Encrypted nostr DM over Tor"); ui.add_space(16.0); @@ -459,6 +501,11 @@ impl SendFlow { } else { Some(self.note.trim().to_string()) }; + // Reset the send phase to Working so sending_ui waits for the real + // dispatch result rather than flipping to Success prematurely. + if let Some(service) = wallet.nostr_service() { + service.set_send_phase(crate::nostr::send_phase::WORKING); + } wallet.task(WalletTask::NostrSend(amount, recipient.npub.clone(), note)); } } @@ -475,13 +522,55 @@ impl SendFlow { .color(t.text), ); }); - // Move to success once the wallet is no longer creating the send. - if !wallet.send_creating() { - self.stage = Stage::Success; + // Advance only on the real dispatch result: the payment DM must have + // actually been sent (or failed) over the relay, not merely created. + let phase = wallet + .nostr_service() + .map(|s| s.send_phase()) + .unwrap_or(crate::nostr::send_phase::FAILED); + match phase { + crate::nostr::send_phase::SENT => self.stage = Stage::Success, + crate::nostr::send_phase::FAILED => self.stage = Stage::Failed, + _ => {} } ui.ctx().request_repaint(); } + fn failed_ui(&mut self, ui: &mut egui::Ui, wallet: &Wallet) -> bool { + let t = theme::tokens(); + ui.add_space(80.0); + let mut done = false; + ui.vertical_centered(|ui| { + ui.label( + RichText::new(crate::gui::icons::WARNING_CIRCLE) + .font(FontId::new(56.0, fonts::regular())) + .color(t.neg), + ); + ui.add_space(16.0); + ui.label( + RichText::new("Couldn't send") + .font(FontId::new(22.0, fonts::bold())) + .color(t.text), + ); + ui.add_space(6.0); + ui.label( + RichText::new("The payment wasn't delivered. Your grin is safe — try again.") + .font(FontId::new(14.0, fonts::regular())) + .color(t.text_dim), + ); + }); + ui.add_space(24.0); + if w::big_action(ui, "Try again", false).clicked() { + self.dispatch(wallet); + self.stage = Stage::Sending; + } + ui.add_space(10.0); + if w::big_action(ui, "Close", true).clicked() { + done = true; + } + done + } + fn success_ui(&mut self, ui: &mut egui::Ui) -> bool { let t = theme::tokens(); let recipient = self.recipient.clone().unwrap(); diff --git a/src/nostr/client.rs b/src/nostr/client.rs index 094683b..0cc7437 100644 --- a/src/nostr/client.rs +++ b/src/nostr/client.rs @@ -18,7 +18,7 @@ use log::{error, info, warn}; use nostr_sdk::{ Client, Event, EventBuilder, Filter, Keys, Kind, Metadata, PublicKey, RelayPoolNotification, - RelayStatus, Tag, TagKind, Timestamp, + RelayStatus, Tag, TagKind, Timestamp, ToBech32, }; use parking_lot::{Mutex, RwLock}; use std::collections::HashMap; @@ -75,6 +75,16 @@ pub struct NostrService { pub has_new_requests: AtomicBool, /// Per-sender rate limiting state (unix seconds of accepted events). rate: Mutex>>, + /// Current outgoing-send phase for the UI (see [`SendPhase`]). + send_phase: std::sync::atomic::AtomicU8, +} + +/// Phase of the most recent outgoing send, polled by the send UI. +pub mod send_phase { + pub const IDLE: u8 = 0; + pub const WORKING: u8 = 1; + pub const SENT: u8 = 2; + pub const FAILED: u8 = 3; } impl NostrService { @@ -98,6 +108,7 @@ impl NostrService { connected: AtomicBool::new(false), has_new_requests: AtomicBool::new(false), rate: Mutex::new(HashMap::new()), + send_phase: std::sync::atomic::AtomicU8::new(send_phase::IDLE), }) } @@ -111,6 +122,21 @@ impl NostrService { self.identity.read().npub.clone() } + /// Own nsec (secret key) bech32 — for explicit user backup only. + pub fn nsec(&self) -> Option { + self.keys.secret_key().to_bech32().ok() + } + + /// Read the current outgoing-send phase (see [`send_phase`]). + pub fn send_phase(&self) -> u8 { + self.send_phase.load(Ordering::Relaxed) + } + + /// Set the outgoing-send phase (called by the send task + UI). + pub fn set_send_phase(&self, phase: u8) { + self.send_phase.store(phase, Ordering::Relaxed); + } + /// Whether at least one relay is connected. pub fn is_connected(&self) -> bool { self.connected.load(Ordering::Relaxed) diff --git a/src/nostr/mod.rs b/src/nostr/mod.rs index 950f5d7..401b1ae 100644 --- a/src/nostr/mod.rs +++ b/src/nostr/mod.rs @@ -37,6 +37,6 @@ pub mod ingest; pub use ingest::*; mod client; -pub use client::NostrService; +pub use client::{NostrService, send_phase}; pub mod nip05; diff --git a/src/wallet/wallet.rs b/src/wallet/wallet.rs index dcb5f65..6a36338 100644 --- a/src/wallet/wallet.rs +++ b/src/wallet/wallet.rs @@ -2123,6 +2123,7 @@ async fn handle_task(w: &Wallet, t: WalletTask) { return; }; w.send_creating.store(true, Ordering::Relaxed); + service.set_send_phase(crate::nostr::send_phase::WORKING); match w.send(*a, None) { Ok(s) => { sync_wallet_data(&w, false); @@ -2161,6 +2162,7 @@ async fn handle_task(w: &Wallet, t: WalletTask) { contact.unknown = false; service.store.save_contact(&contact); } + service.set_send_phase(crate::nostr::send_phase::SENT); } Err(e) => { error!("nostr send dispatch failed: {e}"); @@ -2176,14 +2178,19 @@ async fn handle_task(w: &Wallet, t: WalletTask) { ))), ); } + service.set_send_phase(crate::nostr::send_phase::FAILED); } } + } else { + // No slatepack text produced — treat as failure. + service.set_send_phase(crate::nostr::send_phase::FAILED); } w.on_task_result(tx, &t); } Err(e) => { error!("nostr send error: {:?}", e); w.send_creating.store(false, Ordering::Relaxed); + service.set_send_phase(crate::nostr::send_phase::FAILED); } } }