diff --git a/img/goblin-logo2-48.png b/img/goblin-logo2-48.png new file mode 100644 index 00000000..e01d63a2 Binary files /dev/null and b/img/goblin-logo2-48.png differ diff --git a/linux/Goblin.AppDir/.DirIcon b/linux/Goblin.AppDir/.DirIcon index 0573ded1..a25debeb 120000 --- a/linux/Goblin.AppDir/.DirIcon +++ b/linux/Goblin.AppDir/.DirIcon @@ -1 +1 @@ -grim.png \ No newline at end of file +goblin.png \ No newline at end of file diff --git a/src/gui/views/goblin/data.rs b/src/gui/views/goblin/data.rs index bf75a1ce..86b889a8 100644 --- a/src/gui/views/goblin/data.rs +++ b/src/gui/views/goblin/data.rs @@ -71,6 +71,18 @@ pub fn hue_of(hex: &str) -> usize { % crate::gui::theme::avatar_pairs_len() } +/// Single-line display form of a handle for narrow chips: middle-ellipsis +/// past 16 chars, keeping the tail (names often differ at the end). +pub fn short_handle(handle: &str) -> String { + let chars: Vec = handle.chars().collect(); + if chars.len() <= 16 { + return handle.to_string(); + } + let head: String = chars[..10].iter().collect(); + let tail: String = chars[chars.len() - 4..].iter().collect(); + format!("{head}…{tail}") +} + pub fn short_npub(hex: &str) -> String { use nostr_sdk::{PublicKey, ToBech32}; if let Ok(pk) = PublicKey::from_hex(hex) { diff --git a/src/gui/views/goblin/mod.rs b/src/gui/views/goblin/mod.rs index 9bdc26a0..2e75a9e2 100644 --- a/src/gui/views/goblin/mod.rs +++ b/src/gui/views/goblin/mod.rs @@ -1110,12 +1110,16 @@ impl GoblinWalletView { }) .unwrap_or_else(|| "—".to_string()); let npub = wallet.nostr_service().map(|s| s.npub()).unwrap_or_default(); + let nprofile = wallet + .nostr_service() + .map(|s| s.nprofile()) + .unwrap_or_else(|| npub.clone()); w::card(ui, |ui| { ui.vertical_centered(|ui| { // QR of the nostr handle (nostr: URI). ui.add_space(12.0); - let uri = format!("nostr:{}", npub); + let uri = format!("nostr:{}", nprofile); w::qr_code(ui, &uri, 220.0); ui.add_space(14.0); ui.label( @@ -1171,13 +1175,13 @@ impl GoblinWalletView { let label = if copied0 { format!("{} Copied", CHECK) } else { - format!("{} Copy npub", COPY) + format!("{} Copy nostr ID", COPY) }; if w::big_action(ui, &label, true).clicked() { - let copy = if npub.is_empty() { + let copy = if nprofile.is_empty() { handle.clone() } else { - npub.clone() + nprofile.clone() }; cb.copy_string_to_buffer(copy); self.receive_copied = Some((0, std::time::Instant::now())); @@ -2606,6 +2610,11 @@ fn start_claim_flow(claim: &mut ClaimState, name: &str, wallet: &Wallet) { RegisterResult::Conflict(_) => { 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." + .into(), + ), RegisterResult::Rejected(e) => ClaimMsg::Error(e), RegisterResult::Network => ClaimMsg::Error( "Couldn't reach goblin.st — connection hiccup. Try again.".into(), @@ -2641,6 +2650,9 @@ fn start_release(claim: &mut ClaimState, name: &str, wallet: &Wallet) { }; 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); @@ -2682,15 +2694,21 @@ fn start_avatar_upload( /// Draw the small Goblin mascot mark. pub fn widgets_logo(ui: &mut egui::Ui) { - widgets_logo_sized(ui, 22.0); + widgets_logo_sized(ui, 24.0); } /// Tinted goblin mark at a given size. pub fn widgets_logo_sized(ui: &mut egui::Ui, size: f32) { let (rect, _) = ui.allocate_exact_size(Vec2::splat(size), Sense::hover()); - let img = egui::Image::new(egui::include_image!("../../../../img/goblin-logo2.svg")) - .tint(theme::tokens().text) - .fit_to_exact_size(Vec2::splat(size)); + // Chip-sized marks use a pre-rendered 48px raster: cleaner antialiasing + // at ~24px than runtime svg rasterization, with 2x headroom for hidpi. + let img = egui::Image::new(if size <= 32.0 { + egui::include_image!("../../../../img/goblin-logo2-48.png") + } else { + egui::include_image!("../../../../img/goblin-logo2.svg") + }) + .tint(theme::tokens().text) + .fit_to_exact_size(Vec2::splat(size)); img.paint_at(ui, rect); } diff --git a/src/gui/views/goblin/send.rs b/src/gui/views/goblin/send.rs index 884c18db..0d9d5ea9 100644 --- a/src/gui/views/goblin/send.rs +++ b/src/gui/views/goblin/send.rs @@ -64,6 +64,9 @@ struct Recipient { name: String, npub: String, hue: usize, + /// Recipient relay hints (nprofile / NIP-05 resolution), extra delivery + /// targets for a recipient whose kind 10050 isn't discoverable yet. + relay_hints: Vec, } /// A recipient search hit shown as a tappable card. @@ -77,6 +80,8 @@ struct Candidate { verified: bool, /// Short provenance tag shown on the card ("on nostr", "@goblin.st", …). tag: &'static str, + /// Relay hints carried by an nprofile or NIP-05 resolution. + relay_hints: Vec, } /// Async network lookup result for the typed query. @@ -142,7 +147,12 @@ impl SendFlow { /// Pre-fill a contact and skip to amount entry. pub fn prefill_contact(&mut self, name: String, npub: String) { let hue = data::hue_of(&npub); - self.recipient = Some(Recipient { name, npub, hue }); + self.recipient = Some(Recipient { + name, + npub, + hue, + relay_hints: vec![], + }); self.stage = Stage::Amount; } @@ -360,6 +370,7 @@ impl SendFlow { hue, verified: true, tag: "", + relay_hints: vec![], }); } } @@ -376,6 +387,7 @@ impl SendFlow { hue, verified: true, tag: "contact", + relay_hints: vec![], }) .collect(); if let Some(net) = &self.net_candidate { @@ -435,6 +447,7 @@ impl SendFlow { name: cand.name, npub: cand.npub, hue: cand.hue, + relay_hints: cand.relay_hints, }); let preset = amount_from_hr_string(&self.amount) .map(|a| a > 0) @@ -589,13 +602,20 @@ impl SendFlow { self.lookup_query = query.clone(); self.error = None; + use nostr_sdk::nips::nip19::Nip19Profile; use nostr_sdk::{FromBech32, PublicKey}; - 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()) + let key_input = query.strip_prefix("nostr:").unwrap_or(&query); + let (hex, key_hints) = if let Ok(pk) = PublicKey::from_bech32(key_input) { + (Some(pk.to_hex()), vec![]) + } else if let Ok(p) = Nip19Profile::from_bech32(key_input) { + // nprofile carries the recipient's own relay hints — the only + // routing info available for a fresh, undiscoverable key. + let hints = p.relays.iter().map(|r| r.to_string()).collect(); + (Some(p.public_key.to_hex()), hints) + } else if key_input.len() == 64 && key_input.chars().all(|c| c.is_ascii_hexdigit()) { + (Some(key_input.to_lowercase()), vec![]) } else { - None + (None, vec![]) }; let slot = self.lookup_slot.clone(); @@ -619,6 +639,7 @@ impl SendFlow { hue, verified: true, tag: "contact", + relay_hints: key_hints, }), (None, Some(p)) => { let name = p @@ -633,6 +654,7 @@ impl SendFlow { hue, verified: true, tag: "on nostr", + relay_hints: key_hints, }) } (None, None) => LookupResult::Unverified(Candidate { @@ -641,6 +663,7 @@ impl SendFlow { hue, verified: false, tag: "", + relay_hints: key_hints, }), }; *slot.lock().unwrap() = Some(res); @@ -653,20 +676,30 @@ impl SendFlow { let res = match resolve_nip05_blocking(&name, &domain) { Some(r) => { let hex = r.pubkey.to_hex(); + let home = domain == crate::nostr::relays::HOME_NIP05_DOMAIN; + // Foreign handles display with their domain so they + // can't masquerade as goblin handles; the NIP-05 root + // convention `_@domain` displays as just the domain. + let display = if home { + format!("@{name}") + } else if name == "_" { + domain.clone() + } else { + format!("{name}@{domain}") + }; LookupResult::Found(Candidate { - name: format!("@{name}"), + name: display, npub: hex.clone(), hue: data::hue_of(&hex), // Only goblin.st identities skip the confirm gate. // A third-party domain's well-known could point at // any key, so route those through the same "pay an // unverified key?" gate as a bare npub. - verified: domain == "goblin.st", - tag: if domain == "goblin.st" { - "@goblin.st" - } else { - "nip-05" - }, + verified: home, + tag: if home { "@goblin.st" } else { "nip-05" }, + // Resolution relay hints help deliver to a + // recipient whose kind 10050 we can't see. + relay_hints: r.relays, }) } None => LookupResult::NotFound(label), @@ -873,7 +906,12 @@ impl SendFlow { if let Some(service) = wallet.nostr_service() { service.set_send_phase(crate::nostr::send_phase::WORKING); } - wallet.task(WalletTask::NostrSend(amount, recipient.npub.clone(), note)); + wallet.task(WalletTask::NostrSend( + amount, + recipient.npub.clone(), + note, + recipient.relay_hints.clone(), + )); } } diff --git a/src/nostr/client.rs b/src/nostr/client.rs index 6f9a6d5b..70043781 100644 --- a/src/nostr/client.rs +++ b/src/nostr/client.rs @@ -129,6 +129,24 @@ impl NostrService { self.identity.read().npub.clone() } + /// Shareable NIP-19 nprofile: our pubkey plus up to two of our relays as + /// routing hints, so a sender can reach us without any registry or + /// indexer lookup. Falls back to the bare npub when encoding fails. + pub fn nprofile(&self) -> String { + use nostr_sdk::RelayUrl; + use nostr_sdk::nips::nip19::Nip19Profile; + let relays: Vec = self + .relays() + .iter() + .filter_map(|r| RelayUrl::parse(r).ok()) + .take(2) + .collect(); + Nip19Profile::new(self.keys.public_key(), relays) + .to_bech32() + .ok() + .unwrap_or_else(|| self.npub()) + } + /// Own nsec (secret key) bech32 — for explicit user backup only. pub fn nsec(&self) -> Option { self.keys.secret_key().to_bech32().ok() @@ -279,12 +297,16 @@ impl NostrService { } /// Dispatch a payment DM (slatepack + optional note) to a recipient, - /// publishing to their DM relays plus our own relay set. + /// publishing to their DM relays plus our own relay set. `relay_hints` + /// are extra recipient relays carried by an nprofile the sender pasted + /// or scanned — the only routing info we have for a fresh recipient + /// whose kind 10050 isn't discoverable from our relays. pub async fn send_payment_dm( &self, receiver_hex: &str, slatepack: &str, note: Option<&str>, + relay_hints: &[String], ) -> Result { let client = { let r_client = self.client.read(); @@ -297,6 +319,11 @@ impl NostrService { // Resolve receiver DM relays (kind 10050) with our relays as fallback. let mut urls = self.fetch_dm_relays(&client, &receiver).await; + for r in relay_hints { + if !urls.contains(r) { + urls.push(r.clone()); + } + } for r in self.relays() { if !urls.contains(&r) { urls.push(r); @@ -526,7 +553,7 @@ async fn reconcile(svc: &Arc, wallet: &Wallet) { meta.slate_id, state ); match svc - .send_payment_dm(&meta.npub, &text, meta.note.as_deref()) + .send_payment_dm(&meta.npub, &text, meta.note.as_deref(), &[]) .await { Ok(event_id) => { @@ -677,7 +704,10 @@ async fn handle_wrap(svc: &Arc, wallet: &Wallet, client: &Client, created_at: now, updated_at: now, }); - match svc.send_payment_dm(&sender_hex, &reply_text, None).await { + match svc + .send_payment_dm(&sender_hex, &reply_text, None, &[]) + .await + { Ok(event_id) => { if let Some(mut meta) = svc.store.tx_meta(&slate.id.to_string()) { meta.status = NostrSendStatus::RepliedS2; diff --git a/src/wallet/types.rs b/src/wallet/types.rs index 6623c837..853a6c3b 100644 --- a/src/wallet/types.rs +++ b/src/wallet/types.rs @@ -437,7 +437,7 @@ pub enum WalletTask { /// * amount /// * receiver public key (hex) /// * optional note (subject line) - NostrSend(u64, String, Option), + NostrSend(u64, String, Option, Vec), /// Re-dispatch the pending nostr message for transaction. /// * tx id NostrResend(u32), diff --git a/src/wallet/wallet.rs b/src/wallet/wallet.rs index 7fc8ef4c..67ff405f 100644 --- a/src/wallet/wallet.rs +++ b/src/wallet/wallet.rs @@ -2256,7 +2256,7 @@ async fn handle_task(w: &Wallet, t: WalletTask) { w.on_tx_error(*id, Some(e)); } }, - WalletTask::NostrSend(a, receiver, note) => { + WalletTask::NostrSend(a, receiver, note, relay_hints) => { let Some(service) = w.nostr_service() else { error!("nostr send: service not available"); return; @@ -2285,7 +2285,7 @@ async fn handle_task(w: &Wallet, t: WalletTask) { w.send_creating.store(false, Ordering::Relaxed); if let Some(text) = w.read_slatepack_text(s.id, &s.state) { match service - .send_payment_dm(receiver, &text, note.as_deref()) + .send_payment_dm(receiver, &text, note.as_deref(), relay_hints) .await { Ok(event_id) => { @@ -2342,7 +2342,7 @@ async fn handle_task(w: &Wallet, t: WalletTask) { if let Some(meta) = service.store.tx_meta(&slate_id) { if let Some(text) = w.read_slatepack_text(s.id, &s.state) { match service - .send_payment_dm(&meta.npub, &text, meta.note.as_deref()) + .send_payment_dm(&meta.npub, &text, meta.note.as_deref(), &[]) .await { Ok(event_id) => { @@ -2397,7 +2397,10 @@ async fn handle_task(w: &Wallet, t: WalletTask) { created_at: now, updated_at: now, }); - match service.send_payment_dm(&request.npub, &text, None).await { + match service + .send_payment_dm(&request.npub, &text, None, &[]) + .await + { Ok(event_id) => { if let Some(mut meta) = service.store.tx_meta(&reply.id.to_string()) { @@ -2436,7 +2439,10 @@ async fn handle_task(w: &Wallet, t: WalletTask) { created_at: now, updated_at: now, }); - match service.send_payment_dm(&request.npub, &text, None).await { + match service + .send_payment_dm(&request.npub, &text, None, &[]) + .await + { Ok(event_id) => { if let Some(mut meta) = service.store.tx_meta(&reply.id.to_string())