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:
@@ -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…"
|
||||
|
||||
@@ -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…"
|
||||
|
||||
@@ -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…"
|
||||
|
||||
@@ -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: "Обработка…"
|
||||
|
||||
@@ -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…"
|
||||
|
||||
@@ -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
@@ -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| {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user