From 6b7aaf5d4c7098e242f5a75907e66e7b26ca3277 Mon Sep 17 00:00:00 2001 From: 2ro <17595647+2ro@users.noreply.github.com> Date: Tue, 14 Jul 2026 21:31:09 -0400 Subject: [PATCH] goblin: lock wallet keeps relays alive behind an unlock screen The Lock wallet button now calls wallet.lock() instead of wallet.close(), so the wallet stays open and its nostr relays stay connected (payments keep arriving and queue up) while the money seed is sealed. A full-surface unlock screen replaces the whole wallet UI whenever is_locked() is set: it verifies the typed password against the live nostr identity (no re-open) and calls unlock() on success, or fully closes the wallet as a fallback. Because the screen is an early return from GoblinWalletView::ui it covers every tab, overlay, modal and pending deep link, and system/Android back is swallowed while locked, so no in-app money action is reachable. lock() also stops the Foreign API receive listener (its /v2/foreign receive_tx is a seed op); the sync loop restarts it on unlock, guarded by !is_locked(). Adds goblin.lock.{title,subtitle,unlock,close_wallet} to all 10 locales. (cherry picked from commit ee27be3020592d9c1228df9d0f23451920ca5035) --- locales/de.yml | 5 + locales/en.yml | 5 + locales/es.yml | 5 + locales/fr.yml | 5 + locales/ja.yml | 5 + locales/ko.yml | 5 + locales/ru.yml | 5 + locales/tr.yml | 5 + locales/zh-CN.yml | 5 + locales/zh-TW.yml | 5 + src/gui/views/goblin/me.rs | 128 +- src/gui/views/goblin/mod.rs | 6896 +++--------------------------- src/gui/views/wallets/content.rs | 13 + src/wallet/wallet.rs | 25 +- 14 files changed, 699 insertions(+), 6413 deletions(-) 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 {