1
0
forked from GRIN/grim

goblin: identity switcher UI (top-right swap glyph + its own page)

A swap glyph (phosphor arrows-left-right) sits top-right of the Settings
profile row and opens a dedicated identity switcher page. The page lists the
held identities with the active one checked, tap a non-active one to switch,
and adds a new identity by generating a fresh nsec or importing an existing
one, then switches to it. One shared wallet-password field unlocks a target
on switch and encrypts a new identity on add; it is cleared from memory when
the page closes. A syncing then "caught up" / "you were paid while away"
banner reflects the switch catch-up (the count honors hide-amounts). Android
back and the back button return to Settings.
This commit is contained in:
2ro
2026-07-05 16:11:31 -04:00
parent 2e2d96016e
commit 63d0cb7dee
+433 -8
View File
@@ -79,6 +79,8 @@ pub struct GoblinWalletView {
import_nsec: Option<ImportState>,
/// Inline "back up identity to a file" flow state.
backup: Option<BackupState>,
/// Identity switcher (one wallet, many nostr identities) page state.
identity_switch: IdentitySwitchState,
/// Inline "change name authority" editor state.
name_authority: Option<NameAuthorityState>,
/// Amount being entered on the Pay tab.
@@ -142,6 +144,8 @@ enum SettingsPage {
Slatepack,
Privacy,
Advanced,
/// The identity switcher: one wallet, one balance, many nostr identities.
Identities,
}
/// Inline state for the Advanced (wallet-recovery) settings page: the
@@ -206,6 +210,7 @@ impl Default for GoblinWalletView {
rotate: None,
import_nsec: None,
backup: None,
identity_switch: IdentitySwitchState::default(),
name_authority: None,
pay_amount: String::new(),
pay_shake: None,
@@ -284,6 +289,31 @@ 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.
#[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,
/// A background switch/add is running.
busy: bool,
/// Transient error to show (wrong password, invalid nsec, 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>>>>,
/// True while the last completed action was a switch (drives the syncing
/// banner), false for a plain add.
awaiting_sync: bool,
}
/// Inline "change name authority" (federation) editor state.
#[derive(Default)]
struct NameAuthorityState {
@@ -396,6 +426,11 @@ impl GoblinWalletView {
return false;
}
if self.tab == Tab::Me && self.settings_page != SettingsPage::Main {
// Don't leave the wallet password sitting in memory after leaving the
// identity switcher.
if self.settings_page == SettingsPage::Identities {
self.identity_switch = IdentitySwitchState::default();
}
self.settings_page = SettingsPage::Main;
return false;
}
@@ -431,6 +466,7 @@ impl GoblinWalletView {
self.request_amount = None;
self.settings_page = SettingsPage::Main;
self.advanced = AdvancedState::default();
self.identity_switch = IdentitySwitchState::default();
}
// A pending payment deep link (`goblin:` / `nostr:` pay URI, routed here
@@ -2435,6 +2471,7 @@ impl GoblinWalletView {
SettingsPage::Slatepack => return self.slatepack_ui(ui, wallet, cb),
SettingsPage::Privacy => return self.privacy_ui(ui),
SettingsPage::Advanced => return self.advanced_ui(ui, wallet, cb),
SettingsPage::Identities => return self.identities_ui(ui, wallet, cb),
SettingsPage::Main => {}
}
ui.add_space(8.0);
@@ -2480,6 +2517,10 @@ impl GoblinWalletView {
.as_deref()
.and_then(|_| self.handle_tex(ui.ctx(), wallet, &handle));
// Set inside the (deeply nested) card closure when the identity-switcher
// glyph is tapped; applied after the card so the closures don't need a
// mutable borrow of `self`.
let mut open_identities = false;
w::card(ui, |ui| {
ui.set_min_width(ui.available_width());
ui.horizontal(|ui| {
@@ -2537,12 +2578,32 @@ impl GoblinWalletView {
.request_repaint_after(std::time::Duration::from_millis(600));
}
});
// Update-available badge, pinned to the right of the profile
// panel. Shown only when the release check found a newer build
// (reuses the AppConfig::app_update state the wallet list uses);
// tapping it opens the release download page.
if let Some(update) = crate::AppConfig::app_update() {
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
// Trailing (top-right) controls of the identity row: the identity
// SWITCHER (one wallet, many nostr identities) and, when present,
// the update-available badge. Right-aligned into the space to the
// right of the name/avatar; the switcher sits in the far corner.
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
let (rect, resp) = ui.allocate_exact_size(Vec2::splat(36.0), Sense::click());
ui.painter().circle_filled(rect.center(), 18.0, t.surface2);
ui.painter().text(
rect.center(),
egui::Align2::CENTER_CENTER,
crate::gui::icons::ARROWS_LEFT_RIGHT,
FontId::new(17.0, fonts::regular()),
theme::ink_for(t.surface2),
);
if resp
.on_hover_cursor(egui::CursorIcon::PointingHand)
.on_hover_text(t!("goblin.identities.switch_hint").to_string())
.clicked()
{
open_identities = true;
}
// Update-available badge to the LEFT of the switcher. Shown only
// when the release check found a newer build; tapping it opens
// the release download page.
if let Some(update) = crate::AppConfig::app_update() {
ui.add_space(6.0);
let (rect, resp) =
ui.allocate_exact_size(Vec2::splat(36.0), Sense::click());
ui.painter().circle_filled(rect.center(), 18.0, t.accent);
@@ -2560,10 +2621,14 @@ impl GoblinWalletView {
{
open_url(ui, &update.url);
}
});
}
}
});
});
});
if open_identities {
self.settings_page = SettingsPage::Identities;
self.identity_switch = IdentitySwitchState::default();
}
ui.add_space(16.0);
// Mark the scroll boundary: rows clipping under the pinned profile
@@ -4659,6 +4724,366 @@ impl GoblinWalletView {
}
}
/// The identity switcher page: one wallet, one grin balance, many nostr
/// identities. Lists the held identities (tap to make one active), and adds a
/// new one (generate a fresh nsec or import an existing one). Switching runs a
/// catch-up so payments that arrived while an identity was dormant land in the
/// single shared balance; the syncing / "you were paid while away" state shows
/// here. The wallet password (entered once on this page) unlocks a target on
/// switch and encrypts a new identity on add — every held nsec is stored the
/// same way: its own NIP-49 ncryptsec under the wallet password.
fn identities_ui(&mut self, ui: &mut egui::Ui, wallet: &Wallet, cb: &dyn PlatformCallbacks) {
let t = theme::tokens();
if self.sub_header(ui, &t!("goblin.identities.title")) {
self.settings_page = SettingsPage::Main;
self.identity_switch = IdentitySwitchState::default();
return;
}
// Poll the background switch/add worker.
if self.identity_switch.busy
&& let Some(res) = self.identity_switch.result.lock().unwrap().take()
{
self.identity_switch.busy = false;
match res {
Ok(_) => {
self.identity_switch.error.clear();
self.identity_switch.adding = false;
self.identity_switch.import = false;
self.identity_switch.nsec.clear();
}
Err(e) => {
self.identity_switch.error = e;
self.identity_switch.awaiting_sync = false;
}
}
}
let identities = wallet.nostr_identities();
let service = wallet.nostr_service();
let syncing = service
.as_ref()
.map(|s| s.is_switch_syncing())
.unwrap_or(false);
let paid = service.as_ref().map(|s| s.switch_received()).unwrap_or(0);
ScrollArea::vertical()
.auto_shrink([false; 2])
.scroll_bar_visibility(egui::scroll_area::ScrollBarVisibility::AlwaysHidden)
.show(ui, |ui| {
ui.label(
RichText::new(t!("goblin.identities.blurb"))
.font(FontId::new(13.5, fonts::regular()))
.color(t.text_dim),
);
ui.add_space(8.0);
ui.label(
RichText::new(t!("goblin.identities.privacy_note"))
.font(FontId::new(12.0, fonts::regular()))
.color(t.text_mute),
);
ui.add_space(14.0);
// Syncing / "you were paid while away" banner after a switch.
if self.identity_switch.awaiting_sync {
let (icon, line, color) = if syncing {
(
crate::gui::icons::ARROWS_CLOCKWISE,
t!("goblin.identities.syncing").to_string(),
t.surface_text_dim,
)
} else if paid > 0 {
let msg = if crate::AppConfig::hide_amounts() {
t!("goblin.identities.paid_while_away").to_string()
} else {
t!("goblin.identities.paid_while_away_n", n => paid.to_string())
.to_string()
};
(crate::gui::icons::CHECK_CIRCLE, msg, t.pos)
} else {
(
crate::gui::icons::CHECK_CIRCLE,
t!("goblin.identities.caught_up").to_string(),
t.surface_text_dim,
)
};
w::card(ui, |ui| {
ui.set_min_width(ui.available_width());
ui.horizontal(|ui| {
if syncing {
View::small_loading_spinner(ui);
} else {
ui.label(
RichText::new(icon)
.font(FontId::new(16.0, fonts::regular()))
.color(color),
);
}
ui.add_space(8.0);
ui.label(
RichText::new(line)
.font(FontId::new(13.5, fonts::semibold()))
.color(color),
);
});
});
if syncing {
ui.ctx()
.request_repaint_after(std::time::Duration::from_millis(500));
}
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.
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());
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);
ui.add_space(12.0);
ui.vertical(|ui| {
ui.label(
RichText::new(&display)
.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),
);
});
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
if id.active {
ui.label(
RichText::new(crate::gui::icons::CHECK_CIRCLE)
.font(FontId::new(18.0, fonts::regular()))
.color(t.pos),
);
} else {
ui.label(
RichText::new(crate::gui::icons::ARROWS_LEFT_RIGHT)
.font(FontId::new(16.0, fonts::regular()))
.color(t.surface_text_dim),
);
}
});
})
.response
.rect
});
if !id.active && !busy {
let hit = ui.interact(
row,
egui::Id::new(("id_switch", id.pubkey_hex.as_str())),
Sense::click(),
);
if hit
.on_hover_cursor(egui::CursorIcon::PointingHand)
.clicked()
{
switch_to = Some(id.pubkey_hex.clone());
}
}
ui.add_space(6.0);
}
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);
});
}
}
ui.add_space(8.0);
// Add-identity section.
if !self.identity_switch.adding {
if w::big_action(ui, &t!("goblin.identities.add"), false).clicked() {
self.identity_switch.adding = true;
self.identity_switch.error.clear();
}
} else {
w::card(ui, |ui| {
ui.set_min_width(ui.available_width());
ui.label(
RichText::new(t!("goblin.identities.add_title"))
.font(FontId::new(15.0, fonts::semibold()))
.color(t.surface_text),
);
ui.add_space(8.0);
// Generate vs import toggle.
ui.horizontal(|ui| {
if w::big_action_on_card_ink(
ui,
&t!("goblin.identities.generate"),
if self.identity_switch.import {
t.surface_text
} else {
t.pos
},
)
.clicked()
{
self.identity_switch.import = false;
}
ui.add_space(8.0);
if w::big_action_on_card_ink(
ui,
&t!("goblin.identities.import"),
if self.identity_switch.import {
t.pos
} else {
t.surface_text
},
)
.clicked()
{
self.identity_switch.import = true;
}
});
if self.identity_switch.import {
ui.add_space(8.0);
w::field_well(ui, |ui| {
TextEdit::new(egui::Id::from("identity_add_nsec"))
.focus(false)
.hint_text(t!("goblin.identities.nsec_hint"))
.password()
.text_color(t.surface_text)
.body()
.ui(ui, &mut self.identity_switch.nsec, cb);
});
}
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;
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.adding = false;
self.identity_switch.import = false;
self.identity_switch.nsec.clear();
self.identity_switch.error.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| {
ui.add_enabled_ui(armed, |ui| {
if w::big_action(
ui,
&t!("goblin.identities.add_switch"),
false,
)
.clicked()
{
self.identity_switch.error.clear();
self.identity_switch.busy = true;
self.identity_switch.awaiting_sync = true;
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);
});
}
});
},
);
});
});
}
if self.identity_switch.busy {
ui.add_space(10.0);
ui.horizontal(|ui| {
View::small_loading_spinner(ui);
ui.add_space(8.0);
ui.label(
RichText::new(t!("goblin.identities.working"))
.font(FontId::new(13.0, fonts::regular()))
.color(t.surface_text_dim),
);
});
ui.ctx().request_repaint();
}
if !self.identity_switch.error.is_empty() {
ui.add_space(8.0);
ui.label(
RichText::new(&self.identity_switch.error)
.font(FontId::new(12.5, fonts::regular()))
.color(t.neg),
);
}
ui.add_space(24.0);
});
}
/// 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();