From 3237174c2ed4d9df1f31024ab987e7e0ad37aeff Mon Sep 17 00:00:00 2001 From: 2ro <17595647+2ro@users.noreply.github.com> Date: Sun, 5 Jul 2026 23:07:50 -0400 Subject: [PATCH] goblin: modal-lock the identity manage sheet, uniform paired buttons (Build 147) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/gui/views/goblin/mod.rs | 404 ++++++++++++++++++++---------------- 1 file changed, 230 insertions(+), 174 deletions(-) diff --git a/src/gui/views/goblin/mod.rs b/src/gui/views/goblin/mod.rs index f4230220..43608f78 100644 --- a/src/gui/views/goblin/mod.rs +++ b/src/gui/views/goblin/mod.rs @@ -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();