mirror of
https://code.gri.mw/GUI/grim.git
synced 2026-07-10 08:54:11 +00:00
Build 17: elastic recipient search w/ nostr verification, numpad, copy
Recipient picker (send.rs) is now type-ahead instead of button-driven: - as you type, instant local-contact matches + a debounced network lookup surface as tappable cards (no more "Find recipient" button) - a name or @handle is treated as a goblin username (nip05 resolve) and shown as a tappable card — tap the right identity to select it - a pasted npub/hex is verified over nostr: fetch its kind-0 profile and badge the card "✓ on nostr"; a valid key with no profile shows "no profile" and, when tapped, asks "Pay an unverified key?" before continuing (owner choice — never hard-blocked) NostrService::fetch_profile_blocking (client.rs) — one-shot kind-0 fetch over the connected relay pool (nrelay + damus + nos.lol), so arbitrary npubs resolve across the public network. data::search_contacts for the local half. Numpad (widgets.rs): centered, evenly-columned grid — the trailing per-key space was drifting it off-center under the amount; hovered keys now highlight in accent. Onboarding identity step: added a plain-spoken line that you can swap in a fresh key any time to unlink from your old identity. 34 lib tests green. Verified live on :2: numpad centers (92/198/300), fiatjaf npub → "✓ on nostr", an unprofiled key → "no profile" → confirm gate → Pay anyway → Review. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
+341
-139
@@ -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>), 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<String>,
|
||||
/// Async NIP-05 resolution result, written by a worker thread.
|
||||
resolve_slot: std::sync::Arc<std::sync::Mutex<Option<ResolveOutcome>>>,
|
||||
/// Resolution in flight (bech32/nip05).
|
||||
resolving: bool,
|
||||
/// Async lookup result for the current query, written by a worker.
|
||||
lookup_slot: std::sync::Arc<std::sync::Mutex<Option<LookupResult>>>,
|
||||
/// 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<Candidate>,
|
||||
/// Pending "pay an unverified key?" confirm gate.
|
||||
confirm_unverified: Option<Candidate>,
|
||||
/// Camera QR scanner when scanning for a recipient.
|
||||
scan: Option<CameraContent>,
|
||||
/// 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<Option<egui::TextureHandle>> = 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<Option<egui::TextureHandle>> = peers
|
||||
// Type-ahead results: instant local matches + the network candidate.
|
||||
let mut cands: Vec<Candidate> = 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<Option<egui::TextureHandle>> = 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<String>) {
|
||||
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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<String>,
|
||||
pub nip05: Option<String>,
|
||||
}
|
||||
|
||||
/// 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<NostrProfile> {
|
||||
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)
|
||||
|
||||
+1
-1
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user