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:
@@ -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, ¬e, 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
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
+23
-6949
File diff suppressed because it is too large
Load Diff
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
¬e,
|
||||
&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
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user