goblin: approving a payment request goes through a hold-to-accept review
Tapping Approve on an incoming request no longer pays immediately — it opens a full-surface review (who's asking, amount, note, live network fee, privacy, delivery) with a hold-to-accept gesture, mirroring the send review. Paying a request is a spend, so it should confirm like one. The NostrPayRequest is dispatched only when the hold completes; decline is unchanged, and an over-balance request disables the accept. New goblin.request.review_title / hold_to_accept / hold_accept_hint across all six locales (drift green).
This commit is contained in:
@@ -426,6 +426,9 @@ goblin:
|
||||
title: "%{name} fordert an"
|
||||
approve: "Annehmen"
|
||||
decline: "Ablehnen"
|
||||
review_title: "Anfrage prüfen"
|
||||
hold_to_accept: "Zum Annehmen halten"
|
||||
hold_accept_hint: "Halte gedrückt, um diese Anfrage zu bezahlen"
|
||||
receive:
|
||||
title: "Empfangen"
|
||||
requesting: "Fordere %{amt}%{tsu} an — teilen, um bezahlt zu werden"
|
||||
|
||||
@@ -426,6 +426,9 @@ goblin:
|
||||
title: "%{name} requests"
|
||||
approve: "Approve"
|
||||
decline: "Decline"
|
||||
review_title: "Review request"
|
||||
hold_to_accept: "Hold to accept"
|
||||
hold_accept_hint: "Press and hold to pay this request"
|
||||
receive:
|
||||
title: "Receive"
|
||||
requesting: "Requesting %{amt}%{tsu} — share to get paid"
|
||||
|
||||
@@ -426,6 +426,9 @@ goblin:
|
||||
title: "%{name} demande"
|
||||
approve: "Approuver"
|
||||
decline: "Refuser"
|
||||
review_title: "Vérifier la demande"
|
||||
hold_to_accept: "Maintenir pour accepter"
|
||||
hold_accept_hint: "Maintenez pour payer cette demande"
|
||||
receive:
|
||||
title: "Recevoir"
|
||||
requesting: "Demande de %{amt}%{tsu} — partagez pour être payé"
|
||||
|
||||
@@ -426,6 +426,9 @@ goblin:
|
||||
title: "%{name} запрашивает"
|
||||
approve: "Принять"
|
||||
decline: "Отклонить"
|
||||
review_title: "Проверить запрос"
|
||||
hold_to_accept: "Удерживайте, чтобы принять"
|
||||
hold_accept_hint: "Нажмите и удерживайте, чтобы оплатить запрос"
|
||||
receive:
|
||||
title: "Получить"
|
||||
requesting: "Запрос %{amt}%{tsu} — поделитесь, чтобы получить оплату"
|
||||
|
||||
@@ -426,6 +426,9 @@ goblin:
|
||||
title: "%{name} istiyor"
|
||||
approve: "Onayla"
|
||||
decline: "Reddet"
|
||||
review_title: "İsteği incele"
|
||||
hold_to_accept: "Kabul için basılı tut"
|
||||
hold_accept_hint: "Bu isteği ödemek için basılı tutun"
|
||||
receive:
|
||||
title: "Al"
|
||||
requesting: "%{amt}%{tsu} isteniyor — ödeme almak için paylaş"
|
||||
|
||||
@@ -426,6 +426,9 @@ goblin:
|
||||
title: "%{name} 发起请求"
|
||||
approve: "同意"
|
||||
decline: "拒绝"
|
||||
review_title: "审核请求"
|
||||
hold_to_accept: "按住以接受"
|
||||
hold_accept_hint: "按住以支付此请求"
|
||||
receive:
|
||||
title: "收款"
|
||||
requesting: "正在请求 %{amt}%{tsu} — 分享以收款"
|
||||
|
||||
+181
-6
@@ -57,6 +57,13 @@ pub struct GoblinWalletView {
|
||||
receipt: Option<u32>,
|
||||
/// Open contact profile by npub hex (full-surface overlay).
|
||||
profile: Option<String>,
|
||||
/// Request being reviewed before payment (full-surface hold-to-accept
|
||||
/// overlay); approving a request goes through this, not a one-tap pay.
|
||||
approve_review: Option<crate::nostr::PaymentRequest>,
|
||||
/// Hold-to-accept gesture state for the request-review screen.
|
||||
approve_hold: w::HoldToSend,
|
||||
/// Amount the last CalculateFee was requested for on the review screen.
|
||||
approve_fee_for: Option<u64>,
|
||||
/// Request ids already approved this session (double-tap guard).
|
||||
approving: std::collections::HashSet<String>,
|
||||
/// Why the last approve failed (e.g. funds confirming), shown above the
|
||||
@@ -172,6 +179,9 @@ impl Default for GoblinWalletView {
|
||||
send: None,
|
||||
receipt: None,
|
||||
profile: None,
|
||||
approve_review: None,
|
||||
approve_hold: w::HoldToSend::default(),
|
||||
approve_fee_for: None,
|
||||
approving: std::collections::HashSet::new(),
|
||||
request_error: None,
|
||||
wallet_id: None,
|
||||
@@ -382,6 +392,9 @@ impl GoblinWalletView {
|
||||
self.claim = None;
|
||||
self.rotate = None;
|
||||
self.import_nsec = None;
|
||||
self.approve_review = None;
|
||||
self.approve_hold = w::HoldToSend::default();
|
||||
self.approve_fee_for = None;
|
||||
self.approving.clear();
|
||||
self.request_error = None;
|
||||
self.pay_amount.clear();
|
||||
@@ -418,6 +431,14 @@ impl GoblinWalletView {
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Approving a request opens a full-surface review with hold-to-accept.
|
||||
if self.approve_review.is_some() {
|
||||
if self.approve_review_ui(ui, wallet) {
|
||||
self.approve_review = None;
|
||||
self.approve_fee_for = None;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Desktop (wide) shows a left sidebar (shell B); narrow/mobile shows a
|
||||
// bottom tab bar (shell A). Both drive the same Tab state and screens.
|
||||
@@ -1957,13 +1978,13 @@ impl GoblinWalletView {
|
||||
ui.ctx().request_repaint();
|
||||
}
|
||||
} else 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());
|
||||
// Don't pay on the tap — open the review screen and make
|
||||
// the user hold-to-accept there, like a send. The actual
|
||||
// NostrPayRequest is dispatched from approve_review_ui.
|
||||
self.request_error = None;
|
||||
wallet.task(crate::wallet::types::WalletTask::NostrPayRequest(
|
||||
req.rumor_id.clone(),
|
||||
));
|
||||
self.approve_hold = w::HoldToSend::default();
|
||||
self.approve_fee_for = None;
|
||||
self.approve_review = Some(req.clone());
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -1972,6 +1993,160 @@ impl GoblinWalletView {
|
||||
ui.add_space(10.0);
|
||||
}
|
||||
|
||||
/// Full-surface review for an incoming payment request: who's asking, how
|
||||
/// much, the network fee — then hold-to-accept. Paying a request is a spend,
|
||||
/// so this mirrors the send review's confirm gesture instead of a one-tap
|
||||
/// accept. Returns true when the screen should close (back, or after the
|
||||
/// payment is enqueued by the hold).
|
||||
fn approve_review_ui(&mut self, ui: &mut egui::Ui, wallet: &Wallet) -> bool {
|
||||
let t = theme::tokens();
|
||||
let Some(req) = self.approve_review.clone() else {
|
||||
return true;
|
||||
};
|
||||
let (name, hue) = wallet
|
||||
.nostr_service()
|
||||
.map(|s| data::contact_title(&s.store, &req.npub))
|
||||
.unwrap_or_else(|| (data::short_npub(&req.npub), 0));
|
||||
let tex = self.handle_tex(ui.ctx(), wallet, &name);
|
||||
// Paying a request spends our balance, so guard against over-balance and
|
||||
// disable the accept gesture (re-checked each frame).
|
||||
let spendable = wallet
|
||||
.get_data()
|
||||
.map(|d| d.info.amount_currently_spendable)
|
||||
.unwrap_or(0);
|
||||
let over = req.amount > spendable;
|
||||
let mut close = false;
|
||||
egui::CentralPanel::default()
|
||||
.frame(egui::Frame {
|
||||
fill: t.bg,
|
||||
inner_margin: Margin {
|
||||
left: (View::far_left_inset_margin(ui) + 20.0) as i8,
|
||||
right: (View::get_right_inset() + 20.0) as i8,
|
||||
top: (View::get_top_inset() + 12.0) as i8,
|
||||
bottom: (View::get_bottom_inset() + 12.0) as i8,
|
||||
},
|
||||
..Default::default()
|
||||
})
|
||||
.show_inside(ui, |ui| {
|
||||
w::centered_column(ui, Content::SIDE_PANEL_WIDTH * 1.2, |ui| {
|
||||
if Self::overlay_back_header(ui, &t!("goblin.request.review_title")) {
|
||||
close = true;
|
||||
}
|
||||
ScrollArea::vertical()
|
||||
.auto_shrink([false; 2])
|
||||
.scroll_bar_visibility(egui::scroll_area::ScrollBarVisibility::AlwaysHidden)
|
||||
.show(ui, |ui| {
|
||||
ui.add_space(8.0);
|
||||
w::card(ui, |ui| {
|
||||
ui.set_min_width(ui.available_width());
|
||||
ui.add_space(8.0);
|
||||
ui.vertical_centered(|ui| {
|
||||
w::avatar_any(ui, &name, &req.npub, 40.0, hue, tex.as_ref());
|
||||
ui.add_space(6.0);
|
||||
ui.label(
|
||||
RichText::new(t!("goblin.request.title", name => &name))
|
||||
.font(FontId::new(14.0, fonts::regular()))
|
||||
.color(t.surface_text_dim),
|
||||
);
|
||||
});
|
||||
ui.add_space(8.0);
|
||||
let amt = w::amount_str(req.amount);
|
||||
w::amount_text_centered_ink(
|
||||
ui,
|
||||
&amt,
|
||||
48.0,
|
||||
t.surface_text,
|
||||
t.surface_text_dim,
|
||||
);
|
||||
ui.add_space(8.0);
|
||||
});
|
||||
ui.add_space(16.0);
|
||||
|
||||
w::info_row(ui, &t!("goblin.send.row_from"), &name);
|
||||
if let Some(note) = &req.note {
|
||||
if !note.trim().is_empty() {
|
||||
w::info_row(
|
||||
ui,
|
||||
&t!("goblin.send.row_note"),
|
||||
&format!("\u{201C}{}\u{201D}", note.trim()),
|
||||
);
|
||||
}
|
||||
}
|
||||
// Live network fee for paying this request (a spend),
|
||||
// priced like the send review — one CalculateFee per amount.
|
||||
if req.amount > 0 && self.approve_fee_for != Some(req.amount) {
|
||||
self.approve_fee_for = Some(req.amount);
|
||||
wallet.task(crate::wallet::types::WalletTask::CalculateFee(
|
||||
req.amount, 0,
|
||||
));
|
||||
}
|
||||
let fee_val = match wallet.calculated_fee(req.amount) {
|
||||
Some(fee) => format!("{} {}", w::amount_str(fee), w::TSU),
|
||||
None => {
|
||||
ui.ctx().request_repaint_after(
|
||||
std::time::Duration::from_millis(120),
|
||||
);
|
||||
"…".to_string()
|
||||
}
|
||||
};
|
||||
w::info_row(ui, &t!("goblin.send.row_network_fee"), &fee_val);
|
||||
w::info_row(
|
||||
ui,
|
||||
&t!("goblin.send.row_privacy"),
|
||||
&t!("goblin.send.row_privacy_val"),
|
||||
);
|
||||
w::info_row(
|
||||
ui,
|
||||
&t!("goblin.send.row_delivery"),
|
||||
&t!("goblin.send.row_delivery_val"),
|
||||
);
|
||||
ui.add_space(16.0);
|
||||
|
||||
if over {
|
||||
ui.vertical_centered(|ui| {
|
||||
ui.label(
|
||||
RichText::new(t!("goblin.send.not_enough"))
|
||||
.font(FontId::new(14.0, fonts::regular()))
|
||||
.color(t.neg),
|
||||
);
|
||||
});
|
||||
ui.add_space(8.0);
|
||||
}
|
||||
ui.add_enabled_ui(!over, |ui| {
|
||||
if self
|
||||
.approve_hold
|
||||
.ui(ui, &t!("goblin.request.hold_to_accept"))
|
||||
&& !over
|
||||
{
|
||||
// Guard double-pay + show the spinner back on the
|
||||
// request card; dispatch the actual payment.
|
||||
self.approving.insert(req.rumor_id.clone());
|
||||
self.request_error = None;
|
||||
wallet.task(crate::wallet::types::WalletTask::NostrPayRequest(
|
||||
req.rumor_id.clone(),
|
||||
));
|
||||
close = true;
|
||||
}
|
||||
});
|
||||
ui.add_space(6.0);
|
||||
ui.vertical_centered(|ui| {
|
||||
ui.label(
|
||||
RichText::new(if over {
|
||||
t!("goblin.send.lower_amount")
|
||||
} else {
|
||||
t!("goblin.request.hold_accept_hint")
|
||||
})
|
||||
.font(FontId::new(12.0, fonts::regular()))
|
||||
.color(t.text_mute),
|
||||
);
|
||||
});
|
||||
ui.add_space(16.0);
|
||||
});
|
||||
});
|
||||
});
|
||||
close
|
||||
}
|
||||
|
||||
fn receive_ui(&mut self, ui: &mut egui::Ui, wallet: &Wallet, cb: &dyn PlatformCallbacks) {
|
||||
let t = theme::tokens();
|
||||
ui.add_space(8.0);
|
||||
|
||||
Reference in New Issue
Block a user