diff --git a/src/gui/views/goblin/data.rs b/src/gui/views/goblin/data.rs index bcab5048..1a9ba7c2 100644 --- a/src/gui/views/goblin/data.rs +++ b/src/gui/views/goblin/data.rs @@ -34,6 +34,10 @@ pub struct ActivityItem { pub time: i64, /// Counterparty npub hex, when known. pub npub: Option, + /// 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, } /// 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, } } diff --git a/src/gui/views/goblin/identicon.rs b/src/gui/views/goblin/identicon.rs index aba46978..0fb06c24 100644 --- a/src/gui/views/goblin/identicon.rs +++ b/src/gui/views/goblin/identicon.rs @@ -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 diff --git a/src/gui/views/goblin/mod.rs b/src/gui/views/goblin/mod.rs index 53bd2329..d02f2a46 100644 --- a/src/gui/views/goblin/mod.rs +++ b/src/gui/views/goblin/mod.rs @@ -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, +} + +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); } } diff --git a/src/gui/views/goblin/widgets.rs b/src/gui/views/goblin/widgets.rs index 74f8099f..2539088c 100644 --- a/src/gui/views/goblin/widgets.rs +++ b/src/gui/views/goblin/widgets.rs @@ -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};