From 7b7e93a6086171fec8a710063c86dbe5d4b9841f Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Mon, 3 Aug 2026 22:52:54 -0700 Subject: Give the nostr settings page a way to reconnect MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The app could tell you your signer wasn't connected and then send you somewhere with nothing to click: once an account was linked, the nostr card showed only an npub. It now shows connected/not connected, says what's missing, and offers the same signer options as signing in — refusing a different account rather than quietly swapping identities, since this is a reconnect and not a login. Pasted keys go back to not being persisted at all. Storing one would have survived a reload, but the UI promises a pasted key is used once and never kept, and that promise is worth more than saving a paste; those logins reconnect from this card like any other. The chat banner now tracks the live signer alone. The flag that suppressed it after a reconnect would also have hidden a later genuine disconnect. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Wvj4aoeMZwA7VUGdJP9Swi --- src/features/messages.js | 4 ++-- src/features/nostrlogin.js | 54 +++++++++++++++++++++++++++++++--------------- src/i18n.js | 5 +++++ 3 files changed, 44 insertions(+), 19 deletions(-) (limited to 'src') diff --git a/src/features/messages.js b/src/features/messages.js index 7f21d53..e9d1799 100644 --- a/src/features/messages.js +++ b/src/features/messages.js @@ -1232,7 +1232,7 @@ export function messagesFeature(ctx) { // missing — so this offers to fetch it back rather than to log in again. function signerNotice() { const id = hook('nostrLoginIdentity'); - if (!id || id.signer || ui.msgReconnected) return null; + if (!id || id.signer) return null; return h('div', { class: 'row gap6 chat-signer-off', style: 'align-items:center' }, h('span', { class: 'small muted grow' }, t('msgSignerOff')), h('button', { @@ -1241,7 +1241,7 @@ export function messagesFeature(ctx) { ui.msgReconnecting = true; render(); const s = await hook('nostrLoginResume'); ui.msgReconnecting = false; - if (s) { ui.msgReconnected = true; toast(t('msgSignerBack')); } + if (s) toast(t('msgSignerBack')); else { ui.chatOpen = false; ui.screen = 'wallet'; ui.tab = 'settings'; ui.settingsPage = 'nostr'; } render(); }, diff --git a/src/features/nostrlogin.js b/src/features/nostrlogin.js index 95ab3fe..611a707 100644 --- a/src/features/nostrlogin.js +++ b/src/features/nostrlogin.js @@ -12,7 +12,6 @@ import { newMnemonic } from '../wallet.js'; import { npubOf } from '../nostr.js'; import { t } from '../i18n.js'; import { qrSvg } from '../qr.js'; -import { hex } from '@scure/base'; import { extensionSigner, bunkerSigner, keySigner, parseNostrSecret, walletForSigner, publishWalletBackup, nostrConnect, resumeBunker } from '../nostr-login.js'; @@ -61,16 +60,15 @@ export function nostrLoginFeature(ctx) { // own key — a different pubkey, which is exactly the mismatch people notice // when their chat messages come out under a stranger's name. // - // A remote signer stores its NIP-46 client key, which the signer authorised - // for this app and which cannot sign anything on its own. A pasted key - // stores the key itself: it is already the seed of everything here (the - // wallet's mnemonic derives from it), so this is the same secret the app is - // holding either way, not a new one. + // A remote signer stores its NIP-46 client key: the signer authorised that + // key for this app, and it cannot sign anything on its own. + // + // A pasted key stores NOTHING. The UI promises it is used once and never + // kept, and that promise is worth more than saving someone a paste after a + // reload — they reconnect from the nostr settings card instead. const sessionOf = (signer) => { if (!signer) return {}; - if (signer.session) return { session: signer.session, secret: null }; - if (signer.secret instanceof Uint8Array) return { session: null, secret: hex.encode(signer.secret) }; - return { session: null, secret: null }; + return { session: signer.session || null }; }; const busy = (v) => { ui.nostrLoginBusy = v; render(); }; @@ -261,14 +259,42 @@ export function nostrLoginFeature(ctx) { signerButtons(loginWith)); } + // Re-attach a signer for the account already linked here. Deliberately not + // linkOpenWallet: this must not become a way to quietly swap identities, so + // a different account is refused rather than linked. + async function reconnectSigner(makeSigner) { + ui.nostrLoginError = ''; + busy(true); + try { + const st = load(); + const signer = await makeSigner(); + if (signer.pubkey !== st.pubkey) { + if (signer.close) signer.close(); + throw new Error(t('nlWrongAccount', { npub: (npubOf(st.pubkey) || '').slice(0, 16) + '…' })); + } + live = selfHealing(signer); + save({ ...load(), ...sessionOf(signer) }); + toast(t('nlReconnected')); + busy(false); + } catch (e) { fail(e); } + } + function settingsCard() { if (wallet.watchOnly || !wallet.mnemonic) return null; const st = load(); if (st.pubkey) { return h('div', { class: 'card col' }, - h('h3', {}, t('nlLinkTitle')), + h('div', { class: 'row between' }, + h('h3', {}, t('nlLinkTitle')), + h('span', { class: 'badge dot' + (live ? ' live' : ' off') }, live ? t('nlConnected') : t('nlDisconnected'))), h('div', { class: 'small muted break' }, npubOf(st.pubkey) || st.pubkey), - h('p', { class: 'small faint', style: 'margin:0' }, t('nlLinkedDesc'))); + h('p', { class: 'small faint', style: 'margin:0' }, t('nlLinkedDesc')), + // Signing needs a live signer, and a reload always drops a remote one. + // Without this the app can tell you it isn't connected and offer you + // nowhere to fix it. + live ? null : h('div', { class: 'col', style: 'gap:8px' }, + h('p', { class: 'small muted', style: 'margin:0' }, t('nlReconnectDesc')), + signerButtons(reconnectSigner))); } return h('div', { class: 'card col' }, h('h3', {}, t('nlLinkTitle')), @@ -311,12 +337,6 @@ export function nostrLoginFeature(ctx) { if (s.pubkey === st.pubkey) return (live = s); } catch {} } - if (st.secret) { - try { - const s = keySigner(hex.decode(st.secret)); - if (s.pubkey === st.pubkey) return (live = s); - } catch {} - } if (st.session) { try { const s = await resumeBunker(st.session, { onAuth: (url) => { ui.nostrLoginAuthUrl = url; render(); } }); diff --git a/src/i18n.js b/src/i18n.js index 7c55c65..82a48b9 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -575,6 +575,11 @@ const STR = { nlLinkTitle: 'Nostr account', nlLinkDesc: 'Link this wallet to your Nostr account so you can open it on other devices by logging in with Nostr.', nlLinkedDesc: 'This wallet opens when you log in with this Nostr account.', + nlConnected: 'connected', + nlDisconnected: 'not connected', + nlReconnectDesc: "Your signer isn't connected, so this app can't sign as you. Reconnect it to post and update your profile — your wallet and balance aren't affected.", + nlReconnected: 'Signer reconnected', + nlWrongAccount: 'That signer is a different Nostr account. Reconnect with {npub}.', nlLinked: 'Wallet linked to your Nostr account', nlNeedSeed: 'This wallet has no recovery phrase loaded.', nlAlreadyLinked: 'That Nostr account already has a different wallet.', -- cgit v1.2.3