Fix functional bugs found in UI audit
Send flow: - "Sent" success now gates on the real dispatch result (NostrService send phase Working/Sent/Failed) instead of send_creating, which cleared before the DM left. Added a Failed stage with Try again / Close so a failed send is no longer shown as success. - NIP-05 handle resolution moved off the UI thread (was .join()-blocking the render thread for the Tor timeout); now a worker thread writes into a polled slot with an inline "Looking up…" spinner. - Desktop amount entry no longer steals keystrokes from the Note field (guards on the note TextEdit focus). - Review screen now shows a network-fee line. Requests / identity / archive: - Approve is now double-tap safe (per-session approving set + disabled button), closing a double-pay window. - "Backup nostr key" copied the public npub; split into "Copy npub (public)" and "Back up secret key (nsec)" which copies the real secret. - Added inline username claim (availability check + NIP-98 register over Tor, off-thread) for anonymous users; persists nip05 + republishes identity. - Added archive Export / Wipe history controls. - Receive "Slatepack" button now copies the grin1 address; "Scan" tab relabeled "Receive" (no scanner was wired). - Goblin view resets on wallet switch so a half-filled send can't leak across. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
+291
-20
@@ -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<SendFlow>,
|
||||
/// Request ids already approved this session (double-tap guard).
|
||||
approving: std::collections::HashSet<String>,
|
||||
/// Identifier of the wallet this view is bound to (reset on change).
|
||||
wallet_id: Option<String>,
|
||||
/// Inline username-claim state for the Me tab.
|
||||
claim: Option<ClaimState>,
|
||||
}
|
||||
|
||||
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<bool>,
|
||||
result: std::sync::Arc<std::sync::Mutex<Option<ClaimMsg>>>,
|
||||
message: Option<String>,
|
||||
}
|
||||
|
||||
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.
|
||||
|
||||
+108
-19
@@ -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>), String>;
|
||||
|
||||
/// The send flow state.
|
||||
pub struct SendFlow {
|
||||
stage: Stage,
|
||||
@@ -56,6 +60,8 @@ pub struct SendFlow {
|
||||
note: String,
|
||||
hold: HoldToSend,
|
||||
error: Option<String>,
|
||||
/// Async NIP-05 resolution result, written by a worker thread.
|
||||
resolve_slot: std::sync::Arc<std::sync::Mutex<Option<ResolveOutcome>>>,
|
||||
/// 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<String>) {
|
||||
@@ -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();
|
||||
|
||||
+27
-1
@@ -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<HashMap<String, Vec<i64>>>,
|
||||
/// 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<String> {
|
||||
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)
|
||||
|
||||
+1
-1
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user