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);
}