From 6af8fd1c264507b33ab39f40affea5db77f636bd 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] 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 f0009dc8..e9670b9d 100644 --- a/locales/de.yml +++ b/locales/de.yml @@ -597,6 +597,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 a28cac87..3030e66e 100644 --- a/locales/en.yml +++ b/locales/en.yml @@ -597,6 +597,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 02062d7b..312e4340 100644 --- a/locales/fr.yml +++ b/locales/fr.yml @@ -597,6 +597,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 27688e7d..c161f8be 100644 --- a/locales/ru.yml +++ b/locales/ru.yml @@ -597,6 +597,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 ed7d2d99..adfb065f 100644 --- a/locales/tr.yml +++ b/locales/tr.yml @@ -597,6 +597,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 3cf3a9eb..b59b1535 100644 --- a/locales/zh-CN.yml +++ b/locales/zh-CN.yml @@ -597,6 +597,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 e893131e..a1a11675 100644 --- a/src/gui/views/goblin/mod.rs +++ b/src/gui/views/goblin/mod.rs @@ -164,6 +164,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 @@ -173,6 +177,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 @@ -205,6 +214,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, @@ -307,6 +322,7 @@ impl Default for GoblinWalletView { trust_hold: w::HoldToSend::default(), money: None, money_hold: w::HoldToSend::default(), + balance_revealed: false, } } } @@ -1347,6 +1363,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), @@ -1826,16 +1847,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 { @@ -2894,15 +2924,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, @@ -2916,7 +2963,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; @@ -3350,6 +3397,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), @@ -3537,17 +3586,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(); @@ -3589,26 +3645,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 { @@ -3641,10 +3693,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); @@ -3727,6 +3775,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 @@ -3756,15 +3805,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 { @@ -3773,6 +3818,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| { @@ -4119,6 +4167,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. @@ -5229,109 +5520,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) { @@ -8519,6 +8707,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 5bca7e06..7a6978c8 100644 --- a/src/nostr/client.rs +++ b/src/nostr/client.rs @@ -2746,16 +2746,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, &[]) @@ -2807,9 +2824,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"); + } }