1
0
forked from GRIN/grim

goblin: private identity tags + per-row management sheet (Build 146)

PRIVATE TAG: a local, app-only name the user gives an identity. Stored as
NostrIdentity.private_tag in its 0600 identity file (serde-default; it also
rides inside the NIP-44-sealed .backup envelope, which serializes the whole
struct, and is restored on backup import). NEVER published — kind-0 metadata is
built only from the claimed NIP-05 name, untouched. Display precedence
everywhere an identity is shown (switcher rows, delete confirm, transaction-
detail attribution): private tag, else claimed name (bare, no @), else
truncated npub (HeldIdentitySummary::display).

ROW UX: identity rows show the label with a checkmark on the active one; TAP =
instant switch (unchanged). A small pencil affordance per row opens a
management sheet: Rename (set/clear the private tag; saves via
Wallet::rename_nostr_identity, which persists the file and updates the running
service in place — no password needed, the ncryptsec is untouched) on top, and
Delete at the bottom, visually separated and destructive-styled, feeding the
existing double gate (danger card, then the wallet-password modal). Delete only
renders while more than one identity is held. New strings in all six locales.
This commit is contained in:
2ro
2026-07-05 21:58:44 -04:00
parent e1e5179080
commit 06efe02a1c
10 changed files with 222 additions and 37 deletions
+4
View File
@@ -695,6 +695,10 @@ goblin:
delete_blurb: "Dies entfernt die Identität dauerhaft aus dieser Wallet. Bereits erhaltenes Geld bleibt in deinem Guthaben."
delete_backup_note: "Sichere sie zuerst — ohne ihren nsec oder ihre .backup-Datei ist sie nicht wiederherstellbar."
delete_confirm: "Löschen"
manage_title: "Identität verwalten"
tag_note: "Gib ihr einen privaten Namen. Nur du siehst ihn — er wird nie geteilt."
tag_hint: "Privater Name"
tag_save: "Speichern"
add_confirm: "Hinzufügen"
pass_prompt: "Dein Wallet-Passwort verschlüsselt und entsperrt jede Identität. Gib es ein, um fortzufahren."
working: "Arbeite…"
+4
View File
@@ -695,6 +695,10 @@ goblin:
delete_blurb: "This permanently removes the identity from this wallet. Money already received stays in your balance."
delete_backup_note: "Back it up first — without its nsec or .backup file it cannot be recovered."
delete_confirm: "Delete"
manage_title: "Manage identity"
tag_note: "Give it a private name. Only you see it — it is never shared."
tag_hint: "Private name"
tag_save: "Save"
add_confirm: "Add"
pass_prompt: "Your wallet password encrypts and unlocks each identity. Enter it to continue."
working: "Working…"
+4
View File
@@ -695,6 +695,10 @@ goblin:
delete_blurb: "Cela supprime définitivement l'identité de ce portefeuille. L'argent déjà reçu reste dans votre solde."
delete_backup_note: "Sauvegardez-la d'abord — sans son nsec ou son fichier .backup, elle est irrécupérable."
delete_confirm: "Supprimer"
manage_title: "Gérer l'identité"
tag_note: "Donnez-lui un nom privé. Vous seul le voyez — il nest jamais partagé."
tag_hint: "Nom privé"
tag_save: "Enregistrer"
add_confirm: "Ajouter"
pass_prompt: "Le mot de passe de votre portefeuille chiffre et déverrouille chaque identité. Saisissez-le pour continuer."
working: "En cours…"
+4
View File
@@ -695,6 +695,10 @@ goblin:
delete_blurb: "Это навсегда удалит личность из этого кошелька. Уже полученные деньги останутся на балансе."
delete_backup_note: "Сначала сделайте резервную копию — без её nsec или файла .backup её невозможно восстановить."
delete_confirm: "Удалить"
manage_title: "Управление личностью"
tag_note: "Дайте ей приватное имя. Его видите только вы — оно никогда не передаётся."
tag_hint: "Приватное имя"
tag_save: "Сохранить"
add_confirm: "Добавить"
pass_prompt: "Пароль кошелька шифрует и разблокирует каждую личность. Введите его, чтобы продолжить."
working: "Обработка…"
+4
View File
@@ -695,6 +695,10 @@ goblin:
delete_blurb: "Bu, kimliği bu cüzdandan kalıcı olarak kaldırır. Zaten alınan para bakiyende kalır."
delete_backup_note: "Önce yedeğini al — nseci veya .backup dosyası olmadan geri getirilemez."
delete_confirm: "Sil"
manage_title: "Kimliği yönet"
tag_note: "Ona özel bir ad ver. Sadece sen görürsün — asla paylaşılmaz."
tag_hint: "Özel ad"
tag_save: "Kaydet"
add_confirm: "Ekle"
pass_prompt: "Cüzdan parolan her kimliği şifreler ve açar. Devam etmek için gir."
working: "Çalışıyor…"
+4
View File
@@ -695,6 +695,10 @@ goblin:
delete_blurb: "这将从此钱包中永久移除该身份。已收到的钱仍保留在你的余额中。"
delete_backup_note: "请先备份——没有它的 nsec 或 .backup 文件将无法恢复。"
delete_confirm: "删除"
manage_title: "管理身份"
tag_note: "给它起一个私密名称。只有你能看到——绝不会被分享。"
tag_hint: "私密名称"
tag_save: "保存"
add_confirm: "添加"
pass_prompt: "钱包密码用于加密和解锁每个身份。输入以继续。"
working: "处理中…"
+128 -36
View File
@@ -349,6 +349,10 @@ struct IdentitySwitchState {
backup_input: String,
/// A native .backup file pick is in flight (Android returns it asynchronously).
picking: bool,
/// A held identity whose management sheet (rename / delete) is open (hex).
manage: Option<String>,
/// Private-tag text being edited in the management sheet.
tag_input: String,
/// A held identity awaiting the step-1 delete confirmation (pubkey hex).
confirm_delete: Option<String>,
/// The password-gated action the modal is currently gating.
@@ -1636,11 +1640,7 @@ impl GoblinWalletView {
// convention), else the truncated npub. Never a
// placeholder word.
let id_label = owner
.map(|i| {
i.name
.clone()
.unwrap_or_else(|| data::short_npub(&i.pubkey_hex))
})
.map(|i| i.display())
.or_else(|| owning_hex.as_deref().map(data::short_npub))
.unwrap_or_default();
if !id_label.is_empty() {
@@ -4913,18 +4913,14 @@ impl GoblinWalletView {
w::kicker(ui, &t!("goblin.identities.held"));
ui.add_space(8.0);
let busy = self.identity_switch.busy;
// A wallet must keep at least one identity, so delete is only offered
// when more than one is held.
let can_delete = identities.len() > 1 && !busy;
let mut switch_to: Option<String> = None;
let mut delete_target: Option<String> = None;
let mut manage_target: Option<String> = None;
for id in &identities {
// A claimed name (no leading @, per the project convention), else
// the truncated npub. Never a placeholder word.
// Display precedence: private tag, else claimed name (bare, no
// leading @), else truncated npub. Never a placeholder word.
let short = data::short_npub(&id.pubkey_hex);
let title = id.name.clone().unwrap_or_else(|| short.clone());
let named = id.name.is_some();
let mut delete_this = false;
let title = id.display();
let mut pencil_hit = false;
let row = w::card(ui, |ui| {
ui.set_min_width(ui.available_width());
ui.horizontal(|ui| {
@@ -4936,9 +4932,9 @@ impl GoblinWalletView {
.font(FontId::new(15.0, fonts::semibold()))
.color(t.surface_text),
);
// Show the npub underneath only when the title is a name,
// so an unnamed identity isn't the npub twice.
if named {
// The npub underneath, unless the title already IS the
// npub (unnamed, untagged identity).
if title != short {
ui.label(
RichText::new(&short)
.font(FontId::new(12.0, fonts::regular()))
@@ -4960,24 +4956,25 @@ impl GoblinWalletView {
.color(t.surface_text_dim),
);
}
// A trash affordance to the LEFT of that indicator; its
// own tap target so it doesn't trigger the row switch.
if can_delete {
// Edit (pencil) affordance: opens the per-identity
// management sheet (rename / delete). Its own tap target
// so it never triggers the row switch.
if !busy {
ui.add_space(8.0);
let (r, resp) =
ui.allocate_exact_size(Vec2::splat(28.0), Sense::click());
ui.painter().text(
r.center(),
egui::Align2::CENTER_CENTER,
crate::gui::icons::TRASH,
crate::gui::icons::PENCIL_SIMPLE,
FontId::new(16.0, fonts::regular()),
t.neg,
t.surface_text_dim,
);
if resp
.on_hover_cursor(egui::CursorIcon::PointingHand)
.clicked()
{
delete_this = true;
pencil_hit = true;
}
}
});
@@ -4985,15 +4982,15 @@ impl GoblinWalletView {
.response
.rect
});
// Tap anywhere else on a non-active row = switch (skip when the
// trash was tapped, whose rect overlaps the row interact).
// Tap anywhere else on a non-active row = INSTANT switch (skip
// when the pencil was tapped, whose rect overlaps the row).
if !id.active && !busy {
let hit = ui.interact(
row,
egui::Id::new(("id_switch", id.pubkey_hex.as_str())),
Sense::click(),
);
if !delete_this
if !pencil_hit
&& hit
.on_hover_cursor(egui::CursorIcon::PointingHand)
.clicked()
@@ -5001,8 +4998,8 @@ impl GoblinWalletView {
switch_to = Some(id.pubkey_hex.clone());
}
}
if delete_this {
delete_target = Some(id.pubkey_hex.clone());
if pencil_hit {
manage_target = Some(id.pubkey_hex.clone());
}
ui.add_space(6.0);
}
@@ -5015,10 +5012,109 @@ impl GoblinWalletView {
self.identity_switch.error = e;
}
}
// Tapping the trash opens the step-1 danger confirmation.
if let Some(target) = delete_target {
// The pencil opens the management sheet, pre-filled with the tag.
if let Some(target) = manage_target {
self.identity_switch.error.clear();
self.identity_switch.confirm_delete = Some(target);
self.identity_switch.confirm_delete = None;
self.identity_switch.tag_input = identities
.iter()
.find(|i| i.pubkey_hex == target)
.and_then(|i| i.tag.clone())
.unwrap_or_default();
self.identity_switch.manage = Some(target);
}
// Per-identity management sheet: Rename (the private, app-only tag —
// never published) on top; Delete, visually separated and
// destructive-styled, at the bottom (double-gated: danger card, then
// the wallet-password modal).
if let Some(target) = self.identity_switch.manage.clone() {
ui.add_space(8.0);
w::card(ui, |ui| {
ui.set_min_width(ui.available_width());
ui.label(
RichText::new(t!("goblin.identities.manage_title"))
.font(FontId::new(15.0, fonts::semibold()))
.color(t.surface_text),
);
ui.add_space(6.0);
ui.label(
RichText::new(t!("goblin.identities.tag_note"))
.font(FontId::new(12.5, fonts::regular()))
.color(t.surface_text_dim),
);
ui.add_space(8.0);
w::field_well(ui, |ui| {
TextEdit::new(egui::Id::from("identity_tag_input"))
.focus(false)
.hint_text(t!("goblin.identities.tag_hint"))
.text_color(t.surface_text)
.body()
.ui(ui, &mut self.identity_switch.tag_input, cb);
});
ui.add_space(10.0);
ui.horizontal(|ui| {
let half = (ui.available_width() - 10.0) / 2.0;
ui.scope_builder(
egui::UiBuilder::new().max_rect(egui::Rect::from_min_size(
ui.cursor().min,
Vec2::new(half, 44.0),
)),
|ui| {
if w::big_action_on_card(ui, &t!("goblin.settings.cancel"))
.clicked()
{
self.identity_switch.manage = None;
self.identity_switch.tag_input.clear();
}
},
);
ui.add_space(10.0);
ui.scope_builder(
egui::UiBuilder::new().max_rect(egui::Rect::from_min_size(
ui.cursor().min,
Vec2::new(half, 44.0),
)),
|ui| {
if w::big_action(ui, &t!("goblin.identities.tag_save"), false)
.clicked()
{
let tag =
std::mem::take(&mut self.identity_switch.tag_input);
if let Err(e) =
wallet.rename_nostr_identity(target.clone(), tag)
{
self.identity_switch.error = e;
}
self.identity_switch.manage = None;
}
},
);
});
// Delete, clearly separated from rename and destructive-styled.
// Only offered while more than one identity is held (a wallet
// must keep at least one).
if identities.len() > 1 {
ui.add_space(12.0);
let line_y = ui.cursor().min.y;
ui.painter().hline(
ui.max_rect().x_range(),
line_y,
eframe::epaint::Stroke::new(1.0, t.line),
);
ui.add_space(12.0);
if w::big_action_on_card_ink(
ui,
&t!("goblin.identities.delete_short"),
t.neg,
)
.clicked()
{
self.identity_switch.manage = None;
self.identity_switch.confirm_delete = Some(target.clone());
}
}
});
}
// Step-1 delete confirmation: a clear danger card naming the
@@ -5029,11 +5125,7 @@ impl GoblinWalletView {
let display = identities
.iter()
.find(|i| i.pubkey_hex == target)
.map(|i| {
i.name
.clone()
.unwrap_or_else(|| data::short_npub(&i.pubkey_hex))
})
.map(|i| i.display())
.unwrap_or_else(|| data::short_npub(&target));
ui.add_space(8.0);
w::card(ui, |ui| {
+14
View File
@@ -231,6 +231,20 @@ impl NostrService {
self.recv.read().iter().any(|h| &h.keys.public_key() == pk)
}
/// Update a held identity's PRIVATE tag in the in-memory set (and the active
/// copy when it is the same identity), so the switcher re-renders immediately
/// after a rename without a service rebuild. The caller persists the file.
pub fn set_private_tag(&self, hex: &str, tag: Option<String>) {
for h in self.recv.write().iter_mut() {
if h.keys.public_key().to_hex() == hex {
h.identity.private_tag = tag.clone();
}
}
if self.public_key().to_hex() == hex {
self.identity.write().private_tag = tag;
}
}
/// Instant, purely-local identity switch: re-point the active keys/identity to
/// a held identity already unlocked and already listening. No password, no
/// teardown, no catch-up. `false` if `hex` is not held.
+7
View File
@@ -59,6 +59,12 @@ pub struct NostrIdentity {
/// the first service start selects them.
#[serde(default)]
pub dm_relays: Vec<String>,
/// PRIVATE, app-only label the user sets to name this identity for
/// themselves. Stays local: it lives in this 0600 file (and rides inside the
/// NIP-44-sealed .backup envelope, which serializes the whole struct), and is
/// NEVER published — not in kind-0 metadata, not in any event.
#[serde(default)]
pub private_tag: Option<String>,
}
/// NIP-49 scrypt work factor (~64 MiB, interactive-grade).
@@ -236,6 +242,7 @@ impl NostrIdentity {
anonymous: true,
prev_npubs: Vec::new(),
dm_relays: Vec::new(),
private_tag: None,
})
}
+49 -1
View File
@@ -76,12 +76,28 @@ pub struct HeldIdentitySummary {
/// Full NIP-05 identifier ("user@domain") when this identity has a claimed
/// name, for the transaction detail view.
pub nip05: Option<String>,
/// Short human label (its @name, or "Primary").
/// PRIVATE, app-only label the user set for this identity. Local metadata
/// only, never published. Display precedence: tag, else claimed name, else
/// truncated npub.
pub tag: Option<String>,
/// Convenience label from the index (claimed name's local part, or empty).
pub label: String,
/// Whether this is the currently active identity.
pub active: bool,
}
impl HeldIdentitySummary {
/// What the UI shows for this identity: the private tag if set, else the
/// claimed name (bare, no leading @), else the truncated npub.
pub fn display(&self) -> String {
self.tag
.clone()
.filter(|s| !s.trim().is_empty())
.or_else(|| self.name.clone())
.unwrap_or_else(|| crate::gui::views::goblin::data::short_npub(&self.pubkey_hex))
}
}
/// Contains wallet instance, configuration and state, handles wallet commands.
#[derive(Clone)]
pub struct Wallet {
@@ -692,6 +708,7 @@ impl Wallet {
ident.nip05 = backup.nip05.clone();
ident.anonymous = backup.anonymous;
ident.prev_npubs = backup.prev_npubs.clone();
ident.private_tag = backup.private_tag.clone();
(ident, keys)
} else if input.starts_with('{') {
// Legacy plaintext identity-backup JSON (pre-.backup-file): decrypt
@@ -709,6 +726,7 @@ impl Wallet {
ident.nip05 = backup.nip05.clone();
ident.anonymous = backup.anonymous;
ident.prev_npubs = backup.prev_npubs.clone();
ident.private_tag = backup.private_tag.clone();
(ident, keys)
} else {
NostrIdentity::create_imported(input, &password)
@@ -799,6 +817,7 @@ impl Wallet {
npub: identity.npub.clone(),
name,
nip05: identity.nip05.clone(),
tag: identity.private_tag.clone(),
label: entry.label.clone(),
active: entry.pubkey == index.active,
})
@@ -865,6 +884,7 @@ impl Wallet {
ident.nip05 = backup.nip05.clone();
ident.anonymous = backup.anonymous;
ident.prev_npubs = backup.prev_npubs.clone();
ident.private_tag = backup.private_tag.clone();
(ident, keys)
} else {
NostrIdentity::create_imported(blob, &password)
@@ -938,6 +958,34 @@ impl Wallet {
Ok(new_npub)
}
/// Set (or clear, with an empty string) a held identity's PRIVATE tag — the
/// local, app-only name the user gives it. Persisted in its 0600 identity
/// file (and thus inside future sealed .backups) and updated in the running
/// service so the switcher re-renders immediately. NEVER published to nostr.
/// Local metadata only, so no password is required (the ncryptsec is untouched).
pub fn rename_nostr_identity(&self, target_hex: String, tag: String) -> Result<(), String> {
let svc = self
.nostr_service()
.ok_or_else(|| "nostr is not running".to_string())?;
let nostr_dir = self.get_config().get_nostr_path();
let index = HeldIdentities::load(&nostr_dir)
.ok_or_else(|| "identity index unavailable".to_string())?;
let entry = index
.entry(&target_hex)
.ok_or_else(|| "identity not held by this wallet".to_string())?;
let mut identity = entry
.load(&nostr_dir)
.ok_or_else(|| "identity file unreadable".to_string())?;
let tag = tag.trim().to_string();
let tag = if tag.is_empty() { None } else { Some(tag) };
identity.private_tag = tag.clone();
identity
.save_at(&entry.abs_path(&nostr_dir))
.map_err(|e| format!("save failed: {e}"))?;
svc.set_private_tag(&target_hex, tag);
Ok(())
}
/// PERMANENTLY delete a held identity: drop it from the held-identity index,
/// delete its on-disk encrypted file, and rebuild the running service without
/// it (so its pubkey leaves the multi-pubkey gift-wrap subscription and its key