1
0
forked from GRIN/grim

Build 32: federate identity + sharpen identity chrome

- Accept any NIP-05 domain in the send flow (user@domain resolves and pays;
  foreign handles display with their domain and hit the unverified-key gate)
- Share and scan nprofile (npub + relay hints) so a recipient is reachable
  with no registry/indexer lookup; hints ride the DM send path
- Receive QR, copy buttons and settings row now emit nostr ID (nprofile)
- Sidebar identity chip truncates long handles on one line (was wrapping)
- Crisper small goblin mark via a 2x raster at chip sizes
- Map the server's name-change cooldown to friendly copy in claim/release
This commit is contained in:
2ro
2026-06-12 11:54:35 -04:00
parent 60414e9477
commit b9ce88e996
8 changed files with 136 additions and 32 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

+1 -1
View File
@@ -1 +1 @@
grim.png
goblin.png
+12
View File
@@ -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<char> = 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) {
+26 -8
View File
@@ -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);
}
+52 -14
View File
@@ -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<String>,
}
/// 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<String>,
}
/// 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(),
));
}
}
+33 -3
View File
@@ -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<RelayUrl> = 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<String> {
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<String, String> {
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<NostrService>, 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<NostrService>, 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;
+1 -1
View File
@@ -437,7 +437,7 @@ pub enum WalletTask {
/// * amount
/// * receiver public key (hex)
/// * optional note (subject line)
NostrSend(u64, String, Option<String>),
NostrSend(u64, String, Option<String>, Vec<String>),
/// Re-dispatch the pending nostr message for transaction.
/// * tx id
NostrResend(u32),
+11 -5
View File
@@ -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())