1
0
forked from GRIN/grim

Goblin UI: Cash App-style wallet surface (P4-P5)

New src/gui/views/goblin/ module rendered as the primary surface for an open
wallet: bottom tab bar (Wallet/Activity/Scan/Me), balance hero in ツ, Send/
Receive, recent peers, activity feed (wallet txs joined with nostr metadata
by slate id), pending payment requests with approve/decline, receive screen
with nostr-handle QR, settings/Me tab. Full send flow (recipient resolve via
npub/NIP-05 over Tor -> numpad amount -> review -> hold-to-send -> success)
dispatching WalletTask::NostrSend. Widgets library, fiat preview over Tor.
Fixed a font-binding panic: weight families are referenced only at widget
call sites, not in default text styles (set_fonts applies a pass later).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Claude
2026-06-10 01:50:52 -04:00
parent 1848d0c796
commit 8a6d442544
12 changed files with 2077 additions and 14 deletions
+158
View File
@@ -0,0 +1,158 @@
// 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.
//! Activity model: wallet transactions joined with nostr metadata.
use grin_wallet_libwallet::TxLogEntryType;
use crate::nostr::{Contact, NostrStore, TxNostrMeta};
use crate::wallet::Wallet;
use crate::wallet::types::WalletTx;
/// A unified activity entry for the Goblin feed.
pub struct ActivityItem {
pub tx_id: u32,
pub title: String,
pub note: Option<String>,
pub amount: u64,
pub incoming: bool,
pub confirmed: bool,
pub system: bool,
pub hue: usize,
pub time: i64,
/// Counterparty npub hex, when known.
pub npub: Option<String>,
}
/// Resolve the display title for a contact npub.
pub fn contact_title(store: &NostrStore, npub: &str) -> (String, usize) {
if let Some(contact) = store.contact(npub) {
(display_name(&contact), contact.hue as usize)
} else {
let hue = usize::from_str_radix(&npub[..2.min(npub.len())], 16).unwrap_or(0) % 7;
(short_npub(npub), hue)
}
}
/// Display rule: petname → @user (verified goblin.st) → user@domain → npub short.
pub fn display_name(contact: &Contact) -> String {
if let Some(petname) = &contact.petname {
if !petname.is_empty() {
return petname.clone();
}
}
if let (Some(nip05), Some(_)) = (&contact.nip05, contact.nip05_verified_at) {
if let Some((name, domain)) = nip05.split_once('@') {
if domain == crate::nostr::relays::HOME_NIP05_DOMAIN {
return format!("@{}", name);
}
return nip05.clone();
}
}
short_npub(&contact.npub)
}
/// Short npub display (npub1abcd…wxyz) from a hex pubkey.
pub fn short_npub(hex: &str) -> String {
use nostr_sdk::{PublicKey, ToBech32};
if let Ok(pk) = PublicKey::from_hex(hex) {
if let Ok(npub) = pk.to_bech32() {
if npub.len() > 16 {
return format!("{}{}", &npub[..9], &npub[npub.len() - 4..]);
}
return npub;
}
}
format!("{}", &hex[..8.min(hex.len())])
}
/// Build the activity feed for a wallet, newest first.
pub fn activity_items(wallet: &Wallet) -> Vec<ActivityItem> {
let data = match wallet.get_data() {
Some(d) => d,
None => return vec![],
};
let txs = data.txs.unwrap_or_default();
let store = wallet.nostr_service().map(|s| s.store.clone());
let mut items: Vec<ActivityItem> = txs
.iter()
.map(|tx| build_item(tx, store.as_deref()))
.collect();
items.sort_by_key(|i| std::cmp::Reverse(i.time));
items
}
fn build_item(tx: &WalletTx, store: Option<&NostrStore>) -> ActivityItem {
let incoming = matches!(
tx.data.tx_type,
TxLogEntryType::TxReceived | TxLogEntryType::ConfirmedCoinbase
);
let system = matches!(tx.data.tx_type, TxLogEntryType::ConfirmedCoinbase);
let slate_id = tx.data.tx_slate_id.map(|u| u.to_string());
let meta: Option<TxNostrMeta> = slate_id
.as_ref()
.and_then(|sid| store.and_then(|s| s.tx_meta(sid)));
let (title, hue) = if system {
("Mining reward".to_string(), 5)
} else if let Some(meta) = &meta {
store
.map(|s| contact_title(s, &meta.npub))
.unwrap_or_else(|| (short_npub(&meta.npub), 0))
} else {
// Fall back to slatepack address counterparty or generic label.
let label = if incoming {
"Received".to_string()
} else {
"Sent".to_string()
};
(label, (tx.data.id as usize) % 7)
};
let note = meta.as_ref().and_then(|m| m.note.clone());
let time = tx
.data
.confirmation_ts
.or(Some(tx.data.creation_ts))
.map(|t| t.timestamp())
.unwrap_or(0);
ActivityItem {
tx_id: tx.data.id,
title,
note,
amount: tx.amount,
incoming,
confirmed: tx.data.confirmed,
system,
hue,
time,
npub: meta.map(|m| m.npub),
}
}
/// Recent unique peers for the home strip (most recent first).
pub fn recent_peers(wallet: &Wallet, limit: usize) -> Vec<(String, usize, String)> {
let store = match wallet.nostr_service() {
Some(s) => s.store.clone(),
None => return vec![],
};
let mut contacts = store.all_contacts();
contacts.sort_by_key(|c| std::cmp::Reverse(c.last_paid_at.unwrap_or(c.added_at)));
contacts
.into_iter()
.take(limit)
.map(|c| (display_name(&c), c.hue as usize, c.npub))
.collect()
}
+691
View File
@@ -0,0 +1,691 @@
// 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 data;
pub mod send;
pub mod widgets;
use eframe::epaint::{FontId, Stroke};
use egui::{Align, Layout, Margin, RichText, ScrollArea, Sense, Vec2};
use crate::gui::icons::{CLOCK, COPY, SCAN, USER_CIRCLE, WALLET};
use crate::gui::platform::PlatformCallbacks;
use crate::gui::theme::{self, fonts};
use crate::gui::views::wallets::wallet::WalletTransactionsContent;
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.
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum Tab {
Home,
Activity,
Receive,
Me,
}
/// Goblin wallet content view.
pub struct GoblinWalletView {
tab: Tab,
send: Option<SendFlow>,
/// Legacy transactions content reused for detail rendering when needed.
txs_content: WalletTransactionsContent,
}
impl Default for GoblinWalletView {
fn default() -> Self {
Self {
tab: Tab::Home,
send: None,
txs_content: WalletTransactionsContent::new(None),
}
}
}
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::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();
// Send flow takes the full surface when active.
if let Some(send) = &mut self.send {
let done = send.ui(ui, wallet, cb);
if done {
self.send = None;
}
return;
}
// Bottom tab bar.
let bottom_inset = View::get_bottom_inset();
egui::TopBottomPanel::bottom("goblin_tabs")
.frame(egui::Frame {
fill: t.bg,
inner_margin: Margin {
left: 8,
right: 8,
top: 8,
bottom: (4.0 + bottom_inset) as i8,
},
stroke: Stroke::new(1.0, t.line),
..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),
Tab::Activity => self.activity_ui(ui, wallet, cb),
Tab::Receive => self.receive_ui(ui, wallet, cb),
Tab::Me => self.me_ui(ui, wallet, cb),
});
});
}
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 tabs = [
(Tab::Home, WALLET, "Wallet", false),
(Tab::Activity, CLOCK, "Activity", has_requests),
(Tab::Receive, SCAN, "Scan", false),
(Tab::Me, USER_CIRCLE, "Me", false),
];
ui.horizontal(|ui| {
let w = ui.available_width() / tabs.len() as f32;
for (tab, icon, label, badge) in tabs {
let (rect, resp) = ui.allocate_exact_size(Vec2::new(w, 48.0), Sense::click());
let active = self.tab == tab;
let color = if active { t.text } else { t.text_mute };
ui.painter().text(
rect.center() - Vec2::new(0.0, 8.0),
egui::Align2::CENTER_CENTER,
icon,
FontId::new(22.0, fonts::regular()),
color,
);
ui.painter().text(
rect.center() + Vec2::new(0.0, 14.0),
egui::Align2::CENTER_CENTER,
label,
FontId::new(11.0, fonts::semibold()),
color,
);
if badge {
ui.painter()
.circle_filled(rect.center() + Vec2::new(12.0, -10.0), 4.0, t.neg);
}
if resp.clicked() {
if tab == Tab::Receive {
// "Scan" routes to receive screen for now.
self.tab = Tab::Receive;
} else {
self.tab = tab;
}
}
}
});
}
fn home_ui(&mut self, ui: &mut egui::Ui, wallet: &Wallet, cb: &dyn PlatformCallbacks) {
let data = wallet.get_data();
ScrollArea::vertical()
.auto_shrink([false; 2])
.show(ui, |ui| {
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.
let peers = recent_peers(wallet, 8);
if !peers.is_empty() {
w::kicker(ui, "Recent");
ui.add_space(12.0);
ScrollArea::horizontal()
.id_salt("goblin_peers")
.auto_shrink([false, true])
.show(ui, |ui| {
ui.horizontal(|ui| {
for (name, hue, npub) in &peers {
ui.vertical(|ui| {
let resp = w::avatar(ui, name, 48.0, *hue);
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);
}
// 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);
});
}
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);
// 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])
.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(),
};
w::activity_row(
ui,
&item.title,
&subtitle,
item.hue,
&amount,
item.incoming,
item.system,
);
}
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));
w::card(ui, |ui| {
ui.horizontal(|ui| {
w::avatar(ui, &name, 40.0, hue);
ui.add_space(12.0);
ui.vertical(|ui| {
ui.label(
RichText::new(format!("{} requests", name))
.font(FontId::new(15.0, fonts::semibold()))
.color(t.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.text),
);
ui.label(
RichText::new(w::TSU)
.font(FontId::new(13.0, fonts::medium()))
.color(t.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| {
if approve_button(ui) {
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).
let uri = format!("nostr:{}", npub);
crate::gui::views::QrCodeContent::new(uri, false).ui(ui, cb);
ui.add_space(8.0);
ui.label(
RichText::new(&handle)
.font(FontId::new(18.0, fonts::bold()))
.color(t.text),
);
ui.label(
RichText::new("Share your handle to get paid")
.font(FontId::new(13.0, fonts::regular()))
.color(t.text_dim),
);
});
});
ui.add_space(12.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, 56.0),
)),
|ui| {
if w::big_action(ui, &format!("{} Copy", COPY), true).clicked() {
let copy = if npub.is_empty() {
handle.clone()
} else {
npub.clone()
};
cb.copy_string_to_buffer(copy);
}
},
);
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, "Slatepack", false).clicked() {
// Fall back to manual slatepack receive via legacy flow.
self.tab = Tab::Activity;
}
},
);
});
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();
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) = wallet
.nostr_service()
.map(|s| {
let identity = s.identity.read();
let handle = identity
.nip05
.clone()
.map(|n| format!("@{}", n.split('@').next().unwrap_or("")))
.unwrap_or_else(|| data::short_npub(&hex_of(&identity.npub)));
(handle, s.npub(), s.is_connected())
})
.unwrap_or_else(|| ("Anonymous".to_string(), String::new(), false));
w::card(ui, |ui| {
ui.horizontal(|ui| {
w::avatar(ui, &handle, 56.0, 6);
ui.add_space(14.0);
ui.vertical(|ui| {
ui.label(
RichText::new(&handle)
.font(FontId::new(17.0, fonts::bold()))
.color(t.text),
);
let status = if connected {
"Connected over Tor"
} else {
"Connecting…"
};
ui.label(
RichText::new(status)
.font(FontId::new(13.0, fonts::regular()))
.color(t.text_dim),
);
});
});
});
ui.add_space(16.0);
ScrollArea::vertical()
.auto_shrink([false; 2])
.show(ui, |ui| {
settings_group(ui, "Privacy", |ui| {
settings_row(ui, "Tor routing", "All payments routed over Tor");
settings_row(ui, "Auto-accept", accept_policy_label(wallet));
});
ui.add_space(16.0);
settings_group(ui, "Wallet", |ui| {
settings_row(ui, "Display unit", "ツ (grin)");
settings_row(ui, "Relays", &relay_summary(wallet));
if !npub.is_empty() {
if settings_row_btn(ui, "Backup nostr key", COPY) {
cb.copy_string_to_buffer(npub.clone());
}
}
});
ui.add_space(16.0);
settings_group(ui, "About", |ui| {
settings_row(ui, "Network", "Mimblewimble · no address on chain");
settings_row(ui, "Version", env!("CARGO_PKG_VERSION"));
});
ui.add_space(16.0);
});
}
}
/// Draw the small Goblin mascot mark.
pub fn widgets_logo(ui: &mut egui::Ui) {
let size = 22.0;
let (rect, _) = ui.allocate_exact_size(Vec2::splat(size), Sense::hover());
let img = egui::Image::new(egui::include_image!("../../../../img/goblin-mask-64.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.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.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;
ui.horizontal(|ui| {
ui.label(
RichText::new(label)
.font(FontId::new(15.0, fonts::medium()))
.color(t.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.text_dim),
);
if resp.interact(Sense::click()).clicked() {
clicked = true;
}
});
});
ui.add_space(10.0);
clicked
}
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")
}
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.
fn fiat_line(data: &Option<WalletData>) -> Option<String> {
let _ = data;
// Fiat rate provider is wired in P7; hide the line until a rate is available.
crate::http::grin_usd_rate().map(|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;
format!("≈ ${:.2} · 1ツ = ${:.4}", grin * rate, rate)
})
}
/// 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())
}
+573
View File
@@ -0,0 +1,573 @@
// 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 send flow: pick recipient → enter amount → review → success.
use eframe::epaint::FontId;
use egui::{Align, Layout, RichText, ScrollArea, Sense, Vec2};
use grin_core::core::amount_from_hr_string;
use crate::gui::icons::{ARROW_LEFT, MAGNIFYING_GLASS, USERS};
use crate::gui::platform::PlatformCallbacks;
use crate::gui::theme::{self, fonts};
use crate::gui::views::View;
use crate::nostr::nip05;
use crate::wallet::Wallet;
use crate::wallet::types::WalletTask;
use super::data::{display_name, recent_peers, short_npub};
use super::widgets::{self as w, HoldToSend};
/// Stage of the send flow.
#[derive(PartialEq, Eq)]
enum Stage {
Recipient,
Amount,
Review,
Sending,
Success,
}
/// A resolved recipient.
#[derive(Clone)]
struct Recipient {
name: String,
npub: String,
hue: usize,
}
/// The send flow state.
pub struct SendFlow {
stage: Stage,
search: String,
recipient: Option<Recipient>,
amount: String,
note: String,
hold: HoldToSend,
error: Option<String>,
/// Resolution in flight (bech32/nip05).
resolving: bool,
}
impl Default for SendFlow {
fn default() -> Self {
Self {
stage: Stage::Recipient,
search: String::new(),
recipient: None,
amount: String::new(),
note: String::new(),
hold: HoldToSend::default(),
error: None,
resolving: false,
}
}
}
impl SendFlow {
/// Pre-fill a contact and skip to amount entry.
pub fn prefill_contact(&mut self, name: String, npub: String) {
let hue = usize::from_str_radix(&npub[..2.min(npub.len())], 16).unwrap_or(0) % 7;
self.recipient = Some(Recipient { name, npub, hue });
self.stage = Stage::Amount;
}
/// Render the flow. Returns true when the flow is finished (close it).
pub fn ui(&mut self, ui: &mut egui::Ui, wallet: &Wallet, cb: &dyn PlatformCallbacks) -> bool {
let t = theme::tokens();
let mut done = false;
egui::CentralPanel::default()
.frame(egui::Frame {
fill: if self.stage == Stage::Success {
t.accent
} else {
t.bg
},
inner_margin: egui::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,
crate::gui::views::Content::SIDE_PANEL_WIDTH * 1.2,
|ui| match self.stage {
Stage::Recipient => done = self.recipient_ui(ui, wallet, cb),
Stage::Amount => done = self.amount_ui(ui, wallet),
Stage::Review => done = self.review_ui(ui, wallet),
Stage::Sending => self.sending_ui(ui, wallet),
Stage::Success => done = self.success_ui(ui),
},
);
});
done
}
fn back_header(&self, 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.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
}
fn recipient_ui(
&mut self,
ui: &mut egui::Ui,
wallet: &Wallet,
cb: &dyn PlatformCallbacks,
) -> bool {
let t = theme::tokens();
if self.back_header(ui, "Send to") {
return true;
}
// Search field.
let mut search = self.search.clone();
ui.horizontal(|ui| {
ui.label(
RichText::new(MAGNIFYING_GLASS)
.font(FontId::new(18.0, fonts::regular()))
.color(t.text_mute),
);
ui.add_space(8.0);
let edit = egui::TextEdit::singleline(&mut search)
.hint_text("@handle, npub, or name")
.desired_width(ui.available_width())
.frame(false);
ui.add(edit);
});
if search != self.search {
self.search = search;
self.error = None;
}
ui.add_space(6.0);
View::horizontal_line(ui, t.line);
ui.add_space(12.0);
// Resolve button when input looks like a handle/npub.
if !self.search.trim().is_empty() {
if w::big_action(ui, "Find recipient", false).clicked() {
self.resolve_search(wallet);
}
if let Some(err) = &self.error {
ui.add_space(8.0);
ui.label(
RichText::new(err)
.font(FontId::new(13.0, fonts::regular()))
.color(t.neg),
);
}
ui.add_space(16.0);
}
// Suggested contacts / recent peers.
ui.label(
RichText::new(format!("{} Suggested", USERS))
.font(fonts::kicker())
.color(t.text_mute),
);
ui.add_space(8.0);
let peers = recent_peers(wallet, 20);
let _ = cb;
ScrollArea::vertical()
.auto_shrink([false; 2])
.show(ui, |ui| {
if peers.is_empty() {
ui.add_space(20.0);
ui.label(
RichText::new("No contacts yet. Find someone by their @handle.")
.font(FontId::new(14.0, fonts::regular()))
.color(t.text_dim),
);
}
for (name, hue, npub) in peers {
let resp =
w::activity_row(ui, &name, &short_npub(&npub), hue, "", false, false);
if resp.clicked() {
self.recipient = Some(Recipient { name, npub, hue });
self.stage = Stage::Amount;
}
}
});
false
}
fn resolve_search(&mut self, wallet: &Wallet) {
let input = self.search.trim().to_string();
self.resolving = true;
// Try bech32 npub directly.
use nostr_sdk::{FromBech32, PublicKey};
if let Ok(pk) = PublicKey::from_bech32(&input) {
self.set_recipient_from_hex(wallet, &pk.to_hex(), None);
self.resolving = false;
return;
}
if input.len() == 64 && input.chars().all(|c| c.is_ascii_hexdigit()) {
self.set_recipient_from_hex(wallet, &input.to_lowercase(), None);
self.resolving = false;
return;
}
// Try NIP-05 resolution (user@domain or @user) over Tor.
if let Some((name, domain)) = nip05::split_identifier(&input) {
match resolve_nip05_blocking(&name, &domain) {
Some(res) => {
let nip05_id = format!("{}@{}", name, domain);
self.set_recipient_from_hex(wallet, &res.pubkey.to_hex(), Some(nip05_id));
}
None => self.error = Some("Couldn't find that handle".to_string()),
}
} else {
self.error = Some("Enter an @handle, npub, or name".to_string());
}
self.resolving = false;
}
fn set_recipient_from_hex(&mut self, wallet: &Wallet, hex: &str, nip05: Option<String>) {
let (name, hue) = wallet
.nostr_service()
.map(|s| {
if let Some(c) = s.store.contact(hex) {
(display_name(&c), c.hue as usize)
} else {
let hue = usize::from_str_radix(&hex[..2], 16).unwrap_or(0) % 7;
(
nip05
.clone()
.map(|n| format!("@{}", n.split('@').next().unwrap_or("")))
.unwrap_or_else(|| short_npub(hex)),
hue,
)
}
})
.unwrap_or_else(|| (short_npub(hex), 0));
self.recipient = Some(Recipient {
name,
npub: hex.to_string(),
hue,
});
self.stage = Stage::Amount;
}
fn amount_ui(&mut self, ui: &mut egui::Ui, wallet: &Wallet) -> bool {
let t = theme::tokens();
if self.back_header(ui, "Amount") {
self.stage = Stage::Recipient;
return false;
}
let recipient = self.recipient.clone().unwrap();
// Recipient chip.
ui.vertical_centered(|ui| {
ui.horizontal(|ui| {
w::avatar(ui, &recipient.name, 28.0, recipient.hue);
ui.add_space(8.0);
ui.label(
RichText::new(format!("To {}", recipient.name))
.font(FontId::new(14.0, fonts::semibold()))
.color(t.text),
);
});
});
ui.add_space(20.0);
// Big amount display.
let display = if self.amount.is_empty() {
"0".to_string()
} else {
self.amount.clone()
};
ui.vertical_centered(|ui| {
w::amount_text(ui, &display, 64.0);
if let Some(rate) = crate::http::grin_usd_rate() {
if let Ok(grin) = display.parse::<f64>() {
ui.add_space(6.0);
ui.label(
RichText::new(format!("≈ ${:.2}", grin * rate))
.font(FontId::new(14.0, fonts::regular()))
.color(t.text_dim),
);
}
}
});
ui.add_space(16.0);
// Quick chips.
ui.horizontal(|ui| {
ui.add_space((ui.available_width() - 220.0).max(0.0) / 2.0);
for v in ["1", "10", "100", "Max"] {
if w::chip(ui, v, false).clicked() {
if v == "Max" {
let max = wallet
.get_data()
.map(|d| d.info.amount_currently_spendable)
.unwrap_or(0);
self.amount = w::amount_str(max);
} else {
self.amount = v.to_string();
}
}
ui.add_space(8.0);
}
});
ui.add_space(16.0);
// Note field.
w::card(ui, |ui| {
ui.horizontal(|ui| {
ui.label(
RichText::new("Note")
.font(FontId::new(14.0, fonts::regular()))
.color(t.text_dim),
);
ui.add_space(8.0);
let edit = egui::TextEdit::singleline(&mut self.note)
.hint_text("Add a note…")
.desired_width(ui.available_width())
.frame(false);
ui.add(edit);
});
});
ui.add_space(12.0);
// Numpad (mobile) — desktop also accepts keyboard via egui events.
if !View::is_desktop() {
w::numpad(ui, &mut self.amount);
} else {
ui.input(|i| {
for ev in &i.events {
if let egui::Event::Text(txt) = ev {
for ch in txt.chars() {
if ch.is_ascii_digit() {
w::apply_key(&mut self.amount, &ch.to_string());
} else if ch == '.' {
w::apply_key(&mut self.amount, ".");
}
}
}
if let egui::Event::Key {
key: egui::Key::Backspace,
pressed: true,
..
} = ev
{
w::apply_key(&mut self.amount, "<");
}
}
});
}
ui.add_space(8.0);
let valid = amount_from_hr_string(&self.amount)
.map(|a| a > 0)
.unwrap_or(false);
ui.add_enabled_ui(valid, |ui| {
if w::big_action(ui, "Review", false).clicked() {
self.stage = Stage::Review;
}
});
false
}
fn review_ui(&mut self, ui: &mut egui::Ui, wallet: &Wallet) -> bool {
let t = theme::tokens();
if self.back_header(ui, "Review") {
self.stage = Stage::Amount;
return false;
}
let recipient = self.recipient.clone().unwrap();
let amount = self.amount.clone();
w::card(ui, |ui| {
ui.vertical_centered(|ui| {
ui.horizontal(|ui| {
w::avatar(ui, &recipient.name, 36.0, recipient.hue);
ui.add_space(8.0);
ui.label(
RichText::new(format!("You're sending {}", recipient.name))
.font(FontId::new(14.0, fonts::regular()))
.color(t.text_dim),
);
});
ui.add_space(8.0);
w::amount_text(ui, &amount, 48.0);
});
});
ui.add_space(16.0);
w::info_row(ui, "To", &recipient.name);
if !self.note.trim().is_empty() {
w::info_row(ui, "Note", &format!("\u{201C}{}\u{201D}", self.note.trim()));
}
w::info_row(ui, "Privacy", "Mimblewimble");
w::info_row(ui, "Delivery", "Encrypted nostr DM over Tor");
ui.add_space(16.0);
if self.hold.ui(ui, "Hold to send") {
self.dispatch(wallet);
self.stage = Stage::Sending;
}
ui.add_space(6.0);
ui.vertical_centered(|ui| {
ui.label(
RichText::new("Press and hold to confirm")
.font(FontId::new(12.0, fonts::regular()))
.color(t.text_mute),
);
});
false
}
fn dispatch(&mut self, wallet: &Wallet) {
if let (Some(recipient), Ok(amount)) =
(&self.recipient, amount_from_hr_string(&self.amount))
{
let note = if self.note.trim().is_empty() {
None
} else {
Some(self.note.trim().to_string())
};
wallet.task(WalletTask::NostrSend(amount, recipient.npub.clone(), note));
}
}
fn sending_ui(&mut self, ui: &mut egui::Ui, wallet: &Wallet) {
let t = theme::tokens();
ui.add_space(80.0);
ui.vertical_centered(|ui| {
View::big_loading_spinner(ui);
ui.add_space(16.0);
ui.label(
RichText::new("Sending…")
.font(FontId::new(18.0, fonts::semibold()))
.color(t.text),
);
});
// Move to success once the wallet is no longer creating the send.
if !wallet.send_creating() {
self.stage = Stage::Success;
}
ui.ctx().request_repaint();
}
fn success_ui(&mut self, ui: &mut egui::Ui) -> bool {
let t = theme::tokens();
let recipient = self.recipient.clone().unwrap();
ui.add_space(80.0);
ui.vertical_centered(|ui| {
// Mascot in an ink circle.
let (rect, _) = ui.allocate_exact_size(Vec2::splat(120.0), Sense::hover());
ui.painter()
.circle_filled(rect.center(), 60.0, t.accent_ink);
let img = egui::Image::new(egui::include_image!("../../../../img/goblin-mask-128.png"))
.tint(t.accent)
.fit_to_exact_size(Vec2::splat(72.0));
img.paint_at(
ui,
egui::Rect::from_center_size(rect.center(), Vec2::splat(72.0)),
);
ui.add_space(24.0);
ui.label(
RichText::new("Sent")
.font(FontId::new(34.0, fonts::bold()))
.color(t.accent_ink),
);
ui.add_space(8.0);
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 0.0;
let total_w = ui.available_width();
ui.add_space(total_w / 2.0 - 60.0);
ui.label(
RichText::new(&self.amount)
.font(FontId::new(40.0, fonts::mono_semibold()))
.color(t.accent_ink),
);
ui.label(
RichText::new(w::TSU)
.font(FontId::new(20.0, fonts::medium()))
.color(t.accent_ink),
);
});
ui.add_space(8.0);
ui.label(
RichText::new(format!("to {} · just now", recipient.name))
.font(FontId::new(15.0, fonts::regular()))
.color(t.accent_ink.gamma_multiply(0.7)),
);
});
let mut done = false;
ui.with_layout(Layout::bottom_up(Align::Center), |ui| {
ui.add_space(20.0);
let (rect, resp) =
ui.allocate_exact_size(Vec2::new(ui.available_width(), 56.0), Sense::click());
ui.painter().rect(
rect,
eframe::epaint::CornerRadius::same(14),
t.accent_ink,
eframe::epaint::Stroke::NONE,
egui::StrokeKind::Inside,
);
ui.painter().text(
rect.center(),
egui::Align2::CENTER_CENTER,
"Done",
FontId::new(17.0, fonts::semibold()),
t.accent,
);
if resp.clicked() {
done = true;
}
});
done
}
}
/// Resolve a NIP-05 identifier on a short-lived runtime (blocking the UI
/// briefly is acceptable for an explicit "find recipient" action).
fn resolve_nip05_blocking(name: &str, domain: &str) -> Option<nip05::Nip05Resolution> {
let name = name.to_string();
let domain = domain.to_string();
std::thread::spawn(move || {
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.ok()?;
rt.block_on(nip05::resolve(&name, &domain))
})
.join()
.ok()
.flatten()
}
+488
View File
@@ -0,0 +1,488 @@
// 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.
//! Reusable Goblin design widgets: avatars, amounts, buttons, rows, chips.
use eframe::epaint::{CornerRadius, FontId, Stroke};
use egui::{Align, Color32, Layout, Response, RichText, Sense, Ui, Vec2};
use crate::gui::theme::{self, fonts};
/// Currency mark for grin amounts.
pub const TSU: &str = "";
/// Format atomic grin units to a trimmed human string (no unit).
pub fn amount_str(atomic: u64) -> String {
grin_core::core::amount_to_hr_string(atomic, true)
}
/// Draw a colored avatar puck with the contact initial.
pub fn avatar(ui: &mut Ui, name: &str, size: f32, hue: usize) -> Response {
let (rect, resp) = ui.allocate_exact_size(Vec2::splat(size), Sense::click());
let bg = theme::avatar_color(hue);
ui.painter().circle_filled(rect.center(), size / 2.0, bg);
let initial = name
.chars()
.next()
.map(|c| c.to_uppercase().to_string())
.unwrap_or_else(|| "?".to_string());
ui.painter().text(
rect.center(),
egui::Align2::CENTER_CENTER,
initial,
FontId::new(size * 0.42, fonts::bold()),
theme::ink_for(bg),
);
resp
}
/// Draw a balance/amount: big mono number + smaller ツ mark, tight.
pub fn amount_text(ui: &mut Ui, value: &str, size: f32) {
let t = theme::tokens();
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 0.0;
ui.label(
RichText::new(value)
.font(FontId::new(size, fonts::mono_semibold()))
.color(t.text),
);
ui.add_space(1.0);
ui.label(
RichText::new(TSU)
.font(FontId::new(size * 0.4, fonts::medium()))
.color(t.text_dim),
);
});
}
/// An uppercase letterspaced kicker label.
pub fn kicker(ui: &mut Ui, text: &str) {
let t = theme::tokens();
ui.label(
RichText::new(text.to_uppercase())
.font(fonts::kicker())
.color(t.text_mute),
);
}
/// Big primary/secondary action button (56px, radius 14).
pub fn big_action(ui: &mut Ui, label: &str, secondary: bool) -> Response {
let t = theme::tokens();
let desired = Vec2::new(ui.available_width(), 56.0);
let (rect, resp) = ui.allocate_exact_size(desired, Sense::click());
let (fill, ink, stroke) = if secondary {
(Color32::TRANSPARENT, t.text, Stroke::new(1.5, t.line))
} else {
(t.accent, t.accent_ink, Stroke::NONE)
};
let visual_fill = if resp.hovered() && !secondary {
t.accent_dark
} else {
fill
};
ui.painter().rect(
rect,
CornerRadius::same(14),
visual_fill,
stroke,
egui::StrokeKind::Inside,
);
ui.painter().text(
rect.center(),
egui::Align2::CENTER_CENTER,
label,
FontId::new(17.0, fonts::semibold()),
ink,
);
resp
}
/// A pill/chip; returns the click response. `active` paints it inverted.
pub fn chip(ui: &mut Ui, label: &str, active: bool) -> Response {
let t = theme::tokens();
let galley = ui.painter().layout_no_wrap(
label.to_string(),
FontId::new(13.0, fonts::semibold()),
if active { t.bg } else { t.text },
);
let pad = Vec2::new(14.0, 8.0);
let size = galley.size() + pad * 2.0;
let (rect, resp) = ui.allocate_exact_size(size, Sense::click());
let fill = if active { t.text } else { t.surface2 };
ui.painter().rect(
rect,
CornerRadius::same(255),
fill,
Stroke::NONE,
egui::StrokeKind::Inside,
);
ui.painter().galley(
rect.center() - galley.size() / 2.0,
galley,
if active { t.bg } else { t.text },
);
resp
}
/// A balance hero block: kicker, big number + ツ, optional fiat line.
pub fn balance_hero(ui: &mut Ui, atomic: u64, fiat: Option<&str>, size: f32) {
let t = theme::tokens();
kicker(ui, "Balance");
ui.add_space(6.0);
amount_text(ui, &amount_str(atomic), size);
if let Some(fiat) = fiat {
ui.add_space(4.0);
ui.label(
RichText::new(fiat)
.font(FontId::new(13.0, fonts::regular()))
.color(t.text_dim),
);
}
}
/// An activity row: avatar, title, subtitle, signed amount.
/// Returns the row click response.
pub fn activity_row(
ui: &mut Ui,
title: &str,
subtitle: &str,
hue: usize,
amount: &str,
incoming: bool,
system: bool,
) -> Response {
let t = theme::tokens();
let row_h = 60.0;
let (rect, resp) =
ui.allocate_exact_size(Vec2::new(ui.available_width(), row_h), Sense::click());
let mut content = ui.new_child(
egui::UiBuilder::new()
.max_rect(rect.shrink2(Vec2::new(0.0, 8.0)))
.layout(Layout::left_to_right(Align::Center)),
);
content.horizontal(|ui| {
if system {
let (r, _) = ui.allocate_exact_size(Vec2::splat(40.0), Sense::hover());
ui.painter().rect(
r,
CornerRadius::same(10),
t.surface2,
Stroke::NONE,
egui::StrokeKind::Inside,
);
ui.painter().text(
r.center(),
egui::Align2::CENTER_CENTER,
crate::gui::icons::CUBE,
FontId::new(20.0, fonts::regular()),
t.text,
);
} else {
avatar(ui, title, 40.0, hue);
}
ui.add_space(12.0);
ui.vertical(|ui| {
ui.add_space(2.0);
ui.label(
RichText::new(title)
.font(FontId::new(15.0, fonts::semibold()))
.color(t.text),
);
ui.label(
RichText::new(subtitle)
.font(FontId::new(13.0, fonts::regular()))
.color(t.text_dim),
);
});
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
ui.label(
RichText::new(amount)
.font(FontId::new(15.0, fonts::mono_semibold()))
.color(if incoming { t.pos } else { t.text }),
);
});
});
// Divider.
let line_y = rect.bottom();
ui.painter()
.hline(rect.left()..=rect.right(), line_y, Stroke::new(1.0, t.line));
resp
}
/// Section header used above grouped lists.
pub fn section_header(ui: &mut Ui, text: &str) {
ui.add_space(8.0);
kicker(ui, text);
ui.add_space(6.0);
}
/// Draw a rounded surface card and run a closure inside it.
pub fn card<R>(ui: &mut Ui, add_contents: impl FnOnce(&mut Ui) -> R) -> R {
let t = theme::tokens();
egui::Frame::new()
.fill(t.surface)
.stroke(Stroke::new(1.0, t.line))
.corner_radius(CornerRadius::same(18))
.inner_margin(16.0)
.show(ui, add_contents)
.inner
}
/// A bordered rect helper for non-interactive value rows.
pub fn info_row(ui: &mut Ui, label: &str, value: &str) {
let t = theme::tokens();
ui.horizontal(|ui| {
ui.label(
RichText::new(label)
.font(FontId::new(14.0, fonts::regular()))
.color(t.text_dim),
);
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
ui.label(
RichText::new(value)
.font(FontId::new(15.0, fonts::semibold()))
.color(t.text),
);
});
});
ui.add_space(8.0);
ui.painter().hline(
ui.min_rect().left()..=ui.min_rect().right(),
ui.cursor().top(),
Stroke::new(1.0, t.line),
);
ui.add_space(8.0);
}
/// Draw a centered Send / Receive split. Returns (send, receive) clicks.
pub fn send_receive(ui: &mut Ui) -> (bool, bool) {
let t = theme::tokens();
let mut send = false;
let mut receive = false;
let h = 60.0;
ui.horizontal(|ui| {
let w = (ui.available_width() - 10.0) / 2.0;
let (rs, resp_s) = ui.allocate_exact_size(Vec2::new(w, h), Sense::click());
ui.painter().rect(
rs,
CornerRadius::same(14),
if resp_s.hovered() {
t.accent_dark
} else {
t.accent
},
Stroke::NONE,
egui::StrokeKind::Inside,
);
ui.painter().text(
rs.center(),
egui::Align2::CENTER_CENTER,
format!("{} Send", crate::gui::icons::ARROW_UP),
FontId::new(16.0, fonts::semibold()),
t.accent_ink,
);
send = resp_s.clicked();
ui.add_space(10.0);
let (rr, resp_r) = ui.allocate_exact_size(Vec2::new(w, h), Sense::click());
ui.painter().rect(
rr,
CornerRadius::same(14),
if resp_r.hovered() {
t.hover
} else {
t.surface2
},
Stroke::NONE,
egui::StrokeKind::Inside,
);
ui.painter().text(
rr.center(),
egui::Align2::CENTER_CENTER,
format!("{} Receive", crate::gui::icons::ARROW_DOWN),
FontId::new(16.0, fonts::semibold()),
t.text,
);
receive = resp_r.clicked();
});
(send, receive)
}
/// A simple numeric keypad. Mutates `amount` string. Returns true if changed.
pub fn numpad(ui: &mut Ui, amount: &mut String) -> bool {
let t = theme::tokens();
let mut changed = false;
let keys = [
["1", "2", "3"],
["4", "5", "6"],
["7", "8", "9"],
[".", "0", "<"],
];
let key_h = 56.0;
for row in keys.iter() {
ui.horizontal(|ui| {
let w = (ui.available_width() - 2.0 * 8.0) / 3.0;
for &k in row.iter() {
let (rect, resp) = ui.allocate_exact_size(Vec2::new(w, key_h), Sense::click());
let label = if k == "<" {
crate::gui::icons::BACKSPACE.to_string()
} else {
k.to_string()
};
let col = if resp.hovered() { t.text } else { t.text };
ui.painter().text(
rect.center(),
egui::Align2::CENTER_CENTER,
label,
FontId::new(28.0, fonts::medium()),
col,
);
if resp.clicked() {
apply_key(amount, k);
changed = true;
}
ui.add_space(8.0);
}
});
ui.add_space(4.0);
}
changed
}
/// Apply a numpad key to the amount string with validation.
pub fn apply_key(amount: &mut String, key: &str) {
match key {
"<" => {
amount.pop();
}
"." => {
if !amount.contains('.') {
if amount.is_empty() {
amount.push('0');
}
amount.push('.');
}
}
d => {
// Limit to 9 decimals (grin precision).
if let Some(dot) = amount.find('.') {
if amount.len() - dot - 1 >= 9 {
return;
}
}
// Avoid leading zeros like "00".
if amount == "0" {
amount.clear();
}
amount.push_str(d);
}
}
}
/// Paint a full-rect background fill on the current panel.
pub fn fill_bg(ui: &Ui, color: Color32) {
let rect = ui.ctx().screen_rect();
ui.painter().rect_filled(rect, CornerRadius::ZERO, color);
}
/// Center a fixed-width column for narrow content on wide screens.
pub fn centered_column<R>(ui: &mut Ui, width: f32, add: impl FnOnce(&mut Ui) -> R) -> R {
let avail = ui.available_width();
let w = width.min(avail);
let margin = ((avail - w) / 2.0).max(0.0);
let mut result = None;
ui.horizontal(|ui| {
ui.add_space(margin);
ui.vertical(|ui| {
ui.set_width(w);
result = Some(add(ui));
});
});
result.unwrap()
}
/// Hold-to-send button: fills over `hold_secs`; returns true once on completion.
pub struct HoldToSend {
progress: f32,
}
impl Default for HoldToSend {
fn default() -> Self {
Self { progress: 0.0 }
}
}
impl HoldToSend {
pub fn ui(&mut self, ui: &mut Ui, label: &str) -> bool {
let t = theme::tokens();
let (rect, resp) = ui.allocate_exact_size(
Vec2::new(ui.available_width(), 56.0),
Sense::click_and_drag(),
);
// Background.
ui.painter().rect(
rect,
CornerRadius::same(14),
t.surface2,
Stroke::NONE,
egui::StrokeKind::Inside,
);
let held = resp.is_pointer_button_down_on() || resp.dragged();
let dt = ui.input(|i| i.stable_dt).min(0.1);
if held {
self.progress = (self.progress + dt / 0.7).min(1.0);
ui.ctx().request_repaint();
} else {
self.progress = (self.progress - dt / 0.3).max(0.0);
if self.progress > 0.0 {
ui.ctx().request_repaint();
}
}
// Progress fill.
if self.progress > 0.0 {
let mut fill_rect = rect;
fill_rect.set_width(rect.width() * self.progress);
ui.painter().rect(
fill_rect,
CornerRadius::same(14),
t.accent,
Stroke::NONE,
egui::StrokeKind::Inside,
);
}
ui.painter().text(
rect.center(),
egui::Align2::CENTER_CENTER,
label,
FontId::new(17.0, fonts::semibold()),
if self.progress > 0.5 {
t.accent_ink
} else {
t.text
},
);
if self.progress >= 1.0 {
self.progress = 0.0;
return true;
}
false
}
}
/// Shorten a long key/address for display (8…6).
pub fn short_key(key: &str) -> String {
if key.len() <= 16 {
return key.to_string();
}
format!("{}{}", &key[..8], &key[key.len() - 6..])
}
+1
View File
@@ -26,6 +26,7 @@ pub use modal::*;
mod content;
pub use content::*;
pub mod goblin;
pub mod network;
pub mod settings;
pub mod wallets;
+1 -1
View File
@@ -18,5 +18,5 @@ pub mod modals;
mod content;
pub use content::*;
mod wallet;
pub mod wallet;
use wallet::*;
+50 -3
View File
@@ -52,6 +52,9 @@ pub struct WalletContent {
invoice_content: Option<InvoiceRequestContent>,
/// Send request creation [`Modal`] content.
send_content: Option<SendRequestContent>,
/// Goblin Cash App-style surface (primary UI).
goblin: crate::gui::views::goblin::GoblinWalletView,
}
/// Identifier for invoice creation [`Modal`].
@@ -83,6 +86,41 @@ impl WalletContentContainer for WalletContent {
}
fn container_ui(&mut self, ui: &mut egui::Ui, wallet: &Wallet, cb: &dyn PlatformCallbacks) {
// Goblin surface is the primary UI. Show a sync screen until data is
// ready, then hand the whole surface to the Cash App-style view.
let block_nav_goblin = self.block_navigation_on_sync(wallet);
if block_nav_goblin || wallet.get_data().is_none() {
egui::CentralPanel::default()
.frame(egui::Frame {
fill: Colors::fill(),
..Default::default()
})
.show_inside(ui, |ui| {
sync_ui(ui, wallet);
});
self.handle_task_result(wallet);
return;
}
egui::CentralPanel::default()
.frame(egui::Frame {
fill: Colors::fill(),
..Default::default()
})
.show_inside(ui, |ui| {
self.goblin.ui(ui, wallet, cb);
self.handle_task_result(wallet);
});
}
}
impl WalletContent {
#[allow(dead_code)]
fn legacy_container_ui(
&mut self,
ui: &mut egui::Ui,
wallet: &Wallet,
cb: &dyn PlatformCallbacks,
) {
let dual_panel = Content::is_dual_panel_mode(ui.ctx());
let show_wallets_dual = AppConfig::show_wallets_at_dual_panel();
@@ -293,6 +331,7 @@ impl Default for WalletContent {
transport_content: WalletTransportContent::default(),
invoice_content: None,
send_content: None,
goblin: crate::gui::views::goblin::GoblinWalletView::default(),
}
}
}
@@ -317,16 +356,24 @@ impl WalletContent {
/// Check if it's possible to go back at navigation stack.
pub fn can_back(&self) -> bool {
self.account_content.can_back() || self.transport_content.can_back()
self.goblin.overlay_active()
|| self.account_content.can_back()
|| self.transport_content.can_back()
}
/// Navigate back on navigation stack.
pub fn back(&mut self, cb: &dyn PlatformCallbacks) {
/// Navigate back on navigation stack. Returns true if not consumed.
pub fn back(&mut self, cb: &dyn PlatformCallbacks) -> bool {
if self.goblin.overlay_active() {
return self.goblin.on_back();
}
if self.account_content.can_back() {
self.account_content.back(cb);
return false;
} else if self.transport_content.can_back() {
self.transport_content.back();
return false;
}
self.goblin.on_back()
}
/// Check when to block tabs navigation on sync progress.
+3
View File
@@ -17,3 +17,6 @@ pub use client::*;
mod release;
pub use release::*;
mod price;
pub use price::grin_usd_rate;
+86
View File
@@ -0,0 +1,86 @@
// 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.
//! GRIN/USD price preview, fetched over Tor and cached.
use lazy_static::lazy_static;
use parking_lot::RwLock;
use std::time::{SystemTime, UNIX_EPOCH};
use crate::AppConfig;
use crate::tor::Tor;
/// Cache refresh interval (seconds).
const REFRESH_SECS: i64 = 300;
lazy_static! {
/// Cached (rate, fetched_at) and an in-flight flag.
static ref RATE: RwLock<Option<(f64, i64)>> = RwLock::new(None);
static ref FETCHING: std::sync::atomic::AtomicBool =
std::sync::atomic::AtomicBool::new(false);
}
fn now() -> i64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0)
}
/// Get the cached GRIN/USD rate if fresh, triggering a refresh otherwise.
/// Returns `None` until the first successful fetch or when fiat is disabled.
pub fn grin_usd_rate() -> Option<f64> {
if !AppConfig::fiat_preview() {
return None;
}
let cached = { RATE.read().clone() };
let needs_refresh = match cached {
Some((_, ts)) => now() - ts > REFRESH_SECS,
None => true,
};
if needs_refresh {
trigger_refresh();
}
cached.map(|(rate, _)| rate)
}
/// Spawn a background refresh over Tor (deduplicated).
fn trigger_refresh() {
use std::sync::atomic::Ordering;
if FETCHING.swap(true, Ordering::SeqCst) {
return;
}
std::thread::spawn(|| {
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
rt.block_on(async {
if let Some(rate) = fetch_rate().await {
let mut w = RATE.write();
*w = Some((rate, now()));
}
});
FETCHING.store(false, Ordering::SeqCst);
});
}
/// Fetch the GRIN/USD rate from CoinGecko over Tor.
async fn fetch_rate() -> Option<f64> {
let url =
"https://api.coingecko.com/api/v3/simple/price?ids=grin&vs_currencies=usd".to_string();
let body = Tor::http_request("GET", url, None, vec![]).await?;
let doc: serde_json::Value = serde_json::from_str(&body).ok()?;
doc.get("grin")?.get("usd")?.as_f64()
}
+7 -8
View File
@@ -271,17 +271,16 @@ pub fn setup_fonts(ctx: &Context) {
use egui::FontId;
use egui::TextStyle;
// NOTE: text_styles must only reference Proportional/Monospace families.
// set_fonts() applies on the next pass while set_style() is immediate; a
// default text style referencing a custom Name family would panic on the
// first frame before the fonts swap in. Goblin weights are applied at the
// widget call sites via RichText::font(), which render after the swap.
let mut style = (*ctx.style()).clone();
style.text_styles = [
(
TextStyle::Heading,
FontId::new(19.0, egui::FontFamily::Name("geist-semibold".into())),
),
(TextStyle::Heading, FontId::new(19.0, Proportional)),
(TextStyle::Body, FontId::new(16.0, Proportional)),
(
TextStyle::Button,
FontId::new(17.0, egui::FontFamily::Name("geist-medium".into())),
),
(TextStyle::Button, FontId::new(17.0, Proportional)),
(TextStyle::Small, FontId::new(15.0, Proportional)),
(
TextStyle::Monospace,
+2 -2
View File
@@ -19,8 +19,8 @@
mod types;
pub use types::*;
mod config;
pub use config::NostrConfig;
pub mod config;
pub use config::{AcceptPolicy, NostrConfig};
pub mod relays;
+17
View File
@@ -75,6 +75,8 @@ pub struct AppConfig {
density: Option<String>,
/// Identifier of the last opened wallet to boot into.
last_wallet_id: Option<i64>,
/// Show fiat (USD) preview alongside amounts.
fiat_preview: Option<bool>,
/// Flag to use proxy for network requests.
use_proxy: Option<bool>,
@@ -109,6 +111,7 @@ impl Default for AppConfig {
theme: None,
density: None,
last_wallet_id: None,
fiat_preview: None,
use_proxy: None,
use_socks_proxy: None,
http_proxy_url: None,
@@ -360,6 +363,20 @@ impl AppConfig {
w_config.save();
}
/// Check if fiat (USD) preview is enabled (default on).
pub fn fiat_preview() -> bool {
let r_config = Settings::app_config_to_read();
r_config.fiat_preview.unwrap_or(true)
}
/// Toggle fiat preview.
pub fn toggle_fiat_preview() {
let enabled = Self::fiat_preview();
let mut w_config = Settings::app_config_to_update();
w_config.fiat_preview = Some(!enabled);
w_config.save();
}
/// Get identifier of the last opened wallet.
pub fn last_wallet_id() -> Option<i64> {
let r_config = Settings::app_config_to_read();