Refactor default payment method setting (#803)

* Refactor setting of default providers

* fixed warning about component update while rendering another component
* individual providers no longer need to know if they are the default or not
* default setting is now handled by WebLNContext -- the same context that returns the provider. this makes a lot more sense and is a lot easier to read
* default payment checkbox is now also disabled if there is only one enabled provider or if it is the default provider

* Fix order lost on page reload

On page reload, the providers were synced in the order they were loaded.

This means that the default payment provider setting was lost.

Fixed this by syncing order to local storage and on page reload, only syncing providers when they were initialized (else the order would have been lost again).
This commit is contained in:
ekzyis 2024-02-09 16:42:26 +01:00 committed by GitHub
parent ec3e8f0079
commit b3d485e8c4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 103 additions and 81 deletions

View File

@ -1,45 +1,76 @@
import { createContext, useContext, useEffect, useState } from 'react' import { createContext, useCallback, useContext, useEffect, useState } from 'react'
import { LNbitsProvider, useLNbits } from './lnbits' import { LNbitsProvider, useLNbits } from './lnbits'
import { NWCProvider, useNWC } from './nwc' import { NWCProvider, useNWC } from './nwc'
import { useToast } from '../toast' import { useToast } from '../toast'
import { gql, useMutation } from '@apollo/client' import { gql, useMutation } from '@apollo/client'
const WebLNContext = createContext({}) const WebLNContext = createContext({})
const syncProvider = (array, provider) => {
const idx = array.findIndex(({ name }) => provider.name === name)
if (idx === -1) {
// add provider to end if enabled
return provider.enabled ? [...array, provider] : array
}
return [
...array.slice(0, idx),
// remove provider if not enabled
...provider.enabled ? [provider] : [],
...array.slice(idx + 1)
]
}
const storageKey = 'webln:providers' const storageKey = 'webln:providers'
const paymentMethodHook = (methods, { name, enabled }) => {
let newMethods
if (enabled) {
newMethods = methods.includes(name) ? methods : [...methods, name]
} else {
newMethods = methods.filter(m => m !== name)
}
savePaymentMethods(newMethods)
return newMethods
}
const savePaymentMethods = (methods) => {
window.localStorage.setItem(storageKey, JSON.stringify(methods))
}
function RawWebLNProvider ({ children }) { function RawWebLNProvider ({ children }) {
const lnbits = useLNbits() const lnbits = useLNbits()
const nwc = useNWC() const nwc = useNWC()
const providers = [lnbits, nwc] const availableProviders = [lnbits, nwc]
const [enabledProviders, setEnabledProviders] = useState([])
// TODO: Order of payment methods depends on user preference. // restore order on page reload
// Payment method at index 0 should be default, useEffect(() => {
// if that one fails we try the remaining ones in order as fallbacks. const storedOrder = window.localStorage.getItem(storageKey)
// We should be able to implement this via dragging of cards. if (!storedOrder) return
// This list should then match the order in which the (payment) cards are rendered. const providerNames = JSON.parse(storedOrder)
// eslint-disable-next-line no-unused-vars setEnabledProviders(providers => {
const [paymentMethods, setPaymentMethods] = useState([]) return providerNames.map(name => {
const loadPaymentMethods = () => { for (const p of availableProviders) {
const methods = window.localStorage.getItem(storageKey) if (p.name === name) return p
if (!methods) return
setPaymentMethods(JSON.parse(methods))
} }
useEffect(loadPaymentMethods, []) console.warn(`Stored provider with name ${name} not available`)
return null
})
})
}, [])
// keep list in sync with underlying providers
useEffect(() => {
setEnabledProviders(providers => {
// Sync existing provider state with new provider state
// in the list while keeping the order they are in.
// If provider does not exist but is enabled, it is just added to the end of the list.
// This can be the case if we're syncing from a page reload
// where the providers are initially not enabled.
// If provider is no longer enabled, it is removed from the list.
const isInitialized = p => p.initialized
const newProviders = availableProviders.filter(isInitialized).reduce(syncProvider, providers)
const newOrder = newProviders.map(({ name }) => name)
window.localStorage.setItem(storageKey, JSON.stringify(newOrder))
return newProviders
})
}, [lnbits, nwc])
// sanity check
for (const p of enabledProviders) {
if (!p.enabled && p.initialized) {
console.warn('Expected provider to be enabled but is not:', p.name)
}
}
// first provider in list is the default provider
// TODO: implement fallbacks via provider priority
const provider = enabledProviders[0]
const toaster = useToast() const toaster = useToast()
const [cancelInvoice] = useMutation(gql` const [cancelInvoice] = useMutation(gql`
@ -50,43 +81,6 @@ function RawWebLNProvider ({ children }) {
} }
`) `)
useEffect(() => {
setPaymentMethods(methods => paymentMethodHook(methods, nwc))
if (!nwc.enabled) nwc.setIsDefault(false)
}, [nwc.enabled])
useEffect(() => {
setPaymentMethods(methods => paymentMethodHook(methods, lnbits))
if (!lnbits.enabled) lnbits.setIsDefault(false)
}, [lnbits.enabled])
const setDefaultPaymentMethod = (provider) => {
for (const p of providers) {
if (p.name !== provider.name) {
p.setIsDefault(false)
}
}
}
useEffect(() => {
if (nwc.isDefault) setDefaultPaymentMethod(nwc)
}, [nwc.isDefault])
useEffect(() => {
if (lnbits.isDefault) setDefaultPaymentMethod(lnbits)
}, [lnbits.isDefault])
// TODO: implement numeric provider priority using paymentMethods list
// when we have more than two providers for sending
let provider = providers.filter(p => p.enabled && p.isDefault)[0]
if (!provider && providers.length > 0) {
// if no provider is the default, pick the first one and use that one as the default
provider = providers.filter(p => p.enabled)[0]
if (provider) {
provider.setIsDefault(true)
}
}
const sendPaymentWithToast = function ({ bolt11, hash, hmac }) { const sendPaymentWithToast = function ({ bolt11, hash, hmac }) {
let canceled = false let canceled = false
let removeToast = toaster.warning('payment pending', { let removeToast = toaster.warning('payment pending', {
@ -116,8 +110,21 @@ function RawWebLNProvider ({ children }) {
}) })
} }
const setProvider = useCallback((defaultProvider) => {
// move provider to the start to set it as default
setEnabledProviders(providers => {
const idx = providers.findIndex(({ name }) => defaultProvider.name === name)
if (idx === -1) {
console.warn(`tried to set unenabled provider ${defaultProvider.name} as default`)
return providers
}
return [defaultProvider, ...providers.slice(0, idx), ...providers.slice(idx + 1)]
})
}, [setEnabledProviders])
const value = { provider: { ...provider, sendPayment: sendPaymentWithToast }, enabledProviders, setProvider }
return ( return (
<WebLNContext.Provider value={{ ...provider, sendPayment: sendPaymentWithToast }}> <WebLNContext.Provider value={value}>
{children} {children}
</WebLNContext.Provider> </WebLNContext.Provider>
) )
@ -136,5 +143,10 @@ export function WebLNProvider ({ children }) {
} }
export function useWebLN () { export function useWebLN () {
const { provider } = useContext(WebLNContext)
return provider
}
export function useWebLNConfigurator () {
return useContext(WebLNContext) return useContext(WebLNContext)
} }

View File

@ -64,7 +64,7 @@ export function LNbitsProvider ({ children }) {
const [url, setUrl] = useState('') const [url, setUrl] = useState('')
const [adminKey, setAdminKey] = useState('') const [adminKey, setAdminKey] = useState('')
const [enabled, setEnabled] = useState() const [enabled, setEnabled] = useState()
const [isDefault, setIsDefault] = useState() const [initialized, setInitialized] = useState(false)
const name = 'LNbits' const name = 'LNbits'
const storageKey = 'webln:provider:lnbits' const storageKey = 'webln:provider:lnbits'
@ -104,10 +104,9 @@ export function LNbitsProvider ({ children }) {
const config = JSON.parse(configStr) const config = JSON.parse(configStr)
const { url, adminKey, isDefault } = config const { url, adminKey } = config
setUrl(url) setUrl(url)
setAdminKey(adminKey) setAdminKey(adminKey)
setIsDefault(isDefault)
try { try {
// validate config by trying to fetch wallet // validate config by trying to fetch wallet
@ -117,6 +116,8 @@ export function LNbitsProvider ({ children }) {
console.error('invalid LNbits config:', err) console.error('invalid LNbits config:', err)
setEnabled(false) setEnabled(false)
throw err throw err
} finally {
setInitialized(true)
} }
}, []) }, [])
@ -124,7 +125,6 @@ export function LNbitsProvider ({ children }) {
// immediately store config so it's not lost even if config is invalid // immediately store config so it's not lost even if config is invalid
setUrl(config.url) setUrl(config.url)
setAdminKey(config.adminKey) setAdminKey(config.adminKey)
setIsDefault(config.isDefault)
// XXX This is insecure, XSS vulns could lead to loss of funds! // XXX This is insecure, XSS vulns could lead to loss of funds!
// -> check how mutiny encrypts their wallet and/or check if we can leverage web workers // -> check how mutiny encrypts their wallet and/or check if we can leverage web workers
@ -153,7 +153,7 @@ export function LNbitsProvider ({ children }) {
loadConfig().catch(console.error) loadConfig().catch(console.error)
}, []) }, [])
const value = { name, url, adminKey, saveConfig, clearConfig, enabled, isDefault, setIsDefault, getInfo, sendPayment } const value = { name, url, adminKey, initialized, enabled, saveConfig, clearConfig, getInfo, sendPayment }
return ( return (
<LNbitsContext.Provider value={value}> <LNbitsContext.Provider value={value}>
{children} {children}

View File

@ -11,7 +11,7 @@ export function NWCProvider ({ children }) {
const [relayUrl, setRelayUrl] = useState() const [relayUrl, setRelayUrl] = useState()
const [secret, setSecret] = useState() const [secret, setSecret] = useState()
const [enabled, setEnabled] = useState() const [enabled, setEnabled] = useState()
const [isDefault, setIsDefault] = useState() const [initialized, setInitialized] = useState(false)
const [relay, setRelay] = useState() const [relay, setRelay] = useState()
const name = 'NWC' const name = 'NWC'
@ -26,9 +26,8 @@ export function NWCProvider ({ children }) {
const config = JSON.parse(configStr) const config = JSON.parse(configStr)
const { nwcUrl, isDefault } = config const { nwcUrl } = config
setNwcUrl(nwcUrl) setNwcUrl(nwcUrl)
setIsDefault(isDefault)
const params = parseWalletConnectUrl(nwcUrl) const params = parseWalletConnectUrl(nwcUrl)
setRelayUrl(params.relayUrl) setRelayUrl(params.relayUrl)
@ -42,14 +41,15 @@ export function NWCProvider ({ children }) {
console.error('invalid NWC config:', err) console.error('invalid NWC config:', err)
setEnabled(false) setEnabled(false)
throw err throw err
} finally {
setInitialized(true)
} }
}, []) }, [])
const saveConfig = useCallback(async (config) => { const saveConfig = useCallback(async (config) => {
// immediately store config so it's not lost even if config is invalid // immediately store config so it's not lost even if config is invalid
const { nwcUrl, isDefault } = config const { nwcUrl } = config
setNwcUrl(nwcUrl) setNwcUrl(nwcUrl)
setIsDefault(isDefault)
if (!nwcUrl) { if (!nwcUrl) {
setEnabled(undefined) setEnabled(undefined)
return return
@ -174,7 +174,7 @@ export function NWCProvider ({ children }) {
loadConfig().catch(console.error) loadConfig().catch(console.error)
}, []) }, [])
const value = { name, nwcUrl, relayUrl, walletPubkey, secret, saveConfig, clearConfig, enabled, isDefault, setIsDefault, getInfo, sendPayment } const value = { name, nwcUrl, relayUrl, walletPubkey, secret, initialized, enabled, saveConfig, clearConfig, getInfo, sendPayment }
return ( return (
<NWCContext.Provider value={value}> <NWCContext.Provider value={value}>
{children} {children}

View File

@ -7,11 +7,15 @@ import { useToast } from '../../../components/toast'
import { useRouter } from 'next/router' import { useRouter } from 'next/router'
import { useLNbits } from '../../../components/webln/lnbits' import { useLNbits } from '../../../components/webln/lnbits'
import { WalletSecurityBanner } from '../../../components/banners' import { WalletSecurityBanner } from '../../../components/banners'
import { useWebLNConfigurator } from '../../../components/webln'
export const getServerSideProps = getGetServerSideProps({ authRequired: true }) export const getServerSideProps = getGetServerSideProps({ authRequired: true })
export default function LNbits () { export default function LNbits () {
const { url, adminKey, saveConfig, clearConfig, enabled, isDefault } = useLNbits() const { provider, enabledProviders, setProvider } = useWebLNConfigurator()
const lnbits = useLNbits()
const { name, url, adminKey, saveConfig, clearConfig, enabled } = lnbits
const isDefault = provider?.name === name
const toaster = useToast() const toaster = useToast()
const router = useRouter() const router = useRouter()
@ -27,9 +31,10 @@ export default function LNbits () {
isDefault: isDefault || false isDefault: isDefault || false
}} }}
schema={lnbitsSchema} schema={lnbitsSchema}
onSubmit={async (values) => { onSubmit={async ({ isDefault, ...values }) => {
try { try {
await saveConfig(values) await saveConfig(values)
if (isDefault) setProvider(lnbits)
toaster.success('saved settings') toaster.success('saved settings')
router.push('/settings/wallets') router.push('/settings/wallets')
} catch (err) { } catch (err) {
@ -53,7 +58,7 @@ export default function LNbits () {
name='adminKey' name='adminKey'
/> />
<ClientCheckbox <ClientCheckbox
disabled={!enabled} disabled={!enabled || isDefault || enabledProviders.length === 1}
initialValue={isDefault} initialValue={isDefault}
label='default payment method' label='default payment method'
name='isDefault' name='isDefault'

View File

@ -7,11 +7,15 @@ import { useToast } from '../../../components/toast'
import { useRouter } from 'next/router' import { useRouter } from 'next/router'
import { useNWC } from '../../../components/webln/nwc' import { useNWC } from '../../../components/webln/nwc'
import { WalletSecurityBanner } from '../../../components/banners' import { WalletSecurityBanner } from '../../../components/banners'
import { useWebLNConfigurator } from '../../../components/webln'
export const getServerSideProps = getGetServerSideProps({ authRequired: true }) export const getServerSideProps = getGetServerSideProps({ authRequired: true })
export default function NWC () { export default function NWC () {
const { nwcUrl, saveConfig, clearConfig, enabled, isDefault } = useNWC() const { provider, enabledProviders, setProvider } = useWebLNConfigurator()
const nwc = useNWC()
const { name, nwcUrl, saveConfig, clearConfig, enabled } = nwc
const isDefault = provider?.name === name
const toaster = useToast() const toaster = useToast()
const router = useRouter() const router = useRouter()
@ -26,9 +30,10 @@ export default function NWC () {
isDefault: isDefault || false isDefault: isDefault || false
}} }}
schema={nwcSchema} schema={nwcSchema}
onSubmit={async (values) => { onSubmit={async ({ isDefault, ...values }) => {
try { try {
await saveConfig(values) await saveConfig(values)
if (isDefault) setProvider(nwc)
toaster.success('saved settings') toaster.success('saved settings')
router.push('/settings/wallets') router.push('/settings/wallets')
} catch (err) { } catch (err) {
@ -45,7 +50,7 @@ export default function NWC () {
autoFocus autoFocus
/> />
<ClientCheckbox <ClientCheckbox
disabled={!enabled} disabled={!enabled || isDefault || enabledProviders.length === 1}
initialValue={isDefault} initialValue={isDefault}
label='default payment method' label='default payment method'
name='isDefault' name='isDefault'