1
0
forked from GRIN/grim

Federation, note modal, save-to-device, onboarding fixes + UI polish

- Configurable name authority (Settings → Identity → Name authority): bare
  names resolve there, own-domain names show bare, foreign verified names show
  'name · domain' with a check — no '@' anywhere. Lets bob@otherdomain pay
  alice@goblin.st. Home domain derived from the configured server.
- Note entry is now a modal that floats above the soft keyboard (dimmed
  backdrop) instead of an inline editor the keyboard covered.
- Backup export SAVES to a chosen location (Android CREATE_DOCUMENT / desktop
  save dialog) instead of opening the share sheet.
- Onboarding status-bar icons are legible again (white on the dark surface,
  not black); identity step is less wordy and drops the '@' prefix; claiming a
  name during onboarding now republishes kind 0 so it's visible immediately.
- App-open name re-verify sweep (persisted, runs if >78s since last).
- Advanced 'Manage node connection' opens GRIM's native Connections UI.
- Manual slatepack paste: removed the QR icon. Pay screen: bolder, bigger ツ.
- Localized new strings across 6 locales.
This commit is contained in:
2ro
2026-06-16 03:22:08 -04:00
parent dfbd85c7b3
commit 11033b93fe
19 changed files with 504 additions and 118 deletions
@@ -69,6 +69,9 @@ public class MainActivity extends GameActivity {
private ActivityResultLauncher<Intent> mFilePickResult = null;
private ActivityResultLauncher<Intent> mOpenFilePermissionsResult = null;
private ActivityResultLauncher<Intent> 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) {
+8 -2
View File
@@ -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"
+8 -2
View File
@@ -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"
+8 -2
View File
@@ -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"
+8 -2
View File
@@ -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: "Занять имя"
+8 -2
View File
@@ -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"
+8 -2
View File
@@ -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: "注册用户名"
+32
View File
@@ -158,6 +158,38 @@ impl PlatformCallbacks for Android {
Ok(())
}
fn save_file(&self, name: String, data: Vec<u8>) -> 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();
+8
View File
@@ -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<u8>) -> 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<u8>) -> 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).
+21 -3
View File
@@ -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<Option<String>> {
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).
+176 -10
View File
@@ -72,6 +72,8 @@ pub struct GoblinWalletView {
import_nsec: Option<ImportState>,
/// Inline "back up identity to a file" flow state.
backup: Option<BackupState>,
/// Inline "change name authority" editor state.
name_authority: Option<NameAuthorityState>,
/// 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<String>,
/// 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<String>,
}
/// 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;
+3 -11
View File
@@ -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)
+79 -71
View File
@@ -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<u64>,
/// 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);
+2 -2
View File
@@ -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),
);
});
+9 -7
View File
@@ -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();
+7 -1
View File
@@ -714,6 +714,8 @@ async fn run_service(svc: Arc<NostrService>, 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<NostrService>, 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<NostrService>, 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()
+26
View File
@@ -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<String>) {
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)
+27 -1
View File
@@ -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<Option<String>> = 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())),
}
}
+20
View File
@@ -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<i64> {
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.