diff --git a/src/gui/views/goblin/data.rs b/src/gui/views/goblin/data.rs index 841c33e9..bf75a1ce 100644 --- a/src/gui/views/goblin/data.rs +++ b/src/gui/views/goblin/data.rs @@ -167,3 +167,34 @@ pub fn recent_peers(wallet: &Wallet, limit: usize) -> Vec<(String, usize, String .map(|c| (display_name(&c), c.hue as usize, c.npub)) .collect() } + +/// Local contacts whose petname / nip05 / npub contains `query` (case- +/// insensitive) — the instant, no-network half of the recipient search. +pub fn search_contacts(wallet: &Wallet, query: &str, limit: usize) -> Vec<(String, usize, String)> { + let store = match wallet.nostr_service() { + Some(s) => s.store.clone(), + None => return vec![], + }; + let q = query.trim().trim_start_matches('@').to_lowercase(); + if q.is_empty() { + return vec![]; + } + let mut hits: Vec<(String, usize, String)> = store + .all_contacts() + .into_iter() + .filter(|c| { + c.petname + .as_deref() + .map(|p| p.to_lowercase().contains(&q)) + .unwrap_or(false) + || c.nip05 + .as_deref() + .map(|n| n.to_lowercase().contains(&q)) + .unwrap_or(false) + || c.npub.to_lowercase().contains(&q) + }) + .map(|c| (display_name(&c), c.hue as usize, c.npub)) + .collect(); + hits.truncate(limit); + hits +} diff --git a/src/gui/views/goblin/onboarding.rs b/src/gui/views/goblin/onboarding.rs index e59540f2..0f908855 100644 --- a/src/gui/views/goblin/onboarding.rs +++ b/src/gui/views/goblin/onboarding.rs @@ -725,6 +725,16 @@ impl OnboardingContent { .font(FontId::new(12.5, fonts::regular())) .color(t.surface_text_dim), ); + ui.add_space(6.0); + ui.label( + RichText::new( + "Want a clean slate later? Swap in a brand-new key any \ + time from Settings — your grin stays put, and the new \ + you isn't linked to the old one. Same wallet, fresh face.", + ) + .font(FontId::new(12.5, fonts::regular())) + .color(t.surface_text_dim), + ); }); ui.add_space(14.0); diff --git a/src/gui/views/goblin/send.rs b/src/gui/views/goblin/send.rs index a1b999d6..d02c45b8 100644 --- a/src/gui/views/goblin/send.rs +++ b/src/gui/views/goblin/send.rs @@ -28,7 +28,7 @@ use crate::wallet::Wallet; use crate::wallet::types::WalletTask; use super::avatars::AvatarTextures; -use super::data::{self, display_name, recent_peers, short_npub}; +use super::data::{self, display_name, recent_peers, search_contacts, short_npub}; use super::widgets::{self as w, HoldToSend}; /// Avatar texture for a display handle ("@name"), if one is cached. @@ -66,8 +66,28 @@ struct Recipient { hue: usize, } -/// Outcome of an async NIP-05 resolution: (pubkey hex, optional nip05 id). -type ResolveOutcome = Result<(String, Option), String>; +/// A recipient search hit shown as a tappable card. +#[derive(Clone)] +struct Candidate { + name: String, + npub: String, + hue: usize, + /// Known contact, resolved goblin handle, or has a published nostr + /// profile. Unverified = a syntactically valid key with no profile. + verified: bool, + /// Short provenance tag shown on the card ("on nostr", "@goblin.st", …). + tag: &'static str, +} + +/// Async network lookup result for the typed query. +enum LookupResult { + /// A resolved/verified identity (nip05 hit or kind-0 profile found). + Found(Candidate), + /// A syntactically valid key with no published profile. + Unverified(Candidate), + /// Nothing found for a handle/name. + NotFound(String), +} /// The send flow state. pub struct SendFlow { @@ -78,10 +98,18 @@ pub struct SendFlow { note: String, hold: HoldToSend, error: Option, - /// Async NIP-05 resolution result, written by a worker thread. - resolve_slot: std::sync::Arc>>, - /// Resolution in flight (bech32/nip05). - resolving: bool, + /// Async lookup result for the current query, written by a worker. + lookup_slot: std::sync::Arc>>, + /// Network lookup in flight. + looking_up: bool, + /// The query the current network result/`looking_up` belongs to. + lookup_query: String, + /// egui time of the last search edit, for debounce. + input_changed_at: f64, + /// Network candidate from the last completed lookup (deduped into view). + net_candidate: Option, + /// Pending "pay an unverified key?" confirm gate. + confirm_unverified: Option, /// Camera QR scanner when scanning for a recipient. scan: Option, /// Start scanning on the next recipient frame (entry from header icon). @@ -98,8 +126,12 @@ impl Default for SendFlow { note: String::new(), hold: HoldToSend::default(), error: None, - resolve_slot: std::sync::Arc::new(std::sync::Mutex::new(None)), - resolving: false, + lookup_slot: std::sync::Arc::new(std::sync::Mutex::new(None)), + looking_up: false, + lookup_query: String::new(), + input_changed_at: 0.0, + net_candidate: None, + confirm_unverified: None, scan: None, start_scan: false, } @@ -269,84 +301,209 @@ impl SendFlow { if search != self.search { self.search = search; self.error = None; + self.input_changed_at = ui.input(|i| i.time); + // New query — drop the stale network result until it re-resolves. + self.net_candidate = None; } ui.add_space(14.0); - // Poll the async resolution result (set by a worker thread). - self.poll_resolution(wallet); - - // Resolve button when input looks like a handle/npub. - if !self.search.trim().is_empty() { - if self.resolving { - ui.horizontal(|ui| { - View::small_loading_spinner(ui); - ui.add_space(8.0); - ui.label( - RichText::new("Looking up handle…") - .font(FontId::new(14.0, fonts::regular())) - .color(t.text_dim), - ); - }); - ui.ctx().request_repaint(); - } else if w::big_action(ui, "Find recipient", false).clicked() { - self.resolve_search(wallet); - } - ui.add_space(16.0); + // The pay-an-unverified-key gate pre-empts the picker. + if let Some(cand) = self.confirm_unverified.clone() { + self.unverified_gate_ui(ui, &cand); + return false; } - // Shown outside the non-empty-search gate: scan failures land here - // with the search box still empty. - if let Some(err) = &self.error { + + // Drive the debounced network lookup + poll its result. + self.drive_lookup(wallet, ui.input(|i| i.time)); + + let query = self.search.trim().to_string(); + if query.is_empty() { + // Empty query → suggested recent peers, as before. ui.label( - RichText::new(err) - .font(FontId::new(13.0, fonts::regular())) - .color(t.neg), + RichText::new(format!("{} Suggested", USERS)) + .font(fonts::kicker()) + .color(t.text_mute), ); - ui.add_space(16.0); + ui.add_space(8.0); + let peers = recent_peers(wallet, 20); + let texs: Vec> = peers + .iter() + .map(|(name, _, _)| tex_for(avatars, ui.ctx(), wallet, name)) + .collect(); + ScrollArea::vertical() + .auto_shrink([false; 2]) + .show(ui, |ui| { + if peers.is_empty() { + ui.add_space(20.0); + ui.label( + RichText::new("No contacts yet. Find someone by their @handle.") + .font(FontId::new(14.0, fonts::regular())) + .color(t.text_dim), + ); + } + for ((name, hue, npub), tex) in peers.into_iter().zip(texs.iter()) { + if w::activity_row( + ui, + &name, + &short_npub(&npub), + hue, + "", + false, + false, + tex.as_ref(), + ) + .clicked() + { + self.pick(Candidate { + name, + npub, + hue, + verified: true, + tag: "", + }); + } + } + }); + return false; } - // Suggested contacts / recent peers. - ui.label( - RichText::new(format!("{} Suggested", USERS)) - .font(fonts::kicker()) - .color(t.text_mute), - ); - ui.add_space(8.0); - let peers = recent_peers(wallet, 20); - let texs: Vec> = peers + // Type-ahead results: instant local matches + the network candidate. + let mut cands: Vec = search_contacts(wallet, &query, 6) + .into_iter() + .map(|(name, hue, npub)| Candidate { + name, + npub, + hue, + verified: true, + tag: "contact", + }) + .collect(); + if let Some(net) = &self.net_candidate { + if !cands.iter().any(|c| c.npub == net.npub) { + cands.push(net.clone()); + } + } + let texs: Vec> = cands .iter() - .map(|(name, _, _)| tex_for(avatars, ui.ctx(), wallet, name)) + .map(|c| tex_for(avatars, ui.ctx(), wallet, &c.name)) .collect(); ScrollArea::vertical() .auto_shrink([false; 2]) .show(ui, |ui| { - if peers.is_empty() { - ui.add_space(20.0); - ui.label( - RichText::new("No contacts yet. Find someone by their @handle.") - .font(FontId::new(14.0, fonts::regular())) - .color(t.text_dim), - ); - } - for ((name, hue, npub), tex) in peers.into_iter().zip(texs.iter()) { - let resp = w::activity_row( - ui, - &name, - &short_npub(&npub), - hue, - "", - false, - false, - tex.as_ref(), - ); - if resp.clicked() { - self.recipient = Some(Recipient { name, npub, hue }); - self.stage = Stage::Amount; + for (c, tex) in cands.iter().zip(texs.iter()) { + let tag = if c.verified { + format!("✓ {}", c.tag) + } else { + "no profile".to_string() + }; + if w::activity_row(ui, &c.name, &tag, c.hue, "", false, false, tex.as_ref()) + .clicked() + { + self.pick(c.clone()); } } + if self.looking_up { + ui.add_space(10.0); + ui.horizontal(|ui| { + View::small_loading_spinner(ui); + ui.add_space(8.0); + ui.label( + RichText::new("Searching nostr…") + .font(FontId::new(14.0, fonts::regular())) + .color(t.text_dim), + ); + }); + ui.ctx().request_repaint(); + } + if let Some(err) = &self.error { + ui.add_space(10.0); + ui.label( + RichText::new(err) + .font(FontId::new(13.0, fonts::regular())) + .color(t.neg), + ); + } }); false } + /// Select a candidate: verified ones go straight in; unverified keys hit + /// the confirm gate first. + fn pick(&mut self, cand: Candidate) { + if cand.verified { + self.recipient = Some(Recipient { + name: cand.name, + npub: cand.npub, + hue: cand.hue, + }); + let preset = amount_from_hr_string(&self.amount) + .map(|a| a > 0) + .unwrap_or(false); + self.stage = if preset { Stage::Review } else { Stage::Amount }; + } else { + self.confirm_unverified = Some(cand); + } + } + + /// "Pay an unverified key?" gate for a valid key with no nostr profile. + fn unverified_gate_ui(&mut self, ui: &mut egui::Ui, cand: &Candidate) { + let t = theme::tokens(); + w::card(ui, |ui| { + ui.set_min_width(ui.available_width()); + ui.label( + RichText::new("Pay an unverified key?") + .font(FontId::new(15.0, fonts::semibold())) + .color(t.surface_text), + ); + ui.add_space(4.0); + ui.label( + RichText::new( + "No nostr profile is published for this key — it may be \ + brand new, anonymous, or mistyped. Double-check it's the \ + right one before sending.", + ) + .font(FontId::new(12.5, fonts::regular())) + .color(t.surface_text_dim), + ); + ui.add_space(8.0); + ui.label( + RichText::new(short_npub(&cand.npub)) + .font(FontId::new(12.0, fonts::mono())) + .color(t.surface_text_mute), + ); + ui.add_space(12.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, "Keep looking").clicked() { + self.confirm_unverified = None; + } + }, + ); + 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, "Pay anyway", false).clicked() { + let mut c = cand.clone(); + c.verified = true; + self.confirm_unverified = None; + self.pick(c); + } + }, + ); + }); + }); + } + /// Camera feed scanning for a recipient QR (npub / nostr: URI / @handle). fn scan_ui(&mut self, ui: &mut egui::Ui, wallet: &Wallet, cb: &dyn PlatformCallbacks) { const NO_RECIPIENT: &str = "That QR isn't a goblin recipient — expected an npub or @handle"; @@ -370,11 +527,13 @@ impl SendFlow { .strip_prefix("nostr:") .or_else(|| text.strip_prefix("NOSTR:")) .unwrap_or(text); + // Drop the scanned key into the search box; the picker's + // debounced lookup resolves + verifies it like typed input. self.search = text.to_string(); - self.resolve_search(wallet); - if self.error.is_some() { - self.error = Some(NO_RECIPIENT.to_string()); - } + self.input_changed_at = ui.input(|i| i.time); + self.lookup_query.clear(); + self.net_candidate = None; + let _ = wallet; } _ => self.error = Some(NO_RECIPIENT.to_string()), } @@ -395,83 +554,126 @@ impl SendFlow { } } - fn resolve_search(&mut self, wallet: &Wallet) { - let input = self.search.trim().to_string(); + /// Debounced network resolution: ~0.4s after the last keystroke, kick off + /// one lookup for the current query (npub/hex → nostr kind-0 profile; + /// name/@handle → goblin.st nip05). Poll the worker's result each frame. + fn drive_lookup(&mut self, wallet: &Wallet, now: f64) { + // Poll a finished lookup first. + if self.looking_up { + if let Some(res) = self.lookup_slot.lock().unwrap().take() { + self.looking_up = false; + match res { + LookupResult::Found(c) | LookupResult::Unverified(c) => { + self.net_candidate = Some(c); + self.error = None; + } + LookupResult::NotFound(label) => { + self.net_candidate = None; + self.error = Some(format!("No one found for {label}")); + } + } + } else { + return; // still in flight + } + } + + let query = self.search.trim().to_string(); + if query.is_empty() { + self.lookup_query.clear(); + return; + } + // Debounce, and only resolve a given query once. + if query == self.lookup_query || now - self.input_changed_at < 0.4 { + return; + } + self.lookup_query = query.clone(); self.error = None; - // Instant cases (no network): bech32 npub or raw hex pubkey. + use nostr_sdk::{FromBech32, PublicKey}; - if let Ok(pk) = PublicKey::from_bech32(&input) { - self.set_recipient_from_hex(wallet, &pk.to_hex(), None); - return; - } - if input.len() == 64 && input.chars().all(|c| c.is_ascii_hexdigit()) { - self.set_recipient_from_hex(wallet, &input.to_lowercase(), None); - return; - } - // NIP-05 resolution over Tor: run on a worker thread, never block the - // UI. The result is written to `resolve_slot` and polled each frame. - if let Some((name, domain)) = nip05::split_identifier(&input) { - self.resolving = true; - *self.resolve_slot.lock().unwrap() = None; - let slot = self.resolve_slot.clone(); + let hex = if let Ok(pk) = PublicKey::from_bech32(&query) { + Some(pk.to_hex()) + } else if query.len() == 64 && query.chars().all(|c| c.is_ascii_hexdigit()) { + Some(query.to_lowercase()) + } else { + None + }; + let slot = self.lookup_slot.clone(); + + if let Some(hex) = hex { + // Valid key → confirm it's a live identity via its kind-0 profile. + self.looking_up = true; + let service = wallet.nostr_service(); + let known = wallet.nostr_service().and_then(|s| { + s.store + .contact(&hex) + .map(|c| (display_name(&c), c.hue as usize)) + }); std::thread::spawn(move || { - let outcome: ResolveOutcome = match resolve_nip05_blocking(&name, &domain) { - Some(res) => Ok((res.pubkey.to_hex(), Some(format!("{}@{}", name, domain)))), - None => Err("Couldn't find that handle".to_string()), + let hue = data::hue_of(&hex); + let profile = service.and_then(|s| s.fetch_profile_blocking(&hex)); + let res = match (known, profile) { + // Already a saved contact — trust it. + (Some((name, hue)), _) => LookupResult::Found(Candidate { + name, + npub: hex, + hue, + verified: true, + tag: "contact", + }), + (None, Some(p)) => { + let name = p + .nip05 + .as_deref() + .map(|n| format!("@{}", n.split('@').next().unwrap_or(""))) + .or(p.name) + .unwrap_or_else(|| short_npub(&hex)); + LookupResult::Found(Candidate { + name, + npub: hex, + hue, + verified: true, + tag: "on nostr", + }) + } + (None, None) => LookupResult::Unverified(Candidate { + name: short_npub(&hex), + npub: hex, + hue, + verified: false, + tag: "", + }), }; - *slot.lock().unwrap() = Some(outcome); + *slot.lock().unwrap() = Some(res); + }); + } else if let Some((name, domain)) = nip05::split_identifier(&query) { + // Name / @handle → goblin.st (or other) nip05 resolution. + self.looking_up = true; + let label = format!("@{name}"); + std::thread::spawn(move || { + let res = match resolve_nip05_blocking(&name, &domain) { + Some(r) => { + let hex = r.pubkey.to_hex(); + LookupResult::Found(Candidate { + name: format!("@{name}"), + npub: hex.clone(), + hue: data::hue_of(&hex), + verified: true, + tag: if domain == "goblin.st" { + "@goblin.st" + } else { + "nip-05" + }, + }) + } + None => LookupResult::NotFound(label), + }; + *slot.lock().unwrap() = Some(res); }); } else { self.error = Some("Enter an @handle, npub, or name".to_string()); } } - /// Apply an async NIP-05 resolution result once the worker finishes. - fn poll_resolution(&mut self, wallet: &Wallet) { - if !self.resolving { - return; - } - let outcome = self.resolve_slot.lock().unwrap().take(); - if let Some(outcome) = outcome { - self.resolving = false; - match outcome { - Ok((hex, nip05)) => self.set_recipient_from_hex(wallet, &hex, nip05), - Err(e) => self.error = Some(e), - } - } - } - - fn set_recipient_from_hex(&mut self, wallet: &Wallet, hex: &str, nip05: Option) { - let (name, hue) = wallet - .nostr_service() - .map(|s| { - if let Some(c) = s.store.contact(hex) { - (display_name(&c), c.hue as usize) - } else { - let hue = data::hue_of(hex); - ( - nip05 - .clone() - .map(|n| format!("@{}", n.split('@').next().unwrap_or(""))) - .unwrap_or_else(|| short_npub(hex)), - hue, - ) - } - }) - .unwrap_or_else(|| (short_npub(hex), 0)); - self.recipient = Some(Recipient { - name, - npub: hex.to_string(), - hue, - }); - // Amount-first (Pay tab): the amount is already set, go straight - // to review; otherwise continue to amount entry. - let preset = amount_from_hr_string(&self.amount) - .map(|a| a > 0) - .unwrap_or(false); - self.stage = if preset { Stage::Review } else { Stage::Amount }; - } - fn amount_ui( &mut self, ui: &mut egui::Ui, diff --git a/src/gui/views/goblin/widgets.rs b/src/gui/views/goblin/widgets.rs index 6abdc2a7..5e03ca67 100644 --- a/src/gui/views/goblin/widgets.rs +++ b/src/gui/views/goblin/widgets.rs @@ -535,17 +535,26 @@ pub fn numpad(ui: &mut Ui, amount: &mut String) -> bool { [".", "0", "<"], ]; let key_h = 56.0; + let gap = 10.0; + // Center a fixed-width pad so the three columns line up directly under + // the centered amount above, on any width. + let pad_w = ui.available_width().min(300.0); + let key_w = (pad_w - 2.0 * gap) / 3.0; + let side = ((ui.available_width() - pad_w) / 2.0).max(0.0); for row in keys.iter() { ui.horizontal(|ui| { - let w = (ui.available_width() - 2.0 * 8.0) / 3.0; - for &k in row.iter() { - let (rect, resp) = ui.allocate_exact_size(Vec2::new(w, key_h), Sense::click()); + ui.add_space(side); + for (i, &k) in row.iter().enumerate() { + if i > 0 { + ui.add_space(gap); + } + let (rect, resp) = ui.allocate_exact_size(Vec2::new(key_w, key_h), Sense::click()); let label = if k == "<" { crate::gui::icons::BACKSPACE.to_string() } else { k.to_string() }; - let col = if resp.hovered() { t.text } else { t.text }; + let col = if resp.hovered() { t.accent } else { t.text }; ui.painter().text( rect.center(), egui::Align2::CENTER_CENTER, @@ -557,7 +566,6 @@ pub fn numpad(ui: &mut Ui, amount: &mut String) -> bool { apply_key(amount, k); changed = true; } - ui.add_space(8.0); } }); ui.add_space(4.0); diff --git a/src/nostr/client.rs b/src/nostr/client.rs index 0cc7437c..52900b6e 100644 --- a/src/nostr/client.rs +++ b/src/nostr/client.rs @@ -36,6 +36,13 @@ use crate::nostr::{NostrConfig, NostrIdentity, NostrStore}; use crate::tor::transport::ArtiWebSocketTransport; use crate::wallet::Wallet; +/// A peer's published nostr profile (kind-0 metadata), used to confirm a +/// pasted key belongs to a live identity before paying it. +pub struct NostrProfile { + pub name: Option, + pub nip05: Option, +} + /// Subscription look-back window beyond the last connection time: gift wrap /// timestamps are randomized up to 2 days into the past (NIP-59), use 3 days. const LOOKBACK_SECS: i64 = 3 * 86_400; @@ -127,6 +134,31 @@ impl NostrService { self.keys.secret_key().to_bech32().ok() } + /// Fetch a pubkey's published kind-0 profile over the connected relay + /// pool (one shot, short timeout). `Some` means the key is a live nostr + /// identity; `None` means no profile is published (new/anonymous key) or + /// the relays were unreachable. Blocking — call from a worker thread. + pub fn fetch_profile_blocking(&self, hex: &str) -> Option { + let client = self.client.read().clone()?; + let pk = PublicKey::from_hex(hex).ok()?; + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .ok()?; + rt.block_on(async { + let filter = Filter::new().kind(Kind::Metadata).author(pk).limit(1); + let events = client + .fetch_events(filter, Duration::from_secs(10)) + .await + .ok()?; + let md: Metadata = serde_json::from_str(&events.first()?.content).ok()?; + Some(NostrProfile { + name: md.name.filter(|s| !s.is_empty()), + nip05: md.nip05.filter(|s| !s.is_empty()), + }) + }) + } + /// Read the current outgoing-send phase (see [`send_phase`]). pub fn send_phase(&self) -> u8 { self.send_phase.load(Ordering::Relaxed) diff --git a/src/nostr/mod.rs b/src/nostr/mod.rs index 55f9e13c..2f899ba8 100644 --- a/src/nostr/mod.rs +++ b/src/nostr/mod.rs @@ -37,7 +37,7 @@ pub mod ingest; pub use ingest::*; mod client; -pub use client::{NostrService, send_phase}; +pub use client::{NostrProfile, NostrService, send_phase}; pub mod avatar; pub mod nip05;