diff --git a/src/components/Earn.jsx b/src/components/Earn.tsx similarity index 50% rename from src/components/Earn.jsx rename to src/components/Earn.tsx index c1c2120d..857980a9 100644 --- a/src/components/Earn.jsx +++ b/src/components/Earn.tsx @@ -1,11 +1,11 @@ -import { useEffect, useMemo, useState } from 'react' -import { Formik } from 'formik' +import { useCallback, useEffect, useMemo, useState } from 'react' +import { Formik, FormikErrors } from 'formik' import * as rb from 'react-bootstrap' import { useTranslation } from 'react-i18next' import { useSettings } from '../context/SettingsContext' -import { useCurrentWalletInfo, useReloadCurrentWalletInfo } from '../context/WalletContext' -import { useServiceInfo, useReloadServiceInfo } from '../context/ServiceInfoContext' -import { factorToPercentage, percentageToFactor } from '../utils' +import { CurrentWallet, useCurrentWalletInfo, useReloadCurrentWalletInfo } from '../context/WalletContext' +import { useServiceInfo, useReloadServiceInfo, Offer } from '../context/ServiceInfoContext' +import { factorToPercentage, isValidNumber, percentageToFactor } from '../utils' import * as Api from '../libs/JmWalletApi' import * as fb from './fb/utils' import Sprite from './Sprite' @@ -19,6 +19,7 @@ import { OrderbookOverlay } from './Orderbook' import Balance from './Balance' import styles from './Earn.module.css' import Accordion from './Accordion' +import { TFunction } from 'i18next' // In order to prevent state mismatch, the 'maker stop' response is delayed shortly. // Even though the API response suggests that the maker has started or stopped immediately, it seems that this is not always the case. @@ -34,10 +35,10 @@ const OFFERTYPE_REL = 'sw0reloffer' const OFFERTYPE_ABS = 'sw0absoffer' // can be any of ['sw0reloffer', 'swreloffer', 'reloffer'] -const isRelativeOffer = (offertype) => offertype.includes('reloffer') +const isRelativeOffer = (offertype: string) => offertype.includes('reloffer') // can be any of ['sw0absoffer', 'swabsoffer', 'absoffer'] -const isAbsoluteOffer = (offertype) => offertype.includes('absoffer') +const isAbsoluteOffer = (offertype: string) => offertype.includes('absoffer') const FORM_INPUT_LOCAL_STORAGE_KEYS = { offertype: 'jm-offertype', @@ -46,48 +47,70 @@ const FORM_INPUT_LOCAL_STORAGE_KEYS = { minsize: 'jm-minsize', } -const FORM_INPUT_DEFAULT_VALUES = { +export interface EarnFormValues { + offertype: Api.OfferType + feeRel: number + feeAbs: number + minsize: number +} + +const FORM_INPUT_DEFAULT_VALUES: EarnFormValues = { offertype: OFFERTYPE_REL, feeRel: 0.000_3, feeAbs: 250, minsize: 100_000, } -const persistFormValues = (values) => { +const persistFormValues = (values: EarnFormValues) => { window.localStorage.setItem(FORM_INPUT_LOCAL_STORAGE_KEYS.offertype, values.offertype) - window.localStorage.setItem(FORM_INPUT_LOCAL_STORAGE_KEYS.minsize, values.minsize) + window.localStorage.setItem(FORM_INPUT_LOCAL_STORAGE_KEYS.minsize, String(values.minsize)) if (isRelativeOffer(values.offertype)) { - window.localStorage.setItem(FORM_INPUT_LOCAL_STORAGE_KEYS.feeRel, values.feeRel) + window.localStorage.setItem(FORM_INPUT_LOCAL_STORAGE_KEYS.feeRel, String(values.feeRel)) } if (isAbsoluteOffer(values.offertype)) { - window.localStorage.setItem(FORM_INPUT_LOCAL_STORAGE_KEYS.feeAbs, values.feeAbs) + window.localStorage.setItem(FORM_INPUT_LOCAL_STORAGE_KEYS.feeAbs, String(values.feeAbs)) } } -const initialFormValues = () => ({ - offertype: - window.localStorage.getItem(FORM_INPUT_LOCAL_STORAGE_KEYS.offertype) || FORM_INPUT_DEFAULT_VALUES.offertype, - feeRel: - parseFloat(window.localStorage.getItem(FORM_INPUT_LOCAL_STORAGE_KEYS.feeRel)) || FORM_INPUT_DEFAULT_VALUES.feeRel, - feeAbs: - parseInt(window.localStorage.getItem(FORM_INPUT_LOCAL_STORAGE_KEYS.feeAbs), 10) || FORM_INPUT_DEFAULT_VALUES.feeAbs, - minsize: - parseInt(window.localStorage.getItem(FORM_INPUT_LOCAL_STORAGE_KEYS.minsize), 10) || - FORM_INPUT_DEFAULT_VALUES.minsize, -}) - -const renderOrderType = (val, t) => { - if (isAbsoluteOffer(val)) { +const initialFormValues = (): EarnFormValues => { + const feeRel = parseFloat( + window.localStorage.getItem(FORM_INPUT_LOCAL_STORAGE_KEYS.feeRel) ?? String(FORM_INPUT_DEFAULT_VALUES.feeRel), + ) + const feeAbs = parseInt( + window.localStorage.getItem(FORM_INPUT_LOCAL_STORAGE_KEYS.feeAbs) ?? String(FORM_INPUT_DEFAULT_VALUES.feeAbs), + 10, + ) + const minsize = parseInt( + window.localStorage.getItem(FORM_INPUT_LOCAL_STORAGE_KEYS.minsize) ?? String(FORM_INPUT_DEFAULT_VALUES.minsize), + 10, + ) + const offertype = + window.localStorage.getItem(FORM_INPUT_LOCAL_STORAGE_KEYS.offertype) ?? FORM_INPUT_DEFAULT_VALUES.offertype + return { + offertype, + feeRel: isValidNumber(feeRel) ? feeRel : FORM_INPUT_DEFAULT_VALUES.feeRel, + feeAbs: isValidNumber(feeAbs) ? feeAbs : FORM_INPUT_DEFAULT_VALUES.feeAbs, + minsize: isValidNumber(minsize) ? minsize : FORM_INPUT_DEFAULT_VALUES.minsize, + } +} + +const renderOfferType = (offer: Offer, t: TFunction) => { + if (isAbsoluteOffer(offer.ordertype)) { return {t('earn.current.text_offer_type_absolute')} } - if (isRelativeOffer(val)) { + if (isRelativeOffer(offer.ordertype)) { return {t('earn.current.text_offer_type_relative')} } - return {val} + return {offer.ordertype} +} + +interface CurrentOfferProps { + offer: Offer + nickname: string } -function CurrentOffer({ offer, nickname }) { +function CurrentOffer({ offer, nickname }: CurrentOfferProps) { const { t } = useTranslation() const settings = useSettings() @@ -100,7 +123,7 @@ function CurrentOffer({ offer, nickname }) { {nickname}:{offer.oid} -
{renderOrderType(offer.ordertype, t)}
+
{renderOfferType(offer, t)}
@@ -109,7 +132,7 @@ function CurrentOffer({ offer, nickname }) {
{t('earn.current.text_cjfee')}
{isRelativeOffer(offer.ordertype) ? ( - <>{factorToPercentage(offer.cjfee)}% + <>{factorToPercentage(parseFloat(offer.cjfee) || 0)}% ) : ( <> React.ReactNode | string + onSubmit: (values: EarnFormValues) => Promise + isLoading: boolean + disabled?: boolean +} + +const EarnForm = ({ + initialValues = FORM_INPUT_DEFAULT_VALUES, + submitButtonText, + onSubmit, + isLoading, + disabled = false, +}: EarnFormProps) => { + const { t } = useTranslation() + + const validate = (values: EarnFormValues) => { + const errors = {} as FormikErrors + const isRelOffer = isRelativeOffer(values.offertype) + const isAbsOffer = isAbsoluteOffer(values.offertype) + + if (!isRelOffer && !isAbsOffer) { + // currently no need for translation, this should never occur -> input is controlled by toggle + errors.offertype = 'Offertype is not supported' + } + + if (isRelOffer) { + if (typeof values.feeRel !== 'number' || values.feeRel < feeRelMin || values.feeRel > feeRelMax) { + errors.feeRel = t('earn.feedback_invalid_rel_fee', { + feeRelPercentageMin: `${factorToPercentage(feeRelMin)}%`, + feeRelPercentageMax: `${factorToPercentage(feeRelMax)}%`, + }) + } + } + + if (isAbsOffer) { + if (typeof values.feeAbs !== 'number' || values.feeAbs < 0) { + errors.feeAbs = t('earn.feedback_invalid_abs_fee') + } + } + + if (typeof values.minsize !== 'number' || values.minsize < 0) { + errors.minsize = t('earn.feedback_invalid_min_amount') + } + + return errors + } + + return ( + + {({ handleSubmit, setFieldValue, handleChange, handleBlur, values, touched, errors, isSubmitting }) => ( + <> + + + <> + + { + checked && setFieldValue('offertype', tab.value, true) + }} + initialValue={values.offertype} + disabled={isLoading || isSubmitting} + /> + + {values.offertype === OFFERTYPE_REL ? ( + + + {t('earn.label_rel_fee', { + fee: typeof values.feeRel === 'number' ? `(${factorToPercentage(values.feeRel)}%)` : '', + })} + + {t('earn.description_rel_fee')} + {isLoading ? ( + + + + ) : ( + + + % + + { + const value = e.target.value || '' + setFieldValue('feeRel', value !== '' ? percentageToFactor(parseFloat(value)) : '', true) + }} + onBlur={handleBlur} + value={typeof values.feeRel === 'number' ? factorToPercentage(values.feeRel) : ''} + isValid={touched.feeRel && !errors.feeRel} + isInvalid={touched.feeRel && !!errors.feeRel} + min={0} + step={feeRelPercentageStep} + /> + {errors.feeRel} + + )} + + ) : ( + + + {t('earn.label_abs_fee', { + fee: + typeof values.feeAbs === 'number' + ? `(${values.feeAbs} ${values.feeAbs === 1 ? 'sat' : 'sats'})` + : '', + })} + + {t('earn.description_abs_fee')} + {isLoading ? ( + + + + ) : ( + + + + + + {errors.feeAbs} + + )} + + )} + + + {t('earn.label_min_amount')} + {isLoading ? ( + + + + ) : ( + + + + + + {errors.minsize} + + )} + + + + +
{submitButtonText(isSubmitting)}
+
+
+ + )} +
+ ) +} + +const toStartMakerRequest = (values: EarnFormValues): Api.StartMakerRequest => { + // both fee properties need to be provided. + // prevent providing an invalid value by setting the ignored prop to zero + const cjfee_a = isAbsoluteOffer(values.offertype) ? values.feeAbs : 0 + const cjfee_r = isRelativeOffer(values.offertype) ? values.feeRel : 0 + return { + ordertype: values.offertype, + minsize: values.minsize, + cjfee_a, + cjfee_r, + } +} + +interface EarnProps { + wallet: CurrentWallet +} + +export default function Earn({ wallet }: EarnProps) { const { t } = useTranslation() const settings = useSettings() const currentWalletInfo = useCurrentWalletInfo() @@ -177,47 +421,46 @@ export default function Earn({ wallet }) { const serviceInfo = useServiceInfo() const reloadServiceInfo = useReloadServiceInfo() - const [alert, setAlert] = useState(null) - const [serviceInfoAlert, setServiceInfoAlert] = useState(null) + const [alert, setAlert] = useState() + const [serviceInfoAlert, setServiceInfoAlert] = useState() const [isLoading, setIsLoading] = useState(true) const [isSending, setIsSending] = useState(false) const [isWaitingMakerStart, setIsWaitingMakerStart] = useState(false) const [isWaitingMakerStop, setIsWaitingMakerStop] = useState(false) const [isShowReport, setIsShowReport] = useState(false) const [isShowOrderbook, setIsShowOrderbook] = useState(false) + + const [initialValues, setInitialValues] = useState(initialFormValues()) + const fidelityBonds = useMemo(() => { return currentWalletInfo?.fidelityBondSummary.fbOutputs || [] }, [currentWalletInfo]) - const [moveToJarFidelityBondId, setMoveToJarFidelityBondId] = useState() - - const startMakerService = (ordertype, minsize, cjfee_a, cjfee_r) => { - setIsSending(true) - setIsWaitingMakerStart(true) - - const data = { - ordertype, - minsize, - cjfee_a, - cjfee_r, - } - - // There is no response data to check if maker got started: - // Wait for the websocket or session response! - return ( - Api.postMakerStart({ ...wallet }, data) - .then((res) => (res.ok ? true : Api.Helper.throwError(res))) - // show the loader a little longer to avoid flickering - .then((result) => new Promise((r) => setTimeout(() => r(result), 200))) - .catch((e) => { - setIsWaitingMakerStart(false) - throw e - }) - .finally(() => setIsSending(false)) - ) - } + const [moveToJarFidelityBondId, setMoveToJarFidelityBondId] = useState() + + const startMakerService = useCallback( + (values: EarnFormValues) => { + setIsSending(true) + setIsWaitingMakerStart(true) + + // There is no response data to check if maker got started: + // Wait for the websocket or session response! + return ( + Api.postMakerStart({ ...wallet }, toStartMakerRequest(values)) + .then((res) => (res.ok ? true : Api.Helper.throwError(res))) + // show the loader a little longer to avoid flickering + .then((result) => new Promise((r) => setTimeout(() => r(result), 200))) + .catch((e) => { + setIsWaitingMakerStart(false) + throw e + }) + .finally(() => setIsSending(false)) + ) + }, + [wallet], + ) - const stopMakerService = () => { + const stopMakerService = useCallback(() => { setIsSending(true) setIsWaitingMakerStop(true) @@ -231,7 +474,7 @@ export default function Earn({ wallet }) { throw e }) .finally(() => setIsSending(false)) - } + }, [wallet]) useEffect(() => { if (isSending) return @@ -255,7 +498,7 @@ export default function Earn({ wallet }) { useEffect(() => { if (isSending) return - const makerRunning = serviceInfo?.makerRunning + const makerRunning = serviceInfo?.makerRunning === true const waitingForMakerToStart = isWaitingMakerStart && !makerRunning setIsWaitingMakerStart(waitingForMakerToStart) @@ -269,97 +512,73 @@ export default function Earn({ wallet }) { if (!waiting && makerRunning) { return { variant: 'success', message: t('earn.alert_running') } } else if (!waiting) { - return null + return undefined } return current }) }, [isSending, serviceInfo, isWaitingMakerStart, isWaitingMakerStop, t]) - const reloadFidelityBonds = ({ delay }) => { - const abortCtrl = new AbortController() + const reloadFidelityBonds = useCallback( + ({ delay }: { delay: number }) => { + const abortCtrl = new AbortController() - setIsLoading(true) + setIsLoading(true) - new Promise((resolve) => { - setTimeout(async () => { - resolve(await reloadCurrentWalletInfo.reloadUtxos({ signal: abortCtrl.signal })) - }, delay) - }) - .catch((err) => { - if (abortCtrl.signal.aborted) return - setAlert({ variant: 'danger', message: err.message }) - }) - .finally(() => { - if (abortCtrl.signal.aborted) return - setIsLoading(false) + new Promise((resolve) => { + setTimeout(async () => { + resolve(await reloadCurrentWalletInfo.reloadUtxos({ signal: abortCtrl.signal })) + }, delay) }) - } - - const feeRelMin = 0.0 - const feeRelMax = 0.1 // 10% - const feeRelPercentageStep = 0.0001 + .catch((err) => { + if (abortCtrl.signal.aborted) return + setAlert({ variant: 'danger', message: err.message || t('global.errors.reason_unknown') }) + }) + .finally(() => { + if (abortCtrl.signal.aborted) return + setIsLoading(false) + }) + }, + [reloadCurrentWalletInfo, t], + ) - const initialValues = initialFormValues() + const onSubmitStart = useCallback( + async (values: EarnFormValues) => { + if (isLoading || isSending || isWaitingMakerStart || isWaitingMakerStop) { + return + } - const validate = (values) => { - const errors = {} - const isRelOffer = isRelativeOffer(values.offertype) - const isAbsOffer = isAbsoluteOffer(values.offertype) + setAlert(undefined) - if (!isRelOffer && !isAbsOffer) { - // currently no need for translation, this should never occur -> input is controlled by toggle - errors.offertype = 'Offertype is not supported' - } + try { + persistFormValues(values) + setInitialValues(initialFormValues()) - if (isRelOffer) { - if (typeof values.feeRel !== 'number' || values.feeRel < feeRelMin || values.feeRel > feeRelMax) { - errors.feeRel = t('earn.feedback_invalid_rel_fee', { - feeRelPercentageMin: `${factorToPercentage(feeRelMin)}%`, - feeRelPercentageMax: `${factorToPercentage(feeRelMax)}%`, - }) - } - } + setServiceInfoAlert({ variant: 'success', message: t('earn.alert_starting') }) - if (isAbsOffer) { - if (typeof values.feeAbs !== 'number' || values.feeAbs < 0) { - errors.feeAbs = t('earn.feedback_invalid_abs_fee') + await startMakerService(values) + } catch (e: any) { + setServiceInfoAlert(undefined) + setAlert({ variant: 'danger', message: e.message || t('global.errors.reason_unknown') }) } - } - - if (typeof values.minsize !== 'number' || values.minsize < 0) { - errors.minsize = t('earn.feedback_invalid_min_amount') - } - - return errors - } + }, + [startMakerService, isLoading, isSending, isWaitingMakerStart, isWaitingMakerStop, t], + ) - const onSubmit = async (values) => { + const onSubmitStop = useCallback(async () => { if (isLoading || isSending || isWaitingMakerStart || isWaitingMakerStop) { return } - setAlert(null) + setAlert(undefined) try { - if (serviceInfo?.makerRunning === true) { - setServiceInfoAlert({ variant: 'success', message: t('earn.alert_stopping') }) - await stopMakerService() - } else { - persistFormValues(values) - - setServiceInfoAlert({ variant: 'success', message: t('earn.alert_starting') }) - - // both fee properties need to be provided. - // prevent providing an invalid value by setting the ignored prop to zero - const feeAbs = isAbsoluteOffer(values.offertype) ? values.feeAbs : 0 - const feeRel = isRelativeOffer(values.offertype) ? values.feeRel : 0 - await startMakerService(values.offertype, values.minsize, feeAbs, feeRel) - } - } catch (e) { - setServiceInfoAlert(null) - setAlert({ variant: 'danger', message: e.message }) + setServiceInfoAlert({ variant: 'success', message: t('earn.alert_stopping') }) + await stopMakerService() + } catch (e: any) { + setServiceInfoAlert(undefined) + setAlert({ variant: 'danger', message: e.message || t('global.errors.reason_unknown') }) } - } + }, [stopMakerService, isLoading, isSending, isWaitingMakerStart, isWaitingMakerStop, t]) return (
@@ -382,7 +601,7 @@ export default function Earn({ wallet }) { (serviceInfo?.offers && serviceInfo?.nickname ? ( <> {serviceInfo.offers.map((offer, index) => ( - + ))} ) : ( @@ -465,162 +684,18 @@ export default function Earn({ wallet }) {
)} - {!serviceInfo?.coinjoinInProgress && ( - - {({ handleSubmit, setFieldValue, handleChange, handleBlur, values, touched, errors, isSubmitting }) => ( - <> - - {!serviceInfo?.makerRunning && !isWaitingMakerStart && !isWaitingMakerStop && ( - - <> - - { - checked && setFieldValue('offertype', tab.value, true) - }} - initialValue={values.offertype} - disabled={isLoading || isSubmitting} - /> - - {values.offertype === OFFERTYPE_REL ? ( - - - {t('earn.label_rel_fee', { - fee: - typeof values.feeRel === 'number' ? `(${factorToPercentage(values.feeRel)}%)` : '', - })} - - - {t('earn.description_rel_fee')} - - {isLoading ? ( - - - - ) : ( - - - % - - { - const value = e.target.value || '' - setFieldValue('feeRel', value !== '' ? percentageToFactor(value) : '', true) - }} - onBlur={handleBlur} - value={typeof values.feeRel === 'number' ? factorToPercentage(values.feeRel) : ''} - isValid={touched.feeRel && !errors.feeRel} - isInvalid={touched.feeRel && !!errors.feeRel} - min={0} - step={feeRelPercentageStep} - /> - {errors.feeRel} - - )} - - ) : ( - - - {t('earn.label_abs_fee', { - fee: - typeof values.feeAbs === 'number' - ? `(${values.feeAbs} ${values.feeAbs === 1 ? 'sat' : 'sats'})` - : '', - })} - - - {t('earn.description_abs_fee')} - - {isLoading ? ( - - - - ) : ( - - - - - - {errors.feeAbs} - - )} - - )} - - {t('earn.label_min_amount')} - {isLoading ? ( - - - - ) : ( - - - - - - {errors.minsize} - - )} - - - - )} - -
+ {!serviceInfo?.coinjoinInProgress && ( + <> + {!serviceInfo?.makerRunning && !isWaitingMakerStart && !isWaitingMakerStop ? ( + { + return ( + <> {isWaitingMakerStart || isWaitingMakerStop ? ( <> {serviceInfo?.makerRunning === true ? t('earn.button_stop') : t('earn.button_start')} )} -
-
-
- + + ) + }} + /> + ) : ( + + {({ handleSubmit, isSubmitting }) => ( + + +
+ {isWaitingMakerStart || isWaitingMakerStop ? ( + <> +
+
+
+ )} +
)} -
+ )} @@ -651,7 +766,7 @@ export default function Earn({ wallet }) { setIsShowOrderbook(false)} - nickname={serviceInfo?.nickname} + nickname={serviceInfo?.nickname ?? undefined} /> { }, {} as AddressSummary) } -const toFidelityBondSummary = (res: UtxosResponse): FidenlityBondSummary => { +const toFidelityBondSummary = (res: UtxosResponse): FidelityBondSummary => { const fbOutputs = res.utxos .filter((utxo) => fb.utxo.isFidelityBond(utxo)) .sort((a, b) => { diff --git a/src/libs/JmWalletApi.ts b/src/libs/JmWalletApi.ts index d6329970..ca85ae7a 100644 --- a/src/libs/JmWalletApi.ts +++ b/src/libs/JmWalletApi.ts @@ -99,12 +99,12 @@ interface WalletUnlockRequest { // only support starting the maker with native segwit offers type RelOfferType = 'sw0reloffer' type AbsOfferType = 'sw0absoffer' -type OrderType = RelOfferType | AbsOfferType +type OfferType = RelOfferType | AbsOfferType | string interface StartMakerRequest { cjfee_a: AmountSats cjfee_r: number - ordertype: OrderType + ordertype: OfferType minsize: AmountSats } @@ -553,6 +553,7 @@ export { JmApiError, ApiAuthContext, StartSchedulerRequest, + StartMakerRequest, WalletRequestContext, ApiToken, WalletFileName, @@ -563,5 +564,6 @@ export { UtxoId, Mixdepth, AmountSats, + OfferType, BitcoinAddress, }