goblin: per-identity gradient legend on the transaction detail
The transaction detail's Identity row now carries a small two-tone gradient dot in that identity's OWN gradient, drawn with the exact identicon math (gradient_rgb8, refactored out of gradient_params so the dot matches the identity's avatar everywhere). The dot is theme-aware and deliberately quiet: a faint fill plus a faint ring that stays legible on both light and dark backgrounds without becoming a bright chip. A per-row cue in the activity list is intentionally OFF (SHOW_ROW_IDENTITY_CUE = false): on a row the avatar is the counterparty, so a gradient dot there is both redundant with that avatar and does not tell the user which of their OWN identities was used. The plumbing (the seam const, a per-frame identity context, ActivityItem.owner_pubkey recording the tx's owning identity) is kept so a proper own-identity cue can be added later in one place, but it renders nothing now. The switcher page and profile card already show the active identity via its own gradient avatar and name.
This commit is contained in:
@@ -34,6 +34,10 @@ pub struct ActivityItem {
|
||||
pub time: i64,
|
||||
/// Counterparty npub hex, when known.
|
||||
pub npub: Option<String>,
|
||||
/// The wallet's OWN nostr identity (pubkey hex) that was active when this tx
|
||||
/// happened — the front door it used. Empty/None on pre-feature rows (treated
|
||||
/// as the primary identity). Drives the subtle per-identity row cue.
|
||||
pub owner_pubkey: Option<String>,
|
||||
}
|
||||
|
||||
/// Full detail for the receipt / transaction-detail screen: GRIM tx data
|
||||
@@ -316,6 +320,10 @@ fn build_item(tx: &WalletTx, store: Option<&NostrStore>) -> ActivityItem {
|
||||
.map(|t| t.timestamp())
|
||||
.unwrap_or(0);
|
||||
let canceled = is_canceled(tx, meta.as_ref());
|
||||
let owner_pubkey = meta
|
||||
.as_ref()
|
||||
.map(|m| m.recipient_pubkey.clone())
|
||||
.filter(|h| !h.is_empty());
|
||||
|
||||
ActivityItem {
|
||||
tx_id: tx.data.id,
|
||||
@@ -328,6 +336,7 @@ fn build_item(tx: &WalletTx, store: Option<&NostrStore>) -> ActivityItem {
|
||||
system,
|
||||
time,
|
||||
npub: meta.map(|m| m.npub),
|
||||
owner_pubkey,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -54,12 +54,6 @@ pub(super) fn hsl_rgb8(h: f64, s: f64, l: f64) -> (u8, u8, u8) {
|
||||
(to(r), to(g), to(b))
|
||||
}
|
||||
|
||||
/// Standard HSL → RGB → `#rrggbb`.
|
||||
fn hsl_to_rgb(h: f64, s: f64, l: f64) -> String {
|
||||
let (r, g, b) = hsl_rgb8(h, s, l);
|
||||
format!("#{r:02x}{g:02x}{b:02x}")
|
||||
}
|
||||
|
||||
/// Normalise any caller-supplied id (npub bech32 OR raw hex) to the canonical
|
||||
/// lowercase hex pubkey used as the seed everywhere.
|
||||
pub fn to_hex_seed(id: &str) -> String {
|
||||
@@ -70,21 +64,38 @@ pub fn to_hex_seed(id: &str) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// Gradient stop colors (`#rrggbb`) + rotation angle derived from the seed `hex`.
|
||||
/// Shared by the Grin-mark avatar and the bare-background variant so both draw
|
||||
/// the byte-identical gradient for one key. Keep this math in lockstep with the
|
||||
/// shared reference port.
|
||||
fn gradient_params(hex: &str) -> (String, String, f64) {
|
||||
/// Gradient stop colors (RGB bytes) + rotation angle derived from the seed `hex`.
|
||||
/// The single source of the per-identity gradient math; both the `#rrggbb`
|
||||
/// string form (for the SVG avatar) and the byte form (for the small egui cue)
|
||||
/// come from here, so a dot/edge cue matches the avatar exactly. Keep this in
|
||||
/// lockstep with the shared reference port.
|
||||
fn gradient_rgb(hex: &str) -> ((u8, u8, u8), (u8, u8, u8), f64) {
|
||||
let hash = Sha256::digest(hex.as_bytes());
|
||||
let base = ((u16::from(hash[0]) << 8 | u16::from(hash[1])) as f64 / 65_535.0) * 360.0;
|
||||
let offset = 40.0 + (hash[2] as f64 / 255.0) * 120.0;
|
||||
let h2 = (base + offset) % 360.0;
|
||||
let angle = (hash[3] as f64 / 255.0) * 360.0;
|
||||
let c1 = hsl_to_rgb(base, 0.62, 0.55);
|
||||
let c2 = hsl_to_rgb(h2, 0.62, 0.42);
|
||||
let c1 = hsl_rgb8(base, 0.62, 0.55);
|
||||
let c2 = hsl_rgb8(h2, 0.62, 0.42);
|
||||
(c1, c2, angle)
|
||||
}
|
||||
|
||||
/// Gradient stop colors (`#rrggbb`) + rotation angle for the SVG avatar.
|
||||
fn gradient_params(hex: &str) -> (String, String, f64) {
|
||||
let (c1, c2, angle) = gradient_rgb(hex);
|
||||
let hex_of = |(r, g, b): (u8, u8, u8)| format!("#{r:02x}{g:02x}{b:02x}");
|
||||
(hex_of(c1), hex_of(c2), angle)
|
||||
}
|
||||
|
||||
/// The two gradient stop colors (RGB bytes) for an identity, seeded by the same
|
||||
/// pubkey math as its gradient avatar, so a small dot or edge cue drawn from
|
||||
/// these matches that identity's avatar. `id` may be an npub or raw hex.
|
||||
pub fn gradient_rgb8(id: &str) -> ((u8, u8, u8), (u8, u8, u8)) {
|
||||
let hex = to_hex_seed(id);
|
||||
let (c1, c2, _angle) = gradient_rgb(&hex);
|
||||
(c1, c2)
|
||||
}
|
||||
|
||||
/// The gradient avatar as a standalone SVG document, seeded by `hex` (lowercase
|
||||
/// hex pubkey). `id_suffix` makes the gradient element id unique when several
|
||||
/// are inlined into ONE html document; for a standalone document (how egui
|
||||
|
||||
+86
-29
@@ -130,6 +130,35 @@ pub struct GoblinWalletView {
|
||||
wipe_confirm: bool,
|
||||
}
|
||||
|
||||
/// Whether a per-identity cue is drawn on activity rows. OFF by design: on an
|
||||
/// activity row the avatar is the COUNTERPARTY, so a dot in an identity gradient
|
||||
/// there is both redundant with that avatar and does not convey which of the
|
||||
/// USER's own identities was involved. A correct in-list cue would need a
|
||||
/// distinct element encoding the user's own active identity, which is a separate
|
||||
/// follow-up with its own design pass. The seam (this const + the plumbing below)
|
||||
/// is kept so that cue can be added in one place; the detail-view identity
|
||||
/// attribution is the shipped "which identity" answer. Leave false.
|
||||
const SHOW_ROW_IDENTITY_CUE: bool = false;
|
||||
|
||||
/// 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
|
||||
/// once per activity list, not per row.
|
||||
struct IdentityCueCtx {
|
||||
multi: bool,
|
||||
primary: Option<String>,
|
||||
}
|
||||
|
||||
impl IdentityCueCtx {
|
||||
fn compute(wallet: &Wallet) -> Self {
|
||||
let ids = wallet.nostr_identities();
|
||||
IdentityCueCtx {
|
||||
multi: ids.len() > 1,
|
||||
primary: ids.first().map(|i| i.pubkey_hex.clone()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Sub-pages of the Settings tab.
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
enum SettingsPage {
|
||||
@@ -1091,6 +1120,7 @@ impl GoblinWalletView {
|
||||
w::kicker(ui, &t!("goblin.home.activity"));
|
||||
ui.add_space(6.0);
|
||||
let items = activity_items(wallet);
|
||||
let id_cue = IdentityCueCtx::compute(wallet);
|
||||
if items.is_empty() {
|
||||
empty_state(
|
||||
ui,
|
||||
@@ -1099,7 +1129,7 @@ impl GoblinWalletView {
|
||||
);
|
||||
} else {
|
||||
for item in items.iter().take(6) {
|
||||
self.activity_item_ui(ui, item, wallet, cb);
|
||||
self.activity_item_ui(ui, item, wallet, cb, &id_cue);
|
||||
}
|
||||
}
|
||||
ui.add_space(16.0);
|
||||
@@ -1570,30 +1600,38 @@ impl GoblinWalletView {
|
||||
})
|
||||
.map(|m| m.recipient_pubkey)
|
||||
.filter(|h| !h.is_empty());
|
||||
let id_label = match owning_hex {
|
||||
Some(hex) => wallet
|
||||
.nostr_identities()
|
||||
.iter()
|
||||
.find(|i| i.pubkey_hex == hex)
|
||||
.map(|i| {
|
||||
i.nip05.clone().unwrap_or_else(|| {
|
||||
data::short_npub(&i.pubkey_hex)
|
||||
})
|
||||
})
|
||||
.unwrap_or_else(|| data::short_npub(&hex)),
|
||||
// Legacy/primary row: the first held identity.
|
||||
None => wallet
|
||||
.nostr_identities()
|
||||
.first()
|
||||
.map(|i| {
|
||||
i.nip05.clone().unwrap_or_else(|| {
|
||||
data::short_npub(&i.pubkey_hex)
|
||||
})
|
||||
})
|
||||
.unwrap_or_default(),
|
||||
let ids = wallet.nostr_identities();
|
||||
// The identity this tx used: the one recorded on it,
|
||||
// else the primary for pre-feature rows.
|
||||
let owner = match &owning_hex {
|
||||
Some(hex) => ids.iter().find(|i| &i.pubkey_hex == hex),
|
||||
None => ids.first(),
|
||||
};
|
||||
let seed = owner
|
||||
.map(|i| i.pubkey_hex.clone())
|
||||
.or_else(|| owning_hex.clone());
|
||||
let id_label = owner
|
||||
.map(|i| {
|
||||
i.nip05
|
||||
.clone()
|
||||
.unwrap_or_else(|| data::short_npub(&i.pubkey_hex))
|
||||
})
|
||||
.or_else(|| owning_hex.as_deref().map(data::short_npub))
|
||||
.unwrap_or_default();
|
||||
if !id_label.is_empty() {
|
||||
w::info_row(ui, &t!("goblin.receipt.identity"), &id_label);
|
||||
match &seed {
|
||||
Some(seed) => w::info_row_dot(
|
||||
ui,
|
||||
&t!("goblin.receipt.identity"),
|
||||
&id_label,
|
||||
seed,
|
||||
),
|
||||
None => w::info_row(
|
||||
ui,
|
||||
&t!("goblin.receipt.identity"),
|
||||
&id_label,
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(npub) = &d.npub {
|
||||
@@ -2024,6 +2062,7 @@ impl GoblinWalletView {
|
||||
.scroll_bar_visibility(egui::scroll_area::ScrollBarVisibility::AlwaysHidden)
|
||||
.show(ui, |ui| {
|
||||
let items = activity_items(wallet);
|
||||
let id_cue = IdentityCueCtx::compute(wallet);
|
||||
if items.is_empty() {
|
||||
empty_state(
|
||||
ui,
|
||||
@@ -2040,7 +2079,7 @@ impl GoblinWalletView {
|
||||
if !pending.is_empty() {
|
||||
w::section_header(ui, &t!("goblin.activity.pending_header"));
|
||||
for item in pending {
|
||||
self.activity_item_ui(ui, item, wallet, cb);
|
||||
self.activity_item_ui(ui, item, wallet, cb, &id_cue);
|
||||
}
|
||||
ui.add_space(8.0);
|
||||
}
|
||||
@@ -2055,7 +2094,7 @@ impl GoblinWalletView {
|
||||
w::section_header(ui, &label);
|
||||
last = Some(label);
|
||||
}
|
||||
self.activity_item_ui(ui, item, wallet, cb);
|
||||
self.activity_item_ui(ui, item, wallet, cb, &id_cue);
|
||||
}
|
||||
}
|
||||
ui.add_space(16.0);
|
||||
@@ -2068,6 +2107,7 @@ impl GoblinWalletView {
|
||||
item: &ActivityItem,
|
||||
wallet: &Wallet,
|
||||
_cb: &dyn PlatformCallbacks,
|
||||
id_cue: &IdentityCueCtx,
|
||||
) {
|
||||
// No +/- for canceled: nothing moved.
|
||||
let sign = if item.canceled {
|
||||
@@ -2080,7 +2120,7 @@ impl GoblinWalletView {
|
||||
let amount = 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);
|
||||
if w::activity_row(
|
||||
let resp = w::activity_row(
|
||||
ui,
|
||||
&item.title,
|
||||
¬e,
|
||||
@@ -2091,9 +2131,26 @@ impl GoblinWalletView {
|
||||
item.canceled,
|
||||
item.system,
|
||||
tex.as_ref(),
|
||||
)
|
||||
.clicked()
|
||||
{
|
||||
);
|
||||
// Provisional per-identity cue (owner reviews the look before it is final):
|
||||
// only when the wallet holds MORE THAN ONE identity, and never on system
|
||||
// (mining) rows. A single, quiet gradient dot at the row's top-left corner,
|
||||
// seeded by the identity this tx used (its own gradient, matching that
|
||||
// identity's avatar in the switcher). Behind SHOW_ROW_IDENTITY_CUE so it can
|
||||
// be toned down or dropped in one place without touching the rest.
|
||||
if 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;
|
||||
w::identity_dot(
|
||||
ui.painter(),
|
||||
egui::pos2(r.left() + 8.0, r.top() + 7.0),
|
||||
4.0,
|
||||
&seed,
|
||||
);
|
||||
}
|
||||
}
|
||||
if resp.clicked() {
|
||||
self.receipt = Some(item.tx_id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1067,6 +1067,75 @@ impl HoldToSend {
|
||||
}
|
||||
}
|
||||
|
||||
/// Like [`info_row`], but with the per-identity gradient dot drawn just left of
|
||||
/// the value — the transaction-detail legend for which held identity a payment
|
||||
/// used, alongside its name/npub.
|
||||
pub fn info_row_dot(ui: &mut Ui, label: &str, value: &str, seed: &str) {
|
||||
let t = theme::tokens();
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(
|
||||
RichText::new(label)
|
||||
.font(FontId::new(14.0, fonts::regular()))
|
||||
.color(t.text_dim),
|
||||
);
|
||||
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
|
||||
ui.add(
|
||||
egui::Label::new(
|
||||
RichText::new(value)
|
||||
.font(FontId::new(15.0, fonts::semibold()))
|
||||
.color(t.text),
|
||||
)
|
||||
.truncate(),
|
||||
);
|
||||
ui.add_space(7.0);
|
||||
let (rect, _) = ui.allocate_exact_size(Vec2::splat(10.0), Sense::hover());
|
||||
identity_dot(ui.painter(), rect.center(), 4.0, seed);
|
||||
});
|
||||
});
|
||||
ui.add_space(8.0);
|
||||
ui.painter().hline(
|
||||
ui.min_rect().left()..=ui.min_rect().right(),
|
||||
ui.cursor().top(),
|
||||
Stroke::new(1.0, t.line),
|
||||
);
|
||||
ui.add_space(8.0);
|
||||
}
|
||||
|
||||
/// A deliberately QUIET per-identity cue: a small two-tone dot in an identity's
|
||||
/// OWN gradient — the same pubkey-seeded math as its avatar (`identicon`), so the
|
||||
/// dot reads as a legend for that identity and matches everywhere it appears
|
||||
/// (activity row, switcher page, transaction detail). A faint, theme-aware ring
|
||||
/// keeps a PALE gradient legible on a light background and a DARK one on a dark
|
||||
/// background, without ever becoming a bright chip. `seed` is the identity's npub
|
||||
/// or pubkey hex. This is the single shared renderer, so toning it down (or
|
||||
/// removing the row cue) happens in exactly one place.
|
||||
pub fn identity_dot(painter: &egui::Painter, center: egui::Pos2, radius: f32, seed: &str) {
|
||||
let t = theme::tokens();
|
||||
let ((r1, g1, b1), (r2, g2, b2)) = super::identicon::gradient_rgb8(seed);
|
||||
// Faint fill in BOTH themes; the ring below carries legibility, not the fill.
|
||||
let fill_a = if t.dark_base { 165 } else { 185 };
|
||||
let c1 = Color32::from_rgba_unmultiplied(r1, g1, b1, fill_a);
|
||||
let c2 = Color32::from_rgba_unmultiplied(r2, g2, b2, fill_a);
|
||||
// Base disc in the first stop, then the second stop as a half-disc so the dot
|
||||
// is two-tone like the gradient avatar rather than a flat chip.
|
||||
painter.circle_filled(center, radius, c1);
|
||||
let start = std::f32::consts::FRAC_PI_4; // 45 degrees, the gradient axis-ish
|
||||
let mut pts = Vec::with_capacity(14);
|
||||
for i in 0..=12 {
|
||||
let a = start + std::f32::consts::PI * (i as f32 / 12.0);
|
||||
pts.push(center + radius * egui::Vec2::angled(a));
|
||||
}
|
||||
painter.add(egui::Shape::convex_polygon(pts, c2, Stroke::NONE));
|
||||
// Faint ring: light on dark, dark on light — a low-contrast gradient still
|
||||
// gets an edge on either background.
|
||||
let ring = if t.dark_base {
|
||||
Color32::from_rgba_unmultiplied(255, 255, 255, 55)
|
||||
} else {
|
||||
Color32::from_rgba_unmultiplied(0, 0, 0, 45)
|
||||
};
|
||||
painter.circle_stroke(center, radius, Stroke::new(1.0, ring));
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{BalanceSubline, balance_subline};
|
||||
|
||||
Reference in New Issue
Block a user