From bb1023e51b6f4d03c814e3ee5f7b6f768dbffb86 Mon Sep 17 00:00:00 2001 From: 2ro <17595647+2ro@users.noreply.github.com> Date: Tue, 7 Jul 2026 00:14:23 -0400 Subject: [PATCH] Anonymous mode: censored avatars + Recent strip While anonymous mode is on, replace every counterparty avatar on the wallet home and activity panels with one uniform censored tile: a solid Goblin-yellow (#FED60E) circle with the Goblin mark inked dark on top (reusing the img/goblin-logo2 asset that widgets_logo draws). Identical for every identity, so no picture, gradient, or initial leaks. The Recent strip is now anonymized like the rest of the surface: the censored tile for every avatar and dotted names. Tap-to-reveal is intact (the row/tile still senses clicks and opens the full detail). Shared CENSOR_NAME_DOTS backs the activity title and the Recent names. Adds a unit test that the censored name is dots-only. --- src/gui/views/goblin/mod.rs | 74 ++++++++++++++++++++++++++------- src/gui/views/goblin/send.rs | 2 + src/gui/views/goblin/widgets.rs | 38 +++++++++++++++++ 3 files changed, 99 insertions(+), 15 deletions(-) diff --git a/src/gui/views/goblin/mod.rs b/src/gui/views/goblin/mod.rs index 94c88c4c..5439d69b 100644 --- a/src/gui/views/goblin/mod.rs +++ b/src/gui/views/goblin/mod.rs @@ -1989,10 +1989,18 @@ impl GoblinWalletView { if peers.is_empty() { return; } - let texs: Vec> = peers - .iter() - .map(|(name, _)| self.handle_tex(ui.ctx(), wallet, name)) - .collect(); + // Anonymous mode censors the Recent strip exactly like the rest of the + // surface: the uniform yellow tile for every avatar and dotted names, so a + // recent recipient is no more identifiable here than in the activity feed. + let anon = crate::AppConfig::anonymous_mode(); + let texs: Vec> = if anon { + peers.iter().map(|_| None).collect() + } else { + peers + .iter() + .map(|(name, _)| self.handle_tex(ui.ctx(), wallet, name)) + .collect() + }; w::kicker(ui, &t!("goblin.home.recent")); ui.add_space(12.0); ScrollArea::horizontal() @@ -2007,17 +2015,27 @@ impl GoblinWalletView { Vec2::new(72.0, 78.0), Layout::top_down(Align::Center), |ui| { - let resp = w::avatar_any(ui, name, npub, 48.0, tex.as_ref()); - ui.add_space(6.0); - let chars: Vec = name.chars().collect(); - let short: String = if chars.len() > 8 { - format!("{}…", chars[..8].iter().collect::()) + let resp = if anon { + w::avatar_censored(ui, 48.0) } else { - name.to_string() + w::avatar_any(ui, name, npub, 48.0, tex.as_ref()) + }; + ui.add_space(6.0); + let short: String = if anon { + CENSOR_NAME_DOTS.to_string() + } else { + let chars: Vec = name.chars().collect(); + if chars.len() > 8 { + format!("{}…", chars[..8].iter().collect::()) + } else { + name.to_string() + } }; ui.label( RichText::new(short).font(FontId::new(12.0, fonts::medium())), ); + // Tapping still opens the profile (tap-to-reveal); the strip + // itself stays censored. if resp.clicked() { self.profile = Some(npub.clone()); } @@ -2726,6 +2744,7 @@ impl GoblinWalletView { item.canceled, item.system, htex.as_ref(), + false, ) .clicked() { @@ -2944,9 +2963,10 @@ impl GoblinWalletView { } else { "− " }; - // 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. + // Anonymous mode dots the name and amount and replaces the avatar with the + // uniform censored tile (drawn inside `activity_row` from the `anon` flag) + // and drops the 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 { // Fixed dot count, never digit-matched, so a censored row can't leak @@ -2962,7 +2982,7 @@ impl GoblinWalletView { self.handle_tex(ui.ctx(), wallet, &item.title) }; let (title, note_ref, id_ref): (&str, &str, &str) = if anon { - ("••••••", "", "") + (CENSOR_NAME_DOTS, "", "") } else { (&item.title, ¬e, item.npub.as_deref().unwrap_or("")) }; @@ -2977,6 +2997,7 @@ impl GoblinWalletView { item.canceled, item.system, tex.as_ref(), + anon, ); // Per-identity cue (owner-approved): only when the wallet holds MORE THAN // ONE identity, and never on system (mining) rows. A small corner badge on @@ -8745,6 +8766,11 @@ fn fiat_line(data: &Option) -> Option { /// digit count) so anonymous mode can't leak the balance magnitude. const CENSOR_DOT_COUNT: usize = 5; +/// The fixed dot string a censored name renders as (activity rows and the Recent +/// strip). A constant width, never derived from the real name, so its length +/// can't hint at who the counterparty is. +const CENSOR_NAME_DOTS: &str = "••••••"; + /// 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. @@ -8862,7 +8888,10 @@ fn fit_news_title_pt(ui: &egui::Ui, text: &str, avail: f32) -> f32 { #[cfg(test)] mod anon_censor_tests { - use super::{CENSOR_DOT_COUNT, SettingsPage, Tab, censored_amount_dots, settings_page_after}; + use super::{ + CENSOR_DOT_COUNT, CENSOR_NAME_DOTS, 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 @@ -8883,6 +8912,21 @@ mod anon_censor_tests { } } + /// The censored name is a fixed run of dots, never empty and containing no + /// alphanumerics, so a dotted name on the activity feed or the Recent strip + /// can't leak any characters of who the counterparty is. + #[test] + fn censored_name_is_fixed_dots_only() { + assert!( + !CENSOR_NAME_DOTS.is_empty(), + "censored name must not be blank" + ); + assert!( + CENSOR_NAME_DOTS.chars().all(|c| c == '•'), + "censored name must be dots only, no leaked characters" + ); + } + /// 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). diff --git a/src/gui/views/goblin/send.rs b/src/gui/views/goblin/send.rs index 52c04f7b..1e470b05 100644 --- a/src/gui/views/goblin/send.rs +++ b/src/gui/views/goblin/send.rs @@ -635,6 +635,7 @@ impl SendFlow { false, false, tex.as_ref(), + false, ) .clicked() { @@ -698,6 +699,7 @@ impl SendFlow { false, false, tex.as_ref(), + false, ) .clicked() { diff --git a/src/gui/views/goblin/widgets.rs b/src/gui/views/goblin/widgets.rs index d2773647..faa555a4 100644 --- a/src/gui/views/goblin/widgets.rs +++ b/src/gui/views/goblin/widgets.rs @@ -84,6 +84,39 @@ pub fn avatar_any( } } +/// Fixed Goblin yellow for the anonymous-mode censored avatar (#FED60E). A +/// literal constant, never seeded by an identity or read from the theme, so +/// every censored tile is byte-identical and no per-user color can leak. +const CENSOR_AVATAR_FILL: Color32 = Color32::from_rgb(0xFE, 0xD6, 0x0E); + +/// The anonymous-mode censored avatar: one uniform tile that replaces every +/// real picture, gradient, or initial while anonymous mode is on. A solid +/// Goblin-yellow circle with the Goblin mark inked dark on top (the same mark +/// [`super::widgets_logo`] draws), tinted with the dark ink so it reads on the +/// yellow in both light and dark themes. Identical for every identity on the +/// home, activity, and Recent surfaces, so nothing about who the counterparty +/// is leaks. `size` matches the avatar it stands in for; the row still taps +/// through (the returned `Response` senses clicks) so tap-to-reveal is intact. +pub fn avatar_censored(ui: &mut Ui, size: f32) -> Response { + let (rect, resp) = ui.allocate_exact_size(Vec2::splat(size), Sense::click()); + ui.painter() + .circle_filled(rect.center(), rect.width() / 2.0, CENSOR_AVATAR_FILL); + // Goblin mark centered at ~62% of the tile, inked dark so it reads on the + // yellow regardless of theme. Small marks use the pre-rendered raster (same + // crossover as widgets_logo_sized) for cleaner antialiasing. + let mark = size * 0.62; + let mrect = egui::Rect::from_center_size(rect.center(), Vec2::splat(mark)); + egui::Image::new(if mark <= 32.0 { + egui::include_image!("../../../../img/goblin-logo2-48.png") + } else { + egui::include_image!("../../../../img/goblin-logo2.svg") + }) + .tint(Color32::from_rgb(0x0E, 0x0E, 0x0C)) + .fit_to_exact_size(Vec2::splat(mark)) + .paint_at(ui, mrect); + resp +} + /// Draw a balance/amount: big bold number + smaller ツ mark, tight. /// Geist (sans) per the design; mono is reserved for kernel/block ids. pub fn amount_text(ui: &mut Ui, value: &str, size: f32) { @@ -617,6 +650,7 @@ pub fn activity_row( canceled: bool, system: bool, tex: Option<&egui::TextureHandle>, + anon: bool, ) -> Response { let t = theme::tokens(); // A touch taller than a single-line row so the amount can sit centered @@ -647,6 +681,10 @@ pub fn activity_row( FontId::new(20.0, fonts::regular()), t.text, ); + } else if anon { + // Anonymous mode: the uniform censored tile stands in for every + // counterparty avatar, so nothing about who they are leaks. + avatar_censored(ui, 40.0); } else { avatar_any(ui, title, id, 40.0, tex); }