Build 78: honest transport labels, request decline/cancel, NIP-05 on requests, full localization
Settings now says "Manual transaction" and the privacy row reads "Messages & lookups" opening a new Network privacy page that tells the truth: messages, names, price and avatars ride the Nym mixnet; the grin node connects directly. README and lander updated to match. Requests are messages, payments are final: declining a request now sends the requester a void control message (NIP-17), a requester can cancel a request they sent (cancels the local invoice and notifies the payer), and incoming requests resolve the sender's verified @username instead of a bare npub. The Requested amount on the success screen is centered. New NostrDecline/NostrCancel tasks and a goblin-action control message carry it, bound to the stored counterparty. Localization: every Goblin-screen string moved to t!() keys (370 keys) and translated into de/fr/ru/tr/zh-CN, guarded by a key/placeholder drift test. System-locale auto-detect now matches region locales like zh-CN.
This commit is contained in:
+564
-345
File diff suppressed because it is too large
Load Diff
+119
-101
@@ -185,22 +185,18 @@ impl OnboardingContent {
|
||||
);
|
||||
});
|
||||
ui.add_space(26.0);
|
||||
let lines: [(&str, &str); 3] = [
|
||||
let lines: [(String, String); 3] = [
|
||||
(
|
||||
"Private money",
|
||||
"Goblin is a wallet for grin — digital cash with no amounts \
|
||||
or addresses on its chain.",
|
||||
t!("goblin.onboarding.intro.private_money_head").to_string(),
|
||||
t!("goblin.onboarding.intro.private_money_body").to_string(),
|
||||
),
|
||||
(
|
||||
"Send like a message",
|
||||
"Pay a @username or npub and it arrives as an end-to-end \
|
||||
encrypted message over nostr and the Nym mixnet — no one in \
|
||||
between can see the amount or who's involved.",
|
||||
t!("goblin.onboarding.intro.send_like_message_head").to_string(),
|
||||
t!("goblin.onboarding.intro.send_like_message_body").to_string(),
|
||||
),
|
||||
(
|
||||
"Yours alone",
|
||||
"Keys, names and history live on this device. Built on the \
|
||||
GRIM wallet.",
|
||||
t!("goblin.onboarding.intro.yours_alone_head").to_string(),
|
||||
t!("goblin.onboarding.intro.yours_alone_body").to_string(),
|
||||
),
|
||||
];
|
||||
for (head, body) in lines {
|
||||
@@ -221,13 +217,13 @@ impl OnboardingContent {
|
||||
ui.add_space(10.0);
|
||||
}
|
||||
ui.add_space(16.0);
|
||||
if w::big_action(ui, "Get started", false).clicked() {
|
||||
if w::big_action(ui, &t!("goblin.onboarding.intro.get_started"), false).clicked() {
|
||||
self.step = Step::Node;
|
||||
}
|
||||
ui.add_space(8.0);
|
||||
ui.vertical_centered(|ui| {
|
||||
ui.label(
|
||||
RichText::new("Takes about a minute. You can change everything later.")
|
||||
RichText::new(t!("goblin.onboarding.intro.footnote"))
|
||||
.font(FontId::new(12.5, fonts::regular()))
|
||||
.color(t.text_mute),
|
||||
);
|
||||
@@ -293,17 +289,16 @@ impl OnboardingContent {
|
||||
let t = theme::tokens();
|
||||
self.step_header(
|
||||
ui,
|
||||
"STEP 1 OF 3 · NETWORK",
|
||||
"How should Goblin\nwatch the chain?",
|
||||
&t!("goblin.onboarding.node.kicker"),
|
||||
&t!("goblin.onboarding.node.title"),
|
||||
Step::Intro,
|
||||
);
|
||||
if Self::node_card(
|
||||
ui,
|
||||
self.integrated,
|
||||
"Run my own node",
|
||||
"Private",
|
||||
"Trusts no one — your wallet checks the chain itself. Syncs in \
|
||||
the background while you finish setup.",
|
||||
&t!("goblin.onboarding.node.own_title"),
|
||||
&t!("goblin.onboarding.node.own_badge"),
|
||||
&t!("goblin.onboarding.node.own_body"),
|
||||
) {
|
||||
self.integrated = true;
|
||||
}
|
||||
@@ -311,9 +306,9 @@ impl OnboardingContent {
|
||||
if Self::node_card(
|
||||
ui,
|
||||
!self.integrated,
|
||||
"Connect to a node",
|
||||
"Instant",
|
||||
"No sync wait. The node you pick can see your wallet's queries.",
|
||||
&t!("goblin.onboarding.node.connect_title"),
|
||||
&t!("goblin.onboarding.node.connect_badge"),
|
||||
&t!("goblin.onboarding.node.connect_body"),
|
||||
) {
|
||||
self.integrated = false;
|
||||
}
|
||||
@@ -330,7 +325,7 @@ impl OnboardingContent {
|
||||
}
|
||||
ui.add_space(8.0);
|
||||
ui.label(
|
||||
RichText::new("Changeable any time in Settings → Node.")
|
||||
RichText::new(t!("goblin.onboarding.node.changeable"))
|
||||
.font(FontId::new(12.5, fonts::regular()))
|
||||
.color(t.text_mute),
|
||||
);
|
||||
@@ -338,13 +333,13 @@ impl OnboardingContent {
|
||||
let url_ok = self.integrated
|
||||
|| self.ext_url.trim().starts_with("http://")
|
||||
|| self.ext_url.trim().starts_with("https://");
|
||||
if w::big_action(ui, "Continue", false).clicked() && url_ok {
|
||||
if w::big_action(ui, &t!("goblin.onboarding.node.continue"), false).clicked() && url_ok {
|
||||
self.step = Step::WalletSetup;
|
||||
}
|
||||
if !url_ok {
|
||||
ui.add_space(8.0);
|
||||
ui.label(
|
||||
RichText::new("Node URL must start with http:// or https://")
|
||||
RichText::new(t!("goblin.onboarding.node.url_invalid"))
|
||||
.font(FontId::new(13.0, fonts::regular()))
|
||||
.color(t.neg),
|
||||
);
|
||||
@@ -355,12 +350,20 @@ impl OnboardingContent {
|
||||
|
||||
fn wallet_setup_ui(&mut self, ui: &mut egui::Ui, cb: &dyn PlatformCallbacks) {
|
||||
let t = theme::tokens();
|
||||
self.step_header(ui, "STEP 2 OF 3 · WALLET", "Set up your wallet", Step::Node);
|
||||
self.step_header(
|
||||
ui,
|
||||
&t!("goblin.onboarding.wallet.kicker"),
|
||||
&t!("goblin.onboarding.wallet.title"),
|
||||
Step::Node,
|
||||
);
|
||||
|
||||
// Create / Restore segmented choice.
|
||||
ui.horizontal(|ui| {
|
||||
let half = (ui.available_width() - 10.0) / 2.0;
|
||||
for (restore, label) in [(false, "Create new"), (true, "Restore from seed")] {
|
||||
for (restore, label) in [
|
||||
(false, t!("goblin.onboarding.wallet.create_new")),
|
||||
(true, t!("goblin.onboarding.wallet.restore_from_seed")),
|
||||
] {
|
||||
ui.scope_builder(
|
||||
egui::UiBuilder::new().max_rect(egui::Rect::from_min_size(
|
||||
ui.cursor().min,
|
||||
@@ -368,7 +371,7 @@ impl OnboardingContent {
|
||||
)),
|
||||
|ui| {
|
||||
let active = self.restore == restore;
|
||||
let resp = w::chip(ui, label, active);
|
||||
let resp = w::chip(ui, &label, active);
|
||||
if resp.clicked() {
|
||||
self.restore = restore;
|
||||
}
|
||||
@@ -382,7 +385,7 @@ impl OnboardingContent {
|
||||
w::field_well(ui, |ui| {
|
||||
TextEdit::new(egui::Id::from("onb_name"))
|
||||
.focus(false)
|
||||
.hint_text("Wallet name")
|
||||
.hint_text(t!("goblin.onboarding.wallet.name_hint"))
|
||||
.text_color(t.surface_text)
|
||||
.body()
|
||||
.ui(ui, &mut self.name, cb);
|
||||
@@ -391,7 +394,7 @@ impl OnboardingContent {
|
||||
w::field_well(ui, |ui| {
|
||||
TextEdit::new(egui::Id::from("onb_pass"))
|
||||
.focus(false)
|
||||
.hint_text("Password")
|
||||
.hint_text(t!("goblin.onboarding.wallet.password_hint"))
|
||||
.password()
|
||||
.text_color(t.surface_text)
|
||||
.body()
|
||||
@@ -401,7 +404,7 @@ impl OnboardingContent {
|
||||
w::field_well(ui, |ui| {
|
||||
TextEdit::new(egui::Id::from("onb_pass2"))
|
||||
.focus(false)
|
||||
.hint_text("Repeat password")
|
||||
.hint_text(t!("goblin.onboarding.wallet.repeat_password_hint"))
|
||||
.password()
|
||||
.text_color(t.surface_text)
|
||||
.body()
|
||||
@@ -410,10 +413,9 @@ impl OnboardingContent {
|
||||
ui.add_space(10.0);
|
||||
ui.label(
|
||||
RichText::new(if self.restore {
|
||||
"Have your seed words ready — you'll enter them next."
|
||||
t!("goblin.onboarding.wallet.restore_hint")
|
||||
} else {
|
||||
"Next you'll get 24 seed words to write down. They are the \
|
||||
money — anyone holding them holds your funds."
|
||||
t!("goblin.onboarding.wallet.create_hint")
|
||||
})
|
||||
.font(FontId::new(12.5, fonts::regular()))
|
||||
.color(t.text_mute),
|
||||
@@ -422,7 +424,10 @@ impl OnboardingContent {
|
||||
|
||||
let pass_ok = !self.pass.is_empty() && self.pass == self.pass2;
|
||||
let name_ok = !self.name.trim().is_empty();
|
||||
if w::big_action(ui, "Continue", false).clicked() && pass_ok && name_ok {
|
||||
if w::big_action(ui, &t!("goblin.onboarding.wallet.continue"), false).clicked()
|
||||
&& pass_ok
|
||||
&& name_ok
|
||||
{
|
||||
self.mnemonic_setup.reset();
|
||||
self.mnemonic_setup.mnemonic.set_mode(if self.restore {
|
||||
PhraseMode::Import
|
||||
@@ -436,7 +441,7 @@ impl OnboardingContent {
|
||||
if !self.pass.is_empty() && self.pass != self.pass2 {
|
||||
ui.add_space(8.0);
|
||||
ui.label(
|
||||
RichText::new("Passwords don't match")
|
||||
RichText::new(t!("goblin.onboarding.wallet.passwords_no_match"))
|
||||
.font(FontId::new(13.0, fonts::regular()))
|
||||
.color(t.neg),
|
||||
);
|
||||
@@ -453,14 +458,15 @@ impl OnboardingContent {
|
||||
) {
|
||||
let t = theme::tokens();
|
||||
let restore = self.mnemonic_setup.mnemonic.mode() == PhraseMode::Import;
|
||||
let words_title = if restore {
|
||||
t!("goblin.onboarding.words.title_restore")
|
||||
} else {
|
||||
t!("goblin.onboarding.words.title_create")
|
||||
};
|
||||
self.step_header(
|
||||
ui,
|
||||
"STEP 2 OF 3 · WALLET",
|
||||
if restore {
|
||||
"Enter your seed words"
|
||||
} else {
|
||||
"Write these words down"
|
||||
},
|
||||
&t!("goblin.onboarding.words.kicker"),
|
||||
&words_title,
|
||||
Step::WalletSetup,
|
||||
);
|
||||
if restore {
|
||||
@@ -478,12 +484,9 @@ impl OnboardingContent {
|
||||
ui.add_space(10.0);
|
||||
} else {
|
||||
ui.label(
|
||||
RichText::new(
|
||||
"On paper, in order. Anyone with these words can take \
|
||||
your funds; without them a lost device means lost funds.",
|
||||
)
|
||||
.font(FontId::new(13.0, fonts::regular()))
|
||||
.color(t.text_dim),
|
||||
RichText::new(t!("goblin.onboarding.words.write_down_hint"))
|
||||
.font(FontId::new(13.0, fonts::regular()))
|
||||
.color(t.text_dim),
|
||||
);
|
||||
ui.add_space(10.0);
|
||||
}
|
||||
@@ -501,7 +504,7 @@ impl OnboardingContent {
|
||||
Vec2::new(half, 44.0),
|
||||
)),
|
||||
|ui| {
|
||||
if w::chip(ui, "Paste", false).clicked() {
|
||||
if w::chip(ui, &t!("goblin.onboarding.words.paste"), false).clicked() {
|
||||
let data = ZeroingString::from(cb.get_string_from_buffer());
|
||||
self.mnemonic_setup.mnemonic.import(&data);
|
||||
}
|
||||
@@ -514,7 +517,7 @@ impl OnboardingContent {
|
||||
Vec2::new(half, 44.0),
|
||||
)),
|
||||
|ui| {
|
||||
if w::chip(ui, "Scan QR", false).clicked() {
|
||||
if w::chip(ui, &t!("goblin.onboarding.words.scan_qr"), false).clicked() {
|
||||
self.scan_modal = Some(CameraScanContent::default());
|
||||
Modal::new(OB_PHRASE_SCAN_MODAL)
|
||||
.position(ModalPosition::CenterTop)
|
||||
@@ -527,7 +530,7 @@ impl OnboardingContent {
|
||||
);
|
||||
});
|
||||
ui.add_space(14.0);
|
||||
} else if w::chip(ui, "Copy to clipboard (avoid this)", false).clicked() {
|
||||
} else if w::chip(ui, &t!("goblin.onboarding.words.copy_clipboard"), false).clicked() {
|
||||
cb.copy_string_to_buffer(self.mnemonic_setup.mnemonic.get_phrase());
|
||||
}
|
||||
if !restore {
|
||||
@@ -540,12 +543,12 @@ impl OnboardingContent {
|
||||
true
|
||||
};
|
||||
let label = if restore {
|
||||
"Restore wallet"
|
||||
t!("goblin.onboarding.words.restore_wallet")
|
||||
} else {
|
||||
"I wrote them down"
|
||||
t!("goblin.onboarding.words.wrote_them_down")
|
||||
};
|
||||
if ready {
|
||||
if w::big_action(ui, label, false).clicked() {
|
||||
if w::big_action(ui, &label, false).clicked() {
|
||||
if restore {
|
||||
self.create_wallet(wallets);
|
||||
} else {
|
||||
@@ -554,7 +557,7 @@ impl OnboardingContent {
|
||||
}
|
||||
} else {
|
||||
ui.label(
|
||||
RichText::new("Fill every word — tap a word to edit it, or paste the phrase.")
|
||||
RichText::new(t!("goblin.onboarding.words.fill_every_word"))
|
||||
.font(FontId::new(13.0, fonts::regular()))
|
||||
.color(t.text_mute),
|
||||
);
|
||||
@@ -569,27 +572,32 @@ impl OnboardingContent {
|
||||
cb: &dyn PlatformCallbacks,
|
||||
) {
|
||||
let t = theme::tokens();
|
||||
self.step_header(ui, "STEP 2 OF 3 · WALLET", "Now prove it", Step::Words);
|
||||
self.step_header(
|
||||
ui,
|
||||
&t!("goblin.onboarding.confirm.kicker"),
|
||||
&t!("goblin.onboarding.confirm.title"),
|
||||
Step::Words,
|
||||
);
|
||||
ui.label(
|
||||
RichText::new("Enter the words you just wrote down. Tap a word to type it.")
|
||||
RichText::new(t!("goblin.onboarding.confirm.enter_hint"))
|
||||
.font(FontId::new(13.0, fonts::regular()))
|
||||
.color(t.text_dim),
|
||||
);
|
||||
ui.add_space(10.0);
|
||||
self.mnemonic_setup.word_list_ui(ui, true);
|
||||
ui.add_space(14.0);
|
||||
if w::chip(ui, "Paste", false).clicked() {
|
||||
if w::chip(ui, &t!("goblin.onboarding.confirm.paste"), false).clicked() {
|
||||
let data = ZeroingString::from(cb.get_string_from_buffer());
|
||||
self.mnemonic_setup.mnemonic.import(&data);
|
||||
}
|
||||
ui.add_space(14.0);
|
||||
if !self.mnemonic_setup.mnemonic.has_empty_or_invalid() {
|
||||
if w::big_action(ui, "Create wallet", false).clicked() {
|
||||
if w::big_action(ui, &t!("goblin.onboarding.confirm.create_wallet"), false).clicked() {
|
||||
self.create_wallet(wallets);
|
||||
}
|
||||
} else {
|
||||
ui.label(
|
||||
RichText::new("Keep going — every word, in order.")
|
||||
RichText::new(t!("goblin.onboarding.confirm.keep_going"))
|
||||
.font(FontId::new(13.0, fonts::regular()))
|
||||
.color(t.text_mute),
|
||||
);
|
||||
@@ -650,10 +658,20 @@ impl OnboardingContent {
|
||||
self.error = None;
|
||||
self.step = Step::Identity;
|
||||
}
|
||||
Err(e) => self.error = Some(format!("Couldn't open the wallet: {:?}", e)),
|
||||
Err(e) => {
|
||||
self.error = Some(
|
||||
t!("goblin.onboarding.errors.cant_open", err => format!("{:?}", e))
|
||||
.to_string(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => self.error = Some(format!("Couldn't create the wallet: {:?}", e)),
|
||||
Err(e) => {
|
||||
self.error = Some(
|
||||
t!("goblin.onboarding.errors.cant_create", err => format!("{:?}", e))
|
||||
.to_string(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -663,13 +681,13 @@ impl OnboardingContent {
|
||||
let t = theme::tokens();
|
||||
// No back from here: the wallet exists now.
|
||||
ui.label(
|
||||
RichText::new("STEP 3 OF 3 · IDENTITY")
|
||||
RichText::new(t!("goblin.onboarding.identity.kicker"))
|
||||
.font(fonts::kicker())
|
||||
.color(t.text_mute),
|
||||
);
|
||||
ui.add_space(18.0);
|
||||
ui.label(
|
||||
RichText::new("Your payment identity")
|
||||
RichText::new(t!("goblin.onboarding.identity.title"))
|
||||
.font(FontId::new(26.0, fonts::bold()))
|
||||
.color(t.text),
|
||||
);
|
||||
@@ -698,7 +716,7 @@ impl OnboardingContent {
|
||||
let short = if npub.len() > 20 {
|
||||
format!("{}…{}", &npub[..12], &npub[npub.len() - 6..])
|
||||
} else if npub.is_empty() {
|
||||
"key being made…".to_string()
|
||||
t!("goblin.onboarding.identity.key_being_made").to_string()
|
||||
} else {
|
||||
npub.clone()
|
||||
};
|
||||
@@ -709,9 +727,9 @@ impl OnboardingContent {
|
||||
);
|
||||
ui.label(
|
||||
RichText::new(if connected {
|
||||
"connected over Nym"
|
||||
t!("goblin.onboarding.identity.connected_nym")
|
||||
} else {
|
||||
"connecting over Nym…"
|
||||
t!("goblin.onboarding.identity.connecting_nym")
|
||||
})
|
||||
.font(FontId::new(12.0, fonts::regular()))
|
||||
.color(t.surface_text_mute),
|
||||
@@ -720,24 +738,15 @@ impl OnboardingContent {
|
||||
});
|
||||
ui.add_space(8.0);
|
||||
ui.label(
|
||||
RichText::new(
|
||||
"A fresh key, made for payments — deliberately not part \
|
||||
of your seed, so you can rotate it anytime to maintain \
|
||||
your privacy, without ever touching your funds. Back it \
|
||||
up in Settings → Identity.",
|
||||
)
|
||||
.font(FontId::new(12.5, fonts::regular()))
|
||||
.color(t.surface_text_dim),
|
||||
RichText::new(t!("goblin.onboarding.identity.fresh_key_blurb"))
|
||||
.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? Swap in a brand-new key any time — \
|
||||
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),
|
||||
RichText::new(t!("goblin.onboarding.identity.clean_slate_blurb"))
|
||||
.font(FontId::new(12.5, fonts::regular()))
|
||||
.color(t.surface_text_dim),
|
||||
);
|
||||
});
|
||||
ui.add_space(14.0);
|
||||
@@ -752,8 +761,13 @@ impl OnboardingContent {
|
||||
self.claim.message = Some(msg.to_string());
|
||||
}
|
||||
ClaimMsg::Registered(nip05) => {
|
||||
self.claim.message =
|
||||
Some(format!("You're @{}", nip05.split('@').next().unwrap_or("")));
|
||||
self.claim.message = Some(
|
||||
t!(
|
||||
"goblin.onboarding.identity.youre",
|
||||
name => nip05.split('@').next().unwrap_or("")
|
||||
)
|
||||
.to_string(),
|
||||
);
|
||||
self.claim.available = Some(true);
|
||||
if let Some(s) = wallet.nostr_service() {
|
||||
{
|
||||
@@ -779,19 +793,15 @@ impl OnboardingContent {
|
||||
w::card(ui, |ui| {
|
||||
ui.set_min_width(ui.available_width());
|
||||
ui.label(
|
||||
RichText::new("Pick a username — optional")
|
||||
RichText::new(t!("goblin.onboarding.identity.pick_username"))
|
||||
.font(FontId::new(15.0, fonts::semibold()))
|
||||
.color(t.surface_text),
|
||||
);
|
||||
ui.add_space(4.0);
|
||||
ui.label(
|
||||
RichText::new(
|
||||
"Friends pay @you instead of a long key. Public on \
|
||||
goblin.st; payments stay encrypted. Skip it and \
|
||||
you're simply anonymous — claim one any time later.",
|
||||
)
|
||||
.font(FontId::new(12.5, fonts::regular()))
|
||||
.color(t.surface_text_dim),
|
||||
RichText::new(t!("goblin.onboarding.identity.username_blurb"))
|
||||
.font(FontId::new(12.5, fonts::regular()))
|
||||
.color(t.surface_text_dim),
|
||||
);
|
||||
ui.add_space(8.0);
|
||||
w::field_well(ui, |ui| {
|
||||
@@ -804,7 +814,7 @@ impl OnboardingContent {
|
||||
let before = self.claim.input.clone();
|
||||
TextEdit::new(egui::Id::from("onb_claim"))
|
||||
.focus(false)
|
||||
.hint_text("yourname")
|
||||
.hint_text(t!("goblin.onboarding.identity.username_field_hint"))
|
||||
.text_color(t.surface_text)
|
||||
.body()
|
||||
.ui(ui, &mut self.claim.input, cb);
|
||||
@@ -833,21 +843,29 @@ impl OnboardingContent {
|
||||
ui.horizontal(|ui| {
|
||||
View::small_loading_spinner(ui);
|
||||
ui.add_space(8.0);
|
||||
ui.label(RichText::new("Working…").color(t.surface_text_dim));
|
||||
ui.label(
|
||||
RichText::new(t!("goblin.onboarding.identity.working"))
|
||||
.color(t.surface_text_dim),
|
||||
);
|
||||
});
|
||||
ui.ctx().request_repaint();
|
||||
} else {
|
||||
ui.add_enabled_ui(valid && connected, |ui| {
|
||||
if w::big_action_on_card(ui, "Claim username").clicked() {
|
||||
if w::big_action_on_card(
|
||||
ui,
|
||||
&t!("goblin.onboarding.identity.claim_username"),
|
||||
)
|
||||
.clicked()
|
||||
{
|
||||
start_claim_flow(&mut self.claim, &name, &wallet);
|
||||
}
|
||||
});
|
||||
if !connected {
|
||||
ui.add_space(6.0);
|
||||
ui.label(
|
||||
RichText::new(
|
||||
"Available once the mixnet connects — or skip and claim later.",
|
||||
)
|
||||
RichText::new(t!(
|
||||
"goblin.onboarding.identity.available_when_connected"
|
||||
))
|
||||
.font(FontId::new(12.0, fonts::regular()))
|
||||
.color(t.surface_text_mute),
|
||||
);
|
||||
@@ -865,11 +883,11 @@ impl OnboardingContent {
|
||||
}
|
||||
|
||||
let main_label = if registered {
|
||||
"Open my wallet"
|
||||
t!("goblin.onboarding.identity.open_wallet")
|
||||
} else {
|
||||
"Skip for now"
|
||||
t!("goblin.onboarding.identity.skip_for_now")
|
||||
};
|
||||
if w::big_action(ui, main_label, false).clicked() {
|
||||
if w::big_action(ui, &main_label, false).clicked() {
|
||||
return Some(wallet);
|
||||
}
|
||||
None
|
||||
|
||||
+150
-111
@@ -288,21 +288,21 @@ impl SendFlow {
|
||||
// Scan-to-pay screen: a Scan | My Code toggle over the camera or your own
|
||||
// payment QR. Replaces the picker until closed.
|
||||
if self.scan_open {
|
||||
if self.back_header(
|
||||
ui,
|
||||
if self.request {
|
||||
"Scan to request"
|
||||
} else {
|
||||
"Scan to pay"
|
||||
},
|
||||
) {
|
||||
let title = if self.request {
|
||||
t!("goblin.send.scan_to_request")
|
||||
} else {
|
||||
t!("goblin.send.scan_to_pay")
|
||||
};
|
||||
if self.back_header(ui, &title) {
|
||||
cb.stop_camera();
|
||||
self.scan = None;
|
||||
self.scan_open = false;
|
||||
return false;
|
||||
}
|
||||
let sel = if self.scan_tab == ScanTab::Scan { 0 } else { 1 };
|
||||
if let Some(i) = w::segmented(ui, &["Scan", "My Code"], sel) {
|
||||
let (tab_scan, tab_my_code) =
|
||||
(t!("goblin.send.tab_scan"), t!("goblin.send.tab_my_code"));
|
||||
if let Some(i) = w::segmented(ui, &[&tab_scan, &tab_my_code], sel) {
|
||||
self.scan_tab = if i == 0 {
|
||||
ScanTab::Scan
|
||||
} else {
|
||||
@@ -331,14 +331,12 @@ impl SendFlow {
|
||||
return false;
|
||||
}
|
||||
|
||||
if self.back_header(
|
||||
ui,
|
||||
if self.request {
|
||||
"Request from"
|
||||
} else {
|
||||
"Send to"
|
||||
},
|
||||
) {
|
||||
let title = if self.request {
|
||||
t!("goblin.send.request_from")
|
||||
} else {
|
||||
t!("goblin.send.send_to")
|
||||
};
|
||||
if self.back_header(ui, &title) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -365,7 +363,7 @@ impl SendFlow {
|
||||
ui.add_space(8.0);
|
||||
let mut te = TextEdit::new(egui::Id::from("send_search"))
|
||||
.focus(false)
|
||||
.hint_text("@handle, npub, or name")
|
||||
.hint_text(t!("goblin.send.search_hint"))
|
||||
.text_color(t.surface_text)
|
||||
.body()
|
||||
.scan_qr();
|
||||
@@ -405,7 +403,7 @@ impl SendFlow {
|
||||
if query.is_empty() {
|
||||
// Empty query → suggested recent peers, as before.
|
||||
ui.label(
|
||||
RichText::new(format!("{} Suggested", USERS))
|
||||
RichText::new(t!("goblin.send.suggested", "icon" => USERS))
|
||||
.font(fonts::kicker())
|
||||
.color(t.text_mute),
|
||||
);
|
||||
@@ -421,7 +419,7 @@ impl SendFlow {
|
||||
if peers.is_empty() {
|
||||
ui.add_space(20.0);
|
||||
ui.label(
|
||||
RichText::new("No contacts yet. Find someone by their @handle.")
|
||||
RichText::new(t!("goblin.send.no_contacts"))
|
||||
.font(FontId::new(14.0, fonts::regular()))
|
||||
.color(t.text_dim),
|
||||
);
|
||||
@@ -480,9 +478,16 @@ impl SendFlow {
|
||||
.show(ui, |ui| {
|
||||
for (c, tex) in cands.iter().zip(texs.iter()) {
|
||||
let tag = if c.verified {
|
||||
format!("✓ {}", c.tag)
|
||||
// Localize the human-worded provenance tags; domain / protocol
|
||||
// tags (@goblin.st, nip-05) display verbatim.
|
||||
let label = match c.tag {
|
||||
"contact" => t!("goblin.send.tag_contact"),
|
||||
"on nostr" => t!("goblin.send.tag_on_nostr"),
|
||||
other => std::borrow::Cow::Borrowed(other),
|
||||
};
|
||||
format!("✓ {}", label)
|
||||
} else {
|
||||
"no profile".to_string()
|
||||
t!("goblin.send.no_profile").to_string()
|
||||
};
|
||||
if w::activity_row(
|
||||
ui,
|
||||
@@ -506,7 +511,7 @@ impl SendFlow {
|
||||
View::small_loading_spinner(ui);
|
||||
ui.add_space(8.0);
|
||||
ui.label(
|
||||
RichText::new("Searching nostr…")
|
||||
RichText::new(t!("goblin.send.searching_nostr"))
|
||||
.font(FontId::new(14.0, fonts::regular()))
|
||||
.color(t.text_dim),
|
||||
);
|
||||
@@ -550,19 +555,15 @@ impl SendFlow {
|
||||
w::card(ui, |ui| {
|
||||
ui.set_min_width(ui.available_width());
|
||||
ui.label(
|
||||
RichText::new("Pay an unverified key?")
|
||||
RichText::new(t!("goblin.send.unverified_title"))
|
||||
.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),
|
||||
RichText::new(t!("goblin.send.unverified_body"))
|
||||
.font(FontId::new(12.5, fonts::regular()))
|
||||
.color(t.surface_text_dim),
|
||||
);
|
||||
ui.add_space(8.0);
|
||||
ui.label(
|
||||
@@ -579,7 +580,7 @@ impl SendFlow {
|
||||
Vec2::new(half, 44.0),
|
||||
)),
|
||||
|ui| {
|
||||
if w::big_action_on_card(ui, "Keep looking").clicked() {
|
||||
if w::big_action_on_card(ui, &t!("goblin.send.keep_looking")).clicked() {
|
||||
self.confirm_unverified = None;
|
||||
}
|
||||
},
|
||||
@@ -591,7 +592,7 @@ impl SendFlow {
|
||||
Vec2::new(half, 44.0),
|
||||
)),
|
||||
|ui| {
|
||||
if w::big_action(ui, "Pay anyway", false).clicked() {
|
||||
if w::big_action(ui, &t!("goblin.send.pay_anyway"), false).clicked() {
|
||||
let mut c = cand.clone();
|
||||
c.verified = true;
|
||||
self.confirm_unverified = None;
|
||||
@@ -605,7 +606,6 @@ impl SendFlow {
|
||||
|
||||
/// 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";
|
||||
let t = theme::tokens();
|
||||
let result = self.scan.as_mut().and_then(|cam| {
|
||||
let res = cam.qr_scan_result();
|
||||
@@ -637,14 +637,14 @@ impl SendFlow {
|
||||
self.net_candidate = None;
|
||||
let _ = wallet;
|
||||
}
|
||||
_ => self.error = Some(NO_RECIPIENT.to_string()),
|
||||
_ => self.error = Some(t!("goblin.send.scan_not_recipient").to_string()),
|
||||
}
|
||||
return;
|
||||
}
|
||||
ui.add_space(14.0);
|
||||
ui.vertical_centered(|ui| {
|
||||
ui.label(
|
||||
RichText::new("Position a goblin code in view to activate")
|
||||
RichText::new(t!("goblin.send.scan_prompt"))
|
||||
.font(FontId::new(14.0, fonts::regular()))
|
||||
.color(t.text_dim),
|
||||
);
|
||||
@@ -676,7 +676,7 @@ impl SendFlow {
|
||||
);
|
||||
ui.add_space(2.0);
|
||||
ui.label(
|
||||
RichText::new("Scan to pay me")
|
||||
RichText::new(t!("goblin.send.scan_to_pay_me"))
|
||||
.font(FontId::new(13.0, fonts::regular()))
|
||||
.color(t.surface_text_dim),
|
||||
);
|
||||
@@ -688,7 +688,7 @@ impl SendFlow {
|
||||
});
|
||||
|
||||
ui.add_space(12.0);
|
||||
if w::big_action(ui, &format!("{} Share", SHARE), false).clicked() {
|
||||
if w::big_action(ui, &t!("goblin.send.share_btn", "icon" => SHARE), false).clicked() {
|
||||
// Share the full nostr identity (npub + relay hints), with the bare
|
||||
// npub as a fallback line, via the platform's native share sheet.
|
||||
let link = if nprofile.is_empty() {
|
||||
@@ -696,7 +696,13 @@ impl SendFlow {
|
||||
} else {
|
||||
format!("nostr:{}", nprofile)
|
||||
};
|
||||
let msg = format!("Pay me on Goblin — {}\n{}\nnpub: {}", handle, link, npub);
|
||||
let msg = t!(
|
||||
"goblin.send.share_message",
|
||||
"handle" => handle,
|
||||
"link" => link,
|
||||
"npub" => npub
|
||||
)
|
||||
.to_string();
|
||||
cb.share_text(msg);
|
||||
}
|
||||
}
|
||||
@@ -716,7 +722,8 @@ impl SendFlow {
|
||||
}
|
||||
LookupResult::NotFound(label) => {
|
||||
self.net_candidate = None;
|
||||
self.error = Some(format!("No one found for {label}"));
|
||||
self.error =
|
||||
Some(t!("goblin.send.none_found", "label" => label).to_string());
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -841,7 +848,7 @@ impl SendFlow {
|
||||
*slot.lock().unwrap() = Some(res);
|
||||
});
|
||||
} else {
|
||||
self.error = Some("Enter an @handle, npub, or name".to_string());
|
||||
self.error = Some(t!("goblin.send.enter_recipient").to_string());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -853,7 +860,7 @@ impl SendFlow {
|
||||
cb: &dyn PlatformCallbacks,
|
||||
) -> bool {
|
||||
let t = theme::tokens();
|
||||
if self.back_header(ui, "Amount") {
|
||||
if self.back_header(ui, &t!("goblin.send.amount_title")) {
|
||||
self.stage = Stage::Recipient;
|
||||
return false;
|
||||
}
|
||||
@@ -870,7 +877,7 @@ impl SendFlow {
|
||||
.unwrap_or(false);
|
||||
|
||||
// Recipient chip, centered per the design.
|
||||
let name_label = format!("To {}", recipient.name);
|
||||
let name_label = t!("goblin.send.to_name", "name" => recipient.name).to_string();
|
||||
let name_galley = ui.painter().layout_no_wrap(
|
||||
name_label.clone(),
|
||||
FontId::new(14.0, fonts::semibold()),
|
||||
@@ -924,7 +931,7 @@ impl SendFlow {
|
||||
ui.add_space(6.0);
|
||||
ui.vertical_centered(|ui| {
|
||||
ui.label(
|
||||
RichText::new("You don't have enough grin")
|
||||
RichText::new(t!("goblin.send.not_enough"))
|
||||
.font(FontId::new(14.0, fonts::regular()))
|
||||
.color(t.neg),
|
||||
);
|
||||
@@ -936,7 +943,12 @@ impl SendFlow {
|
||||
ui.horizontal(|ui| {
|
||||
ui.add_space((ui.available_width() - 220.0).max(0.0) / 2.0);
|
||||
for v in ["1", "10", "100", "Max"] {
|
||||
if w::chip_outline(ui, v).clicked() {
|
||||
let label = if v == "Max" {
|
||||
t!("goblin.send.max")
|
||||
} else {
|
||||
std::borrow::Cow::Borrowed(v)
|
||||
};
|
||||
if w::chip_outline(ui, &label).clicked() {
|
||||
if v == "Max" {
|
||||
let max = wallet
|
||||
.get_data()
|
||||
@@ -957,7 +969,7 @@ impl SendFlow {
|
||||
w::card(ui, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(
|
||||
RichText::new("Note")
|
||||
RichText::new(t!("goblin.send.note_label"))
|
||||
.font(FontId::new(14.0, fonts::regular()))
|
||||
.color(t.surface_text_dim),
|
||||
);
|
||||
@@ -965,7 +977,7 @@ impl SendFlow {
|
||||
let note_id = egui::Id::from("send_note");
|
||||
TextEdit::new(note_id)
|
||||
.focus(false)
|
||||
.hint_text("Add a note…")
|
||||
.hint_text(t!("goblin.send.note_hint"))
|
||||
.text_color(t.surface_text)
|
||||
.body()
|
||||
.ui(ui, &mut self.note, cb);
|
||||
@@ -988,7 +1000,7 @@ impl SendFlow {
|
||||
.map(|a| a > 0)
|
||||
.unwrap_or(false);
|
||||
ui.add_enabled_ui(valid, |ui| {
|
||||
if w::big_action(ui, "Review", false).clicked() {
|
||||
if w::big_action(ui, &t!("goblin.send.review_btn"), false).clicked() {
|
||||
if over {
|
||||
cb.vibrate_error();
|
||||
} else {
|
||||
@@ -1006,14 +1018,12 @@ impl SendFlow {
|
||||
avatars: &mut AvatarTextures,
|
||||
) -> bool {
|
||||
let t = theme::tokens();
|
||||
if self.back_header(
|
||||
ui,
|
||||
if self.request {
|
||||
"Confirm request"
|
||||
} else {
|
||||
"Review"
|
||||
},
|
||||
) {
|
||||
let title = if self.request {
|
||||
t!("goblin.send.confirm_request")
|
||||
} else {
|
||||
t!("goblin.send.review_title")
|
||||
};
|
||||
if self.back_header(ui, &title) {
|
||||
// Requests fix the amount on the Pay tab, so back returns to the
|
||||
// recipient picker rather than the send-style amount step.
|
||||
self.stage = if self.request {
|
||||
@@ -1045,9 +1055,9 @@ impl SendFlow {
|
||||
ui.set_min_width(ui.available_width());
|
||||
ui.add_space(8.0);
|
||||
let label = if self.request {
|
||||
format!("Requesting from {}", recipient.name)
|
||||
t!("goblin.send.requesting_from", "name" => recipient.name).to_string()
|
||||
} else {
|
||||
format!("You're sending {}", recipient.name)
|
||||
t!("goblin.send.youre_sending", "name" => recipient.name).to_string()
|
||||
};
|
||||
// Centered avatar + caption. A long counterparty (a bare npub) wraps
|
||||
// and stays centered instead of overflowing the card.
|
||||
@@ -1073,34 +1083,59 @@ impl SendFlow {
|
||||
});
|
||||
ui.add_space(16.0);
|
||||
|
||||
w::info_row(
|
||||
ui,
|
||||
if self.request { "From" } else { "To" },
|
||||
&recipient.name,
|
||||
);
|
||||
let from_to = if self.request {
|
||||
t!("goblin.send.row_from")
|
||||
} else {
|
||||
t!("goblin.send.row_to")
|
||||
};
|
||||
w::info_row(ui, &from_to, &recipient.name);
|
||||
if !self.note.trim().is_empty() {
|
||||
w::info_row(ui, "Note", &format!("\u{201C}{}\u{201D}", self.note.trim()));
|
||||
w::info_row(
|
||||
ui,
|
||||
&t!("goblin.send.row_note"),
|
||||
&format!("\u{201C}{}\u{201D}", self.note.trim()),
|
||||
);
|
||||
}
|
||||
if self.request {
|
||||
w::info_row(ui, "They pay", "Only if they approve");
|
||||
w::info_row(ui, "Delivery", "NIP-44 encrypted, over Nym");
|
||||
w::info_row(
|
||||
ui,
|
||||
&t!("goblin.send.row_they_pay"),
|
||||
&t!("goblin.send.row_they_pay_val"),
|
||||
);
|
||||
w::info_row(
|
||||
ui,
|
||||
&t!("goblin.send.row_delivery"),
|
||||
&t!("goblin.send.row_delivery_val"),
|
||||
);
|
||||
} else {
|
||||
w::info_row(ui, "Network fee", "Deducted from your balance");
|
||||
w::info_row(ui, "Privacy", "Mimblewimble + Nym");
|
||||
w::info_row(ui, "Delivery", "NIP-44 encrypted, over Nym");
|
||||
w::info_row(
|
||||
ui,
|
||||
&t!("goblin.send.row_network_fee"),
|
||||
&t!("goblin.send.row_network_fee_val"),
|
||||
);
|
||||
w::info_row(
|
||||
ui,
|
||||
&t!("goblin.send.row_privacy"),
|
||||
&t!("goblin.send.row_privacy_val"),
|
||||
);
|
||||
w::info_row(
|
||||
ui,
|
||||
&t!("goblin.send.row_delivery"),
|
||||
&t!("goblin.send.row_delivery_val"),
|
||||
);
|
||||
}
|
||||
ui.add_space(16.0);
|
||||
|
||||
// Requests are not a spend: one tap sends the ask, no hold-to-confirm.
|
||||
if self.request {
|
||||
if w::big_action(ui, "Send request", false).clicked() {
|
||||
if w::big_action(ui, &t!("goblin.send.send_request_btn"), false).clicked() {
|
||||
self.dispatch(wallet);
|
||||
self.stage = Stage::Sending;
|
||||
}
|
||||
ui.add_space(6.0);
|
||||
ui.vertical_centered(|ui| {
|
||||
ui.label(
|
||||
RichText::new("They'll get a request to approve")
|
||||
RichText::new(t!("goblin.send.request_approve_hint"))
|
||||
.font(FontId::new(12.0, fonts::regular()))
|
||||
.color(t.text_mute),
|
||||
);
|
||||
@@ -1111,7 +1146,7 @@ impl SendFlow {
|
||||
if over {
|
||||
ui.vertical_centered(|ui| {
|
||||
ui.label(
|
||||
RichText::new("You don't have enough grin")
|
||||
RichText::new(t!("goblin.send.not_enough"))
|
||||
.font(FontId::new(14.0, fonts::regular()))
|
||||
.color(t.neg),
|
||||
);
|
||||
@@ -1121,7 +1156,7 @@ impl SendFlow {
|
||||
// Greyed out while over balance; the `&& !over` also refuses the send in
|
||||
// case the hold widget ignores the disabled state.
|
||||
ui.add_enabled_ui(!over, |ui| {
|
||||
if self.hold.ui(ui, "Hold to send") && !over {
|
||||
if self.hold.ui(ui, &t!("goblin.send.hold_to_send")) && !over {
|
||||
self.dispatch(wallet);
|
||||
self.stage = Stage::Sending;
|
||||
}
|
||||
@@ -1130,9 +1165,9 @@ impl SendFlow {
|
||||
ui.vertical_centered(|ui| {
|
||||
ui.label(
|
||||
RichText::new(if over {
|
||||
"Go back and lower the amount"
|
||||
t!("goblin.send.lower_amount")
|
||||
} else {
|
||||
"Press and hold to confirm"
|
||||
t!("goblin.send.hold_confirm_hint")
|
||||
})
|
||||
.font(FontId::new(12.0, fonts::regular()))
|
||||
.color(t.text_mute),
|
||||
@@ -1183,9 +1218,9 @@ impl SendFlow {
|
||||
ui.add_space(16.0);
|
||||
ui.label(
|
||||
RichText::new(if self.request {
|
||||
"Requesting…"
|
||||
t!("goblin.send.requesting")
|
||||
} else {
|
||||
"Sending…"
|
||||
t!("goblin.send.sending")
|
||||
})
|
||||
.font(FontId::new(18.0, fonts::semibold()))
|
||||
.color(t.text),
|
||||
@@ -1204,11 +1239,8 @@ impl SendFlow {
|
||||
.recipient
|
||||
.as_ref()
|
||||
.map(|r| r.name.clone())
|
||||
.unwrap_or_else(|| "They".to_string());
|
||||
self.error = Some(format!(
|
||||
"{} isn't accepting requests. Ask them to send you grin instead.",
|
||||
who
|
||||
));
|
||||
.unwrap_or_else(|| t!("goblin.send.they").to_string());
|
||||
self.error = Some(t!("goblin.send.request_blocked", "who" => who).to_string());
|
||||
self.stage = Stage::Failed;
|
||||
}
|
||||
crate::nostr::send_phase::FAILED => self.stage = Stage::Failed,
|
||||
@@ -1230,9 +1262,9 @@ impl SendFlow {
|
||||
ui.add_space(16.0);
|
||||
ui.label(
|
||||
RichText::new(if self.request {
|
||||
"Couldn't request"
|
||||
t!("goblin.send.failed_request_title")
|
||||
} else {
|
||||
"Couldn't send"
|
||||
t!("goblin.send.failed_send_title")
|
||||
})
|
||||
.font(FontId::new(22.0, fonts::bold()))
|
||||
.color(t.text),
|
||||
@@ -1241,10 +1273,9 @@ impl SendFlow {
|
||||
ui.label(
|
||||
RichText::new(self.error.clone().unwrap_or_else(|| {
|
||||
if self.request {
|
||||
"We couldn't deliver the request. Ask them to send you grin instead."
|
||||
.to_string()
|
||||
t!("goblin.send.failed_request_body").to_string()
|
||||
} else {
|
||||
"The payment wasn't delivered. Your grin is safe — try again.".to_string()
|
||||
t!("goblin.send.failed_send_body").to_string()
|
||||
}
|
||||
}))
|
||||
.font(FontId::new(14.0, fonts::regular()))
|
||||
@@ -1252,12 +1283,12 @@ impl SendFlow {
|
||||
);
|
||||
});
|
||||
ui.add_space(24.0);
|
||||
if w::big_action(ui, "Try again", false).clicked() {
|
||||
if w::big_action(ui, &t!("goblin.send.try_again_btn"), false).clicked() {
|
||||
self.dispatch(wallet);
|
||||
self.stage = Stage::Sending;
|
||||
}
|
||||
ui.add_space(10.0);
|
||||
if w::big_action(ui, "Close", true).clicked() {
|
||||
if w::big_action(ui, &t!("goblin.send.close_btn"), true).clicked() {
|
||||
done = true;
|
||||
}
|
||||
done
|
||||
@@ -1281,32 +1312,40 @@ impl SendFlow {
|
||||
);
|
||||
ui.add_space(24.0);
|
||||
ui.label(
|
||||
RichText::new(if self.request { "Requested" } else { "Sent" })
|
||||
RichText::new(if self.request { t!("goblin.send.success.requested") } else { t!("goblin.send.success.sent") })
|
||||
.font(FontId::new(34.0, fonts::bold()))
|
||||
.color(t.accent_ink),
|
||||
);
|
||||
ui.add_space(8.0);
|
||||
ui.horizontal(|ui| {
|
||||
ui.spacing_mut().item_spacing.x = 0.0;
|
||||
let total_w = ui.available_width();
|
||||
ui.add_space(total_w / 2.0 - 60.0);
|
||||
ui.label(
|
||||
RichText::new(&self.amount)
|
||||
.font(FontId::new(40.0, fonts::mono_semibold()))
|
||||
.color(t.accent_ink),
|
||||
);
|
||||
ui.label(
|
||||
RichText::new(w::TSU)
|
||||
.font(FontId::new(20.0, fonts::medium()))
|
||||
.color(t.accent_ink),
|
||||
);
|
||||
});
|
||||
// Amount + ツ as one layout job so vertical_centered centers it exactly,
|
||||
// independent of the number's width (a fixed offset drifts off-center).
|
||||
let mut job = egui::text::LayoutJob::default();
|
||||
job.append(
|
||||
&self.amount,
|
||||
0.0,
|
||||
egui::text::TextFormat {
|
||||
font_id: FontId::new(40.0, fonts::mono_semibold()),
|
||||
color: t.accent_ink,
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
job.append(
|
||||
w::TSU,
|
||||
0.0,
|
||||
egui::text::TextFormat {
|
||||
font_id: FontId::new(20.0, fonts::medium()),
|
||||
color: t.accent_ink,
|
||||
valign: Align::BOTTOM,
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
ui.label(job);
|
||||
ui.add_space(8.0);
|
||||
ui.label(
|
||||
RichText::new(format!(
|
||||
"{} {} · just now",
|
||||
if self.request { "from" } else { "to" },
|
||||
recipient.name
|
||||
RichText::new(t!(
|
||||
"goblin.send.success.subtitle",
|
||||
"dir" => if self.request { t!("goblin.send.success.from") } else { t!("goblin.send.success.to") },
|
||||
"who" => recipient.name
|
||||
))
|
||||
.font(FontId::new(15.0, fonts::regular()))
|
||||
.color(t.accent_ink.gamma_multiply(0.7)),
|
||||
@@ -1330,7 +1369,7 @@ impl SendFlow {
|
||||
ui.painter().text(
|
||||
rect.center(),
|
||||
egui::Align2::CENTER_CENTER,
|
||||
"Done",
|
||||
t!("goblin.send.success.done_btn"),
|
||||
FontId::new(17.0, fonts::semibold()),
|
||||
t.accent,
|
||||
);
|
||||
@@ -1352,7 +1391,7 @@ impl SendFlow {
|
||||
ui.painter().text(
|
||||
r2.center(),
|
||||
egui::Align2::CENTER_CENTER,
|
||||
"Receipt",
|
||||
t!("goblin.send.success.receipt_btn"),
|
||||
FontId::new(17.0, fonts::semibold()),
|
||||
t.accent_ink,
|
||||
);
|
||||
|
||||
+15
-12
@@ -335,18 +335,21 @@ fn setup_i18n() {
|
||||
}
|
||||
} else {
|
||||
let locale = sys_locale::get_locale().unwrap_or(String::from(AppConfig::DEFAULT_LOCALE));
|
||||
let locale_str = if locale.contains("-") {
|
||||
locale
|
||||
.split("-")
|
||||
.next()
|
||||
.unwrap_or(AppConfig::DEFAULT_LOCALE)
|
||||
} else {
|
||||
locale.as_str()
|
||||
};
|
||||
|
||||
// Set best possible locale.
|
||||
if rust_i18n::available_locales!().contains(&locale_str) {
|
||||
rust_i18n::set_locale(locale_str);
|
||||
// sys_locale may hand back either `zh-CN` or `zh_CN`; normalize the
|
||||
// separator so a region-specific locale can match its file name.
|
||||
let normalized = locale.replace('_', "-");
|
||||
let available = rust_i18n::available_locales!();
|
||||
// Prefer an exact region match (e.g. `zh-CN`, the only CJK locale and one
|
||||
// the bare-subtag fallback could never reach), then the language subtag
|
||||
// (e.g. `de` from `de-DE`), else the default.
|
||||
let primary = normalized
|
||||
.split('-')
|
||||
.next()
|
||||
.unwrap_or(AppConfig::DEFAULT_LOCALE);
|
||||
if available.contains(&normalized.as_str()) {
|
||||
rust_i18n::set_locale(normalized.as_str());
|
||||
} else if available.contains(&primary) {
|
||||
rust_i18n::set_locale(primary);
|
||||
} else {
|
||||
rust_i18n::set_locale(AppConfig::DEFAULT_LOCALE);
|
||||
}
|
||||
|
||||
+144
-1
@@ -444,6 +444,46 @@ impl NostrService {
|
||||
Ok(res.val.to_hex())
|
||||
}
|
||||
|
||||
/// Dispatch a control DM that voids a pending request (a decline by the payer
|
||||
/// or a cancel by the requester) to `receiver_hex`, referencing `slate_id`.
|
||||
/// Same routing as a payment DM, but the message carries no slatepack.
|
||||
pub async fn send_control_dm(
|
||||
&self,
|
||||
receiver_hex: &str,
|
||||
slate_id: &str,
|
||||
relay_hints: &[String],
|
||||
) -> Result<String, String> {
|
||||
let client = {
|
||||
let r_client = self.client.read();
|
||||
r_client.clone().ok_or("nostr client is not running")?
|
||||
};
|
||||
let receiver =
|
||||
PublicKey::from_hex(receiver_hex).map_err(|e| format!("invalid receiver: {e}"))?;
|
||||
let content = protocol::build_control_content();
|
||||
let tags = protocol::build_control_tags(slate_id);
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
let res = tokio::time::timeout(
|
||||
SEND_TIMEOUT,
|
||||
client.send_private_msg_to(urls, receiver, content, tags),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| "send timeout".to_string())?
|
||||
.map_err(|e| format!("send failed: {e}"))?;
|
||||
Ok(res.val.to_hex())
|
||||
}
|
||||
|
||||
/// Fetch a contact's kind 10050 DM relay list from our relays.
|
||||
async fn fetch_dm_relays(&self, client: &Client, pk: &PublicKey) -> Vec<String> {
|
||||
// Use cached relays first.
|
||||
@@ -497,6 +537,51 @@ impl NostrService {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Best-effort: resolve a contact's published `@username`. Incoming messages
|
||||
/// only carry the sender's key, so a fresh contact shows as a bare npub. This
|
||||
/// fetches their kind-0 profile and, if it advertises a NIP-05 handle that
|
||||
/// verifies back to their key, records it so the UI can show `@username`.
|
||||
/// Spawns a worker; fail-open (any miss just leaves the npub). Skips contacts
|
||||
/// that already carry a verified handle.
|
||||
pub fn resolve_contact_identity(self: &Arc<Self>, sender_hex: &str) {
|
||||
if let Some(c) = self.store.contact(sender_hex) {
|
||||
if c.nip05.is_some() && c.nip05_verified_at.is_some() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
let svc = self.clone();
|
||||
let hex = sender_hex.to_string();
|
||||
thread::spawn(move || {
|
||||
let Some(profile) = svc.fetch_profile_blocking(&hex) else {
|
||||
return;
|
||||
};
|
||||
let Some(nip05) = profile.nip05 else {
|
||||
return;
|
||||
};
|
||||
let Some((name, domain)) = nip05.split_once('@') else {
|
||||
return;
|
||||
};
|
||||
let Ok(pk) = PublicKey::from_hex(&hex) else {
|
||||
return;
|
||||
};
|
||||
// Trust the handle only if it maps back to this key.
|
||||
let verified = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.ok()
|
||||
.map(|rt| rt.block_on(crate::nostr::nip05::verify(&pk, name, domain)))
|
||||
.unwrap_or(false);
|
||||
if !verified {
|
||||
return;
|
||||
}
|
||||
if let Some(mut c) = svc.store.contact(&hex) {
|
||||
c.nip05 = Some(nip05.clone());
|
||||
c.nip05_verified_at = Some(unix_time());
|
||||
svc.store.save_contact(&c);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Main service loop: connect, publish identity, catch up, listen.
|
||||
@@ -803,6 +888,52 @@ async fn reconcile(svc: &Arc<NostrService>, wallet: &Wallet) {
|
||||
}
|
||||
|
||||
/// Full guarded pipeline for one incoming gift wrap event.
|
||||
/// Apply a request-void control message. Two roles, distinguished by what we
|
||||
/// hold for `slate_id`; in both the `sender` must match the stored counterparty,
|
||||
/// so an attacker can't void a request they're not party to.
|
||||
fn handle_request_void(svc: &Arc<NostrService>, wallet: &Wallet, slate_id: &str, sender: &str) {
|
||||
// Role A — we are the payer and the requester withdrew. Drop the pending card.
|
||||
let mut voided = false;
|
||||
for req in svc.store.pending_requests() {
|
||||
if req.slate_id == slate_id && req.npub == sender {
|
||||
info!(
|
||||
"nostr: incoming request {} withdrawn by requester",
|
||||
req.rumor_id
|
||||
);
|
||||
svc.store
|
||||
.update_request_status(&req.rumor_id, RequestStatus::Cancelled);
|
||||
svc.has_new_requests.store(true, Ordering::Relaxed);
|
||||
voided = true;
|
||||
}
|
||||
}
|
||||
if voided {
|
||||
return;
|
||||
}
|
||||
// Role B — we are the requester and the payer declined. Cancel our invoice tx
|
||||
// and mark the meta cancelled so it leaves the pending state.
|
||||
if let Some(meta) = svc.store.tx_meta(slate_id) {
|
||||
if meta.direction == NostrTxDirection::RequestedByUs
|
||||
&& matches!(
|
||||
meta.status,
|
||||
NostrSendStatus::Created | NostrSendStatus::AwaitingI2
|
||||
) && meta.npub == sender
|
||||
{
|
||||
info!("nostr: outgoing request {} declined by payer", slate_id);
|
||||
if let Some(tx_id) = wallet.get_data().and_then(|d| d.txs).and_then(|txs| {
|
||||
txs.iter()
|
||||
.find(|t| {
|
||||
t.data.tx_slate_id.map(|u| u.to_string()).as_deref() == Some(slate_id)
|
||||
})
|
||||
.map(|t| t.data.id)
|
||||
}) {
|
||||
wallet.task(WalletTask::Cancel(tx_id));
|
||||
}
|
||||
svc.store
|
||||
.update_tx_status(slate_id, NostrSendStatus::Cancelled);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_wrap(svc: &Arc<NostrService>, wallet: &Wallet, client: &Client, event: Event) {
|
||||
// 0. Only gift wraps.
|
||||
if event.kind != Kind::GiftWrap {
|
||||
@@ -879,7 +1010,17 @@ async fn handle_wrap(svc: &Arc<NostrService>, wallet: &Wallet, client: &Client,
|
||||
svc.store.mark_processed(&wrap_id);
|
||||
return;
|
||||
}
|
||||
// 8. Extract the slatepack; non-payment DMs are ignored entirely.
|
||||
// 8. Request-void control message (a decline by the payer or a cancel by the
|
||||
// requester): it carries no slatepack, just an action tag naming a slate id.
|
||||
// Handle it before slatepack extraction; the sender is bound to the stored
|
||||
// counterparty inside, so a stranger can't void someone else's request.
|
||||
if let Some(void_slate_id) = protocol::extract_control(&rumor.tags) {
|
||||
handle_request_void(svc, wallet, &void_slate_id, &sender_hex);
|
||||
svc.store.mark_processed(&wrap_id);
|
||||
svc.store.mark_processed(&rumor_id);
|
||||
return;
|
||||
}
|
||||
// 8b. Extract the slatepack; non-payment DMs are ignored entirely.
|
||||
let Some(armor) = protocol::extract_slatepack(&rumor.content) else {
|
||||
svc.store.mark_processed(&wrap_id);
|
||||
svc.store.mark_processed(&rumor_id);
|
||||
@@ -973,6 +1114,8 @@ async fn handle_wrap(svc: &Arc<NostrService>, wallet: &Wallet, client: &Client,
|
||||
}
|
||||
IngestDecision::SurfaceIncoming | IngestDecision::SurfaceRequest => {
|
||||
svc.ensure_contact(&sender_hex);
|
||||
// Resolve the requester's @username so the card isn't a bare npub.
|
||||
svc.resolve_contact_identity(&sender_hex);
|
||||
svc.store.save_request(&PaymentRequest {
|
||||
ver: 1,
|
||||
rumor_id: rumor_id.clone(),
|
||||
|
||||
@@ -35,6 +35,12 @@ pub const MAX_NOTE_CHARS: usize = 256;
|
||||
pub const GOBLIN_TAG: &str = "goblin";
|
||||
/// Protocol version value.
|
||||
pub const PROTOCOL_VERSION: &str = "1";
|
||||
/// Control-message tag name: carries `[action, slate_id]` for a request that is
|
||||
/// being voided (a decline by the payer or a cancel by the requester).
|
||||
pub const GOBLIN_ACTION_TAG: &str = "goblin-action";
|
||||
/// The one control action: void an unpaid request. Decline and cancel are the
|
||||
/// same wire message — "this request is off" — they only differ by who sends it.
|
||||
pub const ACTION_VOID: &str = "void";
|
||||
|
||||
/// Human readable preamble other NIP-17 clients render.
|
||||
pub const PREAMBLE: &str =
|
||||
@@ -76,6 +82,44 @@ pub fn build_rumor_tags(note: Option<&str>) -> Vec<Tag> {
|
||||
tags
|
||||
}
|
||||
|
||||
/// Build the kind-14 rumor content for a request-void control message. Carries
|
||||
/// NO slatepack — other NIP-17 clients render the human-readable line.
|
||||
pub fn build_control_content() -> String {
|
||||
"[Goblin] Payment request withdrawn — open in Goblin (https://goblin.st).".to_string()
|
||||
}
|
||||
|
||||
/// Build rumor tags for a control message: protocol marker plus the action +
|
||||
/// slate id the control refers to.
|
||||
pub fn build_control_tags(slate_id: &str) -> Vec<Tag> {
|
||||
vec![
|
||||
Tag::custom(TagKind::custom(GOBLIN_TAG), [PROTOCOL_VERSION.to_string()]),
|
||||
Tag::custom(
|
||||
TagKind::custom(GOBLIN_ACTION_TAG),
|
||||
[ACTION_VOID.to_string(), slate_id.to_string()],
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
/// Read a control action from rumor tags. Returns the referenced slate id when a
|
||||
/// well-formed `goblin-action` void tag is present, else `None`. Classification
|
||||
/// NEVER trusts this for payment processing — it only voids a pending request,
|
||||
/// and the caller still binds the sender to the stored counterparty.
|
||||
pub fn extract_control(tags: &Tags) -> Option<String> {
|
||||
for tag in tags.iter() {
|
||||
let parts = tag.as_slice();
|
||||
if parts.first().map(|s| s.as_str()) == Some(GOBLIN_ACTION_TAG) {
|
||||
let action = parts.get(1).map(|s| s.as_str());
|
||||
let slate_id = parts.get(2).map(|s| s.to_string());
|
||||
if action == Some(ACTION_VOID) {
|
||||
if let Some(id) = slate_id.filter(|s| !s.is_empty()) {
|
||||
return Some(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Extract exactly one slatepack armor block from rumor content.
|
||||
/// More than one block, none at all, or an oversized block returns `None`.
|
||||
pub fn extract_slatepack(content: &str) -> Option<String> {
|
||||
@@ -195,4 +239,22 @@ mod tests {
|
||||
assert!(c.starts_with(PREAMBLE));
|
||||
assert!(extract_slatepack(&c).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn control_round_trips_slate_id() {
|
||||
let tags = Tags::from_list(build_control_tags("abc-123"));
|
||||
assert_eq!(extract_control(&tags), Some("abc-123".to_string()));
|
||||
// A control message carries no slatepack.
|
||||
assert!(extract_slatepack(&build_control_content()).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn control_absent_or_malformed_returns_none() {
|
||||
// Ordinary payment tags have no action tag.
|
||||
let tags = Tags::from_list(build_rumor_tags(Some("lunch")));
|
||||
assert!(extract_control(&tags).is_none());
|
||||
// Action present but empty slate id is rejected.
|
||||
let bad = Tags::from_list(build_control_tags(""));
|
||||
assert!(extract_control(&bad).is_none());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,6 +105,8 @@ pub enum RequestStatus {
|
||||
Approved,
|
||||
Declined,
|
||||
Expired,
|
||||
/// Withdrawn by the requester (we received their cancel control message).
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
/// An incoming Invoice1 payment request awaiting explicit user approval.
|
||||
|
||||
@@ -450,4 +450,12 @@ pub enum WalletTask {
|
||||
/// Republish our kind-0 profile (e.g. after toggling the incoming-requests
|
||||
/// preference) so the change propagates to relays immediately.
|
||||
NostrRepublishProfile,
|
||||
/// Decline an incoming payment request: mark it declined and send the
|
||||
/// requester a void control message so their side clears too.
|
||||
/// * request id (rumor event id hex)
|
||||
NostrDeclineRequest(String),
|
||||
/// Cancel a request WE sent: cancel the local invoice tx and send the payer a
|
||||
/// void control message so the pending card disappears on their side.
|
||||
/// * slate id (uuid string)
|
||||
NostrCancelOutgoing(String),
|
||||
}
|
||||
|
||||
@@ -2364,6 +2364,59 @@ async fn handle_task(w: &Wallet, t: WalletTask) {
|
||||
service.republish_identity().await;
|
||||
}
|
||||
}
|
||||
WalletTask::NostrDeclineRequest(rumor_id) => {
|
||||
let Some(service) = w.nostr_service() else {
|
||||
return;
|
||||
};
|
||||
let Some(mut request) = service.store.request(rumor_id) else {
|
||||
error!("nostr decline: request not found");
|
||||
return;
|
||||
};
|
||||
// Mark declined locally (idempotent) so the card stays gone, then tell
|
||||
// the requester. Requests are messages; payments are final.
|
||||
request.status = crate::nostr::RequestStatus::Declined;
|
||||
service.store.save_request(&request);
|
||||
if let Err(e) = service
|
||||
.send_control_dm(&request.npub, &request.slate_id, &[])
|
||||
.await
|
||||
{
|
||||
error!("nostr decline: control dispatch failed: {e}");
|
||||
}
|
||||
}
|
||||
WalletTask::NostrCancelOutgoing(slate_id) => {
|
||||
let Some(service) = w.nostr_service() else {
|
||||
return;
|
||||
};
|
||||
let Some(meta) = service.store.tx_meta(slate_id) else {
|
||||
error!("nostr cancel: no metadata for slate {slate_id}");
|
||||
return;
|
||||
};
|
||||
if meta.direction != crate::nostr::NostrTxDirection::RequestedByUs {
|
||||
error!("nostr cancel: slate {slate_id} is not an outgoing request");
|
||||
return;
|
||||
}
|
||||
// Cancel the underlying grin invoice tx (an issued invoice locks no
|
||||
// outputs, but cancelling keeps the wallet ledger tidy).
|
||||
if let Some(tx_id) = w.get_data().and_then(|d| d.txs).and_then(|txs| {
|
||||
txs.iter()
|
||||
.find(|t| {
|
||||
t.data.tx_slate_id.map(|u| u.to_string()).as_deref()
|
||||
== Some(slate_id.as_str())
|
||||
})
|
||||
.map(|t| t.data.id)
|
||||
}) {
|
||||
if let Err(e) = w.cancel(tx_id) {
|
||||
error!("nostr cancel: wallet cancel failed: {e}");
|
||||
}
|
||||
}
|
||||
service
|
||||
.store
|
||||
.update_tx_status(slate_id, crate::nostr::NostrSendStatus::Cancelled);
|
||||
if let Err(e) = service.send_control_dm(&meta.npub, slate_id, &[]).await {
|
||||
error!("nostr cancel: control dispatch failed: {e}");
|
||||
}
|
||||
sync_wallet_data(&w, false);
|
||||
}
|
||||
WalletTask::NostrResend(id) => {
|
||||
let Some(service) = w.nostr_service() else {
|
||||
return;
|
||||
|
||||
Reference in New Issue
Block a user