Files
grim/src/gui/views/goblin/mod.rs
T
Claude f2402eb24d Build 22: security hardening follow-ups
From the audit's deferred P2 list:
- Global gift-wrap decrypt ceiling (~120/min across all senders) so
  fresh-keypair spam can't force unbounded NIP-44 decrypts. The per-sender
  limit only applied after the decrypt revealed the sender; this caps total
  decrypt work up front.
- NostrStore reads tolerate a poisoned lock (unwrap_or_else into_inner) so a
  single panic can't cascade into taking down all nostr storage for the session.
- Avatar upload/delete reuse the service's keys directly instead of
  round-tripping the secret through a plaintext nsec String.

34 lib tests green.
2026-06-11 23:23:26 -04:00

2977 lines
84 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 Cash App-style wallet surface for an open wallet.
pub mod avatars;
pub mod data;
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, CHECK, CLOCK, COPY, QR_CODE, USER_CIRCLE, WALLET};
use crate::gui::platform::PlatformCallbacks;
use crate::gui::theme::{self, fonts};
use crate::gui::views::{Content, View};
use crate::wallet::Wallet;
use crate::wallet::types::WalletData;
use self::data::{ActivityItem, activity_items, recent_peers};
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>,
/// Request ids already approved this session (double-tap guard).
approving: std::collections::HashSet<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>,
/// Amount being entered on the Pay tab.
pay_amount: String,
/// Amount being requested, shown on the Receive screen.
request_amount: Option<String>,
/// Sub-page open inside the Settings tab.
settings_page: SettingsPage,
/// 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,
/// Profile-picture upload in flight.
avatar_busy: bool,
/// Upload worker result: (server hash, processed png) or error.
avatar_slot: std::sync::Arc<std::sync::Mutex<Option<Result<(String, Vec<u8>), String>>>>,
/// Last upload outcome message (cleared on the next attempt).
avatar_msg: Option<String>,
}
/// Sub-pages of the Settings tab.
#[derive(Clone, Copy, PartialEq, Eq)]
enum SettingsPage {
Main,
Node,
Relays,
Nips,
Pairing,
}
impl Default for GoblinWalletView {
fn default() -> Self {
Self {
tab: Tab::Home,
send: None,
approving: std::collections::HashSet::new(),
wallet_id: None,
claim: None,
rotate: None,
import_nsec: None,
pay_amount: String::new(),
request_amount: None,
settings_page: SettingsPage::Main,
node_url_input: String::new(),
node_secret_input: String::new(),
relay_edit: Vec::new(),
relay_input: String::new(),
receive_copied: None,
avatars: avatars::AvatarTextures::default(),
avatar_busy: false,
avatar_slot: std::sync::Arc::new(std::sync::Mutex::new(None)),
avatar_msg: None,
}
}
}
/// 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,
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(),
result: std::sync::Arc::new(std::sync::Mutex::new(None)),
}
}
}
/// 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>, &'static str) {
use crate::nostr::nip05::Availability::*;
match avail {
Available => (Some(true), "Available!"),
Taken => (Some(false), "Taken"),
Reserved => (Some(false), "Reserved"),
Invalid => (Some(false), "Names are 330 chars: az, 09, _ or -"),
Quarantined => (Some(false), "Not available"),
Unknown => (None, "Couldn't check — connection hiccup. Try again."),
}
}
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()
}
/// Handle a back navigation; returns true if not consumed.
pub fn on_back(&mut self) -> bool {
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.claim = None;
self.rotate = None;
self.import_nsec = None;
self.approving.clear();
self.pay_amount.clear();
self.request_amount = None;
self.settings_page = SettingsPage::Main;
}
// 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 {
self.send = 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 {
fill: 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.
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() + 8.0) as i8,
bottom: 0,
},
..Default::default()
})
.show_inside(ui, |ui| {
w::centered_column(ui, Content::SIDE_PANEL_WIDTH * 1.2, |ui| match self.tab {
Tab::Home => self.home_ui(ui, wallet, cb, wide_desktop),
Tab::Pay => self.pay_ui(ui, wallet),
Tab::Activity => self.activity_ui(ui, wallet, cb),
Tab::Receive => self.receive_ui(ui, wallet, cb),
Tab::Me => self.me_ui(ui, wallet, cb),
});
});
}
/// Floating 3-item pill bar: Wallet · Pay (center ツ puck) · Activity.
fn tab_bar_ui(&mut self, ui: &mut egui::Ui, wallet: &Wallet) {
let t = theme::tokens();
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.
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.
if active {
ui.painter().circle_filled(rect.center(), 22.0, t.surface2);
}
let color = 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.
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,
FontId::new(24.0, fonts::bold()),
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, "Wallet", false),
(Tab::Pay, crate::gui::icons::ARROW_UP, "Pay", false),
(Tab::Activity, CLOCK, "Activity", has_requests),
(Tab::Receive, ARROW_DOWN, "Receive", false),
(Tab::Me, USER_CIRCLE, "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, connected, npub_hex) = wallet
.nostr_service()
.map(|s| {
let id = s.identity.read();
let h = id
.nip05
.clone()
.map(|n| format!("@{}", n.split('@').next().unwrap_or("")))
.unwrap_or_else(|| data::short_npub(&hex_of(&id.npub)));
(h, s.is_connected(), hex_of(&id.npub))
})
.unwrap_or_else(|| ("Anonymous".to_string(), false, String::new()));
let hue = data::hue_of(&npub_hex);
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, 36.0, hue, tex.as_ref());
ui.add_space(10.0);
ui.vertical(|ui| {
ui.label(
RichText::new(&handle)
.font(FontId::new(14.0, fonts::semibold()))
.color(t.surface_text),
);
ui.label(
RichText::new(if connected {
"synced · Tor"
} else {
"connecting…"
})
.font(FontId::new(12.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> {
if !handle.starts_with('@') {
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 {
"Can't reach node"
} else if synced {
"Node synced"
} else {
"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 {
format!("Block {}", fmt_thousands(height))
} else {
"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(),
);
});
});
});
}
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 h = id
.nip05
.clone()
.map(|n| format!("@{}", n.split('@').next().unwrap_or("")))
.unwrap_or_else(|| "N".to_string());
(h, hex_of(&id.npub))
})
.unwrap_or_else(|| ("N".to_string(), String::new()));
let header_hue = data::hue_of(&header_hex);
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,
36.0,
header_hue,
header_tex.as_ref(),
)
.clicked()
{
self.tab = Tab::Me;
}
// Scan-to-pay, left of the avatar per the refs.
ui.add_space(10.0);
let (rect, resp) =
ui.allocate_exact_size(Vec2::splat(36.0), Sense::click());
ui.painter().circle_filled(
rect.center(),
18.0,
theme::tokens().surface2,
);
ui.painter().text(
rect.center(),
egui::Align2::CENTER_CENTER,
QR_CODE,
FontId::new(17.0, fonts::regular()),
theme::tokens().surface_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 spendable = data
.as_ref()
.map(|d| d.info.amount_currently_spendable)
.unwrap_or(0);
w::balance_hero(ui, spendable, fiat_line(&data).as_deref(), 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);
// Recent peers strip.
self.peers_strip_ui(ui, wallet, "goblin_peers_home");
// Recent activity.
w::kicker(ui, "Activity");
ui.add_space(6.0);
let items = activity_items(wallet);
if items.is_empty() {
empty_state(
ui,
"No activity yet",
"Send or receive grin to get started.",
);
} else {
for item in items.iter().take(6) {
self.activity_item_ui(ui, item, wallet, cb);
}
}
ui.add_space(16.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, "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, hue, npub), tex) in peers.iter().zip(texs.iter()) {
ui.vertical(|ui| {
let resp = w::avatar_any(ui, name, 48.0, *hue, tex.as_ref());
ui.add_space(6.0);
let short: String = name.chars().take(6).collect();
ui.label(RichText::new(short).font(FontId::new(12.0, fonts::medium())));
if resp.clicked() {
let mut f = SendFlow::default();
f.prefill_contact(name.clone(), npub.clone());
self.send = Some(f);
}
});
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) {
let t = theme::tokens();
ui.add_space(8.0);
ui.horizontal(|ui| {
ui.label(
RichText::new("Pay")
.font(FontId::new(28.0, fonts::bold()))
.color(t.text),
);
// Scan-to-pay QR, top-right (mirrors the Home header scan puck):
// open the scanner with the typed amount preserved.
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
let (rect, resp) = ui.allocate_exact_size(Vec2::splat(36.0), Sense::click());
ui.painter().circle_filled(rect.center(), 18.0, t.surface2);
ui.painter().text(
rect.center(),
egui::Align2::CENTER_CENTER,
QR_CODE,
FontId::new(17.0, fonts::regular()),
t.surface_text,
);
if resp
.on_hover_cursor(egui::CursorIcon::PointingHand)
.on_hover_text("Scan to pay")
.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;
ui.add_space(if tall { 56.0 } else { 24.0 });
w::amount_text_centered(ui, &display, 76.0);
if let Ok(grin) = display.parse::<f64>() {
if let Some(preview) = pairing_preview(grin) {
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),
);
});
}
}
ui.add_space(if tall { 32.0 } else { 16.0 });
// Numpad at narrow (mobile-shell) widths, typed input on the wide
// desktop layout — gate by width like the shell itself, or narrow
// desktop windows get neither input.
let narrow = ui.available_width() < 700.0;
let typed_hint = !narrow && self.pay_amount.is_empty();
if narrow {
w::numpad(ui, &mut self.pay_amount);
} else {
w::amount_typed_input(ui, &mut self.pay_amount);
if typed_hint {
ui.vertical_centered(|ui| {
ui.label(
RichText::new("Type an amount")
.font(FontId::new(13.0, fonts::regular()))
.color(t.text_mute),
);
});
}
}
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, "Request", true).clicked() && valid {
self.request_amount = Some(self.pay_amount.clone());
self.pay_amount.clear();
self.tab = Tab::Receive;
}
},
);
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, "Pay", false).clicked() && valid {
let mut f = SendFlow::default();
f.prefill_amount(self.pay_amount.clone());
self.pay_amount.clear();
self.send = Some(f);
}
},
);
});
// Skip when the "Type an amount" hint is already showing above.
if !valid && !typed_hint {
ui.add_space(8.0);
ui.vertical_centered(|ui| {
ui.label(
RichText::new("Enter an amount to pay or request")
.font(FontId::new(12.0, fonts::regular()))
.color(t.text_mute),
);
});
}
}
fn activity_ui(&mut self, ui: &mut egui::Ui, wallet: &Wallet, cb: &dyn PlatformCallbacks) {
ui.add_space(8.0);
ui.label(
RichText::new("Activity")
.font(FontId::new(28.0, fonts::bold()))
.color(theme::tokens().text),
);
ui.add_space(12.0);
// Recent contacts strip (Cash 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() {
let requests = service.store.pending_requests();
if !requests.is_empty() {
w::section_header(ui, "Requests");
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, "No activity yet", "Your payments will appear here.");
} else {
for item in &items {
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,
) {
let sign = if item.incoming { "+ " } else { " " };
let amount = format!("{}{}{}", sign, w::amount_str(item.amount), w::TSU);
let subtitle = match (&item.note, item.confirmed) {
(Some(note), true) => format!("{} · {}", note, View::format_time(item.time)),
(Some(note), false) => format!("{} · pending", note),
(None, true) => View::format_time(item.time),
(None, false) => "pending".to_string(),
};
let tex = self.handle_tex(ui.ctx(), wallet, &item.title);
w::activity_row(
ui,
&item.title,
&subtitle,
item.hue,
&amount,
item.incoming,
item.system,
tex.as_ref(),
);
}
fn request_row_ui(
&mut self,
ui: &mut egui::Ui,
req: &crate::nostr::PaymentRequest,
wallet: &Wallet,
) {
let t = theme::tokens();
let (name, hue) = wallet
.nostr_service()
.map(|s| data::contact_title(&s.store, &req.npub))
.unwrap_or_else(|| (data::short_npub(&req.npub), 0));
let tex = self.handle_tex(ui.ctx(), wallet, &name);
w::card(ui, |ui| {
ui.horizontal(|ui| {
w::avatar_any(ui, &name, 40.0, hue, tex.as_ref());
ui.add_space(12.0);
ui.vertical(|ui| {
ui.label(
RichText::new(format!("{} requests", 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) {
let mut r = req.clone();
r.status = crate::nostr::RequestStatus::Declined;
if let Some(s) = wallet.nostr_service() {
s.store.save_request(&r);
}
}
},
);
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);
ui.add_enabled_ui(!already, |ui| {
if approve_button(ui) {
// Guard against double-tap: only enqueue the
// payment once per request id this session.
self.approving.insert(req.rumor_id.clone());
wallet.task(crate::wallet::types::WalletTask::NostrPayRequest(
req.rumor_id.clone(),
));
}
});
},
);
});
});
ui.add_space(10.0);
}
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("Receive")
.font(FontId::new(28.0, fonts::bold()))
.color(t.text),
);
ui.add_space(16.0);
let handle = wallet
.nostr_service()
.map(|s| {
let identity = s.identity.read();
identity
.nip05
.clone()
.map(|n| format!("@{}", n.split('@').next().unwrap_or("")))
.unwrap_or_else(|| data::short_npub(&hex_of(&identity.npub)))
})
.unwrap_or_else(|| "—".to_string());
let npub = wallet.nostr_service().map(|s| s.npub()).unwrap_or_default();
w::card(ui, |ui| {
ui.vertical_centered(|ui| {
// QR of the nostr handle (nostr: URI).
ui.add_space(12.0);
let uri = format!("nostr:{}", npub);
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(format!(
"Requesting {}{} — share to get paid",
amt,
w::TSU
))
.font(FontId::new(13.0, fonts::semibold()))
.color(t.surface_text),
);
ui.add_space(6.0);
if w::chip(ui, "Clear request", false).clicked() {
self.request_amount = None;
}
}
None => {
ui.label(
RichText::new("Share your handle to get paid")
.font(FontId::new(13.0, fonts::regular()))
.color(t.surface_text_dim),
);
}
}
});
});
ui.add_space(12.0);
// Transient per-button "Copied" feedback; silent copies read as dead
// buttons.
let fresh = |at: std::time::Instant| at.elapsed().as_millis() < 1500;
let copied0 = matches!(self.receive_copied, Some((0, at)) if fresh(at));
let copied1 = 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;
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 copied0 {
format!("{} Copied", CHECK)
} else {
format!("{} Copy npub", COPY)
};
if w::big_action(ui, &label, true).clicked() {
let copy = if npub.is_empty() {
handle.clone()
} else {
npub.clone()
};
cb.copy_string_to_buffer(copy);
self.receive_copied = Some((0, std::time::Instant::now()));
}
},
);
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| {
// Copy the grin1 slatepack address for manual/Tor exchange.
let label = if copied1 {
format!("{} Copied", CHECK)
} else {
"Copy address".to_string()
};
if w::big_action(ui, &label, false).clicked() {
if let Some(addr) = wallet.slatepack_address() {
cb.copy_string_to_buffer(addr);
self.receive_copied = Some((1, std::time::Instant::now()));
}
}
},
);
});
ui.add_space(16.0);
ui.label(
RichText::new(
"Your username is public. Payment contents stay encrypted over the network.",
)
.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),
SettingsPage::Relays => return self.relays_ui(ui, wallet),
SettingsPage::Nips => return self.nips_ui(ui),
SettingsPage::Pairing => return self.pairing_settings_ui(ui),
SettingsPage::Main => {}
}
ui.add_space(8.0);
ui.label(
RichText::new("Settings")
.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| format!("@{n}"))
.unwrap_or_else(|| data::short_npub(&hex_of(&identity.npub)));
(
handle,
s.npub(),
s.is_connected(),
bare,
hex_of(&identity.npub),
)
})
.unwrap_or_else(|| {
(
"Anonymous".to_string(),
String::new(),
false,
None,
String::new(),
)
});
// Poll a finished avatar upload.
if let Some(res) = self.avatar_slot.lock().unwrap().take() {
self.avatar_busy = false;
match res {
Ok((hash, png)) => {
if let Some(b) = bare_name.as_deref() {
self.avatars.set_own(ui.ctx(), b, &hash, &png);
}
self.avatar_msg = Some("Profile picture updated".to_string());
}
Err(e) => self.avatar_msg = Some(e),
}
}
let hue = data::hue_of(&npub_hex);
let own_tex = bare_name
.as_deref()
.and_then(|_| self.handle_tex(ui.ctx(), wallet, &handle));
let mut pick_picture = false;
let avatar_busy = self.avatar_busy;
let avatar_msg = self.avatar_msg.clone();
w::card(ui, |ui| {
ui.set_min_width(ui.available_width());
ui.horizontal(|ui| {
let av = w::avatar_any(ui, &handle, 56.0, hue, own_tex.as_ref())
.on_hover_cursor(egui::CursorIcon::PointingHand);
if av.clicked() && !avatar_busy {
pick_picture = true;
}
ui.add_space(14.0);
ui.vertical(|ui| {
ui.label(
RichText::new(&handle)
.font(FontId::new(17.0, fonts::bold()))
.color(t.surface_text),
);
if !npub.is_empty() {
// Full npub when it fits on one line, else head…tail.
let full = ui.painter().layout_no_wrap(
npub.clone(),
FontId::new(11.0, fonts::mono()),
t.surface_text_mute,
);
let text = if full.size().x <= ui.available_width() {
npub.clone()
} else {
format!("{}{}", &npub[..12], &npub[npub.len() - 6..])
};
ui.label(
RichText::new(text)
.font(FontId::new(11.0, fonts::mono()))
.color(t.surface_text_mute),
);
}
let status = if connected {
"Connected over Tor"
} else {
"Connecting…"
};
ui.label(
RichText::new(status)
.font(FontId::new(13.0, fonts::regular()))
.color(t.surface_text_dim),
);
});
});
if avatar_busy {
ui.add_space(6.0);
ui.horizontal(|ui| {
View::small_loading_spinner(ui);
ui.add_space(8.0);
ui.label(
RichText::new("Uploading picture…")
.font(FontId::new(12.5, fonts::regular()))
.color(t.surface_text_dim),
);
});
ui.ctx().request_repaint();
} else if let Some(msg) = &avatar_msg {
ui.add_space(6.0);
let good = msg.starts_with("Profile picture");
ui.label(
RichText::new(msg)
.font(FontId::new(12.5, fonts::regular()))
.color(if good { t.pos } else { t.neg }),
);
}
});
if pick_picture {
match bare_name.clone() {
Some(name) => {
if let Some(path) = cb.pick_image_file() {
self.avatar_busy = true;
self.avatar_msg = None;
start_avatar_upload(self.avatar_slot.clone(), path, name, wallet);
}
}
None => {
self.avatar_msg =
Some("Claim a username first — pictures ride on it".to_string());
}
}
}
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, "Identity");
ui.add_space(8.0);
if self.claim.is_none() {
self.claim = Some(ClaimState::default());
}
self.claim_ui(ui, wallet);
ui.add_space(8.0);
w::card(ui, |ui| {
if !npub.is_empty() {
if settings_row_btn(ui, "Copy npub (public)", COPY) {
cb.copy_string_to_buffer(npub.clone());
}
// A real backup is the SECRET key (nsec), not the npub.
if settings_row_btn(ui, "Back up secret key (nsec)", COPY) {
if let Some(nsec) = wallet.nostr_service().and_then(|s| s.nsec()) {
cb.copy_string_to_buffer(nsec);
}
}
// Encrypted backup file: the identity JSON as stored
// (NIP-49 ncryptsec inside), incl. username + history.
if settings_row_btn(
ui,
"Export identity backup (encrypted)",
crate::gui::icons::DOWNLOAD_SIMPLE,
) {
if let Some(s) = wallet.nostr_service() {
let json = serde_json::to_string_pretty(&*s.identity.read())
.unwrap_or_default();
cb.copy_string_to_buffer(json);
}
}
if settings_row_danger(
ui,
"Rotate nostr key",
crate::gui::icons::ARROWS_CLOCKWISE,
) && self.rotate.is_none()
{
self.rotate = Some(RotateState::default());
}
if settings_row_btn(
ui,
"Import identity (nsec / backup)",
crate::gui::icons::KEY,
) && self.import_nsec.is_none()
{
self.import_nsec = Some(ImportState::default());
}
}
});
ui.add_space(6.0);
ui.label(
RichText::new(
"Moving devices? Back up BOTH: your seed phrase (funds) \
and an identity backup (name + key).",
)
.font(FontId::new(12.0, fonts::regular()))
.color(t.text_mute),
);
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);
}
ui.add_space(16.0);
let mut open_relays = false;
let mut open_node = false;
settings_group(ui, "Wallet", |ui| {
settings_row(ui, "Display unit", "ツ (grin)");
if settings_row_nav(ui, "Relays", &relay_summary(wallet)) {
open_relays = true;
}
if settings_row_nav(ui, "Node", &node_summary(wallet)) {
open_node = true;
}
if settings_row_btn(ui, "Lock wallet", crate::gui::icons::LOCK) {
wallet.close();
}
});
if open_relays {
self.relay_edit = wallet
.nostr_service()
.map(|s| s.config.read().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;
}
ui.add_space(16.0);
let mut open_pairing = false;
settings_group(ui, "Privacy", |ui| {
settings_row(ui, "Tor routing", "All payments routed over Tor");
// Tap to cycle the incoming-payment accept policy.
if settings_row_btn(ui, "Auto-accept", accept_policy_label(wallet)) {
cycle_accept_policy(wallet);
}
// Amount pairing: what the ≈ preview is shown against.
if settings_row_nav(ui, "Pairing", crate::AppConfig::pairing().label()) {
open_pairing = true;
}
});
if open_pairing {
self.settings_page = SettingsPage::Pairing;
}
ui.add_space(16.0);
w::kicker(ui, "Appearance");
ui.add_space(8.0);
w::card(ui, |ui| {
let theme_label = match crate::AppConfig::theme() {
crate::gui::theme::ThemeKind::Light => "Light",
crate::gui::theme::ThemeKind::Dark => "Dark",
crate::gui::theme::ThemeKind::Yellow => "Yellow",
};
if settings_row_btn(ui, "Theme", theme_label) {
cycle_theme(ui.ctx());
}
});
ui.add_space(16.0);
w::kicker(ui, "Archive");
ui.add_space(8.0);
w::card(ui, |ui| {
if settings_row_btn(ui, "Export archive", COPY) {
if let Some(s) = wallet.nostr_service() {
let json = s.store.export_json(&s.npub());
cb.copy_string_to_buffer(json);
}
}
if settings_row_btn(ui, "Wipe payment history", crate::gui::icons::X) {
if let Some(s) = wallet.nostr_service() {
s.store.wipe_archive();
}
}
});
ui.add_space(16.0);
settings_group(ui, "About", |ui| {
settings_row(ui, "Goblin", &format!("Build {}", crate::BUILD));
settings_row(ui, "Network", "Mimblewimble · no address on chain");
});
ui.add_space(16.0);
let mut open_nips = false;
settings_group(ui, "Third party", |ui| {
if settings_row_nav(ui, "GRIM (upstream wallet)", crate::VERSION) {
open_url(ui, "https://github.com/ardocrat/grim");
}
if settings_row_nav(ui, "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, "arti (Tor)", "0.42") {
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;
}
ui.add_space(16.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, "Pairing") {
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("What your balance and amounts are shown against.")
.font(FontId::new(13.0, fonts::regular()))
.color(t.text_dim),
);
ui.add_space(12.0);
let current = crate::AppConfig::pairing();
settings_group(ui, "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(16.0);
});
}
fn node_settings_ui(&mut self, ui: &mut egui::Ui, wallet: &Wallet) {
use crate::wallet::types::ConnectionMethod;
use crate::wallet::{ConnectionsConfig, ExternalConnection};
let t = theme::tokens();
if self.sub_header(ui, "Node") {
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, "Connection", |ui| {
let integrated = matches!(saved, ConnectionMethod::Integrated);
let row = ui.horizontal(|ui| {
ui.label(
RichText::new("Integrated node")
.font(FontId::new(15.0, fonts::medium()))
.color(t.surface_text),
);
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
if integrated {
ui.label(
RichText::new(crate::gui::icons::CHECK)
.font(FontId::new(16.0, fonts::regular()))
.color(t.pos),
);
}
});
});
ui.add_space(10.0);
if !integrated && row.response.interact(Sense::click()).clicked() {
wallet.update_connection(&ConnectionMethod::Integrated);
}
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 {
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() {
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(),
));
}
}
});
if saved != live {
ui.add_space(8.0);
ui.label(
RichText::new("Applies after the wallet is locked and unlocked again.")
.font(FontId::new(13.0, fonts::regular()))
.color(t.text_dim),
);
}
ui.add_space(16.0);
settings_group(ui, "Add external node", |ui| {
ui.add(
egui::TextEdit::singleline(&mut self.node_url_input)
.hint_text("https://node.example.com:3413")
.desired_width(f32::INFINITY)
.text_color(t.surface_text)
.frame(false),
);
ui.add_space(8.0);
ui.add(
egui::TextEdit::singleline(&mut self.node_secret_input)
.hint_text("API secret (optional)")
.desired_width(f32::INFINITY)
.text_color(t.surface_text)
.frame(false),
);
});
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, "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);
wallet
.update_connection(&ConnectionMethod::External(conn.id, conn.url.clone()));
ConnectionsConfig::add_ext_conn(conn);
self.node_url_input.clear();
self.node_secret_input.clear();
}
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) {
let t = theme::tokens();
if self.sub_header(ui, "Relays") {
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(
"Payment messages are mirrored to every relay below; \
one reachable relay is enough to receive.",
)
.font(FontId::new(13.0, fonts::regular()))
.color(t.text_dim),
);
ui.add_space(14.0);
settings_group(ui, "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, "Add relay", |ui| {
ui.add(
egui::TextEdit::singleline(&mut self.relay_input)
.hint_text("wss://relay.example.com")
.desired_width(f32::INFINITY)
.text_color(t.surface_text)
.frame(false),
);
});
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, "Add relay").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, "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);
});
}
/// 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, "nostr & NIPs") {
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(
"Goblin speaks nostr — an open protocol of signed messages \
passed through simple relay servers. Your wallet carries \
its own nostr identity: a standalone random key, kept \
deliberately independent of your funds and seed. Every \
payment travels as an end-to-end encrypted direct message \
between identities, with the slatepack riding inside.",
)
.font(FontId::new(14.0, fonts::regular()))
.color(t.text_dim),
);
ui.add_space(10.0);
ui.label(
RichText::new(
"goblin.st is Goblin's name service: claiming a username \
publishes a name → key mapping there (NIP-05), so people \
can pay @you instead of a long npub. The username is \
public; payment contents never are. NIPs are the \
protocol's building blocks — tap one to read the spec.",
)
.font(FontId::new(14.0, fonts::regular()))
.color(t.text_dim),
);
ui.add_space(16.0);
let nips = [
(
"05",
"Names",
"Maps @username@goblin.st to your key, so handles work like addresses.",
),
(
"17",
"Private messages",
"The encrypted DM envelope every payment travels in.",
),
(
"44",
"Encryption",
"The authenticated cipher used inside those messages.",
),
(
"49",
"Key encryption",
"How the secret key is stored at rest, locked by your password.",
),
(
"59",
"Gift wrap",
"Wraps messages so relays can't see who is talking to whom.",
),
(
"98",
"HTTP auth",
"Signs the username registration request to goblin.st.",
),
];
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)
.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("Rotate nostr key")
.font(FontId::new(15.0, fonts::semibold()))
.color(t.neg),
);
ui.add_space(6.0);
for line in [
"• You get a brand-new RANDOM key; the old npub stops \
receiving. There is no derivation chain between them.",
"• The new key is NOT recoverable from your seed — back \
up the new nsec right after rotating.",
"• Your @username is RELEASED and your profile picture \
deleted — claim the same or a new name right after \
(anyone else can grab it too once it's free).",
"• Payments still in flight to the old key WILL be \
disrupted — wait for pending payments to finish first.",
"• Contacts who saved your npub directly must re-find \
you — share your new npub or re-claimed @username.",
] {
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, "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, "Continue", false).clicked() {
rotate.stage = 2;
}
},
);
});
}
2 => {
ui.label(
RichText::new("Final confirmation")
.font(FontId::new(15.0, fonts::semibold()))
.color(t.neg),
);
ui.add_space(6.0);
ui.label(
RichText::new(
"This cannot be undone from the app. Type RESET and \
enter your wallet password to rotate.",
)
.font(FontId::new(13.0, fonts::regular()))
.color(t.surface_text_dim),
);
ui.add_space(10.0);
w::field_well(ui, |ui| {
ui.add(
egui::TextEdit::singleline(&mut rotate.reset_input)
.hint_text("Type RESET")
.desired_width(f32::INFINITY)
.text_color(t.surface_text)
.frame(false),
);
});
ui.add_space(8.0);
w::field_well(ui, |ui| {
ui.add(
egui::TextEdit::singleline(&mut rotate.password)
.hint_text("Wallet password")
.password(true)
.desired_width(f32::INFINITY)
.text_color(t.surface_text)
.frame(false),
);
});
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, "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, "Rotate key", 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("Rotating key…")
.font(FontId::new(13.0, fonts::regular()))
.color(t.surface_text_dim),
);
});
ui.ctx().request_repaint();
}
4 => {
ui.label(
RichText::new("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(format!("New npub: {}", short))
.font(FontId::new(13.0, fonts::mono()))
.color(t.surface_text_dim),
);
ui.add_space(6.0);
ui.label(
RichText::new(
"Back up the NEW secret key now — your seed cannot \
recover it.",
)
.font(FontId::new(13.0, fonts::semibold()))
.color(t.neg),
);
ui.add_space(10.0);
if w::big_action_on_card(ui, "Copy new nsec backup").clicked() {
if let Some(nsec) = wallet.nostr_service().and_then(|s| s.nsec()) {
cb.copy_string_to_buffer(nsec);
}
}
ui.add_space(8.0);
if w::big_action(ui, "Done", false).clicked() {
close = true;
}
}
_ => {
ui.label(
RichText::new("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, "Close").clicked() {
close = true;
}
}
}
});
if close {
self.rotate = None;
}
}
/// Inline nsec-import flow: replaces the identity with an imported key.
fn import_nsec_ui(&mut self, ui: &mut egui::Ui, wallet: &Wallet) {
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("Import identity")
.font(FontId::new(15.0, fonts::semibold()))
.color(t.surface_text),
);
ui.add_space(6.0);
ui.label(
RichText::new(
"Replaces this wallet's nostr identity — paste a \
bare nsec or an exported identity backup (the \
backup also restores your username and history). \
Back up the current key first if you still need it.",
)
.font(FontId::new(13.0, fonts::regular()))
.color(t.surface_text_dim),
);
ui.add_space(10.0);
w::field_well(ui, |ui| {
ui.add(
egui::TextEdit::singleline(&mut import.nsec)
.hint_text("nsec1… or identity backup JSON")
.password(true)
.desired_width(f32::INFINITY)
.text_color(t.surface_text)
.frame(false),
);
});
ui.add_space(8.0);
w::field_well(ui, |ui| {
ui.add(
egui::TextEdit::singleline(&mut import.password)
.hint_text("Wallet password")
.password(true)
.desired_width(f32::INFINITY)
.text_color(t.surface_text)
.frame(false),
);
});
ui.add_space(8.0);
w::field_well(ui, |ui| {
ui.add(
egui::TextEdit::singleline(&mut import.backup_password)
.hint_text("Backup password (only if exported elsewhere)")
.password(true)
.desired_width(f32::INFINITY)
.text_color(t.surface_text)
.frame(false),
);
});
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, "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, "Import", 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("Importing…")
.font(FontId::new(13.0, fonts::regular()))
.color(t.surface_text_dim),
);
});
ui.ctx().request_repaint();
}
4 => {
ui.label(
RichText::new("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(format!("Now using: {}", short))
.font(FontId::new(13.0, fonts::mono()))
.color(t.surface_text_dim),
);
ui.add_space(10.0);
if w::big_action(ui, "Done", false).clicked() {
close = true;
}
}
_ => {
ui.label(
RichText::new("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, "Close").clicked() {
close = true;
}
}
}
});
if close {
self.import_nsec = None;
}
}
/// Inline username-claim widget (availability check + register over Tor).
fn claim_ui(&mut self, ui: &mut egui::Ui, wallet: &Wallet) {
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(format!("Registered {name}"));
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();
}
}
ClaimMsg::Released => {
claim.message = Some("Released — the name is up for grabs".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(format!("Release @{name}?"))
.font(FontId::new(15.0, fonts::semibold()))
.color(t.surface_text),
);
ui.add_space(4.0);
ui.label(
RichText::new(
"It's up for grabs the moment it's free — anyone can \
claim it, including the next key you rotate to. Your \
profile picture is deleted with it.",
)
.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("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(ui, "Keep it").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, "Release it", t.neg).clicked()
{
start_release(claim, &name, wallet);
}
},
);
});
}
} else {
ui.label(
RichText::new("Username")
.font(FontId::new(15.0, fonts::semibold()))
.color(t.surface_text),
);
ui.add_space(4.0);
ui.label(
RichText::new(format!("@{name}"))
.font(FontId::new(20.0, fonts::bold()))
.color(t.surface_text),
);
ui.add_space(4.0);
ui.label(
RichText::new(
"Shown as @you. Public on goblin.st. Payments stay encrypted.",
)
.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, "Release username", t.neg).clicked() {
claim.confirm_release = true;
claim.message = None;
}
}
} else {
ui.label(
RichText::new("Pick a username — optional")
.font(FontId::new(15.0, fonts::semibold()))
.color(t.surface_text),
);
ui.add_space(8.0);
ui.horizontal(|ui| {
ui.label(
RichText::new("@")
.font(FontId::new(16.0, fonts::semibold()))
.color(t.surface_text),
);
let edit = egui::TextEdit::singleline(&mut claim.input)
.hint_text("yourname")
.desired_width(ui.available_width() - 20.0)
.text_color(t.surface_text)
.frame(false);
if ui.add(edit).changed() {
claim.available = None;
claim.message = None;
}
});
ui.add_space(4.0);
ui.label(
RichText::new("Shown as @you. Public on goblin.st. Payments stay encrypted.")
.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().to_lowercase();
let valid = name.len() >= 3 && name.len() <= 30;
if claim.checking {
ui.horizontal(|ui| {
View::small_loading_spinner(ui);
ui.add_space(8.0);
ui.label(RichText::new("Working…").color(t.surface_text_dim));
});
ui.ctx().request_repaint();
} else {
ui.add_enabled_ui(valid, |ui| {
if w::big_action(ui, "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("That username was just taken".into())
}
RegisterResult::Rejected(e) => ClaimMsg::Error(e),
RegisterResult::Network => ClaimMsg::Error(
"Couldn't reach goblin.st — connection hiccup. Try again.".into(),
),
},
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,
};
let msg = match rt.block_on(crate::nostr::nip05::unregister(&server, &name, &keys)) {
Ok(()) => ClaimMsg::Released,
Err(e) => ClaimMsg::Error(format!("Couldn't release: {e}")),
};
*slot.lock().unwrap() = Some(msg);
});
}
/// Process a picked picture and upload it as the avatar for an owned name.
fn start_avatar_upload(
slot: std::sync::Arc<std::sync::Mutex<Option<Result<(String, Vec<u8>), String>>>>,
path: String,
name: String,
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();
std::thread::spawn(move || {
let res = (|| {
let png = crate::nostr::avatar::process_avatar_file(&path)?;
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.map_err(|e| e.to_string())?;
let hash = rt.block_on(crate::nostr::nip05::upload_avatar(
&server,
&name,
&keys,
png.clone(),
))?;
Ok((hash, png))
})();
*slot.lock().unwrap() = Some(res);
});
}
/// Draw the small Goblin mascot mark.
pub fn widgets_logo(ui: &mut egui::Ui) {
widgets_logo_sized(ui, 22.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());
let img = egui::Image::new(egui::include_image!("../../../../img/goblin-logo2-256.png"))
.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));
}
fn settings_row(ui: &mut egui::Ui, label: &str, value: &str) {
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(t.surface_text_dim),
);
});
});
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 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()
}
/// Open a URL in the system browser.
fn open_url(ui: &egui::Ui, url: &str) {
ui.ctx().open_url(egui::OpenUrl::new_tab(url));
}
fn approve_button(ui: &mut egui::Ui) -> bool {
w::big_action(ui, "Approve", false).clicked()
}
fn decline_button(ui: &mut egui::Ui) -> bool {
w::big_action(ui, "Decline", true).clicked()
}
fn accept_policy_label(wallet: &Wallet) -> &'static str {
use crate::nostr::config::AcceptPolicy;
wallet
.nostr_service()
.map(|s| match s.config.read().accept_from() {
AcceptPolicy::Everyone => "Anyone",
AcceptPolicy::Contacts => "Contacts only",
AcceptPolicy::Ask => "Always ask",
})
.unwrap_or("Anyone")
}
/// 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 => "none".to_string(),
1 => relays[0].replace("wss://", ""),
n => format!("{} relays", n),
}
})
.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 · Tor".
/// 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 => "integrated node".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 => "integrated node".to_string(),
crate::wallet::types::ConnectionMethod::External(_, url) => url
.replace("https://", "")
.replace("http://", "")
.trim_end_matches('/')
.to_string(),
};
if height == 0 {
format!("{} · syncing", conn)
} else {
format!("Block {} · {}", fmt_thousands(height), conn)
}
}
/// 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<String> {
let p = crate::AppConfig::pairing();
let vs = p.vs_currency()?;
let rate = crate::http::grin_rate(vs)?;
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;
Some(format!(
"≈ {} · 1ツ = {}",
fmt_pairing(grin * rate, p),
fmt_pairing(rate, p)
))
}
/// 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) -> Option<String> {
let p = crate::AppConfig::pairing();
let vs = p.vs_currency()?;
let rate = crate::http::grin_rate(vs)?;
Some(format!("≈ {}", fmt_pairing(grin * rate, p)))
}
/// 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())
}