1
0
forked from GRIN/grim

goblin: identity switcher UX fixes for Build 143

- Password entry is now a MODAL popup mirroring the wallet-open password modal
  (Modal + explanation + masked field + wrong-password line + Cancel/Continue),
  used as the unlock step for BOTH switching and adding. The inline page-top
  password field is gone; tapping a held identity opens the modal to unlock and
  switch, and Add opens it to encrypt+store. A correct password is verified
  synchronously so a wrong one stays in the modal.
- Add no longer force-switches into the new identity ("Add and switch" -> "Add");
  it is added to the held set and the user activates it later via tap -> modal.
  This also removes the switch-triggered publish from the create path, so
  creating an identity only generates a fresh anonymous key and stores it
  encrypted (no name lookup/claim).
- Fixes the import-nsec flow. Root cause: the required password lived in a page-
  top field disconnected from the Add card, so the confirm button's armed gate
  stayed disabled unless that separate field was filled — import (and generate)
  read as inert. Moving the password into the modal removes the disconnected
  gate; the underlying add_nostr_identity(Some(nsec)) path was already sound
  (covered by the new regression test).
- Usernames render WITHOUT a leading "@" (project convention), in the switcher
  and the transaction-detail identity row; unnamed identities show a truncated
  npub. New add_confirm/pass_prompt strings in all six locales (drift green).
This commit is contained in:
2ro
2026-07-05 18:36:28 -04:00
parent 0a88adc37a
commit 669d4b01e7
7 changed files with 193 additions and 82 deletions
+2 -1
View File
@@ -693,7 +693,8 @@ goblin:
generate: "Neu erzeugen"
import: "Nsec importieren"
nsec_hint: "Nsec einfügen"
add_switch: "Hinzufügen und wechseln"
add_confirm: "Hinzufügen"
pass_prompt: "Dein Wallet-Passwort verschlüsselt und entsperrt jede Identität. Gib es ein, um fortzufahren."
working: "Arbeite…"
onboarding:
intro:
+2 -1
View File
@@ -693,7 +693,8 @@ goblin:
generate: "Generate new"
import: "Import nsec"
nsec_hint: "Paste an nsec"
add_switch: "Add and switch"
add_confirm: "Add"
pass_prompt: "Your wallet password encrypts and unlocks each identity. Enter it to continue."
working: "Working…"
onboarding:
intro:
+2 -1
View File
@@ -693,7 +693,8 @@ goblin:
generate: "En générer une"
import: "Importer un nsec"
nsec_hint: "Coller un nsec"
add_switch: "Ajouter et basculer"
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…"
onboarding:
intro:
+2 -1
View File
@@ -693,7 +693,8 @@ goblin:
generate: "Создать новую"
import: "Импорт nsec"
nsec_hint: "Вставьте nsec"
add_switch: "Добавить и переключить"
add_confirm: "Добавить"
pass_prompt: "Пароль кошелька шифрует и разблокирует каждую личность. Введите его, чтобы продолжить."
working: "Обработка…"
onboarding:
intro:
+2 -1
View File
@@ -693,7 +693,8 @@ goblin:
generate: "Yeni oluştur"
import: "Nsec içe aktar"
nsec_hint: "Bir nsec yapıştır"
add_switch: "Ekle ve geç"
add_confirm: "Ekle"
pass_prompt: "Cüzdan parolan her kimliği şifreler ve açar. Devam etmek için gir."
working: "Çalışıyor…"
onboarding:
intro:
+2 -1
View File
@@ -693,7 +693,8 @@ goblin:
generate: "新建"
import: "导入 nsec"
nsec_hint: "粘贴 nsec"
add_switch: "添加并切换"
add_confirm: "添加"
pass_prompt: "钱包密码用于加密和解锁每个身份。输入以继续。"
working: "处理中…"
onboarding:
intro:
+181 -76
View File
@@ -24,12 +24,13 @@ pub mod widgets;
use eframe::epaint::{CornerRadius, FontId, Stroke};
use egui::{Align, Color32, Layout, Margin, RichText, ScrollArea, Sense, Vec2};
use crate::gui::Colors;
use crate::gui::icons::{
ARROW_DOWN, ARROW_LEFT, CHECK, CLOCK, COPY, PROHIBIT, QR_CODE, SHARE, USER_CIRCLE, WALLET,
};
use crate::gui::platform::PlatformCallbacks;
use crate::gui::theme::{self, fonts};
use crate::gui::views::{Content, TextEdit, View};
use crate::gui::views::{Content, Modal, TextEdit, View};
use crate::wallet::Wallet;
use crate::wallet::types::WalletData;
@@ -315,23 +316,40 @@ impl Default for ImportState {
}
}
/// Identity switcher page state: the shared wallet password (kept only while the
/// page is open, cleared on leave), the add-identity sub-form, and the last
/// switch/add result. Holds no secret key material.
/// Id of the wallet-password modal used to unlock a switch or encrypt an add,
/// mirroring the wallet-open password modal.
const IDENTITY_PASS_MODAL: &str = "goblin_identity_pass_modal";
/// Which identity action the wallet-password modal is unlocking for.
#[derive(Clone)]
enum PendingIdentityAction {
/// Switch the active identity to this held one (pubkey hex).
Switch(String),
/// Add a new identity: generate a fresh key (`None`) or import an nsec.
Add(Option<String>),
}
/// Identity switcher page state: the add-identity sub-form, the wallet-password
/// modal buffer (the unlock step for BOTH switching and adding — there is no
/// inline password field on the page), and the last switch/add result. Holds no
/// secret key material at rest.
#[derive(Default)]
struct IdentitySwitchState {
/// Wallet password, used to unlock a target on switch and to encrypt a new
/// identity on add. Cleared when the page closes.
password: String,
/// Add-identity sub-form is open.
adding: bool,
/// Add mode is import-an-nsec (else generate a fresh one).
import: bool,
/// Pasted nsec when importing.
nsec: String,
/// The action the password modal is currently gating (switch or add).
pending: Option<PendingIdentityAction>,
/// Password typed into the modal; cleared as soon as it is consumed.
pass: String,
/// The modal password didn't unlock — show the wrong-password line.
wrong_pass: bool,
/// A background switch/add is running.
busy: bool,
/// Transient error to show (wrong password, invalid nsec, at capacity).
/// Transient error to show (invalid nsec, already held, at capacity).
error: String,
/// Result slot for the background switch/add worker: Ok(npub) or Err(message).
result: std::sync::Arc<std::sync::Mutex<Option<Result<String, String>>>>,
@@ -1607,9 +1625,12 @@ impl GoblinWalletView {
let seed = owner
.map(|i| i.pubkey_hex.clone())
.or_else(|| owning_hex.clone());
// The claimed name (no leading @, no domain — the project
// convention), else the truncated npub. Never a
// placeholder word.
let id_label = owner
.map(|i| {
i.nip05
i.name
.clone()
.unwrap_or_else(|| data::short_npub(&i.pubkey_hex))
})
@@ -4850,6 +4871,14 @@ impl GoblinWalletView {
}
}
// Wallet-password modal — the unlock step for BOTH switching and adding,
// mirroring the wallet-open password modal (dimmed backdrop, same buttons).
if Modal::opened() == Some(IDENTITY_PASS_MODAL) {
Modal::ui(ui.ctx(), cb, |ui, modal, cb| {
self.identity_pass_modal_content(ui, modal, wallet, cb);
});
}
let identities = wallet.nostr_identities();
let service = wallet.nostr_service();
let syncing = service
@@ -4925,46 +4954,38 @@ impl GoblinWalletView {
ui.add_space(12.0);
}
// The shared wallet password (unlocks a target on switch, encrypts a
// new identity on add).
w::field_well(ui, |ui| {
TextEdit::new(egui::Id::from("identity_switch_pass"))
.focus(false)
.hint_text(t!("goblin.settings.wallet_password"))
.password()
.text_color(t.surface_text)
.body()
.ui(ui, &mut self.identity_switch.password, cb);
});
ui.add_space(12.0);
// Held identities. Tap a non-active one to switch to it.
// Held identities. Tap a non-active one to switch to it (the
// wallet-password modal opens to unlock and decrypt its nsec).
w::kicker(ui, &t!("goblin.identities.held"));
ui.add_space(8.0);
let busy = self.identity_switch.busy;
let mut switch_to: Option<String> = None;
for id in &identities {
let display = id
.name
.clone()
.map(|n| format!("@{n}"))
.unwrap_or_else(|| id.label.clone());
// A claimed name (no leading @, per the project convention), else
// the 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 row = w::card(ui, |ui| {
ui.set_min_width(ui.available_width());
ui.horizontal(|ui| {
w::avatar_any(ui, &display, &id.pubkey_hex, 40.0, None);
w::avatar_any(ui, &title, &id.pubkey_hex, 40.0, None);
ui.add_space(12.0);
ui.vertical(|ui| {
ui.label(
RichText::new(&display)
RichText::new(&title)
.font(FontId::new(15.0, fonts::semibold()))
.color(t.surface_text),
);
ui.label(
RichText::new(data::short_npub(&id.pubkey_hex))
.font(FontId::new(12.0, fonts::regular()))
.color(t.surface_text_mute),
);
// Show the npub underneath only when the title is a name,
// so an unnamed identity isn't the npub twice.
if named {
ui.label(
RichText::new(&short)
.font(FontId::new(12.0, fonts::regular()))
.color(t.surface_text_mute),
);
}
});
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
if id.active {
@@ -5001,22 +5022,16 @@ impl GoblinWalletView {
ui.add_space(6.0);
}
// Tapping a held identity opens the password modal to unlock and
// switch to it (only the active nsec is ever decrypted).
if let Some(target) = switch_to {
if self.identity_switch.password.is_empty() {
self.identity_switch.error =
t!("goblin.identities.need_password").to_string();
} else {
self.identity_switch.error.clear();
self.identity_switch.busy = true;
self.identity_switch.awaiting_sync = true;
let password = self.identity_switch.password.clone();
let slot = self.identity_switch.result.clone();
let w = wallet.clone();
std::thread::spawn(move || {
let r = w.switch_nostr_identity(target, password);
*slot.lock().unwrap() = Some(r);
});
}
self.identity_switch.error.clear();
self.identity_switch.pass.clear();
self.identity_switch.wrong_pass = false;
self.identity_switch.pending = Some(PendingIdentityAction::Switch(target));
Modal::new(IDENTITY_PASS_MODAL)
.title(t!("goblin.identities.title"))
.show();
}
ui.add_space(8.0);
@@ -5080,10 +5095,11 @@ impl GoblinWalletView {
}
ui.add_space(10.0);
let import = self.identity_switch.import;
let nsec_ok =
!import || self.identity_switch.nsec.trim().starts_with("nsec1");
let armed = !self.identity_switch.password.is_empty()
&& nsec_ok && !self.identity_switch.busy;
// Generate is always ready; import needs a plausible nsec. The
// password is entered in the modal (opened on Add), not here.
let armed = (!import
|| self.identity_switch.nsec.trim().starts_with("nsec1"))
&& !self.identity_switch.busy;
ui.horizontal(|ui| {
let half = (ui.available_width() - 10.0) / 2.0;
ui.scope_builder(
@@ -5112,37 +5128,27 @@ impl GoblinWalletView {
ui.add_enabled_ui(armed, |ui| {
if w::big_action(
ui,
&t!("goblin.identities.add_switch"),
&t!("goblin.identities.add_confirm"),
false,
)
.clicked()
{
self.identity_switch.error.clear();
self.identity_switch.busy = true;
self.identity_switch.awaiting_sync = true;
// Open the password modal to encrypt+store the
// new identity. It is added WITHOUT switching;
// the user activates it later via tap → modal.
let import_nsec = if import {
Some(self.identity_switch.nsec.trim().to_string())
} else {
None
};
let password = self.identity_switch.password.clone();
let slot = self.identity_switch.result.clone();
let w = wallet.clone();
std::thread::spawn(move || {
// Add the identity, then switch to it so
// the user lands on the one they created.
let r = match w.add_nostr_identity(
import_nsec,
password.clone(),
) {
Ok(npub) => {
let hex = hex_of(&npub);
w.switch_nostr_identity(hex, password)
}
Err(e) => Err(e),
};
*slot.lock().unwrap() = Some(r);
});
self.identity_switch.error.clear();
self.identity_switch.pass.clear();
self.identity_switch.wrong_pass = false;
self.identity_switch.pending =
Some(PendingIdentityAction::Add(import_nsec));
Modal::new(IDENTITY_PASS_MODAL)
.title(t!("goblin.identities.add_title"))
.show();
}
});
},
@@ -5176,6 +5182,105 @@ impl GoblinWalletView {
});
}
/// Content of the wallet-password modal that gates BOTH switching to a held
/// identity (unlock + decrypt its nsec) and adding a new one (encrypt + store).
/// Mirrors the wallet-open password modal (open.rs): explanation, masked field,
/// wrong-password line, Cancel/Continue. A correct password is verified
/// synchronously — like the wallet-open modal — before the add/switch worker is
/// spawned, so a wrong password stays in the modal instead of failing later.
fn identity_pass_modal_content(
&mut self,
ui: &mut egui::Ui,
modal: &Modal,
wallet: &Wallet,
cb: &dyn PlatformCallbacks,
) {
let mut go = false;
let mut cancel = false;
ui.vertical_centered(|ui| {
ui.add_space(6.0);
ui.label(
RichText::new(t!("goblin.identities.pass_prompt"))
.size(16.0)
.color(Colors::gray()),
);
ui.add_space(10.0);
let mut field = TextEdit::new(egui::Id::from(modal.id).with("id_pass")).password();
field.ui(ui, &mut self.identity_switch.pass, cb);
if field.enter_pressed {
go = true;
}
if self.identity_switch.pass.is_empty() {
self.identity_switch.wrong_pass = false;
} else if self.identity_switch.wrong_pass {
ui.add_space(10.0);
ui.label(
RichText::new(t!("goblin.advanced.wrong_password"))
.size(16.0)
.color(Colors::red()),
);
}
ui.add_space(12.0);
});
ui.scope(|ui| {
ui.spacing_mut().item_spacing = egui::Vec2::new(8.0, 0.0);
ui.columns(2, |columns| {
columns[0].vertical_centered_justified(|ui| {
View::button(
ui,
t!("modal.cancel"),
Colors::white_or_black(false),
|| {
cancel = true;
},
);
});
columns[1].vertical_centered_justified(|ui| {
View::button(ui, t!("continue"), Colors::white_or_black(false), || {
go = true;
});
});
});
ui.add_space(6.0);
});
if cancel {
self.identity_switch.pass.clear();
self.identity_switch.pending = None;
self.identity_switch.wrong_pass = false;
Modal::close();
}
if go && !self.identity_switch.pass.is_empty() {
if !wallet.verify_nostr_password(&self.identity_switch.pass) {
self.identity_switch.wrong_pass = true;
} else if let Some(action) = self.identity_switch.pending.take() {
let password = std::mem::take(&mut self.identity_switch.pass);
self.identity_switch.wrong_pass = false;
self.identity_switch.error.clear();
self.identity_switch.busy = true;
let slot = self.identity_switch.result.clone();
let w = wallet.clone();
match action {
PendingIdentityAction::Switch(hex) => {
self.identity_switch.awaiting_sync = true;
std::thread::spawn(move || {
let r = w.switch_nostr_identity(hex, password);
*slot.lock().unwrap() = Some(r);
});
}
PendingIdentityAction::Add(import_nsec) => {
// Add only — never auto-switch into the new identity.
self.identity_switch.awaiting_sync = false;
std::thread::spawn(move || {
let r = w.add_nostr_identity(import_nsec, password);
*slot.lock().unwrap() = Some(r);
});
}
}
Modal::close();
}
}
}
/// Inline username-claim widget (availability check + registration).
fn claim_ui(&mut self, ui: &mut egui::Ui, wallet: &Wallet, cb: &dyn PlatformCallbacks) {
let t = theme::tokens();