1
0
forked from GRIN/grim

Build 63: connect over Nym in seconds, yellow Pay page, gradient-avatar fixes

Nym "Connecting..." for ~1 minute — root cause fixed. open() spawned the
wallet sync thread BEFORE init_nostr() created the nostr service, so the
sync loop's first pass at service.start() found no service and skipped
it; the service only started on the next sync cycle, a full SYNC_DELAY
(60s) later. Measured on the dev box: the gap between "identity ready"
and "starting service" was 62s, while the relay itself connects over the
mixnet in ~2s. Initialising nostr before start_sync collapses that gap
to ~1ms (verified 62s -> 1ms). The mixnet sidecar already warms up at app
launch, so the relay connect was the only thing waiting — and it waited
on loop timing, not on Nym. Added a "first relay Connected ~Nms" log line
so the timing is observable from logs going forward.

Pay tab is now painted in the yellow theme (Cash App-style brand surface)
regardless of the active theme, via a scoped theme override held across
the central panel so the fill and every widget pick up the yellow tokens
together.

Gradient avatar now renders for anonymous npubs on the onboarding
identity card and the mobile home header — both hardcoded a flat "N"
letter tile while Settings already showed the gradient. The header falls
back to the short npub so avatar_any takes the gradient branch.
This commit is contained in:
2ro
2026-06-14 01:58:19 -04:00
parent 63bf92f172
commit bfed0a1cb9
5 changed files with 101 additions and 16 deletions
+30 -2
View File
@@ -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<Option<ThemeKind>> = 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<ThemeKind>);
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.
+21 -10
View File
@@ -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);
+8 -1
View File
@@ -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 {
+31
View File
@@ -535,12 +535,43 @@ async fn run_service(svc: Arc<NostrService>, 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;
+11 -3
View File
@@ -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(())
}