diff --git a/android/app/src/main/java/mw/gri/android/MainActivity.java b/android/app/src/main/java/mw/gri/android/MainActivity.java index ba32b08..e3903c6 100644 --- a/android/app/src/main/java/mw/gri/android/MainActivity.java +++ b/android/app/src/main/java/mw/gri/android/MainActivity.java @@ -69,6 +69,9 @@ public class MainActivity extends GameActivity { private ActivityResultLauncher mFilePickResult = null; private ActivityResultLauncher mOpenFilePermissionsResult = null; + private ActivityResultLauncher mFileSaveResult = null; + // Source path (in the share cache) staged by Rust for the next saveFile(). + private String mPendingSavePath = null; @SuppressLint("UnspecifiedRegisterReceiverFlag") @Override @@ -145,6 +148,32 @@ public class MainActivity extends GameActivity { } }); + // Register file SAVE result (Storage Access Framework CREATE_DOCUMENT): + // copy the staged source file into the user-chosen document. + mFileSaveResult = registerForActivityResult( + new ActivityResultContracts.StartActivityForResult(), + result -> { + String src = mPendingSavePath; + mPendingSavePath = null; + if (result.getResultCode() == Activity.RESULT_OK && src != null) { + Intent data = result.getData(); + if (data != null && data.getData() != null) { + Uri uri = data.getData(); + try (InputStream is = new FileInputStream(new File(src)); + OutputStream os = getContentResolver().openOutputStream(uri)) { + byte[] buffer = new byte[4096]; + int length; + while (is != null && (length = is.read(buffer)) > 0) { + os.write(buffer, 0, length); + } + if (os != null) os.flush(); + } catch (Exception e) { + Log.e("grim", e.toString()); + } + } + } + }); + // Listener for display insets (cutouts) to pass values into native code. View content = getWindow().getDecorView().findViewById(android.R.id.content); ViewCompat.setOnApplyWindowInsetsListener(content, (v, insets) -> { @@ -510,6 +539,23 @@ public class MainActivity extends GameActivity { startActivity(Intent.createChooser(intent, "Share data")); } + // Called from native code to SAVE a staged file to a user-chosen location. + // Launches the Storage Access Framework create-document picker; the result + // handler copies the staged source file into the chosen document. + public void saveFile(String path, String name) { + mPendingSavePath = path; + Intent intent = new Intent(Intent.ACTION_CREATE_DOCUMENT); + intent.addCategory(Intent.CATEGORY_OPENABLE); + intent.setType("application/octet-stream"); + intent.putExtra(Intent.EXTRA_TITLE, name); + try { + mFileSaveResult.launch(intent); + } catch (android.content.ActivityNotFoundException ex) { + Log.e("grim", ex.toString()); + mPendingSavePath = null; + } + } + // Called from native code to share plain text (e.g. a payment link) via the // system share sheet. public void shareText(String text) { diff --git a/locales/de.yml b/locales/de.yml index dc6f441..c46f849 100644 --- a/locales/de.yml +++ b/locales/de.yml @@ -535,6 +535,12 @@ goblin: identity_replaced: "Identität ersetzt" now_using: "Jetzt aktiv: %{npub}" import_failed: "Import fehlgeschlagen" + name_authority: "Namensautorität" + name_authority_title: "Namensautorität ändern" + name_authority_blurb: "Der Server, der Namen registriert und verifiziert. Auf eine andere Instanz zeigen, um dort gehostete Namen zu nutzen und zu bezahlen." + name_authority_invalid: "Vollständige URL eingeben (https://…)." + reset: "Zurücksetzen" + save: "Speichern" backup_file: "In Datei sichern" choose_backup_file: "Eine .backup-Datei wählen" backup_read_failed: "Datei konnte nicht gelesen werden." @@ -704,10 +710,10 @@ goblin: key_being_made: "Schlüssel wird erstellt…" connected_nym: "über Nym verbunden" connecting_nym: "verbinde über Nym…" - fresh_key_blurb: "Ein frischer Schlüssel, für Zahlungen gemacht — bewusst nicht Teil deines Seeds, sodass du ihn jederzeit wechseln kannst, um deine Privatsphäre zu wahren, ohne je dein Guthaben zu berühren. Sichere ihn unter Einstellungen → Identität." + fresh_key_blurb: "Ein Zahlungsschlüssel, der nicht Teil deines Seeds ist — jederzeit rotierbar, ohne deine Mittel zu berühren." clean_slate_blurb: "Lust auf einen Neuanfang? Tausche jederzeit einen brandneuen Schlüssel ein — das neue Du ist nicht mit dem alten verknüpft. Gleiches Wallet, frisches Gesicht." pick_username: "Benutzernamen wählen — optional" - username_blurb: "Freunde zahlen an you statt an einen langen Schlüssel. Öffentlich auf goblin.st; Zahlungen bleiben verschlüsselt. Überspringe es und du bist einfach anonym — sichere jederzeit später einen." + username_blurb: "Freunde zahlen an deinen Namen statt an einen langen Schlüssel. Optional — jederzeit beanspruchbar." username_field_hint: "deinname" working: "Arbeite…" claim_username: "Benutzernamen sichern" diff --git a/locales/en.yml b/locales/en.yml index b29e9e5..9f67986 100644 --- a/locales/en.yml +++ b/locales/en.yml @@ -535,6 +535,12 @@ goblin: identity_replaced: "Identity replaced" now_using: "Now using: %{npub}" import_failed: "Import failed" + name_authority: "Name authority" + name_authority_title: "Change name authority" + name_authority_blurb: "The server that registers and verifies names. Point it at another instance to use and pay names hosted there." + name_authority_invalid: "Enter a full URL (https://…)." + reset: "Reset" + save: "Save" backup_file: "Back up to a file" choose_backup_file: "Choose a .backup file" backup_read_failed: "Couldn't read that file." @@ -704,10 +710,10 @@ goblin: key_being_made: "key being made…" connected_nym: "connected over Nym" connecting_nym: "connecting over Nym…" - fresh_key_blurb: "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." + fresh_key_blurb: "A payment key that isn't part of your seed — rotate it anytime to stay private, without touching your funds." clean_slate_blurb: "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." pick_username: "Pick a username — optional" - username_blurb: "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." + username_blurb: "Friends pay your name instead of a long key. Optional — claim one any time." username_field_hint: "yourname" working: "Working…" claim_username: "Claim username" diff --git a/locales/fr.yml b/locales/fr.yml index 386dec8..13c1fba 100644 --- a/locales/fr.yml +++ b/locales/fr.yml @@ -535,6 +535,12 @@ goblin: identity_replaced: "Identité remplacée" now_using: "Utilise maintenant : %{npub}" import_failed: "Échec de l'importation" + name_authority: "Autorité de noms" + name_authority_title: "Changer l'autorité de noms" + name_authority_blurb: "Le serveur qui enregistre et vérifie les noms. Pointez-le vers une autre instance pour utiliser et payer des noms qui y sont hébergés." + name_authority_invalid: "Saisissez une URL complète (https://…)." + reset: "Réinitialiser" + save: "Enregistrer" backup_file: "Sauvegarder dans un fichier" choose_backup_file: "Choisir un fichier .backup" backup_read_failed: "Impossible de lire ce fichier." @@ -704,10 +710,10 @@ goblin: key_being_made: "clé en cours de création…" connected_nym: "connecté via Nym" connecting_nym: "connexion via Nym…" - fresh_key_blurb: "Une clé neuve, créée pour les paiements — délibérément hors de votre phrase de récupération, pour la renouveler à tout moment et préserver votre confidentialité, sans jamais toucher à vos fonds. Sauvegardez-la dans Réglages → Identité." + fresh_key_blurb: "Une clé de paiement qui ne fait pas partie de votre seed — renouvelable à tout moment, sans toucher à vos fonds." clean_slate_blurb: "Envie de repartir à zéro ? Remplacez par une toute nouvelle clé à tout moment — le nouveau vous n'est pas lié à l'ancien. Même portefeuille, nouveau visage." pick_username: "Choisir un nom d'utilisateur — facultatif" - username_blurb: "Vos amis paient you au lieu d'une longue clé. Public sur goblin.st ; les paiements restent chiffrés. Passez et vous restez simplement anonyme — réservez-en un à tout moment plus tard." + username_blurb: "Vos amis paient votre nom au lieu d'une longue clé. Facultatif — réclamez-en un à tout moment." username_field_hint: "votrenom" working: "En cours…" claim_username: "Réserver le nom d'utilisateur" diff --git a/locales/ru.yml b/locales/ru.yml index 72d793f..308498a 100644 --- a/locales/ru.yml +++ b/locales/ru.yml @@ -535,6 +535,12 @@ goblin: identity_replaced: "Личность заменена" now_using: "Сейчас используется: %{npub}" import_failed: "Импорт не удался" + name_authority: "Сервер имён" + name_authority_title: "Сменить сервер имён" + name_authority_blurb: "Сервер, который регистрирует и проверяет имена. Укажите другой инстанс, чтобы использовать и оплачивать имена оттуда." + name_authority_invalid: "Введите полный URL (https://…)." + reset: "Сброс" + save: "Сохранить" backup_file: "Сохранить в файл" choose_backup_file: "Выбрать файл .backup" backup_read_failed: "Не удалось прочитать файл." @@ -704,10 +710,10 @@ goblin: key_being_made: "ключ создаётся…" connected_nym: "подключено через Nym" connecting_nym: "подключение через Nym…" - fresh_key_blurb: "Новый ключ, созданный для платежей — намеренно не часть вашего seed, поэтому вы можете менять его в любой момент для сохранения приватности, не затрагивая средства. Сохраните его в Настройки → Личность." + fresh_key_blurb: "Платёжный ключ, не связанный с seed-фразой — меняйте его в любой момент, не трогая средства." clean_slate_blurb: "Хотите начать с чистого листа? Подставьте совершенно новый ключ в любой момент — новый вы не связан со старым. Тот же кошелёк, новое лицо." pick_username: "Выберите имя — необязательно" - username_blurb: "Друзья платят на you вместо длинного ключа. Публично на goblin.st; платежи остаются зашифрованными. Пропустите — и вы просто аноним; имя можно занять позже." + username_blurb: "Друзья платят на ваше имя, а не на длинный ключ. Необязательно — можно занять в любой момент." username_field_hint: "yourname" working: "Обработка…" claim_username: "Занять имя" diff --git a/locales/tr.yml b/locales/tr.yml index 53ffc1f..4cdeea4 100644 --- a/locales/tr.yml +++ b/locales/tr.yml @@ -535,6 +535,12 @@ goblin: identity_replaced: "Kimlik değiştirildi" now_using: "Şu an kullanılan: %{npub}" import_failed: "İçe aktarma başarısız" + name_authority: "İsim otoritesi" + name_authority_title: "İsim otoritesini değiştir" + name_authority_blurb: "Adları kaydeden ve doğrulayan sunucu. Başka bir örneğe yönlendirerek oradaki adları kullan ve öde." + name_authority_invalid: "Tam bir URL gir (https://…)." + reset: "Sıfırla" + save: "Kaydet" backup_file: "Dosyaya yedekle" choose_backup_file: "Bir .backup dosyası seç" backup_read_failed: "Dosya okunamadı." @@ -704,10 +710,10 @@ goblin: key_being_made: "anahtar oluşturuluyor…" connected_nym: "Nym üzerinden bağlı" connecting_nym: "Nym üzerinden bağlanılıyor…" - fresh_key_blurb: "Ödemeler için yapılmış yepyeni bir anahtar — kasıtlı olarak tohumunun parçası değildir, böylece paranıza dokunmadan, gizliliğini korumak için onu istediğin zaman değiştirebilirsin. Ayarlar → Kimlik'ten yedekle." + fresh_key_blurb: "Seed'inin parçası olmayan bir ödeme anahtarı — paranı hiç ellemeden istediğin an döndür." clean_slate_blurb: "Temiz bir sayfa mı istiyorsun? İstediğin zaman yepyeni bir anahtar tak — yeni sen eskisine bağlı değil. Aynı cüzdan, yeni yüz." pick_username: "Bir kullanıcı adı seç — isteğe bağlı" - username_blurb: "Arkadaşların uzun bir anahtar yerine you'ya öder. goblin.st'de herkese açık; ödemeler şifreli kalır. Atla, basitçe anonim olursun — istediğin zaman sonra bir tane alabilirsin." + username_blurb: "Arkadaşların uzun bir anahtar yerine adına öder. İsteğe bağlı — istediğin an al." username_field_hint: "adınız" working: "Çalışıyor…" claim_username: "Kullanıcı adı al" diff --git a/locales/zh-CN.yml b/locales/zh-CN.yml index 11d65d9..72e298b 100644 --- a/locales/zh-CN.yml +++ b/locales/zh-CN.yml @@ -535,6 +535,12 @@ goblin: identity_replaced: "身份已替换" now_using: "当前使用:%{npub}" import_failed: "导入失败" + name_authority: "名称授权方" + name_authority_title: "更改名称授权方" + name_authority_blurb: "注册和验证名称的服务器。指向另一个实例即可使用并支付那里托管的名称。" + name_authority_invalid: "请输入完整网址(https://…)。" + reset: "重置" + save: "保存" backup_file: "备份到文件" choose_backup_file: "选择 .backup 文件" backup_read_failed: "无法读取该文件。" @@ -704,10 +710,10 @@ goblin: key_being_made: "正在生成密钥…" connected_nym: "已通过 Nym 连接" connecting_nym: "正在通过 Nym 连接…" - fresh_key_blurb: "一个为付款生成的全新密钥 — 刻意不属于你的助记词,因此你可随时轮换以保护隐私,而不会触及你的资金。请在 设置 → 身份 中备份它。" + fresh_key_blurb: "一个不属于助记词的支付密钥——可随时轮换以保护隐私,且不影响你的资金。" clean_slate_blurb: "想要全新开始?随时换上一个全新密钥 — 新的你与旧的毫无关联。同一个钱包,焕然一新。" pick_username: "选择用户名 — 可选" - username_blurb: "朋友付款给 you 而非冗长的密钥。在 goblin.st 上公开;付款保持加密。跳过则你只是匿名 — 之后随时可注册。" + username_blurb: "朋友支付给你的名称,而不是一长串密钥。可选——随时认领。" username_field_hint: "你的用户名" working: "处理中…" claim_username: "注册用户名" diff --git a/src/gui/platform/android/mod.rs b/src/gui/platform/android/mod.rs index 7552409..cc31c4c 100644 --- a/src/gui/platform/android/mod.rs +++ b/src/gui/platform/android/mod.rs @@ -158,6 +158,38 @@ impl PlatformCallbacks for Android { Ok(()) } + fn save_file(&self, name: String, data: Vec) -> Result<(), std::io::Error> { + // Stage the bytes in the share cache (same dir the FileProvider exposes), + // then let Java copy them to the user-chosen Storage Access Framework + // document. Mirrors `share_data`, but the Java side uses CREATE_DOCUMENT. + let default_cache = OsString::from(dirs::cache_dir().unwrap()); + let mut file = PathBuf::from(env::var_os("XDG_CACHE_HOME").unwrap_or(default_cache)); + file.push("share"); + if !file.exists() { + std::fs::create_dir(file.clone())?; + } + file.push(&name); + if file.exists() { + std::fs::remove_file(file.clone())?; + } + let mut f = File::create_new(file.clone())?; + f.write_all(data.as_slice())?; + f.sync_all()?; + let vm = unsafe { jni::JavaVM::from_raw(self.android_app.vm_as_ptr() as _) }.unwrap(); + let env = vm.attach_current_thread().unwrap(); + let path_arg = env.new_string(file.to_str().unwrap()).unwrap(); + let name_arg = env.new_string(&name).unwrap(); + let _ = self.call_java_method( + "saveFile", + "(Ljava/lang/String;Ljava/lang/String;)V", + &[ + JValue::Object(&JObject::from(path_arg)), + JValue::Object(&JObject::from(name_arg)), + ], + ); + Ok(()) + } + fn share_text(&self, text: String) { let vm = unsafe { jni::JavaVM::from_raw(self.android_app.vm_as_ptr() as _) }.unwrap(); let env = vm.attach_current_thread().unwrap(); diff --git a/src/gui/platform/mod.rs b/src/gui/platform/mod.rs index 694fa61..33f5d94 100644 --- a/src/gui/platform/mod.rs +++ b/src/gui/platform/mod.rs @@ -32,6 +32,14 @@ pub trait PlatformCallbacks { fn can_switch_camera(&self) -> bool; fn switch_camera(&self); fn share_data(&self, name: String, data: Vec) -> Result<(), std::io::Error>; + + /// Save bytes to a user-chosen location on the device (a "save as" dialog). + /// Desktop already does this via `share_data` (rfd save dialog); Android + /// overrides to use the Storage Access Framework (ACTION_CREATE_DOCUMENT) + /// instead of the share sheet. + fn save_file(&self, name: String, data: Vec) -> Result<(), std::io::Error> { + self.share_data(name, data) + } /// Share plain text via the platform's native share sheet (e.g. a payment /// link). Defaults to copying to the clipboard on platforms without a share /// sheet (desktop). diff --git a/src/gui/views/goblin/data.rs b/src/gui/views/goblin/data.rs index 90ca94b..a593c9a 100644 --- a/src/gui/views/goblin/data.rs +++ b/src/gui/views/goblin/data.rs @@ -193,7 +193,9 @@ pub fn contact_title(store: &NostrStore, npub: &str) -> (String, usize) { } } -/// Display rule: petname → @user (verified goblin.st) → user@domain → npub short. +/// Display rule: petname → bare name (verified, home authority) → `name · domain` +/// (verified, foreign authority — never bare, so a foreign "alice" can't pose as +/// your home "alice") → short npub. We never show the `@`. pub fn display_name(contact: &Contact) -> String { if let Some(petname) = &contact.petname { if !petname.is_empty() { @@ -202,15 +204,31 @@ pub fn display_name(contact: &Contact) -> String { } if let (Some(nip05), Some(_)) = (&contact.nip05, contact.nip05_verified_at) { if let Some((name, domain)) = nip05.split_once('@') { - if domain == crate::nostr::relays::HOME_NIP05_DOMAIN { + if domain == crate::nostr::nip05::home_domain() { return name.to_string(); } - return nip05.clone(); + // Foreign authority: show the domain (no @) so it can't masquerade + // as a home name. + return format!("{name} · {domain}"); } } short_npub(&contact.npub) } +/// Whether this contact's name is verified against a name authority (gets the +/// little check), and the foreign domain to surface (None when it's the home +/// authority, where the domain is implied). +pub fn name_verification(contact: &Contact) -> Option> { + let nip05 = contact.nip05.as_ref()?; + contact.nip05_verified_at?; + let (_, domain) = nip05.split_once('@')?; + if domain == crate::nostr::nip05::home_domain() { + Some(None) + } else { + Some(Some(domain.to_string())) + } +} + /// Short npub display (npub1abcd…wxyz) from a hex pubkey. /// Avatar hue index derived from a hex pubkey (stable per identity, spread /// across the full color-pair palette). diff --git a/src/gui/views/goblin/mod.rs b/src/gui/views/goblin/mod.rs index 2a61412..c9b775b 100644 --- a/src/gui/views/goblin/mod.rs +++ b/src/gui/views/goblin/mod.rs @@ -72,6 +72,8 @@ pub struct GoblinWalletView { import_nsec: Option, /// Inline "back up identity to a file" flow state. backup: Option, + /// Inline "change name authority" editor state. + name_authority: Option, /// Amount being entered on the Pay tab. pay_amount: String, /// When set, the over-balance "no" animation is playing: the start time (egui @@ -82,6 +84,8 @@ pub struct GoblinWalletView { request_amount: Option, /// Sub-page open inside the Settings tab. settings_page: SettingsPage, + /// GRIM's native node-connections screen (embedded under Advanced). + grim_connections: crate::gui::views::network::ConnectionsContent, /// Inline state for the Advanced settings page (recovery/repair/delete). advanced: AdvancedState, /// One-shot signal to the wallet host: deselect this wallet (return to the @@ -115,6 +119,8 @@ pub struct GoblinWalletView { enum SettingsPage { Main, Node, + /// GRIM's native node-connections screen, embedded. + Connections, Relays, Nips, Pairing, @@ -173,10 +179,12 @@ impl Default for GoblinWalletView { rotate: None, import_nsec: None, backup: None, + name_authority: None, pay_amount: String::new(), pay_shake: None, request_amount: None, settings_page: SettingsPage::Main, + grim_connections: Default::default(), advanced: AdvancedState::default(), switch_requested: false, node_url_input: String::new(), @@ -247,6 +255,15 @@ impl Default for ImportState { } } +/// Inline "change name authority" (federation) editor state. +#[derive(Default)] +struct NameAuthorityState { + /// Server URL being typed (e.g. https://other.example). + input: String, + /// Validation error to show. + error: Option, +} + /// Inline "back up identity to a file" flow state. #[derive(Default)] struct BackupState { @@ -2082,6 +2099,7 @@ impl GoblinWalletView { let t = theme::tokens(); match self.settings_page { SettingsPage::Node => return self.node_settings_ui(ui, wallet, cb), + SettingsPage::Connections => return self.grim_connections_ui(ui, cb), SettingsPage::Relays => return self.relays_ui(ui, wallet, cb), SettingsPage::Nips => return self.nips_ui(ui), SettingsPage::Pairing => return self.pairing_settings_ui(ui), @@ -2142,11 +2160,22 @@ impl GoblinWalletView { w::avatar_any(ui, &handle, &npub_hex, 56.0, hue, own_tex.as_ref()); ui.add_space(14.0); ui.vertical(|ui| { - ui.label( - RichText::new(&handle) - .font(FontId::new(17.0, fonts::bold())) - .color(t.surface_text), - ); + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 5.0; + ui.label( + RichText::new(&handle) + .font(FontId::new(17.0, fonts::bold())) + .color(t.surface_text), + ); + // A claimed/verified name gets the little check. + if bare_name.is_some() { + ui.label( + RichText::new(crate::gui::icons::SEAL_CHECK) + .font(FontId::new(15.0, fonts::regular())) + .color(t.pos), + ); + } + }); // Mixnet status (fast) in place of the redundant second npub line. let mixnet = if crate::nym::is_ready() { t!("goblin.home.connected_nym") @@ -2233,6 +2262,24 @@ impl GoblinWalletView { { self.import_nsec = Some(ImportState::default()); } + // Federation: which name authority (server) registers and + // verifies names. Shows the current host on the right. + let authority = wallet + .nostr_service() + .map(|s| s.config.read().home_domain()) + .unwrap_or_default(); + if settings_row_nav(ui, &t!("goblin.settings.name_authority"), &authority) + && self.name_authority.is_none() + { + let cur = wallet + .nostr_service() + .map(|s| s.config.read().nip05_server()) + .unwrap_or_default(); + self.name_authority = Some(NameAuthorityState { + input: cur, + error: None, + }); + } } }); // Transient confirmation that the copy landed — pairs with the @@ -2267,6 +2314,10 @@ impl GoblinWalletView { ui.add_space(8.0); self.backup_ui(ui, wallet, cb); } + if self.name_authority.is_some() { + ui.add_space(8.0); + self.name_authority_ui(ui, wallet, cb); + } if self.rotate.is_some() { ui.add_space(8.0); self.rotate_ui(ui, wallet, cb); @@ -2856,12 +2907,26 @@ impl GoblinWalletView { self.settings_page = SettingsPage::Main; } if open_node { - self.node_url_input.clear(); - self.node_secret_input.clear(); - self.settings_page = SettingsPage::Node; + // Advanced → "Manage node connection" opens GRIM's native connections UI. + self.settings_page = SettingsPage::Connections; } } + /// GRIM's native node-connections screen, embedded under a Goblin back header. + fn grim_connections_ui(&mut self, ui: &mut egui::Ui, cb: &dyn PlatformCallbacks) { + use crate::gui::views::types::ContentContainer; + if self.sub_header(ui, &t!("goblin.node.title")) { + self.settings_page = SettingsPage::Advanced; + return; + } + ScrollArea::vertical() + .auto_shrink([false; 2]) + .scroll_bar_visibility(egui::scroll_area::ScrollBarVisibility::AlwaysHidden) + .show(ui, |ui| { + self.grim_connections.ui(ui, cb); + }); + } + fn node_settings_ui(&mut self, ui: &mut egui::Ui, wallet: &Wallet, cb: &dyn PlatformCallbacks) { use crate::wallet::types::ConnectionMethod; use crate::wallet::{ConnectionsConfig, ExternalConnection}; @@ -3101,7 +3166,6 @@ impl GoblinWalletView { TextEdit::new(egui::Id::from("sp_paste")) .focus(false) .paste() - .scan_qr() .hint_text("BEGINSLATEPACK. … ENDSLATEPACK.") .text_color(t.surface_text) .body() @@ -3558,6 +3622,108 @@ impl GoblinWalletView { } /// Inline nsec-import flow: replaces the identity with an imported key. + /// Inline "change name authority" editor: set the NIP-05 server that registers + /// and verifies names. Lets a user on one instance pay names on another. + fn name_authority_ui( + &mut self, + ui: &mut egui::Ui, + wallet: &Wallet, + cb: &dyn PlatformCallbacks, + ) { + let t = theme::tokens(); + let na = self.name_authority.as_mut().unwrap(); + let mut close = false; + w::card(ui, |ui| { + ui.set_min_width(ui.available_width()); + ui.label( + RichText::new(t!("goblin.settings.name_authority_title")) + .font(FontId::new(15.0, fonts::semibold())) + .color(t.surface_text), + ); + ui.add_space(6.0); + ui.label( + RichText::new(t!("goblin.settings.name_authority_blurb")) + .font(FontId::new(13.0, fonts::regular())) + .color(t.surface_text_dim), + ); + ui.add_space(10.0); + w::field_well(ui, |ui| { + TextEdit::new(egui::Id::from("name_authority_input")) + .focus(false) + .hint_text("https://goblin.st") + .text_color(t.surface_text) + .body() + .ui(ui, &mut na.input, cb); + }); + if let Some(err) = &na.error { + ui.add_space(6.0); + ui.label( + RichText::new(err) + .font(FontId::new(12.5, fonts::regular())) + .color(t.neg), + ); + } + ui.add_space(10.0); + ui.horizontal(|ui| { + let third = (ui.available_width() - 20.0) / 3.0; + ui.scope_builder( + egui::UiBuilder::new().max_rect(egui::Rect::from_min_size( + ui.cursor().min, + Vec2::new(third, 44.0), + )), + |ui| { + if w::big_action_on_card(ui, &t!("goblin.settings.cancel")).clicked() { + close = true; + } + }, + ); + ui.add_space(10.0); + ui.scope_builder( + egui::UiBuilder::new().max_rect(egui::Rect::from_min_size( + ui.cursor().min, + Vec2::new(third, 44.0), + )), + |ui| { + if w::big_action_on_card(ui, &t!("goblin.settings.reset")).clicked() { + if let Some(s) = wallet.nostr_service() { + s.config.write().set_nip05_server(None); + crate::nostr::nip05::set_home_domain( + &s.config.read().home_domain(), + ); + } + close = true; + } + }, + ); + ui.add_space(10.0); + ui.scope_builder( + egui::UiBuilder::new().max_rect(egui::Rect::from_min_size( + ui.cursor().min, + Vec2::new(third, 44.0), + )), + |ui| { + if w::big_action(ui, &t!("goblin.settings.save"), false).clicked() { + let url = na.input.trim().to_string(); + if !url.starts_with("https://") && !url.starts_with("http://") { + na.error = + Some(t!("goblin.settings.name_authority_invalid").to_string()); + } else if let Some(s) = wallet.nostr_service() { + s.config.write().set_nip05_server(Some(url)); + crate::nostr::nip05::set_home_domain( + &s.config.read().home_domain(), + ); + close = true; + } + } + }, + ); + }); + }); + if close { + self.name_authority = None; + } + } + /// Inline "back up identity to a file" flow: ask for the wallet password, /// seal the identity, and write a GOBLIN-*.backup file via the native picker. fn backup_ui(&mut self, ui: &mut egui::Ui, wallet: &Wallet, cb: &dyn PlatformCallbacks) { @@ -3642,7 +3808,7 @@ impl GoblinWalletView { Ok(envelope) => { let stamp = chrono::Local::now().format("%Y-%m-%d-%H%M"); let fname = format!("GOBLIN-{stamp}.backup"); - match cb.share_data(fname, envelope.into_bytes()) { + match cb.save_file(fname, envelope.into_bytes()) { Ok(()) => { bk.done = true; bk.error = None; diff --git a/src/gui/views/goblin/onboarding.rs b/src/gui/views/goblin/onboarding.rs index 36242cb..eafa3fa 100644 --- a/src/gui/views/goblin/onboarding.rs +++ b/src/gui/views/goblin/onboarding.rs @@ -748,12 +748,6 @@ impl OnboardingContent { .font(FontId::new(12.5, fonts::regular())) .color(t.surface_text_dim), ); - ui.add_space(6.0); - ui.label( - 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); @@ -783,6 +777,9 @@ impl OnboardingContent { } s.save_identity(); } + // Publish kind 0 now so the just-claimed name is visible to + // others over the relay without waiting for the next app start. + wallet.task(crate::wallet::types::WalletTask::NostrRepublishProfile); } ClaimMsg::Released => {} ClaimMsg::Error(e) => { @@ -812,11 +809,6 @@ impl OnboardingContent { ui.add_space(8.0); w::field_well(ui, |ui| { ui.horizontal(|ui| { - ui.label( - RichText::new("@") - .font(FontId::new(16.0, fonts::semibold())) - .color(t.surface_text), - ); let before = self.claim.input.clone(); TextEdit::new(egui::Id::from("onb_claim")) .focus(false) diff --git a/src/gui/views/goblin/send.rs b/src/gui/views/goblin/send.rs index a2e1ea5..e9fc7cb 100644 --- a/src/gui/views/goblin/send.rs +++ b/src/gui/views/goblin/send.rs @@ -18,11 +18,15 @@ use eframe::epaint::FontId; use egui::{Align, Layout, RichText, ScrollArea, Sense, Vec2}; use grin_core::core::amount_from_hr_string; +use crate::gui::Colors; use crate::gui::icons::{ARROW_LEFT, MAGNIFYING_GLASS, SHARE, USERS}; use crate::gui::platform::PlatformCallbacks; use crate::gui::theme::{self, fonts}; -use crate::gui::views::types::QrScanResult; -use crate::gui::views::{CameraContent, TextEdit, View}; +use crate::gui::views::types::{ModalPosition, QrScanResult}; +use crate::gui::views::{CameraContent, Modal, TextEdit, View}; + +/// Modal id for the send-flow note editor (floats above the soft keyboard). +const NOTE_MODAL: &str = "send_note_modal"; use crate::nostr::nip05; use crate::wallet::Wallet; use crate::wallet::types::WalletTask; @@ -141,9 +145,7 @@ pub struct SendFlow { /// Atomic amount (nanogrin) we last asked the wallet to price, so the review /// page dispatches one `CalculateFee` per amount instead of every frame. fee_requested_for: Option, - /// The note editor sheet is open (tapped "Add note"). - note_editing: bool, - /// Draft note held while the editor is open, so Cancel discards it. + /// Draft note held while the editor modal is open, so Cancel discards it. note_draft: String, } @@ -170,7 +172,6 @@ impl Default for SendFlow { request: false, receipt_npub: None, fee_requested_for: None, - note_editing: false, note_draft: String::new(), } } @@ -223,6 +224,13 @@ impl SendFlow { ) -> bool { let t = theme::tokens(); let mut done = false; + // Note editor modal — floats above the soft keyboard with a dimmed + // backdrop (the GRIM Modal system), like the wallet-password modal. + if Modal::opened() == Some(NOTE_MODAL) { + Modal::ui(ui.ctx(), cb, |ui, _modal, cb| { + self.note_modal_content(ui, cb); + }); + } egui::CentralPanel::default() .frame(egui::Frame { fill: if self.stage == Stage::Success { @@ -255,6 +263,49 @@ impl SendFlow { done } + /// Content of the note-editor modal: a focused text field + Cancel/Save. + fn note_modal_content(&mut self, ui: &mut egui::Ui, cb: &dyn PlatformCallbacks) { + let mut save = false; + let mut cancel = false; + ui.vertical_centered(|ui| { + ui.add_space(4.0); + let mut field = TextEdit::new(egui::Id::from(NOTE_MODAL).with("input")) + .focus(true) + .hint_text(t!("goblin.send.note_hint")); + field.ui(ui, &mut self.note_draft, cb); + if field.enter_pressed { + save = true; + } + ui.add_space(10.0); + }); + ui.columns(2, |columns| { + columns[0].vertical_centered_justified(|ui| { + View::button( + ui, + t!("goblin.send.note_cancel"), + Colors::white_or_black(false), + || cancel = true, + ); + }); + columns[1].vertical_centered_justified(|ui| { + View::button( + ui, + t!("goblin.send.note_save"), + Colors::white_or_black(false), + || save = true, + ); + }); + }); + ui.add_space(4.0); + if cancel { + Modal::close(); + } + if save { + self.note = self.note_draft.trim().to_string(); + Modal::close(); + } + } + fn back_header(&self, ui: &mut egui::Ui, title: &str) -> bool { let t = theme::tokens(); let mut back = false; @@ -828,27 +879,26 @@ 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 home = domain == crate::nostr::nip05::home_domain(); + // Show the name without `@`; a foreign authority shows its + // domain (`name · domain`) so it can't masquerade as a home + // name. The NIP-05 root convention `_@domain` is just domain. let display = if home { name.to_string() } else if name == "_" { domain.clone() } else { - format!("{name}@{domain}") + format!("{name} · {domain}") }; LookupResult::Found(Candidate { 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: home, - tag: if home { "@goblin.st" } else { "nip-05" }, + // A successful NIP-05 resolution (home OR a named foreign + // authority) is verified — the user typed a specific + // handle and the domain is shown, so no bare-key gate. + verified: true, + tag: if home { "verified" } else { "nip-05" }, // Resolution relay hints help deliver to a // recipient whose kind 10050 we can't see. relay_hints: r.relays, @@ -969,61 +1019,16 @@ impl SendFlow { } ui.add_space(12.0); - // Note: a tap-to-open editor instead of an always-open field fighting the - // numpad for the keyboard. "Add note" → type → Cancel discards, Save keeps. - if self.note_editing { - w::card(ui, |ui| { - ui.set_min_width(ui.available_width()); - w::field_well(ui, |ui| { - TextEdit::new(note_id) - .focus(true) - .hint_text(t!("goblin.send.note_hint")) - .text_color(t.surface_text) - .body() - .ui(ui, &mut self.note_draft, cb); - }); - ui.add_space(10.0); - ui.horizontal(|ui| { - let half = (ui.available_width() - 10.0) / 2.0; - ui.scope_builder( - egui::UiBuilder::new().max_rect(egui::Rect::from_min_size( - ui.cursor().min, - Vec2::new(half, 44.0), - )), - |ui| { - if w::big_action_on_card_ink( - ui, - &t!("goblin.send.note_cancel"), - t.surface_text_dim, - ) - .clicked() - { - self.note_editing = false; - self.note_draft.clear(); - ui.ctx().memory_mut(|m| m.surrender_focus(note_id)); - } - }, - ); - ui.add_space(10.0); - ui.scope_builder( - egui::UiBuilder::new().max_rect(egui::Rect::from_min_size( - ui.cursor().min, - Vec2::new(half, 44.0), - )), - |ui| { - if w::big_action_on_card(ui, &t!("goblin.send.note_save")).clicked() { - self.note = self.note_draft.trim().to_string(); - self.note_editing = false; - ui.ctx().memory_mut(|m| m.surrender_focus(note_id)); - } - }, - ); - }); - }); - } else if self.note.trim().is_empty() { + // Note: opens a modal editor that floats above the soft keyboard with a + // dimmed backdrop, so the keyboard never covers it (works on every device). + let _ = note_id; + if self.note.trim().is_empty() { if w::big_action(ui, &t!("goblin.send.add_note"), true).clicked() { self.note_draft = self.note.clone(); - self.note_editing = true; + Modal::new(NOTE_MODAL) + .position(ModalPosition::CenterTop) + .title(t!("goblin.send.note_label")) + .show(); } } else { // Show the saved note, with an Edit button to re-open the editor. @@ -1038,7 +1043,10 @@ impl SendFlow { ui.add_space(8.0); if w::big_action(ui, &t!("goblin.send.edit_note"), true).clicked() { self.note_draft = self.note.clone(); - self.note_editing = true; + Modal::new(NOTE_MODAL) + .position(ModalPosition::CenterTop) + .title(t!("goblin.send.note_label")) + .show(); } } ui.add_space(8.0); diff --git a/src/gui/views/goblin/widgets.rs b/src/gui/views/goblin/widgets.rs index b90682a..158b10f 100644 --- a/src/gui/views/goblin/widgets.rs +++ b/src/gui/views/goblin/widgets.rs @@ -196,7 +196,7 @@ pub fn amount_text_centered_shifted( .layout_no_wrap(value.to_string(), FontId::new(sz, fonts::bold()), num_ink); let mark = ui.painter().layout_no_wrap( TSU.to_string(), - FontId::new(sz * 0.4, fonts::medium()), + FontId::new(sz * 0.46, fonts::semibold()), mark_ink, ); num.size().x + 1.0 + mark.size().x @@ -221,7 +221,7 @@ pub fn amount_text_centered_shifted( ui.add_space(1.0); ui.label( RichText::new(TSU) - .font(FontId::new(size * 0.4, fonts::medium())) + .font(FontId::new(size * 0.46, fonts::semibold())) .color(mark_ink), ); }); diff --git a/src/gui/views/wallets/content.rs b/src/gui/views/wallets/content.rs index 8ff5dec..fe6313d 100644 --- a/src/gui/views/wallets/content.rs +++ b/src/gui/views/wallets/content.rs @@ -203,14 +203,16 @@ impl ContentContainer for WalletsContent { // open but not yet selected). let onboarding_active = !showing_wallet && self.onboarding_active(); // Keep the Android status-bar icons readable against whatever paints the - // top strip. Every screen outside the Goblin wallet surface — the wallet - // list, app settings, wallet creation AND onboarding — leaves the bright - // accent-yellow `title_panel_bg` showing under the status bar, which needs - // DARK icons; a dark global theme would otherwise pick white icons that - // are illegible on yellow. The Goblin surface covers the inset itself and - // sets its own per-tab flag (Pay is yellow, the rest dark). - if !showing_wallet { + // top strip. The GRIM screens (wallet list, app settings, wallet creation) + // leave the bright accent-yellow `title_panel_bg` showing under the status + // bar, which needs DARK icons. Onboarding, though, paints its OWN dark + // full-bleed surface (no title panel), so it needs theme-appropriate icons + // (white on the dark surface) — forcing dark there made them black-on-black. + // The Goblin wallet surface covers the inset itself and sets its per-tab flag. + if !showing_wallet && !onboarding_active { crate::gui::theme::set_status_surface_yellow(true); + } else if onboarding_active { + crate::gui::theme::set_status_surface_yellow(false); } let dual_panel = is_dual_panel_mode(ui); let content_width = ui.available_width(); diff --git a/src/nostr/client.rs b/src/nostr/client.rs index c2316bb..ef300c2 100644 --- a/src/nostr/client.rs +++ b/src/nostr/client.rs @@ -714,6 +714,8 @@ async fn run_service(svc: Arc, wallet: Wallet) { // Publish the service runtime handle so worker-thread one-shots (profile // lookups) can run their fetches here, where the relay I/O actually lives. *svc.rt_handle.write() = Some(tokio::runtime::Handle::current()); + // Mirror the configured name authority so resolution + display follow it. + crate::nostr::nip05::set_home_domain(&svc.config.read().home_domain()); let relays = svc.relays(); info!( "nostr: starting service for {} with relays {:?}", @@ -839,7 +841,10 @@ async fn run_service(svc: Arc, wallet: Wallet) { status_tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); let mut last_heartbeat = unix_time(); let mut last_prune = unix_time(); - let mut last_name_sweep = unix_time(); + // Seed from the persisted sweep time, NOT now: a fresh launch should re-check + // names right away (so you see refreshed info from app open), unless one ran + // within the last interval. + let mut last_name_sweep = svc.store.last_name_sweep_at().unwrap_or(0); loop { if svc.shutdown.load(Ordering::SeqCst) || !wallet.is_open() { break; @@ -874,6 +879,7 @@ async fn run_service(svc: Arc, wallet: Wallet) { // mixnet lookups; each worker re-checks against the identity server. if now - last_name_sweep >= NAME_REVERIFY_INTERVAL_SECS { last_name_sweep = now; + svc.store.set_last_name_sweep_at(now); let mut due: Vec<_> = svc .store .all_contacts() diff --git a/src/nostr/config.rs b/src/nostr/config.rs index 959fc5d..ff7eb2e 100644 --- a/src/nostr/config.rs +++ b/src/nostr/config.rs @@ -130,6 +130,32 @@ impl NostrConfig { .unwrap_or_else(|| DEFAULT_NIP05_SERVER.to_string()) } + /// The name-authority HOST derived from the configured server URL (e.g. + /// `goblin.st`). This is "home": bare names (`alice`) resolve here and own/ + /// home-domain names display without their domain. Federation: a different + /// authority makes `alice` mean `alice@thatdomain`, while a full + /// `bob@goblin.st` always resolves against goblin.st. + pub fn home_domain(&self) -> String { + let server = self.nip05_server(); + server + .trim_start_matches("https://") + .trim_start_matches("http://") + .split('/') + .next() + .unwrap_or("") + .split(':') + .next() + .unwrap_or("") + .to_string() + } + + /// Set the name-authority server (e.g. `https://other.example`). Pass an + /// empty string to reset to the default (goblin.st). + pub fn set_nip05_server(&mut self, server: Option) { + self.nip05_server = server.filter(|s| !s.trim().is_empty()); + self.save(); + } + /// Seconds after which a still-pending transaction is auto-canceled/expired. pub fn expiry_secs(&self) -> i64 { self.expiry_secs.unwrap_or(24 * 60 * 60) diff --git a/src/nostr/nip05.rs b/src/nostr/nip05.rs index dfc1519..acbd4b5 100644 --- a/src/nostr/nip05.rs +++ b/src/nostr/nip05.rs @@ -23,6 +23,32 @@ use sha2::{Digest, Sha256}; use crate::nostr::relays::HOME_NIP05_DOMAIN; use crate::nym; +use parking_lot::RwLock; + +/// The active name-authority "home" domain, mirrored here from the wallet config +/// once per frame so resolution + display (some on worker threads) can read it +/// without threading the config through every call site. `None` = the default +/// (goblin.st). Federation: set this to another authority and bare names resolve +/// there and own-domain names display without a domain suffix. +static HOME_DOMAIN: RwLock> = RwLock::new(None); + +/// Mirror the configured name authority's host (e.g. `goblin.st`). Empty resets +/// to the default. +pub fn set_home_domain(domain: &str) { + *HOME_DOMAIN.write() = if domain.trim().is_empty() { + None + } else { + Some(domain.trim().to_lowercase()) + }; +} + +/// The current name-authority home domain (configured or the goblin.st default). +pub fn home_domain() -> String { + HOME_DOMAIN + .read() + .clone() + .unwrap_or_else(|| HOME_NIP05_DOMAIN.to_string()) +} /// Result of resolving a NIP-05 identifier. #[derive(Debug, Clone)] @@ -47,7 +73,7 @@ pub fn split_identifier(input: &str) -> Option<(String, String)> { Some((name.to_lowercase(), domain)) } Some(_) => None, - None => Some((trimmed.to_lowercase(), HOME_NIP05_DOMAIN.to_string())), + None => Some((trimmed.to_lowercase(), home_domain())), } } diff --git a/src/nostr/store.rs b/src/nostr/store.rs index aebb0f4..53dcc55 100644 --- a/src/nostr/store.rs +++ b/src/nostr/store.rs @@ -272,6 +272,26 @@ impl NostrStore { let _ = writer.commit(); } + /// Unix time of the last contact-name re-verify sweep (persisted across + /// restarts so a fresh launch only re-sweeps if it's been a while). + pub fn last_name_sweep_at(&self) -> Option { + let env = self.env.read().unwrap_or_else(|e| e.into_inner()); + let reader = env.read().unwrap(); + if let Ok(Some(Value::I64(v))) = self.settings.get(&reader, "last_name_sweep_at") { + return Some(v); + } + None + } + + pub fn set_last_name_sweep_at(&self, ts: i64) { + let env = self.env.read().unwrap_or_else(|e| e.into_inner()); + let mut writer = env.write().unwrap(); + let _ = self + .settings + .put(&mut writer, "last_name_sweep_at", &Value::I64(ts)); + let _ = writer.commit(); + } + // ── archive control (user-facing) ─────────────────────────────────────── /// Export the whole archive as a JSON document.