1
0
forked from GRIN/grim

goblin: modal-lock the identity manage sheet, uniform paired buttons (Build 147)

MANAGE SHEET IS A TRUE MODAL: the pencil now opens the per-identity management
sheet through the GRIM Modal system (like the password modal), which dims and
locks the identity list behind it — switching and row taps are disabled until
it closes, killing the bug where the active identity could be switched while a
manage sheet was open for a different one. The modal is titled with the
identity it manages (display precedence: private tag, else bare name, else
truncated npub). The step-1 delete confirmation moved into a modal too (same
standard: a sheet acting on a specific item locks its background), also titled
with the identity, chaining into the wallet-password modal as before.

UNIFORM PAIRED BUTTONS: every paired action row in the identity sheets now
renders one consistent widget per row. In modals both halves are the GRIM
modal button (Cancel/Save; Cancel/Delete with Delete carrying only the red
color at the same size and shape). The in-page add panel's Cancel/Add row —
which mixed big_action_on_card with the 56px big_action inside 44px halves —
now uses big_action_on_card_ink for both, matching the Generate/Import toggle
(the reference pattern); the disabled Add state dims its ink. Audited the rest
of the new identity UI: the toggle row was already uniform and the remaining
buttons are full-width singles.
This commit is contained in:
2ro
2026-07-05 23:07:50 -04:00
parent 0c7a1404fa
commit 3237174c2e
+230 -174
View File
@@ -321,6 +321,15 @@ impl Default for ImportState {
/// mirroring the wallet-open password modal.
const IDENTITY_PASS_MODAL: &str = "goblin_identity_pass_modal";
/// Id of the per-identity management modal (rename / delete). A true modal —
/// the GRIM Modal system dims and locks the list behind it, so no switching or
/// row taps while it is open.
const IDENTITY_MANAGE_MODAL: &str = "goblin_identity_manage_modal";
/// Id of the step-1 delete-confirmation modal (danger text before the
/// wallet-password modal executes the delete). Also background-locking.
const IDENTITY_DELETE_MODAL: &str = "goblin_identity_delete_modal";
/// A password-gated identity action the modal executes. Switching no longer uses
/// the modal (it is instant and local); only these need the wallet password.
#[derive(Clone)]
@@ -4901,6 +4910,19 @@ impl GoblinWalletView {
self.identity_pass_modal_content(ui, modal, wallet, cb);
});
}
// Per-identity management modal (rename / delete): dims and locks the
// list, disabling switching and row taps until it closes.
if Modal::opened() == Some(IDENTITY_MANAGE_MODAL) {
Modal::ui(ui.ctx(), cb, |ui, _modal, cb| {
self.identity_manage_modal_content(ui, wallet, cb);
});
}
// Step-1 delete confirmation modal (danger text), also background-locking.
if Modal::opened() == Some(IDENTITY_DELETE_MODAL) {
Modal::ui(ui.ctx(), cb, |ui, _modal, _cb| {
self.identity_delete_modal_content(ui, wallet);
});
}
let identities = wallet.nostr_identities();
@@ -5027,185 +5049,27 @@ impl GoblinWalletView {
self.identity_switch.error = e;
}
}
// The pencil opens the management sheet, pre-filled with the tag.
// The pencil opens the management MODAL, pre-filled with the tag and
// titled with the identity it manages. The GRIM Modal dims and locks
// the list behind it, so no switching or row taps while it is open.
if let Some(target) = manage_target {
self.identity_switch.error.clear();
self.identity_switch.confirm_delete = None;
let display = identities
.iter()
.find(|i| i.pubkey_hex == target)
.map(|i| i.display())
.unwrap_or_else(|| data::short_npub(&target));
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
// identity, stating the delete is PERMANENT, and reminding the user
// to back it up first. Continuing opens the wallet-password modal
// (step 2), which actually executes the delete.
if let Some(target) = self.identity_switch.confirm_delete.clone() {
let display = identities
.iter()
.find(|i| i.pubkey_hex == target)
.map(|i| i.display())
.unwrap_or_else(|| data::short_npub(&target));
ui.add_space(8.0);
w::card(ui, |ui| {
ui.set_min_width(ui.available_width());
ui.label(
RichText::new(t!("goblin.identities.delete_title", name => display))
.font(FontId::new(15.0, fonts::semibold()))
.color(t.neg),
);
ui.add_space(6.0);
ui.label(
RichText::new(t!("goblin.identities.delete_blurb"))
.font(FontId::new(13.0, fonts::regular()))
.color(t.surface_text_dim),
);
ui.add_space(6.0);
ui.label(
RichText::new(t!("goblin.identities.delete_backup_note"))
.font(FontId::new(13.0, fonts::semibold()))
.color(t.neg),
);
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.confirm_delete = None;
}
},
);
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_on_card_ink(
ui,
&t!("goblin.identities.delete_confirm"),
t.neg,
)
.clicked()
{
// Step 2: the wallet-password modal executes it.
self.identity_switch.pass.clear();
self.identity_switch.wrong_pass = false;
self.identity_switch.pending =
Some(PendingPassAction::Delete(target.clone()));
Modal::new(IDENTITY_PASS_MODAL)
.position(ModalPosition::CenterTop)
.title(t!("goblin.identities.delete_short"))
.show();
}
},
);
});
});
Modal::new(IDENTITY_MANAGE_MODAL)
.position(ModalPosition::CenterTop)
.title(display)
.show();
}
ui.add_space(8.0);
@@ -5352,8 +5216,14 @@ impl GoblinWalletView {
Vec2::new(half, 44.0),
)),
|ui| {
if w::big_action_on_card(ui, &t!("goblin.settings.cancel"))
.clicked()
// Uniform paired buttons (the Generate/Import toggle
// pattern): same widget, size and shape for both halves.
if w::big_action_on_card_ink(
ui,
&t!("goblin.settings.cancel"),
t.surface_text,
)
.clicked()
{
self.identity_switch.adding = false;
self.identity_switch.import = false;
@@ -5371,10 +5241,10 @@ impl GoblinWalletView {
)),
|ui| {
ui.add_enabled_ui(armed, |ui| {
if w::big_action(
if w::big_action_on_card_ink(
ui,
&t!("goblin.identities.add_confirm"),
false,
if armed { t.pos } else { t.surface_text_mute },
)
.clicked()
{
@@ -5533,6 +5403,192 @@ impl GoblinWalletView {
}
}
/// Content of the per-identity management MODAL (title = the identity's
/// display name). Rename (the private, app-only tag — never published) on
/// top with uniform paired Cancel/Save buttons; Delete at the bottom,
/// separated and red, feeding the delete-confirmation modal. Being a GRIM
/// Modal, the identity list behind it is dimmed and locked.
fn identity_manage_modal_content(
&mut self,
ui: &mut egui::Ui,
wallet: &Wallet,
cb: &dyn PlatformCallbacks,
) {
let Some(target) = self.identity_switch.manage.clone() else {
Modal::close();
return;
};
ui.vertical_centered(|ui| {
ui.add_space(6.0);
ui.label(
RichText::new(t!("goblin.identities.tag_note"))
.size(16.0)
.color(Colors::gray()),
);
ui.add_space(10.0);
let mut field = TextEdit::new(egui::Id::from(IDENTITY_MANAGE_MODAL).with("tag"))
.hint_text(t!("goblin.identities.tag_hint"));
field.ui(ui, &mut self.identity_switch.tag_input, cb);
ui.add_space(12.0);
});
let mut save = false;
let mut cancel = false;
let mut delete = false;
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!("goblin.identities.tag_save"),
Colors::white_or_black(false),
|| {
save = true;
},
);
});
});
// Delete lives at the bottom, clearly apart from rename, red, same
// button shape. Only while more than one identity is held.
if wallet.nostr_identities().len() > 1 {
ui.add_space(10.0);
ui.vertical_centered_justified(|ui| {
View::colored_text_button(
ui,
t!("goblin.identities.delete_short").to_string(),
Colors::red(),
Colors::white_or_black(false),
|| {
delete = true;
},
);
});
}
ui.add_space(6.0);
});
if cancel {
self.identity_switch.manage = None;
self.identity_switch.tag_input.clear();
Modal::close();
}
if save {
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;
Modal::close();
}
if delete {
// Step 1 of the delete gate: the danger-confirmation modal.
self.identity_switch.manage = None;
self.identity_switch.confirm_delete = Some(target.clone());
let display = wallet
.nostr_identities()
.iter()
.find(|i| i.pubkey_hex == target)
.map(|i| i.display())
.unwrap_or_else(|| data::short_npub(&target));
Modal::close();
Modal::new(IDENTITY_DELETE_MODAL)
.position(ModalPosition::CenterTop)
.title(display)
.show();
}
}
/// Content of the step-1 delete-confirmation MODAL (title = the identity's
/// display name): states the removal is PERMANENT with a prominent back-up
/// reminder, then uniform paired Cancel/Delete buttons — Delete red, same
/// size and shape. Confirming opens the wallet-password modal (step 2).
fn identity_delete_modal_content(&mut self, ui: &mut egui::Ui, wallet: &Wallet) {
let Some(target) = self.identity_switch.confirm_delete.clone() else {
Modal::close();
return;
};
let display = wallet
.nostr_identities()
.iter()
.find(|i| i.pubkey_hex == target)
.map(|i| i.display())
.unwrap_or_else(|| data::short_npub(&target));
ui.vertical_centered(|ui| {
ui.add_space(6.0);
ui.label(
RichText::new(t!("goblin.identities.delete_title", name => display))
.size(17.0)
.color(Colors::red()),
);
ui.add_space(8.0);
ui.label(
RichText::new(t!("goblin.identities.delete_blurb"))
.size(15.0)
.color(Colors::gray()),
);
ui.add_space(8.0);
ui.label(
RichText::new(t!("goblin.identities.delete_backup_note"))
.size(15.0)
.color(Colors::red()),
);
ui.add_space(12.0);
});
let mut cancel = false;
let mut confirm = false;
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::colored_text_button(
ui,
t!("goblin.identities.delete_confirm").to_string(),
Colors::red(),
Colors::white_or_black(false),
|| {
confirm = true;
},
);
});
});
ui.add_space(6.0);
});
if cancel {
self.identity_switch.confirm_delete = None;
Modal::close();
}
if confirm {
// Step 2: the wallet-password modal executes the delete.
self.identity_switch.pass.clear();
self.identity_switch.wrong_pass = false;
self.identity_switch.pending = Some(PendingPassAction::Delete(target));
Modal::close();
Modal::new(IDENTITY_PASS_MODAL)
.position(ModalPosition::CenterTop)
.title(t!("goblin.identities.delete_short"))
.show();
}
}
/// 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();