diff --git a/locales/de.yml b/locales/de.yml index 4c50409..dc6f441 100644 --- a/locales/de.yml +++ b/locales/de.yml @@ -406,6 +406,7 @@ goblin: pending: "Ausstehend" confs: "%{c}/%{r} Bestätigungen" waiting_to_confirm: "Warte auf Bestätigung" + paying: "Zahlung läuft…" you: "Du" to: "An" from: "Von" @@ -448,16 +449,11 @@ goblin: title: "Einstellungen" connected_nostr: "Mit nostr verbunden" connecting_relays: "Verbinde mit Relays…" - pic_updated: "Profilbild aktualisiert" - uploading_pic: "Lade Bild hoch…" - claim_first: "Erst einen Benutzernamen sichern — Bilder hängen daran" identity: "Identität" copy_npub: "npub kopieren (öffentlich)" - backup_nsec: "Geheimen Schlüssel sichern (nsec)" - export_identity: "Identitäts-Backup exportieren (verschlüsselt)" rotate_key: "nostr-Schlüssel wechseln" - import_identity: "Identität importieren (nsec / Backup)" - backup_note: "Gerätewechsel? Sichere BEIDES: deine Seed-Phrase (Guthaben) und ein Identitäts-Backup (Name + Schlüssel)." + import_identity: "Identität importieren (.backup / nsec)" + backup_note: "Gerät wechseln? Sichere BEIDES: deine Seed-Phrase (Guthaben) und deine Identitäts-.backup-Datei (Name + Schlüssel)." wallet: "Wallet" display_unit: "Anzeigeeinheit" relays: "Relays" @@ -512,7 +508,7 @@ goblin: sp_copy: "Slatepack kopieren" rotate_line1: "• Du bekommst einen brandneuen ZUFÄLLIGEN Schlüssel; das alte npub empfängt nichts mehr. Es gibt keine Ableitungskette zwischen beiden." rotate_line2: "• Der neue Schlüssel ist NICHT aus deinem Seed wiederherstellbar — sichere die neue nsec direkt nach dem Wechsel." - rotate_line3: "• Dein username wird FREIGEGEBEN und dein Profilbild gelöscht — sichere danach denselben oder einen neuen Namen (sobald frei, kann ihn jeder andere ebenfalls greifen)." + rotate_line3: "• Dein Benutzername wird FREIGEGEBEN — beanspruche direkt danach denselben oder einen neuen Namen (sobald frei, kann ihn auch jede andere Person nehmen)." rotate_line4: "• Zahlungen, die noch an den alten Schlüssel unterwegs sind, WERDEN gestört — warte zuerst, bis ausstehende Zahlungen abgeschlossen sind." rotate_line5: "• Kontakte, die dein npub direkt gespeichert haben, müssen dich neu finden — teile dein neues npub oder den neu gesicherten username." cancel: "Abbrechen" @@ -531,18 +527,27 @@ goblin: rotation_failed: "Wechsel fehlgeschlagen" close: "Schließen" import_identity_title: "Identität importieren" - import_blurb: "Ersetzt die nostr-Identität dieses Wallets — füge eine reine nsec oder ein exportiertes Identitäts-Backup ein (das Backup stellt auch deinen Benutzernamen und Verlauf wieder her). Sichere zuerst den aktuellen Schlüssel, falls du ihn noch brauchst." - import_nsec_hint: "nsec1… oder Identitäts-Backup-JSON" + import_blurb: "Ersetzt die nostr-Identität dieser Wallet — wähle eine GOBLIN-.backup-Datei oder füge einen nsec ein. Eine Sicherung stellt auch Benutzername und Verlauf wieder her. Sichere zuerst den aktuellen Schlüssel, falls du ihn noch brauchst." + import_nsec_hint: "nsec1… oder eingefügte Sicherung" backup_password_hint: "Backup-Passwort (nur wenn anderswo exportiert)" import_btn: "Importieren" importing: "Importiere…" identity_replaced: "Identität ersetzt" now_using: "Jetzt aktiv: %{npub}" import_failed: "Import fehlgeschlagen" + backup_file: "In Datei sichern" + choose_backup_file: "Eine .backup-Datei wählen" + backup_read_failed: "Datei konnte nicht gelesen werden." + backup_saved: "Sicherung gespeichert" + backup_saved_sub: "Bewahre die .backup-Datei sicher auf — wer sie UND dein Passwort hat, kann deine Identität wiederherstellen." + backup_file_title: "Identität sichern" + backup_file_blurb: "Erstellt eine verschlüsselte .backup-Datei mit Benutzername und Schlüssel. Gib dein Wallet-Passwort ein, um sie zu versiegeln." + backup_write_failed: "Datei konnte nicht gespeichert werden." + create_backup: "Sicherung erstellen" registered: "%{name} registriert" released_msg: "Freigegeben — der Name ist frei" release_confirm: "%{name} freigeben?" - release_blurb: "Er ist frei, sobald er verfügbar wird — jeder kann ihn beanspruchen, auch der nächste Schlüssel, zu dem du wechselst. Dein Profilbild wird damit gelöscht. Du kannst 10 Minuten lang keinen anderen Benutzernamen registrieren." + release_blurb: "Sobald er frei ist, ist er verfügbar — jeder kann ihn beanspruchen, auch dein nächster rotierter Schlüssel. Du kannst 10 Minuten lang keinen anderen Benutzernamen registrieren." releasing: "Gebe frei…" keep_it: "Behalten" release_it: "Freigeben" @@ -583,6 +588,10 @@ goblin: delete: "Wallet löschen" delete_desc: "Diese Wallet dauerhaft von diesem Gerät entfernen. Ohne deinen Seed sind Guthaben nicht wiederherstellbar." delete_confirm: "Zum Löschen erneut tippen" + manage_node: "Node-Verbindung verwalten" + repair_confirm: "Ja, jetzt reparieren" + repair_confirm_note: "Die Reparatur scannt die Chain neu und kann einige Minuten dauern." + restore_confirm_note: "Dies löscht lokale Daten und baut sie aus deinem Seed neu auf — das kann einige Minuten dauern." privacy: title: "Netzwerk-Privatsphäre" intro: "Goblin sendet seinen privaten Datenverkehr durch das Nym mixnet — ein Netzwerk mit fünf Sprüngen, das verbirgt, wer mit wem kommuniziert, sodass ein Relay eine Zahlung nicht zu dir zurückverfolgen kann." @@ -590,8 +599,8 @@ goblin: payments_blurb: "Jede nostr-Nachricht, die einen slatepack trägt." usernames: "usernames" usernames_blurb: "NIP-05-Namensabfragen zu und von goblin.st." - price_avatars: "Kurs & Avatare" - price_avatars_blurb: "Die Kursvorschau und Kontaktbilder." + price_avatars: "Preis" + price_avatars_blurb: "Der Live-Wechselkurs neben den Beträgen." over_mixnet: "Über das mixnet" direct_connection: "Direkte Verbindung" grin_node: "Grin-Node" @@ -740,6 +749,10 @@ goblin: max: "Max" note_label: "Notiz" note_hint: "Notiz hinzufügen…" + add_note: "Notiz hinzufügen" + edit_note: "Notiz bearbeiten" + note_cancel: "Abbrechen" + note_save: "Speichern" review_btn: "Prüfen" confirm_request: "Anfrage bestätigen" review_title: "Prüfen" diff --git a/locales/en.yml b/locales/en.yml index c50fe2d..b29e9e5 100644 --- a/locales/en.yml +++ b/locales/en.yml @@ -406,6 +406,7 @@ goblin: pending: "Pending" confs: "%{c}/%{r} confirmations" waiting_to_confirm: "Waiting to confirm" + paying: "Paying…" you: "You" to: "To" from: "From" @@ -448,16 +449,11 @@ goblin: title: "Settings" connected_nostr: "Connected to nostr" connecting_relays: "Connecting to relays…" - pic_updated: "Profile picture updated" - uploading_pic: "Uploading picture…" - claim_first: "Claim a username first — pictures ride on it" identity: "Identity" copy_npub: "Copy npub (public)" - backup_nsec: "Back up secret key (nsec)" - export_identity: "Export identity backup (encrypted)" rotate_key: "Rotate nostr key" - import_identity: "Import identity (nsec / backup)" - backup_note: "Moving devices? Back up BOTH: your seed phrase (funds) and an identity backup (name + key)." + import_identity: "Import identity (.backup / nsec)" + backup_note: "Moving devices? Back up BOTH: your seed phrase (funds) and your identity .backup file (name + key)." wallet: "Wallet" display_unit: "Display unit" relays: "Relays" @@ -512,7 +508,7 @@ goblin: sp_copy: "Copy slatepack" rotate_line1: "• You get a brand-new RANDOM key; the old npub stops receiving. There is no derivation chain between them." rotate_line2: "• The new key is NOT recoverable from your seed — back up the new nsec right after rotating." - rotate_line3: "• Your username is RELEASED and your profile picture deleted — claim the same or a new name right after (anyone else can grab it too once it's free)." + rotate_line3: "• Your username is RELEASED — claim the same or a new name right after (anyone else can grab it too once it's free)." rotate_line4: "• Payments still in flight to the old key WILL be disrupted — wait for pending payments to finish first." rotate_line5: "• Contacts who saved your npub directly must re-find you — share your new npub or re-claimed username." cancel: "Cancel" @@ -531,18 +527,27 @@ goblin: rotation_failed: "Rotation failed" close: "Close" import_identity_title: "Import identity" - import_blurb: "Replaces this wallet's nostr identity — paste a bare nsec or an exported identity backup (the backup also restores your username and history). Back up the current key first if you still need it." - import_nsec_hint: "nsec1… or identity backup JSON" + import_blurb: "Replaces this wallet's nostr identity — choose a GOBLIN .backup file, or paste a bare nsec. A backup also restores your username and history. Back up the current key first if you still need it." + import_nsec_hint: "nsec1… or pasted backup" backup_password_hint: "Backup password (only if exported elsewhere)" import_btn: "Import" importing: "Importing…" identity_replaced: "Identity replaced" now_using: "Now using: %{npub}" import_failed: "Import failed" + backup_file: "Back up to a file" + choose_backup_file: "Choose a .backup file" + backup_read_failed: "Couldn't read that file." + backup_saved: "Backup saved" + backup_saved_sub: "Keep the .backup file safe — anyone with it AND your password can restore your identity." + backup_file_title: "Back up identity" + backup_file_blurb: "Creates one encrypted .backup file with your username and key. Enter your wallet password to seal it." + backup_write_failed: "Couldn't save the file." + create_backup: "Create backup" registered: "Registered %{name}" released_msg: "Released — the name is up for grabs" release_confirm: "Release %{name}?" - release_blurb: "It's up for grabs the moment it's free — anyone can claim it, including the next key you rotate to. Your profile picture is deleted with it. You won't be able to register another username for 10 minutes." + release_blurb: "It's up for grabs the moment it's free — anyone can claim it, including the next key you rotate to. You won't be able to register another username for 10 minutes." releasing: "Releasing…" keep_it: "Keep it" release_it: "Release it" @@ -583,6 +588,10 @@ goblin: delete: "Delete wallet" delete_desc: "Permanently remove this wallet from this device. Without your seed, funds can't be recovered." delete_confirm: "Tap again to delete" + manage_node: "Manage node connection" + repair_confirm: "Yes, repair now" + repair_confirm_note: "Repair re-scans the chain and can take a few minutes." + restore_confirm_note: "This erases local data and rebuilds it from your seed — it can take several minutes." privacy: title: "Network privacy" intro: "Goblin sends its private traffic through the Nym mixnet — a five-hop network that hides who is talking to whom, so a relay can't link a payment back to you." @@ -590,8 +599,8 @@ goblin: payments_blurb: "Every nostr message carrying a slatepack." usernames: "usernames" usernames_blurb: "NIP-05 name lookups to and from goblin.st." - price_avatars: "Price & avatars" - price_avatars_blurb: "The rate preview and contact pictures." + price_avatars: "Price" + price_avatars_blurb: "The live fiat rate shown next to amounts." over_mixnet: "Over the mixnet" direct_connection: "Direct connection" grin_node: "Grin node" @@ -740,6 +749,10 @@ goblin: max: "Max" note_label: "Note" note_hint: "Add a note…" + add_note: "Add a note" + edit_note: "Edit note" + note_cancel: "Cancel" + note_save: "Save" review_btn: "Review" confirm_request: "Confirm request" review_title: "Review" diff --git a/locales/fr.yml b/locales/fr.yml index d63cbfa..386dec8 100644 --- a/locales/fr.yml +++ b/locales/fr.yml @@ -406,6 +406,7 @@ goblin: pending: "En attente" confs: "%{c}/%{r} confirmations" waiting_to_confirm: "En attente de confirmation" + paying: "Paiement…" you: "Vous" to: "À" from: "De" @@ -448,16 +449,11 @@ goblin: title: "Réglages" connected_nostr: "Connecté à nostr" connecting_relays: "Connexion aux relais…" - pic_updated: "Photo de profil mise à jour" - uploading_pic: "Envoi de la photo…" - claim_first: "Réservez d'abord un nom d'utilisateur — la photo en dépend" identity: "Identité" copy_npub: "Copier le npub (public)" - backup_nsec: "Sauvegarder la clé secrète (nsec)" - export_identity: "Exporter la sauvegarde d'identité (chiffrée)" rotate_key: "Renouveler la clé nostr" - import_identity: "Importer une identité (nsec / sauvegarde)" - backup_note: "Changement d'appareil ? Sauvegardez LES DEUX : votre phrase de récupération (fonds) et une sauvegarde d'identité (nom + clé)." + import_identity: "Importer l'identité (.backup / nsec)" + backup_note: "Changement d'appareil ? Sauvegardez les DEUX : votre phrase seed (fonds) et votre fichier .backup d'identité (nom + clé)." wallet: "Portefeuille" display_unit: "Unité d'affichage" relays: "Relais" @@ -512,7 +508,7 @@ goblin: sp_copy: "Copier le slatepack" rotate_line1: "• Vous obtenez une toute nouvelle clé ALÉATOIRE ; l'ancien npub cesse de recevoir. Il n'y a aucune chaîne de dérivation entre eux." rotate_line2: "• La nouvelle clé n'est PAS récupérable depuis votre phrase de récupération — sauvegardez le nouveau nsec juste après le renouvellement." - rotate_line3: "• Votre username est LIBÉRÉ et votre photo de profil supprimée — réservez le même nom ou un nouveau juste après (n'importe qui peut le prendre une fois libre)." + rotate_line3: "• Votre nom d'utilisateur est LIBÉRÉ — réclamez le même ou un nouveau juste après (une fois libre, n'importe qui peut le prendre)." rotate_line4: "• Les paiements encore en cours vers l'ancienne clé SERONT interrompus — attendez d'abord la fin des paiements en attente." rotate_line5: "• Les contacts qui ont enregistré votre npub directement doivent vous retrouver — partagez votre nouveau npub ou votre username re-réservé." cancel: "Annuler" @@ -531,18 +527,27 @@ goblin: rotation_failed: "Échec du renouvellement" close: "Fermer" import_identity_title: "Importer une identité" - import_blurb: "Remplace l'identité nostr de ce portefeuille — collez un nsec brut ou une sauvegarde d'identité exportée (la sauvegarde restaure aussi votre nom d'utilisateur et votre historique). Sauvegardez d'abord la clé actuelle si vous en avez encore besoin." - import_nsec_hint: "nsec1… ou JSON de sauvegarde d'identité" + import_blurb: "Remplace l'identité nostr de ce portefeuille — choisissez un fichier .backup GOBLIN, ou collez un nsec. Une sauvegarde restaure aussi votre nom d'utilisateur et votre historique. Sauvegardez d'abord la clé actuelle si besoin." + import_nsec_hint: "nsec1… ou sauvegarde collée" backup_password_hint: "Mot de passe de sauvegarde (uniquement si exportée ailleurs)" import_btn: "Importer" importing: "Importation…" identity_replaced: "Identité remplacée" now_using: "Utilise maintenant : %{npub}" import_failed: "Échec de l'importation" + backup_file: "Sauvegarder dans un fichier" + choose_backup_file: "Choisir un fichier .backup" + backup_read_failed: "Impossible de lire ce fichier." + backup_saved: "Sauvegarde enregistrée" + backup_saved_sub: "Conservez le fichier .backup en lieu sûr — quiconque l'a AVEC votre mot de passe peut restaurer votre identité." + backup_file_title: "Sauvegarder l'identité" + backup_file_blurb: "Crée un fichier .backup chiffré avec votre nom d'utilisateur et votre clé. Saisissez le mot de passe du portefeuille pour le sceller." + backup_write_failed: "Impossible d'enregistrer le fichier." + create_backup: "Créer la sauvegarde" registered: "%{name} enregistré" released_msg: "Libéré — le nom est disponible" release_confirm: "Libérer %{name} ?" - release_blurb: "Il est disponible dès qu'il est libre — n'importe qui peut le réserver, y compris la prochaine clé vers laquelle vous renouvelez. Votre photo de profil est supprimée avec lui. Vous ne pourrez pas enregistrer un autre nom d'utilisateur pendant 10 minutes." + release_blurb: "Dès qu'il est libre, il est disponible — n'importe qui peut le réclamer, y compris votre prochaine clé. Vous ne pourrez pas enregistrer un autre nom d'utilisateur pendant 10 minutes." releasing: "Libération…" keep_it: "Le garder" release_it: "Le libérer" @@ -583,6 +588,10 @@ goblin: delete: "Supprimer le portefeuille" delete_desc: "Supprimer définitivement ce portefeuille de cet appareil. Sans votre seed, les fonds sont irrécupérables." delete_confirm: "Touchez à nouveau pour supprimer" + manage_node: "Gérer la connexion au nœud" + repair_confirm: "Oui, réparer maintenant" + repair_confirm_note: "La réparation réanalyse la chaîne et peut prendre quelques minutes." + restore_confirm_note: "Cela efface les données locales et les reconstruit depuis votre seed — cela peut prendre plusieurs minutes." privacy: title: "Confidentialité réseau" intro: "Goblin envoie son trafic privé via le mixnet Nym — un réseau à cinq sauts qui masque qui parle à qui, afin qu'un relais ne puisse pas relier un paiement à vous." @@ -590,8 +599,8 @@ goblin: payments_blurb: "Chaque message nostr transportant un slatepack." usernames: "usernames" usernames_blurb: "Recherches de noms NIP-05 vers et depuis goblin.st." - price_avatars: "Cours et avatars" - price_avatars_blurb: "L'aperçu du cours et les photos de contacts." + price_avatars: "Prix" + price_avatars_blurb: "Le taux en temps réel affiché à côté des montants." over_mixnet: "Via le mixnet" direct_connection: "Connexion directe" grin_node: "Nœud grin" @@ -740,6 +749,10 @@ goblin: max: "Max" note_label: "Note" note_hint: "Ajouter une note…" + add_note: "Ajouter une note" + edit_note: "Modifier la note" + note_cancel: "Annuler" + note_save: "Enregistrer" review_btn: "Vérifier" confirm_request: "Confirmer la demande" review_title: "Vérification" diff --git a/locales/ru.yml b/locales/ru.yml index db67b5d..72d793f 100644 --- a/locales/ru.yml +++ b/locales/ru.yml @@ -406,6 +406,7 @@ goblin: pending: "В ожидании" confs: "%{c}/%{r} подтверждений" waiting_to_confirm: "Ожидание подтверждения" + paying: "Оплата…" you: "Вы" to: "Кому" from: "От" @@ -448,16 +449,11 @@ goblin: title: "Настройки" connected_nostr: "Подключено к nostr" connecting_relays: "Подключение к реле…" - pic_updated: "Фото профиля обновлено" - uploading_pic: "Загрузка фото…" - claim_first: "Сначала займите имя — фото привязывается к нему" identity: "Личность" copy_npub: "Копировать npub (публичный)" - backup_nsec: "Резерв секретного ключа (nsec)" - export_identity: "Экспорт резерва личности (зашифр.)" rotate_key: "Сменить ключ nostr" - import_identity: "Импорт личности (nsec / резерв)" - backup_note: "Меняете устройство? Сохраните ОБА: seed-фразу (средства) и резерв личности (имя + ключ)." + import_identity: "Импорт личности (.backup / nsec)" + backup_note: "Меняете устройство? Сохраните ОБА: seed-фразу (средства) и файл .backup личности (имя + ключ)." wallet: "Кошелёк" display_unit: "Единица отображения" relays: "Реле" @@ -512,7 +508,7 @@ goblin: sp_copy: "Копировать slatepack" rotate_line1: "• Вы получите совершенно новый СЛУЧАЙНЫЙ ключ; старый npub перестанет принимать. Между ними нет цепочки вывода." rotate_line2: "• Новый ключ НЕЛЬЗЯ восстановить из seed — сохраните новый nsec сразу после смены." - rotate_line3: "• Ваш username ОСВОБОЖДАЕТСЯ, а фото профиля удаляется — займите то же или новое имя сразу после (любой другой тоже может его занять, как только оно свободно)." + rotate_line3: "• Ваше имя пользователя ОСВОБОЖДАЕТСЯ — сразу после заявите то же или новое имя (как только свободно, его может занять кто угодно)." rotate_line4: "• Платежи, всё ещё идущие к старому ключу, БУДУТ нарушены — сначала дождитесь завершения ожидающих платежей." rotate_line5: "• Контакты, сохранившие ваш npub напрямую, должны найти вас заново — поделитесь новым npub или заново занятым username." cancel: "Отмена" @@ -531,18 +527,27 @@ goblin: rotation_failed: "Смена не удалась" close: "Закрыть" import_identity_title: "Импорт личности" - import_blurb: "Заменяет nostr-личность этого кошелька — вставьте чистый nsec или экспортированный резерв личности (резерв также восстанавливает имя и историю). Сначала сохраните текущий ключ, если он ещё нужен." - import_nsec_hint: "nsec1… или JSON резерва личности" + import_blurb: "Заменяет nostr-личность этого кошелька — выберите файл GOBLIN .backup или вставьте nsec. Резервная копия также восстанавливает имя и историю. Сначала сохраните текущий ключ, если он ещё нужен." + import_nsec_hint: "nsec1… или вставленная копия" backup_password_hint: "Пароль резерва (только если экспортирован в другом месте)" import_btn: "Импорт" importing: "Импорт…" identity_replaced: "Личность заменена" now_using: "Сейчас используется: %{npub}" import_failed: "Импорт не удался" + backup_file: "Сохранить в файл" + choose_backup_file: "Выбрать файл .backup" + backup_read_failed: "Не удалось прочитать файл." + backup_saved: "Резервная копия сохранена" + backup_saved_sub: "Храните файл .backup в безопасности — любой, у кого есть он И ваш пароль, может восстановить вашу личность." + backup_file_title: "Резервная копия личности" + backup_file_blurb: "Создаёт один зашифрованный файл .backup с именем и ключом. Введите пароль кошелька, чтобы запечатать его." + backup_write_failed: "Не удалось сохранить файл." + create_backup: "Создать копию" registered: "Зарегистрировано %{name}" released_msg: "Освобождено — имя свободно для занятия" release_confirm: "Освободить %{name}?" - release_blurb: "Имя свободно для занятия сразу после освобождения — любой может его занять, включая следующий ключ, на который вы смените. Фото профиля удаляется вместе с ним. Вы не сможете зарегистрировать другое имя в течение 10 минут." + release_blurb: "Как только оно свободно, его можно занять — кто угодно, включая ваш следующий ключ. Вы не сможете зарегистрировать другое имя в течение 10 минут." releasing: "Освобождение…" keep_it: "Оставить" release_it: "Освободить" @@ -583,6 +588,10 @@ goblin: delete: "Удалить кошелёк" delete_desc: "Безвозвратно удалить этот кошелёк с этого устройства. Без seed-фразы средства не восстановить." delete_confirm: "Нажмите ещё раз для удаления" + manage_node: "Управление подключением к узлу" + repair_confirm: "Да, восстановить сейчас" + repair_confirm_note: "Восстановление повторно сканирует цепочку и может занять несколько минут." + restore_confirm_note: "Это стирает локальные данные и восстанавливает их из seed-фразы — может занять несколько минут." privacy: title: "Сетевая приватность" intro: "Goblin отправляет приватный трафик через mixnet Nym — сеть из пяти переходов, скрывающую, кто с кем общается, чтобы реле не могло связать платёж с вами." @@ -590,8 +599,8 @@ goblin: payments_blurb: "Каждое nostr-сообщение, несущее slatepack." usernames: "usernames" usernames_blurb: "Поиск имён NIP-05 к и от goblin.st." - price_avatars: "Курс и аватары" - price_avatars_blurb: "Предпросмотр курса и фото контактов." + price_avatars: "Цена" + price_avatars_blurb: "Текущий курс рядом с суммами." over_mixnet: "Через mixnet" direct_connection: "Прямое соединение" grin_node: "Узел Grin" @@ -740,6 +749,10 @@ goblin: max: "Макс" note_label: "Заметка" note_hint: "Добавить заметку…" + add_note: "Добавить заметку" + edit_note: "Изменить заметку" + note_cancel: "Отмена" + note_save: "Сохранить" review_btn: "Проверить" confirm_request: "Подтвердить запрос" review_title: "Проверка" diff --git a/locales/tr.yml b/locales/tr.yml index f4c462f..53ffc1f 100644 --- a/locales/tr.yml +++ b/locales/tr.yml @@ -406,6 +406,7 @@ goblin: pending: "Beklemede" confs: "%{c}/%{r} onay" waiting_to_confirm: "Onay bekleniyor" + paying: "Ödeniyor…" you: "Sen" to: "Alıcı" from: "Gönderen" @@ -448,16 +449,11 @@ goblin: title: "Ayarlar" connected_nostr: "nostr'a bağlı" connecting_relays: "Relaylara bağlanılıyor…" - pic_updated: "Profil fotoğrafı güncellendi" - uploading_pic: "Fotoğraf yükleniyor…" - claim_first: "Önce bir kullanıcı adı al — fotoğraflar ona bağlıdır" identity: "Kimlik" copy_npub: "npub kopyala (genel)" - backup_nsec: "Gizli anahtarı yedekle (nsec)" - export_identity: "Kimlik yedeğini dışa aktar (şifreli)" rotate_key: "nostr anahtarını değiştir" - import_identity: "Kimlik içe aktar (nsec / yedek)" - backup_note: "Cihaz mı değiştiriyorsun? İKİSİNİ de yedekle: tohum kelimeleri (para) ve kimlik yedeği (ad + anahtar)." + import_identity: "Kimlik içe aktar (.backup / nsec)" + backup_note: "Cihaz mı değiştiriyorsun? İKİSİNİ de yedekle: seed ifaden (bakiye) ve kimlik .backup dosyan (ad + anahtar)." wallet: "Cüzdan" display_unit: "Görüntüleme birimi" relays: "Relaylar" @@ -512,7 +508,7 @@ goblin: sp_copy: "Slatepack kopyala" rotate_line1: "• Tamamen yeni RASTGELE bir anahtar alırsın; eski npub artık almaz. Aralarında türetme zinciri yoktur." rotate_line2: "• Yeni anahtar tohumundan kurtarılamaz — anahtarı değiştirdikten hemen sonra yeni nsec'i yedekle." - rotate_line3: "• username'in BIRAKILIR ve profil fotoğrafın silinir — hemen aynı ya da yeni bir adı al (boştayken başkası da kapabilir)." + rotate_line3: "• Kullanıcı adın SERBEST BIRAKILIR — hemen ardından aynı adı ya da yeni bir ad al (serbest kaldığında başkası da kapabilir)." rotate_line4: "• Eski anahtara hâlâ yoldaki ödemeler KESİNTİYE uğrar — önce bekleyen ödemelerin bitmesini bekle." rotate_line5: "• npub'unu doğrudan kaydeden kişiler seni yeniden bulmalı — yeni npub'unu ya da yeniden aldığın username'i paylaş." cancel: "İptal" @@ -531,18 +527,27 @@ goblin: rotation_failed: "Değiştirme başarısız" close: "Kapat" import_identity_title: "Kimlik içe aktar" - import_blurb: "Bu cüzdanın nostr kimliğini değiştirir — çıplak bir nsec ya da dışa aktarılmış kimlik yedeği yapıştır (yedek ayrıca kullanıcı adını ve geçmişini de geri yükler). Hâlâ gerekiyorsa önce mevcut anahtarı yedekle." - import_nsec_hint: "nsec1… ya da kimlik yedeği JSON" + import_blurb: "Bu cüzdanın nostr kimliğini değiştirir — bir GOBLIN .backup dosyası seç ya da nsec yapıştır. Yedek ayrıca kullanıcı adını ve geçmişini geri yükler. Hâlâ gerekiyorsa önce mevcut anahtarı yedekle." + import_nsec_hint: "nsec1… veya yapıştırılan yedek" backup_password_hint: "Yedek parolası (yalnızca başka yerde dışa aktarıldıysa)" import_btn: "İçe aktar" importing: "İçe aktarılıyor…" identity_replaced: "Kimlik değiştirildi" now_using: "Şu an kullanılan: %{npub}" import_failed: "İçe aktarma başarısız" + backup_file: "Dosyaya yedekle" + choose_backup_file: "Bir .backup dosyası seç" + backup_read_failed: "Dosya okunamadı." + backup_saved: "Yedek kaydedildi" + backup_saved_sub: ".backup dosyasını güvende tut — hem ona hem de parolana sahip olan kimliğini geri yükleyebilir." + backup_file_title: "Kimliği yedekle" + backup_file_blurb: "Kullanıcı adın ve anahtarınla tek bir şifreli .backup dosyası oluşturur. Mühürlemek için cüzdan parolanı gir." + backup_write_failed: "Dosya kaydedilemedi." + create_backup: "Yedek oluştur" registered: "%{name} kaydedildi" released_msg: "Bırakıldı — ad artık alınabilir" release_confirm: "%{name} bırakılsın mı?" - release_blurb: "Boşaldığı an alınabilir — değiştireceğin yeni anahtar dahil herkes kapabilir. Profil fotoğrafın da onunla silinir. 10 dakika boyunca başka kullanıcı adı kaydedemezsin." + release_blurb: "Serbest kalır kalmaz herkes alabilir — döndüğün bir sonraki anahtar dahil. 10 dakika boyunca başka bir kullanıcı adı kaydedemezsin." releasing: "Bırakılıyor…" keep_it: "Vazgeç" release_it: "Bırak" @@ -583,6 +588,10 @@ goblin: delete: "Cüzdanı sil" delete_desc: "Bu cüzdanı bu cihazdan kalıcı olarak kaldır. Tohumun olmadan fonlar kurtarılamaz." delete_confirm: "Silmek için tekrar dokun" + manage_node: "Düğüm bağlantısını yönet" + repair_confirm: "Evet, şimdi onar" + repair_confirm_note: "Onarım zinciri yeniden tarar ve birkaç dakika sürebilir." + restore_confirm_note: "Bu, yerel verileri siler ve seed'inizden yeniden oluşturur — birkaç dakika sürebilir." privacy: title: "Ağ gizliliği" intro: "Goblin özel trafiğini Nym mixnet üzerinden gönderir — kimin kiminle konuştuğunu gizleyen beş atlamalı bir ağ, böylece bir relay bir ödemeyi sana bağlayamaz." @@ -590,8 +599,8 @@ goblin: payments_blurb: "Slatepack taşıyan her nostr mesajı." usernames: "usernamelar" usernames_blurb: "goblin.st'ye ve oradan NIP-05 ad aramaları." - price_avatars: "Fiyat ve avatarlar" - price_avatars_blurb: "Kur önizlemesi ve kişi fotoğrafları." + price_avatars: "Fiyat" + price_avatars_blurb: "Tutarların yanında gösterilen anlık kur." over_mixnet: "Mixnet üzerinden" direct_connection: "Doğrudan bağlantı" grin_node: "Grin düğümü" @@ -740,6 +749,10 @@ goblin: max: "Maks" note_label: "Not" note_hint: "Bir not ekle…" + add_note: "Not ekle" + edit_note: "Notu düzenle" + note_cancel: "İptal" + note_save: "Kaydet" review_btn: "İncele" confirm_request: "İsteği onayla" review_title: "İncele" diff --git a/locales/zh-CN.yml b/locales/zh-CN.yml index 0997be4..11d65d9 100644 --- a/locales/zh-CN.yml +++ b/locales/zh-CN.yml @@ -406,6 +406,7 @@ goblin: pending: "待处理" confs: "%{c}/%{r} 次确认" waiting_to_confirm: "等待确认" + paying: "支付中…" you: "你" to: "收款方" from: "付款方" @@ -448,16 +449,11 @@ goblin: title: "设置" connected_nostr: "已连接 nostr" connecting_relays: "正在连接中继…" - pic_updated: "头像已更新" - uploading_pic: "正在上传头像…" - claim_first: "请先注册用户名 — 头像依附于它" identity: "身份" copy_npub: "复制 npub(公开)" - backup_nsec: "备份私钥(nsec)" - export_identity: "导出身份备份(加密)" rotate_key: "轮换 nostr 密钥" - import_identity: "导入身份(nsec / 备份)" - backup_note: "更换设备?请同时备份:助记词(资金)和身份备份(用户名 + 密钥)。" + import_identity: "导入身份(.backup / nsec)" + backup_note: "更换设备?两者都要备份:你的助记词(资金)和身份 .backup 文件(名称 + 密钥)。" wallet: "钱包" display_unit: "显示单位" relays: "中继" @@ -512,7 +508,7 @@ goblin: sp_copy: "复制 slatepack" rotate_line1: "• 你会得到一个全新的随机密钥;旧 npub 将停止接收。两者之间没有任何派生关系。" rotate_line2: "• 新密钥无法从助记词恢复 — 轮换后请立即备份新的 nsec。" - rotate_line3: "• 你的 username 将被释放,头像也会被删除 — 请立即重新注册同名或新用户名(一旦释放,任何人都可抢注)。" + rotate_line3: "• 你的用户名将被释放——请立即认领相同或新的名称(一旦释放,他人也可抢注)。" rotate_line4: "• 正在发往旧密钥的付款将受影响 — 请先等待待处理付款完成。" rotate_line5: "• 直接保存了你 npub 的联系人需要重新查找你 — 分享你的新 npub 或重新注册的 username。" cancel: "取消" @@ -531,18 +527,27 @@ goblin: rotation_failed: "轮换失败" close: "关闭" import_identity_title: "导入身份" - import_blurb: "将替换此钱包的 nostr 身份 — 粘贴纯 nsec 或导出的身份备份(备份还会恢复你的用户名和历史记录)。若仍需要当前密钥,请先备份。" - import_nsec_hint: "nsec1… 或身份备份 JSON" + import_blurb: "替换此钱包的 nostr 身份——选择一个 GOBLIN .backup 文件,或粘贴 nsec。备份也会恢复你的用户名和历史。如果仍需要当前密钥,请先备份。" + import_nsec_hint: "nsec1… 或粘贴的备份" backup_password_hint: "备份密码(仅当在他处导出时需要)" import_btn: "导入" importing: "正在导入…" identity_replaced: "身份已替换" now_using: "当前使用:%{npub}" import_failed: "导入失败" + backup_file: "备份到文件" + choose_backup_file: "选择 .backup 文件" + backup_read_failed: "无法读取该文件。" + backup_saved: "备份已保存" + backup_saved_sub: "妥善保管 .backup 文件——同时拥有它和你密码的人都能恢复你的身份。" + backup_file_title: "备份身份" + backup_file_blurb: "创建一个包含你的用户名和密钥的加密 .backup 文件。输入钱包密码以封存它。" + backup_write_failed: "无法保存文件。" + create_backup: "创建备份" registered: "已注册 %{name}" released_msg: "已释放 — 用户名可被抢注" release_confirm: "释放 %{name}?" - release_blurb: "释放后立即可被抢注 — 任何人都能注册,包括你下次轮换到的密钥。头像也会一并删除。10 分钟内你无法注册新用户名。" + release_blurb: "一旦释放即可被认领——任何人都可以,包括你接下来轮换到的密钥。10 分钟内你无法注册另一个用户名。" releasing: "正在释放…" keep_it: "保留" release_it: "释放" @@ -583,6 +588,10 @@ goblin: delete: "删除钱包" delete_desc: "从此设备永久移除该钱包。没有助记词,资金将无法找回。" delete_confirm: "再次点击以删除" + manage_node: "管理节点连接" + repair_confirm: "是的,立即修复" + repair_confirm_note: "修复会重新扫描链,可能需要几分钟。" + restore_confirm_note: "这会清除本地数据并从助记词重建——可能需要几分钟。" privacy: title: "网络隐私" intro: "Goblin 通过 Nym mixnet 发送其私密流量 — 这是一个五跳网络,可隐藏通信双方的身份,使中继无法将付款关联到你。" @@ -590,8 +599,8 @@ goblin: payments_blurb: "每条携带 slatepack 的 nostr 消息。" usernames: "用户名" usernames_blurb: "往返 goblin.st 的 NIP-05 名称查询。" - price_avatars: "汇率和头像" - price_avatars_blurb: "汇率预览和联系人头像。" + price_avatars: "价格" + price_avatars_blurb: "金额旁显示的实时法币汇率。" over_mixnet: "经由 mixnet" direct_connection: "直接连接" grin_node: "Grin 节点" @@ -740,6 +749,10 @@ goblin: max: "最大" note_label: "备注" note_hint: "添加备注…" + add_note: "添加备注" + edit_note: "编辑备注" + note_cancel: "取消" + note_save: "保存" review_btn: "查看" confirm_request: "确认请求" review_title: "查看" diff --git a/src/gui/views/goblin/data.rs b/src/gui/views/goblin/data.rs index 454681c..90ca94b 100644 --- a/src/gui/views/goblin/data.rs +++ b/src/gui/views/goblin/data.rs @@ -105,16 +105,25 @@ pub fn receipt_detail(wallet: &Wallet, tx_id: u32) -> Option { } else { Some(tx.data.fee.map(|f| f.fee()).unwrap_or(0)) }; - let confs = if tx.data.confirmed { - None - } else { - match tx.height { - Some(h) if h > 0 && data.info.last_confirmed_height >= h => Some(( - data.info.last_confirmed_height - h + 1, - data.info.minimum_confirmations, - )), - _ => Some((0, data.info.minimum_confirmations)), + // Confirmation progress toward the spendable threshold (min_confirmations). + // grin flips `confirmed` to true at the FIRST on-chain block, but a payment + // isn't spendable until min_confirmations — so keep counting 1/10 … 10/10 + // instead of jumping straight to "complete" at one block (which is why the + // count never appeared to move). + let min_conf = data.info.minimum_confirmations; + let confs = match tx.height { + Some(h) if h > 0 && data.info.last_confirmed_height >= h => { + let count = data.info.last_confirmed_height - h + 1; + if count >= min_conf { + None // matured — fully spendable + } else { + Some((count, min_conf)) + } } + // On-chain but exact height not yet known: at least one block in. + _ if tx.data.confirmed => Some((1.min(min_conf), min_conf)), + // Broadcast but not yet mined. + _ => Some((0, min_conf)), }; let canceled = is_canceled(tx, meta.as_ref()); let has_identity = meta diff --git a/src/gui/views/goblin/mod.rs b/src/gui/views/goblin/mod.rs index 56a5047..354fb47 100644 --- a/src/gui/views/goblin/mod.rs +++ b/src/gui/views/goblin/mod.rs @@ -70,6 +70,8 @@ pub struct GoblinWalletView { rotate: Option, /// Inline nsec-import state for the Me tab. import_nsec: Option, + /// Inline "back up identity to a file" flow state. + backup: Option, /// Amount being entered on the Pay tab. pay_amount: String, /// When set, the over-balance "no" animation is playing: the start time (egui @@ -99,12 +101,6 @@ pub struct GoblinWalletView { avatars: avatars::AvatarTextures, /// Manual slatepack page state (GRIM-native send/receive fallback). slatepack: SlatepackManual, - /// Profile-picture upload in flight. - avatar_busy: bool, - /// Upload worker result: (server hash, processed png) or error. - avatar_slot: std::sync::Arc), String>>>>, - /// Last upload outcome message (cleared on the next attempt). - avatar_msg: Option, /// Receipt "Cancel payment" tap-twice confirm: the tx_id awaiting a second /// confirming tap (cleared when another receipt opens or it's fired). cancel_confirm: Option, @@ -139,6 +135,8 @@ struct AdvancedState { wrong_pass: bool, /// Armed "really restore?" confirm. confirm_restore: bool, + /// Armed "really repair?" confirm (repair takes a few minutes). + confirm_repair: bool, /// Armed "really delete?" confirm. confirm_delete: bool, } @@ -174,6 +172,7 @@ impl Default for GoblinWalletView { claim: None, rotate: None, import_nsec: None, + backup: None, pay_amount: String::new(), pay_shake: None, request_amount: None, @@ -187,9 +186,6 @@ impl Default for GoblinWalletView { receive_copied: None, slatepack: SlatepackManual::default(), avatars: avatars::AvatarTextures::default(), - avatar_busy: false, - avatar_slot: std::sync::Arc::new(std::sync::Mutex::new(None)), - avatar_msg: None, cancel_confirm: None, cancel_msg: None, copy_flash: None, @@ -231,6 +227,8 @@ struct ImportState { backup_password: String, new_npub: String, error: String, + /// A native file pick is in flight (Android returns the path asynchronously). + picking: bool, result: std::sync::Arc>>>, } @@ -243,11 +241,23 @@ impl Default for ImportState { backup_password: String::new(), new_npub: String::new(), error: String::new(), + picking: false, result: std::sync::Arc::new(std::sync::Mutex::new(None)), } } } +/// Inline "back up identity to a file" flow state. +#[derive(Default)] +struct BackupState { + /// Wallet password to unseal the identity for the backup. + password: String, + /// Error to show (wrong password / write failed). + error: Option, + /// The backup file was created. + done: bool, +} + /// Inline username-claim widget state. struct ClaimState { input: String, @@ -1321,6 +1331,23 @@ impl GoblinWalletView { t!("goblin.receipt.funds_returned").to_string() }, ) + } else if let Some((c, r)) = d.confs { + // On-chain but still maturing toward the spendable + // threshold — show the live X/N count (grin marks a + // tx confirmed at one block; spendable takes N). + if c == 0 && !d.incoming && d.npub.is_some() { + // Sent but not yet picked up / mined. + ( + t!("goblin.receipt.pending").to_string(), + t!("goblin.receipt.waiting_to_receive", name => d.title) + .to_string(), + ) + } else { + ( + t!("goblin.receipt.pending").to_string(), + t!("goblin.receipt.confs", c => c, r => r).to_string(), + ) + } } else if d.confirmed { ( t!("goblin.receipt.complete").to_string(), @@ -1333,22 +1360,7 @@ impl GoblinWalletView { } else { ( t!("goblin.receipt.pending").to_string(), - match d.confs { - Some((c, r)) => { - t!("goblin.receipt.confs", c => c, r => r) - .to_string() - } - // An outgoing nostr send with no confirmations - // yet hasn't been received — say so honestly, - // rather than implying it's on-chain. - None if !d.incoming && d.npub.is_some() => { - t!("goblin.receipt.waiting_to_receive", name => d.title) - .to_string() - } - None => { - t!("goblin.receipt.waiting_to_confirm").to_string() - } - }, + t!("goblin.receipt.waiting_to_confirm").to_string(), ) }; w::info_row(ui, &status, &sub); @@ -1368,12 +1380,18 @@ impl GoblinWalletView { &data::short_npub(npub), ); } - let fee = match d.fee { - Some(0) => t!("goblin.receipt.fee_none").to_string(), - Some(f) => format!("{}{}", w::amount_str(f), w::TSU), - None => "—".to_string(), - }; - w::info_row(ui, &t!("goblin.receipt.network_fee"), &fee); + // Only the SENDER pays a network fee, so the row only makes + // sense on outgoing payments. A received payment has no fee + // (data sets it to None) — hide the row entirely instead of + // showing a confusing "—". + if let Some(fee_amount) = d.fee { + let fee = if fee_amount == 0 { + t!("goblin.receipt.fee_none").to_string() + } else { + format!("{}{}", w::amount_str(fee_amount), w::TSU) + }; + w::info_row(ui, &t!("goblin.receipt.network_fee"), &fee); + } w::info_row( ui, &t!("goblin.receipt.privacy"), @@ -1900,17 +1918,36 @@ impl GoblinWalletView { )), |ui| { let already = self.approving.contains(&req.rumor_id); - ui.add_enabled_ui(!already, |ui| { - if approve_button(ui) { - // Guard against double-tap: only enqueue the - // payment once per request id this session. - self.approving.insert(req.rumor_id.clone()); - self.request_error = None; - wallet.task(crate::wallet::types::WalletTask::NostrPayRequest( - req.rumor_id.clone(), - )); + let working = already + && wallet + .nostr_service() + .map(|s| s.send_phase() == crate::nostr::send_phase::WORKING) + .unwrap_or(false); + if already { + // Paying: show a centered spinner so the tap clearly + // registered (the card clears itself once it's sent). + ui.vertical_centered(|ui| { + ui.add_space(6.0); + View::small_loading_spinner(ui); + ui.add_space(2.0); + ui.label( + RichText::new(t!("goblin.receipt.paying")) + .font(FontId::new(12.0, fonts::regular())) + .color(t.text_dim), + ); + }); + if working { + ui.ctx().request_repaint(); } - }); + } else if approve_button(ui) { + // Guard against double-tap: only enqueue the + // payment once per request id this session. + self.approving.insert(req.rumor_id.clone()); + self.request_error = None; + wallet.task(crate::wallet::types::WalletTask::NostrPayRequest( + req.rumor_id.clone(), + )); + } }, ); }); @@ -2092,34 +2129,17 @@ impl GoblinWalletView { ) }); - // Poll a finished avatar upload. - if let Some(res) = self.avatar_slot.lock().unwrap().take() { - self.avatar_busy = false; - match res { - Ok((hash, png)) => { - if let Some(b) = bare_name.as_deref() { - self.avatars.set_own(ui.ctx(), b, &hash, &png); - } - self.avatar_msg = Some(t!("goblin.settings.pic_updated").to_string()); - } - Err(e) => self.avatar_msg = Some(e), - } - } let hue = data::hue_of(&npub_hex); let own_tex = bare_name .as_deref() .and_then(|_| self.handle_tex(ui.ctx(), wallet, &handle)); - let mut pick_picture = false; - let avatar_busy = self.avatar_busy; - let avatar_msg = self.avatar_msg.clone(); w::card(ui, |ui| { ui.set_min_width(ui.available_width()); ui.horizontal(|ui| { - // Avatar is display-only for now: tapping does nothing (no custom - // picture upload). Letter/identicon pucks only. + // Avatar is a generated identicon (gradient + initial) — Goblin has + // no uploaded profile pictures. w::avatar_any(ui, &handle, &npub_hex, 56.0, hue, own_tex.as_ref()); - let _ = (avatar_busy, &mut pick_picture); ui.add_space(14.0); ui.vertical(|ui| { ui.label( @@ -2155,42 +2175,7 @@ impl GoblinWalletView { } }); }); - if avatar_busy { - ui.add_space(6.0); - ui.horizontal(|ui| { - View::small_loading_spinner(ui); - ui.add_space(8.0); - ui.label( - RichText::new(t!("goblin.settings.uploading_pic")) - .font(FontId::new(12.5, fonts::regular())) - .color(t.surface_text_dim), - ); - }); - ui.ctx().request_repaint(); - } else if let Some(msg) = &avatar_msg { - ui.add_space(6.0); - let good = *msg == t!("goblin.settings.pic_updated"); - ui.label( - RichText::new(msg) - .font(FontId::new(12.5, fonts::regular())) - .color(if good { t.pos } else { t.neg }), - ); - } }); - if pick_picture { - match bare_name.clone() { - Some(name) => { - if let Some(path) = cb.pick_image_file() { - self.avatar_busy = true; - self.avatar_msg = None; - start_avatar_upload(self.avatar_slot.clone(), path, name, wallet); - } - } - None => { - self.avatar_msg = Some(t!("goblin.settings.claim_first").to_string()); - } - } - } ui.add_space(16.0); // Mark the scroll boundary: rows clipping under the pinned profile @@ -2222,28 +2207,15 @@ impl GoblinWalletView { cb.vibrate_copy(); self.copy_flash = Some(std::time::Instant::now()); } - // A real backup is the SECRET key (nsec), not the npub. - if settings_row_btn(ui, &t!("goblin.settings.backup_nsec"), COPY) { - if let Some(nsec) = wallet.nostr_service().and_then(|s| s.nsec()) { - cb.copy_string_to_buffer(nsec); - cb.vibrate_copy(); - self.copy_flash = Some(std::time::Instant::now()); - } - } - // Encrypted backup file: the identity JSON as stored - // (NIP-49 ncryptsec inside), incl. username + history. + // One encrypted backup FILE (key + username + history sealed + // together) — replaces the old copy-nsec / copy-JSON split. if settings_row_btn( ui, - &t!("goblin.settings.export_identity"), + &t!("goblin.settings.backup_file"), crate::gui::icons::DOWNLOAD_SIMPLE, - ) { - if let Some(s) = wallet.nostr_service() { - let json = serde_json::to_string_pretty(&*s.identity.read()) - .unwrap_or_default(); - cb.copy_string_to_buffer(json); - cb.vibrate_copy(); - self.copy_flash = Some(std::time::Instant::now()); - } + ) && self.backup.is_none() + { + self.backup = Some(BackupState::default()); } if settings_row_danger( ui, @@ -2291,6 +2263,10 @@ impl GoblinWalletView { .font(FontId::new(12.0, fonts::regular())) .color(t.text_mute), ); + if self.backup.is_some() { + ui.add_space(8.0); + self.backup_ui(ui, wallet, cb); + } if self.rotate.is_some() { ui.add_space(8.0); self.rotate_ui(ui, wallet, cb); @@ -2671,6 +2647,7 @@ impl GoblinWalletView { let repairing = wallet.is_repairing(); let progress = wallet.repairing_progress(); let mut leave = false; + let mut open_node = false; { let adv = &mut self.advanced; ScrollArea::vertical() @@ -2684,8 +2661,8 @@ impl GoblinWalletView { ); ui.add_space(16.0); - // Run your own node (the internal node). External nodes stay in - // Settings; this advanced toggle starts a full local node instead. + // Run your own node (the internal node). Opens the node-connection + // page, where you pick the integrated node or an external one. w::card(ui, |ui| { ui.set_min_width(ui.available_width()); advanced_head(ui, &t!("goblin.node.integrated"), t.surface_text); @@ -2701,9 +2678,10 @@ impl GoblinWalletView { .font(FontId::new(13.0, fonts::medium())) .color(t.pos), ); - } else if w::big_action_on_card(ui, &t!("goblin.node.integrated")).clicked() - { - wallet.update_connection(&ConnectionMethod::Integrated); + ui.add_space(10.0); + } + if w::big_action_on_card(ui, &t!("goblin.advanced.manage_node")).clicked() { + open_node = true; } }); ui.add_space(12.0); @@ -2728,9 +2706,28 @@ impl GoblinWalletView { .font(FontId::new(13.0, fonts::medium())) .color(t.neg), ); + } else if adv.confirm_repair { + // Repair re-scans the chain — it can take a few minutes. + // Warn + confirm in the accent (yellow) before starting. + ui.label( + RichText::new(t!("goblin.advanced.repair_confirm_note")) + .font(FontId::new(13.0, fonts::regular())) + .color(t.text_dim), + ); + ui.add_space(10.0); + if w::big_action_on_card_ink( + ui, + &t!("goblin.advanced.repair_confirm"), + t.accent, + ) + .clicked() + { + adv.confirm_repair = false; + wallet.repair(); + } } else if w::big_action_on_card(ui, &t!("goblin.advanced.repair")).clicked() { - wallet.repair(); + adv.confirm_repair = true; } }); ui.add_space(12.0); @@ -2742,6 +2739,12 @@ impl GoblinWalletView { advanced_desc(ui, &t!("goblin.advanced.restore_desc")); ui.add_space(10.0); if adv.confirm_restore { + ui.label( + RichText::new(t!("goblin.advanced.restore_confirm_note")) + .font(FontId::new(13.0, fonts::regular())) + .color(t.text_dim), + ); + ui.add_space(10.0); if w::big_action_on_card_ink( ui, &t!("goblin.advanced.restore_confirm"), @@ -2852,6 +2855,11 @@ impl GoblinWalletView { self.advanced = AdvancedState::default(); self.settings_page = SettingsPage::Main; } + if open_node { + self.node_url_input.clear(); + self.node_secret_input.clear(); + self.settings_page = SettingsPage::Node; + } } fn node_settings_ui(&mut self, ui: &mut egui::Ui, wallet: &Wallet, cb: &dyn PlatformCallbacks) { @@ -2869,6 +2877,30 @@ impl GoblinWalletView { let live = wallet.get_current_connection(); let saved = wallet.get_config().connection(); settings_group(ui, &t!("goblin.node.connection"), |ui| { + // Integrated node (run your own) sits at the top of the picker. + { + let active = matches!(&saved, ConnectionMethod::Integrated); + let row = ui.horizontal(|ui| { + ui.label( + RichText::new(t!("goblin.node.integrated")) + .font(FontId::new(15.0, fonts::medium())) + .color(t.surface_text), + ); + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + if active { + ui.label( + RichText::new(crate::gui::icons::CHECK) + .font(FontId::new(16.0, fonts::regular())) + .color(t.pos), + ); + } + }); + }); + ui.add_space(10.0); + if !active && row.response.interact(Sense::click()).clicked() { + wallet.update_connection(&ConnectionMethod::Integrated); + } + } for conn in ConnectionsConfig::ext_conn_list() { let active = matches!(&saved, ConnectionMethod::External(id, _) if *id == conn.id); @@ -3526,6 +3558,117 @@ impl GoblinWalletView { } /// Inline nsec-import flow: replaces the identity with an imported key. + /// 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) { + let t = theme::tokens(); + let bk = self.backup.as_mut().unwrap(); + let mut close = false; + w::card(ui, |ui| { + ui.set_min_width(ui.available_width()); + if bk.done { + ui.label( + RichText::new(t!("goblin.settings.backup_saved")) + .font(FontId::new(15.0, fonts::semibold())) + .color(t.pos), + ); + ui.add_space(4.0); + ui.label( + RichText::new(t!("goblin.settings.backup_saved_sub")) + .font(FontId::new(13.0, fonts::regular())) + .color(t.surface_text_dim), + ); + ui.add_space(10.0); + if w::big_action(ui, &t!("goblin.settings.done"), false).clicked() { + close = true; + } + return; + } + ui.label( + RichText::new(t!("goblin.settings.backup_file_title")) + .font(FontId::new(15.0, fonts::semibold())) + .color(t.surface_text), + ); + ui.add_space(6.0); + ui.label( + RichText::new(t!("goblin.settings.backup_file_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("backup_pass")) + .focus(false) + .hint_text(t!("goblin.settings.wallet_password")) + .password() + .text_color(t.surface_text) + .body() + .ui(ui, &mut bk.password, cb); + }); + if let Some(err) = &bk.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 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(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(half, 44.0), + )), + |ui| { + ui.add_enabled_ui(!bk.password.is_empty(), |ui| { + if w::big_action(ui, &t!("goblin.settings.create_backup"), false) + .clicked() + { + match wallet.create_nostr_backup(&bk.password) { + 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()) { + Ok(()) => { + bk.done = true; + bk.error = None; + bk.password.clear(); + } + Err(_) => { + bk.error = Some( + t!("goblin.settings.backup_write_failed") + .to_string(), + ); + } + } + } + Err(e) => bk.error = Some(e), + } + } + }); + }, + ); + }); + }); + if close { + self.backup = None; + } + } + fn import_nsec_ui(&mut self, ui: &mut egui::Ui, wallet: &Wallet, cb: &dyn PlatformCallbacks) { let t = theme::tokens(); let import = self.import_nsec.as_mut().unwrap(); @@ -3560,6 +3703,52 @@ impl GoblinWalletView { .color(t.surface_text_dim), ); ui.add_space(10.0); + // Native ".backup file" picker. Desktop returns the path now; + // Android returns it asynchronously (poll picked_file()). + if import.picking { + if let Some(path) = cb.picked_file() { + import.picking = false; + if !path.is_empty() { + match std::fs::read_to_string(&path) { + Ok(contents) => import.nsec = contents.trim().to_string(), + Err(_) => { + import.error = + t!("goblin.settings.backup_read_failed").to_string(); + } + } + } + } else { + ui.ctx().request_repaint(); + } + } + if w::big_action_on_card(ui, &t!("goblin.settings.choose_backup_file")) + .clicked() + { + import.error.clear(); + match cb.pick_file() { + Some(path) if !path.is_empty() => { + match std::fs::read_to_string(&path) { + Ok(contents) => import.nsec = contents.trim().to_string(), + Err(_) => { + import.error = + t!("goblin.settings.backup_read_failed").to_string(); + } + } + } + // Empty string = Android async pick in flight. + Some(_) => import.picking = true, + None => {} + } + } + if !import.error.is_empty() && import.stage == 1 { + ui.add_space(6.0); + ui.label( + RichText::new(&import.error) + .font(FontId::new(12.5, fonts::regular())) + .color(t.neg), + ); + } + ui.add_space(8.0); w::field_well(ui, |ui| { TextEdit::new(egui::Id::from("import_nsec")) .focus(false) @@ -3727,6 +3916,10 @@ impl GoblinWalletView { } s.save_identity(); } + // Publish kind 0 NOW so others can resolve our @name without + // waiting for the next app start — otherwise a just-claimed + // name is invisible over the relays (no kind-0 event exists). + wallet.task(crate::wallet::types::WalletTask::NostrRepublishProfile); } ClaimMsg::Released => { claim.message = Some(t!("goblin.settings.released_msg").to_string()); @@ -4007,37 +4200,6 @@ fn start_release(claim: &mut ClaimState, name: &str, wallet: &Wallet) { } /// Process a picked picture and upload it as the avatar for an owned name. -fn start_avatar_upload( - slot: std::sync::Arc), String>>>>, - path: String, - name: String, - wallet: &Wallet, -) { - let Some(service) = wallet.nostr_service() else { - return; - }; - let server = service.config.read().nip05_server(); - // Reuse the service's keys directly — never round-trip the secret through a - // plaintext nsec String to rebuild keys the service already holds. - let keys = service.keys(); - std::thread::spawn(move || { - let res = (|| { - let png = crate::nostr::avatar::process_avatar_file(&path)?; - let rt = tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - .map_err(|e| e.to_string())?; - let hash = rt.block_on(crate::nostr::nip05::upload_avatar( - &server, - &name, - &keys, - png.clone(), - ))?; - Ok((hash, png)) - })(); - *slot.lock().unwrap() = Some(res); - }); -} /// Draw the small Goblin mascot mark. pub fn widgets_logo(ui: &mut egui::Ui) { diff --git a/src/gui/views/goblin/send.rs b/src/gui/views/goblin/send.rs index a82fa93..a2e1ea5 100644 --- a/src/gui/views/goblin/send.rs +++ b/src/gui/views/goblin/send.rs @@ -141,6 +141,10 @@ 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. + note_draft: String, } impl Default for SendFlow { @@ -166,6 +170,8 @@ impl Default for SendFlow { request: false, receipt_npub: None, fee_requested_for: None, + note_editing: false, + note_draft: String::new(), } } } @@ -944,31 +950,6 @@ impl SendFlow { } ui.add_space(16.0); - // Quick chips. - ui.horizontal(|ui| { - ui.add_space((ui.available_width() - 220.0).max(0.0) / 2.0); - for v in ["1", "10", "100", "Max"] { - let label = if v == "Max" { - t!("goblin.send.max") - } else { - std::borrow::Cow::Borrowed(v) - }; - if w::chip_outline(ui, &label).clicked() { - if v == "Max" { - let max = wallet - .get_data() - .map(|d| d.info.amount_currently_spendable) - .unwrap_or(0); - self.amount = w::amount_str(max); - } else { - self.amount = v.to_string(); - } - } - ui.add_space(8.0); - } - }); - ui.add_space(16.0); - let note_id = egui::Id::from("send_note"); // Numpad / typed amount FIRST, then the note BELOW it. On mobile the soft // keyboard for the note covers the bottom of the screen — keeping the pad @@ -988,23 +969,78 @@ impl SendFlow { } ui.add_space(12.0); - // Note field (under the pad). - w::card(ui, |ui| { - ui.horizontal(|ui| { - ui.label( - RichText::new(t!("goblin.send.note_label")) - .font(FontId::new(14.0, fonts::regular())) - .color(t.surface_text_dim), - ); - ui.add_space(8.0); - TextEdit::new(note_id) - .focus(false) - .hint_text(t!("goblin.send.note_hint")) - .text_color(t.surface_text) - .body() - .ui(ui, &mut self.note, cb); + // 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() { + if w::big_action(ui, &t!("goblin.send.add_note"), true).clicked() { + self.note_draft = self.note.clone(); + self.note_editing = true; + } + } else { + // Show the saved note, with an Edit button to re-open the editor. + w::card(ui, |ui| { + ui.set_min_width(ui.available_width()); + ui.label( + RichText::new(format!("\u{201C}{}\u{201D}", self.note.trim())) + .font(FontId::new(14.0, fonts::regular())) + .color(t.surface_text), + ); + }); + 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; + } + } ui.add_space(8.0); let valid = amount_from_hr_string(&self.amount)