From a946e8f60a89d08149286e95dfd3bbbf33439384 Mon Sep 17 00:00:00 2001 From: Alex Ruzenhack Date: Tue, 14 May 2024 16:24:30 +0100 Subject: [PATCH] feat: add nano contract transaction components chore: rename NanoContractTransactionScreen.js chore: some tweaks lint: comply with rules fix: small fixes --- src/App.js | 4 +- src/components/Icons/Received.icon.js | 31 ++- src/components/Icons/Sent.icon.js | 31 ++- ...ontractTransactionBalanceList.component.js | 161 +++++++++++++ ...actTransactionBalanceListItem.component.js | 194 ++++++++++++++++ ...NanoContractTransactionHeader.component.js | 219 ++++++++++++++++++ .../NanoContractTransactionsList.component.js | 2 +- ...ontractTransactionsListHeader.component.js | 2 - src/components/TextValue.js | 11 +- ...en.js => NanoContractTransactionScreen.js} | 6 +- 10 files changed, 637 insertions(+), 24 deletions(-) create mode 100644 src/components/NanoContract/NanoContractTransactionBalanceList.component.js create mode 100644 src/components/NanoContract/NanoContractTransactionBalanceListItem.component.js create mode 100644 src/components/NanoContract/NanoContractTransactionHeader.component.js rename src/screens/NanoContract/{NanoContractTransaction.screen.js => NanoContractTransactionScreen.js} (92%) diff --git a/src/App.js b/src/App.js index fed69d8a0..cbbeff4a3 100644 --- a/src/App.js +++ b/src/App.js @@ -87,7 +87,7 @@ import { COLORS, HathorTheme } from './styles/themes'; import { NetworkSettingsFlowNav, NetworkSettingsFlowStack } from './screens/NetworkSettings'; import { NetworkStatusBar } from './components/NetworkSettings/NetworkStatusBar'; import { NanoContractTransactionsScreen } from './screens/NanoContract/NanoContractTransactionsScreen'; -import { NanoContractTransaction } from './screens/NanoContract/NanoContractTransaction.screen'; +import { NanoContractTransactionScreen } from './screens/NanoContract/NanoContractTransactionScreen'; /** * This Stack Navigator is exhibited when there is no wallet initialized on the local storage. @@ -379,7 +379,7 @@ const AppStack = () => { component={TabNavigator} /> - + diff --git a/src/components/Icons/Received.icon.js b/src/components/Icons/Received.icon.js index 4d3971f9a..cf71232b0 100644 --- a/src/components/Icons/Received.icon.js +++ b/src/components/Icons/Received.icon.js @@ -1,25 +1,40 @@ +/** + * Copyright (c) Hathor Labs and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + import * as React from 'react' import Svg, { G, Path, Defs, ClipPath } from 'react-native-svg' +import { COLORS } from '../../styles/themes'; import { BaseIcon } from './Base.icon'; +import { DEFAULT_ICON_SIZE } from './constants'; +import { getScale, getViewBox } from './helper'; /** - * @param {SvgProps|{type: 'default'|'outline'|'fill'}} props + * @param {object} props + * @param {'default'|'outline'|'fill'} props.type + * @property {number} props.size + * @property {string} props.color + * @property {string} props.backgroundColor * * @description * Svg converted from Figma using transaformer at https://react-svgr.com/playground/?native=true */ -export const ReceivedIcon = (props) => ( - +export const ReceivedIcon = ({ type = 'default', size = DEFAULT_ICON_SIZE, color = 'hsla(180, 85%, 34%, 1)', backgroundColor = COLORS.white }) => ( + ( diff --git a/src/components/Icons/Sent.icon.js b/src/components/Icons/Sent.icon.js index 8da8baa74..ce8ce3129 100644 --- a/src/components/Icons/Sent.icon.js +++ b/src/components/Icons/Sent.icon.js @@ -1,25 +1,40 @@ +/** + * Copyright (c) Hathor Labs and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + import * as React from 'react' import Svg, { G, Path, Defs, ClipPath } from 'react-native-svg' +import { COLORS } from '../../styles/themes'; import { BaseIcon } from './Base.icon'; +import { DEFAULT_ICON_SIZE } from './constants'; +import { getScale, getViewBox } from './helper'; /** - * @param {SvgProps|{type: 'default'|'outline'|'fill'}} props + * @param {object} props + * @param {'default'|'outline'|'fill'} props.type + * @property {number} props.size + * @property {string} props.color + * @property {string} props.backgroundColor * * @description * Svg converted from Figma using transaformer at https://react-svgr.com/playground/?native=true */ -export const SentIcon = (props) => ( - +export const SentIcon = ({ type = 'default', size = DEFAULT_ICON_SIZE, color = COLORS.black, backgroundColor = COLORS.white }) => ( + ( diff --git a/src/components/NanoContract/NanoContractTransactionBalanceList.component.js b/src/components/NanoContract/NanoContractTransactionBalanceList.component.js new file mode 100644 index 000000000..27c11f164 --- /dev/null +++ b/src/components/NanoContract/NanoContractTransactionBalanceList.component.js @@ -0,0 +1,161 @@ +/** + * Copyright (c) Hathor Labs and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import React, { useEffect, useState } from 'react'; +import { StyleSheet, View } from 'react-native'; +import { useSelector } from 'react-redux'; +import { transactionUtils } from '@hathor/wallet-lib'; +import { COLORS } from '../../styles/themes'; +import { NanoContractTransactionBalanceListItem } from './NanoContractTransactionBalanceListItem.component'; +import { HathorFlatList } from '../HathorFlatList'; + +/** + * Calculate the balance of a transaction for all the addresses found, + * not only the ones from the wallet. + * + * @param tx Transaction to get balance from + * @param storage Storage to get metadata from + * @returns {Promise>} Balance of the transaction + */ +async function getTxBalance(tx, storage) { + const balance = {}; + const getEmptyBalance = () => ({ + tokens: { locked: 0, unlocked: 0 }, + authorities: { + mint: { locked: 0, unlocked: 0 }, + melt: { locked: 0, unlocked: 0 }, + }, + }); + + const nowTs = Math.floor(Date.now() / 1000); + const nowHeight = await storage.getCurrentHeight(); + const rewardLock = storage.version?.reward_spend_min_blocks; + const isHeightLocked = this.isHeightLocked(tx.height, nowHeight, rewardLock); + + for (const output of tx.outputs) { + // Removed isAddressMine filter + if (!balance[output.token]) { + balance[output.token] = getEmptyBalance(); + } + const isLocked = this.isOutputLocked(output, { refTs: nowTs }) || isHeightLocked; + + if (this.isAuthorityOutput(output)) { + if (this.isMint(output)) { + if (isLocked) { + balance[output.token].authorities.mint.locked += 1; + } else { + balance[output.token].authorities.mint.unlocked += 1; + } + } + if (this.isMelt(output)) { + if (isLocked) { + balance[output.token].authorities.melt.locked += 1; + } else { + balance[output.token].authorities.melt.unlocked += 1; + } + } + } else if (isLocked) { + balance[output.token].tokens.locked += output.value; + } else { + balance[output.token].tokens.unlocked += output.value; + } + } + + for (const input of tx.inputs) { + // Removed isAddressMine filter + if (!balance[input.token]) { + balance[input.token] = getEmptyBalance(); + } + + if (this.isAuthorityOutput(input)) { + if (this.isMint(input)) { + balance[input.token].authorities.mint.unlocked -= 1; + } + if (this.isMelt(input)) { + balance[input.token].authorities.melt.unlocked -= 1; + } + } else { + balance[input.token].tokens.unlocked -= input.value; + } + } + + return balance; +} + +/** + * Retrives transaction's balance per token. + * + * @param {Object} tx Transaction data + * @param {Object} wallet Wallet from redux store + * + * @returns {{ + * tokenUid: string; + * available: number; + * locked: number; + * }[]} Array token's balance + */ +async function getTokensBalance(tx, wallet) { + const tokensBalance = await getTxBalance.bind(transactionUtils)(tx, wallet.storage); + const balances = []; + for (const [key, balance] of Object.entries(tokensBalance)) { + const tokenBalance = { + tokenUid: key, + available: balance.tokens.unlocked, + locked: balance.tokens.locked + }; + balances.push(tokenBalance); + } + return balances; +} + +/** + * It presents a list of balance for tokens input and ouput of a transaction. + * + * @param {Object} props + * @param {Object} props.tx Transaction data + */ +export const NanoContractTransactionBalanceList = ({ tx }) => { + const wallet = useSelector((state) => state.wallet); + const [tokensBalance, setTokensBalance] = useState([]); + + useEffect(() => { + const fetchTokensBalance = async () => { + const balance = await getTokensBalance(tx, wallet); + setTokensBalance(balance); + }; + fetchTokensBalance(); + }, []); + + return ( + + ( + + )} + keyExtractor={(item) => item.tokenUid} + /> + + ); +}; + +const Wrapper = ({ children }) => ( + + {children} + +); + +const styles = StyleSheet.create({ + wrapper: { + flex: 1, + alignSelf: 'stretch', + backgroundColor: COLORS.lowContrastDetail, // Defines an outer area on the main list content + }, +}); diff --git a/src/components/NanoContract/NanoContractTransactionBalanceListItem.component.js b/src/components/NanoContract/NanoContractTransactionBalanceListItem.component.js new file mode 100644 index 000000000..860c3c4b1 --- /dev/null +++ b/src/components/NanoContract/NanoContractTransactionBalanceListItem.component.js @@ -0,0 +1,194 @@ +/** + * Copyright (c) Hathor Labs and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import React from 'react'; +import { + TouchableHighlight, + StyleSheet, + View, + Text, +} from 'react-native'; +import { useSelector } from 'react-redux'; +import { t } from 'ttag'; +import { COLORS } from '../../styles/themes'; +import { getShortHash, isTokenNFT, renderValue } from '../../utils'; +import { ReceivedIcon } from '../Icons/Received.icon'; +import { SentIcon } from '../Icons/Sent.icon'; + +/** + * It returns either 'sent' or 'received' depending on value. + * + * @param {number} value + * + * @returns {'sent'|'received'} + */ +function getBalanceType(value) { + if (value < 0) { + return 'sent'; + } + return 'received'; +} + +/** + * Retrives token symbol, otherwise returns a shortened token hash. + * + * @param {string} tokenUid Token hash + * @param {Object[]} tokens Registered tokens from redux store + */ +function getTokenValue(tokenUid, tokens) { + const registeredToken = Object.values(tokens).filter((token) => token.uid === tokenUid).pop(); + if (registeredToken) { + return registeredToken.symbol; + } + return getShortHash(tokenUid, 7); +} + +/** + * It renders the item of Nano Contract Transactions List. + * + * @param {Object} props + * @param {Object} props.item registered Nano Contract data + * @param {number} props.index position in the list + */ +export const NanoContractTransactionBalanceListItem = ({ item, index }) => { + const balance = item.available + item.locked; + const tokens = useSelector((state) => state.tokens) || {}; + const tokenValue = getTokenValue(item.tokenUid, tokens); + const type = getBalanceType(balance); + const tokensMetadata = useSelector((state) => state.tokenMetadata); + const isNft = isTokenNFT(item.tokenUid, tokensMetadata); + + return ( + + + + + + ); +}; + +const Wrapper = ({ index, children }) => { + const isFirstItem = index === 0; + return ( + + {children} + + ); +}; + +/** + * It renders the balance icon, either sent or received. + * + * @param {Object} props + * @param {'sent'|'received'} props.type + */ +const Icon = ({ type }) => { + const iconMap = { + sent: SentIcon({ type: 'default' }), + received: ReceivedIcon({ type: 'default' }), + }; + + return (iconMap[type]); +}; + +/** + * Renders item core content. + * + * @param {Object} ncItem + * @property {Obeject} ncItem.nc registered Nano Contract data + */ +const ContentWrapper = ({ tokenValue, type }) => { + const contentMap = { + sent: t`Sent ${tokenValue}`, + received: t`Received ${tokenValue}`, + }; + + return ( + + {contentMap[type]} + + ); +}; + +/** + * It presents the balance value using the right style. + * + * @param {Object} props + * @param {number} props.balance + * @param {boolean} props.isNft + */ +const BalanceValue = ({ balance, isNft }) => { + const isReceivedType = getBalanceType(balance) === 'received'; + const balanceValue = renderValue(balance, isNft); + + return ( + + + {balanceValue} + + + ) +}; + +const styles = StyleSheet.create({ + wrapper: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + flexWrap: 'wrap', + width: '100%', + paddingVertical: 24, + paddingHorizontal: 16, + }, + firstItem: { + borderTopLeftRadius: 16, + borderTopRightRadius: 16, + }, + contentWrapper: { + maxWidth: '80%', + flexDirection: 'column', + alignItems: 'flex-start', + justifyContent: 'space-between', + marginRight: 'auto', + paddingHorizontal: 16, + }, + icon: { + alignSelf: 'flex-start', + }, + text: { + fontSize: 14, + lineHeight: 20, + paddingBottom: 6, + color: 'hsla(0, 0%, 38%, 1)', + }, + property: { + paddingBottom: 4, + fontWeight: 'bold', + color: 'black', + }, + padding0: { + paddingBottom: 0, + }, + balanceWrapper: { + marginLeft: 'auto', + }, + balance: { + fontSize: 16, + lineHeight: 20, + }, + balanceReceived: { + color: 'hsla(180, 85%, 34%, 1)', + fontWeight: 'bold', + }, +}); diff --git a/src/components/NanoContract/NanoContractTransactionHeader.component.js b/src/components/NanoContract/NanoContractTransactionHeader.component.js new file mode 100644 index 000000000..eb70c06dd --- /dev/null +++ b/src/components/NanoContract/NanoContractTransactionHeader.component.js @@ -0,0 +1,219 @@ +/** + * Copyright (c) Hathor Labs and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import React, { useState } from 'react'; +import { + StyleSheet, + View, + TouchableWithoutFeedback, + Linking, + Text, +} from 'react-native'; +import { t } from 'ttag'; +import { useSelector } from 'react-redux'; +import HathorHeader from '../HathorHeader'; +import { COLORS } from '../../styles/themes'; +import { combineURLs, getShortContent, getShortHash, getTimestampFormat } from '../../utils'; +import SimpleButton from '../SimpleButton'; +import { ArrowDownIcon } from '../Icons/ArrowDown.icon'; +import { ArrowUpIcon } from '../Icons/ArrowUp.icon'; +import { TextValue } from '../TextValue'; +import { TextLabel } from '../TextLabel'; +import { TransactionStatusLabel } from '../TransactionStatusLabel'; + +/** + * It presents the header of Nano Contract Transaction screen. + * + * @param {Obejct} props + * @param {Obejct} props.tx Transaction data + */ +export const NanoContractTransactionHeader = ({ tx }) => { + const [isShrank, toggleShrank] = useState(true); + + const isExpanded = () => !isShrank; + + return ( + + + toggleShrank(!isShrank)}> + + + {getShortHash(tx.txId, 7)} + {t`Transaction ID`} + + {isShrank + && } + {isExpanded() + && } + + + + + ) +}; + +const HeaderShrank = () => ( + +); + +/** + * It presents the expanded header of Nano Contract Transaction screen + * containing contextual information about the Nano Contract and the transaction. + * + * @param {Obejct} props + * @param {Obejct} props.tx Transaction data + */ +const HeaderExpanded = ({ tx }) => { + const baseExplorerUrl = useSelector((state) => state.networkSettings.explorerUrl); + const ncId = getShortHash(tx.ncId, 7); + const callerAddr = getShortContent(tx.caller.base58, 7); + const hasFirstBlock = tx.firstBlock != null; + + const navigatesToExplorer = () => { + const txUrl = `transaction/${tx.txId}`; + const explorerLink = combineURLs(baseExplorerUrl, txUrl); + Linking.openURL(explorerLink); + }; + + return ( + <> + + + + + + {ncId} + {t`Nano Contract ID`} + + + {tx.ncMethod} + {t`Blueprint Method`} + + + {getTimestampFormat(tx.timestamp)} + {t`Date and Time`} + + + {callerAddr} + {tx.isMine + && ( + + {t`From this wallet`} + + )} + {t`Caller`} + + + + + + + + ) +}; + +/** + * Container for value and label pair components. + * + * @param {Object} props + * @param {Object} props.children + */ +const InfoContainer = ({ lastElement, children }) => ( + + {children} + +); + +/** + * It presents two button options inline. + * + * @param {Object} props + * @param {Object} props.children + */ +const TwoActionsWrapper = ({ children }) => ( + + {children} + +); + +/** + * Text button in primary color and style. + * + * @param {Object} props + * @param {string} props.title Text content + * @param {() => void} props.onPress Callback for interaction + */ +const PrimaryTextButton = ({ title, onPress }) => ( + +); + +const styles = StyleSheet.create({ + headerCentral: { + flex: 1, + alignItems: 'center', + paddingTop: 24, + }, + headerWrapper: { + alignItems: 'center', + }, + headerTitle: { + fontSize: 18, + lineHeight: 20, + fontWeight: 'bold', + paddingVertical: 16, + }, + wrapper: { + paddingHorizontal: 16, + paddingBottom: 16, + }, + infoContainer: { + alignItems: 'center', + paddingBottom: 16, + }, + lastElement: { + paddingBottom: 0, + }, + TwoActionsWrapper: { + flexDirection: 'row', + justifyContent: 'center', + flexWrap: 'nowrap', + }, + buttonWrapper: { + paddingTop: 24, + }, + buttonText: { + fontWeight: 'bold', + }, + buttonUnregister: { + marginStart: 24, + color: 'hsla(0, 100%, 41%, 1)', + }, + buttonDetails: { + display: 'inline-block', + /* We are using negative margin here to correct the text position + * and create an optic effect of alignment. */ + marginBottom: -2, + borderBottomWidth: 1, + borderColor: COLORS.primary, + }, + headlineLabel: { + marginVertical: 6, + borderRadius: 20, + paddingHorizontal: 12, + paddingVertical: 2, + backgroundColor: COLORS.freeze100, + }, + isMineLabel: { + fontSize: 12, + lineHeight: 20, + }, +}); diff --git a/src/components/NanoContract/NanoContractTransactionsList.component.js b/src/components/NanoContract/NanoContractTransactionsList.component.js index 418eed62b..49abbc66b 100644 --- a/src/components/NanoContract/NanoContractTransactionsList.component.js +++ b/src/components/NanoContract/NanoContractTransactionsList.component.js @@ -45,7 +45,7 @@ export const NanoContractTransactionsList = ({ nc }) => { } const navigatesToNanoContractTransaction = (tx) => { - navigation.navigate('NanoContractTransaction', { tx }); + navigation.navigate('NanoContractTransactionScreen', { tx }); }; return ( diff --git a/src/components/NanoContract/NanoContractTransactionsListHeader.component.js b/src/components/NanoContract/NanoContractTransactionsListHeader.component.js index d520820a1..24791bf2e 100644 --- a/src/components/NanoContract/NanoContractTransactionsListHeader.component.js +++ b/src/components/NanoContract/NanoContractTransactionsListHeader.component.js @@ -159,7 +159,6 @@ const HeaderExpanded = ({ nc, address, onEditAddress, onUnregisterNanoContract } * Container for value and label pair components. * * @param {Object} props - * @param {string} props.last * @param {Object} props.children */ const InfoContainer = ({ children }) => ( @@ -172,7 +171,6 @@ const InfoContainer = ({ children }) => ( * It presents two button options inline. * * @param {Object} props - * @param {string} props.last * @param {Object} props.children */ const TwoActionsWrapper = ({ children }) => ( diff --git a/src/components/TextValue.js b/src/components/TextValue.js index 757bfc679..578aad083 100644 --- a/src/components/TextValue.js +++ b/src/components/TextValue.js @@ -11,7 +11,16 @@ import { Text, } from 'react-native'; -export const TextValue = ({ bold, pb4, title, children }) => ( +/** + * It renders any text value. + * + * @param {Object} props + * @param {boolean?} props.title It determines the addtion of title style + * @param {boolean?} props.bold It determines the addition of bold style + * @param {boolean?} props.pb4 It determines the addition of padding bottom style + * @param {Object} props.children + */ +export const TextValue = ({ title, bold, pb4, children }) => (