From de0c8737f3a1632d5a11a06f7c142a97db19e664 Mon Sep 17 00:00:00 2001 From: 2ro <17595647+2ro@users.noreply.github.com> Date: Thu, 9 Jul 2026 21:17:08 -0400 Subject: [PATCH] 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. --- src/gui/views/goblin/activity.rs | 509 ++ src/gui/views/goblin/format.rs | 270 + src/gui/views/goblin/helpers.rs | 332 + src/gui/views/goblin/home.rs | 348 + src/gui/views/goblin/identities.rs | 740 +++ src/gui/views/goblin/me.rs | 566 ++ src/gui/views/goblin/mod.rs | 6972 +-------------------- src/gui/views/goblin/modals.rs | 334 + src/gui/views/goblin/pay.rs | 185 + src/gui/views/goblin/privacy.rs | 318 + src/gui/views/goblin/profile.rs | 191 + src/gui/views/goblin/prompts.rs | 1143 ++++ src/gui/views/goblin/receipt.rs | 389 ++ src/gui/views/goblin/receive.rs | 161 + src/gui/views/goblin/settings.rs | 349 ++ src/gui/views/goblin/settings_advanced.rs | 453 ++ src/gui/views/goblin/settings_node.rs | 483 ++ src/gui/views/goblin/username.rs | 567 ++ 18 files changed, 7361 insertions(+), 6949 deletions(-) create mode 100644 src/gui/views/goblin/activity.rs create mode 100644 src/gui/views/goblin/format.rs create mode 100644 src/gui/views/goblin/helpers.rs create mode 100644 src/gui/views/goblin/home.rs create mode 100644 src/gui/views/goblin/identities.rs create mode 100644 src/gui/views/goblin/me.rs create mode 100644 src/gui/views/goblin/modals.rs create mode 100644 src/gui/views/goblin/pay.rs create mode 100644 src/gui/views/goblin/privacy.rs create mode 100644 src/gui/views/goblin/profile.rs create mode 100644 src/gui/views/goblin/prompts.rs create mode 100644 src/gui/views/goblin/receipt.rs create mode 100644 src/gui/views/goblin/receive.rs create mode 100644 src/gui/views/goblin/settings.rs create mode 100644 src/gui/views/goblin/settings_advanced.rs create mode 100644 src/gui/views/goblin/settings_node.rs create mode 100644 src/gui/views/goblin/username.rs diff --git a/src/gui/views/goblin/activity.rs b/src/gui/views/goblin/activity.rs new file mode 100644 index 00000000..18ef3086 --- /dev/null +++ b/src/gui/views/goblin/activity.rs @@ -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 = 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 + } +} diff --git a/src/gui/views/goblin/format.rs b/src/gui/views/goblin/format.rs new file mode 100644 index 00000000..4d291d30 --- /dev/null +++ b/src/gui/views/goblin/format.rs @@ -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) -> Option { + 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 { + 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" + ); + } +} diff --git a/src/gui/views/goblin/helpers.rs b/src/gui/views/goblin/helpers.rs new file mode 100644 index 00000000..2a49cf5f --- /dev/null +++ b/src/gui/views/goblin/helpers.rs @@ -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 { + 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 + } +} diff --git a/src/gui/views/goblin/home.rs b/src/gui/views/goblin/home.rs new file mode 100644 index 00000000..6a8e5f06 --- /dev/null +++ b/src/gui/views/goblin/home.rs @@ -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> = 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 = name.chars().collect(); + if chars.len() > 8 { + format!("{}…", chars[..8].iter().collect::()) + } 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); + } +} diff --git a/src/gui/views/goblin/identities.rs b/src/gui/views/goblin/identities.rs new file mode 100644 index 00000000..845bfa14 --- /dev/null +++ b/src/gui/views/goblin/identities.rs @@ -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 = None; + let mut manage_target: Option = 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> = 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(); + } + } +} diff --git a/src/gui/views/goblin/me.rs b/src/gui/views/goblin/me.rs new file mode 100644 index 00000000..d6f3b8d2 --- /dev/null +++ b/src/gui/views/goblin/me.rs @@ -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); + }); + } +} diff --git a/src/gui/views/goblin/mod.rs b/src/gui/views/goblin/mod.rs index c61e275a..84d0e683 100644 --- a/src/gui/views/goblin/mod.rs +++ b/src/gui/views/goblin/mod.rs @@ -14,11 +14,28 @@ //! The Goblin payment-app-style wallet surface for an open wallet. +mod activity; pub mod avatars; pub mod data; +mod format; +mod helpers; +mod home; pub mod identicon; +mod identities; +mod me; +mod modals; pub mod onboarding; +mod pay; +mod privacy; +mod profile; +mod prompts; +mod receipt; +mod receive; pub mod send; +mod settings; +mod settings_advanced; +mod settings_node; +mod username; pub mod widgets; use eframe::epaint::{CornerRadius, FontId, Stroke}; @@ -39,6 +56,11 @@ use self::data::{ActivityItem, activity_items, news_latest, recent_peers, split_ use self::send::SendFlow; use self::widgets as w; +use self::format::*; +use self::helpers::*; +use self::privacy::*; +use self::username::{availability_feedback, start_claim_flow}; + /// Goblin navigation tabs. The mobile bar shows Home / Pay / Activity; /// Receive and Me stay reachable (Pay's Request action, header avatar, /// desktop sidebar). @@ -603,29 +625,6 @@ enum ClaimMsg { Error(String), } -/// Map an availability probe to user-facing state: `None` availability -/// means the check itself failed — never present that as "Taken". -fn availability_feedback(avail: crate::nostr::nip05::Availability) -> (Option, String) { - use crate::nostr::nip05::Availability::*; - match avail { - Available => ( - Some(true), - t!("goblin.settings.avail_available").to_string(), - ), - Taken => (Some(false), t!("goblin.settings.avail_taken").to_string()), - Reserved => ( - Some(false), - t!("goblin.settings.avail_reserved").to_string(), - ), - Invalid => (Some(false), t!("goblin.settings.avail_invalid").to_string()), - Quarantined => ( - Some(false), - t!("goblin.settings.avail_quarantined").to_string(), - ), - Unknown => (None, t!("goblin.settings.avail_unknown").to_string()), - } -} - impl Default for ClaimState { fn default() -> Self { Self { @@ -1671,6938 +1670,13 @@ impl GoblinWalletView { let tex = self.handle_tex(ui.ctx(), wallet, &handle); w::avatar_any(ui, &handle, &npub_hex, size, tex.as_ref()) } - - /// Compact node status card: sync state dot, block height, connection. - fn node_card_ui(&mut self, ui: &mut egui::Ui, wallet: &Wallet) { - let t = theme::tokens(); - let height = wallet - .get_data() - .map(|d| d.info.last_confirmed_height) - .unwrap_or(0); - // Distinguish "scanning" from "can't reach the node": a flaky - // external node otherwise reads as syncing forever. - let error = wallet.sync_error(); - let synced = height > 0 && !wallet.syncing() && !error; - w::card(ui, |ui| { - ui.set_min_width(ui.available_width()); - ui.horizontal(|ui| { - let (dot, _) = ui.allocate_exact_size(Vec2::splat(10.0), Sense::hover()); - ui.painter().circle_filled( - dot.center(), - 4.0, - if error { - t.neg - } else if synced { - t.pos - } else { - t.accent - }, - ); - ui.add_space(8.0); - ui.vertical(|ui| { - ui.label( - RichText::new(if error { - t!("goblin.home.cant_reach_node") - } else if synced { - t!("goblin.home.node_synced") - } else { - t!("goblin.home.syncing") - }) - .font(FontId::new(14.0, fonts::semibold())) - .color(t.surface_text), - ); - // Three lines: status, block height, then the node host - // on its own line so it never truncates the height. - let height = wallet - .get_data() - .map(|d| d.info.last_confirmed_height) - .unwrap_or(0); - ui.label( - RichText::new(if height > 0 { - t!("goblin.home.block", height => fmt_thousands(height)).to_string() - } else { - t!("goblin.home.waiting_for_chain").to_string() - }) - .font(FontId::new(12.0, fonts::regular())) - .color(t.surface_text_dim), - ); - ui.add( - egui::Label::new( - RichText::new(node_host(wallet)) - .font(FontId::new(12.0, fonts::regular())) - .color(t.surface_text_mute), - ) - .truncate(), - ); - }); - // Low-opacity gear so the card reads as a tappable settings - // shortcut; on-surface ink keeps it theme-aware. - ui.with_layout(Layout::right_to_left(Align::Center), |ui| { - ui.label( - RichText::new(crate::gui::icons::GEAR) - .font(FontId::new(16.0, fonts::regular())) - .color(t.surface_text.gamma_multiply(0.35)), - ); - }); - }); - }); - } - - fn home_ui( - &mut self, - ui: &mut egui::Ui, - wallet: &Wallet, - cb: &dyn PlatformCallbacks, - wide: bool, - ) { - let data = wallet.get_data(); - ScrollArea::vertical() - .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. - fn news_panel_ui(&mut self, ui: &mut egui::Ui, wallet: &Wallet) { - let Some(news) = news_latest(wallet) else { - return; - }; - let t = theme::tokens(); - w::kicker(ui, &t!("goblin.home.news")); - ui.add_space(8.0); - w::card(ui, |ui| { - // Span the full content width like the balance/activity rows so the - // panel reads as a band, not a content-hugging chip. - ui.set_min_width(ui.available_width()); - // Date first, ISO 8601 (YYYY-MM-DD, UTC). Dated by the article's - // published_at tag when present, else the event's created_at. - let stamp = news.published_at.unwrap_or(news.created_at); - ui.label( - RichText::new(data::news_date_iso(stamp)) - .font(FontId::new(12.0, fonts::medium())) - .color(t.surface_text_dim), - ); - if !news.title.is_empty() { - ui.add_space(2.0); - // Title guardrail: hard-cap the length (predictable ellipsis past - // NEWS_TITLE_MAX_CHARS) then shrink the font to fit the card width on - // one line down to a 12pt floor, so a title never clips. `.truncate()` - // backs the floor for the pathological narrow case. - let title = data::news_title_clamped(&news.title); - let pt = fit_news_title_pt(ui, &title, ui.available_width()); - ui.add( - egui::Label::new( - RichText::new(&title) - .font(FontId::new(pt, fonts::semibold())) - .color(t.surface_text), - ) - .truncate(), - ); - } - if !news.summary.is_empty() { - ui.add_space(4.0); - ui.horizontal_wrapped(|ui| { - ui.spacing_mut().item_spacing.x = 0.0; - for (seg, is_url) in split_urls(&news.summary) { - if is_url { - // hyperlink opens via ctx.open_url, same as open_url(). - ui.hyperlink(seg); - } else { - ui.label( - RichText::new(seg) - .font(FontId::new(13.0, fonts::regular())) - .color(t.surface_text_dim), - ); - } - } - }); - } - }); - ui.add_space(24.0); - } - - /// Horizontal recent-contacts strip; tapping one starts a prefilled send. - fn peers_strip_ui(&mut self, ui: &mut egui::Ui, wallet: &Wallet, salt: &str) { - let peers = recent_peers(wallet, 8); - if peers.is_empty() { - return; - } - // 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> = 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 = name.chars().collect(); - if chars.len() > 8 { - format!("{}…", chars[..8].iter().collect::()) - } 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); - } - - /// Pay tab: amount-first combined pay/request surface. - fn pay_ui(&mut self, ui: &mut egui::Ui, wallet: &Wallet, cb: &dyn PlatformCallbacks) { - let t = theme::tokens(); - ui.add_space(8.0); - 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::() { - if let Some(preview) = pairing_preview(grin, ui.ctx()) { - ui.add_space(6.0); - ui.vertical_centered(|ui| { - ui.label( - RichText::new(preview) - .font(FontId::new(14.0, fonts::regular())) - .color(t.text_dim), - ); - }); - } - } - // Drop the keypad toward the bottom on phone layouts (thumb reach) so it - // isn't stranded in the middle with a big empty gap below it. - let narrow = ui.available_width() < 700.0; - let drop = if narrow { - ((ui.available_height() - 430.0) * 0.6).max(0.0) - } else { - 0.0 - }; - ui.add_space(if tall { 32.0 } else { 16.0 } + drop); - - // The pay column is capped at 480 by `centered_column`, so the old - // `< 700` width gate was always narrow: the numpad always showed and - // the typed-input branch was dead — a physical keyboard did nothing. - // Show the pad and accept typed digits alongside it. - w::numpad(ui, &mut self.pay_amount, cb); - w::amount_typed_input(ui, &mut self.pay_amount); - ui.add_space(20.0); - - // Request | Pay actions, half width each. - let valid = grin_core::core::amount_from_hr_string(&self.pay_amount) - .map(|a| a > 0) - .unwrap_or(false); - ui.horizontal(|ui| { - let half = (ui.available_width() - 10.0) / 2.0; - ui.scope_builder( - egui::UiBuilder::new().max_rect(egui::Rect::from_min_size( - ui.cursor().min, - Vec2::new(half, 56.0), - )), - |ui| { - if w::big_action(ui, &t!("goblin.home.request"), true).clicked() && valid { - // Open the request flow: pick a contact, then DM them a - // grin Invoice1 they can approve to pay. - let f = SendFlow::new_request(self.pay_amount.clone()); - self.pay_amount.clear(); - self.send = Some(f); - } - }, - ); - ui.add_space(10.0); - ui.scope_builder( - egui::UiBuilder::new().max_rect(egui::Rect::from_min_size( - ui.cursor().min, - Vec2::new(half, 56.0), - )), - |ui| { - if w::big_action(ui, &t!("goblin.home.pay"), false).clicked() && valid { - let over = grin_core::core::amount_from_hr_string(&self.pay_amount) - .map(|a| a > spendable) - .unwrap_or(false); - if over { - // "No, you can't pay that": shake + flash the amount red - // and buzz the phone. Nothing is reddened while typing. - self.pay_shake = Some(now); - cb.vibrate_error(); - } else { - let mut f = SendFlow::default(); - f.prefill_amount(self.pay_amount.clone()); - self.pay_amount.clear(); - self.send = Some(f); - } - } - }, - ); - }); - if !valid { - ui.add_space(8.0); - ui.vertical_centered(|ui| { - ui.label( - RichText::new(t!("goblin.home.enter_amount")) - .font(FontId::new(12.0, fonts::regular())) - .color(t.text_mute), - ); - }); - } - } - - /// Round back button + title for full-surface overlays. Returns true on tap. - fn overlay_back_header(ui: &mut egui::Ui, title: &str) -> bool { - let t = theme::tokens(); - let mut back = false; - ui.horizontal(|ui| { - let (rect, resp) = ui.allocate_exact_size(Vec2::splat(36.0), Sense::click()); - ui.painter().circle_filled(rect.center(), 18.0, t.surface2); - ui.painter().text( - rect.center(), - egui::Align2::CENTER_CENTER, - ARROW_LEFT, - FontId::new(16.0, fonts::regular()), - t.text, - ); - back = resp - .on_hover_cursor(egui::CursorIcon::PointingHand) - .clicked(); - ui.add_space(12.0); - ui.label( - RichText::new(title) - .font(FontId::new(18.0, fonts::bold())) - .color(t.text), - ); - }); - ui.add_space(12.0); - back - } - - /// Full-surface transaction receipt: GRIM metadata joined with the nostr - /// counterparty + note. Tapping the counterparty opens their profile. - fn receipt_ui(&mut self, ui: &mut egui::Ui, wallet: &Wallet, tx_id: u32) -> bool { - let t = theme::tokens(); - let d = data::receipt_detail(wallet, tx_id); - let tex = d - .as_ref() - .and_then(|d| self.handle_tex(ui.ctx(), wallet, &d.title)); - let mut close = false; - let mut open_profile: Option = 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 - } - - /// Full-surface contact profile: who they are, history between us, and a - /// block toggle (a nostr-level mute). - fn profile_ui( - &mut self, - ui: &mut egui::Ui, - wallet: &Wallet, - _cb: &dyn PlatformCallbacks, - npub: &str, - ) -> bool { - let t = theme::tokens(); - let name = wallet - .nostr_service() - .map(|s| data::contact_title(&s.store, npub)) - .unwrap_or_else(|| data::short_npub(npub)); - let contact = wallet.nostr_service().and_then(|s| s.store.contact(npub)); - let blocked = contact.as_ref().map(|c| c.blocked).unwrap_or(false); - let nip05 = contact.as_ref().and_then(|c| c.nip05.clone()); - let history = data::history_with(wallet, npub); - let tex = self.handle_tex(ui.ctx(), wallet, &name); - let htexs: Vec> = 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 = 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 - } - - /// List-row timestamp: date + HH:MM, no seconds. The tap-in detail view keeps - /// the full timestamp to the second (see [`View::format_time`]). - fn list_time(ts: i64) -> String { - let utc_offset = chrono::Local::now().offset().local_minus_utc(); - chrono::DateTime::from_timestamp(ts + utc_offset as i64, 0) - .map(|t| t.format("%d/%m/%Y %H:%M").to_string()) - .unwrap_or_default() - } - - /// The (left message, right timestamp) an [`ActivityItem`] shows in a row. The - /// timestamp (no seconds) is only set for a confirmed tx; otherwise the status - /// word (canceled/pending) folds into the message so a row with no time still - /// reads its state without an empty right-side time slot. - fn activity_note_time(item: &ActivityItem) -> (String, String) { - let status_word = if item.canceled { - t!("goblin.activity.canceled").to_string() - } else { - t!("goblin.activity.pending").to_string() - }; - let time = if item.confirmed { - Self::list_time(item.time) - } else { - String::new() - }; - let note = match (item.note.as_deref(), item.confirmed) { - (Some(n), false) => format!("{n} · {status_word}"), - (None, false) => status_word, - (Some(n), true) => n.to_string(), - (None, true) => String::new(), - }; - (note, time) - } - - /// Friendly day-grouping label for the activity feed. - fn day_label(ts: i64) -> String { - use chrono::{TimeZone, Utc}; - let Some(dt) = Utc.timestamp_opt(ts, 0).single() else { - return t!("goblin.activity.earlier").to_string(); - }; - let today = Utc::now().date_naive(); - let day = dt.date_naive(); - if day == today { - t!("goblin.activity.today").to_string() - } else if (today - day).num_days() == 1 { - t!("goblin.activity.yesterday").to_string() - } else { - dt.format("%b %-d, %Y").to_string() - } - } - - fn activity_ui(&mut self, ui: &mut egui::Ui, wallet: &Wallet, cb: &dyn PlatformCallbacks) { - ui.add_space(8.0); - ui.label( - RichText::new(t!("goblin.activity.title")) - .font(FontId::new(28.0, fonts::bold())) - .color(theme::tokens().text), - ); - ui.add_space(12.0); - - // Recent contacts strip (payment-app-style row above the feed). - self.peers_strip_ui(ui, wallet, "goblin_peers_activity"); - - // Pending payment requests pinned on top. - if let Some(service) = wallet.nostr_service() { - // An approve that failed (e.g. funds still confirming) flips the send - // phase to FAILED — un-grey the buttons so the user can retry, and - // surface why instead of leaving the card stuck. - if service.send_phase() == crate::nostr::send_phase::FAILED - && !self.approving.is_empty() - { - self.approving.clear(); - self.request_error = service.last_send_error(); - } - let requests = service.store.pending_requests(); - if !requests.is_empty() { - w::section_header(ui, &t!("goblin.activity.requests")); - if let Some(err) = &self.request_error { - ui.add_space(4.0); - ui.label( - RichText::new(err) - .font(FontId::new(13.0, fonts::regular())) - .color(theme::tokens().neg), - ); - ui.add_space(4.0); - } - for req in requests { - self.request_row_ui(ui, &req, wallet); - } - ui.add_space(8.0); - } - } - - ScrollArea::vertical() - .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 = 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); - }); - } - - 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); - } - } - - 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). - 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 - } - - 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), - ); - } - - 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"), - }; - if settings_row_btn(ui, &t!("goblin.settings.theme"), &theme_label) { - cycle_theme(ui.ctx()); - } - // Language sits beside theme under Appearance; the value is the - // active language in its own name (e.g. "Deutsch"). - let current = crate::AppConfig::locale() - .unwrap_or_else(|| rust_i18n::locale().to_string()); - if settings_row_nav( - ui, - &t!("goblin.settings.language"), - &t!("lang_name", locale = current.as_str()), - ) { - open_language = true; - } - }); - if open_language { - self.settings_page = SettingsPage::Language; - } - - ui.add_space(16.0); - settings_group(ui, &t!("goblin.settings.about"), |ui| { - if settings_row_nav( - ui, - &t!("goblin.settings.goblin"), - &t!("goblin.settings.build", build => crate::BUILD), - ) { - open_url(ui, "https://github.com/2ro/goblin/releases"); - } - settings_row( - ui, - &t!("goblin.settings.network"), - &t!("goblin.settings.network_value"), - ); - }); - - ui.add_space(16.0); - let mut open_nips = false; - settings_group(ui, &t!("goblin.settings.third_party"), |ui| { - if settings_row_nav(ui, &t!("goblin.settings.grim"), crate::VERSION) { - // Live upstream GRIM (GitHub mirror of code.gri.mw/GUI/grim). - // Was github.com/ardocrat/grim — a stale personal fork. - open_url(ui, "https://github.com/GetGrin/grim"); - } - if settings_row_nav(ui, &t!("goblin.settings.grin_node"), "5.4.0") { - open_url(ui, "https://github.com/mimblewimble/grin"); - } - if settings_row_nav(ui, "nostr-sdk", "0.44") { - open_url(ui, "https://github.com/rust-nostr/nostr"); - } - if settings_row_nav(ui, "Tor (arti)", "0.43") { - open_url(ui, "https://gitlab.torproject.org/tpo/core/arti"); - } - if settings_row_nav(ui, "egui", "0.33") { - open_url(ui, "https://github.com/emilk/egui"); - } - if settings_row_nav(ui, "NIPs", "05 · 17 · 44 · 49 · 59 · 98") { - open_nips = true; - } - }); - if open_nips { - self.settings_page = SettingsPage::Nips; - } - - // Wallet management lives at the foot of Settings: a neutral - // switch, the red lock, then the advanced (recovery) tools — - // each its own outlined action, so the destructive lock reads - // apart from the rest. - ui.add_space(24.0); - let dim = theme::tokens().surface_text_dim; - if w::outlined_icon_action( - ui, - crate::gui::icons::USER_SWITCH, - &t!("goblin.settings.switch_wallet"), - dim, - ) - .clicked() - { - self.settings_page = SettingsPage::Main; - self.switch_requested = true; - } - ui.add_space(10.0); - if w::outlined_icon_action( - ui, - crate::gui::icons::LOCK, - &t!("goblin.settings.lock_wallet"), - theme::tokens().neg, - ) - .clicked() - { - wallet.close(); - } - ui.add_space(10.0); - if w::outlined_icon_action( - ui, - crate::gui::icons::WRENCH, - &t!("goblin.settings.advanced"), - dim, - ) - .clicked() - { - self.advanced = AdvancedState::default(); - self.settings_page = SettingsPage::Advanced; - } - ui.add_space(20.0); - }); - } - - /// Back header for Settings sub-pages; returns true when back is tapped. - fn sub_header(&mut self, ui: &mut egui::Ui, title: &str) -> bool { - let t = theme::tokens(); - let mut back = false; - ui.add_space(8.0); - ui.horizontal(|ui| { - let (rect, resp) = ui.allocate_exact_size(Vec2::splat(36.0), Sense::click()); - ui.painter().circle_filled(rect.center(), 18.0, t.surface2); - ui.painter().text( - rect.center(), - egui::Align2::CENTER_CENTER, - crate::gui::icons::ARROW_LEFT, - FontId::new(16.0, fonts::regular()), - theme::ink_for(t.surface2), - ); - back = resp.clicked(); - ui.add_space(12.0); - ui.label( - RichText::new(title) - .font(FontId::new(24.0, fonts::bold())) - .color(t.text), - ); - }); - ui.add_space(16.0); - back - } - - /// Node connection editor: pick integrated/external, add or remove nodes. - fn pairing_settings_ui(&mut self, ui: &mut egui::Ui) { - let t = theme::tokens(); - if self.sub_header(ui, &t!("goblin.pairing.title")) { - self.settings_page = SettingsPage::Main; - return; - } - ScrollArea::vertical() - .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). - 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); - }); - } - - /// 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. - 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()); - } - } - } - - /// 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. - 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 = 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); - }); - } - - /// 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. - 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); - }); - } - - /// Advanced (wallet-recovery) page — GRIM's low-level tools surfaced in the - /// goblin style: repair, restore-from-seed, reveal the recovery phrase, and - /// delete. The two destructive actions arm a tap-twice confirm. - fn advanced_ui(&mut self, ui: &mut egui::Ui, wallet: &Wallet, cb: &dyn PlatformCallbacks) { - use crate::wallet::types::ConnectionMethod; - let t = theme::tokens(); - if self.sub_header(ui, &t!("goblin.advanced.title")) { - self.advanced = AdvancedState::default(); - // 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; - } - } - - /// GRIM's four integrated-node tabs (Info / Metrics / Mining / Settings) - /// hosted under a Goblin back header and segmented control — GRIM's - /// dual-panel and floating-navbar chrome are never rendered. The header - /// title follows the active tab, like GRIM's own title panel. - fn integrated_node_ui(&mut self, ui: &mut egui::Ui, cb: &dyn PlatformCallbacks) { - use crate::gui::icons::{DATABASE, FACTORY, FADERS, GAUGE}; - use crate::gui::views::network::types::NodeTabType; - use crate::gui::views::network::{ - NetworkContent, NetworkMetrics, NetworkMining, NetworkNode, NetworkSettings, - disabled_node_ui, node_error_ui, - }; - use crate::node::Node; - let title = self.node_tab.get_type().title(); - if self.sub_header(ui, &title) { - self.settings_page = self.node_tab_back; - return; - } - let selected = match self.node_tab.get_type() { - NodeTabType::Info => 0, - NodeTabType::Metrics => 1, - NodeTabType::Mining => 2, - NodeTabType::Settings => 3, - }; - if let Some(i) = w::segmented(ui, &[DATABASE, GAUGE, FACTORY, FADERS], selected) { - self.node_tab = match i { - 0 => Box::new(NetworkNode), - 1 => Box::new(NetworkMetrics), - 2 => Box::new(NetworkMining::default()), - _ => Box::new(NetworkSettings::default()), - }; - } - ui.add_space(12.0); - // Same availability gate as GRIM's NetworkContent: the Settings tab is - // editable with the node off; the live tabs need a running node with - // stats before their content can draw. - if self.node_tab.get_type() != NodeTabType::Settings { - if let Some(err) = Node::get_error() { - node_error_ui(ui, err); - } else if !Node::is_running() { - disabled_node_ui(ui); - } else if Node::get_stats().is_none() || Node::is_restarting() || Node::is_stopping() { - NetworkContent::loading_ui(ui, None::); - } else { - self.node_tab.tab_ui(ui, cb); - } - } else { - self.node_tab.tab_ui(ui, cb); - } - // Keep the stats fresh while the node runs. - if Node::is_running() { - ui.ctx().request_repaint_after(Node::STATS_UPDATE_DELAY); - } - } - - fn node_settings_ui(&mut self, ui: &mut egui::Ui, wallet: &Wallet, cb: &dyn PlatformCallbacks) { - use crate::wallet::types::ConnectionMethod; - use crate::wallet::{ConnectionsConfig, ExternalConnection}; - let t = theme::tokens(); - if self.sub_header(ui, &t!("goblin.node.title")) { - self.settings_page = SettingsPage::Main; - return; - } - ScrollArea::vertical() - .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. - 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 = None; - let many = self.relay_edit.len() > 1; - for (i, relay) in self.relay_edit.iter().enumerate() { - ui.horizontal(|ui| { - ui.label( - RichText::new(relay) - .font(FontId::new(14.0, fonts::medium())) - .color(t.surface_text), - ); - ui.with_layout(Layout::right_to_left(Align::Center), |ui| { - if many { - let x = ui.label( - RichText::new(crate::gui::icons::X) - .font(FontId::new(15.0, fonts::regular())) - .color(t.surface_text_mute), - ); - if x.interact(Sense::click()).clicked() { - remove = Some(i); - } - } - }); - }); - ui.add_space(10.0); - } - if let Some(i) = remove { - self.relay_edit.remove(i); - } - }); - - ui.add_space(16.0); - settings_group(ui, &t!("goblin.relays.add_relay"), |ui| { - TextEdit::new(egui::Id::from("set_relay")) - .focus(false) - .hint_text("wss://relay.example.com") - .text_color(t.surface_text) - .body() - .ui(ui, &mut self.relay_input, cb); - }); - ui.add_space(10.0); - let relay = self.relay_input.trim().to_string(); - let valid = relay.starts_with("wss://") || relay.starts_with("ws://"); - if w::big_action_on_card(ui, &t!("goblin.relays.add_relay_btn")).clicked() - && valid && !self.relay_edit.contains(&relay) - { - self.relay_edit.push(relay); - self.relay_input.clear(); - } - ui.add_space(10.0); - if w::big_action(ui, &t!("goblin.relays.save_reconnect"), false).clicked() { - if let Some(s) = wallet.nostr_service() { - { - let mut c = s.config.write(); - c.set_relays(self.relay_edit.clone()); - c.save(); - } - s.restart(wallet.clone()); - } - self.settings_page = SettingsPage::Main; - } - ui.add_space(16.0); - }); - } - - /// Manual slatepack exchange — GRIM's native by-hand flow, exposed as an - /// advanced fallback for when a payment can't ride a @username. - fn slatepack_ui(&mut self, ui: &mut egui::Ui, wallet: &Wallet, cb: &dyn PlatformCallbacks) { - let t = theme::tokens(); - if self.sub_header(ui, &t!("goblin.settings.slatepacks")) { - self.settings_page = SettingsPage::Main; - return; - } - ScrollArea::vertical() - .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); - }); - } - - /// What-is-nostr explainer and tappable NIP reference list. - fn nips_ui(&mut self, ui: &mut egui::Ui) { - let t = theme::tokens(); - if self.sub_header(ui, &t!("goblin.nips.title")) { - self.settings_page = SettingsPage::Main; - return; - } - ScrollArea::vertical() - .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. - 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; - } - } - - /// 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. - 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 = None; - let mut manage_target: Option = 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> = 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. - 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. - 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). - 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(); - } - } - - /// 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. - 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::() { - 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::().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. - 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(); - } - } - - /// Content of the "Sign in with Goblin" approval modal: the requesting - /// domain up top, the signing identity (a picker when several are held; - /// the truncated npub is always visible as the anchor), a one-line plain - /// explanation, the wallet password, and uniform paired Cancel / Sign in - /// buttons. Approving signs the one-time kind-22242 challenge with the - /// CHOSEN identity's key and POSTs it to the callback off the UI thread; - /// a wrong password stays in the modal without consuming the request. - fn login_modal_content( - &mut self, - ui: &mut egui::Ui, - modal: &Modal, - wallet: &Wallet, - cb: &dyn PlatformCallbacks, - ) { - let Some(st) = self.login.as_mut() else { - Modal::close(); - return; - }; - if st.posting { - // Already signed and in flight; nothing left to gate here. - Modal::close(); - return; - } - let domain = st.uri.domain.clone(); - let identities = wallet.nostr_identities(); - let mut go = false; - let mut cancel = false; - ui.vertical_centered(|ui| { - ui.add_space(6.0); - // Headline with the requesting domain prominent. - ui.label( - RichText::new(t!("goblin.login.headline", domain => domain.clone())) - .size(17.0) - .color(Colors::title(false)), - ); - ui.add_space(10.0); - // The signing identity. Display precedence is the switcher's: - // private tag, else bare claimed name, else truncated npub — and - // the truncated npub is ALWAYS shown as the anchor. - ui.label( - RichText::new(t!("goblin.login.identity")) - .size(13.0) - .color(Colors::gray()), - ); - ui.add_space(6.0); - if identities.len() > 1 { - // Identity picker: the held-identities list, tap to choose - // which one signs. Defaults to the active identity. - for id in &identities { - let selected = st.selected == id.pubkey_hex; - let title = id.display(); - let short = data::short_npub(&id.pubkey_hex); - let row = ui - .scope(|ui| { - ui.horizontal(|ui| { - ui.add_space(4.0); - ui.label( - RichText::new(if selected { - crate::gui::icons::CHECK_CIRCLE - } else { - crate::gui::icons::CIRCLE - }) - .size(18.0) - .color(if selected { - Colors::green() - } else { - Colors::gray() - }), - ); - ui.add_space(8.0); - ui.vertical(|ui| { - if title != short { - ui.label( - RichText::new(&title) - .size(15.0) - .color(Colors::text(false)), - ); - } - ui.label( - RichText::new(&short).size(12.5).color(Colors::gray()), - ); - }); - }); - }) - .response - .rect; - let hit = ui.interact( - row, - egui::Id::from(modal.id).with(("login_id", id.pubkey_hex.as_str())), - Sense::click(), - ); - if hit - .on_hover_cursor(egui::CursorIcon::PointingHand) - .clicked() - { - st.selected = id.pubkey_hex.clone(); - } - ui.add_space(4.0); - } - } else if let Some(id) = identities.first() { - let title = id.display(); - let short = data::short_npub(&id.pubkey_hex); - if title != short { - ui.label(RichText::new(&title).size(15.0).color(Colors::text(false))); - } - ui.label(RichText::new(&short).size(12.5).color(Colors::gray())); - } - ui.add_space(10.0); - // One plain line on what approving does (and does not do). - ui.label( - RichText::new(t!("goblin.login.explain")) - .size(13.0) - .color(Colors::gray()), - ); - ui.add_space(10.0); - // The wallet password gates the signature, mirroring the identity - // password modal (masked field, same wrong-password line). - ui.label( - RichText::new(t!("goblin.login.pass_prompt")) - .size(16.0) - .color(Colors::gray()), - ); - ui.add_space(10.0); - let mut field = TextEdit::new(egui::Id::from(modal.id).with("login_pass")).password(); - field.ui(ui, &mut st.pass, cb); - if field.enter_pressed { - go = true; - } - if st.pass.is_empty() { - st.wrong_pass = false; - } else if st.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!("goblin.login.confirm"), - Colors::white_or_black(false), - || { - go = true; - }, - ); - }); - }); - ui.add_space(6.0); - }); - if cancel { - // Cancel drops the request: it is single-use, no retry. - self.login = None; - Modal::close(); - return; - } - if go { - let (pass, selected) = match self.login.as_ref() { - Some(st) => (st.pass.clone(), st.selected.clone()), - None => return, - }; - if pass.is_empty() { - return; - } - if !wallet.verify_nostr_password(&pass) { - // Wrong password: stay in the modal, request NOT consumed. - if let Some(st) = self.login.as_mut() { - st.wrong_pass = true; - } - return; - } - // The chosen identity's unlocked in-memory keys, from the running - // service (the Build-145 model: every held identity is unlocked). - let keys = wallet.nostr_service().and_then(|s| { - s.recv_snapshot() - .into_iter() - .find(|h| h.keys.public_key().to_hex() == selected) - .map(|h| h.keys) - }); - let Some(keys) = keys else { - // No running service / identity gone: drop the request. - self.login = None; - Modal::close(); - return; - }; - let st = self.login.as_mut().unwrap(); - st.pass.clear(); - st.wrong_pass = false; - match crate::nostr::loginuri::build_login_event( - &keys, - &st.uri.challenge, - &st.uri.domain, - ) { - Ok(event) => { - // Signed: the request is consumed from here on, whatever - // the POST outcome. Deliver off the UI thread with the - // shared HTTP client and a hard timeout. - st.posting = true; - let callback = st.uri.callback.clone(); - let slot = st.result.clone(); - std::thread::spawn(move || { - let res = match tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - { - Ok(rt) => rt.block_on(async { - let post = - crate::nostr::loginuri::post_login_event(&callback, &event); - match tokio::time::timeout( - std::time::Duration::from_secs(LOGIN_POST_TIMEOUT_SECS), - post, - ) - .await - { - Ok(r) => r, - Err(_) => Err("timeout".to_string()), - } - }), - Err(e) => Err(e.to_string()), - }; - *slot.lock().unwrap() = Some(res); - }); - Modal::close(); - // The return-to-caller decision is DEFERRED to the outcome - // poll: returning now would background the app with the POST - // still in flight, stop the frame pump, and strand the - // completion work (the Build 153 QR-trust bug). The app stays - // foreground (frames pumping) until the POST result lands. - } - Err(e) => { - // Signing failed (never expected): consume the request and - // surface the quiet failure toast. - log::error!("login event signing failed: {e}"); - self.login_toast = Some(( - t!("goblin.login.failed", domain => domain).to_string(), - std::time::Instant::now(), - )); - self.login = None; - Modal::close(); - } - } - } - } - - /// The "Authorize with Goblin" approval modal: headline, the rendered event - /// (kind label, escaped content preview with an optional show-full view, and - /// per-kind key-tag summary), the identity picker, the password gate, and the - /// Cancel/Authorize buttons. Mirrors [`Self::login_modal_content`]; on - /// confirm it signs one event with the chosen identity and POSTs it off the - /// UI thread. Signed = consumed, whatever the POST outcome. - fn authorize_modal_content( - &mut self, - ui: &mut egui::Ui, - modal: &Modal, - wallet: &Wallet, - cb: &dyn PlatformCallbacks, - ) { - use crate::nostr::authuri; - let Some(st) = self.authorize.as_mut() else { - Modal::close(); - return; - }; - if st.posting { - // Already signed and in flight; nothing left to gate here. - Modal::close(); - return; - } - let domain = st.uri.domain.clone(); - // Clone the small template once so the read-only render borrows nothing - // from `st` while its password/show-full fields are mutated below. - let template = st.uri.template.clone(); - let kind = template.kind; - let label = authuri::kind_label(kind); - let (preview, remaining) = authuri::content_preview(&template.content); - let preview_esc = authuri::escape_for_display(&preview); - let full_esc = authuri::escape_for_display(&template.content); - // Key tags, all escaped before display. - let e_tag = template - .first_tag_value("e") - .map(|v| authuri::escape_for_display(&authuri::truncate_id(v))); - let p_tag = template - .first_tag_value("p") - .map(|v| authuri::escape_for_display(&authuri::truncate_id(v))); - let title = template - .first_tag_value("title") - .map(authuri::escape_for_display); - let identities = wallet.nostr_identities(); - let mut go = false; - let mut cancel = false; - ui.vertical_centered(|ui| { - ui.add_space(6.0); - // Headline with the requesting domain prominent. - ui.label( - RichText::new(t!("goblin.authorize.headline", domain => domain.clone())) - .size(17.0) - .color(Colors::title(false)), - ); - ui.add_space(10.0); - // The plain-language kind label (and, for an off-allowlist kind that - // v1 cannot actually reach, a caution line). - let kind_text = match label { - authuri::KindLabel::Post => t!("goblin.authorize.kind_post"), - authuri::KindLabel::Repost => t!("goblin.authorize.kind_repost"), - authuri::KindLabel::Reaction => t!("goblin.authorize.kind_reaction"), - authuri::KindLabel::Article => t!("goblin.authorize.kind_article"), - authuri::KindLabel::Unknown => { - t!("goblin.authorize.kind_unknown", n => kind.to_string()) - } - }; - ui.label( - RichText::new(kind_text) - .size(15.0) - .color(Colors::text(false)), - ); - if label == authuri::KindLabel::Unknown { - ui.add_space(4.0); - ui.label( - RichText::new(t!("goblin.authorize.unknown_caution", domain => domain.clone())) - .size(12.5) - .color(Colors::red()), - ); - } - ui.add_space(8.0); - // Per-kind rendering of the event body and its key tags. All - // requester-controlled text is escaped before it hits a label. - let show_preview = |ui: &mut egui::Ui| { - if !preview_esc.is_empty() { - ui.label(RichText::new(&preview_esc).size(13.5).color(Colors::gray())); - } - }; - match kind { - 7 => { - // Reaction: the reaction glyph (emoji or "+"), then target. - let reaction = if full_esc.is_empty() { - "+".to_string() - } else { - full_esc.clone() - }; - ui.label( - RichText::new(reaction) - .size(20.0) - .color(Colors::text(false)), - ); - ui.add_space(4.0); - if let Some(id) = &e_tag { - ui.label( - RichText::new(t!("goblin.authorize.reacts_to", id => id.clone())) - .size(12.5) - .color(Colors::gray()), - ); - } - if let Some(id) = &p_tag { - ui.label( - RichText::new(t!("goblin.authorize.by_author", id => id.clone())) - .size(12.5) - .color(Colors::gray()), - ); - } - } - 6 => { - // Repost: reposted event id and author. - if let Some(id) = &e_tag { - ui.label( - RichText::new(t!("goblin.authorize.repost_of", id => id.clone())) - .size(12.5) - .color(Colors::gray()), - ); - } - if let Some(id) = &p_tag { - ui.label( - RichText::new(t!("goblin.authorize.by_author", id => id.clone())) - .size(12.5) - .color(Colors::gray()), - ); - } - } - 30023 => { - // Article: title tag if present, then the content preview. - if let Some(t) = &title { - ui.label( - RichText::new(t!("goblin.authorize.article_title", title => t.clone())) - .size(14.0) - .color(Colors::text(false)), - ); - ui.add_space(4.0); - } - show_preview(ui); - } - _ => { - // Kind 1 (and the unreachable fallback): content preview, then - // the reply/mention tags. - show_preview(ui); - if let Some(id) = &e_tag { - ui.add_space(4.0); - ui.label( - RichText::new(t!("goblin.authorize.replying_to", id => id.clone())) - .size(12.5) - .color(Colors::gray()), - ); - } - if let Some(id) = &p_tag { - ui.label( - RichText::new(t!("goblin.authorize.mentions", id => id.clone())) - .size(12.5) - .color(Colors::gray()), - ); - } - } - } - // Truncation marker plus the mandatory show-full affordance. Approval - // is never blocked on opening it. - if remaining > 0 { - ui.add_space(6.0); - ui.label( - RichText::new(t!("goblin.authorize.truncated", n => remaining.to_string())) - .size(12.0) - .color(Colors::gray()), - ); - let toggle = if st.show_full { - t!("goblin.authorize.show_less") - } else { - t!("goblin.authorize.show_full") - }; - let rect = ui - .label(RichText::new(toggle).size(13.0).color(Colors::green())) - .rect; - let hit = ui.interact( - rect, - egui::Id::from(modal.id).with("auth_showfull"), - Sense::click(), - ); - if hit - .on_hover_cursor(egui::CursorIcon::PointingHand) - .clicked() - { - st.show_full = !st.show_full; - } - if st.show_full { - ui.add_space(6.0); - ScrollArea::vertical() - .max_height(160.0) - .auto_shrink([false, true]) - .show(ui, |ui| { - ui.label(RichText::new(&full_esc).size(13.0).color(Colors::gray())); - }); - } - } - ui.add_space(10.0); - // The signing identity. Display precedence is the switcher's: private - // tag, else bare claimed name, else truncated npub, and the truncated - // npub is ALWAYS shown as the anchor. Defaults to the active identity. - ui.label( - RichText::new(t!("goblin.authorize.identity")) - .size(13.0) - .color(Colors::gray()), - ); - ui.add_space(6.0); - if identities.len() > 1 { - for id in &identities { - let selected = st.selected == id.pubkey_hex; - let name = id.display(); - let short = data::short_npub(&id.pubkey_hex); - let row = ui - .scope(|ui| { - ui.horizontal(|ui| { - ui.add_space(4.0); - ui.label( - RichText::new(if selected { - crate::gui::icons::CHECK_CIRCLE - } else { - crate::gui::icons::CIRCLE - }) - .size(18.0) - .color(if selected { - Colors::green() - } else { - Colors::gray() - }), - ); - ui.add_space(8.0); - ui.vertical(|ui| { - if name != short { - ui.label( - RichText::new(&name) - .size(15.0) - .color(Colors::text(false)), - ); - } - ui.label( - RichText::new(&short).size(12.5).color(Colors::gray()), - ); - }); - }); - }) - .response - .rect; - let hit = ui.interact( - row, - egui::Id::from(modal.id).with(("auth_id", id.pubkey_hex.as_str())), - Sense::click(), - ); - if hit - .on_hover_cursor(egui::CursorIcon::PointingHand) - .clicked() - { - st.selected = id.pubkey_hex.clone(); - } - ui.add_space(4.0); - } - } else if let Some(id) = identities.first() { - let name = id.display(); - let short = data::short_npub(&id.pubkey_hex); - if name != short { - ui.label(RichText::new(&name).size(15.0).color(Colors::text(false))); - } - ui.label(RichText::new(&short).size(12.5).color(Colors::gray())); - } - ui.add_space(10.0); - // One plain line on what approving does (and does not do). - ui.label( - RichText::new(t!("goblin.authorize.explain", domain => domain.clone())) - .size(13.0) - .color(Colors::gray()), - ); - ui.add_space(10.0); - // The wallet password gates the signature, mirroring the identity - // password modal (masked field, same wrong-password line). - ui.label( - RichText::new(t!("goblin.authorize.pass_prompt")) - .size(16.0) - .color(Colors::gray()), - ); - ui.add_space(10.0); - let mut field = TextEdit::new(egui::Id::from(modal.id).with("auth_pass")).password(); - field.ui(ui, &mut st.pass, cb); - if field.enter_pressed { - go = true; - } - if st.pass.is_empty() { - st.wrong_pass = false; - } else if st.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!("goblin.authorize.confirm"), - Colors::white_or_black(false), - || { - go = true; - }, - ); - }); - }); - ui.add_space(6.0); - }); - if cancel { - // Cancel drops the request: it is single-use, no retry. - self.authorize = None; - Modal::close(); - return; - } - if go { - let (pass, selected) = match self.authorize.as_ref() { - Some(st) => (st.pass.clone(), st.selected.clone()), - None => return, - }; - if pass.is_empty() { - return; - } - if !wallet.verify_nostr_password(&pass) { - // Wrong password: stay in the modal, request NOT consumed. - if let Some(st) = self.authorize.as_mut() { - st.wrong_pass = true; - } - return; - } - // The chosen identity's unlocked in-memory keys, from the running - // service (the Build-145 model: every held identity is unlocked). - let keys = wallet.nostr_service().and_then(|s| { - s.recv_snapshot() - .into_iter() - .find(|h| h.keys.public_key().to_hex() == selected) - .map(|h| h.keys) - }); - let Some(keys) = keys else { - // No running service / identity gone: drop the request. - self.authorize = None; - Modal::close(); - return; - }; - let st = self.authorize.as_mut().unwrap(); - st.pass.clear(); - st.wrong_pass = false; - match crate::nostr::authuri::build_authorize_event(&keys, &st.uri.template) { - Ok(event) => { - // Signed: the request is consumed from here on, whatever the - // POST outcome. Deliver off the UI thread with the shared HTTP - // client and a hard timeout. - st.posting = true; - let callback = st.uri.callback.clone(); - let challenge = st.uri.challenge.clone(); - let domain = st.uri.domain.clone(); - let slot = st.result.clone(); - std::thread::spawn(move || { - let res = match tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - { - Ok(rt) => rt.block_on(async { - let post = crate::nostr::authuri::post_authorize_event( - &callback, &challenge, &domain, &event, - ); - match tokio::time::timeout( - std::time::Duration::from_secs(LOGIN_POST_TIMEOUT_SECS), - post, - ) - .await - { - Ok(r) => r, - Err(_) => Err("timeout".to_string()), - } - }), - Err(e) => Err(e.to_string()), - }; - *slot.lock().unwrap() = Some(res); - }); - Modal::close(); - // Return-to-caller is DEFERRED to the outcome poll (see the - // login flow): the app must stay foreground until the POST - // result lands, or the completion work freezes backgrounded. - } - Err(e) => { - // Signing failed (never expected): consume the request and - // surface the quiet failure toast. - log::error!("authorize event signing failed: {e}"); - self.login_toast = Some(( - t!("goblin.authorize.failed", domain => domain).to_string(), - std::time::Instant::now(), - )); - self.authorize = None; - Modal::close(); - } - } - } - } - - /// The "Trust with Goblin" (Authorize Sessions) grant modal: proves identity - /// (folds login in) AND establishes the session in one password-gated, - /// hold-to-confirm decision. Shows the identity, the low-tier categories being - /// granted for silent signing, and the fixed line that money always asks. - fn trust_modal_content( - &mut self, - ui: &mut egui::Ui, - modal: &Modal, - wallet: &Wallet, - cb: &dyn PlatformCallbacks, - ) { - let Some(st) = self.trust.as_mut() else { - Modal::close(); - return; - }; - if st.posting { - Modal::close(); - return; - } - let domain = st.uri.domain.clone(); - let identities = wallet.nostr_identities(); - let display = crate::nostr::session::render_grant(&st.uri.requested_kinds); - let mut go = false; - let mut cancel = false; - ui.vertical_centered(|ui| { - ui.add_space(6.0); - ui.label( - RichText::new(t!("goblin.trust.headline", domain => domain.clone())) - .size(17.0) - .color(Colors::title(false)), - ); - ui.add_space(10.0); - ui.label( - RichText::new(t!("goblin.trust.identity")) - .size(13.0) - .color(Colors::gray()), - ); - ui.add_space(6.0); - // Identity picker (defaults to the active identity), the truncated - // npub always shown as the anchor. - if identities.len() > 1 { - for id in &identities { - let selected = st.selected == id.pubkey_hex; - let title = id.display(); - let short = data::short_npub(&id.pubkey_hex); - let row = ui - .scope(|ui| { - ui.horizontal(|ui| { - ui.add_space(4.0); - ui.label( - RichText::new(if selected { - crate::gui::icons::CHECK_CIRCLE - } else { - crate::gui::icons::CIRCLE - }) - .size(18.0) - .color(if selected { - Colors::green() - } else { - Colors::gray() - }), - ); - ui.add_space(8.0); - ui.vertical(|ui| { - if title != short { - ui.label( - RichText::new(&title) - .size(15.0) - .color(Colors::text(false)), - ); - } - ui.label( - RichText::new(&short).size(12.5).color(Colors::gray()), - ); - }); - }); - }) - .response - .rect; - let hit = ui.interact( - row, - egui::Id::from(modal.id).with(("trust_id", id.pubkey_hex.as_str())), - Sense::click(), - ); - if hit - .on_hover_cursor(egui::CursorIcon::PointingHand) - .clicked() - { - st.selected = id.pubkey_hex.clone(); - } - ui.add_space(4.0); - } - } else if let Some(id) = identities.first() { - let title = id.display(); - let short = data::short_npub(&id.pubkey_hex); - if title != short { - ui.label(RichText::new(&title).size(15.0).color(Colors::text(false))); - } - ui.label(RichText::new(&short).size(12.5).color(Colors::gray())); - } - ui.add_space(12.0); - // The gist in one short line (grant + the money rule), with the full - // permission detail behind a small disclosure for anyone who wants it. - ui.label( - RichText::new(t!("goblin.trust.lead", domain => domain.clone())) - .size(13.5) - .color(Colors::text(false)), - ); - // Caution lines are safety-relevant and stay visible even collapsed. - for kind in &display.unknown_kinds { - ui.label( - RichText::new(format!( - "• {}", - t!("goblin.trust.cat_unknown", n => kind.to_string()) - )) - .size(13.5) - .color(Colors::red()), - ); - } - if display.stripped_login { - ui.label( - RichText::new(t!("goblin.trust.login_excluded")) - .size(12.5) - .color(Colors::red()), - ); - } - ui.add_space(8.0); - // The disclosure toggle, same idiom as the authorize full-content view. - let toggle = if st.show_full { - t!("goblin.authorize.show_less") - } else { - t!("goblin.authorize.show_full") - }; - let rect = ui - .label(RichText::new(toggle).size(13.0).color(Colors::green())) - .rect; - let hit = ui.interact( - rect, - egui::Id::from(modal.id).with("trust_showfull"), - Sense::click(), - ); - if hit - .on_hover_cursor(egui::CursorIcon::PointingHand) - .clicked() - { - st.show_full = !st.show_full; - } - if st.show_full { - ui.add_space(6.0); - // What the silent grant covers, as human categories. - ui.label( - RichText::new(t!("goblin.trust.grant_intro")) - .size(13.0) - .color(Colors::gray()), - ); - ui.add_space(4.0); - for cat in &display.categories { - ui.label( - RichText::new(format!("• {}", t!(cat.key()))) - .size(13.5) - .color(Colors::text(false)), - ); - } - ui.add_space(6.0); - // The fixed money line: the low-tier grant is not a money grant. - ui.label( - RichText::new(t!("goblin.trust.money_line")) - .size(13.0) - .color(Colors::title(false)), - ); - ui.add_space(4.0); - ui.label( - RichText::new(t!("goblin.trust.duration")) - .size(12.5) - .color(Colors::gray()), - ); - } - ui.add_space(12.0); - ui.label( - RichText::new(t!("goblin.trust.pass_prompt")) - .size(16.0) - .color(Colors::gray()), - ); - ui.add_space(10.0); - let mut field = TextEdit::new(egui::Id::from(modal.id).with("trust_pass")).password(); - field.ui(ui, &mut st.pass, cb); - if st.pass.is_empty() { - st.wrong_pass = false; - } else if st.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); - }); - // Hold-to-confirm: the single high-value decision cannot be a stray tap. - if self.trust_hold.ui(ui, &t!("goblin.trust.confirm_hold")) { - go = true; - } - ui.add_space(6.0); - ui.scope(|ui| { - ui.spacing_mut().item_spacing = egui::Vec2::new(8.0, 0.0); - ui.vertical_centered_justified(|ui| { - View::button( - ui, - t!("modal.cancel"), - Colors::white_or_black(false), - || { - cancel = true; - }, - ); - }); - ui.add_space(6.0); - }); - if cancel { - self.trust = None; - Modal::close(); - return; - } - if go { - let (pass, selected) = match self.trust.as_ref() { - Some(st) => (st.pass.clone(), st.selected.clone()), - None => return, - }; - if pass.is_empty() { - self.trust_hold = w::HoldToSend::default(); - return; - } - if !wallet.verify_nostr_password(&pass) { - if let Some(st) = self.trust.as_mut() { - st.wrong_pass = true; - } - self.trust_hold = w::HoldToSend::default(); - return; - } - let keys = wallet.nostr_service().and_then(|s| { - s.recv_snapshot() - .into_iter() - .find(|h| h.keys.public_key().to_hex() == selected) - .map(|h| h.keys) - }); - let Some(keys) = keys else { - self.trust = None; - Modal::close(); - return; - }; - let st = self.trust.as_mut().unwrap(); - st.pass.clear(); - st.wrong_pass = false; - // Sign and POST the kind-22242 login event exactly as Build 150 does; - // the session is created by the router once this POST succeeds. - match crate::nostr::loginuri::build_login_event( - &keys, - &st.uri.challenge, - &st.uri.domain, - ) { - Ok(event) => { - st.posting = true; - let callback = st.uri.callback.clone(); - let slot = st.result.clone(); - std::thread::spawn(move || { - let res = match tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - { - Ok(rt) => rt.block_on(async { - let post = - crate::nostr::loginuri::post_login_event(&callback, &event); - match tokio::time::timeout( - std::time::Duration::from_secs(LOGIN_POST_TIMEOUT_SECS), - post, - ) - .await - { - Ok(r) => r, - Err(_) => Err("timeout".to_string()), - } - }), - Err(e) => Err(e.to_string()), - }; - *slot.lock().unwrap() = Some(res); - }); - Modal::close(); - // Return-to-caller is DEFERRED even further than login: past - // the POST outcome AND past the session-open announce - // confirmation (the trust_wait poll). Returning here was the - // Build 153 QR-trust bug: the app backgrounded with the POST - // in flight and the session-open never published. - } - Err(e) => { - log::error!("trust login event signing failed: {e}"); - self.login_toast = Some(( - t!("goblin.trust.failed", domain => domain).to_string(), - std::time::Instant::now(), - )); - self.trust = None; - Modal::close(); - } - } - } - } - - /// The money-tier per-action approval modal: a value-moving sign (or a - /// pay-committing encrypt) arriving over a live session channel. Identical - /// gravity to a v1 authorize — what is being done, which identity, a masked - /// password, hold-to-confirm — raised every time, never silent. - fn money_modal_content( - &mut self, - ui: &mut egui::Ui, - modal: &Modal, - wallet: &Wallet, - cb: &dyn PlatformCallbacks, - ) { - use crate::nostr::session::ChannelOp; - let Some(st) = self.money.as_mut() else { - Modal::close(); - return; - }; - let domain = st.pending.domain.clone(); - let req_id = st.pending.id().to_string(); - let short_id = data::short_npub(&st.pending.identity_pubkey.to_hex()); - // A one-line description of exactly what is being committed to. - let what = match &st.pending.op { - ChannelOp::Sign(req) => { - let label = crate::nostr::authuri::kind_label(req.event.kind); - let (preview, _) = crate::nostr::authuri::content_preview(&req.event.content); - let preview = crate::nostr::authuri::escape_for_display(&preview); - if preview.trim().is_empty() { - t!(label.key(), n => req.event.kind.to_string()).to_string() - } else { - format!( - "{}: {}", - t!(label.key(), n => req.event.kind.to_string()), - preview - ) - } - } - ChannelOp::Encrypt(e) => { - // Order DMs are where payment agreements live: show the inspected - // plaintext (escaped + truncated exactly like the sign path), so - // the user sees WHAT they are agreeing to pay, not a blind label. - let (preview, _) = crate::nostr::authuri::content_preview(&e.plaintext); - let preview = crate::nostr::authuri::escape_for_display(&preview); - if preview.trim().is_empty() { - t!("goblin.money.encrypt_desc").to_string() - } else { - format!("{}: {}", t!("goblin.money.encrypt_desc"), preview) - } - } - }; - let mut approve = false; - let mut decline = false; - ui.vertical_centered(|ui| { - ui.add_space(6.0); - ui.label( - RichText::new(t!("goblin.money.headline", domain => domain.clone())) - .size(17.0) - .color(Colors::title(false)), - ); - ui.add_space(8.0); - ui.label(RichText::new(&what).size(14.0).color(Colors::text(false))); - ui.add_space(8.0); - ui.label( - RichText::new(t!("goblin.money.identity", id => short_id.clone())) - .size(12.5) - .color(Colors::gray()), - ); - ui.add_space(8.0); - ui.label( - RichText::new(t!("goblin.money.explain")) - .size(13.0) - .color(Colors::gray()), - ); - ui.add_space(10.0); - ui.label( - RichText::new(t!("goblin.money.pass_prompt")) - .size(16.0) - .color(Colors::gray()), - ); - ui.add_space(10.0); - let mut field = TextEdit::new(egui::Id::from(modal.id).with("money_pass")).password(); - field.ui(ui, &mut st.pass, cb); - if st.pass.is_empty() { - st.wrong_pass = false; - } else if st.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); - }); - if self.money_hold.ui(ui, &t!("goblin.money.confirm_hold")) { - approve = true; - } - ui.add_space(6.0); - ui.vertical_centered_justified(|ui| { - View::button( - ui, - t!("modal.cancel"), - Colors::white_or_black(false), - || { - decline = true; - }, - ); - }); - ui.add_space(6.0); - if decline { - if let Some(svc) = wallet.nostr_service() { - svc.answer_money_prompt(&req_id, false); - } - self.money = None; - Modal::close(); - return; - } - if approve { - let pass = self - .money - .as_ref() - .map(|s| s.pass.clone()) - .unwrap_or_default(); - if pass.is_empty() || !wallet.verify_nostr_password(&pass) { - if let Some(st) = self.money.as_mut() { - st.wrong_pass = true; - } - self.money_hold = w::HoldToSend::default(); - return; - } - if let Some(svc) = wallet.nostr_service() { - svc.answer_money_prompt(&req_id, true); - } - self.money = None; - Modal::close(); - } - } - - /// Trusted Sites: the active Authorize Sessions, what each can sign silently, - /// time remaining, and a one-tap end (immediate, unilateral revocation). - 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 = None; - let mut to_resume: Option = None; - for s in &summaries { - settings_group(ui, &s.domain, |ui| { - // The low-tier categories this session signs silently. - let cats: Vec = 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. - 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)); - } - - /// Inline username-claim widget (availability check + registration). - fn claim_ui(&mut self, ui: &mut egui::Ui, wallet: &Wallet, cb: &dyn PlatformCallbacks) { - let t = theme::tokens(); - // Poll the worker result; avatar invalidation happens after the - // claim borrow is released. - let mut invalidate_avatar: Option = 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 = wallet - .nostr_service() - .and_then(|s| s.identity.read().nip05.clone()) - .map(|n| n.split('@').next().unwrap_or("").to_string()); - - w::card(ui, |ui| { - ui.set_min_width(ui.available_width()); - if let Some(name) = registered { - if claim.confirm_release { - // The are-you-sure gate. - ui.label( - RichText::new(t!("goblin.settings.release_confirm", name => name)) - .font(FontId::new(15.0, fonts::semibold())) - .color(t.surface_text), - ); - ui.add_space(4.0); - ui.label( - RichText::new(t!("goblin.settings.release_blurb")) - .font(FontId::new(12.5, fonts::regular())) - .color(t.surface_text_dim), - ); - ui.add_space(10.0); - if claim.checking { - ui.horizontal(|ui| { - View::small_loading_spinner(ui); - ui.add_space(8.0); - ui.label( - RichText::new(t!("goblin.settings.releasing")) - .color(t.surface_text_dim), - ); - }); - ui.ctx().request_repaint(); - } else { - ui.horizontal(|ui| { - let half = (ui.available_width() - 10.0) / 2.0; - ui.scope_builder( - egui::UiBuilder::new().max_rect(egui::Rect::from_min_size( - ui.cursor().min, - Vec2::new(half, 44.0), - )), - |ui| { - if w::big_action_on_card_ink( - ui, - &t!("goblin.settings.keep_it"), - t.surface_text, - ) - .clicked() - { - claim.confirm_release = false; - } - }, - ); - ui.add_space(10.0); - ui.scope_builder( - egui::UiBuilder::new().max_rect(egui::Rect::from_min_size( - ui.cursor().min, - Vec2::new(half, 44.0), - )), - |ui| { - if w::big_action_on_card_ink( - ui, - &t!("goblin.settings.release_it"), - t.neg, - ) - .clicked() - { - start_release(claim, &name, wallet); - } - }, - ); - }); - } - } else { - ui.label( - RichText::new(t!("goblin.settings.username")) - .font(FontId::new(15.0, fonts::semibold())) - .color(t.surface_text), - ); - ui.add_space(4.0); - ui.label( - RichText::new(name.to_string()) - .font(FontId::new(20.0, fonts::bold())) - .color(t.surface_text), - ); - ui.add_space(4.0); - ui.label( - RichText::new(t!("goblin.settings.username_note")) - .font(FontId::new(12.0, fonts::regular())) - .color(t.surface_text_mute), - ); - if let Some(msg) = &claim.message { - ui.add_space(6.0); - ui.label( - RichText::new(msg) - .font(FontId::new(13.0, fonts::regular())) - .color(match claim.available { - Some(false) => t.neg, - Some(true) => t.pos, - None => t.surface_text_dim, - }), - ); - } - ui.add_space(10.0); - if w::big_action_on_card_ink(ui, &t!("goblin.settings.release_username"), t.neg) - .clicked() - { - claim.confirm_release = true; - claim.message = None; - } - } - } else { - ui.label( - RichText::new(t!("goblin.settings.pick_username")) - .font(FontId::new(15.0, fonts::semibold())) - .color(t.surface_text), - ); - ui.add_space(8.0); - // Placeholder shows the bare handle ("yourname") — we never display - // the "@" to users. A leading "@" the user happens to type is still - // stripped when the name is read below. - let before = claim.input.clone(); - TextEdit::new(egui::Id::from("settings_claim")) - .focus(false) - .hint_text(t!("goblin.onboarding.identity.username_field_hint")) - .text_color(t.surface_text) - .body() - .ui(ui, &mut claim.input, cb); - if claim.input != before { - claim.available = None; - claim.message = None; - } - ui.add_space(4.0); - ui.label( - RichText::new(t!("goblin.settings.username_note")) - .font(FontId::new(12.0, fonts::regular())) - .color(t.surface_text_mute), - ); - if let Some(msg) = &claim.message { - ui.add_space(6.0); - ui.label( - RichText::new(msg) - .font(FontId::new(13.0, fonts::regular())) - .color(match claim.available { - Some(false) => t.neg, - Some(true) => t.pos, - None => t.surface_text_dim, - }), - ); - } - ui.add_space(10.0); - let name = claim.input.trim().trim_start_matches('@').to_lowercase(); - let valid = name.len() >= 3 && name.len() <= 20; - if claim.checking { - ui.horizontal(|ui| { - View::small_loading_spinner(ui); - ui.add_space(8.0); - ui.label( - RichText::new(t!("goblin.settings.working")).color(t.surface_text_dim), - ); - }); - ui.ctx().request_repaint(); - } else { - ui.add_enabled_ui(valid, |ui| { - if w::big_action(ui, &t!("goblin.settings.claim"), false).clicked() { - start_claim_flow(claim, &name, wallet); - } - }); - } - } - }); - } -} - -/// Spawn the combined claim: availability check first, then registration -/// in the same worker — one button, no separate Check step. -fn start_claim_flow(claim: &mut ClaimState, name: &str, wallet: &Wallet) { - let Some(service) = wallet.nostr_service() else { - return; - }; - let server = service.config.read().nip05_server(); - // Reuse the service's keys directly — never round-trip the secret through a - // plaintext nsec String to rebuild keys the service already holds. - let keys = service.keys(); - claim.checking = true; - claim.message = None; - claim.available = None; - let slot = claim.result.clone(); - let name = name.to_string(); - std::thread::spawn(move || { - let rt = match tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - { - Ok(rt) => rt, - Err(_) => return, - }; - let msg = rt.block_on(async { - use crate::nostr::nip05::{Availability, RegisterResult, check_availability, register}; - match check_availability(&server, &name).await { - Availability::Available => match register(&server, &name, &keys).await { - RegisterResult::Ok(nip05) => ClaimMsg::Registered(nip05), - RegisterResult::Conflict(_) => { - ClaimMsg::Error(t!("goblin.settings.err_just_taken").to_string()) - } - RegisterResult::Rejected(e) if e == "name_change_cooldown" => { - ClaimMsg::Error(t!("goblin.settings.err_cooldown").to_string()) - } - RegisterResult::Rejected(e) => ClaimMsg::Error(e), - RegisterResult::Network => { - ClaimMsg::Error(t!("goblin.settings.err_unreachable").to_string()) - } - }, - other => ClaimMsg::Availability(other), - } - }); - *slot.lock().unwrap() = Some(msg); - }); -} - -/// Spawn the username release; the server deletes its avatar with it. -fn start_release(claim: &mut ClaimState, name: &str, wallet: &Wallet) { - let Some(service) = wallet.nostr_service() else { - return; - }; - let server = service.config.read().nip05_server(); - // Reuse the service's keys directly — never round-trip the secret through a - // plaintext nsec String to rebuild keys the service already holds. - let keys = service.keys(); - claim.checking = true; - claim.message = None; - let slot = claim.result.clone(); - let name = name.to_string(); - std::thread::spawn(move || { - let rt = match tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - { - Ok(rt) => rt, - Err(_) => return, - }; - // Release is always allowed server-side (it's what arms the cooldown), - // so there's no cooldown rejection to handle here. - let msg = match rt.block_on(crate::nostr::nip05::unregister(&server, &name, &keys)) { - Ok(()) => ClaimMsg::Released, - Err(e) => ClaimMsg::Error(t!("goblin.settings.err_release", err => e).to_string()), - }; - *slot.lock().unwrap() = Some(msg); - }); } /// Process a picked picture and upload it as the avatar for an owned name. -/// Draw the small Goblin mascot mark. -pub fn widgets_logo(ui: &mut egui::Ui) { - widgets_logo_sized(ui, 24.0); -} - -/// Tinted goblin mark at a given size. -pub fn widgets_logo_sized(ui: &mut egui::Ui, size: f32) { - let (rect, _) = ui.allocate_exact_size(Vec2::splat(size), Sense::hover()); - // Chip-sized marks use a pre-rendered 48px raster: cleaner antialiasing - // at ~24px than runtime svg rasterization, with 2x headroom for hidpi. - let img = egui::Image::new(if size <= 32.0 { - egui::include_image!("../../../../img/goblin-logo2-48.png") - } else { - egui::include_image!("../../../../img/goblin-logo2.svg") - }) - .tint(theme::tokens().text) - .fit_to_exact_size(Vec2::splat(size)); - img.paint_at(ui, rect); -} - -fn empty_state(ui: &mut egui::Ui, title: &str, subtitle: &str) { - let t = theme::tokens(); - ui.add_space(40.0); - ui.vertical_centered(|ui| { - ui.label( - RichText::new(title) - .font(FontId::new(17.0, fonts::semibold())) - .color(t.text), - ); - ui.add_space(4.0); - ui.label( - RichText::new(subtitle) - .font(FontId::new(14.0, fonts::regular())) - .color(t.text_dim), - ); - }); -} - -fn settings_group(ui: &mut egui::Ui, title: &str, add: impl FnOnce(&mut egui::Ui)) { - w::kicker(ui, title); - ui.add_space(8.0); - w::card(ui, |ui| add(ui)); -} - -/// Title row for an Advanced-page action card. -fn advanced_head(ui: &mut egui::Ui, label: &str, color: Color32) { - ui.label( - RichText::new(label) - .font(FontId::new(15.0, fonts::semibold())) - .color(color), - ); - ui.add_space(4.0); -} - -/// Wrapped description line under an Advanced-page action title. -fn advanced_desc(ui: &mut egui::Ui, text: &str) { - let t = theme::tokens(); - ui.label( - RichText::new(text) - .font(FontId::new(13.0, fonts::regular())) - .color(t.surface_text_dim), - ); -} - -/// A settings row: label + subtitle on the left, an on/off switch on the right. -/// Returns `Some(new_value)` on the frame it is toggled. -fn settings_row_toggle(ui: &mut egui::Ui, label: &str, sub: &str, on: bool) -> Option { - 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 -} - -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. -fn settings_row_ink(ui: &mut egui::Ui, label: &str, value: &str, value_ink: Color32) { - let t = theme::tokens(); - ui.horizontal(|ui| { - ui.label( - RichText::new(label) - .font(FontId::new(15.0, fonts::medium())) - .color(t.surface_text), - ); - ui.with_layout(Layout::right_to_left(Align::Center), |ui| { - ui.label( - RichText::new(value) - .font(FontId::new(13.0, fonts::regular())) - .color(value_ink), - ); - }); - }); - ui.add_space(10.0); -} - -fn settings_row_btn(ui: &mut egui::Ui, label: &str, icon: &str) -> bool { - let t = theme::tokens(); - let mut clicked = false; - let row = ui.horizontal(|ui| { - ui.label( - RichText::new(label) - .font(FontId::new(15.0, fonts::medium())) - .color(t.surface_text), - ); - ui.with_layout(Layout::right_to_left(Align::Center), |ui| { - let resp = ui.label( - RichText::new(icon) - .font(FontId::new(18.0, fonts::regular())) - .color(t.surface_text_dim), - ); - if resp.interact(Sense::click()).clicked() { - clicked = true; - } - }); - }); - ui.add_space(10.0); - // The whole row is tappable, not just the trailing value/icon. - clicked || row.response.interact(Sense::click()).clicked() -} - -/// A danger-styled settings row button (whole row taps). -fn settings_row_danger(ui: &mut egui::Ui, label: &str, icon: &str) -> bool { - let t = theme::tokens(); - let row = ui.horizontal(|ui| { - ui.label( - RichText::new(label) - .font(FontId::new(15.0, fonts::medium())) - .color(t.neg), - ); - ui.with_layout(Layout::right_to_left(Align::Center), |ui| { - ui.label( - RichText::new(icon) - .font(FontId::new(18.0, fonts::regular())) - .color(t.neg), - ); - }); - }); - ui.add_space(10.0); - row.response.interact(Sense::click()).clicked() -} - -/// A settings row whose value cycles in place on tap (no navigation): the -/// value is drawn in the same small/dim style as [`settings_row_nav`] so it -/// sits consistently next to chevroned siblings, just without the chevron. -fn settings_row_cycle(ui: &mut egui::Ui, label: &str, value: &str) -> bool { - let t = theme::tokens(); - let row = ui.horizontal(|ui| { - ui.label( - RichText::new(label) - .font(FontId::new(15.0, fonts::medium())) - .color(t.surface_text), - ); - ui.with_layout(Layout::right_to_left(Align::Center), |ui| { - ui.label( - RichText::new(value) - .font(FontId::new(13.0, fonts::regular())) - .color(t.surface_text_dim), - ); - }); - }); - ui.add_space(10.0); - row.response.interact(Sense::click()).clicked() -} - -/// A settings row that navigates somewhere: value + chevron, whole row taps. -fn settings_row_nav(ui: &mut egui::Ui, label: &str, value: &str) -> bool { - let t = theme::tokens(); - let row = ui.horizontal(|ui| { - ui.label( - RichText::new(label) - .font(FontId::new(15.0, fonts::medium())) - .color(t.surface_text), - ); - ui.with_layout(Layout::right_to_left(Align::Center), |ui| { - ui.label( - RichText::new(crate::gui::icons::CARET_RIGHT) - .font(FontId::new(13.0, fonts::regular())) - .color(t.surface_text_mute), - ); - ui.add_space(4.0); - ui.label( - RichText::new(value) - .font(FontId::new(13.0, fonts::regular())) - .color(t.surface_text_dim), - ); - }); - }); - ui.add_space(10.0); - row.response.interact(Sense::click()).clicked() -} - -/// One channel row on the Network-privacy page: a status dot, a title and a -/// wrapped blurb explaining where that traffic goes. -fn privacy_line(ui: &mut egui::Ui, dot: Color32, title: &str, blurb: &str) { - let t = theme::tokens(); - ui.horizontal_top(|ui| { - let (rect, _) = ui.allocate_exact_size(Vec2::new(14.0, 20.0), Sense::hover()); - ui.painter() - .circle_filled(rect.center() + Vec2::new(0.0, -2.0), 4.0, dot); - ui.vertical(|ui| { - ui.label( - RichText::new(title) - .font(FontId::new(14.0, fonts::semibold())) - .color(t.surface_text), - ); - ui.add_space(2.0); - ui.label( - RichText::new(blurb) - .font(FontId::new(12.0, fonts::regular())) - .color(t.surface_text_dim), - ); - }); - }); - ui.add_space(10.0); -} - -/// 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. -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). -fn network_privacy_panels(ui: &mut egui::Ui, tor_on: bool) -> Option { - 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". -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"), - } -} - -/// Open a URL in the system browser. -fn open_url(ui: &egui::Ui, url: &str) { - ui.ctx().open_url(egui::OpenUrl::new_tab(url)); -} - -/// Linear blend between two colors (`p` 0→`a`, 1→`b`). Used by the Pay-screen -/// over-balance flash to ease the digits from red back to normal ink. -fn lerp_color(a: Color32, b: Color32, p: f32) -> Color32 { - let p = p.clamp(0.0, 1.0); - let mix = |x: u8, y: u8| (x as f32 + (y as f32 - x as f32) * p).round() as u8; - Color32::from_rgb(mix(a.r(), b.r()), mix(a.g(), b.g()), mix(a.b(), b.b())) -} - -fn approve_button(ui: &mut egui::Ui) -> bool { - w::big_action(ui, &t!("goblin.request.approve"), false).clicked() -} - -fn decline_button(ui: &mut egui::Ui) -> bool { - w::big_action(ui, &t!("goblin.request.decline"), true).clicked() -} - -fn accept_policy_label(wallet: &Wallet) -> String { - use crate::nostr::config::AcceptPolicy; - wallet - .nostr_service() - .map(|s| match s.config.read().accept_from() { - AcceptPolicy::Everyone => t!("goblin.settings.accept_anyone").to_string(), - AcceptPolicy::Contacts => t!("goblin.settings.accept_contacts").to_string(), - AcceptPolicy::Ask => t!("goblin.settings.accept_ask").to_string(), - }) - .unwrap_or_else(|| t!("goblin.settings.accept_anyone").to_string()) -} - -/// Cycle the color theme Dark ↔ Light and re-apply visuals. Yellow is kept -/// defined (gui/theme.rs) but out of the picker for now — it's still in beta; -/// `Yellow => Dark` is an escape hatch for anyone whose config already has it. -fn cycle_theme(ctx: &egui::Context) { - use crate::gui::theme::ThemeKind; - let next = match crate::AppConfig::theme() { - ThemeKind::Dark => ThemeKind::Light, - ThemeKind::Light => ThemeKind::Dark, - ThemeKind::Yellow => ThemeKind::Dark, - }; - crate::AppConfig::set_theme(next); - crate::setup_visuals(ctx); -} - -/// Cycle the density Comfy → Regular → Compact → Comfy. -/// Cycle the incoming-payment accept policy Anyone → Contacts → Ask → Anyone. -fn cycle_accept_policy(wallet: &Wallet) { - use crate::nostr::config::AcceptPolicy; - if let Some(s) = wallet.nostr_service() { - let next = match s.config.read().accept_from() { - AcceptPolicy::Everyone => AcceptPolicy::Contacts, - AcceptPolicy::Contacts => AcceptPolicy::Ask, - AcceptPolicy::Ask => AcceptPolicy::Everyone, - }; - s.config.write().set_accept_from(next); - } -} - -fn relay_summary(wallet: &Wallet) -> String { - wallet - .nostr_service() - .map(|s| { - let relays = s.relays(); - match relays.len() { - 0 => t!("goblin.relays.none").to_string(), - 1 => relays[0].replace("wss://", ""), - n => t!("goblin.relays.count", n => n).to_string(), - } - }) - .unwrap_or_else(|| "—".to_string()) -} - -/// Compute a fiat preview line for the balance, when a rate is available. -/// One-line node summary: "Block 1,847,221 · main.gri.mw". -/// Bare node host (or "integrated node") for the sidebar card's third line. -fn node_host(wallet: &Wallet) -> String { - match wallet.get_current_connection() { - crate::wallet::types::ConnectionMethod::Integrated => { - t!("goblin.node.integrated_host").to_string() - } - crate::wallet::types::ConnectionMethod::External(_, url) => url - .replace("https://", "") - .replace("http://", "") - .trim_end_matches('/') - .to_string(), - } -} - -fn node_summary(wallet: &Wallet) -> String { - let height = wallet - .get_data() - .map(|d| d.info.last_confirmed_height) - .unwrap_or(0); - let conn = match wallet.get_current_connection() { - crate::wallet::types::ConnectionMethod::Integrated => { - t!("goblin.node.integrated_host").to_string() - } - crate::wallet::types::ConnectionMethod::External(_, url) => url - .replace("https://", "") - .replace("http://", "") - .trim_end_matches('/') - .to_string(), - }; - if height == 0 { - t!("goblin.node.summary_syncing", conn => conn).to_string() - } else { - t!("goblin.node.summary_block", height => fmt_thousands(height), conn => conn).to_string() - } -} - -/// Format a number with thousands separators. -fn fmt_thousands(n: u64) -> String { - let s = n.to_string(); - let mut out = String::with_capacity(s.len() + s.len() / 3); - for (i, c) in s.chars().enumerate() { - if i > 0 && (s.len() - i) % 3 == 0 { - out.push(','); - } - out.push(c); - } - out -} - -fn fiat_line(data: &Option) -> Option { - 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, - }) -} - -/// Number of dots a censored money value renders as. FIXED (never the real -/// digit count) so anonymous mode can't leak the balance magnitude. -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. -const CENSOR_NAME_DOTS: &str = "••••••"; - -/// 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. -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. -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. -fn fmt_pairing(value: f64, p: crate::settings::Pairing) -> String { - use crate::settings::Pairing; - match p { - Pairing::Usd => format!("${:.2}", value), - Pairing::Eur => format!("€{:.2}", value), - Pairing::Gbp => format!("£{:.2}", value), - Pairing::Jpy => format!("¥{:.0}", value), - Pairing::Cny => format!("CN¥{:.2}", value), - Pairing::Btc => { - let s = format!("{:.8}", value); - let s = s.trim_end_matches('0').trim_end_matches('.'); - format!("₿{}", if s.is_empty() { "0" } else { s }) - } - Pairing::Sats => format!("{} sats", fmt_thousands((value * 1e8).round() as u64)), - Pairing::Off => String::new(), - } -} - -/// The "≈ …" amount preview for the current pairing, or `None` when off / no -/// rate yet. Shared by the Pay screen, the send flow, and the balance hero. -fn pairing_preview(grin: f64, ctx: &egui::Context) -> Option { - use crate::http::RateState; - let p = crate::AppConfig::pairing(); - let vs = p.vs_currency()?; - match crate::http::grin_rate(vs) { - RateState::Fresh(rate) => Some(format!("≈ {}", fmt_pairing(grin * rate, p))), - // No stale fallback: show nothing until a fresh rate lands. Nudge a repaint - // while loading so the preview appears once the live fetch returns. - RateState::Loading => { - ctx.request_repaint_after(std::time::Duration::from_millis(300)); - None - } - RateState::Unavailable => None, - } -} - -/// Convert a bech32 npub to hex for short display fallbacks. -fn hex_of(npub: &str) -> String { - use nostr_sdk::{FromBech32, PublicKey}; - PublicKey::from_bech32(npub) - .map(|pk| pk.to_hex()) - .unwrap_or_else(|_| npub.to_string()) -} - -/// Largest point size in `[12.0, 16.0]` at which the semibold news title fits on -/// one line within `avail` px, measured against the live font atlas and stepping -/// down by 0.5. Returns the 12pt floor when even that overflows (the caller pairs -/// it with `.truncate()`). This is the shrink-to-fit safety net that keeps a -/// title readable on a 390px screen; the hard char cap (`news_title_clamped`) is -/// the predictable ceiling. -fn fit_news_title_pt(ui: &egui::Ui, text: &str, avail: f32) -> f32 { - const CEIL: f32 = 16.0; - const FLOOR: f32 = 12.0; - let mut pt = CEIL; - while pt > FLOOR { - let w = ui - .painter() - .layout_no_wrap( - text.to_owned(), - FontId::new(pt, fonts::semibold()), - egui::Color32::WHITE, - ) - .size() - .x; - if w <= avail { - return pt; - } - pt -= 0.5; - } - FLOOR -} - #[cfg(test)] mod anon_censor_tests { - use super::{ - CENSOR_DOT_COUNT, CENSOR_NAME_DOTS, SettingsPage, Tab, censored_amount_dots, - settings_page_after, - }; - - /// 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" - ); - } + use super::{SettingsPage, Tab, settings_page_after}; /// Leaving the Settings tab resets the sub-page to the root, so re-entering /// always lands on the top-level Settings page. Staying on the Settings tab diff --git a/src/gui/views/goblin/modals.rs b/src/gui/views/goblin/modals.rs new file mode 100644 index 00000000..0759be1b --- /dev/null +++ b/src/gui/views/goblin/modals.rs @@ -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::() { + 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::().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 = None; + let mut to_resume: Option = None; + for s in &summaries { + settings_group(ui, &s.domain, |ui| { + // The low-tier categories this session signs silently. + let cats: Vec = 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)); + } +} diff --git a/src/gui/views/goblin/pay.rs b/src/gui/views/goblin/pay.rs new file mode 100644 index 00000000..926a9e2b --- /dev/null +++ b/src/gui/views/goblin/pay.rs @@ -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::() { + 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), + ); + }); + } + } +} diff --git a/src/gui/views/goblin/privacy.rs b/src/gui/views/goblin/privacy.rs new file mode 100644 index 00000000..d481943f --- /dev/null +++ b/src/gui/views/goblin/privacy.rs @@ -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 { + 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); + }); + } +} diff --git a/src/gui/views/goblin/profile.rs b/src/gui/views/goblin/profile.rs new file mode 100644 index 00000000..48fdfb90 --- /dev/null +++ b/src/gui/views/goblin/profile.rs @@ -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> = 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 = 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 + } +} diff --git a/src/gui/views/goblin/prompts.rs b/src/gui/views/goblin/prompts.rs new file mode 100644 index 00000000..78509f1f --- /dev/null +++ b/src/gui/views/goblin/prompts.rs @@ -0,0 +1,1143 @@ +// 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. + +//! Session / authorization hold-to-confirm approval modals. + +use super::*; + +impl GoblinWalletView { + /// Content of the "Sign in with Goblin" approval modal: the requesting + /// domain up top, the signing identity (a picker when several are held; + /// the truncated npub is always visible as the anchor), a one-line plain + /// explanation, the wallet password, and uniform paired Cancel / Sign in + /// buttons. Approving signs the one-time kind-22242 challenge with the + /// CHOSEN identity's key and POSTs it to the callback off the UI thread; + /// a wrong password stays in the modal without consuming the request. + pub(super) fn login_modal_content( + &mut self, + ui: &mut egui::Ui, + modal: &Modal, + wallet: &Wallet, + cb: &dyn PlatformCallbacks, + ) { + let Some(st) = self.login.as_mut() else { + Modal::close(); + return; + }; + if st.posting { + // Already signed and in flight; nothing left to gate here. + Modal::close(); + return; + } + let domain = st.uri.domain.clone(); + let identities = wallet.nostr_identities(); + let mut go = false; + let mut cancel = false; + ui.vertical_centered(|ui| { + ui.add_space(6.0); + // Headline with the requesting domain prominent. + ui.label( + RichText::new(t!("goblin.login.headline", domain => domain.clone())) + .size(17.0) + .color(Colors::title(false)), + ); + ui.add_space(10.0); + // The signing identity. Display precedence is the switcher's: + // private tag, else bare claimed name, else truncated npub — and + // the truncated npub is ALWAYS shown as the anchor. + ui.label( + RichText::new(t!("goblin.login.identity")) + .size(13.0) + .color(Colors::gray()), + ); + ui.add_space(6.0); + if identities.len() > 1 { + // Identity picker: the held-identities list, tap to choose + // which one signs. Defaults to the active identity. + for id in &identities { + let selected = st.selected == id.pubkey_hex; + let title = id.display(); + let short = data::short_npub(&id.pubkey_hex); + let row = ui + .scope(|ui| { + ui.horizontal(|ui| { + ui.add_space(4.0); + ui.label( + RichText::new(if selected { + crate::gui::icons::CHECK_CIRCLE + } else { + crate::gui::icons::CIRCLE + }) + .size(18.0) + .color(if selected { + Colors::green() + } else { + Colors::gray() + }), + ); + ui.add_space(8.0); + ui.vertical(|ui| { + if title != short { + ui.label( + RichText::new(&title) + .size(15.0) + .color(Colors::text(false)), + ); + } + ui.label( + RichText::new(&short).size(12.5).color(Colors::gray()), + ); + }); + }); + }) + .response + .rect; + let hit = ui.interact( + row, + egui::Id::from(modal.id).with(("login_id", id.pubkey_hex.as_str())), + Sense::click(), + ); + if hit + .on_hover_cursor(egui::CursorIcon::PointingHand) + .clicked() + { + st.selected = id.pubkey_hex.clone(); + } + ui.add_space(4.0); + } + } else if let Some(id) = identities.first() { + let title = id.display(); + let short = data::short_npub(&id.pubkey_hex); + if title != short { + ui.label(RichText::new(&title).size(15.0).color(Colors::text(false))); + } + ui.label(RichText::new(&short).size(12.5).color(Colors::gray())); + } + ui.add_space(10.0); + // One plain line on what approving does (and does not do). + ui.label( + RichText::new(t!("goblin.login.explain")) + .size(13.0) + .color(Colors::gray()), + ); + ui.add_space(10.0); + // The wallet password gates the signature, mirroring the identity + // password modal (masked field, same wrong-password line). + ui.label( + RichText::new(t!("goblin.login.pass_prompt")) + .size(16.0) + .color(Colors::gray()), + ); + ui.add_space(10.0); + let mut field = TextEdit::new(egui::Id::from(modal.id).with("login_pass")).password(); + field.ui(ui, &mut st.pass, cb); + if field.enter_pressed { + go = true; + } + if st.pass.is_empty() { + st.wrong_pass = false; + } else if st.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!("goblin.login.confirm"), + Colors::white_or_black(false), + || { + go = true; + }, + ); + }); + }); + ui.add_space(6.0); + }); + if cancel { + // Cancel drops the request: it is single-use, no retry. + self.login = None; + Modal::close(); + return; + } + if go { + let (pass, selected) = match self.login.as_ref() { + Some(st) => (st.pass.clone(), st.selected.clone()), + None => return, + }; + if pass.is_empty() { + return; + } + if !wallet.verify_nostr_password(&pass) { + // Wrong password: stay in the modal, request NOT consumed. + if let Some(st) = self.login.as_mut() { + st.wrong_pass = true; + } + return; + } + // The chosen identity's unlocked in-memory keys, from the running + // service (the Build-145 model: every held identity is unlocked). + let keys = wallet.nostr_service().and_then(|s| { + s.recv_snapshot() + .into_iter() + .find(|h| h.keys.public_key().to_hex() == selected) + .map(|h| h.keys) + }); + let Some(keys) = keys else { + // No running service / identity gone: drop the request. + self.login = None; + Modal::close(); + return; + }; + let st = self.login.as_mut().unwrap(); + st.pass.clear(); + st.wrong_pass = false; + match crate::nostr::loginuri::build_login_event( + &keys, + &st.uri.challenge, + &st.uri.domain, + ) { + Ok(event) => { + // Signed: the request is consumed from here on, whatever + // the POST outcome. Deliver off the UI thread with the + // shared HTTP client and a hard timeout. + st.posting = true; + let callback = st.uri.callback.clone(); + let slot = st.result.clone(); + std::thread::spawn(move || { + let res = match tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + { + Ok(rt) => rt.block_on(async { + let post = + crate::nostr::loginuri::post_login_event(&callback, &event); + match tokio::time::timeout( + std::time::Duration::from_secs(LOGIN_POST_TIMEOUT_SECS), + post, + ) + .await + { + Ok(r) => r, + Err(_) => Err("timeout".to_string()), + } + }), + Err(e) => Err(e.to_string()), + }; + *slot.lock().unwrap() = Some(res); + }); + Modal::close(); + // The return-to-caller decision is DEFERRED to the outcome + // poll: returning now would background the app with the POST + // still in flight, stop the frame pump, and strand the + // completion work (the Build 153 QR-trust bug). The app stays + // foreground (frames pumping) until the POST result lands. + } + Err(e) => { + // Signing failed (never expected): consume the request and + // surface the quiet failure toast. + log::error!("login event signing failed: {e}"); + self.login_toast = Some(( + t!("goblin.login.failed", domain => domain).to_string(), + std::time::Instant::now(), + )); + self.login = None; + Modal::close(); + } + } + } + } + + /// The "Authorize with Goblin" approval modal: headline, the rendered event + /// (kind label, escaped content preview with an optional show-full view, and + /// per-kind key-tag summary), the identity picker, the password gate, and the + /// Cancel/Authorize buttons. Mirrors [`Self::login_modal_content`]; on + /// confirm it signs one event with the chosen identity and POSTs it off the + /// UI thread. Signed = consumed, whatever the POST outcome. + pub(super) fn authorize_modal_content( + &mut self, + ui: &mut egui::Ui, + modal: &Modal, + wallet: &Wallet, + cb: &dyn PlatformCallbacks, + ) { + use crate::nostr::authuri; + let Some(st) = self.authorize.as_mut() else { + Modal::close(); + return; + }; + if st.posting { + // Already signed and in flight; nothing left to gate here. + Modal::close(); + return; + } + let domain = st.uri.domain.clone(); + // Clone the small template once so the read-only render borrows nothing + // from `st` while its password/show-full fields are mutated below. + let template = st.uri.template.clone(); + let kind = template.kind; + let label = authuri::kind_label(kind); + let (preview, remaining) = authuri::content_preview(&template.content); + let preview_esc = authuri::escape_for_display(&preview); + let full_esc = authuri::escape_for_display(&template.content); + // Key tags, all escaped before display. + let e_tag = template + .first_tag_value("e") + .map(|v| authuri::escape_for_display(&authuri::truncate_id(v))); + let p_tag = template + .first_tag_value("p") + .map(|v| authuri::escape_for_display(&authuri::truncate_id(v))); + let title = template + .first_tag_value("title") + .map(authuri::escape_for_display); + let identities = wallet.nostr_identities(); + let mut go = false; + let mut cancel = false; + ui.vertical_centered(|ui| { + ui.add_space(6.0); + // Headline with the requesting domain prominent. + ui.label( + RichText::new(t!("goblin.authorize.headline", domain => domain.clone())) + .size(17.0) + .color(Colors::title(false)), + ); + ui.add_space(10.0); + // The plain-language kind label (and, for an off-allowlist kind that + // v1 cannot actually reach, a caution line). + let kind_text = match label { + authuri::KindLabel::Post => t!("goblin.authorize.kind_post"), + authuri::KindLabel::Repost => t!("goblin.authorize.kind_repost"), + authuri::KindLabel::Reaction => t!("goblin.authorize.kind_reaction"), + authuri::KindLabel::Article => t!("goblin.authorize.kind_article"), + authuri::KindLabel::Unknown => { + t!("goblin.authorize.kind_unknown", n => kind.to_string()) + } + }; + ui.label( + RichText::new(kind_text) + .size(15.0) + .color(Colors::text(false)), + ); + if label == authuri::KindLabel::Unknown { + ui.add_space(4.0); + ui.label( + RichText::new(t!("goblin.authorize.unknown_caution", domain => domain.clone())) + .size(12.5) + .color(Colors::red()), + ); + } + ui.add_space(8.0); + // Per-kind rendering of the event body and its key tags. All + // requester-controlled text is escaped before it hits a label. + let show_preview = |ui: &mut egui::Ui| { + if !preview_esc.is_empty() { + ui.label(RichText::new(&preview_esc).size(13.5).color(Colors::gray())); + } + }; + match kind { + 7 => { + // Reaction: the reaction glyph (emoji or "+"), then target. + let reaction = if full_esc.is_empty() { + "+".to_string() + } else { + full_esc.clone() + }; + ui.label( + RichText::new(reaction) + .size(20.0) + .color(Colors::text(false)), + ); + ui.add_space(4.0); + if let Some(id) = &e_tag { + ui.label( + RichText::new(t!("goblin.authorize.reacts_to", id => id.clone())) + .size(12.5) + .color(Colors::gray()), + ); + } + if let Some(id) = &p_tag { + ui.label( + RichText::new(t!("goblin.authorize.by_author", id => id.clone())) + .size(12.5) + .color(Colors::gray()), + ); + } + } + 6 => { + // Repost: reposted event id and author. + if let Some(id) = &e_tag { + ui.label( + RichText::new(t!("goblin.authorize.repost_of", id => id.clone())) + .size(12.5) + .color(Colors::gray()), + ); + } + if let Some(id) = &p_tag { + ui.label( + RichText::new(t!("goblin.authorize.by_author", id => id.clone())) + .size(12.5) + .color(Colors::gray()), + ); + } + } + 30023 => { + // Article: title tag if present, then the content preview. + if let Some(t) = &title { + ui.label( + RichText::new(t!("goblin.authorize.article_title", title => t.clone())) + .size(14.0) + .color(Colors::text(false)), + ); + ui.add_space(4.0); + } + show_preview(ui); + } + _ => { + // Kind 1 (and the unreachable fallback): content preview, then + // the reply/mention tags. + show_preview(ui); + if let Some(id) = &e_tag { + ui.add_space(4.0); + ui.label( + RichText::new(t!("goblin.authorize.replying_to", id => id.clone())) + .size(12.5) + .color(Colors::gray()), + ); + } + if let Some(id) = &p_tag { + ui.label( + RichText::new(t!("goblin.authorize.mentions", id => id.clone())) + .size(12.5) + .color(Colors::gray()), + ); + } + } + } + // Truncation marker plus the mandatory show-full affordance. Approval + // is never blocked on opening it. + if remaining > 0 { + ui.add_space(6.0); + ui.label( + RichText::new(t!("goblin.authorize.truncated", n => remaining.to_string())) + .size(12.0) + .color(Colors::gray()), + ); + let toggle = if st.show_full { + t!("goblin.authorize.show_less") + } else { + t!("goblin.authorize.show_full") + }; + let rect = ui + .label(RichText::new(toggle).size(13.0).color(Colors::green())) + .rect; + let hit = ui.interact( + rect, + egui::Id::from(modal.id).with("auth_showfull"), + Sense::click(), + ); + if hit + .on_hover_cursor(egui::CursorIcon::PointingHand) + .clicked() + { + st.show_full = !st.show_full; + } + if st.show_full { + ui.add_space(6.0); + ScrollArea::vertical() + .max_height(160.0) + .auto_shrink([false, true]) + .show(ui, |ui| { + ui.label(RichText::new(&full_esc).size(13.0).color(Colors::gray())); + }); + } + } + ui.add_space(10.0); + // The signing identity. Display precedence is the switcher's: private + // tag, else bare claimed name, else truncated npub, and the truncated + // npub is ALWAYS shown as the anchor. Defaults to the active identity. + ui.label( + RichText::new(t!("goblin.authorize.identity")) + .size(13.0) + .color(Colors::gray()), + ); + ui.add_space(6.0); + if identities.len() > 1 { + for id in &identities { + let selected = st.selected == id.pubkey_hex; + let name = id.display(); + let short = data::short_npub(&id.pubkey_hex); + let row = ui + .scope(|ui| { + ui.horizontal(|ui| { + ui.add_space(4.0); + ui.label( + RichText::new(if selected { + crate::gui::icons::CHECK_CIRCLE + } else { + crate::gui::icons::CIRCLE + }) + .size(18.0) + .color(if selected { + Colors::green() + } else { + Colors::gray() + }), + ); + ui.add_space(8.0); + ui.vertical(|ui| { + if name != short { + ui.label( + RichText::new(&name) + .size(15.0) + .color(Colors::text(false)), + ); + } + ui.label( + RichText::new(&short).size(12.5).color(Colors::gray()), + ); + }); + }); + }) + .response + .rect; + let hit = ui.interact( + row, + egui::Id::from(modal.id).with(("auth_id", id.pubkey_hex.as_str())), + Sense::click(), + ); + if hit + .on_hover_cursor(egui::CursorIcon::PointingHand) + .clicked() + { + st.selected = id.pubkey_hex.clone(); + } + ui.add_space(4.0); + } + } else if let Some(id) = identities.first() { + let name = id.display(); + let short = data::short_npub(&id.pubkey_hex); + if name != short { + ui.label(RichText::new(&name).size(15.0).color(Colors::text(false))); + } + ui.label(RichText::new(&short).size(12.5).color(Colors::gray())); + } + ui.add_space(10.0); + // One plain line on what approving does (and does not do). + ui.label( + RichText::new(t!("goblin.authorize.explain", domain => domain.clone())) + .size(13.0) + .color(Colors::gray()), + ); + ui.add_space(10.0); + // The wallet password gates the signature, mirroring the identity + // password modal (masked field, same wrong-password line). + ui.label( + RichText::new(t!("goblin.authorize.pass_prompt")) + .size(16.0) + .color(Colors::gray()), + ); + ui.add_space(10.0); + let mut field = TextEdit::new(egui::Id::from(modal.id).with("auth_pass")).password(); + field.ui(ui, &mut st.pass, cb); + if field.enter_pressed { + go = true; + } + if st.pass.is_empty() { + st.wrong_pass = false; + } else if st.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!("goblin.authorize.confirm"), + Colors::white_or_black(false), + || { + go = true; + }, + ); + }); + }); + ui.add_space(6.0); + }); + if cancel { + // Cancel drops the request: it is single-use, no retry. + self.authorize = None; + Modal::close(); + return; + } + if go { + let (pass, selected) = match self.authorize.as_ref() { + Some(st) => (st.pass.clone(), st.selected.clone()), + None => return, + }; + if pass.is_empty() { + return; + } + if !wallet.verify_nostr_password(&pass) { + // Wrong password: stay in the modal, request NOT consumed. + if let Some(st) = self.authorize.as_mut() { + st.wrong_pass = true; + } + return; + } + // The chosen identity's unlocked in-memory keys, from the running + // service (the Build-145 model: every held identity is unlocked). + let keys = wallet.nostr_service().and_then(|s| { + s.recv_snapshot() + .into_iter() + .find(|h| h.keys.public_key().to_hex() == selected) + .map(|h| h.keys) + }); + let Some(keys) = keys else { + // No running service / identity gone: drop the request. + self.authorize = None; + Modal::close(); + return; + }; + let st = self.authorize.as_mut().unwrap(); + st.pass.clear(); + st.wrong_pass = false; + match crate::nostr::authuri::build_authorize_event(&keys, &st.uri.template) { + Ok(event) => { + // Signed: the request is consumed from here on, whatever the + // POST outcome. Deliver off the UI thread with the shared HTTP + // client and a hard timeout. + st.posting = true; + let callback = st.uri.callback.clone(); + let challenge = st.uri.challenge.clone(); + let domain = st.uri.domain.clone(); + let slot = st.result.clone(); + std::thread::spawn(move || { + let res = match tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + { + Ok(rt) => rt.block_on(async { + let post = crate::nostr::authuri::post_authorize_event( + &callback, &challenge, &domain, &event, + ); + match tokio::time::timeout( + std::time::Duration::from_secs(LOGIN_POST_TIMEOUT_SECS), + post, + ) + .await + { + Ok(r) => r, + Err(_) => Err("timeout".to_string()), + } + }), + Err(e) => Err(e.to_string()), + }; + *slot.lock().unwrap() = Some(res); + }); + Modal::close(); + // Return-to-caller is DEFERRED to the outcome poll (see the + // login flow): the app must stay foreground until the POST + // result lands, or the completion work freezes backgrounded. + } + Err(e) => { + // Signing failed (never expected): consume the request and + // surface the quiet failure toast. + log::error!("authorize event signing failed: {e}"); + self.login_toast = Some(( + t!("goblin.authorize.failed", domain => domain).to_string(), + std::time::Instant::now(), + )); + self.authorize = None; + Modal::close(); + } + } + } + } + + /// The "Trust with Goblin" (Authorize Sessions) grant modal: proves identity + /// (folds login in) AND establishes the session in one password-gated, + /// hold-to-confirm decision. Shows the identity, the low-tier categories being + /// granted for silent signing, and the fixed line that money always asks. + pub(super) fn trust_modal_content( + &mut self, + ui: &mut egui::Ui, + modal: &Modal, + wallet: &Wallet, + cb: &dyn PlatformCallbacks, + ) { + let Some(st) = self.trust.as_mut() else { + Modal::close(); + return; + }; + if st.posting { + Modal::close(); + return; + } + let domain = st.uri.domain.clone(); + let identities = wallet.nostr_identities(); + let display = crate::nostr::session::render_grant(&st.uri.requested_kinds); + let mut go = false; + let mut cancel = false; + ui.vertical_centered(|ui| { + ui.add_space(6.0); + ui.label( + RichText::new(t!("goblin.trust.headline", domain => domain.clone())) + .size(17.0) + .color(Colors::title(false)), + ); + ui.add_space(10.0); + ui.label( + RichText::new(t!("goblin.trust.identity")) + .size(13.0) + .color(Colors::gray()), + ); + ui.add_space(6.0); + // Identity picker (defaults to the active identity), the truncated + // npub always shown as the anchor. + if identities.len() > 1 { + for id in &identities { + let selected = st.selected == id.pubkey_hex; + let title = id.display(); + let short = data::short_npub(&id.pubkey_hex); + let row = ui + .scope(|ui| { + ui.horizontal(|ui| { + ui.add_space(4.0); + ui.label( + RichText::new(if selected { + crate::gui::icons::CHECK_CIRCLE + } else { + crate::gui::icons::CIRCLE + }) + .size(18.0) + .color(if selected { + Colors::green() + } else { + Colors::gray() + }), + ); + ui.add_space(8.0); + ui.vertical(|ui| { + if title != short { + ui.label( + RichText::new(&title) + .size(15.0) + .color(Colors::text(false)), + ); + } + ui.label( + RichText::new(&short).size(12.5).color(Colors::gray()), + ); + }); + }); + }) + .response + .rect; + let hit = ui.interact( + row, + egui::Id::from(modal.id).with(("trust_id", id.pubkey_hex.as_str())), + Sense::click(), + ); + if hit + .on_hover_cursor(egui::CursorIcon::PointingHand) + .clicked() + { + st.selected = id.pubkey_hex.clone(); + } + ui.add_space(4.0); + } + } else if let Some(id) = identities.first() { + let title = id.display(); + let short = data::short_npub(&id.pubkey_hex); + if title != short { + ui.label(RichText::new(&title).size(15.0).color(Colors::text(false))); + } + ui.label(RichText::new(&short).size(12.5).color(Colors::gray())); + } + ui.add_space(12.0); + // The gist in one short line (grant + the money rule), with the full + // permission detail behind a small disclosure for anyone who wants it. + ui.label( + RichText::new(t!("goblin.trust.lead", domain => domain.clone())) + .size(13.5) + .color(Colors::text(false)), + ); + // Caution lines are safety-relevant and stay visible even collapsed. + for kind in &display.unknown_kinds { + ui.label( + RichText::new(format!( + "• {}", + t!("goblin.trust.cat_unknown", n => kind.to_string()) + )) + .size(13.5) + .color(Colors::red()), + ); + } + if display.stripped_login { + ui.label( + RichText::new(t!("goblin.trust.login_excluded")) + .size(12.5) + .color(Colors::red()), + ); + } + ui.add_space(8.0); + // The disclosure toggle, same idiom as the authorize full-content view. + let toggle = if st.show_full { + t!("goblin.authorize.show_less") + } else { + t!("goblin.authorize.show_full") + }; + let rect = ui + .label(RichText::new(toggle).size(13.0).color(Colors::green())) + .rect; + let hit = ui.interact( + rect, + egui::Id::from(modal.id).with("trust_showfull"), + Sense::click(), + ); + if hit + .on_hover_cursor(egui::CursorIcon::PointingHand) + .clicked() + { + st.show_full = !st.show_full; + } + if st.show_full { + ui.add_space(6.0); + // What the silent grant covers, as human categories. + ui.label( + RichText::new(t!("goblin.trust.grant_intro")) + .size(13.0) + .color(Colors::gray()), + ); + ui.add_space(4.0); + for cat in &display.categories { + ui.label( + RichText::new(format!("• {}", t!(cat.key()))) + .size(13.5) + .color(Colors::text(false)), + ); + } + ui.add_space(6.0); + // The fixed money line: the low-tier grant is not a money grant. + ui.label( + RichText::new(t!("goblin.trust.money_line")) + .size(13.0) + .color(Colors::title(false)), + ); + ui.add_space(4.0); + ui.label( + RichText::new(t!("goblin.trust.duration")) + .size(12.5) + .color(Colors::gray()), + ); + } + ui.add_space(12.0); + ui.label( + RichText::new(t!("goblin.trust.pass_prompt")) + .size(16.0) + .color(Colors::gray()), + ); + ui.add_space(10.0); + let mut field = TextEdit::new(egui::Id::from(modal.id).with("trust_pass")).password(); + field.ui(ui, &mut st.pass, cb); + if st.pass.is_empty() { + st.wrong_pass = false; + } else if st.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); + }); + // Hold-to-confirm: the single high-value decision cannot be a stray tap. + if self.trust_hold.ui(ui, &t!("goblin.trust.confirm_hold")) { + go = true; + } + ui.add_space(6.0); + ui.scope(|ui| { + ui.spacing_mut().item_spacing = egui::Vec2::new(8.0, 0.0); + ui.vertical_centered_justified(|ui| { + View::button( + ui, + t!("modal.cancel"), + Colors::white_or_black(false), + || { + cancel = true; + }, + ); + }); + ui.add_space(6.0); + }); + if cancel { + self.trust = None; + Modal::close(); + return; + } + if go { + let (pass, selected) = match self.trust.as_ref() { + Some(st) => (st.pass.clone(), st.selected.clone()), + None => return, + }; + if pass.is_empty() { + self.trust_hold = w::HoldToSend::default(); + return; + } + if !wallet.verify_nostr_password(&pass) { + if let Some(st) = self.trust.as_mut() { + st.wrong_pass = true; + } + self.trust_hold = w::HoldToSend::default(); + return; + } + let keys = wallet.nostr_service().and_then(|s| { + s.recv_snapshot() + .into_iter() + .find(|h| h.keys.public_key().to_hex() == selected) + .map(|h| h.keys) + }); + let Some(keys) = keys else { + self.trust = None; + Modal::close(); + return; + }; + let st = self.trust.as_mut().unwrap(); + st.pass.clear(); + st.wrong_pass = false; + // Sign and POST the kind-22242 login event exactly as Build 150 does; + // the session is created by the router once this POST succeeds. + match crate::nostr::loginuri::build_login_event( + &keys, + &st.uri.challenge, + &st.uri.domain, + ) { + Ok(event) => { + st.posting = true; + let callback = st.uri.callback.clone(); + let slot = st.result.clone(); + std::thread::spawn(move || { + let res = match tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + { + Ok(rt) => rt.block_on(async { + let post = + crate::nostr::loginuri::post_login_event(&callback, &event); + match tokio::time::timeout( + std::time::Duration::from_secs(LOGIN_POST_TIMEOUT_SECS), + post, + ) + .await + { + Ok(r) => r, + Err(_) => Err("timeout".to_string()), + } + }), + Err(e) => Err(e.to_string()), + }; + *slot.lock().unwrap() = Some(res); + }); + Modal::close(); + // Return-to-caller is DEFERRED even further than login: past + // the POST outcome AND past the session-open announce + // confirmation (the trust_wait poll). Returning here was the + // Build 153 QR-trust bug: the app backgrounded with the POST + // in flight and the session-open never published. + } + Err(e) => { + log::error!("trust login event signing failed: {e}"); + self.login_toast = Some(( + t!("goblin.trust.failed", domain => domain).to_string(), + std::time::Instant::now(), + )); + self.trust = None; + Modal::close(); + } + } + } + } + + /// The money-tier per-action approval modal: a value-moving sign (or a + /// pay-committing encrypt) arriving over a live session channel. Identical + /// gravity to a v1 authorize — what is being done, which identity, a masked + /// password, hold-to-confirm — raised every time, never silent. + pub(super) fn money_modal_content( + &mut self, + ui: &mut egui::Ui, + modal: &Modal, + wallet: &Wallet, + cb: &dyn PlatformCallbacks, + ) { + use crate::nostr::session::ChannelOp; + let Some(st) = self.money.as_mut() else { + Modal::close(); + return; + }; + let domain = st.pending.domain.clone(); + let req_id = st.pending.id().to_string(); + let short_id = data::short_npub(&st.pending.identity_pubkey.to_hex()); + // A one-line description of exactly what is being committed to. + let what = match &st.pending.op { + ChannelOp::Sign(req) => { + let label = crate::nostr::authuri::kind_label(req.event.kind); + let (preview, _) = crate::nostr::authuri::content_preview(&req.event.content); + let preview = crate::nostr::authuri::escape_for_display(&preview); + if preview.trim().is_empty() { + t!(label.key(), n => req.event.kind.to_string()).to_string() + } else { + format!( + "{}: {}", + t!(label.key(), n => req.event.kind.to_string()), + preview + ) + } + } + ChannelOp::Encrypt(e) => { + // Order DMs are where payment agreements live: show the inspected + // plaintext (escaped + truncated exactly like the sign path), so + // the user sees WHAT they are agreeing to pay, not a blind label. + let (preview, _) = crate::nostr::authuri::content_preview(&e.plaintext); + let preview = crate::nostr::authuri::escape_for_display(&preview); + if preview.trim().is_empty() { + t!("goblin.money.encrypt_desc").to_string() + } else { + format!("{}: {}", t!("goblin.money.encrypt_desc"), preview) + } + } + }; + let mut approve = false; + let mut decline = false; + ui.vertical_centered(|ui| { + ui.add_space(6.0); + ui.label( + RichText::new(t!("goblin.money.headline", domain => domain.clone())) + .size(17.0) + .color(Colors::title(false)), + ); + ui.add_space(8.0); + ui.label(RichText::new(&what).size(14.0).color(Colors::text(false))); + ui.add_space(8.0); + ui.label( + RichText::new(t!("goblin.money.identity", id => short_id.clone())) + .size(12.5) + .color(Colors::gray()), + ); + ui.add_space(8.0); + ui.label( + RichText::new(t!("goblin.money.explain")) + .size(13.0) + .color(Colors::gray()), + ); + ui.add_space(10.0); + ui.label( + RichText::new(t!("goblin.money.pass_prompt")) + .size(16.0) + .color(Colors::gray()), + ); + ui.add_space(10.0); + let mut field = TextEdit::new(egui::Id::from(modal.id).with("money_pass")).password(); + field.ui(ui, &mut st.pass, cb); + if st.pass.is_empty() { + st.wrong_pass = false; + } else if st.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); + }); + if self.money_hold.ui(ui, &t!("goblin.money.confirm_hold")) { + approve = true; + } + ui.add_space(6.0); + ui.vertical_centered_justified(|ui| { + View::button( + ui, + t!("modal.cancel"), + Colors::white_or_black(false), + || { + decline = true; + }, + ); + }); + ui.add_space(6.0); + if decline { + if let Some(svc) = wallet.nostr_service() { + svc.answer_money_prompt(&req_id, false); + } + self.money = None; + Modal::close(); + return; + } + if approve { + let pass = self + .money + .as_ref() + .map(|s| s.pass.clone()) + .unwrap_or_default(); + if pass.is_empty() || !wallet.verify_nostr_password(&pass) { + if let Some(st) = self.money.as_mut() { + st.wrong_pass = true; + } + self.money_hold = w::HoldToSend::default(); + return; + } + if let Some(svc) = wallet.nostr_service() { + svc.answer_money_prompt(&req_id, true); + } + self.money = None; + Modal::close(); + } + } +} diff --git a/src/gui/views/goblin/receipt.rs b/src/gui/views/goblin/receipt.rs new file mode 100644 index 00000000..cd87c0cc --- /dev/null +++ b/src/gui/views/goblin/receipt.rs @@ -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 = 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 + } +} diff --git a/src/gui/views/goblin/receive.rs b/src/gui/views/goblin/receive.rs new file mode 100644 index 00000000..36f46844 --- /dev/null +++ b/src/gui/views/goblin/receive.rs @@ -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), + ); + } +} diff --git a/src/gui/views/goblin/settings.rs b/src/gui/views/goblin/settings.rs new file mode 100644 index 00000000..bf65c49f --- /dev/null +++ b/src/gui/views/goblin/settings.rs @@ -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; + } + } +} diff --git a/src/gui/views/goblin/settings_advanced.rs b/src/gui/views/goblin/settings_advanced.rs new file mode 100644 index 00000000..9161377f --- /dev/null +++ b/src/gui/views/goblin/settings_advanced.rs @@ -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; + } + } +} diff --git a/src/gui/views/goblin/settings_node.rs b/src/gui/views/goblin/settings_node.rs new file mode 100644 index 00000000..9e950ef2 --- /dev/null +++ b/src/gui/views/goblin/settings_node.rs @@ -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::); + } 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 = 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); + }); + } +} diff --git a/src/gui/views/goblin/username.rs b/src/gui/views/goblin/username.rs new file mode 100644 index 00000000..edd3012e --- /dev/null +++ b/src/gui/views/goblin/username.rs @@ -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, 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 = 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 = 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 = 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); + } + }); + } + } + }); + } +}