1
0
forked from GRIN/grim

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.
This commit is contained in:
2ro
2026-07-07 00:14:23 -04:00
parent c3bd0c00d0
commit bb1023e51b
3 changed files with 99 additions and 15 deletions
+59 -15
View File
@@ -1989,10 +1989,18 @@ impl GoblinWalletView {
if peers.is_empty() {
return;
}
let texs: Vec<Option<egui::TextureHandle>> = 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<Option<egui::TextureHandle>> = 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<char> = name.chars().collect();
let short: String = if chars.len() > 8 {
format!("{}", chars[..8].iter().collect::<String>())
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<char> = name.chars().collect();
if chars.len() > 8 {
format!("{}", chars[..8].iter().collect::<String>())
} 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, &note, 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<WalletData>) -> Option<w::FiatLine> {
/// 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).
+2
View File
@@ -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()
{
+38
View File
@@ -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);
}