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:
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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: "注册用户名"
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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
@@ -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())),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user