diff --git a/apps/ledger-live-desktop/src/renderer/components/QRCode.tsx b/apps/ledger-live-desktop/src/renderer/components/QRCode.tsx index b13d09b363df..b8daa8910797 100644 --- a/apps/ledger-live-desktop/src/renderer/components/QRCode.tsx +++ b/apps/ledger-live-desktop/src/renderer/components/QRCode.tsx @@ -4,6 +4,7 @@ import qrcode from "qrcode"; type Props = { data: string; size: number; + errorCorrectionLevel?: qrcode.QRCodeErrorCorrectionLevel | undefined; }; class QRCode extends PureComponent { static defaultProps = { @@ -21,7 +22,7 @@ class QRCode extends PureComponent { canvas: React.RefObject = React.createRef(); drawQRCode() { - const { data, size } = this.props; + const { data, size, errorCorrectionLevel } = this.props; const { current } = this.canvas; if (!current) return; qrcode.toCanvas( @@ -30,6 +31,7 @@ class QRCode extends PureComponent { { width: current.width, margin: 0, + errorCorrectionLevel, }, () => { // fix again the CSS because lib changes it –_– diff --git a/apps/ledger-live-desktop/src/renderer/families/evm/SendAmountFields/MaxFeeField.tsx b/apps/ledger-live-desktop/src/renderer/families/evm/SendAmountFields/MaxFeeField.tsx index acdb41edd00f..38ceaa156938 100644 --- a/apps/ledger-live-desktop/src/renderer/families/evm/SendAmountFields/MaxFeeField.tsx +++ b/apps/ledger-live-desktop/src/renderer/families/evm/SendAmountFields/MaxFeeField.tsx @@ -19,7 +19,7 @@ import { EvmFamily } from "../types"; const ErrorContainer = styled(Box)<{ hasError: Error }>` margin-top: 0px; - font-size: 12px; + font-size: 10px; width: 100%; transition: all 0.4s ease-in-out; will-change: max-height; diff --git a/apps/ledger-live-desktop/src/renderer/families/mimblewimble_coin/ReceiveStepConnectDevice.tsx b/apps/ledger-live-desktop/src/renderer/families/mimblewimble_coin/ReceiveStepConnectDevice.tsx new file mode 100644 index 000000000000..599ad4b22376 --- /dev/null +++ b/apps/ledger-live-desktop/src/renderer/families/mimblewimble_coin/ReceiveStepConnectDevice.tsx @@ -0,0 +1,7 @@ +const StepConnectDeviceFooter = () => { + return null; +}; + +export default { + StepConnectDeviceFooter, +}; diff --git a/apps/ledger-live-desktop/src/renderer/families/mimblewimble_coin/SendRecipientFields.tsx b/apps/ledger-live-desktop/src/renderer/families/mimblewimble_coin/SendRecipientFields.tsx new file mode 100644 index 000000000000..495a70249cd9 --- /dev/null +++ b/apps/ledger-live-desktop/src/renderer/families/mimblewimble_coin/SendRecipientFields.tsx @@ -0,0 +1,46 @@ +import React, { useCallback } from "react"; +import { Trans } from "react-i18next"; +import { getAccountBridge } from "@ledgerhq/live-common/bridge/index"; +import { Account } from "@ledgerhq/types-live"; +import { + Transaction, + TransactionStatus, +} from "@ledgerhq/live-common/families/mimblewimble_coin/types"; +import Box from "~/renderer/components/Box"; +import Label from "~/renderer/components/Label"; +import Switch from "~/renderer/components/Switch"; + +type Props = { + account: Account; + transaction: Transaction; + status: TransactionStatus; + onChange: (t: Transaction) => void; +}; + +const SendRecipientFields = (props: Props) => { + const { account, transaction, onChange } = props; + const onChangeSendAsFile = useCallback( + (sendAsFile: boolean) => { + const bridge = getAccountBridge(account); + onChange( + bridge.updateTransaction(transaction, { + sendAsFile, + }), + ); + }, + [account, onChange, transaction], + ); + return ( + + + + + ); +}; + +export default { + component: SendRecipientFields, + fields: ["sendAsFile"], +}; diff --git a/apps/ledger-live-desktop/src/renderer/families/mimblewimble_coin/SendStepConnectDevice.tsx b/apps/ledger-live-desktop/src/renderer/families/mimblewimble_coin/SendStepConnectDevice.tsx new file mode 100644 index 000000000000..a263c8d46cc5 --- /dev/null +++ b/apps/ledger-live-desktop/src/renderer/families/mimblewimble_coin/SendStepConnectDevice.tsx @@ -0,0 +1,554 @@ +import React, { PureComponent } from "react"; +import invariant from "invariant"; +import { StepProps } from "~/renderer/modals/Send/types"; +import TrackPage from "~/renderer/analytics/TrackPage"; +import { Trans } from "react-i18next"; +import { Device } from "@ledgerhq/live-common/hw/actions/types"; +import DeviceAction from "~/renderer/components/DeviceAction"; +import StepProgress from "~/renderer/components/StepProgress"; +import { createAction as createTransactionAction } from "@ledgerhq/live-common/hw/actions/transaction"; +import { createAction as createOpenAction } from "@ledgerhq/live-common/hw/actions/app"; +import { Account, Operation, SignedOperation } from "@ledgerhq/types-live"; +import { DeviceBlocker } from "~/renderer/components/DeviceAction/DeviceBlocker"; +import { getMainAccount } from "@ledgerhq/live-common/account/index"; +import { updateAccountWithUpdater } from "~/renderer/actions/accounts"; +import { connect } from "react-redux"; +import { getAccountBridge } from "@ledgerhq/live-common/bridge/index"; +import { execAndWaitAtLeast } from "@ledgerhq/live-common/promise"; +import { toAccountRaw } from "@ledgerhq/live-common/account/serialization"; +import { toTransactionRaw } from "@ledgerhq/live-common/transaction/index"; +import qrcode from "qrcode"; +import Box from "~/renderer/components/Box"; +import Text from "~/renderer/components/Text"; +import LinkShowQRCode from "~/renderer/components/LinkShowQRCode"; +import ReadOnlyTransactionField from "./components/ReadOnlyTransactionField"; +import TextAreaTransaction from "./components/TextAreaTransaction"; +import Label from "~/renderer/components/Label"; +import StepRecipientSeparator from "~/renderer/components/StepRecipientSeparator"; +import Modal from "~/renderer/components/Modal"; +import ModalBody from "~/renderer/components/Modal/ModalBody"; +import QRCode from "~/renderer/components/QRCode"; +import styled from "styled-components"; +import Button from "~/renderer/components/Button"; +import { + validateTransactionResponse, + addSentTransactionToAccount, +} from "@ledgerhq/live-common/families/mimblewimble_coin/react"; +import BigNumber from "bignumber.js"; +import connectApp from "@ledgerhq/live-common/hw/connectApp"; +import prepareTransaction from "@ledgerhq/live-common/families/mimblewimble_coin/prepareTransaction"; +import { withDevice } from "@ledgerhq/live-common/hw/deviceAccess"; +import { from, Subscription } from "rxjs"; +import { + Transaction, + TransactionRaw, +} from "@ledgerhq/live-common/families/mimblewimble_coin/types"; + +const transactionAction = createTransactionAction(connectApp); + +const openAction = createOpenAction(connectApp); + +const QRCodeWrapper = styled.div` + border: 24px solid white; + background: white; + display: flex; +`; + +type State = { + currentDevice: Device | null; + transactionData: string | null; + useTransactionDataQrCode: boolean; + modalVisible: boolean; + disableContinue: boolean; + finalizingTransaction: boolean; + transactionResponse: string | null; + transactionResponseError: Error | undefined; + transactionResponseWarning: Error | undefined; +}; + +type Props = { + updateAccountWithUpdater: (b: string, a: (a: Account) => Account) => void; +} & StepProps; + +const mapDispatchToProps = { + updateAccountWithUpdater, +}; + +class StepConnectDevice extends PureComponent { + private prepareTransactionSubscription: Subscription | null; + + constructor(props: Props) { + super(props); + this.state = { + currentDevice: null, + transactionData: null, + useTransactionDataQrCode: true, + modalVisible: false, + disableContinue: true, + finalizingTransaction: false, + transactionResponse: null, + transactionResponseError: undefined, + transactionResponseWarning: undefined, + }; + this.prepareTransactionSubscription = null; + } + + componentDidMount() { + invariant(setFooterState, "Footer doesn't exist"); + setFooterState({ + ...this.state, + stepConnectDevice: this, + }); + } + + componentWillUnmount() { + const { account, parentAccount, transaction, onChangeTransaction } = this.props; + this.unsubscribe(); + if (!account) { + return; + } + const bridge = getAccountBridge(account, parentAccount); + onChangeTransaction( + bridge.updateTransaction(transaction, { + height: undefined, + id: undefined, + offset: undefined, + proof: undefined, + privateNonceIndex: undefined, + transactionResponse: undefined, + }), + ); + } + + componentDidUpdate(previousProps: Props, previousState: State) { + const { + account, + parentAccount, + transaction, + onFailHandler, + onTransactionError, + transitionTo, + closeModal, + onChangeTransaction, + } = this.props; + if (!account) { + return; + } + const { currentDevice } = this.state; + const mainAccount = getMainAccount(account, parentAccount); + if (!previousState.currentDevice && currentDevice) { + this.unsubscribe(); + let transactionDataReceived = false; + this.prepareTransactionSubscription = withDevice(currentDevice.deviceId)(transport => + from( + prepareTransaction( + toAccountRaw(mainAccount), + transport, + toTransactionRaw(transaction!) as TransactionRaw, + ), + ), + ).subscribe({ + next: ({ + transactionData, + height, + id, + offset, + proof, + privateNonceIndex, + }: { + transactionData: string; + height: string; + id: string; + offset: string; + proof: string | undefined; + privateNonceIndex: number; + }) => { + transactionDataReceived = true; + qrcode.toString( + transactionData, + { + errorCorrectionLevel: "L", + }, + (error: Error | null | undefined) => { + if (this.prepareTransactionSubscription) { + this.updateState({ + transactionData, + useTransactionDataQrCode: !error, + currentDevice: null, + }); + const bridge = getAccountBridge(account, parentAccount); + onChangeTransaction( + bridge.updateTransaction(transaction, { + height: new BigNumber(height), + id, + offset: Buffer.from(offset, "hex"), + proof: proof !== undefined ? Buffer.from(proof, "hex") : undefined, + privateNonceIndex, + }), + ); + } + }, + ); + }, + error: (error: Error) => { + if (!transactionDataReceived) { + this.updateState({ + currentDevice: null, + }); + if (!onFailHandler) { + onTransactionError(error); + transitionTo("confirmation"); + } else { + closeModal(); + onFailHandler(error); + } + } + }, + }); + } else if (previousState.currentDevice && !currentDevice) { + this.unsubscribe(); + } + } + + unsubscribe() { + if (this.prepareTransactionSubscription) { + this.prepareTransactionSubscription.unsubscribe(); + this.prepareTransactionSubscription = null; + } + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + updateState(newState: { [key: string]: any }) { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + this.setState(newState); + if (setFooterState) { + setFooterState(newState); + } + } + + hideQRCodeModal = () => { + this.updateState({ + modalVisible: false, + }); + }; + + showQRCodeModal = () => { + this.updateState({ + modalVisible: true, + }); + }; + + broadcast = async (signedOperation: SignedOperation): Promise => { + const { account, parentAccount } = this.props; + const mainAccount = account ? getMainAccount(account, parentAccount) : null; + invariant(account && mainAccount, "No account given"); + const bridge = getAccountBridge(account, parentAccount); + return execAndWaitAtLeast(3000, (): Promise => { + return bridge.broadcast({ + account: mainAccount, + signedOperation, + }); + }); + }; + + onTransactionResponseChange = (transactionResponse: string) => { + const { account, parentAccount, transaction, onChangeTransaction } = this.props; + if (!account) { + return; + } + const mainAccount = getMainAccount(account, parentAccount); + if (transactionResponse) { + const { error, warning } = validateTransactionResponse( + mainAccount.currency, + transactionResponse, + ); + if (error) { + this.updateState({ + transactionResponseError: error, + disableContinue: true, + }); + } else { + this.updateState({ + transactionResponseError: undefined, + disableContinue: false, + }); + } + if (warning) { + this.updateState({ + transactionResponseWarning: warning, + }); + } else { + this.updateState({ + transactionResponseWarning: undefined, + }); + } + } else { + this.updateState({ + transactionResponseError: undefined, + transactionResponseWarning: undefined, + disableContinue: true, + }); + } + this.updateState({ + transactionResponse, + }); + const bridge = getAccountBridge(account, parentAccount); + onChangeTransaction( + bridge.updateTransaction(transaction, { + transactionResponse, + }), + ); + }; + + onContinue = () => { + this.updateState({ + finalizingTransaction: true, + }); + }; + + onDeviceConnected = ({ device }: { device: Device }) => { + this.updateState({ + currentDevice: device, + }); + }; + + onTransactionSigned = ({ + signedOperation, + transactionSignError, + }: { + signedOperation?: SignedOperation | undefined | null; + transactionSignError?: Error | undefined; + }) => { + const { + account, + parentAccount, + setSigned, + onConfirmationHandler, + onOperationBroadcasted, + transitionTo, + closeModal, + onFailHandler, + onTransactionError, + updateAccountWithUpdater, + } = this.props; + if (signedOperation) { + setSigned(true); + this.broadcast(signedOperation).then( + (operation: Operation) => { + if (!onConfirmationHandler) { + onOperationBroadcasted(operation); + transitionTo("confirmation"); + } else { + closeModal(); + onConfirmationHandler(operation); + } + const mainAccount = account ? getMainAccount(account, parentAccount) : null; + invariant(account && mainAccount, "No account given"); + updateAccountWithUpdater(mainAccount.id, (account: Account) => { + return addSentTransactionToAccount(account, signedOperation); + }); + }, + (error: Error) => { + if (!onFailHandler) { + onTransactionError(error); + transitionTo("confirmation"); + } else { + closeModal(); + onFailHandler(error); + } + }, + ); + } else if (transactionSignError) { + if (!onFailHandler) { + onTransactionError(transactionSignError); + transitionTo("confirmation"); + } else { + closeModal(); + onFailHandler(transactionSignError); + } + } + }; + + render() { + const { account, parentAccount, transaction, status, isNFTSend, currencyName } = this.props; + const { + transactionData, + useTransactionDataQrCode, + modalVisible, + finalizingTransaction, + transactionResponse, + transactionResponseError, + transactionResponseWarning, + } = this.state; + + const mainAccount = account ? getMainAccount(account, parentAccount) : null; + invariant(account && mainAccount, "No account given"); + const tokenCurrency = account && account.type === "TokenAccount" && account.token; + + if (!transaction || !account) { + return null; + } + + return ( + <> + + + {!(transaction as Transaction).sendAsFile || finalizingTransaction ? ( + { + if (!("signedOperation" in props)) return null; + return ( + + + + + ); + }} + onResult={this.onTransactionSigned} + analyticsPropertyFlow="send" + /> + ) : transactionData !== null ? ( + <> + + + + + + {useTransactionDataQrCode ? ( + + + + ) : null} + + + + + + + + ) : ( + { + return ; + }} + onResult={this.onDeviceConnected} + analyticsPropertyFlow="send" + /> + )} + + + ( + + + + + + + + + )} + /> + + + ); + } +} + +export interface FooterState extends State { + stepConnectDevice: StepConnectDevice | undefined; +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +let setFooterState: ((state: { [key: string]: any }) => void) | undefined; + +class StepConnectDeviceFooter extends PureComponent { + constructor(props: StepProps) { + super(props); + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + this.state = {}; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + setFooterState = (state: { [key: string]: any }) => { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + this.setState(state); + }; + } + + componentWillUnmount() { + setFooterState = undefined; + } + + render() { + const { transaction } = this.props; + const { transactionData, disableContinue, stepConnectDevice, finalizingTransaction } = + this.state; + + if (!stepConnectDevice || !transaction) { + return null; + } + + return ( + <> + {(transaction as Transaction).sendAsFile && + !finalizingTransaction && + transactionData !== null ? ( + + ) : null} + + ); + } +} + +export default { + StepConnectDevice: connect(null, mapDispatchToProps)(StepConnectDevice), + StepConnectDeviceFooter, +}; diff --git a/apps/ledger-live-desktop/src/renderer/families/mimblewimble_coin/StepReceiveFunds.tsx b/apps/ledger-live-desktop/src/renderer/families/mimblewimble_coin/StepReceiveFunds.tsx new file mode 100644 index 000000000000..29d4f25e0024 --- /dev/null +++ b/apps/ledger-live-desktop/src/renderer/families/mimblewimble_coin/StepReceiveFunds.tsx @@ -0,0 +1,805 @@ +import invariant from "invariant"; +import React, { PureComponent } from "react"; +import { + getAccountUnit, + getMainAccount, + getAccountName, +} from "@ledgerhq/live-common/account/index"; +import TrackPage from "~/renderer/analytics/TrackPage"; +import ErrorDisplay from "~/renderer/components/ErrorDisplay"; +import { Trans } from "react-i18next"; +import styled from "styled-components"; +import useTheme from "~/renderer/hooks/useTheme"; +import { urls } from "~/config/urls"; +import { openURL } from "~/renderer/linking"; +import Box from "~/renderer/components/Box"; +import Button from "~/renderer/components/Button"; +import Text from "~/renderer/components/Text"; +import Ellipsis from "~/renderer/components/Ellipsis"; +import ReadOnlyAddressField from "~/renderer/components/ReadOnlyAddressField"; +import LinkWithExternalIcon from "~/renderer/components/LinkWithExternalIcon"; +import LinkShowQRCode from "~/renderer/components/LinkShowQRCode"; +import SuccessDisplay from "~/renderer/components/SuccessDisplay"; +import { renderVerifyUnwrapped } from "~/renderer/components/DeviceAction/rendering"; +import { StepProps } from "~/renderer/modals/Receive/Body"; +import { Account, AccountLike, Address, OperationRaw } from "@ledgerhq/types-live"; +import Modal from "~/renderer/components/Modal"; +import ModalBody from "~/renderer/components/Modal/ModalBody"; +import QRCode from "~/renderer/components/QRCode"; +import AccountTagDerivationMode from "~/renderer/components/AccountTagDerivationMode"; +import StepProgress from "~/renderer/components/StepProgress"; +import DeviceAction from "~/renderer/components/DeviceAction"; +import { createAction } from "@ledgerhq/live-common/hw/actions/app"; +import { + validateTransactionData, + addReceivedTransactionToAccount, +} from "@ledgerhq/live-common/families/mimblewimble_coin/react"; +import TextAreaTransaction from "./components/TextAreaTransaction"; +import ReadOnlyTransactionField from "./components/ReadOnlyTransactionField"; +import Label from "~/renderer/components/Label"; +import qrcode from "qrcode"; +import { toAccountRaw } from "@ledgerhq/live-common/account/serialization"; +import { updateAccountWithUpdater } from "~/renderer/actions/accounts"; +import { connect } from "react-redux"; +import { OperationDetails } from "~/renderer/drawers/OperationDetails"; +import { setDrawer } from "~/renderer/drawers/Provider"; +import { localeSelector } from "~/renderer/reducers/settings"; +import { formatCurrencyUnit } from "@ledgerhq/live-common/currencies/index"; +import BigNumber from "bignumber.js"; +import WarnBox from "~/renderer/components/WarnBox"; +import TransactionConfirmField from "~/renderer/components/TransactionConfirm/TransactionConfirmField"; +import FormattedVal from "~/renderer/components/FormattedVal"; +import connectApp from "@ledgerhq/live-common/hw/connectApp"; +import getTransactionResponse from "@ledgerhq/live-common/families/mimblewimble_coin/getTransactionResponse"; +import { Subscription } from "rxjs"; + +const action = createAction(connectApp); + +const Separator = styled.div` + border-top: 1px solid ${p => p.theme.colors.palette.divider}; + margin-top: 24px; + margin-bottom: 24px; +`; + +const QRCodeWrapper = styled.div` + border: 24px solid white; + background: white; + display: flex; +`; + +const Container = styled(Box).attrs(() => ({ + alignItems: "center", + fontSize: 4, + pb: 4, +}))``; + +const Info = styled(Box).attrs(() => ({ + ff: "Inter|SemiBold", + color: "palette.text.shade100", + mb: 4, + px: 5, +}))` + text-align: center; +`; + +const FieldText = styled(Text).attrs(() => ({ + ml: 1, + ff: "Inter|Medium", + color: "palette.text.shade80", + fontSize: 3, +}))` + word-break: break-all; + text-align: right; + max-width: 50%; +`; + +const Receive1ShareAddress = ({ + account, + name, + address, + showQRCodeModal, +}: { + account: AccountLike; + name: string; + address: string; + showQRCodeModal: () => void; +}) => { + return ( + <> + + + {name ? ( + + + + {"Address for "} + {name} + + + + + ) : ( + + )} + + + + + + ); +}; + +const Receive2Device = ({ name, device }: { name: string; device: Device }) => { + const type = useTheme().colors.palette.type; + return ( + <> + + + + + + openURL(urls.recipientAddressInfo)} + label={} + /> + + + {renderVerifyUnwrapped({ modelId: device.modelId, type })} + + ); +}; + +const ApproveReceivingTransaction = ({ + account, + device, + amount, + fee, + senderPaymentProofAddress, +}: { + account: Account; + device: Device; + amount: string; + fee: string; + senderPaymentProofAddress: string | null; +}) => { + const unit = getAccountUnit(account); + const type = useTheme().colors.palette.type; + return ( + + {senderPaymentProofAddress === null ? ( + + + + ) : null} + + + + + + {account.index.toFixed()} + + + + + + + + + {"Plain"} + + + + {senderPaymentProofAddress !== null ? senderPaymentProofAddress.trim() : "N/A"} + + + + {renderVerifyUnwrapped({ modelId: device.modelId, type })} + + ); +}; + +type State = { + modalVisible: boolean; + transactionData: string; + transactionDataError: Error | undefined; + transactionDataWarning: Error | undefined; + connectingToDevice: boolean; + processingTransactionError: Error | null; + transactionResponse: string | null; + currentDevice: Device | null; + initialDevice: Device; + disableContinue: boolean; + useTransactionResponseQrCode: boolean; + operationId: string | null; + operationAmount: string | null; + operationFee: string | null; + operationSenderPaymentProofAddress: string | null; + signatureRequested: boolean; + signatureReceived: boolean; +}; + +type Props = { + updateAccountWithUpdater: (b: string, a: (a: Account) => Account) => void; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + locale: any; +} & StepProps; + +const mapDispatchToProps = { + updateAccountWithUpdater, + locale: localeSelector, +}; + +class StepReceiveFunds extends PureComponent { + private processTransactionSubscription: Subscription | null; + + constructor(props: Props) { + super(props); + const { device } = props; + this.state = { + modalVisible: false, + transactionData: "", + transactionDataError: undefined, + transactionDataWarning: undefined, + connectingToDevice: false, + processingTransactionError: null, + transactionResponse: null, + currentDevice: null, + initialDevice: device!, + disableContinue: true, + useTransactionResponseQrCode: true, + operationId: null, + operationAmount: null, + operationFee: null, + operationSenderPaymentProofAddress: null, + signatureRequested: false, + signatureReceived: false, + }; + this.processTransactionSubscription = null; + } + + componentDidMount() { + invariant(setFooterState, "Footer doesn't exist"); + setFooterState({ + ...this.state, + stepReceiveFunds: this, + }); + } + + componentWillUnmount() { + const { onChangeOnBack } = this.props; + onChangeOnBack(undefined); + this.unsubscribe(); + } + + componentDidUpdate(previousProps: Props, previousState: State) { + const { + account, + parentAccount, + onChangeAddressVerified, + onChangeOnBack, + updateAccountWithUpdater, + } = this.props; + if (!account) { + return; + } + const { transactionData, currentDevice } = this.state; + const mainAccount = getMainAccount(account, parentAccount); + if (!previousState.currentDevice && currentDevice) { + this.unsubscribe(); + let transactionResponseReceived = false; + this.processTransactionSubscription = getTransactionResponse( + toAccountRaw(mainAccount), + currentDevice.deviceId, + transactionData, + ).subscribe({ + next: ({ + type, + transactionResponse, + freshAddress, + nextIdentifier, + operation, + }: { + type: string; + transactionResponse?: string; + freshAddress?: Address; + nextIdentifier?: string; + operation?: OperationRaw; + }) => { + switch (type) { + case "device-signature-requested": + this.updateState({ + signatureRequested: true, + operationAmount: operation!.value, + operationFee: operation!.fee, + operationSenderPaymentProofAddress: operation!.senders.length + ? operation!.senders[0] + : null, + }); + break; + case "device-signature-granted": + this.updateState({ + signatureReceived: true, + }); + break; + case "signed": + transactionResponseReceived = true; + qrcode.toString( + transactionResponse!, + { + errorCorrectionLevel: "L", + }, + (error: Error | null | undefined) => { + if (this.processTransactionSubscription) { + this.updateState({ + transactionResponse: transactionResponse!, + useTransactionResponseQrCode: !error, + currentDevice: null, + operationId: operation!.id, + operationAmount: operation!.value, + }); + onChangeOnBack(undefined); + updateAccountWithUpdater(mainAccount.id, (account: Account) => { + return addReceivedTransactionToAccount( + account, + freshAddress!, + nextIdentifier!, + operation!, + ); + }); + } + }, + ); + break; + } + }, + error: (error: Error) => { + if (!transactionResponseReceived) { + this.updateState({ + processingTransactionError: error, + currentDevice: null, + }); + onChangeAddressVerified(true, error); + } + }, + }); + } else if (previousState.currentDevice && !currentDevice) { + this.unsubscribe(); + } + } + + unsubscribe() { + if (this.processTransactionSubscription) { + this.processTransactionSubscription.unsubscribe(); + this.processTransactionSubscription = null; + } + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + updateState(newState: { [key: string]: any }) { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + this.setState(newState); + if (setFooterState) { + setFooterState(newState); + } + } + + hideQRCodeModal = () => { + this.updateState({ + modalVisible: false, + }); + }; + + showQRCodeModal = () => { + this.updateState({ + modalVisible: true, + }); + }; + + onVerify = () => { + const { isAddressVerified, transitionTo, onChangeAddressVerified, onResetSkip, device } = + this.props; + const { initialDevice } = this.state; + if (device !== initialDevice || !isAddressVerified) { + transitionTo("device"); + } else { + this.updateState({ + transactionData: "", + transactionDataError: undefined, + transactionDataWarning: undefined, + disableContinue: true, + }); + } + onChangeAddressVerified(null); + onResetSkip(); + }; + + onTransactionDataChange = (transactionData: string) => { + const { account, parentAccount } = this.props; + if (!account) { + return; + } + const mainAccount = getMainAccount(account, parentAccount); + if (transactionData) { + const { error, warning } = validateTransactionData(mainAccount.currency, transactionData); + if (error) { + this.updateState({ + transactionDataError: error, + disableContinue: true, + }); + } else { + this.updateState({ + transactionDataError: undefined, + disableContinue: false, + }); + } + if (warning) { + this.updateState({ + transactionDataWarning: warning, + }); + } else { + this.updateState({ + transactionDataWarning: undefined, + }); + } + } else { + this.updateState({ + transactionDataError: undefined, + transactionDataWarning: undefined, + disableContinue: true, + }); + } + this.updateState({ + transactionData, + }); + }; + + onContinue = () => { + const { onChangeOnBack } = this.props; + this.updateState({ + connectingToDevice: true, + }); + onChangeOnBack(() => { + this.onRetry(true); + }); + }; + + onDeviceConnected = ({ device }: { device: Device }) => { + this.updateState({ + currentDevice: device, + }); + }; + + onRetry = (forceDisconnectFromDevice = false) => { + const { onChangeAddressVerified, onChangeOnBack } = this.props; + const { processingTransactionError } = this.state; + if (forceDisconnectFromDevice === true) { + this.updateState({ + connectingToDevice: false, + }); + onChangeOnBack((props: StepProps) => { + const { transitionTo, onChangeAddressVerified, onResetSkip } = props; + transitionTo("account"); + onChangeAddressVerified(null); + onResetSkip(); + }); + } else if (processingTransactionError) { + const errorHandled = + ["DisconnectedDevice", "DisconnectedDeviceDuringOperation", "CantOpenDevice"].indexOf( + processingTransactionError.name, + ) !== -1; + if (!errorHandled) { + this.updateState({ + connectingToDevice: false, + }); + onChangeOnBack((props: StepProps) => { + const { transitionTo, onChangeAddressVerified, onResetSkip } = props; + transitionTo("account"); + onChangeAddressVerified(null); + onResetSkip(); + }); + } + } + this.updateState({ + processingTransactionError: null, + currentDevice: null, + signatureRequested: false, + signatureReceived: false, + }); + onChangeAddressVerified(true, null); + }; + + render() { + const { + isAddressVerified, + account, + parentAccount, + device, + verifyAddressError, + token, + eventType, + currencyName, + locale, + } = this.props; + const { + modalVisible, + transactionData, + transactionDataError, + transactionDataWarning, + connectingToDevice, + processingTransactionError, + transactionResponse, + useTransactionResponseQrCode, + operationAmount, + operationFee, + operationSenderPaymentProofAddress, + signatureRequested, + signatureReceived, + } = this.state; + + const mainAccount = account ? getMainAccount(account, parentAccount) : null; + invariant(account && mainAccount, "No account given"); + const name = token ? token.name : getAccountName(account); + const address = mainAccount.freshAddresses[0].address; + const formattedAmount = formatCurrencyUnit( + mainAccount.unit, + new BigNumber(operationAmount !== null ? operationAmount : 0), + { + disableRounding: true, + alwaysShowSign: false, + showCode: true, + locale, + }, + ); + + return ( + <> + + + {transactionResponse !== null ? ( + + } + description={ + <> + + + + + + + + {useTransactionResponseQrCode ? ( + + + + ) : null} + + + + + + + } + > + + ) : processingTransactionError ? ( + + ) : signatureReceived ? ( + + ) : signatureRequested ? ( + + ) : connectingToDevice ? ( + { + return ; + }} + onResult={this.onDeviceConnected} + analyticsPropertyFlow="receive" + /> + ) : verifyAddressError ? ( + + ) : isAddressVerified === true ? ( + <> + + + + ) : device ? ( + <> + + + + + ) : null} + + + ( + + + + + + {transactionResponse !== null ? ( + + ) : ( + + )} + + + )} + /> + + + ); + } +} + +export interface FooterState extends State { + stepReceiveFunds: StepReceiveFunds | undefined; +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +let setFooterState: ((state: { [key: string]: any }) => void) | undefined; + +class StepReceiveFundsFooter extends PureComponent { + constructor(props: StepProps) { + super(props); + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + this.state = {}; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + setFooterState = (state: { [key: string]: any }) => { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + this.setState(state); + }; + } + + componentWillUnmount() { + setFooterState = undefined; + } + + render() { + const { account, parentAccount, isAddressVerified, closeModal } = this.props; + const { + connectingToDevice, + transactionResponse, + disableContinue, + stepReceiveFunds, + operationId, + } = this.state; + + if (!stepReceiveFunds) { + return null; + } + + return ( + <> + {transactionResponse !== null ? ( + + ) : !connectingToDevice && isAddressVerified === true ? ( + + + + + ) : null} + + ); + } +} + +const StepReceiveFundsOnBack = (props: StepProps) => { + const { transitionTo, onChangeAddressVerified, onResetSkip } = props; + transitionTo("account"); + onChangeAddressVerified(null); + onResetSkip(); +}; + +export default { + StepReceiveFunds: connect(null, mapDispatchToProps)(StepReceiveFunds), + StepReceiveFundsFooter, + StepReceiveFundsOnBack, +}; diff --git a/apps/ledger-live-desktop/src/renderer/families/mimblewimble_coin/TransactionConfirmFields.tsx b/apps/ledger-live-desktop/src/renderer/families/mimblewimble_coin/TransactionConfirmFields.tsx new file mode 100644 index 000000000000..82ad14a2636c --- /dev/null +++ b/apps/ledger-live-desktop/src/renderer/families/mimblewimble_coin/TransactionConfirmFields.tsx @@ -0,0 +1,27 @@ +import React from "react"; +import { TransactionStatus } from "@ledgerhq/live-common/families/mimblewimble_coin/types"; +import WarnBox from "~/renderer/components/WarnBox"; +import { Trans } from "react-i18next"; +import { + MimbleWimbleCoinTransactionWontHavePaymentProofNoRecipient, + MimbleWimbleCoinTransactionWontHavePaymentProofInapplicableAddress, +} from "@ledgerhq/live-common/families/mimblewimble_coin/errors"; + +const Warning = ({ status }: { status: TransactionStatus }) => { + return ( + + {(status.warnings.recipient as unknown) instanceof + MimbleWimbleCoinTransactionWontHavePaymentProofNoRecipient || + (status.warnings.recipient as unknown) instanceof + MimbleWimbleCoinTransactionWontHavePaymentProofInapplicableAddress ? ( + + ) : ( + + )} + + ); +}; + +export default { + warning: Warning, +}; diff --git a/apps/ledger-live-desktop/src/renderer/families/mimblewimble_coin/components/ReadOnlyTransactionField.tsx b/apps/ledger-live-desktop/src/renderer/families/mimblewimble_coin/components/ReadOnlyTransactionField.tsx new file mode 100644 index 000000000000..5c4e6e55d9d0 --- /dev/null +++ b/apps/ledger-live-desktop/src/renderer/families/mimblewimble_coin/components/ReadOnlyTransactionField.tsx @@ -0,0 +1,173 @@ +import React, { useRef, useState, useEffect, useCallback } from "react"; +import { Trans, withTranslation } from "react-i18next"; +import { TFunction } from "i18next"; +import { clipboard, ipcRenderer } from "electron"; +import styled from "styled-components"; +import Box from "~/renderer/components/Box"; +import IconCopy from "~/renderer/icons/Copy"; +import IconDownloadFile from "~/renderer/icons/DownloadFile"; +import { space } from "~/renderer/styles/theme"; + +const TransactionData = styled(Box).attrs(() => ({ + bg: "palette.background.default", + borderRadius: 1, + color: "palette.text.shade100", + ff: "Inter", + fontSize: 4, + relative: true, +}))` + border: ${p => `1px solid ${p.theme.colors.palette.divider}`}; + border-right: none; + border-top-right-radius: 0; + border-bottom-right-radius: 0; + text-align: center; + flex: 1; + height: 200px; + overflow-y: scroll; +`; + +const TransactionDataWrapper = styled(Box).attrs(() => ({ + mx: 4, + my: 3, +}))` + cursor: text; + user-select: text; + word-break: break-all; +`; + +const Feedback = styled(Box).attrs(() => ({ + sticky: true, + bg: "palette.background.default", + alignItems: "center", + justifyContent: "center", + fontSize: 4, + borderRadius: 1, +}))` + margin-right: ${space[3] * 2 + 18}px; + border: ${p => `1px solid ${p.theme.colors.palette.divider}`}; + border-right: none; + border-bottom-right-radius: 0; + border-top-right-radius: 0; +`; + +const ClipboardSuspicious = styled.div` + font-family: Inter; + font-weight: 400; + font-style: normal; + font-size: 12px; + align-self: center; + color: ${p => p.theme.colors.alertRed}; +`; + +const Right = styled(Box).attrs(() => ({ + bg: "palette.background.paper", + color: "palette.text.shade100", + alignItems: "center", + justifyContent: "space-evenly", + borderRadius: 1, +}))` + border-top-left-radius: 0; + border-bottom-left-radius: 0; + border: ${p => `1px solid ${p.theme.colors.palette.divider}`}; +`; + +const RightIconWrapper = styled(Box).attrs(() => ({ + px: 3, + py: 3, +}))` + width: ${space[3] * 2 + 16}px; + z-index: 2; + + &:hover { + opacity: 0.8; + } +`; + +type Props = { + transactionData: string; + t: TFunction; + allowSave?: boolean; +}; + +function ReadOnlyTransactionField(props: Props) { + const { transactionData, t, allowSave } = props; + const [copyFeedback, setCopyFeedback] = useState(false); + const [saveFeedback, setSaveFeedback] = useState(false); + const [clipboardChanged, setClipboardChanged] = useState(false); + const copyTimeout = useRef(); + const saveTimeout = useRef(); + + const handleClickCopy = useCallback(() => { + clipboard.writeText(transactionData); + setCopyFeedback(true); + clearTimeout(copyTimeout.current); + setTimeout(() => { + const copiedTransactionData = clipboard.readText(); + if (copiedTransactionData !== transactionData) { + setClipboardChanged(true); + } + }, 300); + copyTimeout.current = setTimeout(() => setCopyFeedback(false), 1e3); + clearTimeout(saveTimeout.current); + setSaveFeedback(false); + }, [transactionData]); + + const handleClickDownloadFile = useCallback(async () => { + if ( + await ipcRenderer.invoke( + "save-file-dialog", + t("families.mimblewimble_coin.saveTransactionFile"), + transactionData, + ) + ) { + setSaveFeedback(true); + clearTimeout(saveTimeout.current); + saveTimeout.current = setTimeout(() => setSaveFeedback(false), 1e3); + clearTimeout(copyTimeout.current); + setCopyFeedback(false); + } + }, [t, transactionData]); + + useEffect(() => { + return () => { + clearTimeout(copyTimeout.current); + clearTimeout(saveTimeout.current); + }; + }, []); + + return ( + + {clipboardChanged ? ( + + + + ) : null} + + + {transactionData} + + {copyFeedback ? ( + + + + ) : saveFeedback ? ( + + + + ) : null} + + + + + {allowSave ? ( + + + + ) : null} + + + + ); +} + +export default withTranslation()(ReadOnlyTransactionField); diff --git a/apps/ledger-live-desktop/src/renderer/families/mimblewimble_coin/components/TextAreaTransaction.tsx b/apps/ledger-live-desktop/src/renderer/families/mimblewimble_coin/components/TextAreaTransaction.tsx new file mode 100644 index 000000000000..4181bdc207c5 --- /dev/null +++ b/apps/ledger-live-desktop/src/renderer/families/mimblewimble_coin/components/TextAreaTransaction.tsx @@ -0,0 +1,143 @@ +import React, { PureComponent, createRef } from "react"; +import styled from "styled-components"; +import { withTranslation } from "react-i18next"; +import { TFunction } from "i18next"; +import Box from "~/renderer/components/Box"; +import TextArea from "~/renderer/components/TextArea"; +import QRCodeCameraPickerCanvas from "~/renderer/components/QRCodeCameraPickerCanvas"; +import { radii, space } from "~/renderer/styles/theme"; +import IconQrCode from "~/renderer/icons/QrCode"; +import IconUploadFile from "~/renderer/icons/UploadFile"; +import { ipcRenderer } from "electron"; +import { track } from "~/renderer/analytics/segment"; + +const Right = styled(Box).attrs(() => ({ + bg: "palette.background.default", + alignItems: "center", + justifyContent: "space-evenly", +}))` + border-top-right-radius: ${radii[1]}px; + border-bottom-right-radius: ${radii[1]}px; + border-left: 1px solid ${p => p.theme.colors.palette.divider}; +`; + +const RightIconWrapper = styled(Box).attrs(() => ({ + px: 3, + py: 3, +}))` + width: ${space[3] * 2 + 16}px; + z-index: 2; + + &:hover { + opacity: 0.8; + } +`; + +const QrCodeWrapper = styled(Box)` + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + z-index: 4; +`; + +const BackgroundLayer = styled(Box)` + position: fixed; + right: 0; + top: 0; + width: 100%; + height: 100%; + z-index: 3; +`; + +type Props = { + onChange: (s: string) => void; + t: TFunction; + value: string; + error: Error | undefined; + warning: Error | undefined; +}; + +type State = { + qrReaderOpened: boolean; +}; + +class TextAreaTransaction extends PureComponent { + private element: React.RefObject; + + constructor(props: Props) { + super(props); + this.state = { + qrReaderOpened: false, + }; + this.element = createRef(); + } + + handleClickQrCode = () => { + const { qrReaderOpened } = this.state; + this.setState((previousState: State) => ({ + qrReaderOpened: !previousState.qrReaderOpened, + })); + !qrReaderOpened ? track("Send Flow QR Code Opened") : track("Send Flow QR Code Closed"); + }; + + handlePickQrCode = (value: string) => { + this.setValue(value); + this.setState({ + qrReaderOpened: false, + }); + }; + + handleClickUploadFile = async () => { + const { t } = this.props; + const fileContents = await ipcRenderer.invoke( + "open-file-dialog", + t("families.mimblewimble_coin.openTransactionFile"), + ); + if (fileContents !== undefined) { + this.setValue(fileContents); + } + }; + + setValue = (value: string) => { + const { onChange } = this.props; + onChange(value); + this.element.current!.scrollTop = 0; + }; + + render() { + const { qrReaderOpened } = this.state; + + return ( +