From 127f10bfd2bf944c7904df57f28e317163985b08 Mon Sep 17 00:00:00 2001 From: theborakompanioni Date: Tue, 21 Nov 2023 12:18:34 +0100 Subject: [PATCH 01/19] dev: switch to kristapsks backend branch --- .../regtest/dockerfile-deps/joinmarket/latest/Dockerfile | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/docker/regtest/dockerfile-deps/joinmarket/latest/Dockerfile b/docker/regtest/dockerfile-deps/joinmarket/latest/Dockerfile index 871c2875..0b71d25c 100644 --- a/docker/regtest/dockerfile-deps/joinmarket/latest/Dockerfile +++ b/docker/regtest/dockerfile-deps/joinmarket/latest/Dockerfile @@ -7,9 +7,10 @@ RUN apt-get update \ tor \ && rm -rf /var/lib/apt/lists/* -ENV REPO https://github.com/JoinMarket-Org/joinmarket-clientserver -ENV REPO_BRANCH master -ENV REPO_REF master +#ENV REPO https://github.com/JoinMarket-Org/joinmarket-clientserver +ENV REPO https://github.com/kristapsk/joinmarket-clientserver +ENV REPO_BRANCH wallet_rpc-direct-send-txfee +ENV REPO_REF wallet_rpc-direct-send-txfee WORKDIR /src RUN git clone "$REPO" . --depth=10 --branch "$REPO_BRANCH" && git checkout "$REPO_REF" From f48a66d09f627dafc35c248c934c66d3700a30ef Mon Sep 17 00:00:00 2001 From: theborakompanioni Date: Tue, 21 Nov 2023 12:23:45 +0100 Subject: [PATCH 02/19] dev: add directSendTxFee to feature toggles --- src/constants/features.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/constants/features.ts b/src/constants/features.ts index 236fc6c3..c3fb168d 100644 --- a/src/constants/features.ts +++ b/src/constants/features.ts @@ -3,11 +3,13 @@ import { ServiceInfo } from '../context/ServiceInfoContext' interface Features { importWallet: SemVer rescanChain: SemVer + directSendTxFee: SemVer } const features: Features = { importWallet: { major: 0, minor: 9, patch: 10 }, // added in https://github.com/JoinMarket-Org/joinmarket-clientserver/pull/1461 rescanChain: { major: 0, minor: 9, patch: 10 }, // added in https://github.com/JoinMarket-Org/joinmarket-clientserver/pull/1461 + directSendTxFee: { major: 0, minor: 9, patch: 10 }, // added in https://github.com/JoinMarket-Org/joinmarket-clientserver/pull/1597 } type Feature = keyof Features From 3208bfccc8998895c9340c9117f4a1f6608a5f5a Mon Sep 17 00:00:00 2001 From: theborakompanioni Date: Wed, 22 Nov 2023 15:55:56 +0100 Subject: [PATCH 03/19] dev: add tx_fee to DirectSendRequest --- src/libs/JmWalletApi.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/libs/JmWalletApi.ts b/src/libs/JmWalletApi.ts index ca85ae7a..c28b26f4 100644 --- a/src/libs/JmWalletApi.ts +++ b/src/libs/JmWalletApi.ts @@ -112,6 +112,7 @@ interface DirectSendRequest { mixdepth: Mixdepth destination: BitcoinAddress amount_sats: AmountSats + txfee?: number } interface DoCoinjoinRequest { @@ -390,7 +391,10 @@ const postDirectSend = async ({ token, signal, walletFileName }: WalletRequestCo return await fetch(`${basePath()}/v1/wallet/${encodeURIComponent(walletFileName)}/taker/direct-send`, { method: 'POST', headers: { ...Helper.buildAuthHeader(token) }, - body: JSON.stringify(req), + body: JSON.stringify({ + ...req, + txfee: req.txfee ? String(req.txfee) : undefined, + }), signal, }) } From fadf42f7b35dcf3ffd1d22ae8eed7317d3609d79 Mon Sep 17 00:00:00 2001 From: theborakompanioni Date: Wed, 22 Nov 2023 16:56:14 +0100 Subject: [PATCH 04/19] refactor: reusable TxFeeInputField component --- src/components/PaymentConfirmModal.tsx | 25 +-- src/components/settings/FeeConfigModal.tsx | 142 +++-------------- src/components/settings/TxFeeInputField.tsx | 166 ++++++++++++++++++++ src/hooks/Fees.ts | 16 +- 4 files changed, 217 insertions(+), 132 deletions(-) create mode 100644 src/components/settings/TxFeeInputField.tsx diff --git a/src/components/PaymentConfirmModal.tsx b/src/components/PaymentConfirmModal.tsx index 5b5f1520..70987a2f 100644 --- a/src/components/PaymentConfirmModal.tsx +++ b/src/components/PaymentConfirmModal.tsx @@ -4,17 +4,20 @@ import * as rb from 'react-bootstrap' import Sprite from './Sprite' import Balance from './Balance' import { useSettings } from '../context/SettingsContext' -import { FeeValues, useEstimatedMaxCollaboratorFee, toTxFeeValueUnit } from '../hooks/Fees' +import { FeeValues, TxFee, useEstimatedMaxCollaboratorFee } from '../hooks/Fees' import { ConfirmModal, ConfirmModalProps } from './Modal' import styles from './PaymentConfirmModal.module.css' import { AmountSats } from '../libs/JmWalletApi' import { jarInitial } from './jars/Jar' import { isValidNumber } from '../utils' -const feeRange: (feeValues: FeeValues) => [number, number] = (feeValues) => { - const feeTargetInSatsPerVByte = feeValues.tx_fees! / 1_000 +const feeRange: (txFee: TxFee, txFeeFactor: number) => [number, number] = (txFee, txFeeFactor) => { + if (txFee.unit !== 'sats/kilo-vbyte') { + throw new Error('This function can only be used with unit `sats/kilo-vbyte`') + } + const feeTargetInSatsPerVByte = txFee.value! / 1_000 const minFeeSatsPerVByte = Math.max(1, feeTargetInSatsPerVByte) - const maxFeeSatsPerVByte = feeTargetInSatsPerVByte * (1 + feeValues.tx_fees_factor!) + const maxFeeSatsPerVByte = feeTargetInSatsPerVByte * (1 + txFeeFactor) return [minFeeSatsPerVByte, maxFeeSatsPerVByte] } @@ -23,15 +26,17 @@ const useMiningFeeText = ({ feeConfigValues }: { feeConfigValues?: FeeValues }) const miningFeeText = useMemo(() => { if (!feeConfigValues) return null - if (!isValidNumber(feeConfigValues.tx_fees) || !isValidNumber(feeConfigValues.tx_fees_factor)) return null + if (!isValidNumber(feeConfigValues.tx_fees?.value) || !isValidNumber(feeConfigValues.tx_fees_factor)) return null - const unit = toTxFeeValueUnit(feeConfigValues.tx_fees) - if (!unit) { + if (!feeConfigValues.tx_fees?.unit) { return null - } else if (unit === 'blocks') { - return t('send.confirm_send_modal.text_miner_fee_in_targeted_blocks', { count: feeConfigValues.tx_fees }) + } else if (feeConfigValues.tx_fees.unit === 'blocks') { + return t('send.confirm_send_modal.text_miner_fee_in_targeted_blocks', { count: feeConfigValues.tx_fees.value }) } else { - const [minFeeSatsPerVByte, maxFeeSatsPerVByte] = feeRange(feeConfigValues) + const [minFeeSatsPerVByte, maxFeeSatsPerVByte] = feeRange( + feeConfigValues.tx_fees, + feeConfigValues.tx_fees_factor!, + ) const fractionDigits = 2 if (minFeeSatsPerVByte.toFixed(fractionDigits) === maxFeeSatsPerVByte.toFixed(fractionDigits)) { diff --git a/src/components/settings/FeeConfigModal.tsx b/src/components/settings/FeeConfigModal.tsx index 881ffcda..ff5a2e1c 100644 --- a/src/components/settings/FeeConfigModal.tsx +++ b/src/components/settings/FeeConfigModal.tsx @@ -1,16 +1,16 @@ import { forwardRef, useRef, useCallback, useEffect, useState } from 'react' import * as rb from 'react-bootstrap' import { Trans, useTranslation } from 'react-i18next' -import { Formik, FormikErrors, FormikProps } from 'formik' +import { Formik, FormikErrors, FormikProps, Field } from 'formik' import classNames from 'classnames' -import { FEE_CONFIG_KEYS, TxFeeValueUnit, toTxFeeValueUnit, FeeValues, useLoadFeeConfigValues } from '../../hooks/Fees' -import { useUpdateConfigValues } from '../../context/ServiceConfigContext' -import { isValidNumber, factorToPercentage, percentageToFactor } from '../../utils' import Sprite from '../Sprite' -import SegmentedTabs from '../SegmentedTabs' -import styles from './FeeConfigModal.module.css' +import TxFeeInputField from './TxFeeInputField' +import { FEE_CONFIG_KEYS, FeeValues, useLoadFeeConfigValues } from '../../hooks/Fees' +import { useUpdateConfigValues } from '../../context/ServiceConfigContext' import { isDebugFeatureEnabled } from '../../constants/debugFeatures' import ToggleSwitch from '../ToggleSwitch' +import { isValidNumber, factorToPercentage, percentageToFactor } from '../../utils' +import styles from './FeeConfigModal.module.css' const __dev_allowFeeValuesReset = isDebugFeatureEnabled('allowFeeValuesReset') @@ -63,8 +63,8 @@ type FeeFormValues = FeeValues & { interface FeeConfigFormProps { initialValues: FeeFormValues - validate: (values: FeeFormValues, txFeesUnit: TxFeeValueUnit) => FormikErrors - onSubmit: (values: FeeFormValues, txFeesUnit: TxFeeValueUnit) => void + validate: (values: FeeValues) => FormikErrors + onSubmit: (values: FeeValues) => void defaultActiveSectionKey?: FeeConfigSectionKey } @@ -75,14 +75,12 @@ const FeeConfigForm = forwardRef( ) => { const { t, i18n } = useTranslation() - const [txFeesUnit, setTxFeesUnit] = useState(toTxFeeValueUnit(initialValues.tx_fees) || 'blocks') - return ( validate(values, txFeesUnit)} - onSubmit={(values) => onSubmit(values, txFeesUnit)} + validate={(values) => validate(values)} + onSubmit={(values) => onSubmit(values)} > {({ handleSubmit, setFieldValue, handleBlur, validateForm, values, touched, errors, isSubmitting }) => ( @@ -203,102 +201,8 @@ const FeeConfigForm = forwardRef(
{t('settings.fees.description_general_fee_settings')}
- {t('settings.fees.label_tx_fees')} - {txFeesUnit && ( - - { - const value = tab.value as TxFeeValueUnit - setTxFeesUnit(value) - - if (values.tx_fees) { - if (value === 'sats/kilo-vbyte') { - setFieldValue('tx_fees', values.tx_fees * 1_000, false) - } else { - setFieldValue('tx_fees', Math.round(values.tx_fees / 1_000), false) - } - } - setTimeout(() => validateForm(), 4) - }} - initialValue={txFeesUnit} - disabled={isSubmitting} - /> - - )} - - {t( - txFeesUnit === 'sats/kilo-vbyte' - ? 'settings.fees.description_tx_fees_satspervbyte' - : 'settings.fees.description_tx_fees_blocks', - )} - - - - - {txFeesUnit === 'sats/kilo-vbyte' ? ( - <> - / vB - - ) : ( - - )} - - {txFeesUnit === 'sats/kilo-vbyte' ? ( - { - const value = parseFloat(e.target.value) - setFieldValue('tx_fees', value * 1_000, true) - }} - isValid={touched.tx_fees && !errors.tx_fees} - isInvalid={touched.tx_fees && !!errors.tx_fees} - min={TX_FEES_SATSPERKILOVBYTE_MIN / 1_000} - max={TX_FEES_SATSPERKILOVBYTE_MAX / 1_000} - step={0.001} - /> - ) : ( - { - const value = parseInt(e.target.value, 10) - setFieldValue('tx_fees', value, true) - }} - isValid={touched.tx_fees && !errors.tx_fees} - isInvalid={touched.tx_fees && !!errors.tx_fees} - min={TX_FEES_BLOCKS_MIN} - max={TX_FEES_BLOCKS_MAX} - step={1} - /> - )} - {errors.tx_fees} - - + @@ -393,12 +297,12 @@ export default function FeeConfigModal({ } }, [show, loadFeeConfigValues]) - const submit = async (feeValues: FeeValues, txFeesUnit: TxFeeValueUnit) => { + const submit = async (feeValues: FeeValues) => { const allValuesPresent = Object.values(feeValues).every((it) => it !== undefined) if (!allValuesPresent) return - let adjustedTxFees = feeValues.tx_fees! - if (txFeesUnit === 'sats/kilo-vbyte') { + let adjustedTxFees = feeValues.tx_fees!.value + if (feeValues.tx_fees?.unit === 'sats/kilo-vbyte') { // There is one special case for value `tx_fees`: // Users are allowed to specify the value in "sats/vbyte", but this might // be interpreted by JM as "targeted blocks". This adaption makes sure @@ -445,8 +349,8 @@ export default function FeeConfigModal({ } const validate = useCallback( - (values: FeeFormValues, txFeesUnit: TxFeeValueUnit) => { - const errors = {} as FormikErrors + (values: FeeFormValues) => { + const errors = {} as FormikErrors if (values.enableValidation === false) { // do not validate form to enable resetting the values @@ -465,11 +369,11 @@ export default function FeeConfigModal({ }) } - if (txFeesUnit === 'sats/kilo-vbyte') { + if (values.tx_fees?.unit === 'sats/kilo-vbyte') { if ( - !isValidNumber(values.tx_fees) || - values.tx_fees! < TX_FEES_SATSPERKILOVBYTE_MIN || - values.tx_fees! > TX_FEES_SATSPERKILOVBYTE_MAX + !isValidNumber(values.tx_fees.value) || + values.tx_fees.value! < TX_FEES_SATSPERKILOVBYTE_MIN || + values.tx_fees.value! > TX_FEES_SATSPERKILOVBYTE_MAX ) { errors.tx_fees = t('settings.fees.feedback_invalid_tx_fees_satspervbyte', { min: (TX_FEES_SATSPERKILOVBYTE_MIN / 1_000).toLocaleString(undefined, { @@ -482,9 +386,9 @@ export default function FeeConfigModal({ } } else { if ( - !isValidNumber(values.tx_fees) || - values.tx_fees! < TX_FEES_BLOCKS_MIN || - values.tx_fees! > TX_FEES_BLOCKS_MAX + !isValidNumber(values.tx_fees?.value) || + values.tx_fees?.value! < TX_FEES_BLOCKS_MIN || + values.tx_fees?.value! > TX_FEES_BLOCKS_MAX ) { errors.tx_fees = t('settings.fees.feedback_invalid_tx_fees_blocks', { min: TX_FEES_BLOCKS_MIN.toLocaleString(), diff --git a/src/components/settings/TxFeeInputField.tsx b/src/components/settings/TxFeeInputField.tsx new file mode 100644 index 00000000..1e34ce7d --- /dev/null +++ b/src/components/settings/TxFeeInputField.tsx @@ -0,0 +1,166 @@ +import { useState } from 'react' +import * as rb from 'react-bootstrap' +import { useTranslation } from 'react-i18next' +import { FieldProps } from 'formik' +import { TxFeeValueUnit, toTxFeeValueUnit, TxFee } from '../../hooks/Fees' +import { isValidNumber } from '../../utils' +import Sprite from '../Sprite' +import SegmentedTabs from '../SegmentedTabs' + +type SatsPerKiloVByte = number + +const TX_FEES_BLOCKS_MIN = 1 +const TX_FEES_BLOCKS_MAX = 1_000 + +/** + * When the fee target is low, JM sometimes constructs transactions, which are + * declined from being relayed. In order to mitigate such situations, the + * minimum fee target (when provided in sats/vbyte) must be higher than + * 1 sats/vbyte, till the problem is addressed. Once resolved, this + * can be lowered to 1 sats/vbyte again. + * See https://github.com/JoinMarket-Org/joinmarket-clientserver/issues/1360#issuecomment-1262295463 + * Last checked on 2022-10-06. + */ +const TX_FEES_SATSPERKILOVBYTE_MIN: SatsPerKiloVByte = 1_000 // 1 sat/vbyte +// 350 sats/vbyte - no enforcement by JM - this should be a "sane" max value (taken default value of "absurd_fee_per_kb") +const TX_FEES_SATSPERKILOVBYTE_MAX: SatsPerKiloVByte = 350_000 + +type TxFeeInputFieldProps = FieldProps & { + label: string +} + +const TxFeeInputField = ({ field, form, label }: TxFeeInputFieldProps) => { + const { t } = useTranslation() + const [txFeesUnit, setTxFeesUnit] = useState(toTxFeeValueUnit(field.value.value) || 'blocks') + + return ( + <> + {label} + {txFeesUnit && ( + + { + const unit = tab.value as TxFeeValueUnit + setTxFeesUnit(unit) + + if (field.value) { + if (unit === 'sats/kilo-vbyte') { + form.setFieldValue( + field.name, + { + value: Math.round(field.value.value * 1_000), + unit, + }, + false, + ) + } else { + form.setFieldValue( + field.name, + { + value: Math.round(field.value.value / 1_000), + unit, + }, + false, + ) + } + } + setTimeout(() => form.validateForm(), 4) + }} + initialValue={txFeesUnit} + disabled={form.isSubmitting} + /> + + )} + + {t( + txFeesUnit === 'sats/kilo-vbyte' + ? 'settings.fees.description_tx_fees_satspervbyte' + : 'settings.fees.description_tx_fees_blocks', + )} + + + + + {txFeesUnit === 'sats/kilo-vbyte' ? ( + <> + / vB + + ) : ( + + )} + + + {txFeesUnit === 'sats/kilo-vbyte' ? ( + { + const value = parseFloat(e.target.value) + form.setFieldValue( + field.name, + { + value: Math.round(value * 1_000), + unit: 'sats/kilo-vbyte', + }, + true, + ) + }} + isValid={form.touched[field.name] && !form.errors[field.name]} + isInvalid={form.touched[field.name] && !!form.errors[field.name]} + min={TX_FEES_SATSPERKILOVBYTE_MIN / 1_000} + max={TX_FEES_SATSPERKILOVBYTE_MAX / 1_000} + step={0.001} + /> + ) : ( + { + const value = parseInt(e.target.value, 10) + form.setFieldValue( + field.name, + { + value, + unit: 'blocks', + }, + true, + ) + }} + isValid={form.touched[field.name] && !form.errors[field.name]} + isInvalid={form.touched[field.name] && !!form.errors[field.name]} + min={TX_FEES_BLOCKS_MIN} + max={TX_FEES_BLOCKS_MAX} + step={1} + /> + )} + {form.errors[field.name]} + + + + ) +} + +export default TxFeeInputField diff --git a/src/hooks/Fees.ts b/src/hooks/Fees.ts index e4fe61a2..28d7b305 100644 --- a/src/hooks/Fees.ts +++ b/src/hooks/Fees.ts @@ -4,8 +4,13 @@ import { AmountSats } from '../libs/JmWalletApi' import { isValidNumber } from '../utils' export type TxFeeValueUnit = 'blocks' | 'sats/kilo-vbyte' +export type TxFeeValue = number +export type TxFee = { + value: TxFeeValue + unit: TxFeeValueUnit +} -export const toTxFeeValueUnit = (val?: number): TxFeeValueUnit | undefined => { +export const toTxFeeValueUnit = (val?: TxFeeValue): TxFeeValueUnit | undefined => { if (val === undefined || !Number.isInteger(val) || val < 1) return undefined return val <= 1_000 ? 'blocks' : 'sats/kilo-vbyte' } @@ -18,7 +23,7 @@ export const FEE_CONFIG_KEYS = { } export interface FeeValues { - tx_fees?: number + tx_fees?: TxFee tx_fees_factor?: number max_cj_fee_abs?: number max_cj_fee_rel?: number @@ -42,7 +47,12 @@ export const useLoadFeeConfigValues = () => { const parsedMaxFeeRel = parseFloat(policy.max_cj_fee_rel || '') const feeValues: FeeValues = { - tx_fees: isValidNumber(parsedTxFees) ? parsedTxFees : undefined, + tx_fees: isValidNumber(parsedTxFees) + ? { + value: parsedTxFees, + unit: toTxFeeValueUnit(parsedTxFees) || 'blocks', + } + : undefined, tx_fees_factor: isValidNumber(parsedTxFeesFactor) ? parsedTxFeesFactor : undefined, max_cj_fee_abs: isValidNumber(parsedMaxFeeAbs) ? parsedMaxFeeAbs : undefined, max_cj_fee_rel: isValidNumber(parsedMaxFeeRel) ? parsedMaxFeeRel : undefined, From e3f802a2305c795a335492f056fe188611787050 Mon Sep 17 00:00:00 2001 From: theborakompanioni Date: Wed, 22 Nov 2023 17:22:27 +0100 Subject: [PATCH 05/19] refactor: move validate method of tx fee input field --- src/components/settings/FeeConfigModal.tsx | 62 +------- src/components/settings/TxFeeInputField.tsx | 166 ++++++++++++-------- 2 files changed, 104 insertions(+), 124 deletions(-) diff --git a/src/components/settings/FeeConfigModal.tsx b/src/components/settings/FeeConfigModal.tsx index ff5a2e1c..8dee42ed 100644 --- a/src/components/settings/FeeConfigModal.tsx +++ b/src/components/settings/FeeConfigModal.tsx @@ -4,7 +4,7 @@ import { Trans, useTranslation } from 'react-i18next' import { Formik, FormikErrors, FormikProps, Field } from 'formik' import classNames from 'classnames' import Sprite from '../Sprite' -import TxFeeInputField from './TxFeeInputField' +import { TxFeeInputField, validateTxFee } from './TxFeeInputField' import { FEE_CONFIG_KEYS, FeeValues, useLoadFeeConfigValues } from '../../hooks/Fees' import { useUpdateConfigValues } from '../../context/ServiceConfigContext' import { isDebugFeatureEnabled } from '../../constants/debugFeatures' @@ -14,24 +14,6 @@ import styles from './FeeConfigModal.module.css' const __dev_allowFeeValuesReset = isDebugFeatureEnabled('allowFeeValuesReset') -type SatsPerKiloVByte = number - -const TX_FEES_BLOCKS_MIN = 1 -const TX_FEES_BLOCKS_MAX = 1_000 - -/** - * When the fee target is low, JM sometimes constructs transactions, which are - * declined from being relayed. In order to mitigate such situations, the - * minimum fee target (when provided in sats/vbyte) must be higher than - * 1 sats/vbyte, till the problem is addressed. Once resolved, this - * can be lowered to 1 sats/vbyte again. - * See https://github.com/JoinMarket-Org/joinmarket-clientserver/issues/1360#issuecomment-1262295463 - * Last checked on 2022-10-06. - */ -const TX_FEES_SATSPERKILOVBYTE_MIN: SatsPerKiloVByte = 1_000 // 1 sat/vbyte -// 350 sats/vbyte - no enforcement by JM - this should be a "sane" max value (taken default value of "absurd_fee_per_kb") -const TX_FEES_SATSPERKILOVBYTE_MAX: SatsPerKiloVByte = 350_000 -const TX_FEES_SATSPERKILOVBYTE_ADJUSTED_MIN = 1_001 // actual min of `tx_fees` if unit is sats/kilo-vbyte const TX_FEES_FACTOR_MIN = 0 // 0% /** * For the same reasons as stated above (comment for `TX_FEES_SATSPERKILOVBYTE_MIN`), @@ -300,21 +282,12 @@ export default function FeeConfigModal({ const submit = async (feeValues: FeeValues) => { const allValuesPresent = Object.values(feeValues).every((it) => it !== undefined) if (!allValuesPresent) return - - let adjustedTxFees = feeValues.tx_fees!.value - if (feeValues.tx_fees?.unit === 'sats/kilo-vbyte') { - // There is one special case for value `tx_fees`: - // Users are allowed to specify the value in "sats/vbyte", but this might - // be interpreted by JM as "targeted blocks". This adaption makes sure - // that it is in fact closer to what the user actually expects, albeit it - // can be surprising that the value is slightly different as specified. - adjustedTxFees = Math.max(adjustedTxFees, TX_FEES_SATSPERKILOVBYTE_ADJUSTED_MIN) - } + if (feeValues.tx_fees?.value === undefined) return const updates = [ { key: FEE_CONFIG_KEYS.tx_fees, - value: String(adjustedTxFees), + value: String(feeValues.tx_fees?.value!), }, { key: FEE_CONFIG_KEYS.tx_fees_factor, @@ -369,32 +342,9 @@ export default function FeeConfigModal({ }) } - if (values.tx_fees?.unit === 'sats/kilo-vbyte') { - if ( - !isValidNumber(values.tx_fees.value) || - values.tx_fees.value! < TX_FEES_SATSPERKILOVBYTE_MIN || - values.tx_fees.value! > TX_FEES_SATSPERKILOVBYTE_MAX - ) { - errors.tx_fees = t('settings.fees.feedback_invalid_tx_fees_satspervbyte', { - min: (TX_FEES_SATSPERKILOVBYTE_MIN / 1_000).toLocaleString(undefined, { - maximumFractionDigits: Math.log10(1_000), - }), - max: (TX_FEES_SATSPERKILOVBYTE_MAX / 1_000).toLocaleString(undefined, { - maximumFractionDigits: Math.log10(1_000), - }), - }) - } - } else { - if ( - !isValidNumber(values.tx_fees?.value) || - values.tx_fees?.value! < TX_FEES_BLOCKS_MIN || - values.tx_fees?.value! > TX_FEES_BLOCKS_MAX - ) { - errors.tx_fees = t('settings.fees.feedback_invalid_tx_fees_blocks', { - min: TX_FEES_BLOCKS_MIN.toLocaleString(), - max: TX_FEES_BLOCKS_MAX.toLocaleString(), - }) - } + const txFeeErrors = validateTxFee(values.tx_fees, t) + if (txFeeErrors.value) { + errors.tx_fees = txFeeErrors.value } if ( diff --git a/src/components/settings/TxFeeInputField.tsx b/src/components/settings/TxFeeInputField.tsx index 1e34ce7d..79bec3e7 100644 --- a/src/components/settings/TxFeeInputField.tsx +++ b/src/components/settings/TxFeeInputField.tsx @@ -1,8 +1,8 @@ -import { useState } from 'react' import * as rb from 'react-bootstrap' import { useTranslation } from 'react-i18next' -import { FieldProps } from 'formik' -import { TxFeeValueUnit, toTxFeeValueUnit, TxFee } from '../../hooks/Fees' +import { TFunction } from 'i18next' +import { FieldProps, FormikErrors } from 'formik' +import { TxFeeValueUnit, TxFee } from '../../hooks/Fees' import { isValidNumber } from '../../utils' import Sprite from '../Sprite' import SegmentedTabs from '../SegmentedTabs' @@ -12,79 +12,111 @@ type SatsPerKiloVByte = number const TX_FEES_BLOCKS_MIN = 1 const TX_FEES_BLOCKS_MAX = 1_000 -/** - * When the fee target is low, JM sometimes constructs transactions, which are - * declined from being relayed. In order to mitigate such situations, the - * minimum fee target (when provided in sats/vbyte) must be higher than - * 1 sats/vbyte, till the problem is addressed. Once resolved, this - * can be lowered to 1 sats/vbyte again. - * See https://github.com/JoinMarket-Org/joinmarket-clientserver/issues/1360#issuecomment-1262295463 - * Last checked on 2022-10-06. - */ -const TX_FEES_SATSPERKILOVBYTE_MIN: SatsPerKiloVByte = 1_000 // 1 sat/vbyte +const TX_FEES_SATSPERKILOVBYTE_MIN: SatsPerKiloVByte = 1_001 // actual min of `tx_fees` if unit is sats/kilo-vbyte // 350 sats/vbyte - no enforcement by JM - this should be a "sane" max value (taken default value of "absurd_fee_per_kb") const TX_FEES_SATSPERKILOVBYTE_MAX: SatsPerKiloVByte = 350_000 +const adjustTxFees = (val: TxFee) => { + if (val.unit === 'sats/kilo-vbyte') { + // There is one special case for value `tx_fees`: + // Users are allowed to specify the value in "sats/vbyte", but this might + // be interpreted by JM as "targeted blocks". This adaption makes sure + // that it is in fact closer to what the user actually expects, albeit it + // can be surprising that the value is slightly different as specified. + return { + ...val, + value: Math.max(val.value, TX_FEES_SATSPERKILOVBYTE_MIN), + } + } + return val +} + +export const validateTxFee = (val: TxFee | undefined, t: TFunction): FormikErrors => { + const errors = {} as FormikErrors + + if (val?.unit === 'sats/kilo-vbyte') { + if ( + !isValidNumber(val.value) || + val.value < TX_FEES_SATSPERKILOVBYTE_MIN || + val.value > TX_FEES_SATSPERKILOVBYTE_MAX + ) { + errors.value = t('settings.fees.feedback_invalid_tx_fees_satspervbyte', { + min: (TX_FEES_SATSPERKILOVBYTE_MIN / 1_000).toLocaleString(undefined, { + maximumFractionDigits: Math.log10(1_000), + }), + max: (TX_FEES_SATSPERKILOVBYTE_MAX / 1_000).toLocaleString(undefined, { + maximumFractionDigits: Math.log10(1_000), + }), + }) + } + } else { + if (!isValidNumber(val?.value) || val?.value! < TX_FEES_BLOCKS_MIN || val?.value! > TX_FEES_BLOCKS_MAX) { + errors.value = t('settings.fees.feedback_invalid_tx_fees_blocks', { + min: TX_FEES_BLOCKS_MIN.toLocaleString(), + max: TX_FEES_BLOCKS_MAX.toLocaleString(), + }) + } + } + + return errors +} + type TxFeeInputFieldProps = FieldProps & { label: string } -const TxFeeInputField = ({ field, form, label }: TxFeeInputFieldProps) => { +export const TxFeeInputField = ({ field, form, label }: TxFeeInputFieldProps) => { const { t } = useTranslation() - const [txFeesUnit, setTxFeesUnit] = useState(toTxFeeValueUnit(field.value.value) || 'blocks') return ( <> {label} - {txFeesUnit && ( - - { - const unit = tab.value as TxFeeValueUnit - setTxFeesUnit(unit) - if (field.value) { - if (unit === 'sats/kilo-vbyte') { - form.setFieldValue( - field.name, - { - value: Math.round(field.value.value * 1_000), - unit, - }, - false, - ) - } else { - form.setFieldValue( - field.name, - { - value: Math.round(field.value.value / 1_000), - unit, - }, - false, - ) - } + + { + const unit = tab.value as TxFeeValueUnit + + if (field.value) { + if (unit === 'sats/kilo-vbyte') { + form.setFieldValue( + field.name, + adjustTxFees({ + value: Math.round(field.value.value * 1_000), + unit, + }), + true, + ) + } else { + form.setFieldValue( + field.name, + adjustTxFees({ + value: Math.round(field.value.value / 1_000), + unit, + }), + true, + ) } - setTimeout(() => form.validateForm(), 4) - }} - initialValue={txFeesUnit} - disabled={form.isSubmitting} - /> - - )} + } + }} + initialValue={field.value.unit} + disabled={form.isSubmitting} + /> + {t( - txFeesUnit === 'sats/kilo-vbyte' + field.value.unit === 'sats/kilo-vbyte' ? 'settings.fees.description_tx_fees_satspervbyte' : 'settings.fees.description_tx_fees_blocks', )} @@ -92,7 +124,7 @@ const TxFeeInputField = ({ field, form, label }: TxFeeInputFieldProps) => { - {txFeesUnit === 'sats/kilo-vbyte' ? ( + {field.value.unit === 'sats/kilo-vbyte' ? ( <> / vB @@ -101,7 +133,7 @@ const TxFeeInputField = ({ field, form, label }: TxFeeInputFieldProps) => { )} - {txFeesUnit === 'sats/kilo-vbyte' ? ( + {field.value.unit === 'sats/kilo-vbyte' ? ( { const value = parseFloat(e.target.value) form.setFieldValue( field.name, - { + adjustTxFees({ value: Math.round(value * 1_000), unit: 'sats/kilo-vbyte', - }, + }), true, ) }} @@ -142,10 +174,10 @@ const TxFeeInputField = ({ field, form, label }: TxFeeInputFieldProps) => { const value = parseInt(e.target.value, 10) form.setFieldValue( field.name, - { + adjustTxFees({ value, unit: 'blocks', - }, + }), true, ) }} @@ -162,5 +194,3 @@ const TxFeeInputField = ({ field, form, label }: TxFeeInputFieldProps) => { ) } - -export default TxFeeInputField From d2c00c7227c10cf152e636f6ac61d1c5c41b9c09 Mon Sep 17 00:00:00 2001 From: theborakompanioni Date: Fri, 24 Nov 2023 16:04:44 +0100 Subject: [PATCH 06/19] refactor(wip): split send form into reusable components --- .../Send/AmountInputField.module.css | 23 + src/components/Send/AmountInputField.tsx | 143 ++++ src/components/Send/CollaboratorsSelector.tsx | 34 +- .../Send/DestinationInputField.module.css | 22 + src/components/Send/DestinationInputField.tsx | 173 +++++ src/components/Send/Send.module.css | 74 -- src/components/Send/SendForm.tsx | 424 +++++++++++ src/components/Send/SweepBreakdown.module.css | 55 ++ src/components/Send/SweepBreakdown.tsx | 107 +++ src/components/Send/helpers.ts | 9 +- src/components/Send/index.tsx | 661 +++--------------- src/components/settings/TxFeeInputField.tsx | 2 +- src/i18n/locales/en/translation.json | 1 + src/index.css | 7 +- 14 files changed, 1086 insertions(+), 649 deletions(-) create mode 100644 src/components/Send/AmountInputField.module.css create mode 100644 src/components/Send/AmountInputField.tsx create mode 100644 src/components/Send/DestinationInputField.module.css create mode 100644 src/components/Send/DestinationInputField.tsx create mode 100644 src/components/Send/SendForm.tsx create mode 100644 src/components/Send/SweepBreakdown.module.css create mode 100644 src/components/Send/SweepBreakdown.tsx diff --git a/src/components/Send/AmountInputField.module.css b/src/components/Send/AmountInputField.module.css new file mode 100644 index 00000000..8cf9420b --- /dev/null +++ b/src/components/Send/AmountInputField.module.css @@ -0,0 +1,23 @@ +.inputLoader { + height: 3.5rem; + border-radius: 0.25rem; +} + +.input { + border-right: none !important; +} + +.button { + --bs-btn-border-color: var(--bs-border-color) !important; + border-left: none !important; + font-size: 0.875rem !important; +} + +/* hack to show feedback element with input-groups */ +:global div.is-invalid .invalid-feedback { + display: block; +} + +:global .form-control.is-invalid + button { + --bs-btn-border-color: red !important; +} diff --git a/src/components/Send/AmountInputField.tsx b/src/components/Send/AmountInputField.tsx new file mode 100644 index 00000000..f1d7e1e0 --- /dev/null +++ b/src/components/Send/AmountInputField.tsx @@ -0,0 +1,143 @@ +import { useMemo } from 'react' +import { useTranslation } from 'react-i18next' +import * as rb from 'react-bootstrap' +import classNames from 'classnames' +import { useField, useFormikContext } from 'formik' +import * as Api from '../../libs/JmWalletApi' +import Sprite from '../Sprite' +import { AccountBalanceSummary } from '../../context/BalanceSummary' +import { noop } from '../../utils' +import styles from './AmountInputField.module.css' + +export type AmountValue = { + value: Api.AmountSats | null + isSweep: boolean +} + +export type AmountInputFieldProps = { + name: string + label: string + className?: string + sourceJarBalance?: AccountBalanceSummary + isLoading: boolean + disabled?: boolean +} + +export const AmountInputField = ({ + name, + label, + className, + sourceJarBalance, + isLoading, + disabled = false, +}: AmountInputFieldProps) => { + const { t } = useTranslation() + const [field] = useField(name) + const form = useFormikContext() + + const amountFieldValue = useMemo(() => { + if (field.value?.isSweep) { + if (!sourceJarBalance) return '' + return `${sourceJarBalance.calculatedAvailableBalanceInSats}` + } + + return field.value?.value ?? '' + }, [sourceJarBalance, field]) + + return ( + <> + + {label} + + {isLoading ? ( + + + + ) : ( + <> + {field.value?.isSweep === true ? ( + + + { + form.setFieldValue(field.name, form.initialValues[field.name], true) + }} + disabled={disabled} + > +
+ + <>{t('send.button_clear_sweep')} +
+
+
+ ) : ( +
+ + { + const value = e.target.value + form.setFieldValue( + field.name, + { + value: value === '' ? null : parseInt(value, 10), + fromJar: null, + }, + true, + ) + }} + isInvalid={form.touched[field.name] && !!form.errors[field.name]} + disabled={disabled} + /> + { + if (!sourceJarBalance) return + form.setFieldValue( + field.name, + { + value: 0, + isSweep: true, + }, + true, + ) + }} + disabled={disabled} + > +
+ + {t('send.button_sweep')} +
+
+
+
+ )} + + )} + {form.errors[field.name]} +
+ + ) +} diff --git a/src/components/Send/CollaboratorsSelector.tsx b/src/components/Send/CollaboratorsSelector.tsx index e9f93c81..8d24979d 100644 --- a/src/components/Send/CollaboratorsSelector.tsx +++ b/src/components/Send/CollaboratorsSelector.tsx @@ -1,5 +1,6 @@ import { useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' +import { useField, useFormikContext } from 'formik' import { useSettings } from '../../context/SettingsContext' import * as rb from 'react-bootstrap' import classNames from 'classnames' @@ -7,20 +8,23 @@ import styles from './CollaboratorsSelector.module.css' import { isValidNumCollaborators } from './helpers' type CollaboratorsSelectorProps = { - numCollaborators: number | null - setNumCollaborators: (val: number | null) => void + name: string minNumCollaborators: number + maxNumCollaborators: number disabled?: boolean } const CollaboratorsSelector = ({ - numCollaborators, - setNumCollaborators, + name, minNumCollaborators, + maxNumCollaborators, disabled = false, }: CollaboratorsSelectorProps) => { const { t } = useTranslation() const settings = useSettings() + const [field] = useField(name) + const form = useFormikContext() + const [usesCustomNumCollaborators, setUsesCustomNumCollaborators] = useState(false) const defaultCollaboratorsSelection = useMemo(() => { @@ -31,21 +35,23 @@ const CollaboratorsSelector = ({ const validateAndSetCustomNumCollaborators = (candidate: string) => { const parsed = parseInt(candidate, 10) if (isValidNumCollaborators(parsed, minNumCollaborators)) { - setNumCollaborators(parsed) + form.setFieldValue(field.name, parsed, true) } else { - setNumCollaborators(null) + form.setFieldValue(field.name, undefined, true) } } return ( - {t('send.label_num_collaborators', { numCollaborators })} + + {t('send.label_num_collaborators', { numCollaborators: field.value })} +
{t('send.description_num_collaborators')}
{defaultCollaboratorsSelection.map((number) => { - const isSelected = !usesCustomNumCollaborators && numCollaborators === number + const isSelected = !usesCustomNumCollaborators && field.value === number return ( { setUsesCustomNumCollaborators(false) - setNumCollaborators(number) + validateAndSetCustomNumCollaborators(String(number)) }} disabled={disabled} > @@ -66,8 +72,8 @@ const CollaboratorsSelector = ({ - {usesCustomNumCollaborators && ( - - {t('send.error_invalid_num_collaborators', { minNumCollaborators, maxNumCollaborators: 99 })} - - )} + {form.errors[field.name]}
) diff --git a/src/components/Send/DestinationInputField.module.css b/src/components/Send/DestinationInputField.module.css new file mode 100644 index 00000000..3a8d8233 --- /dev/null +++ b/src/components/Send/DestinationInputField.module.css @@ -0,0 +1,22 @@ +.inputLoader { + height: 3.5rem; + border-radius: 0.25rem; +} + +.input { + border-right: none !important; +} + +.button { + --bs-btn-border-color: var(--bs-border-color) !important; + border-left: none !important; +} + +/* hack to show feedback element with input-groups */ +:global div.is-invalid .invalid-feedback { + display: block; +} + +:global .form-control.is-invalid + button { + --bs-btn-border-color: red !important; +} diff --git a/src/components/Send/DestinationInputField.tsx b/src/components/Send/DestinationInputField.tsx new file mode 100644 index 00000000..6f74c3b7 --- /dev/null +++ b/src/components/Send/DestinationInputField.tsx @@ -0,0 +1,173 @@ +import { useState } from 'react' +import { useTranslation } from 'react-i18next' +import * as rb from 'react-bootstrap' +import classNames from 'classnames' +import { useField, useFormikContext } from 'formik' +import * as Api from '../../libs/JmWalletApi' +import Sprite from '../Sprite' +import { jarName } from '../jars/Jar' +import JarSelectorModal from '../JarSelectorModal' + +import { CurrentWallet, WalletInfo } from '../../context/WalletContext' +import { noop } from '../../utils' +import styles from './DestinationInputField.module.css' + +export type DestinationValue = { + value: Api.BitcoinAddress | null + fromJar: JarIndex | null +} + +export type DestinationInputFieldProps = { + name: string + label: string + className?: string + wallet: CurrentWallet + setAlert: (value: SimpleAlert | undefined) => void + walletInfo?: WalletInfo + sourceJarIndex?: JarIndex + isLoading: boolean + disabled?: boolean +} + +export const DestinationInputField = ({ + name, + label, + className, + wallet, + walletInfo, + setAlert, + sourceJarIndex, + isLoading, + disabled = false, +}: DestinationInputFieldProps) => { + const { t } = useTranslation() + const [field] = useField(name) + const form = useFormikContext() + + const [destinationJarPickerShown, setDestinationJarPickerShown] = useState(false) + + return ( + <> + {!isLoading && walletInfo && ( + <> + setDestinationJarPickerShown(false)} + onConfirm={(selectedJar) => { + const abortCtrl = new AbortController() + return Api.getAddressNew({ + ...wallet, + signal: abortCtrl.signal, + mixdepth: selectedJar, + }) + .then((res) => + res.ok ? res.json() : Api.Helper.throwError(res, t('receive.error_loading_address_failed')), + ) + .then((data) => { + if (abortCtrl.signal.aborted) return + form.setFieldValue( + field.name, + { + value: data.address, + fromJar: selectedJar, + }, + true, + ) + + setDestinationJarPickerShown(false) + }) + .catch((err) => { + if (abortCtrl.signal.aborted) return + setAlert({ variant: 'danger', message: err.message }) + setDestinationJarPickerShown(false) + }) + }} + /> + + )} + + {label} + {isLoading ? ( + + + + ) : ( + <> + {field.value.fromJar !== null ? ( + + + { + form.setFieldValue(field.name, form.initialValues[field.name], true) + }} + disabled={disabled} + > +
+ +
+
+
+ ) : ( +
+ + { + const value = e.target.value + form.setFieldValue( + field.name, + { + value: value === '' ? null : value, + fromJar: null, + }, + true, + ) + }} + isInvalid={form.touched[field.name] && !!form.errors[field.name]} + disabled={disabled} + /> + setDestinationJarPickerShown(true)} + disabled={disabled || !walletInfo} + > +
+ +
+
+
+
+ )} + + {form.errors[field.name]} + + )} +
+ + ) +} diff --git a/src/components/Send/Send.module.css b/src/components/Send/Send.module.css index e4539b2f..7e32f937 100644 --- a/src/components/Send/Send.module.css +++ b/src/components/Send/Send.module.css @@ -56,53 +56,6 @@ input[type='number'] { color: var(--bs-white) !important; } -.buttonSweepItem { - display: flex; - align-items: center; - justify-content: center; - gap: 0.2rem; - padding-right: 0.2rem; - font-size: 0.9rem; -} - -.sweepBreakdown { - font-size: 0.8rem; -} - -.sweepBreakdownTable { - color: var(--bs-black); -} - -:root[data-theme='dark'] .sweepBreakdownTable { - color: var(--bs-white); -} - -.sweepBreakdownTable .balanceCol { - text-align: right; -} - -.accordionButton { - background-color: transparent; - color: var(--bs-black); - font-size: 0.8rem; - height: 1rem; - padding: 0; - box-shadow: none; -} - -.sweepBreakdownAnchor { - font-size: 0.8rem; - color: var(--bs-black); -} - -:root[data-theme='dark'] .sweepBreakdownAnchor { - color: var(--bs-white) !important; -} - -:root[data-theme='dark'] .sweepBreakdownParagraph { - color: var(--bs-white) !important; -} - .inputLoader { height: 3.5rem; border-radius: 0.25rem; @@ -114,33 +67,6 @@ input[type='number'] { filter: blur(2px); } -.accordionButton { - border: none; - width: 100%; - background-color: transparent; - color: var(--bs-black); - display: flex; - justify-content: flex-end; - font-size: 0.8rem; - height: 1rem; - padding: 0; - box-shadow: none; -} - -.accordionButton:hover { - text-decoration: underline; -} - -.accordionButton::after { - width: 0rem; - height: 0rem; - background-image: none; -} - -:root[data-theme='dark'] .accordionButton { - color: var(--bs-white); -} - .sourceJarsContainer { display: flex; flex-wrap: wrap; diff --git a/src/components/Send/SendForm.tsx b/src/components/Send/SendForm.tsx new file mode 100644 index 00000000..ad13b892 --- /dev/null +++ b/src/components/Send/SendForm.tsx @@ -0,0 +1,424 @@ +import { useState, useMemo, RefObject } from 'react' +import { Trans, useTranslation } from 'react-i18next' +import * as rb from 'react-bootstrap' +import ToggleSwitch from '../ToggleSwitch' +import Sprite from '../Sprite' +import { jarFillLevel, SelectableJar } from '../jars/Jar' +import { CoinjoinPreconditionViolationAlert } from '../CoinjoinPreconditionViolationAlert' +import CollaboratorsSelector from './CollaboratorsSelector' +import Accordion from '../Accordion' +import FeeConfigModal, { FeeConfigSectionKey } from '../settings/FeeConfigModal' +import { useFeeConfigValues, useEstimatedMaxCollaboratorFee } from '../../hooks/Fees' +import { CurrentWallet, WalletInfo } from '../../context/WalletContext' +import { buildCoinjoinRequirementSummary } from '../../hooks/CoinjoinRequirements' +import { formatSats } from '../../utils' + +import { + MAX_NUM_COLLABORATORS, + isValidAddress, + isValidAmount, + isValidJarIndex, + isValidNumCollaborators, +} from './helpers' +import styles from './Send.module.css' +import FeeBreakdown from './FeeBreakdown' +import { Formik, FormikErrors, FormikProps } from 'formik' +import { AccountBalanceSummary, AccountBalances } from '../../context/BalanceSummary' +import { DestinationInputField, DestinationValue } from './DestinationInputField' +import { AmountInputField, AmountValue } from './AmountInputField' +import { SweepBreakdown } from './SweepBreakdown' + +type CollaborativeTransactionOptionsProps = { + selectedAmount?: AmountValue + selectedNumCollaborators?: number + sourceJarBalance?: AccountBalanceSummary + isLoading: boolean + disabled?: boolean + minNumCollaborators: number + numCollaborators: number | null + setNumCollaborators: (val: number | null) => void +} + +function CollaborativeTransactionOptions({ + selectedAmount, + selectedNumCollaborators, + sourceJarBalance, + isLoading, + disabled, + minNumCollaborators, +}: CollaborativeTransactionOptionsProps) { + const { t } = useTranslation() + + const [feeConfigValues, reloadFeeConfigValues] = useFeeConfigValues() + const [activeFeeConfigModalSection, setActiveFeeConfigModalSection] = useState() + const [showFeeConfigModal, setShowFeeConfigModal] = useState(false) + + const estimatedMaxCollaboratorFee = useEstimatedMaxCollaboratorFee({ + feeConfigValues, + amount: + selectedAmount?.isSweep && sourceJarBalance + ? sourceJarBalance.calculatedAvailableBalanceInSats + : selectedAmount?.value ?? null, + numCollaborators: selectedNumCollaborators ?? null, + isCoinjoin: true, + }) + + return ( + <> + + + + + {t('send.fee_breakdown.title', { + maxCollaboratorFee: estimatedMaxCollaboratorFee + ? `≤${formatSats(estimatedMaxCollaboratorFee)} sats` + : '...', + })} + + + { + setActiveFeeConfigModalSection('cj_fee') + setShowFeeConfigModal(true) + }} + className="text-decoration-underline link-secondary" + /> + ), + }} + /> + + + + { + setActiveFeeConfigModalSection('tx_fee') + setShowFeeConfigModal(true) + }} + className="text-decoration-underline link-secondary" + /> + ), + }} + /> + + + {sourceJarBalance && ( + { + setActiveFeeConfigModalSection('cj_fee') + setShowFeeConfigModal(true) + }} + /> + )} + + {showFeeConfigModal && ( + reloadFeeConfigValues()} + onHide={() => setShowFeeConfigModal(false)} + defaultActiveSectionKey={activeFeeConfigModalSection} + /> + )} + + + ) +} + +export interface SendFormValues { + sourceJarIndex?: JarIndex + destination?: DestinationValue + amount?: AmountValue + numCollaborators?: number + isCoinJoin: boolean +} + +interface InnerSendFormProps { + props: FormikProps + isLoading: boolean + disabled?: boolean + wallet: CurrentWallet + walletInfo?: WalletInfo + setAlert: (value: SimpleAlert | undefined) => void + jarBalances: AccountBalanceSummary[] + minNumCollaborators: number +} + +const InnerSendForm = ({ + props, + wallet, + setAlert, + isLoading, + walletInfo, + disabled, + jarBalances, + minNumCollaborators, +}: InnerSendFormProps) => { + const { t } = useTranslation() + + const sourceJarUtxos = useMemo(() => { + if (!walletInfo || props.values.sourceJarIndex === undefined) return null + return walletInfo.data.utxos.utxos.filter((it) => it.mixdepth === props.values.sourceJarIndex) + }, [walletInfo, props.values.sourceJarIndex]) + + const sourceJarCoinjoinPreconditionSummary = useMemo(() => { + if (sourceJarUtxos === null) return null + console.log(1) + return buildCoinjoinRequirementSummary(sourceJarUtxos) + }, [sourceJarUtxos]) + + const sourceJarBalance = + props.values.sourceJarIndex !== undefined ? jarBalances[props.values.sourceJarIndex] : undefined + + const submitButtonOptions = (() => { + if (!isLoading) { + if (!props.values.isCoinJoin) { + return { + variant: 'danger', + text: t('send.button_send_without_improved_privacy'), + } + } else if (sourceJarCoinjoinPreconditionSummary?.isFulfilled === false) { + return { + variant: 'warning', + text: t('send.button_send_despite_warning'), + } + } + } + + return { + variant: 'dark', + text: t('send.button_send'), + } + })() + + return ( + <> + + + {t('send.label_source_jar')} + {!walletInfo || jarBalances.length === 0 ? ( + + + + ) : ( +
+ {jarBalances.map((it) => ( + 0} + isSelected={it.accountIndex === props.values.sourceJarIndex} + fillLevel={jarFillLevel( + it.calculatedTotalBalanceInSats, + walletInfo.balanceSummary.calculatedTotalBalanceInSats, + )} + variant={ + it.accountIndex === props.values.sourceJarIndex && + props.values.isCoinJoin && + sourceJarCoinjoinPreconditionSummary?.isFulfilled === false + ? 'warning' + : undefined + } + onClick={(jarIndex) => props.setFieldValue('sourceJarIndex', jarIndex, true)} + /> + ))} +
+ )} +
+ {!isLoading && + !disabled && + props.values.isCoinJoin && + sourceJarCoinjoinPreconditionSummary?.isFulfilled === false && ( +
+ +
+ )} + + + + + {props.values.amount?.isSweep && sourceJarBalance && ( +
+ +
+ )} + + + + props.setFieldValue('isCoinJoin', isToggled, true)} + disabled={disabled || isLoading} + /> + +
+ {/* direct-send options: empty on purpose */} +
+
+ props.setFieldValue('numCollaborators', val, true)} + /> +
+
+ + +
+ {props.isSubmitting ? ( + <> +
+
+
+ + ) +} + +interface SendFormProps { + initialValues: SendFormValues + onSubmit: (values: SendFormValues) => Promise + isLoading: boolean + disabled?: boolean + wallet: CurrentWallet + walletInfo?: WalletInfo + setAlert: (value: SimpleAlert | undefined) => void + minNumCollaborators: number + formRef?: React.Ref> +} + +export const SendForm = ({ + initialValues, + onSubmit, + isLoading, + disabled = false, + wallet, + walletInfo, + setAlert, + minNumCollaborators, + formRef, +}: SendFormProps) => { + const { t } = useTranslation() + + const sortedJarBalances = useMemo(() => { + if (!walletInfo) return [] + return Object.values(walletInfo.balanceSummary.accountBalances).sort( + (lhs, rhs) => lhs.accountIndex - rhs.accountIndex, + ) + }, [walletInfo]) + + const validate = (values: SendFormValues) => { + const errors = {} as FormikErrors + /** source jar */ + if (!isValidJarIndex(values.sourceJarIndex ?? -1)) { + errors.sourceJarIndex = t('send.feedback_invalid_destination_address') + } + /** source jar - end */ + + /** destination address */ + if (!isValidAddress(values.destination?.value || null)) { + errors.destination = t('send.feedback_invalid_destination_address') + } + if (!!values.destination?.value && walletInfo?.addressSummary[values.destination.value]) { + if (walletInfo.addressSummary[values.destination.value].status !== 'new') { + errors.destination = t('send.feedback_reused_address') + } + } + /** destination address - end */ + + /** amount */ + if (!isValidAmount(values.amount?.value ?? null, values.amount?.isSweep || false)) { + errors.amount = t('send.feedback_invalid_amount') + } + /** amount - end */ + + /** collaborators */ + if (values.isCoinJoin && !isValidNumCollaborators(values.numCollaborators ?? null, minNumCollaborators)) { + errors.numCollaborators = t('send.error_invalid_num_collaborators', { + minNumCollaborators, + maxNumCollaborators: MAX_NUM_COLLABORATORS, + }) + } + /** collaborators - end */ + + return errors + } + + return ( + + {(props) => { + return ( + + ) + }} + + ) +} diff --git a/src/components/Send/SweepBreakdown.module.css b/src/components/Send/SweepBreakdown.module.css new file mode 100644 index 00000000..ccceca5a --- /dev/null +++ b/src/components/Send/SweepBreakdown.module.css @@ -0,0 +1,55 @@ +.sweepBreakdown { + font-size: 0.8rem; +} + +.sweepBreakdownTable { + color: var(--bs-black); +} + +:root[data-theme='dark'] .sweepBreakdownTable { + color: var(--bs-white); +} + +.sweepBreakdownTable .balanceCol { + text-align: right; +} + +.accordionButton { + background-color: transparent; + color: var(--bs-black); + display: flex; + justify-content: flex-end; + font-size: 0.8rem; + height: 1rem; + padding: 0; + box-shadow: none; + border: none; + width: 100%; +} + +.accordionButton:hover { + text-decoration: underline; +} + +.accordionButton::after { + width: 0rem; + height: 0rem; + background-image: none; +} + +:root[data-theme='dark'] .accordionButton { + color: var(--bs-white); +} + +.sweepBreakdownAnchor { + font-size: 0.8rem; + color: var(--bs-black); +} + +:root[data-theme='dark'] .sweepBreakdownAnchor { + color: var(--bs-white) !important; +} + +:root[data-theme='dark'] .sweepBreakdownParagraph { + color: var(--bs-white) !important; +} diff --git a/src/components/Send/SweepBreakdown.tsx b/src/components/Send/SweepBreakdown.tsx new file mode 100644 index 00000000..0ade1516 --- /dev/null +++ b/src/components/Send/SweepBreakdown.tsx @@ -0,0 +1,107 @@ +import { Trans, useTranslation } from 'react-i18next' +import * as rb from 'react-bootstrap' +import { AccountBalanceSummary } from '../../context/BalanceSummary' +import Balance from '../Balance' +import { SATS } from '../../utils' + +import styles from './SweepBreakdown.module.css' + +type SweepAccordionToggleProps = { + eventKey: string +} +function SweepAccordionToggle({ eventKey }: SweepAccordionToggleProps) { + const { t } = useTranslation() + return ( + + ) +} + +type SweepBreakdownProps = { + jarBalance: AccountBalanceSummary +} +export function SweepBreakdown({ jarBalance }: SweepBreakdownProps) { + const { t } = useTranslation() + + return ( +
+ + + + + + + + + + + + + + + + + + + +
{t('send.sweep_amount_breakdown_total_balance')} + +
{t('send.sweep_amount_breakdown_frozen_balance')} + +
{t('send.sweep_amount_breakdown_estimated_amount')} + +
+

+ + A sweep transaction will consume all UTXOs of a mixdepth leaving no coins behind except those that have + been + + frozen + + or + + time-locked + + . Mining fees and collaborator fees will be deducted from the amount so as to leave zero change. The + exact transaction amount can only be calculated by JoinMarket at the point when the transaction is made. + Therefore the estimated amount shown might deviate from the actually sent amount. Refer to the + + JoinMarket documentation + + for more details. + +

+
+
+
+
+ ) +} diff --git a/src/components/Send/helpers.ts b/src/components/Send/helpers.ts index 50159368..b284c2f3 100644 --- a/src/components/Send/helpers.ts +++ b/src/components/Send/helpers.ts @@ -1,5 +1,7 @@ import { isValidNumber } from '../../utils' +export const MAX_NUM_COLLABORATORS = 99 + export const initialNumCollaborators = (minValue: number) => { if (minValue > 8) { return minValue + pseudoRandomNumber(0, 2) @@ -25,5 +27,10 @@ export const isValidAmount = (candidate: number | null, isSweep: boolean) => { } export const isValidNumCollaborators = (candidate: number | null, minNumCollaborators: number) => { - return candidate !== null && isValidNumber(candidate) && candidate >= minNumCollaborators && candidate <= 99 + return ( + candidate !== null && + isValidNumber(candidate) && + candidate >= minNumCollaborators && + candidate <= MAX_NUM_COLLABORATORS + ) } diff --git a/src/components/Send/index.tsx b/src/components/Send/index.tsx index ad71aa1f..b1f9798f 100644 --- a/src/components/Send/index.tsx +++ b/src/components/Send/index.tsx @@ -1,60 +1,83 @@ -import { useEffect, useState, useMemo, useRef, FormEventHandler } from 'react' +import { useEffect, useState, useMemo, useRef } from 'react' import { Link } from 'react-router-dom' -import { Trans, useTranslation } from 'react-i18next' +import { FormikProps } from 'formik' +import { useTranslation } from 'react-i18next' import * as rb from 'react-bootstrap' import classNames from 'classnames' import * as Api from '../../libs/JmWalletApi' import PageTitle from '../PageTitle' -import ToggleSwitch from '../ToggleSwitch' import Sprite from '../Sprite' -import Balance from '../Balance' import { ConfirmModal } from '../Modal' -import { jarFillLevel, jarName, SelectableJar } from '../jars/Jar' -import JarSelectorModal from '../JarSelectorModal' import { PaymentConfirmModal } from '../PaymentConfirmModal' -import { CoinjoinPreconditionViolationAlert } from '../CoinjoinPreconditionViolationAlert' -import CollaboratorsSelector from './CollaboratorsSelector' -import Accordion from '../Accordion' import FeeConfigModal, { FeeConfigSectionKey } from '../settings/FeeConfigModal' -import { useFeeConfigValues, useEstimatedMaxCollaboratorFee } from '../../hooks/Fees' - +import { useFeeConfigValues } from '../../hooks/Fees' import { useReloadCurrentWalletInfo, useCurrentWalletInfo, CurrentWallet } from '../../context/WalletContext' import { useServiceInfo, useReloadServiceInfo } from '../../context/ServiceInfoContext' import { useLoadConfigValue } from '../../context/ServiceConfigContext' -import { buildCoinjoinRequirementSummary } from '../../hooks/CoinjoinRequirements' - import { routes } from '../../constants/routes' import { JM_MINIMUM_MAKERS_DEFAULT } from '../../constants/config' -import { SATS, formatSats, isValidNumber, scrollToTop } from '../../utils' - -import { - initialNumCollaborators, - isValidAddress, - isValidAmount, - isValidJarIndex, - isValidNumCollaborators, -} from './helpers' +import { scrollToTop } from '../../utils' +import { initialNumCollaborators } from './helpers' +import { SendForm, SendFormValues } from './SendForm' import styles from './Send.module.css' -import FeeBreakdown from './FeeBreakdown' - -const IS_COINJOIN_DEFAULT_VAL = true const INITIAL_DESTINATION = null const INITIAL_SOURCE_JAR_INDEX = null const INITIAL_AMOUNT = null +const INITIAL_IS_COINJOIN = true -type SweepAccordionToggleProps = { - eventKey: string +type MaxFeeConfigMissingAlertProps = { + onSuccess: () => void } -function SweepAccordionToggle({ eventKey }: SweepAccordionToggleProps) { + +function MaxFeeConfigMissingAlert({ onSuccess }: MaxFeeConfigMissingAlertProps) { const { t } = useTranslation() + + const [activeFeeConfigModalSection, setActiveFeeConfigModalSection] = useState() + const [showFeeConfigModal, setShowFeeConfigModal] = useState(false) + return ( - + <> + {showFeeConfigModal && ( + setShowFeeConfigModal(false)} + defaultActiveSectionKey={activeFeeConfigModalSection} + /> + )} + + {t('send.taker_error_message_max_fees_config_missing')} +   + { + setActiveFeeConfigModalSection('cj_fee') + setShowFeeConfigModal(true) + }} + > + {t('settings.show_fee_config')} + + + ) } +const createInitialValues = (minNumCollaborators: number) => { + return { + sourceJarIndex: INITIAL_SOURCE_JAR_INDEX ?? undefined, + destination: { + value: INITIAL_DESTINATION, + fromJar: null, + }, + amount: { + value: INITIAL_AMOUNT, + isSweep: false, + }, + isCoinJoin: INITIAL_IS_COINJOIN, + numCollaborators: initialNumCollaborators(minNumCollaborators), + } +} + type SendProps = { wallet: CurrentWallet } @@ -73,12 +96,7 @@ export default function Send({ wallet }: SendProps) { const [alert, setAlert] = useState() const [isSending, setIsSending] = useState(false) - const [isCoinjoin, setIsCoinjoin] = useState(IS_COINJOIN_DEFAULT_VAL) const [minNumCollaborators, setMinNumCollaborators] = useState(JM_MINIMUM_MAKERS_DEFAULT) - const [isSweep, setIsSweep] = useState(false) - const [destinationJarPickerShown, setDestinationJarPickerShown] = useState(false) - const [destinationJar, setDestinationJar] = useState(null) - const [destinationIsReusedAddress, setDestinationIsReusedAddress] = useState(false) const [feeConfigValues, reloadFeeConfigValues] = useFeeConfigValues() const maxFeesConfigMissing = useMemo( @@ -86,8 +104,6 @@ export default function Send({ wallet }: SendProps) { feeConfigValues && (feeConfigValues.max_cj_fee_abs === undefined || feeConfigValues.max_cj_fee_rel === undefined), [feeConfigValues], ) - const [activeFeeConfigModalSection, setActiveFeeConfigModalSection] = useState() - const [showFeeConfigModal, setShowFeeConfigModal] = useState(false) const [waitForUtxosToBeSpent, setWaitForUtxosToBeSpent] = useState([]) const [paymentSuccessfulInfoAlert, setPaymentSuccessfulInfoAlert] = useState() @@ -107,12 +123,6 @@ export default function Send({ wallet }: SendProps) { [walletInfo, isInitializing, waitForUtxosToBeSpent], ) - const [destination, setDestination] = useState(INITIAL_DESTINATION) - const [sourceJarIndex, setSourceJarIndex] = useState(INITIAL_SOURCE_JAR_INDEX) - const [amount, setAmount] = useState(INITIAL_AMOUNT) - // see https://github.com/JoinMarket-Org/joinmarket-clientserver/blob/master/docs/USAGE.md#try-out-a-coinjoin-using-sendpaymentpy - const [numCollaborators, setNumCollaborators] = useState(initialNumCollaborators(minNumCollaborators)) - const sortedAccountBalances = useMemo(() => { if (!walletInfo) return [] return Object.values(walletInfo.balanceSummary.accountBalances).sort( @@ -120,72 +130,12 @@ export default function Send({ wallet }: SendProps) { ) }, [walletInfo]) - const accountBalance = useMemo(() => { - if (sourceJarIndex === null) return null - return sortedAccountBalances[sourceJarIndex] - }, [sortedAccountBalances, sourceJarIndex]) - - const sourceJarUtxos = useMemo(() => { - if (!walletInfo || sourceJarIndex === null) return null - return walletInfo.data.utxos.utxos.filter((it) => it.mixdepth === sourceJarIndex) - }, [walletInfo, sourceJarIndex]) - - const sourceJarCoinjoinPreconditionSummary = useMemo(() => { - if (sourceJarUtxos === null) return null - return buildCoinjoinRequirementSummary(sourceJarUtxos) - }, [sourceJarUtxos]) - - const estimatedMaxCollaboratorFee = useEstimatedMaxCollaboratorFee({ - feeConfigValues, - amount: isSweep && accountBalance ? accountBalance.calculatedAvailableBalanceInSats : amount, - numCollaborators, - isCoinjoin, - }) - const [showConfirmAbortModal, setShowConfirmAbortModal] = useState(false) - const [showConfirmSendModal, setShowConfirmSendModal] = useState(false) - const submitButtonRef = useRef(null) - const submitButtonOptions = useMemo(() => { - if (!isLoading) { - if (!isCoinjoin) { - return { - variant: 'danger', - text: t('send.button_send_without_improved_privacy'), - } - } else if (sourceJarCoinjoinPreconditionSummary?.isFulfilled === false) { - return { - variant: 'warning', - text: t('send.button_send_despite_warning'), - } - } - } + const [showConfirmSendModal, setShowConfirmSendModal] = useState() - return { - variant: 'dark', - text: t('send.button_send'), - } - }, [isLoading, isCoinjoin, sourceJarCoinjoinPreconditionSummary, t]) - - const formIsValid = useMemo(() => { - return ( - isValidAddress(destination) && - !destinationIsReusedAddress && - isValidJarIndex(sourceJarIndex ?? -1) && - isValidAmount(amount, isSweep) && - (isCoinjoin ? isValidNumCollaborators(numCollaborators, minNumCollaborators) : true) - ) - }, [ - destination, - sourceJarIndex, - amount, - numCollaborators, - minNumCollaborators, - isCoinjoin, - isSweep, - destinationIsReusedAddress, - ]) - - useEffect(() => setAmount(isSweep ? 0 : null), [isSweep]) + const initialValues = useMemo(() => createInitialValues(minNumCollaborators), [minNumCollaborators]) + + const formRef = useRef>(null) // This callback is responsible for updating `waitForUtxosToBeSpent` while // the wallet is synchronizing. The wallet needs some time after a tx is sent @@ -277,7 +227,6 @@ export default function Send({ wallet }: SendProps) { if (abortCtrl.signal.aborted) return setMinNumCollaborators(minimumMakers) - setNumCollaborators(initialNumCollaborators(minimumMakers)) }) .catch((err) => { if (abortCtrl.signal.aborted) return @@ -293,20 +242,6 @@ export default function Send({ wallet }: SendProps) { [isOperationDisabled, wallet, reloadCurrentWalletInfo, reloadServiceInfo, loadConfigValue, t], ) - useEffect( - function checkReusedDestinationAddressHook() { - if (destination !== null && walletInfo?.addressSummary[destination]) { - if (walletInfo.addressSummary[destination].status !== 'new') { - setDestinationIsReusedAddress(true) - return - } - } - - setDestinationIsReusedAddress(false) - }, - [walletInfo, destination], - ) - const sendPayment = async (sourceJarIndex: JarIndex, destination: Api.BitcoinAddress, amountSats: Api.AmountSats) => { setAlert(undefined) setPaymentSuccessfulInfoAlert(undefined) @@ -420,145 +355,52 @@ export default function Send({ wallet }: SendProps) { }) } - const onSubmit: FormEventHandler = async (e) => { - e.preventDefault() - - if (isLoading || isOperationDisabled) return + const onSubmit = async (values: SendFormValues) => { + if (isLoading || isOperationDisabled || isSending) return setPaymentSuccessfulInfoAlert(undefined) - const form = e.currentTarget - const isValid = formIsValid + const isValid = + values.amount !== undefined && values.sourceJarIndex !== undefined && values.destination !== undefined if (isValid) { - if (isSweep && amount !== 0) { + if (values.amount!.isSweep === true && values.amount!.value !== 0) { console.error('Sanity check failed: Sweep amount mismatch. This should not happen.') return } - if (sourceJarIndex === null || !destination || amount === null || (isCoinjoin && numCollaborators === null)) { + if ( + values.destination!.value === null || + values.amount!.value === null || + (values.isCoinJoin === true && values.numCollaborators === undefined) + ) { console.error('Sanity check failed: Form is invalid and is missing required values. This should not happen.') return } - if (!showConfirmSendModal) { - setShowConfirmSendModal(true) + if (showConfirmSendModal === undefined) { + setShowConfirmSendModal(values) return } - setShowConfirmSendModal(false) + setShowConfirmSendModal(undefined) - const success = isCoinjoin - ? await startCoinjoin(sourceJarIndex, destination, amount, numCollaborators!) - : await sendPayment(sourceJarIndex, destination, amount) + const success = values.isCoinJoin + ? await startCoinjoin( + values.sourceJarIndex!, + values.destination!.value!, + values.amount!.value, + values.numCollaborators!, + ) + : await sendPayment(values.sourceJarIndex!, values.destination!.value!, values.amount!.value) if (success) { - setSourceJarIndex(INITIAL_SOURCE_JAR_INDEX) - setDestination(INITIAL_DESTINATION) - setDestinationJar(null) - setAmount(INITIAL_AMOUNT) - setNumCollaborators(initialNumCollaborators(minNumCollaborators)) - setIsCoinjoin(IS_COINJOIN_DEFAULT_VAL) - setIsSweep(false) - - form.reset() + formRef.current?.resetForm({ values: initialValues }) } scrollToTop() } } - const amountFieldValue = useMemo(() => { - if (amount === null || !isValidNumber(amount)) return '' - - if (isSweep) { - if (!accountBalance) return '' - return `${accountBalance.calculatedAvailableBalanceInSats}` - } - - return amount.toString() - }, [accountBalance, amount, isSweep]) - - const frozenOrLockedWarning = useMemo(() => { - if (!accountBalance || amountFieldValue === '') return <> - - return ( -
- - - - - - - - - - - - - - - - - - - -
{t('send.sweep_amount_breakdown_total_balance')} - -
{t('send.sweep_amount_breakdown_frozen_balance')} - -
{t('send.sweep_amount_breakdown_estimated_amount')} - -
-

- - A sweep transaction will consume all UTXOs of a mixdepth leaving no coins behind except those that - have been - - frozen - - or - - time-locked - - . Mining fees and collaborator fees will be deducted from the amount so as to leave zero change. The - exact transaction amount can only be calculated by JoinMarket at the point when the transaction is - made. Therefore the estimated amount shown might deviate from the actually sent amount. Refer to the - - JoinMarket documentation - - for more details. - -

-
-
-
-
- ) - }, [amountFieldValue, accountBalance, t]) - return ( <>
- {maxFeesConfigMissing && ( - - {t('send.taker_error_message_max_fees_config_missing')} -   - { - setActiveFeeConfigModalSection('cj_fee') - setShowFeeConfigModal(true) - }} - > - {t('settings.show_fee_config')} - - - )} + + {maxFeesConfigMissing && reloadFeeConfigValues()} />} + {alert && ( {alert.message} )} + {paymentSuccessfulInfoAlert && ( <>
@@ -638,303 +470,19 @@ export default function Send({ wallet }: SendProps) { )} - {!isLoading && walletInfo && ( - setDestinationJarPickerShown(false)} - onConfirm={(selectedJar) => { - const abortCtrl = new AbortController() - return Api.getAddressNew({ - ...wallet, - signal: abortCtrl.signal, - mixdepth: selectedJar, - }) - .then((res) => - res.ok ? res.json() : Api.Helper.throwError(res, t('receive.error_loading_address_failed')), - ) - .then((data) => { - if (abortCtrl.signal.aborted) return - setDestination(data.address) - setDestinationJar(selectedJar) - setDestinationJarPickerShown(false) - }) - .catch((err) => { - if (abortCtrl.signal.aborted) return - setAlert({ variant: 'danger', message: err.message }) - setDestinationJarPickerShown(false) - }) - }} - /> - )} - - - {t('send.label_source_jar')} - {!walletInfo || sortedAccountBalances.length === 0 ? ( - - - - ) : ( -
- {sortedAccountBalances.map((it) => ( - 0} - isSelected={it.accountIndex === sourceJarIndex} - fillLevel={jarFillLevel( - it.calculatedTotalBalanceInSats, - walletInfo.balanceSummary.calculatedTotalBalanceInSats, - )} - variant={ - it.accountIndex === sourceJarIndex && - isCoinjoin && - sourceJarCoinjoinPreconditionSummary?.isFulfilled === false - ? 'warning' - : undefined - } - onClick={(jarIndex) => setSourceJarIndex(jarIndex)} - /> - ))} -
- )} -
+ - {!isLoading && - !isOperationDisabled && - isCoinjoin && - sourceJarCoinjoinPreconditionSummary?.isFulfilled === false && ( -
- -
- )} - - - {t('send.label_recipient')} -
- {isLoading ? ( - - - - ) : ( - <> - { - const value = e.target.value - setDestination(value === '' ? null : value) - }} - isInvalid={(destination !== null && !isValidAddress(destination)) || destinationIsReusedAddress} - disabled={isOperationDisabled || destinationJar !== null} - /> - {destinationIsReusedAddress && ( - - {t('send.feedback_reused_address')} - - )} - {!destinationIsReusedAddress && ( - { - if (destinationJar !== null) { - setDestinationJar(null) - setDestination(INITIAL_DESTINATION) - } else { - setDestinationJarPickerShown(true) - } - }} - disabled={isOperationDisabled} - > - {destinationJar !== null ? ( -
- -
- ) : ( -
- -
- )} -
- )} - - )} -
-
- - {t('send.label_amount')} -
- {isLoading ? ( - - - - ) : ( - <> - setAmount(parseInt(e.target.value, 10))} - isInvalid={amount !== null && !isValidAmount(amount, isSweep)} - disabled={isSweep || isOperationDisabled} - /> - setIsSweep((current) => !current)} - disabled={isOperationDisabled || sourceJarIndex === null} - > -
- {isSweep ? ( - <>{t('send.button_clear_sweep')} - ) : ( - <> - - {t('send.button_sweep')} - - )} -
-
- - )} -
- - {t('send.feedback_invalid_amount')} - - {isSweep && <>{frozenOrLockedWarning}} -
- - - setIsCoinjoin(isToggled)} - disabled={isLoading || isOperationDisabled} - /> - -
- - - - - {t('send.fee_breakdown.title', { - maxCollaboratorFee: estimatedMaxCollaboratorFee - ? `≤${formatSats(estimatedMaxCollaboratorFee)} sats` - : '...', - })} - - - { - setActiveFeeConfigModalSection('cj_fee') - setShowFeeConfigModal(true) - }} - className="text-decoration-underline link-secondary" - /> - ), - }} - /> - - - - { - setActiveFeeConfigModalSection('tx_fee') - setShowFeeConfigModal(true) - }} - className="text-decoration-underline link-secondary" - /> - ), - }} - /> - - - {accountBalance && ( - { - setActiveFeeConfigModalSection('cj_fee') - setShowFeeConfigModal(true) - }} - /> - )} - - {showFeeConfigModal && ( - reloadFeeConfigValues()} - onHide={() => setShowFeeConfigModal(false)} - defaultActiveSectionKey={activeFeeConfigModalSection} - /> - )} - -
-
-
- - {isSending ? ( -
-
- ) : ( - <>{submitButtonOptions.text} - )} -
{showConfirmAbortModal && ( )} + {showConfirmSendModal && ( setShowConfirmSendModal(false)} + onCancel={() => setShowConfirmSendModal(undefined)} onConfirm={() => { - submitButtonRef.current?.click() + formRef.current?.submitForm() }} data={{ - sourceJarIndex: sourceJarIndex!, - destination: destination!, - amount: parseInt(amountFieldValue, 10), - isSweep, - isCoinjoin, - numCollaborators: numCollaborators!, + sourceJarIndex: showConfirmSendModal.sourceJarIndex, + destination: showConfirmSendModal.destination?.value!, + amount: showConfirmSendModal.amount!.isSweep + ? sortedAccountBalances[showConfirmSendModal.sourceJarIndex!].calculatedAvailableBalanceInSats + : showConfirmSendModal.amount!.value!, + isSweep: showConfirmSendModal.amount!.isSweep, + isCoinjoin: showConfirmSendModal.isCoinJoin, + numCollaborators: showConfirmSendModal.numCollaborators!, feeConfigValues, }} /> diff --git a/src/components/settings/TxFeeInputField.tsx b/src/components/settings/TxFeeInputField.tsx index 79bec3e7..9128a9ae 100644 --- a/src/components/settings/TxFeeInputField.tsx +++ b/src/components/settings/TxFeeInputField.tsx @@ -162,7 +162,7 @@ export const TxFeeInputField = ({ field, form, label }: TxFeeInputFieldProps) => /> ) : ( Date: Sat, 25 Nov 2023 01:13:02 +0100 Subject: [PATCH 07/19] refactor: pass loadNewWalletAddress to SendFrom --- src/components/Send/DestinationInputField.tsx | 22 +++++------- src/components/Send/SendForm.tsx | 24 +++++-------- src/components/Send/index.tsx | 34 +++++++++++++++---- 3 files changed, 44 insertions(+), 36 deletions(-) diff --git a/src/components/Send/DestinationInputField.tsx b/src/components/Send/DestinationInputField.tsx index 6f74c3b7..1f24023d 100644 --- a/src/components/Send/DestinationInputField.tsx +++ b/src/components/Send/DestinationInputField.tsx @@ -8,7 +8,7 @@ import Sprite from '../Sprite' import { jarName } from '../jars/Jar' import JarSelectorModal from '../JarSelectorModal' -import { CurrentWallet, WalletInfo } from '../../context/WalletContext' +import { WalletInfo } from '../../context/WalletContext' import { noop } from '../../utils' import styles from './DestinationInputField.module.css' @@ -21,10 +21,9 @@ export type DestinationInputFieldProps = { name: string label: string className?: string - wallet: CurrentWallet - setAlert: (value: SimpleAlert | undefined) => void walletInfo?: WalletInfo sourceJarIndex?: JarIndex + loadNewWalletAddress: (props: { signal: AbortSignal; jarIndex: JarIndex }) => Promise isLoading: boolean disabled?: boolean } @@ -33,10 +32,9 @@ export const DestinationInputField = ({ name, label, className, - wallet, walletInfo, - setAlert, sourceJarIndex, + loadNewWalletAddress, isLoading, disabled = false, }: DestinationInputFieldProps) => { @@ -59,20 +57,17 @@ export const DestinationInputField = ({ onCancel={() => setDestinationJarPickerShown(false)} onConfirm={(selectedJar) => { const abortCtrl = new AbortController() - return Api.getAddressNew({ - ...wallet, + + return loadNewWalletAddress({ signal: abortCtrl.signal, - mixdepth: selectedJar, + jarIndex: selectedJar, }) - .then((res) => - res.ok ? res.json() : Api.Helper.throwError(res, t('receive.error_loading_address_failed')), - ) - .then((data) => { + .then((address) => { if (abortCtrl.signal.aborted) return form.setFieldValue( field.name, { - value: data.address, + value: address, fromJar: selectedJar, }, true, @@ -82,7 +77,6 @@ export const DestinationInputField = ({ }) .catch((err) => { if (abortCtrl.signal.aborted) return - setAlert({ variant: 'danger', message: err.message }) setDestinationJarPickerShown(false) }) }} diff --git a/src/components/Send/SendForm.tsx b/src/components/Send/SendForm.tsx index ad13b892..3b88315f 100644 --- a/src/components/Send/SendForm.tsx +++ b/src/components/Send/SendForm.tsx @@ -1,6 +1,7 @@ -import { useState, useMemo, RefObject } from 'react' +import { useState, useMemo } from 'react' import { Trans, useTranslation } from 'react-i18next' import * as rb from 'react-bootstrap' +import * as Api from '../../libs/JmWalletApi' import ToggleSwitch from '../ToggleSwitch' import Sprite from '../Sprite' import { jarFillLevel, SelectableJar } from '../jars/Jar' @@ -9,10 +10,9 @@ import CollaboratorsSelector from './CollaboratorsSelector' import Accordion from '../Accordion' import FeeConfigModal, { FeeConfigSectionKey } from '../settings/FeeConfigModal' import { useFeeConfigValues, useEstimatedMaxCollaboratorFee } from '../../hooks/Fees' -import { CurrentWallet, WalletInfo } from '../../context/WalletContext' +import { WalletInfo } from '../../context/WalletContext' import { buildCoinjoinRequirementSummary } from '../../hooks/CoinjoinRequirements' import { formatSats } from '../../utils' - import { MAX_NUM_COLLABORATORS, isValidAddress, @@ -156,17 +156,15 @@ interface InnerSendFormProps { props: FormikProps isLoading: boolean disabled?: boolean - wallet: CurrentWallet walletInfo?: WalletInfo - setAlert: (value: SimpleAlert | undefined) => void + loadNewWalletAddress: (props: { signal: AbortSignal; jarIndex: JarIndex }) => Promise jarBalances: AccountBalanceSummary[] minNumCollaborators: number } const InnerSendForm = ({ props, - wallet, - setAlert, + loadNewWalletAddress, isLoading, walletInfo, disabled, @@ -260,11 +258,10 @@ const InnerSendForm = ({ @@ -339,9 +336,8 @@ interface SendFormProps { onSubmit: (values: SendFormValues) => Promise isLoading: boolean disabled?: boolean - wallet: CurrentWallet walletInfo?: WalletInfo - setAlert: (value: SimpleAlert | undefined) => void + loadNewWalletAddress: (props: { signal: AbortSignal; jarIndex: JarIndex }) => Promise minNumCollaborators: number formRef?: React.Ref> } @@ -351,9 +347,8 @@ export const SendForm = ({ onSubmit, isLoading, disabled = false, - wallet, + loadNewWalletAddress, walletInfo, - setAlert, minNumCollaborators, formRef, }: SendFormProps) => { @@ -411,10 +406,9 @@ export const SendForm = ({ props={props} jarBalances={sortedJarBalances} minNumCollaborators={minNumCollaborators} + loadNewWalletAddress={loadNewWalletAddress} isLoading={isLoading} - wallet={wallet} walletInfo={walletInfo} - setAlert={setAlert} disabled={disabled} /> ) diff --git a/src/components/Send/index.tsx b/src/components/Send/index.tsx index b1f9798f..426c7790 100644 --- a/src/components/Send/index.tsx +++ b/src/components/Send/index.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState, useMemo, useRef } from 'react' +import { useEffect, useState, useMemo, useRef, useCallback } from 'react' import { Link } from 'react-router-dom' import { FormikProps } from 'formik' import { useTranslation } from 'react-i18next' @@ -137,6 +137,27 @@ export default function Send({ wallet }: SendProps) { const formRef = useRef>(null) + const loadNewWalletAddress = useCallback( + (props: { signal: AbortSignal; jarIndex: JarIndex }): Promise => { + return Api.getAddressNew({ + ...wallet, + signal: props.signal, + mixdepth: props.jarIndex, + }) + .then((res) => (res.ok ? res.json() : Api.Helper.throwError(res, t('receive.error_loading_address_failed')))) + .then((data) => { + return data.address + }) + .catch((err) => { + if (!props.signal.aborted) { + setAlert({ variant: 'danger', message: err.message }) + } + throw err + }) + }, + [wallet, setAlert, t], + ) + // This callback is responsible for updating `waitForUtxosToBeSpent` while // the wallet is synchronizing. The wallet needs some time after a tx is sent // to reflect the changes internally. In order to show the actual balance, @@ -472,15 +493,14 @@ export default function Send({ wallet }: SendProps) { )} {showConfirmAbortModal && ( From 46fe0a5e5a58daa50d310677efc7d84774c72794 Mon Sep 17 00:00:00 2001 From: theborakompanioni Date: Sat, 25 Nov 2023 11:13:09 +0100 Subject: [PATCH 08/19] refactor: fix active state of custom input field in CollaboratorsSelector --- src/components/Send/CollaboratorsSelector.tsx | 22 ++++++------ src/components/Send/SendForm.tsx | 36 ++++++++++--------- 2 files changed, 31 insertions(+), 27 deletions(-) diff --git a/src/components/Send/CollaboratorsSelector.tsx b/src/components/Send/CollaboratorsSelector.tsx index 8d24979d..a3a0755b 100644 --- a/src/components/Send/CollaboratorsSelector.tsx +++ b/src/components/Send/CollaboratorsSelector.tsx @@ -25,13 +25,17 @@ const CollaboratorsSelector = ({ const [field] = useField(name) const form = useFormikContext() - const [usesCustomNumCollaborators, setUsesCustomNumCollaborators] = useState(false) + const [customNumCollaboratorsInput, setCustomNumCollaboratorsInput] = useState() const defaultCollaboratorsSelection = useMemo(() => { const start = Math.max(minNumCollaborators, 8) return [start, start + 1, start + 2] }, [minNumCollaborators]) + const usesCustomNumCollaborators = useMemo(() => { + return field.value === undefined || String(field.value) === customNumCollaboratorsInput + }, [field.value, customNumCollaboratorsInput]) + const validateAndSetCustomNumCollaborators = (candidate: string) => { const parsed = parseInt(candidate, 10) if (isValidNumCollaborators(parsed, minNumCollaborators)) { @@ -60,7 +64,6 @@ const CollaboratorsSelector = ({ [styles.selected]: isSelected, })} onClick={() => { - setUsesCustomNumCollaborators(false) validateAndSetCustomNumCollaborators(String(number)) }} disabled={disabled} @@ -75,20 +78,19 @@ const CollaboratorsSelector = ({ max={maxNumCollaborators} isInvalid={usesCustomNumCollaborators && !isValidNumCollaborators(field.value, minNumCollaborators)} placeholder={t('send.input_num_collaborators_placeholder')} - defaultValue="" + value={customNumCollaboratorsInput || ''} className={classNames(styles.collaboratorsSelectorElement, 'border', 'border-1', { [styles.selected]: usesCustomNumCollaborators, })} onChange={(e) => { - setUsesCustomNumCollaborators(true) + setCustomNumCollaboratorsInput(e.target.value) validateAndSetCustomNumCollaborators(e.target.value) }} - onClick={(e) => { - // @ts-ignore - FIXME: "Property 'value' does not exist on type 'EventTarget'" - if (e.target.value !== '') { - setUsesCustomNumCollaborators(true) - // @ts-ignore - FIXME: "Property 'value' does not exist on type 'EventTarget'" - validateAndSetCustomNumCollaborators(e.target.value) + onClick={(e: any) => { + const val = e.target?.value + if (val !== undefined && val !== '') { + setCustomNumCollaboratorsInput(val) + validateAndSetCustomNumCollaborators(val) } }} disabled={disabled} diff --git a/src/components/Send/SendForm.tsx b/src/components/Send/SendForm.tsx index 3b88315f..666c64cc 100644 --- a/src/components/Send/SendForm.tsx +++ b/src/components/Send/SendForm.tsx @@ -23,7 +23,7 @@ import { import styles from './Send.module.css' import FeeBreakdown from './FeeBreakdown' import { Formik, FormikErrors, FormikProps } from 'formik' -import { AccountBalanceSummary, AccountBalances } from '../../context/BalanceSummary' +import { AccountBalanceSummary } from '../../context/BalanceSummary' import { DestinationInputField, DestinationValue } from './DestinationInputField' import { AmountInputField, AmountValue } from './AmountInputField' import { SweepBreakdown } from './SweepBreakdown' @@ -180,33 +180,44 @@ const InnerSendForm = ({ const sourceJarCoinjoinPreconditionSummary = useMemo(() => { if (sourceJarUtxos === null) return null - console.log(1) return buildCoinjoinRequirementSummary(sourceJarUtxos) }, [sourceJarUtxos]) const sourceJarBalance = props.values.sourceJarIndex !== undefined ? jarBalances[props.values.sourceJarIndex] : undefined - const submitButtonOptions = (() => { + const submitButtonOptions = useMemo(() => { + if (props.isSubmitting) { + return { + variant: 'dark', + element: ( + <> +
- -
{submitButtonOptions.element}
-
+ ) @@ -356,13 +377,6 @@ export const SendForm = ({ }: SendFormProps) => { const { t } = useTranslation() - const sortedJarBalances = useMemo(() => { - if (!walletInfo) return [] - return Object.values(walletInfo.balanceSummary.accountBalances).sort( - (lhs, rhs) => lhs.accountIndex - rhs.accountIndex, - ) - }, [walletInfo]) - const validate = (values: SendFormValues) => { const errors = {} as FormikErrors /** source jar */ @@ -406,11 +420,10 @@ export const SendForm = ({ return ( ) From 486a28946bd4f3858528aaf5bbca44258a3bd09f Mon Sep 17 00:00:00 2001 From: theborakompanioni Date: Sat, 25 Nov 2023 12:31:10 +0100 Subject: [PATCH 10/19] fix(styles): clean up SendForm styles --- .../Send/AmountInputField.module.css | 2 + .../Send/DestinationInputField.module.css | 2 + src/components/Send/Send.module.css | 84 ------- src/components/Send/SendForm.module.css | 31 +++ src/components/Send/SendForm.tsx | 55 ++--- src/components/Send/SweepBreakdown.module.css | 34 +-- src/components/Send/index.tsx | 213 +++++++++--------- 7 files changed, 185 insertions(+), 236 deletions(-) delete mode 100644 src/components/Send/Send.module.css create mode 100644 src/components/Send/SendForm.module.css diff --git a/src/components/Send/AmountInputField.module.css b/src/components/Send/AmountInputField.module.css index 8cf9420b..b76073f1 100644 --- a/src/components/Send/AmountInputField.module.css +++ b/src/components/Send/AmountInputField.module.css @@ -4,6 +4,8 @@ } .input { + height: 3.5rem; + width: 100%; border-right: none !important; } diff --git a/src/components/Send/DestinationInputField.module.css b/src/components/Send/DestinationInputField.module.css index 3a8d8233..ccac22f2 100644 --- a/src/components/Send/DestinationInputField.module.css +++ b/src/components/Send/DestinationInputField.module.css @@ -4,6 +4,8 @@ } .input { + height: 3.5rem; + width: 100%; border-right: none !important; } diff --git a/src/components/Send/Send.module.css b/src/components/Send/Send.module.css deleted file mode 100644 index 7e32f937..00000000 --- a/src/components/Send/Send.module.css +++ /dev/null @@ -1,84 +0,0 @@ -.input { - height: 3.5rem; - width: 100%; -} - -.jarInput { - padding-right: 5rem; -} - -/* Chrome, Safari, Edge, Opera */ -input::-webkit-outer-spin-button, -input::-webkit-inner-spin-button { - -webkit-appearance: none; - margin: 0; -} - -/* Firefox */ -input[type='number'] { - -moz-appearance: textfield; -} - -.buttonJarSelector { - position: absolute; - top: 50%; - right: 0.75rem; - transform: translate(0%, -50%); - padding: 0.2rem 0.5rem !important; - border: none !important; -} - -:root[data-theme='dark'] .buttonJarSelector { - color: var(--bs-gray-800) !important; -} -:root[data-theme='dark'] .buttonJarSelector:hover { - color: var(--bs-white) !important; -} - -.buttonSweep { - width: 6rem; - height: 2rem; - position: absolute; - top: 50%; - right: 0.75rem; - transform: translate(0%, -50%); - padding: 0.2rem 0.5rem !important; -} - -:global .position-relative .form-control.is-invalid + button { - right: 2.1875rem !important; -} - -:root[data-theme='dark'] .buttonSweep { - color: var(--bs-gray-800) !important; -} -:root[data-theme='dark'] .buttonSweep:hover { - color: var(--bs-white) !important; -} - -.inputLoader { - height: 3.5rem; - border-radius: 0.25rem; -} - -.serviceRunning .sendForm, -.serviceRunning .collaboratorsSelector, -.serviceRunning .sendButton { - filter: blur(2px); -} - -.sourceJarsContainer { - display: flex; - flex-wrap: wrap; - flex-direction: row; - justify-content: center; - align-items: flex-start; - gap: 2rem; - color: var(--bs-body-color); - margin-bottom: 1.5rem; -} - -.sourceJarsPlaceholder { - width: 100%; - height: 8rem; -} diff --git a/src/components/Send/SendForm.module.css b/src/components/Send/SendForm.module.css new file mode 100644 index 00000000..b854c1a9 --- /dev/null +++ b/src/components/Send/SendForm.module.css @@ -0,0 +1,31 @@ +/* Chrome, Safari, Edge, Opera */ +input::-webkit-outer-spin-button, +input::-webkit-inner-spin-button { + -webkit-appearance: none; + margin: 0; +} + +/* Firefox */ +input[type='number'] { + -moz-appearance: textfield; +} + +.blurred { + filter: blur(2px); +} + +.sourceJarsContainer { + display: flex; + flex-wrap: wrap; + flex-direction: row; + justify-content: center; + align-items: flex-start; + gap: 2rem; + color: var(--bs-body-color); + margin-bottom: 1.5rem; +} + +.sourceJarsPlaceholder { + width: 100%; + height: 8rem; +} diff --git a/src/components/Send/SendForm.tsx b/src/components/Send/SendForm.tsx index a9249fc7..be783e62 100644 --- a/src/components/Send/SendForm.tsx +++ b/src/components/Send/SendForm.tsx @@ -1,5 +1,6 @@ import { useState, useMemo } from 'react' import { Trans, useTranslation } from 'react-i18next' +import { Formik, FormikErrors, FormikProps } from 'formik' import * as rb from 'react-bootstrap' import * as Api from '../../libs/JmWalletApi' import ToggleSwitch from '../ToggleSwitch' @@ -7,10 +8,13 @@ import Sprite from '../Sprite' import { jarFillLevel, SelectableJar } from '../jars/Jar' import { CoinjoinPreconditionViolationAlert } from '../CoinjoinPreconditionViolationAlert' import CollaboratorsSelector from './CollaboratorsSelector' +import { DestinationInputField, DestinationValue } from './DestinationInputField' +import { AmountInputField, AmountValue } from './AmountInputField' +import { SweepBreakdown } from './SweepBreakdown' +import FeeBreakdown from './FeeBreakdown' import Accordion from '../Accordion' import FeeConfigModal, { FeeConfigSectionKey } from '../settings/FeeConfigModal' -import { useFeeConfigValues, useEstimatedMaxCollaboratorFee } from '../../hooks/Fees' -import { WalletInfo } from '../../context/WalletContext' +import { useEstimatedMaxCollaboratorFee, FeeValues } from '../../hooks/Fees' import { buildCoinjoinRequirementSummary } from '../../hooks/CoinjoinRequirements' import { formatSats } from '../../utils' import { @@ -20,13 +24,9 @@ import { isValidJarIndex, isValidNumCollaborators, } from './helpers' -import styles from './Send.module.css' -import FeeBreakdown from './FeeBreakdown' -import { Formik, FormikErrors, FormikProps } from 'formik' import { AccountBalanceSummary } from '../../context/BalanceSummary' -import { DestinationInputField, DestinationValue } from './DestinationInputField' -import { AmountInputField, AmountValue } from './AmountInputField' -import { SweepBreakdown } from './SweepBreakdown' +import { WalletInfo } from '../../context/WalletContext' +import styles from './SendForm.module.css' type CollaborativeTransactionOptionsProps = { selectedAmount?: AmountValue @@ -37,6 +37,8 @@ type CollaborativeTransactionOptionsProps = { minNumCollaborators: number numCollaborators: number | null setNumCollaborators: (val: number | null) => void + feeConfigValues?: FeeValues + reloadFeeConfigValues: () => void } function CollaborativeTransactionOptions({ @@ -46,10 +48,11 @@ function CollaborativeTransactionOptions({ isLoading, disabled, minNumCollaborators, + feeConfigValues, + reloadFeeConfigValues, }: CollaborativeTransactionOptionsProps) { const { t } = useTranslation() - const [feeConfigValues, reloadFeeConfigValues] = useFeeConfigValues() const [activeFeeConfigModalSection, setActiveFeeConfigModalSection] = useState() const [showFeeConfigModal, setShowFeeConfigModal] = useState(false) @@ -205,20 +208,26 @@ export interface SendFormValues { interface InnerSendFormProps { props: FormikProps + className?: string isLoading: boolean walletInfo?: WalletInfo loadNewWalletAddress: (props: { signal: AbortSignal; jarIndex: JarIndex }) => Promise minNumCollaborators: number + feeConfigValues?: FeeValues + reloadFeeConfigValues: () => void disabled?: boolean } const InnerSendForm = ({ props, + className, isLoading, walletInfo, loadNewWalletAddress, minNumCollaborators, - disabled, + feeConfigValues, + reloadFeeConfigValues, + disabled = false, }: InnerSendFormProps) => { const { t } = useTranslation() @@ -244,7 +253,7 @@ const InnerSendForm = ({ return ( <> - + {t('send.label_source_jar')} {!walletInfo || jarBalances.length === 0 ? ( @@ -290,7 +299,6 @@ const InnerSendForm = ({
)} props.setFieldValue('numCollaborators', val, true)} + feeConfigValues={feeConfigValues} + reloadFeeConfigValues={reloadFeeConfigValues} /> @@ -354,26 +363,21 @@ const InnerSendForm = ({ ) } -interface SendFormProps { +type SendFormProps = Omit & { initialValues: SendFormValues onSubmit: (values: SendFormValues) => Promise - isLoading: boolean - disabled?: boolean - walletInfo?: WalletInfo - loadNewWalletAddress: (props: { signal: AbortSignal; jarIndex: JarIndex }) => Promise - minNumCollaborators: number formRef?: React.Ref> + blurred?: boolean } export const SendForm = ({ initialValues, onSubmit, - isLoading, - disabled = false, - loadNewWalletAddress, + formRef, + blurred = false, walletInfo, minNumCollaborators, - formRef, + ...innerProps }: SendFormProps) => { const { t } = useTranslation() @@ -420,11 +424,10 @@ export const SendForm = ({ return ( ) }} diff --git a/src/components/Send/SweepBreakdown.module.css b/src/components/Send/SweepBreakdown.module.css index ccceca5a..3675891d 100644 --- a/src/components/Send/SweepBreakdown.module.css +++ b/src/components/Send/SweepBreakdown.module.css @@ -3,20 +3,33 @@ } .sweepBreakdownTable { - color: var(--bs-black); + color: var(--bs-dark); } :root[data-theme='dark'] .sweepBreakdownTable { - color: var(--bs-white); + color: var(--bs-light); } .sweepBreakdownTable .balanceCol { text-align: right; } +.sweepBreakdownAnchor { + font-size: 0.8rem; + color: var(--bs-dark); +} + +:root[data-theme='dark'] .sweepBreakdownAnchor { + color: var(--bs-light) !important; +} + +:root[data-theme='dark'] .sweepBreakdownParagraph { + color: var(--bs-light) !important; +} + .accordionButton { background-color: transparent; - color: var(--bs-black); + color: var(--bs-dark); display: flex; justify-content: flex-end; font-size: 0.8rem; @@ -38,18 +51,5 @@ } :root[data-theme='dark'] .accordionButton { - color: var(--bs-white); -} - -.sweepBreakdownAnchor { - font-size: 0.8rem; - color: var(--bs-black); -} - -:root[data-theme='dark'] .sweepBreakdownAnchor { - color: var(--bs-white) !important; -} - -:root[data-theme='dark'] .sweepBreakdownParagraph { - color: var(--bs-white) !important; + color: var(--bs-light); } diff --git a/src/components/Send/index.tsx b/src/components/Send/index.tsx index 426c7790..9dedc9f4 100644 --- a/src/components/Send/index.tsx +++ b/src/components/Send/index.tsx @@ -3,7 +3,6 @@ import { Link } from 'react-router-dom' import { FormikProps } from 'formik' import { useTranslation } from 'react-i18next' import * as rb from 'react-bootstrap' -import classNames from 'classnames' import * as Api from '../../libs/JmWalletApi' import PageTitle from '../PageTitle' import Sprite from '../Sprite' @@ -19,7 +18,6 @@ import { JM_MINIMUM_MAKERS_DEFAULT } from '../../constants/config' import { scrollToTop } from '../../utils' import { initialNumCollaborators } from './helpers' import { SendForm, SendFormValues } from './SendForm' -import styles from './Send.module.css' const INITIAL_DESTINATION = null const INITIAL_SOURCE_JAR_INDEX = null @@ -423,119 +421,116 @@ export default function Send({ wallet }: SendProps) { } return ( - <> -
- - - <> - {isMakerRunning && ( - - - - {t('send.text_maker_running')} - - - - - - - )} - {isCoinjoinInProgress && ( -
-
-
- -
+
+ + + <> + {isMakerRunning && ( + + + + {t('send.text_maker_running')} + + + + + + + )} + {isCoinjoinInProgress && ( +
+
+
+
- - {t('send.text_coinjoin_already_running')} - - - abortCoinjoin()} - > -
- - {t('global.abort')} -
-
- )} - - + + {t('send.text_coinjoin_already_running')} + + + abortCoinjoin()} + > +
+ + {t('global.abort')} +
+
+
+ )} + +
- {maxFeesConfigMissing && reloadFeeConfigValues()} />} + {maxFeesConfigMissing && reloadFeeConfigValues()} />} - {alert && ( - - {alert.message} - - )} + {alert && ( + + {alert.message} + + )} - {paymentSuccessfulInfoAlert && ( - <> -
-
- -
+ {paymentSuccessfulInfoAlert && ( + <> +
+
+
- - {paymentSuccessfulInfoAlert.message} - - - )} - - +
+ + {paymentSuccessfulInfoAlert.message} + + + )} - {showConfirmAbortModal && ( - setShowConfirmAbortModal(false)} - onConfirm={() => abortCoinjoin()} - > - {t('send.confirm_abort_modal.text_body')} - - )} - - {showConfirmSendModal && ( - setShowConfirmSendModal(undefined)} - onConfirm={() => { - formRef.current?.submitForm() - }} - data={{ - sourceJarIndex: showConfirmSendModal.sourceJarIndex, - destination: showConfirmSendModal.destination?.value!, - amount: showConfirmSendModal.amount!.isSweep - ? sortedAccountBalances[showConfirmSendModal.sourceJarIndex!].calculatedAvailableBalanceInSats - : showConfirmSendModal.amount!.value!, - isSweep: showConfirmSendModal.amount!.isSweep, - isCoinjoin: showConfirmSendModal.isCoinJoin, - numCollaborators: showConfirmSendModal.numCollaborators!, - feeConfigValues, - }} - /> - )} -
- + + + {showConfirmAbortModal && ( + setShowConfirmAbortModal(false)} + onConfirm={() => abortCoinjoin()} + > + {t('send.confirm_abort_modal.text_body')} + + )} + + {showConfirmSendModal && ( + setShowConfirmSendModal(undefined)} + onConfirm={() => { + formRef.current?.submitForm() + }} + data={{ + sourceJarIndex: showConfirmSendModal.sourceJarIndex, + destination: showConfirmSendModal.destination?.value!, + amount: showConfirmSendModal.amount!.isSweep + ? sortedAccountBalances[showConfirmSendModal.sourceJarIndex!].calculatedAvailableBalanceInSats + : showConfirmSendModal.amount!.value!, + isSweep: showConfirmSendModal.amount!.isSweep, + isCoinjoin: showConfirmSendModal.isCoinJoin, + numCollaborators: showConfirmSendModal.numCollaborators!, + feeConfigValues, + }} + /> + )} +
) } From faac4791b4a55566ad64d8be812fca456d63e7c7 Mon Sep 17 00:00:00 2001 From: theborakompanioni Date: Sat, 25 Nov 2023 12:59:01 +0100 Subject: [PATCH 11/19] refactor: SourceJarSelector component --- src/components/Send/SendForm.module.css | 16 ----- src/components/Send/SendForm.tsx | 72 +++++++------------ .../Send/SourceJarSelector.module.css | 15 ++++ src/components/Send/SourceJarSelector.tsx | 69 ++++++++++++++++++ 4 files changed, 108 insertions(+), 64 deletions(-) create mode 100644 src/components/Send/SourceJarSelector.module.css create mode 100644 src/components/Send/SourceJarSelector.tsx diff --git a/src/components/Send/SendForm.module.css b/src/components/Send/SendForm.module.css index b854c1a9..2941a46e 100644 --- a/src/components/Send/SendForm.module.css +++ b/src/components/Send/SendForm.module.css @@ -13,19 +13,3 @@ input[type='number'] { .blurred { filter: blur(2px); } - -.sourceJarsContainer { - display: flex; - flex-wrap: wrap; - flex-direction: row; - justify-content: center; - align-items: flex-start; - gap: 2rem; - color: var(--bs-body-color); - margin-bottom: 1.5rem; -} - -.sourceJarsPlaceholder { - width: 100%; - height: 8rem; -} diff --git a/src/components/Send/SendForm.tsx b/src/components/Send/SendForm.tsx index be783e62..28e74682 100644 --- a/src/components/Send/SendForm.tsx +++ b/src/components/Send/SendForm.tsx @@ -5,11 +5,11 @@ import * as rb from 'react-bootstrap' import * as Api from '../../libs/JmWalletApi' import ToggleSwitch from '../ToggleSwitch' import Sprite from '../Sprite' -import { jarFillLevel, SelectableJar } from '../jars/Jar' +import { SourceJarSelector } from './SourceJarSelector' import { CoinjoinPreconditionViolationAlert } from '../CoinjoinPreconditionViolationAlert' -import CollaboratorsSelector from './CollaboratorsSelector' import { DestinationInputField, DestinationValue } from './DestinationInputField' import { AmountInputField, AmountValue } from './AmountInputField' +import CollaboratorsSelector from './CollaboratorsSelector' import { SweepBreakdown } from './SweepBreakdown' import FeeBreakdown from './FeeBreakdown' import Accordion from '../Accordion' @@ -251,61 +251,37 @@ const InnerSendForm = ({ const sourceJarBalance = props.values.sourceJarIndex !== undefined ? jarBalances[props.values.sourceJarIndex] : undefined + const showCoinjoinPreconditionViolationAlert = + !isLoading && !disabled && props.values.isCoinJoin && sourceJarCoinjoinPreconditionSummary?.isFulfilled === false + return ( <> - - {t('send.label_source_jar')} - {!walletInfo || jarBalances.length === 0 ? ( - - - - ) : ( -
- {jarBalances.map((it) => ( - 0} - isSelected={it.accountIndex === props.values.sourceJarIndex} - fillLevel={jarFillLevel( - it.calculatedTotalBalanceInSats, - walletInfo.balanceSummary.calculatedTotalBalanceInSats, - )} - variant={ - it.accountIndex === props.values.sourceJarIndex && - props.values.isCoinJoin && - sourceJarCoinjoinPreconditionSummary?.isFulfilled === false - ? 'warning' - : undefined - } - onClick={(jarIndex) => props.setFieldValue('sourceJarIndex', jarIndex, true)} - /> - ))} -
- )} -
- {!isLoading && - !disabled && - props.values.isCoinJoin && - sourceJarCoinjoinPreconditionSummary?.isFulfilled === false && ( -
- -
- )} + + {showCoinjoinPreconditionViolationAlert && ( +
+ +
+ )} + { + const { t } = useTranslation() + const [field] = useField(name) + const form = useFormikContext() + + const jarBalances = useMemo(() => { + if (!walletInfo) return [] + return Object.values(walletInfo.balanceSummary.accountBalances).sort( + (lhs, rhs) => lhs.accountIndex - rhs.accountIndex, + ) + }, [walletInfo]) + + return ( + <> + + {label} + {!walletInfo || jarBalances.length === 0 ? ( + + + + ) : ( +
+ {jarBalances.map((it) => ( + 0} + isSelected={it.accountIndex === field.value} + fillLevel={jarFillLevel( + it.calculatedTotalBalanceInSats, + walletInfo.balanceSummary.calculatedTotalBalanceInSats, + )} + variant={it.accountIndex === field.value ? variant : undefined} + onClick={(jarIndex) => form.setFieldValue(field.name, jarIndex, true)} + /> + ))} +
+ )} +
+ + ) +} From 3813640595429060dc1073484464983760a92916 Mon Sep 17 00:00:00 2001 From: theborakompanioni Date: Sat, 25 Nov 2023 13:06:04 +0100 Subject: [PATCH 12/19] fix(ui): do not display number input as textfield on Send page --- src/components/Send/SendForm.module.css | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/src/components/Send/SendForm.module.css b/src/components/Send/SendForm.module.css index 2941a46e..e9cc4d50 100644 --- a/src/components/Send/SendForm.module.css +++ b/src/components/Send/SendForm.module.css @@ -1,15 +1,3 @@ -/* Chrome, Safari, Edge, Opera */ -input::-webkit-outer-spin-button, -input::-webkit-inner-spin-button { - -webkit-appearance: none; - margin: 0; -} - -/* Firefox */ -input[type='number'] { - -moz-appearance: textfield; -} - .blurred { filter: blur(2px); } From d6be5b29cdfb7e035d53c183807777a6ceb67dc0 Mon Sep 17 00:00:00 2001 From: theborakompanioni Date: Sat, 25 Nov 2023 13:21:22 +0100 Subject: [PATCH 13/19] Revert "dev: switch to kristapsks backend branch" This reverts commit bad32e6eec99404b9a5c03277db5e889e3480699. --- .../regtest/dockerfile-deps/joinmarket/latest/Dockerfile | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/docker/regtest/dockerfile-deps/joinmarket/latest/Dockerfile b/docker/regtest/dockerfile-deps/joinmarket/latest/Dockerfile index 0b71d25c..871c2875 100644 --- a/docker/regtest/dockerfile-deps/joinmarket/latest/Dockerfile +++ b/docker/regtest/dockerfile-deps/joinmarket/latest/Dockerfile @@ -7,10 +7,9 @@ RUN apt-get update \ tor \ && rm -rf /var/lib/apt/lists/* -#ENV REPO https://github.com/JoinMarket-Org/joinmarket-clientserver -ENV REPO https://github.com/kristapsk/joinmarket-clientserver -ENV REPO_BRANCH wallet_rpc-direct-send-txfee -ENV REPO_REF wallet_rpc-direct-send-txfee +ENV REPO https://github.com/JoinMarket-Org/joinmarket-clientserver +ENV REPO_BRANCH master +ENV REPO_REF master WORKDIR /src RUN git clone "$REPO" . --depth=10 --branch "$REPO_BRANCH" && git checkout "$REPO_REF" From bd159569153edaf1e74c8ff18ddfbb75feceae8d Mon Sep 17 00:00:00 2001 From: theborakompanioni Date: Sat, 25 Nov 2023 13:43:31 +0100 Subject: [PATCH 14/19] fix: remove unused var --- src/components/Send/SourceJarSelector.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/components/Send/SourceJarSelector.tsx b/src/components/Send/SourceJarSelector.tsx index 52bbcc86..fc82d14f 100644 --- a/src/components/Send/SourceJarSelector.tsx +++ b/src/components/Send/SourceJarSelector.tsx @@ -1,5 +1,4 @@ import { useMemo } from 'react' -import { useTranslation } from 'react-i18next' import { useField, useFormikContext } from 'formik' import * as rb from 'react-bootstrap' import { jarFillLevel, SelectableJar } from '../jars/Jar' @@ -24,7 +23,6 @@ export const SourceJarSelector = ({ isLoading, disabled = false, }: SourceJarSelectorProps) => { - const { t } = useTranslation() const [field] = useField(name) const form = useFormikContext() From 3ff21b27362b75680f8dd5341d68ff730c0c82ab Mon Sep 17 00:00:00 2001 From: theborakompanioni Date: Sun, 26 Nov 2023 14:37:46 +0100 Subject: [PATCH 15/19] fix(style): input groups with validation in SendForm --- src/components/Send/AmountInputField.module.css | 10 +--------- src/components/Send/AmountInputField.tsx | 6 +++--- src/components/Send/DestinationInputField.module.css | 10 +--------- src/components/Send/DestinationInputField.tsx | 7 +++---- src/index.css | 7 ++++++- 5 files changed, 14 insertions(+), 26 deletions(-) diff --git a/src/components/Send/AmountInputField.module.css b/src/components/Send/AmountInputField.module.css index b76073f1..7131bd8b 100644 --- a/src/components/Send/AmountInputField.module.css +++ b/src/components/Send/AmountInputField.module.css @@ -11,15 +11,7 @@ .button { --bs-btn-border-color: var(--bs-border-color) !important; + --bs-btn-hover-border-color: var(--bs-btn-border-color) !important; border-left: none !important; font-size: 0.875rem !important; } - -/* hack to show feedback element with input-groups */ -:global div.is-invalid .invalid-feedback { - display: block; -} - -:global .form-control.is-invalid + button { - --bs-btn-border-color: red !important; -} diff --git a/src/components/Send/AmountInputField.tsx b/src/components/Send/AmountInputField.tsx index f1d7e1e0..11350b34 100644 --- a/src/components/Send/AmountInputField.tsx +++ b/src/components/Send/AmountInputField.tsx @@ -56,7 +56,7 @@ export const AmountInputField = ({ ) : ( <> {field.value?.isSweep === true ? ( - + ) : (
- + + {form.errors[field.name]}
)} )} - {form.errors[field.name]} ) diff --git a/src/components/Send/DestinationInputField.module.css b/src/components/Send/DestinationInputField.module.css index ccac22f2..2db805e7 100644 --- a/src/components/Send/DestinationInputField.module.css +++ b/src/components/Send/DestinationInputField.module.css @@ -11,14 +11,6 @@ .button { --bs-btn-border-color: var(--bs-border-color) !important; + --bs-btn-hover-border-color: var(--bs-btn-border-color) !important; border-left: none !important; } - -/* hack to show feedback element with input-groups */ -:global div.is-invalid .invalid-feedback { - display: block; -} - -:global .form-control.is-invalid + button { - --bs-btn-border-color: red !important; -} diff --git a/src/components/Send/DestinationInputField.tsx b/src/components/Send/DestinationInputField.tsx index 1f24023d..2a4c4a45 100644 --- a/src/components/Send/DestinationInputField.tsx +++ b/src/components/Send/DestinationInputField.tsx @@ -92,7 +92,7 @@ export const DestinationInputField = ({ ) : ( <> {field.value.fromJar !== null ? ( - + ) : (
- +
+ {form.errors[field.name]}
)} - - {form.errors[field.name]} )} diff --git a/src/index.css b/src/index.css index 945fff7d..04db82f3 100644 --- a/src/index.css +++ b/src/index.css @@ -523,6 +523,10 @@ h2 { color: currentColor; } +.form-control.is-invalid + .btn { + --bs-btn-border-color: var(--bs-form-invalid-border-color) !important; +} + .alert-info { color: var(--bs-white); background-color: var(--bs-gray-800); @@ -709,13 +713,14 @@ h2 { } :root[data-theme='dark'] .form-control { + background-color: var(--bs-light); color: var(--bs-body-bg); - background-color: var(--bs-white); } :root[data-theme='dark'] .form-control:disabled, :root[data-theme='dark'] .form-select:disabled, .form-control[readonly] { + --bs-border-color: var(--bs-gray-500); background-color: var(--bs-gray-500); } From fd3449838d1f2a504d81d1ffb0b777e5e756cac1 Mon Sep 17 00:00:00 2001 From: theborakompanioni Date: Sun, 26 Nov 2023 16:34:29 +0100 Subject: [PATCH 16/19] fix(style): reduce custom btn colors --- src/components/Accordion.tsx | 4 +- src/components/Earn.module.css | 10 --- src/components/Modal.module.css | 2 +- src/components/Receive.module.css | 11 ---- .../Send/AmountInputField.module.css | 4 -- src/components/Send/AmountInputField.tsx | 4 +- .../Send/DestinationInputField.module.css | 7 --- src/components/Send/DestinationInputField.tsx | 2 +- src/components/Wallet.tsx | 9 ++- src/index.css | 62 +++++++++---------- 10 files changed, 39 insertions(+), 76 deletions(-) diff --git a/src/components/Accordion.tsx b/src/components/Accordion.tsx index 5e5ede36..0a60c98e 100644 --- a/src/components/Accordion.tsx +++ b/src/components/Accordion.tsx @@ -25,7 +25,7 @@ const Accordion = ({
setIsOpen((current) => !current)} disabled={disabled} > @@ -50,7 +50,7 @@ const Accordion = ({
-
+
{children}
diff --git a/src/components/Earn.module.css b/src/components/Earn.module.css index f4207675..44c842f4 100644 --- a/src/components/Earn.module.css +++ b/src/components/Earn.module.css @@ -20,16 +20,6 @@ font-size: 1.2rem; } -.earn hr { - border-color: rgba(222, 222, 222, 1); - opacity: 100%; -} - -:root[data-theme='dark'] .earn hr { - border-color: var(--bs-gray-800); - opacity: 100%; -} - .offerLoader { height: 10rem; border-radius: 0.25rem; diff --git a/src/components/Modal.module.css b/src/components/Modal.module.css index f3383043..40ca3644 100644 --- a/src/components/Modal.module.css +++ b/src/components/Modal.module.css @@ -38,8 +38,8 @@ } .modal-footer :global .btn { + --bs-btn-border-color: var(--bs-border-color); flex-grow: 1; min-height: 2.8rem; font-weight: 500; - border-color: rgba(222, 222, 222, 1); } diff --git a/src/components/Receive.module.css b/src/components/Receive.module.css index 31adaff6..23b573ee 100644 --- a/src/components/Receive.module.css +++ b/src/components/Receive.module.css @@ -1,16 +1,5 @@ -.receive hr { - border-color: rgba(222, 222, 222, 1); - opacity: 100%; -} - -:root[data-theme='dark'] .receive hr { - border-color: var(--bs-gray-800); - opacity: 100%; -} - .receive button { font-weight: 500; - border-color: rgba(222, 222, 222, 1); } .receive form input { diff --git a/src/components/Send/AmountInputField.module.css b/src/components/Send/AmountInputField.module.css index 7131bd8b..28159634 100644 --- a/src/components/Send/AmountInputField.module.css +++ b/src/components/Send/AmountInputField.module.css @@ -6,12 +6,8 @@ .input { height: 3.5rem; width: 100%; - border-right: none !important; } .button { - --bs-btn-border-color: var(--bs-border-color) !important; - --bs-btn-hover-border-color: var(--bs-btn-border-color) !important; - border-left: none !important; font-size: 0.875rem !important; } diff --git a/src/components/Send/AmountInputField.tsx b/src/components/Send/AmountInputField.tsx index 11350b34..b21fb135 100644 --- a/src/components/Send/AmountInputField.tsx +++ b/src/components/Send/AmountInputField.tsx @@ -109,7 +109,7 @@ export const AmountInputField = ({ disabled={disabled} />
diff --git a/src/components/Send/DestinationInputField.module.css b/src/components/Send/DestinationInputField.module.css index 2db805e7..419e00af 100644 --- a/src/components/Send/DestinationInputField.module.css +++ b/src/components/Send/DestinationInputField.module.css @@ -6,11 +6,4 @@ .input { height: 3.5rem; width: 100%; - border-right: none !important; -} - -.button { - --bs-btn-border-color: var(--bs-border-color) !important; - --bs-btn-hover-border-color: var(--bs-btn-border-color) !important; - border-left: none !important; } diff --git a/src/components/Send/DestinationInputField.tsx b/src/components/Send/DestinationInputField.tsx index 2a4c4a45..c83dc69f 100644 --- a/src/components/Send/DestinationInputField.tsx +++ b/src/components/Send/DestinationInputField.tsx @@ -140,7 +140,7 @@ export const DestinationInputField = ({ disabled={disabled} /> setDestinationJarPickerShown(true)} disabled={disabled || !walletInfo} diff --git a/src/components/Wallet.tsx b/src/components/Wallet.tsx index ebfa1f07..9246026b 100644 --- a/src/components/Wallet.tsx +++ b/src/components/Wallet.tsx @@ -3,12 +3,12 @@ import { Link } from 'react-router-dom' import { Formik, FormikErrors } from 'formik' import * as rb from 'react-bootstrap' import { useTranslation } from 'react-i18next' -import { walletDisplayName } from '../utils' import { TabActivityIndicator, JoiningIndicator } from './ActivityIndicators' import Sprite from './Sprite' import { routes } from '../constants/routes' -import styles from './Wallet.module.css' +import { walletDisplayName } from '../utils' import { WalletFileName } from '../libs/JmWalletApi' +import styles from './Wallet.module.css' interface WalletLockFormProps { walletFileName: WalletFileName @@ -82,9 +82,8 @@ const WalletUnlockForm = ({ walletFileName, unlockWallet }: WalletUnlockFormProp } const onSubmit = useCallback( - async (values) => { - const { password } = values - await unlockWallet(walletFileName, password) + async (values: WalletUnlockFormValues) => { + await unlockWallet(walletFileName, values.password) }, [walletFileName, unlockWallet], ) diff --git a/src/index.css b/src/index.css index 04db82f3..077dfffa 100644 --- a/src/index.css +++ b/src/index.css @@ -523,10 +523,6 @@ h2 { color: currentColor; } -.form-control.is-invalid + .btn { - --bs-btn-border-color: var(--bs-form-invalid-border-color) !important; -} - .alert-info { color: var(--bs-white); background-color: var(--bs-gray-800); @@ -562,7 +558,6 @@ h2 { } /* Wallets Styles */ - .wallets a.wallet-name { text-decoration: none; color: var(--bs-body-color); @@ -572,14 +567,7 @@ h2 { text-decoration: underline; } -.wallets .btn.btn-outline-dark:not(:hover) { - border-color: rgba(222, 222, 222, 1); -} -:root[data-theme='dark'] .wallets .btn.btn-outline-dark:not(:hover) { - border-color: var(--bs-gray-800); -} /* Alpha Warning */ - .warning-card-wrapper { position: fixed; height: 100%; @@ -670,29 +658,37 @@ h2 { :root[data-theme='dark'] .btn:not(.btn-light) { --bs-btn-color: var(--bs-white); } - -:root[data-theme='dark'] .btn-dark { - background-color: var(--bs-gray-800); - border-color: var(--bs-gray-800); -} - :root[data-theme='dark'] .input-group-text { background-color: var(--bs-gray-800); } -:root[data-theme='dark'] .btn-dark:hover { - background-color: var(--bs-gray-dark); - border-color: var(--bs-gray-dark) !important; +:root[data-theme='dark'] .btn-dark { + --bs-btn-color: var(--bs-light); + --bs-btn-bg: var(--bs-gray-800); + --bs-btn-border-color: var(--bs-btn-bg); + --bs-btn-hover-bg: var(--bs-gray-dark); + --bs-btn-hover-border-color: var(--bs-btn-hover-bg); + --bs-btn-active-color: #fff; + --bs-btn-active-bg: var(--bs-btn-hover-bg); + --bs-btn-active-border-color: var(--bs-btn-active-bg); + --bs-btn-active-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125); + --bs-btn-disabled-color: var(--bs-light); + --bs-btn-disabled-bg: transparent; + --bs-btn-disabled-border-color: var(--bs-btn-border-color); } :root[data-theme='dark'] .btn-outline-dark { - color: var(--bs-white); - border-color: var(--bs-gray-800); -} - -:root[data-theme='dark'] .btn-outline-dark:hover { - background-color: var(--bs-gray-dark) !important; - border-color: var(--bs-gray-dark) !important; + --bs-btn-color: var(--bs-light); + --bs-btn-border-color: var(--bs-gray-800); + --bs-btn-hover-bg: var(--bs-gray-dark); + --bs-btn-hover-border-color: var(--bs-btn-hover-bg); + --bs-btn-active-color: #fff; + --bs-btn-active-bg: var(--bs-btn-hover-bg); + --bs-btn-active-border-color: var(--bs-btn-active-bg); + --bs-btn-active-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125); + --bs-btn-disabled-color: var(--bs-light); + --bs-btn-disabled-bg: transparent; + --bs-btn-disabled-border-color: var(--bs-btn-border-color); } :root[data-theme='dark'] .offcanvas { @@ -700,7 +696,7 @@ h2 { } :root[data-theme='dark'] .spinner-border { - border-color: var(--bs-white) !important; + border-color: var(--bs-light) !important; border-right-color: transparent !important; } @@ -719,7 +715,7 @@ h2 { :root[data-theme='dark'] .form-control:disabled, :root[data-theme='dark'] .form-select:disabled, -.form-control[readonly] { +:root[data-theme='dark'] .form-control[readonly] { --bs-border-color: var(--bs-gray-500); background-color: var(--bs-gray-500); } @@ -729,15 +725,15 @@ h2 { } :root[data-theme='dark'] .link-dark { - color: var(--bs-white) !important; - text-decoration-color: RGBA(var(--bs-white), var(--bs-link-underline-opacity, 1)) !important; + color: var(--bs-light) !important; + text-decoration-color: rgba(var(--bs-light), var(--bs-link-underline-opacity, 1)) !important; } :root[data-theme='dark'] .toast { background-color: transparent; } :root[data-theme='dark'] .toast-header { - color: var(--bs-white); + color: var(--bs-light); background-color: var(--bs-gray-800); border-color: var(--bs-gray-900); } From a803ded0a20e84aa339d0777df0e2d8e42058a39 Mon Sep 17 00:00:00 2001 From: theborakompanioni Date: Thu, 30 Nov 2023 22:51:39 +0100 Subject: [PATCH 17/19] chore(fee): improve fee config modal handling --- src/components/settings/FeeConfigModal.tsx | 102 ++++++++++---------- src/components/settings/TxFeeInputField.tsx | 12 +-- 2 files changed, 55 insertions(+), 59 deletions(-) diff --git a/src/components/settings/FeeConfigModal.tsx b/src/components/settings/FeeConfigModal.tsx index 8dee42ed..50960daa 100644 --- a/src/components/settings/FeeConfigModal.tsx +++ b/src/components/settings/FeeConfigModal.tsx @@ -45,8 +45,8 @@ type FeeFormValues = FeeValues & { interface FeeConfigFormProps { initialValues: FeeFormValues - validate: (values: FeeValues) => FormikErrors - onSubmit: (values: FeeValues) => void + validate: (values: FeeFormValues) => FormikErrors + onSubmit: (values: FeeFormValues) => void defaultActiveSectionKey?: FeeConfigSectionKey } @@ -247,13 +247,13 @@ export default function FeeConfigModal({ const loadFeeConfigValues = useLoadFeeConfigValues() const [isLoading, setIsLoading] = useState(true) const [isSubmitting, setIsSubmitting] = useState(false) - const [loadError, setLoadError] = useState(false) + const [loadError, setLoadError] = useState() const [saveErrorMessage, setSaveErrorMessage] = useState() - const [feeFormValues, setFeeFormValues] = useState(null) + const [feeFormValues, setFeeFormValues] = useState() const formRef = useRef>(null) useEffect(() => { - setLoadError(false) + setLoadError(undefined) const abortCtrl = new AbortController() if (show) { @@ -265,10 +265,10 @@ export default function FeeConfigModal({ setIsLoading(false) setFeeFormValues(val) }) - .catch((e) => { + .catch((error) => { if (abortCtrl.signal.aborted) return setIsLoading(false) - setLoadError(true) + setLoadError(error) }) } else { setSaveErrorMessage(undefined) @@ -279,48 +279,6 @@ export default function FeeConfigModal({ } }, [show, loadFeeConfigValues]) - const submit = async (feeValues: FeeValues) => { - const allValuesPresent = Object.values(feeValues).every((it) => it !== undefined) - if (!allValuesPresent) return - if (feeValues.tx_fees?.value === undefined) return - - const updates = [ - { - key: FEE_CONFIG_KEYS.tx_fees, - value: String(feeValues.tx_fees?.value!), - }, - { - key: FEE_CONFIG_KEYS.tx_fees_factor, - value: String(feeValues.tx_fees_factor), - }, - { - key: FEE_CONFIG_KEYS.max_cj_fee_abs, - value: String(feeValues.max_cj_fee_abs), - }, - { - key: FEE_CONFIG_KEYS.max_cj_fee_rel, - value: String(feeValues.max_cj_fee_rel), - }, - ] - - setSaveErrorMessage(undefined) - setIsSubmitting(true) - try { - await updateConfigValues({ updates }) - - setIsSubmitting(false) - onSuccess && onSuccess() - onHide() - } catch (err: any) { - setIsSubmitting(false) - setSaveErrorMessage((_) => - t('settings.fees.error_saving_fee_config_failed', { - reason: err.message || t('global.errors.reason_unknown'), - }), - ) - } - } - const validate = useCallback( (values: FeeFormValues) => { const errors = {} as FormikErrors @@ -373,6 +331,44 @@ export default function FeeConfigModal({ [t], ) + const submit = async (values: FeeFormValues) => { + const updates = [ + { + key: FEE_CONFIG_KEYS.tx_fees, + value: String(values.tx_fees?.value ?? ''), + }, + { + key: FEE_CONFIG_KEYS.tx_fees_factor, + value: String(values.tx_fees_factor ?? ''), + }, + { + key: FEE_CONFIG_KEYS.max_cj_fee_abs, + value: String(values.max_cj_fee_abs ?? ''), + }, + { + key: FEE_CONFIG_KEYS.max_cj_fee_rel, + value: String(values.max_cj_fee_rel ?? ''), + }, + ] + + setSaveErrorMessage(undefined) + setIsSubmitting(true) + try { + await updateConfigValues({ updates }) + + setIsSubmitting(false) + onSuccess && onSuccess() + onHide() + } catch (err: any) { + setIsSubmitting(false) + setSaveErrorMessage((_) => + t('settings.fees.error_saving_fee_config_failed', { + reason: err.message || t('global.errors.reason_unknown'), + }), + ) + } + } + const cancel = useCallback(() => { onCancel && onCancel() onHide() @@ -455,10 +451,10 @@ export default function FeeConfigModal({ variant="outline-dark" className="position-relative" onClick={() => { - formRef.current?.setFieldValue('max_cj_fee_abs', '', false) - formRef.current?.setFieldValue('max_cj_fee_rel', '', false) - formRef.current?.setFieldValue('tx_fees', '', false) - formRef.current?.setFieldValue('tx_fees_factor', '', false) + formRef.current?.setFieldValue('max_cj_fee_abs', undefined, false) + formRef.current?.setFieldValue('max_cj_fee_rel', undefined, false) + formRef.current?.setFieldValue('tx_fees', undefined, false) + formRef.current?.setFieldValue('tx_fees_factor', undefined, false) setTimeout(() => formRef.current?.validateForm(), 4) }} disabled={isLoading || isSubmitting} diff --git a/src/components/settings/TxFeeInputField.tsx b/src/components/settings/TxFeeInputField.tsx index 9128a9ae..344d68ed 100644 --- a/src/components/settings/TxFeeInputField.tsx +++ b/src/components/settings/TxFeeInputField.tsx @@ -61,7 +61,7 @@ export const validateTxFee = (val: TxFee | undefined, t: TFunction): FormikError return errors } -type TxFeeInputFieldProps = FieldProps & { +type TxFeeInputFieldProps = FieldProps & { label: string } @@ -110,13 +110,13 @@ export const TxFeeInputField = ({ field, form, label }: TxFeeInputFieldProps) => } } }} - initialValue={field.value.unit} + initialValue={field.value?.unit || 'blocks'} disabled={form.isSubmitting} /> {t( - field.value.unit === 'sats/kilo-vbyte' + field.value?.unit === 'sats/kilo-vbyte' ? 'settings.fees.description_tx_fees_satspervbyte' : 'settings.fees.description_tx_fees_blocks', )} @@ -124,7 +124,7 @@ export const TxFeeInputField = ({ field, form, label }: TxFeeInputFieldProps) => - {field.value.unit === 'sats/kilo-vbyte' ? ( + {field.value?.unit === 'sats/kilo-vbyte' ? ( <> / vB @@ -133,7 +133,7 @@ export const TxFeeInputField = ({ field, form, label }: TxFeeInputFieldProps) => )} - {field.value.unit === 'sats/kilo-vbyte' ? ( + {field.value?.unit === 'sats/kilo-vbyte' ? ( name={field.name} type="number" placeholder="1" - value={isValidNumber(field.value.value) ? field.value.value : ''} + value={isValidNumber(field.value?.value) ? field.value?.value : ''} disabled={form.isSubmitting} onBlur={field.onBlur} onChange={(e) => { From 6639f3ec269fc3fc14a40c5cbaead604efd508b4 Mon Sep 17 00:00:00 2001 From: theborakompanioni Date: Thu, 30 Nov 2023 23:05:04 +0100 Subject: [PATCH 18/19] build(deps): update formik from v2.2.9 to v2.4.5 --- package-lock.json | 56 +++++++++++++++++++++++++++++++++++++---------- package.json | 2 +- 2 files changed, 46 insertions(+), 12 deletions(-) diff --git a/package-lock.json b/package-lock.json index 983f776e..2c4107a4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,7 +14,7 @@ "@table-library/react-table-library": "^4.0.23", "bootstrap": "^5.3.2", "classnames": "^2.3.2", - "formik": "^2.2.9", + "formik": "^2.4.5", "i18next": "^22.0.4", "i18next-browser-languagedetector": "^7.0.1", "qrcode": "^1.5.1", @@ -4625,6 +4625,15 @@ "@types/node": "*" } }, + "node_modules/@types/hoist-non-react-statics": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.5.tgz", + "integrity": "sha512-SbcrWzkKBw2cdwRTwQAswfpB9g9LJWfjtUeW/jvNwbhC8cpmmNYVePa+ncbUe0rGTQ7G3Ff6mYUN2VMfLVr+Sg==", + "dependencies": { + "@types/react": "*", + "hoist-non-react-statics": "^3.3.0" + } + }, "node_modules/@types/html-minifier-terser": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/@types/html-minifier-terser/-/html-minifier-terser-6.1.0.tgz", @@ -9868,9 +9877,9 @@ } }, "node_modules/formik": { - "version": "2.2.9", - "resolved": "https://registry.npmjs.org/formik/-/formik-2.2.9.tgz", - "integrity": "sha512-LQLcISMmf1r5at4/gyJigGn0gOwFbeEAlji+N9InZF6LIMXnFNkO42sCI8Jt84YZggpD4cPWObAZaxpEFtSzNA==", + "version": "2.4.5", + "resolved": "https://registry.npmjs.org/formik/-/formik-2.4.5.tgz", + "integrity": "sha512-Gxlht0TD3vVdzMDHwkiNZqJ7Mvg77xQNfmBRrNtvzcHZs72TJppSTDKHpImCMJZwcWPBJ8jSQQ95GJzXFf1nAQ==", "funding": [ { "type": "individual", @@ -9878,18 +9887,24 @@ } ], "dependencies": { + "@types/hoist-non-react-statics": "^3.3.1", "deepmerge": "^2.1.1", "hoist-non-react-statics": "^3.3.0", "lodash": "^4.17.21", "lodash-es": "^4.17.21", "react-fast-compare": "^2.0.1", "tiny-warning": "^1.0.2", - "tslib": "^1.10.0" + "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0" } }, + "node_modules/formik/node_modules/tslib": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz", + "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==" + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -21114,7 +21129,8 @@ "node_modules/tslib": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", - "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==" + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "dev": true }, "node_modules/tsutils": { "version": "3.21.0", @@ -25834,6 +25850,15 @@ "@types/node": "*" } }, + "@types/hoist-non-react-statics": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.5.tgz", + "integrity": "sha512-SbcrWzkKBw2cdwRTwQAswfpB9g9LJWfjtUeW/jvNwbhC8cpmmNYVePa+ncbUe0rGTQ7G3Ff6mYUN2VMfLVr+Sg==", + "requires": { + "@types/react": "*", + "hoist-non-react-statics": "^3.3.0" + } + }, "@types/html-minifier-terser": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/@types/html-minifier-terser/-/html-minifier-terser-6.1.0.tgz", @@ -29832,17 +29857,25 @@ } }, "formik": { - "version": "2.2.9", - "resolved": "https://registry.npmjs.org/formik/-/formik-2.2.9.tgz", - "integrity": "sha512-LQLcISMmf1r5at4/gyJigGn0gOwFbeEAlji+N9InZF6LIMXnFNkO42sCI8Jt84YZggpD4cPWObAZaxpEFtSzNA==", + "version": "2.4.5", + "resolved": "https://registry.npmjs.org/formik/-/formik-2.4.5.tgz", + "integrity": "sha512-Gxlht0TD3vVdzMDHwkiNZqJ7Mvg77xQNfmBRrNtvzcHZs72TJppSTDKHpImCMJZwcWPBJ8jSQQ95GJzXFf1nAQ==", "requires": { + "@types/hoist-non-react-statics": "^3.3.1", "deepmerge": "^2.1.1", "hoist-non-react-statics": "^3.3.0", "lodash": "^4.17.21", "lodash-es": "^4.17.21", "react-fast-compare": "^2.0.1", "tiny-warning": "^1.0.2", - "tslib": "^1.10.0" + "tslib": "^2.0.0" + }, + "dependencies": { + "tslib": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz", + "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==" + } } }, "forwarded": { @@ -38040,7 +38073,8 @@ "tslib": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", - "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==" + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "dev": true }, "tsutils": { "version": "3.21.0", diff --git a/package.json b/package.json index 3b5b11c7..b3e6a063 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,7 @@ "@table-library/react-table-library": "^4.0.23", "bootstrap": "^5.3.2", "classnames": "^2.3.2", - "formik": "^2.2.9", + "formik": "^2.4.5", "i18next": "^22.0.4", "i18next-browser-languagedetector": "^7.0.1", "qrcode": "^1.5.1", From a4988c3ba10090278272786f389222582de52bb6 Mon Sep 17 00:00:00 2001 From: theborakompanioni Date: Fri, 1 Dec 2023 00:20:59 +0100 Subject: [PATCH 19/19] ui(send): show form feedback for source jar index value --- src/components/MnemonicWordInput.tsx | 2 +- src/components/Send/SendForm.tsx | 2 +- src/components/Send/SourceJarSelector.tsx | 13 +++++++++++++ src/i18n/locales/en/translation.json | 1 + 4 files changed, 16 insertions(+), 2 deletions(-) diff --git a/src/components/MnemonicWordInput.tsx b/src/components/MnemonicWordInput.tsx index fcbf93a2..eb61abc8 100644 --- a/src/components/MnemonicWordInput.tsx +++ b/src/components/MnemonicWordInput.tsx @@ -15,7 +15,7 @@ const MnemonicWordInput = ({ index, value, setValue, isValid, disabled }: Mnemon return ( {index + 1}. - /** source jar */ if (!isValidJarIndex(values.sourceJarIndex ?? -1)) { - errors.sourceJarIndex = t('send.feedback_invalid_destination_address') + errors.sourceJarIndex = t('send.feedback_invalid_source_jar') } /** source jar - end */ diff --git a/src/components/Send/SourceJarSelector.tsx b/src/components/Send/SourceJarSelector.tsx index fc82d14f..3456797e 100644 --- a/src/components/Send/SourceJarSelector.tsx +++ b/src/components/Send/SourceJarSelector.tsx @@ -2,6 +2,7 @@ import { useMemo } from 'react' import { useField, useFormikContext } from 'formik' import * as rb from 'react-bootstrap' import { jarFillLevel, SelectableJar } from '../jars/Jar' +import { noop } from '../../utils' import { WalletInfo } from '../../context/WalletContext' import styles from './SourceJarSelector.module.css' @@ -61,6 +62,18 @@ export const SourceJarSelector = ({ ))}
)} + +