diff --git a/locales/de.yml b/locales/de.yml index 5a2e1845..299d5be9 100644 --- a/locales/de.yml +++ b/locales/de.yml @@ -770,6 +770,11 @@ goblin: pass_prompt: "Gib dein Wallet-Passwort zum Bestätigen ein." confirm_hold: "Halten zum Bestätigen" encrypt_desc: "Eine verschlüsselte Bestellnachricht, die eine Zahlung zusichert." + lock: + title: "Wallet gesperrt" + subtitle: "Zahlungen kommen auch im gesperrten Zustand weiter an. Gib dein Passwort ein, um zu entsperren und dein Guthaben zu nutzen." + unlock: "Entsperren" + close_wallet: "Wallet schließen" trusted_sites: title: "Vertrauenswürdige Seiten" intro: "Seiten, denen du in dieser Sitzung vertraut hast, können risikoarme Aktionen ohne Nachfrage signieren. Bei Geld wird immer gefragt. Beende jede Sitzung hier." diff --git a/locales/en.yml b/locales/en.yml index e058f6f3..14b67f1a 100644 --- a/locales/en.yml +++ b/locales/en.yml @@ -770,6 +770,11 @@ goblin: pass_prompt: "Enter your wallet password to confirm." confirm_hold: "Hold to confirm" encrypt_desc: "An encrypted order message that commits to a payment." + lock: + title: "Wallet locked" + subtitle: "Payments keep arriving while locked. Enter your password to unlock and use your funds." + unlock: "Unlock" + close_wallet: "Close wallet" trusted_sites: title: "Trusted Sites" intro: "Sites you trusted this session can sign low-risk actions without asking. Money always asks. End any session here." diff --git a/locales/es.yml b/locales/es.yml index 3b06982e..7deea9ab 100644 --- a/locales/es.yml +++ b/locales/es.yml @@ -770,6 +770,11 @@ goblin: pass_prompt: "Ingresa tu contraseña de billetera para confirmar." confirm_hold: "Mantén presionado para confirmar" encrypt_desc: "Un mensaje de pedido cifrado que compromete un pago." + lock: + title: "Billetera bloqueada" + subtitle: "Los pagos siguen llegando mientras está bloqueada. Ingresa tu contraseña para desbloquear y usar tus fondos." + unlock: "Desbloquear" + close_wallet: "Cerrar billetera" trusted_sites: title: "Sitios de confianza" intro: "Los sitios en los que confiaste esta sesión pueden firmar acciones de bajo riesgo sin preguntar. El dinero siempre pregunta. Termina cualquier sesión aquí." diff --git a/locales/fr.yml b/locales/fr.yml index 4c32cfc1..75f247fa 100644 --- a/locales/fr.yml +++ b/locales/fr.yml @@ -770,6 +770,11 @@ goblin: pass_prompt: "Saisissez le mot de passe de votre portefeuille pour confirmer." confirm_hold: "Maintenez pour confirmer" encrypt_desc: "Un message de commande chiffré qui engage un paiement." + lock: + title: "Portefeuille verrouillé" + subtitle: "Les paiements continuent d'arriver pendant le verrouillage. Saisissez votre mot de passe pour déverrouiller et utiliser vos fonds." + unlock: "Déverrouiller" + close_wallet: "Fermer le portefeuille" trusted_sites: title: "Sites de confiance" intro: "Les sites auxquels vous avez fait confiance cette session peuvent signer des actions à faible risque sans demander. L'argent demande toujours. Terminez toute session ici." diff --git a/locales/ja.yml b/locales/ja.yml index 85b44db0..f4232402 100644 --- a/locales/ja.yml +++ b/locales/ja.yml @@ -770,6 +770,11 @@ goblin: pass_prompt: "確認するにはウォレットのパスワードを入力してください。" confirm_hold: "長押しで確認" encrypt_desc: "支払いを確定する、暗号化された注文メッセージです。" + lock: + title: "ウォレットをロック中" + subtitle: "ロック中も支払いは届き続けます。パスワードを入力してロックを解除し、残高を使えるようにします。" + unlock: "ロック解除" + close_wallet: "ウォレットを閉じる" trusted_sites: title: "信頼済みサイト" intro: "このセッションで信頼したサイトは、リスクの低い操作を確認なしで署名できます。お金が動く操作は必ず確認します。ここでいつでもセッションを終了できます。" diff --git a/locales/ko.yml b/locales/ko.yml index 7ac5d804..d6647650 100644 --- a/locales/ko.yml +++ b/locales/ko.yml @@ -770,6 +770,11 @@ goblin: pass_prompt: "확인하려면 지갑 비밀번호를 입력하세요." confirm_hold: "길게 눌러서 확인" encrypt_desc: "결제를 확정하는 암호화된 주문 메시지입니다." + lock: + title: "지갑 잠김" + subtitle: "잠긴 동안에도 결제는 계속 도착합니다. 비밀번호를 입력해 잠금을 풀고 자금을 사용하세요." + unlock: "잠금 해제" + close_wallet: "지갑 닫기" trusted_sites: title: "신뢰하는 사이트" intro: "이번 세션에 신뢰한 사이트는 확인 없이 위험도가 낮은 작업에 서명할 수 있습니다. 돈이 오갈 때는 항상 확인합니다. 여기에서 언제든 세션을 종료할 수 있습니다." diff --git a/locales/ru.yml b/locales/ru.yml index e5531f78..207baf4e 100644 --- a/locales/ru.yml +++ b/locales/ru.yml @@ -770,6 +770,11 @@ goblin: pass_prompt: "Введите пароль кошелька для подтверждения." confirm_hold: "Удерживайте для подтверждения" encrypt_desc: "Зашифрованное сообщение заказа, фиксирующее платёж." + lock: + title: "Кошелёк заблокирован" + subtitle: "Платежи продолжают поступать, пока кошелёк заблокирован. Введите пароль, чтобы разблокировать и распоряжаться средствами." + unlock: "Разблокировать" + close_wallet: "Закрыть кошелёк" trusted_sites: title: "Доверенные сайты" intro: "Сайты, которым вы доверились в этой сессии, могут подписывать действия с низким риском без запроса. Деньги всегда запрашивают подтверждение. Завершите любую сессию здесь." diff --git a/locales/tr.yml b/locales/tr.yml index 023cc181..f810e1a5 100644 --- a/locales/tr.yml +++ b/locales/tr.yml @@ -770,6 +770,11 @@ goblin: pass_prompt: "Onaylamak için cüzdan parolanızı girin." confirm_hold: "Onaylamak için basılı tutun" encrypt_desc: "Bir ödemeyi bağlayan şifreli sipariş mesajı." + lock: + title: "Cüzdan kilitli" + subtitle: "Kilitliyken ödemeler gelmeye devam eder. Kilidi açıp bakiyeni kullanmak için parolanı gir." + unlock: "Kilidi aç" + close_wallet: "Cüzdanı kapat" trusted_sites: title: "Güvenilen Siteler" intro: "Bu oturumda güvendiğiniz siteler, düşük riskli eylemleri sormadan imzalayabilir. Para her zaman sorar. Herhangi bir oturumu buradan sonlandırın." diff --git a/locales/zh-CN.yml b/locales/zh-CN.yml index eed18e70..f40d7a39 100644 --- a/locales/zh-CN.yml +++ b/locales/zh-CN.yml @@ -770,6 +770,11 @@ goblin: pass_prompt: "输入钱包密码以确认。" confirm_hold: "长按以确认" encrypt_desc: "一条锁定付款的加密订单消息。" + lock: + title: "钱包已锁定" + subtitle: "锁定期间付款仍会到账。输入密码即可解锁并使用资金。" + unlock: "解锁" + close_wallet: "关闭钱包" trusted_sites: title: "受信任网站" intro: "本次会话中你信任的网站可在不询问的情况下签署低风险操作。资金操作始终询问。可在此结束任意会话。" diff --git a/locales/zh-TW.yml b/locales/zh-TW.yml index 7279e21c..04122b71 100644 --- a/locales/zh-TW.yml +++ b/locales/zh-TW.yml @@ -770,6 +770,11 @@ goblin: pass_prompt: "輸入錢包密碼以確認。" confirm_hold: "長按以確認" encrypt_desc: "一條鎖定付款的加密訂單訊息。" + lock: + title: "錢包已鎖定" + subtitle: "鎖定期間付款仍會到帳。輸入密碼即可解鎖並使用資金。" + unlock: "解鎖" + close_wallet: "關閉錢包" trusted_sites: title: "受信任網站" intro: "本次會話中你信任的網站可在不詢問的情況下籤署低風險操作。資金操作始終詢問。可在此結束任意會話。" diff --git a/src/gui/views/goblin/me.rs b/src/gui/views/goblin/me.rs index d6f3b8d2..2a672c09 100644 --- a/src/gui/views/goblin/me.rs +++ b/src/gui/views/goblin/me.rs @@ -546,7 +546,11 @@ impl GoblinWalletView { ) .clicked() { - wallet.close(); + // Lock — NOT close. The wallet stays open and its nostr relays + // stay connected (payments keep arriving and queue up), but the + // whole surface is replaced by the unlock screen and the money + // seed is sealed until the password is re-entered. + wallet.lock(); } ui.add_space(10.0); if w::outlined_icon_action( @@ -563,4 +567,126 @@ impl GoblinWalletView { ui.add_space(20.0); }); } + + /// The money-path unlock screen, shown full-surface whenever the wallet is + /// locked (`wallet.is_locked()`). It is drawn as an early return from + /// [`GoblinWalletView::ui`], so it covers every tab, header, overlay and + /// pending deep link — no money action is reachable behind it. It offers + /// exactly two ways out: unlock in place (the relays stayed connected, so any + /// payments that queued while locked drain right after), or fully close the + /// wallet (the old lock-button behavior: stops the nostr service and seals + /// the seed to disk). + pub(super) fn lock_screen_ui( + &mut self, + ui: &mut egui::Ui, + wallet: &Wallet, + cb: &dyn PlatformCallbacks, + ) { + let t = theme::tokens(); + 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| { + ScrollArea::vertical() + .id_salt("goblin_lock_scroll") + .auto_shrink([false; 2]) + .scroll_bar_visibility(egui::scroll_area::ScrollBarVisibility::AlwaysHidden) + .show(ui, |ui| { + ui.add_space(48.0); + ui.vertical_centered(|ui| { + ui.label( + RichText::new(crate::gui::icons::LOCK_KEY) + .font(FontId::new(56.0, fonts::regular())) + .color(t.surface_text), + ); + ui.add_space(16.0); + ui.label( + RichText::new(t!("goblin.lock.title")) + .size(22.0) + .color(Colors::title(false)), + ); + ui.add_space(8.0); + ui.label( + RichText::new(t!("goblin.lock.subtitle")) + .size(14.0) + .color(Colors::gray()), + ); + }); + ui.add_space(24.0); + + let mut field = + TextEdit::new(egui::Id::new("goblin_lock_pass")).password(); + field.ui(ui, &mut self.lock_pass, cb); + // Wrong-password line, cleared the moment the field empties. + if self.lock_pass.is_empty() { + self.lock_wrong = false; + } else if self.lock_wrong { + ui.add_space(10.0); + ui.vertical_centered(|ui| { + ui.label( + RichText::new(t!("goblin.advanced.wrong_password")) + .size(15.0) + .color(Colors::red()), + ); + }); + } + ui.add_space(18.0); + + // Enter submits, matching the wallet-open password modal. + let mut unlock = ui.input(|i| i.key_pressed(egui::Key::Enter)) + && !self.lock_pass.is_empty(); + ui.vertical_centered_justified(|ui| { + View::colored_text_button( + ui, + t!("goblin.lock.unlock").to_string(), + t.accent_ink, + t.accent, + || { + unlock = true; + }, + ); + }); + ui.add_space(10.0); + ui.vertical_centered_justified(|ui| { + View::button( + ui, + t!("goblin.lock.close_wallet"), + Colors::white_or_black(false), + || { + self.lock_pass.clear(); + self.lock_wrong = false; + wallet.close(); + }, + ); + }); + ui.add_space(24.0); + + if unlock { + if self.lock_pass.is_empty() + || !wallet.verify_nostr_password(&self.lock_pass) + { + self.lock_wrong = true; + } else { + // Verified WITHOUT re-opening — the wallet is still + // open, only the money path was sealed. Clearing the + // flag lets the service loop drain queued payments and + // the sync loop restart the Foreign API listener. + wallet.unlock(); + self.lock_pass.clear(); + self.lock_wrong = false; + } + } + }); + }); + }); + } } diff --git a/src/gui/views/goblin/mod.rs b/src/gui/views/goblin/mod.rs index 67554ae1..19a866e1 100644 --- a/src/gui/views/goblin/mod.rs +++ b/src/gui/views/goblin/mod.rs @@ -14,11 +14,28 @@ //! The Goblin payment-app-style wallet surface for an open wallet. +mod activity; pub mod avatars; pub mod data; +mod format; +mod helpers; +mod home; pub mod identicon; +mod identities; +mod me; +mod modals; pub mod onboarding; +mod pay; +mod privacy; +mod profile; +mod prompts; +mod receipt; +mod receive; pub mod send; +mod settings; +mod settings_advanced; +mod settings_node; +mod username; pub mod widgets; use eframe::epaint::{CornerRadius, FontId, Stroke}; @@ -39,6 +56,11 @@ use self::data::{ActivityItem, activity_items, news_latest, recent_peers, split_ use self::send::SendFlow; use self::widgets as w; +use self::format::*; +use self::helpers::*; +use self::privacy::*; +use self::username::{availability_feedback, start_claim_flow}; + /// Goblin navigation tabs. The mobile bar shows Home / Pay / Activity; /// Receive and Me stay reachable (Pay's Request action, header avatar, /// desktop sidebar). @@ -75,10 +97,6 @@ pub struct GoblinWalletView { wallet_id: Option, /// Inline username-claim state for the Me tab. claim: Option, - /// Inline key-rotation state for the Me tab. - rotate: Option, - /// Inline nsec-import state for the Me tab. - import_nsec: Option, /// Inline "back up identity to a file" flow state. backup: Option, /// Identity switcher (one wallet, many nostr identities) page state. @@ -149,6 +167,31 @@ pub struct GoblinWalletView { /// Quiet toast for the login outcome: text and when it appeared. Reused for /// the authorize outcome (same quiet pill, different strings). login_toast: Option<(String, std::time::Instant)>, + /// A "Trust with Goblin" (Authorize Sessions) grant awaiting approval. While + /// this OR `login`/`authorize`/`money` is `Some`, new incoming requests are + /// ignored (one approval at a time). + trust: Option, + /// A granted trust whose `session-open` announce is still unconfirmed: the + /// toast and any return-to-caller wait here until the service loop confirms + /// the publish reached a relay (or the deadline passes). + trust_wait: Option, + /// The hold-to-confirm gesture for the single high-value trust decision. + trust_hold: w::HoldToSend, + /// A money-tier sign or encrypt arriving mid-session that must be + /// password-approved per action (the v1-style prompt raised over the channel). + money: Option, + /// The hold-to-confirm gesture for a money-tier approval. + money_hold: w::HoldToSend, + /// Anonymous mode: whether the home balance has been tapped to reveal this + /// visit. Presentation-only and transient — reset whenever the user leaves + /// the Home tab so a later glance is censored again. + balance_revealed: bool, + /// Money-path unlock screen: the password typed to unlock. While + /// `wallet.is_locked()` the whole surface is replaced by the unlock screen; + /// this holds the in-progress entry, cleared the moment it is consumed. + lock_pass: String, + /// The last unlock attempt used a wrong password — show the red line. + lock_wrong: bool, } /// Whether the per-identity cue is drawn on activity rows (owner-approved). The @@ -158,6 +201,11 @@ pub struct GoblinWalletView { /// each payment used. Only shown when the wallet holds more than one identity. const SHOW_ROW_IDENTITY_CUE: bool = true; +/// Known name authorities offered on the Username page as a tappable list, on top +/// of the free-typed custom entry. Kept to servers we actually run; anything else +/// goes through the custom field. `(display, base URL)`. +const KNOWN_AUTHORITIES: &[(&str, &str)] = &[("goblin.st", "https://goblin.st")]; + /// Per-frame identity context for the activity rows: whether the wallet holds /// more than one identity (the cue only shows then) and the primary identity's /// pubkey hex (the seed for pre-feature rows that carry no owner tag). Computed @@ -177,8 +225,21 @@ impl IdentityCueCtx { } } +/// The settings sub-page for the coming frame: reset to the root once the user +/// is off the Settings (Me) tab, otherwise keep the current page. Pure so the +/// leave/enter boundary rule is unit-testable without an egui context. Deep +/// links open a sub-page while setting the tab to Me in the same frame, so they +/// are preserved. +fn settings_page_after(tab: Tab, page: SettingsPage) -> SettingsPage { + if tab == Tab::Me { + page + } else { + SettingsPage::Main + } +} + /// Sub-pages of the Settings tab. -#[derive(Clone, Copy, PartialEq, Eq)] +#[derive(Clone, Copy, PartialEq, Eq, Debug)] enum SettingsPage { Main, Node, @@ -190,9 +251,18 @@ enum SettingsPage { Language, Slatepack, Privacy, + /// Everything username: claim, release, and the name authority (known list + /// plus a free-typed custom one). The single home for names. + Username, + /// Notification privacy (hide amounts / names / all details) plus the + /// anonymous-mode toggle that dots the home balance and activity list. + AdvancedPrivacy, Advanced, /// The identity switcher: one wallet, one balance, many nostr identities. Identities, + /// Trusted Sites: the active Authorize Sessions, what each can sign, and a + /// one-tap end (revocation). + TrustedSites, } /// Inline state for the Advanced (wallet-recovery) settings page: the @@ -220,6 +290,10 @@ struct AdvancedState { confirm_repair: bool, /// Armed "really delete?" confirm. confirm_delete: bool, + /// Wallet password typed into the Danger Zone delete gate. + delete_pass: String, + /// The delete password didn't decrypt the seed — show the wrong-password line. + delete_wrong: bool, } /// Inputs and last result for the manual slatepack page (GRIM's native flow, @@ -254,8 +328,6 @@ impl Default for GoblinWalletView { request_error: None, wallet_id: None, claim: None, - rotate: None, - import_nsec: None, backup: None, identity_switch: IdentitySwitchState::default(), name_authority: None, @@ -284,60 +356,14 @@ impl Default for GoblinWalletView { login: None, authorize: None, login_toast: None, - } - } -} - -/// Inline key-rotation flow state (two warnings + typed confirmation). -struct RotateState { - /// 1 = warning, 2 = confirm (RESET + password), 3 = working, - /// 4 = done (new npub), 5 = error (message). - stage: u8, - reset_input: String, - password: String, - new_npub: String, - error: String, - result: std::sync::Arc>>>, -} - -impl Default for RotateState { - fn default() -> Self { - Self { - stage: 1, - reset_input: String::new(), - password: String::new(), - new_npub: String::new(), - error: String::new(), - result: std::sync::Arc::new(std::sync::Mutex::new(None)), - } - } -} - -/// Inline nsec-import flow state (restore path for the random-key model). -struct ImportState { - /// 1 = form, 3 = working, 4 = done, 5 = error. - stage: u8, - nsec: String, - password: String, - backup_password: String, - new_npub: String, - error: String, - /// A native file pick is in flight (Android returns the path asynchronously). - picking: bool, - result: std::sync::Arc>>>, -} - -impl Default for ImportState { - fn default() -> Self { - Self { - stage: 1, - nsec: String::new(), - password: String::new(), - backup_password: String::new(), - new_npub: String::new(), - error: String::new(), - picking: false, - result: std::sync::Arc::new(std::sync::Mutex::new(None)), + trust: None, + trust_wait: None, + trust_hold: w::HoldToSend::default(), + money: None, + money_hold: w::HoldToSend::default(), + balance_revealed: false, + lock_pass: String::new(), + lock_wrong: false, } } } @@ -381,6 +407,30 @@ const LOGIN_EXPIRY_SECS: u64 = 120; /// The login callback POST gives up after this many seconds. const LOGIN_POST_TIMEOUT_SECS: u64 = 15; +/// How long the trust flow waits for the `session-open` announce to be +/// confirmed accepted by the site's relay before giving up with an honest +/// toast. The service loop ticks every 2s and re-publishes until a hint relay +/// confirms, so this covers a cold Tor circuit to a relay we weren't already +/// connected to (which can take well over 15s on the first reach). +const TRUST_ANNOUNCE_TIMEOUT_SECS: u64 = 30; + +/// A granted trust waiting on its `session-open` announce confirmation. The +/// success toast and the return-to-caller decision (same-device flows) are +/// deferred until [`crate::nostr::NostrService::session_announced`] turns true +/// for `channel_pk`, or `deadline` passes. Keeping the app foreground for this +/// window is what fixes the Build 153 QR-trust bug: backgrounding stops the +/// frame pump and strands the announce in the paused service. +struct TrustWait { + /// The wallet channel pubkey (hex) identifying the announced session. + channel_pk: String, + /// The granted domain, for the toast. + domain: String, + /// Give-up time for the confirmation wait. + deadline: std::time::Instant, + /// The parsed `rt` flag: whether a completed flow may hand focus back. + want_return: bool, +} + /// One pending "Sign in with Goblin" approval. Single-use by construction: /// once the event is signed (`posting`), or on cancel/expiry, the state is /// dropped and only a fresh URI can start another. @@ -429,6 +479,57 @@ struct AuthorizeState { result: std::sync::Arc>>>, } +/// The "Trust with Goblin" (Authorize Sessions) grant modal id. Its verb is +/// "Trust", kept distinct from login's "Sign in" and authorize's "Authorize" so +/// the user always knows which decision they are making. +const TRUST_MODAL: &str = "goblin_trust_modal"; +/// The money-tier per-action approval modal id (a channel request the wallet +/// classified as money, raised mid-session with the same shape as v1 authorize). +const MONEY_MODAL: &str = "goblin_money_modal"; + +/// One pending "Trust with Goblin" grant. Mirrors [`LoginState`] (it folds login +/// in as its identity step, then establishes the session), plus the freshly +/// generated wallet channel keypair carried into session creation on success. +struct TrustState { + /// The validated request (nonce, domain, callback, channel key, relay, kinds). + uri: crate::nostr::trusturi::TrustUri, + /// The CHOSEN signing identity (pubkey hex); defaults to the active one. + selected: String, + /// When the request arrived, for the [`LOGIN_EXPIRY_SECS`] deadline. + created: std::time::Instant, + /// Wallet password typed into the modal; cleared as soon as consumed. + pass: String, + /// The typed password did not verify; the request stays pending. + wrong_pass: bool, + /// The login event is signed and its callback POST is in flight; on success + /// the session is created, on failure the whole grant fails (spec 4.3). + posting: bool, + /// The wallet's ephemeral channel keypair for this session, generated at + /// approval and carried into [`crate::nostr::session::Session::new`]. + channel: nostr_sdk::Keys, + /// A friendly label for the identity (for the Trusted Sites list). + label: String, + /// Whether the collapsed permission detail (categories, money line, + /// duration) is expanded. The modal leads with the one-line gist. + show_full: bool, + /// Result slot the login-POST worker fills. + result: std::sync::Arc>>>, +} + +/// One pending money-tier approval arriving over a live session channel. The +/// user sees exactly what they are committing to and gates it with the wallet +/// password, hold-to-confirm, every time. +struct MoneyState { + /// The request awaiting the user's decision (sign or pay-committing encrypt). + pending: crate::nostr::session::PendingMoney, + /// When it arrived, for the [`LOGIN_EXPIRY_SECS`] deadline. + created: std::time::Instant, + /// Wallet password typed into the modal; cleared as soon as consumed. + pass: String, + /// The typed password did not verify; the request stays pending. + wrong_pass: bool, +} + /// A password-gated identity action the modal executes. Switching no longer uses /// the modal (it is instant and local); only these need the wallet password. #[derive(Clone)] @@ -509,6 +610,9 @@ struct BackupState { error: Option, /// The backup file was created. done: bool, + /// The form was opened from the Danger Zone delete flow, so it renders + /// inline there instead of under the Advanced nostr section. + anchor_delete: bool, } /// Inline username-claim widget state. @@ -529,29 +633,6 @@ enum ClaimMsg { Error(String), } -/// Map an availability probe to user-facing state: `None` availability -/// means the check itself failed — never present that as "Taken". -fn availability_feedback(avail: crate::nostr::nip05::Availability) -> (Option, String) { - use crate::nostr::nip05::Availability::*; - match avail { - Available => ( - Some(true), - t!("goblin.settings.avail_available").to_string(), - ), - Taken => (Some(false), t!("goblin.settings.avail_taken").to_string()), - Reserved => ( - Some(false), - t!("goblin.settings.avail_reserved").to_string(), - ), - Invalid => (Some(false), t!("goblin.settings.avail_invalid").to_string()), - Quarantined => ( - Some(false), - t!("goblin.settings.avail_quarantined").to_string(), - ), - Unknown => (None, t!("goblin.settings.avail_unknown").to_string()), - } -} - impl Default for ClaimState { fn default() -> Self { Self { @@ -680,6 +761,17 @@ impl GoblinWalletView { pub fn ui(&mut self, ui: &mut egui::Ui, wallet: &Wallet, cb: &dyn PlatformCallbacks) { let t = theme::tokens(); + // Money-path lock: while locked, the ENTIRE wallet surface is replaced by + // the unlock screen. Returning here — before any tab, header, deep-link + // intake, modal, or overlay is drawn — is precisely what makes every + // in-app money action (Approve, send, the QR scanner, pending deep links) + // unreachable. There is deliberately no route around this screen: pending + // pay/login/authorize URIs are left un-consumed and fire only after unlock. + if wallet.is_locked() { + self.lock_screen_ui(ui, wallet, cb); + return; + } + // 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(); @@ -690,8 +782,6 @@ impl GoblinWalletView { self.receipt = None; self.profile = None; 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; @@ -707,6 +797,13 @@ impl GoblinWalletView { self.login_toast = None; } + // Settings navigation resets to its ROOT whenever the user is not on the + // Settings (Me) tab, so leaving Settings and coming back always lands on + // the top-level page instead of the last sub-page. One reset at the + // leave/enter boundary — deep links that open a sub-page set the tab to Me + // in the same frame, so they are unaffected (the tab is Me by next frame). + self.settings_page = settings_page_after(self.tab, self.settings_page); + // Transient login-outcome toast (drawn as a Foreground area, so it rides // above whatever surface or overlay is showing). self.login_toast_ui(ui.ctx()); @@ -799,18 +896,25 @@ impl GoblinWalletView { let mut login_outcome = None; if let Some(st) = &self.login { if st.posting { + // The return decision only counts as FRESH within the posting + // window: if the user backgrounded the wallet themselves and the + // result is consumed on a much-later resume, bouncing them back + // out again would be a surprise, not a completion. + let fresh = + st.created.elapsed().as_secs() <= LOGIN_EXPIRY_SECS + LOGIN_POST_TIMEOUT_SECS; login_outcome = st .result .lock() .unwrap() .take() - .map(|res| (res, st.uri.domain.clone())); + .map(|res| (res, st.uri.domain.clone(), st.uri.return_to_caller && fresh)); if login_outcome.is_none() { ui.ctx().request_repaint(); } } } - if let Some((res, domain)) = login_outcome { + if let Some((res, domain, want_return)) = login_outcome { + let posted_ok = res.is_ok(); let text = match res { Ok(()) => t!("goblin.login.sent", domain => domain).to_string(), Err(e) => { @@ -820,6 +924,11 @@ impl GoblinWalletView { }; self.login_toast = Some((text, std::time::Instant::now())); self.login = None; + // The flow is fully complete only NOW (POST result in hand); a + // failed POST keeps the user in the wallet with the honest toast. + if crate::nostr::authuri::should_return_to_caller(want_return, Some(posted_ok), true) { + cb.return_to_caller(); + } } // The sign-in approval modal itself. if Modal::opened() == Some(LOGIN_MODAL) { @@ -872,18 +981,23 @@ impl GoblinWalletView { let mut authorize_outcome = None; if let Some(st) = &self.authorize { if st.posting { + // Same freshness rule as login: a result consumed on a late + // resume must not bounce the user back out. + let fresh = + st.created.elapsed().as_secs() <= LOGIN_EXPIRY_SECS + LOGIN_POST_TIMEOUT_SECS; authorize_outcome = st .result .lock() .unwrap() .take() - .map(|res| (res, st.uri.domain.clone())); + .map(|res| (res, st.uri.domain.clone(), st.uri.return_to_caller && fresh)); if authorize_outcome.is_none() { ui.ctx().request_repaint(); } } } - if let Some((res, domain)) = authorize_outcome { + if let Some((res, domain, want_return)) = authorize_outcome { + let posted_ok = res.is_ok(); let text = match res { Ok(()) => t!("goblin.authorize.sent", domain => domain).to_string(), Err(e) => { @@ -893,6 +1007,11 @@ impl GoblinWalletView { }; self.login_toast = Some((text, std::time::Instant::now())); self.authorize = None; + // Fully complete only NOW (POST result in hand); a failed POST keeps + // the user in the wallet with the honest toast. + if crate::nostr::authuri::should_return_to_caller(want_return, Some(posted_ok), true) { + cb.return_to_caller(); + } } // The authorize approval modal itself. if Modal::opened() == Some(AUTHORIZE_MODAL) { @@ -901,6 +1020,222 @@ impl GoblinWalletView { }); } + // A pending "Trust with Goblin" (Authorize Sessions) grant. One approval + // at a time: while login/authorize/trust/money is pending, drop new URIs. + if let Some(trust) = crate::take_pending_trust() { + if self.login.is_none() + && self.authorize.is_none() + && self.trust.is_none() + && self.money.is_none() + { + if let Some(active) = wallet.active_nostr_pubkey() { + self.send = None; + let label = wallet + .nostr_identities() + .into_iter() + .find(|i| i.pubkey_hex == active) + .map(|i| i.display()) + .unwrap_or_else(|| data::short_npub(&active)); + self.trust = Some(TrustState { + uri: trust, + selected: active, + created: std::time::Instant::now(), + pass: String::new(), + wrong_pass: false, + posting: false, + channel: nostr_sdk::Keys::generate(), + label, + show_full: false, + result: std::sync::Arc::new(std::sync::Mutex::new(None)), + }); + self.trust_hold = w::HoldToSend::default(); + Modal::new(TRUST_MODAL) + .position(ModalPosition::CenterTop) + .title(t!("goblin.trust.title")) + .show(); + } + } + } + // An untouched grant dies after 2 minutes (posting is governed by the POST + // timeout, so it is exempt here). + let trust_expired = matches!(&self.trust, Some(st) + if !st.posting && st.created.elapsed().as_secs() >= LOGIN_EXPIRY_SECS); + if trust_expired { + self.trust = None; + if Modal::opened() == Some(TRUST_MODAL) { + Modal::close(); + } + } + // Poll the login-callback POST. On success the session is created together + // with the identity; on failure the whole grant fails (spec 4.3). + let mut trust_outcome = None; + if let Some(st) = &self.trust { + if st.posting { + trust_outcome = st + .result + .lock() + .unwrap() + .take() + .map(|res| (res, st.uri.domain.clone())); + if trust_outcome.is_none() { + ui.ctx().request_repaint(); + } + } + } + if let Some((res, domain)) = trust_outcome { + let st = self.trust.take(); + let text = match (res, st) { + (Ok(()), Some(st)) => { + let mut session_added = false; + if let Some(svc) = wallet.nostr_service() { + if let (Ok(site_pk), Ok(id_pk)) = ( + nostr_sdk::PublicKey::from_hex(&st.uri.site_session_pubkey), + nostr_sdk::PublicKey::from_hex(&st.selected), + ) { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + let session = crate::nostr::session::Session::new( + st.uri.domain.clone(), + id_pk, + st.label.clone(), + &st.uri.requested_kinds, + &st.channel, + site_pk, + vec![st.uri.relay.clone()], + now, + ); + svc.add_session(session); + session_added = true; + } + } + if session_added { + // The grant is NOT complete yet: the session-open channel + // event still has to reach the relay (the service loop + // publishes it on its next tick). Hold the success toast + // AND the return-to-caller decision until the announce is + // confirmed, or the site never learns the session exists + // (the Build 153 QR-trust bug). + self.trust_wait = Some(TrustWait { + channel_pk: st.channel.public_key().to_hex(), + domain, + deadline: std::time::Instant::now() + + std::time::Duration::from_secs(TRUST_ANNOUNCE_TIMEOUT_SECS), + want_return: st.uri.return_to_caller, + }); + ui.ctx().request_repaint(); + None + } else { + // No service / bad keys: no session, no announce. Show the + // login-sent toast (the 22242 POST did succeed) but never + // return-to-caller on an incomplete grant. + Some(t!("goblin.trust.sent", domain => domain).to_string()) + } + } + (Err(e), _) => { + log::warn!("trust login callback failed: {e}"); + Some(t!("goblin.trust.failed", domain => domain).to_string()) + } + (Ok(()), None) => Some(t!("goblin.trust.sent", domain => domain).to_string()), + }; + if let Some(text) = text { + self.login_toast = Some((text, std::time::Instant::now())); + } + } + // Wait out the session-open announce: the service loop confirms the + // publish reached a relay, then (and only then) the grant is complete, + // the toast shows, and a same-device flow may hand focus back. The + // repaint keeps frames pumping so the app stays foreground while waiting. + let mut trust_wait_done = None; + if let Some(wt) = &self.trust_wait { + let announced = wallet + .nostr_service() + .map(|s| s.session_announced(&wt.channel_pk)) + .unwrap_or(false); + if announced { + // Fresh only within the wait window: an announce observed on a + // late resume (the user backgrounded the wallet themselves) shows + // the toast but must not bounce them back out. + let fresh = std::time::Instant::now() < wt.deadline; + trust_wait_done = Some((true, wt.domain.clone(), wt.want_return && fresh)); + } else if std::time::Instant::now() >= wt.deadline { + trust_wait_done = Some((false, wt.domain.clone(), wt.want_return)); + } else { + ui.ctx().request_repaint(); + } + } + if let Some((announced, domain, want_return)) = trust_wait_done { + self.trust_wait = None; + let text = if announced { + t!("goblin.trust.sent", domain => domain).to_string() + } else { + // Honest: the login went through and the session exists in the + // wallet, but the site has not confirmably received it. + log::warn!("trust session-open announce unconfirmed for {domain}"); + t!("goblin.trust.announce_failed", domain => domain).to_string() + }; + self.login_toast = Some((text, std::time::Instant::now())); + if crate::nostr::authuri::should_return_to_caller(want_return, Some(true), announced) { + cb.return_to_caller(); + } + } + + // A money-tier request arriving over a live session channel raises the + // per-action approval, every time (never silent). One at a time. + if self.money.is_none() + && self.login.is_none() + && self.authorize.is_none() + && self.trust.is_none() + { + if let Some(pending) = wallet.nostr_service().and_then(|s| s.peek_money_prompt()) { + self.money = Some(MoneyState { + pending, + created: std::time::Instant::now(), + pass: String::new(), + wrong_pass: false, + }); + self.money_hold = w::HoldToSend::default(); + Modal::new(MONEY_MODAL) + .position(ModalPosition::CenterTop) + .title(t!("goblin.money.title")) + .show(); + } + } + // An unanswered money prompt times out and declines the action. + let money_expired = matches!(&self.money, Some(st) + if st.created.elapsed().as_secs() >= LOGIN_EXPIRY_SECS); + if money_expired { + if let (Some(st), Some(svc)) = (self.money.as_ref(), wallet.nostr_service()) { + svc.answer_money_prompt(st.pending.id(), false); + } + self.money = None; + if Modal::opened() == Some(MONEY_MODAL) { + Modal::close(); + } + } + // A session volume notice surfaces as the quiet toast, with honest wording + // per capability: heavy DM reading is called out as reading, not signing. + if let Some(kind) = wallet.nostr_service().and_then(|s| s.take_session_notice()) { + let text = if kind == "reading" { + t!("goblin.trust.notice_decrypt").to_string() + } else { + t!("goblin.trust.notice_volume").to_string() + }; + self.login_toast = Some((text, std::time::Instant::now())); + } + // Draw the trust and money modals. + if Modal::opened() == Some(TRUST_MODAL) { + Modal::ui(ui.ctx(), cb, |ui, modal, cb| { + self.trust_modal_content(ui, modal, wallet, cb); + }); + } + if Modal::opened() == Some(MONEY_MODAL) { + Modal::ui(ui.ctx(), cb, |ui, modal, cb| { + self.money_modal_content(ui, modal, wallet, cb); + }); + } + // Send flow takes the full surface when active. if let Some(send) = &mut self.send { let done = send.ui(ui, wallet, cb, &mut self.avatars); @@ -1012,6 +1347,11 @@ impl GoblinWalletView { } else { Content::SIDE_PANEL_WIDTH * 1.2 }; + // Leaving Home re-censors the balance (anonymous mode): the reveal + // is a per-visit tap, never sticky. + if self.tab != Tab::Home { + self.balance_revealed = false; + } w::centered_column(ui, col_width, |ui| match self.tab { Tab::Home => self.home_ui(ui, wallet, cb, wide_desktop), Tab::Pay => self.pay_ui(ui, wallet, cb), @@ -1274,15 +1614,18 @@ impl GoblinWalletView { .color(t.surface_text), ); ui.label( - // Relay-gated: "Connected over Nym" only once a - // relay is live on the current tunnel generation. - RichText::new(if crate::tor::transport_ready() { - t!("goblin.home.connected_nym") - } else if crate::tor::is_ready() { - t!("goblin.home.nym_ready") - } else { - t!("goblin.home.connecting_nym") - }) + // Transport-aware: Tor states are relay-gated, + // while a clearnet wallet reads "Connected + // (direct)" instead of forever "connecting over + // Tor" (which never flips off-Tor). + RichText::new(transport_status_label( + wallet + .nostr_service() + .map(|s| s.transport_status()) + .unwrap_or( + crate::nostr::TransportStatus::ConnectingTor, + ), + )) .font(FontId::new(11.0, fonts::regular())) .color(t.surface_text_mute), ); @@ -1321,6334 +1664,67 @@ impl GoblinWalletView { self.avatars.texture_for(ctx, &server, handle) } - /// Compact node status card: sync state dot, block height, connection. - fn node_card_ui(&mut self, ui: &mut egui::Ui, wallet: &Wallet) { - let t = theme::tokens(); - let height = wallet - .get_data() - .map(|d| d.info.last_confirmed_height) - .unwrap_or(0); - // Distinguish "scanning" from "can't reach the node": a flaky - // external node otherwise reads as syncing forever. - let error = wallet.sync_error(); - let synced = height > 0 && !wallet.syncing() && !error; - w::card(ui, |ui| { - ui.set_min_width(ui.available_width()); - ui.horizontal(|ui| { - let (dot, _) = ui.allocate_exact_size(Vec2::splat(10.0), Sense::hover()); - ui.painter().circle_filled( - dot.center(), - 4.0, - if error { - t.neg - } else if synced { - t.pos - } else { - t.accent - }, - ); - ui.add_space(8.0); - ui.vertical(|ui| { - ui.label( - RichText::new(if error { - t!("goblin.home.cant_reach_node") - } else if synced { - t!("goblin.home.node_synced") - } else { - t!("goblin.home.syncing") - }) - .font(FontId::new(14.0, fonts::semibold())) - .color(t.surface_text), - ); - // Three lines: status, block height, then the node host - // on its own line so it never truncates the height. - let height = wallet - .get_data() - .map(|d| d.info.last_confirmed_height) - .unwrap_or(0); - ui.label( - RichText::new(if height > 0 { - t!("goblin.home.block", height => fmt_thousands(height)).to_string() - } else { - t!("goblin.home.waiting_for_chain").to_string() - }) - .font(FontId::new(12.0, fonts::regular())) - .color(t.surface_text_dim), - ); - ui.add( - egui::Label::new( - RichText::new(node_host(wallet)) - .font(FontId::new(12.0, fonts::regular())) - .color(t.surface_text_mute), - ) - .truncate(), - ); - }); - // Low-opacity gear so the card reads as a tappable settings - // shortcut; on-surface ink keeps it theme-aware. - ui.with_layout(Layout::right_to_left(Align::Center), |ui| { - ui.label( - RichText::new(crate::gui::icons::GEAR) - .font(FontId::new(16.0, fonts::regular())) - .color(t.surface_text.gamma_multiply(0.35)), - ); - }); - }); - }); - } - - fn home_ui( - &mut self, - ui: &mut egui::Ui, - wallet: &Wallet, - cb: &dyn PlatformCallbacks, - wide: bool, - ) { - let data = wallet.get_data(); - ScrollArea::vertical() - .id_salt("goblin_home_scroll") - .auto_shrink([false; 2]) - .scroll_bar_visibility(egui::scroll_area::ScrollBarVisibility::AlwaysHidden) - .show(ui, |ui| { - // Mobile header: wordmark left, avatar (opens settings) right. - if !wide { - ui.add_space(10.0); - let (header_handle, header_hex) = wallet - .nostr_service() - .map(|s| { - let id = s.identity.read(); - let hex = hex_of(&id.npub); - // With a verified handle show "@name"; otherwise fall back to - // the short npub (avatar_any then draws the deterministic - // pubkey-seeded gradient). - let h = id - .nip05 - .clone() - .map(|n| n.split('@').next().unwrap_or("").to_string()) - .unwrap_or_else(|| data::short_npub(&hex)); - (h, hex) - }) - .unwrap_or_else(|| ("N".to_string(), String::new())); - let header_tex = self.handle_tex(ui.ctx(), wallet, &header_handle); - ui.horizontal(|ui| { - widgets_logo(ui); - ui.add_space(8.0); - ui.label( - RichText::new("goblin") - .font(FontId::new(18.0, fonts::bold())) - .color(theme::tokens().text), - ); - ui.with_layout(Layout::right_to_left(Align::Center), |ui| { - if w::avatar_any( - ui, - &header_handle, - &header_hex, - 40.0, - header_tex.as_ref(), - ) - .clicked() - { - self.tab = Tab::Me; - } - // Scan-to-pay, left of the avatar. No frame: a bold white QR - // glyph sized and centered to mirror the Pay-page header - // treatment next to the avatar (was a tacky filled circle). - ui.add_space(12.0); - let (rect, resp) = - ui.allocate_exact_size(Vec2::splat(44.0), Sense::click()); - ui.painter().text( - rect.center(), - egui::Align2::CENTER_CENTER, - QR_CODE, - FontId::new(38.0, fonts::regular()), - theme::tokens().text, - ); - let resp = resp.on_hover_cursor(egui::CursorIcon::PointingHand); - if resp.clicked() { - let mut flow = SendFlow::default(); - flow.request_scan(); - self.send = Some(flow); - } - }); - }); - ui.add_space(28.0); - } else { - ui.add_space(48.0); - } - let (total, spendable) = data - .as_ref() - .map(|d| (d.info.total, d.info.amount_currently_spendable)) - .unwrap_or((0, 0)); - // Zero can just mean "in transit" (locked change / awaiting - // finalization) or a first sync still running. - let in_flight = data - .as_ref() - .map(|d| d.info.amount_locked + d.info.amount_awaiting_finalization) - .unwrap_or(0); - let updating = total == 0 && (in_flight > 0 || wallet.syncing()); - // Distinguish "still updating" from "can't reach the node" so a - // node outage never renders as a silent zero (see balance_hero). - let error = wallet.sync_error(); - w::balance_hero( - ui, - total, - spendable, - updating, - error, - wallet.info_sync_progress(), - fiat_line(&data), - 56.0, - ); - ui.add_space(20.0); - let (send, receive) = w::send_receive(ui); - if send { - self.send = Some(SendFlow::default()); - } - if receive { - self.tab = Tab::Receive; - } - ui.add_space(24.0); - - // Latest news post (hidden entirely when none seen yet). - self.news_panel_ui(ui, wallet); - - // Recent peers strip. - self.peers_strip_ui(ui, wallet, "goblin_peers_home"); - - // Recent activity. - w::kicker(ui, &t!("goblin.home.activity")); - ui.add_space(6.0); - let items = activity_items(wallet); - let id_cue = IdentityCueCtx::compute(wallet); - if items.is_empty() { - empty_state( - ui, - &t!("goblin.home.empty_title"), - &t!("goblin.home.empty_sub"), - ); - } else { - for item in items.iter().take(6) { - self.activity_item_ui(ui, item, wallet, cb, &id_cue); - } - } - ui.add_space(16.0); - }); - } - - /// Latest news post from the Goblin news key. Title + summary in a card, with - /// any http(s) URL in the summary rendered as a tappable link. Renders nothing - /// (early return) when no post has been cached yet — no empty state. - fn news_panel_ui(&mut self, ui: &mut egui::Ui, wallet: &Wallet) { - let Some(news) = news_latest(wallet) else { - return; - }; - let t = theme::tokens(); - w::kicker(ui, &t!("goblin.home.news")); - ui.add_space(8.0); - w::card(ui, |ui| { - // Span the full content width like the balance/activity rows so the - // panel reads as a band, not a content-hugging chip. - ui.set_min_width(ui.available_width()); - // Date first, ISO 8601 (YYYY-MM-DD, UTC). Dated by the article's - // published_at tag when present, else the event's created_at. - let stamp = news.published_at.unwrap_or(news.created_at); - ui.label( - RichText::new(data::news_date_iso(stamp)) - .font(FontId::new(12.0, fonts::medium())) - .color(t.surface_text_dim), - ); - if !news.title.is_empty() { - ui.add_space(2.0); - // Title guardrail: hard-cap the length (predictable ellipsis past - // NEWS_TITLE_MAX_CHARS) then shrink the font to fit the card width on - // one line down to a 12pt floor, so a title never clips. `.truncate()` - // backs the floor for the pathological narrow case. - let title = data::news_title_clamped(&news.title); - let pt = fit_news_title_pt(ui, &title, ui.available_width()); - ui.add( - egui::Label::new( - RichText::new(&title) - .font(FontId::new(pt, fonts::semibold())) - .color(t.surface_text), - ) - .truncate(), - ); - } - if !news.summary.is_empty() { - ui.add_space(4.0); - ui.horizontal_wrapped(|ui| { - ui.spacing_mut().item_spacing.x = 0.0; - for (seg, is_url) in split_urls(&news.summary) { - if is_url { - // hyperlink opens via ctx.open_url, same as open_url(). - ui.hyperlink(seg); - } else { - ui.label( - RichText::new(seg) - .font(FontId::new(13.0, fonts::regular())) - .color(t.surface_text_dim), - ); - } - } - }); - } - }); - ui.add_space(24.0); - } - - /// Horizontal recent-contacts strip; tapping one starts a prefilled send. - fn peers_strip_ui(&mut self, ui: &mut egui::Ui, wallet: &Wallet, salt: &str) { - let peers = recent_peers(wallet, 8); - if peers.is_empty() { - return; + /// The user's OWN avatar as shown top-right on the front surfaces (home, + /// pay). Mode-aware: anonymous mode is the ONLY thing that makes it yellow. + /// Anon ON → the flat Goblin-yellow censored tile ([`w::avatar_censored`]); + /// anon OFF → the exact same picture-or-gradient identicon this identity + /// renders everywhere else ([`w::avatar_any`]). Returns the tap Response so + /// the caller can route to settings. + fn avatar_self(&mut self, ui: &mut egui::Ui, wallet: &Wallet, size: f32) -> egui::Response { + if crate::AppConfig::anonymous_mode() { + return w::avatar_censored(ui, size); } - let texs: Vec> = peers - .iter() - .map(|(name, _)| self.handle_tex(ui.ctx(), wallet, name)) - .collect(); - w::kicker(ui, &t!("goblin.home.recent")); - ui.add_space(12.0); - ScrollArea::horizontal() - .id_salt(salt.to_string()) - .auto_shrink([false, true]) - .show(ui, |ui| { - ui.horizontal(|ui| { - for ((name, npub), tex) in peers.iter().zip(texs.iter()) { - // Fixed-width centered cell so the name sits centered under the - // avatar (not left-aligned to a wider label). - ui.allocate_ui_with_layout( - Vec2::new(72.0, 78.0), - Layout::top_down(Align::Center), - |ui| { - let resp = w::avatar_any(ui, name, npub, 48.0, tex.as_ref()); - ui.add_space(6.0); - let chars: Vec = name.chars().collect(); - let short: String = if chars.len() > 8 { - format!("{}…", chars[..8].iter().collect::()) - } else { - name.to_string() - }; - ui.label( - RichText::new(short).font(FontId::new(12.0, fonts::medium())), - ); - if resp.clicked() { - self.profile = Some(npub.clone()); - } - }, - ); - ui.add_space(12.0); - } - }); - }); - ui.add_space(20.0); - } - - /// Pay tab: amount-first combined pay/request surface. - fn pay_ui(&mut self, ui: &mut egui::Ui, wallet: &Wallet, cb: &dyn PlatformCallbacks) { - let t = theme::tokens(); - ui.add_space(8.0); - // Header identity for the avatar (→ settings), mirroring the Home header. - let (header_handle, header_hex) = wallet + let (handle, npub_hex) = wallet .nostr_service() .map(|s| { let id = s.identity.read(); - let hex = hex_of(&id.npub); let h = id .nip05 .clone() .map(|n| n.split('@').next().unwrap_or("").to_string()) - .unwrap_or_else(|| data::short_npub(&hex)); - (h, hex) + .unwrap_or_else(|| data::short_npub(&hex_of(&id.npub))); + (h, hex_of(&id.npub)) }) - .unwrap_or_else(|| ("N".to_string(), String::new())); - let header_tex = self.handle_tex(ui.ctx(), wallet, &header_handle); - ui.horizontal(|ui| { - // Goblin mark (left), sized to match the right-side controls. - ui.add( - egui::Image::new(egui::include_image!("../../../../img/goblin-logo2.svg")) - .tint(t.text) - .fit_to_exact_size(Vec2::splat(40.0)), - ); - // Right cluster: scan QR (black, no background) then the profile - // picture at the far right; all three controls about the same size. - ui.with_layout(Layout::right_to_left(Align::Center), |ui| { - if w::avatar_any(ui, &header_handle, &header_hex, 40.0, header_tex.as_ref()) - .on_hover_cursor(egui::CursorIcon::PointingHand) - .clicked() - { - self.tab = Tab::Me; - } - ui.add_space(12.0); - let (rect, resp) = ui.allocate_exact_size(Vec2::splat(44.0), Sense::click()); - ui.painter().text( - rect.center(), - egui::Align2::CENTER_CENTER, - QR_CODE, - FontId::new(38.0, fonts::regular()), - t.text, - ); - if resp - .on_hover_cursor(egui::CursorIcon::PointingHand) - .on_hover_text(t!("goblin.home.scan_to_pay").to_string()) - .clicked() - { - let mut f = SendFlow::default(); - f.prefill_amount(self.pay_amount.clone()); - f.request_scan(); - self.pay_amount.clear(); - self.send = Some(f); - } - }); - }); - - // Big centered amount. - let display = if self.pay_amount.is_empty() { - "0".to_string() - } else { - self.pay_amount.clone() - }; - let tall = ui.available_height() > 560.0; - // Over-balance is NOT shown while typing — requesting more than you hold is - // valid, and reddening digits mid-entry reads as an error when it isn't. - // The only feedback is on the Pay press: a brief red flash + shake + buzz - // (see the Pay button below). `spendable` is read there too. - let spendable = wallet - .get_data() - .map(|d| d.info.amount_currently_spendable) - .unwrap_or(0); - // Drive the "can't pay that" animation if it's running. - let now = ui.input(|i| i.time); - const SHAKE_DUR: f64 = 0.45; - if self.pay_shake.is_some_and(|s| now - s >= SHAKE_DUR) { - self.pay_shake = None; - } - ui.add_space(if tall { 56.0 } else { 24.0 }); - if let Some(start) = self.pay_shake { - ui.ctx().request_repaint(); // keep the animation ticking - let p = ((now - start) / SHAKE_DUR).clamp(0.0, 1.0) as f32; - // Damped horizontal oscillation, amplitude decaying to zero. - let dx = 14.0 * (1.0 - p) * (p * std::f32::consts::PI * 9.0).sin(); - // Red flash that eases back to the normal ink over the shake. - let num = lerp_color(t.neg, t.text, p); - let mark = lerp_color(t.neg, t.text_dim, p); - w::amount_text_centered_shifted(ui, &display, 76.0, num, mark, dx); - } else { - w::amount_text_centered(ui, &display, 76.0); - } - if let Ok(grin) = display.parse::() { - if let Some(preview) = pairing_preview(grin, ui.ctx()) { - ui.add_space(6.0); - ui.vertical_centered(|ui| { - ui.label( - RichText::new(preview) - .font(FontId::new(14.0, fonts::regular())) - .color(t.text_dim), - ); - }); - } - } - // Drop the keypad toward the bottom on phone layouts (thumb reach) so it - // isn't stranded in the middle with a big empty gap below it. - let narrow = ui.available_width() < 700.0; - let drop = if narrow { - ((ui.available_height() - 430.0) * 0.6).max(0.0) - } else { - 0.0 - }; - ui.add_space(if tall { 32.0 } else { 16.0 } + drop); - - // The pay column is capped at 480 by `centered_column`, so the old - // `< 700` width gate was always narrow: the numpad always showed and - // the typed-input branch was dead — a physical keyboard did nothing. - // Show the pad and accept typed digits alongside it. - w::numpad(ui, &mut self.pay_amount, cb); - w::amount_typed_input(ui, &mut self.pay_amount); - ui.add_space(20.0); - - // Request | Pay actions, half width each. - let valid = grin_core::core::amount_from_hr_string(&self.pay_amount) - .map(|a| a > 0) - .unwrap_or(false); - 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, 56.0), - )), - |ui| { - if w::big_action(ui, &t!("goblin.home.request"), true).clicked() && valid { - // Open the request flow: pick a contact, then DM them a - // grin Invoice1 they can approve to pay. - let f = SendFlow::new_request(self.pay_amount.clone()); - self.pay_amount.clear(); - self.send = Some(f); - } - }, - ); - ui.add_space(10.0); - ui.scope_builder( - egui::UiBuilder::new().max_rect(egui::Rect::from_min_size( - ui.cursor().min, - Vec2::new(half, 56.0), - )), - |ui| { - if w::big_action(ui, &t!("goblin.home.pay"), false).clicked() && valid { - let over = grin_core::core::amount_from_hr_string(&self.pay_amount) - .map(|a| a > spendable) - .unwrap_or(false); - if over { - // "No, you can't pay that": shake + flash the amount red - // and buzz the phone. Nothing is reddened while typing. - self.pay_shake = Some(now); - cb.vibrate_error(); - } else { - let mut f = SendFlow::default(); - f.prefill_amount(self.pay_amount.clone()); - self.pay_amount.clear(); - self.send = Some(f); - } - } - }, - ); - }); - if !valid { - ui.add_space(8.0); - ui.vertical_centered(|ui| { - ui.label( - RichText::new(t!("goblin.home.enter_amount")) - .font(FontId::new(12.0, fonts::regular())) - .color(t.text_mute), - ); - }); - } + .unwrap_or_else(|| (t!("goblin.home.anonymous").to_string(), String::new())); + let tex = self.handle_tex(ui.ctx(), wallet, &handle); + w::avatar_any(ui, &handle, &npub_hex, size, tex.as_ref()) } - - /// Round back button + title for full-surface overlays. Returns true on tap. - fn overlay_back_header(ui: &mut egui::Ui, title: &str) -> bool { - let t = theme::tokens(); - let mut back = false; - ui.horizontal(|ui| { - let (rect, resp) = ui.allocate_exact_size(Vec2::splat(36.0), Sense::click()); - ui.painter().circle_filled(rect.center(), 18.0, t.surface2); - ui.painter().text( - rect.center(), - egui::Align2::CENTER_CENTER, - ARROW_LEFT, - FontId::new(16.0, fonts::regular()), - t.text, - ); - back = resp - .on_hover_cursor(egui::CursorIcon::PointingHand) - .clicked(); - ui.add_space(12.0); - ui.label( - RichText::new(title) - .font(FontId::new(18.0, fonts::bold())) - .color(t.text), - ); - }); - ui.add_space(12.0); - back - } - - /// Full-surface transaction receipt: GRIM metadata joined with the nostr - /// counterparty + note. Tapping the counterparty opens their profile. - fn receipt_ui(&mut self, ui: &mut egui::Ui, wallet: &Wallet, tx_id: u32) -> bool { - let t = theme::tokens(); - let d = data::receipt_detail(wallet, tx_id); - let tex = d - .as_ref() - .and_then(|d| self.handle_tex(ui.ctx(), wallet, &d.title)); - let mut close = false; - let mut open_profile: Option = None; - 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.receipt.title")) { - close = true; - } - let Some(d) = d else { - ui.add_space(40.0); - ui.vertical_centered(|ui| { - ui.label( - RichText::new(t!("goblin.receipt.not_found")) - .font(FontId::new(15.0, fonts::regular())) - .color(t.text_dim), - ); - }); - return; - }; - ScrollArea::vertical() - .id_salt("goblin_receipt_scroll") - .auto_shrink([false; 2]) - .show(ui, |ui| { - ui.add_space(8.0); - ui.vertical_centered(|ui| { - let resp = w::avatar_any( - ui, - &d.title, - d.npub.as_deref().unwrap_or(""), - 64.0, - tex.as_ref(), - ); - ui.add_space(10.0); - ui.label( - RichText::new(&d.title) - .font(FontId::new(22.0, fonts::bold())) - .color(t.text), - ); - ui.add_space(2.0); - ui.label( - RichText::new(View::format_time(d.time)) - .font(FontId::new(13.0, fonts::regular())) - .color(t.text_dim), - ); - if let Some(note) = &d.note { - ui.add_space(2.0); - ui.label( - RichText::new(t!("goblin.receipt.for_note", note => note)) - .font(FontId::new(13.0, fonts::regular())) - .color(t.text_dim), - ); - } - ui.add_space(14.0); - w::amount_text_centered(ui, &w::amount_str(d.amount), 56.0); - if resp.clicked() { - if let Some(npub) = &d.npub { - open_profile = Some(npub.clone()); - } - } - }); - ui.add_space(20.0); - w::kicker(ui, &t!("goblin.receipt.details")); - ui.add_space(10.0); - w::card(ui, |ui| { - let (status, sub): (String, String) = if d.canceled { - ( - t!("goblin.receipt.canceled").to_string(), - if d.incoming { - t!("goblin.receipt.expired").to_string() - } else { - t!("goblin.receipt.funds_returned").to_string() - }, - ) - } else if let Some((c, r)) = d.confs { - // On-chain but still maturing toward the spendable - // threshold — show the live X/N count (grin marks a - // tx confirmed at one block; spendable takes N). - if c == 0 && !d.incoming && d.npub.is_some() { - // Sent but not yet picked up / mined. - ( - t!("goblin.receipt.pending").to_string(), - t!("goblin.receipt.waiting_to_receive", name => d.title) - .to_string(), - ) - } else { - ( - t!("goblin.receipt.pending").to_string(), - t!("goblin.receipt.confs", c => c, r => r).to_string(), - ) - } - } else if d.confirmed { - ( - t!("goblin.receipt.complete").to_string(), - if d.incoming { - t!("goblin.receipt.payment_received").to_string() - } else { - t!("goblin.receipt.payment_sent").to_string() - }, - ) - } else { - ( - t!("goblin.receipt.pending").to_string(), - t!("goblin.receipt.waiting_to_confirm").to_string(), - ) - }; - w::info_row(ui, &status, &sub); - if d.has_identity { - let (to, from) = if d.incoming { - (t!("goblin.receipt.you").to_string(), d.title.clone()) - } else { - (d.title.clone(), t!("goblin.receipt.you").to_string()) - }; - w::info_row(ui, &t!("goblin.receipt.to"), &to); - w::info_row(ui, &t!("goblin.receipt.from"), &from); - // Which of the wallet's held nostr identities was active - // when this payment was received/sent — the "front door" - // it used. Uses the identity recorded on the tx - // (recipient_pubkey), falling back to the primary for - // pre-feature rows. NIP-05 name when claimed, else a - // truncated npub. - let owning_hex = d - .slate_id - .as_deref() - .and_then(|sid| { - wallet - .nostr_service() - .and_then(|s| s.store.tx_meta(sid)) - }) - .map(|m| m.recipient_pubkey) - .filter(|h| !h.is_empty()); - let ids = wallet.nostr_identities(); - // The identity this tx used: the one recorded on it, - // else the primary for pre-feature rows. - let owner = match &owning_hex { - Some(hex) => ids.iter().find(|i| &i.pubkey_hex == hex), - None => ids.first(), - }; - let seed = owner - .map(|i| i.pubkey_hex.clone()) - .or_else(|| owning_hex.clone()); - // The claimed name (no leading @, no domain — the project - // convention), else the truncated npub. Never a - // placeholder word. - let id_label = owner - .map(|i| i.display()) - .or_else(|| owning_hex.as_deref().map(data::short_npub)) - .unwrap_or_default(); - if !id_label.is_empty() { - match &seed { - Some(seed) => w::info_row_dot( - ui, - &t!("goblin.receipt.identity"), - &id_label, - seed, - ), - None => w::info_row( - ui, - &t!("goblin.receipt.identity"), - &id_label, - ), - } - } - } - if let Some(npub) = &d.npub { - w::info_row( - ui, - &t!("goblin.receipt.nostr"), - &data::short_npub(npub), - ); - } - // Only the SENDER pays a network fee, so the row only makes - // sense on outgoing payments. A received payment has no fee - // (data sets it to None) — hide the row entirely instead of - // showing a confusing "—". - if let Some(fee_amount) = d.fee { - let fee = if fee_amount == 0 { - t!("goblin.receipt.fee_none").to_string() - } else { - format!("{}{}", w::amount_str(fee_amount), w::TSU) - }; - w::info_row(ui, &t!("goblin.receipt.network_fee"), &fee); - } - w::info_row( - ui, - &t!("goblin.receipt.privacy"), - &t!("goblin.receipt.privacy_value"), - ); - if let Some(sid) = &d.slate_id { - let short = if sid.len() > 13 { - format!("{}…{}", &sid[..8], &sid[sid.len() - 4..]) - } else { - sid.clone() - }; - w::info_row(ui, &t!("goblin.receipt.transaction"), &short); - } - }); - // Withdraw a request we sent that hasn't been paid yet: - // cancel the local invoice and tell the payer (a void - // message). Requests are messages; payments are final. - let cancelable_request = d - .slate_id - .as_ref() - .and_then(|sid| { - wallet.nostr_service().and_then(|s| s.store.tx_meta(sid)) - }) - .map(|m| { - m.direction == crate::nostr::NostrTxDirection::RequestedByUs - && matches!( - m.status, - crate::nostr::NostrSendStatus::Created - | crate::nostr::NostrSendStatus::AwaitingI2 - ) - }) - .unwrap_or(false) && !d.canceled - && !d.confirmed; - if cancelable_request { - ui.add_space(16.0); - if w::big_action(ui, &t!("goblin.receipt.cancel_request"), true) - .clicked() - { - if let Some(sid) = &d.slate_id { - wallet.task( - crate::wallet::types::WalletTask::NostrCancelOutgoing( - sid.clone(), - ), - ); - } - close = true; - } - } - // Reclaim a payment WE sent that the recipient never - // completed: cancel the grin tx to unlock our funds, mark - // it cancelled, best-effort void. Appears after the grace - // window (or immediately if it never reached a relay). - let send_meta = d.slate_id.as_ref().and_then(|sid| { - wallet.nostr_service().and_then(|s| s.store.tx_meta(sid)) - }); - let grace = wallet - .nostr_service() - .map(|s| s.config.read().cancel_grace_secs()) - .unwrap_or(600); - let cancelable_send = send_meta - .as_ref() - .map(|m| { - m.direction == crate::nostr::NostrTxDirection::Sent - && matches!( - m.status, - crate::nostr::NostrSendStatus::Created - | crate::nostr::NostrSendStatus::AwaitingS2 - | crate::nostr::NostrSendStatus::SendFailed - ) && (matches!( - m.status, - crate::nostr::NostrSendStatus::SendFailed - ) || crate::nostr::unix_time() - m.created_at > grace) - }) - .unwrap_or(false) && !d.canceled - && !d.confirmed; - if cancelable_send { - ui.add_space(16.0); - let confirming = self.cancel_confirm == Some(d.tx_id); - let label = if confirming { - t!("goblin.receipt.cancel_send_confirm") - } else { - t!("goblin.receipt.cancel_send") - }; - if w::big_action(ui, &label, true).clicked() { - if confirming { - if let Some(sid) = &d.slate_id { - wallet.task( - crate::wallet::types::WalletTask::NostrCancelSend( - sid.clone(), - ), - ); - } - self.cancel_confirm = None; - } else { - self.cancel_confirm = Some(d.tx_id); - } - } - } else { - self.cancel_confirm = None; - } - // Transient outcome notice, set async by the task handler. - if let Some(outcome) = - wallet.nostr_service().and_then(|s| s.take_cancel_notice()) - { - self.cancel_msg = Some((outcome, std::time::Instant::now())); - } - if let Some((outcome, at)) = self.cancel_msg { - if at.elapsed().as_secs() < 5 { - ui.add_space(10.0); - let (msg, col) = match outcome { - crate::nostr::CancelOutcome::Cancelled => { - (t!("goblin.receipt.cancel_send_done"), t.pos) - } - crate::nostr::CancelOutcome::AlreadyCompleted => { - (t!("goblin.receipt.cancel_send_too_late"), t.text_dim) - } - }; - ui.vertical_centered(|ui| { - ui.label( - RichText::new(msg) - .font(FontId::new(13.0, fonts::regular())) - .color(col), - ); - }); - ui.ctx().request_repaint_after( - std::time::Duration::from_millis(300), - ); - } else { - self.cancel_msg = None; - } - } - ui.add_space(20.0); - }); - }); - }); - if let Some(npub) = open_profile { - self.profile = Some(npub); - close = true; - } - close - } - - /// Full-surface contact profile: who they are, history between us, and a - /// block toggle (a nostr-level mute). - fn profile_ui( - &mut self, - ui: &mut egui::Ui, - wallet: &Wallet, - _cb: &dyn PlatformCallbacks, - npub: &str, - ) -> bool { - let t = theme::tokens(); - let name = wallet - .nostr_service() - .map(|s| data::contact_title(&s.store, npub)) - .unwrap_or_else(|| data::short_npub(npub)); - let contact = wallet.nostr_service().and_then(|s| s.store.contact(npub)); - let blocked = contact.as_ref().map(|c| c.blocked).unwrap_or(false); - let nip05 = contact.as_ref().and_then(|c| c.nip05.clone()); - let history = data::history_with(wallet, npub); - let tex = self.handle_tex(ui.ctx(), wallet, &name); - let htexs: Vec> = history - .iter() - .map(|i| self.handle_tex(ui.ctx(), wallet, &i.title)) - .collect(); - let mut close = false; - let mut do_pay = false; - let mut do_block = false; - let mut open_receipt: Option = None; - 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.profile.title")) { - close = true; - } - ScrollArea::vertical() - .id_salt("goblin_profile_scroll") - .auto_shrink([false; 2]) - .show(ui, |ui| { - ui.add_space(8.0); - ui.vertical_centered(|ui| { - w::avatar_any(ui, &name, npub, 72.0, tex.as_ref()); - ui.add_space(12.0); - ui.label( - RichText::new(&name) - .font(FontId::new(22.0, fonts::bold())) - .color(t.text), - ); - ui.add_space(2.0); - let sub = nip05 - .clone() - .map(|n| format!("✓ {}", n)) - .unwrap_or_else(|| data::short_npub(npub)); - ui.label( - RichText::new(sub) - .font(FontId::new(13.0, fonts::regular())) - .color(t.text_dim), - ); - }); - ui.add_space(18.0); - if !blocked - && w::big_action(ui, &t!("goblin.home.pay"), false).clicked() - { - do_pay = true; - } - ui.add_space(18.0); - w::kicker(ui, &t!("goblin.profile.activity")); - ui.add_space(10.0); - if history.is_empty() { - ui.label( - RichText::new(t!("goblin.profile.no_activity")) - .font(FontId::new(13.0, fonts::regular())) - .color(t.text_dim), - ); - } else { - for (item, htex) in history.iter().zip(htexs.iter()) { - // No +/- for canceled: nothing moved. - let sign = if item.canceled { - "" - } else if item.incoming { - "+ " - } else { - "− " - }; - let amount = - format!("{}{}{}", sign, w::amount_str(item.amount), w::TSU); - let (note, time) = Self::activity_note_time(item); - if w::activity_row( - ui, - &item.title, - ¬e, - &time, - item.npub.as_deref().unwrap_or(""), - &amount, - item.incoming, - item.canceled, - item.system, - htex.as_ref(), - ) - .clicked() - { - open_receipt = Some(item.tx_id); - } - } - } - ui.add_space(24.0); - let label = if blocked { - t!("goblin.profile.unblock").to_string() - } else { - format!("{} {}", PROHIBIT, t!("goblin.profile.block")) - }; - if w::big_action_on_card_ink(ui, &label, t.neg).clicked() { - do_block = true; - } - ui.add_space(8.0); - ui.vertical_centered(|ui| { - ui.label( - RichText::new(if blocked { - t!("goblin.profile.blocked_blurb") - } else { - t!("goblin.profile.block_blurb") - }) - .font(FontId::new(12.0, fonts::regular())) - .color(t.text_mute), - ); - }); - ui.add_space(20.0); - }); - }); - }); - if let Some(id) = open_receipt { - self.receipt = Some(id); - close = true; - } - if do_pay { - let mut f = SendFlow::default(); - f.prefill_contact(name.clone(), npub.to_string()); - self.send = Some(f); - close = true; - } - if do_block { - if let Some(s) = wallet.nostr_service() { - let mut c = s.store.contact(npub).unwrap_or(crate::nostr::Contact { - ver: 1, - npub: npub.to_string(), - petname: None, - nip05: nip05.clone(), - nip05_verified_at: None, - relays: vec![], - nip44_v3: false, - hue: data::hue_of(npub) as u8, - unknown: true, - added_at: crate::nostr::unix_time(), - last_paid_at: None, - blocked: false, - }); - c.blocked = !c.blocked; - s.store.save_contact(&c); - } - } - close - } - - /// List-row timestamp: date + HH:MM, no seconds. The tap-in detail view keeps - /// the full timestamp to the second (see [`View::format_time`]). - fn list_time(ts: i64) -> String { - let utc_offset = chrono::Local::now().offset().local_minus_utc(); - chrono::DateTime::from_timestamp(ts + utc_offset as i64, 0) - .map(|t| t.format("%d/%m/%Y %H:%M").to_string()) - .unwrap_or_default() - } - - /// The (left message, right timestamp) an [`ActivityItem`] shows in a row. The - /// timestamp (no seconds) is only set for a confirmed tx; otherwise the status - /// word (canceled/pending) folds into the message so a row with no time still - /// reads its state without an empty right-side time slot. - fn activity_note_time(item: &ActivityItem) -> (String, String) { - let status_word = if item.canceled { - t!("goblin.activity.canceled").to_string() - } else { - t!("goblin.activity.pending").to_string() - }; - let time = if item.confirmed { - Self::list_time(item.time) - } else { - String::new() - }; - let note = match (item.note.as_deref(), item.confirmed) { - (Some(n), false) => format!("{n} · {status_word}"), - (None, false) => status_word, - (Some(n), true) => n.to_string(), - (None, true) => String::new(), - }; - (note, time) - } - - /// Friendly day-grouping label for the activity feed. - fn day_label(ts: i64) -> String { - use chrono::{TimeZone, Utc}; - let Some(dt) = Utc.timestamp_opt(ts, 0).single() else { - return t!("goblin.activity.earlier").to_string(); - }; - let today = Utc::now().date_naive(); - let day = dt.date_naive(); - if day == today { - t!("goblin.activity.today").to_string() - } else if (today - day).num_days() == 1 { - t!("goblin.activity.yesterday").to_string() - } else { - dt.format("%b %-d, %Y").to_string() - } - } - - fn activity_ui(&mut self, ui: &mut egui::Ui, wallet: &Wallet, cb: &dyn PlatformCallbacks) { - ui.add_space(8.0); - ui.label( - RichText::new(t!("goblin.activity.title")) - .font(FontId::new(28.0, fonts::bold())) - .color(theme::tokens().text), - ); - ui.add_space(12.0); - - // Recent contacts strip (payment-app-style row above the feed). - self.peers_strip_ui(ui, wallet, "goblin_peers_activity"); - - // Pending payment requests pinned on top. - if let Some(service) = wallet.nostr_service() { - // An approve that failed (e.g. funds still confirming) flips the send - // phase to FAILED — un-grey the buttons so the user can retry, and - // surface why instead of leaving the card stuck. - if service.send_phase() == crate::nostr::send_phase::FAILED - && !self.approving.is_empty() - { - self.approving.clear(); - self.request_error = service.last_send_error(); - } - let requests = service.store.pending_requests(); - if !requests.is_empty() { - w::section_header(ui, &t!("goblin.activity.requests")); - if let Some(err) = &self.request_error { - ui.add_space(4.0); - ui.label( - RichText::new(err) - .font(FontId::new(13.0, fonts::regular())) - .color(theme::tokens().neg), - ); - ui.add_space(4.0); - } - for req in requests { - self.request_row_ui(ui, &req, wallet); - } - ui.add_space(8.0); - } - } - - ScrollArea::vertical() - .id_salt("goblin_activity_scroll") - .auto_shrink([false; 2]) - .scroll_bar_visibility(egui::scroll_area::ScrollBarVisibility::AlwaysHidden) - .show(ui, |ui| { - let items = activity_items(wallet); - let id_cue = IdentityCueCtx::compute(wallet); - if items.is_empty() { - empty_state( - ui, - &t!("goblin.activity.empty_title"), - &t!("goblin.activity.empty_sub"), - ); - } else { - // Unconfirmed (< min confirmations) pinned on top as Pending. - // Canceled txs are not pending — they group with history below. - let pending: Vec<&_> = items - .iter() - .filter(|i| !i.confirmed && !i.system && !i.canceled) - .collect(); - if !pending.is_empty() { - w::section_header(ui, &t!("goblin.activity.pending_header")); - for item in pending { - self.activity_item_ui(ui, item, wallet, cb, &id_cue); - } - ui.add_space(8.0); - } - // Confirmed (and canceled), grouped by day (newest first). - let mut last: Option = None; - for item in items - .iter() - .filter(|i| i.confirmed || i.system || i.canceled) - { - let label = Self::day_label(item.time); - if last.as_deref() != Some(label.as_str()) { - w::section_header(ui, &label); - last = Some(label); - } - self.activity_item_ui(ui, item, wallet, cb, &id_cue); - } - } - ui.add_space(16.0); - }); - } - - fn activity_item_ui( - &mut self, - ui: &mut egui::Ui, - item: &ActivityItem, - wallet: &Wallet, - _cb: &dyn PlatformCallbacks, - id_cue: &IdentityCueCtx, - ) { - // No +/- for canceled: nothing moved. - let sign = if item.canceled { - "" - } else if item.incoming { - "+ " - } else { - "− " - }; - let amount = format!("{}{}{}", sign, w::amount_str(item.amount), w::TSU); - let (note, time) = Self::activity_note_time(item); - let tex = self.handle_tex(ui.ctx(), wallet, &item.title); - let resp = w::activity_row( - ui, - &item.title, - ¬e, - &time, - item.npub.as_deref().unwrap_or(""), - &amount, - item.incoming, - item.canceled, - item.system, - tex.as_ref(), - ); - // Per-identity cue (owner-approved): only when the wallet holds MORE THAN - // ONE identity, and never on system (mining) rows. A small corner badge on - // the counterparty avatar, filled with the identity THIS tx used (its own - // gradient; falls back to the primary for pre-feature rows). The row avatar - // is 40px, flush to the row's left and vertically centred, so its - // bottom-right corner is at (left+40, mid+20); the badge overhangs that - // corner by ~4px (matching the mock's right:-4/bottom:-4, 14px badge). - if SHOW_ROW_IDENTITY_CUE && id_cue.multi && !item.system { - let seed = item.owner_pubkey.clone().or_else(|| id_cue.primary.clone()); - if let Some(seed) = seed { - let r = resp.rect; - let badge = egui::pos2(r.left() + 37.0, r.center().y + 17.0); - w::identity_dot(ui.painter(), badge, 6.0, &seed); - } - } - if resp.clicked() { - self.receipt = Some(item.tx_id); - } - } - - fn request_row_ui( - &mut self, - ui: &mut egui::Ui, - req: &crate::nostr::PaymentRequest, - wallet: &Wallet, - ) { - let t = theme::tokens(); - let name = wallet - .nostr_service() - .map(|s| data::contact_title(&s.store, &req.npub)) - .unwrap_or_else(|| data::short_npub(&req.npub)); - let tex = self.handle_tex(ui.ctx(), wallet, &name); - w::card(ui, |ui| { - ui.horizontal(|ui| { - w::avatar_any(ui, &name, &req.npub, 40.0, tex.as_ref()); - ui.add_space(12.0); - ui.vertical(|ui| { - ui.label( - RichText::new(t!("goblin.request.title", name => name)) - .font(FontId::new(15.0, fonts::semibold())) - .color(t.surface_text), - ); - ui.horizontal(|ui| { - ui.spacing_mut().item_spacing.x = 0.0; - ui.label( - RichText::new(w::amount_str(req.amount)) - .font(FontId::new(15.0, fonts::mono_semibold())) - .color(t.surface_text), - ); - ui.label( - RichText::new(w::TSU) - .font(FontId::new(13.0, fonts::medium())) - .color(t.surface_text_dim), - ); - }); - }); - }); - if let Some(note) = &req.note { - ui.add_space(6.0); - ui.label( - RichText::new(format!("\u{201C}{}\u{201D}", note)) - .font(FontId::new(13.0, fonts::regular())) - .color(t.text_dim), - ); - } - ui.add_space(10.0); - 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| { - if decline_button(ui) { - // Optimistically clear the card, then send the decline as - // a void control message so the requester's side clears - // too. Requests are messages; payments are final. - let mut r = req.clone(); - r.status = crate::nostr::RequestStatus::Declined; - if let Some(s) = wallet.nostr_service() { - s.store.save_request(&r); - } - wallet.task(crate::wallet::types::WalletTask::NostrDeclineRequest( - req.rumor_id.clone(), - )); - } - }, - ); - 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| { - let already = self.approving.contains(&req.rumor_id); - let working = already - && wallet - .nostr_service() - .map(|s| s.send_phase() == crate::nostr::send_phase::WORKING) - .unwrap_or(false); - if already { - // Paying: show a centered spinner so the tap clearly - // registered (the card clears itself once it's sent). - ui.vertical_centered(|ui| { - ui.add_space(6.0); - View::small_loading_spinner(ui); - ui.add_space(2.0); - ui.label( - RichText::new(t!("goblin.receipt.paying")) - .font(FontId::new(12.0, fonts::regular())) - .color(t.text_dim), - ); - }); - if working { - ui.ctx().request_repaint(); - } - } else if approve_button(ui) { - // 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; - self.approve_hold = w::HoldToSend::default(); - self.approve_fee_for = None; - self.approve_review = Some(req.clone()); - } - }, - ); - }); - }); - 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 = wallet - .nostr_service() - .map(|s| data::contact_title(&s.store, &req.npub)) - .unwrap_or_else(|| data::short_npub(&req.npub)); - 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() - .id_salt("goblin_approve_scroll") - .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, 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); - ui.label( - RichText::new(t!("goblin.receive.title")) - .font(FontId::new(28.0, fonts::bold())) - .color(t.text), - ); - ui.add_space(16.0); - - // `has_name`: a claimed nip05 name exists — gates the "handle"/"username" - // wording, which would mislead when only the raw npub is shown. - let (handle, has_name) = wallet - .nostr_service() - .map(|s| { - let identity = s.identity.read(); - match identity.nip05.clone() { - Some(n) => (n.split('@').next().unwrap_or("").to_string(), true), - None => (data::short_npub(&hex_of(&identity.npub)), false), - } - }) - .unwrap_or_else(|| ("—".to_string(), false)); - let npub = wallet.nostr_service().map(|s| s.npub()).unwrap_or_default(); - let nprofile = wallet - .nostr_service() - .map(|s| s.nprofile()) - .unwrap_or_else(|| npub.clone()); - - w::card(ui, |ui| { - ui.vertical_centered(|ui| { - // QR of the nostr handle (nostr: URI). - ui.add_space(12.0); - let uri = format!("nostr:{}", nprofile); - w::qr_code(ui, &uri, 220.0); - ui.add_space(14.0); - ui.label( - RichText::new(&handle) - .font(FontId::new(18.0, fonts::bold())) - .color(t.surface_text), - ); - match &self.request_amount { - Some(amt) => { - ui.label( - RichText::new(t!( - "goblin.receive.requesting", - amt => amt, - tsu => w::TSU - )) - .font(FontId::new(13.0, fonts::semibold())) - .color(t.surface_text), - ); - ui.add_space(6.0); - if w::chip(ui, &t!("goblin.receive.clear_request"), false).clicked() { - self.request_amount = None; - } - } - None => { - let caption = if has_name { - t!("goblin.receive.share_handle") - } else { - t!("goblin.receive.share_npub") - }; - ui.label( - RichText::new(caption) - .font(FontId::new(13.0, fonts::regular())) - .color(t.surface_text_dim), - ); - } - } - }); - }); - - ui.add_space(12.0); - // Transient "Copied" feedback on the copy button; a silent copy reads as - // a dead button. - let fresh = |at: std::time::Instant| at.elapsed().as_millis() < 1500; - let copied = matches!(self.receive_copied, Some((1, at)) if fresh(at)); - if self.receive_copied.is_some() { - ui.ctx() - .request_repaint_after(std::time::Duration::from_millis(200)); - } - ui.horizontal(|ui| { - let half = (ui.available_width() - 10.0) / 2.0; - // Share a friendly "pay me" message carrying the bare npub — the - // public key people pay you on. Never the nprofile or a grin address. - ui.scope_builder( - egui::UiBuilder::new().max_rect(egui::Rect::from_min_size( - ui.cursor().min, - Vec2::new(half, 56.0), - )), - |ui| { - let label = t!("goblin.send.share_btn", "icon" => SHARE); - if w::big_action(ui, &label, true).clicked() && !npub.is_empty() { - cb.share_text( - t!("goblin.receive.share_message", "npub" => npub.clone()).to_string(), - ); - } - }, - ); - ui.add_space(10.0); - // Copy the bare npub itself. - ui.scope_builder( - egui::UiBuilder::new().max_rect(egui::Rect::from_min_size( - ui.cursor().min, - Vec2::new(half, 56.0), - )), - |ui| { - let label = if copied { - format!("{} {}", CHECK, t!("goblin.receive.copied")) - } else { - format!("{} {}", COPY, t!("goblin.receive.copy_npub")) - }; - if w::big_action(ui, &label, false).clicked() && !npub.is_empty() { - cb.copy_string_to_buffer(npub.clone()); - cb.vibrate_copy(); - self.receive_copied = Some((1, std::time::Instant::now())); - } - }, - ); - }); - - ui.add_space(16.0); - let privacy_note = if has_name { - t!("goblin.receive.privacy_note") - } else { - t!("goblin.receive.privacy_note_npub") - }; - ui.label( - RichText::new(privacy_note) - .font(FontId::new(12.0, fonts::regular())) - .color(t.text_mute), - ); - } - - fn me_ui(&mut self, ui: &mut egui::Ui, wallet: &Wallet, cb: &dyn PlatformCallbacks) { - let t = theme::tokens(); - match self.settings_page { - SettingsPage::Node => return self.node_settings_ui(ui, wallet, cb), - SettingsPage::IntegratedNode => return self.integrated_node_ui(ui, cb), - SettingsPage::Relays => return self.relays_ui(ui, wallet, cb), - SettingsPage::Nips => return self.nips_ui(ui), - SettingsPage::Pairing => return self.pairing_settings_ui(ui), - SettingsPage::Language => return self.language_settings_ui(ui), - SettingsPage::Slatepack => return self.slatepack_ui(ui, wallet, cb), - SettingsPage::Privacy => return self.privacy_ui(ui), - SettingsPage::Advanced => return self.advanced_ui(ui, wallet, cb), - SettingsPage::Identities => return self.identities_ui(ui, wallet, cb), - SettingsPage::Main => {} - } - // Minimum-confirmations edit modal (GRIM parity), opened from the - // wallet group below. - if Modal::opened() == Some(MIN_CONF_MODAL) { - Modal::ui(ui.ctx(), cb, |ui, modal, cb| { - self.min_conf_modal_content(ui, wallet, modal, cb); - }); - } - ui.add_space(8.0); - ui.label( - RichText::new(t!("goblin.settings.title")) - .font(FontId::new(28.0, fonts::bold())) - .color(t.text), - ); - ui.add_space(16.0); - - // Profile card. - let (handle, npub, connected, bare_name, npub_hex) = wallet - .nostr_service() - .map(|s| { - let identity = s.identity.read(); - let bare = identity - .nip05 - .clone() - .map(|n| n.split('@').next().unwrap_or("").to_string()); - let handle = bare - .clone() - .map(|n| n.to_string()) - .unwrap_or_else(|| data::short_npub(&hex_of(&identity.npub))); - ( - handle, - s.npub(), - s.is_connected(), - bare, - hex_of(&identity.npub), - ) - }) - .unwrap_or_else(|| { - ( - t!("goblin.home.anonymous").to_string(), - String::new(), - false, - None, - String::new(), - ) - }); - - let own_tex = bare_name - .as_deref() - .and_then(|_| self.handle_tex(ui.ctx(), wallet, &handle)); - - // Set inside the (deeply nested) card closure when the identity-switcher - // glyph is tapped; applied after the card so the closures don't need a - // mutable borrow of `self`. - let mut open_identities = false; - w::card(ui, |ui| { - ui.set_min_width(ui.available_width()); - ui.horizontal(|ui| { - // Custom picture when one is set; otherwise the deterministic - // pubkey-seeded gradient identicon. - w::avatar_any(ui, &handle, &npub_hex, 56.0, own_tex.as_ref()); - ui.add_space(14.0); - ui.vertical(|ui| { - ui.horizontal(|ui| { - ui.spacing_mut().item_spacing.x = 5.0; - ui.label( - RichText::new(&handle) - .font(FontId::new(17.0, fonts::bold())) - .color(t.surface_text), - ); - // A claimed/verified name gets the little check. - if bare_name.is_some() { - ui.label( - RichText::new(crate::gui::icons::SEAL_CHECK) - .font(FontId::new(15.0, fonts::regular())) - .color(t.pos), - ); - } - }); - // Transport status in place of the redundant second npub line. - // "Connected over Nym" is RELAY-GATED (transport_ready): the - // tunnel being warm is not enough — a relay must actually carry - // our traffic on the current exit. Otherwise show the tunnel is - // up but relays are still connecting/reconnecting. - let mixnet = if crate::tor::transport_ready() { - t!("goblin.home.connected_nym") - } else if crate::tor::is_ready() { - t!("goblin.home.nym_ready") - } else { - t!("goblin.home.connecting_nym") - }; - ui.label( - RichText::new(mixnet) - .font(FontId::new(13.0, fonts::regular())) - .color(t.surface_text_dim), - ); - // nostr relay status — the slower step (a relay reached over Nym). - let nostr = if connected { - t!("goblin.settings.connected_nostr") - } else { - t!("goblin.settings.connecting_relays") - }; - ui.label( - RichText::new(nostr) - .font(FontId::new(12.0, fonts::regular())) - .color(t.surface_text_mute), - ); - if !crate::tor::transport_ready() || !connected { - ui.ctx() - .request_repaint_after(std::time::Duration::from_millis(600)); - } - }); - // Trailing (top-right) controls of the identity row: the identity - // SWITCHER (one wallet, many nostr identities) and, when present, - // the update-available badge. Right-aligned into the space to the - // right of the name/avatar; the switcher sits in the far corner. - ui.with_layout(Layout::right_to_left(Align::Center), |ui| { - let (rect, resp) = ui.allocate_exact_size(Vec2::splat(36.0), Sense::click()); - ui.painter().circle_filled(rect.center(), 18.0, t.surface2); - ui.painter().text( - rect.center(), - egui::Align2::CENTER_CENTER, - crate::gui::icons::ARROWS_LEFT_RIGHT, - FontId::new(17.0, fonts::regular()), - theme::ink_for(t.surface2), - ); - if resp - .on_hover_cursor(egui::CursorIcon::PointingHand) - .on_hover_text(t!("goblin.identities.switch_hint").to_string()) - .clicked() - { - open_identities = true; - } - // Update-available badge to the LEFT of the switcher. Shown only - // when the release check found a newer build; tapping it opens - // the release download page. - if let Some(update) = crate::AppConfig::app_update() { - ui.add_space(6.0); - let (rect, resp) = - ui.allocate_exact_size(Vec2::splat(36.0), Sense::click()); - ui.painter().circle_filled(rect.center(), 18.0, t.accent); - ui.painter().text( - rect.center(), - egui::Align2::CENTER_CENTER, - crate::gui::icons::CLOUD_ARROW_DOWN, - FontId::new(18.0, fonts::regular()), - t.accent_ink, - ); - if resp - .on_hover_cursor(egui::CursorIcon::PointingHand) - .on_hover_text(t!("goblin.settings.update_available").to_string()) - .clicked() - { - open_url(ui, &update.url); - } - } - }); - }); - }); - if open_identities { - self.settings_page = SettingsPage::Identities; - self.identity_switch = IdentitySwitchState::default(); - } - - ui.add_space(16.0); - // Mark the scroll boundary: rows clipping under the pinned profile - // card otherwise read as sliced glyphs on an invisible edge. - let line_y = ui.cursor().min.y; - ui.painter().hline( - ui.max_rect().x_range(), - line_y, - eframe::epaint::Stroke::new(1.0, t.line), - ); - ui.add_space(6.0); - ScrollArea::vertical() - .id_salt("goblin_settings_scroll") - .auto_shrink([false; 2]) - .scroll_bar_visibility(egui::scroll_area::ScrollBarVisibility::AlwaysHidden) - .show(ui, |ui| { - // Identity: username, picture, keys — first because it is the - // face of the wallet. - w::kicker(ui, &t!("goblin.settings.identity")); - ui.add_space(8.0); - if self.claim.is_none() { - self.claim = Some(ClaimState::default()); - } - self.claim_ui(ui, wallet, cb); - ui.add_space(8.0); - // Hoisted above the identity card: the Nostr Relays row now lives - // inside that card (relays are a nostr concern, like the keys), but - // its open handler runs further down — so the flag is declared here. - let mut open_relays = false; - w::card(ui, |ui| { - if !npub.is_empty() { - if settings_row_btn(ui, &t!("goblin.settings.copy_npub"), COPY) { - cb.copy_string_to_buffer(npub.clone()); - cb.vibrate_copy(); - self.copy_flash = Some(std::time::Instant::now()); - } - // One encrypted backup FILE (key + username + history sealed - // together) — replaces the old copy-nsec / copy-JSON split. - if settings_row_btn( - ui, - &t!("goblin.settings.backup_file"), - crate::gui::icons::DOWNLOAD_SIMPLE, - ) && self.backup.is_none() - { - self.backup = Some(BackupState::default()); - } - if settings_row_danger( - ui, - &t!("goblin.settings.rotate_key"), - crate::gui::icons::ARROWS_CLOCKWISE, - ) && self.rotate.is_none() - { - self.rotate = Some(RotateState::default()); - } - if settings_row_btn( - ui, - &t!("goblin.settings.import_identity"), - crate::gui::icons::KEY, - ) && self.import_nsec.is_none() - { - self.import_nsec = Some(ImportState::default()); - } - // Nostr relays the wallet publishes/reads gift wraps on. - // Sits with the identity rows because relays are a nostr - // concern; opens the relay editor (handled below). - if settings_row_nav( - ui, - &t!("goblin.settings.nostr_relays"), - &relay_summary(wallet), - ) { - open_relays = true; - } - // Federation: which name authority (server) registers and - // verifies names. Shows the current host on the right. - let authority = wallet - .nostr_service() - .map(|s| s.config.read().home_domain()) - .unwrap_or_default(); - if settings_row_nav(ui, &t!("goblin.settings.name_authority"), &authority) - && self.name_authority.is_none() - { - let cur = wallet - .nostr_service() - .map(|s| s.config.read().nip05_server()) - .unwrap_or_default(); - self.name_authority = Some(NameAuthorityState { - input: cur, - error: None, - }); - } - } - }); - // Transient confirmation that the copy landed — pairs with the - // haptic tick so the tap feels acknowledged. - if let Some(at) = self.copy_flash { - if at.elapsed().as_secs_f32() < 1.5 { - ui.add_space(6.0); - ui.vertical_centered(|ui| { - ui.label( - RichText::new(format!( - "{} {}", - crate::gui::icons::CHECK, - t!("goblin.receipt.copied") - )) - .font(FontId::new(13.0, fonts::medium())) - .color(t.pos), - ); - }); - ui.ctx() - .request_repaint_after(std::time::Duration::from_millis(120)); - } else { - self.copy_flash = None; - } - } - ui.add_space(6.0); - ui.label( - RichText::new(t!("goblin.settings.backup_note")) - .font(FontId::new(12.0, fonts::regular())) - .color(t.text_mute), - ); - if self.backup.is_some() { - ui.add_space(8.0); - self.backup_ui(ui, wallet, cb); - } - if self.name_authority.is_some() { - ui.add_space(8.0); - self.name_authority_ui(ui, wallet, cb); - } - if self.rotate.is_some() { - ui.add_space(8.0); - self.rotate_ui(ui, wallet, cb); - } - if self.import_nsec.is_some() { - ui.add_space(8.0); - self.import_nsec_ui(ui, wallet, cb); - } - - ui.add_space(16.0); - let mut open_node = false; - let mut open_slatepack = false; - settings_group(ui, &t!("goblin.settings.wallet"), |ui| { - if settings_row_nav(ui, &t!("goblin.settings.node"), &node_summary(wallet)) { - open_node = true; - } - // Minimum confirmations before received funds are spendable - // (GRIM parity, default 10). Prominent, just below the node - // row; tapping opens the numeric edit modal. The value feeds - // the wallet's spendable/send logic via - // WalletConfig::min_confirmations. - if settings_row_nav( - ui, - &t!("goblin.settings.min_conf"), - &wallet.get_config().min_confirmations.to_string(), - ) { - self.min_conf_edit = wallet.get_config().min_confirmations.to_string(); - Modal::new(MIN_CONF_MODAL) - .position(ModalPosition::CenterTop) - .title(t!("goblin.settings.min_conf")) - .show(); - } - // GRIM's native by-hand slatepack exchange, for when a payment - // can't go through a username. - if settings_row_nav( - ui, - &t!("goblin.settings.slatepacks"), - &t!("goblin.settings.slatepacks_value"), - ) { - open_slatepack = true; - } - }); - if open_slatepack { - self.slatepack = SlatepackManual::default(); - self.settings_page = SettingsPage::Slatepack; - } - if open_relays { - // The ACTIVE set (override or per-identity advertised set), - // so the editor shows what is really in use. - self.relay_edit = wallet - .nostr_service() - .map(|s| s.relays()) - .unwrap_or_default(); - self.relay_input.clear(); - self.settings_page = SettingsPage::Relays; - } - if open_node { - self.node_url_input.clear(); - self.node_secret_input.clear(); - self.settings_page = SettingsPage::Node; - } - - ui.add_space(16.0); - let mut open_pairing = false; - let mut open_privacy = false; - settings_group(ui, &t!("goblin.settings.privacy"), |ui| { - // Messages, names, price and avatars ride the mixnet; the grin - // node connects directly. Normal dim value ink: the salmon - // privacy color doubled as the destructive-action color on - // this page, making a plain navigable row read as a warning. - if settings_row_nav( - ui, - &t!("goblin.settings.mixnet_routing"), - &t!("goblin.settings.messages_lookups"), - ) { - open_privacy = true; - } - // Tap to cycle the incoming-payment accept policy. Value styled - // like the sibling rows' values (small/dim), not like an icon. - if settings_row_cycle( - ui, - &t!("goblin.settings.auto_accept"), - &accept_policy_label(wallet), - ) { - cycle_accept_policy(wallet); - } - // Amount pairing: what the ≈ preview is shown against. - if settings_row_nav( - ui, - &t!("goblin.settings.pairing"), - crate::AppConfig::pairing().label(), - ) { - open_pairing = true; - } - // Hide received amounts in payment notifications/alerts. Same - // switch widget as the incoming-requests toggle below. - if let Some(v) = settings_row_toggle( - ui, - &t!("goblin.settings.hide_amounts"), - &t!("goblin.settings.hide_amounts_sub"), - crate::AppConfig::hide_amounts(), - ) { - crate::AppConfig::set_hide_amounts(v); - } - }); - if open_pairing { - self.settings_page = SettingsPage::Pairing; - } - if open_privacy { - self.settings_page = SettingsPage::Privacy; - } - - ui.add_space(16.0); - settings_group(ui, &t!("goblin.settings.requests"), |ui| { - let allow = wallet - .nostr_service() - .map(|s| s.config.read().allow_incoming_requests()) - .unwrap_or(true); - if let Some(v) = settings_row_toggle( - ui, - &t!("goblin.settings.incoming_requests"), - &t!("goblin.settings.incoming_requests_sub"), - allow, - ) { - if let Some(s) = wallet.nostr_service() { - s.config.write().set_allow_incoming_requests(v); - } - // Advertise the change so requesters see it before asking. - wallet.task(crate::wallet::types::WalletTask::NostrRepublishProfile); - } - }); - - ui.add_space(16.0); - w::kicker(ui, &t!("goblin.settings.appearance")); - ui.add_space(8.0); - let mut open_language = false; - w::card(ui, |ui| { - let theme_label = match crate::AppConfig::theme() { - crate::gui::theme::ThemeKind::Light => t!("goblin.settings.theme_light"), - crate::gui::theme::ThemeKind::Dark => t!("goblin.settings.theme_dark"), - crate::gui::theme::ThemeKind::Yellow => t!("goblin.settings.theme_yellow"), - }; - // Cycle-in-place (not a nav/icon row) so the value ("Dark") is - // drawn in the same small/dim style as the Language value beside - // it, instead of the larger icon size settings_row_btn uses. - if settings_row_cycle(ui, &t!("goblin.settings.theme"), &theme_label) { - cycle_theme(ui.ctx()); - } - // Language sits beside theme under Appearance; the value is the - // active language in its own name (e.g. "Deutsch"). - let current = crate::AppConfig::locale() - .unwrap_or_else(|| rust_i18n::locale().to_string()); - if settings_row_nav( - ui, - &t!("goblin.settings.language"), - &t!("lang_name", locale = current.as_str()), - ) { - open_language = true; - } - }); - if open_language { - self.settings_page = SettingsPage::Language; - } - - ui.add_space(16.0); - w::kicker(ui, &t!("goblin.settings.archive")); - ui.add_space(8.0); - w::card(ui, |ui| { - if settings_row_btn(ui, &t!("goblin.settings.export_archive"), COPY) { - if let Some(s) = wallet.nostr_service() { - let json = s.store.export_json(&s.npub()); - cb.copy_string_to_buffer(json); - cb.vibrate_copy(); - } - } - // Destructive: danger styling + tap-twice confirm (like the - // receipt's "Cancel payment") before the archive is wiped. - let wipe_label = if self.wipe_confirm { - t!("goblin.settings.wipe_history_confirm") - } else { - t!("goblin.settings.wipe_history") - }; - if settings_row_danger(ui, &wipe_label, crate::gui::icons::X) { - if self.wipe_confirm { - if let Some(s) = wallet.nostr_service() { - s.store.wipe_archive(); - } - self.wipe_confirm = false; - } else { - self.wipe_confirm = true; - } - } - }); - - ui.add_space(16.0); - settings_group(ui, &t!("goblin.settings.about"), |ui| { - if settings_row_nav( - ui, - &t!("goblin.settings.goblin"), - &t!("goblin.settings.build", build => crate::BUILD), - ) { - open_url(ui, "https://github.com/2ro/goblin/releases"); - } - settings_row( - ui, - &t!("goblin.settings.network"), - &t!("goblin.settings.network_value"), - ); - }); - - ui.add_space(16.0); - let mut open_nips = false; - settings_group(ui, &t!("goblin.settings.third_party"), |ui| { - if settings_row_nav(ui, &t!("goblin.settings.grim"), crate::VERSION) { - // Live upstream GRIM (GitHub mirror of code.gri.mw/GUI/grim). - // Was github.com/ardocrat/grim — a stale personal fork. - open_url(ui, "https://github.com/GetGrin/grim"); - } - if settings_row_nav(ui, &t!("goblin.settings.grin_node"), "5.4.0") { - open_url(ui, "https://github.com/mimblewimble/grin"); - } - if settings_row_nav(ui, "nostr-sdk", "0.44") { - open_url(ui, "https://github.com/rust-nostr/nostr"); - } - if settings_row_nav(ui, "Tor (arti)", "0.43") { - open_url(ui, "https://gitlab.torproject.org/tpo/core/arti"); - } - if settings_row_nav(ui, "egui", "0.33") { - open_url(ui, "https://github.com/emilk/egui"); - } - if settings_row_nav(ui, "NIPs", "05 · 17 · 44 · 49 · 59 · 98") { - open_nips = true; - } - }); - if open_nips { - self.settings_page = SettingsPage::Nips; - } - - // Wallet management lives at the foot of Settings: a neutral - // switch, the red lock, then the advanced (recovery) tools — - // each its own outlined action, so the destructive lock reads - // apart from the rest. - ui.add_space(24.0); - let dim = theme::tokens().surface_text_dim; - if w::outlined_icon_action( - ui, - crate::gui::icons::USER_SWITCH, - &t!("goblin.settings.switch_wallet"), - dim, - ) - .clicked() - { - self.settings_page = SettingsPage::Main; - self.switch_requested = true; - } - ui.add_space(10.0); - if w::outlined_icon_action( - ui, - crate::gui::icons::LOCK, - &t!("goblin.settings.lock_wallet"), - theme::tokens().neg, - ) - .clicked() - { - wallet.close(); - } - ui.add_space(10.0); - if w::outlined_icon_action( - ui, - crate::gui::icons::WRENCH, - &t!("goblin.settings.advanced"), - dim, - ) - .clicked() - { - self.advanced = AdvancedState::default(); - self.settings_page = SettingsPage::Advanced; - } - ui.add_space(20.0); - }); - } - - /// Back header for Settings sub-pages; returns true when back is tapped. - fn sub_header(&mut self, ui: &mut egui::Ui, title: &str) -> bool { - let t = theme::tokens(); - let mut back = false; - ui.add_space(8.0); - ui.horizontal(|ui| { - let (rect, resp) = ui.allocate_exact_size(Vec2::splat(36.0), Sense::click()); - ui.painter().circle_filled(rect.center(), 18.0, t.surface2); - ui.painter().text( - rect.center(), - egui::Align2::CENTER_CENTER, - crate::gui::icons::ARROW_LEFT, - FontId::new(16.0, fonts::regular()), - theme::ink_for(t.surface2), - ); - back = resp.clicked(); - ui.add_space(12.0); - ui.label( - RichText::new(title) - .font(FontId::new(24.0, fonts::bold())) - .color(t.text), - ); - }); - ui.add_space(16.0); - back - } - - /// Node connection editor: pick integrated/external, add or remove nodes. - fn pairing_settings_ui(&mut self, ui: &mut egui::Ui) { - let t = theme::tokens(); - if self.sub_header(ui, &t!("goblin.pairing.title")) { - self.settings_page = SettingsPage::Main; - return; - } - ScrollArea::vertical() - .id_salt("goblin_pairing_scroll") - .auto_shrink([false; 2]) - .scroll_bar_visibility(egui::scroll_area::ScrollBarVisibility::AlwaysHidden) - .show(ui, |ui| { - ui.label( - RichText::new(t!("goblin.pairing.intro")) - .font(FontId::new(13.0, fonts::regular())) - .color(t.text_dim), - ); - ui.add_space(12.0); - let current = crate::AppConfig::pairing(); - settings_group(ui, &t!("goblin.pairing.pair_with"), |ui| { - for p in crate::settings::Pairing::ALL { - let active = p == current; - let row = ui.horizontal(|ui| { - ui.label( - RichText::new(p.label()) - .font(FontId::new(15.0, fonts::medium())) - .color(t.surface_text), - ); - ui.with_layout(Layout::right_to_left(Align::Center), |ui| { - if active { - ui.label( - RichText::new(crate::gui::icons::CHECK) - .font(FontId::new(16.0, fonts::regular())) - .color(t.pos), - ); - } - }); - }); - ui.add_space(10.0); - if !active && row.response.interact(Sense::click()).clicked() { - crate::AppConfig::set_pairing(p); - } - } - }); - ui.add_space(10.0); - ui.label( - RichText::new(t!("goblin.pairing.rates_note")) - .font(FontId::new(12.0, fonts::regular())) - .color(t.text_dim), - ); - ui.add_space(16.0); - }); - } - - /// Language picker: the six shipped locales, each in its own name. Tapping one - /// switches the active locale and persists it (mirrors the GRIM interface - /// settings, but in Goblin's row style like the pairing picker). - fn language_settings_ui(&mut self, ui: &mut egui::Ui) { - let t = theme::tokens(); - if self.sub_header(ui, &t!("goblin.settings.language")) { - self.settings_page = SettingsPage::Main; - return; - } - let current = crate::AppConfig::locale().unwrap_or_else(|| rust_i18n::locale().to_string()); - ScrollArea::vertical() - .id_salt("goblin_language_scroll") - .auto_shrink([false; 2]) - .scroll_bar_visibility(egui::scroll_area::ScrollBarVisibility::AlwaysHidden) - .show(ui, |ui| { - settings_group(ui, &t!("goblin.settings.language"), |ui| { - for locale in rust_i18n::available_locales!() { - let active = current == locale; - let row = ui.horizontal(|ui| { - ui.label( - RichText::new(t!("lang_name", locale = locale)) - .font(FontId::new(15.0, fonts::medium())) - .color(t.surface_text), - ); - ui.with_layout(Layout::right_to_left(Align::Center), |ui| { - if active { - ui.label( - RichText::new(crate::gui::icons::CHECK) - .font(FontId::new(16.0, fonts::regular())) - .color(t.pos), - ); - } - }); - }); - ui.add_space(10.0); - if !active && row.response.interact(Sense::click()).clicked() { - rust_i18n::set_locale(locale); - crate::AppConfig::save_locale(locale); - } - } - }); - ui.add_space(16.0); - }); - } - - /// Network-privacy breakdown: what rides the Nym mixnet versus what connects - /// directly. Honest by design — no claim that node traffic is mixed, and no - /// toggle to route it (chain sync is heavy and not tied to your identity). - fn privacy_ui(&mut self, ui: &mut egui::Ui) { - let t = theme::tokens(); - if self.sub_header(ui, &t!("goblin.privacy.title")) { - self.settings_page = SettingsPage::Main; - return; - } - ScrollArea::vertical() - .id_salt("goblin_privacy_scroll") - .auto_shrink([false; 2]) - .scroll_bar_visibility(egui::scroll_area::ScrollBarVisibility::AlwaysHidden) - .show(ui, |ui| { - ui.label( - RichText::new(t!("goblin.privacy.intro")) - .font(FontId::new(14.0, fonts::regular())) - .color(t.text_dim), - ); - ui.add_space(16.0); - let mixnet = [ - ( - t!("goblin.privacy.payments"), - t!("goblin.privacy.payments_blurb"), - ), - ( - t!("goblin.privacy.usernames"), - t!("goblin.privacy.usernames_blurb"), - ), - ( - t!("goblin.privacy.price_avatars"), - t!("goblin.privacy.price_avatars_blurb"), - ), - ]; - settings_group(ui, &t!("goblin.privacy.over_mixnet"), |ui| { - for (title, blurb) in &mixnet { - privacy_line(ui, t.neg, title, blurb); - } - }); - ui.add_space(16.0); - settings_group(ui, &t!("goblin.privacy.direct_connection"), |ui| { - privacy_line( - ui, - t.surface_text_mute, - &t!("goblin.privacy.grin_node"), - &t!("goblin.privacy.grin_node_blurb"), - ); - }); - ui.add_space(16.0); - }); - } - - /// Advanced (wallet-recovery) page — GRIM's low-level tools surfaced in the - /// goblin style: repair, restore-from-seed, reveal the recovery phrase, and - /// delete. The two destructive actions arm a tap-twice confirm. - fn advanced_ui(&mut self, ui: &mut egui::Ui, wallet: &Wallet, cb: &dyn PlatformCallbacks) { - use crate::wallet::types::ConnectionMethod; - let t = theme::tokens(); - if self.sub_header(ui, &t!("goblin.advanced.title")) { - self.advanced = AdvancedState::default(); - self.settings_page = SettingsPage::Main; - return; - } - // Repair needs a synced node; mirror GRIM's availability check. - let integrated = wallet.get_current_connection() == ConnectionMethod::Integrated; - let integrated_ready = - crate::node::Node::get_sync_status() == Some(grin_chain::SyncStatus::NoSync); - let repair_unavailable = wallet.sync_error() || (integrated && !integrated_ready); - let repairing = wallet.is_repairing(); - let progress = wallet.repairing_progress(); - let mut leave = false; - let mut open_node = false; - let mut open_integrated = false; - { - let adv = &mut self.advanced; - ScrollArea::vertical() - .id_salt("goblin_advanced_scroll") - .auto_shrink([false; 2]) - .scroll_bar_visibility(egui::scroll_area::ScrollBarVisibility::AlwaysHidden) - .show(ui, |ui| { - ui.label( - RichText::new(t!("goblin.advanced.intro")) - .font(FontId::new(14.0, fonts::regular())) - .color(t.text_dim), - ); - ui.add_space(16.0); - - // Run your own node (the internal node). Opens the node-connection - // page, where you pick the integrated node or an external one. - w::card(ui, |ui| { - ui.set_min_width(ui.available_width()); - advanced_head(ui, &t!("goblin.node.integrated"), t.surface_text); - advanced_desc(ui, &t!("goblin.advanced.own_node_desc")); - ui.add_space(10.0); - if wallet.get_current_connection() == ConnectionMethod::Integrated { - ui.label( - RichText::new(format!( - "{} {}", - crate::gui::icons::CHECK, - t!("goblin.advanced.own_node_active") - )) - .font(FontId::new(13.0, fonts::medium())) - .color(t.pos), - ); - ui.add_space(10.0); - } - if w::big_action_on_card(ui, &t!("goblin.advanced.manage_node")).clicked() { - open_node = true; - } - ui.add_space(10.0); - // GRIM's integrated-node tabs (info, metrics, mining with - // stratum, node settings) in Goblin chrome. Their ONE home - // (single-home rule): Goblin is the lighter client, so they - // live here under Advanced, not in main Settings. - let node_label = if crate::node::Node::is_running() { - format!( - "{} · {}", - t!("goblin.settings.integrated_node"), - crate::node::Node::get_sync_status_text() - ) - } else { - t!("goblin.settings.integrated_node").to_string() - }; - if w::big_action_on_card(ui, &node_label).clicked() { - open_integrated = true; - } - }); - ui.add_space(12.0); - - // Repair. - w::card(ui, |ui| { - ui.set_min_width(ui.available_width()); - advanced_head(ui, &t!("goblin.advanced.repair"), t.surface_text); - advanced_desc(ui, &t!("goblin.advanced.repair_desc")); - ui.add_space(10.0); - if repairing { - ui.label( - RichText::new( - t!("goblin.advanced.repairing", pct => progress.to_string()), - ) - .font(FontId::new(13.0, fonts::medium())) - .color(t.accent), - ); - } else if repair_unavailable { - ui.label( - RichText::new(t!("goblin.advanced.repair_unavailable")) - .font(FontId::new(13.0, fonts::medium())) - .color(t.neg), - ); - } else if adv.confirm_repair { - // Repair re-scans the chain — it can take a few minutes. - // Warn + confirm in the accent (yellow) before starting. - ui.label( - RichText::new(t!("goblin.advanced.repair_confirm_note")) - .font(FontId::new(13.0, fonts::regular())) - .color(t.text_dim), - ); - ui.add_space(10.0); - if w::big_action_on_card_ink( - ui, - &t!("goblin.advanced.repair_confirm"), - t.accent, - ) - .clicked() - { - adv.confirm_repair = false; - wallet.repair(); - } - } else if w::big_action_on_card(ui, &t!("goblin.advanced.repair")).clicked() - { - adv.confirm_repair = true; - } - }); - ui.add_space(12.0); - - // Restore (rebuild local data from the seed). - w::card(ui, |ui| { - ui.set_min_width(ui.available_width()); - advanced_head(ui, &t!("goblin.advanced.restore"), t.surface_text); - advanced_desc(ui, &t!("goblin.advanced.restore_desc")); - ui.add_space(10.0); - if adv.confirm_restore { - ui.label( - RichText::new(t!("goblin.advanced.restore_confirm_note")) - .font(FontId::new(13.0, fonts::regular())) - .color(t.text_dim), - ); - ui.add_space(10.0); - if w::big_action_on_card_ink( - ui, - &t!("goblin.advanced.restore_confirm"), - t.neg, - ) - .clicked() - { - wallet.delete_db(); - leave = true; - } - } else if w::big_action_on_card(ui, &t!("goblin.advanced.restore")) - .clicked() - { - adv.confirm_restore = true; - } - }); - ui.add_space(12.0); - - // Recovery phrase (the grin seed words). - w::card(ui, |ui| { - ui.set_min_width(ui.available_width()); - advanced_head(ui, &t!("goblin.advanced.show_phrase"), t.surface_text); - advanced_desc(ui, &t!("goblin.advanced.phrase_desc")); - ui.add_space(10.0); - if let Some(words) = adv.revealed.clone() { - w::field_well(ui, |ui| { - ui.label( - RichText::new(words) - .font(FontId::new(15.0, fonts::medium())) - .color(t.surface_text), - ); - }); - ui.add_space(10.0); - if w::big_action_on_card(ui, &t!("goblin.advanced.hide")).clicked() { - adv.revealed = None; - adv.reveal_pass.clear(); - } - } else { - w::field_well(ui, |ui| { - TextEdit::new(egui::Id::from("advanced_reveal_pass")) - .focus(false) - .hint_text(t!("goblin.advanced.password")) - .password() - .text_color(t.surface_text) - .body() - .ui(ui, &mut adv.reveal_pass, cb); - }); - if adv.wrong_pass { - ui.add_space(6.0); - ui.label( - RichText::new(t!("goblin.advanced.wrong_password")) - .font(FontId::new(13.0, fonts::medium())) - .color(t.neg), - ); - } - ui.add_space(10.0); - ui.add_enabled_ui(!adv.reveal_pass.is_empty(), |ui| { - if w::big_action_on_card(ui, &t!("goblin.advanced.reveal")) - .clicked() - { - match wallet.get_recovery(adv.reveal_pass.clone()) { - Ok(phrase) => { - adv.revealed = Some(phrase.to_string()); - adv.wrong_pass = false; - adv.reveal_pass.clear(); - } - Err(_) => { - adv.wrong_pass = true; - } - } - } - }); - } - }); - ui.add_space(12.0); - - // Nostr key (nsec). Password-gated reveal, then Copy + a QR - // so it can be carried into a nostr app's private-key login - // (e.g. magick.market) without retyping. Same gate as the - // recovery phrase above. - w::card(ui, |ui| { - ui.set_min_width(ui.available_width()); - advanced_head(ui, &t!("goblin.advanced.nostr_key"), t.surface_text); - advanced_desc(ui, &t!("goblin.advanced.nostr_key_desc")); - ui.add_space(10.0); - if let Some(nsec) = adv.nsec_revealed.clone() { - w::field_well(ui, |ui| { - ui.label( - RichText::new(&nsec) - .font(FontId::new(14.0, fonts::medium())) - .color(t.surface_text), - ); - }); - ui.add_space(10.0); - if w::big_action_on_card(ui, &t!("goblin.advanced.copy_nsec")).clicked() - { - // Secret: auto-clears from the clipboard after a delay - // (compare-then-clear) so it does not linger there. - cb.copy_secret_to_buffer(nsec.clone()); - } - ui.add_space(8.0); - let qr_label = if adv.nsec_qr { - t!("goblin.advanced.hide_qr") - } else { - t!("goblin.advanced.show_qr") - }; - if w::big_action_on_card(ui, &qr_label).clicked() { - adv.nsec_qr = !adv.nsec_qr; - } - if adv.nsec_qr { - ui.add_space(10.0); - ui.vertical_centered(|ui| { - w::qr_code(ui, &nsec, 220.0); - }); - } - ui.add_space(10.0); - if w::big_action_on_card(ui, &t!("goblin.advanced.hide")).clicked() { - adv.nsec_revealed = None; - adv.nsec_qr = false; - adv.nsec_pass.clear(); - } - } else { - w::field_well(ui, |ui| { - TextEdit::new(egui::Id::from("advanced_nsec_pass")) - .focus(false) - .hint_text(t!("goblin.advanced.password")) - .password() - .text_color(t.surface_text) - .body() - .ui(ui, &mut adv.nsec_pass, cb); - }); - if adv.nsec_wrong { - ui.add_space(6.0); - ui.label( - RichText::new(t!("goblin.advanced.wrong_password")) - .font(FontId::new(13.0, fonts::medium())) - .color(t.neg), - ); - } - ui.add_space(10.0); - ui.add_enabled_ui(!adv.nsec_pass.is_empty(), |ui| { - if w::big_action_on_card(ui, &t!("goblin.advanced.reveal_nsec")) - .clicked() - { - match wallet.get_nostr_nsec(adv.nsec_pass.clone()) { - Ok(nsec) => { - adv.nsec_revealed = Some(nsec); - adv.nsec_wrong = false; - adv.nsec_pass.clear(); - } - Err(_) => { - adv.nsec_wrong = true; - } - } - } - }); - } - }); - ui.add_space(12.0); - - // Delete. - w::card(ui, |ui| { - ui.set_min_width(ui.available_width()); - advanced_head(ui, &t!("goblin.advanced.delete"), t.neg); - advanced_desc(ui, &t!("goblin.advanced.delete_desc")); - ui.add_space(10.0); - if adv.confirm_delete { - if w::big_action_on_card_ink( - ui, - &t!("goblin.advanced.delete_confirm"), - t.neg, - ) - .clicked() - { - wallet.delete_wallet(); - leave = true; - } - } else if w::big_action_on_card_ink( - ui, - &t!("goblin.advanced.delete"), - t.neg, - ) - .clicked() - { - adv.confirm_delete = true; - } - }); - ui.add_space(20.0); - }); - } - if leave { - self.advanced = AdvancedState::default(); - self.settings_page = SettingsPage::Main; - } - if open_node { - // Advanced → "Manage node connection" opens Goblin's own Node screen. - self.node_url_input.clear(); - self.node_secret_input.clear(); - self.settings_page = SettingsPage::Node; - } - if open_integrated { - // Advanced is the one home of the integrated-node tabs; back - // returns here. - self.node_tab = Box::new(crate::gui::views::network::NetworkNode); - self.node_tab_back = SettingsPage::Advanced; - self.settings_page = SettingsPage::IntegratedNode; - } - } - - /// GRIM's four integrated-node tabs (Info / Metrics / Mining / Settings) - /// hosted under a Goblin back header and segmented control — GRIM's - /// dual-panel and floating-navbar chrome are never rendered. The header - /// title follows the active tab, like GRIM's own title panel. - fn integrated_node_ui(&mut self, ui: &mut egui::Ui, cb: &dyn PlatformCallbacks) { - use crate::gui::icons::{DATABASE, FACTORY, FADERS, GAUGE}; - use crate::gui::views::network::types::NodeTabType; - use crate::gui::views::network::{ - NetworkContent, NetworkMetrics, NetworkMining, NetworkNode, NetworkSettings, - disabled_node_ui, node_error_ui, - }; - use crate::node::Node; - let title = self.node_tab.get_type().title(); - if self.sub_header(ui, &title) { - self.settings_page = self.node_tab_back; - return; - } - let selected = match self.node_tab.get_type() { - NodeTabType::Info => 0, - NodeTabType::Metrics => 1, - NodeTabType::Mining => 2, - NodeTabType::Settings => 3, - }; - if let Some(i) = w::segmented(ui, &[DATABASE, GAUGE, FACTORY, FADERS], selected) { - self.node_tab = match i { - 0 => Box::new(NetworkNode), - 1 => Box::new(NetworkMetrics), - 2 => Box::new(NetworkMining::default()), - _ => Box::new(NetworkSettings::default()), - }; - } - ui.add_space(12.0); - // Same availability gate as GRIM's NetworkContent: the Settings tab is - // editable with the node off; the live tabs need a running node with - // stats before their content can draw. - if self.node_tab.get_type() != NodeTabType::Settings { - if let Some(err) = Node::get_error() { - node_error_ui(ui, err); - } else if !Node::is_running() { - disabled_node_ui(ui); - } else if Node::get_stats().is_none() || Node::is_restarting() || Node::is_stopping() { - NetworkContent::loading_ui(ui, None::); - } else { - self.node_tab.tab_ui(ui, cb); - } - } else { - self.node_tab.tab_ui(ui, cb); - } - // Keep the stats fresh while the node runs. - if Node::is_running() { - ui.ctx().request_repaint_after(Node::STATS_UPDATE_DELAY); - } - } - - fn node_settings_ui(&mut self, ui: &mut egui::Ui, wallet: &Wallet, cb: &dyn PlatformCallbacks) { - use crate::wallet::types::ConnectionMethod; - use crate::wallet::{ConnectionsConfig, ExternalConnection}; - let t = theme::tokens(); - if self.sub_header(ui, &t!("goblin.node.title")) { - self.settings_page = SettingsPage::Main; - return; - } - ScrollArea::vertical() - .id_salt("goblin_node_scroll") - .auto_shrink([false; 2]) - .scroll_bar_visibility(egui::scroll_area::ScrollBarVisibility::AlwaysHidden) - .show(ui, |ui| { - let live = wallet.get_current_connection(); - let saved = wallet.get_config().connection(); - settings_group(ui, &t!("goblin.node.connection"), |ui| { - // Integrated node (run your own) sits at the top of the picker. - { - let active = matches!(&saved, ConnectionMethod::Integrated); - let row = ui.horizontal(|ui| { - ui.label( - RichText::new(t!("goblin.node.integrated")) - .font(FontId::new(15.0, fonts::medium())) - .color(t.surface_text), - ); - ui.with_layout(Layout::right_to_left(Align::Center), |ui| { - if active { - ui.label( - RichText::new(crate::gui::icons::CHECK) - .font(FontId::new(16.0, fonts::regular())) - .color(t.pos), - ); - } - }); - }); - ui.add_space(10.0); - if !active && row.response.interact(Sense::click()).clicked() { - wallet.update_connection(&ConnectionMethod::Integrated); - // Apply to the running session now, not on next unlock. - wallet.reconnect_node(); - } - } - for conn in ConnectionsConfig::ext_conn_list() { - let active = - matches!(&saved, ConnectionMethod::External(id, _) if *id == conn.id); - let label = conn.url.replace("https://", "").replace("http://", ""); - let mut removed = false; - let row = ui.horizontal(|ui| { - ui.label( - RichText::new(&label) - .font(FontId::new(15.0, fonts::medium())) - .color(t.surface_text), - ); - ui.with_layout(Layout::right_to_left(Align::Center), |ui| { - if active { - ui.label( - RichText::new(crate::gui::icons::CHECK) - .font(FontId::new(16.0, fonts::regular())) - .color(t.pos), - ); - } else { - // Trash, not an X: a grey × next to the active row's - // green check read as a failed-status icon, not the - // remove action it actually is. - let x = ui.label( - RichText::new(crate::gui::icons::TRASH_SIMPLE) - .font(FontId::new(15.0, fonts::regular())) - .color(t.surface_text_dim), - ); - if x.interact(Sense::click()).clicked() { - ConnectionsConfig::remove_ext_conn(conn.id); - removed = true; - } - } - }); - }); - ui.add_space(10.0); - if !removed && !active && row.response.interact(Sense::click()).clicked() { - wallet.update_connection(&ConnectionMethod::External( - conn.id, - conn.url.clone(), - )); - // Apply to the running session now, not on next unlock. - wallet.reconnect_node(); - } - } - }); - if saved != live { - ui.add_space(8.0); - ui.label( - RichText::new(t!("goblin.node.applies_after")) - .font(FontId::new(13.0, fonts::regular())) - .color(t.text_dim), - ); - } - - ui.add_space(16.0); - settings_group(ui, &t!("goblin.node.add_external"), |ui| { - TextEdit::new(egui::Id::from("set_node_url")) - .focus(false) - .hint_text("https://node.example.com:3413") - .text_color(t.surface_text) - .body() - .ui(ui, &mut self.node_url_input, cb); - ui.add_space(8.0); - TextEdit::new(egui::Id::from("set_node_secret")) - .focus(false) - .hint_text(t!("goblin.node.api_secret_hint")) - .text_color(t.surface_text) - .body() - .ui(ui, &mut self.node_secret_input, cb); - }); - ui.add_space(10.0); - let url = self.node_url_input.trim().to_string(); - let valid = url.starts_with("http://") || url.starts_with("https://"); - if w::big_action(ui, &t!("goblin.node.add_node"), false).clicked() && valid { - let secret = { - let s = self.node_secret_input.trim(); - if s.is_empty() { - None - } else { - Some(s.to_string()) - } - }; - let conn = ExternalConnection::new(url, None, secret); - ConnectionsConfig::add_ext_conn(conn.clone()); - wallet - .update_connection(&ConnectionMethod::External(conn.id, conn.url.clone())); - // Apply to the running session now, not on next unlock. - wallet.reconnect_node(); - self.node_url_input.clear(); - self.node_secret_input.clear(); - } - // (The integrated-node tabs' single home is Settings → Advanced; - // no duplicate entry here.) - ui.add_space(16.0); - }); - } - - /// Relay list editor; saving restarts the nostr service live. - fn relays_ui(&mut self, ui: &mut egui::Ui, wallet: &Wallet, cb: &dyn PlatformCallbacks) { - let t = theme::tokens(); - if self.sub_header(ui, &t!("goblin.relays.title")) { - self.settings_page = SettingsPage::Main; - return; - } - ScrollArea::vertical() - .id_salt("goblin_relays_scroll") - .auto_shrink([false; 2]) - .scroll_bar_visibility(egui::scroll_area::ScrollBarVisibility::AlwaysHidden) - .show(ui, |ui| { - ui.label( - RichText::new(t!("goblin.relays.intro")) - .font(FontId::new(13.0, fonts::regular())) - .color(t.text_dim), - ); - ui.add_space(14.0); - settings_group(ui, &t!("goblin.relays.your_relays"), |ui| { - let mut remove: Option = None; - let many = self.relay_edit.len() > 1; - for (i, relay) in self.relay_edit.iter().enumerate() { - ui.horizontal(|ui| { - ui.label( - RichText::new(relay) - .font(FontId::new(14.0, fonts::medium())) - .color(t.surface_text), - ); - ui.with_layout(Layout::right_to_left(Align::Center), |ui| { - if many { - let x = ui.label( - RichText::new(crate::gui::icons::X) - .font(FontId::new(15.0, fonts::regular())) - .color(t.surface_text_mute), - ); - if x.interact(Sense::click()).clicked() { - remove = Some(i); - } - } - }); - }); - ui.add_space(10.0); - } - if let Some(i) = remove { - self.relay_edit.remove(i); - } - }); - - ui.add_space(16.0); - settings_group(ui, &t!("goblin.relays.add_relay"), |ui| { - TextEdit::new(egui::Id::from("set_relay")) - .focus(false) - .hint_text("wss://relay.example.com") - .text_color(t.surface_text) - .body() - .ui(ui, &mut self.relay_input, cb); - }); - ui.add_space(10.0); - let relay = self.relay_input.trim().to_string(); - let valid = relay.starts_with("wss://") || relay.starts_with("ws://"); - if w::big_action_on_card(ui, &t!("goblin.relays.add_relay_btn")).clicked() - && valid && !self.relay_edit.contains(&relay) - { - self.relay_edit.push(relay); - self.relay_input.clear(); - } - ui.add_space(10.0); - if w::big_action(ui, &t!("goblin.relays.save_reconnect"), false).clicked() { - if let Some(s) = wallet.nostr_service() { - { - let mut c = s.config.write(); - c.set_relays(self.relay_edit.clone()); - c.save(); - } - s.restart(wallet.clone()); - } - self.settings_page = SettingsPage::Main; - } - ui.add_space(16.0); - }); - } - - /// Manual slatepack exchange — GRIM's native by-hand flow, exposed as an - /// advanced fallback for when a payment can't ride a @username. - fn slatepack_ui(&mut self, ui: &mut egui::Ui, wallet: &Wallet, cb: &dyn PlatformCallbacks) { - let t = theme::tokens(); - if self.sub_header(ui, &t!("goblin.settings.slatepacks")) { - self.settings_page = SettingsPage::Main; - return; - } - ScrollArea::vertical() - .id_salt("goblin_slatepack_scroll") - .auto_shrink([false; 2]) - .scroll_bar_visibility(egui::scroll_area::ScrollBarVisibility::AlwaysHidden) - .show(ui, |ui| { - ui.label( - RichText::new(t!("goblin.settings.sp_intro")) - .font(FontId::new(13.0, fonts::regular())) - .color(t.text_dim), - ); - ui.add_space(14.0); - - // Receive / continue: paste a slatepack, let the wallet route it. - let mut do_process = false; - settings_group(ui, &t!("goblin.settings.sp_receive_group"), |ui| { - ui.label( - RichText::new(t!("goblin.settings.sp_receive_blurb")) - .font(FontId::new(12.5, fonts::regular())) - .color(t.surface_text_dim), - ); - ui.add_space(8.0); - TextEdit::new(egui::Id::from("sp_paste")) - .focus(false) - .paste() - .hint_text("BEGINSLATEPACK. … ENDSLATEPACK.") - .text_color(t.surface_text) - .body() - .ui(ui, &mut self.slatepack.paste, cb); - }); - ui.add_space(10.0); - if w::big_action(ui, &t!("goblin.settings.sp_process"), false).clicked() { - do_process = true; - } - if do_process { - let text = self.slatepack.paste.trim().to_string(); - if text.is_empty() { - self.slatepack.error = - Some(t!("goblin.settings.sp_paste_first").to_string()); - self.slatepack.status = None; - } else { - use crate::wallet::types::ManualSlatepackOutcome as Out; - match wallet.manual_process_slatepack(&text) { - Ok(Out::Response(reply)) => { - self.slatepack.result = reply; - self.slatepack.status = - Some(t!("goblin.settings.sp_reply_ready").to_string()); - self.slatepack.error = None; - self.slatepack.paste.clear(); - } - Ok(Out::Finalizing) => { - self.slatepack.result.clear(); - self.slatepack.status = - Some(t!("goblin.settings.sp_finalizing").to_string()); - self.slatepack.error = None; - self.slatepack.paste.clear(); - } - Err(e) => { - self.slatepack.error = Some(e.to_string()); - self.slatepack.status = None; - } - } - } - } - - // Send: create a slatepack to hand over out-of-band. - ui.add_space(16.0); - let mut do_send = false; - settings_group(ui, &t!("goblin.settings.sp_create_group"), |ui| { - ui.label( - RichText::new(t!("goblin.settings.sp_create_blurb")) - .font(FontId::new(12.5, fonts::regular())) - .color(t.surface_text_dim), - ); - ui.add_space(8.0); - TextEdit::new(egui::Id::from("sp_amount")) - .focus(false) - .numeric() - .hint_text(t!("goblin.settings.sp_amount_hint")) - .text_color(t.surface_text) - .body() - .ui(ui, &mut self.slatepack.amount, cb); - ui.add_space(8.0); - TextEdit::new(egui::Id::from("sp_addr")) - .focus(false) - .hint_text(t!("goblin.settings.sp_addr_hint")) - .text_color(t.surface_text) - .body() - .ui(ui, &mut self.slatepack.address, cb); - }); - ui.add_space(10.0); - if w::big_action(ui, &t!("goblin.settings.sp_create"), false).clicked() { - do_send = true; - } - if do_send { - match grin_core::core::amount_from_hr_string(self.slatepack.amount.trim()) { - Ok(a) if a > 0 => { - let s = self.slatepack.address.trim(); - let dest = if s.is_empty() { - None - } else { - Some(s.to_string()) - }; - match wallet.manual_send_slatepack(a, dest) { - Ok(text) => { - self.slatepack.result = text; - self.slatepack.status = - Some(t!("goblin.settings.sp_ready").to_string()); - self.slatepack.error = None; - } - Err(e) => { - self.slatepack.error = Some(e.to_string()); - self.slatepack.status = None; - } - } - } - _ => { - self.slatepack.error = - Some(t!("goblin.settings.sp_amount_gt_zero").to_string()); - self.slatepack.status = None; - } - } - } - - // Status, error, and the produced slatepack (copyable). - if let Some(err) = self.slatepack.error.clone() { - ui.add_space(10.0); - ui.label( - RichText::new(err) - .font(FontId::new(13.0, fonts::regular())) - .color(t.neg), - ); - } - if let Some(status) = self.slatepack.status.clone() { - ui.add_space(10.0); - ui.label( - RichText::new(status) - .font(FontId::new(13.0, fonts::regular())) - .color(t.text_dim), - ); - } - let result = self.slatepack.result.clone(); - if !result.is_empty() { - ui.add_space(14.0); - settings_group(ui, &t!("goblin.settings.sp_to_send"), |ui| { - let preview: String = result.chars().take(120).collect(); - let preview = if result.chars().count() > 120 { - format!("{preview}…") - } else { - preview - }; - ui.label( - RichText::new(preview) - .font(FontId::new(12.0, fonts::mono())) - .color(t.surface_text_dim), - ); - }); - ui.add_space(10.0); - if w::big_action(ui, &t!("goblin.settings.sp_copy"), false).clicked() { - cb.copy_string_to_buffer(result); - cb.vibrate_copy(); - } - } - ui.add_space(16.0); - }); - } - - /// What-is-nostr explainer and tappable NIP reference list. - fn nips_ui(&mut self, ui: &mut egui::Ui) { - let t = theme::tokens(); - if self.sub_header(ui, &t!("goblin.nips.title")) { - self.settings_page = SettingsPage::Main; - return; - } - ScrollArea::vertical() - .id_salt("goblin_nips_scroll") - .auto_shrink([false; 2]) - .scroll_bar_visibility(egui::scroll_area::ScrollBarVisibility::AlwaysHidden) - .show(ui, |ui| { - ui.label( - RichText::new(t!("goblin.nips.intro1")) - .font(FontId::new(14.0, fonts::regular())) - .color(t.text_dim), - ); - ui.add_space(10.0); - ui.label( - RichText::new(t!("goblin.nips.intro2")) - .font(FontId::new(14.0, fonts::regular())) - .color(t.text_dim), - ); - ui.add_space(16.0); - let nips = [ - ( - "05", - t!("goblin.nips.n05_title"), - t!("goblin.nips.n05_blurb"), - ), - ( - "17", - t!("goblin.nips.n17_title"), - t!("goblin.nips.n17_blurb"), - ), - ( - "44", - t!("goblin.nips.n44_title"), - t!("goblin.nips.n44_blurb"), - ), - ( - "49", - t!("goblin.nips.n49_title"), - t!("goblin.nips.n49_blurb"), - ), - ( - "59", - t!("goblin.nips.n59_title"), - t!("goblin.nips.n59_blurb"), - ), - ( - "98", - t!("goblin.nips.n98_title"), - t!("goblin.nips.n98_blurb"), - ), - ]; - for (num, title, blurb) in &nips { - let resp = ui.scope(|ui| { - w::card(ui, |ui| { - ui.set_min_width(ui.available_width()); - ui.label( - RichText::new(format!("NIP-{} · {}", num, title)) - .font(FontId::new(14.0, fonts::semibold())) - .color(t.surface_text), - ); - ui.add_space(2.0); - ui.label( - RichText::new(blurb.as_ref()) - .font(FontId::new(12.0, fonts::regular())) - .color(t.surface_text_dim), - ); - }); - }); - if resp.response.interact(Sense::click()).clicked() { - open_url( - ui, - &format!( - "https://github.com/nostr-protocol/nips/blob/master/{}.md", - num - ), - ); - } - ui.add_space(8.0); - } - ui.add_space(16.0); - }); - } - - /// Inline key-rotation flow: warning → typed RESET + password → result. - fn rotate_ui(&mut self, ui: &mut egui::Ui, wallet: &Wallet, cb: &dyn PlatformCallbacks) { - let t = theme::tokens(); - let rotate = self.rotate.as_mut().unwrap(); - // Poll the worker result. - if rotate.stage == 3 { - if let Some(res) = rotate.result.lock().unwrap().take() { - match res { - Ok(npub) => { - rotate.new_npub = npub; - rotate.stage = 4; - } - Err(e) => { - rotate.error = e; - rotate.stage = 5; - } - } - } - } - let mut close = false; - w::card(ui, |ui| { - ui.set_min_width(ui.available_width()); - match rotate.stage { - 1 => { - ui.label( - RichText::new(t!("goblin.settings.rotate_key")) - .font(FontId::new(15.0, fonts::semibold())) - .color(t.neg), - ); - ui.add_space(6.0); - for line in [ - t!("goblin.settings.rotate_line1"), - t!("goblin.settings.rotate_line2"), - t!("goblin.settings.rotate_line3"), - t!("goblin.settings.rotate_line4"), - t!("goblin.settings.rotate_line5"), - ] { - ui.label( - RichText::new(line) - .font(FontId::new(13.0, fonts::regular())) - .color(t.surface_text_dim), - ); - ui.add_space(4.0); - } - ui.add_space(8.0); - 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| { - if w::big_action_on_card(ui, &t!("goblin.settings.cancel")) - .clicked() - { - close = true; - } - }, - ); - 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| { - if w::big_action(ui, &t!("goblin.settings.continue"), false) - .clicked() - { - rotate.stage = 2; - } - }, - ); - }); - } - 2 => { - ui.label( - RichText::new(t!("goblin.settings.final_confirmation")) - .font(FontId::new(15.0, fonts::semibold())) - .color(t.neg), - ); - ui.add_space(6.0); - ui.label( - RichText::new(t!("goblin.settings.rotate_confirm_blurb")) - .font(FontId::new(13.0, fonts::regular())) - .color(t.surface_text_dim), - ); - ui.add_space(10.0); - w::field_well(ui, |ui| { - TextEdit::new(egui::Id::from("rotate_reset")) - .focus(false) - .hint_text(t!("goblin.settings.type_reset")) - .text_color(t.surface_text) - .body() - .ui(ui, &mut rotate.reset_input, cb); - }); - ui.add_space(8.0); - w::field_well(ui, |ui| { - TextEdit::new(egui::Id::from("rotate_pass")) - .focus(false) - .hint_text(t!("goblin.settings.wallet_password")) - .password() - .text_color(t.surface_text) - .body() - .ui(ui, &mut rotate.password, cb); - }); - ui.add_space(10.0); - let armed = rotate.reset_input.trim() == "RESET" && !rotate.password.is_empty(); - 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| { - if w::big_action_on_card(ui, &t!("goblin.settings.cancel")) - .clicked() - { - close = true; - } - }, - ); - 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(armed, |ui| { - if w::big_action( - ui, - &t!("goblin.settings.rotate_key_btn"), - false, - ) - .clicked() - { - rotate.stage = 3; - let slot = rotate.result.clone(); - let password = std::mem::take(&mut rotate.password); - rotate.reset_input.clear(); - let wallet = wallet.clone(); - std::thread::spawn(move || { - let res = wallet.rotate_nostr_identity(password); - *slot.lock().unwrap() = Some(res); - }); - } - }); - }, - ); - }); - } - 3 => { - ui.horizontal(|ui| { - View::small_loading_spinner(ui); - ui.add_space(8.0); - ui.label( - RichText::new(t!("goblin.settings.rotating_key")) - .font(FontId::new(13.0, fonts::regular())) - .color(t.surface_text_dim), - ); - }); - ui.ctx().request_repaint(); - } - 4 => { - ui.label( - RichText::new(t!("goblin.settings.key_rotated")) - .font(FontId::new(15.0, fonts::semibold())) - .color(t.pos), - ); - ui.add_space(4.0); - let npub = &rotate.new_npub; - let short = if npub.len() > 18 { - format!("{}…{}", &npub[..12], &npub[npub.len() - 6..]) - } else { - npub.clone() - }; - ui.label( - RichText::new(t!("goblin.settings.new_npub", npub => short)) - .font(FontId::new(13.0, fonts::mono())) - .color(t.surface_text_dim), - ); - ui.add_space(6.0); - ui.label( - RichText::new(t!("goblin.settings.backup_new_key")) - .font(FontId::new(13.0, fonts::semibold())) - .color(t.neg), - ); - ui.add_space(10.0); - if w::big_action_on_card(ui, &t!("goblin.settings.copy_new_nsec")).clicked() { - if let Some(nsec) = wallet.nostr_service().and_then(|s| s.nsec()) { - // Secret: auto-clears from the clipboard after a delay - // (compare-then-clear) so it does not linger there. - cb.copy_secret_to_buffer(nsec); - cb.vibrate_copy(); - } - } - ui.add_space(8.0); - if w::big_action(ui, &t!("goblin.settings.done"), false).clicked() { - close = true; - } - } - _ => { - ui.label( - RichText::new(t!("goblin.settings.rotation_failed")) - .font(FontId::new(15.0, fonts::semibold())) - .color(t.neg), - ); - ui.add_space(4.0); - ui.label( - RichText::new(&rotate.error) - .font(FontId::new(13.0, fonts::regular())) - .color(t.surface_text_dim), - ); - ui.add_space(10.0); - if w::big_action_on_card(ui, &t!("goblin.settings.close")).clicked() { - close = true; - } - } - } - }); - if close { - self.rotate = None; - } - } - - /// Inline nsec-import flow: replaces the identity with an imported key. - /// Inline "change name authority" editor: set the NIP-05 server that registers - /// and verifies names. Lets a user on one instance pay names on another. - fn name_authority_ui( - &mut self, - ui: &mut egui::Ui, - wallet: &Wallet, - cb: &dyn PlatformCallbacks, - ) { - let t = theme::tokens(); - let na = self.name_authority.as_mut().unwrap(); - let mut close = false; - w::card(ui, |ui| { - ui.set_min_width(ui.available_width()); - ui.label( - RichText::new(t!("goblin.settings.name_authority_title")) - .font(FontId::new(15.0, fonts::semibold())) - .color(t.surface_text), - ); - ui.add_space(6.0); - ui.label( - RichText::new(t!("goblin.settings.name_authority_blurb")) - .font(FontId::new(13.0, fonts::regular())) - .color(t.surface_text_dim), - ); - ui.add_space(10.0); - w::field_well(ui, |ui| { - TextEdit::new(egui::Id::from("name_authority_input")) - .focus(false) - .hint_text("https://goblin.st") - .text_color(t.surface_text) - .body() - .ui(ui, &mut na.input, cb); - }); - if let Some(err) = &na.error { - ui.add_space(6.0); - ui.label( - RichText::new(err) - .font(FontId::new(12.5, fonts::regular())) - .color(t.neg), - ); - } - ui.add_space(10.0); - ui.horizontal(|ui| { - let third = (ui.available_width() - 20.0) / 3.0; - ui.scope_builder( - egui::UiBuilder::new().max_rect(egui::Rect::from_min_size( - ui.cursor().min, - Vec2::new(third, 44.0), - )), - |ui| { - if w::big_action_on_card(ui, &t!("goblin.settings.cancel")).clicked() { - close = true; - } - }, - ); - ui.add_space(10.0); - ui.scope_builder( - egui::UiBuilder::new().max_rect(egui::Rect::from_min_size( - ui.cursor().min, - Vec2::new(third, 44.0), - )), - |ui| { - if w::big_action_on_card(ui, &t!("goblin.settings.reset")).clicked() { - if let Some(s) = wallet.nostr_service() { - s.config.write().set_nip05_server(None); - crate::nostr::nip05::set_home_domain( - &s.config.read().home_domain(), - ); - } - close = true; - } - }, - ); - ui.add_space(10.0); - ui.scope_builder( - egui::UiBuilder::new().max_rect(egui::Rect::from_min_size( - ui.cursor().min, - Vec2::new(third, 44.0), - )), - |ui| { - if w::big_action(ui, &t!("goblin.settings.save"), false).clicked() { - let url = na.input.trim().to_string(); - if !url.starts_with("https://") && !url.starts_with("http://") { - na.error = - Some(t!("goblin.settings.name_authority_invalid").to_string()); - } else if let Some(s) = wallet.nostr_service() { - s.config.write().set_nip05_server(Some(url)); - crate::nostr::nip05::set_home_domain( - &s.config.read().home_domain(), - ); - close = true; - } - } - }, - ); - }); - }); - if close { - self.name_authority = None; - } - } - - /// Inline "back up identity to a file" flow: ask for the wallet password, - /// seal the identity, and write a GOBLIN-*.backup file via the native picker. - fn backup_ui(&mut self, ui: &mut egui::Ui, wallet: &Wallet, cb: &dyn PlatformCallbacks) { - let t = theme::tokens(); - let bk = self.backup.as_mut().unwrap(); - let mut close = false; - w::card(ui, |ui| { - ui.set_min_width(ui.available_width()); - if bk.done { - ui.label( - RichText::new(t!("goblin.settings.backup_saved")) - .font(FontId::new(15.0, fonts::semibold())) - .color(t.pos), - ); - ui.add_space(4.0); - ui.label( - RichText::new(t!("goblin.settings.backup_saved_sub")) - .font(FontId::new(13.0, fonts::regular())) - .color(t.surface_text_dim), - ); - ui.add_space(10.0); - if w::big_action(ui, &t!("goblin.settings.done"), false).clicked() { - close = true; - } - return; - } - ui.label( - RichText::new(t!("goblin.settings.backup_file_title")) - .font(FontId::new(15.0, fonts::semibold())) - .color(t.surface_text), - ); - ui.add_space(6.0); - ui.label( - RichText::new(t!("goblin.settings.backup_file_blurb")) - .font(FontId::new(13.0, fonts::regular())) - .color(t.surface_text_dim), - ); - ui.add_space(10.0); - w::field_well(ui, |ui| { - TextEdit::new(egui::Id::from("backup_pass")) - .focus(false) - .hint_text(t!("goblin.settings.wallet_password")) - .password() - .text_color(t.surface_text) - .body() - .ui(ui, &mut bk.password, cb); - }); - if let Some(err) = &bk.error { - ui.add_space(6.0); - ui.label( - RichText::new(err) - .font(FontId::new(12.5, fonts::regular())) - .color(t.neg), - ); - } - ui.add_space(10.0); - 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| { - if w::big_action_on_card(ui, &t!("goblin.settings.cancel")).clicked() { - close = true; - } - }, - ); - 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(!bk.password.is_empty(), |ui| { - if w::big_action(ui, &t!("goblin.settings.create_backup"), false) - .clicked() - { - match wallet.create_nostr_backup(&bk.password) { - Ok(envelope) => { - let stamp = chrono::Local::now().format("%Y-%m-%d-%H%M"); - let fname = format!("GOBLIN-{stamp}.backup"); - match cb.save_file(fname, envelope.into_bytes()) { - Ok(()) => { - bk.done = true; - bk.error = None; - bk.password.clear(); - } - Err(_) => { - bk.error = Some( - t!("goblin.settings.backup_write_failed") - .to_string(), - ); - } - } - } - Err(e) => bk.error = Some(e), - } - } - }); - }, - ); - }); - }); - if close { - self.backup = None; - } - } - - fn import_nsec_ui(&mut self, ui: &mut egui::Ui, wallet: &Wallet, cb: &dyn PlatformCallbacks) { - let t = theme::tokens(); - let import = self.import_nsec.as_mut().unwrap(); - if import.stage == 3 { - if let Some(res) = import.result.lock().unwrap().take() { - match res { - Ok(npub) => { - import.new_npub = npub; - import.stage = 4; - } - Err(e) => { - import.error = e; - import.stage = 5; - } - } - } - } - let mut close = false; - w::card(ui, |ui| { - ui.set_min_width(ui.available_width()); - match import.stage { - 1 => { - ui.label( - RichText::new(t!("goblin.settings.import_identity_title")) - .font(FontId::new(15.0, fonts::semibold())) - .color(t.surface_text), - ); - ui.add_space(6.0); - ui.label( - RichText::new(t!("goblin.settings.import_blurb")) - .font(FontId::new(13.0, fonts::regular())) - .color(t.surface_text_dim), - ); - ui.add_space(10.0); - // Native ".backup file" picker. Desktop returns the path now; - // Android returns it asynchronously (poll picked_file()). - if import.picking { - if let Some(path) = cb.picked_file() { - import.picking = false; - if !path.is_empty() { - match std::fs::read_to_string(&path) { - Ok(contents) => import.nsec = contents.trim().to_string(), - Err(_) => { - import.error = - t!("goblin.settings.backup_read_failed").to_string(); - } - } - } - } else { - ui.ctx().request_repaint(); - } - } - if w::big_action_on_card(ui, &t!("goblin.settings.choose_backup_file")) - .clicked() - { - import.error.clear(); - match cb.pick_file() { - Some(path) if !path.is_empty() => { - match std::fs::read_to_string(&path) { - Ok(contents) => import.nsec = contents.trim().to_string(), - Err(_) => { - import.error = - t!("goblin.settings.backup_read_failed").to_string(); - } - } - } - // Empty string = Android async pick in flight. - Some(_) => import.picking = true, - None => {} - } - } - if !import.error.is_empty() && import.stage == 1 { - ui.add_space(6.0); - ui.label( - RichText::new(&import.error) - .font(FontId::new(12.5, fonts::regular())) - .color(t.neg), - ); - } - ui.add_space(8.0); - w::field_well(ui, |ui| { - TextEdit::new(egui::Id::from("import_nsec")) - .focus(false) - .hint_text(t!("goblin.settings.import_nsec_hint")) - .password() - .text_color(t.surface_text) - .body() - .ui(ui, &mut import.nsec, cb); - }); - ui.add_space(8.0); - w::field_well(ui, |ui| { - TextEdit::new(egui::Id::from("import_pass")) - .focus(false) - .hint_text(t!("goblin.settings.wallet_password")) - .password() - .text_color(t.surface_text) - .body() - .ui(ui, &mut import.password, cb); - }); - ui.add_space(8.0); - w::field_well(ui, |ui| { - TextEdit::new(egui::Id::from("import_backup_pass")) - .focus(false) - .hint_text(t!("goblin.settings.backup_password_hint")) - .password() - .text_color(t.surface_text) - .body() - .ui(ui, &mut import.backup_password, cb); - }); - ui.add_space(10.0); - let pasted = import.nsec.trim(); - let armed = (pasted.starts_with("nsec1") || pasted.starts_with('{')) - && !import.password.is_empty(); - 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| { - if w::big_action_on_card(ui, &t!("goblin.settings.cancel")) - .clicked() - { - close = true; - } - }, - ); - 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(armed, |ui| { - if w::big_action(ui, &t!("goblin.settings.import_btn"), false) - .clicked() - { - import.stage = 3; - let slot = import.result.clone(); - let nsec = std::mem::take(&mut import.nsec); - let password = std::mem::take(&mut import.password); - let bpw = std::mem::take(&mut import.backup_password); - let bpw = if bpw.is_empty() { None } else { Some(bpw) }; - let wallet = wallet.clone(); - std::thread::spawn(move || { - let res = - wallet.import_nostr_identity(nsec, password, bpw); - *slot.lock().unwrap() = Some(res); - }); - } - }); - }, - ); - }); - } - 3 => { - ui.horizontal(|ui| { - View::small_loading_spinner(ui); - ui.add_space(8.0); - ui.label( - RichText::new(t!("goblin.settings.importing")) - .font(FontId::new(13.0, fonts::regular())) - .color(t.surface_text_dim), - ); - }); - ui.ctx().request_repaint(); - } - 4 => { - ui.label( - RichText::new(t!("goblin.settings.identity_replaced")) - .font(FontId::new(15.0, fonts::semibold())) - .color(t.pos), - ); - ui.add_space(4.0); - let npub = &import.new_npub; - let short = if npub.len() > 18 { - format!("{}…{}", &npub[..12], &npub[npub.len() - 6..]) - } else { - npub.clone() - }; - ui.label( - RichText::new(t!("goblin.settings.now_using", npub => short)) - .font(FontId::new(13.0, fonts::mono())) - .color(t.surface_text_dim), - ); - ui.add_space(10.0); - if w::big_action(ui, &t!("goblin.settings.done"), false).clicked() { - close = true; - } - } - _ => { - ui.label( - RichText::new(t!("goblin.settings.import_failed")) - .font(FontId::new(15.0, fonts::semibold())) - .color(t.neg), - ); - ui.add_space(4.0); - ui.label( - RichText::new(&import.error) - .font(FontId::new(13.0, fonts::regular())) - .color(t.surface_text_dim), - ); - ui.add_space(10.0); - if w::big_action_on_card(ui, &t!("goblin.settings.close")).clicked() { - close = true; - } - } - } - }); - if close { - self.import_nsec = None; - } - } - - /// The identity switcher page: one wallet, one grin balance, many nostr - /// identities. Lists the held identities (tap to make one active), and adds a - /// new one (generate a fresh nsec or import an existing one). Switching runs a - /// catch-up so payments that arrived while an identity was dormant land in the - /// single shared balance; the syncing / "you were paid while away" state shows - /// here. The wallet password (entered once on this page) unlocks a target on - /// switch and encrypts a new identity on add — every held nsec is stored the - /// same way: its own NIP-49 ncryptsec under the wallet password. - fn identities_ui(&mut self, ui: &mut egui::Ui, wallet: &Wallet, cb: &dyn PlatformCallbacks) { - let t = theme::tokens(); - if self.sub_header(ui, &t!("goblin.identities.title")) { - self.settings_page = SettingsPage::Main; - self.identity_switch = IdentitySwitchState::default(); - return; - } - // Poll the background switch/add worker. - if self.identity_switch.busy - && let Some(res) = self.identity_switch.result.lock().unwrap().take() - { - self.identity_switch.busy = false; - match res { - Ok(_) => { - self.identity_switch.error.clear(); - self.identity_switch.adding = false; - self.identity_switch.import = false; - self.identity_switch.nsec.clear(); - self.identity_switch.backup_input.clear(); - self.identity_switch.confirm_delete = None; - } - Err(e) => { - self.identity_switch.error = e; - } - } - } - - // Wallet-password modal — the unlock step for ADDING an identity only - // (switching is instant and local, no password), mirroring the wallet-open - // password modal (dimmed backdrop, same buttons). - if Modal::opened() == Some(IDENTITY_PASS_MODAL) { - Modal::ui(ui.ctx(), cb, |ui, modal, cb| { - self.identity_pass_modal_content(ui, modal, wallet, cb); - }); - } - // Per-identity management modal (rename / delete): dims and locks the - // list, disabling switching and row taps until it closes. - if Modal::opened() == Some(IDENTITY_MANAGE_MODAL) { - Modal::ui(ui.ctx(), cb, |ui, _modal, cb| { - self.identity_manage_modal_content(ui, wallet, cb); - }); - } - // Step-1 delete confirmation modal (danger text), also background-locking. - if Modal::opened() == Some(IDENTITY_DELETE_MODAL) { - Modal::ui(ui.ctx(), cb, |ui, _modal, _cb| { - self.identity_delete_modal_content(ui, wallet); - }); - } - - let identities = wallet.nostr_identities(); - - ScrollArea::vertical() - .id_salt("goblin_identities_scroll") - .auto_shrink([false; 2]) - .scroll_bar_visibility(egui::scroll_area::ScrollBarVisibility::AlwaysHidden) - .show(ui, |ui| { - ui.label( - RichText::new(t!("goblin.identities.blurb")) - .font(FontId::new(13.5, fonts::regular())) - .color(t.text_dim), - ); - ui.add_space(8.0); - ui.label( - RichText::new(t!("goblin.identities.privacy_note")) - .font(FontId::new(12.0, fonts::regular())) - .color(t.text_mute), - ); - ui.add_space(14.0); - - // Held identities. Tap a non-active one to switch to it INSTANTLY — - // all identities are already unlocked and listening, so a switch is a - // local change of which one is presented and used for sending. - w::kicker(ui, &t!("goblin.identities.held")); - ui.add_space(8.0); - let busy = self.identity_switch.busy; - let mut switch_to: Option = None; - let mut manage_target: Option = None; - for id in &identities { - // Display precedence: private tag, else claimed name (bare, no - // leading @), else truncated npub. Never a placeholder word. - let short = data::short_npub(&id.pubkey_hex); - let title = id.display(); - let mut pencil_hit = false; - let row = w::card(ui, |ui| { - ui.set_min_width(ui.available_width()); - ui.horizontal(|ui| { - w::avatar_any(ui, &title, &id.pubkey_hex, 40.0, None); - ui.add_space(12.0); - ui.vertical(|ui| { - ui.label( - RichText::new(&title) - .font(FontId::new(15.0, fonts::semibold())) - .color(t.surface_text), - ); - // The npub underneath, unless the title already IS the - // npub (unnamed, untagged identity). - if title != short { - ui.label( - RichText::new(&short) - .font(FontId::new(12.0, fonts::regular())) - .color(t.surface_text_mute), - ); - } - }); - ui.with_layout(Layout::right_to_left(Align::Center), |ui| { - if id.active { - ui.label( - RichText::new(crate::gui::icons::CHECK_CIRCLE) - .font(FontId::new(18.0, fonts::regular())) - .color(t.pos), - ); - } else { - ui.label( - RichText::new(crate::gui::icons::ARROWS_LEFT_RIGHT) - .font(FontId::new(16.0, fonts::regular())) - .color(t.surface_text_dim), - ); - } - // Edit (pencil) affordance: opens the per-identity - // management sheet (rename / delete). Its own tap target - // so it never triggers the row switch. - if !busy { - ui.add_space(8.0); - let (r, resp) = - ui.allocate_exact_size(Vec2::splat(28.0), Sense::click()); - ui.painter().text( - r.center(), - egui::Align2::CENTER_CENTER, - crate::gui::icons::PENCIL_SIMPLE, - FontId::new(16.0, fonts::regular()), - t.surface_text_dim, - ); - if resp - .on_hover_cursor(egui::CursorIcon::PointingHand) - .clicked() - { - pencil_hit = true; - } - } - }); - }) - .response - .rect - }); - // Tap anywhere else on a non-active row = INSTANT switch (skip - // when the pencil was tapped, whose rect overlaps the row). - if !id.active && !busy { - let hit = ui.interact( - row, - egui::Id::new(("id_switch", id.pubkey_hex.as_str())), - Sense::click(), - ); - if !pencil_hit - && hit - .on_hover_cursor(egui::CursorIcon::PointingHand) - .clicked() - { - switch_to = Some(id.pubkey_hex.clone()); - } - } - if pencil_hit { - manage_target = Some(id.pubkey_hex.clone()); - } - ui.add_space(6.0); - } - - // Tapping a held identity switches to it INSTANTLY — no password, no - // sync (it was already unlocked and listening). Purely local. - if let Some(target) = switch_to { - self.identity_switch.error.clear(); - if let Err(e) = wallet.switch_nostr_identity(target) { - self.identity_switch.error = e; - } - } - // The pencil opens the management MODAL, pre-filled with the tag and - // titled with the identity it manages. The GRIM Modal dims and locks - // the list behind it, so no switching or row taps while it is open. - if let Some(target) = manage_target { - self.identity_switch.error.clear(); - self.identity_switch.confirm_delete = None; - let display = identities - .iter() - .find(|i| i.pubkey_hex == target) - .map(|i| i.display()) - .unwrap_or_else(|| data::short_npub(&target)); - self.identity_switch.tag_input = identities - .iter() - .find(|i| i.pubkey_hex == target) - .and_then(|i| i.tag.clone()) - .unwrap_or_default(); - self.identity_switch.manage = Some(target); - Modal::new(IDENTITY_MANAGE_MODAL) - .position(ModalPosition::CenterTop) - .title(display) - .show(); - } - - ui.add_space(8.0); - - // Add-identity section. - if !self.identity_switch.adding { - if w::big_action(ui, &t!("goblin.identities.add"), false).clicked() { - self.identity_switch.adding = true; - self.identity_switch.error.clear(); - } - } else { - w::card(ui, |ui| { - ui.set_min_width(ui.available_width()); - ui.label( - RichText::new(t!("goblin.identities.add_title")) - .font(FontId::new(15.0, fonts::semibold())) - .color(t.surface_text), - ); - ui.add_space(8.0); - // The sheet defaults to GENERATE (a fresh anonymous key). What - // generating means, in one line, so the default mode isn't blank. - if !self.identity_switch.import { - ui.label( - RichText::new(t!("goblin.identities.generate_note")) - .font(FontId::new(12.5, fonts::regular())) - .color(t.surface_text_dim), - ); - } - if self.identity_switch.import { - ui.add_space(8.0); - // (a) Select a .backup file. Desktop returns the path now; - // Android returns it asynchronously (poll picked_file()). - if self.identity_switch.picking { - if let Some(path) = cb.picked_file() { - self.identity_switch.picking = false; - if !path.is_empty() { - match std::fs::read_to_string(&path) { - Ok(c) => { - self.identity_switch.backup_input = - c.trim().to_string() - } - Err(_) => { - self.identity_switch.error = - t!("goblin.settings.backup_read_failed") - .to_string() - } - } - } - } else { - ui.ctx().request_repaint(); - } - } - let file_label = if self.identity_switch.backup_input.is_empty() { - t!("goblin.identities.choose_backup").to_string() - } else { - t!("goblin.identities.backup_selected").to_string() - }; - if w::big_action_on_card(ui, &file_label).clicked() { - self.identity_switch.error.clear(); - match cb.pick_file() { - Some(path) if !path.is_empty() => { - match std::fs::read_to_string(&path) { - Ok(c) => { - self.identity_switch.backup_input = - c.trim().to_string() - } - Err(_) => { - self.identity_switch.error = - t!("goblin.settings.backup_read_failed") - .to_string() - } - } - } - Some(_) => self.identity_switch.picking = true, - None => {} - } - } - ui.add_space(8.0); - // (b) Or paste an nsec. - w::field_well(ui, |ui| { - TextEdit::new(egui::Id::from("identity_add_nsec")) - .focus(false) - .hint_text(t!("goblin.identities.nsec_hint")) - .password() - .text_color(t.surface_text) - .body() - .ui(ui, &mut self.identity_switch.nsec, cb); - }); - } - ui.add_space(10.0); - let import = self.identity_switch.import; - let busy = self.identity_switch.busy; - // Two real actions instead of a mode toggle: Generate is always - // ready; Import first reveals the .backup/nsec inputs, then - // confirms once one of them is filled. The password is entered - // in the modal. - let has_import = !self.identity_switch.backup_input.trim().is_empty() - || self.identity_switch.nsec.trim().starts_with("nsec1"); - let import_armed = (!import || has_import) && !busy; - // The password-gated add to launch this frame: `Some(None)` for - // a fresh key, `Some(Some(blob))` for an import. - let mut open_add: Option> = None; - ui.horizontal(|ui| { - let half = (ui.available_width() - 10.0) / 2.0; - // Generate on the LEFT, the positive (green) action: a - // fresh anonymous key via the existing password step. - 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(!busy, |ui| { - if w::big_action_on_card_ink( - ui, - &t!("goblin.identities.generate"), - if busy { t.surface_text_mute } else { t.pos }, - ) - .clicked() - { - open_add = Some(None); - } - }); - }, - ); - ui.add_space(10.0); - // Import on the RIGHT, neutral: reveal-then-confirm. - 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(import_armed, |ui| { - if w::big_action_on_card_ink( - ui, - &t!("goblin.identities.import"), - if import_armed { - t.surface_text - } else { - t.surface_text_mute - }, - ) - .clicked() - { - if !import { - // First tap: reveal the import inputs. - self.identity_switch.import = true; - self.identity_switch.error.clear(); - } else { - // Confirm: the selected .backup wins over - // a pasted nsec. - let b = self.identity_switch.backup_input.trim(); - let blob = if b.is_empty() { - self.identity_switch.nsec.trim().to_string() - } else { - b.to_string() - }; - open_add = Some(Some(blob)); - } - } - }); - }, - ); - }); - // Cancel centered BENEATH the pair: red, same uniform size. - ui.add_space(10.0); - ui.horizontal(|ui| { - let half = (ui.available_width() - 10.0) / 2.0; - ui.add_space((ui.available_width() - half) / 2.0); - ui.scope_builder( - egui::UiBuilder::new().max_rect(egui::Rect::from_min_size( - ui.cursor().min, - Vec2::new(half, 44.0), - )), - |ui| { - if w::big_action_on_card_ink( - ui, - &t!("goblin.settings.cancel"), - t.neg, - ) - .clicked() - { - self.identity_switch.adding = false; - self.identity_switch.import = false; - self.identity_switch.nsec.clear(); - self.identity_switch.backup_input.clear(); - self.identity_switch.error.clear(); - } - }, - ); - }); - if let Some(import_blob) = open_add { - // Open the password modal to encrypt+store the new - // identity. It is added WITHOUT switching; the user - // activates it later by tapping it. - self.identity_switch.error.clear(); - self.identity_switch.pass.clear(); - self.identity_switch.wrong_pass = false; - self.identity_switch.pending = - Some(PendingPassAction::Add(import_blob)); - Modal::new(IDENTITY_PASS_MODAL) - .position(ModalPosition::CenterTop) - .title(t!("goblin.identities.add_title")) - .show(); - } - }); - } - - if self.identity_switch.busy { - ui.add_space(10.0); - ui.horizontal(|ui| { - View::small_loading_spinner(ui); - ui.add_space(8.0); - ui.label( - RichText::new(t!("goblin.identities.working")) - .font(FontId::new(13.0, fonts::regular())) - .color(t.surface_text_dim), - ); - }); - ui.ctx().request_repaint(); - } - if !self.identity_switch.error.is_empty() { - ui.add_space(8.0); - ui.label( - RichText::new(&self.identity_switch.error) - .font(FontId::new(12.5, fonts::regular())) - .color(t.neg), - ); - } - ui.add_space(24.0); - }); - } - - /// Content of the wallet-password modal that gates ADDING an identity (encrypt - /// + store its new nsec). Switching no longer uses this — it is instant and - /// local. Mirrors the wallet-open password modal (open.rs): explanation, masked - /// field, wrong-password line, Cancel/Continue. A correct password is verified - /// synchronously before the add worker is spawned, so a wrong password stays in - /// the modal instead of failing later. - fn identity_pass_modal_content( - &mut self, - ui: &mut egui::Ui, - modal: &Modal, - wallet: &Wallet, - cb: &dyn PlatformCallbacks, - ) { - let mut go = false; - let mut cancel = false; - ui.vertical_centered(|ui| { - ui.add_space(6.0); - ui.label( - RichText::new(t!("goblin.identities.pass_prompt")) - .size(16.0) - .color(Colors::gray()), - ); - ui.add_space(10.0); - let mut field = TextEdit::new(egui::Id::from(modal.id).with("id_pass")).password(); - field.ui(ui, &mut self.identity_switch.pass, cb); - if field.enter_pressed { - go = true; - } - if self.identity_switch.pass.is_empty() { - self.identity_switch.wrong_pass = false; - } else if self.identity_switch.wrong_pass { - ui.add_space(10.0); - ui.label( - RichText::new(t!("goblin.advanced.wrong_password")) - .size(16.0) - .color(Colors::red()), - ); - } - ui.add_space(12.0); - }); - ui.scope(|ui| { - ui.spacing_mut().item_spacing = egui::Vec2::new(8.0, 0.0); - ui.columns(2, |columns| { - columns[0].vertical_centered_justified(|ui| { - View::button( - ui, - t!("modal.cancel"), - Colors::white_or_black(false), - || { - cancel = true; - }, - ); - }); - columns[1].vertical_centered_justified(|ui| { - View::button(ui, t!("continue"), Colors::white_or_black(false), || { - go = true; - }); - }); - }); - ui.add_space(6.0); - }); - if cancel { - self.identity_switch.pass.clear(); - self.identity_switch.pending = None; - self.identity_switch.wrong_pass = false; - Modal::close(); - } - if go && !self.identity_switch.pass.is_empty() { - if !wallet.verify_nostr_password(&self.identity_switch.pass) { - self.identity_switch.wrong_pass = true; - } else if let Some(action) = self.identity_switch.pending.take() { - let password = std::mem::take(&mut self.identity_switch.pass); - self.identity_switch.wrong_pass = false; - self.identity_switch.error.clear(); - self.identity_switch.busy = true; - let slot = self.identity_switch.result.clone(); - let w = wallet.clone(); - std::thread::spawn(move || { - let r = match action { - // Add only — never auto-switch into the new identity. - PendingPassAction::Add(import) => w.add_nostr_identity(import, password), - // Delete returns the surviving active npub for the result slot. - PendingPassAction::Delete(hex) => w - .delete_nostr_identity(hex, password) - .map(|_| String::new()), - }; - *slot.lock().unwrap() = Some(r); - }); - Modal::close(); - } - } - } - - /// Content of the per-identity management MODAL (title = the identity's - /// display name). Rename (the private, app-only tag — never published) on - /// top with uniform paired Cancel/Save buttons; Delete at the bottom, - /// separated and red, feeding the delete-confirmation modal. Being a GRIM - /// Modal, the identity list behind it is dimmed and locked. - fn identity_manage_modal_content( - &mut self, - ui: &mut egui::Ui, - wallet: &Wallet, - cb: &dyn PlatformCallbacks, - ) { - let Some(target) = self.identity_switch.manage.clone() else { - Modal::close(); - return; - }; - ui.vertical_centered(|ui| { - ui.add_space(6.0); - ui.label( - RichText::new(t!("goblin.identities.tag_note")) - .size(16.0) - .color(Colors::gray()), - ); - ui.add_space(10.0); - let mut field = TextEdit::new(egui::Id::from(IDENTITY_MANAGE_MODAL).with("tag")) - .hint_text(t!("goblin.identities.tag_hint")); - field.ui(ui, &mut self.identity_switch.tag_input, cb); - ui.add_space(12.0); - }); - let mut save = false; - let mut cancel = false; - let mut delete = false; - ui.scope(|ui| { - ui.spacing_mut().item_spacing = egui::Vec2::new(8.0, 0.0); - ui.columns(2, |columns| { - columns[0].vertical_centered_justified(|ui| { - View::button( - ui, - t!("modal.cancel"), - Colors::white_or_black(false), - || { - cancel = true; - }, - ); - }); - columns[1].vertical_centered_justified(|ui| { - View::button( - ui, - t!("goblin.identities.tag_save"), - Colors::white_or_black(false), - || { - save = true; - }, - ); - }); - }); - // Delete lives at the bottom, clearly apart from rename, red, same - // button shape. Only while more than one identity is held. - if wallet.nostr_identities().len() > 1 { - ui.add_space(10.0); - ui.vertical_centered_justified(|ui| { - View::colored_text_button( - ui, - t!("goblin.identities.delete_short").to_string(), - Colors::red(), - Colors::white_or_black(false), - || { - delete = true; - }, - ); - }); - } - ui.add_space(6.0); - }); - if cancel { - self.identity_switch.manage = None; - self.identity_switch.tag_input.clear(); - Modal::close(); - } - if save { - let tag = std::mem::take(&mut self.identity_switch.tag_input); - if let Err(e) = wallet.rename_nostr_identity(target.clone(), tag) { - self.identity_switch.error = e; - } - self.identity_switch.manage = None; - Modal::close(); - } - if delete { - // Step 1 of the delete gate: the danger-confirmation modal. - self.identity_switch.manage = None; - self.identity_switch.confirm_delete = Some(target.clone()); - let display = wallet - .nostr_identities() - .iter() - .find(|i| i.pubkey_hex == target) - .map(|i| i.display()) - .unwrap_or_else(|| data::short_npub(&target)); - Modal::close(); - Modal::new(IDENTITY_DELETE_MODAL) - .position(ModalPosition::CenterTop) - .title(display) - .show(); - } - } - - /// Content of the step-1 delete-confirmation MODAL (title = the identity's - /// display name): states the removal is PERMANENT with a prominent back-up - /// reminder, then uniform paired Cancel/Delete buttons — Delete red, same - /// size and shape. Confirming opens the wallet-password modal (step 2). - fn identity_delete_modal_content(&mut self, ui: &mut egui::Ui, wallet: &Wallet) { - let Some(target) = self.identity_switch.confirm_delete.clone() else { - Modal::close(); - return; - }; - let display = wallet - .nostr_identities() - .iter() - .find(|i| i.pubkey_hex == target) - .map(|i| i.display()) - .unwrap_or_else(|| data::short_npub(&target)); - ui.vertical_centered(|ui| { - ui.add_space(6.0); - ui.label( - RichText::new(t!("goblin.identities.delete_title", name => display)) - .size(17.0) - .color(Colors::red()), - ); - ui.add_space(8.0); - ui.label( - RichText::new(t!("goblin.identities.delete_blurb")) - .size(15.0) - .color(Colors::gray()), - ); - ui.add_space(8.0); - ui.label( - RichText::new(t!("goblin.identities.delete_backup_note")) - .size(15.0) - .color(Colors::red()), - ); - ui.add_space(12.0); - }); - let mut cancel = false; - let mut confirm = false; - ui.scope(|ui| { - ui.spacing_mut().item_spacing = egui::Vec2::new(8.0, 0.0); - ui.columns(2, |columns| { - columns[0].vertical_centered_justified(|ui| { - View::button( - ui, - t!("modal.cancel"), - Colors::white_or_black(false), - || { - cancel = true; - }, - ); - }); - columns[1].vertical_centered_justified(|ui| { - View::colored_text_button( - ui, - t!("goblin.identities.delete_confirm").to_string(), - Colors::red(), - Colors::white_or_black(false), - || { - confirm = true; - }, - ); - }); - }); - ui.add_space(6.0); - }); - if cancel { - self.identity_switch.confirm_delete = None; - Modal::close(); - } - if confirm { - // Step 2: the wallet-password modal executes the delete. - self.identity_switch.pass.clear(); - self.identity_switch.wrong_pass = false; - self.identity_switch.pending = Some(PendingPassAction::Delete(target)); - Modal::close(); - Modal::new(IDENTITY_PASS_MODAL) - .position(ModalPosition::CenterTop) - .title(t!("goblin.identities.delete_short")) - .show(); - } - } - - /// Content of the minimum-confirmations edit modal — a direct port of GRIM's - /// min_conf_modal_ui (numeric input, invalid-value error, Cancel/Save). The - /// saved value persists in WalletConfig::min_confirmations and feeds the - /// wallet's spendable/send logic on the next balance refresh. - fn min_conf_modal_content( - &mut self, - ui: &mut egui::Ui, - wallet: &Wallet, - modal: &Modal, - cb: &dyn PlatformCallbacks, - ) { - let on_save = |s: &mut GoblinWalletView| { - if let Ok(min_conf) = s.min_conf_edit.parse::() { - wallet.update_min_confirmations(min_conf); - Modal::close(); - } - }; - ui.add_space(6.0); - ui.vertical_centered(|ui| { - ui.label( - RichText::new(t!("wallets.min_tx_conf_count")) - .size(17.0) - .color(Colors::gray()), - ); - ui.add_space(8.0); - let mut edit = TextEdit::new(egui::Id::from(modal.id)).h_center().numeric(); - edit.ui(ui, &mut self.min_conf_edit, cb); - if edit.enter_pressed { - on_save(self); - } - if self.min_conf_edit.parse::().is_err() { - ui.add_space(12.0); - ui.label( - RichText::new(t!("network_settings.not_valid_value")) - .size(17.0) - .color(Colors::red()), - ); - } - ui.add_space(12.0); - }); - ui.scope(|ui| { - ui.spacing_mut().item_spacing = egui::Vec2::new(8.0, 0.0); - ui.columns(2, |columns| { - columns[0].vertical_centered_justified(|ui| { - View::button( - ui, - t!("modal.cancel"), - Colors::white_or_black(false), - || { - Modal::close(); - }, - ); - }); - columns[1].vertical_centered_justified(|ui| { - View::button(ui, t!("modal.save"), Colors::white_or_black(false), || { - on_save(self); - }); - }); - }); - ui.add_space(6.0); - }); - } - - /// Content of the batch-invoice approval modal: ONE approval for N payment - /// requests to the same payer, each request minted its own fresh per-sale - /// proof address so no two sales share an address. Approve fires the same - /// request task the single flow uses, N times; the requests then appear in - /// activity as they dispatch, exactly like single requests. - fn batch_invoice_modal_content(&mut self, ui: &mut egui::Ui, wallet: &Wallet) { - let Some(b) = &self.batch_invoice else { - Modal::close(); - return; - }; - let name = wallet - .nostr_service() - .map(|s| data::contact_title(&s.store, &b.hex)) - .unwrap_or_else(|| data::short_npub(&b.hex)); - let each = grin_core::core::amount_from_hr_string(&b.amount).unwrap_or(0); - let total = each.saturating_mul(b.count as u64); - ui.vertical_centered(|ui| { - ui.add_space(6.0); - ui.label( - RichText::new(t!( - "goblin.batch.blurb", - n => b.count.to_string(), - name => name, - amount => format!("{}{}", w::amount_str(each), w::TSU), - total => format!("{}{}", w::amount_str(total), w::TSU) - )) - .size(16.0) - .color(Colors::gray()), - ); - if let Some(memo) = &b.memo { - ui.add_space(6.0); - ui.label( - RichText::new(format!("\u{201C}{}\u{201D}", memo)) - .size(15.0) - .color(Colors::gray()), - ); - } - ui.add_space(12.0); - }); - let mut cancel = false; - let mut approve = false; - ui.scope(|ui| { - ui.spacing_mut().item_spacing = egui::Vec2::new(8.0, 0.0); - ui.columns(2, |columns| { - columns[0].vertical_centered_justified(|ui| { - View::button( - ui, - t!("modal.cancel"), - Colors::white_or_black(false), - || { - cancel = true; - }, - ); - }); - columns[1].vertical_centered_justified(|ui| { - View::button( - ui, - t!("goblin.batch.approve"), - Colors::white_or_black(false), - || { - approve = true; - }, - ); - }); - }); - ui.add_space(6.0); - }); - if cancel { - self.batch_invoice = None; - Modal::close(); - } - if approve { - if let Some(b) = self.batch_invoice.take() { - if let Some(service) = wallet.nostr_service() { - service.set_send_phase(crate::nostr::send_phase::WORKING); - } - let w = wallet.clone(); - std::thread::spawn(move || { - for _ in 0..b.count { - // Each request gets its OWN fresh proof address; a mint - // failure stops the batch (already-issued requests stand). - match w.mint_proof_address() { - Ok((_index, addr)) => { - w.task(crate::wallet::types::WalletTask::NostrRequest( - each, - b.hex.clone(), - b.memo.clone(), - b.relay_hints.clone(), - Some(addr), - )); - } - Err(e) => { - log::error!("batch invoice: mint failed: {e}"); - break; - } - } - } - }); - } - Modal::close(); - } - } - - /// Content of the "Sign in with Goblin" approval modal: the requesting - /// domain up top, the signing identity (a picker when several are held; - /// the truncated npub is always visible as the anchor), a one-line plain - /// explanation, the wallet password, and uniform paired Cancel / Sign in - /// buttons. Approving signs the one-time kind-22242 challenge with the - /// CHOSEN identity's key and POSTs it to the callback off the UI thread; - /// a wrong password stays in the modal without consuming the request. - fn login_modal_content( - &mut self, - ui: &mut egui::Ui, - modal: &Modal, - wallet: &Wallet, - cb: &dyn PlatformCallbacks, - ) { - let Some(st) = self.login.as_mut() else { - Modal::close(); - return; - }; - if st.posting { - // Already signed and in flight; nothing left to gate here. - Modal::close(); - return; - } - let domain = st.uri.domain.clone(); - let identities = wallet.nostr_identities(); - let mut go = false; - let mut cancel = false; - ui.vertical_centered(|ui| { - ui.add_space(6.0); - // Headline with the requesting domain prominent. - ui.label( - RichText::new(t!("goblin.login.headline", domain => domain.clone())) - .size(17.0) - .color(Colors::title(false)), - ); - ui.add_space(10.0); - // The signing identity. Display precedence is the switcher's: - // private tag, else bare claimed name, else truncated npub — and - // the truncated npub is ALWAYS shown as the anchor. - ui.label( - RichText::new(t!("goblin.login.identity")) - .size(13.0) - .color(Colors::gray()), - ); - ui.add_space(6.0); - if identities.len() > 1 { - // Identity picker: the held-identities list, tap to choose - // which one signs. Defaults to the active identity. - for id in &identities { - let selected = st.selected == id.pubkey_hex; - let title = id.display(); - let short = data::short_npub(&id.pubkey_hex); - let row = ui - .scope(|ui| { - ui.horizontal(|ui| { - ui.add_space(4.0); - ui.label( - RichText::new(if selected { - crate::gui::icons::CHECK_CIRCLE - } else { - crate::gui::icons::CIRCLE - }) - .size(18.0) - .color(if selected { - Colors::green() - } else { - Colors::gray() - }), - ); - ui.add_space(8.0); - ui.vertical(|ui| { - if title != short { - ui.label( - RichText::new(&title) - .size(15.0) - .color(Colors::text(false)), - ); - } - ui.label( - RichText::new(&short).size(12.5).color(Colors::gray()), - ); - }); - }); - }) - .response - .rect; - let hit = ui.interact( - row, - egui::Id::from(modal.id).with(("login_id", id.pubkey_hex.as_str())), - Sense::click(), - ); - if hit - .on_hover_cursor(egui::CursorIcon::PointingHand) - .clicked() - { - st.selected = id.pubkey_hex.clone(); - } - ui.add_space(4.0); - } - } else if let Some(id) = identities.first() { - let title = id.display(); - let short = data::short_npub(&id.pubkey_hex); - if title != short { - ui.label(RichText::new(&title).size(15.0).color(Colors::text(false))); - } - ui.label(RichText::new(&short).size(12.5).color(Colors::gray())); - } - ui.add_space(10.0); - // One plain line on what approving does (and does not do). - ui.label( - RichText::new(t!("goblin.login.explain")) - .size(13.0) - .color(Colors::gray()), - ); - ui.add_space(10.0); - // The wallet password gates the signature, mirroring the identity - // password modal (masked field, same wrong-password line). - ui.label( - RichText::new(t!("goblin.login.pass_prompt")) - .size(16.0) - .color(Colors::gray()), - ); - ui.add_space(10.0); - let mut field = TextEdit::new(egui::Id::from(modal.id).with("login_pass")).password(); - field.ui(ui, &mut st.pass, cb); - if field.enter_pressed { - go = true; - } - if st.pass.is_empty() { - st.wrong_pass = false; - } else if st.wrong_pass { - ui.add_space(10.0); - ui.label( - RichText::new(t!("goblin.advanced.wrong_password")) - .size(16.0) - .color(Colors::red()), - ); - } - ui.add_space(12.0); - }); - ui.scope(|ui| { - ui.spacing_mut().item_spacing = egui::Vec2::new(8.0, 0.0); - ui.columns(2, |columns| { - columns[0].vertical_centered_justified(|ui| { - View::button( - ui, - t!("modal.cancel"), - Colors::white_or_black(false), - || { - cancel = true; - }, - ); - }); - columns[1].vertical_centered_justified(|ui| { - View::button( - ui, - t!("goblin.login.confirm"), - Colors::white_or_black(false), - || { - go = true; - }, - ); - }); - }); - ui.add_space(6.0); - }); - if cancel { - // Cancel drops the request: it is single-use, no retry. - self.login = None; - Modal::close(); - return; - } - if go { - let (pass, selected) = match self.login.as_ref() { - Some(st) => (st.pass.clone(), st.selected.clone()), - None => return, - }; - if pass.is_empty() { - return; - } - if !wallet.verify_nostr_password(&pass) { - // Wrong password: stay in the modal, request NOT consumed. - if let Some(st) = self.login.as_mut() { - st.wrong_pass = true; - } - return; - } - // The chosen identity's unlocked in-memory keys, from the running - // service (the Build-145 model: every held identity is unlocked). - let keys = wallet.nostr_service().and_then(|s| { - s.recv_snapshot() - .into_iter() - .find(|h| h.keys.public_key().to_hex() == selected) - .map(|h| h.keys) - }); - let Some(keys) = keys else { - // No running service / identity gone: drop the request. - self.login = None; - Modal::close(); - return; - }; - let st = self.login.as_mut().unwrap(); - st.pass.clear(); - st.wrong_pass = false; - match crate::nostr::loginuri::build_login_event( - &keys, - &st.uri.challenge, - &st.uri.domain, - ) { - Ok(event) => { - // Signed: the request is consumed from here on, whatever - // the POST outcome. Deliver off the UI thread with the - // shared HTTP client and a hard timeout. - st.posting = true; - let callback = st.uri.callback.clone(); - let slot = st.result.clone(); - std::thread::spawn(move || { - let res = match tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - { - Ok(rt) => rt.block_on(async { - let post = - crate::nostr::loginuri::post_login_event(&callback, &event); - match tokio::time::timeout( - std::time::Duration::from_secs(LOGIN_POST_TIMEOUT_SECS), - post, - ) - .await - { - Ok(r) => r, - Err(_) => Err("timeout".to_string()), - } - }), - Err(e) => Err(e.to_string()), - }; - *slot.lock().unwrap() = Some(res); - }); - Modal::close(); - // Signed and handed to the POST worker: return the user to - // the app that deep-linked in (the polling browser tab), - // without waiting on the HTTP response. Success path only. - cb.return_to_caller(); - } - Err(e) => { - // Signing failed (never expected): consume the request and - // surface the quiet failure toast. - log::error!("login event signing failed: {e}"); - self.login_toast = Some(( - t!("goblin.login.failed", domain => domain).to_string(), - std::time::Instant::now(), - )); - self.login = None; - Modal::close(); - } - } - } - } - - /// The "Authorize with Goblin" approval modal: headline, the rendered event - /// (kind label, escaped content preview with an optional show-full view, and - /// per-kind key-tag summary), the identity picker, the password gate, and the - /// Cancel/Authorize buttons. Mirrors [`Self::login_modal_content`]; on - /// confirm it signs one event with the chosen identity and POSTs it off the - /// UI thread. Signed = consumed, whatever the POST outcome. - fn authorize_modal_content( - &mut self, - ui: &mut egui::Ui, - modal: &Modal, - wallet: &Wallet, - cb: &dyn PlatformCallbacks, - ) { - use crate::nostr::authuri; - let Some(st) = self.authorize.as_mut() else { - Modal::close(); - return; - }; - if st.posting { - // Already signed and in flight; nothing left to gate here. - Modal::close(); - return; - } - let domain = st.uri.domain.clone(); - // Clone the small template once so the read-only render borrows nothing - // from `st` while its password/show-full fields are mutated below. - let template = st.uri.template.clone(); - let kind = template.kind; - let label = authuri::kind_label(kind); - let (preview, remaining) = authuri::content_preview(&template.content); - let preview_esc = authuri::escape_for_display(&preview); - let full_esc = authuri::escape_for_display(&template.content); - // Key tags, all escaped before display. - let e_tag = template - .first_tag_value("e") - .map(|v| authuri::escape_for_display(&authuri::truncate_id(v))); - let p_tag = template - .first_tag_value("p") - .map(|v| authuri::escape_for_display(&authuri::truncate_id(v))); - let title = template - .first_tag_value("title") - .map(authuri::escape_for_display); - let identities = wallet.nostr_identities(); - let mut go = false; - let mut cancel = false; - ui.vertical_centered(|ui| { - ui.add_space(6.0); - // Headline with the requesting domain prominent. - ui.label( - RichText::new(t!("goblin.authorize.headline", domain => domain.clone())) - .size(17.0) - .color(Colors::title(false)), - ); - ui.add_space(10.0); - // The plain-language kind label (and, for an off-allowlist kind that - // v1 cannot actually reach, a caution line). - let kind_text = match label { - authuri::KindLabel::Post => t!("goblin.authorize.kind_post"), - authuri::KindLabel::Repost => t!("goblin.authorize.kind_repost"), - authuri::KindLabel::Reaction => t!("goblin.authorize.kind_reaction"), - authuri::KindLabel::Article => t!("goblin.authorize.kind_article"), - authuri::KindLabel::Unknown => { - t!("goblin.authorize.kind_unknown", n => kind.to_string()) - } - }; - ui.label( - RichText::new(kind_text) - .size(15.0) - .color(Colors::text(false)), - ); - if label == authuri::KindLabel::Unknown { - ui.add_space(4.0); - ui.label( - RichText::new(t!("goblin.authorize.unknown_caution", domain => domain.clone())) - .size(12.5) - .color(Colors::red()), - ); - } - ui.add_space(8.0); - // Per-kind rendering of the event body and its key tags. All - // requester-controlled text is escaped before it hits a label. - let show_preview = |ui: &mut egui::Ui| { - if !preview_esc.is_empty() { - ui.label(RichText::new(&preview_esc).size(13.5).color(Colors::gray())); - } - }; - match kind { - 7 => { - // Reaction: the reaction glyph (emoji or "+"), then target. - let reaction = if full_esc.is_empty() { - "+".to_string() - } else { - full_esc.clone() - }; - ui.label( - RichText::new(reaction) - .size(20.0) - .color(Colors::text(false)), - ); - ui.add_space(4.0); - if let Some(id) = &e_tag { - ui.label( - RichText::new(t!("goblin.authorize.reacts_to", id => id.clone())) - .size(12.5) - .color(Colors::gray()), - ); - } - if let Some(id) = &p_tag { - ui.label( - RichText::new(t!("goblin.authorize.by_author", id => id.clone())) - .size(12.5) - .color(Colors::gray()), - ); - } - } - 6 => { - // Repost: reposted event id and author. - if let Some(id) = &e_tag { - ui.label( - RichText::new(t!("goblin.authorize.repost_of", id => id.clone())) - .size(12.5) - .color(Colors::gray()), - ); - } - if let Some(id) = &p_tag { - ui.label( - RichText::new(t!("goblin.authorize.by_author", id => id.clone())) - .size(12.5) - .color(Colors::gray()), - ); - } - } - 30023 => { - // Article: title tag if present, then the content preview. - if let Some(t) = &title { - ui.label( - RichText::new(t!("goblin.authorize.article_title", title => t.clone())) - .size(14.0) - .color(Colors::text(false)), - ); - ui.add_space(4.0); - } - show_preview(ui); - } - _ => { - // Kind 1 (and the unreachable fallback): content preview, then - // the reply/mention tags. - show_preview(ui); - if let Some(id) = &e_tag { - ui.add_space(4.0); - ui.label( - RichText::new(t!("goblin.authorize.replying_to", id => id.clone())) - .size(12.5) - .color(Colors::gray()), - ); - } - if let Some(id) = &p_tag { - ui.label( - RichText::new(t!("goblin.authorize.mentions", id => id.clone())) - .size(12.5) - .color(Colors::gray()), - ); - } - } - } - // Truncation marker plus the mandatory show-full affordance. Approval - // is never blocked on opening it. - if remaining > 0 { - ui.add_space(6.0); - ui.label( - RichText::new(t!("goblin.authorize.truncated", n => remaining.to_string())) - .size(12.0) - .color(Colors::gray()), - ); - let toggle = if st.show_full { - t!("goblin.authorize.show_less") - } else { - t!("goblin.authorize.show_full") - }; - let rect = ui - .label(RichText::new(toggle).size(13.0).color(Colors::green())) - .rect; - let hit = ui.interact( - rect, - egui::Id::from(modal.id).with("auth_showfull"), - Sense::click(), - ); - if hit - .on_hover_cursor(egui::CursorIcon::PointingHand) - .clicked() - { - st.show_full = !st.show_full; - } - if st.show_full { - ui.add_space(6.0); - ScrollArea::vertical() - .max_height(160.0) - .auto_shrink([false, true]) - .show(ui, |ui| { - ui.label(RichText::new(&full_esc).size(13.0).color(Colors::gray())); - }); - } - } - ui.add_space(10.0); - // The signing identity. Display precedence is the switcher's: private - // tag, else bare claimed name, else truncated npub, and the truncated - // npub is ALWAYS shown as the anchor. Defaults to the active identity. - ui.label( - RichText::new(t!("goblin.authorize.identity")) - .size(13.0) - .color(Colors::gray()), - ); - ui.add_space(6.0); - if identities.len() > 1 { - for id in &identities { - let selected = st.selected == id.pubkey_hex; - let name = id.display(); - let short = data::short_npub(&id.pubkey_hex); - let row = ui - .scope(|ui| { - ui.horizontal(|ui| { - ui.add_space(4.0); - ui.label( - RichText::new(if selected { - crate::gui::icons::CHECK_CIRCLE - } else { - crate::gui::icons::CIRCLE - }) - .size(18.0) - .color(if selected { - Colors::green() - } else { - Colors::gray() - }), - ); - ui.add_space(8.0); - ui.vertical(|ui| { - if name != short { - ui.label( - RichText::new(&name) - .size(15.0) - .color(Colors::text(false)), - ); - } - ui.label( - RichText::new(&short).size(12.5).color(Colors::gray()), - ); - }); - }); - }) - .response - .rect; - let hit = ui.interact( - row, - egui::Id::from(modal.id).with(("auth_id", id.pubkey_hex.as_str())), - Sense::click(), - ); - if hit - .on_hover_cursor(egui::CursorIcon::PointingHand) - .clicked() - { - st.selected = id.pubkey_hex.clone(); - } - ui.add_space(4.0); - } - } else if let Some(id) = identities.first() { - let name = id.display(); - let short = data::short_npub(&id.pubkey_hex); - if name != short { - ui.label(RichText::new(&name).size(15.0).color(Colors::text(false))); - } - ui.label(RichText::new(&short).size(12.5).color(Colors::gray())); - } - ui.add_space(10.0); - // One plain line on what approving does (and does not do). - ui.label( - RichText::new(t!("goblin.authorize.explain", domain => domain.clone())) - .size(13.0) - .color(Colors::gray()), - ); - ui.add_space(10.0); - // The wallet password gates the signature, mirroring the identity - // password modal (masked field, same wrong-password line). - ui.label( - RichText::new(t!("goblin.authorize.pass_prompt")) - .size(16.0) - .color(Colors::gray()), - ); - ui.add_space(10.0); - let mut field = TextEdit::new(egui::Id::from(modal.id).with("auth_pass")).password(); - field.ui(ui, &mut st.pass, cb); - if field.enter_pressed { - go = true; - } - if st.pass.is_empty() { - st.wrong_pass = false; - } else if st.wrong_pass { - ui.add_space(10.0); - ui.label( - RichText::new(t!("goblin.advanced.wrong_password")) - .size(16.0) - .color(Colors::red()), - ); - } - ui.add_space(12.0); - }); - ui.scope(|ui| { - ui.spacing_mut().item_spacing = egui::Vec2::new(8.0, 0.0); - ui.columns(2, |columns| { - columns[0].vertical_centered_justified(|ui| { - View::button( - ui, - t!("modal.cancel"), - Colors::white_or_black(false), - || { - cancel = true; - }, - ); - }); - columns[1].vertical_centered_justified(|ui| { - View::button( - ui, - t!("goblin.authorize.confirm"), - Colors::white_or_black(false), - || { - go = true; - }, - ); - }); - }); - ui.add_space(6.0); - }); - if cancel { - // Cancel drops the request: it is single-use, no retry. - self.authorize = None; - Modal::close(); - return; - } - if go { - let (pass, selected) = match self.authorize.as_ref() { - Some(st) => (st.pass.clone(), st.selected.clone()), - None => return, - }; - if pass.is_empty() { - return; - } - if !wallet.verify_nostr_password(&pass) { - // Wrong password: stay in the modal, request NOT consumed. - if let Some(st) = self.authorize.as_mut() { - st.wrong_pass = true; - } - return; - } - // The chosen identity's unlocked in-memory keys, from the running - // service (the Build-145 model: every held identity is unlocked). - let keys = wallet.nostr_service().and_then(|s| { - s.recv_snapshot() - .into_iter() - .find(|h| h.keys.public_key().to_hex() == selected) - .map(|h| h.keys) - }); - let Some(keys) = keys else { - // No running service / identity gone: drop the request. - self.authorize = None; - Modal::close(); - return; - }; - let st = self.authorize.as_mut().unwrap(); - st.pass.clear(); - st.wrong_pass = false; - match crate::nostr::authuri::build_authorize_event(&keys, &st.uri.template) { - Ok(event) => { - // Signed: the request is consumed from here on, whatever the - // POST outcome. Deliver off the UI thread with the shared HTTP - // client and a hard timeout. - st.posting = true; - let callback = st.uri.callback.clone(); - let challenge = st.uri.challenge.clone(); - let domain = st.uri.domain.clone(); - let slot = st.result.clone(); - std::thread::spawn(move || { - let res = match tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - { - Ok(rt) => rt.block_on(async { - let post = crate::nostr::authuri::post_authorize_event( - &callback, &challenge, &domain, &event, - ); - match tokio::time::timeout( - std::time::Duration::from_secs(LOGIN_POST_TIMEOUT_SECS), - post, - ) - .await - { - Ok(r) => r, - Err(_) => Err("timeout".to_string()), - } - }), - Err(e) => Err(e.to_string()), - }; - *slot.lock().unwrap() = Some(res); - }); - Modal::close(); - // Signed and handed to the POST worker: return the user to - // the app that deep-linked in (the polling browser tab), - // without waiting on the HTTP response. Success path only. - cb.return_to_caller(); - } - Err(e) => { - // Signing failed (never expected): consume the request and - // surface the quiet failure toast. - log::error!("authorize event signing failed: {e}"); - self.login_toast = Some(( - t!("goblin.authorize.failed", domain => domain).to_string(), - std::time::Instant::now(), - )); - self.authorize = None; - Modal::close(); - } - } - } - } - - /// Draw the transient login-outcome toast: the same quiet pill as the back - /// hint (solid soft pill, no border, small dim text), bottom-anchored, - /// non-blocking, fading out. - fn login_toast_ui(&mut self, ctx: &egui::Context) { - const SHOW_SECS: f32 = 3.5; - let Some((text, at)) = &self.login_toast else { - return; - }; - let elapsed = at.elapsed().as_secs_f32(); - if elapsed >= SHOW_SECS { - self.login_toast = None; - return; - } - let text = text.clone(); - let t = theme::tokens(); - // Fade over the final 0.5s. - let alpha = ((SHOW_SECS - elapsed) / 0.5).clamp(0.0, 1.0); - let font = FontId::new(13.0, fonts::regular()); - egui::Area::new(egui::Id::new("goblin_login_toast")) - .order(egui::Order::Foreground) - .anchor( - egui::Align2::CENTER_BOTTOM, - Vec2::new(0.0, -(View::get_bottom_inset() + 92.0)), - ) - .interactable(false) - .show(ctx, |ui| { - let galley = ui - .painter() - .layout_no_wrap(text, font.clone(), t.surface_text_dim); - let pad = Vec2::new(16.0, 10.0); - let size = galley.size() + pad * 2.0; - let (rect, _) = ui.allocate_exact_size(size, Sense::hover()); - ui.painter().rect( - rect, - CornerRadius::same((size.y / 2.0) as u8), - t.surface2.gamma_multiply(alpha), - Stroke::NONE, - egui::StrokeKind::Inside, - ); - ui.painter().galley( - rect.min + pad, - galley, - t.surface_text_dim.gamma_multiply(alpha), - ); - }); - ctx.request_repaint_after(std::time::Duration::from_millis(50)); - } - - /// Inline username-claim widget (availability check + registration). - fn claim_ui(&mut self, ui: &mut egui::Ui, wallet: &Wallet, cb: &dyn PlatformCallbacks) { - let t = theme::tokens(); - // Poll the worker result; avatar invalidation happens after the - // claim borrow is released. - let mut invalidate_avatar: Option = None; - { - let claim = self.claim.as_mut().unwrap(); - if let Some(msg) = claim.result.lock().unwrap().take() { - claim.checking = false; - match msg { - ClaimMsg::Availability(avail) => { - let (available, msg) = availability_feedback(avail); - claim.available = available; - claim.message = Some(msg.to_string()); - } - ClaimMsg::Registered(nip05) => { - let name = nip05.split('@').next().unwrap_or("").to_string(); - claim.message = - Some(t!("goblin.settings.registered", name => name).to_string()); - claim.available = Some(true); - claim.input.clear(); - // 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(); - } - // Publish kind 0 NOW so others can resolve our @name without - // waiting for the next app start — otherwise a just-claimed - // name is invisible over the relays (no kind-0 event exists). - wallet.task(crate::wallet::types::WalletTask::NostrRepublishProfile); - } - ClaimMsg::Released => { - claim.message = Some(t!("goblin.settings.released_msg").to_string()); - claim.available = None; - claim.confirm_release = false; - if let Some(s) = wallet.nostr_service() { - let name = { - let mut id = s.identity.write(); - let n = id.nip05.take(); - id.anonymous = true; - n - }; - s.save_identity(); - invalidate_avatar = - name.map(|n| n.split('@').next().unwrap_or("").to_string()); - } - } - ClaimMsg::Error(e) => { - claim.available = Some(false); - claim.message = Some(e); - } - } - } - } - if let Some(name) = invalidate_avatar { - self.avatars.invalidate(&name); - } - let claim = self.claim.as_mut().unwrap(); - - let registered: Option = wallet - .nostr_service() - .and_then(|s| s.identity.read().nip05.clone()) - .map(|n| n.split('@').next().unwrap_or("").to_string()); - - w::card(ui, |ui| { - ui.set_min_width(ui.available_width()); - if let Some(name) = registered { - if claim.confirm_release { - // The are-you-sure gate. - ui.label( - RichText::new(t!("goblin.settings.release_confirm", name => name)) - .font(FontId::new(15.0, fonts::semibold())) - .color(t.surface_text), - ); - ui.add_space(4.0); - ui.label( - RichText::new(t!("goblin.settings.release_blurb")) - .font(FontId::new(12.5, fonts::regular())) - .color(t.surface_text_dim), - ); - ui.add_space(10.0); - if claim.checking { - ui.horizontal(|ui| { - View::small_loading_spinner(ui); - ui.add_space(8.0); - ui.label( - RichText::new(t!("goblin.settings.releasing")) - .color(t.surface_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| { - if w::big_action_on_card_ink( - ui, - &t!("goblin.settings.keep_it"), - t.surface_text, - ) - .clicked() - { - claim.confirm_release = false; - } - }, - ); - 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| { - if w::big_action_on_card_ink( - ui, - &t!("goblin.settings.release_it"), - t.neg, - ) - .clicked() - { - start_release(claim, &name, wallet); - } - }, - ); - }); - } - } else { - ui.label( - RichText::new(t!("goblin.settings.username")) - .font(FontId::new(15.0, fonts::semibold())) - .color(t.surface_text), - ); - ui.add_space(4.0); - ui.label( - RichText::new(name.to_string()) - .font(FontId::new(20.0, fonts::bold())) - .color(t.surface_text), - ); - ui.add_space(4.0); - ui.label( - RichText::new(t!("goblin.settings.username_note")) - .font(FontId::new(12.0, fonts::regular())) - .color(t.surface_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(match claim.available { - Some(false) => t.neg, - Some(true) => t.pos, - None => t.surface_text_dim, - }), - ); - } - ui.add_space(10.0); - if w::big_action_on_card_ink(ui, &t!("goblin.settings.release_username"), t.neg) - .clicked() - { - claim.confirm_release = true; - claim.message = None; - } - } - } else { - ui.label( - RichText::new(t!("goblin.settings.pick_username")) - .font(FontId::new(15.0, fonts::semibold())) - .color(t.surface_text), - ); - ui.add_space(8.0); - // Placeholder shows the bare handle ("yourname") — we never display - // the "@" to users. A leading "@" the user happens to type is still - // stripped when the name is read below. - let before = claim.input.clone(); - TextEdit::new(egui::Id::from("settings_claim")) - .focus(false) - .hint_text(t!("goblin.onboarding.identity.username_field_hint")) - .text_color(t.surface_text) - .body() - .ui(ui, &mut claim.input, cb); - if claim.input != before { - claim.available = None; - claim.message = None; - } - ui.add_space(4.0); - ui.label( - RichText::new(t!("goblin.settings.username_note")) - .font(FontId::new(12.0, fonts::regular())) - .color(t.surface_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(match claim.available { - Some(false) => t.neg, - Some(true) => t.pos, - None => t.surface_text_dim, - }), - ); - } - ui.add_space(10.0); - let name = claim.input.trim().trim_start_matches('@').to_lowercase(); - let valid = name.len() >= 3 && name.len() <= 20; - if claim.checking { - ui.horizontal(|ui| { - View::small_loading_spinner(ui); - ui.add_space(8.0); - ui.label( - RichText::new(t!("goblin.settings.working")).color(t.surface_text_dim), - ); - }); - ui.ctx().request_repaint(); - } else { - ui.add_enabled_ui(valid, |ui| { - if w::big_action(ui, &t!("goblin.settings.claim"), false).clicked() { - start_claim_flow(claim, &name, wallet); - } - }); - } - } - }); - } -} - -/// Spawn the combined claim: availability check first, then registration -/// in the same worker — one button, no separate Check step. -fn start_claim_flow(claim: &mut ClaimState, name: &str, wallet: &Wallet) { - let Some(service) = wallet.nostr_service() else { - return; - }; - let server = service.config.read().nip05_server(); - // Reuse the service's keys directly — never round-trip the secret through a - // plaintext nsec String to rebuild keys the service already holds. - let keys = service.keys(); - claim.checking = true; - claim.message = None; - claim.available = None; - 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 msg = rt.block_on(async { - use crate::nostr::nip05::{Availability, RegisterResult, check_availability, register}; - match check_availability(&server, &name).await { - Availability::Available => match register(&server, &name, &keys).await { - RegisterResult::Ok(nip05) => ClaimMsg::Registered(nip05), - RegisterResult::Conflict(_) => { - ClaimMsg::Error(t!("goblin.settings.err_just_taken").to_string()) - } - RegisterResult::Rejected(e) if e == "name_change_cooldown" => { - ClaimMsg::Error(t!("goblin.settings.err_cooldown").to_string()) - } - RegisterResult::Rejected(e) => ClaimMsg::Error(e), - RegisterResult::Network => { - ClaimMsg::Error(t!("goblin.settings.err_unreachable").to_string()) - } - }, - other => ClaimMsg::Availability(other), - } - }); - *slot.lock().unwrap() = Some(msg); - }); -} - -/// Spawn the username release; the server deletes its avatar with it. -fn start_release(claim: &mut ClaimState, name: &str, wallet: &Wallet) { - let Some(service) = wallet.nostr_service() else { - return; - }; - let server = service.config.read().nip05_server(); - // Reuse the service's keys directly — never round-trip the secret through a - // plaintext nsec String to rebuild keys the service already holds. - let keys = service.keys(); - claim.checking = true; - claim.message = None; - 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, - }; - // Release is always allowed server-side (it's what arms the cooldown), - // so there's no cooldown rejection to handle here. - let msg = match rt.block_on(crate::nostr::nip05::unregister(&server, &name, &keys)) { - Ok(()) => ClaimMsg::Released, - Err(e) => ClaimMsg::Error(t!("goblin.settings.err_release", err => e).to_string()), - }; - *slot.lock().unwrap() = Some(msg); - }); } /// Process a picked picture and upload it as the avatar for an owned name. -/// Draw the small Goblin mascot mark. -pub fn widgets_logo(ui: &mut egui::Ui) { - widgets_logo_sized(ui, 24.0); -} +#[cfg(test)] +mod anon_censor_tests { + use super::{SettingsPage, Tab, settings_page_after}; -/// Tinted goblin mark at a given size. -pub fn widgets_logo_sized(ui: &mut egui::Ui, size: f32) { - let (rect, _) = ui.allocate_exact_size(Vec2::splat(size), Sense::hover()); - // Chip-sized marks use a pre-rendered 48px raster: cleaner antialiasing - // at ~24px than runtime svg rasterization, with 2x headroom for hidpi. - let img = egui::Image::new(if size <= 32.0 { - egui::include_image!("../../../../img/goblin-logo2-48.png") - } else { - egui::include_image!("../../../../img/goblin-logo2.svg") - }) - .tint(theme::tokens().text) - .fit_to_exact_size(Vec2::splat(size)); - img.paint_at(ui, rect); -} - -fn empty_state(ui: &mut egui::Ui, title: &str, subtitle: &str) { - let t = theme::tokens(); - ui.add_space(40.0); - ui.vertical_centered(|ui| { - ui.label( - RichText::new(title) - .font(FontId::new(17.0, fonts::semibold())) - .color(t.text), + /// Leaving the Settings tab resets the sub-page to the root, so re-entering + /// always lands on the top-level Settings page. Staying on the Settings tab + /// preserves the current sub-page (so deep links into a sub-page survive). + #[test] + fn settings_nav_resets_to_root_off_settings_tab() { + // Off the Settings tab: any sub-page collapses to the root. + for tab in [Tab::Home, Tab::Pay, Tab::Activity, Tab::Receive] { + assert_eq!( + settings_page_after(tab, SettingsPage::AdvancedPrivacy), + SettingsPage::Main + ); + assert_eq!( + settings_page_after(tab, SettingsPage::Username), + SettingsPage::Main + ); + } + // On the Settings tab: the current sub-page is preserved. + assert_eq!( + settings_page_after(Tab::Me, SettingsPage::Username), + SettingsPage::Username ); - ui.add_space(4.0); - ui.label( - RichText::new(subtitle) - .font(FontId::new(14.0, fonts::regular())) - .color(t.text_dim), + assert_eq!( + settings_page_after(Tab::Me, SettingsPage::AdvancedPrivacy), + SettingsPage::AdvancedPrivacy ); - }); -} - -fn settings_group(ui: &mut egui::Ui, title: &str, add: impl FnOnce(&mut egui::Ui)) { - w::kicker(ui, title); - ui.add_space(8.0); - w::card(ui, |ui| add(ui)); -} - -/// Title row for an Advanced-page action card. -fn advanced_head(ui: &mut egui::Ui, label: &str, color: Color32) { - ui.label( - RichText::new(label) - .font(FontId::new(15.0, fonts::semibold())) - .color(color), - ); - ui.add_space(4.0); -} - -/// Wrapped description line under an Advanced-page action title. -fn advanced_desc(ui: &mut egui::Ui, text: &str) { - let t = theme::tokens(); - ui.label( - RichText::new(text) - .font(FontId::new(13.0, fonts::regular())) - .color(t.surface_text_dim), - ); -} - -/// A settings row: label + subtitle on the left, an on/off switch on the right. -/// Returns `Some(new_value)` on the frame it is toggled. -fn settings_row_toggle(ui: &mut egui::Ui, label: &str, sub: &str, on: bool) -> Option { - let t = theme::tokens(); - let mut toggled = None; - ui.horizontal(|ui| { - ui.vertical(|ui| { - ui.label( - RichText::new(label) - .font(FontId::new(15.0, fonts::medium())) - .color(t.surface_text), - ); - ui.label( - RichText::new(sub) - .font(FontId::new(13.0, fonts::regular())) - .color(t.surface_text_dim), - ); - }); - ui.with_layout(Layout::right_to_left(Align::Center), |ui| { - if w::toggle(ui, on).clicked() { - toggled = Some(!on); - } - }); - }); - ui.add_space(10.0); - toggled -} - -fn settings_row(ui: &mut egui::Ui, label: &str, value: &str) { - settings_row_ink(ui, label, value, theme::tokens().surface_text_dim); -} - -/// Like [`settings_row`] but the value is drawn in an explicit ink — used to flag -/// the always-on mixnet routing in the privacy color. -fn settings_row_ink(ui: &mut egui::Ui, label: &str, value: &str, value_ink: Color32) { - let t = theme::tokens(); - ui.horizontal(|ui| { - ui.label( - RichText::new(label) - .font(FontId::new(15.0, fonts::medium())) - .color(t.surface_text), + assert_eq!( + settings_page_after(Tab::Me, SettingsPage::Main), + SettingsPage::Main ); - ui.with_layout(Layout::right_to_left(Align::Center), |ui| { - ui.label( - RichText::new(value) - .font(FontId::new(13.0, fonts::regular())) - .color(value_ink), - ); - }); - }); - ui.add_space(10.0); -} - -fn settings_row_btn(ui: &mut egui::Ui, label: &str, icon: &str) -> bool { - let t = theme::tokens(); - let mut clicked = false; - let row = ui.horizontal(|ui| { - ui.label( - RichText::new(label) - .font(FontId::new(15.0, fonts::medium())) - .color(t.surface_text), - ); - ui.with_layout(Layout::right_to_left(Align::Center), |ui| { - let resp = ui.label( - RichText::new(icon) - .font(FontId::new(18.0, fonts::regular())) - .color(t.surface_text_dim), - ); - if resp.interact(Sense::click()).clicked() { - clicked = true; - } - }); - }); - ui.add_space(10.0); - // The whole row is tappable, not just the trailing value/icon. - clicked || row.response.interact(Sense::click()).clicked() -} - -/// A danger-styled settings row button (whole row taps). -fn settings_row_danger(ui: &mut egui::Ui, label: &str, icon: &str) -> bool { - let t = theme::tokens(); - let row = ui.horizontal(|ui| { - ui.label( - RichText::new(label) - .font(FontId::new(15.0, fonts::medium())) - .color(t.neg), - ); - ui.with_layout(Layout::right_to_left(Align::Center), |ui| { - ui.label( - RichText::new(icon) - .font(FontId::new(18.0, fonts::regular())) - .color(t.neg), - ); - }); - }); - ui.add_space(10.0); - row.response.interact(Sense::click()).clicked() -} - -/// A settings row whose value cycles in place on tap (no navigation): the -/// value is drawn in the same small/dim style as [`settings_row_nav`] so it -/// sits consistently next to chevroned siblings, just without the chevron. -fn settings_row_cycle(ui: &mut egui::Ui, label: &str, value: &str) -> bool { - let t = theme::tokens(); - let row = ui.horizontal(|ui| { - ui.label( - RichText::new(label) - .font(FontId::new(15.0, fonts::medium())) - .color(t.surface_text), - ); - ui.with_layout(Layout::right_to_left(Align::Center), |ui| { - ui.label( - RichText::new(value) - .font(FontId::new(13.0, fonts::regular())) - .color(t.surface_text_dim), - ); - }); - }); - ui.add_space(10.0); - row.response.interact(Sense::click()).clicked() -} - -/// A settings row that navigates somewhere: value + chevron, whole row taps. -fn settings_row_nav(ui: &mut egui::Ui, label: &str, value: &str) -> bool { - let t = theme::tokens(); - let row = ui.horizontal(|ui| { - ui.label( - RichText::new(label) - .font(FontId::new(15.0, fonts::medium())) - .color(t.surface_text), - ); - ui.with_layout(Layout::right_to_left(Align::Center), |ui| { - ui.label( - RichText::new(crate::gui::icons::CARET_RIGHT) - .font(FontId::new(13.0, fonts::regular())) - .color(t.surface_text_mute), - ); - ui.add_space(4.0); - ui.label( - RichText::new(value) - .font(FontId::new(13.0, fonts::regular())) - .color(t.surface_text_dim), - ); - }); - }); - ui.add_space(10.0); - row.response.interact(Sense::click()).clicked() -} - -/// One channel row on the Network-privacy page: a status dot, a title and a -/// wrapped blurb explaining where that traffic goes. -fn privacy_line(ui: &mut egui::Ui, dot: Color32, title: &str, blurb: &str) { - let t = theme::tokens(); - ui.horizontal_top(|ui| { - let (rect, _) = ui.allocate_exact_size(Vec2::new(14.0, 20.0), Sense::hover()); - ui.painter() - .circle_filled(rect.center() + Vec2::new(0.0, -2.0), 4.0, dot); - ui.vertical(|ui| { - ui.label( - RichText::new(title) - .font(FontId::new(14.0, fonts::semibold())) - .color(t.surface_text), - ); - ui.add_space(2.0); - ui.label( - RichText::new(blurb) - .font(FontId::new(12.0, fonts::regular())) - .color(t.surface_text_dim), - ); - }); - }); - ui.add_space(10.0); -} - -/// Open a URL in the system browser. -fn open_url(ui: &egui::Ui, url: &str) { - ui.ctx().open_url(egui::OpenUrl::new_tab(url)); -} - -/// Linear blend between two colors (`p` 0→`a`, 1→`b`). Used by the Pay-screen -/// over-balance flash to ease the digits from red back to normal ink. -fn lerp_color(a: Color32, b: Color32, p: f32) -> Color32 { - let p = p.clamp(0.0, 1.0); - let mix = |x: u8, y: u8| (x as f32 + (y as f32 - x as f32) * p).round() as u8; - Color32::from_rgb(mix(a.r(), b.r()), mix(a.g(), b.g()), mix(a.b(), b.b())) -} - -fn approve_button(ui: &mut egui::Ui) -> bool { - w::big_action(ui, &t!("goblin.request.approve"), false).clicked() -} - -fn decline_button(ui: &mut egui::Ui) -> bool { - w::big_action(ui, &t!("goblin.request.decline"), true).clicked() -} - -fn accept_policy_label(wallet: &Wallet) -> String { - use crate::nostr::config::AcceptPolicy; - wallet - .nostr_service() - .map(|s| match s.config.read().accept_from() { - AcceptPolicy::Everyone => t!("goblin.settings.accept_anyone").to_string(), - AcceptPolicy::Contacts => t!("goblin.settings.accept_contacts").to_string(), - AcceptPolicy::Ask => t!("goblin.settings.accept_ask").to_string(), - }) - .unwrap_or_else(|| t!("goblin.settings.accept_anyone").to_string()) -} - -/// Cycle the color theme Dark ↔ Light and re-apply visuals. Yellow is kept -/// defined (gui/theme.rs) but out of the picker for now — it's still in beta; -/// `Yellow => Dark` is an escape hatch for anyone whose config already has it. -fn cycle_theme(ctx: &egui::Context) { - use crate::gui::theme::ThemeKind; - let next = match crate::AppConfig::theme() { - ThemeKind::Dark => ThemeKind::Light, - ThemeKind::Light => ThemeKind::Dark, - ThemeKind::Yellow => ThemeKind::Dark, - }; - crate::AppConfig::set_theme(next); - crate::setup_visuals(ctx); -} - -/// Cycle the density Comfy → Regular → Compact → Comfy. -/// Cycle the incoming-payment accept policy Anyone → Contacts → Ask → Anyone. -fn cycle_accept_policy(wallet: &Wallet) { - use crate::nostr::config::AcceptPolicy; - if let Some(s) = wallet.nostr_service() { - let next = match s.config.read().accept_from() { - AcceptPolicy::Everyone => AcceptPolicy::Contacts, - AcceptPolicy::Contacts => AcceptPolicy::Ask, - AcceptPolicy::Ask => AcceptPolicy::Everyone, - }; - s.config.write().set_accept_from(next); } } - -fn relay_summary(wallet: &Wallet) -> String { - wallet - .nostr_service() - .map(|s| { - let relays = s.relays(); - match relays.len() { - 0 => t!("goblin.relays.none").to_string(), - 1 => relays[0].replace("wss://", ""), - n => t!("goblin.relays.count", n => n).to_string(), - } - }) - .unwrap_or_else(|| "—".to_string()) -} - -/// Compute a fiat preview line for the balance, when a rate is available. -/// One-line node summary: "Block 1,847,221 · main.gri.mw". -/// Bare node host (or "integrated node") for the sidebar card's third line. -fn node_host(wallet: &Wallet) -> String { - match wallet.get_current_connection() { - crate::wallet::types::ConnectionMethod::Integrated => { - t!("goblin.node.integrated_host").to_string() - } - crate::wallet::types::ConnectionMethod::External(_, url) => url - .replace("https://", "") - .replace("http://", "") - .trim_end_matches('/') - .to_string(), - } -} - -fn node_summary(wallet: &Wallet) -> String { - let height = wallet - .get_data() - .map(|d| d.info.last_confirmed_height) - .unwrap_or(0); - let conn = match wallet.get_current_connection() { - crate::wallet::types::ConnectionMethod::Integrated => { - t!("goblin.node.integrated_host").to_string() - } - crate::wallet::types::ConnectionMethod::External(_, url) => url - .replace("https://", "") - .replace("http://", "") - .trim_end_matches('/') - .to_string(), - }; - if height == 0 { - t!("goblin.node.summary_syncing", conn => conn).to_string() - } else { - t!("goblin.node.summary_block", height => fmt_thousands(height), conn => conn).to_string() - } -} - -/// Format a number with thousands separators. -fn fmt_thousands(n: u64) -> String { - let s = n.to_string(); - let mut out = String::with_capacity(s.len() + s.len() / 3); - for (i, c) in s.chars().enumerate() { - if i > 0 && (s.len() - i) % 3 == 0 { - out.push(','); - } - out.push(c); - } - out -} - -fn fiat_line(data: &Option) -> Option { - use crate::http::RateState; - let p = crate::AppConfig::pairing(); - let vs = p.vs_currency()?; - // Asking for the rate here (while the balance is on screen) is what kicks a - // live refetch when the in-session rate has aged out; an idle wallet never - // reaches this path. - Some(match crate::http::grin_rate(vs) { - RateState::Fresh(rate) => { - let spendable = data - .as_ref() - .map(|d| d.info.amount_currently_spendable) - .unwrap_or(0); - let grin = spendable as f64 / 1_000_000_000.0; - w::FiatLine::Text(format!( - "≈ {} · 1ツ = {}", - fmt_pairing(grin * rate, p), - fmt_pairing(rate, p) - )) - } - RateState::Loading => w::FiatLine::Loading, - RateState::Unavailable => w::FiatLine::Unavailable, - }) -} - -/// Format a value already in the pairing's unit (dollars, BTC, …) with the -/// right symbol/precision. Sats scales the BTC value by 1e8. -fn fmt_pairing(value: f64, p: crate::settings::Pairing) -> String { - use crate::settings::Pairing; - match p { - Pairing::Usd => format!("${:.2}", value), - Pairing::Eur => format!("€{:.2}", value), - Pairing::Gbp => format!("£{:.2}", value), - Pairing::Jpy => format!("¥{:.0}", value), - Pairing::Cny => format!("CN¥{:.2}", value), - Pairing::Btc => { - let s = format!("{:.8}", value); - let s = s.trim_end_matches('0').trim_end_matches('.'); - format!("₿{}", if s.is_empty() { "0" } else { s }) - } - Pairing::Sats => format!("{} sats", fmt_thousands((value * 1e8).round() as u64)), - Pairing::Off => String::new(), - } -} - -/// The "≈ …" amount preview for the current pairing, or `None` when off / no -/// rate yet. Shared by the Pay screen, the send flow, and the balance hero. -fn pairing_preview(grin: f64, ctx: &egui::Context) -> Option { - use crate::http::RateState; - let p = crate::AppConfig::pairing(); - let vs = p.vs_currency()?; - match crate::http::grin_rate(vs) { - RateState::Fresh(rate) => Some(format!("≈ {}", fmt_pairing(grin * rate, p))), - // No stale fallback: show nothing until a fresh rate lands. Nudge a repaint - // while loading so the preview appears once the live fetch returns. - RateState::Loading => { - ctx.request_repaint_after(std::time::Duration::from_millis(300)); - None - } - RateState::Unavailable => None, - } -} - -/// Convert a bech32 npub to hex for short display fallbacks. -fn hex_of(npub: &str) -> String { - use nostr_sdk::{FromBech32, PublicKey}; - PublicKey::from_bech32(npub) - .map(|pk| pk.to_hex()) - .unwrap_or_else(|_| npub.to_string()) -} - -/// Largest point size in `[12.0, 16.0]` at which the semibold news title fits on -/// one line within `avail` px, measured against the live font atlas and stepping -/// down by 0.5. Returns the 12pt floor when even that overflows (the caller pairs -/// it with `.truncate()`). This is the shrink-to-fit safety net that keeps a -/// title readable on a 390px screen; the hard char cap (`news_title_clamped`) is -/// the predictable ceiling. -fn fit_news_title_pt(ui: &egui::Ui, text: &str, avail: f32) -> f32 { - const CEIL: f32 = 16.0; - const FLOOR: f32 = 12.0; - let mut pt = CEIL; - while pt > FLOOR { - let w = ui - .painter() - .layout_no_wrap( - text.to_owned(), - FontId::new(pt, fonts::semibold()), - egui::Color32::WHITE, - ) - .size() - .x; - if w <= avail { - return pt; - } - pt -= 0.5; - } - FLOOR -} diff --git a/src/gui/views/wallets/content.rs b/src/gui/views/wallets/content.rs index d127701e..b9d1334e 100644 --- a/src/gui/views/wallets/content.rs +++ b/src/gui/views/wallets/content.rs @@ -412,6 +412,19 @@ impl WalletsContent { } return false; } else if self.showing_wallet() { + // While the money path is locked the unlock screen owns the whole + // surface: system back must not escape it — not to a sub-page, and + // not via the double-back deselect to the wallet switcher. Swallow it + // entirely so the only ways out stay the on-screen Unlock / Close. + if self + .wallets + .selected() + .as_ref() + .map(|w| w.is_locked()) + .unwrap_or(false) + { + return false; + } // Go back at stack; on Home with nothing open, back is a no-op. // Leaving the wallet is intentional-only (switch/lock controls), // never a back fallback to the chooser. diff --git a/src/wallet/wallet.rs b/src/wallet/wallet.rs index 33ff09ae..8b75527b 100644 --- a/src/wallet/wallet.rs +++ b/src/wallet/wallet.rs @@ -1423,17 +1423,34 @@ impl Wallet { /// relays stay connected so payments keep arriving, but no incoming slatepack /// is received/finalized until [`Wallet::unlock`]. A no-op on a wallet that /// is not open. + /// + /// This also stops the local Foreign API receive listener: its `/v2/foreign` + /// `receive_tx` builds outputs with the keychain mask (a money-seed + /// operation), so it must not answer while locked. The sync loop restarts it + /// on unlock (its start is guarded by `!is_locked()`). pub fn lock(&self) { if self.is_open() { self.locked.store(true, Ordering::Relaxed); + // Seal the Foreign API listener too — stop it now, immediately. + let running = { self.foreign_api_server.read().is_some() }; + if running { + let mut w_api_server = self.foreign_api_server.write(); + if let Some(server) = w_api_server.as_mut() { + server.0.stop(); + } + *w_api_server = None; + } } } /// Clear the money-path lock. The nostr service loop then drains any incoming /// payments that arrived and were buffered while locked, completing them - /// normally. + /// normally, and the sync loop restarts the Foreign API receive listener. + /// Waking the sync thread makes that restart prompt rather than waiting out + /// the next sync interval. pub fn unlock(&self) { self.locked.store(false, Ordering::Relaxed); + self.sync(); } /// Check if wallet is closing. @@ -2787,7 +2804,11 @@ fn start_sync(wallet: Wallet) -> Thread { sync_wallet_data(&wallet, false); } - if wallet.is_open() && !wallet.is_closing() { + // The Foreign API receive listener performs money-seed operations + // (receive_tx builds outputs with the keychain mask), so it stays + // stopped while the wallet is locked; lock() stops any running + // instance and this guard keeps it from restarting until unlock. + if wallet.is_open() && !wallet.is_closing() && !wallet.is_locked() { // Start Foreign API listener if not running. let api_server_running = { wallet.foreign_api_server.read().is_some() }; if !api_server_running {