1
0
forked from GRIN/grim

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:
2ro
2026-06-17 15:08:51 -04:00
parent 84cc9d663b
commit f0b5410c13
7 changed files with 199 additions and 6 deletions
+3
View File
@@ -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"
+3
View File
@@ -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"
+3
View File
@@ -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é"
+3
View File
@@ -426,6 +426,9 @@ goblin:
title: "%{name} запрашивает"
approve: "Принять"
decline: "Отклонить"
review_title: "Проверить запрос"
hold_to_accept: "Удерживайте, чтобы принять"
hold_accept_hint: "Нажмите и удерживайте, чтобы оплатить запрос"
receive:
title: "Получить"
requesting: "Запрос %{amt}%{tsu} — поделитесь, чтобы получить оплату"
+3
View File
@@ -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ş"
+3
View File
@@ -426,6 +426,9 @@ goblin:
title: "%{name} 发起请求"
approve: "同意"
decline: "拒绝"
review_title: "审核请求"
hold_to_accept: "按住以接受"
hold_accept_hint: "按住以支付此请求"
receive:
title: "收款"
requesting: "正在请求 %{amt}%{tsu} — 分享以收款"
+181 -6
View File
@@ -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);