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:
Binary file not shown.
|
After Width: | Height: | Size: 1.5 KiB |
@@ -1 +1 @@
|
||||
grim.png
|
||||
goblin.png
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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())
|
||||
|
||||
Reference in New Issue
Block a user