Wallet flow (#2362)
* Wallet flow * Prepopulate fields of complementary protocol * Remove TODO about one mutation for save We need to save protocols in separate mutations so we can use the wallet id returned by the first protocol save for the following protocol saves and save them all to the same wallet. * Fix badges not updated on wallet delete * Fix useProtocol call * Fix lightning address save via prompt * Don't pass share as attribute to DOM * Fix useCallback dependency * Progress numbers as SVGs * Fix progress line margins * Remove unused saveWallet arguments * Update cache with settings response * Fix line does not connect with number 1 * Don't reuse page nav arrows in form nav * Fix missing SVG hover style * Fix missing space in wallet save log message * Reuse CSS from nav.module.css * align buttons and their icons/text * center form progress line * increase top padding of form on smaller screens * provide margin above button bar on settings form --------- Co-authored-by: k00b <k00b@stacker.news>
This commit is contained in:
parent
a620c0b0ce
commit
e46f4f01b2
@ -139,8 +139,7 @@ const typeDefs = gql`
|
||||
): Boolean!
|
||||
|
||||
# delete
|
||||
removeWallet(id: ID!): Boolean
|
||||
removeWalletProtocol(id: ID!): Boolean
|
||||
deleteWallet(id: ID!): Boolean
|
||||
|
||||
# crypto
|
||||
updateWalletEncryption(keyHash: String!, wallets: [WalletEncryptionUpdate!]!): Boolean
|
||||
@ -149,7 +148,7 @@ const typeDefs = gql`
|
||||
disablePassphraseExport: Boolean
|
||||
|
||||
# settings
|
||||
setWalletSettings(settings: WalletSettingsInput!): Boolean
|
||||
setWalletSettings(settings: WalletSettingsInput!): WalletSettings!
|
||||
setWalletPriorities(priorities: [WalletPriorityUpdate!]!): Boolean
|
||||
|
||||
# logs
|
||||
|
223
components/multi-step-form.js
Normal file
223
components/multi-step-form.js
Normal file
@ -0,0 +1,223 @@
|
||||
import { createContext, Fragment, useCallback, useContext, useEffect, useMemo, useState } from 'react'
|
||||
import classNames from 'classnames'
|
||||
import { useRouter } from 'next/router'
|
||||
|
||||
const MultiStepFormContext = createContext()
|
||||
|
||||
export function MultiStepForm ({ children, initial, steps }) {
|
||||
const [stepIndex, setStepIndex] = useState(0)
|
||||
const [formState, setFormState] = useState({})
|
||||
const router = useRouter()
|
||||
|
||||
useEffect(() => {
|
||||
// initial state might not be available on first render so we sync changes
|
||||
if (initial) setFormState(initial)
|
||||
}, [initial])
|
||||
|
||||
useEffect(() => {
|
||||
const idx = Math.max(0, steps.indexOf(router.query.step))
|
||||
setStepIndex(idx)
|
||||
router.replace({
|
||||
pathname: router.pathname,
|
||||
query: { type: router.query.type, step: steps[idx] }
|
||||
}, null, { shallow: true })
|
||||
}, [router.query.step, steps])
|
||||
|
||||
const next = useCallback(() => {
|
||||
const idx = Math.min(stepIndex + 1, steps.length - 1)
|
||||
router.push(
|
||||
{ pathname: router.pathname, query: { type: router.query.type, step: steps[idx] } },
|
||||
null,
|
||||
{ shallow: true }
|
||||
)
|
||||
}, [stepIndex, steps, router])
|
||||
|
||||
const prev = useCallback(() => router.back(), [router])
|
||||
|
||||
const updateFormState = useCallback((id, state) => {
|
||||
setFormState(formState => {
|
||||
return id ? { ...formState, [id]: state } : state
|
||||
})
|
||||
}, [])
|
||||
|
||||
const value = useMemo(
|
||||
() => ({ stepIndex, steps, next, prev, formState, updateFormState }),
|
||||
[stepIndex, steps, next, prev, formState, updateFormState])
|
||||
return (
|
||||
<MultiStepFormContext.Provider value={value}>
|
||||
<Progress />
|
||||
{children[stepIndex]}
|
||||
</MultiStepFormContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
function Progress () {
|
||||
const steps = useSteps()
|
||||
const stepIndex = useStepIndex()
|
||||
|
||||
const style = (index) => {
|
||||
switch (index) {
|
||||
case 0: return { marginLeft: '-5px', marginRight: '-13px' }
|
||||
case 1: return { marginLeft: '-13px', marginRight: '-15px' }
|
||||
default: return {}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='d-flex my-3 mx-auto'>
|
||||
{
|
||||
steps.map((label, i) => {
|
||||
const last = i === steps.length - 1
|
||||
return (
|
||||
<Fragment key={i}>
|
||||
<ProgressNumber number={i + 1} label={label} active={stepIndex >= i} />
|
||||
{!last && <ProgressLine style={style(i)} active={stepIndex >= i + 1} />}
|
||||
</Fragment>
|
||||
)
|
||||
})
|
||||
}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
function ProgressNumber ({ number, label, active }) {
|
||||
return (
|
||||
<div className={classNames('z-1 text-center', { 'text-info': active })}>
|
||||
<NumberSVG number={number} active={active} />
|
||||
<div className={classNames('small pt-1', active ? 'text-info' : 'text-muted')}>
|
||||
{label}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const NUMBER_SVG_WIDTH = 24
|
||||
const NUMBER_SVG_HEIGHT = 24
|
||||
|
||||
function NumberSVG ({ number, active }) {
|
||||
const width = NUMBER_SVG_WIDTH
|
||||
const height = NUMBER_SVG_HEIGHT
|
||||
|
||||
const Wrapper = ({ children }) => (
|
||||
<div style={{ position: 'relative', width: `${width}px`, height: `${height}px`, margin: '0 auto' }}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
|
||||
const Circle = () => {
|
||||
const circleProps = {
|
||||
fill: active ? 'var(--bs-info)' : 'var(--bs-body-bg)',
|
||||
stroke: active ? 'var(--bs-info)' : 'var(--theme-grey)'
|
||||
}
|
||||
return (
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'
|
||||
width={width} height={height}
|
||||
style={{ position: 'absolute', top: 0, left: 0 }}
|
||||
>
|
||||
<circle cx='12' cy='12' r='11' strokeWidth='1' {...circleProps} />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
const Number = () => {
|
||||
const svgProps = {
|
||||
xmlns: 'http://www.w3.org/2000/svg',
|
||||
viewBox: '0 0 24 24',
|
||||
// we scale the number down and render it in the center of the circle
|
||||
width: 0.5 * width,
|
||||
height: 0.5 * height,
|
||||
style: { position: 'absolute', top: '50%', left: '50%', transform: 'translate(-50%, -50%)' }
|
||||
}
|
||||
const numberColor = active ? 'var(--bs-white)' : 'var(--theme-grey)'
|
||||
// svgs are from https://remixicon.com/icon/number-1 etc.
|
||||
switch (number) {
|
||||
case 1:
|
||||
return (
|
||||
<svg {...svgProps}>
|
||||
<path fill={numberColor} d='M14 1.5V22H12V3.704L7.5 4.91V2.839L12.5 1.5H14Z' />
|
||||
</svg>
|
||||
)
|
||||
case 2:
|
||||
return (
|
||||
<svg {...svgProps}>
|
||||
<path
|
||||
fill={numberColor}
|
||||
d='M16.0002 7.5C16.0002 5.29086 14.2094 3.5 12.0002 3.5C9.7911 3.5 8.00024 5.29086 8.00024 7.5H6.00024C6.00024 4.18629 8.68653 1.5 12.0002 1.5C15.314 1.5 18.0002 4.18629 18.0002 7.5C18.0002 8.93092 17.4993 10.2448 16.6633 11.276L9.344 19.9991L18.0002 20V22H6.00024L6 20.8731L15.0642 10.071C15.6485 9.37595 16.0002 8.47905 16.0002 7.5Z'
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
case 3:
|
||||
return (
|
||||
<svg {...svgProps}>
|
||||
<path fill={numberColor} d='M18.0001 2V3.36217L12.8087 9.54981C16.0169 9.94792 18.5001 12.684 18.5001 16C18.5001 19.5899 15.5899 22.5 12.0001 22.5C8.95434 22.5 6.39789 20.4052 5.69287 17.5778L7.63351 17.0922C8.12156 19.0497 9.89144 20.5 12.0001 20.5C14.4853 20.5 16.5001 18.4853 16.5001 16C16.5001 13.5147 14.4853 11.5 12.0001 11.5C11.2795 11.5 10.5985 11.6694 9.99465 11.9705L9.76692 12.0923L9.07705 10.8852L14.8551 3.99917L6.50006 4V2H18.0001Z' />
|
||||
</svg>
|
||||
)
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Wrapper>
|
||||
<Circle />
|
||||
<Number />
|
||||
</Wrapper>
|
||||
)
|
||||
}
|
||||
|
||||
function ProgressLine ({ style, active }) {
|
||||
const svgStyle = { display: 'block', position: 'relative', top: `${NUMBER_SVG_HEIGHT / 2}px` }
|
||||
return (
|
||||
<div style={style}>
|
||||
<svg style={svgStyle} width='100%' height='1' viewBox='0 0 100 1' preserveAspectRatio='none'>
|
||||
<path
|
||||
d='M 0 1 L 100 1'
|
||||
stroke={active ? 'var(--bs-info)' : 'var(--theme-grey)'}
|
||||
strokeWidth='1'
|
||||
fill='none'
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function useSteps () {
|
||||
const { steps } = useContext(MultiStepFormContext)
|
||||
return steps
|
||||
}
|
||||
|
||||
export function useStepIndex () {
|
||||
const { stepIndex } = useContext(MultiStepFormContext)
|
||||
return stepIndex
|
||||
}
|
||||
|
||||
export function useMaxSteps () {
|
||||
const steps = useSteps()
|
||||
return steps.length
|
||||
}
|
||||
|
||||
export function useStep () {
|
||||
const stepIndex = useStepIndex()
|
||||
const steps = useSteps()
|
||||
return steps[stepIndex]
|
||||
}
|
||||
|
||||
export function useNext () {
|
||||
const { next } = useContext(MultiStepFormContext)
|
||||
return next
|
||||
}
|
||||
|
||||
export function usePrev () {
|
||||
const { prev } = useContext(MultiStepFormContext)
|
||||
return prev
|
||||
}
|
||||
|
||||
export function useFormState (id) {
|
||||
const { formState, updateFormState } = useContext(MultiStepFormContext)
|
||||
const setFormState = useCallback(state => updateFormState(id, state), [id, updateFormState])
|
||||
return useMemo(
|
||||
() => [
|
||||
id ? formState[id] : formState,
|
||||
setFormState
|
||||
], [formState, id, setFormState])
|
||||
}
|
@ -131,11 +131,6 @@ export const SET_SETTINGS = gql`
|
||||
}
|
||||
}`
|
||||
|
||||
export const DELETE_WALLET = gql`
|
||||
mutation removeWallet {
|
||||
removeWallet
|
||||
}`
|
||||
|
||||
export const NAME_QUERY = gql`
|
||||
query nameAvailable($name: String!) {
|
||||
nameAvailable(name: $name)
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { getGetServerSideProps } from '@/api/ssrApollo'
|
||||
import { useData } from '@/components/use-data'
|
||||
import { WalletForms as WalletFormsComponent } from '@/wallets/client/components'
|
||||
import { WalletMultiStepForm } from '@/wallets/client/components'
|
||||
import { WALLET } from '@/wallets/client/fragments'
|
||||
import { useDecryptedWallet } from '@/wallets/client/hooks'
|
||||
import { unurlify } from '@/wallets/lib/util'
|
||||
@ -8,19 +8,19 @@ import { useQuery } from '@apollo/client'
|
||||
import { useRouter } from 'next/router'
|
||||
|
||||
const variablesFunc = params => {
|
||||
const id = Number(params.slug[0])
|
||||
return !Number.isNaN(id) ? { id } : { name: unurlify(params.slug[0]) }
|
||||
const id = Number(params.type)
|
||||
return !Number.isNaN(id) ? { id } : { name: unurlify(params.type) }
|
||||
}
|
||||
export const getServerSideProps = getGetServerSideProps({ query: WALLET, variables: variablesFunc, authRequired: true })
|
||||
|
||||
export default function WalletForms ({ ssrData }) {
|
||||
export default function Wallet ({ ssrData }) {
|
||||
const router = useRouter()
|
||||
const variables = variablesFunc(router.query)
|
||||
// this will print the following warning in the console:
|
||||
// Warning: fragment with name WalletTemplateFields already exists.
|
||||
// graphql-tag enforces all fragment names across your application to be unique
|
||||
// this is not a problem because the warning is only meant to avoid overwriting fragments but we're reusing it
|
||||
const { data, refetch } = useQuery(WALLET, { variables })
|
||||
const { data } = useQuery(WALLET, { variables })
|
||||
const dat = useData(data, ssrData)
|
||||
|
||||
const decryptedWallet = useDecryptedWallet(dat?.wallet)
|
||||
@ -29,5 +29,5 @@ export default function WalletForms ({ ssrData }) {
|
||||
return null
|
||||
}
|
||||
|
||||
return <WalletFormsComponent wallet={wallet} refetch={refetch} />
|
||||
return <WalletMultiStepForm wallet={wallet} />
|
||||
}
|
@ -113,8 +113,6 @@ export default function Wallet () {
|
||||
<WalletLayoutSubHeader>use real bitcoin</WalletLayoutSubHeader>
|
||||
<div className='text-center'>
|
||||
<WalletLayoutLink href='/wallets/logs'>wallet logs</WalletLayoutLink>
|
||||
<span className='mx-2'>•</span>
|
||||
<WalletLayoutLink href='/wallets/settings'>settings</WalletLayoutLink>
|
||||
{showPassphrase && (
|
||||
<>
|
||||
<span className='mx-2'>•</span>
|
||||
|
@ -1,185 +0,0 @@
|
||||
import { getGetServerSideProps } from '@/api/ssrApollo'
|
||||
import { Checkbox, Form, Input, SubmitButton } from '@/components/form'
|
||||
import Info from '@/components/info'
|
||||
import { isNumber } from '@/lib/format'
|
||||
import { WalletLayout, WalletLayoutHeader, WalletLayoutSubHeader } from '@/wallets/client/components'
|
||||
import { useMutation, useQuery } from '@apollo/client'
|
||||
import Link from 'next/link'
|
||||
import { useCallback, useMemo } from 'react'
|
||||
import { InputGroup } from 'react-bootstrap'
|
||||
import styles from '@/styles/wallet.module.css'
|
||||
import classNames from 'classnames'
|
||||
import { useField } from 'formik'
|
||||
import { SET_WALLET_SETTINGS, WALLET_SETTINGS } from '@/wallets/client/fragments'
|
||||
import { walletSettingsSchema } from '@/lib/validate'
|
||||
import { useToast } from '@/components/toast'
|
||||
import CancelButton from '@/components/cancel-button'
|
||||
|
||||
export const getServerSideProps = getGetServerSideProps({ query: WALLET_SETTINGS, authRequired: true })
|
||||
|
||||
export default function WalletSettings ({ ssrData }) {
|
||||
const { data } = useQuery(WALLET_SETTINGS)
|
||||
const [setSettings] = useMutation(SET_WALLET_SETTINGS)
|
||||
const { walletSettings: settings } = useMemo(() => data ?? ssrData, [data, ssrData])
|
||||
const toaster = useToast()
|
||||
|
||||
const initial = {
|
||||
receiveCreditsBelowSats: settings?.receiveCreditsBelowSats,
|
||||
sendCreditsBelowSats: settings?.sendCreditsBelowSats,
|
||||
autoWithdrawThreshold: settings?.autoWithdrawThreshold ?? 10000,
|
||||
autoWithdrawMaxFeePercent: settings?.autoWithdrawMaxFeePercent ?? 1,
|
||||
autoWithdrawMaxFeeTotal: settings?.autoWithdrawMaxFeeTotal ?? 1,
|
||||
proxyReceive: settings?.proxyReceive
|
||||
}
|
||||
|
||||
const onSubmit = useCallback(async (values) => {
|
||||
try {
|
||||
await setSettings({
|
||||
variables: {
|
||||
settings: values
|
||||
}
|
||||
})
|
||||
toaster.success('saved settings')
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
toaster.danger('failed to save settings')
|
||||
}
|
||||
}, [toaster])
|
||||
|
||||
return (
|
||||
<WalletLayout>
|
||||
<div className='py-5 mx-auto w-100' style={{ maxWidth: '600px' }}>
|
||||
<WalletLayoutHeader>wallet settings</WalletLayoutHeader>
|
||||
<WalletLayoutSubHeader>apply globally to all wallets</WalletLayoutSubHeader>
|
||||
<Form
|
||||
enableReinitialize
|
||||
initial={initial}
|
||||
schema={walletSettingsSchema}
|
||||
className='mt-3'
|
||||
onSubmit={onSubmit}
|
||||
>
|
||||
<CowboyCreditsSettings />
|
||||
<LightningAddressSettings />
|
||||
<AutowithdrawSettings />
|
||||
<LightningNetworkFeesSettings />
|
||||
<div className='d-flex mt-1 justify-content-end'>
|
||||
<CancelButton />
|
||||
<SubmitButton variant='info'>save</SubmitButton>
|
||||
</div>
|
||||
</Form>
|
||||
</div>
|
||||
</WalletLayout>
|
||||
)
|
||||
}
|
||||
|
||||
function CowboyCreditsSettings () {
|
||||
return (
|
||||
<>
|
||||
<Separator>cowboy credits</Separator>
|
||||
<Input
|
||||
label='receive credits for zaps below'
|
||||
name='receiveCreditsBelowSats'
|
||||
required
|
||||
append={<InputGroup.Text className='text-monospace'>sats</InputGroup.Text>}
|
||||
type='number'
|
||||
min={0}
|
||||
/>
|
||||
<Input
|
||||
label='send credits for zaps below'
|
||||
name='sendCreditsBelowSats'
|
||||
required
|
||||
append={<InputGroup.Text className='text-monospace'>sats</InputGroup.Text>}
|
||||
type='number'
|
||||
min={0}
|
||||
/>
|
||||
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function LightningAddressSettings () {
|
||||
return (
|
||||
<>
|
||||
<Separator>@stacker.news lightning address</Separator>
|
||||
<Checkbox
|
||||
label={
|
||||
<div className='d-flex align-items-center'>enhance privacy of my lightning address
|
||||
<Info>
|
||||
<ul>
|
||||
<li>Enabling this setting hides details (ie node pubkey) of your attached wallets when anyone pays your SN lightning address or lnurl-pay</li>
|
||||
<li>The lightning invoice will appear to have SN's node as the destination to preserve your wallet's privacy</li>
|
||||
<li>This will incur in a 10% fee</li>
|
||||
<li>Disable this setting to receive payments directly to your attached wallets (which will reveal their details to the payer)</li>
|
||||
<li>Note: this privacy behavior is standard for internal zaps/payments on SN, and this setting only applies to external payments</li>
|
||||
</ul>
|
||||
</Info>
|
||||
</div>
|
||||
}
|
||||
name='proxyReceive'
|
||||
groupClassName='mb-0'
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function AutowithdrawSettings () {
|
||||
const [{ value: threshold }] = useField('autoWithdrawThreshold')
|
||||
const sendThreshold = Math.max(Math.floor(threshold / 10), 1)
|
||||
|
||||
return (
|
||||
<>
|
||||
<Separator>autowithdrawal</Separator>
|
||||
<Input
|
||||
label='desired balance'
|
||||
name='autoWithdrawThreshold'
|
||||
hint={isNumber(sendThreshold) ? `will attempt autowithdrawal when your balance exceeds ${sendThreshold * 11} sats` : undefined}
|
||||
append={<InputGroup.Text className='text-monospace'>sats</InputGroup.Text>}
|
||||
required
|
||||
type='number'
|
||||
min={0}
|
||||
/>
|
||||
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function LightningNetworkFeesSettings () {
|
||||
return (
|
||||
<>
|
||||
<Separator className='mb-0'>lightning network fees</Separator>
|
||||
<div className='text-center text-muted mb-2'>
|
||||
we'll use whichever setting is higher during{' '}
|
||||
<Link
|
||||
target='_blank'
|
||||
href='https://docs.lightning.engineering/the-lightning-network/pathfinding'
|
||||
rel='noreferrer'
|
||||
>pathfinding
|
||||
</Link>
|
||||
</div>
|
||||
<Input
|
||||
label='max fee rate'
|
||||
name='autoWithdrawMaxFeePercent'
|
||||
hint='max fee as percent of withdrawal amount'
|
||||
append={<InputGroup.Text>%</InputGroup.Text>}
|
||||
required
|
||||
type='number'
|
||||
min={0}
|
||||
/>
|
||||
<Input
|
||||
label='max fee total'
|
||||
name='autoWithdrawMaxFeeTotal'
|
||||
hint='max fee for any withdrawal amount'
|
||||
append={<InputGroup.Text className='text-monospace'>sats</InputGroup.Text>}
|
||||
required
|
||||
type='number'
|
||||
min={0}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function Separator ({ children, className }) {
|
||||
return (
|
||||
<div className={classNames(styles.separator, 'fw-bold', className)}>{children}</div>
|
||||
)
|
||||
}
|
@ -782,6 +782,7 @@ div[contenteditable]:focus,
|
||||
fill: var(--theme-navLink);
|
||||
}
|
||||
|
||||
.nav-link:hover svg,
|
||||
.nav-link.active svg {
|
||||
fill: var(--theme-navLinkActive);
|
||||
}
|
||||
|
@ -106,6 +106,12 @@
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.form {
|
||||
margin-top: 2rem;
|
||||
}
|
||||
}
|
||||
|
||||
.separator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
1
svgs/arrow-left-s-fill.svg
Normal file
1
svgs/arrow-left-s-fill.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M8 12L14 6V18L8 12Z"></path></svg>
|
After Width: | Height: | Size: 123 B |
1
svgs/arrow-left-s-line.svg
Normal file
1
svgs/arrow-left-s-line.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M10.8284 12.0007L15.7782 16.9504L14.364 18.3646L8 12.0007L14.364 5.63672L15.7782 7.05093L10.8284 12.0007Z"></path></svg>
|
After Width: | Height: | Size: 209 B |
@ -7,7 +7,7 @@ import Link from 'next/link'
|
||||
import RecvIcon from '@/svgs/arrow-left-down-line.svg'
|
||||
import SendIcon from '@/svgs/arrow-right-up-line.svg'
|
||||
import DragIcon from '@/svgs/draggable.svg'
|
||||
import { useWalletImage, useWalletSupport, useWalletStatus, WalletStatus, useProtocolTemplates } from '@/wallets/client/hooks'
|
||||
import { useWalletImage, useWalletSupport, useWalletStatus, WalletStatus } from '@/wallets/client/hooks'
|
||||
import { isWallet, urlify, walletDisplayName } from '@/wallets/lib/util'
|
||||
import { Draggable } from '@/wallets/client/components'
|
||||
|
||||
@ -55,15 +55,7 @@ export function WalletCard ({ wallet, draggable = false, index, ...props }) {
|
||||
}
|
||||
|
||||
function WalletLink ({ wallet, children }) {
|
||||
const support = useWalletSupport(wallet)
|
||||
const protocols = useProtocolTemplates(wallet)
|
||||
const firstSend = protocols.find(p => p.send)
|
||||
const firstRecv = protocols.find(p => !p.send)
|
||||
|
||||
let href = '/wallets'
|
||||
href += isWallet(wallet) ? `/${wallet.id}` : `/${urlify(wallet.name)}`
|
||||
href += support.send ? `/send/${urlify(firstSend.name)}` : `/receive/${urlify(firstRecv.name)}`
|
||||
|
||||
const href = '/wallets' + (isWallet(wallet) ? `/${wallet.id}` : `/${urlify(wallet.name)}`)
|
||||
return <Link href={href}>{children}</Link>
|
||||
}
|
||||
|
||||
|
20
wallets/client/components/form/button.js
Normal file
20
wallets/client/components/form/button.js
Normal file
@ -0,0 +1,20 @@
|
||||
import classNames from 'classnames'
|
||||
import { Button } from 'react-bootstrap'
|
||||
import ArrowLeft from '@/svgs/arrow-left-s-fill.svg'
|
||||
|
||||
import { usePrev, useNext } from '@/components/multi-step-form'
|
||||
|
||||
export function BackButton ({ className }) {
|
||||
const prev = usePrev()
|
||||
return (
|
||||
<Button className={classNames('me-3 text-muted nav-link fw-bold d-flex align-items-center', className)} variant='link' onClick={prev}>
|
||||
<ArrowLeft width={24} height={24} />
|
||||
back
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
export function SkipButton ({ className }) {
|
||||
const next = useNext()
|
||||
return <Button className={classNames('ms-auto me-3 text-muted nav-link fw-bold', className)} variant='link' onClick={next}>skip</Button>
|
||||
}
|
136
wallets/client/components/form/hooks.js
Normal file
136
wallets/client/components/form/hooks.js
Normal file
@ -0,0 +1,136 @@
|
||||
import { isTemplate, isWallet, protocolClientSchema, protocolFields, protocolFormId, walletLud16Domain } from '@/wallets/lib/util'
|
||||
import { createContext, useContext, useEffect, useMemo, useCallback, useState } from 'react'
|
||||
import { useWalletProtocolUpsert } from '@/wallets/client/hooks'
|
||||
import { MultiStepForm, useFormState, useStep } from '@/components/multi-step-form'
|
||||
|
||||
export const Step = {
|
||||
SEND: 'send',
|
||||
RECEIVE: 'receive',
|
||||
SETTINGS: 'settings'
|
||||
}
|
||||
|
||||
const WalletMultiStepFormContext = createContext()
|
||||
|
||||
export function WalletMultiStepFormContextProvider ({ wallet, initial, steps, children }) {
|
||||
// save selected protocol, but useProtocol will always return the first protocol if no protocol is selected
|
||||
const [protocol, setProtocol] = useState(null)
|
||||
const value = useMemo(() => ({ wallet, protocol, setProtocol }), [wallet, protocol, setProtocol])
|
||||
return (
|
||||
<WalletMultiStepFormContext.Provider value={value}>
|
||||
<MultiStepForm initial={initial} steps={steps}>
|
||||
{children}
|
||||
</MultiStepForm>
|
||||
</WalletMultiStepFormContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useWallet () {
|
||||
const { wallet } = useContext(WalletMultiStepFormContext)
|
||||
return wallet
|
||||
}
|
||||
|
||||
export function useWalletProtocols () {
|
||||
const step = useStep()
|
||||
const wallet = useWallet()
|
||||
|
||||
const protocolFilter = useCallback(p => step === Step.SEND ? p.send : !p.send, [step])
|
||||
|
||||
return useMemo(() => {
|
||||
// all protocols are templates if wallet is a template
|
||||
if (isTemplate(wallet)) {
|
||||
return wallet.protocols.filter(protocolFilter)
|
||||
}
|
||||
// return template for every protocol that isn't configured
|
||||
const configured = wallet.protocols.filter(protocolFilter)
|
||||
const templates = wallet.template.protocols.filter(protocolFilter)
|
||||
return templates.map(p => configured.find(c => c.name === p.name) ?? p)
|
||||
}, [wallet, protocolFilter])
|
||||
}
|
||||
|
||||
export function useProtocol () {
|
||||
const { protocol, setProtocol } = useContext(WalletMultiStepFormContext)
|
||||
const protocols = useWalletProtocols()
|
||||
|
||||
useEffect(() => {
|
||||
// when we move between send and receive, we need to make sure that we've selected a protocol
|
||||
// that actually exists, so if the protocol is not found, we set it to the first protocol
|
||||
if (!protocol || !protocols.find(p => p.id === protocol.id)) {
|
||||
setProtocol(protocols[0])
|
||||
}
|
||||
}, [protocol, protocols, setProtocol])
|
||||
|
||||
// make sure we always have a protocol, even on first render before useEffect runs
|
||||
return useMemo(() => [protocol ?? protocols[0], setProtocol], [protocol, protocols, setProtocol])
|
||||
}
|
||||
|
||||
function useProtocolFormState (protocol) {
|
||||
const formId = protocolFormId(protocol)
|
||||
const [formState, setFormState] = useFormState(formId)
|
||||
const setProtocolFormState = useCallback(
|
||||
({ enabled, ...config }) => {
|
||||
setFormState({ ...protocol, enabled, config })
|
||||
},
|
||||
[setFormState, protocol])
|
||||
return useMemo(() => [formState, setProtocolFormState], [formState, setProtocolFormState])
|
||||
}
|
||||
|
||||
export function useProtocolForm (protocol) {
|
||||
const [formState, setFormState] = useProtocolFormState(protocol)
|
||||
const [complementaryFormState] = useProtocolFormState({ name: protocol.name, send: !protocol.send })
|
||||
const wallet = useWallet()
|
||||
const lud16Domain = walletLud16Domain(wallet.name)
|
||||
const fields = protocolFields(protocol)
|
||||
const initial = fields.reduce((acc, field) => {
|
||||
// we only fallback to the existing protocol config because formState was not initialized yet on first render
|
||||
// after init, we use formState as the source of truth everywhere
|
||||
let value = formState?.config?.[field.name] ?? protocol.config?.[field.name]
|
||||
|
||||
if (!value && field.share) {
|
||||
value = complementaryFormState?.config?.[field.name]
|
||||
}
|
||||
|
||||
if (field.name === 'address' && lud16Domain && value) {
|
||||
value = value.split('@')[0]
|
||||
}
|
||||
|
||||
return {
|
||||
...acc,
|
||||
[field.name]: value || ''
|
||||
}
|
||||
}, { enabled: formState?.enabled ?? protocol.enabled })
|
||||
|
||||
let schema = protocolClientSchema(protocol)
|
||||
if (lud16Domain) {
|
||||
schema = schema.transform(({ address, ...rest }) => {
|
||||
return {
|
||||
address: address ? `${address}@${lud16Domain}` : '',
|
||||
...rest
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return useMemo(() => [{ fields, initial, schema }, setFormState], [fields, initial, schema, setFormState])
|
||||
}
|
||||
|
||||
export function useSaveWallet () {
|
||||
const wallet = useWallet()
|
||||
const [formState] = useFormState()
|
||||
const upsert = useWalletProtocolUpsert()
|
||||
|
||||
const save = useCallback(async () => {
|
||||
let walletId = isWallet(wallet) ? wallet.id : undefined
|
||||
for (const protocol of Object.values(formState)) {
|
||||
const { id } = await upsert(
|
||||
{
|
||||
...wallet,
|
||||
id: walletId,
|
||||
__typename: walletId ? 'Wallet' : 'WalletTemplate'
|
||||
},
|
||||
protocol, { ...protocol.config, enabled: protocol.enabled }
|
||||
)
|
||||
walletId ??= id
|
||||
}
|
||||
}, [wallet, formState, upsert])
|
||||
|
||||
return save
|
||||
}
|
231
wallets/client/components/form/index.js
Normal file
231
wallets/client/components/form/index.js
Normal file
@ -0,0 +1,231 @@
|
||||
import { useCallback, useMemo } from 'react'
|
||||
import { InputGroup, Nav } from 'react-bootstrap'
|
||||
import classNames from 'classnames'
|
||||
import styles from '@/styles/wallet.module.css'
|
||||
import navStyles from '@/styles/nav.module.css'
|
||||
import { Checkbox, Form, Input, PasswordInput, SubmitButton } from '@/components/form'
|
||||
import CancelButton from '@/components/cancel-button'
|
||||
import Text from '@/components/text'
|
||||
import Info from '@/components/info'
|
||||
import { useFormState, useMaxSteps, useNext, useStepIndex } from '@/components/multi-step-form'
|
||||
import { isTemplate, isWallet, protocolDisplayName, protocolFormId, protocolLogName, walletLud16Domain } from '@/wallets/lib/util'
|
||||
import { WalletLayout, WalletLayoutHeader, WalletLayoutImageOrName, WalletLogs } from '@/wallets/client/components'
|
||||
import { TemplateLogsProvider, useTestSendPayment, useWalletLogger, useTestCreateInvoice, useWalletSupport } from '@/wallets/client/hooks'
|
||||
import ArrowRight from '@/svgs/arrow-right-s-fill.svg'
|
||||
|
||||
import { WalletMultiStepFormContextProvider, Step, useWallet, useWalletProtocols, useProtocol, useProtocolForm } from './hooks'
|
||||
import { Settings } from './settings'
|
||||
import { BackButton, SkipButton } from './button'
|
||||
|
||||
export function WalletMultiStepForm ({ wallet }) {
|
||||
const initial = useMemo(() => wallet.protocols
|
||||
.filter(p => !isTemplate(p))
|
||||
.reduce((acc, p) => {
|
||||
const formId = protocolFormId(p)
|
||||
return {
|
||||
...acc,
|
||||
[formId]: p
|
||||
}
|
||||
}, {}), [wallet])
|
||||
|
||||
const support = useWalletSupport(wallet)
|
||||
const steps = useMemo(() =>
|
||||
[
|
||||
support.send && Step.SEND,
|
||||
support.receive && Step.RECEIVE,
|
||||
Step.SETTINGS
|
||||
].filter(Boolean),
|
||||
[support])
|
||||
|
||||
return (
|
||||
<WalletLayout>
|
||||
<div className={styles.form}>
|
||||
<WalletLayoutHeader>
|
||||
<WalletLayoutImageOrName name={wallet.name} maxHeight='80px' />
|
||||
</WalletLayoutHeader>
|
||||
<WalletMultiStepFormContextProvider wallet={wallet} initial={initial} steps={steps}>
|
||||
{steps.map(step => {
|
||||
// WalletForm is aware of the current step via hooks
|
||||
// and can thus render a different form for send vs. receive
|
||||
if (step === Step.SEND) return <WalletForm key={step} />
|
||||
if (step === Step.RECEIVE) return <WalletForm key={step} />
|
||||
return <Settings key={step} />
|
||||
})}
|
||||
</WalletMultiStepFormContextProvider>
|
||||
</div>
|
||||
</WalletLayout>
|
||||
)
|
||||
}
|
||||
|
||||
function WalletForm () {
|
||||
return (
|
||||
<TemplateLogsProvider>
|
||||
<WalletProtocolSelector />
|
||||
<WalletProtocolForm />
|
||||
</TemplateLogsProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function WalletProtocolSelector () {
|
||||
const protocols = useWalletProtocols()
|
||||
const [protocol, selectProtocol] = useProtocol()
|
||||
|
||||
return (
|
||||
<Nav className={classNames(navStyles.nav, 'mt-0')} activeKey={protocol?.name}>
|
||||
{
|
||||
protocols.map(p => {
|
||||
return (
|
||||
<Nav.Item key={p.id} onClick={() => selectProtocol(p)}>
|
||||
<Nav.Link eventKey={p.name}>
|
||||
{protocolDisplayName(p)}
|
||||
</Nav.Link>
|
||||
</Nav.Item>
|
||||
)
|
||||
})
|
||||
}
|
||||
</Nav>
|
||||
)
|
||||
}
|
||||
|
||||
function WalletProtocolForm () {
|
||||
const wallet = useWallet()
|
||||
const [protocol] = useProtocol()
|
||||
const next = useNext()
|
||||
const testSendPayment = useTestSendPayment(protocol)
|
||||
const testCreateInvoice = useTestCreateInvoice(protocol)
|
||||
const logger = useWalletLogger(protocol)
|
||||
const [{ fields, initial, schema }, setFormState] = useProtocolForm(protocol)
|
||||
|
||||
// create a copy of values to avoid mutating the original
|
||||
const onSubmit = useCallback(async ({ ...values }) => {
|
||||
const lud16Domain = walletLud16Domain(wallet.name)
|
||||
if (values.address && lud16Domain) {
|
||||
values.address = `${values.address}@${lud16Domain}`
|
||||
}
|
||||
|
||||
const name = protocolLogName(protocol)
|
||||
|
||||
if (isTemplate(protocol)) {
|
||||
values.enabled = true
|
||||
}
|
||||
|
||||
if (values.enabled) {
|
||||
try {
|
||||
if (protocol.send) {
|
||||
logger.info(`testing ${name} send ...`)
|
||||
const additionalValues = await testSendPayment(values)
|
||||
values = { ...values, ...additionalValues }
|
||||
logger.ok(`${name} send ok`)
|
||||
} else {
|
||||
logger.info(`testing ${name} receive ...`)
|
||||
await testCreateInvoice(values)
|
||||
logger.ok(`${name} receive ok`)
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error(err.message)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
setFormState(values)
|
||||
next()
|
||||
}, [protocol, wallet, setFormState, testSendPayment, logger, next])
|
||||
|
||||
return (
|
||||
<>
|
||||
<Form
|
||||
key={`form-${protocol.id}`}
|
||||
enableReinitialize
|
||||
initial={initial}
|
||||
schema={schema}
|
||||
onSubmit={onSubmit}
|
||||
>
|
||||
{fields.map(field => <WalletProtocolFormField key={field.name} {...field} />)}
|
||||
{!isTemplate(protocol) && <Checkbox name='enabled' label='enabled' />}
|
||||
<WalletProtocolFormNavigator />
|
||||
</Form>
|
||||
<WalletLogs className='mt-3' protocol={protocol} key={`logs-${protocol.id}`} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function WalletProtocolFormNavigator () {
|
||||
const wallet = useWallet()
|
||||
const stepIndex = useStepIndex()
|
||||
const maxSteps = useMaxSteps()
|
||||
const [formState] = useFormState()
|
||||
|
||||
// was something already configured or was something configured just now?
|
||||
const configExists = (isWallet(wallet) && wallet.protocols.length > 0) || Object.keys(formState).length > 0
|
||||
|
||||
// don't allow going to settings as last step with nothing configured
|
||||
const hideSkip = stepIndex === maxSteps - 2 && !configExists
|
||||
|
||||
return (
|
||||
<div className='d-flex justify-content-end align-items-center'>
|
||||
{stepIndex === 0 ? <CancelButton>cancel</CancelButton> : <BackButton />}
|
||||
{!hideSkip ? <SkipButton /> : <div className='ms-auto' />}
|
||||
<SubmitButton variant='primary' className='ps-3 pe-2 d-flex align-items-center'>
|
||||
next
|
||||
<ArrowRight width={24} height={24} />
|
||||
</SubmitButton>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function WalletProtocolFormField ({ type, ...props }) {
|
||||
const wallet = useWallet()
|
||||
const [protocol] = useProtocol()
|
||||
|
||||
function transform ({ validate, encrypt, editable, help, share, ...props }) {
|
||||
const [upperHint, bottomHint] = Array.isArray(props.hint) ? props.hint : [null, props.hint]
|
||||
|
||||
const parseHelpText = text => Array.isArray(text) ? text.join('\n\n') : text
|
||||
const _help = help
|
||||
? (
|
||||
typeof help === 'string'
|
||||
? { label: null, text: help }
|
||||
: (
|
||||
Array.isArray(help)
|
||||
? { label: null, text: parseHelpText(help) }
|
||||
: { label: help.label, text: parseHelpText(help.text) }
|
||||
)
|
||||
)
|
||||
: null
|
||||
|
||||
const readOnly = !!protocol.config?.[props.name] && editable === false
|
||||
|
||||
const label = (
|
||||
<div className='d-flex align-items-center'>
|
||||
{props.label}
|
||||
{_help && (
|
||||
<Info label={_help.label}>
|
||||
<Text>{_help.text}</Text>
|
||||
</Info>
|
||||
)}
|
||||
<small className='text-muted ms-2'>
|
||||
{upperHint
|
||||
? <Text>{upperHint}</Text>
|
||||
: (!props.required ? 'optional' : null)}
|
||||
</small>
|
||||
</div>
|
||||
)
|
||||
|
||||
return { ...props, hint: bottomHint, label, readOnly }
|
||||
}
|
||||
|
||||
switch (type) {
|
||||
case 'text': {
|
||||
let append
|
||||
const lud16Domain = walletLud16Domain(wallet.name)
|
||||
if (props.name === 'address' && lud16Domain) {
|
||||
append = <InputGroup.Text className='text-monospace'>@{lud16Domain}</InputGroup.Text>
|
||||
}
|
||||
return <Input {...transform(props)} append={append} />
|
||||
}
|
||||
case 'password':
|
||||
return <PasswordInput {...transform(props)} />
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
280
wallets/client/components/form/settings.js
Normal file
280
wallets/client/components/form/settings.js
Normal file
@ -0,0 +1,280 @@
|
||||
import { useCallback } from 'react'
|
||||
import Button from 'react-bootstrap/Button'
|
||||
import InputGroup from 'react-bootstrap/InputGroup'
|
||||
import { useField } from 'formik'
|
||||
import classNames from 'classnames'
|
||||
import { useRouter } from 'next/router'
|
||||
import { useMutation, useQuery } from '@apollo/client'
|
||||
import { Checkbox, Form, Input, SubmitButton } from '@/components/form'
|
||||
import Info from '@/components/info'
|
||||
import { useToast } from '@/components/toast'
|
||||
import AccordianItem from '@/components/accordian-item'
|
||||
import { isNumber } from '@/lib/format'
|
||||
import { walletSettingsSchema } from '@/lib/validate'
|
||||
import styles from '@/styles/wallet.module.css'
|
||||
import { useShowModal } from '@/components/modal'
|
||||
import { SET_WALLET_SETTINGS, WALLET_SETTINGS } from '@/wallets/client/fragments'
|
||||
import { useWalletDelete } from '@/wallets/client/hooks'
|
||||
|
||||
import { useSaveWallet, useWallet } from './hooks'
|
||||
import { BackButton } from './button'
|
||||
import { isWallet } from '@/wallets/lib/util'
|
||||
|
||||
export function Settings () {
|
||||
const wallet = useWallet()
|
||||
const { data } = useQuery(WALLET_SETTINGS)
|
||||
const [setSettings] = useMutation(SET_WALLET_SETTINGS)
|
||||
const toaster = useToast()
|
||||
const saveWallet = useSaveWallet()
|
||||
const router = useRouter()
|
||||
|
||||
const onSubmit = useCallback(async (settings) => {
|
||||
try {
|
||||
await saveWallet()
|
||||
await setSettings({
|
||||
variables: { settings },
|
||||
update: (cache, { data }) => {
|
||||
cache.writeQuery({
|
||||
query: WALLET_SETTINGS,
|
||||
data: {
|
||||
walletSettings: {
|
||||
__typename: 'WalletSettings',
|
||||
...data?.setWalletSettings
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
router.push('/wallets')
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
toaster.danger('failed to save wallet')
|
||||
}
|
||||
}, [saveWallet, setSettings, toaster, router])
|
||||
|
||||
const initial = {
|
||||
receiveCreditsBelowSats: data?.walletSettings?.receiveCreditsBelowSats ?? 10,
|
||||
sendCreditsBelowSats: data?.walletSettings?.sendCreditsBelowSats ?? 10,
|
||||
autoWithdrawThreshold: data?.walletSettings?.autoWithdrawThreshold ?? 10000,
|
||||
autoWithdrawMaxFeePercent: data?.walletSettings?.autoWithdrawMaxFeePercent ?? 1,
|
||||
autoWithdrawMaxFeeTotal: data?.walletSettings?.autoWithdrawMaxFeeTotal ?? 1,
|
||||
proxyReceive: data?.walletSettings?.proxyReceive ?? true
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Form
|
||||
enableReinitialize
|
||||
initial={initial}
|
||||
schema={walletSettingsSchema}
|
||||
onSubmit={onSubmit}
|
||||
>
|
||||
<GlobalSettings />
|
||||
<div className='d-flex mt-5 justify-content-end align-items-center'>
|
||||
<BackButton className='me-auto' />
|
||||
{isWallet(wallet) && <WalletDeleteButton className='me-2' />}
|
||||
<SubmitButton variant='primary'>save</SubmitButton>
|
||||
</div>
|
||||
</Form>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function Separator ({ children, className }) {
|
||||
return (
|
||||
<div className={classNames(styles.separator, 'fw-bold', className)}>{children}</div>
|
||||
)
|
||||
}
|
||||
|
||||
function WalletDeleteButton ({ className }) {
|
||||
const showModal = useShowModal()
|
||||
const wallet = useWallet()
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant='danger'
|
||||
className={className}
|
||||
onClick={() => {
|
||||
showModal(onClose => {
|
||||
// need to pass wallet as prop because the modal can't use the hooks
|
||||
// since it's not rendered as a children of the form
|
||||
return <WalletDeleteObstacle wallet={wallet} onClose={onClose} />
|
||||
})
|
||||
}}
|
||||
>delete
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
function WalletDeleteObstacle ({ wallet, onClose }) {
|
||||
const deleteWallet = useWalletDelete(wallet)
|
||||
const toaster = useToast()
|
||||
const router = useRouter()
|
||||
|
||||
const onClick = useCallback(async () => {
|
||||
try {
|
||||
await deleteWallet()
|
||||
onClose()
|
||||
router.push('/wallets')
|
||||
} catch (err) {
|
||||
console.error('failed to delete wallet:', err)
|
||||
toaster.danger('failed to delete wallet')
|
||||
}
|
||||
}, [deleteWallet, onClose, toaster, router])
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h4>Delete wallet</h4>
|
||||
<p className='line-height-md fw-bold mt-3'>
|
||||
Are you sure you want to delete this wallet?
|
||||
</p>
|
||||
<div className='mt-3 d-flex justify-content-end align-items-center'>
|
||||
<Button className='me-3 text-muted nav-link fw-bold' variant='link' onClick={onClose}>cancel</Button>
|
||||
<Button variant='danger' onClick={onClick}>delete </Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function GlobalSettings () {
|
||||
return (
|
||||
<>
|
||||
<Separator>global settings</Separator>
|
||||
<AutowithdrawSettings />
|
||||
<AccordianItem
|
||||
header='advanced'
|
||||
body={
|
||||
<>
|
||||
<LightningAddressSettings />
|
||||
<CowboyCreditsSettings />
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function AutowithdrawSettings () {
|
||||
const [{ value: threshold }] = useField('autoWithdrawThreshold')
|
||||
const sendThreshold = Math.max(Math.floor(threshold / 10), 1)
|
||||
|
||||
return (
|
||||
<>
|
||||
<Input
|
||||
label='desired balance'
|
||||
name='autoWithdrawThreshold'
|
||||
hint={isNumber(sendThreshold) ? `will attempt autowithdrawal when your balance exceeds ${sendThreshold * 11} sats` : undefined}
|
||||
append={<InputGroup.Text className='text-monospace'>sats</InputGroup.Text>}
|
||||
required
|
||||
type='number'
|
||||
min={0}
|
||||
groupClassName='mb-2'
|
||||
/>
|
||||
<Input
|
||||
label={
|
||||
<div className='d-flex align-items-center'>
|
||||
max fee rate
|
||||
<Info>
|
||||
<ul>
|
||||
<li>configure fee budget for autowithdrawals</li>
|
||||
<li>if max fee total is higher for a withdrawal, we will use it instead to find a route</li>
|
||||
<li>higher fee settings increase the likelihood of successful withdrawals</li>
|
||||
</ul>
|
||||
</Info>
|
||||
</div>
|
||||
}
|
||||
name='autoWithdrawMaxFeePercent'
|
||||
append={<InputGroup.Text>%</InputGroup.Text>}
|
||||
required
|
||||
type='number'
|
||||
min={0}
|
||||
/>
|
||||
<Input
|
||||
label={
|
||||
<div className='d-flex align-items-center'>
|
||||
max fee total
|
||||
<Info>
|
||||
<ul>
|
||||
<li>configure fee budget for autowithdrawals</li>
|
||||
<li>if max fee rate is higher for a withdrawal, we will use it instead to find a route to your wallet</li>
|
||||
<li>higher fee settings increase the likelihood of successful withdrawals</li>
|
||||
</ul>
|
||||
</Info>
|
||||
</div>
|
||||
}
|
||||
name='autoWithdrawMaxFeeTotal'
|
||||
append={<InputGroup.Text className='text-monospace'>sats</InputGroup.Text>}
|
||||
required
|
||||
type='number'
|
||||
min={0}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function LightningAddressSettings () {
|
||||
return (
|
||||
<>
|
||||
<Checkbox
|
||||
label={
|
||||
<div className='d-flex align-items-center'>enhance privacy of my lightning address
|
||||
<Info>
|
||||
<ul>
|
||||
<li>Enabling this setting hides details (ie node pubkey) of your attached wallets when anyone pays your SN lightning address or lnurl-pay</li>
|
||||
<li>The lightning invoice will appear to have SN's node as the destination to preserve your wallet's privacy</li>
|
||||
<li className='fw-bold'>This will incur in a 10% fee</li>
|
||||
<li>Disable this setting to receive payments directly to your attached wallets (which will reveal their details to the payer)</li>
|
||||
<li>Note: this privacy behavior is standard for internal zaps/payments on SN, and this setting only applies to external payments</li>
|
||||
</ul>
|
||||
</Info>
|
||||
</div>
|
||||
}
|
||||
name='proxyReceive'
|
||||
groupClassName='mb-3'
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function CowboyCreditsSettings () {
|
||||
return (
|
||||
<>
|
||||
<Input
|
||||
label={
|
||||
<div className='d-flex align-items-center'>
|
||||
receive credits for zaps below
|
||||
<Info>
|
||||
<ul>
|
||||
<li>we will not attempt to forward zaps below this amount to you, you will receive credits instead</li>
|
||||
<li>this setting is useful if small amounts are expensive to receive for you</li>
|
||||
</ul>
|
||||
</Info>
|
||||
</div>
|
||||
}
|
||||
name='receiveCreditsBelowSats'
|
||||
required
|
||||
append={<InputGroup.Text className='text-monospace'>sats</InputGroup.Text>}
|
||||
type='number'
|
||||
min={0}
|
||||
/>
|
||||
<Input
|
||||
label={
|
||||
<div className='d-flex align-items-center'>
|
||||
send credits for zaps below
|
||||
<Info>
|
||||
<ul>
|
||||
<li>we will not attempt to send zaps below this amount from your wallet if you have enough credits</li>
|
||||
<li>this setting is useful if small amounts are expensive to send for you</li>
|
||||
</ul>
|
||||
</Info>
|
||||
</div>
|
||||
}
|
||||
name='sendCreditsBelowSats'
|
||||
required
|
||||
append={<InputGroup.Text className='text-monospace'>sats</InputGroup.Text>}
|
||||
type='number'
|
||||
min={0}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
@ -1,345 +0,0 @@
|
||||
import { useCallback, useMemo, createContext, useContext } from 'react'
|
||||
import { Button, InputGroup, Nav } from 'react-bootstrap'
|
||||
import Link from 'next/link'
|
||||
import { useParams, usePathname } from 'next/navigation'
|
||||
import { useRouter } from 'next/router'
|
||||
import { WalletLayout, WalletLayoutHeader, WalletLayoutImageOrName, WalletLogs } from '@/wallets/client/components'
|
||||
import { protocolDisplayName, protocolFields, protocolClientSchema, unurlify, urlify, isWallet, isTemplate, walletLud16Domain } from '@/wallets/lib/util'
|
||||
import styles from '@/styles/wallet.module.css'
|
||||
import navStyles from '@/styles/nav.module.css'
|
||||
import { Checkbox, Form, Input, PasswordInput, SubmitButton } from '@/components/form'
|
||||
import CancelButton from '@/components/cancel-button'
|
||||
import { useWalletProtocolUpsert, useWalletProtocolRemove, useProtocolTemplates, TemplateLogsProvider } from '@/wallets/client/hooks'
|
||||
import { useToast } from '@/components/toast'
|
||||
import Text from '@/components/text'
|
||||
import Info from '@/components/info'
|
||||
import classNames from 'classnames'
|
||||
|
||||
const WalletFormsContext = createContext()
|
||||
|
||||
export function WalletForms ({ wallet, refetch }) {
|
||||
return (
|
||||
<WalletLayout>
|
||||
<div className={styles.form}>
|
||||
<WalletLayoutHeader>
|
||||
{wallet && <WalletLayoutImageOrName name={wallet.name} maxHeight='80px' />}
|
||||
</WalletLayoutHeader>
|
||||
{wallet && (
|
||||
<WalletFormsProvider wallet={wallet} refetch={refetch}>
|
||||
<WalletFormSelector />
|
||||
</WalletFormsProvider>
|
||||
)}
|
||||
</div>
|
||||
</WalletLayout>
|
||||
)
|
||||
}
|
||||
|
||||
function WalletFormsProvider ({ children, wallet, refetch }) {
|
||||
const value = useMemo(() => ({ refetch, wallet }), [refetch, wallet])
|
||||
return (
|
||||
<WalletFormsContext.Provider value={value}>
|
||||
{children}
|
||||
</WalletFormsContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
function useWalletRefetch () {
|
||||
const { refetch } = useContext(WalletFormsContext)
|
||||
return refetch
|
||||
}
|
||||
|
||||
function useWallet () {
|
||||
const { wallet } = useContext(WalletFormsContext)
|
||||
return wallet
|
||||
}
|
||||
|
||||
function WalletFormSelector () {
|
||||
const sendRecvParam = useSendRecvParam()
|
||||
const protocolParam = useWalletProtocolParam()
|
||||
|
||||
return (
|
||||
<>
|
||||
<WalletSendRecvSelector />
|
||||
{sendRecvParam && (
|
||||
<div className='position-relative'>
|
||||
<div>
|
||||
<WalletProtocolSelector />
|
||||
{protocolParam && (
|
||||
<TemplateLogsProvider>
|
||||
<WalletProtocolForm />
|
||||
</TemplateLogsProvider>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function WalletSendRecvSelector () {
|
||||
const wallet = useWallet()
|
||||
const path = useWalletPathname()
|
||||
const protocols = useProtocolTemplates(wallet)
|
||||
const selected = useSendRecvParam()
|
||||
|
||||
const firstSend = protocols.find(p => p.send)
|
||||
const firstRecv = protocols.find(p => !p.send)
|
||||
|
||||
// TODO(wallet-v2): if you click a nav link again, it will update the URL
|
||||
// but not run the effect again to select the first protocol by default
|
||||
return (
|
||||
<Nav
|
||||
key={path}
|
||||
className={classNames(navStyles.nav, 'justify-content-center')}
|
||||
activeKey={selected}
|
||||
>
|
||||
<Nav.Item>
|
||||
<Link href={`/${path}/send${firstSend ? `/${urlify(firstSend.name)}` : ''}`} passHref legacyBehavior replace>
|
||||
<Nav.Link className='ps-3' eventKey='send'>SEND</Nav.Link>
|
||||
</Link>
|
||||
</Nav.Item>
|
||||
<Nav.Item>
|
||||
<Link href={`/${path}/receive${firstRecv ? `/${urlify(firstRecv.name)}` : ''}`} passHref legacyBehavior replace>
|
||||
<Nav.Link className='ps-3' eventKey='receive'>RECEIVE</Nav.Link>
|
||||
</Link>
|
||||
</Nav.Item>
|
||||
</Nav>
|
||||
)
|
||||
}
|
||||
|
||||
function WalletProtocolSelector () {
|
||||
const walletPath = useWalletPathname()
|
||||
const sendRecvParam = useSendRecvParam()
|
||||
const selected = useWalletProtocolParam()
|
||||
const path = `${walletPath}/${sendRecvParam}`
|
||||
|
||||
const wallet = useWallet()
|
||||
const protocols = useProtocolTemplates(wallet).filter(p => sendRecvParam === 'send' ? p.send : !p.send)
|
||||
|
||||
if (protocols.length === 0) {
|
||||
// TODO(wallet-v2): let user know how to request support if the wallet actually does support sending
|
||||
return (
|
||||
<div className='mt-3 text-muted text-center'>
|
||||
{sendRecvParam === 'send' ? 'sending' : 'receiving'} not supported
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Nav
|
||||
key={path}
|
||||
className={classNames(navStyles.nav, 'justify-content-start mt-0')}
|
||||
activeKey={selected}
|
||||
>
|
||||
{
|
||||
protocols.map(p => (
|
||||
<Nav.Item key={p.name}>
|
||||
<Link href={`/${path}/${urlify(p.name)}`} passHref legacyBehavior replace>
|
||||
<Nav.Link eventKey={p.name}>{protocolDisplayName(p)}</Nav.Link>
|
||||
</Link>
|
||||
</Nav.Item>
|
||||
))
|
||||
}
|
||||
</Nav>
|
||||
)
|
||||
}
|
||||
|
||||
function WalletProtocolForm () {
|
||||
const sendRecvParam = useSendRecvParam()
|
||||
const router = useRouter()
|
||||
const protocol = useSelectedProtocol()
|
||||
if (!protocol) return null
|
||||
|
||||
// I think it is okay to skip this hook if the protocol is not found
|
||||
// because we will need to change the URL to get a different protocol
|
||||
// so the amount of rendered hooks should stay the same during the lifecycle of this component
|
||||
const wallet = useWallet()
|
||||
const upsertWalletProtocol = useWalletProtocolUpsert(wallet, protocol)
|
||||
const toaster = useToast()
|
||||
const refetch = useWalletRefetch()
|
||||
|
||||
const { fields, initial, schema } = useProtocolForm(protocol)
|
||||
|
||||
// create a copy of values to avoid mutating the original
|
||||
const onSubmit = useCallback(async ({ ...values }) => {
|
||||
const lud16Domain = walletLud16Domain(wallet.name)
|
||||
if (values.address && lud16Domain) {
|
||||
values.address = `${values.address}@${lud16Domain}`
|
||||
}
|
||||
|
||||
const upsert = await upsertWalletProtocol(values)
|
||||
if (isWallet(wallet)) {
|
||||
toaster.success('wallet saved')
|
||||
refetch()
|
||||
return
|
||||
}
|
||||
// we just created a new user wallet from a template
|
||||
router.replace(`/wallets/${upsert.id}/${sendRecvParam}/${urlify(protocol.name)}`, null, { shallow: true })
|
||||
toaster.success('wallet attached', { persistOnNavigate: true })
|
||||
}, [upsertWalletProtocol, toaster, wallet, router])
|
||||
|
||||
return (
|
||||
<>
|
||||
<Form
|
||||
key={router.asPath}
|
||||
enableReinitialize
|
||||
initial={initial}
|
||||
schema={schema}
|
||||
onSubmit={onSubmit}
|
||||
>
|
||||
{fields.map(field => <WalletProtocolFormField key={field.name} {...field} />)}
|
||||
<Checkbox name='enabled' label='enabled' disabled={isTemplate(protocol)} />
|
||||
<WalletProtocolFormButtons />
|
||||
</Form>
|
||||
<WalletLogs className='mt-3' protocol={protocol} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function WalletProtocolFormButtons () {
|
||||
const protocol = useSelectedProtocol()
|
||||
const removeWalletProtocol = useWalletProtocolRemove(protocol)
|
||||
const refetch = useWalletRefetch()
|
||||
const router = useRouter()
|
||||
const wallet = useWallet()
|
||||
const isLastProtocol = wallet.protocols.length === 1
|
||||
|
||||
const onDetach = useCallback(async () => {
|
||||
await removeWalletProtocol()
|
||||
if (isLastProtocol) {
|
||||
router.replace('/wallets', null, { shallow: true })
|
||||
return
|
||||
}
|
||||
refetch()
|
||||
}, [removeWalletProtocol, refetch, isLastProtocol, router])
|
||||
|
||||
return (
|
||||
<div className='d-flex justify-content-end'>
|
||||
{!isTemplate(protocol) && <Button variant='grey-medium' className='me-auto' onClick={onDetach}>detach</Button>}
|
||||
<CancelButton>cancel</CancelButton>
|
||||
<SubmitButton variant='primary'>{isWallet(wallet) ? 'save' : 'attach'}</SubmitButton>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function WalletProtocolFormField ({ type, ...props }) {
|
||||
const wallet = useWallet()
|
||||
const protocol = useSelectedProtocol()
|
||||
|
||||
function transform ({ validate, encrypt, editable, help, ...props }) {
|
||||
const [upperHint, bottomHint] = Array.isArray(props.hint) ? props.hint : [null, props.hint]
|
||||
|
||||
const parseHelpText = text => Array.isArray(text) ? text.join('\n\n') : text
|
||||
const _help = help
|
||||
? (
|
||||
typeof help === 'string'
|
||||
? { label: null, text: help }
|
||||
: (
|
||||
Array.isArray(help)
|
||||
? { label: null, text: parseHelpText(help) }
|
||||
: { label: help.label, text: parseHelpText(help.text) }
|
||||
)
|
||||
)
|
||||
: null
|
||||
|
||||
const readOnly = !!protocol.config?.[props.name] && editable === false
|
||||
|
||||
const label = (
|
||||
<div className='d-flex align-items-center'>
|
||||
{props.label}
|
||||
{_help && (
|
||||
<Info label={_help.label}>
|
||||
<Text>{_help.text}</Text>
|
||||
</Info>
|
||||
)}
|
||||
<small className='text-muted ms-2'>
|
||||
{upperHint
|
||||
? <Text>{upperHint}</Text>
|
||||
: (!props.required ? 'optional' : null)}
|
||||
</small>
|
||||
</div>
|
||||
)
|
||||
|
||||
return { ...props, hint: bottomHint, label, readOnly }
|
||||
}
|
||||
|
||||
switch (type) {
|
||||
case 'text': {
|
||||
let append
|
||||
const lud16Domain = walletLud16Domain(wallet.name)
|
||||
if (props.name === 'address' && lud16Domain) {
|
||||
append = <InputGroup.Text className='text-monospace'>@{lud16Domain}</InputGroup.Text>
|
||||
}
|
||||
return <Input {...transform(props)} append={append} />
|
||||
}
|
||||
case 'password':
|
||||
return <PasswordInput {...transform(props)} />
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function useWalletPathname () {
|
||||
const pathname = usePathname()
|
||||
// returns /wallets/:name
|
||||
return pathname.split('/').filter(Boolean).slice(0, 2).join('/')
|
||||
}
|
||||
|
||||
function useSendRecvParam () {
|
||||
const params = useParams()
|
||||
// returns only :send in /wallets/:name/:send
|
||||
return ['send', 'receive'].includes(params.slug[1]) ? params.slug[1] : null
|
||||
}
|
||||
|
||||
function useWalletProtocolParam () {
|
||||
const params = useParams()
|
||||
const name = params.slug[2]
|
||||
// returns only :protocol in /wallets/:name/:send/:protocol
|
||||
return name ? unurlify(name) : null
|
||||
}
|
||||
|
||||
function useSelectedProtocol () {
|
||||
const wallet = useWallet()
|
||||
const sendRecvParam = useSendRecvParam()
|
||||
const protocolParam = useWalletProtocolParam()
|
||||
|
||||
const send = sendRecvParam === 'send'
|
||||
let protocol = wallet.protocols.find(p => p.name === protocolParam && p.send === send)
|
||||
if (!protocol && isWallet(wallet)) {
|
||||
// the protocol was not found as configured, look for it in the template
|
||||
protocol = wallet.template.protocols.find(p => p.name === protocolParam && p.send === send)
|
||||
}
|
||||
|
||||
return protocol
|
||||
}
|
||||
|
||||
function useProtocolForm (protocol) {
|
||||
const wallet = useWallet()
|
||||
const lud16Domain = walletLud16Domain(wallet.name)
|
||||
const fields = protocolFields(protocol)
|
||||
const initial = fields.reduce((acc, field) => {
|
||||
// wallet templates don't have a config
|
||||
let value = protocol.config?.[field.name]
|
||||
|
||||
if (field.name === 'address' && lud16Domain && value) {
|
||||
value = value.split('@')[0]
|
||||
}
|
||||
|
||||
return {
|
||||
...acc,
|
||||
[field.name]: value || ''
|
||||
}
|
||||
}, { enabled: protocol.enabled })
|
||||
|
||||
let schema = protocolClientSchema(protocol)
|
||||
if (lud16Domain) {
|
||||
schema = schema.transform(({ address, ...rest }) => {
|
||||
return {
|
||||
address: address ? `${address}@${lud16Domain}` : '',
|
||||
...rest
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return { fields, initial, schema }
|
||||
}
|
@ -1,6 +1,6 @@
|
||||
export * from './card'
|
||||
export * from './draggable'
|
||||
export * from './forms'
|
||||
export * from './form/index'
|
||||
export * from './layout'
|
||||
export * from './passphrase'
|
||||
export * from './logger'
|
||||
|
@ -203,6 +203,11 @@ export const RESET_WALLETS = gql`
|
||||
}
|
||||
`
|
||||
|
||||
export const DELETE_WALLET = gql`
|
||||
mutation deleteWallet($id: ID!) {
|
||||
deleteWallet(id: $id)
|
||||
}`
|
||||
|
||||
export const DISABLE_PASSPHRASE_EXPORT = gql`
|
||||
mutation DisablePassphraseExport {
|
||||
disablePassphraseExport
|
||||
@ -224,7 +229,14 @@ export const WALLET_SETTINGS = gql`
|
||||
|
||||
export const SET_WALLET_SETTINGS = gql`
|
||||
mutation SetWalletSettings($settings: WalletSettingsInput!) {
|
||||
setWalletSettings(settings: $settings)
|
||||
setWalletSettings(settings: $settings) {
|
||||
receiveCreditsBelowSats
|
||||
sendCreditsBelowSats
|
||||
proxyReceive
|
||||
autoWithdrawMaxFeePercent
|
||||
autoWithdrawMaxFeeTotal
|
||||
autoWithdrawThreshold
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
|
@ -13,7 +13,6 @@ import {
|
||||
UPSERT_WALLET_SEND_PHOENIXD,
|
||||
UPSERT_WALLET_SEND_WEBLN,
|
||||
WALLETS,
|
||||
REMOVE_WALLET_PROTOCOL,
|
||||
UPDATE_WALLET_ENCRYPTION,
|
||||
RESET_WALLETS,
|
||||
DISABLE_PASSPHRASE_EXPORT,
|
||||
@ -25,13 +24,14 @@ import {
|
||||
TEST_WALLET_RECEIVE_LIGHTNING_ADDRESS,
|
||||
TEST_WALLET_RECEIVE_NWC,
|
||||
TEST_WALLET_RECEIVE_CLN_REST,
|
||||
TEST_WALLET_RECEIVE_LND_GRPC
|
||||
TEST_WALLET_RECEIVE_LND_GRPC,
|
||||
DELETE_WALLET
|
||||
} from '@/wallets/client/fragments'
|
||||
import { gql, useApolloClient, useMutation, useQuery } from '@apollo/client'
|
||||
import { useDecryption, useEncryption, useSetKey, useWalletLogger, useWalletsUpdatedAt, WalletStatus } from '@/wallets/client/hooks'
|
||||
import { useDecryption, useEncryption, useSetKey, useWalletLoggerFactory, useWalletsUpdatedAt, WalletStatus } from '@/wallets/client/hooks'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import {
|
||||
isEncryptedField, isTemplate, isWallet, protocolAvailable, protocolClientSchema, reverseProtocolRelationName
|
||||
isEncryptedField, isTemplate, isWallet, protocolAvailable, protocolClientSchema, protocolLogName, reverseProtocolRelationName
|
||||
} from '@/wallets/lib/util'
|
||||
import { protocolTestSendPayment } from '@/wallets/client/protocols'
|
||||
import { timeoutSignal } from '@/lib/time'
|
||||
@ -146,37 +146,19 @@ function server2Client (wallet) {
|
||||
return wallet ? undoFieldAlias(checkProtocolAvailability(wallet)) : wallet
|
||||
}
|
||||
|
||||
export function useWalletProtocolUpsert (wallet, protocol) {
|
||||
const mutation = getWalletProtocolUpsertMutation(protocol)
|
||||
const [mutate] = useMutation(mutation)
|
||||
const { encryptConfig } = useEncryptConfig(protocol)
|
||||
const testSendPayment = useTestSendPayment(protocol)
|
||||
const testCreateInvoice = useTestCreateInvoice(protocol)
|
||||
const logger = useWalletLogger(protocol)
|
||||
export function useWalletProtocolUpsert () {
|
||||
const client = useApolloClient()
|
||||
const loggerFactory = useWalletLoggerFactory()
|
||||
const { encryptConfig } = useEncryptConfig()
|
||||
|
||||
return useCallback(async (values) => {
|
||||
logger.info('saving wallet ...')
|
||||
return useCallback(async (wallet, protocol, values) => {
|
||||
const logger = loggerFactory(protocol)
|
||||
const mutation = protocolUpsertMutation(protocol)
|
||||
const name = `${protocolLogName(protocol)} ${protocol.send ? 'send' : 'receive'}`
|
||||
|
||||
if (isTemplate(protocol)) {
|
||||
values.enabled = true
|
||||
}
|
||||
logger.info(`saving ${name} ...`)
|
||||
|
||||
// skip network tests if we're disabling the wallet
|
||||
if (values.enabled) {
|
||||
try {
|
||||
if (protocol.send) {
|
||||
const additionalValues = await testSendPayment(values)
|
||||
values = { ...values, ...additionalValues }
|
||||
} else {
|
||||
await testCreateInvoice(values)
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error(err.message)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
const encrypted = await encryptConfig(values)
|
||||
const encrypted = await encryptConfig(values, { protocol })
|
||||
|
||||
const variables = encrypted
|
||||
if (isWallet(wallet)) {
|
||||
@ -187,8 +169,8 @@ export function useWalletProtocolUpsert (wallet, protocol) {
|
||||
|
||||
let updatedWallet
|
||||
try {
|
||||
const { data } = await mutate({ variables })
|
||||
logger.ok('wallet saved')
|
||||
const { data } = await client.mutate({ mutation, variables })
|
||||
logger.ok(`${name} saved`)
|
||||
updatedWallet = Object.values(data)[0]
|
||||
} catch (err) {
|
||||
logger.error(err.message)
|
||||
@ -198,29 +180,20 @@ export function useWalletProtocolUpsert (wallet, protocol) {
|
||||
requestPersistentStorage()
|
||||
|
||||
return updatedWallet
|
||||
}, [wallet, protocol, logger, testSendPayment, testCreateInvoice, encryptConfig, mutate])
|
||||
}, [client, loggerFactory, encryptConfig])
|
||||
}
|
||||
|
||||
export function useLightningAddressUpsert () {
|
||||
// TODO(wallet-v2): parse domain from address input to use correct wallet template
|
||||
// useWalletProtocolUpsert needs to support passing in the wallet in the callback for that
|
||||
const wallet = { name: 'LN_ADDR', __typename: 'WalletTemplate' }
|
||||
const protocol = { name: 'LN_ADDR', send: false, __typename: 'WalletProtocolTemplate' }
|
||||
return useWalletProtocolUpsert(wallet, protocol)
|
||||
}
|
||||
const wallet = useMemo(() => ({ name: 'LN_ADDR', __typename: 'WalletTemplate' }), [])
|
||||
const protocol = useMemo(() => ({ name: 'LN_ADDR', send: false, __typename: 'WalletProtocolTemplate' }), [])
|
||||
const upsert = useWalletProtocolUpsert()
|
||||
const testCreateInvoice = useTestCreateInvoice(protocol)
|
||||
|
||||
export function useWalletProtocolRemove (protocol) {
|
||||
const [mutate] = useMutation(REMOVE_WALLET_PROTOCOL)
|
||||
const toaster = useToast()
|
||||
|
||||
return useCallback(async () => {
|
||||
try {
|
||||
await mutate({ variables: { id: protocol.id } })
|
||||
toaster.success('protocol detached')
|
||||
} catch (err) {
|
||||
toaster.danger('failed to detach protocol: ' + err.message)
|
||||
}
|
||||
}, [protocol?.id, mutate, toaster])
|
||||
return useCallback(async (values) => {
|
||||
// TODO(wallet-v2): parse domain from address input to use correct wallet template
|
||||
await testCreateInvoice(values)
|
||||
return await upsert(wallet, protocol, { ...values, enabled: true })
|
||||
}, [testCreateInvoice, upsert, wallet, protocol])
|
||||
}
|
||||
|
||||
export function useWalletEncryptionUpdate () {
|
||||
@ -295,7 +268,7 @@ export function useSetWalletPriorities () {
|
||||
// (the mutation would throw if called but we make sure to never call it.)
|
||||
const NOOP_MUTATION = gql`mutation noop { noop }`
|
||||
|
||||
function getWalletProtocolUpsertMutation (protocol) {
|
||||
function protocolUpsertMutation (protocol) {
|
||||
switch (protocol.name) {
|
||||
case 'LNBITS':
|
||||
return protocol.send ? UPSERT_WALLET_SEND_LNBITS : UPSERT_WALLET_RECEIVE_LNBITS
|
||||
@ -320,7 +293,7 @@ function getWalletProtocolUpsertMutation (protocol) {
|
||||
}
|
||||
}
|
||||
|
||||
function getWalletProtocolTestMutation (protocol) {
|
||||
function protocolTestMutation (protocol) {
|
||||
if (protocol.send) return NOOP_MUTATION
|
||||
|
||||
switch (protocol.name) {
|
||||
@ -343,7 +316,7 @@ function getWalletProtocolTestMutation (protocol) {
|
||||
}
|
||||
}
|
||||
|
||||
function useTestSendPayment (protocol) {
|
||||
export function useTestSendPayment (protocol) {
|
||||
return useCallback(async (values) => {
|
||||
return await protocolTestSendPayment(
|
||||
protocol,
|
||||
@ -353,8 +326,8 @@ function useTestSendPayment (protocol) {
|
||||
}, [protocol])
|
||||
}
|
||||
|
||||
function useTestCreateInvoice (protocol) {
|
||||
const mutation = getWalletProtocolTestMutation(protocol)
|
||||
export function useTestCreateInvoice (protocol) {
|
||||
const mutation = protocolTestMutation(protocol)
|
||||
const [testCreateInvoice] = useMutation(mutation)
|
||||
|
||||
return useCallback(async (values) => {
|
||||
@ -362,6 +335,14 @@ function useTestCreateInvoice (protocol) {
|
||||
}, [testCreateInvoice])
|
||||
}
|
||||
|
||||
export function useWalletDelete (wallet) {
|
||||
const [mutate] = useMutation(DELETE_WALLET)
|
||||
|
||||
return useCallback(async () => {
|
||||
await mutate({ variables: { id: wallet.id } })
|
||||
}, [mutate, wallet.id])
|
||||
}
|
||||
|
||||
function useWalletDecryption () {
|
||||
const { decryptConfig, ready } = useDecryptConfig()
|
||||
|
||||
@ -491,7 +472,7 @@ export function useWalletMigrationMutation () {
|
||||
}
|
||||
|
||||
await client.mutate({
|
||||
mutation: getWalletProtocolUpsertMutation(protocol),
|
||||
mutation: protocolUpsertMutation(protocol),
|
||||
variables: {
|
||||
...(walletId ? { walletId } : { templateName }),
|
||||
enabled,
|
||||
|
@ -32,6 +32,7 @@ import webln from './webln'
|
||||
* @property {yup.Schema} validate - validation rules to apply
|
||||
* @property {string} [placeholder] - placeholder text shown in input field
|
||||
* @property {string} [hint] - hint text shown below field
|
||||
* @property {boolean} [share] - whether field can be used to prepopulate field of complementary send/receive protocol
|
||||
*/
|
||||
|
||||
/** @type {Protocol[]} */
|
||||
|
@ -14,7 +14,8 @@ export default [
|
||||
label: 'url',
|
||||
type: 'text',
|
||||
validate: urlValidator('clearnet', 'tor'),
|
||||
required: true
|
||||
required: true,
|
||||
share: true
|
||||
},
|
||||
{
|
||||
name: 'apiKey',
|
||||
@ -37,7 +38,8 @@ export default [
|
||||
label: 'url',
|
||||
type: 'text',
|
||||
validate: urlValidator('clearnet', 'tor'),
|
||||
required: true
|
||||
required: true,
|
||||
share: true
|
||||
},
|
||||
{
|
||||
name: 'apiKey',
|
||||
|
@ -10,6 +10,7 @@ export default [
|
||||
name: 'NWC',
|
||||
send: true,
|
||||
displayName: 'Nostr Wallet Connect',
|
||||
logName: 'NWC',
|
||||
fields: [
|
||||
{
|
||||
name: 'url',
|
||||
@ -27,6 +28,7 @@ export default [
|
||||
name: 'NWC',
|
||||
send: false,
|
||||
displayName: 'Nostr Wallet Connect',
|
||||
logName: 'NWC',
|
||||
fields: [
|
||||
{
|
||||
name: 'url',
|
||||
|
@ -14,7 +14,8 @@ export default [
|
||||
type: 'text',
|
||||
label: 'url',
|
||||
validate: urlValidator('clearnet'),
|
||||
required: true
|
||||
required: true,
|
||||
share: true
|
||||
},
|
||||
{
|
||||
name: 'apiKey',
|
||||
@ -42,7 +43,8 @@ export default [
|
||||
type: 'text',
|
||||
label: 'url',
|
||||
validate: urlValidator('clearnet'),
|
||||
required: true
|
||||
required: true,
|
||||
share: true
|
||||
},
|
||||
{
|
||||
name: 'apiKey',
|
||||
|
@ -30,6 +30,10 @@ export function protocolDisplayName ({ name, send }) {
|
||||
return protocol({ name, send })?.displayName || titleCase(name)
|
||||
}
|
||||
|
||||
export function protocolLogName ({ name, send }) {
|
||||
return protocol({ name, send })?.logName ?? protocolDisplayName({ name, send })
|
||||
}
|
||||
|
||||
export function protocolRelationName ({ name, send }) {
|
||||
return protocol({ name, send })?.relationName
|
||||
}
|
||||
@ -120,3 +124,10 @@ export function isWallet (wallet) {
|
||||
export function isTemplate (obj) {
|
||||
return obj.__typename.endsWith('Template')
|
||||
}
|
||||
|
||||
export function protocolFormId ({ name, send }) {
|
||||
// we don't use the protocol id as the form id because then we can't find the
|
||||
// complementary protocol to share fields between templates and non-templates
|
||||
// by simply flipping send to recv and vice versa
|
||||
return `${name}-${send ? 'send' : 'recv'}`
|
||||
}
|
||||
|
@ -62,12 +62,11 @@ export const resolvers = {
|
||||
}, [])
|
||||
),
|
||||
addWalletLog,
|
||||
removeWalletProtocol,
|
||||
deleteWalletLogs
|
||||
}
|
||||
}
|
||||
|
||||
function testWalletProtocol (protocol) {
|
||||
export function testWalletProtocol (protocol) {
|
||||
return async (parent, args, { me, models, tx }) => {
|
||||
if (!me) {
|
||||
throw new GqlAuthenticationError()
|
||||
@ -223,6 +222,7 @@ export function upsertWalletProtocol (protocol) {
|
||||
}
|
||||
}
|
||||
|
||||
// not exposed to the client via GraphQL API, but used when resetting wallets
|
||||
export async function removeWalletProtocol (parent, { id }, { me, models, tx }) {
|
||||
if (!me) {
|
||||
throw new GqlAuthenticationError()
|
||||
@ -336,7 +336,7 @@ async function deleteWalletLogs (parent, { protocolId, debug }, { me, models })
|
||||
return true
|
||||
}
|
||||
|
||||
async function updateWalletBadges ({ userId, tx }) {
|
||||
export async function updateWalletBadges ({ userId, tx }) {
|
||||
const pushNotifications = []
|
||||
|
||||
const wallets = await tx.wallet.findMany({
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { GqlAuthenticationError, GqlInputError } from '@/lib/error'
|
||||
import { mapWalletResolveTypes } from '@/wallets/server/resolvers/util'
|
||||
import { removeWalletProtocol, upsertWalletProtocol } from './protocol'
|
||||
import { removeWalletProtocol, upsertWalletProtocol, updateWalletBadges } from './protocol'
|
||||
import { validateSchema, walletSettingsSchema } from '@/lib/validate'
|
||||
|
||||
const WalletOrTemplate = {
|
||||
@ -47,7 +47,8 @@ export const resolvers = {
|
||||
resetWallets,
|
||||
setWalletPriorities,
|
||||
disablePassphraseExport,
|
||||
setWalletSettings
|
||||
setWalletSettings,
|
||||
deleteWallet
|
||||
}
|
||||
}
|
||||
|
||||
@ -168,6 +169,17 @@ async function updateKeyHash (parent, { keyHash }, { me, models }) {
|
||||
return count > 0
|
||||
}
|
||||
|
||||
async function deleteWallet (parent, { id }, { me, models }) {
|
||||
if (!me) throw new GqlAuthenticationError()
|
||||
|
||||
await models.$transaction(async tx => {
|
||||
await tx.wallet.delete({ where: { id: Number(id), userId: me.id } })
|
||||
await updateWalletBadges({ userId: me.id, tx })
|
||||
})
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
async function resetWallets (parent, { newKeyHash }, { me, models }) {
|
||||
if (!me) throw new GqlAuthenticationError()
|
||||
|
||||
@ -233,5 +245,5 @@ async function setWalletSettings (parent, { settings }, { me, models }) {
|
||||
|
||||
await models.user.update({ where: { id: me.id }, data: settings })
|
||||
|
||||
return true
|
||||
return settings
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user