diff --git a/src/gui/theme.rs b/src/gui/theme.rs index b6331f2..842811b 100644 --- a/src/gui/theme.rs +++ b/src/gui/theme.rs @@ -15,6 +15,8 @@ //! Goblin design tokens: three themes (light/dark/yellow) and density scales, //! taken verbatim from the Goblin design handoff. +use std::cell::Cell; + use egui::Color32; use crate::AppConfig; @@ -182,9 +184,35 @@ pub const YELLOW: ThemeTokens = ThemeTokens { dark_base: false, }; -/// Current theme kind from app config (dark is the product default). +thread_local! { + /// Per-frame theme override (see [`scoped`]). egui renders on one thread, so + /// a thread-local Cell scopes a different theme to a single surface without + /// touching the persisted app config. + static OVERRIDE: Cell> = const { Cell::new(None) }; +} + +/// RAII guard that forces [`kind`]/[`tokens`] to a specific theme for its +/// lifetime, restoring the previous value on drop (panic-safe). Used to paint +/// one surface — the Pay tab — in the yellow theme regardless of the user's +/// chosen theme, à la Cash App's brand-colored pay screen. +#[must_use = "the override only lasts while the guard is alive"] +pub struct ScopedTheme(Option); + +impl Drop for ScopedTheme { + fn drop(&mut self) { + OVERRIDE.with(|c| c.set(self.0.take())); + } +} + +/// Override the active theme until the returned guard drops. +pub fn scoped(kind: ThemeKind) -> ScopedTheme { + ScopedTheme(OVERRIDE.with(|c| c.replace(Some(kind)))) +} + +/// Current theme kind: a scoped override if one is active, else app config +/// (dark is the product default). pub fn kind() -> ThemeKind { - AppConfig::theme() + OVERRIDE.with(|c| c.get()).unwrap_or_else(AppConfig::theme) } /// Current theme tokens. diff --git a/src/gui/views/goblin/mod.rs b/src/gui/views/goblin/mod.rs index fe393f3..e80497a 100644 --- a/src/gui/views/goblin/mod.rs +++ b/src/gui/views/goblin/mod.rs @@ -347,10 +347,16 @@ impl GoblinWalletView { }); } - // Central content. + // Central content. The Pay tab is painted in the yellow theme (Cash + // App-style brand surface) regardless of the user's chosen theme: a + // scoped override held across the whole panel so its fill AND every + // widget inside pick up the yellow tokens together. + let pay = self.tab == Tab::Pay; + let _pay_theme = pay.then(|| theme::scoped(theme::ThemeKind::Yellow)); + let panel_fill = if pay { theme::YELLOW.bg } else { t.bg }; egui::CentralPanel::default() .frame(egui::Frame { - fill: t.bg, + fill: panel_fill, inner_margin: Margin { left: (View::far_left_inset_margin(ui) + 20.0) as i8, right: (View::get_right_inset() + 20.0) as i8, @@ -713,12 +719,17 @@ impl GoblinWalletView { .nostr_service() .map(|s| { let id = s.identity.read(); + let hex = hex_of(&id.npub); + // With a verified handle show "@name"; otherwise fall back to + // the short npub so avatar_any draws the deterministic gradient + // (it keys the gradient branch off a leading "npub"), not a + // meaningless lettered tile. let h = id .nip05 .clone() .map(|n| format!("@{}", n.split('@').next().unwrap_or(""))) - .unwrap_or_else(|| "N".to_string()); - (h, hex_of(&id.npub)) + .unwrap_or_else(|| data::short_npub(&hex)); + (h, hex) }) .unwrap_or_else(|| ("N".to_string(), String::new())); let header_hue = data::hue_of(&header_hex); @@ -2910,7 +2921,8 @@ impl GoblinWalletView { RichText::new( "It's up for grabs the moment it's free — anyone can \ claim it, including the next key you rotate to. Your \ - profile picture is deleted with it.", + profile picture is deleted with it. You won't be able \ + to register another username for 10 minutes.", ) .font(FontId::new(12.5, fonts::regular())) .color(t.surface_text_dim), @@ -3089,8 +3101,8 @@ fn start_claim_flow(claim: &mut ClaimState, name: &str, wallet: &Wallet) { ClaimMsg::Error("That username was just taken".into()) } RegisterResult::Rejected(e) if e == "name_change_cooldown" => ClaimMsg::Error( - "Easy there — one username change every 10 minutes. \ - Try again shortly." + "You recently released a username — you can register a new \ + one within 10 minutes." .into(), ), RegisterResult::Rejected(e) => ClaimMsg::Error(e), @@ -3126,11 +3138,10 @@ fn start_release(claim: &mut ClaimState, name: &str, wallet: &Wallet) { 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) if e.contains("name_change_cooldown") => ClaimMsg::Error( - "Easy there — one username change every 10 minutes. Try again shortly.".into(), - ), Err(e) => ClaimMsg::Error(format!("Couldn't release: {e}")), }; *slot.lock().unwrap() = Some(msg); diff --git a/src/gui/views/goblin/onboarding.rs b/src/gui/views/goblin/onboarding.rs index 74702aa..562cee8 100644 --- a/src/gui/views/goblin/onboarding.rs +++ b/src/gui/views/goblin/onboarding.rs @@ -685,7 +685,14 @@ impl OnboardingContent { w::card(ui, |ui| { ui.set_min_width(ui.available_width()); ui.horizontal(|ui| { - w::avatar(ui, "N", 44.0, 6); + // Same deterministic gradient + Grin mark the rest of the app shows + // for this key; only fall back to a placeholder while the key is + // still being generated (npub not yet available). + if npub.is_empty() { + w::avatar(ui, "N", 44.0, 6); + } else { + w::gradient_avatar(ui, &npub, 44.0); + } ui.add_space(10.0); ui.vertical(|ui| { let short = if npub.len() > 20 { diff --git a/src/nostr/client.rs b/src/nostr/client.rs index c114cb4..1b7dde8 100644 --- a/src/nostr/client.rs +++ b/src/nostr/client.rs @@ -535,12 +535,43 @@ async fn run_service(svc: Arc, wallet: Wallet) { } tokio::time::sleep(Duration::from_millis(500)).await; } + let connect_started = std::time::Instant::now(); client.connect().await; { let mut w_client = svc.client.write(); *w_client = Some(client.clone()); } + // Instrumentation: log the moment the first relay actually reaches Connected + // over the mixnet, measured from the connect() call. Cold-start latency is + // then read off the wall clock (paired with the "Nym sidecar ready after + // ~Nms" line above) instead of guessed. Non-blocking; exits on first success. + { + let client_probe = client.clone(); + let svc_probe = svc.clone(); + tokio::spawn(async move { + loop { + tokio::time::sleep(Duration::from_millis(250)).await; + if relays_connected(&client_probe).await { + info!( + "nostr: first relay Connected ~{}ms after connect()", + connect_started.elapsed().as_millis() + ); + return; + } + if svc_probe.shutdown.load(Ordering::SeqCst) + || connect_started.elapsed() > Duration::from_secs(150) + { + warn!( + "nostr: no relay Connected within {}ms of connect()", + connect_started.elapsed().as_millis() + ); + return; + } + } + }); + } + // Publish identity events (kind 10050 DM relays; kind 0 only when named). publish_identity(&svc, &client).await; diff --git a/src/wallet/wallet.rs b/src/wallet/wallet.rs index db4bcf8..a2c395f 100644 --- a/src/wallet/wallet.rs +++ b/src/wallet/wallet.rs @@ -375,6 +375,17 @@ impl Wallet { self.account_time .store(Utc::now().timestamp(), Ordering::Relaxed); + // Initialize the nostr identity + service BEFORE spawning the + // sync thread. The sync loop starts the service on its first + // iteration (wallet.rs, top of the loop); if the thread races + // ahead of this, that first iteration finds no service, skips + // it, and the service doesn't start until the NEXT cycle — a + // full SYNC_DELAY (60s) later. That 60s gap (not the mixnet, + // which connects a relay in ~2s) is the "stuck on Connecting… + // for a minute" symptom. Synchronous + on this thread, so the + // service is guaranteed present when start_sync runs. + self.init_nostr(&nostr_password); + // Start new synchronization thread or wake up existing one. let mut thread_w = self.sync_thread.write(); if thread_w.is_none() { @@ -398,9 +409,6 @@ impl Wallet { // Update Slatepack address and secret key. self.update_secret_key_addr()?; - // Initialize nostr identity and service (non-fatal on failure). - self.init_nostr(&nostr_password); - Ok(()) }