diff --git a/components/nostr-auth.js b/components/nostr-auth.js index f705608f9..1aa5d1df2 100644 --- a/components/nostr-auth.js +++ b/components/nostr-auth.js @@ -35,12 +35,10 @@ function NostrError ({ message }) { } export function useNostrAuthState ({ - showModalStatus = false, challengeTitle = 'Waiting for confirmation', challengeMessage = 'Please confirm this action on your remote signer', challengeButtonLabel = 'open signer' } = {}) { - const showModal = useShowModal() const toaster = useToast() const [status, setStatus] = useState({ @@ -87,16 +85,30 @@ export function useNostrAuthState ({ } }, []) + return { status, setStatus, setError, challengeResolver } +} + +export function useNostrAuthStateModal ({ + ...args +}) { + const showModal = useShowModal() + + const { status, setStatus, setError, challengeResolver } = useNostrAuthState(args) + const closeModalRef = useRef(null) + useEffect(() => { - if (!showModalStatus || !status?.loading) return - showModal(onClose => { - return ( - <> -

{status.title}

- - - ) - }) + closeModalRef?.current?.() + if (status.loading) { + showModal(onClose => { + closeModalRef.current = onClose + return ( + <> +

{status.title}

+ + + ) + }) + } }, [status]) return { status, setStatus, setError, challengeResolver } diff --git a/components/use-crossposter.js b/components/use-crossposter.js index af312ba62..9b75fedc2 100644 --- a/components/use-crossposter.js +++ b/components/use-crossposter.js @@ -8,7 +8,7 @@ import { ITEM_FULL_FIELDS, POLL_FIELDS } from '@/fragments/items' import { useMe } from '@/components/me' import { useEncryptedPrivates } from '@/components/use-encrypted-privates' import { NDKNip46Signer } from '@nostr-dev-kit/ndk' -import { useNostrAuthState } from '@/components/nostr-auth' +import { useNostrAuthStateModal } from '@/components/nostr-auth' function itemToContent (item, { includeTitle = true } = {}) { let content = includeTitle ? item.title : '' @@ -93,8 +93,7 @@ export default function useCrossposter () { const { me } = useMe() const { encryptedPrivates } = useEncryptedPrivates({ me }) - const { challengeResolver } = useNostrAuthState({ - showModalStatus: true, + const { challengeResolver: nostrAuthChallengeResolver, setStatus: nostrAuthSetStatus } = useNostrAuthStateModal({ challengeTitle: 'Crossposting to Nostr' }) @@ -219,9 +218,10 @@ export default function useCrossposter () { nip07: true }) if (signer instanceof NDKNip46Signer) { - signer.once('authUrl', challengeResolver) + signer.once('authUrl', nostrAuthChallengeResolver) } await signer.blockUntilReady() + nostrAuthSetStatus({ success: true }) do { try { diff --git a/pages/settings/index.js b/pages/settings/index.js index cc51825eb..d20cf8fa6 100644 --- a/pages/settings/index.js +++ b/pages/settings/index.js @@ -16,13 +16,13 @@ import Info from '@/components/info' import Link from 'next/link' import AccordianItem from '@/components/accordian-item' import { bech32 } from 'bech32' -import { NOSTR_MAX_RELAY_NUM, NOSTR_PUBKEY_BECH32, DEFAULT_CROSSPOSTING_RELAYS } from '@/lib/nostr' +import Nostr, { NOSTR_MAX_RELAY_NUM, NOSTR_PUBKEY_BECH32, DEFAULT_CROSSPOSTING_RELAYS } from '@/lib/nostr' import { emailSchema, lastAuthRemovalSchema, settingsSchema } from '@/lib/validate' import { SUPPORTED_CURRENCIES } from '@/lib/currency' import PageLoading from '@/components/page-loading' import { useShowModal } from '@/components/modal' import { authErrorMessage } from '@/components/login' -import { NostrAuth } from '@/components/nostr-auth' +import { NostrAuth, useNostrAuthStateModal } from '@/components/nostr-auth' import { useToast } from '@/components/toast' import { useServiceWorkerLogger } from '@/components/logger' import { useMe } from '@/components/me' @@ -34,6 +34,8 @@ import { AuthBanner } from '@/components/banners' import { useEncryptedPrivates } from '@/components/use-encrypted-privates' import { generateSecretKey } from 'nostr-tools' import { bytesToHex } from '@noble/hashes/utils' +import { NDKNip46Signer } from '@nostr-dev-kit/ndk' +import { callWithTimeout } from '@/lib/time' export const getServerSideProps = getGetServerSideProps({ query: SETTINGS, authRequired: true }) @@ -93,7 +95,7 @@ export function SettingsHeader () { export default function Settings ({ ssrData }) { const toaster = useToast() const { me } = useMe() - const { encryptedPrivates, setEncryptedSettings } = useEncryptedPrivates({ me }) + const { encryptedPrivates, setEncryptedSettings, refreshEncryptedPrivates } = useEncryptedPrivates({ me }) const [setSettings] = useMutation(SET_SETTINGS, { update (cache, { data: { setSettings } }) { @@ -112,6 +114,10 @@ export default function Settings ({ ssrData }) { const { data } = useQuery(SETTINGS) const [settings, setSettingsState] = useState(() => (data ?? ssrData)?.settings?.privates) + const { challengeResolver: nostrAuthChallengeResolver, setStatus: nostrAuthSetStatus } = useNostrAuthStateModal({ + challengeTitle: 'Configuring crosspost to Nostr' + }) + useEffect(() => { let settings = (data ?? ssrData)?.settings?.privates if (!settings) return @@ -221,8 +227,34 @@ export default function Settings ({ ssrData }) { // this is used to identify the app instance by nip46 and permit // token reuse signerInstanceKey = bytesToHex(generateSecretKey()) + + // we create the initial connection for the nip46 signer here + // because it makes for better ux (no confirmation delay on crosspost) + // and because we can test immediately if the connection works + if (signerType === 'nip46' && signer) { + const nostr = new Nostr() + try { + await callWithTimeout(async () => { + const testSignerInstance = nostr.getSigner({ + userPreferences: { signer, signerType, signerInstanceKey } + }) + if (testSignerInstance instanceof NDKNip46Signer) { + testSignerInstance.once('authUrl', nostrAuthChallengeResolver) + } + await testSignerInstance.blockUntilReady() + nostrAuthSetStatus({ success: true }) + }, 60_000 * 5) // after some time we give up, likely the signer is not responding at this point + } catch (err) { + toaster.danger('invalid nostr signer: ' + err.message) + throw err + } finally { + nostr.close() + } + } } + await setEncryptedSettings({ signer, signerType, signerInstanceKey }) + await refreshEncryptedPrivates() toaster.success('saved settings') } catch (err) {