diff --git a/src/gui/views/goblin/mod.rs b/src/gui/views/goblin/mod.rs index 6237fae..85efffc 100644 --- a/src/gui/views/goblin/mod.rs +++ b/src/gui/views/goblin/mod.rs @@ -92,6 +92,7 @@ enum SettingsPage { Node, Relays, Nips, + Pairing, } impl Default for GoblinWalletView { @@ -496,14 +497,25 @@ impl GoblinWalletView { } // Node status + profile cards pinned to the bottom (node info lives - // here so the surface needs no separate network column). + // here so the surface needs no separate network column). Each card is + // its own shortcut: the node card opens the Node menu, the identity + // chip opens identity settings. ui.with_layout(Layout::bottom_up(Align::Min), |ui| { let width = ui.available_width(); - let bottom = ui.allocate_ui_with_layout( + ui.allocate_ui_with_layout( Vec2::new(width, 196.0), Layout::top_down(Align::Min), |ui| { - self.node_card_ui(ui, wallet); + // Node status card → Settings → Node menu. + let node = ui + .scope(|ui| self.node_card_ui(ui, wallet)) + .response + .interact(Sense::click()) + .on_hover_cursor(egui::CursorIcon::PointingHand); + if node.clicked() { + self.tab = Tab::Me; + self.settings_page = SettingsPage::Node; + } ui.add_space(8.0); let (handle, connected, npub_hex) = wallet .nostr_service() @@ -519,35 +531,42 @@ impl GoblinWalletView { .unwrap_or_else(|| ("Anonymous".to_string(), false, String::new())); let hue = data::hue_of(&npub_hex); let tex = self.handle_tex(ui.ctx(), wallet, &handle); - w::card(ui, |ui| { - ui.set_min_width(ui.available_width()); - ui.horizontal(|ui| { - w::avatar_any(ui, &handle, 36.0, hue, tex.as_ref()); - ui.add_space(10.0); - ui.vertical(|ui| { - ui.label( - RichText::new(&handle) - .font(FontId::new(14.0, fonts::semibold())) - .color(t.surface_text), - ); - ui.label( - RichText::new(if connected { - "synced · Tor" - } else { - "connecting…" - }) - .font(FontId::new(12.0, fonts::regular())) - .color(t.surface_text_mute), - ); + // Identity chip → identity settings. + let id_resp = ui + .scope(|ui| { + w::card(ui, |ui| { + ui.set_min_width(ui.available_width()); + ui.horizontal(|ui| { + w::avatar_any(ui, &handle, 36.0, hue, tex.as_ref()); + ui.add_space(10.0); + ui.vertical(|ui| { + ui.label( + RichText::new(&handle) + .font(FontId::new(14.0, fonts::semibold())) + .color(t.surface_text), + ); + ui.label( + RichText::new(if connected { + "synced · Tor" + } else { + "connecting…" + }) + .font(FontId::new(12.0, fonts::regular())) + .color(t.surface_text_mute), + ); + }); + }); }); - }); - }); + }) + .response + .interact(Sense::click()) + .on_hover_cursor(egui::CursorIcon::PointingHand); + if id_resp.clicked() { + self.tab = Tab::Me; + self.settings_page = SettingsPage::Main; + } }, ); - // Both bottom cards (node status + profile) open Settings. - if bottom.response.interact(Sense::click()).clicked() { - self.tab = Tab::Me; - } }); } @@ -789,11 +808,37 @@ impl GoblinWalletView { fn pay_ui(&mut self, ui: &mut egui::Ui, _wallet: &Wallet) { let t = theme::tokens(); ui.add_space(8.0); - ui.label( - RichText::new("Pay") - .font(FontId::new(28.0, fonts::bold())) - .color(t.text), - ); + ui.horizontal(|ui| { + ui.label( + RichText::new("Pay") + .font(FontId::new(28.0, fonts::bold())) + .color(t.text), + ); + // Scan-to-pay QR, top-right (mirrors the Home header scan puck): + // open the scanner with the typed amount preserved. + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + let (rect, resp) = ui.allocate_exact_size(Vec2::splat(36.0), Sense::click()); + ui.painter().circle_filled(rect.center(), 18.0, t.surface2); + ui.painter().text( + rect.center(), + egui::Align2::CENTER_CENTER, + QR_CODE, + FontId::new(17.0, fonts::regular()), + t.surface_text, + ); + if resp + .on_hover_cursor(egui::CursorIcon::PointingHand) + .on_hover_text("Scan to pay") + .clicked() + { + let mut f = SendFlow::default(); + f.prefill_amount(self.pay_amount.clone()); + f.request_scan(); + self.pay_amount.clear(); + self.send = Some(f); + } + }); + }); // Big centered amount. let display = if self.pay_amount.is_empty() { @@ -804,12 +849,12 @@ impl GoblinWalletView { let tall = ui.available_height() > 560.0; ui.add_space(if tall { 56.0 } else { 24.0 }); w::amount_text_centered(ui, &display, 76.0); - if let Some(rate) = crate::http::grin_usd_rate() { - if let Ok(grin) = display.parse::() { + if let Ok(grin) = display.parse::() { + if let Some(preview) = pairing_preview(grin) { ui.add_space(6.0); ui.vertical_centered(|ui| { ui.label( - RichText::new(format!("≈ ${:.2}", grin * rate)) + RichText::new(preview) .font(FontId::new(14.0, fonts::regular())) .color(t.text_dim), ); @@ -1178,6 +1223,7 @@ impl GoblinWalletView { SettingsPage::Node => return self.node_settings_ui(ui, wallet), SettingsPage::Relays => return self.relays_ui(ui, wallet), SettingsPage::Nips => return self.nips_ui(ui), + SettingsPage::Pairing => return self.pairing_settings_ui(ui), SettingsPage::Main => {} } ui.add_space(8.0); @@ -1436,22 +1482,21 @@ impl GoblinWalletView { } ui.add_space(16.0); + let mut open_pairing = false; settings_group(ui, "Privacy", |ui| { settings_row(ui, "Tor routing", "All payments routed over Tor"); // Tap to cycle the incoming-payment accept policy. if settings_row_btn(ui, "Auto-accept", accept_policy_label(wallet)) { cycle_accept_policy(wallet); } - // Tap to toggle the fiat (USD) preview. - let fiat = if crate::AppConfig::fiat_preview() { - "On" - } else { - "Off" - }; - if settings_row_btn(ui, "Fiat preview (USD)", fiat) { - crate::AppConfig::toggle_fiat_preview(); + // Amount pairing: what the ≈ preview is shown against. + if settings_row_nav(ui, "Pairing", crate::AppConfig::pairing().label()) { + open_pairing = true; } }); + if open_pairing { + self.settings_page = SettingsPage::Pairing; + } ui.add_space(16.0); w::kicker(ui, "Appearance"); @@ -1547,6 +1592,52 @@ impl GoblinWalletView { } /// Node connection editor: pick integrated/external, add or remove nodes. + fn pairing_settings_ui(&mut self, ui: &mut egui::Ui) { + let t = theme::tokens(); + if self.sub_header(ui, "Pairing") { + self.settings_page = SettingsPage::Main; + return; + } + ScrollArea::vertical() + .auto_shrink([false; 2]) + .scroll_bar_visibility(egui::scroll_area::ScrollBarVisibility::AlwaysHidden) + .show(ui, |ui| { + ui.label( + RichText::new("What your balance and amounts are shown against.") + .font(FontId::new(13.0, fonts::regular())) + .color(t.text_dim), + ); + ui.add_space(12.0); + let current = crate::AppConfig::pairing(); + settings_group(ui, "Pair with", |ui| { + for p in crate::settings::Pairing::ALL { + let active = p == current; + let row = ui.horizontal(|ui| { + ui.label( + RichText::new(p.label()) + .font(FontId::new(15.0, fonts::medium())) + .color(t.surface_text), + ); + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + if active { + ui.label( + RichText::new(crate::gui::icons::CHECK) + .font(FontId::new(16.0, fonts::regular())) + .color(t.pos), + ); + } + }); + }); + ui.add_space(10.0); + if !active && row.response.interact(Sense::click()).clicked() { + crate::AppConfig::set_pairing(p); + } + } + }); + ui.add_space(16.0); + }); + } + fn node_settings_ui(&mut self, ui: &mut egui::Ui, wallet: &Wallet) { use crate::wallet::types::ConnectionMethod; use crate::wallet::{ConnectionsConfig, ExternalConnection}; @@ -2754,12 +2845,14 @@ fn accept_policy_label(wallet: &Wallet) -> &'static str { .unwrap_or("Anyone") } -/// Cycle the color theme Dark → Light → Yellow → Dark and re-apply visuals. +/// 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::Yellow, + ThemeKind::Light => ThemeKind::Dark, ThemeKind::Yellow => ThemeKind::Dark, }; crate::AppConfig::set_theme(next); @@ -2842,16 +2935,48 @@ fn fmt_thousands(n: u64) -> String { } fn fiat_line(data: &Option) -> Option { - let _ = data; - // Fiat rate provider is wired in P7; hide the line until a rate is available. - crate::http::grin_usd_rate().map(|rate| { - let spendable = data - .as_ref() - .map(|d| d.info.amount_currently_spendable) - .unwrap_or(0); - let grin = spendable as f64 / 1_000_000_000.0; - format!("≈ ${:.2} · 1ツ = ${:.4}", grin * rate, rate) - }) + let p = crate::AppConfig::pairing(); + let vs = p.vs_currency()?; + let rate = crate::http::grin_rate(vs)?; + let spendable = data + .as_ref() + .map(|d| d.info.amount_currently_spendable) + .unwrap_or(0); + let grin = spendable as f64 / 1_000_000_000.0; + Some(format!( + "≈ {} · 1ツ = {}", + fmt_pairing(grin * rate, p), + fmt_pairing(rate, p) + )) +} + +/// Format a value already in the pairing's unit (dollars, BTC, …) with the +/// right symbol/precision. Sats scales the BTC value by 1e8. +fn fmt_pairing(value: f64, p: crate::settings::Pairing) -> String { + use crate::settings::Pairing; + match p { + Pairing::Usd => format!("${:.2}", value), + Pairing::Eur => format!("€{:.2}", value), + Pairing::Gbp => format!("£{:.2}", value), + Pairing::Jpy => format!("¥{:.0}", value), + Pairing::Cny => format!("CN¥{:.2}", value), + Pairing::Btc => { + let s = format!("{:.8}", value); + let s = s.trim_end_matches('0').trim_end_matches('.'); + format!("₿{}", if s.is_empty() { "0" } else { s }) + } + Pairing::Sats => format!("{} sats", fmt_thousands((value * 1e8).round() as u64)), + Pairing::Off => String::new(), + } +} + +/// The "≈ …" amount preview for the current pairing, or `None` when off / no +/// rate yet. Shared by the Pay screen, the send flow, and the balance hero. +fn pairing_preview(grin: f64) -> Option { + let p = crate::AppConfig::pairing(); + let vs = p.vs_currency()?; + let rate = crate::http::grin_rate(vs)?; + Some(format!("≈ {}", fmt_pairing(grin * rate, p))) } /// Convert a bech32 npub to hex for short display fallbacks. diff --git a/src/gui/views/goblin/send.rs b/src/gui/views/goblin/send.rs index d02c45b..b746698 100644 --- a/src/gui/views/goblin/send.rs +++ b/src/gui/views/goblin/send.rs @@ -715,12 +715,12 @@ impl SendFlow { self.amount.clone() }; w::amount_text_centered(ui, &display, 64.0); - if let Some(rate) = crate::http::grin_usd_rate() { - if let Ok(grin) = display.parse::() { + if let Ok(grin) = display.parse::() { + if let Some(preview) = super::pairing_preview(grin) { ui.add_space(6.0); ui.vertical_centered(|ui| { ui.label( - RichText::new(format!("≈ ${:.2}", grin * rate)) + RichText::new(preview) .font(FontId::new(14.0, fonts::regular())) .color(t.text_dim), ); diff --git a/src/http/mod.rs b/src/http/mod.rs index 2589147..ead3613 100644 --- a/src/http/mod.rs +++ b/src/http/mod.rs @@ -19,4 +19,4 @@ mod release; pub use release::*; mod price; -pub use price::grin_usd_rate; +pub use price::grin_rate; diff --git a/src/http/price.rs b/src/http/price.rs index c26b9ab..2356de0 100644 --- a/src/http/price.rs +++ b/src/http/price.rs @@ -12,30 +12,31 @@ // See the License for the specific language governing permissions and // limitations under the License. -//! GRIN/USD price preview, fetched over Tor and cached. +//! GRIN price preview, fetched over Tor and cached per currency. use lazy_static::lazy_static; use parking_lot::RwLock; +use std::collections::{HashMap, HashSet}; use std::time::{SystemTime, UNIX_EPOCH}; -use crate::AppConfig; use crate::tor::Tor; /// Cache refresh interval (seconds). const REFRESH_SECS: i64 = 300; -lazy_static! { - /// Cached (rate, fetched_at) and an in-flight flag. - static ref RATE: RwLock> = RwLock::new(None); - static ref FETCHING: std::sync::atomic::AtomicBool = - std::sync::atomic::AtomicBool::new(false); - static ref LAST_TRY: std::sync::atomic::AtomicI64 = std::sync::atomic::AtomicI64::new(0); -} - -/// Minimum delay between fetch attempts, so a failing fetch (e.g. Tor still -/// bootstrapping) does not respawn a thread every frame. +/// Minimum delay between fetch attempts for a currency, so a failing fetch +/// (e.g. Tor still bootstrapping) does not respawn a thread every frame. const RETRY_SECS: i64 = 30; +lazy_static! { + /// Cached GRIN rates per `vs_currency`: code -> (rate, fetched_at). + static ref RATES: RwLock> = RwLock::new(HashMap::new()); + /// Currencies with a fetch currently in flight. + static ref FETCHING: RwLock> = RwLock::new(HashSet::new()); + /// Last fetch attempt per currency (unix secs). + static ref LAST_TRY: RwLock> = RwLock::new(HashMap::new()); +} + fn now() -> i64 { SystemTime::now() .duration_since(UNIX_EPOCH) @@ -43,60 +44,65 @@ fn now() -> i64 { .unwrap_or(0) } -/// Get the cached GRIN/USD rate if fresh, triggering a refresh otherwise. -/// Returns `None` until the first successful fetch or when fiat is disabled. -pub fn grin_usd_rate() -> Option { - if !AppConfig::fiat_preview() { - return None; - } - let cached = { RATE.read().clone() }; +/// Get the cached GRIN rate against `vs` (e.g. "usd", "eur", "btc") if fresh, +/// triggering a background refresh otherwise. Returns `None` until the first +/// successful fetch for that currency. +pub fn grin_rate(vs: &str) -> Option { + let cached = { RATES.read().get(vs).cloned() }; let needs_refresh = match cached { Some((_, ts)) => now() - ts > REFRESH_SECS, None => true, }; if needs_refresh { - trigger_refresh(); + trigger_refresh(vs.to_string()); } cached.map(|(rate, _)| rate) } -/// Spawn a background refresh over Tor (deduplicated). -fn trigger_refresh() { - use std::sync::atomic::Ordering; +/// Spawn a background refresh over Tor for one currency (deduplicated per code). +fn trigger_refresh(vs: String) { let t = now(); - if t - LAST_TRY.load(Ordering::SeqCst) < RETRY_SECS { - return; + { + let last = LAST_TRY.read().get(&vs).copied().unwrap_or(0); + if t - last < RETRY_SECS { + return; + } } - if FETCHING.swap(true, Ordering::SeqCst) { - return; + { + let mut fetching = FETCHING.write(); + if fetching.contains(&vs) { + return; + } + fetching.insert(vs.clone()); } - LAST_TRY.store(t, Ordering::SeqCst); - std::thread::spawn(|| { + LAST_TRY.write().insert(vs.clone(), t); + std::thread::spawn(move || { let rt = tokio::runtime::Builder::new_current_thread() .enable_all() .build() .unwrap(); rt.block_on(async { - if let Some(rate) = fetch_rate().await { - let mut w = RATE.write(); - *w = Some((rate, now())); + if let Some(rate) = fetch_rate(&vs).await { + RATES.write().insert(vs.clone(), (rate, now())); } }); - FETCHING.store(false, Ordering::SeqCst); + FETCHING.write().remove(&vs); }); } -/// Fetch the GRIN/USD rate from CoinGecko over Tor. -async fn fetch_rate() -> Option { - let url = - "https://api.coingecko.com/api/v3/simple/price?ids=grin&vs_currencies=usd".to_string(); +/// Fetch the GRIN/`vs` rate from CoinGecko over Tor. +async fn fetch_rate(vs: &str) -> Option { + let url = format!( + "https://api.coingecko.com/api/v3/simple/price?ids=grin&vs_currencies={}", + vs + ); // CoinGecko rejects requests without a User-Agent (403). A static, // non-identifying UA is fine over Tor. let headers = vec![("User-Agent".to_string(), "goblin-wallet".to_string())]; let body = Tor::http_request("GET", url, None, headers).await?; let parsed: Option = serde_json::from_str::(&body) .ok() - .and_then(|doc| doc.get("grin")?.get("usd")?.as_f64()); + .and_then(|doc| doc.get("grin")?.get(vs)?.as_f64()); if parsed.is_none() { log::warn!( "price: unexpected response from rate API (Tor exit blocked?): {}", diff --git a/src/settings/config.rs b/src/settings/config.rs index cae9902..c09a26a 100644 --- a/src/settings/config.rs +++ b/src/settings/config.rs @@ -75,8 +75,10 @@ pub struct AppConfig { density: Option, /// Identifier of the last opened wallet to boot into. last_wallet_id: Option, - /// Show fiat (USD) preview alongside amounts. + /// Show fiat (USD) preview alongside amounts (legacy; migrated to pairing). fiat_preview: Option, + /// Amount pairing code: off|usd|eur|gbp|jpy|cny|btc|sats (default usd). + pairing: Option, /// Flag to use proxy for network requests. use_proxy: Option, @@ -93,6 +95,85 @@ pub struct AppConfig { app_update: Option, } +/// What the amount preview is paired to: nothing, a fiat currency, or bitcoin. +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum Pairing { + Off, + Usd, + Eur, + Gbp, + Jpy, + Cny, + Btc, + Sats, +} + +impl Pairing { + /// All variants, in picker order. + pub const ALL: [Pairing; 8] = [ + Pairing::Off, + Pairing::Usd, + Pairing::Eur, + Pairing::Gbp, + Pairing::Jpy, + Pairing::Cny, + Pairing::Btc, + Pairing::Sats, + ]; + + /// Stable config code. + pub fn code(&self) -> &'static str { + match self { + Pairing::Off => "off", + Pairing::Usd => "usd", + Pairing::Eur => "eur", + Pairing::Gbp => "gbp", + Pairing::Jpy => "jpy", + Pairing::Cny => "cny", + Pairing::Btc => "btc", + Pairing::Sats => "sats", + } + } + + pub fn from_code(s: &str) -> Option { + Some(match s { + "off" => Pairing::Off, + "usd" => Pairing::Usd, + "eur" => Pairing::Eur, + "gbp" => Pairing::Gbp, + "jpy" => Pairing::Jpy, + "cny" => Pairing::Cny, + "btc" => Pairing::Btc, + "sats" => Pairing::Sats, + _ => return None, + }) + } + + /// The CoinGecko `vs_currency` to price against (sats prices vs btc). + /// `None` when pairing is off. + pub fn vs_currency(&self) -> Option<&'static str> { + match self { + Pairing::Off => None, + Pairing::Sats => Some("btc"), + other => Some(other.code()), + } + } + + /// Human label for the picker / settings row. + pub fn label(&self) -> &'static str { + match self { + Pairing::Off => "Off", + Pairing::Usd => "USD", + Pairing::Eur => "EUR", + Pairing::Gbp => "GBP", + Pairing::Jpy => "JPY", + Pairing::Cny => "CNY", + Pairing::Btc => "Bitcoin", + Pairing::Sats => "Sats", + } + } +} + impl Default for AppConfig { fn default() -> Self { Self { @@ -112,6 +193,7 @@ impl Default for AppConfig { density: None, last_wallet_id: None, fiat_preview: None, + pairing: None, use_proxy: None, use_socks_proxy: None, http_proxy_url: None, @@ -363,17 +445,25 @@ impl AppConfig { w_config.save(); } - /// Check if fiat (USD) preview is enabled (default on). - pub fn fiat_preview() -> bool { + /// What amount previews are paired to (default USD). Migrates the legacy + /// `fiat_preview = false` to `Off`. + pub fn pairing() -> Pairing { let r_config = Settings::app_config_to_read(); - r_config.fiat_preview.unwrap_or(true) + if let Some(code) = r_config.pairing.clone() { + if let Some(p) = Pairing::from_code(&code) { + return p; + } + } + match r_config.fiat_preview { + Some(false) => Pairing::Off, + _ => Pairing::Usd, + } } - /// Toggle fiat preview. - pub fn toggle_fiat_preview() { - let enabled = Self::fiat_preview(); + /// Save the amount pairing. + pub fn set_pairing(p: Pairing) { let mut w_config = Settings::app_config_to_update(); - w_config.fiat_preview = Some(!enabled); + w_config.pairing = Some(p.code().to_string()); w_config.save(); }