From 1f8071e5d86104f5ad86d7d8f94f32daeda7fc76 Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Tue, 18 Aug 2026 08:27:27 -0700 Subject: A missing signer opens a door instead of a toast: the reconnect screen offers the extension, the signer-app link, or a pasted key — same account enforced — and the silent reattach closes it on its own when it lands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01JtaatFfAbx91Nh3xqKVqKz --- src/app.js | 1 + src/features/hats.js | 12 ++++++++++-- src/features/messages.js | 7 ++++++- src/features/nostrlogin.js | 42 ++++++++++++++++++++++++++++++++++++++++++ src/i18n.js | 2 ++ 5 files changed, 61 insertions(+), 3 deletions(-) diff --git a/src/app.js b/src/app.js index fd536e1..9c793ab 100644 --- a/src/app.js +++ b/src/app.js @@ -2268,6 +2268,7 @@ function goHome() { ui.arkExitPage = null; ui.settingsPage = null; ui.logoutConfirm = null; + ui.nostrReconnect = null; ui.addrScan = false; ui.arkMoveOpen = false; ui.nameEditOpen = null; diff --git a/src/features/hats.js b/src/features/hats.js index 4f6e977..6516459 100644 --- a/src/features/hats.js +++ b/src/features/hats.js @@ -230,7 +230,11 @@ export function hatsFeature(ctx) { render(); try { const id = await identity(); - if (!id) throw new Error(t('msgNoIdentity')); + if (!id) { + if (!hook('nostrReconnectPrompt')) toast(t('msgNoIdentity')); + ui.hatBuying = null; ui.hatConfirm = null; render(); + return; + } const inv = await post('/hats/invoice', { hat: hat.id, net: hatNet() }, id.signer); await hook('arkPayInvoice', inv.invoice, { maxAmountSat: inv.sat }); const rec = await post('/hats/claim', { paymentHash: inv.paymentHash, hat: hat.id, net: hatNet() }, id.signer); @@ -252,7 +256,11 @@ export function hatsFeature(ctx) { render(); try { const id = await identity(); - if (!id) throw new Error(t('msgNoIdentity')); + if (!id) { + if (!hook('nostrReconnectPrompt')) toast(t('msgNoIdentity')); + ui.hatBuying = null; render(); + return; + } const rec = await post('/hats/equip', { hat: hatId, net: hatNet() }, id.signer); ui.hatShopData = { ...ui.hatShopData, owned: rec.owned, equipped: rec.equipped }; worn.set(hatNet() + ':' + id.pubkey, { hat: rec.equipped, t: Date.now() }); diff --git a/src/features/messages.js b/src/features/messages.js index 4202a75..02affc9 100644 --- a/src/features/messages.js +++ b/src/features/messages.js @@ -155,7 +155,12 @@ export function messagesFeature(ctx) { const isMe = (pk) => myPubkeys().includes(pk); // "No identity" while soft-locked means the keys left with the lock — say // that, not "signer disconnected" (which reads as a nostr-login problem). - const noIdToast = () => toast(t(wallet.watchOnly ? 'msgLockedChat' : 'msgNoIdentity')); + const noIdToast = () => { + // A missing signer gets the reconnect screen, not a dead-end toast — + // logging out and back in was the workaround nobody should need. + if (!wallet.watchOnly && hook('nostrReconnectPrompt')) return; + toast(t(wallet.watchOnly ? 'msgLockedChat' : 'msgNoIdentity')); + }; // ---- shared runtime ----------------------------------------------------- diff --git a/src/features/nostrlogin.js b/src/features/nostrlogin.js index 6c06fc8..5195778 100644 --- a/src/features/nostrlogin.js +++ b/src/features/nostrlogin.js @@ -389,9 +389,51 @@ export function nostrLoginFeature(ctx) { linkSection()); } + // The reconnect takeover: opened by any feature whose action just found + // the signer missing (a reload dropped it; Amethyst and friends can't + // reattach silently). Same guarded buttons as the settings card — an + // extension, the signer-app deeplink/QR, or a pasted key — and the run + // refuses a different account. + function reconnectScreen() { + const st = load(); + const run = (makeSigner) => reconnectSigner(makeSigner).then(() => { + if (live) { ui.nostrReconnect = null; render(); } + }); + return h('div', { class: 'col', style: 'gap:16px' }, + ctx.brandHeader(false), + h('div', { class: 'card col', style: 'gap:12px' }, + h('h3', { style: 'margin:0' }, t('nlReconnectTitle')), + h('p', { class: 'small muted', style: 'margin:0' }, t('nlReconnectBody', { npub: (npubOf(st.pubkey) || '').slice(0, 12) })), + signerButtons(run), + ui.nostrLoginError ? h('div', { class: 'notice err' }, ui.nostrLoginError) : null), + h('button', { class: 'btn-ghost btn-block', onClick: () => { + stopNostrConnect(); + ui.nostrReconnect = null; ui.nostrConnectOpen = false; ui.nostrLoginError = ''; + render(); + } }, t('back'))); + } + return { id: 'nostrlogin', stop() { stopNostrConnect(); }, + screenView() { + if (ui.screen !== 'wallet' || !ui.nostrReconnect) return null; + return reconnectScreen(); + }, + // An action needed the login signer and it isn't there: open the + // reconnect screen (and try the silent extension reattach in the + // background — it closes the screen by itself when it lands). + nostrReconnectPrompt() { + const st = load(); + if (!st.pubkey || live) return false; + ui.nostrReconnect = true; + ui.nostrLoginError = ''; + this.nostrLoginResume().then((sg) => { + if (sg && ui.nostrReconnect) { ui.nostrReconnect = null; render(); } + }).catch(() => {}); + render(); + return true; + }, init() { // A live signer is bound to the wallet that linked it. Switching to an // account that never did must not inherit the login identity — it used diff --git a/src/i18n.js b/src/i18n.js index 14e6d14..5f8f478 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -649,6 +649,8 @@ const STR = { 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.", + nlReconnectTitle: 'Reconnect your signer', + nlReconnectBody: 'Your Nostr signer isn’t connected on this device. Reconnect to keep acting as {npub}…', 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.', -- cgit v1.2.3