diff --git a/locales/de.yml b/locales/de.yml index 5cb9d93..2e25076 100644 --- a/locales/de.yml +++ b/locales/de.yml @@ -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" diff --git a/locales/en.yml b/locales/en.yml index 125092b..aedc8d7 100644 --- a/locales/en.yml +++ b/locales/en.yml @@ -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" diff --git a/locales/fr.yml b/locales/fr.yml index 8ab19a4..37bc554 100644 --- a/locales/fr.yml +++ b/locales/fr.yml @@ -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é" diff --git a/locales/ru.yml b/locales/ru.yml index 3fe83fb..10bbd5c 100644 --- a/locales/ru.yml +++ b/locales/ru.yml @@ -426,6 +426,9 @@ goblin: title: "%{name} запрашивает" approve: "Принять" decline: "Отклонить" + review_title: "Проверить запрос" + hold_to_accept: "Удерживайте, чтобы принять" + hold_accept_hint: "Нажмите и удерживайте, чтобы оплатить запрос" receive: title: "Получить" requesting: "Запрос %{amt}%{tsu} — поделитесь, чтобы получить оплату" diff --git a/locales/tr.yml b/locales/tr.yml index d240925..bcd0b90 100644 --- a/locales/tr.yml +++ b/locales/tr.yml @@ -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ş" diff --git a/locales/zh-CN.yml b/locales/zh-CN.yml index aee107f..0a000b7 100644 --- a/locales/zh-CN.yml +++ b/locales/zh-CN.yml @@ -426,6 +426,9 @@ goblin: title: "%{name} 发起请求" approve: "同意" decline: "拒绝" + review_title: "审核请求" + hold_to_accept: "按住以接受" + hold_accept_hint: "按住以支付此请求" receive: title: "收款" requesting: "正在请求 %{amt}%{tsu} — 分享以收款" diff --git a/src/gui/views/goblin/mod.rs b/src/gui/views/goblin/mod.rs index c9b775b..5d07d51 100644 --- a/src/gui/views/goblin/mod.rs +++ b/src/gui/views/goblin/mod.rs @@ -57,6 +57,13 @@ pub struct GoblinWalletView { receipt: Option, /// Open contact profile by npub hex (full-surface overlay). profile: Option, + /// 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, + /// 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, /// Request ids already approved this session (double-tap guard). approving: std::collections::HashSet, /// 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);