From 528bae6925560184fa1647497c252c89cd49d8c1 Mon Sep 17 00:00:00 2001 From: 2ro <17595647+2ro@users.noreply.github.com> Date: Mon, 6 Jul 2026 20:06:26 -0400 Subject: [PATCH 1/4] goblin: Settings redesign (Username + Advanced Privacy pages) Two new Settings pages, each the single home for its feature. Username (SettingsPage::Username) consolidates everything name-related off the main Settings and out of the identity card: claim a name if you have none, release the one you own, and choose the name authority from a known list OR a free-typed custom server. Reuses the existing claim_ui and claim/release worker flow; the old inline name-authority editor and the main-settings claim card are removed (single home). Advanced Privacy (SettingsPage::AdvancedPrivacy) replaces the lone "hide amounts" toggle. Notifications section: hide amounts (existing), hide names, or hide all details (a generic localized private alert that leaks no name or amount; an empty amount collapses the Android notification template to just the private line). Anonymous mode: one toggle that censors STRICTLY the home balance (dots, tap to reveal; the fiat rate fetch stays off until revealed) and the activity list (dots for names and amounts, tap a row to reveal and open). Presentation only, with no money-path or storage change. Migration: the existing hide_amounts config keeps its exact meaning (notification amount hiding); the three new fields default off, so no surprise on upgrade. Covered by a new config test (227 lib tests). All new strings via t!() in all six locales; drift test green. --- .../mw/gri/android/BackgroundService.java | 10 +- locales/de.yml | 21 + locales/en.yml | 21 + locales/fr.yml | 21 + locales/ru.yml | 21 + locales/tr.yml | 21 + locales/zh-CN.yml | 21 + src/gui/views/goblin/mod.rs | 529 +++++++++++++----- src/nostr/client.rs | 57 +- src/settings/config.rs | 84 +++ 10 files changed, 637 insertions(+), 169 deletions(-) diff --git a/android/app/src/main/java/mw/gri/android/BackgroundService.java b/android/app/src/main/java/mw/gri/android/BackgroundService.java index 51b6d07a..5d97e1d7 100644 --- a/android/app/src/main/java/mw/gri/android/BackgroundService.java +++ b/android/app/src/main/java/mw/gri/android/BackgroundService.java @@ -217,9 +217,13 @@ public class BackgroundService extends Service { } Intent i = context.getPackageManager().getLaunchIntentForPackage(context.getPackageName()); PendingIntent pendingIntent = PendingIntent.getActivity(context, 0, i, PendingIntent.FLAG_IMMUTABLE); + // An empty amount is the "hide all details" private alert: the native + // side already composed the whole localized line into `name`, so show it + // verbatim rather than the "X paid Y ツ" scaffolding. + String receivedText = amount.isEmpty() ? name : name + " paid " + amount + " ツ"; NotificationCompat.Builder builder = new NotificationCompat.Builder(context, PAYMENT_CHANNEL_ID) .setContentTitle("Payment received") - .setContentText(name + " paid " + amount + " ツ") + .setContentText(receivedText) .setSmallIcon(R.drawable.ic_stat_name) .setPriority(NotificationCompat.PRIORITY_HIGH) .setAutoCancel(true) @@ -251,9 +255,11 @@ public class BackgroundService extends Service { } Intent i = context.getPackageManager().getLaunchIntentForPackage(context.getPackageName()); PendingIntent pendingIntent = PendingIntent.getActivity(context, 0, i, PendingIntent.FLAG_IMMUTABLE); + // Empty amount = the "hide all details" private alert (see notifyPaymentReceived). + String requestedText = amount.isEmpty() ? name : name + " requested " + amount + " ツ"; NotificationCompat.Builder builder = new NotificationCompat.Builder(context, REQUEST_CHANNEL_ID) .setContentTitle("Payment requested") - .setContentText(name + " requested " + amount + " ツ") + .setContentText(requestedText) .setSmallIcon(R.drawable.ic_stat_name) .setPriority(NotificationCompat.PRIORITY_HIGH) .setAutoCancel(true) diff --git a/locales/de.yml b/locales/de.yml index d2b72fc9..79711f82 100644 --- a/locales/de.yml +++ b/locales/de.yml @@ -595,6 +595,27 @@ goblin: avail_invalid: "Namen haben 3–20 Zeichen: a–z, 0–9, _ oder -" avail_quarantined: "Nicht verfügbar" avail_unknown: "Prüfung fehlgeschlagen — Verbindungsproblem. Versuche es erneut." + username_none: "Nicht festgelegt" + advanced_privacy: "Erweiterte Privatsphäre" + tap_reveal: "Zum Anzeigen tippen" + notif_someone: "Jemand" + notif_private_received: "Du wurdest bezahlt. Öffne Goblin, um Details zu sehen." + notif_private_requested: "Neue Zahlungsanfrage. Öffne Goblin, um Details zu sehen." + username: + title: "Benutzername" + authority: "Namensautorität" + authority_blurb: "Wo dein Benutzername registriert und überprüft wird. Wähle eine bekannte aus oder richte Goblin auf eine andere Instanz, um dort gehostete Namen zu nutzen." + custom: "Eigene Autorität" + advprivacy: + intro: "Steuere, was deine Benachrichtigungen preisgeben, und verbirg dein Guthaben und deinen Verlauf auf diesem Gerät." + notifications: "Benachrichtigungen" + hide_names: "Namen verbergen" + hide_names_sub: "Wer bezahlt hat, wird in Benachrichtigungen weggelassen" + hide_details: "Alle Details verbergen" + hide_details_sub: "Nur ein privater Hinweis, ohne Name oder Betrag" + anon: "Anonymer Modus" + anon_toggle: "Guthaben und Aktivität verwischen" + anon_sub: "Guthaben und Aktivität erscheinen als Punkte, bis du zum Anzeigen tippst" advanced: title: "Erweitert" intro: "Wallet-Werkzeuge auf niedriger Ebene von GRIM. Normalerweise brauchst du diese nicht." diff --git a/locales/en.yml b/locales/en.yml index 16f45086..dbe5608d 100644 --- a/locales/en.yml +++ b/locales/en.yml @@ -595,6 +595,27 @@ goblin: avail_invalid: "Names are 3–20 chars: a–z, 0–9, _ or -" avail_quarantined: "Not available" avail_unknown: "Couldn't check — connection hiccup. Try again." + username_none: "Not set" + advanced_privacy: "Advanced Privacy" + tap_reveal: "Tap to reveal" + notif_someone: "Someone" + notif_private_received: "You got paid. Open Goblin to see." + notif_private_requested: "New payment request. Open Goblin to see." + username: + title: "Username" + authority: "Name authority" + authority_blurb: "Where your username is registered and verified. Pick a known one or point Goblin at another instance to use names hosted there." + custom: "Custom authority" + advprivacy: + intro: "Control what your notifications reveal, and hide your balance and history on this device." + notifications: "Notifications" + hide_names: "Hide names" + hide_names_sub: "Leave out who paid in notifications" + hide_details: "Hide all details" + hide_details_sub: "Show only a private alert, no name or amount" + anon: "Anonymous mode" + anon_toggle: "Blur balance and activity" + anon_sub: "Your balance and activity show as dots until you tap to reveal" advanced: title: "Advanced" intro: "Low-level wallet tools from GRIM. You won't normally need these." diff --git a/locales/fr.yml b/locales/fr.yml index 46dbfbe3..6629a40b 100644 --- a/locales/fr.yml +++ b/locales/fr.yml @@ -595,6 +595,27 @@ goblin: avail_invalid: "Les noms font 3 à 20 caractères : a–z, 0–9, _ ou -" avail_quarantined: "Indisponible" avail_unknown: "Vérification impossible — souci de connexion. Réessayez." + username_none: "Non défini" + advanced_privacy: "Confidentialité avancée" + tap_reveal: "Toucher pour révéler" + notif_someone: "Quelqu'un" + notif_private_received: "Vous avez été payé. Ouvrez Goblin pour voir." + notif_private_requested: "Nouvelle demande de paiement. Ouvrez Goblin pour voir." + username: + title: "Nom d'utilisateur" + authority: "Autorité de noms" + authority_blurb: "Là où votre nom d'utilisateur est enregistré et vérifié. Choisissez-en une connue ou pointez Goblin vers une autre instance pour utiliser les noms qui y sont hébergés." + custom: "Autorité personnalisée" + advprivacy: + intro: "Contrôlez ce que révèlent vos notifications, et masquez votre solde et votre historique sur cet appareil." + notifications: "Notifications" + hide_names: "Masquer les noms" + hide_names_sub: "Ne pas indiquer qui a payé dans les notifications" + hide_details: "Masquer tous les détails" + hide_details_sub: "Afficher seulement une alerte privée, sans nom ni montant" + anon: "Mode anonyme" + anon_toggle: "Flouter le solde et l'activité" + anon_sub: "Votre solde et votre activité s'affichent en points jusqu'à ce que vous touchiez pour révéler" advanced: title: "Avancé" intro: "Outils de portefeuille bas niveau de GRIM. Vous n'en aurez normalement pas besoin." diff --git a/locales/ru.yml b/locales/ru.yml index 994f5186..678d578d 100644 --- a/locales/ru.yml +++ b/locales/ru.yml @@ -595,6 +595,27 @@ goblin: avail_invalid: "Имена 3–20 символов: a–z, 0–9, _ или -" avail_quarantined: "Недоступно" avail_unknown: "Не удалось проверить — сбой соединения. Попробуйте снова." + username_none: "Не задано" + advanced_privacy: "Расширенная приватность" + tap_reveal: "Нажмите, чтобы показать" + notif_someone: "Кто-то" + notif_private_received: "Вам заплатили. Откройте Goblin, чтобы увидеть." + notif_private_requested: "Новый запрос платежа. Откройте Goblin, чтобы увидеть." + username: + title: "Имя пользователя" + authority: "Центр имён" + authority_blurb: "Где регистрируется и проверяется ваше имя. Выберите известный центр или укажите Goblin другой сервер, чтобы использовать размещённые там имена." + custom: "Свой центр" + advprivacy: + intro: "Управляйте тем, что раскрывают уведомления, и скрывайте баланс и историю на этом устройстве." + notifications: "Уведомления" + hide_names: "Скрывать имена" + hide_names_sub: "Не показывать в уведомлениях, кто заплатил" + hide_details: "Скрывать все данные" + hide_details_sub: "Показывать только приватное оповещение, без имени и суммы" + anon: "Анонимный режим" + anon_toggle: "Скрывать баланс и активность" + anon_sub: "Баланс и активность показываются точками, пока вы не нажмёте, чтобы показать" advanced: title: "Дополнительно" intro: "Низкоуровневые инструменты кошелька из GRIM. Обычно они вам не нужны." diff --git a/locales/tr.yml b/locales/tr.yml index 82d330f3..cd3b2b96 100644 --- a/locales/tr.yml +++ b/locales/tr.yml @@ -595,6 +595,27 @@ goblin: avail_invalid: "Adlar 3–20 karakter: a–z, 0–9, _ ya da -" avail_quarantined: "Müsait değil" avail_unknown: "Kontrol edilemedi — bağlantı sorunu. Tekrar dene." + username_none: "Ayarlı değil" + advanced_privacy: "Gelişmiş Gizlilik" + tap_reveal: "Göstermek için dokun" + notif_someone: "Biri" + notif_private_received: "Ödeme aldın. Görmek için Goblin'i aç." + notif_private_requested: "Yeni ödeme isteği. Görmek için Goblin'i aç." + username: + title: "Kullanıcı adı" + authority: "İsim otoritesi" + authority_blurb: "Kullanıcı adının kaydedildiği ve doğrulandığı yer. Bilinen birini seç ya da orada barındırılan isimleri kullanmak için Goblin'i başka bir sunucuya yönlendir." + custom: "Özel otorite" + advprivacy: + intro: "Bildirimlerinin neyi açığa çıkardığını denetle ve bu cihazda bakiyeni ve geçmişini gizle." + notifications: "Bildirimler" + hide_names: "İsimleri gizle" + hide_names_sub: "Kimin ödediğini bildirimlerde gösterme" + hide_details: "Tüm ayrıntıları gizle" + hide_details_sub: "Yalnızca özel bir uyarı göster, isim ya da tutar olmadan" + anon: "Anonim mod" + anon_toggle: "Bakiyeyi ve etkinliği bulanıklaştır" + anon_sub: "Bakiyen ve etkinliğin, göstermek için dokunana kadar noktalar olarak görünür" advanced: title: "Gelişmiş" intro: "GRIM'den düşük seviyeli cüzdan araçları. Bunlara normalde ihtiyacın olmaz." diff --git a/locales/zh-CN.yml b/locales/zh-CN.yml index 77835d9d..77aba7fd 100644 --- a/locales/zh-CN.yml +++ b/locales/zh-CN.yml @@ -595,6 +595,27 @@ goblin: avail_invalid: "用户名为 3–20 个字符:a–z、0–9、_ 或 -" avail_quarantined: "不可用" avail_unknown: "无法检查 — 连接中断。请重试。" + username_none: "未设置" + advanced_privacy: "高级隐私" + tap_reveal: "点按以显示" + notif_someone: "某人" + notif_private_received: "你收到了付款。打开 Goblin 查看。" + notif_private_requested: "新的付款请求。打开 Goblin 查看。" + username: + title: "用户名" + authority: "名称机构" + authority_blurb: "你的用户名注册和验证的地方。选择一个已知机构,或将 Goblin 指向其他实例以使用其托管的名称。" + custom: "自定义机构" + advprivacy: + intro: "控制通知会透露的内容,并在此设备上隐藏你的余额和历史记录。" + notifications: "通知" + hide_names: "隐藏名称" + hide_names_sub: "通知中不显示是谁付款" + hide_details: "隐藏全部详情" + hide_details_sub: "仅显示私密提醒,不含名称或金额" + anon: "匿名模式" + anon_toggle: "模糊余额和活动" + anon_sub: "余额和活动显示为圆点,点按后才会显示" advanced: title: "高级" intro: "来自 GRIM 的底层钱包工具。通常你用不到这些。" diff --git a/src/gui/views/goblin/mod.rs b/src/gui/views/goblin/mod.rs index 4c3b421c..0e1fc7cf 100644 --- a/src/gui/views/goblin/mod.rs +++ b/src/gui/views/goblin/mod.rs @@ -160,6 +160,10 @@ pub struct GoblinWalletView { 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, } /// Whether the per-identity cue is drawn on activity rows (owner-approved). The @@ -169,6 +173,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 @@ -201,6 +210,12 @@ 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, @@ -302,6 +317,7 @@ impl Default for GoblinWalletView { trust_hold: w::HoldToSend::default(), money: None, money_hold: w::HoldToSend::default(), + balance_revealed: false, } } } @@ -1231,6 +1247,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), @@ -1708,16 +1729,25 @@ impl GoblinWalletView { // 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, - ); + // Anonymous mode: the balance is a row of dots until tapped. The + // fiat lookup is skipped entirely while censored (fiat_line is what + // kicks the rate fetch) and only fires once revealed. + if crate::AppConfig::anonymous_mode() && !self.balance_revealed { + if censored_balance_hero(ui) { + self.balance_revealed = true; + } + } else { + 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 { @@ -2737,15 +2767,32 @@ impl GoblinWalletView { } else { "− " }; - let amount = format!("{}{}{}", sign, w::amount_str(item.amount), w::TSU); + // Anonymous mode dots the name and amount (and drops the avatar/memo so + // nothing leaks); the row still taps through to the full detail, which is + // the "reveal" the spec calls for. + let anon = crate::AppConfig::anonymous_mode(); + let amount = if anon { + "•••".to_string() + } else { + 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 tex = if anon { + None + } else { + self.handle_tex(ui.ctx(), wallet, &item.title) + }; + let (title, note_ref, id_ref): (&str, &str, &str) = if anon { + ("••••••", "", "") + } else { + (&item.title, ¬e, item.npub.as_deref().unwrap_or("")) + }; let resp = w::activity_row( ui, - &item.title, - ¬e, + title, + note_ref, &time, - item.npub.as_deref().unwrap_or(""), + id_ref, &amount, item.incoming, item.canceled, @@ -2759,7 +2806,7 @@ impl GoblinWalletView { // 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 { + if !anon && 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; @@ -3193,6 +3240,8 @@ impl GoblinWalletView { 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::Username => return self.username_ui(ui, wallet, cb), + SettingsPage::AdvancedPrivacy => return self.advanced_privacy_ui(ui), SettingsPage::Advanced => return self.advanced_ui(ui, wallet, cb), SettingsPage::Identities => return self.identities_ui(ui, wallet, cb), SettingsPage::TrustedSites => return self.trusted_sites_ui(ui, wallet, cb), @@ -3380,17 +3429,24 @@ impl GoblinWalletView { // 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; + // Username has its own home (claim/release + name authority); the + // row shows the current name (or "Not set") and opens that page. + let mut open_username = false; w::card(ui, |ui| { if !npub.is_empty() { + let username = wallet + .nostr_service() + .and_then(|s| s.identity.read().nip05.clone()) + .map(|n| n.split('@').next().unwrap_or("").to_string()); + let uname_val = username + .unwrap_or_else(|| t!("goblin.settings.username_none").to_string()); + if settings_row_nav(ui, &t!("goblin.settings.username"), &uname_val) { + open_username = true; + } if settings_row_btn(ui, &t!("goblin.settings.copy_npub"), COPY) { cb.copy_string_to_buffer(npub.clone()); cb.vibrate_copy(); @@ -3432,26 +3488,22 @@ impl GoblinWalletView { ) { 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, - }); - } } }); + if open_username { + self.claim = Some(ClaimState::default()); + // Seed the free-type authority field with the current server so + // the Username page opens showing where names resolve today. + let cur = wallet + .nostr_service() + .map(|s| s.config.read().nip05_server()) + .unwrap_or_default(); + self.name_authority = Some(NameAuthorityState { + input: cur, + error: None, + }); + self.settings_page = SettingsPage::Username; + } // Transient confirmation that the copy landed — pairs with the // haptic tick so the tap feels acknowledged. if let Some(at) = self.copy_flash { @@ -3484,10 +3536,6 @@ impl GoblinWalletView { 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); @@ -3570,6 +3618,7 @@ impl GoblinWalletView { ui.add_space(16.0); let mut open_pairing = false; let mut open_privacy = false; + let mut open_adv_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 @@ -3599,15 +3648,11 @@ impl GoblinWalletView { ) { 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); + // Notification hiding (amounts/names/details) and anonymous mode + // now live together on their own page. Replaces the lone + // hide-amounts toggle that used to sit here. + if settings_row_nav(ui, &t!("goblin.settings.advanced_privacy"), "") { + open_adv_privacy = true; } }); if open_pairing { @@ -3616,6 +3661,9 @@ impl GoblinWalletView { if open_privacy { self.settings_page = SettingsPage::Privacy; } + if open_adv_privacy { + self.settings_page = SettingsPage::AdvancedPrivacy; + } ui.add_space(16.0); settings_group(ui, &t!("goblin.settings.requests"), |ui| { @@ -3965,6 +4013,249 @@ impl GoblinWalletView { }); } + /// Username page — the single home for everything name-related: claim one if + /// you have none, release the one you own, and choose the name authority from + /// a known list or by free-typing a custom server. Reuses [`claim_ui`] for the + /// claim/release card; the authority controls live only here. + fn username_ui(&mut self, ui: &mut egui::Ui, wallet: &Wallet, cb: &dyn PlatformCallbacks) { + let t = theme::tokens(); + if self.sub_header(ui, &t!("goblin.username.title")) { + self.settings_page = SettingsPage::Main; + return; + } + if self.claim.is_none() { + self.claim = Some(ClaimState::default()); + } + if 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, + }); + } + ScrollArea::vertical() + .id_salt("goblin_username_scroll") + .auto_shrink([false; 2]) + .scroll_bar_visibility(egui::scroll_area::ScrollBarVisibility::AlwaysHidden) + .show(ui, |ui| { + // Claim / release + the owned-name display. + self.claim_ui(ui, wallet, cb); + + ui.add_space(18.0); + w::kicker(ui, &t!("goblin.username.authority")); + ui.add_space(8.0); + ui.label( + RichText::new(t!("goblin.username.authority_blurb")) + .font(FontId::new(13.0, fonts::regular())) + .color(t.text_dim), + ); + ui.add_space(10.0); + let cur_server = wallet + .nostr_service() + .map(|s| s.config.read().nip05_server()) + .unwrap_or_default(); + let norm = |u: &str| u.trim().trim_end_matches('/').to_lowercase(); + // Known authorities: a tap sets the server. Free-type handles the rest. + let mut chosen: Option = None; + w::card(ui, |ui| { + for (label, url) in KNOWN_AUTHORITIES { + let active = norm(&cur_server) == norm(url); + let row = 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(url.replace("https://", "")) + .font(FontId::new(12.5, fonts::regular())) + .color(t.surface_text_dim), + ); + }); + 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() { + chosen = Some((*url).to_string()); + } + } + }); + if let Some(url) = chosen { + 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()); + } + if let Some(na) = self.name_authority.as_mut() { + na.input = wallet + .nostr_service() + .map(|s| s.config.read().nip05_server()) + .unwrap_or_default(); + na.error = None; + } + } + + ui.add_space(14.0); + // Free-typed custom authority + Reset/Save. + let (save, reset, input) = { + let na = self.name_authority.as_mut().unwrap(); + ui.label( + RichText::new(t!("goblin.username.custom")) + .font(FontId::new(13.0, fonts::medium())) + .color(t.text_dim), + ); + ui.add_space(6.0); + w::field_well(ui, |ui| { + TextEdit::new(egui::Id::from("username_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); + let mut save = false; + let mut reset = 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, 44.0), + )), + |ui| { + if w::big_action_on_card(ui, &t!("goblin.settings.reset")).clicked() + { + reset = 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.save"), false).clicked() { + save = true; + } + }, + ); + }); + (save, reset, na.input.trim().to_string()) + }; + if reset { + 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()); + } + if let Some(na) = self.name_authority.as_mut() { + na.input = wallet + .nostr_service() + .map(|s| s.config.read().nip05_server()) + .unwrap_or_default(); + na.error = None; + } + } + if save { + if !input.starts_with("https://") && !input.starts_with("http://") { + if let Some(na) = self.name_authority.as_mut() { + 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(input)); + crate::nostr::nip05::set_home_domain(&s.config.read().home_domain()); + if let Some(na) = self.name_authority.as_mut() { + na.error = None; + } + } + } + ui.add_space(16.0); + }); + } + + /// Advanced Privacy page — notification hiding (amounts / names / all + /// details) and the anonymous-mode toggle that dots the home balance and the + /// activity list. All presentation-only; nothing here touches the money path. + fn advanced_privacy_ui(&mut self, ui: &mut egui::Ui) { + let t = theme::tokens(); + if self.sub_header(ui, &t!("goblin.settings.advanced_privacy")) { + self.settings_page = SettingsPage::Main; + return; + } + ScrollArea::vertical() + .id_salt("goblin_adv_privacy_scroll") + .auto_shrink([false; 2]) + .scroll_bar_visibility(egui::scroll_area::ScrollBarVisibility::AlwaysHidden) + .show(ui, |ui| { + ui.label( + RichText::new(t!("goblin.advprivacy.intro")) + .font(FontId::new(14.0, fonts::regular())) + .color(t.text_dim), + ); + ui.add_space(16.0); + settings_group(ui, &t!("goblin.advprivacy.notifications"), |ui| { + 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 let Some(v) = settings_row_toggle( + ui, + &t!("goblin.advprivacy.hide_names"), + &t!("goblin.advprivacy.hide_names_sub"), + crate::AppConfig::notif_hide_names(), + ) { + crate::AppConfig::set_notif_hide_names(v); + } + if let Some(v) = settings_row_toggle( + ui, + &t!("goblin.advprivacy.hide_details"), + &t!("goblin.advprivacy.hide_details_sub"), + crate::AppConfig::notif_hide_details(), + ) { + crate::AppConfig::set_notif_hide_details(v); + } + }); + ui.add_space(16.0); + settings_group(ui, &t!("goblin.advprivacy.anon"), |ui| { + if let Some(v) = settings_row_toggle( + ui, + &t!("goblin.advprivacy.anon_toggle"), + &t!("goblin.advprivacy.anon_sub"), + crate::AppConfig::anonymous_mode(), + ) { + crate::AppConfig::set_anonymous_mode(v); + } + }); + 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. @@ -5075,109 +5366,6 @@ impl GoblinWalletView { } } - /// 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) { @@ -8329,6 +8517,37 @@ fn fiat_line(data: &Option) -> Option { }) } +/// The anonymous-mode balance: a centered row of dots standing in for the +/// number, tappable to reveal. Returns true on the frame it is tapped. No fiat +/// line is drawn (and no rate fetch is triggered) while censored. +fn censored_balance_hero(ui: &mut egui::Ui) -> bool { + let t = theme::tokens(); + let mut clicked = false; + ui.vertical_centered(|ui| { + w::kicker(ui, "Balance"); + ui.add_space(6.0); + let resp = ui.add( + egui::Label::new( + RichText::new("• • • •") + .font(FontId::new(56.0, fonts::bold())) + .color(t.text), + ) + .sense(Sense::click()), + ); + let resp = resp + .on_hover_cursor(egui::CursorIcon::PointingHand) + .on_hover_text(t!("goblin.settings.tap_reveal")); + ui.add_space(4.0); + ui.label( + RichText::new(t!("goblin.settings.tap_reveal")) + .font(FontId::new(12.5, fonts::medium())) + .color(t.text_dim), + ); + clicked = resp.clicked(); + }); + clicked +} + /// 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 { diff --git a/src/nostr/client.rs b/src/nostr/client.rs index 74811ab9..b881b362 100644 --- a/src/nostr/client.rs +++ b/src/nostr/client.rs @@ -2711,16 +2711,33 @@ async fn handle_wrap(svc: &Arc, wallet: &Wallet, event: Event) { // on desktop): payer's display name (or short npub) and // the human-readable amount. { - let name = - crate::gui::views::goblin::data::contact_title(&svc.store, &sender_hex); - // Honor the "hide amounts" setting: keep the numeric grin - // out of the received-payment alert when the user opted in. - let amount = if crate::AppConfig::hide_amounts() { - "•••".to_string() + // Notification privacy (Advanced Privacy → Notifications): + // "hide details" trumps the finer toggles with a generic + // alert that leaks neither name nor amount (empty amount + // collapses the Java template to just the private line). + if crate::AppConfig::notif_hide_details() { + crate::notify_payment_received( + &t!("goblin.settings.notif_private_received"), + "", + ); } else { - amount_to_hr_string(slate.amount, true) - }; - crate::notify_payment_received(&name, &amount); + let name = if crate::AppConfig::notif_hide_names() { + t!("goblin.settings.notif_someone").to_string() + } else { + crate::gui::views::goblin::data::contact_title( + &svc.store, + &sender_hex, + ) + }; + // Honor the "hide amounts" setting: keep the numeric + // grin out of the alert when the user opted in. + let amount = if crate::AppConfig::hide_amounts() { + "•••".to_string() + } else { + amount_to_hr_string(slate.amount, true) + }; + crate::notify_payment_received(&name, &amount); + } } match svc .send_payment_dm(&sender_hex, &reply_text, None, &[]) @@ -2772,9 +2789,25 @@ async fn handle_wrap(svc: &Arc, wallet: &Wallet, event: Event) { // received-payment notification's dedup. Requester's display name // (or short npub) and the human-readable amount, with the ツ mark. if decision == IngestDecision::SurfaceRequest { - let name = crate::gui::views::goblin::data::contact_title(&svc.store, &sender_hex); - let amount = amount_to_hr_string(slate.amount, true); - crate::notify_payment_requested(&name, &amount); + // Same notification-privacy ladder as the received-payment alert. + if crate::AppConfig::notif_hide_details() { + crate::notify_payment_requested( + &t!("goblin.settings.notif_private_requested"), + "", + ); + } else { + let name = if crate::AppConfig::notif_hide_names() { + t!("goblin.settings.notif_someone").to_string() + } else { + crate::gui::views::goblin::data::contact_title(&svc.store, &sender_hex) + }; + let amount = if crate::AppConfig::hide_amounts() { + "•••".to_string() + } else { + amount_to_hr_string(slate.amount, true) + }; + crate::notify_payment_requested(&name, &amount); + } } } IngestDecision::FinalizePost => { diff --git a/src/settings/config.rs b/src/settings/config.rs index 0e417561..f96433ec 100644 --- a/src/settings/config.rs +++ b/src/settings/config.rs @@ -98,6 +98,19 @@ pub struct AppConfig { /// so existing configs (and new wallets) keep showing the amount. hide_amounts: Option, + /// Hide the payer/requester name in payment notifications/alerts. Default + /// false. Independent of `hide_amounts`; a notification can hide either or + /// both. + notif_hide_names: Option, + /// Hide every detail in payment notifications/alerts: the alert becomes a + /// generic "you got paid / a request arrived" line with no name and no + /// amount. Default false. When on, it overrides the two finer toggles. + notif_hide_details: Option, + /// Anonymous mode: censor the wallet home balance and the activity list + /// (dots until tapped to reveal). Presentation-only, no money-path or + /// storage effect. Default false. + anonymous_mode: Option, + /// Last-known-good Nym ENTRY gateway (base58 identity). Only the gateway /// CHOICE is remembered — the mixnet keys stay ephemeral — so a warm reconnect /// can skip re-picking a (possibly dead) random first hop. @@ -217,6 +230,9 @@ impl Default for AppConfig { check_updates: Some(true), app_update: None, hide_amounts: None, + notif_hide_names: None, + notif_hide_details: None, + anonymous_mode: None, nym_entry_gateway: None, nym_last_ipr: None, } @@ -642,6 +658,48 @@ impl AppConfig { w_config.hide_amounts = Some(hide); w_config.save(); } + + /// Whether the payer/requester name is hidden in payment notifications. + pub fn notif_hide_names() -> bool { + Settings::app_config_to_read() + .notif_hide_names + .unwrap_or(false) + } + + /// Set whether the payer/requester name is hidden in payment notifications. + pub fn set_notif_hide_names(hide: bool) { + let mut w_config = Settings::app_config_to_update(); + w_config.notif_hide_names = Some(hide); + w_config.save(); + } + + /// Whether payment notifications are reduced to a generic private alert. + pub fn notif_hide_details() -> bool { + Settings::app_config_to_read() + .notif_hide_details + .unwrap_or(false) + } + + /// Set whether payment notifications are reduced to a generic private alert. + pub fn set_notif_hide_details(hide: bool) { + let mut w_config = Settings::app_config_to_update(); + w_config.notif_hide_details = Some(hide); + w_config.save(); + } + + /// Whether anonymous mode censors the home balance and activity list. + pub fn anonymous_mode() -> bool { + Settings::app_config_to_read() + .anonymous_mode + .unwrap_or(false) + } + + /// Set whether anonymous mode censors the home balance and activity list. + pub fn set_anonymous_mode(on: bool) { + let mut w_config = Settings::app_config_to_update(); + w_config.anonymous_mode = Some(on); + w_config.save(); + } } #[cfg(test)] @@ -662,4 +720,30 @@ mod tests { "old config with removed price-cache fields should still load" ); } + + /// A pre-redesign config carries `hide_amounts` but none of the new privacy + /// fields. It must load unchanged (serde fills the absent Options with None), + /// so the notification hide-amounts choice is preserved and the new controls + /// default off: no user surprise on upgrade. + #[test] + fn old_config_without_new_privacy_fields_migrates_sensibly() { + let mut cfg = AppConfig::default(); + cfg.hide_amounts = Some(true); + let mut toml = toml::to_string(&cfg).expect("serialize"); + // Emulate a pre-redesign file that never knew the new keys. + toml = toml + .lines() + .filter(|l| { + !l.starts_with("notif_hide_names") + && !l.starts_with("notif_hide_details") + && !l.starts_with("anonymous_mode") + }) + .collect::>() + .join("\n"); + let parsed = toml::from_str::(&toml).expect("legacy config should load"); + assert_eq!(parsed.hide_amounts, Some(true), "existing choice preserved"); + assert_eq!(parsed.notif_hide_names, None, "new control defaults off"); + assert_eq!(parsed.notif_hide_details, None, "new control defaults off"); + assert_eq!(parsed.anonymous_mode, None, "anonymous mode defaults off"); + } } From 094b6dc29b72609fe34f69035893199839d019b7 Mon Sep 17 00:00:00 2001 From: 2ro <17595647+2ro@users.noreply.github.com> Date: Mon, 6 Jul 2026 20:56:13 -0400 Subject: [PATCH 2/4] goblin: settings polish (wrap fix, 5-dot censor, nav reset) Follow-ups on the Settings redesign, all in the goblin view: - Truncation sweep: settings_row_toggle now reserves room for the switch and bounds the text column, so a long label/subtitle WRAPS to another line instead of running under the switch and clipping. Fixes the Advanced Privacy notification descriptions (worst in longer locales); applies uniformly to every toggle row. The Username and Advanced Privacy blurbs are full-width labels that already wrap. No description on the new surfaces can truncate: wrapping is width-bounded, not length-tuned. - Anonymous-mode censor is a FIXED count of dots (five), computed by a pure censored_amount_dots() that ignores the real amount, so the balance magnitude never leaks. Used for both the home balance hero and censored activity amounts. Unit-tested to never vary with size. - Settings navigation resets to the root whenever the user is off the Settings (Me) tab, so leaving and re-entering always lands on the top-level page instead of the last sub-page. One reset at the leave/enter boundary via a pure settings_page_after(); deep links that open a sub-page set the tab to Me in the same frame, so they survive. Unit-tested. --- src/gui/views/goblin/mod.rs | 109 ++++++++++++++++++++++++++++++++++-- 1 file changed, 103 insertions(+), 6 deletions(-) diff --git a/src/gui/views/goblin/mod.rs b/src/gui/views/goblin/mod.rs index 0e1fc7cf..b1e9e71d 100644 --- a/src/gui/views/goblin/mod.rs +++ b/src/gui/views/goblin/mod.rs @@ -197,8 +197,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, @@ -789,6 +802,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()); @@ -1733,7 +1753,7 @@ impl GoblinWalletView { // fiat lookup is skipped entirely while censored (fiat_line is what // kicks the rate fetch) and only fires once revealed. if crate::AppConfig::anonymous_mode() && !self.balance_revealed { - if censored_balance_hero(ui) { + if censored_balance_hero(ui, total) { self.balance_revealed = true; } } else { @@ -2772,7 +2792,9 @@ impl GoblinWalletView { // the "reveal" the spec calls for. let anon = crate::AppConfig::anonymous_mode(); let amount = if anon { - "•••".to_string() + // Fixed dot count, never digit-matched, so a censored row can't leak + // the amount's magnitude. + censored_amount_dots(item.amount, false) } else { format!("{}{}{}", sign, w::amount_str(item.amount), w::TSU) }; @@ -8198,7 +8220,13 @@ fn settings_row_toggle(ui: &mut egui::Ui, label: &str, sub: &str, on: bool) -> O let t = theme::tokens(); let mut toggled = None; ui.horizontal(|ui| { + // Reserve room for the switch and bound the text column, so a long + // label/subtitle WRAPS onto another line instead of running under the + // switch and clipping (longer locales clipped worst). 46px switch + gap. + let toggle_w = 58.0; + let text_w = (ui.available_width() - toggle_w).max(0.0); ui.vertical(|ui| { + ui.set_width(text_w); ui.label( RichText::new(label) .font(FontId::new(15.0, fonts::medium())) @@ -8517,10 +8545,24 @@ fn fiat_line(data: &Option) -> Option { }) } +/// Number of dots a censored money value renders as. FIXED (never the real +/// digit count) so anonymous mode can't leak the balance magnitude. +const CENSOR_DOT_COUNT: usize = 5; + +/// The anonymous-mode censor for a money value: always [`CENSOR_DOT_COUNT`] +/// dots, deliberately ignoring the real amount so its magnitude never leaks. +/// `spaced` widens the dots for the balance hero; activity amounts pass false. +fn censored_amount_dots(_atomic: u64, spaced: bool) -> String { + let sep = if spaced { " " } else { "" }; + ["•"; CENSOR_DOT_COUNT].join(sep) +} + /// The anonymous-mode balance: a centered row of dots standing in for the /// number, tappable to reveal. Returns true on the frame it is tapped. No fiat -/// line is drawn (and no rate fetch is triggered) while censored. -fn censored_balance_hero(ui: &mut egui::Ui) -> bool { +/// line is drawn (and no rate fetch is triggered) while censored. `total` is +/// passed only so the censor is computed the same way everywhere; it is ignored +/// (the dot count is fixed) so the balance size never leaks. +fn censored_balance_hero(ui: &mut egui::Ui, total: u64) -> bool { let t = theme::tokens(); let mut clicked = false; ui.vertical_centered(|ui| { @@ -8528,7 +8570,7 @@ fn censored_balance_hero(ui: &mut egui::Ui) -> bool { ui.add_space(6.0); let resp = ui.add( egui::Label::new( - RichText::new("• • • •") + RichText::new(censored_amount_dots(total, true)) .font(FontId::new(56.0, fonts::bold())) .color(t.text), ) @@ -8621,3 +8663,58 @@ fn fit_news_title_pt(ui: &egui::Ui, text: &str, avail: f32) -> f32 { } FLOOR } + +#[cfg(test)] +mod anon_censor_tests { + use super::{CENSOR_DOT_COUNT, SettingsPage, Tab, censored_amount_dots, settings_page_after}; + + /// The censored money display must be a fixed number of dots that never + /// reflects the real amount — otherwise anonymous mode leaks the magnitude + /// (a bigger balance would show more/longer digits). + #[test] + fn censored_amount_is_fixed_width_regardless_of_size() { + for spaced in [false, true] { + let zero = censored_amount_dots(0, spaced); + let small = censored_amount_dots(1, spaced); + let huge = censored_amount_dots(u64::MAX, spaced); + assert_eq!(zero, small, "censor must not vary with amount"); + assert_eq!(small, huge, "censor must not vary with amount"); + assert_eq!( + zero.chars().filter(|c| *c == '•').count(), + CENSOR_DOT_COUNT, + "censor must always show exactly {CENSOR_DOT_COUNT} dots" + ); + } + } + + /// 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 + ); + assert_eq!( + settings_page_after(Tab::Me, SettingsPage::AdvancedPrivacy), + SettingsPage::AdvancedPrivacy + ); + assert_eq!( + settings_page_after(Tab::Me, SettingsPage::Main), + SettingsPage::Main + ); + } +} From 9841b25ddf8fd0a76bef22aace25a3b6d3023781 Mon Sep 17 00:00:00 2001 From: 2ro <17595647+2ro@users.noreply.github.com> Date: Mon, 6 Jul 2026 20:56:28 -0400 Subject: [PATCH 3/4] goblin: promote "Import identity" to first-class onboarding buttons The returning-user identity-import path (a .backup file or a bare nsec, existing plumbing since Build 92) is now surfaced as proper centered buttons instead of a small left-aligned text link. - Wallet step: a first-class "Import identity" choice stands alongside Create new / Restore from seed. It composes with either seed choice; the import panel itself opens on the identity step once the wallet exists. - Identity step: the left-aligned "Already have an identity? Import it" link is replaced by a centered secondary button labelled "Import identity", consistent with the other onboarding buttons. The panel behind it clearly offers BOTH a .backup file picker and an nsec field. New keys goblin.onboarding.wallet.import_identity{,_hint} in all six locales; drift test green. --- locales/de.yml | 2 + locales/en.yml | 2 + locales/fr.yml | 2 + locales/ru.yml | 2 + locales/tr.yml | 2 + locales/zh-CN.yml | 2 + src/gui/views/goblin/onboarding.rs | 62 +++++++++++++++++++++++------- 7 files changed, 61 insertions(+), 13 deletions(-) diff --git a/locales/de.yml b/locales/de.yml index 79711f82..0f643519 100644 --- a/locales/de.yml +++ b/locales/de.yml @@ -829,6 +829,8 @@ goblin: title: "Richte dein Wallet ein" create_new: "Neu erstellen" restore_from_seed: "Aus Seed wiederherstellen" + import_identity: "Identität importieren" + import_identity_hint: "Nach diesem Schritt bringst du deinen vorhandenen Schlüssel mit (eine .backup-Datei oder nsec)." name_hint: "Wallet-Name" password_hint: "Passwort" repeat_password_hint: "Passwort wiederholen" diff --git a/locales/en.yml b/locales/en.yml index dbe5608d..0c52b0fb 100644 --- a/locales/en.yml +++ b/locales/en.yml @@ -829,6 +829,8 @@ goblin: title: "Set up your wallet" create_new: "Create new" restore_from_seed: "Restore from seed" + import_identity: "Import identity" + import_identity_hint: "You'll bring in your existing key (a .backup file or nsec) after this step." name_hint: "Wallet name" password_hint: "Password" repeat_password_hint: "Repeat password" diff --git a/locales/fr.yml b/locales/fr.yml index 6629a40b..9eb34355 100644 --- a/locales/fr.yml +++ b/locales/fr.yml @@ -829,6 +829,8 @@ goblin: title: "Configurez votre portefeuille" create_new: "Créer un nouveau" restore_from_seed: "Restaurer depuis la phrase" + import_identity: "Importer une identité" + import_identity_hint: "Vous importerez votre clé existante (un fichier .backup ou un nsec) après cette étape." name_hint: "Nom du portefeuille" password_hint: "Mot de passe" repeat_password_hint: "Répéter le mot de passe" diff --git a/locales/ru.yml b/locales/ru.yml index 678d578d..873e64d8 100644 --- a/locales/ru.yml +++ b/locales/ru.yml @@ -829,6 +829,8 @@ goblin: title: "Настройте кошелёк" create_new: "Создать новый" restore_from_seed: "Восстановить из seed" + import_identity: "Импорт личности" + import_identity_hint: "После этого шага вы добавите свой существующий ключ (файл .backup или nsec)." name_hint: "Имя кошелька" password_hint: "Пароль" repeat_password_hint: "Повторите пароль" diff --git a/locales/tr.yml b/locales/tr.yml index cd3b2b96..0af42cc0 100644 --- a/locales/tr.yml +++ b/locales/tr.yml @@ -829,6 +829,8 @@ goblin: title: "Cüzdanını kur" create_new: "Yeni oluştur" restore_from_seed: "Tohumdan geri yükle" + import_identity: "Kimliği içe aktar" + import_identity_hint: "Bu adımdan sonra mevcut anahtarını (bir .backup dosyası veya nsec) getireceksin." name_hint: "Cüzdan adı" password_hint: "Parola" repeat_password_hint: "Parolayı tekrarla" diff --git a/locales/zh-CN.yml b/locales/zh-CN.yml index 77aba7fd..483e9c58 100644 --- a/locales/zh-CN.yml +++ b/locales/zh-CN.yml @@ -829,6 +829,8 @@ goblin: title: "设置你的钱包" create_new: "新建" restore_from_seed: "从助记词恢复" + import_identity: "导入身份" + import_identity_hint: "此步骤之后,你将导入现有密钥(.backup 文件或 nsec)。" name_hint: "钱包名称" password_hint: "密码" repeat_password_hint: "重复密码" diff --git a/src/gui/views/goblin/onboarding.rs b/src/gui/views/goblin/onboarding.rs index ea1e3cbf..2f5b2fcc 100644 --- a/src/gui/views/goblin/onboarding.rs +++ b/src/gui/views/goblin/onboarding.rs @@ -58,6 +58,11 @@ pub struct OnboardingContent { ext_url: String, /// Wallet setup inputs. restore: bool, + /// The user chose "Import identity" on the wallet step: after the wallet is + /// created the identity step opens the import panel straight away (bringing + /// over an existing key from a `.backup` or nsec), instead of the fresh + /// random key. Composes with either seed choice above it. + want_import: bool, name: String, pass: String, pass2: String, @@ -108,6 +113,7 @@ impl Default for OnboardingContent { integrated: false, ext_url: "https://grincoin.org".to_string(), restore: false, + want_import: false, name: "Main wallet".to_string(), pass: String::new(), pass2: String::new(), @@ -414,6 +420,37 @@ impl OnboardingContent { ui.add_space(10.0); } }); + ui.add_space(10.0); + + // First-class "Import identity" option, standing alongside Create/Restore: + // bring an existing key over (from a .backup file or a bare nsec). It + // composes with either seed choice above — the import panel itself opens + // on the identity step, once the wallet exists. + ui.scope_builder( + egui::UiBuilder::new().max_rect(egui::Rect::from_min_size( + ui.cursor().min, + Vec2::new(ui.available_width(), 44.0), + )), + |ui| { + if w::chip( + ui, + &t!("goblin.onboarding.wallet.import_identity"), + self.want_import, + ) + .clicked() + { + self.want_import = !self.want_import; + } + }, + ); + if self.want_import { + ui.add_space(6.0); + ui.label( + RichText::new(t!("goblin.onboarding.wallet.import_identity_hint")) + .font(FontId::new(12.5, fonts::regular())) + .color(t.text_mute), + ); + } ui.add_space(14.0); w::field_well(ui, |ui| { @@ -864,6 +901,13 @@ impl OnboardingContent { .nostr_service() .map(|s| s.identity.read().nip05.is_some()) .unwrap_or(false); + // Came in via the wallet step's "Import identity" button: open the import + // panel straight away (offers both a .backup file and a bare nsec), rather + // than the fresh-key claim card. + if self.want_import && self.import.is_none() { + self.import = Some(OnbImport::default()); + self.want_import = false; + } if self.import.is_some() { // Returning user is swapping the random key for their existing identity. self.import_ui(ui, &wallet, cb); @@ -946,19 +990,11 @@ impl OnboardingContent { } }); ui.add_space(10.0); - // Returning user? Let them restore their existing identity (nsec or a - // .backup file) instead of claiming a fresh name on the random key. - let import_resp = ui - .add( - egui::Label::new( - RichText::new(t!("goblin.onboarding.identity.import_existing")) - .font(FontId::new(13.0, fonts::semibold())) - .color(t.accent), - ) - .sense(Sense::click()), - ) - .on_hover_cursor(egui::CursorIcon::PointingHand); - if import_resp.clicked() { + // Returning user? A centered, first-class "Import identity" button + // (was a left-aligned text link) restores their existing identity + // from a .backup file or a bare nsec instead of the fresh random key. + // The .backup-or-nsec choice lives behind it, in import_ui. + if w::big_action(ui, &t!("goblin.onboarding.wallet.import_identity"), true).clicked() { self.import = Some(OnbImport::default()); } ui.add_space(16.0); From 74d52c34160192ea6c85a87d47e6bd15d3d16b60 Mon Sep 17 00:00:00 2001 From: 2ro <17595647+2ro@users.noreply.github.com> Date: Mon, 6 Jul 2026 21:10:25 -0400 Subject: [PATCH 4/4] goblin: move Trusted Sites into the nostr settings group + fix raw-key leaks Trusted Sites is nostr-identity signing, and name authority now lives on the Username page, so the nostr cluster is its logical home. Pure placement move: the row now renders inside the identity card just below Nostr Relays; label, live session-count value, and open handler are unchanged. Also carries the two raw-key leak fixes (goblin.receipt.copied -> goblin.receive.copied, wallets.canceled -> wallets.tx_canceled) and the every_t_call_site_key_exists_in_en guard, so this branch is clean on its own. Translations for the new settings/username/advprivacy keys were reviewed across all six locales and left as-is (consistent with house register). --- src/gui/views/goblin/mod.rs | 34 ++++--- src/gui/views/wallets/wallet/txs/content.rs | 2 +- tests/i18n_keys.rs | 103 ++++++++++++++++++++ 3 files changed, 123 insertions(+), 16 deletions(-) diff --git a/src/gui/views/goblin/mod.rs b/src/gui/views/goblin/mod.rs index b1e9e71d..15ba5b02 100644 --- a/src/gui/views/goblin/mod.rs +++ b/src/gui/views/goblin/mod.rs @@ -3458,6 +3458,10 @@ impl GoblinWalletView { // Username has its own home (claim/release + name authority); the // row shows the current name (or "Not set") and opens that page. let mut open_username = false; + // Trusted Sites (the active Authorize Sessions) lives with the + // nostr rows — it is nostr-identity signing, not a wallet setting + // — but its open handler runs further down, so the flag is here. + let mut open_trusted = false; w::card(ui, |ui| { if !npub.is_empty() { let username = wallet @@ -3510,6 +3514,20 @@ impl GoblinWalletView { ) { open_relays = true; } + // Trusted Sites: the active Authorize Sessions, with a + // one-tap end; the row value is the live session count. + // Nostr-identity signing, so it sits with the keys/relays. + let session_count = wallet + .nostr_service() + .map(|s| s.session_summaries().len()) + .unwrap_or(0); + if settings_row_nav( + ui, + &t!("goblin.settings.trusted_sites"), + &session_count.to_string(), + ) { + open_trusted = true; + } } }); if open_username { @@ -3536,7 +3554,7 @@ impl GoblinWalletView { RichText::new(format!( "{} {}", crate::gui::icons::CHECK, - t!("goblin.receipt.copied") + t!("goblin.receive.copied") )) .font(FontId::new(13.0, fonts::medium())) .color(t.pos), @@ -3570,7 +3588,6 @@ impl GoblinWalletView { ui.add_space(16.0); let mut open_node = false; let mut open_slatepack = false; - let mut open_trusted = false; settings_group(ui, &t!("goblin.settings.wallet"), |ui| { if settings_row_nav(ui, &t!("goblin.settings.node"), &node_summary(wallet)) { open_node = true; @@ -3600,19 +3617,6 @@ impl GoblinWalletView { ) { open_slatepack = true; } - // Trusted Sites: the active Authorize Sessions, with a one-tap - // end. Shows the live count as the row value. - let session_count = wallet - .nostr_service() - .map(|s| s.session_summaries().len()) - .unwrap_or(0); - if settings_row_nav( - ui, - &t!("goblin.settings.trusted_sites"), - &session_count.to_string(), - ) { - open_trusted = true; - } }); if open_slatepack { self.slatepack = SlatepackManual::default(); diff --git a/src/gui/views/wallets/wallet/txs/content.rs b/src/gui/views/wallets/wallet/txs/content.rs index d4f812f1..951f4d57 100644 --- a/src/gui/views/wallets/wallet/txs/content.rs +++ b/src/gui/views/wallets/wallet/txs/content.rs @@ -569,7 +569,7 @@ impl WalletTransactionsContent { ) } } - _ => format!("{} {}", X_CIRCLE, t!("wallets.canceled")), + _ => format!("{} {}", X_CIRCLE, t!("wallets.tx_canceled")), } }; diff --git a/tests/i18n_keys.rs b/tests/i18n_keys.rs index 6bc70c30..bfbd89ed 100644 --- a/tests/i18n_keys.rs +++ b/tests/i18n_keys.rs @@ -123,3 +123,106 @@ fn every_locale_has_all_goblin_keys() { problems.join("\n") ); } + +/// Every `t!("literal.key")` in the source must resolve to a key present in +/// en.yml. Guards against raw-key leaks (a call site typing a key that was +/// never added, or renamed out from under it) that render the dotted key +/// verbatim in the UI. The cross-locale check above only compares locale files +/// to each other; it cannot see the call sites. +#[test] +fn every_t_call_site_key_exists_in_en() { + // Full en.yml, all namespaces (t! is used for goblin.*, wallets.*, network.*, …). + let path = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("locales") + .join("en.yml"); + let text = std::fs::read_to_string(&path) + .unwrap_or_else(|e| panic!("cannot read {}: {e}", path.display())); + let doc: serde_yaml::Value = serde_yaml::from_str(&text).expect("invalid YAML in en.yml"); + let mut en = BTreeMap::new(); + flatten(&doc, "", &mut en); + let en_keys: BTreeSet<&String> = en.keys().collect(); + + // A localization key: dotted, lowercase-ish namespace segments. This filters + // out format strings, URIs, and other literals that happen to sit in a t! arg + // position but are not keys (there are none today, but keep the guard honest). + fn is_key_like(s: &str) -> bool { + s.contains('.') + && s.bytes() + .all(|b| b.is_ascii_alphanumeric() || b == b'.' || b == b'_') + && s.split('.').all(|seg| { + !seg.is_empty() + && seg + .bytes() + .next() + .map_or(false, |b| b.is_ascii_alphabetic()) + }) + } + + // Walk src/ collecting t!("…") literal keys with their location. + fn walk(dir: &Path, out: &mut Vec<(String, String, usize)>) { + let entries = match std::fs::read_dir(dir) { + Ok(e) => e, + Err(_) => return, + }; + for entry in entries.flatten() { + let p = entry.path(); + if p.is_dir() { + walk(&p, out); + } else if p.extension().and_then(|e| e.to_str()) == Some("rs") { + let Ok(src) = std::fs::read_to_string(&p) else { + continue; + }; + for (lineno, line) in src.lines().enumerate() { + collect_line(line, &p.display().to_string(), lineno + 1, out); + } + } + } + } + + // Find `t!("key"` occurrences on a line, honoring a word boundary so we do + // not match `format!("…")`, `print!("…")`, etc. + fn collect_line(line: &str, file: &str, lineno: usize, out: &mut Vec<(String, String, usize)>) { + let bytes = line.as_bytes(); + let mut i = 0; + while let Some(rel) = line[i..].find("t!(") { + let start = i + rel; + // word boundary before the `t` + let boundary = start == 0 + || !matches!(bytes[start - 1], b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9' | b'_'); + let after = &line[start + 3..]; + let after_trim = after.trim_start(); + if boundary { + if let Some(rest) = after_trim.strip_prefix('"') { + if let Some(end) = rest.find('"') { + out.push((rest[..end].to_string(), file.to_string(), lineno)); + } + } + } + i = start + 3; + } + } + + let mut sites = Vec::new(); + walk( + &Path::new(env!("CARGO_MANIFEST_DIR")).join("src"), + &mut sites, + ); + + let mut leaks = Vec::new(); + for (key, file, lineno) in &sites { + if is_key_like(key) && !en_keys.contains(key) { + leaks.push(format!( + "{file}:{lineno}: t!(\"{key}\") — key not in en.yml" + )); + } + } + leaks.sort(); + leaks.dedup(); + + assert!( + leaks.is_empty(), + "raw-key leak: {} t! call site(s) reference a key absent from en.yml:\n{}", + leaks.len(), + leaks.join("\n") + ); +}