diff --git a/frontends/web/src/api/account.ts b/frontends/web/src/api/account.ts index edd2a9b8e5..ecf2259df3 100644 --- a/frontends/web/src/api/account.ts +++ b/frontends/web/src/api/account.ts @@ -1,5 +1,5 @@ /** - * Copyright 2021-2024 Shift Crypto AG + * Copyright 2021-2025 Shift Crypto AG * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -364,7 +364,7 @@ export const sendTx = ( return apiPost(`account/${code}/sendtx`, txNote); }; -export type FeeTargetCode = 'custom' | 'low' | 'economy' | 'normal' | 'high'; +export type FeeTargetCode = 'custom' | 'low' | 'economy' | 'normal' | 'high' | 'mHour' | 'mHalfHour' | 'mFastest'; export interface IProposeTxData { address?: string; diff --git a/frontends/web/src/routes/account/send/feetargets.tsx b/frontends/web/src/routes/account/send/feetargets.tsx index 69742f11ed..3ffcc715c4 100644 --- a/frontends/web/src/routes/account/send/feetargets.tsx +++ b/frontends/web/src/routes/account/send/feetargets.tsx @@ -1,6 +1,6 @@ /** * Copyright 2018 Shift Devices AG - * Copyright 2021-2024 Shift Crypto AG + * Copyright 2021-2025 Shift Crypto AG * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -91,13 +91,16 @@ export const FeeTargets = ({ }); } setOptions(options); + if (feeTarget) { + return; + } setFeeTarget(feeTargets.defaultFeeTarget); onFeeTargetChange(feeTargets.defaultFeeTarget); if (feeTargets.feeTargets.length === 0) { setNoFeeTargets(true); } focusInput(); - }, [t, feeTargets, focusInput, accountCode, config, onFeeTargetChange, disabled]); + }, [t, feeTarget, feeTargets, focusInput, accountCode, config, onFeeTargetChange, disabled]); const handleFeeTargetChange = (event: React.SyntheticEvent) => { const target = event.target as HTMLSelectElement; diff --git a/frontends/web/src/routes/account/send/send.tsx b/frontends/web/src/routes/account/send/send.tsx index 61bfee207c..70a89686d5 100644 --- a/frontends/web/src/routes/account/send/send.tsx +++ b/frontends/web/src/routes/account/send/send.tsx @@ -1,6 +1,6 @@ /** * Copyright 2018 Shift Devices AG - * Copyright 2023-2024 Shift Crypto AG + * Copyright 2023-2025 Shift Crypto AG * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -15,7 +15,10 @@ * limitations under the License. */ -import { Component } from 'react'; +import { useState, useRef, useEffect, useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; +import type { TSelectedUTXOs } from './utxos'; +import { useMountedRef } from '@/hooks/mount'; import * as accountApi from '@/api/account'; import { syncdone } from '@/api/accountsync'; import { convertFromCurrency, convertToCurrency, parseExternalBtcAmount } from '@/api/coins'; @@ -26,7 +29,6 @@ import { HideAmountsButton } from '@/components/hideamountsbutton/hideamountsbut import { Button } from '@/components/forms'; import { BackButton } from '@/components/backbutton/backbutton'; import { Column, ColumnButtons, Grid, GuideWrapper, GuidedContent, Header, Main } from '@/components/layout'; -import { translate, TranslateProps } from '@/decorators/translate'; import { Amount } from '@/components/amount/amount'; import { FeeTargets } from './feetargets'; import { isBitcoinBased } from '@/routes/account/utils'; @@ -38,502 +40,460 @@ import { CoinInput } from './components/inputs/coin-input'; import { FiatInput } from './components/inputs/fiat-input'; import { NoteInput } from './components/inputs/note-input'; import { FiatValue } from './components/fiat-value'; -import { TSelectedUTXOs } from './utxos'; import { TProposalError, txProposalErrorHandling } from './services'; import { CoinControl } from './coin-control'; import style from './send.module.css'; -type SendProps = { +type TProps = { account: accountApi.IAccount; activeCurrency: accountApi.Fiat; } -type Props = SendProps & TranslateProps; - -export type State = { - balance?: accountApi.IBalance; - proposedFee?: accountApi.TAmountWithConversions; - proposedTotal?: accountApi.TAmountWithConversions; - recipientAddress: string; - proposedAmount?: accountApi.TAmountWithConversions; - valid: boolean; - amount: string; - fiatAmount: string; - sendAll: boolean; - feeTarget?: accountApi.FeeTargetCode; - customFee: string; - isConfirming: boolean; - sendResult?: accountApi.TSendTx; - isUpdatingProposal: boolean; - errorHandling: TProposalError; - note: string; -} +const useAccountBalance = (accountCode: accountApi.AccountCode) => { + const mounted = useMountedRef(); + const [balance, setBalance] = useState(); -class Send extends Component { - private selectedUTXOs: TSelectedUTXOs = {}; - private unsubscribe?: () => void; + const updateBalance = useCallback(async (code: accountApi.AccountCode) => { + if (mounted.current) { + const result = await accountApi.getBalance(code); + if (result.success && mounted.current) { + setBalance(result.balance); + } + } + }, [mounted]); - // in case there are multiple parallel tx proposals we can ignore all other but the last one - private lastProposal: Promise | null = null; - private proposeTimeout: ReturnType | null = null; - - public readonly state: State = { - recipientAddress: '', - amount: '', - fiatAmount: '', - valid: false, - sendAll: false, - isConfirming: false, - isUpdatingProposal: false, - note: '', - customFee: '', - errorHandling: {}, - }; + useEffect(() => { + updateBalance(accountCode); + return syncdone(accountCode, () => updateBalance(accountCode)); + }, [accountCode, updateBalance]); - public componentDidMount() { - const updateBalance = (code: string) => accountApi.getBalance(code) - .then(balance => { - if (!balance.success) { - return; - } - this.setState({ balance: balance.balance }); - }) - .catch(console.error); + return balance; +}; - updateBalance(this.props.account.code); +export const Send = ({ + account, + activeCurrency, +}: TProps) => { + const { t } = useTranslation(); - const currentCode = this.props.account.code; - this.unsubscribe = syncdone(currentCode, () => { - if (this.props.account.code === currentCode) { - updateBalance(currentCode); - } - }); - } + const selectedUTXOsRef = useRef({}); + // in case there are multiple parallel tx proposals we can ignore all other but the last one + const lastProposal = useRef | null>(null); + const proposeTimeout = useRef | null>(null); + + const [recipientAddress, setRecipientAddress] = useState(''); + const [amount, setAmount] = useState(''); + const [fiatAmount, setFiatAmount] = useState(''); + const [valid, setValid] = useState(false); + const [sendAll, setSendAll] = useState(false); + const [isConfirming, setIsConfirming] = useState(false); + const [isUpdatingProposal, setIsUpdatingProposal] = useState(false); + const [note, setNote] = useState(''); + const [customFee, setCustomFee] = useState(''); + const [errorHandling, setErrorHandling] = useState({}); + + const [proposedFee, setProposedFee] = useState(); + const [proposedTotal, setProposedTotal] = useState(); + const [proposedAmount, setProposedAmount] = useState(); + const [feeTarget, setFeeTarget] = useState(); + const [sendResult, setSendResult] = useState(); + + const [updateFiat, setUpdateFiat] = useState(true); + + const balance = useAccountBalance(account.code); + + const handleContinue = () => { + setSendAll(false); + setIsConfirming(false); + setRecipientAddress(''); + setProposedAmount(undefined); + setProposedFee(undefined); + setProposedTotal(undefined); + setFiatAmount(''); + setAmount(''); + setNote(''); + setCustomFee(''); + setSendResult(undefined); + selectedUTXOsRef.current = {}; + }; - public componentWillUnmount() { - if (this.unsubscribe) { - this.unsubscribe(); - } - } - - private reset = () => { - this.setState({ - sendAll: false, - isConfirming: false, - recipientAddress: '', - proposedAmount: undefined, - proposedFee: undefined, - proposedTotal: undefined, - fiatAmount: '', - amount: '', - note: '', - customFee: '', - }); - this.selectedUTXOs = {}; + const handleRetry = () => { + setSendResult(undefined); }; - private send = async () => { - const code = this.props.account.code; - const connectResult = await accountApi.connectKeystore(code); + const handleSend = useCallback(async () => { + const connectResult = await accountApi.connectKeystore(account.code); if (!connectResult.success) { return; } - - this.setState({ isConfirming: true }); + setIsConfirming(true); try { - const result = await accountApi.sendTx(code, this.state.note); - this.setState({ sendResult: result, isConfirming: false }); + const result = await accountApi.sendTx(account.code, note); + setSendResult(result); + setIsConfirming(false); } catch (err) { console.error(err); } finally { // The following method allows pressing escape again. - this.setState({ isConfirming: false, }); + setIsConfirming(false); } - }; + }, [account.code, note]); - private getValidTxInputData = (): Required | false => { + const getValidTxInputData = useCallback((): Required | false => { if ( - !this.state.recipientAddress - || this.state.feeTarget === undefined - || (!this.state.sendAll && !this.state.amount) - || (this.state.feeTarget === 'custom' && !this.state.customFee) + !recipientAddress + || feeTarget === undefined + || (!sendAll && !amount) + || (feeTarget === 'custom' && !customFee) ) { return false; } return { - address: this.state.recipientAddress, - amount: this.state.amount, - feeTarget: this.state.feeTarget, - customFee: this.state.customFee, - sendAll: (this.state.sendAll ? 'yes' : 'no'), - selectedUTXOs: Object.keys(this.selectedUTXOs), + address: recipientAddress, + amount, + feeTarget, + customFee, + sendAll: (sendAll ? 'yes' : 'no'), + selectedUTXOs: Object.keys(selectedUTXOsRef.current), paymentRequest: null, useHighestFee: false }; - }; - - private validateAndDisplayFee = (updateFiat: boolean = true) => { - this.setState({ - proposedTotal: undefined, - errorHandling: {}, - }); - const txInput = this.getValidTxInputData(); - if (!txInput) { - return; - } - if (this.proposeTimeout) { - clearTimeout(this.proposeTimeout); - this.proposeTimeout = null; - } - this.setState({ isUpdatingProposal: true }); - // defer the transaction proposal - this.proposeTimeout = setTimeout(async () => { - const proposePromise = accountApi.proposeTx(this.props.account.code, txInput); - // keep this as the last known proposal - this.lastProposal = proposePromise; - try { - const result = await proposePromise; - // continue only if this is the most recent proposal - if (proposePromise === this.lastProposal) { - this.txProposal(updateFiat, result); - } - } catch (error) { - this.setState({ valid: false }); - console.error('Failed to propose transaction:', error); - } finally { - // cleanup regardless of success or failure - if (proposePromise === this.lastProposal) { - this.lastProposal = null; - } - } - }, 400); // Delay the proposal by 400 ms - }; - - private txProposal = ( - updateFiat: boolean, - result: accountApi.TTxProposalResult, - ) => { - this.setState({ valid: result.success }); - if (result.success) { - this.setState({ - errorHandling: {}, - proposedFee: result.fee, - proposedAmount: result.amount, - proposedTotal: result.total, - isUpdatingProposal: false, - }); - if (updateFiat) { - this.convertToFiat(result.amount.amount); - } - } else { - const errorHandling = txProposalErrorHandling(result.errorCode); - this.setState({ errorHandling, isUpdatingProposal: false }); - if (errorHandling.amountError - || Object.keys(errorHandling).length === 0) { - this.setState({ proposedFee: undefined }); - } - } - }; - - private handleFiatInput = (fiatAmount: string) => { - this.setState({ fiatAmount }); - this.convertFromFiat(fiatAmount); - }; + }, [recipientAddress, feeTarget, sendAll, amount, customFee]); - private convertToFiat = async (amount: string) => { + const convertToFiat = useCallback(async (amount: string) => { if (amount) { - const coinCode = this.props.account.coinCode; + const coinCode = account.coinCode; const data = await convertToCurrency({ amount, coinCode, - fiatUnit: this.props.activeCurrency, + fiatUnit: activeCurrency, }); if (data.success) { - this.setState({ fiatAmount: data.fiatAmount }); + setFiatAmount(data.fiatAmount); } else { - this.setState({ errorHandling: { amountError: this.props.t('send.error.invalidAmount') } }); + setErrorHandling({ + amountError: t('send.error.invalidAmount') + }); } } else { - this.setState({ fiatAmount: '' }); + setFiatAmount(''); } - }; + }, [account.coinCode, activeCurrency, t]); - private convertFromFiat = async (amount: string) => { + const convertFromFiat = useCallback(async (amount: string) => { if (amount) { - const coinCode = this.props.account.coinCode; + const coinCode = account.coinCode; const data = await convertFromCurrency({ amount, coinCode, - fiatUnit: this.props.activeCurrency, + fiatUnit: activeCurrency, }); if (data.success) { - this.setState({ amount: data.amount }, () => this.validateAndDisplayFee(false)); + setAmount(data.amount); + setUpdateFiat(false); } else { - this.setState({ errorHandling: { amountError: this.props.t('send.error.invalidAmount') } }); + setErrorHandling({ amountError: t('send.error.invalidAmount') }); } } else { - this.setState({ amount: '' }); + setAmount(''); } + }, [account.coinCode, activeCurrency, t]); + + const txProposal = useCallback(( + updateFiat: boolean, + result: accountApi.TTxProposalResult, + ) => { + setValid(result.success); + if (result.success) { + setErrorHandling({}); + setProposedFee(result.fee); + setProposedAmount(result.amount); + setProposedTotal(result.total); + setIsUpdatingProposal(false); + if (updateFiat) { + convertToFiat(result.amount.amount); + } + } else { + const errorHandling = txProposalErrorHandling(result.errorCode); + setErrorHandling(errorHandling); + setIsUpdatingProposal(false); + + if ( + errorHandling.amountError + || Object.keys(errorHandling).length === 0 + ) { + setProposedFee(undefined); + } + } + }, [convertToFiat]); + + const validateAndDisplayFee = useCallback(( + updateFiat: boolean = true, + ) => { + setProposedTotal(undefined); + setErrorHandling({}); + const txInput = getValidTxInputData(); + if (!txInput) { + return; + } + if (proposeTimeout.current) { + clearTimeout(proposeTimeout.current); + proposeTimeout.current = null; + } + setIsUpdatingProposal(true); + // defer the transaction proposal + proposeTimeout.current = setTimeout(async () => { + let proposePromise; + try { + proposePromise = accountApi.proposeTx(account.code, txInput); + // keep this as the last known proposal + lastProposal.current = proposePromise; + const result = await proposePromise; + // continue only if this is the most recent proposal + if (proposePromise === lastProposal.current) { + txProposal(updateFiat, result); + } + } catch (error) { + if (proposePromise === lastProposal.current) { + setValid(false); + console.error('Failed to propose transaction:', error); + } + } finally { + // cleanup regardless of success or failure + if (proposePromise === lastProposal.current) { + lastProposal.current = null; + } + } + }, 400); // Delay the proposal by 400 ms + }, [account.code, getValidTxInputData, txProposal]); + + useEffect(() => { + validateAndDisplayFee(updateFiat); + }, [amount, customFee, feeTarget, fiatAmount, updateFiat, validateAndDisplayFee]); + + const handleFeeTargetChange = (feeTarget: accountApi.FeeTargetCode) => { + setFeeTarget(feeTarget); + setCustomFee(''); + setUpdateFiat(sendAll); + }; + + const handleFiatInput = (fiatAmount: string) => { + setFiatAmount(fiatAmount); + convertFromFiat(fiatAmount); }; - private feeTargetChange = (feeTarget: accountApi.FeeTargetCode) => { - this.setState( - { feeTarget, customFee: '' }, - () => this.validateAndDisplayFee(this.state.sendAll), - ); + const handleSelectedUTXOsChange = (selectedUTXOs: TSelectedUTXOs) => { + selectedUTXOsRef.current = selectedUTXOs; + setUpdateFiat(true); + validateAndDisplayFee(true); }; - private onSelectedUTXOsChange = (selectedUTXOs: TSelectedUTXOs) => { - this.selectedUTXOs = selectedUTXOs; - this.validateAndDisplayFee(true); + const hasSelectedUTXOs = (): boolean => { + return Object.keys(selectedUTXOsRef.current).length !== 0; }; - private hasSelectedUTXOs = (): boolean => { - return Object.keys(this.selectedUTXOs).length !== 0; + const handleReceiverAddressInputChange = (recipientAddress: string) => { + setRecipientAddress(recipientAddress); + setUpdateFiat(true); }; - private parseQRResult = async (uri: string) => { - let address; - let amount = ''; + const parseQRResult = async (uri: string) => { + let qrAddress; + let qrAmount = ''; try { const url = new URL(uri); if (url.protocol !== 'bitcoin:' && url.protocol !== 'litecoin:' && url.protocol !== 'ethereum:') { - alertUser(this.props.t('invalidFormat')); + alertUser(t('invalidFormat')); return; } - address = url.pathname; - if (isBitcoinBased(this.props.account.coinCode)) { - amount = url.searchParams.get('amount') || ''; + qrAddress = url.pathname; + if (isBitcoinBased(account.coinCode)) { + qrAmount = url.searchParams.get('amount') || ''; } } catch { - address = uri; + qrAddress = uri; } - let updateState = { - recipientAddress: address, - sendAll: false, - fiatAmount: '' - } as Pick; - const coinCode = this.props.account.coinCode; - if (amount) { - if (coinCode === 'btc' || coinCode === 'tbtc') { - const result = await parseExternalBtcAmount(amount); + if (qrAmount) { + if (account.coinCode === 'btc' || account.coinCode === 'tbtc') { + const result = await parseExternalBtcAmount(qrAmount); if (result.success) { - updateState['amount'] = result.amount; + setAmount(result.amount); } else { - updateState['errorHandling'] = { amountError: this.props.t('send.error.invalidAmount') }; - this.setState(updateState); + setRecipientAddress(qrAddress); + setSendAll(false); + setFiatAmount(''); + setErrorHandling({ amountError: t('send.error.invalidAmount') }); return; } } else { - updateState['amount'] = amount; + setAmount(qrAmount); } } - - this.setState(updateState, () => { - this.convertToFiat(this.state.amount); - this.validateAndDisplayFee(true); - }); - }; - - private onReceiverAddressInputChange = (recipientAddress: string) => { - this.setState({ recipientAddress }, () => { - this.validateAndDisplayFee(true); - }); + setRecipientAddress(qrAddress); + setSendAll(false); + setFiatAmount(''); + convertToFiat(qrAmount); + setUpdateFiat(true); }; - private onCoinAmountChange = (amount: string) => { - this.convertToFiat(amount); - this.setState({ amount }, () => { - this.validateAndDisplayFee(true); - }); + const handleCoinAmountChange = (amount: string) => { + convertToFiat(amount); + setAmount(amount); + setUpdateFiat(true); }; - private onSendAllChange = (sendAll: boolean) => { + const handleSendAllChange = (sendAll: boolean) => { if (!sendAll) { - this.convertToFiat(this.state.amount); + convertToFiat(amount); } - this.setState({ sendAll }, () => { - this.validateAndDisplayFee(true); - }); + setSendAll(sendAll); + setUpdateFiat(true); }; - private handleContinue = () => { - this.setState({ - sendResult: undefined, - }); - this.reset(); + const handleCustomFee = (customFee: string) => { + setCustomFee(customFee); + setUpdateFiat(false); }; - public render() { - const { - account, - activeCurrency, - t, - } = this.props; - - const { - balance, - proposedFee, - proposedTotal, - recipientAddress, - proposedAmount, - valid, - amount, - /* data, */ - fiatAmount, - sendAll, - feeTarget, - customFee, - isConfirming, - sendResult, - isUpdatingProposal, - errorHandling, - note, - } = this.state; - - const waitDialogTransactionDetails = { - proposedFee, - proposedAmount, - proposedTotal, - customFee, - feeTarget, - recipientAddress, - activeCurrency, - }; - - return ( - - -
-
{t('send.title', { accountName: account.coinName })}} - > - -
- - -
- -
- -
- -
- -
- + const handleNodeChange = (note: string) => setNote(note); + + return ( + + +
+
{t('send.title', { accountName: account.coinName })}} + > + +
+ + +
+ +
+ +
+ +
+
- - - - - - - - - - - - - - - - this.setState({ customFee }, this.validateAndDisplayFee)} - error={errorHandling.feeError} /> - - - this.setState({ note: note })} - /> - - - - {t('button.back')} - - - - - - - {sendResult && ( - this.setState({ sendResult: undefined })}> -

- {(proposedAmount && - ) || 'N/A'} - {' '} - - {(proposedAmount && proposedAmount.unit) || 'N/A'} - -

- {(proposedAmount && proposedAmount.conversions && proposedAmount.conversions[activeCurrency]) ? ( - - ) : null} -
- )} - -
-
- -
- - ); - } -} - -const TranslatedSend = translate()(Send); -export { TranslatedSend as Send }; +
+ + + + + + + + + + + + + + + + + + + + + + + {t('button.back')} + + + + +
+ + {sendResult && ( + +

+ {(proposedAmount && + ) || 'N/A'} + {' '} + + {(proposedAmount && proposedAmount.unit) || 'N/A'} + +

+ {(proposedAmount && proposedAmount.conversions && proposedAmount.conversions[activeCurrency]) ? ( + + ) : null} +
+ )} +
+
+
+ +
+ ); +};