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:
+59
-15
@@ -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, ¬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<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).
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user