1
0
forked from GRIN/grim

goblin: split mod.rs into focused sibling submodules

Behavior-preserving structural refactor of the ~8,600-line
src/gui/views/goblin/mod.rs into 17 flat sibling modules following the
existing send.rs/onboarding.rs/widgets.rs pattern. Pure move: no UI,
string, layout, or logic changes.

mod.rs keeps the shell (Tab, GoblinWalletView + state structs,
SettingsPage, Default impl, nav/lifecycle methods, the ui() dispatcher,
and chrome: tab_bar_ui/sidebar_ui/handle_tex/avatar_self) and shrinks
from 8,640 to 1,711 lines.

New siblings (each `use super::*;`, cross-module items pub(super)):
home, pay, receipt, profile, activity, receive, me, settings,
settings_advanced, settings_node, username, identities, prompts, modals,
privacy (network/Tor screen), helpers, format. Unit tests moved next to
their subjects (censor tests -> format::tests; dispatcher test stays).

cargo check --all-targets clean; cargo test --lib 257 passed;
i18n_keys passed. rustfmt applied to touched files.
This commit is contained in:
2ro
2026-07-09 21:17:08 -04:00
parent fc0999e271
commit de0c8737f3
18 changed files with 7361 additions and 6949 deletions
+509
View File
@@ -0,0 +1,509 @@
// 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 tab: history rows, incoming requests and request review.
use super::*;
impl GoblinWalletView {
/// List-row timestamp: date + HH:MM, no seconds. The tap-in detail view keeps
/// the full timestamp to the second (see [`View::format_time`]).
pub(super) fn list_time(ts: i64) -> String {
let utc_offset = chrono::Local::now().offset().local_minus_utc();
chrono::DateTime::from_timestamp(ts + utc_offset as i64, 0)
.map(|t| t.format("%d/%m/%Y %H:%M").to_string())
.unwrap_or_default()
}
/// The (left message, right timestamp) an [`ActivityItem`] shows in a row. The
/// timestamp (no seconds) is only set for a confirmed tx; otherwise the status
/// word (canceled/pending) folds into the message so a row with no time still
/// reads its state without an empty right-side time slot.
pub(super) fn activity_note_time(item: &ActivityItem) -> (String, String) {
let status_word = if item.canceled {
t!("goblin.activity.canceled").to_string()
} else {
t!("goblin.activity.pending").to_string()
};
let time = if item.confirmed {
Self::list_time(item.time)
} else {
String::new()
};
let note = match (item.note.as_deref(), item.confirmed) {
(Some(n), false) => format!("{n} · {status_word}"),
(None, false) => status_word,
(Some(n), true) => n.to_string(),
(None, true) => String::new(),
};
(note, time)
}
/// Friendly day-grouping label for the activity feed.
pub(super) fn day_label(ts: i64) -> String {
use chrono::{TimeZone, Utc};
let Some(dt) = Utc.timestamp_opt(ts, 0).single() else {
return t!("goblin.activity.earlier").to_string();
};
let today = Utc::now().date_naive();
let day = dt.date_naive();
if day == today {
t!("goblin.activity.today").to_string()
} else if (today - day).num_days() == 1 {
t!("goblin.activity.yesterday").to_string()
} else {
dt.format("%b %-d, %Y").to_string()
}
}
pub(super) fn activity_ui(
&mut self,
ui: &mut egui::Ui,
wallet: &Wallet,
cb: &dyn PlatformCallbacks,
) {
ui.add_space(8.0);
ui.label(
RichText::new(t!("goblin.activity.title"))
.font(FontId::new(28.0, fonts::bold()))
.color(theme::tokens().text),
);
ui.add_space(12.0);
// Recent contacts strip (payment-app-style row above the feed).
self.peers_strip_ui(ui, wallet, "goblin_peers_activity");
// Pending payment requests pinned on top.
if let Some(service) = wallet.nostr_service() {
// An approve that failed (e.g. funds still confirming) flips the send
// phase to FAILED — un-grey the buttons so the user can retry, and
// surface why instead of leaving the card stuck.
if service.send_phase() == crate::nostr::send_phase::FAILED
&& !self.approving.is_empty()
{
self.approving.clear();
self.request_error = service.last_send_error();
}
let requests = service.store.pending_requests();
if !requests.is_empty() {
w::section_header(ui, &t!("goblin.activity.requests"));
if let Some(err) = &self.request_error {
ui.add_space(4.0);
ui.label(
RichText::new(err)
.font(FontId::new(13.0, fonts::regular()))
.color(theme::tokens().neg),
);
ui.add_space(4.0);
}
for req in requests {
self.request_row_ui(ui, &req, wallet);
}
ui.add_space(8.0);
}
}
ScrollArea::vertical()
.id_salt("goblin_activity_scroll")
.auto_shrink([false; 2])
.scroll_bar_visibility(egui::scroll_area::ScrollBarVisibility::AlwaysHidden)
.show(ui, |ui| {
let items = activity_items(wallet);
let id_cue = IdentityCueCtx::compute(wallet);
if items.is_empty() {
empty_state(
ui,
&t!("goblin.activity.empty_title"),
&t!("goblin.activity.empty_sub"),
);
} else {
// Unconfirmed (< min confirmations) pinned on top as Pending.
// Canceled txs are not pending — they group with history below.
let pending: Vec<&_> = items
.iter()
.filter(|i| !i.confirmed && !i.system && !i.canceled)
.collect();
if !pending.is_empty() {
w::section_header(ui, &t!("goblin.activity.pending_header"));
for item in pending {
self.activity_item_ui(ui, item, wallet, cb, &id_cue);
}
ui.add_space(8.0);
}
// Confirmed (and canceled), grouped by day (newest first).
let mut last: Option<String> = None;
for item in items
.iter()
.filter(|i| i.confirmed || i.system || i.canceled)
{
let label = Self::day_label(item.time);
if last.as_deref() != Some(label.as_str()) {
w::section_header(ui, &label);
last = Some(label);
}
self.activity_item_ui(ui, item, wallet, cb, &id_cue);
}
}
ui.add_space(16.0);
});
}
pub(super) fn activity_item_ui(
&mut self,
ui: &mut egui::Ui,
item: &ActivityItem,
wallet: &Wallet,
_cb: &dyn PlatformCallbacks,
id_cue: &IdentityCueCtx,
) {
// No +/- for canceled: nothing moved.
let sign = if item.canceled {
""
} else if item.incoming {
"+ "
} else {
" "
};
// Anonymous mode dots the name and amount and replaces the avatar with the
// uniform censored tile (drawn inside `activity_row` from the `anon` flag)
// and drops the memo, so nothing leaks; the row still taps through to the
// full detail, which is the "reveal" the spec calls for.
let anon = crate::AppConfig::anonymous_mode();
let amount = if anon {
// Fixed dot count, never digit-matched, so a censored row can't leak
// the amount's magnitude.
censored_amount_dots(item.amount, false)
} else {
format!("{}{}{}", sign, w::amount_str(item.amount), w::TSU)
};
let (note, time) = Self::activity_note_time(item);
let tex = if anon {
None
} else {
self.handle_tex(ui.ctx(), wallet, &item.title)
};
let (title, note_ref, id_ref): (&str, &str, &str) = if anon {
(CENSOR_NAME_DOTS, "", "")
} else {
(&item.title, &note, item.npub.as_deref().unwrap_or(""))
};
let resp = w::activity_row(
ui,
title,
note_ref,
&time,
id_ref,
&amount,
item.incoming,
item.canceled,
item.system,
tex.as_ref(),
anon,
);
// Per-identity cue (owner-approved): only when the wallet holds MORE THAN
// ONE identity, and never on system (mining) rows. A small corner badge on
// the counterparty avatar, filled with the identity THIS tx used (its own
// gradient; falls back to the primary for pre-feature rows). The row avatar
// is 40px, flush to the row's left and vertically centred, so its
// bottom-right corner is at (left+40, mid+20); the badge overhangs that
// corner by ~4px (matching the mock's right:-4/bottom:-4, 14px badge).
if !anon && SHOW_ROW_IDENTITY_CUE && id_cue.multi && !item.system {
let seed = item.owner_pubkey.clone().or_else(|| id_cue.primary.clone());
if let Some(seed) = seed {
let r = resp.rect;
let badge = egui::pos2(r.left() + 37.0, r.center().y + 17.0);
w::identity_dot(ui.painter(), badge, 6.0, &seed);
}
}
if resp.clicked() {
self.receipt = Some(item.tx_id);
}
}
pub(super) fn request_row_ui(
&mut self,
ui: &mut egui::Ui,
req: &crate::nostr::PaymentRequest,
wallet: &Wallet,
) {
let t = theme::tokens();
// While an approved request is being paid, the whole card becomes one
// centered spinner labelled with the action, sitting exactly where the card
// was: no Decline, no amount, no buttons. It vanishes once the send
// completes and the request clears from the pending list.
if self.approving.contains(&req.rumor_id) {
let working = wallet
.nostr_service()
.map(|s| s.send_phase() == crate::nostr::send_phase::WORKING)
.unwrap_or(false);
w::card(ui, |ui| {
ui.vertical_centered(|ui| {
ui.add_space(6.0);
View::small_loading_spinner(ui);
ui.add_space(2.0);
ui.label(
RichText::new(t!("goblin.receipt.paying"))
.font(FontId::new(12.0, fonts::regular()))
.color(t.text_dim),
);
ui.add_space(6.0);
});
});
if working {
ui.ctx().request_repaint();
}
ui.add_space(10.0);
return;
}
let name = wallet
.nostr_service()
.map(|s| data::contact_title(&s.store, &req.npub))
.unwrap_or_else(|| data::short_npub(&req.npub));
let tex = self.handle_tex(ui.ctx(), wallet, &name);
w::card(ui, |ui| {
ui.horizontal(|ui| {
w::avatar_any(ui, &name, &req.npub, 40.0, tex.as_ref());
ui.add_space(12.0);
ui.vertical(|ui| {
ui.label(
RichText::new(t!("goblin.request.title", name => name))
.font(FontId::new(15.0, fonts::semibold()))
.color(t.surface_text),
);
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 0.0;
ui.label(
RichText::new(w::amount_str(req.amount))
.font(FontId::new(15.0, fonts::mono_semibold()))
.color(t.surface_text),
);
ui.label(
RichText::new(w::TSU)
.font(FontId::new(13.0, fonts::medium()))
.color(t.surface_text_dim),
);
});
});
});
if let Some(note) = &req.note {
ui.add_space(6.0);
ui.label(
RichText::new(format!("\u{201C}{}\u{201D}", note))
.font(FontId::new(13.0, fonts::regular()))
.color(t.text_dim),
);
}
ui.add_space(10.0);
ui.horizontal(|ui| {
let half = (ui.available_width() - 10.0) / 2.0;
ui.scope_builder(
egui::UiBuilder::new().max_rect(egui::Rect::from_min_size(
ui.cursor().min,
Vec2::new(half, 44.0),
)),
|ui| {
if decline_button(ui) {
// Optimistically clear the card, then send the decline as
// a void control message so the requester's side clears
// too. Requests are messages; payments are final.
let mut r = req.clone();
r.status = crate::nostr::RequestStatus::Declined;
if let Some(s) = wallet.nostr_service() {
s.store.save_request(&r);
}
wallet.task(crate::wallet::types::WalletTask::NostrDeclineRequest(
req.rumor_id.clone(),
));
}
},
);
ui.add_space(10.0);
ui.scope_builder(
egui::UiBuilder::new().max_rect(egui::Rect::from_min_size(
ui.cursor().min,
Vec2::new(half, 44.0),
)),
|ui| {
if approve_button(ui) {
// Don't pay on the tap — open the review screen and make
// the user hold-to-accept there, like a send. The actual
// NostrPayRequest is dispatched from approve_review_ui. Once
// approved, the in-flight branch above takes over this card.
self.request_error = None;
self.approve_hold = w::HoldToSend::default();
self.approve_fee_for = None;
self.approve_review = Some(req.clone());
}
},
);
});
});
ui.add_space(10.0);
}
/// Full-surface review for an incoming payment request: who's asking, how
/// much, the network fee — then hold-to-accept. Paying a request is a spend,
/// so this mirrors the send review's confirm gesture instead of a one-tap
/// accept. Returns true when the screen should close (back, or after the
/// payment is enqueued by the hold).
pub(super) fn approve_review_ui(&mut self, ui: &mut egui::Ui, wallet: &Wallet) -> bool {
let t = theme::tokens();
let Some(req) = self.approve_review.clone() else {
return true;
};
let name = wallet
.nostr_service()
.map(|s| data::contact_title(&s.store, &req.npub))
.unwrap_or_else(|| data::short_npub(&req.npub));
let tex = self.handle_tex(ui.ctx(), wallet, &name);
// Paying a request spends our balance, so guard against over-balance and
// disable the accept gesture (re-checked each frame).
let spendable = wallet
.get_data()
.map(|d| d.info.amount_currently_spendable)
.unwrap_or(0);
let over = req.amount > spendable;
let mut close = false;
egui::CentralPanel::default()
.frame(egui::Frame {
fill: t.bg,
inner_margin: Margin {
left: (View::far_left_inset_margin(ui) + 20.0) as i8,
right: (View::get_right_inset() + 20.0) as i8,
top: (View::get_top_inset() + 12.0) as i8,
bottom: (View::get_bottom_inset() + 12.0) as i8,
},
..Default::default()
})
.show_inside(ui, |ui| {
w::centered_column(ui, Content::SIDE_PANEL_WIDTH * 1.2, |ui| {
if Self::overlay_back_header(ui, &t!("goblin.request.review_title")) {
close = true;
}
ScrollArea::vertical()
.id_salt("goblin_approve_scroll")
.auto_shrink([false; 2])
.scroll_bar_visibility(egui::scroll_area::ScrollBarVisibility::AlwaysHidden)
.show(ui, |ui| {
ui.add_space(8.0);
w::card(ui, |ui| {
ui.set_min_width(ui.available_width());
ui.add_space(8.0);
ui.vertical_centered(|ui| {
w::avatar_any(ui, &name, &req.npub, 40.0, tex.as_ref());
ui.add_space(6.0);
ui.label(
RichText::new(t!("goblin.request.title", name => &name))
.font(FontId::new(14.0, fonts::regular()))
.color(t.surface_text_dim),
);
});
ui.add_space(8.0);
let amt = w::amount_str(req.amount);
w::amount_text_centered_ink(
ui,
&amt,
48.0,
t.surface_text,
t.surface_text_dim,
);
ui.add_space(8.0);
});
ui.add_space(16.0);
w::info_row(ui, &t!("goblin.send.row_from"), &name);
if let Some(note) = &req.note {
if !note.trim().is_empty() {
w::info_row(
ui,
&t!("goblin.send.row_note"),
&format!("\u{201C}{}\u{201D}", note.trim()),
);
}
}
// Live network fee for paying this request (a spend),
// priced like the send review — one CalculateFee per amount.
if req.amount > 0 && self.approve_fee_for != Some(req.amount) {
self.approve_fee_for = Some(req.amount);
wallet.task(crate::wallet::types::WalletTask::CalculateFee(
req.amount, 0,
));
}
let fee_val = match wallet.calculated_fee(req.amount) {
Some(fee) => format!("{}{}", w::amount_str(fee), w::TSU),
None => {
ui.ctx().request_repaint_after(
std::time::Duration::from_millis(120),
);
"".to_string()
}
};
w::info_row(ui, &t!("goblin.send.row_network_fee"), &fee_val);
w::info_row(
ui,
&t!("goblin.send.row_privacy"),
&t!("goblin.send.row_privacy_val"),
);
w::info_row(
ui,
&t!("goblin.send.row_delivery"),
&t!("goblin.send.row_delivery_val"),
);
ui.add_space(16.0);
if over {
ui.vertical_centered(|ui| {
ui.label(
RichText::new(t!("goblin.send.not_enough"))
.font(FontId::new(14.0, fonts::regular()))
.color(t.neg),
);
});
ui.add_space(8.0);
}
ui.add_enabled_ui(!over, |ui| {
if self
.approve_hold
.ui(ui, &t!("goblin.request.hold_to_accept"))
&& !over
{
// Guard double-pay + show the spinner back on the
// request card; dispatch the actual payment.
self.approving.insert(req.rumor_id.clone());
self.request_error = None;
wallet.task(crate::wallet::types::WalletTask::NostrPayRequest(
req.rumor_id.clone(),
));
close = true;
}
});
ui.add_space(6.0);
ui.vertical_centered(|ui| {
ui.label(
RichText::new(if over {
t!("goblin.send.lower_amount")
} else {
t!("goblin.request.hold_accept_hint")
})
.font(FontId::new(12.0, fonts::regular()))
.color(t.text_mute),
);
});
ui.add_space(16.0);
});
});
});
close
}
}
+270
View File
@@ -0,0 +1,270 @@
// 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.
//! Pure formatting and summary helpers.
use super::*;
/// Number of dots a censored money value renders as. FIXED (never the real
/// digit count) so anonymous mode can't leak the balance magnitude.
pub(super) const CENSOR_DOT_COUNT: usize = 5;
/// The fixed dot string a censored name renders as (activity rows and the Recent
/// strip). A constant width, never derived from the real name, so its length
/// can't hint at who the counterparty is.
pub(super) const CENSOR_NAME_DOTS: &str = "••••••";
pub(super) fn relay_summary(wallet: &Wallet) -> String {
wallet
.nostr_service()
.map(|s| {
let relays = s.relays();
match relays.len() {
0 => t!("goblin.relays.none").to_string(),
1 => relays[0].replace("wss://", ""),
n => t!("goblin.relays.count", n => n).to_string(),
}
})
.unwrap_or_else(|| "".to_string())
}
/// Compute a fiat preview line for the balance, when a rate is available.
/// One-line node summary: "Block 1,847,221 · main.gri.mw".
/// Bare node host (or "integrated node") for the sidebar card's third line.
pub(super) fn node_host(wallet: &Wallet) -> String {
match wallet.get_current_connection() {
crate::wallet::types::ConnectionMethod::Integrated => {
t!("goblin.node.integrated_host").to_string()
}
crate::wallet::types::ConnectionMethod::External(_, url) => url
.replace("https://", "")
.replace("http://", "")
.trim_end_matches('/')
.to_string(),
}
}
pub(super) fn node_summary(wallet: &Wallet) -> String {
let height = wallet
.get_data()
.map(|d| d.info.last_confirmed_height)
.unwrap_or(0);
let conn = match wallet.get_current_connection() {
crate::wallet::types::ConnectionMethod::Integrated => {
t!("goblin.node.integrated_host").to_string()
}
crate::wallet::types::ConnectionMethod::External(_, url) => url
.replace("https://", "")
.replace("http://", "")
.trim_end_matches('/')
.to_string(),
};
if height == 0 {
t!("goblin.node.summary_syncing", conn => conn).to_string()
} else {
t!("goblin.node.summary_block", height => fmt_thousands(height), conn => conn).to_string()
}
}
/// Format a number with thousands separators.
pub(super) 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
}
pub(super) fn fiat_line(data: &Option<WalletData>) -> Option<w::FiatLine> {
use crate::http::RateState;
let p = crate::AppConfig::pairing();
let vs = p.vs_currency()?;
// Asking for the rate here (while the balance is on screen) is what kicks a
// live refetch when the in-session rate has aged out; an idle wallet never
// reaches this path.
Some(match crate::http::grin_rate(vs) {
RateState::Fresh(rate) => {
let spendable = data
.as_ref()
.map(|d| d.info.amount_currently_spendable)
.unwrap_or(0);
let grin = spendable as f64 / 1_000_000_000.0;
w::FiatLine::Text(format!(
"{} · 1ツ = {}",
fmt_pairing(grin * rate, p),
fmt_pairing(rate, p)
))
}
RateState::Loading => w::FiatLine::Loading,
RateState::Unavailable => w::FiatLine::Unavailable,
})
}
/// The anonymous-mode censor for a money value: always [`CENSOR_DOT_COUNT`]
/// dots, deliberately ignoring the real amount so its magnitude never leaks.
/// `spaced` widens the dots for the balance hero; activity amounts pass false.
pub(super) fn censored_amount_dots(_atomic: u64, spaced: bool) -> String {
let sep = if spaced { " " } else { "" };
[""; CENSOR_DOT_COUNT].join(sep)
}
/// The anonymous-mode balance: a centered row of dots standing in for the
/// number, tappable to reveal. Returns true on the frame it is tapped. No fiat
/// line is drawn (and no rate fetch is triggered) while censored. `total` is
/// passed only so the censor is computed the same way everywhere; it is ignored
/// (the dot count is fixed) so the balance size never leaks.
pub(super) fn censored_balance_hero(ui: &mut egui::Ui, total: u64) -> bool {
let t = theme::tokens();
let mut clicked = false;
ui.vertical_centered(|ui| {
w::kicker(ui, "Balance");
ui.add_space(6.0);
let resp = ui.add(
egui::Label::new(
RichText::new(censored_amount_dots(total, true))
.font(FontId::new(56.0, fonts::bold()))
.color(t.text),
)
.sense(Sense::click()),
);
let resp = resp
.on_hover_cursor(egui::CursorIcon::PointingHand)
.on_hover_text(t!("goblin.settings.tap_reveal"));
ui.add_space(4.0);
ui.label(
RichText::new(t!("goblin.settings.tap_reveal"))
.font(FontId::new(12.5, fonts::medium()))
.color(t.text_dim),
);
clicked = resp.clicked();
});
clicked
}
/// Format a value already in the pairing's unit (dollars, BTC, …) with the
/// right symbol/precision. Sats scales the BTC value by 1e8.
pub(super) 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.
pub(super) fn pairing_preview(grin: f64, ctx: &egui::Context) -> Option<String> {
use crate::http::RateState;
let p = crate::AppConfig::pairing();
let vs = p.vs_currency()?;
match crate::http::grin_rate(vs) {
RateState::Fresh(rate) => Some(format!("{}", fmt_pairing(grin * rate, p))),
// No stale fallback: show nothing until a fresh rate lands. Nudge a repaint
// while loading so the preview appears once the live fetch returns.
RateState::Loading => {
ctx.request_repaint_after(std::time::Duration::from_millis(300));
None
}
RateState::Unavailable => None,
}
}
/// Convert a bech32 npub to hex for short display fallbacks.
pub(super) fn hex_of(npub: &str) -> String {
use nostr_sdk::{FromBech32, PublicKey};
PublicKey::from_bech32(npub)
.map(|pk| pk.to_hex())
.unwrap_or_else(|_| npub.to_string())
}
/// Largest point size in `[12.0, 16.0]` at which the semibold news title fits on
/// one line within `avail` px, measured against the live font atlas and stepping
/// down by 0.5. Returns the 12pt floor when even that overflows (the caller pairs
/// it with `.truncate()`). This is the shrink-to-fit safety net that keeps a
/// title readable on a 390px screen; the hard char cap (`news_title_clamped`) is
/// the predictable ceiling.
pub(super) fn fit_news_title_pt(ui: &egui::Ui, text: &str, avail: f32) -> f32 {
const CEIL: f32 = 16.0;
const FLOOR: f32 = 12.0;
let mut pt = CEIL;
while pt > FLOOR {
let w = ui
.painter()
.layout_no_wrap(
text.to_owned(),
FontId::new(pt, fonts::semibold()),
egui::Color32::WHITE,
)
.size()
.x;
if w <= avail {
return pt;
}
pt -= 0.5;
}
FLOOR
}
#[cfg(test)]
mod tests {
use super::*;
/// The censored money display must be a fixed number of dots that never
/// reflects the real amount — otherwise anonymous mode leaks the magnitude
/// (a bigger balance would show more/longer digits).
#[test]
fn censored_amount_is_fixed_width_regardless_of_size() {
for spaced in [false, true] {
let zero = censored_amount_dots(0, spaced);
let small = censored_amount_dots(1, spaced);
let huge = censored_amount_dots(u64::MAX, spaced);
assert_eq!(zero, small, "censor must not vary with amount");
assert_eq!(small, huge, "censor must not vary with amount");
assert_eq!(
zero.chars().filter(|c| *c == '•').count(),
CENSOR_DOT_COUNT,
"censor must always show exactly {CENSOR_DOT_COUNT} dots"
);
}
}
/// The censored name is a fixed run of dots, never empty and containing no
/// alphanumerics, so a dotted name on the activity feed or the Recent strip
/// can't leak any characters of who the counterparty is.
#[test]
fn censored_name_is_fixed_dots_only() {
assert!(
!CENSOR_NAME_DOTS.is_empty(),
"censored name must not be blank"
);
assert!(
CENSOR_NAME_DOTS.chars().all(|c| c == '•'),
"censored name must be dots only, no leaked characters"
);
}
}
+332
View File
@@ -0,0 +1,332 @@
// 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.
//! Shared row / button / style helpers.
use super::*;
/// Draw the small Goblin mascot mark.
pub fn widgets_logo(ui: &mut egui::Ui) {
widgets_logo_sized(ui, 24.0);
}
/// Tinted goblin mark at a given size.
pub fn widgets_logo_sized(ui: &mut egui::Ui, size: f32) {
let (rect, _) = ui.allocate_exact_size(Vec2::splat(size), Sense::hover());
// Chip-sized marks use a pre-rendered 48px raster: cleaner antialiasing
// at ~24px than runtime svg rasterization, with 2x headroom for hidpi.
let img = egui::Image::new(if size <= 32.0 {
egui::include_image!("../../../../img/goblin-logo2-48.png")
} else {
egui::include_image!("../../../../img/goblin-logo2.svg")
})
.tint(theme::tokens().text)
.fit_to_exact_size(Vec2::splat(size));
img.paint_at(ui, rect);
}
pub(super) 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),
);
});
}
pub(super) fn settings_group(ui: &mut egui::Ui, title: &str, add: impl FnOnce(&mut egui::Ui)) {
w::kicker(ui, title);
ui.add_space(8.0);
w::card(ui, |ui| add(ui));
}
/// Title row for an Advanced-page action card.
pub(super) fn advanced_head(ui: &mut egui::Ui, label: &str, color: Color32) {
ui.label(
RichText::new(label)
.font(FontId::new(15.0, fonts::semibold()))
.color(color),
);
ui.add_space(4.0);
}
/// Wrapped description line under an Advanced-page action title.
pub(super) fn advanced_desc(ui: &mut egui::Ui, text: &str) {
let t = theme::tokens();
ui.label(
RichText::new(text)
.font(FontId::new(13.0, fonts::regular()))
.color(t.surface_text_dim),
);
}
/// A settings row: label + subtitle on the left, an on/off switch on the right.
/// Returns `Some(new_value)` on the frame it is toggled.
pub(super) fn settings_row_toggle(
ui: &mut egui::Ui,
label: &str,
sub: &str,
on: bool,
) -> Option<bool> {
let t = theme::tokens();
let mut toggled = None;
ui.horizontal(|ui| {
// Reserve room for the switch and bound the text column, so a long
// label/subtitle WRAPS onto another line instead of running under the
// switch and clipping (longer locales clipped worst). 46px switch + gap.
let toggle_w = 58.0;
let text_w = (ui.available_width() - toggle_w).max(0.0);
ui.vertical(|ui| {
ui.set_width(text_w);
ui.label(
RichText::new(label)
.font(FontId::new(15.0, fonts::medium()))
.color(t.surface_text),
);
ui.label(
RichText::new(sub)
.font(FontId::new(13.0, fonts::regular()))
.color(t.surface_text_dim),
);
});
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
if w::toggle(ui, on).clicked() {
toggled = Some(!on);
}
});
});
ui.add_space(10.0);
toggled
}
pub(super) fn settings_row(ui: &mut egui::Ui, label: &str, value: &str) {
settings_row_ink(ui, label, value, theme::tokens().surface_text_dim);
}
/// Like [`settings_row`] but the value is drawn in an explicit ink — used to flag
/// the always-on Tor routing in the privacy color.
pub(super) fn settings_row_ink(ui: &mut egui::Ui, label: &str, value: &str, value_ink: Color32) {
let t = theme::tokens();
ui.horizontal(|ui| {
ui.label(
RichText::new(label)
.font(FontId::new(15.0, fonts::medium()))
.color(t.surface_text),
);
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
ui.label(
RichText::new(value)
.font(FontId::new(13.0, fonts::regular()))
.color(value_ink),
);
});
});
ui.add_space(10.0);
}
pub(super) 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).
pub(super) fn settings_row_danger(ui: &mut egui::Ui, label: &str, icon: &str) -> bool {
let t = theme::tokens();
let row = ui.horizontal(|ui| {
ui.label(
RichText::new(label)
.font(FontId::new(15.0, fonts::medium()))
.color(t.neg),
);
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
ui.label(
RichText::new(icon)
.font(FontId::new(18.0, fonts::regular()))
.color(t.neg),
);
});
});
ui.add_space(10.0);
row.response.interact(Sense::click()).clicked()
}
/// A settings row whose value cycles in place on tap (no navigation): the
/// value is drawn in the same small/dim style as [`settings_row_nav`] so it
/// sits consistently next to chevroned siblings, just without the chevron.
pub(super) fn settings_row_cycle(ui: &mut egui::Ui, label: &str, value: &str) -> bool {
let t = theme::tokens();
let row = ui.horizontal(|ui| {
ui.label(
RichText::new(label)
.font(FontId::new(15.0, fonts::medium()))
.color(t.surface_text),
);
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
ui.label(
RichText::new(value)
.font(FontId::new(13.0, fonts::regular()))
.color(t.surface_text_dim),
);
});
});
ui.add_space(10.0);
row.response.interact(Sense::click()).clicked()
}
/// A settings row that navigates somewhere: value + chevron, whole row taps.
pub(super) 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.
pub(super) fn open_url(ui: &egui::Ui, url: &str) {
ui.ctx().open_url(egui::OpenUrl::new_tab(url));
}
/// Linear blend between two colors (`p` 0→`a`, 1→`b`). Used by the Pay-screen
/// over-balance flash to ease the digits from red back to normal ink.
pub(super) fn lerp_color(a: Color32, b: Color32, p: f32) -> Color32 {
let p = p.clamp(0.0, 1.0);
let mix = |x: u8, y: u8| (x as f32 + (y as f32 - x as f32) * p).round() as u8;
Color32::from_rgb(mix(a.r(), b.r()), mix(a.g(), b.g()), mix(a.b(), b.b()))
}
pub(super) fn approve_button(ui: &mut egui::Ui) -> bool {
w::big_action(ui, &t!("goblin.request.approve"), false).clicked()
}
pub(super) fn decline_button(ui: &mut egui::Ui) -> bool {
w::big_action(ui, &t!("goblin.request.decline"), true).clicked()
}
pub(super) fn accept_policy_label(wallet: &Wallet) -> String {
use crate::nostr::config::AcceptPolicy;
wallet
.nostr_service()
.map(|s| match s.config.read().accept_from() {
AcceptPolicy::Everyone => t!("goblin.settings.accept_anyone").to_string(),
AcceptPolicy::Contacts => t!("goblin.settings.accept_contacts").to_string(),
AcceptPolicy::Ask => t!("goblin.settings.accept_ask").to_string(),
})
.unwrap_or_else(|| t!("goblin.settings.accept_anyone").to_string())
}
/// Cycle the color theme Dark ↔ Light and re-apply visuals. Yellow is kept
/// defined (gui/theme.rs) but out of the picker for now — it's still in beta;
/// `Yellow => Dark` is an escape hatch for anyone whose config already has it.
pub(super) 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.
pub(super) 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);
}
}
impl GoblinWalletView {
/// Round back button + title for full-surface overlays. Returns true on tap.
pub(super) fn overlay_back_header(ui: &mut egui::Ui, title: &str) -> bool {
let t = theme::tokens();
let mut back = false;
ui.horizontal(|ui| {
let (rect, resp) = ui.allocate_exact_size(Vec2::splat(36.0), Sense::click());
ui.painter().circle_filled(rect.center(), 18.0, t.surface2);
ui.painter().text(
rect.center(),
egui::Align2::CENTER_CENTER,
ARROW_LEFT,
FontId::new(16.0, fonts::regular()),
t.text,
);
back = resp
.on_hover_cursor(egui::CursorIcon::PointingHand)
.clicked();
ui.add_space(12.0);
ui.label(
RichText::new(title)
.font(FontId::new(18.0, fonts::bold()))
.color(t.text),
);
});
ui.add_space(12.0);
back
}
}
+348
View File
@@ -0,0 +1,348 @@
// 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.
//! Home tab: balance hero, node card, news and peers strip.
use super::*;
impl GoblinWalletView {
/// Compact node status card: sync state dot, block height, connection.
pub(super) fn node_card_ui(&mut self, ui: &mut egui::Ui, wallet: &Wallet) {
let t = theme::tokens();
let height = wallet
.get_data()
.map(|d| d.info.last_confirmed_height)
.unwrap_or(0);
// Distinguish "scanning" from "can't reach the node": a flaky
// external node otherwise reads as syncing forever.
let error = wallet.sync_error();
let synced = height > 0 && !wallet.syncing() && !error;
w::card(ui, |ui| {
ui.set_min_width(ui.available_width());
ui.horizontal(|ui| {
let (dot, _) = ui.allocate_exact_size(Vec2::splat(10.0), Sense::hover());
ui.painter().circle_filled(
dot.center(),
4.0,
if error {
t.neg
} else if synced {
t.pos
} else {
t.accent
},
);
ui.add_space(8.0);
ui.vertical(|ui| {
ui.label(
RichText::new(if error {
t!("goblin.home.cant_reach_node")
} else if synced {
t!("goblin.home.node_synced")
} else {
t!("goblin.home.syncing")
})
.font(FontId::new(14.0, fonts::semibold()))
.color(t.surface_text),
);
// Three lines: status, block height, then the node host
// on its own line so it never truncates the height.
let height = wallet
.get_data()
.map(|d| d.info.last_confirmed_height)
.unwrap_or(0);
ui.label(
RichText::new(if height > 0 {
t!("goblin.home.block", height => fmt_thousands(height)).to_string()
} else {
t!("goblin.home.waiting_for_chain").to_string()
})
.font(FontId::new(12.0, fonts::regular()))
.color(t.surface_text_dim),
);
ui.add(
egui::Label::new(
RichText::new(node_host(wallet))
.font(FontId::new(12.0, fonts::regular()))
.color(t.surface_text_mute),
)
.truncate(),
);
});
// Low-opacity gear so the card reads as a tappable settings
// shortcut; on-surface ink keeps it theme-aware.
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
ui.label(
RichText::new(crate::gui::icons::GEAR)
.font(FontId::new(16.0, fonts::regular()))
.color(t.surface_text.gamma_multiply(0.35)),
);
});
});
});
}
pub(super) fn home_ui(
&mut self,
ui: &mut egui::Ui,
wallet: &Wallet,
cb: &dyn PlatformCallbacks,
wide: bool,
) {
let data = wallet.get_data();
ScrollArea::vertical()
.id_salt("goblin_home_scroll")
.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);
ui.horizontal(|ui| {
// Owner-sized: +50% over the original 24px mark so the lockup
// carries the same visual weight as the 40-44px right cluster.
widgets_logo_sized(ui, 36.0);
ui.add_space(9.0);
ui.label(
RichText::new("goblin")
.font(FontId::new(26.0, fonts::bold()))
.color(theme::tokens().text),
);
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
// The user's own avatar (opens settings). Mode-aware: the
// flat yellow + Grin mark tile only in anonymous mode,
// otherwise this identity's normal gradient/picture.
if self.avatar_self(ui, wallet, 40.0).clicked() {
self.tab = Tab::Me;
}
// Scan-to-pay, left of the avatar. No frame: a bold white QR
// glyph sized and centered to mirror the Pay-page header
// treatment next to the avatar (was a tacky filled circle).
ui.add_space(12.0);
let (rect, resp) =
ui.allocate_exact_size(Vec2::splat(44.0), Sense::click());
ui.painter().text(
rect.center(),
egui::Align2::CENTER_CENTER,
QR_CODE,
FontId::new(38.0, fonts::regular()),
theme::tokens().text,
);
let resp = resp.on_hover_cursor(egui::CursorIcon::PointingHand);
if resp.clicked() {
let mut flow = SendFlow::default();
flow.request_scan();
self.send = Some(flow);
}
});
});
ui.add_space(28.0);
} else {
ui.add_space(48.0);
}
let (total, spendable) = data
.as_ref()
.map(|d| (d.info.total, d.info.amount_currently_spendable))
.unwrap_or((0, 0));
// Zero can just mean "in transit" (locked change / awaiting
// finalization) or a first sync still running.
let in_flight = data
.as_ref()
.map(|d| d.info.amount_locked + d.info.amount_awaiting_finalization)
.unwrap_or(0);
let updating = total == 0 && (in_flight > 0 || wallet.syncing());
// Distinguish "still updating" from "can't reach the node" so a
// node outage never renders as a silent zero (see balance_hero).
let error = wallet.sync_error();
// Anonymous mode: the balance is a row of dots until tapped. The
// fiat lookup is skipped entirely while censored (fiat_line is what
// kicks the rate fetch) and only fires once revealed.
if crate::AppConfig::anonymous_mode() && !self.balance_revealed {
if censored_balance_hero(ui, total) {
self.balance_revealed = true;
}
} else {
w::balance_hero(
ui,
total,
spendable,
updating,
error,
wallet.info_sync_progress(),
fiat_line(&data),
56.0,
);
}
ui.add_space(20.0);
let (send, receive) = w::send_receive(ui);
if send {
self.send = Some(SendFlow::default());
}
if receive {
self.tab = Tab::Receive;
}
ui.add_space(24.0);
// Latest news post (hidden entirely when none seen yet).
self.news_panel_ui(ui, wallet);
// Recent peers strip.
self.peers_strip_ui(ui, wallet, "goblin_peers_home");
// Recent activity.
w::kicker(ui, &t!("goblin.home.activity"));
ui.add_space(6.0);
let items = activity_items(wallet);
let id_cue = IdentityCueCtx::compute(wallet);
if items.is_empty() {
empty_state(
ui,
&t!("goblin.home.empty_title"),
&t!("goblin.home.empty_sub"),
);
} else {
for item in items.iter().take(6) {
self.activity_item_ui(ui, item, wallet, cb, &id_cue);
}
}
ui.add_space(16.0);
});
}
/// Latest news post from the Goblin news key. Title + summary in a card, with
/// any http(s) URL in the summary rendered as a tappable link. Renders nothing
/// (early return) when no post has been cached yet — no empty state.
pub(super) fn news_panel_ui(&mut self, ui: &mut egui::Ui, wallet: &Wallet) {
let Some(news) = news_latest(wallet) else {
return;
};
let t = theme::tokens();
w::kicker(ui, &t!("goblin.home.news"));
ui.add_space(8.0);
w::card(ui, |ui| {
// Span the full content width like the balance/activity rows so the
// panel reads as a band, not a content-hugging chip.
ui.set_min_width(ui.available_width());
// Date first, ISO 8601 (YYYY-MM-DD, UTC). Dated by the article's
// published_at tag when present, else the event's created_at.
let stamp = news.published_at.unwrap_or(news.created_at);
ui.label(
RichText::new(data::news_date_iso(stamp))
.font(FontId::new(12.0, fonts::medium()))
.color(t.surface_text_dim),
);
if !news.title.is_empty() {
ui.add_space(2.0);
// Title guardrail: hard-cap the length (predictable ellipsis past
// NEWS_TITLE_MAX_CHARS) then shrink the font to fit the card width on
// one line down to a 12pt floor, so a title never clips. `.truncate()`
// backs the floor for the pathological narrow case.
let title = data::news_title_clamped(&news.title);
let pt = fit_news_title_pt(ui, &title, ui.available_width());
ui.add(
egui::Label::new(
RichText::new(&title)
.font(FontId::new(pt, fonts::semibold()))
.color(t.surface_text),
)
.truncate(),
);
}
if !news.summary.is_empty() {
ui.add_space(4.0);
ui.horizontal_wrapped(|ui| {
ui.spacing_mut().item_spacing.x = 0.0;
for (seg, is_url) in split_urls(&news.summary) {
if is_url {
// hyperlink opens via ctx.open_url, same as open_url().
ui.hyperlink(seg);
} else {
ui.label(
RichText::new(seg)
.font(FontId::new(13.0, fonts::regular()))
.color(t.surface_text_dim),
);
}
}
});
}
});
ui.add_space(24.0);
}
/// Horizontal recent-contacts strip; tapping one starts a prefilled send.
pub(super) 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;
}
// Anonymous mode censors the Recent strip exactly like the rest of the
// surface: the uniform yellow tile for every avatar and dotted names, so a
// recent recipient is no more identifiable here than in the activity feed.
let anon = crate::AppConfig::anonymous_mode();
let texs: Vec<Option<egui::TextureHandle>> = if anon {
peers.iter().map(|_| None).collect()
} else {
peers
.iter()
.map(|(name, _)| self.handle_tex(ui.ctx(), wallet, name))
.collect()
};
w::kicker(ui, &t!("goblin.home.recent"));
ui.add_space(12.0);
ScrollArea::horizontal()
.id_salt(salt.to_string())
.auto_shrink([false, true])
.show(ui, |ui| {
ui.horizontal(|ui| {
for ((name, npub), tex) in peers.iter().zip(texs.iter()) {
// Fixed-width centered cell so the name sits centered under the
// avatar (not left-aligned to a wider label).
ui.allocate_ui_with_layout(
Vec2::new(72.0, 78.0),
Layout::top_down(Align::Center),
|ui| {
let resp = if anon {
w::avatar_censored(ui, 48.0)
} else {
w::avatar_any(ui, name, npub, 48.0, tex.as_ref())
};
ui.add_space(6.0);
let short: String = if anon {
CENSOR_NAME_DOTS.to_string()
} else {
let chars: Vec<char> = name.chars().collect();
if chars.len() > 8 {
format!("{}", chars[..8].iter().collect::<String>())
} else {
name.to_string()
}
};
ui.label(
RichText::new(short).font(FontId::new(12.0, fonts::medium())),
);
// Tapping still opens the profile (tap-to-reveal); the strip
// itself stays censored.
if resp.clicked() {
self.profile = Some(npub.clone());
}
},
);
ui.add_space(12.0);
}
});
});
ui.add_space(20.0);
}
}
+740
View File
@@ -0,0 +1,740 @@
// 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.
//! Identity switcher and identity-management modals.
use super::*;
impl GoblinWalletView {
/// The identity switcher page: one wallet, one grin balance, many nostr
/// identities. Lists the held identities (tap to make one active), and adds a
/// new one (generate a fresh nsec or import an existing one). Switching runs a
/// catch-up so payments that arrived while an identity was dormant land in the
/// single shared balance; the syncing / "you were paid while away" state shows
/// here. The wallet password (entered once on this page) unlocks a target on
/// switch and encrypts a new identity on add — every held nsec is stored the
/// same way: its own NIP-49 ncryptsec under the wallet password.
pub(super) fn identities_ui(
&mut self,
ui: &mut egui::Ui,
wallet: &Wallet,
cb: &dyn PlatformCallbacks,
) {
let t = theme::tokens();
if self.sub_header(ui, &t!("goblin.identities.title")) {
self.settings_page = SettingsPage::Main;
self.identity_switch = IdentitySwitchState::default();
return;
}
// Poll the background switch/add worker.
if self.identity_switch.busy
&& let Some(res) = self.identity_switch.result.lock().unwrap().take()
{
self.identity_switch.busy = false;
match res {
Ok(_) => {
self.identity_switch.error.clear();
self.identity_switch.adding = false;
self.identity_switch.import = false;
self.identity_switch.nsec.clear();
self.identity_switch.backup_input.clear();
self.identity_switch.confirm_delete = None;
}
Err(e) => {
self.identity_switch.error = e;
}
}
}
// Wallet-password modal — the unlock step for ADDING an identity only
// (switching is instant and local, no password), mirroring the wallet-open
// password modal (dimmed backdrop, same buttons).
if Modal::opened() == Some(IDENTITY_PASS_MODAL) {
Modal::ui(ui.ctx(), cb, |ui, modal, cb| {
self.identity_pass_modal_content(ui, modal, wallet, cb);
});
}
// Per-identity management modal (rename / delete): dims and locks the
// list, disabling switching and row taps until it closes.
if Modal::opened() == Some(IDENTITY_MANAGE_MODAL) {
Modal::ui(ui.ctx(), cb, |ui, _modal, cb| {
self.identity_manage_modal_content(ui, wallet, cb);
});
}
// Step-1 delete confirmation modal (danger text), also background-locking.
if Modal::opened() == Some(IDENTITY_DELETE_MODAL) {
Modal::ui(ui.ctx(), cb, |ui, _modal, _cb| {
self.identity_delete_modal_content(ui, wallet);
});
}
let identities = wallet.nostr_identities();
ScrollArea::vertical()
.id_salt("goblin_identities_scroll")
.auto_shrink([false; 2])
.scroll_bar_visibility(egui::scroll_area::ScrollBarVisibility::AlwaysHidden)
.show(ui, |ui| {
ui.label(
RichText::new(t!("goblin.identities.blurb"))
.font(FontId::new(13.5, fonts::regular()))
.color(t.text_dim),
);
ui.add_space(8.0);
ui.label(
RichText::new(t!("goblin.identities.privacy_note"))
.font(FontId::new(12.0, fonts::regular()))
.color(t.text_mute),
);
ui.add_space(14.0);
// Held identities. Tap a non-active one to switch to it INSTANTLY —
// all identities are already unlocked and listening, so a switch is a
// local change of which one is presented and used for sending.
w::kicker(ui, &t!("goblin.identities.held"));
ui.add_space(8.0);
let busy = self.identity_switch.busy;
let mut switch_to: Option<String> = None;
let mut manage_target: Option<String> = None;
for id in &identities {
// Display precedence: private tag, else claimed name (bare, no
// leading @), else truncated npub. Never a placeholder word.
let short = data::short_npub(&id.pubkey_hex);
let title = id.display();
let mut pencil_hit = false;
let row = w::card(ui, |ui| {
ui.set_min_width(ui.available_width());
ui.horizontal(|ui| {
w::avatar_any(ui, &title, &id.pubkey_hex, 40.0, None);
ui.add_space(12.0);
ui.vertical(|ui| {
ui.label(
RichText::new(&title)
.font(FontId::new(15.0, fonts::semibold()))
.color(t.surface_text),
);
// The npub underneath, unless the title already IS the
// npub (unnamed, untagged identity).
if title != short {
ui.label(
RichText::new(&short)
.font(FontId::new(12.0, fonts::regular()))
.color(t.surface_text_mute),
);
}
});
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
if id.active {
ui.label(
RichText::new(crate::gui::icons::CHECK_CIRCLE)
.font(FontId::new(18.0, fonts::regular()))
.color(t.pos),
);
} else {
ui.label(
RichText::new(crate::gui::icons::ARROWS_LEFT_RIGHT)
.font(FontId::new(16.0, fonts::regular()))
.color(t.surface_text_dim),
);
}
// Edit (pencil) affordance: opens the per-identity
// management sheet (rename / delete). Its own tap target
// so it never triggers the row switch.
if !busy {
ui.add_space(8.0);
let (r, resp) =
ui.allocate_exact_size(Vec2::splat(28.0), Sense::click());
ui.painter().text(
r.center(),
egui::Align2::CENTER_CENTER,
crate::gui::icons::PENCIL_SIMPLE,
FontId::new(16.0, fonts::regular()),
t.surface_text_dim,
);
if resp
.on_hover_cursor(egui::CursorIcon::PointingHand)
.clicked()
{
pencil_hit = true;
}
}
});
})
.response
.rect
});
// Tap anywhere else on a non-active row = INSTANT switch (skip
// when the pencil was tapped, whose rect overlaps the row).
if !id.active && !busy {
let hit = ui.interact(
row,
egui::Id::new(("id_switch", id.pubkey_hex.as_str())),
Sense::click(),
);
if !pencil_hit
&& hit
.on_hover_cursor(egui::CursorIcon::PointingHand)
.clicked()
{
switch_to = Some(id.pubkey_hex.clone());
}
}
if pencil_hit {
manage_target = Some(id.pubkey_hex.clone());
}
ui.add_space(6.0);
}
// Tapping a held identity switches to it INSTANTLY — no password, no
// sync (it was already unlocked and listening). Purely local.
if let Some(target) = switch_to {
self.identity_switch.error.clear();
if let Err(e) = wallet.switch_nostr_identity(target) {
self.identity_switch.error = e;
}
}
// The pencil opens the management MODAL, pre-filled with the tag and
// titled with the identity it manages. The GRIM Modal dims and locks
// the list behind it, so no switching or row taps while it is open.
if let Some(target) = manage_target {
self.identity_switch.error.clear();
self.identity_switch.confirm_delete = None;
let display = identities
.iter()
.find(|i| i.pubkey_hex == target)
.map(|i| i.display())
.unwrap_or_else(|| data::short_npub(&target));
self.identity_switch.tag_input = identities
.iter()
.find(|i| i.pubkey_hex == target)
.and_then(|i| i.tag.clone())
.unwrap_or_default();
self.identity_switch.manage = Some(target);
Modal::new(IDENTITY_MANAGE_MODAL)
.position(ModalPosition::CenterTop)
.title(display)
.show();
}
ui.add_space(8.0);
// Add-identity section.
if !self.identity_switch.adding {
if w::big_action(ui, &t!("goblin.identities.add"), false).clicked() {
self.identity_switch.adding = true;
self.identity_switch.error.clear();
}
} else {
w::card(ui, |ui| {
ui.set_min_width(ui.available_width());
ui.label(
RichText::new(t!("goblin.identities.add_title"))
.font(FontId::new(15.0, fonts::semibold()))
.color(t.surface_text),
);
ui.add_space(8.0);
// The sheet defaults to GENERATE (a fresh anonymous key). What
// generating means, in one line, so the default mode isn't blank.
if !self.identity_switch.import {
ui.label(
RichText::new(t!("goblin.identities.generate_note"))
.font(FontId::new(12.5, fonts::regular()))
.color(t.surface_text_dim),
);
}
if self.identity_switch.import {
ui.add_space(8.0);
// (a) Select a .backup file. Desktop returns the path now;
// Android returns it asynchronously (poll picked_file()).
if self.identity_switch.picking {
if let Some(path) = cb.picked_file() {
self.identity_switch.picking = false;
if !path.is_empty() {
match std::fs::read_to_string(&path) {
Ok(c) => {
self.identity_switch.backup_input =
c.trim().to_string()
}
Err(_) => {
self.identity_switch.error =
t!("goblin.settings.backup_read_failed")
.to_string()
}
}
}
} else {
ui.ctx().request_repaint();
}
}
let file_label = if self.identity_switch.backup_input.is_empty() {
t!("goblin.identities.choose_backup").to_string()
} else {
t!("goblin.identities.backup_selected").to_string()
};
if w::big_action_on_card(ui, &file_label).clicked() {
self.identity_switch.error.clear();
match cb.pick_file() {
Some(path) if !path.is_empty() => {
match std::fs::read_to_string(&path) {
Ok(c) => {
self.identity_switch.backup_input =
c.trim().to_string()
}
Err(_) => {
self.identity_switch.error =
t!("goblin.settings.backup_read_failed")
.to_string()
}
}
}
Some(_) => self.identity_switch.picking = true,
None => {}
}
}
ui.add_space(8.0);
// (b) Or paste an nsec.
w::field_well(ui, |ui| {
TextEdit::new(egui::Id::from("identity_add_nsec"))
.focus(false)
.hint_text(t!("goblin.identities.nsec_hint"))
.password()
.text_color(t.surface_text)
.body()
.ui(ui, &mut self.identity_switch.nsec, cb);
});
}
ui.add_space(10.0);
let import = self.identity_switch.import;
let busy = self.identity_switch.busy;
// Two real actions instead of a mode toggle: Generate is always
// ready; Import first reveals the .backup/nsec inputs, then
// confirms once one of them is filled. The password is entered
// in the modal.
let has_import = !self.identity_switch.backup_input.trim().is_empty()
|| self.identity_switch.nsec.trim().starts_with("nsec1");
let import_armed = (!import || has_import) && !busy;
// The password-gated add to launch this frame: `Some(None)` for
// a fresh key, `Some(Some(blob))` for an import.
let mut open_add: Option<Option<String>> = None;
ui.horizontal(|ui| {
let half = (ui.available_width() - 10.0) / 2.0;
// Generate on the LEFT, the positive (green) action: a
// fresh anonymous key via the existing password step.
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(!busy, |ui| {
if w::big_action_on_card_ink(
ui,
&t!("goblin.identities.generate"),
if busy { t.surface_text_mute } else { t.pos },
)
.clicked()
{
open_add = Some(None);
}
});
},
);
ui.add_space(10.0);
// Import on the RIGHT, neutral: reveal-then-confirm.
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(import_armed, |ui| {
if w::big_action_on_card_ink(
ui,
&t!("goblin.identities.import"),
if import_armed {
t.surface_text
} else {
t.surface_text_mute
},
)
.clicked()
{
if !import {
// First tap: reveal the import inputs.
self.identity_switch.import = true;
self.identity_switch.error.clear();
} else {
// Confirm: the selected .backup wins over
// a pasted nsec.
let b = self.identity_switch.backup_input.trim();
let blob = if b.is_empty() {
self.identity_switch.nsec.trim().to_string()
} else {
b.to_string()
};
open_add = Some(Some(blob));
}
}
});
},
);
});
// Cancel centered BENEATH the pair: red, same uniform size.
ui.add_space(10.0);
ui.horizontal(|ui| {
let half = (ui.available_width() - 10.0) / 2.0;
ui.add_space((ui.available_width() - half) / 2.0);
ui.scope_builder(
egui::UiBuilder::new().max_rect(egui::Rect::from_min_size(
ui.cursor().min,
Vec2::new(half, 44.0),
)),
|ui| {
if w::big_action_on_card_ink(
ui,
&t!("goblin.settings.cancel"),
t.neg,
)
.clicked()
{
self.identity_switch.adding = false;
self.identity_switch.import = false;
self.identity_switch.nsec.clear();
self.identity_switch.backup_input.clear();
self.identity_switch.error.clear();
}
},
);
});
if let Some(import_blob) = open_add {
// Open the password modal to encrypt+store the new
// identity. It is added WITHOUT switching; the user
// activates it later by tapping it.
self.identity_switch.error.clear();
self.identity_switch.pass.clear();
self.identity_switch.wrong_pass = false;
self.identity_switch.pending =
Some(PendingPassAction::Add(import_blob));
Modal::new(IDENTITY_PASS_MODAL)
.position(ModalPosition::CenterTop)
.title(t!("goblin.identities.add_title"))
.show();
}
});
}
if self.identity_switch.busy {
ui.add_space(10.0);
ui.horizontal(|ui| {
View::small_loading_spinner(ui);
ui.add_space(8.0);
ui.label(
RichText::new(t!("goblin.identities.working"))
.font(FontId::new(13.0, fonts::regular()))
.color(t.surface_text_dim),
);
});
ui.ctx().request_repaint();
}
if !self.identity_switch.error.is_empty() {
ui.add_space(8.0);
ui.label(
RichText::new(&self.identity_switch.error)
.font(FontId::new(12.5, fonts::regular()))
.color(t.neg),
);
}
ui.add_space(24.0);
});
}
/// Content of the wallet-password modal that gates ADDING an identity (encrypt
/// + store its new nsec). Switching no longer uses this — it is instant and
/// local. Mirrors the wallet-open password modal (open.rs): explanation, masked
/// field, wrong-password line, Cancel/Continue. A correct password is verified
/// synchronously before the add worker is spawned, so a wrong password stays in
/// the modal instead of failing later.
pub(super) fn identity_pass_modal_content(
&mut self,
ui: &mut egui::Ui,
modal: &Modal,
wallet: &Wallet,
cb: &dyn PlatformCallbacks,
) {
let mut go = false;
let mut cancel = false;
ui.vertical_centered(|ui| {
ui.add_space(6.0);
ui.label(
RichText::new(t!("goblin.identities.pass_prompt"))
.size(16.0)
.color(Colors::gray()),
);
ui.add_space(10.0);
let mut field = TextEdit::new(egui::Id::from(modal.id).with("id_pass")).password();
field.ui(ui, &mut self.identity_switch.pass, cb);
if field.enter_pressed {
go = true;
}
if self.identity_switch.pass.is_empty() {
self.identity_switch.wrong_pass = false;
} else if self.identity_switch.wrong_pass {
ui.add_space(10.0);
ui.label(
RichText::new(t!("goblin.advanced.wrong_password"))
.size(16.0)
.color(Colors::red()),
);
}
ui.add_space(12.0);
});
ui.scope(|ui| {
ui.spacing_mut().item_spacing = egui::Vec2::new(8.0, 0.0);
ui.columns(2, |columns| {
columns[0].vertical_centered_justified(|ui| {
View::button(
ui,
t!("modal.cancel"),
Colors::white_or_black(false),
|| {
cancel = true;
},
);
});
columns[1].vertical_centered_justified(|ui| {
View::button(ui, t!("continue"), Colors::white_or_black(false), || {
go = true;
});
});
});
ui.add_space(6.0);
});
if cancel {
self.identity_switch.pass.clear();
self.identity_switch.pending = None;
self.identity_switch.wrong_pass = false;
Modal::close();
}
if go && !self.identity_switch.pass.is_empty() {
if !wallet.verify_nostr_password(&self.identity_switch.pass) {
self.identity_switch.wrong_pass = true;
} else if let Some(action) = self.identity_switch.pending.take() {
let password = std::mem::take(&mut self.identity_switch.pass);
self.identity_switch.wrong_pass = false;
self.identity_switch.error.clear();
self.identity_switch.busy = true;
let slot = self.identity_switch.result.clone();
let w = wallet.clone();
std::thread::spawn(move || {
let r = match action {
// Add only — never auto-switch into the new identity.
PendingPassAction::Add(import) => w.add_nostr_identity(import, password),
// Delete returns the surviving active npub for the result slot.
PendingPassAction::Delete(hex) => w
.delete_nostr_identity(hex, password)
.map(|_| String::new()),
};
*slot.lock().unwrap() = Some(r);
});
Modal::close();
}
}
}
/// Content of the per-identity management MODAL (title = the identity's
/// display name). Rename (the private, app-only tag — never published) on
/// top with uniform paired Cancel/Save buttons; Delete at the bottom,
/// separated and red, feeding the delete-confirmation modal. Being a GRIM
/// Modal, the identity list behind it is dimmed and locked.
pub(super) fn identity_manage_modal_content(
&mut self,
ui: &mut egui::Ui,
wallet: &Wallet,
cb: &dyn PlatformCallbacks,
) {
let Some(target) = self.identity_switch.manage.clone() else {
Modal::close();
return;
};
ui.vertical_centered(|ui| {
ui.add_space(6.0);
ui.label(
RichText::new(t!("goblin.identities.tag_note"))
.size(16.0)
.color(Colors::gray()),
);
ui.add_space(10.0);
let mut field = TextEdit::new(egui::Id::from(IDENTITY_MANAGE_MODAL).with("tag"))
.hint_text(t!("goblin.identities.tag_hint"));
field.ui(ui, &mut self.identity_switch.tag_input, cb);
ui.add_space(12.0);
});
let mut save = false;
let mut cancel = false;
let mut delete = false;
ui.scope(|ui| {
ui.spacing_mut().item_spacing = egui::Vec2::new(8.0, 0.0);
ui.columns(2, |columns| {
columns[0].vertical_centered_justified(|ui| {
View::button(
ui,
t!("modal.cancel"),
Colors::white_or_black(false),
|| {
cancel = true;
},
);
});
columns[1].vertical_centered_justified(|ui| {
View::button(
ui,
t!("goblin.identities.tag_save"),
Colors::white_or_black(false),
|| {
save = true;
},
);
});
});
// Delete lives at the bottom, clearly apart from rename, red, same
// button shape. Only while more than one identity is held.
if wallet.nostr_identities().len() > 1 {
ui.add_space(10.0);
ui.vertical_centered_justified(|ui| {
View::colored_text_button(
ui,
t!("goblin.identities.delete_short").to_string(),
Colors::red(),
Colors::white_or_black(false),
|| {
delete = true;
},
);
});
}
ui.add_space(6.0);
});
if cancel {
self.identity_switch.manage = None;
self.identity_switch.tag_input.clear();
Modal::close();
}
if save {
let tag = std::mem::take(&mut self.identity_switch.tag_input);
if let Err(e) = wallet.rename_nostr_identity(target.clone(), tag) {
self.identity_switch.error = e;
}
self.identity_switch.manage = None;
Modal::close();
}
if delete {
// Step 1 of the delete gate: the danger-confirmation modal.
self.identity_switch.manage = None;
self.identity_switch.confirm_delete = Some(target.clone());
let display = wallet
.nostr_identities()
.iter()
.find(|i| i.pubkey_hex == target)
.map(|i| i.display())
.unwrap_or_else(|| data::short_npub(&target));
Modal::close();
Modal::new(IDENTITY_DELETE_MODAL)
.position(ModalPosition::CenterTop)
.title(display)
.show();
}
}
/// Content of the step-1 delete-confirmation MODAL (title = the identity's
/// display name): states the removal is PERMANENT with a prominent back-up
/// reminder, then uniform paired Cancel/Delete buttons — Delete red, same
/// size and shape. Confirming opens the wallet-password modal (step 2).
pub(super) fn identity_delete_modal_content(&mut self, ui: &mut egui::Ui, wallet: &Wallet) {
let Some(target) = self.identity_switch.confirm_delete.clone() else {
Modal::close();
return;
};
let display = wallet
.nostr_identities()
.iter()
.find(|i| i.pubkey_hex == target)
.map(|i| i.display())
.unwrap_or_else(|| data::short_npub(&target));
ui.vertical_centered(|ui| {
ui.add_space(6.0);
ui.label(
RichText::new(t!("goblin.identities.delete_title", name => display))
.size(17.0)
.color(Colors::red()),
);
ui.add_space(8.0);
ui.label(
RichText::new(t!("goblin.identities.delete_blurb"))
.size(15.0)
.color(Colors::gray()),
);
ui.add_space(8.0);
ui.label(
RichText::new(t!("goblin.identities.delete_backup_note"))
.size(15.0)
.color(Colors::red()),
);
ui.add_space(12.0);
});
let mut cancel = false;
let mut confirm = false;
ui.scope(|ui| {
ui.spacing_mut().item_spacing = egui::Vec2::new(8.0, 0.0);
ui.columns(2, |columns| {
columns[0].vertical_centered_justified(|ui| {
View::button(
ui,
t!("modal.cancel"),
Colors::white_or_black(false),
|| {
cancel = true;
},
);
});
columns[1].vertical_centered_justified(|ui| {
View::colored_text_button(
ui,
t!("goblin.identities.delete_confirm").to_string(),
Colors::red(),
Colors::white_or_black(false),
|| {
confirm = true;
},
);
});
});
ui.add_space(6.0);
});
if cancel {
self.identity_switch.confirm_delete = None;
Modal::close();
}
if confirm {
// Step 2: the wallet-password modal executes the delete.
self.identity_switch.pass.clear();
self.identity_switch.wrong_pass = false;
self.identity_switch.pending = Some(PendingPassAction::Delete(target));
Modal::close();
Modal::new(IDENTITY_PASS_MODAL)
.position(ModalPosition::CenterTop)
.title(t!("goblin.identities.delete_short"))
.show();
}
}
}
+566
View File
@@ -0,0 +1,566 @@
// 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.
//! "Me" account-header screen.
use super::*;
impl GoblinWalletView {
pub(super) fn me_ui(&mut self, ui: &mut egui::Ui, wallet: &Wallet, cb: &dyn PlatformCallbacks) {
let t = theme::tokens();
match self.settings_page {
SettingsPage::Node => return self.node_settings_ui(ui, wallet, cb),
SettingsPage::IntegratedNode => return self.integrated_node_ui(ui, cb),
SettingsPage::Relays => return self.relays_ui(ui, wallet, cb),
SettingsPage::Nips => return self.nips_ui(ui),
SettingsPage::Pairing => return self.pairing_settings_ui(ui),
SettingsPage::Language => return self.language_settings_ui(ui),
SettingsPage::Slatepack => return self.slatepack_ui(ui, wallet, cb),
SettingsPage::Privacy => return self.privacy_ui(ui, wallet),
SettingsPage::Username => return self.username_ui(ui, wallet, cb),
SettingsPage::AdvancedPrivacy => return self.advanced_privacy_ui(ui, wallet, cb),
SettingsPage::Advanced => return self.advanced_ui(ui, wallet, cb),
SettingsPage::Identities => return self.identities_ui(ui, wallet, cb),
SettingsPage::TrustedSites => return self.trusted_sites_ui(ui, wallet, cb),
SettingsPage::Main => {}
}
// Minimum-confirmations edit modal (GRIM parity), opened from the
// wallet group below.
if Modal::opened() == Some(MIN_CONF_MODAL) {
Modal::ui(ui.ctx(), cb, |ui, modal, cb| {
self.min_conf_modal_content(ui, wallet, modal, cb);
});
}
ui.add_space(8.0);
ui.label(
RichText::new(t!("goblin.settings.title"))
.font(FontId::new(28.0, fonts::bold()))
.color(t.text),
);
ui.add_space(16.0);
// Profile card.
let (handle, npub, connected, bare_name, npub_hex) = wallet
.nostr_service()
.map(|s| {
let identity = s.identity.read();
let bare = identity
.nip05
.clone()
.map(|n| n.split('@').next().unwrap_or("").to_string());
let handle = bare
.clone()
.map(|n| n.to_string())
.unwrap_or_else(|| data::short_npub(&hex_of(&identity.npub)));
(
handle,
s.npub(),
s.is_connected(),
bare,
hex_of(&identity.npub),
)
})
.unwrap_or_else(|| {
(
t!("goblin.home.anonymous").to_string(),
String::new(),
false,
None,
String::new(),
)
});
let own_tex = bare_name
.as_deref()
.and_then(|_| self.handle_tex(ui.ctx(), wallet, &handle));
// Set inside the (deeply nested) card closure when the identity-switcher
// glyph is tapped; applied after the card so the closures don't need a
// mutable borrow of `self`.
let mut open_identities = false;
w::card(ui, |ui| {
ui.set_min_width(ui.available_width());
ui.horizontal(|ui| {
// Custom picture when one is set; otherwise the deterministic
// pubkey-seeded gradient identicon.
w::avatar_any(ui, &handle, &npub_hex, 56.0, own_tex.as_ref());
ui.add_space(14.0);
ui.vertical(|ui| {
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 5.0;
ui.label(
RichText::new(&handle)
.font(FontId::new(17.0, fonts::bold()))
.color(t.surface_text),
);
// A claimed/verified name gets the little check.
if bare_name.is_some() {
ui.label(
RichText::new(crate::gui::icons::SEAL_CHECK)
.font(FontId::new(15.0, fonts::regular()))
.color(t.pos),
);
}
});
// Transport status in place of the redundant second npub line.
// Transport-aware: Tor states are relay-gated (a warm tunnel is
// not enough — a relay must carry our traffic), while a clearnet
// wallet reads "Connected (direct)" rather than forever
// "connecting over Tor".
let transport = transport_status_label(
wallet
.nostr_service()
.map(|s| s.transport_status())
.unwrap_or(crate::nostr::TransportStatus::ConnectingTor),
);
ui.label(
RichText::new(transport)
.font(FontId::new(13.0, fonts::regular()))
.color(t.surface_text_dim),
);
// nostr relay status — the slower step (a relay reached over Tor).
let nostr = if connected {
t!("goblin.settings.connected_nostr")
} else {
t!("goblin.settings.connecting_relays")
};
ui.label(
RichText::new(nostr)
.font(FontId::new(12.0, fonts::regular()))
.color(t.surface_text_mute),
);
// Keep repainting until a relay is live. Gating on the Tor-only
// transport_ready would spin forever on a clearnet wallet, so
// use the transport-neutral relay-connected flag.
if !connected {
ui.ctx()
.request_repaint_after(std::time::Duration::from_millis(600));
}
});
// Trailing (top-right) controls of the identity row: the identity
// SWITCHER (one wallet, many nostr identities) and, when present,
// the update-available badge. Right-aligned into the space to the
// right of the name/avatar; the switcher sits in the far corner.
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
let (rect, resp) = ui.allocate_exact_size(Vec2::splat(36.0), Sense::click());
ui.painter().circle_filled(rect.center(), 18.0, t.surface2);
ui.painter().text(
rect.center(),
egui::Align2::CENTER_CENTER,
crate::gui::icons::ARROWS_LEFT_RIGHT,
FontId::new(17.0, fonts::regular()),
theme::ink_for(t.surface2),
);
if resp
.on_hover_cursor(egui::CursorIcon::PointingHand)
.on_hover_text(t!("goblin.identities.switch_hint").to_string())
.clicked()
{
open_identities = true;
}
// Update-available badge to the LEFT of the switcher. Shown only
// when the release check found a newer build; tapping it opens
// the release download page.
if let Some(update) = crate::AppConfig::app_update() {
ui.add_space(6.0);
let (rect, resp) =
ui.allocate_exact_size(Vec2::splat(36.0), Sense::click());
ui.painter().circle_filled(rect.center(), 18.0, t.accent);
ui.painter().text(
rect.center(),
egui::Align2::CENTER_CENTER,
crate::gui::icons::CLOUD_ARROW_DOWN,
FontId::new(18.0, fonts::regular()),
t.accent_ink,
);
if resp
.on_hover_cursor(egui::CursorIcon::PointingHand)
.on_hover_text(t!("goblin.settings.update_available").to_string())
.clicked()
{
open_url(ui, &update.url);
}
}
});
});
});
if open_identities {
self.settings_page = SettingsPage::Identities;
self.identity_switch = IdentitySwitchState::default();
}
ui.add_space(16.0);
// Mark the scroll boundary: rows clipping under the pinned profile
// 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()
.id_salt("goblin_settings_scroll")
.auto_shrink([false; 2])
.scroll_bar_visibility(egui::scroll_area::ScrollBarVisibility::AlwaysHidden)
.show(ui, |ui| {
// Identity: username, picture, keys — first because it is the
// face of the wallet.
w::kicker(ui, &t!("goblin.settings.identity"));
ui.add_space(8.0);
// Hoisted above the identity card: the Nostr Relays row now lives
// inside that card (relays are a nostr concern, like the keys), but
// its open handler runs further down — so the flag is declared here.
let mut open_relays = false;
// Username has its own home (claim/release + name authority); the
// row shows the current name (or "Not set") and opens that page.
let mut open_username = false;
// Trusted Sites (the active Authorize Sessions) lives with the
// nostr rows — it is nostr-identity signing, not a wallet setting
// — but its open handler runs further down, so the flag is here.
let mut open_trusted = false;
w::card(ui, |ui| {
if !npub.is_empty() {
let username = wallet
.nostr_service()
.and_then(|s| s.identity.read().nip05.clone())
.map(|n| n.split('@').next().unwrap_or("").to_string());
let uname_val = username
.unwrap_or_else(|| t!("goblin.settings.username_none").to_string());
if settings_row_nav(ui, &t!("goblin.settings.username"), &uname_val) {
open_username = true;
}
if settings_row_btn(ui, &t!("goblin.settings.copy_npub"), COPY) {
cb.copy_string_to_buffer(npub.clone());
cb.vibrate_copy();
self.copy_flash = Some(std::time::Instant::now());
}
// The encrypted .backup file now lives on the Advanced page,
// under ADVANCED NOSTR SETTINGS (single home — no duplicate
// exposure here).
// Nostr relays the wallet publishes/reads gift wraps on.
// Sits with the identity rows because relays are a nostr
// concern; opens the relay editor (handled below).
if settings_row_nav(
ui,
&t!("goblin.settings.nostr_relays"),
&relay_summary(wallet),
) {
open_relays = true;
}
// Trusted Sites: the active Authorize Sessions, with a
// one-tap end; the row value is the live session count.
// Nostr-identity signing, so it sits with the keys/relays.
let session_count = wallet
.nostr_service()
.map(|s| s.session_summaries().len())
.unwrap_or(0);
if settings_row_nav(
ui,
&t!("goblin.settings.trusted_sites"),
&session_count.to_string(),
) {
open_trusted = true;
}
}
});
if open_username {
self.claim = Some(ClaimState::default());
// Seed the free-type authority field with the current server so
// the Username page opens showing where names resolve today.
let cur = wallet
.nostr_service()
.map(|s| s.config.read().nip05_server())
.unwrap_or_default();
self.name_authority = Some(NameAuthorityState {
input: cur,
error: None,
});
self.settings_page = SettingsPage::Username;
}
// Transient confirmation that the copy landed — pairs with the
// haptic tick so the tap feels acknowledged.
if let Some(at) = self.copy_flash {
if at.elapsed().as_secs_f32() < 1.5 {
ui.add_space(6.0);
ui.vertical_centered(|ui| {
ui.label(
RichText::new(format!(
"{} {}",
crate::gui::icons::CHECK,
t!("goblin.receive.copied")
))
.font(FontId::new(13.0, fonts::medium()))
.color(t.pos),
);
});
ui.ctx()
.request_repaint_after(std::time::Duration::from_millis(120));
} else {
self.copy_flash = None;
}
}
ui.add_space(16.0);
let mut open_node = false;
let mut open_slatepack = false;
settings_group(ui, &t!("goblin.settings.wallet"), |ui| {
if settings_row_nav(ui, &t!("goblin.settings.node"), &node_summary(wallet)) {
open_node = true;
}
// Minimum confirmations before received funds are spendable
// (GRIM parity, default 10). Prominent, just below the node
// row; tapping opens the numeric edit modal. The value feeds
// the wallet's spendable/send logic via
// WalletConfig::min_confirmations.
if settings_row_nav(
ui,
&t!("goblin.settings.min_conf"),
&wallet.get_config().min_confirmations.to_string(),
) {
self.min_conf_edit = wallet.get_config().min_confirmations.to_string();
Modal::new(MIN_CONF_MODAL)
.position(ModalPosition::CenterTop)
.title(t!("goblin.settings.min_conf"))
.show();
}
// GRIM's native by-hand slatepack exchange, for when a payment
// can't go through a username.
if settings_row_nav(
ui,
&t!("goblin.settings.slatepacks"),
&t!("goblin.settings.slatepacks_value"),
) {
open_slatepack = true;
}
});
if open_slatepack {
self.slatepack = SlatepackManual::default();
self.settings_page = SettingsPage::Slatepack;
}
if open_trusted {
self.settings_page = SettingsPage::TrustedSites;
}
if open_relays {
// The ACTIVE set (override or per-identity advertised set),
// so the editor shows what is really in use.
self.relay_edit = wallet
.nostr_service()
.map(|s| s.relays())
.unwrap_or_default();
self.relay_input.clear();
self.settings_page = SettingsPage::Relays;
}
if open_node {
self.node_url_input.clear();
self.node_secret_input.clear();
self.settings_page = SettingsPage::Node;
}
ui.add_space(16.0);
let mut open_pairing = false;
let mut open_privacy = false;
let mut open_adv_privacy = false;
settings_group(ui, &t!("goblin.settings.privacy"), |ui| {
// Value shows the wallet's current Tor state (On/Off) in the
// same dim font; the row opens the Network-privacy screen where
// the big switch flips it.
let tor_on = wallet
.nostr_service()
.map(|s| s.tor_routing())
.unwrap_or(true);
if settings_row_nav(
ui,
&t!("goblin.settings.tor_routing"),
&if tor_on {
t!("goblin.settings.tor_on")
} else {
t!("goblin.settings.tor_off")
},
) {
open_privacy = true;
}
// Tap to cycle the incoming-payment accept policy. Value styled
// like the sibling rows' values (small/dim), not like an icon.
if settings_row_cycle(
ui,
&t!("goblin.settings.auto_accept"),
&accept_policy_label(wallet),
) {
cycle_accept_policy(wallet);
}
// Amount pairing: what the ≈ preview is shown against.
if settings_row_nav(
ui,
&t!("goblin.settings.pairing"),
crate::AppConfig::pairing().label(),
) {
open_pairing = true;
}
// Notification hiding (amounts/names/details) and anonymous mode
// now live together on their own page. Replaces the lone
// hide-amounts toggle that used to sit here.
if settings_row_nav(ui, &t!("goblin.settings.advanced_privacy"), "") {
open_adv_privacy = true;
}
});
if open_pairing {
self.settings_page = SettingsPage::Pairing;
}
if open_privacy {
self.settings_page = SettingsPage::Privacy;
}
if open_adv_privacy {
self.settings_page = SettingsPage::AdvancedPrivacy;
}
ui.add_space(16.0);
settings_group(ui, &t!("goblin.settings.requests"), |ui| {
let allow = wallet
.nostr_service()
.map(|s| s.config.read().allow_incoming_requests())
.unwrap_or(true);
if let Some(v) = settings_row_toggle(
ui,
&t!("goblin.settings.incoming_requests"),
&t!("goblin.settings.incoming_requests_sub"),
allow,
) {
if let Some(s) = wallet.nostr_service() {
s.config.write().set_allow_incoming_requests(v);
}
// Advertise the change so requesters see it before asking.
wallet.task(crate::wallet::types::WalletTask::NostrRepublishProfile);
}
});
ui.add_space(16.0);
w::kicker(ui, &t!("goblin.settings.appearance"));
ui.add_space(8.0);
let mut open_language = false;
w::card(ui, |ui| {
let theme_label = match crate::AppConfig::theme() {
crate::gui::theme::ThemeKind::Light => t!("goblin.settings.theme_light"),
crate::gui::theme::ThemeKind::Dark => t!("goblin.settings.theme_dark"),
crate::gui::theme::ThemeKind::Yellow => t!("goblin.settings.theme_yellow"),
};
// Cycle-in-place (not a nav/icon row) so the value ("Dark") is
// drawn in the same small/dim style as the Language value beside
// it, instead of the larger icon size settings_row_btn uses.
if settings_row_cycle(ui, &t!("goblin.settings.theme"), &theme_label) {
cycle_theme(ui.ctx());
}
// Language sits beside theme under Appearance; the value is the
// active language in its own name (e.g. "Deutsch").
let current = crate::AppConfig::locale()
.unwrap_or_else(|| rust_i18n::locale().to_string());
if settings_row_nav(
ui,
&t!("goblin.settings.language"),
&t!("lang_name", locale = current.as_str()),
) {
open_language = true;
}
});
if open_language {
self.settings_page = SettingsPage::Language;
}
ui.add_space(16.0);
settings_group(ui, &t!("goblin.settings.about"), |ui| {
if settings_row_nav(
ui,
&t!("goblin.settings.goblin"),
&t!("goblin.settings.build", build => crate::BUILD),
) {
open_url(ui, "https://github.com/2ro/goblin/releases");
}
settings_row(
ui,
&t!("goblin.settings.network"),
&t!("goblin.settings.network_value"),
);
});
ui.add_space(16.0);
let mut open_nips = false;
settings_group(ui, &t!("goblin.settings.third_party"), |ui| {
if settings_row_nav(ui, &t!("goblin.settings.grim"), crate::VERSION) {
// Live upstream GRIM (GitHub mirror of code.gri.mw/GUI/grim).
// Was github.com/ardocrat/grim — a stale personal fork.
open_url(ui, "https://github.com/GetGrin/grim");
}
if settings_row_nav(ui, &t!("goblin.settings.grin_node"), "5.4.0") {
open_url(ui, "https://github.com/mimblewimble/grin");
}
if settings_row_nav(ui, "nostr-sdk", "0.44") {
open_url(ui, "https://github.com/rust-nostr/nostr");
}
if settings_row_nav(ui, "Tor (arti)", "0.43") {
open_url(ui, "https://gitlab.torproject.org/tpo/core/arti");
}
if settings_row_nav(ui, "egui", "0.33") {
open_url(ui, "https://github.com/emilk/egui");
}
if settings_row_nav(ui, "NIPs", "05 · 17 · 44 · 49 · 59 · 98") {
open_nips = true;
}
});
if open_nips {
self.settings_page = SettingsPage::Nips;
}
// Wallet management lives at the foot of Settings: a neutral
// switch, the red lock, then the advanced (recovery) tools —
// each its own outlined action, so the destructive lock reads
// apart from the rest.
ui.add_space(24.0);
let dim = theme::tokens().surface_text_dim;
if w::outlined_icon_action(
ui,
crate::gui::icons::USER_SWITCH,
&t!("goblin.settings.switch_wallet"),
dim,
)
.clicked()
{
self.settings_page = SettingsPage::Main;
self.switch_requested = true;
}
ui.add_space(10.0);
if w::outlined_icon_action(
ui,
crate::gui::icons::LOCK,
&t!("goblin.settings.lock_wallet"),
theme::tokens().neg,
)
.clicked()
{
wallet.close();
}
ui.add_space(10.0);
if w::outlined_icon_action(
ui,
crate::gui::icons::WRENCH,
&t!("goblin.settings.advanced"),
dim,
)
.clicked()
{
self.advanced = AdvancedState::default();
self.settings_page = SettingsPage::Advanced;
}
ui.add_space(20.0);
});
}
}
File diff suppressed because it is too large Load Diff
+334
View File
@@ -0,0 +1,334 @@
// 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.
//! Small modals (min-conf, batch invoice, trusted sites) and the login toast.
use super::*;
impl GoblinWalletView {
/// Content of the minimum-confirmations edit modal — a direct port of GRIM's
/// min_conf_modal_ui (numeric input, invalid-value error, Cancel/Save). The
/// saved value persists in WalletConfig::min_confirmations and feeds the
/// wallet's spendable/send logic on the next balance refresh.
pub(super) fn min_conf_modal_content(
&mut self,
ui: &mut egui::Ui,
wallet: &Wallet,
modal: &Modal,
cb: &dyn PlatformCallbacks,
) {
let on_save = |s: &mut GoblinWalletView| {
if let Ok(min_conf) = s.min_conf_edit.parse::<u64>() {
wallet.update_min_confirmations(min_conf);
Modal::close();
}
};
ui.add_space(6.0);
ui.vertical_centered(|ui| {
ui.label(
RichText::new(t!("wallets.min_tx_conf_count"))
.size(17.0)
.color(Colors::gray()),
);
ui.add_space(8.0);
let mut edit = TextEdit::new(egui::Id::from(modal.id)).h_center().numeric();
edit.ui(ui, &mut self.min_conf_edit, cb);
if edit.enter_pressed {
on_save(self);
}
if self.min_conf_edit.parse::<u64>().is_err() {
ui.add_space(12.0);
ui.label(
RichText::new(t!("network_settings.not_valid_value"))
.size(17.0)
.color(Colors::red()),
);
}
ui.add_space(12.0);
});
ui.scope(|ui| {
ui.spacing_mut().item_spacing = egui::Vec2::new(8.0, 0.0);
ui.columns(2, |columns| {
columns[0].vertical_centered_justified(|ui| {
View::button(
ui,
t!("modal.cancel"),
Colors::white_or_black(false),
|| {
Modal::close();
},
);
});
columns[1].vertical_centered_justified(|ui| {
View::button(ui, t!("modal.save"), Colors::white_or_black(false), || {
on_save(self);
});
});
});
ui.add_space(6.0);
});
}
/// Content of the batch-invoice approval modal: ONE approval for N payment
/// requests to the same payer, each request minted its own fresh per-sale
/// proof address so no two sales share an address. Approve fires the same
/// request task the single flow uses, N times; the requests then appear in
/// activity as they dispatch, exactly like single requests.
pub(super) fn batch_invoice_modal_content(&mut self, ui: &mut egui::Ui, wallet: &Wallet) {
let Some(b) = &self.batch_invoice else {
Modal::close();
return;
};
let name = wallet
.nostr_service()
.map(|s| data::contact_title(&s.store, &b.hex))
.unwrap_or_else(|| data::short_npub(&b.hex));
let each = grin_core::core::amount_from_hr_string(&b.amount).unwrap_or(0);
let total = each.saturating_mul(b.count as u64);
ui.vertical_centered(|ui| {
ui.add_space(6.0);
ui.label(
RichText::new(t!(
"goblin.batch.blurb",
n => b.count.to_string(),
name => name,
amount => format!("{}{}", w::amount_str(each), w::TSU),
total => format!("{}{}", w::amount_str(total), w::TSU)
))
.size(16.0)
.color(Colors::gray()),
);
if let Some(memo) = &b.memo {
ui.add_space(6.0);
ui.label(
RichText::new(format!("\u{201C}{}\u{201D}", memo))
.size(15.0)
.color(Colors::gray()),
);
}
ui.add_space(12.0);
});
let mut cancel = false;
let mut approve = false;
ui.scope(|ui| {
ui.spacing_mut().item_spacing = egui::Vec2::new(8.0, 0.0);
ui.columns(2, |columns| {
columns[0].vertical_centered_justified(|ui| {
View::button(
ui,
t!("modal.cancel"),
Colors::white_or_black(false),
|| {
cancel = true;
},
);
});
columns[1].vertical_centered_justified(|ui| {
View::button(
ui,
t!("goblin.batch.approve"),
Colors::white_or_black(false),
|| {
approve = true;
},
);
});
});
ui.add_space(6.0);
});
if cancel {
self.batch_invoice = None;
Modal::close();
}
if approve {
if let Some(b) = self.batch_invoice.take() {
if let Some(service) = wallet.nostr_service() {
service.set_send_phase(crate::nostr::send_phase::WORKING);
}
let w = wallet.clone();
std::thread::spawn(move || {
for _ in 0..b.count {
// Each request gets its OWN fresh proof address; a mint
// failure stops the batch (already-issued requests stand).
match w.mint_proof_address() {
Ok((_index, addr)) => {
w.task(crate::wallet::types::WalletTask::NostrRequest(
each,
b.hex.clone(),
b.memo.clone(),
b.relay_hints.clone(),
Some(addr),
));
}
Err(e) => {
log::error!("batch invoice: mint failed: {e}");
break;
}
}
}
});
}
Modal::close();
}
}
/// Trusted Sites: the active Authorize Sessions, what each can sign silently,
/// time remaining, and a one-tap end (immediate, unilateral revocation).
pub(super) fn trusted_sites_ui(
&mut self,
ui: &mut egui::Ui,
wallet: &Wallet,
_cb: &dyn PlatformCallbacks,
) {
let t = theme::tokens();
if self.sub_header(ui, &t!("goblin.trusted_sites.title")) {
self.settings_page = SettingsPage::Main;
return;
}
let summaries = wallet
.nostr_service()
.map(|s| s.session_summaries())
.unwrap_or_default();
ScrollArea::vertical()
.id_salt("goblin_trusted_sites_scroll")
.auto_shrink([false; 2])
.scroll_bar_visibility(egui::scroll_area::ScrollBarVisibility::AlwaysHidden)
.show(ui, |ui| {
ui.label(
RichText::new(t!("goblin.trusted_sites.intro"))
.font(FontId::new(13.0, fonts::regular()))
.color(t.text_dim),
);
ui.add_space(12.0);
if summaries.is_empty() {
ui.label(
RichText::new(t!("goblin.trusted_sites.empty"))
.font(FontId::new(13.0, fonts::regular()))
.color(t.text_dim),
);
return;
}
let mut to_end: Option<String> = None;
let mut to_resume: Option<String> = None;
for s in &summaries {
settings_group(ui, &s.domain, |ui| {
// The low-tier categories this session signs silently.
let cats: Vec<String> = s
.categories
.iter()
.map(|c| t!(c.key()).to_string())
.collect();
let cats = if cats.is_empty() {
t!("goblin.trusted_sites.none").to_string()
} else {
cats.join(", ")
};
ui.label(
RichText::new(t!("goblin.trusted_sites.can_sign", cats => cats))
.font(FontId::new(12.5, fonts::regular()))
.color(t.text_dim),
);
ui.add_space(4.0);
let mins = s.ttl_remaining_secs / 60;
ui.label(
RichText::new(
t!("goblin.trusted_sites.time_left", mins => mins.to_string()),
)
.font(FontId::new(12.5, fonts::regular()))
.color(t.text_dim),
);
if s.paused {
ui.add_space(4.0);
ui.label(
RichText::new(t!("goblin.trusted_sites.paused"))
.font(FontId::new(12.5, fonts::regular()))
.color(Colors::red()),
);
if settings_row_btn(
ui,
&t!("goblin.trusted_sites.resume"),
crate::gui::icons::PLAY,
) {
to_resume = Some(s.domain.clone());
}
}
if settings_row_btn(
ui,
&t!("goblin.trusted_sites.end"),
crate::gui::icons::X_CIRCLE,
) {
to_end = Some(s.domain.clone());
}
});
ui.add_space(8.0);
}
if let Some(svc) = wallet.nostr_service() {
if let Some(d) = to_end {
svc.end_session(&d);
}
if let Some(d) = to_resume {
svc.resume_session(&d);
}
}
});
}
/// Draw the transient login-outcome toast: the same quiet pill as the back
/// hint (solid soft pill, no border, small dim text), bottom-anchored,
/// non-blocking, fading out.
pub(super) fn login_toast_ui(&mut self, ctx: &egui::Context) {
const SHOW_SECS: f32 = 3.5;
let Some((text, at)) = &self.login_toast else {
return;
};
let elapsed = at.elapsed().as_secs_f32();
if elapsed >= SHOW_SECS {
self.login_toast = None;
return;
}
let text = text.clone();
let t = theme::tokens();
// Fade over the final 0.5s.
let alpha = ((SHOW_SECS - elapsed) / 0.5).clamp(0.0, 1.0);
let font = FontId::new(13.0, fonts::regular());
egui::Area::new(egui::Id::new("goblin_login_toast"))
.order(egui::Order::Foreground)
.anchor(
egui::Align2::CENTER_BOTTOM,
Vec2::new(0.0, -(View::get_bottom_inset() + 92.0)),
)
.interactable(false)
.show(ctx, |ui| {
let galley = ui
.painter()
.layout_no_wrap(text, font.clone(), t.surface_text_dim);
let pad = Vec2::new(16.0, 10.0);
let size = galley.size() + pad * 2.0;
let (rect, _) = ui.allocate_exact_size(size, Sense::hover());
ui.painter().rect(
rect,
CornerRadius::same((size.y / 2.0) as u8),
t.surface2.gamma_multiply(alpha),
Stroke::NONE,
egui::StrokeKind::Inside,
);
ui.painter().galley(
rect.min + pad,
galley,
t.surface_text_dim.gamma_multiply(alpha),
);
});
ctx.request_repaint_after(std::time::Duration::from_millis(50));
}
}
+185
View File
@@ -0,0 +1,185 @@
// 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.
//! Pay tab: amount entry and send launch.
use super::*;
impl GoblinWalletView {
/// Pay tab: amount-first combined pay/request surface.
pub(super) fn pay_ui(
&mut self,
ui: &mut egui::Ui,
wallet: &Wallet,
cb: &dyn PlatformCallbacks,
) {
let t = theme::tokens();
ui.add_space(8.0);
ui.horizontal(|ui| {
// Goblin mark (left), sized to match the right-side controls.
ui.add(
egui::Image::new(egui::include_image!("../../../../img/goblin-logo2.svg"))
.tint(t.text)
.fit_to_exact_size(Vec2::splat(40.0)),
);
// Right cluster: scan-QR glyph (black, no background). The
// tap-to-Settings profile avatar that used to sit at the far right
// was removed per owner request; Settings stays on the nav bar.
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
ui.add_space(12.0);
let (rect, resp) = ui.allocate_exact_size(Vec2::splat(44.0), Sense::click());
ui.painter().text(
rect.center(),
egui::Align2::CENTER_CENTER,
QR_CODE,
FontId::new(38.0, fonts::regular()),
t.text,
);
if resp
.on_hover_cursor(egui::CursorIcon::PointingHand)
.on_hover_text(t!("goblin.home.scan_to_pay").to_string())
.clicked()
{
let mut f = SendFlow::default();
f.prefill_amount(self.pay_amount.clone());
f.request_scan();
self.pay_amount.clear();
self.send = Some(f);
}
});
});
// Big centered amount.
let display = if self.pay_amount.is_empty() {
"0".to_string()
} else {
self.pay_amount.clone()
};
let tall = ui.available_height() > 560.0;
// Over-balance is NOT shown while typing — requesting more than you hold is
// valid, and reddening digits mid-entry reads as an error when it isn't.
// The only feedback is on the Pay press: a brief red flash + shake + buzz
// (see the Pay button below). `spendable` is read there too.
let spendable = wallet
.get_data()
.map(|d| d.info.amount_currently_spendable)
.unwrap_or(0);
// Drive the "can't pay that" animation if it's running.
let now = ui.input(|i| i.time);
const SHAKE_DUR: f64 = 0.45;
if self.pay_shake.is_some_and(|s| now - s >= SHAKE_DUR) {
self.pay_shake = None;
}
ui.add_space(if tall { 56.0 } else { 24.0 });
if let Some(start) = self.pay_shake {
ui.ctx().request_repaint(); // keep the animation ticking
let p = ((now - start) / SHAKE_DUR).clamp(0.0, 1.0) as f32;
// Damped horizontal oscillation, amplitude decaying to zero.
let dx = 14.0 * (1.0 - p) * (p * std::f32::consts::PI * 9.0).sin();
// Red flash that eases back to the normal ink over the shake.
let num = lerp_color(t.neg, t.text, p);
let mark = lerp_color(t.neg, t.text_dim, p);
w::amount_text_centered_shifted(ui, &display, 76.0, num, mark, dx);
} else {
w::amount_text_centered(ui, &display, 76.0);
}
if let Ok(grin) = display.parse::<f64>() {
if let Some(preview) = pairing_preview(grin, ui.ctx()) {
ui.add_space(6.0);
ui.vertical_centered(|ui| {
ui.label(
RichText::new(preview)
.font(FontId::new(14.0, fonts::regular()))
.color(t.text_dim),
);
});
}
}
// Drop the keypad toward the bottom on phone layouts (thumb reach) so it
// isn't stranded in the middle with a big empty gap below it.
let narrow = ui.available_width() < 700.0;
let drop = if narrow {
((ui.available_height() - 430.0) * 0.6).max(0.0)
} else {
0.0
};
ui.add_space(if tall { 32.0 } else { 16.0 } + drop);
// The pay column is capped at 480 by `centered_column`, so the old
// `< 700` width gate was always narrow: the numpad always showed and
// the typed-input branch was dead — a physical keyboard did nothing.
// Show the pad and accept typed digits alongside it.
w::numpad(ui, &mut self.pay_amount, cb);
w::amount_typed_input(ui, &mut self.pay_amount);
ui.add_space(20.0);
// Request | Pay actions, half width each.
let valid = grin_core::core::amount_from_hr_string(&self.pay_amount)
.map(|a| a > 0)
.unwrap_or(false);
ui.horizontal(|ui| {
let half = (ui.available_width() - 10.0) / 2.0;
ui.scope_builder(
egui::UiBuilder::new().max_rect(egui::Rect::from_min_size(
ui.cursor().min,
Vec2::new(half, 56.0),
)),
|ui| {
if w::big_action(ui, &t!("goblin.home.request"), true).clicked() && valid {
// Open the request flow: pick a contact, then DM them a
// grin Invoice1 they can approve to pay.
let f = SendFlow::new_request(self.pay_amount.clone());
self.pay_amount.clear();
self.send = Some(f);
}
},
);
ui.add_space(10.0);
ui.scope_builder(
egui::UiBuilder::new().max_rect(egui::Rect::from_min_size(
ui.cursor().min,
Vec2::new(half, 56.0),
)),
|ui| {
if w::big_action(ui, &t!("goblin.home.pay"), false).clicked() && valid {
let over = grin_core::core::amount_from_hr_string(&self.pay_amount)
.map(|a| a > spendable)
.unwrap_or(false);
if over {
// "No, you can't pay that": shake + flash the amount red
// and buzz the phone. Nothing is reddened while typing.
self.pay_shake = Some(now);
cb.vibrate_error();
} else {
let mut f = SendFlow::default();
f.prefill_amount(self.pay_amount.clone());
self.pay_amount.clear();
self.send = Some(f);
}
}
},
);
});
if !valid {
ui.add_space(8.0);
ui.vertical_centered(|ui| {
ui.label(
RichText::new(t!("goblin.home.enter_amount"))
.font(FontId::new(12.0, fonts::regular()))
.color(t.text_mute),
);
});
}
}
}
+318
View File
@@ -0,0 +1,318 @@
// 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.
//! Network-privacy screen: transport (Tor) panels and status.
use super::*;
/// One channel row on the Network-privacy page: a status dot, a title and a
/// wrapped blurb explaining where that traffic goes.
pub(super) fn privacy_line(ui: &mut egui::Ui, dot: Color32, title: &str, blurb: &str) {
let t = theme::tokens();
ui.horizontal_top(|ui| {
let (rect, _) = ui.allocate_exact_size(Vec2::new(14.0, 20.0), Sense::hover());
ui.painter()
.circle_filled(rect.center() + Vec2::new(0.0, -2.0), 4.0, dot);
ui.vertical(|ui| {
ui.label(
RichText::new(title)
.font(FontId::new(14.0, fonts::semibold()))
.color(t.surface_text),
);
ui.add_space(2.0);
ui.label(
RichText::new(blurb)
.font(FontId::new(12.0, fonts::regular()))
.color(t.surface_text_dim),
);
});
});
ui.add_space(10.0);
}
/// Like [`settings_group`] but with a colored header kicker — used by the
/// Network-privacy private-traffic panel, whose header is red ("TOR OFF") or
/// green ("TOR ON") to signal the current state.
pub(super) fn settings_group_colored(
ui: &mut egui::Ui,
title: &str,
color: Color32,
add: impl FnOnce(&mut egui::Ui),
) {
ui.label(
RichText::new(title.to_uppercase())
.font(fonts::kicker())
.color(color),
);
ui.add_space(8.0);
w::card(ui, |ui| add(ui));
}
/// The shared Network-privacy body, rendered by BOTH the onboarding privacy
/// step and the Settings privacy screen so the two never drift. Draws the intro
/// copy (what Goblin sends + how privacy works — deliberately no crypto version
/// numbers), the always-direct Grin-node panel, the private-traffic panel whose
/// header/dots reflect `tor_on`, the large Tor switch, and the VPN nudge.
///
/// Returns `Some(new_value)` on the frame the switch is toggled; the caller
/// persists it (Settings writes the wallet Tor setting + restarts the service;
/// onboarding stashes it and writes it before the service starts).
pub(super) fn network_privacy_panels(ui: &mut egui::Ui, tor_on: bool) -> Option<bool> {
let t = theme::tokens();
ui.label(
RichText::new(t!("goblin.privacy.intro"))
.font(FontId::new(14.0, fonts::regular()))
.color(t.text_dim),
);
ui.add_space(14.0);
ui.label(
RichText::new(t!("goblin.privacy.how_privacy"))
.font(FontId::new(14.0, fonts::semibold()))
.color(t.text),
);
ui.add_space(4.0);
ui.label(
RichText::new(t!("goblin.privacy.how_privacy_blurb"))
.font(FontId::new(13.0, fonts::regular()))
.color(t.text_dim),
);
ui.add_space(16.0);
// Grin node — ALWAYS DIRECT, grey dot, full width. Public chain data, the
// same for everyone, never routed over Tor.
settings_group(ui, &t!("goblin.privacy.always_direct"), |ui| {
ui.set_min_width(ui.available_width());
privacy_line(
ui,
t.surface_text_mute,
&t!("goblin.privacy.grin_node"),
&t!("goblin.privacy.grin_node_blurb"),
);
});
ui.add_space(16.0);
// Private traffic — header "TOR OFF" (red) / "TOR ON" (green); status dots
// goblin-yellow when off (clearnet), blueviolet when on.
let (header, header_color) = if tor_on {
(t!("goblin.privacy.tor_on"), t.pos)
} else {
(t!("goblin.privacy.tor_off"), t.neg)
};
let dot = if tor_on { t.tor_purple } else { t.accent };
let transport = [
(
t!("goblin.privacy.payments"),
t!("goblin.privacy.payments_blurb"),
),
(
t!("goblin.privacy.usernames"),
t!("goblin.privacy.usernames_blurb"),
),
(
t!("goblin.privacy.price_avatars"),
t!("goblin.privacy.price_avatars_blurb"),
),
];
settings_group_colored(ui, &header, header_color, |ui| {
ui.set_min_width(ui.available_width());
for (title, blurb) in &transport {
privacy_line(ui, dot, title, blurb);
}
});
ui.add_space(18.0);
// Large Tor switch — dormant gray when off, blueviolet when on (never
// yellow). The color state lives on the dots above; this reads on/off.
let mut toggled = None;
w::card(ui, |ui| {
ui.set_min_width(ui.available_width());
ui.horizontal(|ui| {
ui.vertical(|ui| {
ui.label(
RichText::new(t!("goblin.privacy.tor_switch"))
.font(FontId::new(15.0, fonts::semibold()))
.color(t.surface_text),
);
ui.add_space(2.0);
ui.label(
RichText::new(t!("goblin.privacy.tor_switch_sub"))
.font(FontId::new(12.5, fonts::regular()))
.color(t.surface_text_dim),
);
});
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
if w::toggle_large(ui, tor_on).clicked() {
toggled = Some(!tor_on);
}
});
});
});
ui.add_space(12.0);
ui.label(
RichText::new(t!("goblin.privacy.vpn_note"))
.font(FontId::new(12.5, fonts::regular()))
.color(t.text_mute),
);
toggled
}
/// Localized transport-status label for the identity status lines. Tor and
/// clearnet are distinct states so a Tor-off wallet reads "Connected (direct)"
/// instead of forever "connecting over Tor".
pub(super) fn transport_status_label(
status: crate::nostr::TransportStatus,
) -> std::borrow::Cow<'static, str> {
use crate::nostr::TransportStatus::*;
match status {
ConnectedTor => t!("goblin.home.connected_tor"),
TorReady => t!("goblin.home.tor_ready"),
ConnectingTor => t!("goblin.home.connecting_tor"),
ConnectedDirect => t!("goblin.home.connected_direct"),
ConnectingDirect => t!("goblin.home.connecting_direct"),
}
}
impl GoblinWalletView {
/// Network-privacy screen: the interactive Tor toggle shared with onboarding.
/// The Grin node is always direct; the private traffic (payments, name
/// lookups, price) rides Tor when the switch is on, else connects directly.
/// Flipping the switch writes the wallet Tor setting and restarts the service
/// so the relay pool is rebuilt on the newly-selected transport.
pub(super) fn privacy_ui(&mut self, ui: &mut egui::Ui, wallet: &Wallet) {
if self.sub_header(ui, &t!("goblin.privacy.title")) {
self.settings_page = SettingsPage::Main;
return;
}
let tor_on = wallet
.nostr_service()
.map(|s| s.tor_routing())
.unwrap_or(true);
let mut toggled = None;
ScrollArea::vertical()
.id_salt("goblin_privacy_scroll")
.auto_shrink([false; 2])
.scroll_bar_visibility(egui::scroll_area::ScrollBarVisibility::AlwaysHidden)
.show(ui, |ui| {
toggled = network_privacy_panels(ui, tor_on);
ui.add_space(16.0);
});
if let Some(new_val) = toggled {
if let Some(s) = wallet.nostr_service() {
// Persist the explicit choice (auto-saves nostr.toml) and mirror
// the process-global route flag so free-function HTTP callers pick
// the matching transport immediately, then rebuild the pool.
s.config.write().set_tor_enabled(new_val);
crate::tor::set_route_over_tor(new_val);
s.restart(wallet.clone());
}
}
}
/// Advanced Privacy page — notification hiding (amounts / names / all
/// details) and the anonymous-mode toggle that dots the home balance and the
/// activity list. All presentation-only; nothing here touches the money path.
pub(super) fn advanced_privacy_ui(
&mut self,
ui: &mut egui::Ui,
wallet: &Wallet,
cb: &dyn PlatformCallbacks,
) {
let t = theme::tokens();
if self.sub_header(ui, &t!("goblin.settings.advanced_privacy")) {
self.settings_page = SettingsPage::Main;
return;
}
ScrollArea::vertical()
.id_salt("goblin_adv_privacy_scroll")
.auto_shrink([false; 2])
.scroll_bar_visibility(egui::scroll_area::ScrollBarVisibility::AlwaysHidden)
.show(ui, |ui| {
ui.label(
RichText::new(t!("goblin.advprivacy.intro"))
.font(FontId::new(14.0, fonts::regular()))
.color(t.text_dim),
);
ui.add_space(16.0);
settings_group(ui, &t!("goblin.advprivacy.notifications"), |ui| {
if let Some(v) = settings_row_toggle(
ui,
&t!("goblin.settings.hide_amounts"),
&t!("goblin.settings.hide_amounts_sub"),
crate::AppConfig::hide_amounts(),
) {
crate::AppConfig::set_hide_amounts(v);
}
if let Some(v) = settings_row_toggle(
ui,
&t!("goblin.advprivacy.hide_names"),
&t!("goblin.advprivacy.hide_names_sub"),
crate::AppConfig::notif_hide_names(),
) {
crate::AppConfig::set_notif_hide_names(v);
}
if let Some(v) = settings_row_toggle(
ui,
&t!("goblin.advprivacy.hide_details"),
&t!("goblin.advprivacy.hide_details_sub"),
crate::AppConfig::notif_hide_details(),
) {
crate::AppConfig::set_notif_hide_details(v);
}
});
ui.add_space(16.0);
settings_group(ui, &t!("goblin.advprivacy.anon"), |ui| {
if let Some(v) = settings_row_toggle(
ui,
&t!("goblin.advprivacy.anon_toggle"),
&t!("goblin.advprivacy.anon_sub"),
crate::AppConfig::anonymous_mode(),
) {
crate::AppConfig::set_anonymous_mode(v);
}
});
ui.add_space(16.0);
// The local archive (contacts + payment history + requests) lives
// here under Advanced privacy.
settings_group(ui, &t!("goblin.settings.archive"), |ui| {
if settings_row_btn(ui, &t!("goblin.settings.export_archive"), COPY) {
if let Some(s) = wallet.nostr_service() {
let json = s.store.export_json(&s.npub());
cb.copy_string_to_buffer(json);
cb.vibrate_copy();
}
}
advanced_desc(ui, &t!("goblin.settings.export_archive_caption"));
ui.add_space(10.0);
// Destructive: danger styling + tap-twice confirm (like the
// receipt's "Cancel payment") before the archive is wiped.
let wipe_label = if self.wipe_confirm {
t!("goblin.settings.wipe_history_confirm")
} else {
t!("goblin.settings.wipe_history")
};
if settings_row_danger(ui, &wipe_label, crate::gui::icons::X) {
if self.wipe_confirm {
if let Some(s) = wallet.nostr_service() {
s.store.wipe_archive();
}
self.wipe_confirm = false;
} else {
self.wipe_confirm = true;
}
}
});
ui.add_space(16.0);
});
}
}
+191
View File
@@ -0,0 +1,191 @@
// 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.
//! Contact-profile full-surface overlay.
use super::*;
impl GoblinWalletView {
/// Full-surface contact profile: who they are, history between us, and a
/// block toggle (a nostr-level mute).
pub(super) fn profile_ui(
&mut self,
ui: &mut egui::Ui,
wallet: &Wallet,
_cb: &dyn PlatformCallbacks,
npub: &str,
) -> bool {
let t = theme::tokens();
let name = wallet
.nostr_service()
.map(|s| data::contact_title(&s.store, npub))
.unwrap_or_else(|| data::short_npub(npub));
let contact = wallet.nostr_service().and_then(|s| s.store.contact(npub));
let blocked = contact.as_ref().map(|c| c.blocked).unwrap_or(false);
let nip05 = contact.as_ref().and_then(|c| c.nip05.clone());
let history = data::history_with(wallet, npub);
let tex = self.handle_tex(ui.ctx(), wallet, &name);
let htexs: Vec<Option<egui::TextureHandle>> = history
.iter()
.map(|i| self.handle_tex(ui.ctx(), wallet, &i.title))
.collect();
let mut close = false;
let mut do_pay = false;
let mut do_block = false;
let mut open_receipt: Option<u32> = None;
egui::CentralPanel::default()
.frame(egui::Frame {
fill: t.bg,
inner_margin: Margin {
left: (View::far_left_inset_margin(ui) + 20.0) as i8,
right: (View::get_right_inset() + 20.0) as i8,
top: (View::get_top_inset() + 12.0) as i8,
bottom: (View::get_bottom_inset() + 12.0) as i8,
},
..Default::default()
})
.show_inside(ui, |ui| {
w::centered_column(ui, Content::SIDE_PANEL_WIDTH * 1.2, |ui| {
if Self::overlay_back_header(ui, &t!("goblin.profile.title")) {
close = true;
}
ScrollArea::vertical()
.id_salt("goblin_profile_scroll")
.auto_shrink([false; 2])
.show(ui, |ui| {
ui.add_space(8.0);
ui.vertical_centered(|ui| {
w::avatar_any(ui, &name, npub, 72.0, tex.as_ref());
ui.add_space(12.0);
ui.label(
RichText::new(&name)
.font(FontId::new(22.0, fonts::bold()))
.color(t.text),
);
ui.add_space(2.0);
let sub = nip05
.clone()
.map(|n| format!("{}", n))
.unwrap_or_else(|| data::short_npub(npub));
ui.label(
RichText::new(sub)
.font(FontId::new(13.0, fonts::regular()))
.color(t.text_dim),
);
});
ui.add_space(18.0);
if !blocked
&& w::big_action(ui, &t!("goblin.home.pay"), false).clicked()
{
do_pay = true;
}
ui.add_space(18.0);
w::kicker(ui, &t!("goblin.profile.activity"));
ui.add_space(10.0);
if history.is_empty() {
ui.label(
RichText::new(t!("goblin.profile.no_activity"))
.font(FontId::new(13.0, fonts::regular()))
.color(t.text_dim),
);
} else {
for (item, htex) in history.iter().zip(htexs.iter()) {
// No +/- for canceled: nothing moved.
let sign = if item.canceled {
""
} else if item.incoming {
"+ "
} else {
" "
};
let amount =
format!("{}{}{}", sign, w::amount_str(item.amount), w::TSU);
let (note, time) = Self::activity_note_time(item);
if w::activity_row(
ui,
&item.title,
&note,
&time,
item.npub.as_deref().unwrap_or(""),
&amount,
item.incoming,
item.canceled,
item.system,
htex.as_ref(),
false,
)
.clicked()
{
open_receipt = Some(item.tx_id);
}
}
}
ui.add_space(24.0);
let label = if blocked {
t!("goblin.profile.unblock").to_string()
} else {
format!("{} {}", PROHIBIT, t!("goblin.profile.block"))
};
if w::big_action_on_card_ink(ui, &label, t.neg).clicked() {
do_block = true;
}
ui.add_space(8.0);
ui.vertical_centered(|ui| {
ui.label(
RichText::new(if blocked {
t!("goblin.profile.blocked_blurb")
} else {
t!("goblin.profile.block_blurb")
})
.font(FontId::new(12.0, fonts::regular()))
.color(t.text_mute),
);
});
ui.add_space(20.0);
});
});
});
if let Some(id) = open_receipt {
self.receipt = Some(id);
close = true;
}
if do_pay {
let mut f = SendFlow::default();
f.prefill_contact(name.clone(), npub.to_string());
self.send = Some(f);
close = true;
}
if do_block {
if let Some(s) = wallet.nostr_service() {
let mut c = s.store.contact(npub).unwrap_or(crate::nostr::Contact {
ver: 1,
npub: npub.to_string(),
petname: None,
nip05: nip05.clone(),
nip05_verified_at: None,
relays: vec![],
nip44_v3: false,
hue: data::hue_of(npub) as u8,
unknown: true,
added_at: crate::nostr::unix_time(),
last_paid_at: None,
blocked: false,
});
c.blocked = !c.blocked;
s.store.save_contact(&c);
}
}
close
}
}
File diff suppressed because it is too large Load Diff
+389
View File
@@ -0,0 +1,389 @@
// 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.
//! Transaction-receipt full-surface overlay.
use super::*;
impl GoblinWalletView {
/// Full-surface transaction receipt: GRIM metadata joined with the nostr
/// counterparty + note. Tapping the counterparty opens their profile.
pub(super) fn receipt_ui(&mut self, ui: &mut egui::Ui, wallet: &Wallet, tx_id: u32) -> bool {
let t = theme::tokens();
let d = data::receipt_detail(wallet, tx_id);
let tex = d
.as_ref()
.and_then(|d| self.handle_tex(ui.ctx(), wallet, &d.title));
let mut close = false;
let mut open_profile: Option<String> = None;
egui::CentralPanel::default()
.frame(egui::Frame {
fill: t.bg,
inner_margin: Margin {
left: (View::far_left_inset_margin(ui) + 20.0) as i8,
right: (View::get_right_inset() + 20.0) as i8,
top: (View::get_top_inset() + 12.0) as i8,
bottom: (View::get_bottom_inset() + 12.0) as i8,
},
..Default::default()
})
.show_inside(ui, |ui| {
w::centered_column(ui, Content::SIDE_PANEL_WIDTH * 1.2, |ui| {
if Self::overlay_back_header(ui, &t!("goblin.receipt.title")) {
close = true;
}
let Some(d) = d else {
ui.add_space(40.0);
ui.vertical_centered(|ui| {
ui.label(
RichText::new(t!("goblin.receipt.not_found"))
.font(FontId::new(15.0, fonts::regular()))
.color(t.text_dim),
);
});
return;
};
ScrollArea::vertical()
.id_salt("goblin_receipt_scroll")
.auto_shrink([false; 2])
.show(ui, |ui| {
ui.add_space(8.0);
ui.vertical_centered(|ui| {
let resp = w::avatar_any(
ui,
&d.title,
d.npub.as_deref().unwrap_or(""),
64.0,
tex.as_ref(),
);
ui.add_space(10.0);
ui.label(
RichText::new(&d.title)
.font(FontId::new(22.0, fonts::bold()))
.color(t.text),
);
ui.add_space(2.0);
ui.label(
RichText::new(View::format_time(d.time))
.font(FontId::new(13.0, fonts::regular()))
.color(t.text_dim),
);
if let Some(note) = &d.note {
ui.add_space(2.0);
ui.label(
RichText::new(t!("goblin.receipt.for_note", note => note))
.font(FontId::new(13.0, fonts::regular()))
.color(t.text_dim),
);
}
ui.add_space(14.0);
w::amount_text_centered(ui, &w::amount_str(d.amount), 56.0);
if resp.clicked() {
if let Some(npub) = &d.npub {
open_profile = Some(npub.clone());
}
}
});
ui.add_space(20.0);
w::kicker(ui, &t!("goblin.receipt.details"));
ui.add_space(10.0);
w::card(ui, |ui| {
let (status, sub): (String, String) = if d.canceled {
(
t!("goblin.receipt.canceled").to_string(),
if d.incoming {
t!("goblin.receipt.expired").to_string()
} else {
t!("goblin.receipt.funds_returned").to_string()
},
)
} else if let Some((c, r)) = d.confs {
// On-chain but still maturing toward the spendable
// threshold — show the live X/N count (grin marks a
// tx confirmed at one block; spendable takes N).
if c == 0 && !d.incoming && d.npub.is_some() {
// Sent but not yet picked up / mined.
(
t!("goblin.receipt.pending").to_string(),
t!("goblin.receipt.waiting_to_receive", name => d.title)
.to_string(),
)
} else {
(
t!("goblin.receipt.pending").to_string(),
t!("goblin.receipt.confs", c => c, r => r).to_string(),
)
}
} else if d.confirmed {
(
t!("goblin.receipt.complete").to_string(),
if d.incoming {
t!("goblin.receipt.payment_received").to_string()
} else {
t!("goblin.receipt.payment_sent").to_string()
},
)
} else {
(
t!("goblin.receipt.pending").to_string(),
t!("goblin.receipt.waiting_to_confirm").to_string(),
)
};
w::info_row(ui, &status, &sub);
if d.has_identity {
let (to, from) = if d.incoming {
(t!("goblin.receipt.you").to_string(), d.title.clone())
} else {
(d.title.clone(), t!("goblin.receipt.you").to_string())
};
w::info_row(ui, &t!("goblin.receipt.to"), &to);
w::info_row(ui, &t!("goblin.receipt.from"), &from);
// Which of the wallet's held nostr identities was active
// when this payment was received/sent — the "front door"
// it used. Uses the identity recorded on the tx
// (recipient_pubkey), falling back to the primary for
// pre-feature rows. NIP-05 name when claimed, else a
// truncated npub.
let owning_hex = d
.slate_id
.as_deref()
.and_then(|sid| {
wallet
.nostr_service()
.and_then(|s| s.store.tx_meta(sid))
})
.map(|m| m.recipient_pubkey)
.filter(|h| !h.is_empty());
let ids = wallet.nostr_identities();
// The identity this tx used: the one recorded on it,
// else the primary for pre-feature rows.
let owner = match &owning_hex {
Some(hex) => ids.iter().find(|i| &i.pubkey_hex == hex),
None => ids.first(),
};
let seed = owner
.map(|i| i.pubkey_hex.clone())
.or_else(|| owning_hex.clone());
// The claimed name (no leading @, no domain — the project
// convention), else the truncated npub. Never a
// placeholder word.
let id_label = owner
.map(|i| i.display())
.or_else(|| owning_hex.as_deref().map(data::short_npub))
.unwrap_or_default();
if !id_label.is_empty() {
match &seed {
Some(seed) => w::info_row_dot(
ui,
&t!("goblin.receipt.identity"),
&id_label,
seed,
),
None => w::info_row(
ui,
&t!("goblin.receipt.identity"),
&id_label,
),
}
}
}
if let Some(npub) = &d.npub {
w::info_row(
ui,
&t!("goblin.receipt.nostr"),
&data::short_npub(npub),
);
}
// Only the SENDER pays a network fee, so the row only makes
// sense on outgoing payments. A received payment has no fee
// (data sets it to None) — hide the row entirely instead of
// showing a confusing "—".
if let Some(fee_amount) = d.fee {
let fee = if fee_amount == 0 {
t!("goblin.receipt.fee_none").to_string()
} else {
format!("{}{}", w::amount_str(fee_amount), w::TSU)
};
w::info_row(ui, &t!("goblin.receipt.network_fee"), &fee);
}
w::info_row(
ui,
&t!("goblin.receipt.privacy"),
&t!("goblin.receipt.privacy_value"),
);
if let Some(sid) = &d.slate_id {
let short = if sid.len() > 13 {
format!("{}{}", &sid[..8], &sid[sid.len() - 4..])
} else {
sid.clone()
};
w::info_row(ui, &t!("goblin.receipt.transaction"), &short);
}
});
// Withdraw a request we sent that hasn't been paid yet:
// cancel the local invoice and tell the payer (a void
// message). Requests are messages; payments are final.
let cancelable_request = d
.slate_id
.as_ref()
.and_then(|sid| {
wallet.nostr_service().and_then(|s| s.store.tx_meta(sid))
})
.map(|m| {
m.direction == crate::nostr::NostrTxDirection::RequestedByUs
&& matches!(
m.status,
crate::nostr::NostrSendStatus::Created
| crate::nostr::NostrSendStatus::AwaitingI2
)
})
.unwrap_or(false) && !d.canceled
&& !d.confirmed;
if cancelable_request {
ui.add_space(16.0);
if w::big_action(ui, &t!("goblin.receipt.cancel_request"), true)
.clicked()
{
if let Some(sid) = &d.slate_id {
wallet.task(
crate::wallet::types::WalletTask::NostrCancelOutgoing(
sid.clone(),
),
);
}
close = true;
}
}
// Reclaim a payment WE sent that the recipient never
// completed: cancel the grin tx to unlock our funds, mark
// it cancelled, best-effort void. Appears after the grace
// window (or immediately if it never reached a relay).
let send_meta = d.slate_id.as_ref().and_then(|sid| {
wallet.nostr_service().and_then(|s| s.store.tx_meta(sid))
});
let grace = wallet
.nostr_service()
.map(|s| s.config.read().cancel_grace_secs())
.unwrap_or(600);
let cancelable_send = send_meta
.as_ref()
.map(|m| {
m.direction == crate::nostr::NostrTxDirection::Sent
&& matches!(
m.status,
crate::nostr::NostrSendStatus::Created
| crate::nostr::NostrSendStatus::AwaitingS2
| crate::nostr::NostrSendStatus::SendFailed
) && (matches!(
m.status,
crate::nostr::NostrSendStatus::SendFailed
) || crate::nostr::unix_time() - m.created_at > grace)
})
.unwrap_or(false) && !d.canceled
&& !d.confirmed;
// A manual Cancel is ALWAYS available for a stuck pending. The
// nostr-aware path above (after the grace window) also voids
// the counterparty's DM; this fallback covers every other
// cancellable pending it missed — e.g. a tx orphaned by an
// identity switch (its meta lives in another identity's
// store) or one left by an older build. Both run the plain
// libwallet cancel that unlocks our reserved inputs; nothing
// auto-fires on a timer.
let fallback_cancel =
!cancelable_request && !cancelable_send && d.can_cancel;
if cancelable_send || fallback_cancel {
// Soft nudge that this pending has been waiting a long
// time (a hint; the Cancel button sits right below).
if d.stale {
ui.add_space(12.0);
ui.vertical_centered(|ui| {
ui.label(
RichText::new(t!("goblin.receipt.stale_note"))
.font(FontId::new(13.0, fonts::regular()))
.color(t.accent),
);
});
}
ui.add_space(16.0);
let confirming = self.cancel_confirm == Some(d.tx_id);
let label = if confirming {
t!("goblin.receipt.cancel_send_confirm")
} else {
t!("goblin.receipt.cancel_send")
};
if w::big_action(ui, &label, true).clicked() {
if confirming {
if cancelable_send {
if let Some(sid) = &d.slate_id {
wallet.task(
crate::wallet::types::WalletTask::NostrCancelSend(
sid.clone(),
),
);
}
} else {
wallet.task(crate::wallet::types::WalletTask::Cancel(
d.tx_id,
));
}
self.cancel_confirm = None;
} else {
self.cancel_confirm = Some(d.tx_id);
}
}
} else {
self.cancel_confirm = None;
}
// Transient outcome notice, set async by the task handler.
if let Some(outcome) =
wallet.nostr_service().and_then(|s| s.take_cancel_notice())
{
self.cancel_msg = Some((outcome, std::time::Instant::now()));
}
if let Some((outcome, at)) = self.cancel_msg {
if at.elapsed().as_secs() < 5 {
ui.add_space(10.0);
let (msg, col) = match outcome {
crate::nostr::CancelOutcome::Cancelled => {
(t!("goblin.receipt.cancel_send_done"), t.pos)
}
crate::nostr::CancelOutcome::AlreadyCompleted => {
(t!("goblin.receipt.cancel_send_too_late"), t.text_dim)
}
};
ui.vertical_centered(|ui| {
ui.label(
RichText::new(msg)
.font(FontId::new(13.0, fonts::regular()))
.color(col),
);
});
ui.ctx().request_repaint_after(
std::time::Duration::from_millis(300),
);
} else {
self.cancel_msg = None;
}
}
ui.add_space(20.0);
});
});
});
if let Some(npub) = open_profile {
self.profile = Some(npub);
close = true;
}
close
}
}
+161
View File
@@ -0,0 +1,161 @@
// 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.
//! Receive screen: npub / grin-address copy flow.
use super::*;
impl GoblinWalletView {
pub(super) fn receive_ui(
&mut self,
ui: &mut egui::Ui,
wallet: &Wallet,
cb: &dyn PlatformCallbacks,
) {
let t = theme::tokens();
ui.add_space(8.0);
ui.label(
RichText::new(t!("goblin.receive.title"))
.font(FontId::new(28.0, fonts::bold()))
.color(t.text),
);
ui.add_space(16.0);
// `has_name`: a claimed nip05 name exists — gates the "handle"/"username"
// wording, which would mislead when only the raw npub is shown.
let (handle, has_name) = wallet
.nostr_service()
.map(|s| {
let identity = s.identity.read();
match identity.nip05.clone() {
Some(n) => (n.split('@').next().unwrap_or("").to_string(), true),
None => (data::short_npub(&hex_of(&identity.npub)), false),
}
})
.unwrap_or_else(|| ("".to_string(), false));
let npub = wallet.nostr_service().map(|s| s.npub()).unwrap_or_default();
let nprofile = wallet
.nostr_service()
.map(|s| s.nprofile())
.unwrap_or_else(|| npub.clone());
w::card(ui, |ui| {
ui.vertical_centered(|ui| {
// QR of the nostr handle (nostr: URI).
ui.add_space(12.0);
let uri = format!("nostr:{}", nprofile);
w::qr_code(ui, &uri, 220.0);
ui.add_space(14.0);
ui.label(
RichText::new(&handle)
.font(FontId::new(18.0, fonts::bold()))
.color(t.surface_text),
);
match &self.request_amount {
Some(amt) => {
ui.label(
RichText::new(t!(
"goblin.receive.requesting",
amt => amt,
tsu => w::TSU
))
.font(FontId::new(13.0, fonts::semibold()))
.color(t.surface_text),
);
ui.add_space(6.0);
if w::chip(ui, &t!("goblin.receive.clear_request"), false).clicked() {
self.request_amount = None;
}
}
None => {
let caption = if has_name {
t!("goblin.receive.share_handle")
} else {
t!("goblin.receive.share_npub")
};
ui.label(
RichText::new(caption)
.font(FontId::new(13.0, fonts::regular()))
.color(t.surface_text_dim),
);
}
}
});
});
ui.add_space(12.0);
// Transient "Copied" feedback on the copy button; a silent copy reads as
// a dead button.
let fresh = |at: std::time::Instant| at.elapsed().as_millis() < 1500;
let copied = matches!(self.receive_copied, Some((1, at)) if fresh(at));
if self.receive_copied.is_some() {
ui.ctx()
.request_repaint_after(std::time::Duration::from_millis(200));
}
ui.horizontal(|ui| {
let half = (ui.available_width() - 10.0) / 2.0;
// Share the nprofile: the payer learns which relays to reach you on
// (the bare npub still shows in-app as who-is-paid; the shareable
// handle carries relay hints so per-identity relays interoperate).
ui.scope_builder(
egui::UiBuilder::new().max_rect(egui::Rect::from_min_size(
ui.cursor().min,
Vec2::new(half, 56.0),
)),
|ui| {
let label = t!("goblin.send.share_btn", "icon" => SHARE);
if w::big_action(ui, &label, true).clicked() && !nprofile.is_empty() {
cb.share_text(
t!("goblin.receive.share_message", "npub" => nprofile.clone())
.to_string(),
);
}
},
);
ui.add_space(10.0);
// Copy the nprofile (the QR encodes the same); the human-copy now
// matches what the QR scans.
ui.scope_builder(
egui::UiBuilder::new().max_rect(egui::Rect::from_min_size(
ui.cursor().min,
Vec2::new(half, 56.0),
)),
|ui| {
let label = if copied {
format!("{} {}", CHECK, t!("goblin.receive.copied"))
} else {
format!("{} {}", COPY, t!("goblin.receive.copy"))
};
if w::big_action(ui, &label, false).clicked() && !nprofile.is_empty() {
cb.copy_string_to_buffer(nprofile.clone());
cb.vibrate_copy();
self.receive_copied = Some((1, std::time::Instant::now()));
}
},
);
});
ui.add_space(16.0);
let privacy_note = if has_name {
t!("goblin.receive.privacy_note")
} else {
t!("goblin.receive.privacy_note_npub")
};
ui.label(
RichText::new(privacy_note)
.font(FontId::new(12.0, fonts::regular()))
.color(t.text_mute),
);
}
}
+349
View File
@@ -0,0 +1,349 @@
// 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.
//! Settings index and simple settings screens.
use super::*;
impl GoblinWalletView {
/// Back header for Settings sub-pages; returns true when back is tapped.
pub(super) 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.
pub(super) fn pairing_settings_ui(&mut self, ui: &mut egui::Ui) {
let t = theme::tokens();
if self.sub_header(ui, &t!("goblin.pairing.title")) {
self.settings_page = SettingsPage::Main;
return;
}
ScrollArea::vertical()
.id_salt("goblin_pairing_scroll")
.auto_shrink([false; 2])
.scroll_bar_visibility(egui::scroll_area::ScrollBarVisibility::AlwaysHidden)
.show(ui, |ui| {
ui.label(
RichText::new(t!("goblin.pairing.intro"))
.font(FontId::new(13.0, fonts::regular()))
.color(t.text_dim),
);
ui.add_space(12.0);
let current = crate::AppConfig::pairing();
settings_group(ui, &t!("goblin.pairing.pair_with"), |ui| {
for p in crate::settings::Pairing::ALL {
let active = p == current;
let row = ui.horizontal(|ui| {
ui.label(
RichText::new(p.label())
.font(FontId::new(15.0, fonts::medium()))
.color(t.surface_text),
);
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
if active {
ui.label(
RichText::new(crate::gui::icons::CHECK)
.font(FontId::new(16.0, fonts::regular()))
.color(t.pos),
);
}
});
});
ui.add_space(10.0);
if !active && row.response.interact(Sense::click()).clicked() {
crate::AppConfig::set_pairing(p);
}
}
});
ui.add_space(10.0);
ui.label(
RichText::new(t!("goblin.pairing.rates_note"))
.font(FontId::new(12.0, fonts::regular()))
.color(t.text_dim),
);
ui.add_space(16.0);
});
}
/// Language picker: the nine shipped locales, each in its own name. Tapping one
/// switches the active locale and persists it (mirrors the GRIM interface
/// settings, but in Goblin's row style like the pairing picker).
pub(super) fn language_settings_ui(&mut self, ui: &mut egui::Ui) {
let t = theme::tokens();
if self.sub_header(ui, &t!("goblin.settings.language")) {
self.settings_page = SettingsPage::Main;
return;
}
let current = crate::AppConfig::locale().unwrap_or_else(|| rust_i18n::locale().to_string());
ScrollArea::vertical()
.id_salt("goblin_language_scroll")
.auto_shrink([false; 2])
.scroll_bar_visibility(egui::scroll_area::ScrollBarVisibility::AlwaysHidden)
.show(ui, |ui| {
settings_group(ui, &t!("goblin.settings.language"), |ui| {
for locale in rust_i18n::available_locales!() {
let active = current == locale;
let row = ui.horizontal(|ui| {
ui.label(
RichText::new(t!("lang_name", locale = locale))
.font(FontId::new(15.0, fonts::medium()))
.color(t.surface_text),
);
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
if active {
ui.label(
RichText::new(crate::gui::icons::CHECK)
.font(FontId::new(16.0, fonts::regular()))
.color(t.pos),
);
}
});
});
ui.add_space(10.0);
if !active && row.response.interact(Sense::click()).clicked() {
rust_i18n::set_locale(locale);
crate::AppConfig::save_locale(locale);
}
}
});
ui.add_space(16.0);
});
}
/// What-is-nostr explainer and tappable NIP reference list.
pub(super) fn nips_ui(&mut self, ui: &mut egui::Ui) {
let t = theme::tokens();
if self.sub_header(ui, &t!("goblin.nips.title")) {
self.settings_page = SettingsPage::Main;
return;
}
ScrollArea::vertical()
.id_salt("goblin_nips_scroll")
.auto_shrink([false; 2])
.scroll_bar_visibility(egui::scroll_area::ScrollBarVisibility::AlwaysHidden)
.show(ui, |ui| {
ui.label(
RichText::new(t!("goblin.nips.intro1"))
.font(FontId::new(14.0, fonts::regular()))
.color(t.text_dim),
);
ui.add_space(10.0);
ui.label(
RichText::new(t!("goblin.nips.intro2"))
.font(FontId::new(14.0, fonts::regular()))
.color(t.text_dim),
);
ui.add_space(16.0);
let nips = [
(
"05",
t!("goblin.nips.n05_title"),
t!("goblin.nips.n05_blurb"),
),
(
"17",
t!("goblin.nips.n17_title"),
t!("goblin.nips.n17_blurb"),
),
(
"44",
t!("goblin.nips.n44_title"),
t!("goblin.nips.n44_blurb"),
),
(
"49",
t!("goblin.nips.n49_title"),
t!("goblin.nips.n49_blurb"),
),
(
"59",
t!("goblin.nips.n59_title"),
t!("goblin.nips.n59_blurb"),
),
(
"98",
t!("goblin.nips.n98_title"),
t!("goblin.nips.n98_blurb"),
),
];
for (num, title, blurb) in &nips {
let resp = ui.scope(|ui| {
w::card(ui, |ui| {
ui.set_min_width(ui.available_width());
ui.label(
RichText::new(format!("NIP-{} · {}", num, title))
.font(FontId::new(14.0, fonts::semibold()))
.color(t.surface_text),
);
ui.add_space(2.0);
ui.label(
RichText::new(blurb.as_ref())
.font(FontId::new(12.0, fonts::regular()))
.color(t.surface_text_dim),
);
});
});
if resp.response.interact(Sense::click()).clicked() {
open_url(
ui,
&format!(
"https://github.com/nostr-protocol/nips/blob/master/{}.md",
num
),
);
}
ui.add_space(8.0);
}
ui.add_space(16.0);
});
}
/// Inline "back up identity to a file" flow: ask for the wallet password,
/// seal the identity, and write a GOBLIN-*.backup file via the native picker.
pub(super) fn backup_ui(
&mut self,
ui: &mut egui::Ui,
wallet: &Wallet,
cb: &dyn PlatformCallbacks,
) {
let t = theme::tokens();
let bk = self.backup.as_mut().unwrap();
let mut close = false;
w::card(ui, |ui| {
ui.set_min_width(ui.available_width());
if bk.done {
ui.label(
RichText::new(t!("goblin.settings.backup_saved"))
.font(FontId::new(15.0, fonts::semibold()))
.color(t.pos),
);
ui.add_space(4.0);
ui.label(
RichText::new(t!("goblin.settings.backup_saved_sub"))
.font(FontId::new(13.0, fonts::regular()))
.color(t.surface_text_dim),
);
ui.add_space(10.0);
if w::big_action(ui, &t!("goblin.settings.done"), false).clicked() {
close = true;
}
return;
}
ui.label(
RichText::new(t!("goblin.settings.backup_file_title"))
.font(FontId::new(15.0, fonts::semibold()))
.color(t.surface_text),
);
ui.add_space(6.0);
ui.label(
RichText::new(t!("goblin.settings.backup_file_blurb"))
.font(FontId::new(13.0, fonts::regular()))
.color(t.surface_text_dim),
);
ui.add_space(10.0);
w::field_well(ui, |ui| {
TextEdit::new(egui::Id::from("backup_pass"))
.focus(false)
.hint_text(t!("goblin.settings.wallet_password"))
.password()
.text_color(t.surface_text)
.body()
.ui(ui, &mut bk.password, cb);
});
if let Some(err) = &bk.error {
ui.add_space(6.0);
ui.label(
RichText::new(err)
.font(FontId::new(12.5, fonts::regular()))
.color(t.neg),
);
}
ui.add_space(10.0);
ui.horizontal(|ui| {
let half = (ui.available_width() - 10.0) / 2.0;
ui.scope_builder(
egui::UiBuilder::new().max_rect(egui::Rect::from_min_size(
ui.cursor().min,
Vec2::new(half, 44.0),
)),
|ui| {
if w::big_action_on_card(ui, &t!("goblin.settings.cancel")).clicked() {
close = true;
}
},
);
ui.add_space(10.0);
ui.scope_builder(
egui::UiBuilder::new().max_rect(egui::Rect::from_min_size(
ui.cursor().min,
Vec2::new(half, 44.0),
)),
|ui| {
ui.add_enabled_ui(!bk.password.is_empty(), |ui| {
if w::big_action(ui, &t!("goblin.settings.create_backup"), false)
.clicked()
{
match wallet.create_full_backup(&bk.password) {
Ok(envelope) => {
let stamp = chrono::Local::now().format("%Y-%m-%d-%H%M");
let fname = format!("GOBLIN-{stamp}.backup");
match cb.save_file(fname, envelope.into_bytes()) {
Ok(()) => {
bk.done = true;
bk.error = None;
bk.password.clear();
}
Err(_) => {
bk.error = Some(
t!("goblin.settings.backup_write_failed")
.to_string(),
);
}
}
}
Err(e) => bk.error = Some(e),
}
}
});
},
);
});
});
if close {
self.backup = None;
}
}
}
+453
View File
@@ -0,0 +1,453 @@
// 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.
//! Advanced settings page (recovery / repair / delete).
use super::*;
impl GoblinWalletView {
/// Advanced (wallet-recovery) page — GRIM's low-level tools surfaced in the
/// goblin style: repair, restore-from-seed, reveal the recovery phrase, and
/// delete. The two destructive actions arm a tap-twice confirm.
pub(super) fn advanced_ui(
&mut self,
ui: &mut egui::Ui,
wallet: &Wallet,
cb: &dyn PlatformCallbacks,
) {
use crate::wallet::types::ConnectionMethod;
let t = theme::tokens();
if self.sub_header(ui, &t!("goblin.advanced.title")) {
self.advanced = AdvancedState::default();
// Don't leave a half-entered backup password sitting in memory.
self.backup = None;
self.settings_page = SettingsPage::Main;
return;
}
// Repair needs a synced node; mirror GRIM's availability check.
let integrated = wallet.get_current_connection() == ConnectionMethod::Integrated;
let integrated_ready =
crate::node::Node::get_sync_status() == Some(grin_chain::SyncStatus::NoSync);
let repair_unavailable = wallet.sync_error() || (integrated && !integrated_ready);
let repairing = wallet.is_repairing();
let progress = wallet.repairing_progress();
let mut leave = false;
let mut open_node = false;
let mut open_integrated = false;
{
ScrollArea::vertical()
.id_salt("goblin_advanced_scroll")
.auto_shrink([false; 2])
.scroll_bar_visibility(egui::scroll_area::ScrollBarVisibility::AlwaysHidden)
.show(ui, |ui| {
// Borrow ends (NLL) at the last `adv.` use in the Nostr-key card;
// the .backup + Danger Zone sections below then use `self`.
let adv = &mut self.advanced;
ui.label(
RichText::new(t!("goblin.advanced.intro"))
.font(FontId::new(14.0, fonts::regular()))
.color(t.text_dim),
);
ui.add_space(16.0);
// Run your own node (the internal node). Opens the node-connection
// page, where you pick the integrated node or an external one.
w::card(ui, |ui| {
ui.set_min_width(ui.available_width());
advanced_head(ui, &t!("goblin.node.integrated"), t.surface_text);
advanced_desc(ui, &t!("goblin.advanced.own_node_desc"));
ui.add_space(10.0);
if wallet.get_current_connection() == ConnectionMethod::Integrated {
ui.label(
RichText::new(format!(
"{} {}",
crate::gui::icons::CHECK,
t!("goblin.advanced.own_node_active")
))
.font(FontId::new(13.0, fonts::medium()))
.color(t.pos),
);
ui.add_space(10.0);
}
if w::big_action_on_card(ui, &t!("goblin.advanced.manage_node")).clicked() {
open_node = true;
}
ui.add_space(10.0);
// GRIM's integrated-node tabs (info, metrics, mining with
// stratum, node settings) in Goblin chrome. Their ONE home
// (single-home rule): Goblin is the lighter client, so they
// live here under Advanced, not in main Settings.
let node_label = if crate::node::Node::is_running() {
format!(
"{} · {}",
t!("goblin.settings.integrated_node"),
crate::node::Node::get_sync_status_text()
)
} else {
t!("goblin.settings.integrated_node").to_string()
};
if w::big_action_on_card(ui, &node_label).clicked() {
open_integrated = true;
}
});
ui.add_space(12.0);
// Repair.
w::card(ui, |ui| {
ui.set_min_width(ui.available_width());
advanced_head(ui, &t!("goblin.advanced.repair"), t.surface_text);
advanced_desc(ui, &t!("goblin.advanced.repair_desc"));
ui.add_space(10.0);
if repairing {
ui.label(
RichText::new(
t!("goblin.advanced.repairing", pct => progress.to_string()),
)
.font(FontId::new(13.0, fonts::medium()))
.color(t.accent),
);
} else if repair_unavailable {
ui.label(
RichText::new(t!("goblin.advanced.repair_unavailable"))
.font(FontId::new(13.0, fonts::medium()))
.color(t.neg),
);
} else if adv.confirm_repair {
// Repair re-scans the chain — it can take a few minutes.
// Warn + confirm in the accent (yellow) before starting.
ui.label(
RichText::new(t!("goblin.advanced.repair_confirm_note"))
.font(FontId::new(13.0, fonts::regular()))
.color(t.text_dim),
);
ui.add_space(10.0);
if w::big_action_on_card_ink(
ui,
&t!("goblin.advanced.repair_confirm"),
t.accent,
)
.clicked()
{
adv.confirm_repair = false;
wallet.repair();
}
} else if w::big_action_on_card(ui, &t!("goblin.advanced.repair")).clicked()
{
adv.confirm_repair = true;
}
});
ui.add_space(12.0);
// Restore (rebuild local data from the seed).
w::card(ui, |ui| {
ui.set_min_width(ui.available_width());
advanced_head(ui, &t!("goblin.advanced.restore"), t.surface_text);
advanced_desc(ui, &t!("goblin.advanced.restore_desc"));
ui.add_space(10.0);
if adv.confirm_restore {
ui.label(
RichText::new(t!("goblin.advanced.restore_confirm_note"))
.font(FontId::new(13.0, fonts::regular()))
.color(t.text_dim),
);
ui.add_space(10.0);
if w::big_action_on_card_ink(
ui,
&t!("goblin.advanced.restore_confirm"),
t.neg,
)
.clicked()
{
wallet.delete_db();
leave = true;
}
} else if w::big_action_on_card(ui, &t!("goblin.advanced.restore"))
.clicked()
{
adv.confirm_restore = true;
}
});
ui.add_space(12.0);
// Recovery phrase (the grin seed words).
w::card(ui, |ui| {
ui.set_min_width(ui.available_width());
advanced_head(ui, &t!("goblin.advanced.show_phrase"), t.surface_text);
advanced_desc(ui, &t!("goblin.advanced.phrase_desc"));
ui.add_space(10.0);
if let Some(words) = adv.revealed.clone() {
w::field_well(ui, |ui| {
ui.label(
RichText::new(words)
.font(FontId::new(15.0, fonts::medium()))
.color(t.surface_text),
);
});
ui.add_space(10.0);
if w::big_action_on_card(ui, &t!("goblin.advanced.hide")).clicked() {
adv.revealed = None;
adv.reveal_pass.clear();
}
} else {
w::field_well(ui, |ui| {
TextEdit::new(egui::Id::from("advanced_reveal_pass"))
.focus(false)
.hint_text(t!("goblin.advanced.password"))
.password()
.text_color(t.surface_text)
.body()
.ui(ui, &mut adv.reveal_pass, cb);
});
if adv.wrong_pass {
ui.add_space(6.0);
ui.label(
RichText::new(t!("goblin.advanced.wrong_password"))
.font(FontId::new(13.0, fonts::medium()))
.color(t.neg),
);
}
ui.add_space(10.0);
ui.add_enabled_ui(!adv.reveal_pass.is_empty(), |ui| {
if w::big_action_on_card(ui, &t!("goblin.advanced.reveal"))
.clicked()
{
match wallet.get_recovery(adv.reveal_pass.clone()) {
Ok(phrase) => {
adv.revealed = Some(phrase.to_string());
adv.wrong_pass = false;
adv.reveal_pass.clear();
}
Err(_) => {
adv.wrong_pass = true;
}
}
}
});
}
});
ui.add_space(12.0);
// ── ADVANCED NOSTR SETTINGS ──────────────────────────────
// The Nostr key, and directly below it the .backup download.
w::kicker(ui, &t!("goblin.advanced.nostr_section"));
ui.add_space(8.0);
// Nostr key (nsec). Password-gated reveal, then Copy + a QR
// so it can be carried into a nostr app's private-key login
// (e.g. magick.market) without retyping. Same gate as the
// recovery phrase above.
w::card(ui, |ui| {
ui.set_min_width(ui.available_width());
advanced_head(ui, &t!("goblin.advanced.nostr_key"), t.surface_text);
advanced_desc(ui, &t!("goblin.advanced.nostr_key_desc"));
ui.add_space(10.0);
if let Some(nsec) = adv.nsec_revealed.clone() {
w::field_well(ui, |ui| {
ui.label(
RichText::new(&nsec)
.font(FontId::new(14.0, fonts::medium()))
.color(t.surface_text),
);
});
ui.add_space(10.0);
if w::big_action_on_card(ui, &t!("goblin.advanced.copy_nsec")).clicked()
{
// Secret: auto-clears from the clipboard after a delay
// (compare-then-clear) so it does not linger there.
cb.copy_secret_to_buffer(nsec.clone());
}
ui.add_space(8.0);
let qr_label = if adv.nsec_qr {
t!("goblin.advanced.hide_qr")
} else {
t!("goblin.advanced.show_qr")
};
if w::big_action_on_card(ui, &qr_label).clicked() {
adv.nsec_qr = !adv.nsec_qr;
}
if adv.nsec_qr {
ui.add_space(10.0);
ui.vertical_centered(|ui| {
w::qr_code(ui, &nsec, 220.0);
});
}
ui.add_space(10.0);
if w::big_action_on_card(ui, &t!("goblin.advanced.hide")).clicked() {
adv.nsec_revealed = None;
adv.nsec_qr = false;
adv.nsec_pass.clear();
}
} else {
w::field_well(ui, |ui| {
TextEdit::new(egui::Id::from("advanced_nsec_pass"))
.focus(false)
.hint_text(t!("goblin.advanced.password"))
.password()
.text_color(t.surface_text)
.body()
.ui(ui, &mut adv.nsec_pass, cb);
});
if adv.nsec_wrong {
ui.add_space(6.0);
ui.label(
RichText::new(t!("goblin.advanced.wrong_password"))
.font(FontId::new(13.0, fonts::medium()))
.color(t.neg),
);
}
ui.add_space(10.0);
ui.add_enabled_ui(!adv.nsec_pass.is_empty(), |ui| {
if w::big_action_on_card(ui, &t!("goblin.advanced.reveal_nsec"))
.clicked()
{
match wallet.get_nostr_nsec(adv.nsec_pass.clone()) {
Ok(nsec) => {
adv.nsec_revealed = Some(nsec);
adv.nsec_wrong = false;
adv.nsec_pass.clear();
}
Err(_) => {
adv.nsec_wrong = true;
}
}
}
});
}
});
ui.add_space(12.0);
// .backup download — directly under the Nostr key, the second
// half of the ADVANCED NOSTR SETTINGS group. One button, no
// checklist: it seals your CURRENT identity (key + username)
// into an encrypted .backup file. (`adv` is no longer used past
// here, so `self` is free again.)
w::card(ui, |ui| {
ui.set_min_width(ui.available_width());
advanced_head(ui, &t!("goblin.settings.backup_file_title"), t.surface_text);
advanced_desc(ui, &t!("goblin.advanced.backup_caption"));
ui.add_space(10.0);
if w::big_action_on_card(ui, &t!("goblin.settings.backup_file")).clicked()
&& self.backup.is_none()
{
self.backup = Some(BackupState::default());
}
});
// The password/seal form, anchored here unless it was opened from
// the Danger Zone delete flow below.
if self.backup.as_ref().is_some_and(|b| !b.anchor_delete) {
ui.add_space(8.0);
self.backup_ui(ui, wallet, cb);
}
ui.add_space(16.0);
// ── DANGER ZONE ──────────────────────────────────────────
// Delete the wallet — password-gated, with a back-up prompt.
w::kicker_danger(ui, &t!("goblin.advanced.danger_zone"));
ui.add_space(8.0);
w::card(ui, |ui| {
ui.set_min_width(ui.available_width());
advanced_head(ui, &t!("goblin.advanced.delete"), t.neg);
advanced_desc(ui, &t!("goblin.advanced.delete_desc"));
ui.add_space(10.0);
if self.advanced.confirm_delete {
ui.label(
RichText::new(t!("goblin.advanced.delete_warning"))
.font(FontId::new(13.0, fonts::regular()))
.color(t.neg),
);
ui.add_space(10.0);
// Back up BEFORE the password field (spec) — the same seal
// action, but anchored to this flow so its form renders
// here, not up in the nostr section.
if w::big_action_on_card(ui, &t!("goblin.advanced.download_backup"))
.clicked() && self.backup.is_none()
{
self.backup = Some(BackupState {
anchor_delete: true,
..Default::default()
});
}
if self.backup.as_ref().is_some_and(|b| b.anchor_delete) {
self.backup_ui(ui, wallet, cb);
ui.add_space(10.0);
}
w::field_well(ui, |ui| {
TextEdit::new(egui::Id::from("advanced_delete_pass"))
.focus(false)
.hint_text(t!("goblin.advanced.password"))
.password()
.text_color(t.surface_text)
.body()
.ui(ui, &mut self.advanced.delete_pass, cb);
});
if self.advanced.delete_wrong {
ui.add_space(6.0);
ui.label(
RichText::new(t!("goblin.advanced.wrong_password"))
.font(FontId::new(13.0, fonts::medium()))
.color(t.neg),
);
}
ui.add_space(10.0);
let adv = &mut self.advanced;
ui.add_enabled_ui(!adv.delete_pass.is_empty(), |ui| {
if w::big_action_on_card_ink(
ui,
&t!("goblin.advanced.delete_final"),
t.neg,
)
.clicked()
{
// Wallet-password gate: get_recovery only returns Ok
// when the password decrypts the seed.
if wallet.get_recovery(adv.delete_pass.clone()).is_ok() {
wallet.delete_wallet();
leave = true;
} else {
adv.delete_wrong = true;
}
}
});
} else if w::big_action_on_card_ink(
ui,
&t!("goblin.advanced.delete"),
t.neg,
)
.clicked()
{
self.advanced.confirm_delete = true;
}
});
ui.add_space(20.0);
});
}
if leave {
self.advanced = AdvancedState::default();
self.backup = None;
self.settings_page = SettingsPage::Main;
}
if open_node {
// Advanced → "Manage node connection" opens Goblin's own Node screen.
self.node_url_input.clear();
self.node_secret_input.clear();
self.settings_page = SettingsPage::Node;
}
if open_integrated {
// Advanced is the one home of the integrated-node tabs; back
// returns here.
self.node_tab = Box::new(crate::gui::views::network::NetworkNode);
self.node_tab_back = SettingsPage::Advanced;
self.settings_page = SettingsPage::IntegratedNode;
}
}
}
+483
View File
@@ -0,0 +1,483 @@
// 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.
//! Node and connectivity screens.
use super::*;
impl GoblinWalletView {
/// GRIM's four integrated-node tabs (Info / Metrics / Mining / Settings)
/// hosted under a Goblin back header and segmented control — GRIM's
/// dual-panel and floating-navbar chrome are never rendered. The header
/// title follows the active tab, like GRIM's own title panel.
pub(super) fn integrated_node_ui(&mut self, ui: &mut egui::Ui, cb: &dyn PlatformCallbacks) {
use crate::gui::icons::{DATABASE, FACTORY, FADERS, GAUGE};
use crate::gui::views::network::types::NodeTabType;
use crate::gui::views::network::{
NetworkContent, NetworkMetrics, NetworkMining, NetworkNode, NetworkSettings,
disabled_node_ui, node_error_ui,
};
use crate::node::Node;
let title = self.node_tab.get_type().title();
if self.sub_header(ui, &title) {
self.settings_page = self.node_tab_back;
return;
}
let selected = match self.node_tab.get_type() {
NodeTabType::Info => 0,
NodeTabType::Metrics => 1,
NodeTabType::Mining => 2,
NodeTabType::Settings => 3,
};
if let Some(i) = w::segmented(ui, &[DATABASE, GAUGE, FACTORY, FADERS], selected) {
self.node_tab = match i {
0 => Box::new(NetworkNode),
1 => Box::new(NetworkMetrics),
2 => Box::new(NetworkMining::default()),
_ => Box::new(NetworkSettings::default()),
};
}
ui.add_space(12.0);
// Same availability gate as GRIM's NetworkContent: the Settings tab is
// editable with the node off; the live tabs need a running node with
// stats before their content can draw.
if self.node_tab.get_type() != NodeTabType::Settings {
if let Some(err) = Node::get_error() {
node_error_ui(ui, err);
} else if !Node::is_running() {
disabled_node_ui(ui);
} else if Node::get_stats().is_none() || Node::is_restarting() || Node::is_stopping() {
NetworkContent::loading_ui(ui, None::<String>);
} else {
self.node_tab.tab_ui(ui, cb);
}
} else {
self.node_tab.tab_ui(ui, cb);
}
// Keep the stats fresh while the node runs.
if Node::is_running() {
ui.ctx().request_repaint_after(Node::STATS_UPDATE_DELAY);
}
}
pub(super) fn node_settings_ui(
&mut self,
ui: &mut egui::Ui,
wallet: &Wallet,
cb: &dyn PlatformCallbacks,
) {
use crate::wallet::types::ConnectionMethod;
use crate::wallet::{ConnectionsConfig, ExternalConnection};
let t = theme::tokens();
if self.sub_header(ui, &t!("goblin.node.title")) {
self.settings_page = SettingsPage::Main;
return;
}
ScrollArea::vertical()
.id_salt("goblin_node_scroll")
.auto_shrink([false; 2])
.scroll_bar_visibility(egui::scroll_area::ScrollBarVisibility::AlwaysHidden)
.show(ui, |ui| {
let live = wallet.get_current_connection();
let saved = wallet.get_config().connection();
settings_group(ui, &t!("goblin.node.connection"), |ui| {
// Integrated node (run your own) sits at the top of the picker.
{
let active = matches!(&saved, ConnectionMethod::Integrated);
let row = ui.horizontal(|ui| {
ui.label(
RichText::new(t!("goblin.node.integrated"))
.font(FontId::new(15.0, fonts::medium()))
.color(t.surface_text),
);
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
if active {
ui.label(
RichText::new(crate::gui::icons::CHECK)
.font(FontId::new(16.0, fonts::regular()))
.color(t.pos),
);
}
});
});
ui.add_space(10.0);
if !active && row.response.interact(Sense::click()).clicked() {
wallet.update_connection(&ConnectionMethod::Integrated);
// Apply to the running session now, not on next unlock.
wallet.reconnect_node();
}
}
for conn in ConnectionsConfig::ext_conn_list() {
let active =
matches!(&saved, ConnectionMethod::External(id, _) if *id == conn.id);
let label = conn.url.replace("https://", "").replace("http://", "");
let mut removed = false;
let row = ui.horizontal(|ui| {
ui.label(
RichText::new(&label)
.font(FontId::new(15.0, fonts::medium()))
.color(t.surface_text),
);
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
if active {
ui.label(
RichText::new(crate::gui::icons::CHECK)
.font(FontId::new(16.0, fonts::regular()))
.color(t.pos),
);
} else {
// Trash, not an X: a grey × next to the active row's
// green check read as a failed-status icon, not the
// remove action it actually is.
let x = ui.label(
RichText::new(crate::gui::icons::TRASH_SIMPLE)
.font(FontId::new(15.0, fonts::regular()))
.color(t.surface_text_dim),
);
if x.interact(Sense::click()).clicked() {
ConnectionsConfig::remove_ext_conn(conn.id);
removed = true;
}
}
});
});
ui.add_space(10.0);
if !removed && !active && row.response.interact(Sense::click()).clicked() {
wallet.update_connection(&ConnectionMethod::External(
conn.id,
conn.url.clone(),
));
// Apply to the running session now, not on next unlock.
wallet.reconnect_node();
}
}
});
if saved != live {
ui.add_space(8.0);
ui.label(
RichText::new(t!("goblin.node.applies_after"))
.font(FontId::new(13.0, fonts::regular()))
.color(t.text_dim),
);
}
ui.add_space(16.0);
settings_group(ui, &t!("goblin.node.add_external"), |ui| {
TextEdit::new(egui::Id::from("set_node_url"))
.focus(false)
.hint_text("https://node.example.com:3413")
.text_color(t.surface_text)
.body()
.ui(ui, &mut self.node_url_input, cb);
ui.add_space(8.0);
TextEdit::new(egui::Id::from("set_node_secret"))
.focus(false)
.hint_text(t!("goblin.node.api_secret_hint"))
.text_color(t.surface_text)
.body()
.ui(ui, &mut self.node_secret_input, cb);
});
ui.add_space(10.0);
let url = self.node_url_input.trim().to_string();
let valid = url.starts_with("http://") || url.starts_with("https://");
if w::big_action(ui, &t!("goblin.node.add_node"), false).clicked() && valid {
let secret = {
let s = self.node_secret_input.trim();
if s.is_empty() {
None
} else {
Some(s.to_string())
}
};
let conn = ExternalConnection::new(url, None, secret);
ConnectionsConfig::add_ext_conn(conn.clone());
wallet
.update_connection(&ConnectionMethod::External(conn.id, conn.url.clone()));
// Apply to the running session now, not on next unlock.
wallet.reconnect_node();
self.node_url_input.clear();
self.node_secret_input.clear();
}
// (The integrated-node tabs' single home is Settings → Advanced;
// no duplicate entry here.)
ui.add_space(16.0);
});
}
/// Relay list editor; saving restarts the nostr service live.
pub(super) fn relays_ui(
&mut self,
ui: &mut egui::Ui,
wallet: &Wallet,
cb: &dyn PlatformCallbacks,
) {
let t = theme::tokens();
if self.sub_header(ui, &t!("goblin.relays.title")) {
self.settings_page = SettingsPage::Main;
return;
}
ScrollArea::vertical()
.id_salt("goblin_relays_scroll")
.auto_shrink([false; 2])
.scroll_bar_visibility(egui::scroll_area::ScrollBarVisibility::AlwaysHidden)
.show(ui, |ui| {
ui.label(
RichText::new(t!("goblin.relays.intro"))
.font(FontId::new(13.0, fonts::regular()))
.color(t.text_dim),
);
ui.add_space(14.0);
settings_group(ui, &t!("goblin.relays.your_relays"), |ui| {
let mut remove: Option<usize> = None;
let many = self.relay_edit.len() > 1;
for (i, relay) in self.relay_edit.iter().enumerate() {
ui.horizontal(|ui| {
ui.label(
RichText::new(relay)
.font(FontId::new(14.0, fonts::medium()))
.color(t.surface_text),
);
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
if many {
let x = ui.label(
RichText::new(crate::gui::icons::X)
.font(FontId::new(15.0, fonts::regular()))
.color(t.surface_text_mute),
);
if x.interact(Sense::click()).clicked() {
remove = Some(i);
}
}
});
});
ui.add_space(10.0);
}
if let Some(i) = remove {
self.relay_edit.remove(i);
}
});
ui.add_space(16.0);
settings_group(ui, &t!("goblin.relays.add_relay"), |ui| {
TextEdit::new(egui::Id::from("set_relay"))
.focus(false)
.hint_text("wss://relay.example.com")
.text_color(t.surface_text)
.body()
.ui(ui, &mut self.relay_input, cb);
});
ui.add_space(10.0);
let relay = self.relay_input.trim().to_string();
let valid = relay.starts_with("wss://") || relay.starts_with("ws://");
if w::big_action_on_card(ui, &t!("goblin.relays.add_relay_btn")).clicked()
&& valid && !self.relay_edit.contains(&relay)
{
self.relay_edit.push(relay);
self.relay_input.clear();
}
ui.add_space(10.0);
if w::big_action(ui, &t!("goblin.relays.save_reconnect"), false).clicked() {
if let Some(s) = wallet.nostr_service() {
{
let mut c = s.config.write();
c.set_relays(self.relay_edit.clone());
c.save();
}
s.restart(wallet.clone());
}
self.settings_page = SettingsPage::Main;
}
ui.add_space(16.0);
});
}
/// Manual slatepack exchange — GRIM's native by-hand flow, exposed as an
/// advanced fallback for when a payment can't ride a @username.
pub(super) fn slatepack_ui(
&mut self,
ui: &mut egui::Ui,
wallet: &Wallet,
cb: &dyn PlatformCallbacks,
) {
let t = theme::tokens();
if self.sub_header(ui, &t!("goblin.settings.slatepacks")) {
self.settings_page = SettingsPage::Main;
return;
}
ScrollArea::vertical()
.id_salt("goblin_slatepack_scroll")
.auto_shrink([false; 2])
.scroll_bar_visibility(egui::scroll_area::ScrollBarVisibility::AlwaysHidden)
.show(ui, |ui| {
ui.label(
RichText::new(t!("goblin.settings.sp_intro"))
.font(FontId::new(13.0, fonts::regular()))
.color(t.text_dim),
);
ui.add_space(14.0);
// Receive / continue: paste a slatepack, let the wallet route it.
let mut do_process = false;
settings_group(ui, &t!("goblin.settings.sp_receive_group"), |ui| {
ui.label(
RichText::new(t!("goblin.settings.sp_receive_blurb"))
.font(FontId::new(12.5, fonts::regular()))
.color(t.surface_text_dim),
);
ui.add_space(8.0);
TextEdit::new(egui::Id::from("sp_paste"))
.focus(false)
.paste()
.hint_text("BEGINSLATEPACK. … ENDSLATEPACK.")
.text_color(t.surface_text)
.body()
.ui(ui, &mut self.slatepack.paste, cb);
});
ui.add_space(10.0);
if w::big_action(ui, &t!("goblin.settings.sp_process"), false).clicked() {
do_process = true;
}
if do_process {
let text = self.slatepack.paste.trim().to_string();
if text.is_empty() {
self.slatepack.error =
Some(t!("goblin.settings.sp_paste_first").to_string());
self.slatepack.status = None;
} else {
use crate::wallet::types::ManualSlatepackOutcome as Out;
match wallet.manual_process_slatepack(&text) {
Ok(Out::Response(reply)) => {
self.slatepack.result = reply;
self.slatepack.status =
Some(t!("goblin.settings.sp_reply_ready").to_string());
self.slatepack.error = None;
self.slatepack.paste.clear();
}
Ok(Out::Finalizing) => {
self.slatepack.result.clear();
self.slatepack.status =
Some(t!("goblin.settings.sp_finalizing").to_string());
self.slatepack.error = None;
self.slatepack.paste.clear();
}
Err(e) => {
self.slatepack.error = Some(e.to_string());
self.slatepack.status = None;
}
}
}
}
// Send: create a slatepack to hand over out-of-band.
ui.add_space(16.0);
let mut do_send = false;
settings_group(ui, &t!("goblin.settings.sp_create_group"), |ui| {
ui.label(
RichText::new(t!("goblin.settings.sp_create_blurb"))
.font(FontId::new(12.5, fonts::regular()))
.color(t.surface_text_dim),
);
ui.add_space(8.0);
TextEdit::new(egui::Id::from("sp_amount"))
.focus(false)
.numeric()
.hint_text(t!("goblin.settings.sp_amount_hint"))
.text_color(t.surface_text)
.body()
.ui(ui, &mut self.slatepack.amount, cb);
ui.add_space(8.0);
TextEdit::new(egui::Id::from("sp_addr"))
.focus(false)
.hint_text(t!("goblin.settings.sp_addr_hint"))
.text_color(t.surface_text)
.body()
.ui(ui, &mut self.slatepack.address, cb);
});
ui.add_space(10.0);
if w::big_action(ui, &t!("goblin.settings.sp_create"), false).clicked() {
do_send = true;
}
if do_send {
match grin_core::core::amount_from_hr_string(self.slatepack.amount.trim()) {
Ok(a) if a > 0 => {
let s = self.slatepack.address.trim();
let dest = if s.is_empty() {
None
} else {
Some(s.to_string())
};
match wallet.manual_send_slatepack(a, dest) {
Ok(text) => {
self.slatepack.result = text;
self.slatepack.status =
Some(t!("goblin.settings.sp_ready").to_string());
self.slatepack.error = None;
}
Err(e) => {
self.slatepack.error = Some(e.to_string());
self.slatepack.status = None;
}
}
}
_ => {
self.slatepack.error =
Some(t!("goblin.settings.sp_amount_gt_zero").to_string());
self.slatepack.status = None;
}
}
}
// Status, error, and the produced slatepack (copyable).
if let Some(err) = self.slatepack.error.clone() {
ui.add_space(10.0);
ui.label(
RichText::new(err)
.font(FontId::new(13.0, fonts::regular()))
.color(t.neg),
);
}
if let Some(status) = self.slatepack.status.clone() {
ui.add_space(10.0);
ui.label(
RichText::new(status)
.font(FontId::new(13.0, fonts::regular()))
.color(t.text_dim),
);
}
let result = self.slatepack.result.clone();
if !result.is_empty() {
ui.add_space(14.0);
settings_group(ui, &t!("goblin.settings.sp_to_send"), |ui| {
let preview: String = result.chars().take(120).collect();
let preview = if result.chars().count() > 120 {
format!("{preview}")
} else {
preview
};
ui.label(
RichText::new(preview)
.font(FontId::new(12.0, fonts::mono()))
.color(t.surface_text_dim),
);
});
ui.add_space(10.0);
if w::big_action(ui, &t!("goblin.settings.sp_copy"), false).clicked() {
cb.copy_string_to_buffer(result);
cb.vibrate_copy();
}
}
ui.add_space(16.0);
});
}
}
+567
View File
@@ -0,0 +1,567 @@
// 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.
//! Username / name-authority settings and the claim flow.
use super::*;
/// Map an availability probe to user-facing state: `None` availability
/// means the check itself failed — never present that as "Taken".
pub(super) fn availability_feedback(
avail: crate::nostr::nip05::Availability,
) -> (Option<bool>, String) {
use crate::nostr::nip05::Availability::*;
match avail {
Available => (
Some(true),
t!("goblin.settings.avail_available").to_string(),
),
Taken => (Some(false), t!("goblin.settings.avail_taken").to_string()),
Reserved => (
Some(false),
t!("goblin.settings.avail_reserved").to_string(),
),
Invalid => (Some(false), t!("goblin.settings.avail_invalid").to_string()),
Quarantined => (
Some(false),
t!("goblin.settings.avail_quarantined").to_string(),
),
Unknown => (None, t!("goblin.settings.avail_unknown").to_string()),
}
}
/// Spawn the combined claim: availability check first, then registration
/// in the same worker — one button, no separate Check step.
pub(super) fn start_claim_flow(claim: &mut ClaimState, name: &str, wallet: &Wallet) {
let Some(service) = wallet.nostr_service() else {
return;
};
let server = service.config.read().nip05_server();
// Reuse the service's keys directly — never round-trip the secret through a
// plaintext nsec String to rebuild keys the service already holds.
let keys = service.keys();
claim.checking = true;
claim.message = None;
claim.available = None;
let slot = claim.result.clone();
let name = name.to_string();
std::thread::spawn(move || {
let rt = match tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
{
Ok(rt) => rt,
Err(_) => return,
};
let msg = rt.block_on(async {
use crate::nostr::nip05::{Availability, RegisterResult, check_availability, register};
match check_availability(&server, &name).await {
Availability::Available => match register(&server, &name, &keys).await {
RegisterResult::Ok(nip05) => ClaimMsg::Registered(nip05),
RegisterResult::Conflict(_) => {
ClaimMsg::Error(t!("goblin.settings.err_just_taken").to_string())
}
RegisterResult::Rejected(e) if e == "name_change_cooldown" => {
ClaimMsg::Error(t!("goblin.settings.err_cooldown").to_string())
}
RegisterResult::Rejected(e) => ClaimMsg::Error(e),
RegisterResult::Network => {
ClaimMsg::Error(t!("goblin.settings.err_unreachable").to_string())
}
},
other => ClaimMsg::Availability(other),
}
});
*slot.lock().unwrap() = Some(msg);
});
}
/// Spawn the username release; the server deletes its avatar with it.
pub(super) fn start_release(claim: &mut ClaimState, name: &str, wallet: &Wallet) {
let Some(service) = wallet.nostr_service() else {
return;
};
let server = service.config.read().nip05_server();
// Reuse the service's keys directly — never round-trip the secret through a
// plaintext nsec String to rebuild keys the service already holds.
let keys = service.keys();
claim.checking = true;
claim.message = None;
let slot = claim.result.clone();
let name = name.to_string();
std::thread::spawn(move || {
let rt = match tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
{
Ok(rt) => rt,
Err(_) => return,
};
// Release is always allowed server-side (it's what arms the cooldown),
// so there's no cooldown rejection to handle here.
let msg = match rt.block_on(crate::nostr::nip05::unregister(&server, &name, &keys)) {
Ok(()) => ClaimMsg::Released,
Err(e) => ClaimMsg::Error(t!("goblin.settings.err_release", err => e).to_string()),
};
*slot.lock().unwrap() = Some(msg);
});
}
impl GoblinWalletView {
/// Username page — the single home for everything name-related: claim one if
/// you have none, release the one you own, and choose the name authority from
/// a known list or by free-typing a custom server. Reuses [`claim_ui`] for the
/// claim/release card; the authority controls live only here.
pub(super) fn username_ui(
&mut self,
ui: &mut egui::Ui,
wallet: &Wallet,
cb: &dyn PlatformCallbacks,
) {
let t = theme::tokens();
if self.sub_header(ui, &t!("goblin.username.title")) {
self.settings_page = SettingsPage::Main;
return;
}
if self.claim.is_none() {
self.claim = Some(ClaimState::default());
}
if self.name_authority.is_none() {
let cur = wallet
.nostr_service()
.map(|s| s.config.read().nip05_server())
.unwrap_or_default();
self.name_authority = Some(NameAuthorityState {
input: cur,
error: None,
});
}
ScrollArea::vertical()
.id_salt("goblin_username_scroll")
.auto_shrink([false; 2])
.scroll_bar_visibility(egui::scroll_area::ScrollBarVisibility::AlwaysHidden)
.show(ui, |ui| {
// Name authority first: pick where your name lives, then claim
// it on that authority below.
w::kicker(ui, &t!("goblin.username.authority"));
ui.add_space(8.0);
ui.label(
RichText::new(t!("goblin.username.authority_blurb"))
.font(FontId::new(13.0, fonts::regular()))
.color(t.text_dim),
);
ui.add_space(6.0);
// "Learn more" opens the name-authority docs chapter in the
// browser (same open_url idiom used elsewhere in Settings).
let learn = ui
.add(
egui::Label::new(
RichText::new(t!("goblin.username.learn_more"))
.font(FontId::new(13.0, fonts::semibold()))
.color(t.accent),
)
.sense(Sense::click()),
)
.on_hover_cursor(egui::CursorIcon::PointingHand);
if learn.clicked() {
open_url(ui, "https://docs.goblin.st/features/name-authority.html");
}
ui.add_space(10.0);
let cur_server = wallet
.nostr_service()
.map(|s| s.config.read().nip05_server())
.unwrap_or_default();
let norm = |u: &str| u.trim().trim_end_matches('/').to_lowercase();
// Known authorities: a tap sets the server. Free-type handles the rest.
let mut chosen: Option<String> = None;
w::card(ui, |ui| {
for (label, url) in KNOWN_AUTHORITIES {
let active = norm(&cur_server) == norm(url);
let row = ui.horizontal(|ui| {
ui.vertical(|ui| {
ui.label(
RichText::new(*label)
.font(FontId::new(15.0, fonts::medium()))
.color(t.surface_text),
);
ui.label(
RichText::new(url.replace("https://", ""))
.font(FontId::new(12.5, fonts::regular()))
.color(t.surface_text_dim),
);
});
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() {
chosen = Some((*url).to_string());
}
}
});
if let Some(url) = chosen {
if let Some(s) = wallet.nostr_service() {
s.config.write().set_nip05_server(Some(url));
crate::nostr::nip05::set_home_domain(&s.config.read().home_domain());
}
if let Some(na) = self.name_authority.as_mut() {
na.input = wallet
.nostr_service()
.map(|s| s.config.read().nip05_server())
.unwrap_or_default();
na.error = None;
}
}
ui.add_space(14.0);
// Free-typed custom authority + Reset/Save.
let (save, reset, input) = {
let na = self.name_authority.as_mut().unwrap();
ui.label(
RichText::new(t!("goblin.username.custom"))
.font(FontId::new(13.0, fonts::medium()))
.color(t.text_dim),
);
ui.add_space(6.0);
w::field_well(ui, |ui| {
TextEdit::new(egui::Id::from("username_authority_input"))
.focus(false)
.hint_text("https://goblin.st")
.text_color(t.surface_text)
.body()
.ui(ui, &mut na.input, cb);
});
if let Some(err) = &na.error {
ui.add_space(6.0);
ui.label(
RichText::new(err)
.font(FontId::new(12.5, fonts::regular()))
.color(t.neg),
);
}
ui.add_space(10.0);
let mut save = false;
let mut reset = 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, 44.0),
)),
|ui| {
if w::big_action_on_card(ui, &t!("goblin.settings.reset")).clicked()
{
reset = true;
}
},
);
ui.add_space(10.0);
ui.scope_builder(
egui::UiBuilder::new().max_rect(egui::Rect::from_min_size(
ui.cursor().min,
Vec2::new(half, 44.0),
)),
|ui| {
if w::big_action(ui, &t!("goblin.settings.save"), false).clicked() {
save = true;
}
},
);
});
(save, reset, na.input.trim().to_string())
};
if reset {
if let Some(s) = wallet.nostr_service() {
s.config.write().set_nip05_server(None);
crate::nostr::nip05::set_home_domain(&s.config.read().home_domain());
}
if let Some(na) = self.name_authority.as_mut() {
na.input = wallet
.nostr_service()
.map(|s| s.config.read().nip05_server())
.unwrap_or_default();
na.error = None;
}
}
if save {
if !input.starts_with("https://") && !input.starts_with("http://") {
if let Some(na) = self.name_authority.as_mut() {
na.error =
Some(t!("goblin.settings.name_authority_invalid").to_string());
}
} else if let Some(s) = wallet.nostr_service() {
s.config.write().set_nip05_server(Some(input));
crate::nostr::nip05::set_home_domain(&s.config.read().home_domain());
if let Some(na) = self.name_authority.as_mut() {
na.error = None;
}
}
}
ui.add_space(18.0);
// Claim / release + the owned-name display.
self.claim_ui(ui, wallet, cb);
ui.add_space(16.0);
});
}
/// Inline username-claim widget (availability check + registration).
pub(super) fn claim_ui(
&mut self,
ui: &mut egui::Ui,
wallet: &Wallet,
cb: &dyn PlatformCallbacks,
) {
let t = theme::tokens();
// Poll the worker result; avatar invalidation happens after the
// claim borrow is released.
let mut invalidate_avatar: Option<String> = None;
{
let claim = self.claim.as_mut().unwrap();
if let Some(msg) = claim.result.lock().unwrap().take() {
claim.checking = false;
match msg {
ClaimMsg::Availability(avail) => {
let (available, msg) = availability_feedback(avail);
claim.available = available;
claim.message = Some(msg.to_string());
}
ClaimMsg::Registered(nip05) => {
let name = nip05.split('@').next().unwrap_or("").to_string();
claim.message =
Some(t!("goblin.settings.registered", name => name).to_string());
claim.available = Some(true);
claim.input.clear();
// Persist nip05 on the identity and republish.
if let Some(s) = wallet.nostr_service() {
{
let mut id = s.identity.write();
id.nip05 = Some(nip05.clone());
id.anonymous = false;
}
s.save_identity();
}
// Publish kind 0 NOW so others can resolve our @name without
// waiting for the next app start — otherwise a just-claimed
// name is invisible over the relays (no kind-0 event exists).
wallet.task(crate::wallet::types::WalletTask::NostrRepublishProfile);
}
ClaimMsg::Released => {
claim.message = Some(t!("goblin.settings.released_msg").to_string());
claim.available = None;
claim.confirm_release = false;
if let Some(s) = wallet.nostr_service() {
let name = {
let mut id = s.identity.write();
let n = id.nip05.take();
id.anonymous = true;
n
};
s.save_identity();
invalidate_avatar =
name.map(|n| n.split('@').next().unwrap_or("").to_string());
}
}
ClaimMsg::Error(e) => {
claim.available = Some(false);
claim.message = Some(e);
}
}
}
}
if let Some(name) = invalidate_avatar {
self.avatars.invalidate(&name);
}
let claim = self.claim.as_mut().unwrap();
let registered: Option<String> = wallet
.nostr_service()
.and_then(|s| s.identity.read().nip05.clone())
.map(|n| n.split('@').next().unwrap_or("").to_string());
w::card(ui, |ui| {
ui.set_min_width(ui.available_width());
if let Some(name) = registered {
if claim.confirm_release {
// The are-you-sure gate.
ui.label(
RichText::new(t!("goblin.settings.release_confirm", name => name))
.font(FontId::new(15.0, fonts::semibold()))
.color(t.surface_text),
);
ui.add_space(4.0);
ui.label(
RichText::new(t!("goblin.settings.release_blurb"))
.font(FontId::new(12.5, fonts::regular()))
.color(t.surface_text_dim),
);
ui.add_space(10.0);
if claim.checking {
ui.horizontal(|ui| {
View::small_loading_spinner(ui);
ui.add_space(8.0);
ui.label(
RichText::new(t!("goblin.settings.releasing"))
.color(t.surface_text_dim),
);
});
ui.ctx().request_repaint();
} else {
ui.horizontal(|ui| {
let half = (ui.available_width() - 10.0) / 2.0;
ui.scope_builder(
egui::UiBuilder::new().max_rect(egui::Rect::from_min_size(
ui.cursor().min,
Vec2::new(half, 44.0),
)),
|ui| {
if w::big_action_on_card_ink(
ui,
&t!("goblin.settings.keep_it"),
t.surface_text,
)
.clicked()
{
claim.confirm_release = false;
}
},
);
ui.add_space(10.0);
ui.scope_builder(
egui::UiBuilder::new().max_rect(egui::Rect::from_min_size(
ui.cursor().min,
Vec2::new(half, 44.0),
)),
|ui| {
if w::big_action_on_card_ink(
ui,
&t!("goblin.settings.release_it"),
t.neg,
)
.clicked()
{
start_release(claim, &name, wallet);
}
},
);
});
}
} else {
ui.label(
RichText::new(t!("goblin.settings.username"))
.font(FontId::new(15.0, fonts::semibold()))
.color(t.surface_text),
);
ui.add_space(4.0);
ui.label(
RichText::new(name.to_string())
.font(FontId::new(20.0, fonts::bold()))
.color(t.surface_text),
);
ui.add_space(4.0);
ui.label(
RichText::new(t!("goblin.settings.username_note"))
.font(FontId::new(12.0, fonts::regular()))
.color(t.surface_text_mute),
);
if let Some(msg) = &claim.message {
ui.add_space(6.0);
ui.label(
RichText::new(msg)
.font(FontId::new(13.0, fonts::regular()))
.color(match claim.available {
Some(false) => t.neg,
Some(true) => t.pos,
None => t.surface_text_dim,
}),
);
}
ui.add_space(10.0);
if w::big_action_on_card_ink(ui, &t!("goblin.settings.release_username"), t.neg)
.clicked()
{
claim.confirm_release = true;
claim.message = None;
}
}
} else {
ui.label(
RichText::new(t!("goblin.settings.pick_username"))
.font(FontId::new(15.0, fonts::semibold()))
.color(t.surface_text),
);
ui.add_space(8.0);
// Placeholder shows the bare handle ("yourname") — we never display
// the "@" to users. A leading "@" the user happens to type is still
// stripped when the name is read below.
let before = claim.input.clone();
TextEdit::new(egui::Id::from("settings_claim"))
.focus(false)
.hint_text(t!("goblin.onboarding.identity.username_field_hint"))
.text_color(t.surface_text)
.body()
.ui(ui, &mut claim.input, cb);
if claim.input != before {
claim.available = None;
claim.message = None;
}
ui.add_space(4.0);
ui.label(
RichText::new(t!("goblin.settings.username_note"))
.font(FontId::new(12.0, fonts::regular()))
.color(t.surface_text_mute),
);
if let Some(msg) = &claim.message {
ui.add_space(6.0);
ui.label(
RichText::new(msg)
.font(FontId::new(13.0, fonts::regular()))
.color(match claim.available {
Some(false) => t.neg,
Some(true) => t.pos,
None => t.surface_text_dim,
}),
);
}
ui.add_space(10.0);
let name = claim.input.trim().trim_start_matches('@').to_lowercase();
let valid = name.len() >= 3 && name.len() <= 20;
if claim.checking {
ui.horizontal(|ui| {
View::small_loading_spinner(ui);
ui.add_space(8.0);
ui.label(
RichText::new(t!("goblin.settings.working")).color(t.surface_text_dim),
);
});
ui.ctx().request_repaint();
} else {
ui.add_enabled_ui(valid, |ui| {
if w::big_action(ui, &t!("goblin.settings.claim"), false).clicked() {
start_claim_flow(claim, &name, wallet);
}
});
}
}
});
}
}