diff --git a/locales/de.yml b/locales/de.yml index 6cbcbb42..380ef0a0 100644 --- a/locales/de.yml +++ b/locales/de.yml @@ -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…" diff --git a/locales/en.yml b/locales/en.yml index bd1560fd..dda28e78 100644 --- a/locales/en.yml +++ b/locales/en.yml @@ -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…" diff --git a/locales/fr.yml b/locales/fr.yml index 4dc42fbc..ea8e6c5d 100644 --- a/locales/fr.yml +++ b/locales/fr.yml @@ -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 n’est 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…" diff --git a/locales/ru.yml b/locales/ru.yml index dd711436..7730ba8b 100644 --- a/locales/ru.yml +++ b/locales/ru.yml @@ -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: "Обработка…" diff --git a/locales/tr.yml b/locales/tr.yml index c4065201..911395b9 100644 --- a/locales/tr.yml +++ b/locales/tr.yml @@ -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 — nsec’i 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…" diff --git a/locales/zh-CN.yml b/locales/zh-CN.yml index e7107694..b09b4495 100644 --- a/locales/zh-CN.yml +++ b/locales/zh-CN.yml @@ -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: "处理中…" diff --git a/src/gui/views/goblin/mod.rs b/src/gui/views/goblin/mod.rs index 703a7887..bdfcfc6b 100644 --- a/src/gui/views/goblin/mod.rs +++ b/src/gui/views/goblin/mod.rs @@ -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, + /// 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, /// 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 = None; - let mut delete_target: Option = None; + let mut manage_target: Option = 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| { diff --git a/src/nostr/client.rs b/src/nostr/client.rs index e2127eb6..8561d824 100644 --- a/src/nostr/client.rs +++ b/src/nostr/client.rs @@ -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) { + 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. diff --git a/src/nostr/identity.rs b/src/nostr/identity.rs index 4b8b0264..93c8137a 100644 --- a/src/nostr/identity.rs +++ b/src/nostr/identity.rs @@ -59,6 +59,12 @@ pub struct NostrIdentity { /// the first service start selects them. #[serde(default)] pub dm_relays: Vec, + /// 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, } /// 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, }) } diff --git a/src/wallet/wallet.rs b/src/wallet/wallet.rs index 4448469c..c83c5716 100644 --- a/src/wallet/wallet.rs +++ b/src/wallet/wallet.rs @@ -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, - /// 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, + /// 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