Files
grim/src/gui/views/goblin/mod.rs
T
2ro bdedcba498 goblin: appearance settings additions for Build 140
Three settings-surface additions:

- Hide amounts: a toggle (same switch widget as the incoming-requests
  toggle) under the amount-privacy rows, persisted in the app config
  (serde-default off so old configs load). When on, the received-payment
  notification masks the numeric grin with dots.

- Language: a picker under Appearance beside the theme row. Lists the six
  shipped locales in their own names; tapping one switches and persists the
  active locale.

- Update available: a badge pinned to the right of the settings profile
  panel, shown only when the release check has found a newer build (reuses
  the existing AppConfig::app_update state); tapping it opens the release
  download page.

New user-facing strings added to all six locales.
2026-07-05 13:11:23 -04:00

5440 lines
166 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Copyright 2026 The Goblin Developers
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//! The Goblin payment-app-style wallet surface for an open wallet.
pub mod avatars;
pub mod data;
pub mod identicon;
pub mod onboarding;
pub mod send;
pub mod widgets;
use eframe::epaint::{CornerRadius, FontId, Stroke};
use egui::{Align, Color32, Layout, Margin, RichText, ScrollArea, Sense, Vec2};
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::wallet::Wallet;
use crate::wallet::types::WalletData;
use self::data::{ActivityItem, activity_items, news_latest, recent_peers, split_urls};
use self::send::SendFlow;
use self::widgets as w;
/// Goblin navigation tabs. The mobile bar shows Home / Pay / Activity;
/// Receive and Me stay reachable (Pay's Request action, header avatar,
/// desktop sidebar).
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum Tab {
Home,
Pay,
Activity,
Receive,
Me,
}
/// Goblin wallet content view.
pub struct GoblinWalletView {
tab: Tab,
send: Option<SendFlow>,
/// Open transaction receipt by tx id (full-surface overlay).
receipt: Option<u32>,
/// Open contact profile by npub hex (full-surface overlay).
profile: Option<String>,
/// Request being reviewed before payment (full-surface hold-to-accept
/// overlay); approving a request goes through this, not a one-tap pay.
approve_review: Option<crate::nostr::PaymentRequest>,
/// Hold-to-accept gesture state for the request-review screen.
approve_hold: w::HoldToSend,
/// Amount the last CalculateFee was requested for on the review screen.
approve_fee_for: Option<u64>,
/// Request ids already approved this session (double-tap guard).
approving: std::collections::HashSet<String>,
/// Why the last approve failed (e.g. funds confirming), shown above the
/// request list; cleared when a new approve is attempted.
request_error: Option<String>,
/// Identifier of the wallet this view is bound to (reset on change).
wallet_id: Option<String>,
/// Inline username-claim state for the Me tab.
claim: Option<ClaimState>,
/// Inline key-rotation state for the Me tab.
rotate: Option<RotateState>,
/// Inline nsec-import state for the Me tab.
import_nsec: Option<ImportState>,
/// Inline "back up identity to a file" flow state.
backup: Option<BackupState>,
/// Inline "change name authority" editor state.
name_authority: Option<NameAuthorityState>,
/// Amount being entered on the Pay tab.
pay_amount: String,
/// When set, the over-balance "no" animation is playing: the start time (egui
/// input seconds) the user pressed Pay without enough funds. Drives a brief
/// red flash + horizontal shake of the amount, then clears itself.
pay_shake: Option<f64>,
/// Amount being requested, shown on the Receive screen.
request_amount: Option<String>,
/// Sub-page open inside the Settings tab.
settings_page: SettingsPage,
/// Active GRIM integrated-node tab (Info/Metrics/Mining/Settings), hosted
/// inside Goblin chrome — GRIM's dual-panel shell is never rendered.
node_tab: Box<dyn crate::gui::views::network::types::NodeTab>,
/// Where the integrated-node page returns to (it has two entry points:
/// the Settings screen and the Node screen).
node_tab_back: SettingsPage,
/// Inline state for the Advanced settings page (recovery/repair/delete).
advanced: AdvancedState,
/// One-shot signal to the wallet host: deselect this wallet (return to the
/// chooser) without locking it, so another can be picked. Consumed by
/// [`WalletContent::take_switch_request`].
switch_requested: bool,
/// Inputs for adding an external node connection.
node_url_input: String,
node_secret_input: String,
/// Relay list being edited and the add-relay input.
relay_edit: Vec<String>,
relay_input: String,
/// Transient "Copied" feedback on the Receive buttons: which one
/// (0 = npub, 1 = grin address) and when it was clicked.
receive_copied: Option<(u8, std::time::Instant)>,
/// Avatar texture layer (disk cache + background fetches).
avatars: avatars::AvatarTextures,
/// Manual slatepack page state (GRIM-native send/receive fallback).
slatepack: SlatepackManual,
/// Receipt "Cancel payment" tap-twice confirm: the tx_id awaiting a second
/// confirming tap (cleared when another receipt opens or it's fired).
cancel_confirm: Option<u32>,
/// Outcome of the last manual cancel, shown transiently on the receipt.
cancel_msg: Option<(crate::nostr::CancelOutcome, std::time::Instant)>,
/// Transient "Copied" flash for the settings backup card (npub/keys).
copy_flash: Option<std::time::Instant>,
/// "Wipe payment history" tap-twice confirm: armed after the first tap,
/// wipes on the second (cleared once fired).
wipe_confirm: bool,
}
/// Sub-pages of the Settings tab.
#[derive(Clone, Copy, PartialEq, Eq)]
enum SettingsPage {
Main,
Node,
/// GRIM's integrated-node tabs, embedded in Goblin chrome.
IntegratedNode,
Relays,
Nips,
Pairing,
Language,
Slatepack,
Privacy,
Advanced,
}
/// Inline state for the Advanced (wallet-recovery) settings page: the
/// recovery-phrase reveal and the two-step confirms for destructive actions.
#[derive(Default)]
struct AdvancedState {
/// Password typed to reveal the grin recovery phrase.
reveal_pass: String,
/// The revealed seed words, held only while shown (cleared on hide/back).
revealed: Option<String>,
/// Set when the entered password didn't decrypt the seed.
wrong_pass: bool,
/// Password typed to reveal the nostr secret key (nsec).
nsec_pass: String,
/// The revealed nsec, held only while shown (cleared on hide/back).
nsec_revealed: Option<String>,
/// Set when the entered password didn't unlock the nostr identity.
nsec_wrong: bool,
/// Whether the nsec QR is expanded (so it can be scanned to log in
/// elsewhere, e.g. magick.market's private-key login).
nsec_qr: bool,
/// Armed "really restore?" confirm.
confirm_restore: bool,
/// Armed "really repair?" confirm (repair takes a few minutes).
confirm_repair: bool,
/// Armed "really delete?" confirm.
confirm_delete: bool,
}
/// Inputs and last result for the manual slatepack page (GRIM's native flow,
/// surfaced as an advanced fallback under Settings → Wallet).
#[derive(Default)]
struct SlatepackManual {
/// Pasted incoming slatepack to receive/finalize.
paste: String,
/// Outgoing amount (human grin) for a manually-created payment.
amount: String,
/// Optional recipient slatepack address for the outgoing payment.
address: String,
/// Produced slatepack text to copy and hand over (send, or a receive reply).
result: String,
/// Transient status line (e.g. "Finalizing…") under the actions.
status: Option<String>,
/// Last error to show in the danger color.
error: Option<String>,
}
impl Default for GoblinWalletView {
fn default() -> Self {
Self {
tab: Tab::Home,
send: None,
receipt: None,
profile: None,
approve_review: None,
approve_hold: w::HoldToSend::default(),
approve_fee_for: None,
approving: std::collections::HashSet::new(),
request_error: None,
wallet_id: None,
claim: None,
rotate: None,
import_nsec: None,
backup: None,
name_authority: None,
pay_amount: String::new(),
pay_shake: None,
request_amount: None,
settings_page: SettingsPage::Main,
node_tab: Box::new(crate::gui::views::network::NetworkNode),
node_tab_back: SettingsPage::Main,
advanced: AdvancedState::default(),
switch_requested: false,
node_url_input: String::new(),
node_secret_input: String::new(),
relay_edit: Vec::new(),
relay_input: String::new(),
receive_copied: None,
slatepack: SlatepackManual::default(),
avatars: avatars::AvatarTextures::default(),
cancel_confirm: None,
cancel_msg: None,
copy_flash: None,
wipe_confirm: false,
}
}
}
/// Inline key-rotation flow state (two warnings + typed confirmation).
struct RotateState {
/// 1 = warning, 2 = confirm (RESET + password), 3 = working,
/// 4 = done (new npub), 5 = error (message).
stage: u8,
reset_input: String,
password: String,
new_npub: String,
error: String,
result: std::sync::Arc<std::sync::Mutex<Option<Result<String, String>>>>,
}
impl Default for RotateState {
fn default() -> Self {
Self {
stage: 1,
reset_input: String::new(),
password: String::new(),
new_npub: String::new(),
error: String::new(),
result: std::sync::Arc::new(std::sync::Mutex::new(None)),
}
}
}
/// Inline nsec-import flow state (restore path for the random-key model).
struct ImportState {
/// 1 = form, 3 = working, 4 = done, 5 = error.
stage: u8,
nsec: String,
password: String,
backup_password: String,
new_npub: String,
error: String,
/// A native file pick is in flight (Android returns the path asynchronously).
picking: bool,
result: std::sync::Arc<std::sync::Mutex<Option<Result<String, String>>>>,
}
impl Default for ImportState {
fn default() -> Self {
Self {
stage: 1,
nsec: String::new(),
password: String::new(),
backup_password: String::new(),
new_npub: String::new(),
error: String::new(),
picking: false,
result: std::sync::Arc::new(std::sync::Mutex::new(None)),
}
}
}
/// Inline "change name authority" (federation) editor state.
#[derive(Default)]
struct NameAuthorityState {
/// Server URL being typed (e.g. https://other.example).
input: String,
/// Validation error to show.
error: Option<String>,
}
/// Inline "back up identity to a file" flow state.
#[derive(Default)]
struct BackupState {
/// Wallet password to unseal the identity for the backup.
password: String,
/// Error to show (wrong password / write failed).
error: Option<String>,
/// The backup file was created.
done: bool,
}
/// Inline username-claim widget state.
struct ClaimState {
input: String,
checking: bool,
available: Option<bool>,
result: std::sync::Arc<std::sync::Mutex<Option<ClaimMsg>>>,
message: Option<String>,
/// The are-you-sure gate before releasing a username.
confirm_release: bool,
}
enum ClaimMsg {
Availability(crate::nostr::nip05::Availability),
Registered(String),
Released,
Error(String),
}
/// Map an availability probe to user-facing state: `None` availability
/// means the check itself failed — never present that as "Taken".
fn availability_feedback(avail: crate::nostr::nip05::Availability) -> (Option<bool>, String) {
use crate::nostr::nip05::Availability::*;
match avail {
Available => (
Some(true),
t!("goblin.settings.avail_available").to_string(),
),
Taken => (Some(false), t!("goblin.settings.avail_taken").to_string()),
Reserved => (
Some(false),
t!("goblin.settings.avail_reserved").to_string(),
),
Invalid => (Some(false), t!("goblin.settings.avail_invalid").to_string()),
Quarantined => (
Some(false),
t!("goblin.settings.avail_quarantined").to_string(),
),
Unknown => (None, t!("goblin.settings.avail_unknown").to_string()),
}
}
impl Default for ClaimState {
fn default() -> Self {
Self {
input: String::new(),
checking: false,
available: None,
result: std::sync::Arc::new(std::sync::Mutex::new(None)),
message: None,
confirm_release: false,
}
}
}
impl GoblinWalletView {
/// Whether an overlay flow (send) is active.
pub fn overlay_active(&self) -> bool {
self.send.is_some()
}
/// Take the pending "switch wallet" request (set by the Settings button),
/// resetting it. The host deselects the wallet when this returns true.
pub fn take_switch_request(&mut self) -> bool {
std::mem::take(&mut self.switch_requested)
}
/// Whether back navigation has anything left to consume: an overlay, a
/// settings sub-page, or a non-Home tab (back routes to Home). Mirrors
/// [`Self::on_back`], so the host never falls back to the wallet chooser.
pub fn can_back(&self) -> bool {
self.receipt.is_some()
|| self.profile.is_some()
|| self.send.is_some()
|| (self.tab == Tab::Me && self.settings_page != SettingsPage::Main)
|| self.tab != Tab::Home
}
/// Handle a back navigation; returns true if not consumed.
pub fn on_back(&mut self) -> bool {
if self.receipt.is_some() {
self.receipt = None;
return false;
}
if self.profile.is_some() {
self.profile = None;
return false;
}
if self.send.is_some() {
self.send = None;
return false;
}
if self.tab == Tab::Me && self.settings_page != SettingsPage::Main {
self.settings_page = SettingsPage::Main;
return false;
}
if self.tab != Tab::Home {
self.tab = Tab::Home;
return false;
}
true
}
/// Render the full Goblin surface for an open wallet.
pub fn ui(&mut self, ui: &mut egui::Ui, wallet: &Wallet, cb: &dyn PlatformCallbacks) {
let t = theme::tokens();
// Reset transient UI state when the bound wallet changes, so a
// half-filled send or claim never leaks across a wallet switch.
let id = wallet.identifier();
if self.wallet_id.as_deref() != Some(id.as_str()) {
self.wallet_id = Some(id);
self.tab = Tab::Home;
self.send = None;
self.receipt = None;
self.profile = None;
self.claim = None;
self.rotate = None;
self.import_nsec = None;
self.approve_review = None;
self.approve_hold = w::HoldToSend::default();
self.approve_fee_for = None;
self.approving.clear();
self.request_error = None;
self.pay_amount.clear();
self.request_amount = None;
self.settings_page = SettingsPage::Main;
self.advanced = AdvancedState::default();
}
// A pending payment deep link (`goblin:` / `nostr:` pay URI, routed here
// from an OS launch/open) opens a prefilled send-review flow — the exact
// destination a scanned checkout QR lands on.
if let Some(uri) = crate::take_pending_pay_uri() {
let now = ui.input(|i| i.time);
self.send = Some(SendFlow::from_deeplink(&uri, wallet, now));
}
// Send flow takes the full surface when active.
if let Some(send) = &mut self.send {
let done = send.ui(ui, wallet, cb, &mut self.avatars);
if done {
let receipt_npub = send.receipt_npub.clone();
self.send = None;
// "Receipt" on the success screen opens the latest tx with them.
if let Some(npub) = receipt_npub {
if let Some(item) = data::history_with(wallet, &npub).into_iter().next() {
self.receipt = Some(item.tx_id);
}
}
}
return;
}
// Receipt + contact profile are full-surface overlays as well.
if let Some(tx_id) = self.receipt {
if self.receipt_ui(ui, wallet, tx_id) {
self.receipt = None;
}
return;
}
if let Some(npub) = self.profile.clone() {
if self.profile_ui(ui, wallet, cb, &npub) {
self.profile = None;
}
return;
}
// Approving a request opens a full-surface review with hold-to-accept.
if self.approve_review.is_some() {
if self.approve_review_ui(ui, wallet) {
self.approve_review = None;
self.approve_fee_for = None;
}
return;
}
// Desktop (wide) shows a left sidebar (shell B); narrow/mobile shows a
// bottom tab bar (shell A). Both drive the same Tab state and screens.
let wide_desktop = View::is_desktop() && ui.available_width() >= 720.0;
if wide_desktop {
egui::SidePanel::left("goblin_sidebar")
.resizable(false)
.exact_width(244.0)
.frame(egui::Frame {
fill: t.bg,
stroke: Stroke::new(1.0, t.line),
inner_margin: Margin {
left: (View::far_left_inset_margin(ui) + 16.0) as i8,
right: 16,
top: (View::get_top_inset() + 28.0) as i8,
bottom: 20,
},
..Default::default()
})
.show_inside(ui, |ui| {
self.sidebar_ui(ui, wallet);
});
} else {
let bottom_inset = View::get_bottom_inset();
egui::TopBottomPanel::bottom("goblin_tabs")
.frame(egui::Frame {
// Bottom strip goes yellow on the Pay surface, like the body.
fill: if self.tab == Tab::Pay {
theme::YELLOW.bg
} else {
t.bg
},
inner_margin: Margin {
left: 16,
right: 16,
top: 10,
bottom: (12.0 + bottom_inset) as i8,
},
..Default::default()
})
.show_inside(ui, |ui| {
self.tab_bar_ui(ui, wallet);
});
}
// Central content. The Pay tab is painted in the yellow theme (Cash
// App-style brand surface) regardless of the user's chosen theme: a
// scoped override held across the whole panel so its fill AND every
// widget inside pick up the yellow tokens together.
let pay = self.tab == Tab::Pay;
let _pay_theme = pay.then(|| theme::scoped(theme::ThemeKind::Yellow));
// Bright yellow top → dark status-bar icons (see status_bar_white_icons).
theme::set_status_surface_yellow(pay);
let panel_fill = if pay { theme::YELLOW.bg } else { t.bg };
egui::CentralPanel::default()
.frame(egui::Frame {
fill: panel_fill,
inner_margin: Margin {
left: (View::far_left_inset_margin(ui) + 20.0) as i8,
right: (View::get_right_inset() + 20.0) as i8,
top: (View::get_top_inset() + 8.0) as i8,
bottom: 0,
},
..Default::default()
})
.show_inside(ui, |ui| {
// Desktop Home fills the window (up to a readable max) instead of
// sitting in the narrow 1.2x column with dead space around it — the news
// panel and the rest of home then use the available width. Every other
// tab, and all of mobile, keeps the original narrow column.
let col_width = if wide_desktop && self.tab == Tab::Home {
Content::SIDE_PANEL_WIDTH * 2.4
} else {
Content::SIDE_PANEL_WIDTH * 1.2
};
w::centered_column(ui, col_width, |ui| match self.tab {
Tab::Home => self.home_ui(ui, wallet, cb, wide_desktop),
Tab::Pay => self.pay_ui(ui, wallet, cb),
Tab::Activity => self.activity_ui(ui, wallet, cb),
Tab::Receive => self.receive_ui(ui, wallet, cb),
Tab::Me => self.me_ui(ui, wallet, cb),
});
});
}
/// 3-item bar: Wallet · Pay (center ツ) · Activity. A floating pill on most
/// surfaces; chromeless (no pill/shadow, dark items on yellow) on the Pay tab.
fn tab_bar_ui(&mut self, ui: &mut egui::Ui, wallet: &Wallet) {
let t = theme::tokens();
let pay = self.tab == Tab::Pay;
let yt = &theme::YELLOW;
let has_requests = wallet
.nostr_service()
.map(|s| !s.store.pending_requests().is_empty())
.unwrap_or(false);
let bar_h = 64.0;
let bar_w = ui.available_width().min(340.0);
let margin = ((ui.available_width() - bar_w) / 2.0).max(0.0);
ui.horizontal(|ui| {
ui.add_space(margin);
let (bar_rect, _) = ui.allocate_exact_size(Vec2::new(bar_w, bar_h), Sense::hover());
// Soft shadow + floating pill — omitted on the Pay surface.
if !pay {
let shadow = bar_rect.translate(Vec2::new(0.0, 3.0)).expand(2.0);
ui.painter().rect_filled(
shadow,
CornerRadius::same(34),
Color32::from_black_alpha(70),
);
ui.painter().rect(
bar_rect,
CornerRadius::same(32),
t.surface,
Stroke::new(1.0, t.line),
egui::StrokeKind::Inside,
);
}
let cell = bar_w / 3.0;
let tabs = [
(Tab::Home, Some(WALLET), false),
(Tab::Pay, None, false),
(Tab::Activity, Some(CLOCK), has_requests),
];
for (i, (tab, icon, badge)) in tabs.into_iter().enumerate() {
let rect = egui::Rect::from_min_size(
bar_rect.min + Vec2::new(i as f32 * cell, 0.0),
Vec2::new(cell, bar_h),
);
let resp = ui.interact(rect, ui.id().with(("goblin_tab", i)), Sense::click());
let active = self.tab == tab;
match icon {
Some(icon) => {
// Icon-only; the active tab gets a circular highlight (only
// where there's a pill — not on the chromeless Pay surface).
if active && !pay {
ui.painter().circle_filled(rect.center(), 22.0, t.surface2);
}
let color = if pay {
if active { yt.text } else { yt.text_dim }
} else if active {
t.surface_text
} else {
t.surface_text_mute
};
ui.painter().text(
rect.center(),
egui::Align2::CENTER_CENTER,
icon,
FontId::new(23.0, fonts::regular()),
color,
);
}
None => {
// Center Pay action: accent ツ puck off the Pay surface; a
// chromeless dark ツ on it (payment-app style).
if !pay {
let grow = if active || resp.hovered() { 1.0 } else { 0.0 };
ui.painter().circle_filled(
rect.center(),
24.0 + grow,
if resp.hovered() {
t.accent_dark
} else {
t.accent
},
);
}
ui.painter().text(
rect.center(),
egui::Align2::CENTER_CENTER,
w::TSU,
// Noto Sans JP's clean ツ — the Pay puck mark, only here.
FontId::new(31.0, egui::FontFamily::Name("noto-tsu".into())),
if pay { yt.text } else { t.accent_ink },
);
}
}
if badge {
ui.painter()
.circle_filled(rect.center() + Vec2::new(13.0, -13.0), 4.5, t.neg);
}
if resp.clicked() {
self.tab = tab;
}
}
});
}
/// Desktop left sidebar: wordmark, nav items, profile card.
fn sidebar_ui(&mut self, ui: &mut egui::Ui, wallet: &Wallet) {
let t = theme::tokens();
// Wordmark.
ui.horizontal(|ui| {
widgets_logo(ui);
ui.add_space(8.0);
ui.label(
RichText::new("goblin")
.font(FontId::new(20.0, fonts::bold()))
.color(t.text),
);
});
ui.add_space(28.0);
let has_requests = wallet
.nostr_service()
.map(|s| !s.store.pending_requests().is_empty())
.unwrap_or(false);
// (tab, icon, label, badge)
let items = [
(Tab::Home, WALLET, t!("goblin.home.nav_wallet"), false),
(
Tab::Pay,
crate::gui::icons::ARROW_UP,
t!("goblin.home.nav_pay"),
false,
),
(
Tab::Activity,
CLOCK,
t!("goblin.home.nav_activity"),
has_requests,
),
(
Tab::Receive,
ARROW_DOWN,
t!("goblin.home.nav_receive"),
false,
),
(Tab::Me, USER_CIRCLE, t!("goblin.home.nav_settings"), false),
];
for (tab, icon, label, badge) in items {
let active = tab == self.tab;
let (rect, resp) =
ui.allocate_exact_size(Vec2::new(ui.available_width(), 44.0), Sense::click());
if active || resp.hovered() {
ui.painter().rect_filled(
rect,
eframe::epaint::CornerRadius::same(12),
if active { t.surface2 } else { t.hover },
);
}
// The active pill is a surface; on-surface ink keeps it readable
// in the yellow theme (dark pill on bright bg).
let color = if active { t.surface_text } else { t.text_dim };
ui.painter().text(
rect.left_center() + Vec2::new(14.0, 0.0),
egui::Align2::LEFT_CENTER,
icon,
FontId::new(20.0, fonts::regular()),
color,
);
ui.painter().text(
rect.left_center() + Vec2::new(44.0, 0.0),
egui::Align2::LEFT_CENTER,
label,
FontId::new(
15.0,
if active {
fonts::semibold()
} else {
fonts::medium()
},
),
color,
);
if badge {
ui.painter()
.circle_filled(rect.right_center() - Vec2::new(14.0, 0.0), 4.0, t.neg);
}
if resp.clicked() {
self.tab = tab;
}
ui.add_space(4.0);
}
// Node status + profile cards pinned to the bottom (node info lives
// here so the surface needs no separate network column). Each card is
// its own shortcut: the node card opens the Node menu, the identity
// chip opens identity settings.
ui.with_layout(Layout::bottom_up(Align::Min), |ui| {
let width = ui.available_width();
ui.allocate_ui_with_layout(
Vec2::new(width, 196.0),
Layout::top_down(Align::Min),
|ui| {
// Node status card → Settings → Node menu.
let node = ui
.scope(|ui| self.node_card_ui(ui, wallet))
.response
.interact(Sense::click())
.on_hover_cursor(egui::CursorIcon::PointingHand);
if node.clicked() {
self.tab = Tab::Me;
self.settings_page = SettingsPage::Node;
}
ui.add_space(8.0);
let (handle, npub_hex) = wallet
.nostr_service()
.map(|s| {
let id = s.identity.read();
let h = id
.nip05
.clone()
.map(|n| n.split('@').next().unwrap_or("").to_string())
.unwrap_or_else(|| data::short_npub(&hex_of(&id.npub)));
(h, hex_of(&id.npub))
})
.unwrap_or_else(|| {
(t!("goblin.home.anonymous").to_string(), String::new())
});
let tex = self.handle_tex(ui.ctx(), wallet, &handle);
// Identity chip → identity settings.
let id_resp = ui
.scope(|ui| {
w::card(ui, |ui| {
ui.set_min_width(ui.available_width());
ui.horizontal(|ui| {
w::avatar_any(ui, &handle, &npub_hex, 28.0, tex.as_ref());
ui.add_space(10.0);
ui.vertical(|ui| {
// Scale the handle to its length: short @names get a
// big, legible size; a long npub shrinks to stay on
// one line.
let len = handle.chars().count() as f32;
let handle_font =
(20.0 - (len - 6.0).max(0.0) * 0.7).clamp(11.0, 16.0);
ui.label(
RichText::new(&handle)
.font(FontId::new(handle_font, fonts::semibold()))
.color(t.surface_text),
);
ui.label(
// Relay-gated: "Connected over Nym" only once a
// relay is live on the current tunnel generation.
RichText::new(if crate::tor::transport_ready() {
t!("goblin.home.connected_nym")
} else if crate::tor::is_ready() {
t!("goblin.home.nym_ready")
} else {
t!("goblin.home.connecting_nym")
})
.font(FontId::new(11.0, fonts::regular()))
.color(t.surface_text_mute),
);
});
});
});
})
.response
.interact(Sense::click())
.on_hover_cursor(egui::CursorIcon::PointingHand);
if id_resp.clicked() {
self.tab = Tab::Me;
self.settings_page = SettingsPage::Main;
}
},
);
});
}
/// Avatar texture for a display handle ("@name"); None for non-handles
/// (anonymous identities keep their letter puck).
fn handle_tex(
&mut self,
ctx: &egui::Context,
wallet: &Wallet,
handle: &str,
) -> Option<egui::TextureHandle> {
// Avatars live on the nip05 server, keyed by handle. Handles no longer
// carry an '@'; skip bare-npub and empty display names (no avatar there).
if handle.is_empty() || handle.starts_with("npub1") {
return None;
}
let server = wallet
.nostr_service()
.map(|s| s.config.read().nip05_server())?;
self.avatars.texture_for(ctx, &server, handle)
}
/// Compact node status card: sync state dot, block height, connection.
fn node_card_ui(&mut self, ui: &mut egui::Ui, wallet: &Wallet) {
let t = theme::tokens();
let height = wallet
.get_data()
.map(|d| d.info.last_confirmed_height)
.unwrap_or(0);
// Distinguish "scanning" from "can't reach the node": a flaky
// external node otherwise reads as syncing forever.
let error = wallet.sync_error();
let synced = height > 0 && !wallet.syncing() && !error;
w::card(ui, |ui| {
ui.set_min_width(ui.available_width());
ui.horizontal(|ui| {
let (dot, _) = ui.allocate_exact_size(Vec2::splat(10.0), Sense::hover());
ui.painter().circle_filled(
dot.center(),
4.0,
if error {
t.neg
} else if synced {
t.pos
} else {
t.accent
},
);
ui.add_space(8.0);
ui.vertical(|ui| {
ui.label(
RichText::new(if error {
t!("goblin.home.cant_reach_node")
} else if synced {
t!("goblin.home.node_synced")
} else {
t!("goblin.home.syncing")
})
.font(FontId::new(14.0, fonts::semibold()))
.color(t.surface_text),
);
// Three lines: status, block height, then the node host
// on its own line so it never truncates the height.
let height = wallet
.get_data()
.map(|d| d.info.last_confirmed_height)
.unwrap_or(0);
ui.label(
RichText::new(if height > 0 {
t!("goblin.home.block", height => fmt_thousands(height)).to_string()
} else {
t!("goblin.home.waiting_for_chain").to_string()
})
.font(FontId::new(12.0, fonts::regular()))
.color(t.surface_text_dim),
);
ui.add(
egui::Label::new(
RichText::new(node_host(wallet))
.font(FontId::new(12.0, fonts::regular()))
.color(t.surface_text_mute),
)
.truncate(),
);
});
// Low-opacity gear so the card reads as a tappable settings
// shortcut; on-surface ink keeps it theme-aware.
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
ui.label(
RichText::new(crate::gui::icons::GEAR)
.font(FontId::new(16.0, fonts::regular()))
.color(t.surface_text.gamma_multiply(0.35)),
);
});
});
});
}
fn home_ui(
&mut self,
ui: &mut egui::Ui,
wallet: &Wallet,
cb: &dyn PlatformCallbacks,
wide: bool,
) {
let data = wallet.get_data();
ScrollArea::vertical()
.auto_shrink([false; 2])
.scroll_bar_visibility(egui::scroll_area::ScrollBarVisibility::AlwaysHidden)
.show(ui, |ui| {
// Mobile header: wordmark left, avatar (opens settings) right.
if !wide {
ui.add_space(10.0);
let (header_handle, header_hex) = wallet
.nostr_service()
.map(|s| {
let id = s.identity.read();
let hex = hex_of(&id.npub);
// With a verified handle show "@name"; otherwise fall back to
// the short npub (avatar_any then draws the deterministic
// pubkey-seeded gradient).
let h = id
.nip05
.clone()
.map(|n| n.split('@').next().unwrap_or("").to_string())
.unwrap_or_else(|| data::short_npub(&hex));
(h, hex)
})
.unwrap_or_else(|| ("N".to_string(), String::new()));
let header_tex = self.handle_tex(ui.ctx(), wallet, &header_handle);
ui.horizontal(|ui| {
widgets_logo(ui);
ui.add_space(8.0);
ui.label(
RichText::new("goblin")
.font(FontId::new(18.0, fonts::bold()))
.color(theme::tokens().text),
);
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
if w::avatar_any(
ui,
&header_handle,
&header_hex,
40.0,
header_tex.as_ref(),
)
.clicked()
{
self.tab = Tab::Me;
}
// Scan-to-pay, left of the avatar. No frame: a bold white QR
// glyph sized and centered to mirror the Pay-page header
// treatment next to the avatar (was a tacky filled circle).
ui.add_space(12.0);
let (rect, resp) =
ui.allocate_exact_size(Vec2::splat(44.0), Sense::click());
ui.painter().text(
rect.center(),
egui::Align2::CENTER_CENTER,
QR_CODE,
FontId::new(38.0, fonts::regular()),
theme::tokens().text,
);
let resp = resp.on_hover_cursor(egui::CursorIcon::PointingHand);
if resp.clicked() {
let mut flow = SendFlow::default();
flow.request_scan();
self.send = Some(flow);
}
});
});
ui.add_space(28.0);
} else {
ui.add_space(48.0);
}
let (total, spendable) = data
.as_ref()
.map(|d| (d.info.total, d.info.amount_currently_spendable))
.unwrap_or((0, 0));
// Zero can just mean "in transit" (locked change / awaiting
// finalization) or a first sync still running.
let in_flight = data
.as_ref()
.map(|d| d.info.amount_locked + d.info.amount_awaiting_finalization)
.unwrap_or(0);
let updating = total == 0 && (in_flight > 0 || wallet.syncing());
// Distinguish "still updating" from "can't reach the node" so a
// node outage never renders as a silent zero (see balance_hero).
let error = wallet.sync_error();
w::balance_hero(
ui,
total,
spendable,
updating,
error,
wallet.info_sync_progress(),
fiat_line(&data),
56.0,
);
ui.add_space(20.0);
let (send, receive) = w::send_receive(ui);
if send {
self.send = Some(SendFlow::default());
}
if receive {
self.tab = Tab::Receive;
}
ui.add_space(24.0);
// Latest news post (hidden entirely when none seen yet).
self.news_panel_ui(ui, wallet);
// Recent peers strip.
self.peers_strip_ui(ui, wallet, "goblin_peers_home");
// Recent activity.
w::kicker(ui, &t!("goblin.home.activity"));
ui.add_space(6.0);
let items = activity_items(wallet);
if items.is_empty() {
empty_state(
ui,
&t!("goblin.home.empty_title"),
&t!("goblin.home.empty_sub"),
);
} else {
for item in items.iter().take(6) {
self.activity_item_ui(ui, item, wallet, cb);
}
}
ui.add_space(16.0);
});
}
/// Latest news post from the Goblin news key. Title + summary in a card, with
/// any http(s) URL in the summary rendered as a tappable link. Renders nothing
/// (early return) when no post has been cached yet — no empty state.
fn news_panel_ui(&mut self, ui: &mut egui::Ui, wallet: &Wallet) {
let Some(news) = news_latest(wallet) else {
return;
};
let t = theme::tokens();
w::kicker(ui, &t!("goblin.home.news"));
ui.add_space(8.0);
w::card(ui, |ui| {
// Span the full content width like the balance/activity rows so the
// panel reads as a band, not a content-hugging chip.
ui.set_min_width(ui.available_width());
// Date first, ISO 8601 (YYYY-MM-DD, UTC). Dated by the article's
// published_at tag when present, else the event's created_at.
let stamp = news.published_at.unwrap_or(news.created_at);
ui.label(
RichText::new(data::news_date_iso(stamp))
.font(FontId::new(12.0, fonts::medium()))
.color(t.surface_text_dim),
);
if !news.title.is_empty() {
ui.add_space(2.0);
// Title guardrail: hard-cap the length (predictable ellipsis past
// NEWS_TITLE_MAX_CHARS) then shrink the font to fit the card width on
// one line down to a 12pt floor, so a title never clips. `.truncate()`
// backs the floor for the pathological narrow case.
let title = data::news_title_clamped(&news.title);
let pt = fit_news_title_pt(ui, &title, ui.available_width());
ui.add(
egui::Label::new(
RichText::new(&title)
.font(FontId::new(pt, fonts::semibold()))
.color(t.surface_text),
)
.truncate(),
);
}
if !news.summary.is_empty() {
ui.add_space(4.0);
ui.horizontal_wrapped(|ui| {
ui.spacing_mut().item_spacing.x = 0.0;
for (seg, is_url) in split_urls(&news.summary) {
if is_url {
// hyperlink opens via ctx.open_url, same as open_url().
ui.hyperlink(seg);
} else {
ui.label(
RichText::new(seg)
.font(FontId::new(13.0, fonts::regular()))
.color(t.surface_text_dim),
);
}
}
});
}
});
ui.add_space(24.0);
}
/// Horizontal recent-contacts strip; tapping one starts a prefilled send.
fn peers_strip_ui(&mut self, ui: &mut egui::Ui, wallet: &Wallet, salt: &str) {
let peers = recent_peers(wallet, 8);
if peers.is_empty() {
return;
}
let texs: Vec<Option<egui::TextureHandle>> = peers
.iter()
.map(|(name, _)| self.handle_tex(ui.ctx(), wallet, name))
.collect();
w::kicker(ui, &t!("goblin.home.recent"));
ui.add_space(12.0);
ScrollArea::horizontal()
.id_salt(salt.to_string())
.auto_shrink([false, true])
.show(ui, |ui| {
ui.horizontal(|ui| {
for ((name, npub), tex) in peers.iter().zip(texs.iter()) {
// Fixed-width centered cell so the name sits centered under the
// avatar (not left-aligned to a wider label).
ui.allocate_ui_with_layout(
Vec2::new(72.0, 78.0),
Layout::top_down(Align::Center),
|ui| {
let resp = w::avatar_any(ui, name, npub, 48.0, tex.as_ref());
ui.add_space(6.0);
let chars: Vec<char> = name.chars().collect();
let short: String = if chars.len() > 8 {
format!("{}…", chars[..8].iter().collect::<String>())
} else {
name.to_string()
};
ui.label(
RichText::new(short).font(FontId::new(12.0, fonts::medium())),
);
if resp.clicked() {
self.profile = Some(npub.clone());
}
},
);
ui.add_space(12.0);
}
});
});
ui.add_space(20.0);
}
/// Pay tab: amount-first combined pay/request surface.
fn pay_ui(&mut self, ui: &mut egui::Ui, wallet: &Wallet, cb: &dyn PlatformCallbacks) {
let t = theme::tokens();
ui.add_space(8.0);
// Header identity for the avatar (→ settings), mirroring the Home header.
let (header_handle, header_hex) = wallet
.nostr_service()
.map(|s| {
let id = s.identity.read();
let hex = hex_of(&id.npub);
let h = id
.nip05
.clone()
.map(|n| n.split('@').next().unwrap_or("").to_string())
.unwrap_or_else(|| data::short_npub(&hex));
(h, hex)
})
.unwrap_or_else(|| ("N".to_string(), String::new()));
let header_tex = self.handle_tex(ui.ctx(), wallet, &header_handle);
ui.horizontal(|ui| {
// Goblin mark (left), sized to match the right-side controls.
ui.add(
egui::Image::new(egui::include_image!("../../../../img/goblin-logo2.svg"))
.tint(t.text)
.fit_to_exact_size(Vec2::splat(40.0)),
);
// Right cluster: scan QR (black, no background) then the profile
// picture at the far right; all three controls about the same size.
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
if w::avatar_any(ui, &header_handle, &header_hex, 40.0, header_tex.as_ref())
.on_hover_cursor(egui::CursorIcon::PointingHand)
.clicked()
{
self.tab = Tab::Me;
}
ui.add_space(12.0);
let (rect, resp) = ui.allocate_exact_size(Vec2::splat(44.0), Sense::click());
ui.painter().text(
rect.center(),
egui::Align2::CENTER_CENTER,
QR_CODE,
FontId::new(38.0, fonts::regular()),
t.text,
);
if resp
.on_hover_cursor(egui::CursorIcon::PointingHand)
.on_hover_text(t!("goblin.home.scan_to_pay").to_string())
.clicked()
{
let mut f = SendFlow::default();
f.prefill_amount(self.pay_amount.clone());
f.request_scan();
self.pay_amount.clear();
self.send = Some(f);
}
});
});
// Big centered amount.
let display = if self.pay_amount.is_empty() {
"0".to_string()
} else {
self.pay_amount.clone()
};
let tall = ui.available_height() > 560.0;
// Over-balance is NOT shown while typing — requesting more than you hold is
// valid, and reddening digits mid-entry reads as an error when it isn't.
// The only feedback is on the Pay press: a brief red flash + shake + buzz
// (see the Pay button below). `spendable` is read there too.
let spendable = wallet
.get_data()
.map(|d| d.info.amount_currently_spendable)
.unwrap_or(0);
// Drive the "can't pay that" animation if it's running.
let now = ui.input(|i| i.time);
const SHAKE_DUR: f64 = 0.45;
if self.pay_shake.is_some_and(|s| now - s >= SHAKE_DUR) {
self.pay_shake = None;
}
ui.add_space(if tall { 56.0 } else { 24.0 });
if let Some(start) = self.pay_shake {
ui.ctx().request_repaint(); // keep the animation ticking
let p = ((now - start) / SHAKE_DUR).clamp(0.0, 1.0) as f32;
// Damped horizontal oscillation, amplitude decaying to zero.
let dx = 14.0 * (1.0 - p) * (p * std::f32::consts::PI * 9.0).sin();
// Red flash that eases back to the normal ink over the shake.
let num = lerp_color(t.neg, t.text, p);
let mark = lerp_color(t.neg, t.text_dim, p);
w::amount_text_centered_shifted(ui, &display, 76.0, num, mark, dx);
} else {
w::amount_text_centered(ui, &display, 76.0);
}
if let Ok(grin) = display.parse::<f64>() {
if let Some(preview) = pairing_preview(grin, ui.ctx()) {
ui.add_space(6.0);
ui.vertical_centered(|ui| {
ui.label(
RichText::new(preview)
.font(FontId::new(14.0, fonts::regular()))
.color(t.text_dim),
);
});
}
}
// Drop the keypad toward the bottom on phone layouts (thumb reach) so it
// isn't stranded in the middle with a big empty gap below it.
let narrow = ui.available_width() < 700.0;
let drop = if narrow {
((ui.available_height() - 430.0) * 0.6).max(0.0)
} else {
0.0
};
ui.add_space(if tall { 32.0 } else { 16.0 } + drop);
// The pay column is capped at 480 by `centered_column`, so the old
// `< 700` width gate was always narrow: the numpad always showed and
// the typed-input branch was dead — a physical keyboard did nothing.
// Show the pad and accept typed digits alongside it.
w::numpad(ui, &mut self.pay_amount, cb);
w::amount_typed_input(ui, &mut self.pay_amount);
ui.add_space(20.0);
// Request | Pay actions, half width each.
let valid = grin_core::core::amount_from_hr_string(&self.pay_amount)
.map(|a| a > 0)
.unwrap_or(false);
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, 56.0),
)),
|ui| {
if w::big_action(ui, &t!("goblin.home.request"), true).clicked() && valid {
// Open the request flow: pick a contact, then DM them a
// grin Invoice1 they can approve to pay.
let f = SendFlow::new_request(self.pay_amount.clone());
self.pay_amount.clear();
self.send = Some(f);
}
},
);
ui.add_space(10.0);
ui.scope_builder(
egui::UiBuilder::new().max_rect(egui::Rect::from_min_size(
ui.cursor().min,
Vec2::new(half, 56.0),
)),
|ui| {
if w::big_action(ui, &t!("goblin.home.pay"), false).clicked() && valid {
let over = grin_core::core::amount_from_hr_string(&self.pay_amount)
.map(|a| a > spendable)
.unwrap_or(false);
if over {
// "No, you can't pay that": shake + flash the amount red
// and buzz the phone. Nothing is reddened while typing.
self.pay_shake = Some(now);
cb.vibrate_error();
} else {
let mut f = SendFlow::default();
f.prefill_amount(self.pay_amount.clone());
self.pay_amount.clear();
self.send = Some(f);
}
}
},
);
});
if !valid {
ui.add_space(8.0);
ui.vertical_centered(|ui| {
ui.label(
RichText::new(t!("goblin.home.enter_amount"))
.font(FontId::new(12.0, fonts::regular()))
.color(t.text_mute),
);
});
}
}
/// Round back button + title for full-surface overlays. Returns true on tap.
fn overlay_back_header(ui: &mut egui::Ui, title: &str) -> bool {
let t = theme::tokens();
let mut back = false;
ui.horizontal(|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,
ARROW_LEFT,
FontId::new(16.0, fonts::regular()),
t.text,
);
back = resp
.on_hover_cursor(egui::CursorIcon::PointingHand)
.clicked();
ui.add_space(12.0);
ui.label(
RichText::new(title)
.font(FontId::new(18.0, fonts::bold()))
.color(t.text),
);
});
ui.add_space(12.0);
back
}
/// Full-surface transaction receipt: GRIM metadata joined with the nostr
/// counterparty + note. Tapping the counterparty opens their profile.
fn receipt_ui(&mut self, ui: &mut egui::Ui, wallet: &Wallet, tx_id: u32) -> bool {
let t = theme::tokens();
let d = data::receipt_detail(wallet, tx_id);
let tex = d
.as_ref()
.and_then(|d| self.handle_tex(ui.ctx(), wallet, &d.title));
let mut close = false;
let mut open_profile: Option<String> = None;
egui::CentralPanel::default()
.frame(egui::Frame {
fill: t.bg,
inner_margin: Margin {
left: (View::far_left_inset_margin(ui) + 20.0) as i8,
right: (View::get_right_inset() + 20.0) as i8,
top: (View::get_top_inset() + 12.0) as i8,
bottom: (View::get_bottom_inset() + 12.0) as i8,
},
..Default::default()
})
.show_inside(ui, |ui| {
w::centered_column(ui, Content::SIDE_PANEL_WIDTH * 1.2, |ui| {
if Self::overlay_back_header(ui, &t!("goblin.receipt.title")) {
close = true;
}
let Some(d) = d else {
ui.add_space(40.0);
ui.vertical_centered(|ui| {
ui.label(
RichText::new(t!("goblin.receipt.not_found"))
.font(FontId::new(15.0, fonts::regular()))
.color(t.text_dim),
);
});
return;
};
ScrollArea::vertical()
.auto_shrink([false; 2])
.show(ui, |ui| {
ui.add_space(8.0);
ui.vertical_centered(|ui| {
let resp = w::avatar_any(
ui,
&d.title,
d.npub.as_deref().unwrap_or(""),
64.0,
tex.as_ref(),
);
ui.add_space(10.0);
ui.label(
RichText::new(&d.title)
.font(FontId::new(22.0, fonts::bold()))
.color(t.text),
);
ui.add_space(2.0);
ui.label(
RichText::new(View::format_time(d.time))
.font(FontId::new(13.0, fonts::regular()))
.color(t.text_dim),
);
if let Some(note) = &d.note {
ui.add_space(2.0);
ui.label(
RichText::new(t!("goblin.receipt.for_note", note => note))
.font(FontId::new(13.0, fonts::regular()))
.color(t.text_dim),
);
}
ui.add_space(14.0);
w::amount_text_centered(ui, &w::amount_str(d.amount), 56.0);
if resp.clicked() {
if let Some(npub) = &d.npub {
open_profile = Some(npub.clone());
}
}
});
ui.add_space(20.0);
w::kicker(ui, &t!("goblin.receipt.details"));
ui.add_space(10.0);
w::card(ui, |ui| {
let (status, sub): (String, String) = if d.canceled {
(
t!("goblin.receipt.canceled").to_string(),
if d.incoming {
t!("goblin.receipt.expired").to_string()
} else {
t!("goblin.receipt.funds_returned").to_string()
},
)
} else if let Some((c, r)) = d.confs {
// On-chain but still maturing toward the spendable
// threshold — show the live X/N count (grin marks a
// tx confirmed at one block; spendable takes N).
if c == 0 && !d.incoming && d.npub.is_some() {
// Sent but not yet picked up / mined.
(
t!("goblin.receipt.pending").to_string(),
t!("goblin.receipt.waiting_to_receive", name => d.title)
.to_string(),
)
} else {
(
t!("goblin.receipt.pending").to_string(),
t!("goblin.receipt.confs", c => c, r => r).to_string(),
)
}
} else if d.confirmed {
(
t!("goblin.receipt.complete").to_string(),
if d.incoming {
t!("goblin.receipt.payment_received").to_string()
} else {
t!("goblin.receipt.payment_sent").to_string()
},
)
} else {
(
t!("goblin.receipt.pending").to_string(),
t!("goblin.receipt.waiting_to_confirm").to_string(),
)
};
w::info_row(ui, &status, &sub);
if d.has_identity {
let (to, from) = if d.incoming {
(t!("goblin.receipt.you").to_string(), d.title.clone())
} else {
(d.title.clone(), t!("goblin.receipt.you").to_string())
};
w::info_row(ui, &t!("goblin.receipt.to"), &to);
w::info_row(ui, &t!("goblin.receipt.from"), &from);
}
if let Some(npub) = &d.npub {
w::info_row(
ui,
&t!("goblin.receipt.nostr"),
&data::short_npub(npub),
);
}
// Only the SENDER pays a network fee, so the row only makes
// sense on outgoing payments. A received payment has no fee
// (data sets it to None) — hide the row entirely instead of
// showing a confusing "—".
if let Some(fee_amount) = d.fee {
let fee = if fee_amount == 0 {
t!("goblin.receipt.fee_none").to_string()
} else {
format!("{}{}", w::amount_str(fee_amount), w::TSU)
};
w::info_row(ui, &t!("goblin.receipt.network_fee"), &fee);
}
w::info_row(
ui,
&t!("goblin.receipt.privacy"),
&t!("goblin.receipt.privacy_value"),
);
if let Some(sid) = &d.slate_id {
let short = if sid.len() > 13 {
format!("{}{}", &sid[..8], &sid[sid.len() - 4..])
} else {
sid.clone()
};
w::info_row(ui, &t!("goblin.receipt.transaction"), &short);
}
});
// Withdraw a request we sent that hasn't been paid yet:
// cancel the local invoice and tell the payer (a void
// message). Requests are messages; payments are final.
let cancelable_request = d
.slate_id
.as_ref()
.and_then(|sid| {
wallet.nostr_service().and_then(|s| s.store.tx_meta(sid))
})
.map(|m| {
m.direction == crate::nostr::NostrTxDirection::RequestedByUs
&& matches!(
m.status,
crate::nostr::NostrSendStatus::Created
| crate::nostr::NostrSendStatus::AwaitingI2
)
})
.unwrap_or(false) && !d.canceled
&& !d.confirmed;
if cancelable_request {
ui.add_space(16.0);
if w::big_action(ui, &t!("goblin.receipt.cancel_request"), true)
.clicked()
{
if let Some(sid) = &d.slate_id {
wallet.task(
crate::wallet::types::WalletTask::NostrCancelOutgoing(
sid.clone(),
),
);
}
close = true;
}
}
// Reclaim a payment WE sent that the recipient never
// completed: cancel the grin tx to unlock our funds, mark
// it cancelled, best-effort void. Appears after the grace
// window (or immediately if it never reached a relay).
let send_meta = d.slate_id.as_ref().and_then(|sid| {
wallet.nostr_service().and_then(|s| s.store.tx_meta(sid))
});
let grace = wallet
.nostr_service()
.map(|s| s.config.read().cancel_grace_secs())
.unwrap_or(600);
let cancelable_send = send_meta
.as_ref()
.map(|m| {
m.direction == crate::nostr::NostrTxDirection::Sent
&& matches!(
m.status,
crate::nostr::NostrSendStatus::Created
| crate::nostr::NostrSendStatus::AwaitingS2
| crate::nostr::NostrSendStatus::SendFailed
) && (matches!(
m.status,
crate::nostr::NostrSendStatus::SendFailed
) || crate::nostr::unix_time() - m.created_at > grace)
})
.unwrap_or(false) && !d.canceled
&& !d.confirmed;
if cancelable_send {
ui.add_space(16.0);
let confirming = self.cancel_confirm == Some(d.tx_id);
let label = if confirming {
t!("goblin.receipt.cancel_send_confirm")
} else {
t!("goblin.receipt.cancel_send")
};
if w::big_action(ui, &label, true).clicked() {
if confirming {
if let Some(sid) = &d.slate_id {
wallet.task(
crate::wallet::types::WalletTask::NostrCancelSend(
sid.clone(),
),
);
}
self.cancel_confirm = None;
} else {
self.cancel_confirm = Some(d.tx_id);
}
}
} else {
self.cancel_confirm = None;
}
// Transient outcome notice, set async by the task handler.
if let Some(outcome) =
wallet.nostr_service().and_then(|s| s.take_cancel_notice())
{
self.cancel_msg = Some((outcome, std::time::Instant::now()));
}
if let Some((outcome, at)) = self.cancel_msg {
if at.elapsed().as_secs() < 5 {
ui.add_space(10.0);
let (msg, col) = match outcome {
crate::nostr::CancelOutcome::Cancelled => {
(t!("goblin.receipt.cancel_send_done"), t.pos)
}
crate::nostr::CancelOutcome::AlreadyCompleted => {
(t!("goblin.receipt.cancel_send_too_late"), t.text_dim)
}
};
ui.vertical_centered(|ui| {
ui.label(
RichText::new(msg)
.font(FontId::new(13.0, fonts::regular()))
.color(col),
);
});
ui.ctx().request_repaint_after(
std::time::Duration::from_millis(300),
);
} else {
self.cancel_msg = None;
}
}
ui.add_space(20.0);
});
});
});
if let Some(npub) = open_profile {
self.profile = Some(npub);
close = true;
}
close
}
/// Full-surface contact profile: who they are, history between us, and a
/// block toggle (a nostr-level mute).
fn profile_ui(
&mut self,
ui: &mut egui::Ui,
wallet: &Wallet,
_cb: &dyn PlatformCallbacks,
npub: &str,
) -> bool {
let t = theme::tokens();
let name = wallet
.nostr_service()
.map(|s| data::contact_title(&s.store, npub))
.unwrap_or_else(|| data::short_npub(npub));
let contact = wallet.nostr_service().and_then(|s| s.store.contact(npub));
let blocked = contact.as_ref().map(|c| c.blocked).unwrap_or(false);
let nip05 = contact.as_ref().and_then(|c| c.nip05.clone());
let history = data::history_with(wallet, npub);
let tex = self.handle_tex(ui.ctx(), wallet, &name);
let htexs: Vec<Option<egui::TextureHandle>> = history
.iter()
.map(|i| self.handle_tex(ui.ctx(), wallet, &i.title))
.collect();
let mut close = false;
let mut do_pay = false;
let mut do_block = false;
let mut open_receipt: Option<u32> = None;
egui::CentralPanel::default()
.frame(egui::Frame {
fill: t.bg,
inner_margin: Margin {
left: (View::far_left_inset_margin(ui) + 20.0) as i8,
right: (View::get_right_inset() + 20.0) as i8,
top: (View::get_top_inset() + 12.0) as i8,
bottom: (View::get_bottom_inset() + 12.0) as i8,
},
..Default::default()
})
.show_inside(ui, |ui| {
w::centered_column(ui, Content::SIDE_PANEL_WIDTH * 1.2, |ui| {
if Self::overlay_back_header(ui, &t!("goblin.profile.title")) {
close = true;
}
ScrollArea::vertical()
.auto_shrink([false; 2])
.show(ui, |ui| {
ui.add_space(8.0);
ui.vertical_centered(|ui| {
w::avatar_any(ui, &name, npub, 72.0, tex.as_ref());
ui.add_space(12.0);
ui.label(
RichText::new(&name)
.font(FontId::new(22.0, fonts::bold()))
.color(t.text),
);
ui.add_space(2.0);
let sub = nip05
.clone()
.map(|n| format!("✓ {}", n))
.unwrap_or_else(|| data::short_npub(npub));
ui.label(
RichText::new(sub)
.font(FontId::new(13.0, fonts::regular()))
.color(t.text_dim),
);
});
ui.add_space(18.0);
if !blocked
&& w::big_action(ui, &t!("goblin.home.pay"), false).clicked()
{
do_pay = true;
}
ui.add_space(18.0);
w::kicker(ui, &t!("goblin.profile.activity"));
ui.add_space(10.0);
if history.is_empty() {
ui.label(
RichText::new(t!("goblin.profile.no_activity"))
.font(FontId::new(13.0, fonts::regular()))
.color(t.text_dim),
);
} else {
for (item, htex) in history.iter().zip(htexs.iter()) {
// No +/- for canceled: nothing moved.
let sign = if item.canceled {
""
} else if item.incoming {
"+ "
} else {
" "
};
let amount =
format!("{}{}{}", sign, w::amount_str(item.amount), w::TSU);
let (note, time) = Self::activity_note_time(item);
if w::activity_row(
ui,
&item.title,
&note,
&time,
item.npub.as_deref().unwrap_or(""),
&amount,
item.incoming,
item.canceled,
item.system,
htex.as_ref(),
)
.clicked()
{
open_receipt = Some(item.tx_id);
}
}
}
ui.add_space(24.0);
let label = if blocked {
t!("goblin.profile.unblock").to_string()
} else {
format!("{} {}", PROHIBIT, t!("goblin.profile.block"))
};
if w::big_action_on_card_ink(ui, &label, t.neg).clicked() {
do_block = true;
}
ui.add_space(8.0);
ui.vertical_centered(|ui| {
ui.label(
RichText::new(if blocked {
t!("goblin.profile.blocked_blurb")
} else {
t!("goblin.profile.block_blurb")
})
.font(FontId::new(12.0, fonts::regular()))
.color(t.text_mute),
);
});
ui.add_space(20.0);
});
});
});
if let Some(id) = open_receipt {
self.receipt = Some(id);
close = true;
}
if do_pay {
let mut f = SendFlow::default();
f.prefill_contact(name.clone(), npub.to_string());
self.send = Some(f);
close = true;
}
if do_block {
if let Some(s) = wallet.nostr_service() {
let mut c = s.store.contact(npub).unwrap_or(crate::nostr::Contact {
ver: 1,
npub: npub.to_string(),
petname: None,
nip05: nip05.clone(),
nip05_verified_at: None,
relays: vec![],
nip44_v3: false,
hue: data::hue_of(npub) as u8,
unknown: true,
added_at: crate::nostr::unix_time(),
last_paid_at: None,
blocked: false,
});
c.blocked = !c.blocked;
s.store.save_contact(&c);
}
}
close
}
/// List-row timestamp: date + HH:MM, no seconds. The tap-in detail view keeps
/// the full timestamp to the second (see [`View::format_time`]).
fn list_time(ts: i64) -> String {
let utc_offset = chrono::Local::now().offset().local_minus_utc();
chrono::DateTime::from_timestamp(ts + utc_offset as i64, 0)
.map(|t| t.format("%d/%m/%Y %H:%M").to_string())
.unwrap_or_default()
}
/// The (left message, right timestamp) an [`ActivityItem`] shows in a row. The
/// timestamp (no seconds) is only set for a confirmed tx; otherwise the status
/// word (canceled/pending) folds into the message so a row with no time still
/// reads its state without an empty right-side time slot.
fn activity_note_time(item: &ActivityItem) -> (String, String) {
let status_word = if item.canceled {
t!("goblin.activity.canceled").to_string()
} else {
t!("goblin.activity.pending").to_string()
};
let time = if item.confirmed {
Self::list_time(item.time)
} else {
String::new()
};
let note = match (item.note.as_deref(), item.confirmed) {
(Some(n), false) => format!("{n} · {status_word}"),
(None, false) => status_word,
(Some(n), true) => n.to_string(),
(None, true) => String::new(),
};
(note, time)
}
/// Friendly day-grouping label for the activity feed.
fn day_label(ts: i64) -> String {
use chrono::{TimeZone, Utc};
let Some(dt) = Utc.timestamp_opt(ts, 0).single() else {
return t!("goblin.activity.earlier").to_string();
};
let today = Utc::now().date_naive();
let day = dt.date_naive();
if day == today {
t!("goblin.activity.today").to_string()
} else if (today - day).num_days() == 1 {
t!("goblin.activity.yesterday").to_string()
} else {
dt.format("%b %-d, %Y").to_string()
}
}
fn activity_ui(&mut self, ui: &mut egui::Ui, wallet: &Wallet, cb: &dyn PlatformCallbacks) {
ui.add_space(8.0);
ui.label(
RichText::new(t!("goblin.activity.title"))
.font(FontId::new(28.0, fonts::bold()))
.color(theme::tokens().text),
);
ui.add_space(12.0);
// Recent contacts strip (payment-app-style row above the feed).
self.peers_strip_ui(ui, wallet, "goblin_peers_activity");
// Pending payment requests pinned on top.
if let Some(service) = wallet.nostr_service() {
// An approve that failed (e.g. funds still confirming) flips the send
// phase to FAILED — un-grey the buttons so the user can retry, and
// surface why instead of leaving the card stuck.
if service.send_phase() == crate::nostr::send_phase::FAILED
&& !self.approving.is_empty()
{
self.approving.clear();
self.request_error = service.last_send_error();
}
let requests = service.store.pending_requests();
if !requests.is_empty() {
w::section_header(ui, &t!("goblin.activity.requests"));
if let Some(err) = &self.request_error {
ui.add_space(4.0);
ui.label(
RichText::new(err)
.font(FontId::new(13.0, fonts::regular()))
.color(theme::tokens().neg),
);
ui.add_space(4.0);
}
for req in requests {
self.request_row_ui(ui, &req, wallet);
}
ui.add_space(8.0);
}
}
ScrollArea::vertical()
.auto_shrink([false; 2])
.scroll_bar_visibility(egui::scroll_area::ScrollBarVisibility::AlwaysHidden)
.show(ui, |ui| {
let items = activity_items(wallet);
if items.is_empty() {
empty_state(
ui,
&t!("goblin.activity.empty_title"),
&t!("goblin.activity.empty_sub"),
);
} else {
// Unconfirmed (< min confirmations) pinned on top as Pending.
// Canceled txs are not pending — they group with history below.
let pending: Vec<&_> = items
.iter()
.filter(|i| !i.confirmed && !i.system && !i.canceled)
.collect();
if !pending.is_empty() {
w::section_header(ui, &t!("goblin.activity.pending_header"));
for item in pending {
self.activity_item_ui(ui, item, wallet, cb);
}
ui.add_space(8.0);
}
// Confirmed (and canceled), grouped by day (newest first).
let mut last: Option<String> = None;
for item in items
.iter()
.filter(|i| i.confirmed || i.system || i.canceled)
{
let label = Self::day_label(item.time);
if last.as_deref() != Some(label.as_str()) {
w::section_header(ui, &label);
last = Some(label);
}
self.activity_item_ui(ui, item, wallet, cb);
}
}
ui.add_space(16.0);
});
}
fn activity_item_ui(
&mut self,
ui: &mut egui::Ui,
item: &ActivityItem,
wallet: &Wallet,
_cb: &dyn PlatformCallbacks,
) {
// No +/- for canceled: nothing moved.
let sign = if item.canceled {
""
} else if item.incoming {
"+ "
} else {
" "
};
let amount = format!("{}{}{}", sign, w::amount_str(item.amount), w::TSU);
let (note, time) = Self::activity_note_time(item);
let tex = self.handle_tex(ui.ctx(), wallet, &item.title);
if w::activity_row(
ui,
&item.title,
&note,
&time,
item.npub.as_deref().unwrap_or(""),
&amount,
item.incoming,
item.canceled,
item.system,
tex.as_ref(),
)
.clicked()
{
self.receipt = Some(item.tx_id);
}
}
fn request_row_ui(
&mut self,
ui: &mut egui::Ui,
req: &crate::nostr::PaymentRequest,
wallet: &Wallet,
) {
let t = theme::tokens();
let name = wallet
.nostr_service()
.map(|s| data::contact_title(&s.store, &req.npub))
.unwrap_or_else(|| data::short_npub(&req.npub));
let tex = self.handle_tex(ui.ctx(), wallet, &name);
w::card(ui, |ui| {
ui.horizontal(|ui| {
w::avatar_any(ui, &name, &req.npub, 40.0, tex.as_ref());
ui.add_space(12.0);
ui.vertical(|ui| {
ui.label(
RichText::new(t!("goblin.request.title", name => name))
.font(FontId::new(15.0, fonts::semibold()))
.color(t.surface_text),
);
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 0.0;
ui.label(
RichText::new(w::amount_str(req.amount))
.font(FontId::new(15.0, fonts::mono_semibold()))
.color(t.surface_text),
);
ui.label(
RichText::new(w::TSU)
.font(FontId::new(13.0, fonts::medium()))
.color(t.surface_text_dim),
);
});
});
});
if let Some(note) = &req.note {
ui.add_space(6.0);
ui.label(
RichText::new(format!("\u{201C}{}\u{201D}", note))
.font(FontId::new(13.0, fonts::regular()))
.color(t.text_dim),
);
}
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 decline_button(ui) {
// Optimistically clear the card, then send the decline as
// a void control message so the requester's side clears
// too. Requests are messages; payments are final.
let mut r = req.clone();
r.status = crate::nostr::RequestStatus::Declined;
if let Some(s) = wallet.nostr_service() {
s.store.save_request(&r);
}
wallet.task(crate::wallet::types::WalletTask::NostrDeclineRequest(
req.rumor_id.clone(),
));
}
},
);
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| {
let already = self.approving.contains(&req.rumor_id);
let working = already
&& wallet
.nostr_service()
.map(|s| s.send_phase() == crate::nostr::send_phase::WORKING)
.unwrap_or(false);
if already {
// Paying: show a centered spinner so the tap clearly
// registered (the card clears itself once it's sent).
ui.vertical_centered(|ui| {
ui.add_space(6.0);
View::small_loading_spinner(ui);
ui.add_space(2.0);
ui.label(
RichText::new(t!("goblin.receipt.paying"))
.font(FontId::new(12.0, fonts::regular()))
.color(t.text_dim),
);
});
if working {
ui.ctx().request_repaint();
}
} else if approve_button(ui) {
// Don't pay on the tap — open the review screen and make
// the user hold-to-accept there, like a send. The actual
// NostrPayRequest is dispatched from approve_review_ui.
self.request_error = None;
self.approve_hold = w::HoldToSend::default();
self.approve_fee_for = None;
self.approve_review = Some(req.clone());
}
},
);
});
});
ui.add_space(10.0);
}
/// Full-surface review for an incoming payment request: who's asking, how
/// much, the network fee — then hold-to-accept. Paying a request is a spend,
/// so this mirrors the send review's confirm gesture instead of a one-tap
/// accept. Returns true when the screen should close (back, or after the
/// payment is enqueued by the hold).
fn approve_review_ui(&mut self, ui: &mut egui::Ui, wallet: &Wallet) -> bool {
let t = theme::tokens();
let Some(req) = self.approve_review.clone() else {
return true;
};
let name = wallet
.nostr_service()
.map(|s| data::contact_title(&s.store, &req.npub))
.unwrap_or_else(|| data::short_npub(&req.npub));
let tex = self.handle_tex(ui.ctx(), wallet, &name);
// Paying a request spends our balance, so guard against over-balance and
// disable the accept gesture (re-checked each frame).
let spendable = wallet
.get_data()
.map(|d| d.info.amount_currently_spendable)
.unwrap_or(0);
let over = req.amount > spendable;
let mut close = false;
egui::CentralPanel::default()
.frame(egui::Frame {
fill: t.bg,
inner_margin: Margin {
left: (View::far_left_inset_margin(ui) + 20.0) as i8,
right: (View::get_right_inset() + 20.0) as i8,
top: (View::get_top_inset() + 12.0) as i8,
bottom: (View::get_bottom_inset() + 12.0) as i8,
},
..Default::default()
})
.show_inside(ui, |ui| {
w::centered_column(ui, Content::SIDE_PANEL_WIDTH * 1.2, |ui| {
if Self::overlay_back_header(ui, &t!("goblin.request.review_title")) {
close = true;
}
ScrollArea::vertical()
.auto_shrink([false; 2])
.scroll_bar_visibility(egui::scroll_area::ScrollBarVisibility::AlwaysHidden)
.show(ui, |ui| {
ui.add_space(8.0);
w::card(ui, |ui| {
ui.set_min_width(ui.available_width());
ui.add_space(8.0);
ui.vertical_centered(|ui| {
w::avatar_any(ui, &name, &req.npub, 40.0, tex.as_ref());
ui.add_space(6.0);
ui.label(
RichText::new(t!("goblin.request.title", name => &name))
.font(FontId::new(14.0, fonts::regular()))
.color(t.surface_text_dim),
);
});
ui.add_space(8.0);
let amt = w::amount_str(req.amount);
w::amount_text_centered_ink(
ui,
&amt,
48.0,
t.surface_text,
t.surface_text_dim,
);
ui.add_space(8.0);
});
ui.add_space(16.0);
w::info_row(ui, &t!("goblin.send.row_from"), &name);
if let Some(note) = &req.note {
if !note.trim().is_empty() {
w::info_row(
ui,
&t!("goblin.send.row_note"),
&format!("\u{201C}{}\u{201D}", note.trim()),
);
}
}
// Live network fee for paying this request (a spend),
// priced like the send review — one CalculateFee per amount.
if req.amount > 0 && self.approve_fee_for != Some(req.amount) {
self.approve_fee_for = Some(req.amount);
wallet.task(crate::wallet::types::WalletTask::CalculateFee(
req.amount, 0,
));
}
let fee_val = match wallet.calculated_fee(req.amount) {
Some(fee) => format!("{}{}", w::amount_str(fee), w::TSU),
None => {
ui.ctx().request_repaint_after(
std::time::Duration::from_millis(120),
);
"…".to_string()
}
};
w::info_row(ui, &t!("goblin.send.row_network_fee"), &fee_val);
w::info_row(
ui,
&t!("goblin.send.row_privacy"),
&t!("goblin.send.row_privacy_val"),
);
w::info_row(
ui,
&t!("goblin.send.row_delivery"),
&t!("goblin.send.row_delivery_val"),
);
ui.add_space(16.0);
if over {
ui.vertical_centered(|ui| {
ui.label(
RichText::new(t!("goblin.send.not_enough"))
.font(FontId::new(14.0, fonts::regular()))
.color(t.neg),
);
});
ui.add_space(8.0);
}
ui.add_enabled_ui(!over, |ui| {
if self
.approve_hold
.ui(ui, &t!("goblin.request.hold_to_accept"))
&& !over
{
// Guard double-pay + show the spinner back on the
// request card; dispatch the actual payment.
self.approving.insert(req.rumor_id.clone());
self.request_error = None;
wallet.task(crate::wallet::types::WalletTask::NostrPayRequest(
req.rumor_id.clone(),
));
close = true;
}
});
ui.add_space(6.0);
ui.vertical_centered(|ui| {
ui.label(
RichText::new(if over {
t!("goblin.send.lower_amount")
} else {
t!("goblin.request.hold_accept_hint")
})
.font(FontId::new(12.0, fonts::regular()))
.color(t.text_mute),
);
});
ui.add_space(16.0);
});
});
});
close
}
fn receive_ui(&mut self, ui: &mut egui::Ui, wallet: &Wallet, cb: &dyn PlatformCallbacks) {
let t = theme::tokens();
ui.add_space(8.0);
ui.label(
RichText::new(t!("goblin.receive.title"))
.font(FontId::new(28.0, fonts::bold()))
.color(t.text),
);
ui.add_space(16.0);
// `has_name`: a claimed nip05 name exists — gates the "handle"/"username"
// wording, which would mislead when only the raw npub is shown.
let (handle, has_name) = wallet
.nostr_service()
.map(|s| {
let identity = s.identity.read();
match identity.nip05.clone() {
Some(n) => (n.split('@').next().unwrap_or("").to_string(), true),
None => (data::short_npub(&hex_of(&identity.npub)), false),
}
})
.unwrap_or_else(|| ("—".to_string(), false));
let npub = wallet.nostr_service().map(|s| s.npub()).unwrap_or_default();
let nprofile = wallet
.nostr_service()
.map(|s| s.nprofile())
.unwrap_or_else(|| npub.clone());
w::card(ui, |ui| {
ui.vertical_centered(|ui| {
// QR of the nostr handle (nostr: URI).
ui.add_space(12.0);
let uri = format!("nostr:{}", nprofile);
w::qr_code(ui, &uri, 220.0);
ui.add_space(14.0);
ui.label(
RichText::new(&handle)
.font(FontId::new(18.0, fonts::bold()))
.color(t.surface_text),
);
match &self.request_amount {
Some(amt) => {
ui.label(
RichText::new(t!(
"goblin.receive.requesting",
amt => amt,
tsu => w::TSU
))
.font(FontId::new(13.0, fonts::semibold()))
.color(t.surface_text),
);
ui.add_space(6.0);
if w::chip(ui, &t!("goblin.receive.clear_request"), false).clicked() {
self.request_amount = None;
}
}
None => {
let caption = if has_name {
t!("goblin.receive.share_handle")
} else {
t!("goblin.receive.share_npub")
};
ui.label(
RichText::new(caption)
.font(FontId::new(13.0, fonts::regular()))
.color(t.surface_text_dim),
);
}
}
});
});
ui.add_space(12.0);
// Transient "Copied" feedback on the copy button; a silent copy reads as
// a dead button.
let fresh = |at: std::time::Instant| at.elapsed().as_millis() < 1500;
let copied = matches!(self.receive_copied, Some((1, at)) if fresh(at));
if self.receive_copied.is_some() {
ui.ctx()
.request_repaint_after(std::time::Duration::from_millis(200));
}
ui.horizontal(|ui| {
let half = (ui.available_width() - 10.0) / 2.0;
// Share a friendly "pay me" message carrying the bare npub — the
// public key people pay you on. Never the nprofile or a grin address.
ui.scope_builder(
egui::UiBuilder::new().max_rect(egui::Rect::from_min_size(
ui.cursor().min,
Vec2::new(half, 56.0),
)),
|ui| {
let label = t!("goblin.send.share_btn", "icon" => SHARE);
if w::big_action(ui, &label, true).clicked() && !npub.is_empty() {
cb.share_text(
t!("goblin.receive.share_message", "npub" => npub.clone()).to_string(),
);
}
},
);
ui.add_space(10.0);
// Copy the bare npub itself.
ui.scope_builder(
egui::UiBuilder::new().max_rect(egui::Rect::from_min_size(
ui.cursor().min,
Vec2::new(half, 56.0),
)),
|ui| {
let label = if copied {
format!("{} {}", CHECK, t!("goblin.receive.copied"))
} else {
format!("{} {}", COPY, t!("goblin.receive.copy_npub"))
};
if w::big_action(ui, &label, false).clicked() && !npub.is_empty() {
cb.copy_string_to_buffer(npub.clone());
cb.vibrate_copy();
self.receive_copied = Some((1, std::time::Instant::now()));
}
},
);
});
ui.add_space(16.0);
let privacy_note = if has_name {
t!("goblin.receive.privacy_note")
} else {
t!("goblin.receive.privacy_note_npub")
};
ui.label(
RichText::new(privacy_note)
.font(FontId::new(12.0, fonts::regular()))
.color(t.text_mute),
);
}
fn me_ui(&mut self, ui: &mut egui::Ui, wallet: &Wallet, cb: &dyn PlatformCallbacks) {
let t = theme::tokens();
match self.settings_page {
SettingsPage::Node => return self.node_settings_ui(ui, wallet, cb),
SettingsPage::IntegratedNode => return self.integrated_node_ui(ui, cb),
SettingsPage::Relays => return self.relays_ui(ui, wallet, cb),
SettingsPage::Nips => return self.nips_ui(ui),
SettingsPage::Pairing => return self.pairing_settings_ui(ui),
SettingsPage::Language => return self.language_settings_ui(ui),
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::Main => {}
}
ui.add_space(8.0);
ui.label(
RichText::new(t!("goblin.settings.title"))
.font(FontId::new(28.0, fonts::bold()))
.color(t.text),
);
ui.add_space(16.0);
// Profile card.
let (handle, npub, connected, bare_name, npub_hex) = wallet
.nostr_service()
.map(|s| {
let identity = s.identity.read();
let bare = identity
.nip05
.clone()
.map(|n| n.split('@').next().unwrap_or("").to_string());
let handle = bare
.clone()
.map(|n| n.to_string())
.unwrap_or_else(|| data::short_npub(&hex_of(&identity.npub)));
(
handle,
s.npub(),
s.is_connected(),
bare,
hex_of(&identity.npub),
)
})
.unwrap_or_else(|| {
(
t!("goblin.home.anonymous").to_string(),
String::new(),
false,
None,
String::new(),
)
});
let own_tex = bare_name
.as_deref()
.and_then(|_| self.handle_tex(ui.ctx(), wallet, &handle));
w::card(ui, |ui| {
ui.set_min_width(ui.available_width());
ui.horizontal(|ui| {
// Custom picture when one is set; otherwise the deterministic
// pubkey-seeded gradient identicon.
w::avatar_any(ui, &handle, &npub_hex, 56.0, own_tex.as_ref());
ui.add_space(14.0);
ui.vertical(|ui| {
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 5.0;
ui.label(
RichText::new(&handle)
.font(FontId::new(17.0, fonts::bold()))
.color(t.surface_text),
);
// A claimed/verified name gets the little check.
if bare_name.is_some() {
ui.label(
RichText::new(crate::gui::icons::SEAL_CHECK)
.font(FontId::new(15.0, fonts::regular()))
.color(t.pos),
);
}
});
// Transport status in place of the redundant second npub line.
// "Connected over Nym" is RELAY-GATED (transport_ready): the
// tunnel being warm is not enough — a relay must actually carry
// our traffic on the current exit. Otherwise show the tunnel is
// up but relays are still connecting/reconnecting.
let mixnet = if crate::tor::transport_ready() {
t!("goblin.home.connected_nym")
} else if crate::tor::is_ready() {
t!("goblin.home.nym_ready")
} else {
t!("goblin.home.connecting_nym")
};
ui.label(
RichText::new(mixnet)
.font(FontId::new(13.0, fonts::regular()))
.color(t.surface_text_dim),
);
// nostr relay status — the slower step (a relay reached over Nym).
let nostr = if connected {
t!("goblin.settings.connected_nostr")
} else {
t!("goblin.settings.connecting_relays")
};
ui.label(
RichText::new(nostr)
.font(FontId::new(12.0, fonts::regular()))
.color(t.surface_text_mute),
);
if !crate::tor::transport_ready() || !connected {
ui.ctx()
.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| {
let (rect, resp) =
ui.allocate_exact_size(Vec2::splat(36.0), Sense::click());
ui.painter().circle_filled(rect.center(), 18.0, t.accent);
ui.painter().text(
rect.center(),
egui::Align2::CENTER_CENTER,
crate::gui::icons::CLOUD_ARROW_DOWN,
FontId::new(18.0, fonts::regular()),
t.accent_ink,
);
if resp
.on_hover_cursor(egui::CursorIcon::PointingHand)
.on_hover_text(t!("goblin.settings.update_available").to_string())
.clicked()
{
open_url(ui, &update.url);
}
});
}
});
});
ui.add_space(16.0);
// Mark the scroll boundary: rows clipping under the pinned profile
// card otherwise read as sliced glyphs on an invisible edge.
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(6.0);
ScrollArea::vertical()
.auto_shrink([false; 2])
.scroll_bar_visibility(egui::scroll_area::ScrollBarVisibility::AlwaysHidden)
.show(ui, |ui| {
// Identity: username, picture, keys — first because it is the
// face of the wallet.
w::kicker(ui, &t!("goblin.settings.identity"));
ui.add_space(8.0);
if self.claim.is_none() {
self.claim = Some(ClaimState::default());
}
self.claim_ui(ui, wallet, cb);
ui.add_space(8.0);
// Hoisted above the identity card: the Nostr Relays row now lives
// inside that card (relays are a nostr concern, like the keys), but
// its open handler runs further down — so the flag is declared here.
let mut open_relays = false;
w::card(ui, |ui| {
if !npub.is_empty() {
if settings_row_btn(ui, &t!("goblin.settings.copy_npub"), COPY) {
cb.copy_string_to_buffer(npub.clone());
cb.vibrate_copy();
self.copy_flash = Some(std::time::Instant::now());
}
// One encrypted backup FILE (key + username + history sealed
// together) — replaces the old copy-nsec / copy-JSON split.
if settings_row_btn(
ui,
&t!("goblin.settings.backup_file"),
crate::gui::icons::DOWNLOAD_SIMPLE,
) && self.backup.is_none()
{
self.backup = Some(BackupState::default());
}
if settings_row_danger(
ui,
&t!("goblin.settings.rotate_key"),
crate::gui::icons::ARROWS_CLOCKWISE,
) && self.rotate.is_none()
{
self.rotate = Some(RotateState::default());
}
if settings_row_btn(
ui,
&t!("goblin.settings.import_identity"),
crate::gui::icons::KEY,
) && self.import_nsec.is_none()
{
self.import_nsec = Some(ImportState::default());
}
// Nostr relays the wallet publishes/reads gift wraps on.
// Sits with the identity rows because relays are a nostr
// concern; opens the relay editor (handled below).
if settings_row_nav(
ui,
&t!("goblin.settings.nostr_relays"),
&relay_summary(wallet),
) {
open_relays = true;
}
// Federation: which name authority (server) registers and
// verifies names. Shows the current host on the right.
let authority = wallet
.nostr_service()
.map(|s| s.config.read().home_domain())
.unwrap_or_default();
if settings_row_nav(ui, &t!("goblin.settings.name_authority"), &authority)
&& self.name_authority.is_none()
{
let cur = wallet
.nostr_service()
.map(|s| s.config.read().nip05_server())
.unwrap_or_default();
self.name_authority = Some(NameAuthorityState {
input: cur,
error: None,
});
}
}
});
// Transient confirmation that the copy landed — pairs with the
// haptic tick so the tap feels acknowledged.
if let Some(at) = self.copy_flash {
if at.elapsed().as_secs_f32() < 1.5 {
ui.add_space(6.0);
ui.vertical_centered(|ui| {
ui.label(
RichText::new(format!(
"{} {}",
crate::gui::icons::CHECK,
t!("goblin.receipt.copied")
))
.font(FontId::new(13.0, fonts::medium()))
.color(t.pos),
);
});
ui.ctx()
.request_repaint_after(std::time::Duration::from_millis(120));
} else {
self.copy_flash = None;
}
}
ui.add_space(6.0);
ui.label(
RichText::new(t!("goblin.settings.backup_note"))
.font(FontId::new(12.0, fonts::regular()))
.color(t.text_mute),
);
if self.backup.is_some() {
ui.add_space(8.0);
self.backup_ui(ui, wallet, cb);
}
if self.name_authority.is_some() {
ui.add_space(8.0);
self.name_authority_ui(ui, wallet, cb);
}
if self.rotate.is_some() {
ui.add_space(8.0);
self.rotate_ui(ui, wallet, cb);
}
if self.import_nsec.is_some() {
ui.add_space(8.0);
self.import_nsec_ui(ui, wallet, cb);
}
ui.add_space(16.0);
let mut open_node = false;
let mut open_integrated = false;
let mut open_slatepack = false;
settings_group(ui, &t!("goblin.settings.wallet"), |ui| {
if settings_row_nav(ui, &t!("goblin.settings.node"), &node_summary(wallet)) {
open_node = true;
}
// GRIM's integrated-node tabs (info, metrics, mining, node
// settings), shown in Goblin chrome. Live sync status when
// the node runs, like the Node row above.
let node_value = if crate::node::Node::is_running() {
crate::node::Node::get_sync_status_text()
} else {
String::new()
};
if settings_row_nav(ui, &t!("goblin.settings.integrated_node"), &node_value) {
open_integrated = true;
}
// GRIM's native by-hand slatepack exchange, for when a payment
// can't go through a username.
if settings_row_nav(
ui,
&t!("goblin.settings.slatepacks"),
&t!("goblin.settings.slatepacks_value"),
) {
open_slatepack = true;
}
});
if open_slatepack {
self.slatepack = SlatepackManual::default();
self.settings_page = SettingsPage::Slatepack;
}
if open_relays {
// The ACTIVE set (override or per-identity advertised set),
// so the editor shows what is really in use.
self.relay_edit = wallet
.nostr_service()
.map(|s| s.relays())
.unwrap_or_default();
self.relay_input.clear();
self.settings_page = SettingsPage::Relays;
}
if open_node {
self.node_url_input.clear();
self.node_secret_input.clear();
self.settings_page = SettingsPage::Node;
}
if open_integrated {
self.node_tab = Box::new(crate::gui::views::network::NetworkNode);
self.node_tab_back = SettingsPage::Main;
self.settings_page = SettingsPage::IntegratedNode;
}
ui.add_space(16.0);
let mut open_pairing = false;
let mut open_privacy = false;
settings_group(ui, &t!("goblin.settings.privacy"), |ui| {
// Messages, names, price and avatars ride the mixnet; the grin
// node connects directly. Normal dim value ink: the salmon
// privacy color doubled as the destructive-action color on
// this page, making a plain navigable row read as a warning.
if settings_row_nav(
ui,
&t!("goblin.settings.mixnet_routing"),
&t!("goblin.settings.messages_lookups"),
) {
open_privacy = true;
}
// Tap to cycle the incoming-payment accept policy. Value styled
// like the sibling rows' values (small/dim), not like an icon.
if settings_row_cycle(
ui,
&t!("goblin.settings.auto_accept"),
&accept_policy_label(wallet),
) {
cycle_accept_policy(wallet);
}
// Amount pairing: what the ≈ preview is shown against.
if settings_row_nav(
ui,
&t!("goblin.settings.pairing"),
crate::AppConfig::pairing().label(),
) {
open_pairing = true;
}
// Hide received amounts in payment notifications/alerts. Same
// switch widget as the incoming-requests toggle below.
if let Some(v) = settings_row_toggle(
ui,
&t!("goblin.settings.hide_amounts"),
&t!("goblin.settings.hide_amounts_sub"),
crate::AppConfig::hide_amounts(),
) {
crate::AppConfig::set_hide_amounts(v);
}
});
if open_pairing {
self.settings_page = SettingsPage::Pairing;
}
if open_privacy {
self.settings_page = SettingsPage::Privacy;
}
ui.add_space(16.0);
settings_group(ui, &t!("goblin.settings.requests"), |ui| {
let allow = wallet
.nostr_service()
.map(|s| s.config.read().allow_incoming_requests())
.unwrap_or(true);
if let Some(v) = settings_row_toggle(
ui,
&t!("goblin.settings.incoming_requests"),
&t!("goblin.settings.incoming_requests_sub"),
allow,
) {
if let Some(s) = wallet.nostr_service() {
s.config.write().set_allow_incoming_requests(v);
}
// Advertise the change so requesters see it before asking.
wallet.task(crate::wallet::types::WalletTask::NostrRepublishProfile);
}
});
ui.add_space(16.0);
w::kicker(ui, &t!("goblin.settings.appearance"));
ui.add_space(8.0);
let mut open_language = false;
w::card(ui, |ui| {
let theme_label = match crate::AppConfig::theme() {
crate::gui::theme::ThemeKind::Light => t!("goblin.settings.theme_light"),
crate::gui::theme::ThemeKind::Dark => t!("goblin.settings.theme_dark"),
crate::gui::theme::ThemeKind::Yellow => t!("goblin.settings.theme_yellow"),
};
if settings_row_btn(ui, &t!("goblin.settings.theme"), &theme_label) {
cycle_theme(ui.ctx());
}
// Language sits beside theme under Appearance; the value is the
// active language in its own name (e.g. "Deutsch").
let current = crate::AppConfig::locale()
.unwrap_or_else(|| rust_i18n::locale().to_string());
if settings_row_nav(
ui,
&t!("goblin.settings.language"),
&t!("lang_name", locale = current.as_str()),
) {
open_language = true;
}
});
if open_language {
self.settings_page = SettingsPage::Language;
}
ui.add_space(16.0);
w::kicker(ui, &t!("goblin.settings.archive"));
ui.add_space(8.0);
w::card(ui, |ui| {
if settings_row_btn(ui, &t!("goblin.settings.export_archive"), COPY) {
if let Some(s) = wallet.nostr_service() {
let json = s.store.export_json(&s.npub());
cb.copy_string_to_buffer(json);
cb.vibrate_copy();
}
}
// Destructive: danger styling + tap-twice confirm (like the
// receipt's "Cancel payment") before the archive is wiped.
let wipe_label = if self.wipe_confirm {
t!("goblin.settings.wipe_history_confirm")
} else {
t!("goblin.settings.wipe_history")
};
if settings_row_danger(ui, &wipe_label, crate::gui::icons::X) {
if self.wipe_confirm {
if let Some(s) = wallet.nostr_service() {
s.store.wipe_archive();
}
self.wipe_confirm = false;
} else {
self.wipe_confirm = true;
}
}
});
ui.add_space(16.0);
settings_group(ui, &t!("goblin.settings.about"), |ui| {
if settings_row_nav(
ui,
&t!("goblin.settings.goblin"),
&t!("goblin.settings.build", build => crate::BUILD),
) {
open_url(ui, "https://github.com/2ro/goblin/releases");
}
settings_row(
ui,
&t!("goblin.settings.network"),
&t!("goblin.settings.network_value"),
);
});
ui.add_space(16.0);
let mut open_nips = false;
settings_group(ui, &t!("goblin.settings.third_party"), |ui| {
if settings_row_nav(ui, &t!("goblin.settings.grim"), crate::VERSION) {
// Live upstream GRIM (GitHub mirror of code.gri.mw/GUI/grim).
// Was github.com/ardocrat/grim — a stale personal fork.
open_url(ui, "https://github.com/GetGrin/grim");
}
if settings_row_nav(ui, &t!("goblin.settings.grin_node"), "5.4.0") {
open_url(ui, "https://github.com/mimblewimble/grin");
}
if settings_row_nav(ui, "nostr-sdk", "0.44") {
open_url(ui, "https://github.com/rust-nostr/nostr");
}
if settings_row_nav(ui, "Tor (arti)", "0.43") {
open_url(ui, "https://gitlab.torproject.org/tpo/core/arti");
}
if settings_row_nav(ui, "egui", "0.33") {
open_url(ui, "https://github.com/emilk/egui");
}
if settings_row_nav(ui, "NIPs", "05 · 17 · 44 · 49 · 59 · 98") {
open_nips = true;
}
});
if open_nips {
self.settings_page = SettingsPage::Nips;
}
// Wallet management lives at the foot of Settings: a neutral
// switch, the red lock, then the advanced (recovery) tools —
// each its own outlined action, so the destructive lock reads
// apart from the rest.
ui.add_space(24.0);
let dim = theme::tokens().surface_text_dim;
if w::outlined_icon_action(
ui,
crate::gui::icons::USER_SWITCH,
&t!("goblin.settings.switch_wallet"),
dim,
)
.clicked()
{
self.settings_page = SettingsPage::Main;
self.switch_requested = true;
}
ui.add_space(10.0);
if w::outlined_icon_action(
ui,
crate::gui::icons::LOCK,
&t!("goblin.settings.lock_wallet"),
theme::tokens().neg,
)
.clicked()
{
wallet.close();
}
ui.add_space(10.0);
if w::outlined_icon_action(
ui,
crate::gui::icons::WRENCH,
&t!("goblin.settings.advanced"),
dim,
)
.clicked()
{
self.advanced = AdvancedState::default();
self.settings_page = SettingsPage::Advanced;
}
ui.add_space(20.0);
});
}
/// Back header for Settings sub-pages; returns true when back is tapped.
fn sub_header(&mut self, ui: &mut egui::Ui, title: &str) -> bool {
let t = theme::tokens();
let mut back = false;
ui.add_space(8.0);
ui.horizontal(|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::ARROW_LEFT,
FontId::new(16.0, fonts::regular()),
theme::ink_for(t.surface2),
);
back = resp.clicked();
ui.add_space(12.0);
ui.label(
RichText::new(title)
.font(FontId::new(24.0, fonts::bold()))
.color(t.text),
);
});
ui.add_space(16.0);
back
}
/// Node connection editor: pick integrated/external, add or remove nodes.
fn pairing_settings_ui(&mut self, ui: &mut egui::Ui) {
let t = theme::tokens();
if self.sub_header(ui, &t!("goblin.pairing.title")) {
self.settings_page = SettingsPage::Main;
return;
}
ScrollArea::vertical()
.auto_shrink([false; 2])
.scroll_bar_visibility(egui::scroll_area::ScrollBarVisibility::AlwaysHidden)
.show(ui, |ui| {
ui.label(
RichText::new(t!("goblin.pairing.intro"))
.font(FontId::new(13.0, fonts::regular()))
.color(t.text_dim),
);
ui.add_space(12.0);
let current = crate::AppConfig::pairing();
settings_group(ui, &t!("goblin.pairing.pair_with"), |ui| {
for p in crate::settings::Pairing::ALL {
let active = p == current;
let row = ui.horizontal(|ui| {
ui.label(
RichText::new(p.label())
.font(FontId::new(15.0, fonts::medium()))
.color(t.surface_text),
);
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
if active {
ui.label(
RichText::new(crate::gui::icons::CHECK)
.font(FontId::new(16.0, fonts::regular()))
.color(t.pos),
);
}
});
});
ui.add_space(10.0);
if !active && row.response.interact(Sense::click()).clicked() {
crate::AppConfig::set_pairing(p);
}
}
});
ui.add_space(10.0);
ui.label(
RichText::new(t!("goblin.pairing.rates_note"))
.font(FontId::new(12.0, fonts::regular()))
.color(t.text_dim),
);
ui.add_space(16.0);
});
}
/// Language picker: the six shipped locales, each in its own name. Tapping one
/// switches the active locale and persists it (mirrors the GRIM interface
/// settings, but in Goblin's row style like the pairing picker).
fn language_settings_ui(&mut self, ui: &mut egui::Ui) {
let t = theme::tokens();
if self.sub_header(ui, &t!("goblin.settings.language")) {
self.settings_page = SettingsPage::Main;
return;
}
let current = crate::AppConfig::locale().unwrap_or_else(|| rust_i18n::locale().to_string());
ScrollArea::vertical()
.auto_shrink([false; 2])
.scroll_bar_visibility(egui::scroll_area::ScrollBarVisibility::AlwaysHidden)
.show(ui, |ui| {
settings_group(ui, &t!("goblin.settings.language"), |ui| {
for locale in rust_i18n::available_locales!() {
let active = current == locale;
let row = ui.horizontal(|ui| {
ui.label(
RichText::new(t!("lang_name", locale = locale))
.font(FontId::new(15.0, fonts::medium()))
.color(t.surface_text),
);
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
if active {
ui.label(
RichText::new(crate::gui::icons::CHECK)
.font(FontId::new(16.0, fonts::regular()))
.color(t.pos),
);
}
});
});
ui.add_space(10.0);
if !active && row.response.interact(Sense::click()).clicked() {
rust_i18n::set_locale(locale);
crate::AppConfig::save_locale(locale);
}
}
});
ui.add_space(16.0);
});
}
/// Network-privacy breakdown: what rides the Nym mixnet versus what connects
/// directly. Honest by design — no claim that node traffic is mixed, and no
/// toggle to route it (chain sync is heavy and not tied to your identity).
fn privacy_ui(&mut self, ui: &mut egui::Ui) {
let t = theme::tokens();
if self.sub_header(ui, &t!("goblin.privacy.title")) {
self.settings_page = SettingsPage::Main;
return;
}
ScrollArea::vertical()
.auto_shrink([false; 2])
.scroll_bar_visibility(egui::scroll_area::ScrollBarVisibility::AlwaysHidden)
.show(ui, |ui| {
ui.label(
RichText::new(t!("goblin.privacy.intro"))
.font(FontId::new(14.0, fonts::regular()))
.color(t.text_dim),
);
ui.add_space(16.0);
let mixnet = [
(
t!("goblin.privacy.payments"),
t!("goblin.privacy.payments_blurb"),
),
(
t!("goblin.privacy.usernames"),
t!("goblin.privacy.usernames_blurb"),
),
(
t!("goblin.privacy.price_avatars"),
t!("goblin.privacy.price_avatars_blurb"),
),
];
settings_group(ui, &t!("goblin.privacy.over_mixnet"), |ui| {
for (title, blurb) in &mixnet {
privacy_line(ui, t.neg, title, blurb);
}
});
ui.add_space(16.0);
settings_group(ui, &t!("goblin.privacy.direct_connection"), |ui| {
privacy_line(
ui,
t.surface_text_mute,
&t!("goblin.privacy.grin_node"),
&t!("goblin.privacy.grin_node_blurb"),
);
});
ui.add_space(16.0);
});
}
/// Advanced (wallet-recovery) page — GRIM's low-level tools surfaced in the
/// goblin style: repair, restore-from-seed, reveal the recovery phrase, and
/// delete. The two destructive actions arm a tap-twice confirm.
fn advanced_ui(&mut self, ui: &mut egui::Ui, wallet: &Wallet, cb: &dyn PlatformCallbacks) {
use crate::wallet::types::ConnectionMethod;
let t = theme::tokens();
if self.sub_header(ui, &t!("goblin.advanced.title")) {
self.advanced = AdvancedState::default();
self.settings_page = SettingsPage::Main;
return;
}
// Repair needs a synced node; mirror GRIM's availability check.
let integrated = wallet.get_current_connection() == ConnectionMethod::Integrated;
let integrated_ready =
crate::node::Node::get_sync_status() == Some(grin_chain::SyncStatus::NoSync);
let repair_unavailable = wallet.sync_error() || (integrated && !integrated_ready);
let repairing = wallet.is_repairing();
let progress = wallet.repairing_progress();
let mut leave = false;
let mut open_node = false;
{
let adv = &mut self.advanced;
ScrollArea::vertical()
.auto_shrink([false; 2])
.scroll_bar_visibility(egui::scroll_area::ScrollBarVisibility::AlwaysHidden)
.show(ui, |ui| {
ui.label(
RichText::new(t!("goblin.advanced.intro"))
.font(FontId::new(14.0, fonts::regular()))
.color(t.text_dim),
);
ui.add_space(16.0);
// Run your own node (the internal node). Opens the node-connection
// page, where you pick the integrated node or an external one.
w::card(ui, |ui| {
ui.set_min_width(ui.available_width());
advanced_head(ui, &t!("goblin.node.integrated"), t.surface_text);
advanced_desc(ui, &t!("goblin.advanced.own_node_desc"));
ui.add_space(10.0);
if wallet.get_current_connection() == ConnectionMethod::Integrated {
ui.label(
RichText::new(format!(
"{} {}",
crate::gui::icons::CHECK,
t!("goblin.advanced.own_node_active")
))
.font(FontId::new(13.0, fonts::medium()))
.color(t.pos),
);
ui.add_space(10.0);
}
if w::big_action_on_card(ui, &t!("goblin.advanced.manage_node")).clicked() {
open_node = true;
}
});
ui.add_space(12.0);
// Repair.
w::card(ui, |ui| {
ui.set_min_width(ui.available_width());
advanced_head(ui, &t!("goblin.advanced.repair"), t.surface_text);
advanced_desc(ui, &t!("goblin.advanced.repair_desc"));
ui.add_space(10.0);
if repairing {
ui.label(
RichText::new(
t!("goblin.advanced.repairing", pct => progress.to_string()),
)
.font(FontId::new(13.0, fonts::medium()))
.color(t.accent),
);
} else if repair_unavailable {
ui.label(
RichText::new(t!("goblin.advanced.repair_unavailable"))
.font(FontId::new(13.0, fonts::medium()))
.color(t.neg),
);
} else if adv.confirm_repair {
// Repair re-scans the chain — it can take a few minutes.
// Warn + confirm in the accent (yellow) before starting.
ui.label(
RichText::new(t!("goblin.advanced.repair_confirm_note"))
.font(FontId::new(13.0, fonts::regular()))
.color(t.text_dim),
);
ui.add_space(10.0);
if w::big_action_on_card_ink(
ui,
&t!("goblin.advanced.repair_confirm"),
t.accent,
)
.clicked()
{
adv.confirm_repair = false;
wallet.repair();
}
} else if w::big_action_on_card(ui, &t!("goblin.advanced.repair")).clicked()
{
adv.confirm_repair = true;
}
});
ui.add_space(12.0);
// Restore (rebuild local data from the seed).
w::card(ui, |ui| {
ui.set_min_width(ui.available_width());
advanced_head(ui, &t!("goblin.advanced.restore"), t.surface_text);
advanced_desc(ui, &t!("goblin.advanced.restore_desc"));
ui.add_space(10.0);
if adv.confirm_restore {
ui.label(
RichText::new(t!("goblin.advanced.restore_confirm_note"))
.font(FontId::new(13.0, fonts::regular()))
.color(t.text_dim),
);
ui.add_space(10.0);
if w::big_action_on_card_ink(
ui,
&t!("goblin.advanced.restore_confirm"),
t.neg,
)
.clicked()
{
wallet.delete_db();
leave = true;
}
} else if w::big_action_on_card(ui, &t!("goblin.advanced.restore"))
.clicked()
{
adv.confirm_restore = true;
}
});
ui.add_space(12.0);
// Recovery phrase (the grin seed words).
w::card(ui, |ui| {
ui.set_min_width(ui.available_width());
advanced_head(ui, &t!("goblin.advanced.show_phrase"), t.surface_text);
advanced_desc(ui, &t!("goblin.advanced.phrase_desc"));
ui.add_space(10.0);
if let Some(words) = adv.revealed.clone() {
w::field_well(ui, |ui| {
ui.label(
RichText::new(words)
.font(FontId::new(15.0, fonts::medium()))
.color(t.surface_text),
);
});
ui.add_space(10.0);
if w::big_action_on_card(ui, &t!("goblin.advanced.hide")).clicked() {
adv.revealed = None;
adv.reveal_pass.clear();
}
} else {
w::field_well(ui, |ui| {
TextEdit::new(egui::Id::from("advanced_reveal_pass"))
.focus(false)
.hint_text(t!("goblin.advanced.password"))
.password()
.text_color(t.surface_text)
.body()
.ui(ui, &mut adv.reveal_pass, cb);
});
if adv.wrong_pass {
ui.add_space(6.0);
ui.label(
RichText::new(t!("goblin.advanced.wrong_password"))
.font(FontId::new(13.0, fonts::medium()))
.color(t.neg),
);
}
ui.add_space(10.0);
ui.add_enabled_ui(!adv.reveal_pass.is_empty(), |ui| {
if w::big_action_on_card(ui, &t!("goblin.advanced.reveal"))
.clicked()
{
match wallet.get_recovery(adv.reveal_pass.clone()) {
Ok(phrase) => {
adv.revealed = Some(phrase.to_string());
adv.wrong_pass = false;
adv.reveal_pass.clear();
}
Err(_) => {
adv.wrong_pass = true;
}
}
}
});
}
});
ui.add_space(12.0);
// Nostr key (nsec). Password-gated reveal, then Copy + a QR
// so it can be carried into a nostr app's private-key login
// (e.g. magick.market) without retyping. Same gate as the
// recovery phrase above.
w::card(ui, |ui| {
ui.set_min_width(ui.available_width());
advanced_head(ui, &t!("goblin.advanced.nostr_key"), t.surface_text);
advanced_desc(ui, &t!("goblin.advanced.nostr_key_desc"));
ui.add_space(10.0);
if let Some(nsec) = adv.nsec_revealed.clone() {
w::field_well(ui, |ui| {
ui.label(
RichText::new(&nsec)
.font(FontId::new(14.0, fonts::medium()))
.color(t.surface_text),
);
});
ui.add_space(10.0);
if w::big_action_on_card(ui, &t!("goblin.advanced.copy_nsec")).clicked()
{
cb.copy_string_to_buffer(nsec.clone());
}
ui.add_space(8.0);
let qr_label = if adv.nsec_qr {
t!("goblin.advanced.hide_qr")
} else {
t!("goblin.advanced.show_qr")
};
if w::big_action_on_card(ui, &qr_label).clicked() {
adv.nsec_qr = !adv.nsec_qr;
}
if adv.nsec_qr {
ui.add_space(10.0);
ui.vertical_centered(|ui| {
w::qr_code(ui, &nsec, 220.0);
});
}
ui.add_space(10.0);
if w::big_action_on_card(ui, &t!("goblin.advanced.hide")).clicked() {
adv.nsec_revealed = None;
adv.nsec_qr = false;
adv.nsec_pass.clear();
}
} else {
w::field_well(ui, |ui| {
TextEdit::new(egui::Id::from("advanced_nsec_pass"))
.focus(false)
.hint_text(t!("goblin.advanced.password"))
.password()
.text_color(t.surface_text)
.body()
.ui(ui, &mut adv.nsec_pass, cb);
});
if adv.nsec_wrong {
ui.add_space(6.0);
ui.label(
RichText::new(t!("goblin.advanced.wrong_password"))
.font(FontId::new(13.0, fonts::medium()))
.color(t.neg),
);
}
ui.add_space(10.0);
ui.add_enabled_ui(!adv.nsec_pass.is_empty(), |ui| {
if w::big_action_on_card(ui, &t!("goblin.advanced.reveal_nsec"))
.clicked()
{
match wallet.get_nostr_nsec(adv.nsec_pass.clone()) {
Ok(nsec) => {
adv.nsec_revealed = Some(nsec);
adv.nsec_wrong = false;
adv.nsec_pass.clear();
}
Err(_) => {
adv.nsec_wrong = true;
}
}
}
});
}
});
ui.add_space(12.0);
// Delete.
w::card(ui, |ui| {
ui.set_min_width(ui.available_width());
advanced_head(ui, &t!("goblin.advanced.delete"), t.neg);
advanced_desc(ui, &t!("goblin.advanced.delete_desc"));
ui.add_space(10.0);
if adv.confirm_delete {
if w::big_action_on_card_ink(
ui,
&t!("goblin.advanced.delete_confirm"),
t.neg,
)
.clicked()
{
wallet.delete_wallet();
leave = true;
}
} else if w::big_action_on_card_ink(
ui,
&t!("goblin.advanced.delete"),
t.neg,
)
.clicked()
{
adv.confirm_delete = true;
}
});
ui.add_space(20.0);
});
}
if leave {
self.advanced = AdvancedState::default();
self.settings_page = SettingsPage::Main;
}
if open_node {
// Advanced → "Manage node connection" opens Goblin's own Node screen
// (its Advanced button reaches the integrated-node tabs from there).
self.node_url_input.clear();
self.node_secret_input.clear();
self.settings_page = SettingsPage::Node;
}
}
/// GRIM's four integrated-node tabs (Info / Metrics / Mining / Settings)
/// hosted under a Goblin back header and segmented control — GRIM's
/// dual-panel and floating-navbar chrome are never rendered. The header
/// title follows the active tab, like GRIM's own title panel.
fn integrated_node_ui(&mut self, ui: &mut egui::Ui, cb: &dyn PlatformCallbacks) {
use crate::gui::icons::{DATABASE, FACTORY, FADERS, GAUGE};
use crate::gui::views::network::types::NodeTabType;
use crate::gui::views::network::{
NetworkContent, NetworkMetrics, NetworkMining, NetworkNode, NetworkSettings,
disabled_node_ui, node_error_ui,
};
use crate::node::Node;
let title = self.node_tab.get_type().title();
if self.sub_header(ui, &title) {
self.settings_page = self.node_tab_back;
return;
}
let selected = match self.node_tab.get_type() {
NodeTabType::Info => 0,
NodeTabType::Metrics => 1,
NodeTabType::Mining => 2,
NodeTabType::Settings => 3,
};
if let Some(i) = w::segmented(ui, &[DATABASE, GAUGE, FACTORY, FADERS], selected) {
self.node_tab = match i {
0 => Box::new(NetworkNode),
1 => Box::new(NetworkMetrics),
2 => Box::new(NetworkMining::default()),
_ => Box::new(NetworkSettings::default()),
};
}
ui.add_space(12.0);
// Same availability gate as GRIM's NetworkContent: the Settings tab is
// editable with the node off; the live tabs need a running node with
// stats before their content can draw.
if self.node_tab.get_type() != NodeTabType::Settings {
if let Some(err) = Node::get_error() {
node_error_ui(ui, err);
} else if !Node::is_running() {
disabled_node_ui(ui);
} else if Node::get_stats().is_none() || Node::is_restarting() || Node::is_stopping() {
NetworkContent::loading_ui(ui, None::<String>);
} else {
self.node_tab.tab_ui(ui, cb);
}
} else {
self.node_tab.tab_ui(ui, cb);
}
// Keep the stats fresh while the node runs.
if Node::is_running() {
ui.ctx().request_repaint_after(Node::STATS_UPDATE_DELAY);
}
}
fn node_settings_ui(&mut self, ui: &mut egui::Ui, wallet: &Wallet, cb: &dyn PlatformCallbacks) {
use crate::wallet::types::ConnectionMethod;
use crate::wallet::{ConnectionsConfig, ExternalConnection};
let t = theme::tokens();
if self.sub_header(ui, &t!("goblin.node.title")) {
self.settings_page = SettingsPage::Main;
return;
}
ScrollArea::vertical()
.auto_shrink([false; 2])
.scroll_bar_visibility(egui::scroll_area::ScrollBarVisibility::AlwaysHidden)
.show(ui, |ui| {
let live = wallet.get_current_connection();
let saved = wallet.get_config().connection();
settings_group(ui, &t!("goblin.node.connection"), |ui| {
// Integrated node (run your own) sits at the top of the picker.
{
let active = matches!(&saved, ConnectionMethod::Integrated);
let row = ui.horizontal(|ui| {
ui.label(
RichText::new(t!("goblin.node.integrated"))
.font(FontId::new(15.0, fonts::medium()))
.color(t.surface_text),
);
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
if active {
ui.label(
RichText::new(crate::gui::icons::CHECK)
.font(FontId::new(16.0, fonts::regular()))
.color(t.pos),
);
}
});
});
ui.add_space(10.0);
if !active && row.response.interact(Sense::click()).clicked() {
wallet.update_connection(&ConnectionMethod::Integrated);
// Apply to the running session now, not on next unlock.
wallet.reconnect_node();
}
}
for conn in ConnectionsConfig::ext_conn_list() {
let active =
matches!(&saved, ConnectionMethod::External(id, _) if *id == conn.id);
let label = conn.url.replace("https://", "").replace("http://", "");
let mut removed = false;
let row = ui.horizontal(|ui| {
ui.label(
RichText::new(&label)
.font(FontId::new(15.0, fonts::medium()))
.color(t.surface_text),
);
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
if active {
ui.label(
RichText::new(crate::gui::icons::CHECK)
.font(FontId::new(16.0, fonts::regular()))
.color(t.pos),
);
} else {
// Trash, not an X: a grey × next to the active row's
// green check read as a failed-status icon, not the
// remove action it actually is.
let x = ui.label(
RichText::new(crate::gui::icons::TRASH_SIMPLE)
.font(FontId::new(15.0, fonts::regular()))
.color(t.surface_text_dim),
);
if x.interact(Sense::click()).clicked() {
ConnectionsConfig::remove_ext_conn(conn.id);
removed = true;
}
}
});
});
ui.add_space(10.0);
if !removed && !active && row.response.interact(Sense::click()).clicked() {
wallet.update_connection(&ConnectionMethod::External(
conn.id,
conn.url.clone(),
));
// Apply to the running session now, not on next unlock.
wallet.reconnect_node();
}
}
});
if saved != live {
ui.add_space(8.0);
ui.label(
RichText::new(t!("goblin.node.applies_after"))
.font(FontId::new(13.0, fonts::regular()))
.color(t.text_dim),
);
}
ui.add_space(16.0);
settings_group(ui, &t!("goblin.node.add_external"), |ui| {
TextEdit::new(egui::Id::from("set_node_url"))
.focus(false)
.hint_text("https://node.example.com:3413")
.text_color(t.surface_text)
.body()
.ui(ui, &mut self.node_url_input, cb);
ui.add_space(8.0);
TextEdit::new(egui::Id::from("set_node_secret"))
.focus(false)
.hint_text(t!("goblin.node.api_secret_hint"))
.text_color(t.surface_text)
.body()
.ui(ui, &mut self.node_secret_input, cb);
});
ui.add_space(10.0);
let url = self.node_url_input.trim().to_string();
let valid = url.starts_with("http://") || url.starts_with("https://");
if w::big_action(ui, &t!("goblin.node.add_node"), false).clicked() && valid {
let secret = {
let s = self.node_secret_input.trim();
if s.is_empty() {
None
} else {
Some(s.to_string())
}
};
let conn = ExternalConnection::new(url, None, secret);
ConnectionsConfig::add_ext_conn(conn.clone());
wallet
.update_connection(&ConnectionMethod::External(conn.id, conn.url.clone()));
// Apply to the running session now, not on next unlock.
wallet.reconnect_node();
self.node_url_input.clear();
self.node_secret_input.clear();
}
ui.add_space(10.0);
// Advanced: GRIM's integrated-node tabs (info, metrics, mining
// with stratum, node settings) inside Goblin chrome.
if w::big_action(ui, &t!("goblin.settings.node_advanced"), true).clicked() {
self.node_tab = Box::new(crate::gui::views::network::NetworkNode);
self.node_tab_back = SettingsPage::Node;
self.settings_page = SettingsPage::IntegratedNode;
}
ui.add_space(16.0);
});
}
/// Relay list editor; saving restarts the nostr service live.
fn relays_ui(&mut self, ui: &mut egui::Ui, wallet: &Wallet, cb: &dyn PlatformCallbacks) {
let t = theme::tokens();
if self.sub_header(ui, &t!("goblin.relays.title")) {
self.settings_page = SettingsPage::Main;
return;
}
ScrollArea::vertical()
.auto_shrink([false; 2])
.scroll_bar_visibility(egui::scroll_area::ScrollBarVisibility::AlwaysHidden)
.show(ui, |ui| {
ui.label(
RichText::new(t!("goblin.relays.intro"))
.font(FontId::new(13.0, fonts::regular()))
.color(t.text_dim),
);
ui.add_space(14.0);
settings_group(ui, &t!("goblin.relays.your_relays"), |ui| {
let mut remove: Option<usize> = None;
let many = self.relay_edit.len() > 1;
for (i, relay) in self.relay_edit.iter().enumerate() {
ui.horizontal(|ui| {
ui.label(
RichText::new(relay)
.font(FontId::new(14.0, fonts::medium()))
.color(t.surface_text),
);
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
if many {
let x = ui.label(
RichText::new(crate::gui::icons::X)
.font(FontId::new(15.0, fonts::regular()))
.color(t.surface_text_mute),
);
if x.interact(Sense::click()).clicked() {
remove = Some(i);
}
}
});
});
ui.add_space(10.0);
}
if let Some(i) = remove {
self.relay_edit.remove(i);
}
});
ui.add_space(16.0);
settings_group(ui, &t!("goblin.relays.add_relay"), |ui| {
TextEdit::new(egui::Id::from("set_relay"))
.focus(false)
.hint_text("wss://relay.example.com")
.text_color(t.surface_text)
.body()
.ui(ui, &mut self.relay_input, cb);
});
ui.add_space(10.0);
let relay = self.relay_input.trim().to_string();
let valid = relay.starts_with("wss://") || relay.starts_with("ws://");
if w::big_action_on_card(ui, &t!("goblin.relays.add_relay_btn")).clicked()
&& valid && !self.relay_edit.contains(&relay)
{
self.relay_edit.push(relay);
self.relay_input.clear();
}
ui.add_space(10.0);
if w::big_action(ui, &t!("goblin.relays.save_reconnect"), false).clicked() {
if let Some(s) = wallet.nostr_service() {
{
let mut c = s.config.write();
c.set_relays(self.relay_edit.clone());
c.save();
}
s.restart(wallet.clone());
}
self.settings_page = SettingsPage::Main;
}
ui.add_space(16.0);
});
}
/// Manual slatepack exchange — GRIM's native by-hand flow, exposed as an
/// advanced fallback for when a payment can't ride a @username.
fn slatepack_ui(&mut self, ui: &mut egui::Ui, wallet: &Wallet, cb: &dyn PlatformCallbacks) {
let t = theme::tokens();
if self.sub_header(ui, &t!("goblin.settings.slatepacks")) {
self.settings_page = SettingsPage::Main;
return;
}
ScrollArea::vertical()
.auto_shrink([false; 2])
.scroll_bar_visibility(egui::scroll_area::ScrollBarVisibility::AlwaysHidden)
.show(ui, |ui| {
ui.label(
RichText::new(t!("goblin.settings.sp_intro"))
.font(FontId::new(13.0, fonts::regular()))
.color(t.text_dim),
);
ui.add_space(14.0);
// Receive / continue: paste a slatepack, let the wallet route it.
let mut do_process = false;
settings_group(ui, &t!("goblin.settings.sp_receive_group"), |ui| {
ui.label(
RichText::new(t!("goblin.settings.sp_receive_blurb"))
.font(FontId::new(12.5, fonts::regular()))
.color(t.surface_text_dim),
);
ui.add_space(8.0);
TextEdit::new(egui::Id::from("sp_paste"))
.focus(false)
.paste()
.hint_text("BEGINSLATEPACK. … ENDSLATEPACK.")
.text_color(t.surface_text)
.body()
.ui(ui, &mut self.slatepack.paste, cb);
});
ui.add_space(10.0);
if w::big_action(ui, &t!("goblin.settings.sp_process"), false).clicked() {
do_process = true;
}
if do_process {
let text = self.slatepack.paste.trim().to_string();
if text.is_empty() {
self.slatepack.error =
Some(t!("goblin.settings.sp_paste_first").to_string());
self.slatepack.status = None;
} else {
use crate::wallet::types::ManualSlatepackOutcome as Out;
match wallet.manual_process_slatepack(&text) {
Ok(Out::Response(reply)) => {
self.slatepack.result = reply;
self.slatepack.status =
Some(t!("goblin.settings.sp_reply_ready").to_string());
self.slatepack.error = None;
self.slatepack.paste.clear();
}
Ok(Out::Finalizing) => {
self.slatepack.result.clear();
self.slatepack.status =
Some(t!("goblin.settings.sp_finalizing").to_string());
self.slatepack.error = None;
self.slatepack.paste.clear();
}
Err(e) => {
self.slatepack.error = Some(e.to_string());
self.slatepack.status = None;
}
}
}
}
// Send: create a slatepack to hand over out-of-band.
ui.add_space(16.0);
let mut do_send = false;
settings_group(ui, &t!("goblin.settings.sp_create_group"), |ui| {
ui.label(
RichText::new(t!("goblin.settings.sp_create_blurb"))
.font(FontId::new(12.5, fonts::regular()))
.color(t.surface_text_dim),
);
ui.add_space(8.0);
TextEdit::new(egui::Id::from("sp_amount"))
.focus(false)
.numeric()
.hint_text(t!("goblin.settings.sp_amount_hint"))
.text_color(t.surface_text)
.body()
.ui(ui, &mut self.slatepack.amount, cb);
ui.add_space(8.0);
TextEdit::new(egui::Id::from("sp_addr"))
.focus(false)
.hint_text(t!("goblin.settings.sp_addr_hint"))
.text_color(t.surface_text)
.body()
.ui(ui, &mut self.slatepack.address, cb);
});
ui.add_space(10.0);
if w::big_action(ui, &t!("goblin.settings.sp_create"), false).clicked() {
do_send = true;
}
if do_send {
match grin_core::core::amount_from_hr_string(self.slatepack.amount.trim()) {
Ok(a) if a > 0 => {
let s = self.slatepack.address.trim();
let dest = if s.is_empty() {
None
} else {
Some(s.to_string())
};
match wallet.manual_send_slatepack(a, dest) {
Ok(text) => {
self.slatepack.result = text;
self.slatepack.status =
Some(t!("goblin.settings.sp_ready").to_string());
self.slatepack.error = None;
}
Err(e) => {
self.slatepack.error = Some(e.to_string());
self.slatepack.status = None;
}
}
}
_ => {
self.slatepack.error =
Some(t!("goblin.settings.sp_amount_gt_zero").to_string());
self.slatepack.status = None;
}
}
}
// Status, error, and the produced slatepack (copyable).
if let Some(err) = self.slatepack.error.clone() {
ui.add_space(10.0);
ui.label(
RichText::new(err)
.font(FontId::new(13.0, fonts::regular()))
.color(t.neg),
);
}
if let Some(status) = self.slatepack.status.clone() {
ui.add_space(10.0);
ui.label(
RichText::new(status)
.font(FontId::new(13.0, fonts::regular()))
.color(t.text_dim),
);
}
let result = self.slatepack.result.clone();
if !result.is_empty() {
ui.add_space(14.0);
settings_group(ui, &t!("goblin.settings.sp_to_send"), |ui| {
let preview: String = result.chars().take(120).collect();
let preview = if result.chars().count() > 120 {
format!("{preview}…")
} else {
preview
};
ui.label(
RichText::new(preview)
.font(FontId::new(12.0, fonts::mono()))
.color(t.surface_text_dim),
);
});
ui.add_space(10.0);
if w::big_action(ui, &t!("goblin.settings.sp_copy"), false).clicked() {
cb.copy_string_to_buffer(result);
cb.vibrate_copy();
}
}
ui.add_space(16.0);
});
}
/// What-is-nostr explainer and tappable NIP reference list.
fn nips_ui(&mut self, ui: &mut egui::Ui) {
let t = theme::tokens();
if self.sub_header(ui, &t!("goblin.nips.title")) {
self.settings_page = SettingsPage::Main;
return;
}
ScrollArea::vertical()
.auto_shrink([false; 2])
.scroll_bar_visibility(egui::scroll_area::ScrollBarVisibility::AlwaysHidden)
.show(ui, |ui| {
ui.label(
RichText::new(t!("goblin.nips.intro1"))
.font(FontId::new(14.0, fonts::regular()))
.color(t.text_dim),
);
ui.add_space(10.0);
ui.label(
RichText::new(t!("goblin.nips.intro2"))
.font(FontId::new(14.0, fonts::regular()))
.color(t.text_dim),
);
ui.add_space(16.0);
let nips = [
(
"05",
t!("goblin.nips.n05_title"),
t!("goblin.nips.n05_blurb"),
),
(
"17",
t!("goblin.nips.n17_title"),
t!("goblin.nips.n17_blurb"),
),
(
"44",
t!("goblin.nips.n44_title"),
t!("goblin.nips.n44_blurb"),
),
(
"49",
t!("goblin.nips.n49_title"),
t!("goblin.nips.n49_blurb"),
),
(
"59",
t!("goblin.nips.n59_title"),
t!("goblin.nips.n59_blurb"),
),
(
"98",
t!("goblin.nips.n98_title"),
t!("goblin.nips.n98_blurb"),
),
];
for (num, title, blurb) in &nips {
let resp = ui.scope(|ui| {
w::card(ui, |ui| {
ui.set_min_width(ui.available_width());
ui.label(
RichText::new(format!("NIP-{} · {}", num, title))
.font(FontId::new(14.0, fonts::semibold()))
.color(t.surface_text),
);
ui.add_space(2.0);
ui.label(
RichText::new(blurb.as_ref())
.font(FontId::new(12.0, fonts::regular()))
.color(t.surface_text_dim),
);
});
});
if resp.response.interact(Sense::click()).clicked() {
open_url(
ui,
&format!(
"https://github.com/nostr-protocol/nips/blob/master/{}.md",
num
),
);
}
ui.add_space(8.0);
}
ui.add_space(16.0);
});
}
/// Inline key-rotation flow: warning → typed RESET + password → result.
fn rotate_ui(&mut self, ui: &mut egui::Ui, wallet: &Wallet, cb: &dyn PlatformCallbacks) {
let t = theme::tokens();
let rotate = self.rotate.as_mut().unwrap();
// Poll the worker result.
if rotate.stage == 3 {
if let Some(res) = rotate.result.lock().unwrap().take() {
match res {
Ok(npub) => {
rotate.new_npub = npub;
rotate.stage = 4;
}
Err(e) => {
rotate.error = e;
rotate.stage = 5;
}
}
}
}
let mut close = false;
w::card(ui, |ui| {
ui.set_min_width(ui.available_width());
match rotate.stage {
1 => {
ui.label(
RichText::new(t!("goblin.settings.rotate_key"))
.font(FontId::new(15.0, fonts::semibold()))
.color(t.neg),
);
ui.add_space(6.0);
for line in [
t!("goblin.settings.rotate_line1"),
t!("goblin.settings.rotate_line2"),
t!("goblin.settings.rotate_line3"),
t!("goblin.settings.rotate_line4"),
t!("goblin.settings.rotate_line5"),
] {
ui.label(
RichText::new(line)
.font(FontId::new(13.0, fonts::regular()))
.color(t.surface_text_dim),
);
ui.add_space(4.0);
}
ui.add_space(8.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()
{
close = true;
}
},
);
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.settings.continue"), false)
.clicked()
{
rotate.stage = 2;
}
},
);
});
}
2 => {
ui.label(
RichText::new(t!("goblin.settings.final_confirmation"))
.font(FontId::new(15.0, fonts::semibold()))
.color(t.neg),
);
ui.add_space(6.0);
ui.label(
RichText::new(t!("goblin.settings.rotate_confirm_blurb"))
.font(FontId::new(13.0, fonts::regular()))
.color(t.surface_text_dim),
);
ui.add_space(10.0);
w::field_well(ui, |ui| {
TextEdit::new(egui::Id::from("rotate_reset"))
.focus(false)
.hint_text(t!("goblin.settings.type_reset"))
.text_color(t.surface_text)
.body()
.ui(ui, &mut rotate.reset_input, cb);
});
ui.add_space(8.0);
w::field_well(ui, |ui| {
TextEdit::new(egui::Id::from("rotate_pass"))
.focus(false)
.hint_text(t!("goblin.settings.wallet_password"))
.password()
.text_color(t.surface_text)
.body()
.ui(ui, &mut rotate.password, cb);
});
ui.add_space(10.0);
let armed = rotate.reset_input.trim() == "RESET" && !rotate.password.is_empty();
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()
{
close = true;
}
},
);
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.settings.rotate_key_btn"),
false,
)
.clicked()
{
rotate.stage = 3;
let slot = rotate.result.clone();
let password = std::mem::take(&mut rotate.password);
rotate.reset_input.clear();
let wallet = wallet.clone();
std::thread::spawn(move || {
let res = wallet.rotate_nostr_identity(password);
*slot.lock().unwrap() = Some(res);
});
}
});
},
);
});
}
3 => {
ui.horizontal(|ui| {
View::small_loading_spinner(ui);
ui.add_space(8.0);
ui.label(
RichText::new(t!("goblin.settings.rotating_key"))
.font(FontId::new(13.0, fonts::regular()))
.color(t.surface_text_dim),
);
});
ui.ctx().request_repaint();
}
4 => {
ui.label(
RichText::new(t!("goblin.settings.key_rotated"))
.font(FontId::new(15.0, fonts::semibold()))
.color(t.pos),
);
ui.add_space(4.0);
let npub = &rotate.new_npub;
let short = if npub.len() > 18 {
format!("{}{}", &npub[..12], &npub[npub.len() - 6..])
} else {
npub.clone()
};
ui.label(
RichText::new(t!("goblin.settings.new_npub", npub => short))
.font(FontId::new(13.0, fonts::mono()))
.color(t.surface_text_dim),
);
ui.add_space(6.0);
ui.label(
RichText::new(t!("goblin.settings.backup_new_key"))
.font(FontId::new(13.0, fonts::semibold()))
.color(t.neg),
);
ui.add_space(10.0);
if w::big_action_on_card(ui, &t!("goblin.settings.copy_new_nsec")).clicked() {
if let Some(nsec) = wallet.nostr_service().and_then(|s| s.nsec()) {
cb.copy_string_to_buffer(nsec);
cb.vibrate_copy();
}
}
ui.add_space(8.0);
if w::big_action(ui, &t!("goblin.settings.done"), false).clicked() {
close = true;
}
}
_ => {
ui.label(
RichText::new(t!("goblin.settings.rotation_failed"))
.font(FontId::new(15.0, fonts::semibold()))
.color(t.neg),
);
ui.add_space(4.0);
ui.label(
RichText::new(&rotate.error)
.font(FontId::new(13.0, fonts::regular()))
.color(t.surface_text_dim),
);
ui.add_space(10.0);
if w::big_action_on_card(ui, &t!("goblin.settings.close")).clicked() {
close = true;
}
}
}
});
if close {
self.rotate = None;
}
}
/// Inline nsec-import flow: replaces the identity with an imported key.
/// Inline "change name authority" editor: set the NIP-05 server that registers
/// and verifies names. Lets a user on one instance pay names on another.
fn name_authority_ui(
&mut self,
ui: &mut egui::Ui,
wallet: &Wallet,
cb: &dyn PlatformCallbacks,
) {
let t = theme::tokens();
let na = self.name_authority.as_mut().unwrap();
let mut close = false;
w::card(ui, |ui| {
ui.set_min_width(ui.available_width());
ui.label(
RichText::new(t!("goblin.settings.name_authority_title"))
.font(FontId::new(15.0, fonts::semibold()))
.color(t.surface_text),
);
ui.add_space(6.0);
ui.label(
RichText::new(t!("goblin.settings.name_authority_blurb"))
.font(FontId::new(13.0, fonts::regular()))
.color(t.surface_text_dim),
);
ui.add_space(10.0);
w::field_well(ui, |ui| {
TextEdit::new(egui::Id::from("name_authority_input"))
.focus(false)
.hint_text("https://goblin.st")
.text_color(t.surface_text)
.body()
.ui(ui, &mut na.input, cb);
});
if let Some(err) = &na.error {
ui.add_space(6.0);
ui.label(
RichText::new(err)
.font(FontId::new(12.5, fonts::regular()))
.color(t.neg),
);
}
ui.add_space(10.0);
ui.horizontal(|ui| {
let third = (ui.available_width() - 20.0) / 3.0;
ui.scope_builder(
egui::UiBuilder::new().max_rect(egui::Rect::from_min_size(
ui.cursor().min,
Vec2::new(third, 44.0),
)),
|ui| {
if w::big_action_on_card(ui, &t!("goblin.settings.cancel")).clicked() {
close = true;
}
},
);
ui.add_space(10.0);
ui.scope_builder(
egui::UiBuilder::new().max_rect(egui::Rect::from_min_size(
ui.cursor().min,
Vec2::new(third, 44.0),
)),
|ui| {
if w::big_action_on_card(ui, &t!("goblin.settings.reset")).clicked() {
if let Some(s) = wallet.nostr_service() {
s.config.write().set_nip05_server(None);
crate::nostr::nip05::set_home_domain(
&s.config.read().home_domain(),
);
}
close = true;
}
},
);
ui.add_space(10.0);
ui.scope_builder(
egui::UiBuilder::new().max_rect(egui::Rect::from_min_size(
ui.cursor().min,
Vec2::new(third, 44.0),
)),
|ui| {
if w::big_action(ui, &t!("goblin.settings.save"), false).clicked() {
let url = na.input.trim().to_string();
if !url.starts_with("https://") && !url.starts_with("http://") {
na.error =
Some(t!("goblin.settings.name_authority_invalid").to_string());
} else if let Some(s) = wallet.nostr_service() {
s.config.write().set_nip05_server(Some(url));
crate::nostr::nip05::set_home_domain(
&s.config.read().home_domain(),
);
close = true;
}
}
},
);
});
});
if close {
self.name_authority = None;
}
}
/// Inline "back up identity to a file" flow: ask for the wallet password,
/// seal the identity, and write a GOBLIN-*.backup file via the native picker.
fn backup_ui(&mut self, ui: &mut egui::Ui, wallet: &Wallet, cb: &dyn PlatformCallbacks) {
let t = theme::tokens();
let bk = self.backup.as_mut().unwrap();
let mut close = false;
w::card(ui, |ui| {
ui.set_min_width(ui.available_width());
if bk.done {
ui.label(
RichText::new(t!("goblin.settings.backup_saved"))
.font(FontId::new(15.0, fonts::semibold()))
.color(t.pos),
);
ui.add_space(4.0);
ui.label(
RichText::new(t!("goblin.settings.backup_saved_sub"))
.font(FontId::new(13.0, fonts::regular()))
.color(t.surface_text_dim),
);
ui.add_space(10.0);
if w::big_action(ui, &t!("goblin.settings.done"), false).clicked() {
close = true;
}
return;
}
ui.label(
RichText::new(t!("goblin.settings.backup_file_title"))
.font(FontId::new(15.0, fonts::semibold()))
.color(t.surface_text),
);
ui.add_space(6.0);
ui.label(
RichText::new(t!("goblin.settings.backup_file_blurb"))
.font(FontId::new(13.0, fonts::regular()))
.color(t.surface_text_dim),
);
ui.add_space(10.0);
w::field_well(ui, |ui| {
TextEdit::new(egui::Id::from("backup_pass"))
.focus(false)
.hint_text(t!("goblin.settings.wallet_password"))
.password()
.text_color(t.surface_text)
.body()
.ui(ui, &mut bk.password, cb);
});
if let Some(err) = &bk.error {
ui.add_space(6.0);
ui.label(
RichText::new(err)
.font(FontId::new(12.5, fonts::regular()))
.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() {
close = true;
}
},
);
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(!bk.password.is_empty(), |ui| {
if w::big_action(ui, &t!("goblin.settings.create_backup"), false)
.clicked()
{
match wallet.create_nostr_backup(&bk.password) {
Ok(envelope) => {
let stamp = chrono::Local::now().format("%Y-%m-%d-%H%M");
let fname = format!("GOBLIN-{stamp}.backup");
match cb.save_file(fname, envelope.into_bytes()) {
Ok(()) => {
bk.done = true;
bk.error = None;
bk.password.clear();
}
Err(_) => {
bk.error = Some(
t!("goblin.settings.backup_write_failed")
.to_string(),
);
}
}
}
Err(e) => bk.error = Some(e),
}
}
});
},
);
});
});
if close {
self.backup = None;
}
}
fn import_nsec_ui(&mut self, ui: &mut egui::Ui, wallet: &Wallet, cb: &dyn PlatformCallbacks) {
let t = theme::tokens();
let import = self.import_nsec.as_mut().unwrap();
if import.stage == 3 {
if let Some(res) = import.result.lock().unwrap().take() {
match res {
Ok(npub) => {
import.new_npub = npub;
import.stage = 4;
}
Err(e) => {
import.error = e;
import.stage = 5;
}
}
}
}
let mut close = false;
w::card(ui, |ui| {
ui.set_min_width(ui.available_width());
match import.stage {
1 => {
ui.label(
RichText::new(t!("goblin.settings.import_identity_title"))
.font(FontId::new(15.0, fonts::semibold()))
.color(t.surface_text),
);
ui.add_space(6.0);
ui.label(
RichText::new(t!("goblin.settings.import_blurb"))
.font(FontId::new(13.0, fonts::regular()))
.color(t.surface_text_dim),
);
ui.add_space(10.0);
// Native ".backup file" picker. Desktop returns the path now;
// Android returns it asynchronously (poll picked_file()).
if import.picking {
if let Some(path) = cb.picked_file() {
import.picking = false;
if !path.is_empty() {
match std::fs::read_to_string(&path) {
Ok(contents) => import.nsec = contents.trim().to_string(),
Err(_) => {
import.error =
t!("goblin.settings.backup_read_failed").to_string();
}
}
}
} else {
ui.ctx().request_repaint();
}
}
if w::big_action_on_card(ui, &t!("goblin.settings.choose_backup_file"))
.clicked()
{
import.error.clear();
match cb.pick_file() {
Some(path) if !path.is_empty() => {
match std::fs::read_to_string(&path) {
Ok(contents) => import.nsec = contents.trim().to_string(),
Err(_) => {
import.error =
t!("goblin.settings.backup_read_failed").to_string();
}
}
}
// Empty string = Android async pick in flight.
Some(_) => import.picking = true,
None => {}
}
}
if !import.error.is_empty() && import.stage == 1 {
ui.add_space(6.0);
ui.label(
RichText::new(&import.error)
.font(FontId::new(12.5, fonts::regular()))
.color(t.neg),
);
}
ui.add_space(8.0);
w::field_well(ui, |ui| {
TextEdit::new(egui::Id::from("import_nsec"))
.focus(false)
.hint_text(t!("goblin.settings.import_nsec_hint"))
.password()
.text_color(t.surface_text)
.body()
.ui(ui, &mut import.nsec, cb);
});
ui.add_space(8.0);
w::field_well(ui, |ui| {
TextEdit::new(egui::Id::from("import_pass"))
.focus(false)
.hint_text(t!("goblin.settings.wallet_password"))
.password()
.text_color(t.surface_text)
.body()
.ui(ui, &mut import.password, cb);
});
ui.add_space(8.0);
w::field_well(ui, |ui| {
TextEdit::new(egui::Id::from("import_backup_pass"))
.focus(false)
.hint_text(t!("goblin.settings.backup_password_hint"))
.password()
.text_color(t.surface_text)
.body()
.ui(ui, &mut import.backup_password, cb);
});
ui.add_space(10.0);
let pasted = import.nsec.trim();
let armed = (pasted.starts_with("nsec1") || pasted.starts_with('{'))
&& !import.password.is_empty();
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()
{
close = true;
}
},
);
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.settings.import_btn"), false)
.clicked()
{
import.stage = 3;
let slot = import.result.clone();
let nsec = std::mem::take(&mut import.nsec);
let password = std::mem::take(&mut import.password);
let bpw = std::mem::take(&mut import.backup_password);
let bpw = if bpw.is_empty() { None } else { Some(bpw) };
let wallet = wallet.clone();
std::thread::spawn(move || {
let res =
wallet.import_nostr_identity(nsec, password, bpw);
*slot.lock().unwrap() = Some(res);
});
}
});
},
);
});
}
3 => {
ui.horizontal(|ui| {
View::small_loading_spinner(ui);
ui.add_space(8.0);
ui.label(
RichText::new(t!("goblin.settings.importing"))
.font(FontId::new(13.0, fonts::regular()))
.color(t.surface_text_dim),
);
});
ui.ctx().request_repaint();
}
4 => {
ui.label(
RichText::new(t!("goblin.settings.identity_replaced"))
.font(FontId::new(15.0, fonts::semibold()))
.color(t.pos),
);
ui.add_space(4.0);
let npub = &import.new_npub;
let short = if npub.len() > 18 {
format!("{}{}", &npub[..12], &npub[npub.len() - 6..])
} else {
npub.clone()
};
ui.label(
RichText::new(t!("goblin.settings.now_using", npub => short))
.font(FontId::new(13.0, fonts::mono()))
.color(t.surface_text_dim),
);
ui.add_space(10.0);
if w::big_action(ui, &t!("goblin.settings.done"), false).clicked() {
close = true;
}
}
_ => {
ui.label(
RichText::new(t!("goblin.settings.import_failed"))
.font(FontId::new(15.0, fonts::semibold()))
.color(t.neg),
);
ui.add_space(4.0);
ui.label(
RichText::new(&import.error)
.font(FontId::new(13.0, fonts::regular()))
.color(t.surface_text_dim),
);
ui.add_space(10.0);
if w::big_action_on_card(ui, &t!("goblin.settings.close")).clicked() {
close = true;
}
}
}
});
if close {
self.import_nsec = None;
}
}
/// 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();
// Poll the worker result; avatar invalidation happens after the
// claim borrow is released.
let mut invalidate_avatar: Option<String> = None;
{
let claim = self.claim.as_mut().unwrap();
if let Some(msg) = claim.result.lock().unwrap().take() {
claim.checking = false;
match msg {
ClaimMsg::Availability(avail) => {
let (available, msg) = availability_feedback(avail);
claim.available = available;
claim.message = Some(msg.to_string());
}
ClaimMsg::Registered(nip05) => {
let name = nip05.split('@').next().unwrap_or("").to_string();
claim.message =
Some(t!("goblin.settings.registered", name => name).to_string());
claim.available = Some(true);
claim.input.clear();
// Persist nip05 on the identity and republish.
if let Some(s) = wallet.nostr_service() {
{
let mut id = s.identity.write();
id.nip05 = Some(nip05.clone());
id.anonymous = false;
}
s.save_identity();
}
// Publish kind 0 NOW so others can resolve our @name without
// waiting for the next app start — otherwise a just-claimed
// name is invisible over the relays (no kind-0 event exists).
wallet.task(crate::wallet::types::WalletTask::NostrRepublishProfile);
}
ClaimMsg::Released => {
claim.message = Some(t!("goblin.settings.released_msg").to_string());
claim.available = None;
claim.confirm_release = false;
if let Some(s) = wallet.nostr_service() {
let name = {
let mut id = s.identity.write();
let n = id.nip05.take();
id.anonymous = true;
n
};
s.save_identity();
invalidate_avatar =
name.map(|n| n.split('@').next().unwrap_or("").to_string());
}
}
ClaimMsg::Error(e) => {
claim.available = Some(false);
claim.message = Some(e);
}
}
}
}
if let Some(name) = invalidate_avatar {
self.avatars.invalidate(&name);
}
let claim = self.claim.as_mut().unwrap();
let registered: Option<String> = wallet
.nostr_service()
.and_then(|s| s.identity.read().nip05.clone())
.map(|n| n.split('@').next().unwrap_or("").to_string());
w::card(ui, |ui| {
ui.set_min_width(ui.available_width());
if let Some(name) = registered {
if claim.confirm_release {
// The are-you-sure gate.
ui.label(
RichText::new(t!("goblin.settings.release_confirm", name => name))
.font(FontId::new(15.0, fonts::semibold()))
.color(t.surface_text),
);
ui.add_space(4.0);
ui.label(
RichText::new(t!("goblin.settings.release_blurb"))
.font(FontId::new(12.5, fonts::regular()))
.color(t.surface_text_dim),
);
ui.add_space(10.0);
if claim.checking {
ui.horizontal(|ui| {
View::small_loading_spinner(ui);
ui.add_space(8.0);
ui.label(
RichText::new(t!("goblin.settings.releasing"))
.color(t.surface_text_dim),
);
});
ui.ctx().request_repaint();
} else {
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_ink(
ui,
&t!("goblin.settings.keep_it"),
t.surface_text,
)
.clicked()
{
claim.confirm_release = false;
}
},
);
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.settings.release_it"),
t.neg,
)
.clicked()
{
start_release(claim, &name, wallet);
}
},
);
});
}
} else {
ui.label(
RichText::new(t!("goblin.settings.username"))
.font(FontId::new(15.0, fonts::semibold()))
.color(t.surface_text),
);
ui.add_space(4.0);
ui.label(
RichText::new(name.to_string())
.font(FontId::new(20.0, fonts::bold()))
.color(t.surface_text),
);
ui.add_space(4.0);
ui.label(
RichText::new(t!("goblin.settings.username_note"))
.font(FontId::new(12.0, fonts::regular()))
.color(t.surface_text_mute),
);
if let Some(msg) = &claim.message {
ui.add_space(6.0);
ui.label(
RichText::new(msg)
.font(FontId::new(13.0, fonts::regular()))
.color(match claim.available {
Some(false) => t.neg,
Some(true) => t.pos,
None => t.surface_text_dim,
}),
);
}
ui.add_space(10.0);
if w::big_action_on_card_ink(ui, &t!("goblin.settings.release_username"), t.neg)
.clicked()
{
claim.confirm_release = true;
claim.message = None;
}
}
} else {
ui.label(
RichText::new(t!("goblin.settings.pick_username"))
.font(FontId::new(15.0, fonts::semibold()))
.color(t.surface_text),
);
ui.add_space(8.0);
// Placeholder shows the bare handle ("yourname") — we never display
// the "@" to users. A leading "@" the user happens to type is still
// stripped when the name is read below.
let before = claim.input.clone();
TextEdit::new(egui::Id::from("settings_claim"))
.focus(false)
.hint_text(t!("goblin.onboarding.identity.username_field_hint"))
.text_color(t.surface_text)
.body()
.ui(ui, &mut claim.input, cb);
if claim.input != before {
claim.available = None;
claim.message = None;
}
ui.add_space(4.0);
ui.label(
RichText::new(t!("goblin.settings.username_note"))
.font(FontId::new(12.0, fonts::regular()))
.color(t.surface_text_mute),
);
if let Some(msg) = &claim.message {
ui.add_space(6.0);
ui.label(
RichText::new(msg)
.font(FontId::new(13.0, fonts::regular()))
.color(match claim.available {
Some(false) => t.neg,
Some(true) => t.pos,
None => t.surface_text_dim,
}),
);
}
ui.add_space(10.0);
let name = claim.input.trim().trim_start_matches('@').to_lowercase();
let valid = name.len() >= 3 && name.len() <= 20;
if claim.checking {
ui.horizontal(|ui| {
View::small_loading_spinner(ui);
ui.add_space(8.0);
ui.label(
RichText::new(t!("goblin.settings.working")).color(t.surface_text_dim),
);
});
ui.ctx().request_repaint();
} else {
ui.add_enabled_ui(valid, |ui| {
if w::big_action(ui, &t!("goblin.settings.claim"), false).clicked() {
start_claim_flow(claim, &name, wallet);
}
});
}
}
});
}
}
/// Spawn the combined claim: availability check first, then registration
/// in the same worker — one button, no separate Check step.
fn start_claim_flow(claim: &mut ClaimState, name: &str, wallet: &Wallet) {
let Some(service) = wallet.nostr_service() else {
return;
};
let server = service.config.read().nip05_server();
// Reuse the service's keys directly — never round-trip the secret through a
// plaintext nsec String to rebuild keys the service already holds.
let keys = service.keys();
claim.checking = true;
claim.message = None;
claim.available = None;
let slot = claim.result.clone();
let name = name.to_string();
std::thread::spawn(move || {
let rt = match tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
{
Ok(rt) => rt,
Err(_) => return,
};
let msg = rt.block_on(async {
use crate::nostr::nip05::{Availability, RegisterResult, check_availability, register};
match check_availability(&server, &name).await {
Availability::Available => match register(&server, &name, &keys).await {
RegisterResult::Ok(nip05) => ClaimMsg::Registered(nip05),
RegisterResult::Conflict(_) => {
ClaimMsg::Error(t!("goblin.settings.err_just_taken").to_string())
}
RegisterResult::Rejected(e) if e == "name_change_cooldown" => {
ClaimMsg::Error(t!("goblin.settings.err_cooldown").to_string())
}
RegisterResult::Rejected(e) => ClaimMsg::Error(e),
RegisterResult::Network => {
ClaimMsg::Error(t!("goblin.settings.err_unreachable").to_string())
}
},
other => ClaimMsg::Availability(other),
}
});
*slot.lock().unwrap() = Some(msg);
});
}
/// Spawn the username release; the server deletes its avatar with it.
fn start_release(claim: &mut ClaimState, name: &str, wallet: &Wallet) {
let Some(service) = wallet.nostr_service() else {
return;
};
let server = service.config.read().nip05_server();
// Reuse the service's keys directly — never round-trip the secret through a
// plaintext nsec String to rebuild keys the service already holds.
let keys = service.keys();
claim.checking = true;
claim.message = None;
let slot = claim.result.clone();
let name = name.to_string();
std::thread::spawn(move || {
let rt = match tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
{
Ok(rt) => rt,
Err(_) => return,
};
// Release is always allowed server-side (it's what arms the cooldown),
// so there's no cooldown rejection to handle here.
let msg = match rt.block_on(crate::nostr::nip05::unregister(&server, &name, &keys)) {
Ok(()) => ClaimMsg::Released,
Err(e) => ClaimMsg::Error(t!("goblin.settings.err_release", err => e).to_string()),
};
*slot.lock().unwrap() = Some(msg);
});
}
/// Process a picked picture and upload it as the avatar for an owned name.
/// Draw the small Goblin mascot mark.
pub fn widgets_logo(ui: &mut egui::Ui) {
widgets_logo_sized(ui, 24.0);
}
/// Tinted goblin mark at a given size.
pub fn widgets_logo_sized(ui: &mut egui::Ui, size: f32) {
let (rect, _) = ui.allocate_exact_size(Vec2::splat(size), Sense::hover());
// Chip-sized marks use a pre-rendered 48px raster: cleaner antialiasing
// at ~24px than runtime svg rasterization, with 2x headroom for hidpi.
let img = egui::Image::new(if size <= 32.0 {
egui::include_image!("../../../../img/goblin-logo2-48.png")
} else {
egui::include_image!("../../../../img/goblin-logo2.svg")
})
.tint(theme::tokens().text)
.fit_to_exact_size(Vec2::splat(size));
img.paint_at(ui, rect);
}
fn empty_state(ui: &mut egui::Ui, title: &str, subtitle: &str) {
let t = theme::tokens();
ui.add_space(40.0);
ui.vertical_centered(|ui| {
ui.label(
RichText::new(title)
.font(FontId::new(17.0, fonts::semibold()))
.color(t.text),
);
ui.add_space(4.0);
ui.label(
RichText::new(subtitle)
.font(FontId::new(14.0, fonts::regular()))
.color(t.text_dim),
);
});
}
fn settings_group(ui: &mut egui::Ui, title: &str, add: impl FnOnce(&mut egui::Ui)) {
w::kicker(ui, title);
ui.add_space(8.0);
w::card(ui, |ui| add(ui));
}
/// Title row for an Advanced-page action card.
fn advanced_head(ui: &mut egui::Ui, label: &str, color: Color32) {
ui.label(
RichText::new(label)
.font(FontId::new(15.0, fonts::semibold()))
.color(color),
);
ui.add_space(4.0);
}
/// Wrapped description line under an Advanced-page action title.
fn advanced_desc(ui: &mut egui::Ui, text: &str) {
let t = theme::tokens();
ui.label(
RichText::new(text)
.font(FontId::new(13.0, fonts::regular()))
.color(t.surface_text_dim),
);
}
/// A settings row: label + subtitle on the left, an on/off switch on the right.
/// Returns `Some(new_value)` on the frame it is toggled.
fn settings_row_toggle(ui: &mut egui::Ui, label: &str, sub: &str, on: bool) -> Option<bool> {
let t = theme::tokens();
let mut toggled = None;
ui.horizontal(|ui| {
ui.vertical(|ui| {
ui.label(
RichText::new(label)
.font(FontId::new(15.0, fonts::medium()))
.color(t.surface_text),
);
ui.label(
RichText::new(sub)
.font(FontId::new(13.0, fonts::regular()))
.color(t.surface_text_dim),
);
});
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
if w::toggle(ui, on).clicked() {
toggled = Some(!on);
}
});
});
ui.add_space(10.0);
toggled
}
fn settings_row(ui: &mut egui::Ui, label: &str, value: &str) {
settings_row_ink(ui, label, value, theme::tokens().surface_text_dim);
}
/// Like [`settings_row`] but the value is drawn in an explicit ink — used to flag
/// the always-on mixnet routing in the privacy color.
fn settings_row_ink(ui: &mut egui::Ui, label: &str, value: &str, value_ink: Color32) {
let t = theme::tokens();
ui.horizontal(|ui| {
ui.label(
RichText::new(label)
.font(FontId::new(15.0, fonts::medium()))
.color(t.surface_text),
);
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
ui.label(
RichText::new(value)
.font(FontId::new(13.0, fonts::regular()))
.color(value_ink),
);
});
});
ui.add_space(10.0);
}
fn settings_row_btn(ui: &mut egui::Ui, label: &str, icon: &str) -> bool {
let t = theme::tokens();
let mut clicked = false;
let row = ui.horizontal(|ui| {
ui.label(
RichText::new(label)
.font(FontId::new(15.0, fonts::medium()))
.color(t.surface_text),
);
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
let resp = ui.label(
RichText::new(icon)
.font(FontId::new(18.0, fonts::regular()))
.color(t.surface_text_dim),
);
if resp.interact(Sense::click()).clicked() {
clicked = true;
}
});
});
ui.add_space(10.0);
// The whole row is tappable, not just the trailing value/icon.
clicked || row.response.interact(Sense::click()).clicked()
}
/// A danger-styled settings row button (whole row taps).
fn settings_row_danger(ui: &mut egui::Ui, label: &str, icon: &str) -> bool {
let t = theme::tokens();
let row = ui.horizontal(|ui| {
ui.label(
RichText::new(label)
.font(FontId::new(15.0, fonts::medium()))
.color(t.neg),
);
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
ui.label(
RichText::new(icon)
.font(FontId::new(18.0, fonts::regular()))
.color(t.neg),
);
});
});
ui.add_space(10.0);
row.response.interact(Sense::click()).clicked()
}
/// A settings row whose value cycles in place on tap (no navigation): the
/// value is drawn in the same small/dim style as [`settings_row_nav`] so it
/// sits consistently next to chevroned siblings, just without the chevron.
fn settings_row_cycle(ui: &mut egui::Ui, label: &str, value: &str) -> bool {
let t = theme::tokens();
let row = ui.horizontal(|ui| {
ui.label(
RichText::new(label)
.font(FontId::new(15.0, fonts::medium()))
.color(t.surface_text),
);
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
ui.label(
RichText::new(value)
.font(FontId::new(13.0, fonts::regular()))
.color(t.surface_text_dim),
);
});
});
ui.add_space(10.0);
row.response.interact(Sense::click()).clicked()
}
/// A settings row that navigates somewhere: value + chevron, whole row taps.
fn settings_row_nav(ui: &mut egui::Ui, label: &str, value: &str) -> bool {
let t = theme::tokens();
let row = ui.horizontal(|ui| {
ui.label(
RichText::new(label)
.font(FontId::new(15.0, fonts::medium()))
.color(t.surface_text),
);
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
ui.label(
RichText::new(crate::gui::icons::CARET_RIGHT)
.font(FontId::new(13.0, fonts::regular()))
.color(t.surface_text_mute),
);
ui.add_space(4.0);
ui.label(
RichText::new(value)
.font(FontId::new(13.0, fonts::regular()))
.color(t.surface_text_dim),
);
});
});
ui.add_space(10.0);
row.response.interact(Sense::click()).clicked()
}
/// One channel row on the Network-privacy page: a status dot, a title and a
/// wrapped blurb explaining where that traffic goes.
fn privacy_line(ui: &mut egui::Ui, dot: Color32, title: &str, blurb: &str) {
let t = theme::tokens();
ui.horizontal_top(|ui| {
let (rect, _) = ui.allocate_exact_size(Vec2::new(14.0, 20.0), Sense::hover());
ui.painter()
.circle_filled(rect.center() + Vec2::new(0.0, -2.0), 4.0, dot);
ui.vertical(|ui| {
ui.label(
RichText::new(title)
.font(FontId::new(14.0, fonts::semibold()))
.color(t.surface_text),
);
ui.add_space(2.0);
ui.label(
RichText::new(blurb)
.font(FontId::new(12.0, fonts::regular()))
.color(t.surface_text_dim),
);
});
});
ui.add_space(10.0);
}
/// Open a URL in the system browser.
fn open_url(ui: &egui::Ui, url: &str) {
ui.ctx().open_url(egui::OpenUrl::new_tab(url));
}
/// Linear blend between two colors (`p` 0→`a`, 1→`b`). Used by the Pay-screen
/// over-balance flash to ease the digits from red back to normal ink.
fn lerp_color(a: Color32, b: Color32, p: f32) -> Color32 {
let p = p.clamp(0.0, 1.0);
let mix = |x: u8, y: u8| (x as f32 + (y as f32 - x as f32) * p).round() as u8;
Color32::from_rgb(mix(a.r(), b.r()), mix(a.g(), b.g()), mix(a.b(), b.b()))
}
fn approve_button(ui: &mut egui::Ui) -> bool {
w::big_action(ui, &t!("goblin.request.approve"), false).clicked()
}
fn decline_button(ui: &mut egui::Ui) -> bool {
w::big_action(ui, &t!("goblin.request.decline"), true).clicked()
}
fn accept_policy_label(wallet: &Wallet) -> String {
use crate::nostr::config::AcceptPolicy;
wallet
.nostr_service()
.map(|s| match s.config.read().accept_from() {
AcceptPolicy::Everyone => t!("goblin.settings.accept_anyone").to_string(),
AcceptPolicy::Contacts => t!("goblin.settings.accept_contacts").to_string(),
AcceptPolicy::Ask => t!("goblin.settings.accept_ask").to_string(),
})
.unwrap_or_else(|| t!("goblin.settings.accept_anyone").to_string())
}
/// Cycle the color theme Dark ↔ Light and re-apply visuals. Yellow is kept
/// defined (gui/theme.rs) but out of the picker for now — it's still in beta;
/// `Yellow => Dark` is an escape hatch for anyone whose config already has it.
fn cycle_theme(ctx: &egui::Context) {
use crate::gui::theme::ThemeKind;
let next = match crate::AppConfig::theme() {
ThemeKind::Dark => ThemeKind::Light,
ThemeKind::Light => ThemeKind::Dark,
ThemeKind::Yellow => ThemeKind::Dark,
};
crate::AppConfig::set_theme(next);
crate::setup_visuals(ctx);
}
/// Cycle the density Comfy → Regular → Compact → Comfy.
/// Cycle the incoming-payment accept policy Anyone → Contacts → Ask → Anyone.
fn cycle_accept_policy(wallet: &Wallet) {
use crate::nostr::config::AcceptPolicy;
if let Some(s) = wallet.nostr_service() {
let next = match s.config.read().accept_from() {
AcceptPolicy::Everyone => AcceptPolicy::Contacts,
AcceptPolicy::Contacts => AcceptPolicy::Ask,
AcceptPolicy::Ask => AcceptPolicy::Everyone,
};
s.config.write().set_accept_from(next);
}
}
fn relay_summary(wallet: &Wallet) -> String {
wallet
.nostr_service()
.map(|s| {
let relays = s.relays();
match relays.len() {
0 => t!("goblin.relays.none").to_string(),
1 => relays[0].replace("wss://", ""),
n => t!("goblin.relays.count", n => n).to_string(),
}
})
.unwrap_or_else(|| "—".to_string())
}
/// Compute a fiat preview line for the balance, when a rate is available.
/// One-line node summary: "Block 1,847,221 · main.gri.mw".
/// Bare node host (or "integrated node") for the sidebar card's third line.
fn node_host(wallet: &Wallet) -> String {
match wallet.get_current_connection() {
crate::wallet::types::ConnectionMethod::Integrated => {
t!("goblin.node.integrated_host").to_string()
}
crate::wallet::types::ConnectionMethod::External(_, url) => url
.replace("https://", "")
.replace("http://", "")
.trim_end_matches('/')
.to_string(),
}
}
fn node_summary(wallet: &Wallet) -> String {
let height = wallet
.get_data()
.map(|d| d.info.last_confirmed_height)
.unwrap_or(0);
let conn = match wallet.get_current_connection() {
crate::wallet::types::ConnectionMethod::Integrated => {
t!("goblin.node.integrated_host").to_string()
}
crate::wallet::types::ConnectionMethod::External(_, url) => url
.replace("https://", "")
.replace("http://", "")
.trim_end_matches('/')
.to_string(),
};
if height == 0 {
t!("goblin.node.summary_syncing", conn => conn).to_string()
} else {
t!("goblin.node.summary_block", height => fmt_thousands(height), conn => conn).to_string()
}
}
/// Format a number with thousands separators.
fn fmt_thousands(n: u64) -> String {
let s = n.to_string();
let mut out = String::with_capacity(s.len() + s.len() / 3);
for (i, c) in s.chars().enumerate() {
if i > 0 && (s.len() - i) % 3 == 0 {
out.push(',');
}
out.push(c);
}
out
}
fn fiat_line(data: &Option<WalletData>) -> Option<w::FiatLine> {
use crate::http::RateState;
let p = crate::AppConfig::pairing();
let vs = p.vs_currency()?;
// Asking for the rate here (while the balance is on screen) is what kicks a
// live refetch when the in-session rate has aged out; an idle wallet never
// reaches this path.
Some(match crate::http::grin_rate(vs) {
RateState::Fresh(rate) => {
let spendable = data
.as_ref()
.map(|d| d.info.amount_currently_spendable)
.unwrap_or(0);
let grin = spendable as f64 / 1_000_000_000.0;
w::FiatLine::Text(format!(
"≈ {} · 1ツ = {}",
fmt_pairing(grin * rate, p),
fmt_pairing(rate, p)
))
}
RateState::Loading => w::FiatLine::Loading,
RateState::Unavailable => w::FiatLine::Unavailable,
})
}
/// Format a value already in the pairing's unit (dollars, BTC, …) with the
/// right symbol/precision. Sats scales the BTC value by 1e8.
fn fmt_pairing(value: f64, p: crate::settings::Pairing) -> String {
use crate::settings::Pairing;
match p {
Pairing::Usd => format!("${:.2}", value),
Pairing::Eur => format!("€{:.2}", value),
Pairing::Gbp => format!({:.2}", value),
Pairing::Jpy => format!({:.0}", value),
Pairing::Cny => format!("CN¥{:.2}", value),
Pairing::Btc => {
let s = format!("{:.8}", value);
let s = s.trim_end_matches('0').trim_end_matches('.');
format!("₿{}", if s.is_empty() { "0" } else { s })
}
Pairing::Sats => format!("{} sats", fmt_thousands((value * 1e8).round() as u64)),
Pairing::Off => String::new(),
}
}
/// The "≈ …" amount preview for the current pairing, or `None` when off / no
/// rate yet. Shared by the Pay screen, the send flow, and the balance hero.
fn pairing_preview(grin: f64, ctx: &egui::Context) -> Option<String> {
use crate::http::RateState;
let p = crate::AppConfig::pairing();
let vs = p.vs_currency()?;
match crate::http::grin_rate(vs) {
RateState::Fresh(rate) => Some(format!("≈ {}", fmt_pairing(grin * rate, p))),
// No stale fallback: show nothing until a fresh rate lands. Nudge a repaint
// while loading so the preview appears once the live fetch returns.
RateState::Loading => {
ctx.request_repaint_after(std::time::Duration::from_millis(300));
None
}
RateState::Unavailable => None,
}
}
/// Convert a bech32 npub to hex for short display fallbacks.
fn hex_of(npub: &str) -> String {
use nostr_sdk::{FromBech32, PublicKey};
PublicKey::from_bech32(npub)
.map(|pk| pk.to_hex())
.unwrap_or_else(|_| npub.to_string())
}
/// Largest point size in `[12.0, 16.0]` at which the semibold news title fits on
/// one line within `avail` px, measured against the live font atlas and stepping
/// down by 0.5. Returns the 12pt floor when even that overflows (the caller pairs
/// it with `.truncate()`). This is the shrink-to-fit safety net that keeps a
/// title readable on a 390px screen; the hard char cap (`news_title_clamped`) is
/// the predictable ceiling.
fn fit_news_title_pt(ui: &egui::Ui, text: &str, avail: f32) -> f32 {
const CEIL: f32 = 16.0;
const FLOOR: f32 = 12.0;
let mut pt = CEIL;
while pt > FLOOR {
let w = ui
.painter()
.layout_no_wrap(
text.to_owned(),
FontId::new(pt, fonts::semibold()),
egui::Color32::WHITE,
)
.size()
.x;
if w <= avail {
return pt;
}
pt -= 0.5;
}
FLOOR
}