diff --git a/app/actions/modals/index.js b/app/actions/modals/index.js index 1e2656eb724..b8088bb9f94 100644 --- a/app/actions/modals/index.js +++ b/app/actions/modals/index.js @@ -5,12 +5,6 @@ export function toggleNetworkModal() { }; } -export function toggleAccountsModal() { - return { - type: 'TOGGLE_ACCOUNT_MODAL', - }; -} - export function toggleCollectibleContractModal() { return { type: 'TOGGLE_COLLECTIBLE_CONTRACT_MODAL', diff --git a/app/component-library/components-temp/Loader/Loader.styles.ts b/app/component-library/components-temp/Loader/Loader.styles.ts new file mode 100644 index 00000000000..dc27d6226ef --- /dev/null +++ b/app/component-library/components-temp/Loader/Loader.styles.ts @@ -0,0 +1,29 @@ +// Third party dependencies. +import { StyleSheet } from 'react-native'; + +// External dependencies. +import { Theme } from '../../../util/theme/models'; + +/** + * Style sheet function for SheetActions component. + * + * @param params Style sheet params. + * @param params.theme App theme from ThemeContext. + * @param params.vars Inputs that the style sheet depends on. + * @returns StyleSheet object. + */ +const styleSheet = (params: { theme: Theme }) => { + const { theme } = params; + const { colors } = theme; + + return StyleSheet.create({ + base: { + ...StyleSheet.absoluteFillObject, + justifyContent: 'center', + alignItems: 'center', + backgroundColor: colors.background.default, + }, + }); +}; + +export default styleSheet; diff --git a/app/component-library/components-temp/Loader/Loader.tsx b/app/component-library/components-temp/Loader/Loader.tsx new file mode 100644 index 00000000000..4ce1c711cd4 --- /dev/null +++ b/app/component-library/components-temp/Loader/Loader.tsx @@ -0,0 +1,23 @@ +// Third party dependencies. +import React from 'react'; +import { ActivityIndicator, View } from 'react-native'; + +// External dependencies. +import { useStyles } from '../../hooks'; + +// Internal dependencies. +import styleSheet from './Loader.styles'; +import { LoaderProps } from './Loader.types'; + +const Loader = ({ size = 'large' }: LoaderProps) => { + const { styles, theme } = useStyles(styleSheet, {}); + const { colors } = theme; + + return ( + + + + ); +}; + +export default Loader; diff --git a/app/component-library/components-temp/Loader/Loader.types.ts b/app/component-library/components-temp/Loader/Loader.types.ts new file mode 100644 index 00000000000..8cf3fcd2635 --- /dev/null +++ b/app/component-library/components-temp/Loader/Loader.types.ts @@ -0,0 +1,12 @@ +// Third party dependencies. +import { ActivityIndicatorProps } from 'react-native'; + +/** + * Loader props. + */ +export interface LoaderProps { + /** + * Activity indicator size. + */ + size?: ActivityIndicatorProps['size']; +} diff --git a/app/component-library/components-temp/Loader/index.ts b/app/component-library/components-temp/Loader/index.ts new file mode 100644 index 00000000000..348c02a9870 --- /dev/null +++ b/app/component-library/components-temp/Loader/index.ts @@ -0,0 +1 @@ +export { default } from './Loader'; diff --git a/app/component-library/components-temp/SheetActions/SheetActions.styles.ts b/app/component-library/components-temp/SheetActions/SheetActions.styles.ts new file mode 100644 index 00000000000..496792849aa --- /dev/null +++ b/app/component-library/components-temp/SheetActions/SheetActions.styles.ts @@ -0,0 +1,28 @@ +// Third party dependencies. +import { StyleSheet } from 'react-native'; + +// External dependencies. +import { Theme } from '../../../util/theme/models'; + +/** + * Style sheet function for SheetActions component. + * + * @param params Style sheet params. + * @param params.theme App theme from ThemeContext. + * @param params.vars Inputs that the style sheet depends on. + * @returns StyleSheet object. + */ +const styleSheet = (params: { theme: Theme }) => { + const { theme } = params; + const { colors } = theme; + + return StyleSheet.create({ + separator: { + height: 1, + backgroundColor: colors.border.muted, + marginHorizontal: 16, + }, + }); +}; + +export default styleSheet; diff --git a/app/component-library/components-temp/SheetActions/SheetActions.tsx b/app/component-library/components-temp/SheetActions/SheetActions.tsx new file mode 100644 index 00000000000..10e5cc7e698 --- /dev/null +++ b/app/component-library/components-temp/SheetActions/SheetActions.tsx @@ -0,0 +1,46 @@ +// Third party dependencies. +import React, { useCallback } from 'react'; +import { View } from 'react-native'; + +// External dependencies. +import { useStyles } from '../../hooks'; +import ButtonTertiary from '../../components/Buttons/ButtonTertiary'; +import { ButtonBaseSize } from '../../components/Buttons/ButtonBase'; +import Loader from '../Loader'; + +// Internal dependencies. +import { SheetActionsProps } from './SheetActions.types'; +import styleSheet from './SheetActions.styles'; + +const SheetActions = ({ actions }: SheetActionsProps) => { + const { styles } = useStyles(styleSheet, {}); + + const renderActions = useCallback( + () => + actions.map(({ label, onPress, testID, isLoading, disabled }, index) => { + const key = `${label}-${index}`; + return ( + + {actions.length > 1 && } + + + {isLoading && } + + + ); + }), + [actions, styles.separator], + ); + + return <>{renderActions()}; +}; + +export default SheetActions; diff --git a/app/component-library/components-temp/SheetActions/SheetActions.types.ts b/app/component-library/components-temp/SheetActions/SheetActions.types.ts new file mode 100644 index 00000000000..967d2eabd85 --- /dev/null +++ b/app/component-library/components-temp/SheetActions/SheetActions.types.ts @@ -0,0 +1,20 @@ +/** + * Sheet action options. + */ +export interface Action { + label: string; + onPress: () => void; + testID?: string; + disabled?: boolean; + isLoading?: boolean; +} + +/** + * SheetActionsProps props. + */ +export interface SheetActionsProps { + /** + * List of actions. + */ + actions: Action[]; +} diff --git a/app/component-library/components-temp/SheetActions/index.ts b/app/component-library/components-temp/SheetActions/index.ts new file mode 100644 index 00000000000..5513e2ffb64 --- /dev/null +++ b/app/component-library/components-temp/SheetActions/index.ts @@ -0,0 +1 @@ +export { default } from './SheetActions'; diff --git a/app/component-library/components/Avatars/AvatarGroup/AvatarGroup.constants.ts b/app/component-library/components/Avatars/AvatarGroup/AvatarGroup.constants.ts index 1edd74fdc77..003df07110d 100644 --- a/app/component-library/components/Avatars/AvatarGroup/AvatarGroup.constants.ts +++ b/app/component-library/components/Avatars/AvatarGroup/AvatarGroup.constants.ts @@ -9,98 +9,84 @@ export const AVAILABLE_TOKEN_LIST: AvatarGroupTokenList = [ imageSource: { uri: 'https://cryptologos.cc/logos/avalanche-avax-logo.png', }, - id: '0', }, { name: 'Ethereum', imageSource: { uri: 'https://cryptologos.cc/logos/avalanche-avax-logo.png', }, - id: '1', }, { name: 'Ethereum', imageSource: { uri: 'https://cryptologos.cc/logos/avalanche-avax-logo.png', }, - id: '2', }, { name: 'Ethereum', imageSource: { uri: 'https://cryptologos.cc/logos/avalanche-avax-logo.png', }, - id: '3', }, { name: 'Ethereum', imageSource: { uri: 'https://cryptologos.cc/logos/avalanche-avax-logo.png', }, - id: '4', }, { name: 'Ethereum', imageSource: { uri: 'https://cryptologos.cc/logos/avalanche-avax-logo.png', }, - id: '5', }, { name: 'Ethereum', imageSource: { uri: 'https://cryptologos.cc/logos/avalanche-avax-logo.png', }, - id: '6', }, { name: 'Ethereum', imageSource: { uri: 'https://cryptologos.cc/logos/avalanche-avax-logo.png', }, - id: '7', }, { name: 'Ethereum', imageSource: { uri: 'https://cryptologos.cc/logos/avalanche-avax-logo.png', }, - id: '8', }, { name: 'Ethereum', imageSource: { uri: 'https://cryptologos.cc/logos/avalanche-avax-logo.png', }, - id: '9', }, { name: 'Ethereum', imageSource: { uri: 'https://cryptologos.cc/logos/avalanche-avax-logo.png', }, - id: '10', }, { name: 'Ethereum', imageSource: { uri: 'https://cryptologos.cc/logos/avalanche-avax-logo.png', }, - id: '11', }, { name: 'Ethereum', imageSource: { uri: 'https://cryptologos.cc/logos/avalanche-avax-logo.png', }, - id: '12', }, { name: 'Ethereum', imageSource: { uri: 'https://cryptologos.cc/logos/avalanche-avax-logo.png', }, - id: '13', }, ]; diff --git a/app/component-library/components/Avatars/AvatarGroup/AvatarGroup.styles.ts b/app/component-library/components/Avatars/AvatarGroup/AvatarGroup.styles.ts index 073ef31963d..0130b75b218 100644 --- a/app/component-library/components/Avatars/AvatarGroup/AvatarGroup.styles.ts +++ b/app/component-library/components/Avatars/AvatarGroup/AvatarGroup.styles.ts @@ -4,6 +4,9 @@ import { StyleSheet } from 'react-native'; // External dependencies. import { Theme } from '../../../../util/theme/models'; +// Internal dependencies. +import { AvatarGroupStyleSheetVars } from './AvatarGroup.types'; + /** * Style sheet function for AvatarGroup component. * @@ -12,18 +15,25 @@ import { Theme } from '../../../../util/theme/models'; * @param params.vars Inputs that the style sheet depends on. * @returns StyleSheet object. */ -const styleSheet = (params: { theme: Theme; vars: any }) => { +const styleSheet = (params: { + theme: Theme; + vars: AvatarGroupStyleSheetVars; +}) => { const { theme, vars } = params; - const { stackWidth } = vars; - + const { stackWidth, stackHeight } = vars; const borderWidth = 1; + const stackHeightWithBorder = stackHeight + borderWidth * 2; return StyleSheet.create({ - base: { flexDirection: 'row' }, - stack: { + base: { flexDirection: 'row', alignItems: 'center', + height: stackHeightWithBorder, + }, + stack: { + flexDirection: 'row', width: stackWidth + borderWidth * 2, + height: stackHeightWithBorder, }, stackedAvatarWrapper: { position: 'absolute', @@ -37,6 +47,7 @@ const styleSheet = (params: { theme: Theme; vars: any }) => { textStyle: { color: theme.colors.text.alternative, marginLeft: 2, + bottom: 2, }, }); }; diff --git a/app/component-library/components/Avatars/AvatarGroup/AvatarGroup.tsx b/app/component-library/components/Avatars/AvatarGroup/AvatarGroup.tsx index 14405cf7767..40ac62d2b3a 100644 --- a/app/component-library/components/Avatars/AvatarGroup/AvatarGroup.tsx +++ b/app/component-library/components/Avatars/AvatarGroup/AvatarGroup.tsx @@ -1,5 +1,5 @@ // Third party dependencies. -import React, { useMemo } from 'react'; +import React, { useCallback } from 'react'; import { View } from 'react-native'; // External dependencies. @@ -26,18 +26,21 @@ const AvatarGroup = ({ tokenList }: AvatarGroupProps) => { const stackWidth = avatarSpacing * (amountOfVisibleAvatars + 1); const shouldRenderOverflowCounter = overflowCounter > 0; - const { styles } = useStyles(styleSheet, { stackWidth }); + const { styles } = useStyles(styleSheet, { + stackWidth, + stackHeight: Number(extraSmallSize), + }); - const renderTokenList = useMemo( + const renderTokenList = useCallback( () => tokenList .slice(0, MAX_STACKED_AVATARS) - .map(({ name, imageSource, id }, index) => { + .map(({ name, imageSource }, index) => { const leftOffset = avatarSpacing * index; return ( { return ( - {renderTokenList} + {renderTokenList()} {shouldRenderOverflowCounter && ( { const { styles } = useStyles(styleSheet, { style }); @@ -30,6 +31,7 @@ const CellSelect = ({ isSelected={isSelected} style={styles.base} testID={CELL_SELECT_TEST_ID} + {...props} > TYPE | REQUIRED | DEFAULT | +| :-------------------------------------------------- | :------------------------------------------------------ | :----------------------------------------------------- | +| number | No | 250 | + ### `children` Content to wrap in sheet. diff --git a/app/component-library/components/Sheet/SheetBottom/SheetBottom.constants.ts b/app/component-library/components/Sheet/SheetBottom/SheetBottom.constants.ts index 40436e3511a..324db6823da 100644 --- a/app/component-library/components/Sheet/SheetBottom/SheetBottom.constants.ts +++ b/app/component-library/components/Sheet/SheetBottom/SheetBottom.constants.ts @@ -17,4 +17,8 @@ export const SWIPE_TRIGGERED_ANIMATION_DURATION = 200; /** * The animation duration used for initial render. */ -export const INITIAL_RENDER_ANIMATION_DURATION = 500; +export const INITIAL_RENDER_ANIMATION_DURATION = 350; +/** + * Minimum spacing reserved for the overlay tappable area. + */ +export const DEFAULT_MIN_OVERLAY_HEIGHT = 250; diff --git a/app/component-library/components/Sheet/SheetBottom/SheetBottom.styles.ts b/app/component-library/components/Sheet/SheetBottom/SheetBottom.styles.ts index 8213f2d00af..8e7480d8e3b 100644 --- a/app/component-library/components/Sheet/SheetBottom/SheetBottom.styles.ts +++ b/app/component-library/components/Sheet/SheetBottom/SheetBottom.styles.ts @@ -21,7 +21,7 @@ const styleSheet = (params: { }) => { const { vars, theme } = params; const { colors } = theme; - const { maxSheetHeight } = vars; + const { maxSheetHeight, screenBottomPadding } = vars; return StyleSheet.create({ base: { ...StyleSheet.absoluteFillObject, @@ -37,6 +37,7 @@ const styleSheet = (params: { borderTopRightRadius: 16, maxHeight: maxSheetHeight, overflow: 'hidden', + paddingBottom: screenBottomPadding, }, fill: { flex: 1, @@ -47,7 +48,7 @@ const styleSheet = (params: { borderRadius: 2, backgroundColor: colors.border.muted, alignSelf: 'center', - marginVertical: 4, + marginTop: 4, }, }); }; diff --git a/app/component-library/components/Sheet/SheetBottom/SheetBottom.tsx b/app/component-library/components/Sheet/SheetBottom/SheetBottom.tsx index 96560b57644..29e8d1612e4 100644 --- a/app/component-library/components/Sheet/SheetBottom/SheetBottom.tsx +++ b/app/component-library/components/Sheet/SheetBottom/SheetBottom.tsx @@ -4,6 +4,7 @@ import { useNavigation } from '@react-navigation/native'; import React, { forwardRef, + useCallback, useEffect, useImperativeHandle, useMemo, @@ -30,6 +31,7 @@ import Animated, { withTiming, } from 'react-native-reanimated'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { debounce } from 'lodash'; // External dependencies. import { useStyles } from '../../../hooks'; @@ -41,6 +43,7 @@ import { TAP_TRIGGERED_ANIMATION_DURATION, SWIPE_TRIGGERED_ANIMATION_DURATION, INITIAL_RENDER_ANIMATION_DURATION, + DEFAULT_MIN_OVERLAY_HEIGHT, } from './SheetBottom.constants'; import styleSheet from './SheetBottom.styles'; import { @@ -50,16 +53,28 @@ import { } from './SheetBottom.types'; const SheetBottom = forwardRef( - ({ children, onDismissed, isInteractable = true, ...props }, ref) => { + ( + { + children, + onDismissed, + isInteractable = true, + reservedMinOverlayHeight = DEFAULT_MIN_OVERLAY_HEIGHT, + ...props + }, + ref, + ) => { const postCallback = useRef(); - const { top: screenTopPadding } = useSafeAreaInsets(); + const { top: screenTopPadding, bottom: screenBottomPadding } = + useSafeAreaInsets(); const { height: screenHeight } = useWindowDimensions(); const { styles } = useStyles(styleSheet, { - maxSheetHeight: screenHeight - screenTopPadding, + maxSheetHeight: + screenHeight - screenTopPadding - reservedMinOverlayHeight, + screenBottomPadding, }); const currentYOffset = useSharedValue(screenHeight); const visibleYOffset = useSharedValue(0); - const sheetHeight = useSharedValue(0); + const sheetHeight = useSharedValue(screenHeight); const overlayOpacity = useDerivedValue(() => interpolate( currentYOffset.value, @@ -70,17 +85,12 @@ const SheetBottom = forwardRef( const navigation = useNavigation(); const isMounted = useRef(false); - useEffect(() => { - // Automatically handles animation when content changes - LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut); - }, [children]); - - const onHidden = () => { + const onHidden = useCallback(() => { // Sheet is automatically unmounted from the navigation stack. navigation.goBack(); onDismissed?.(); postCallback.current?.(); - }; + }, [navigation, onDismissed]); const gestureHandler = useAnimatedGestureHandler< PanGestureHandlerGestureEvent, @@ -140,13 +150,27 @@ const SheetBottom = forwardRef( }); }; - const hide = () => { + const hide = useCallback(() => { currentYOffset.value = withTiming( sheetHeight.value, { duration: TAP_TRIGGERED_ANIMATION_DURATION }, () => runOnJS(onHidden)(), ); - }; + // Ref values do not affect deps. + /* eslint-disable-next-line */ + }, [onHidden]); + + const debouncedHide = useMemo( + // Prevent hide from being called multiple times. Potentially caused by taps in quick succession. + () => debounce(hide, 2000, { leading: true }), + [hide], + ); + + useEffect(() => { + // Automatically handles animation when content changes + LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut); + return () => debouncedHide.cancel(); + }, [children, debouncedHide]); const updateSheetHeight = (e: LayoutChangeEvent) => { const { height } = e.nativeEvent.layout; @@ -160,7 +184,7 @@ const SheetBottom = forwardRef( useImperativeHandle(ref, () => ({ hide: (callback) => { postCallback.current = callback; - hide(); + debouncedHide(); }, })); @@ -199,7 +223,7 @@ const SheetBottom = forwardRef( void; /** - * Boolean that indicates if sheet is swippable. This affects whether or not tapping on the overlay will dismiss the sheet as well. + * Optional boolean that indicates if sheet is swippable. This affects whether or not tapping on the overlay will dismiss the sheet as well. * @default true */ isInteractable?: boolean; + /** + * Optional number for the minimum spacing reserved for the overlay tappable area. + * @default 250 + */ + reservedMinOverlayHeight?: number; } export type SheetBottomPostCallback = () => void; @@ -28,4 +33,5 @@ export interface SheetBottomRef { */ export interface SheetBottomStyleSheetVars { maxSheetHeight: number; + screenBottomPadding: number; } diff --git a/app/component-library/components/Sheet/SheetHeader/SheetHeader.styles.ts b/app/component-library/components/Sheet/SheetHeader/SheetHeader.styles.ts index 0adfc7add07..0b66f0eb888 100644 --- a/app/component-library/components/Sheet/SheetHeader/SheetHeader.styles.ts +++ b/app/component-library/components/Sheet/SheetHeader/SheetHeader.styles.ts @@ -20,7 +20,7 @@ const styleSheet = (params: { theme: Theme }) => { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', - paddingHorizontal: 16, + margin: 16, backgroundColor: colors.background.default, height: 32, }, diff --git a/app/component-library/components/Sheet/SheetHeader/SheetHeader.tsx b/app/component-library/components/Sheet/SheetHeader/SheetHeader.tsx index 8ec2effdc5c..750043094c1 100644 --- a/app/component-library/components/Sheet/SheetHeader/SheetHeader.tsx +++ b/app/component-library/components/Sheet/SheetHeader/SheetHeader.tsx @@ -35,7 +35,7 @@ const SheetHeader = ({ testID={SHEET_HEADER_BACK_BUTTON_ID} variant={ButtonIconVariant.Secondary} onPress={onBack} - icon={IconName.ArrowLeftOutline} + iconName={IconName.ArrowLeftOutline} /> )} diff --git a/app/component-library/components/Sheet/SheetHeader/__snapshots__/SheetHeader.test.tsx.snap b/app/component-library/components/Sheet/SheetHeader/__snapshots__/SheetHeader.test.tsx.snap index b8f4fd7c8a8..72cb9446be3 100644 --- a/app/component-library/components/Sheet/SheetHeader/__snapshots__/SheetHeader.test.tsx.snap +++ b/app/component-library/components/Sheet/SheetHeader/__snapshots__/SheetHeader.test.tsx.snap @@ -9,7 +9,7 @@ exports[`SheetHeader should render correctly 1`] = ` "flexDirection": "row", "height": 32, "justifyContent": "space-between", - "paddingHorizontal": 16, + "margin": 16, } } > diff --git a/app/components/Nav/App/index.js b/app/components/Nav/App/index.js index 8746a67fe83..8eca004830b 100644 --- a/app/components/Nav/App/index.js +++ b/app/components/Nav/App/index.js @@ -54,6 +54,7 @@ import ModalConfirmation from '../../../component-library/components/Modals/Moda import Toast, { ToastContext, } from '../../../component-library/components/Toast'; +import AccountSelector from '../../../components/Views/AccountSelector'; import { TurnOffRememberMeModal } from '../../../components/UI/TurnOffRememberMeModal'; const Stack = createStackNavigator(); @@ -352,6 +353,10 @@ const App = ({ userLoggedIn }) => { component={ModalConfirmation} /> + - StyleSheet.create({ - account: { - borderBottomWidth: StyleSheet.hairlineWidth, - borderColor: colors.border.muted, - flexDirection: 'row', - paddingHorizontal: 20, - paddingVertical: 20, - height: 80, - }, - disabledAccount: { - opacity: 0.5, - }, - accountInfo: { - marginLeft: 15, - marginRight: 0, - flex: 1, - flexDirection: 'row', - }, - accountLabel: { - fontSize: 18, - color: colors.text.default, - ...fontStyles.normal, - }, - accountBalanceWrapper: { - display: 'flex', - flexDirection: 'row', - }, - accountBalance: { - paddingTop: 5, - fontSize: 12, - color: colors.text.alternative, - ...fontStyles.normal, - }, - accountBalanceError: { - color: colors.error.default, - marginLeft: 4, - }, - importedView: { - flex: 0.5, - alignItems: 'flex-start', - marginTop: 2, - }, - accountMain: { - flex: 1, - flexDirection: 'column', - }, - selectedWrapper: { - flex: 0.2, - alignItems: 'flex-end', - }, - importedText: { - color: colors.text.alternative, - fontSize: 10, - ...fontStyles.bold, - }, - importedWrapper: { - paddingHorizontal: 10, - paddingVertical: 3, - borderRadius: 10, - borderWidth: 1, - borderColor: colors.border.default, - }, - }); - -/** - * View that renders specific account element in AccountList - */ -class AccountElement extends PureComponent { - static propTypes = { - /** - * Callback to be called onPress - */ - onPress: PropTypes.func.isRequired, - /** - * Callback to be called onLongPress - */ - onLongPress: PropTypes.func.isRequired, - /** - * Current ticker - */ - ticker: PropTypes.string, - /** - * Whether the account element should be disabled (opaque and not clickable) - */ - disabled: PropTypes.bool, - item: PropTypes.object, - /** - * Updated balance using stored in state - */ - updatedBalanceFromStore: PropTypes.string, - }; - - onPress = () => { - const { onPress } = this.props; - const { address } = this.props.item; - onPress && onPress(address); - }; - - onLongPress = () => { - const { onLongPress } = this.props; - const { address, isImported, index } = this.props.item; - onLongPress && onLongPress(address, isImported, index); - }; - - render() { - const { disabled, updatedBalanceFromStore, ticker } = this.props; - const { - address, - name, - ens, - isSelected, - isImported, - balanceError, - isQRHardware, - } = this.props.item; - const colors = this.context.colors || mockTheme.colors; - const styles = createStyles(colors); - - const selected = isSelected ? ( - - ) : null; - const tag = - isImported || isQRHardware ? ( - - - - {strings( - isImported ? 'accounts.imported' : 'transaction.hardware', - )} - - - - ) : null; - - return ( - true}> - - - - - - {isDefaultAccountName(name) && ens ? ens : name} - - - - {renderFromWei(updatedBalanceFromStore)} {getTicker(ticker)} - - {!!balanceError && ( - - {balanceError} - - )} - - - {!!tag && tag} - {selected} - - - - ); - } -} - -const mapStateToProps = ( - { - engine: { - backgroundState: { PreferencesController, AccountTrackerController }, - }, - }, - { item: { balance, address } }, -) => { - const { selectedAddress } = PreferencesController; - const { accounts } = AccountTrackerController; - const selectedAccount = accounts[selectedAddress]; - const selectedAccountHasBalance = - selectedAccount && - Object.prototype.hasOwnProperty.call(selectedAccount, BALANCE_KEY); - const updatedBalanceFromStore = - balance === EMPTY && - selectedAddress === address && - selectedAccount && - selectedAccountHasBalance - ? selectedAccount[BALANCE_KEY] - : balance; - return { - updatedBalanceFromStore, - }; -}; - -AccountElement.contextType = ThemeContext; - -export default connect(mapStateToProps)(AccountElement); diff --git a/app/components/UI/AccountList/__snapshots__/index.test.tsx.snap b/app/components/UI/AccountList/__snapshots__/index.test.tsx.snap deleted file mode 100644 index ccf4a0e25fe..00000000000 --- a/app/components/UI/AccountList/__snapshots__/index.test.tsx.snap +++ /dev/null @@ -1,7 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`Accounts should render correctly 1`] = ` - -`; diff --git a/app/components/UI/AccountList/index.js b/app/components/UI/AccountList/index.js deleted file mode 100644 index aebeb325b52..00000000000 --- a/app/components/UI/AccountList/index.js +++ /dev/null @@ -1,476 +0,0 @@ -import React, { PureComponent } from 'react'; -import { KeyringTypes } from '@metamask/controllers'; -import Engine from '../../../core/Engine'; -import PropTypes from 'prop-types'; -import { - Alert, - ActivityIndicator, - InteractionManager, - FlatList, - TouchableOpacity, - StyleSheet, - Text, - View, - SafeAreaView, -} from 'react-native'; -import { fontStyles } from '../../../styles/common'; -import Device from '../../../util/device'; -import { strings } from '../../../../locales/i18n'; -import { toChecksumAddress } from 'ethereumjs-util'; -import Logger from '../../../util/Logger'; -import Analytics from '../../../core/Analytics/Analytics'; -import AnalyticsV2 from '../../../util/analyticsV2'; -import { ANALYTICS_EVENT_OPTS } from '../../../util/analytics'; -import { doENSReverseLookup } from '../../../util/ENSUtils'; -import AccountElement from './AccountElement'; -import { connect } from 'react-redux'; -import { ThemeContext, mockTheme } from '../../../util/theme'; - -const createStyles = (colors) => - StyleSheet.create({ - wrapper: { - backgroundColor: colors.background.default, - borderTopLeftRadius: 10, - borderTopRightRadius: 10, - minHeight: 450, - }, - titleWrapper: { - width: '100%', - height: 33, - alignItems: 'center', - justifyContent: 'center', - borderBottomWidth: StyleSheet.hairlineWidth, - borderColor: colors.border.muted, - }, - dragger: { - width: 48, - height: 5, - borderRadius: 4, - backgroundColor: colors.border.default, - opacity: Device.isAndroid() ? 0.6 : 0.5, - }, - accountsWrapper: { - flex: 1, - }, - footer: { - height: Device.isIphoneX() ? 200 : 170, - paddingBottom: Device.isIphoneX() ? 30 : 0, - justifyContent: 'center', - flexDirection: 'column', - alignItems: 'center', - }, - btnText: { - fontSize: 14, - color: colors.primary.default, - ...fontStyles.normal, - }, - footerButton: { - width: '100%', - height: 55, - alignItems: 'center', - justifyContent: 'center', - borderTopWidth: StyleSheet.hairlineWidth, - borderColor: colors.border.muted, - }, - }); - -/** - * View that contains the list of all the available accounts - */ -class AccountList extends PureComponent { - static propTypes = { - /** - * Map of accounts to information objects including balances - */ - accounts: PropTypes.object, - /** - * An object containing each identity in the format address => account - */ - identities: PropTypes.object, - /** - * A string representing the selected address => account - */ - selectedAddress: PropTypes.string, - /** - * An object containing all the keyrings - */ - keyrings: PropTypes.array, - /** - * function to be called when switching accounts - */ - onAccountChange: PropTypes.func, - /** - * function to be called when importing an account - */ - onImportAccount: PropTypes.func, - /** - * function to be called when connect to a QR hardware - */ - onConnectHardware: PropTypes.func, - /** - * Current provider ticker - */ - ticker: PropTypes.string, - /** - * Whether it will show options to create or import accounts - */ - enableAccountsAddition: PropTypes.bool, - /** - * function to generate an error string based on a passed balance - */ - getBalanceError: PropTypes.func, - /** - * Indicates whether third party API mode is enabled - */ - thirdPartyApiMode: PropTypes.bool, - /** - * ID of the current network - */ - network: PropTypes.string, - }; - - state = { - loading: false, - orderedAccounts: {}, - accountsENS: {}, - }; - - flatList = React.createRef(); - lastPosition = 0; - updating = false; - - componentDidMount() { - this.mounted = true; - const orderedAccounts = this.getAccounts(); - InteractionManager.runAfterInteractions(() => { - this.assignENSToAccounts(orderedAccounts); - if (orderedAccounts.length > 4) { - const selectedAccountIndex = - orderedAccounts.findIndex((account) => account.isSelected) || 0; - this.scrollToCurrentAccount(selectedAccountIndex); - } - }); - this.mounted && this.setState({ orderedAccounts }); - } - - componentWillUnmount = () => { - this.mounted = false; - }; - - scrollToCurrentAccount(selectedAccountIndex) { - // eslint-disable-next-line no-unused-expressions - this.flatList?.current?.scrollToIndex({ - index: selectedAccountIndex, - animated: true, - }); - } - - onAccountChange = async (newAddress) => { - const { PreferencesController } = Engine.context; - const { accounts } = this.props; - - requestAnimationFrame(async () => { - try { - // If not enabled is used from address book so we don't change accounts - if (!this.props.enableAccountsAddition) { - this.props.onAccountChange(newAddress); - const orderedAccounts = this.getAccounts(); - this.mounted && this.setState({ orderedAccounts }); - return; - } - - PreferencesController.setSelectedAddress(newAddress); - - this.props.onAccountChange(); - - this.props.thirdPartyApiMode && - InteractionManager.runAfterInteractions(async () => { - setTimeout(() => { - Engine.refreshTransactionHistory(); - }, 1000); - }); - } catch (e) { - Logger.error(e, 'error while trying change the selected account'); // eslint-disable-line - } - InteractionManager.runAfterInteractions(() => { - setTimeout(() => { - // Track Event: "Switched Account" - AnalyticsV2.trackEvent( - AnalyticsV2.ANALYTICS_EVENTS.SWITCHED_ACCOUNT, - { - number_of_accounts: Object.keys(accounts ?? {}).length, - }, - ); - }, 1000); - }); - const orderedAccounts = this.getAccounts(); - this.mounted && this.setState({ orderedAccounts }); - }); - }; - - importAccount = () => { - this.props.onImportAccount(); - InteractionManager.runAfterInteractions(() => { - Analytics.trackEvent(ANALYTICS_EVENT_OPTS.ACCOUNTS_IMPORTED_NEW_ACCOUNT); - }); - }; - - connectHardware = () => { - this.props.onConnectHardware(); - AnalyticsV2.trackEvent( - AnalyticsV2.ANALYTICS_EVENTS.CONNECT_HARDWARE_WALLET, - ); - }; - - addAccount = async () => { - if (this.state.loading) return; - this.mounted && this.setState({ loading: true }); - const { KeyringController } = Engine.context; - requestAnimationFrame(async () => { - try { - await KeyringController.addNewAccount(); - const { PreferencesController } = Engine.context; - const newIndex = Object.keys(this.props.identities).length - 1; - PreferencesController.setSelectedAddress( - Object.keys(this.props.identities)[newIndex], - ); - setTimeout(() => { - this.flatList && - this.flatList.current && - this.flatList.current.scrollToEnd(); - this.mounted && this.setState({ loading: false }); - }, 500); - const orderedAccounts = this.getAccounts(); - this.mounted && this.setState({ orderedAccounts }); - } catch (e) { - // Restore to the previous index in case anything goes wrong - Logger.error(e, 'error while trying to add a new account'); // eslint-disable-line - this.mounted && this.setState({ loading: false }); - } - }); - InteractionManager.runAfterInteractions(() => { - Analytics.trackEvent(ANALYTICS_EVENT_OPTS.ACCOUNTS_ADDED_NEW_ACCOUNT); - }); - }; - - isImported(allKeyrings, address) { - let ret = false; - for (const keyring of allKeyrings) { - if (keyring.accounts.includes(address)) { - ret = keyring.type === KeyringTypes.simple; - break; - } - } - - return ret; - } - - isQRHardware(allKeyrings, address) { - let ret = false; - for (const keyring of allKeyrings) { - if (keyring.accounts.includes(address)) { - ret = keyring.type === KeyringTypes.qr; - break; - } - } - - return ret; - } - - onLongPress = (address, imported, index) => { - if (!imported) return; - Alert.alert( - strings('accounts.remove_account_title'), - strings('accounts.remove_account_message'), - [ - { - text: strings('accounts.no'), - onPress: () => false, - style: 'cancel', - }, - { - text: strings('accounts.yes_remove_it'), - onPress: async () => { - const { PreferencesController } = Engine.context; - const { selectedAddress } = this.props; - const isRemovingCurrentAddress = selectedAddress === address; - const fallbackAccountIndex = index - 1; - const fallbackAccountAddress = - this.state.orderedAccounts[fallbackAccountIndex].address; - - // TODO - Refactor logic. onAccountChange is only used for refreshing latest orderedAccounts after account removal. Duplicate call for PreferencesController.setSelectedAddress exists. - // Set fallback address before removing account if removing current account - isRemovingCurrentAddress && - PreferencesController.setSelectedAddress(fallbackAccountAddress); - await Engine.context.KeyringController.removeAccount(address); - // Default to the previous account in the list if removing current account - this.onAccountChange( - isRemovingCurrentAddress - ? fallbackAccountAddress - : selectedAddress, - ); - }, - }, - ], - { cancelable: false }, - ); - }; - - renderItem = ({ item }) => { - const { ticker } = this.props; - const { accountsENS } = this.state; - return ( - - ); - }; - - getAccounts() { - const { accounts, identities, selectedAddress, keyrings, getBalanceError } = - this.props; - // This is a temporary fix until we can read the state from @metamask/controllers - const allKeyrings = - keyrings && keyrings.length - ? keyrings - : Engine.context.KeyringController.state.keyrings; - - const accountsOrdered = allKeyrings.reduce( - (list, keyring) => list.concat(keyring.accounts), - [], - ); - return accountsOrdered - .filter((address) => !!identities[toChecksumAddress(address)]) - .map((addr, index) => { - const checksummedAddress = toChecksumAddress(addr); - const identity = identities[checksummedAddress]; - const { name, address } = identity; - const identityAddressChecksummed = toChecksumAddress(address); - const isSelected = identityAddressChecksummed === selectedAddress; - const isImported = this.isImported( - allKeyrings, - identityAddressChecksummed, - ); - const isQRHardware = this.isQRHardware( - allKeyrings, - identityAddressChecksummed, - ); - let balance = 0x0; - if (accounts[identityAddressChecksummed]) { - balance = accounts[identityAddressChecksummed].balance; - } - - const balanceError = getBalanceError ? getBalanceError(balance) : null; - return { - index, - name, - address: identityAddressChecksummed, - balance, - isSelected, - isImported, - isQRHardware, - balanceError, - }; - }); - } - - assignENSToAccounts = (orderedAccounts) => { - const { network } = this.props; - orderedAccounts.forEach(async (account) => { - try { - const ens = await doENSReverseLookup(account.address, network); - this.setState((state) => ({ - accountsENS: { - ...state.accountsENS, - [account.address]: ens, - }, - })); - } catch { - // Error - } - }); - }; - - keyExtractor = (item) => item.address; - - render() { - const { orderedAccounts } = this.state; - const { enableAccountsAddition } = this.props; - const colors = this.context.colors || mockTheme.colors; - const styles = createStyles(colors); - - return ( - - - - - ({ - length: 80, - offset: 80 * index, - index, - })} // eslint-disable-line - /> - {enableAccountsAddition && ( - - - {this.state.loading ? ( - - ) : ( - - {strings('accounts.create_new_account')} - - )} - - - - {strings('accounts.import_account')} - - - - - {strings('accounts.connect_hardware')} - - - - )} - - ); - } -} - -AccountList.contextType = ThemeContext; - -const mapStateToProps = (state) => ({ - accounts: state.engine.backgroundState.AccountTrackerController.accounts, - thirdPartyApiMode: state.privacy.thirdPartyApiMode, - keyrings: state.engine.backgroundState.KeyringController.keyrings, - network: state.engine.backgroundState.NetworkController.network, -}); - -export default connect(mapStateToProps)(AccountList); diff --git a/app/components/UI/AccountList/index.test.tsx b/app/components/UI/AccountList/index.test.tsx deleted file mode 100644 index 112a3a07b49..00000000000 --- a/app/components/UI/AccountList/index.test.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import React from 'react'; -import { shallow } from 'enzyme'; -import AccountList from './'; -import { Provider } from 'react-redux'; -import configureMockStore from 'redux-mock-store'; - -const mockStore = configureMockStore(); -const address = '0xe7E125654064EEa56229f273dA586F10DF96B0a1'; -const store = mockStore({ - engine: { - backgroundState: { - AccountTrackerController: { - accounts: { - [address]: { name: 'account 1', address, balance: 0 }, - }, - }, - }, - }, -}); - -describe('Accounts', () => { - it('should render correctly', () => { - const wrapper = shallow( - - - , - ); - expect(wrapper).toMatchSnapshot(); - }); -}); diff --git a/app/components/UI/AccountOverview/index.js b/app/components/UI/AccountOverview/index.js index 7775cb1a85a..5ba46dbb9ee 100644 --- a/app/components/UI/AccountOverview/index.js +++ b/app/components/UI/AccountOverview/index.js @@ -20,10 +20,7 @@ import { strings } from '../../../../locales/i18n'; import { swapsLivenessSelector } from '../../../reducers/swaps'; import { showAlert } from '../../../actions/alert'; import { protectWalletModalVisible } from '../../../actions/user'; -import { - toggleAccountsModal, - toggleReceiveModal, -} from '../../../actions/modals'; +import { toggleReceiveModal } from '../../../actions/modals'; import { newAssetTransaction } from '../../../actions/transaction'; import Device from '../../../util/device'; @@ -164,10 +161,6 @@ class AccountOverview extends PureComponent { /* Triggers global alert */ showAlert: PropTypes.func, - /** - * Action that toggles the accounts modal - */ - toggleAccountsModal: PropTypes.func, /** * whether component is being rendered from onboarding wizard */ @@ -222,17 +215,12 @@ class AccountOverview extends PureComponent { scrollViewContainer = React.createRef(); mainView = React.createRef(); - animatingAccountsModal = false; - - toggleAccountsModal = () => { - const { onboardingWizard } = this.props; - if (!onboardingWizard && !this.animatingAccountsModal) { - this.animatingAccountsModal = true; - this.props.toggleAccountsModal(); - setTimeout(() => { - this.animatingAccountsModal = false; - }, 500); - } + openAccountSelector = () => { + const { onboardingWizard, navigation } = this.props; + !onboardingWizard && + navigation.navigate(Routes.MODAL.ROOT_MODAL_FLOW, { + screen: Routes.SHEET.ACCOUNT_SELECTOR, + }); }; isAccountLabelDefined = (accountLabel) => @@ -395,7 +383,7 @@ class AccountOverview extends PureComponent { ({ const mapDispatchToProps = (dispatch) => ({ showAlert: (config) => dispatch(showAlert(config)), - toggleAccountsModal: () => dispatch(toggleAccountsModal()), protectWalletModalVisible: () => dispatch(protectWalletModalVisible()), newAssetTransaction: (selectedAsset) => dispatch(newAssetTransaction(selectedAsset)), diff --git a/app/components/UI/AccountRightButton/__snapshots__/index.test.tsx.snap b/app/components/UI/AccountRightButton/__snapshots__/index.test.tsx.snap index c654a207d6d..4d9b41a0236 100644 --- a/app/components/UI/AccountRightButton/__snapshots__/index.test.tsx.snap +++ b/app/components/UI/AccountRightButton/__snapshots__/index.test.tsx.snap @@ -1,3 +1,3 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`AccountRightButton should render correctly 1`] = ``; +exports[`AccountRightButton should render correctly 1`] = ``; diff --git a/app/components/UI/AccountRightButton/index.js b/app/components/UI/AccountRightButton/index.js index 17764bff130..8b0927478a0 100644 --- a/app/components/UI/AccountRightButton/index.js +++ b/app/components/UI/AccountRightButton/index.js @@ -3,9 +3,10 @@ import PropTypes from 'prop-types'; import { connect } from 'react-redux'; import { TouchableOpacity, StyleSheet } from 'react-native'; import Identicon from '../Identicon'; -import { toggleAccountsModal } from '../../../actions/modals'; import Device from '../../../util/device'; import AnalyticsV2 from '../../../util/analyticsV2'; +import { withNavigation } from '@react-navigation/compat'; +import Routes from '../../../constants/navigation/Routes'; const styles = StyleSheet.create({ leftButton: { @@ -28,27 +29,21 @@ class AccountRightButton extends PureComponent { * Selected address as string */ address: PropTypes.string, - /** - * Action that toggles the account modal - */ - toggleAccountsModal: PropTypes.func, /** * List of accounts from the AccountTrackerController */ accounts: PropTypes.object, + /** + * Navigation object. + */ + navigation: PropTypes.object, }; - animating = false; - - toggleAccountsModal = () => { - const { accounts } = this.props; - if (!this.animating) { - this.animating = true; - this.props.toggleAccountsModal(); - setTimeout(() => { - this.animating = false; - }, 500); - } + openAccountSelector = () => { + const { accounts, navigation } = this.props; + navigation.navigate(Routes.MODAL.ROOT_MODAL_FLOW, { + screen: Routes.SHEET.ACCOUNT_SELECTOR, + }); // Track Event: "Opened Acount Switcher" AnalyticsV2.trackEvent( AnalyticsV2.ANALYTICS_EVENTS.BROWSER_OPEN_ACCOUNT_SWITCH, @@ -63,7 +58,7 @@ class AccountRightButton extends PureComponent { return ( @@ -77,8 +72,7 @@ const mapStateToProps = (state) => ({ accounts: state.engine.backgroundState.AccountTrackerController.accounts, }); -const mapDispatchToProps = (dispatch) => ({ - toggleAccountsModal: () => dispatch(toggleAccountsModal()), -}); - -export default connect(mapStateToProps, mapDispatchToProps)(AccountRightButton); +export default connect( + mapStateToProps, + null, +)(withNavigation(AccountRightButton)); diff --git a/app/components/UI/AccountSelectorList/AccountSelectorList.styles.ts b/app/components/UI/AccountSelectorList/AccountSelectorList.styles.ts new file mode 100644 index 00000000000..90c0ffedf6d --- /dev/null +++ b/app/components/UI/AccountSelectorList/AccountSelectorList.styles.ts @@ -0,0 +1,20 @@ +// Third party dependencies. +import { StyleSheet } from 'react-native'; + +/** + * Style sheet function for AvatarIcon component. + * + * @param params Style sheet params. + * @param params.theme App theme from ThemeContext. + * @param params.vars Inputs that the style sheet depends on. + * @returns StyleSheet object. + */ +const styleSheet = () => + StyleSheet.create({ + balancesContainer: { + alignItems: 'flex-end', + }, + balanceLabel: { textAlign: 'right' }, + }); + +export default styleSheet; diff --git a/app/components/UI/AccountSelectorList/AccountSelectorList.tsx b/app/components/UI/AccountSelectorList/AccountSelectorList.tsx new file mode 100644 index 00000000000..892092f3fa4 --- /dev/null +++ b/app/components/UI/AccountSelectorList/AccountSelectorList.tsx @@ -0,0 +1,153 @@ +// Third party dependencies. +import React, { useCallback, useEffect, useRef } from 'react'; +import { ListRenderItem, View } from 'react-native'; +import { FlatList } from 'react-native-gesture-handler'; +import { useSelector } from 'react-redux'; +import { KeyringTypes } from '@metamask/controllers'; + +// External dependencies. +import Cell, { + CellVariants, +} from '../../../component-library/components/Cells/Cell'; +import { useStyles } from '../../../component-library/hooks'; +import Text from '../../../component-library/components/Text'; +import AvatarGroup from '../../../component-library/components/Avatars/AvatarGroup'; +import UntypedEngine from '../../../core/Engine'; +import { formatAddress } from '../../../util/address'; +import { AvatarAccountType } from '../../../component-library/components/Avatars/AvatarAccount'; +import { isDefaultAccountName } from '../../../util/ENSUtils'; +import { strings } from '../../../../locales/i18n'; +import { AvatarVariants } from '../../../component-library/components/Avatars/Avatar.types'; + +// Internal dependencies. +import { + Account, + AccountSelectorListProps, + Assets, +} from './AccountSelectorList.types'; +import styleSheet from './AccountSelectorList.styles'; +import { useAccounts } from './hooks'; + +const AccountSelectorList = ({ + onSelectAccount, + checkBalanceError, + isLoading = false, + ...props +}: AccountSelectorListProps) => { + const Engine = UntypedEngine as any; + const accountListRef = useRef(null); + const { styles } = useStyles(styleSheet, {}); + const accountAvatarType = useSelector((state: any) => + state.settings.useBlockieIcon + ? AvatarAccountType.Blockies + : AvatarAccountType.JazzIcon, + ); + const { accounts, ensByAccountAddress } = useAccounts({ + checkBalanceError, + isLoading, + }); + + useEffect(() => { + if (!accounts.length) return; + const account = accounts.find(({ isSelected }) => isSelected); + if (account) { + // Wrap in timeout to provide more time for the list to render. + setTimeout(() => { + accountListRef?.current?.scrollToOffset({ + offset: account.yOffset, + animated: false, + }); + }, 0); + } + // eslint-disable-next-line + }, [accounts.length]); + + const onPress = useCallback( + (address: string) => { + const { PreferencesController } = Engine.context; + PreferencesController.setSelectedAddress(address); + onSelectAccount?.(address); + }, + /* eslint-disable-next-line */ + [onSelectAccount], + ); + + const getKeyExtractor = ({ address }: Account) => address; + + const getTagLabel = (type: KeyringTypes) => { + let label = ''; + switch (type) { + case KeyringTypes.qr: + label = strings('transaction.hardware'); + break; + case KeyringTypes.simple: + label = strings('accounts.imported'); + break; + } + return label; + }; + + const renderAccountBalances = useCallback( + ({ fiatBalance, tokens }: Assets) => ( + + {fiatBalance} + {tokens && } + + ), + [styles.balancesContainer, styles.balanceLabel], + ); + + const renderAccountItem: ListRenderItem = useCallback( + ({ item: { name, address, assets, type, isSelected, balanceError } }) => { + const shortAddress = formatAddress(address, 'short'); + const tagLabel = getTagLabel(type); + const ensName = ensByAccountAddress[address]; + const accountName = + isDefaultAccountName(name) && ensName ? ensName : name; + const isDisabled = !!balanceError || isLoading; + + return ( + onPress(address)} + avatarProps={{ + variant: AvatarVariants.Account, + type: accountAvatarType, + accountAddress: address, + }} + tagLabel={tagLabel} + disabled={isDisabled} + /* eslint-disable-next-line */ + style={{ opacity: isDisabled ? 0.5 : 1 }} + > + {assets && renderAccountBalances(assets)} + + ); + }, + [ + accountAvatarType, + onPress, + renderAccountBalances, + ensByAccountAddress, + isLoading, + ], + ); + + return ( + + ); +}; + +export default AccountSelectorList; diff --git a/app/components/UI/AccountSelectorList/AccountSelectorList.types.ts b/app/components/UI/AccountSelectorList/AccountSelectorList.types.ts new file mode 100644 index 00000000000..7d2e985d5cc --- /dev/null +++ b/app/components/UI/AccountSelectorList/AccountSelectorList.types.ts @@ -0,0 +1,75 @@ +// Third party dependencies. +import { KeyringTypes } from '@metamask/controllers'; +import { FlatListProps } from 'react-native'; + +// External dependencies. +import { AvatarGroupToken } from '../../../component-library/components/Avatars/AvatarGroup/AvatarGroup.types'; + +/** + * Asset information associated with the account, which includes both the fiat balance and owned tokens. + */ +export interface Assets { + /** + * Fiat balance in string format. + */ + fiatBalance: string; + /** + * Tokens owned by this account. + */ + tokens?: AvatarGroupToken[]; +} + +/** + * Account information. + */ +export interface Account { + /** + * Account name. + */ + name: string; + /** + * Account address. + */ + address: string; + /** + * Asset information associated with the account, which includes both the fiat balance and owned tokens. + */ + assets?: Assets; + /** + * Account type. + */ + type: KeyringTypes; + /** + * Y offset of the item. Used for scrolling purposes. + */ + yOffset: number; + /** + * Boolean that indicates if the account is selected. + */ + isSelected: boolean; + /** + * Optional error that indicates if the account has enough funds. Non-empty string will render the account item non-selectable. + */ + balanceError?: string; +} + +/** + * AccountSelectorList props. + */ +export interface AccountSelectorListProps + extends Partial> { + /** + * Optional callback to trigger when account is selected. + */ + onSelectAccount?: (address: string) => void; + /** + * Optional callback that is used to check for a balance requirement. Non-empty string will render the account item non-selectable. + * @param balance - The ticker balance of an account in wei and hex string format. + */ + checkBalanceError?: (balance: string) => string; + /** + * Optional boolean that indicates if accounts are being processed in the background. The accounts will be unselectable as long as this is true. + * @default false + */ + isLoading?: boolean; +} diff --git a/app/components/UI/AccountSelectorList/hooks/index.ts b/app/components/UI/AccountSelectorList/hooks/index.ts new file mode 100644 index 00000000000..632be0949b4 --- /dev/null +++ b/app/components/UI/AccountSelectorList/hooks/index.ts @@ -0,0 +1,3 @@ +/* eslint-disable import/prefer-default-export */ + +export { useAccounts } from './useAccounts'; diff --git a/app/components/UI/AccountSelectorList/hooks/useAccounts/index.ts b/app/components/UI/AccountSelectorList/hooks/useAccounts/index.ts new file mode 100644 index 00000000000..7228467747c --- /dev/null +++ b/app/components/UI/AccountSelectorList/hooks/useAccounts/index.ts @@ -0,0 +1,4 @@ +/* eslint-disable import/prefer-default-export */ + +export { useAccounts } from './useAccounts'; +export type { EnsByAccountAddress } from './useAccounts.types'; diff --git a/app/components/UI/AccountSelectorList/hooks/useAccounts/useAccounts.ts b/app/components/UI/AccountSelectorList/hooks/useAccounts/useAccounts.ts new file mode 100644 index 00000000000..4dfba38565b --- /dev/null +++ b/app/components/UI/AccountSelectorList/hooks/useAccounts/useAccounts.ts @@ -0,0 +1,205 @@ +/* eslint-disable import/prefer-default-export */ + +// Third party dependencies. +import { useCallback, useEffect, useRef, useState } from 'react'; +import { useSelector } from 'react-redux'; +import { toChecksumAddress } from 'ethereumjs-util'; +import { KeyringTypes } from '@metamask/controllers'; +import { isEqual } from 'lodash'; + +// External Dependencies. +import UntypedEngine from '../../../../../core/Engine'; +import { Account } from '../..'; +import { doENSReverseLookup } from '../../../../../util/ENSUtils'; +import { hexToBN, renderFromWei, weiToFiat } from '../../../../../util/number'; +import { getTicker } from '../../../../..//util/transactions'; + +// Internal dependencies +import { + EnsByAccountAddress, + UseAccounts, + UseAccountsParams, +} from './useAccounts.types'; + +/** + * Hook that returns both wallet accounts and ens name information. + * + * @returns Object that contins both wallet accounts and ens name information. + */ +export const useAccounts = ({ + checkBalanceError, + isLoading = false, +}: UseAccountsParams = {}): UseAccounts => { + const Engine = UntypedEngine as any; + const isMountedRef = useRef(false); + const [accounts, setAccounts] = useState([]); + const [ensByAccountAddress, setENSByAccountAddress] = + useState({}); + const identities = useSelector( + (state: any) => + state.engine.backgroundState.PreferencesController.identities, + ); + const network = useSelector( + (state: any) => state.engine.backgroundState.NetworkController.network, + ); + const selectedAddress = useSelector( + (state: any) => + state.engine.backgroundState.PreferencesController.selectedAddress, + ); + const accountInfoByAddress = useSelector( + (state: any) => + state.engine.backgroundState.AccountTrackerController.accounts, + (left, right) => isEqual(left, right), + ); + const conversionRate = useSelector( + (state: any) => + state.engine.backgroundState.CurrencyRateController.conversionRate, + ); + const currentCurrency = useSelector( + (state: any) => + state.engine.backgroundState.CurrencyRateController.currentCurrency, + ); + const ticker = useSelector( + (state: any) => + state.engine.backgroundState.NetworkController.provider.ticker, + ); + + const fetchENSNames = useCallback( + async ({ + flattenedAccounts, + startingIndex, + }: { + flattenedAccounts: Account[]; + startingIndex: number; + }) => { + // Ensure index exists in account list. + let safeStartingIndex = startingIndex; + let mirrorIndex = safeStartingIndex - 1; + let latestENSbyAccountAddress: EnsByAccountAddress = {}; + + if (startingIndex < 0) { + safeStartingIndex = 0; + } else if (startingIndex > flattenedAccounts.length) { + safeStartingIndex = flattenedAccounts.length - 1; + } + + const fetchENSName = async (accountIndex: number) => { + const { address } = flattenedAccounts[accountIndex]; + try { + const ens: string | undefined = await doENSReverseLookup( + address, + network, + ); + if (ens) { + latestENSbyAccountAddress = { + ...latestENSbyAccountAddress, + [address]: ens, + }; + } + } catch (e) { + // ENS either doesn't exists or failed to fetch. + } + }; + + // Iterate outwards in both directions starting at the starting index. + while (mirrorIndex >= 0 || safeStartingIndex < flattenedAccounts.length) { + if (!isMountedRef.current) return; + if (safeStartingIndex < flattenedAccounts.length) { + await fetchENSName(safeStartingIndex); + } + if (mirrorIndex >= 0) { + await fetchENSName(mirrorIndex); + } + mirrorIndex--; + safeStartingIndex++; + setENSByAccountAddress(latestENSbyAccountAddress); + } + }, + [network], + ); + + const getAccounts = useCallback(() => { + // Keep track of the Y position of account item. Used for scrolling purposes. + let yOffset = 0; + let selectedIndex = 0; + // Reading keyrings directly from Redux doesn't work at the momemt. + const keyrings: any[] = Engine.context.KeyringController.state.keyrings; + const flattenedAccounts: Account[] = keyrings.reduce((result, keyring) => { + const { + accounts: accountAddresses, + type, + }: { accounts: string[]; type: KeyringTypes } = keyring; + for (const index in accountAddresses) { + const address = accountAddresses[index]; + const isSelected = selectedAddress === address; + if (isSelected) { + selectedIndex = result.length; + } + const checksummedAddress = toChecksumAddress(address); + const identity = identities[checksummedAddress]; + if (!identity) continue; + const { name } = identity; + // TODO - Improve UI to either include loading and/or balance load failures. + const balanceWeiHex = + accountInfoByAddress?.[checksummedAddress]?.balance || 0x0; + const balanceETH = renderFromWei(balanceWeiHex); // Gives ETH + const balanceFiat = weiToFiat( + hexToBN(balanceWeiHex) as any, + conversionRate, + currentCurrency, + ); + const balanceTicker = getTicker(ticker); + const balanceLabel = `${balanceFiat}\n${balanceETH} ${balanceTicker}`; + const balanceError = checkBalanceError?.(balanceWeiHex); + const mappedAccount: Account = { + name, + address: checksummedAddress, + type, + yOffset, + isSelected, + // TODO - Also fetch assets. Reference AccountList component. + // assets + assets: { fiatBalance: balanceLabel }, + balanceError, + }; + result.push(mappedAccount); + // Calculate height of the account item. + yOffset += 78; + if (balanceError) { + yOffset += 22; + } + if (type !== KeyringTypes.hd) { + yOffset += 24; + } + } + return result; + }, []); + setAccounts(flattenedAccounts); + fetchENSNames({ flattenedAccounts, startingIndex: selectedIndex }); + /* eslint-disable-next-line */ + }, [ + selectedAddress, + identities, + fetchENSNames, + accountInfoByAddress, + conversionRate, + currentCurrency, + ticker, + checkBalanceError, + ]); + + useEffect(() => { + if (!isMountedRef.current) { + isMountedRef.current = true; + } + if (isLoading) return; + // setTimeout is needed for now to ensure next frame contains updated keyrings. + setTimeout(getAccounts, 0); + // Once we can pull keyrings from Redux, we will replace the deps with keyrings. + return () => { + isMountedRef.current = false; + }; + }, [getAccounts, isLoading]); + + return { accounts, ensByAccountAddress }; +}; diff --git a/app/components/UI/AccountSelectorList/hooks/useAccounts/useAccounts.types.ts b/app/components/UI/AccountSelectorList/hooks/useAccounts/useAccounts.types.ts new file mode 100644 index 00000000000..df6ae658af0 --- /dev/null +++ b/app/components/UI/AccountSelectorList/hooks/useAccounts/useAccounts.types.ts @@ -0,0 +1,41 @@ +/* eslint-disable import/prefer-default-export */ + +import { + Account, + AccountSelectorListProps, +} from '../../AccountSelectorList.types'; + +/** + * Mapping of ENS names by account address. + */ +export type EnsByAccountAddress = Record; + +/** + * Optional params that useAccount hook takes. + */ +export interface UseAccountsParams { + /** + * Optional callback that is used to check for a balance requirement. Non-empty string will render the account item non-selectable. + * @param balance - The ticker balance of an account in wei and hex string format. + */ + checkBalanceError?: AccountSelectorListProps['checkBalanceError']; + /** + * Optional boolean that indicates if accounts are being processed in the background. Setting this to true will prevent any unnecessary updates while loading. + * @default false + */ + isLoading?: boolean; +} + +/** + * Return value for useAccounts hook. + */ +export interface UseAccounts { + /** + * List of account information. + */ + accounts: Account[]; + /** + * Mapping of ENS names by account address. + */ + ensByAccountAddress: EnsByAccountAddress; +} diff --git a/app/components/UI/AccountSelectorList/hooks/useAccounts/useAccountsTest.tsx b/app/components/UI/AccountSelectorList/hooks/useAccounts/useAccountsTest.tsx new file mode 100644 index 00000000000..173b7e17168 --- /dev/null +++ b/app/components/UI/AccountSelectorList/hooks/useAccounts/useAccountsTest.tsx @@ -0,0 +1,65 @@ +// TODO - Finish tests + +// import { renderHook } from '@testing-library/react-hooks'; +// import { useAccounts } from './useAccounts'; + +// const initialState = { +// engine: { +// backgroundState: { +// PreferencesController: { +// selectedAddress: '0xc4800C54cB70E7Dac746C2fA829da0004443613e', +// identities: { +// '0xc4800C54cB70E7Dac746C2fA829da0004443613e': { name: 'Account 1' }, +// }, +// }, +// NetworkController: { +// network: '1', +// provider: { +// ticker: 'ETH', +// }, +// }, +// CurrencyRateController: { +// conversionRate: 1641.87, +// currentCurrency: 'usd', +// }, +// AccountTrackerController: { +// accounts: { +// '0xc4800C54cB70E7Dac746C2fA829da0004443613e': { +// balance: '0x0', +// }, +// }, +// }, +// }, +// }, +// }; + +// jest.mock('react-redux', () => ({ +// ...jest.requireActual('react-redux'), +// useSelector: jest.fn().mockImplementation((cb) => cb(initialState)), +// })); + +// jest.mock('../../../../../core/Engine', () => ({ +// context: { +// KeyringController: { +// state: { +// keyrings: [ +// { +// accounts: ['0xc4800C54cB70E7Dac746C2fA829da0004443613e'], +// type: 'HD Key Tree', +// }, +// ], +// }, +// }, +// }, +// })); + +// describe('useAccounts', () => { +// test('it should start with a state of "Loading"', async () => { +// const { result, waitForNextUpdate } = renderHook(() => useAccounts()); +// jest.runAllTimers(); +// await waitForNextUpdate(); + +// // TODO - Actually test hook +// expect(true).toBeTruthy(); +// }); +// }); diff --git a/app/components/UI/AccountSelectorList/index.ts b/app/components/UI/AccountSelectorList/index.ts new file mode 100644 index 00000000000..34f107af8bc --- /dev/null +++ b/app/components/UI/AccountSelectorList/index.ts @@ -0,0 +1,6 @@ +export { default } from './AccountSelectorList'; +export type { + Account, + AccountSelectorListProps, +} from './AccountSelectorList.types'; +export * from './hooks'; diff --git a/app/components/UI/DrawerView/index.js b/app/components/UI/DrawerView/index.js index 9f190e150dc..293115f553d 100644 --- a/app/components/UI/DrawerView/index.js +++ b/app/components/UI/DrawerView/index.js @@ -22,7 +22,6 @@ import { } from '../../../util/networks'; import Identicon from '../Identicon'; import StyledButton from '../StyledButton'; -import AccountList from '../AccountList'; import NetworkList from '../NetworkList'; import { renderFromWei, renderFiat } from '../../../util/number'; import { strings } from '../../../../locales/i18n'; @@ -30,7 +29,6 @@ import Modal from 'react-native-modal'; import SecureKeychain from '../../../core/SecureKeychain'; import { toggleNetworkModal, - toggleAccountsModal, toggleReceiveModal, } from '../../../actions/modals'; import { showAlert } from '../../../actions/alert'; @@ -351,10 +349,6 @@ class DrawerView extends PureComponent { * Action that toggles the network modal */ toggleNetworkModal: PropTypes.func, - /** - * Action that toggles the accounts modal - */ - toggleAccountsModal: PropTypes.func, /** * Action that toggles the receive modal */ @@ -375,10 +369,6 @@ class DrawerView extends PureComponent { * Start transaction with asset */ newAssetTransaction: PropTypes.func.isRequired, - /** - * Boolean that determines the status of the networks modal - */ - accountsModalVisible: PropTypes.bool.isRequired, /** * Boolean that determines if the user has set a password before */ @@ -468,7 +458,6 @@ class DrawerView extends PureComponent { previousBalance = null; processedNewBalance = false; animatingNetworksModal = false; - animatingAccountsModal = false; isCurrentAccountImported() { let ret = false; @@ -612,16 +601,17 @@ class DrawerView extends PureComponent { } }; - toggleAccountsModal = async () => { - if (!this.animatingAccountsModal) { - this.animatingAccountsModal = true; - this.props.toggleAccountsModal(); - setTimeout(() => { - this.animatingAccountsModal = false; - }, 500); - } - !this.props.accountsModalVisible && - this.trackEvent(ANALYTICS_EVENT_OPTS.NAVIGATION_TAPS_ACCOUNT_NAME); + openAccountSelector = () => { + const { navigation } = this.props; + navigation.navigate(Routes.MODAL.ROOT_MODAL_FLOW, { + screen: Routes.SHEET.ACCOUNT_SELECTOR, + params: { + onOpenImportAccount: this.hideDrawer, + onOpenConnectHardwareWallet: this.hideDrawer, + onSelectAccount: this.hideDrawer, + }, + }); + this.trackEvent(ANALYTICS_EVENT_OPTS.NAVIGATION_TAPS_ACCOUNT_NAME); }; toggleReceiveModal = () => { @@ -831,27 +821,8 @@ class DrawerView extends PureComponent { this.hideDrawer(); } - hideDrawer() { + hideDrawer = () => { this.props.onCloseDrawer(); - } - - onAccountChange = () => { - setTimeout(() => { - this.toggleAccountsModal(); - this.hideDrawer(); - }, 300); - }; - - onImportAccount = () => { - this.toggleAccountsModal(); - this.props.navigation.navigate('ImportPrivateKeyView'); - this.hideDrawer(); - }; - - onConnectHardware = () => { - this.toggleAccountsModal(); - this.props.navigation.navigate('ConnectQRHardwareFlow'); - this.hideDrawer(); }; hasBlockExplorer = (providerType) => { @@ -1160,7 +1131,6 @@ class DrawerView extends PureComponent { accounts, identities, selectedAddress, - keyrings, currentCurrency, ticker, seedphraseBackedUp, @@ -1234,7 +1204,7 @@ class DrawerView extends PureComponent { @@ -1243,7 +1213,7 @@ class DrawerView extends PureComponent { @@ -1416,28 +1386,6 @@ class DrawerView extends PureComponent { onClose={this.closeInvalidCustomNetworkAlert} /> - - - {this.renderOnboardingWizard()} ({ state.engine.backgroundState.CurrencyRateController.currentCurrency, keyrings: state.engine.backgroundState.KeyringController.keyrings, networkModalVisible: state.modals.networkModalVisible, - accountsModalVisible: state.modals.accountsModalVisible, receiveModalVisible: state.modals.receiveModalVisible, passwordSet: state.user.passwordSet, wizard: state.wizard, @@ -1493,7 +1440,6 @@ const mapStateToProps = (state) => ({ const mapDispatchToProps = (dispatch) => ({ toggleNetworkModal: () => dispatch(toggleNetworkModal()), - toggleAccountsModal: () => dispatch(toggleAccountsModal()), toggleReceiveModal: () => dispatch(toggleReceiveModal()), showAlert: (config) => dispatch(showAlert(config)), newAssetTransaction: (selectedAsset) => diff --git a/app/components/UI/FiatOnRampAggregator/components/AccountSelector.tsx b/app/components/UI/FiatOnRampAggregator/components/AccountSelector.tsx index 21c03a43a08..b5ae8f04990 100644 --- a/app/components/UI/FiatOnRampAggregator/components/AccountSelector.tsx +++ b/app/components/UI/FiatOnRampAggregator/components/AccountSelector.tsx @@ -1,13 +1,14 @@ -import React, { useCallback } from 'react'; +import React from 'react'; import { StyleSheet } from 'react-native'; -import { useDispatch, useSelector } from 'react-redux'; +import { useSelector } from 'react-redux'; -import { toggleAccountsModal } from '../../../../actions/modals'; import EthereumAddress from '../../EthereumAddress'; import JSIdenticon from '../../Identicon'; import BaseText from '../../../Base/Text'; import JSSelectorButton from '../../../Base/SelectorButton'; +import { useNavigation } from '@react-navigation/native'; +import Routes from '../../../../constants/navigation/Routes'; // TODO: Convert into typescript and correctly type const SelectorButton = JSSelectorButton as any; @@ -26,7 +27,7 @@ const styles = StyleSheet.create({ }); const AccountSelector = () => { - const dispatch = useDispatch(); + const navigation = useNavigation(); const selectedAddress = useSelector( (state: any) => state.engine.backgroundState.PreferencesController.selectedAddress, @@ -37,11 +38,13 @@ const AccountSelector = () => { state.engine.backgroundState.PreferencesController.identities, ); - const handleToggleAccountsModal = useCallback(() => { - dispatch(toggleAccountsModal()); - }, [dispatch]); + const openAccountSelector = () => + navigation.navigate(Routes.MODAL.ROOT_MODAL_FLOW, { + screen: Routes.SHEET.ACCOUNT_SELECTOR, + }); + return ( - + {identities[selectedAddress]?.name} ( diff --git a/app/components/UI/FiatOrders/components/AccountSelector.js b/app/components/UI/FiatOrders/components/AccountSelector.js index 2257c5ed348..e3500c1938d 100644 --- a/app/components/UI/FiatOrders/components/AccountSelector.js +++ b/app/components/UI/FiatOrders/components/AccountSelector.js @@ -2,12 +2,13 @@ import React from 'react'; import PropTypes from 'prop-types'; import { StyleSheet } from 'react-native'; import { connect } from 'react-redux'; +import { useNavigation } from '@react-navigation/native'; -import { toggleAccountsModal } from '../../../../actions/modals'; import EthereumAddress from '../../EthereumAddress'; import Identicon from '../../Identicon'; import Text from '../../../Base/Text'; import SelectorButton from '../../../Base/SelectorButton'; +import Routes from '../../../../constants/navigation/Routes'; const styles = StyleSheet.create({ selector: { @@ -19,22 +20,26 @@ const styles = StyleSheet.create({ marginHorizontal: 5, }, }); -const AccountSelector = ({ - toggleAccountsModal, - selectedAddress, - identities, -}) => ( - - - - {identities[selectedAddress]?.name} ( - ) - - -); +const AccountSelector = ({ selectedAddress, identities }) => { + const navigation = useNavigation(); + + const openAccountSelector = () => + navigation.navigate(Routes.MODAL.ROOT_MODAL_FLOW, { + screen: Routes.SHEET.ACCOUNT_SELECTOR, + }); + + return ( + + + + {identities[selectedAddress]?.name} ( + ) + + + ); +}; AccountSelector.propTypes = { - toggleAccountsModal: PropTypes.func.isRequired, selectedAddress: PropTypes.string.isRequired, identities: PropTypes.object.isRequired, }; @@ -45,7 +50,4 @@ const mapStateToProps = (state) => ({ identities: state.engine.backgroundState.PreferencesController.identities, }); -const mapDispatchToProps = (dispatch) => ({ - toggleAccountsModal: () => dispatch(toggleAccountsModal()), -}); -export default connect(mapStateToProps, mapDispatchToProps)(AccountSelector); +export default connect(mapStateToProps, null)(AccountSelector); diff --git a/app/components/Views/AccountSelector/AccountSelector.constants.ts b/app/components/Views/AccountSelector/AccountSelector.constants.ts new file mode 100644 index 00000000000..b41f8b4becb --- /dev/null +++ b/app/components/Views/AccountSelector/AccountSelector.constants.ts @@ -0,0 +1,5 @@ +/* eslint-disable import/prefer-default-export */ + +export const ACCOUNT_LIST_ID = 'account-list'; +export const CREATE_ACCOUNT_BUTTON_ID = 'create-account-button'; +export const IMPORT_ACCOUNT_BUTTON_ID = 'import-account-button'; diff --git a/app/components/Views/AccountSelector/AccountSelector.tsx b/app/components/Views/AccountSelector/AccountSelector.tsx new file mode 100644 index 00000000000..706bede0b6a --- /dev/null +++ b/app/components/Views/AccountSelector/AccountSelector.tsx @@ -0,0 +1,130 @@ +// Third party dependencies. +import React, { useCallback, useRef, useState } from 'react'; +import { useNavigation } from '@react-navigation/native'; + +// External dependencies. +import AccountSelectorList from '../../UI/AccountSelectorList'; +import SheetActions from '../../../component-library/components-temp/SheetActions'; +import SheetBottom, { + SheetBottomRef, +} from '../../../component-library/components/Sheet/SheetBottom'; +import SheetHeader from '../../../component-library/components/Sheet/SheetHeader'; +import UntypedEngine from '../../../core/Engine'; +import Logger from '../../../util/Logger'; +import AnalyticsV2 from '../../../util/analyticsV2'; +import { ANALYTICS_EVENT_OPTS } from '../../../util/analytics'; +import { strings } from '../../../../locales/i18n'; +import { + ACCOUNT_LIST_ID, + CREATE_ACCOUNT_BUTTON_ID, + IMPORT_ACCOUNT_BUTTON_ID, +} from './AccountSelector.constants'; + +// Internal dependencies. +import { AccountSelectorProps } from './AccountSelector.types'; + +const AccountSelector = ({ route }: AccountSelectorProps) => { + const { + onCreateNewAccount, + onOpenImportAccount, + onOpenConnectHardwareWallet, + onSelectAccount, + checkBalanceError, + isSelectOnly, + } = route.params || {}; + const Engine = UntypedEngine as any; + const [isLoading, setIsLoading] = useState(false); + const sheetRef = useRef(null); + const navigation = useNavigation(); + + const _onSelectAccount = (address: string) => { + sheetRef.current?.hide(); + onSelectAccount?.(address); + }; + + const createNewAccount = useCallback(async () => { + const { KeyringController } = Engine.context; + try { + setIsLoading(true); + await KeyringController.addNewAccount(); + AnalyticsV2.trackEvent(ANALYTICS_EVENT_OPTS.ACCOUNTS_ADDED_NEW_ACCOUNT); + } catch (e: any) { + Logger.error(e, 'error while trying to add a new account'); + } finally { + setIsLoading(false); + } + onCreateNewAccount?.(); + /* eslint-disable-next-line */ + }, [onCreateNewAccount, setIsLoading]); + + const openImportAccount = useCallback(() => { + sheetRef.current?.hide(() => { + navigation.navigate('ImportPrivateKeyView'); + // Is this where we want to track importing an account or within ImportPrivateKeyView screen? + AnalyticsV2.trackEvent( + ANALYTICS_EVENT_OPTS.ACCOUNTS_IMPORTED_NEW_ACCOUNT, + ); + }); + onOpenImportAccount?.(); + }, [onOpenImportAccount, navigation]); + + const openConnectHardwareWallet = useCallback(() => { + sheetRef.current?.hide(() => { + navigation.navigate('ConnectQRHardwareFlow'); + // Is this where we want to track connecting a hardware wallet or within ConnectQRHardwareFlow screen? + AnalyticsV2.trackEvent( + AnalyticsV2.ANALYTICS_EVENTS.CONNECT_HARDWARE_WALLET, + ); + }); + onOpenConnectHardwareWallet?.(); + }, [onOpenConnectHardwareWallet, navigation]); + + const renderSheetActions = useCallback( + () => + !isSelectOnly && ( + + ), + [ + isSelectOnly, + isLoading, + createNewAccount, + openImportAccount, + openConnectHardwareWallet, + ], + ); + + return ( + + + + {renderSheetActions()} + + ); +}; + +export default AccountSelector; diff --git a/app/components/Views/AccountSelector/AccountSelector.types.ts b/app/components/Views/AccountSelector/AccountSelector.types.ts new file mode 100644 index 00000000000..3e9758cd7a7 --- /dev/null +++ b/app/components/Views/AccountSelector/AccountSelector.types.ts @@ -0,0 +1,40 @@ +// External dependencies. +import { AccountSelectorListProps } from '../../../components/UI/AccountSelectorList'; + +/** + * AccountSelectorProps props. + */ +export interface AccountSelectorProps { + /** + * Props that are passed in while navigating to screen. + */ + route: { + params?: { + /** + * Optional callback that is called whenever a new account is being created. + */ + onCreateNewAccount?: () => void; + /** + * Optional callback that is called whenever import account is being opened. + */ + onOpenImportAccount?: () => void; + /** + * Optional callback that is called whenever connect hardware wallet is being opened. + */ + onOpenConnectHardwareWallet?: () => void; + /** + * Optional callback that is called whenever an account is selected. + */ + onSelectAccount?: (address: string) => void; + /** + * Optional boolean that indicates if the sheet is for selection only. Other account actions are disabled when this is true. + */ + isSelectOnly?: boolean; + /** + * Optional callback that is used to check for a balance requirement. Non-empty string will render the account item non-selectable. + * @param balance - The ticker balance of an account in wei and hex string format. + */ + checkBalanceError?: AccountSelectorListProps['checkBalanceError']; + }; + }; +} diff --git a/app/components/Views/AccountSelector/index.ts b/app/components/Views/AccountSelector/index.ts new file mode 100644 index 00000000000..ef2b9e5a0de --- /dev/null +++ b/app/components/Views/AccountSelector/index.ts @@ -0,0 +1 @@ +export { default } from './AccountSelector'; diff --git a/app/components/Views/SendFlow/Confirm/__snapshots__/index.test.tsx.snap b/app/components/Views/SendFlow/Confirm/__snapshots__/index.test.tsx.snap index 91d863c8289..f82e4403280 100644 --- a/app/components/Views/SendFlow/Confirm/__snapshots__/index.test.tsx.snap +++ b/app/components/Views/SendFlow/Confirm/__snapshots__/index.test.tsx.snap @@ -14,16 +14,6 @@ exports[`Confirm should render correctly 1`] = ` conversionRate={1} currentCurrency="USD" identities={Object {}} - keyrings={ - Array [ - Object { - "accounts": Array [ - "0x", - ], - "type": "HD Key Tree", - }, - ] - } network="1" networkType="mainnet" prepareTransaction={[Function]} diff --git a/app/components/Views/SendFlow/Confirm/index.js b/app/components/Views/SendFlow/Confirm/index.js index bada08f1072..2f6961f787d 100644 --- a/app/components/Views/SendFlow/Confirm/index.js +++ b/app/components/Views/SendFlow/Confirm/index.js @@ -46,7 +46,6 @@ import { import { getGasLimit } from '../../../../util/custom-gas'; import Engine from '../../../../core/Engine'; import Logger from '../../../../util/Logger'; -import AccountList from '../../../UI/AccountList'; import CustomNonceModal from '../../../UI/CustomNonceModal'; import { doENSReverseLookup } from '../../../../util/ENSUtils'; import NotificationManager from '../../../../core/NotificationManager'; @@ -324,10 +323,6 @@ class Confirm extends PureComponent { * List of accounts from the PreferencesController */ identities: PropTypes.object, - /** - * List of keyrings - */ - keyrings: PropTypes.array, /** * Selected asset from current transaction state */ @@ -387,7 +382,6 @@ class Confirm extends PureComponent { transactionTotalAmount: undefined, transactionTotalAmountFiat: undefined, errorMessage: undefined, - fromAccountModalVisible: false, warningModalVisible: false, mode: REVIEW, gasSelected: AppConstants.GAS_OPTIONS.MEDIUM, @@ -1030,13 +1024,11 @@ class Confirm extends PureComponent { return balanceIsInsufficient ? strings('transaction.insufficient') : null; }; - onAccountChange = async (accountAddress) => { + onSelectAccount = async (accountAddress) => { const { identities, accounts } = this.props; const { name } = identities[accountAddress]; - const { PreferencesController } = Engine.context; const ens = await doENSReverseLookup(accountAddress); const fromAccountName = ens || name; - PreferencesController.setSelectedAddress(accountAddress); // If new account doesn't have the asset this.setState({ fromAccountName, @@ -1044,7 +1036,18 @@ class Confirm extends PureComponent { balanceIsZero: hexToBN(accounts[accountAddress].balance).isZero(), }); this.parseTransactionDataHeader(); - this.toggleFromAccountModal(); + }; + + openAccountSelector = () => { + const { navigation } = this.props; + navigation.navigate(Routes.MODAL.ROOT_MODAL_FLOW, { + screen: Routes.SHEET.ACCOUNT_SELECTOR, + params: { + isSelectOnly: true, + onSelectAccount: this.onSelectAccount, + checkBalanceError: this.getBalanceError, + }, + }); }; toggleHexDataModal = () => { @@ -1052,11 +1055,6 @@ class Confirm extends PureComponent { this.setState({ hexDataModalVisible: !hexDataModalVisible }); }; - toggleFromAccountModal = () => { - const { fromAccountModalVisible } = this.state; - this.setState({ fromAccountModalVisible: !fromAccountModalVisible }); - }; - cancelGasEdition = () => { this.setState({ EIP1559TransactionDataTemp: { ...this.state.EIP1559TransactionData }, @@ -1286,37 +1284,6 @@ class Confirm extends PureComponent { ); }; - renderFromAccountModal = () => { - const { identities, keyrings, ticker } = this.props; - const { fromAccountModalVisible, fromSelectedAddress } = this.state; - const colors = this.context.colors || mockTheme.colors; - const styles = createStyles(colors); - - return ( - - - - ); - }; - buyEth = () => { const { navigation } = this.props; try { @@ -1464,7 +1431,7 @@ class Confirm extends PureComponent { > - {this.renderFromAccountModal()} {mode === EDIT && this.renderCustomGasModalLegacy()} {mode === EDIT_NONCE && this.renderCustomNonceModal()} {mode === EDIT_EIP1559 && this.renderCustomGasModalEIP1559()} @@ -1666,7 +1632,6 @@ const mapStateToProps = (state) => ({ showCustomNonce: state.settings.showCustomNonce, chainId: state.engine.backgroundState.NetworkController.provider.chainId, ticker: state.engine.backgroundState.NetworkController.provider.ticker, - keyrings: state.engine.backgroundState.KeyringController.keyrings, transaction: getNormalizedTxState(state), selectedAsset: state.transaction.selectedAsset, transactionState: state.transaction, diff --git a/app/components/Views/SendFlow/SendTo/__snapshots__/index.test.tsx.snap b/app/components/Views/SendFlow/SendTo/__snapshots__/index.test.tsx.snap index 01d047181fd..2d985d0e51f 100644 --- a/app/components/Views/SendFlow/SendTo/__snapshots__/index.test.tsx.snap +++ b/app/components/Views/SendFlow/SendTo/__snapshots__/index.test.tsx.snap @@ -29,16 +29,6 @@ exports[`SendTo should render correctly 1`] = ` }, } } - keyrings={ - Array [ - Object { - "accounts": Array [ - "0x", - ], - "type": "HD Key Tree", - }, - ] - } network="1" newAssetTransaction={[Function]} providerType="mainnet" diff --git a/app/components/Views/SendFlow/SendTo/index.js b/app/components/Views/SendFlow/SendTo/index.js index 86d6c118cfd..b8663c8f314 100644 --- a/app/components/Views/SendFlow/SendTo/index.js +++ b/app/components/Views/SendFlow/SendTo/index.js @@ -12,8 +12,6 @@ import { ScrollView, } from 'react-native'; import { AddressFrom, AddressTo } from './../AddressInputs'; -import Modal from 'react-native-modal'; -import AccountList from '../../../UI/AccountList'; import { connect } from 'react-redux'; import { renderFromWei } from '../../../../util/number'; import ActionModal from '../../../UI/ActionModal'; @@ -218,10 +216,6 @@ class SendFlow extends PureComponent { * List of accounts from the PreferencesController */ identities: PropTypes.object, - /** - * List of keyrings - */ - keyrings: PropTypes.array, /** * Current provider ticker */ @@ -257,7 +251,6 @@ class SendFlow extends PureComponent { state = { addressError: undefined, balanceIsZero: false, - fromAccountModalVisible: false, addToAddressBookModalVisible: false, fromSelectedAddress: this.props.selectedAddress, fromAccountName: this.props.identities[this.props.selectedAddress].name, @@ -331,11 +324,6 @@ class SendFlow extends PureComponent { this.updateNavBar(); }; - toggleFromAccountModal = () => { - const { fromAccountModalVisible } = this.state; - this.setState({ fromAccountModalVisible: !fromAccountModalVisible }); - }; - toggleAddToAddressBookModal = () => { const { addToAddressBookModalVisible } = this.state; this.setState({ @@ -343,16 +331,14 @@ class SendFlow extends PureComponent { }); }; - onAccountChange = async (accountAddress) => { - const { identities, ticker, accounts } = this.props; + onSelectAccount = async (accountAddress) => { + const { ticker, accounts, identities } = this.props; const { name } = identities[accountAddress]; - const { PreferencesController } = Engine.context; const fromAccountBalance = `${renderFromWei( accounts[accountAddress].balance, )} ${getTicker(ticker)}`; const ens = await doENSReverseLookup(accountAddress); const fromAccountName = ens || name; - PreferencesController.setSelectedAddress(accountAddress); // If new account doesn't have the asset this.props.setSelectedAsset(getEther(ticker)); this.setState({ @@ -361,8 +347,19 @@ class SendFlow extends PureComponent { fromSelectedAddress: accountAddress, balanceIsZero: hexToBN(accounts[accountAddress].balance).isZero(), }); - this.toggleFromAccountModal(); }; + + openAccountSelector = () => { + const { navigation } = this.props; + navigation.navigate(Routes.MODAL.ROOT_MODAL_FLOW, { + screen: Routes.SHEET.ACCOUNT_SELECTOR, + params: { + isSelectOnly: true, + onSelectAccount: this.onSelectAccount, + }, + }); + }; + /** * This returns the address name from the address book or user accounts if the selectedAddress exist there * @param {String} toAccount - Address input @@ -673,36 +670,6 @@ class SendFlow extends PureComponent { ); }; - renderFromAccountModal = () => { - const { identities, keyrings, ticker } = this.props; - const { fromAccountModalVisible, fromSelectedAddress } = this.state; - const colors = this.context.colors || mockTheme.colors; - const styles = createStyles(colors); - - return ( - - - - ); - }; - onToInputFocus = () => { const { toInputHighlighted } = this.state; this.setState({ toInputHighlighted: !toInputHighlighted }); @@ -784,7 +751,7 @@ class SendFlow extends PureComponent { > )} - - {this.renderFromAccountModal()} {this.renderAddToAddressBookModal()} ); @@ -937,7 +902,6 @@ const mapStateToProps = (state) => ({ state.engine.backgroundState.PreferencesController.selectedAddress, selectedAsset: state.transaction.selectedAsset, identities: state.engine.backgroundState.PreferencesController.identities, - keyrings: state.engine.backgroundState.KeyringController.keyrings, ticker: state.engine.backgroundState.NetworkController.provider.ticker, network: state.engine.backgroundState.NetworkController.network, providerType: state.engine.backgroundState.NetworkController.provider.type, diff --git a/app/constants/navigation/Routes.ts b/app/constants/navigation/Routes.ts index 03d27d4f5ba..261f12fbf5f 100644 --- a/app/constants/navigation/Routes.ts +++ b/app/constants/navigation/Routes.ts @@ -28,6 +28,9 @@ const Routes = { LOGIN: 'Login', NAV: 'OnboardingNav', }, + SHEET: { + ACCOUNT_SELECTOR: 'AccountSelector', + }, }; export default Routes; diff --git a/app/reducers/modals/index.js b/app/reducers/modals/index.js index bae979c7904..1f2d416d1d0 100644 --- a/app/reducers/modals/index.js +++ b/app/reducers/modals/index.js @@ -1,6 +1,5 @@ const initialState = { networkModalVisible: false, - accountsModalVisible: false, collectibleContractModalVisible: false, receiveModalVisible: false, receiveAsset: undefined, @@ -22,11 +21,6 @@ const modalsReducer = (state = initialState, action) => { receiveAsset: action.asset, }; } - case 'TOGGLE_ACCOUNT_MODAL': - return { - ...state, - accountsModalVisible: !state.accountsModalVisible, - }; case 'TOGGLE_COLLECTIBLE_CONTRACT_MODAL': return { ...state, diff --git a/app/util/ENSUtils.js b/app/util/ENSUtils.js index 47030c42f43..7da3a063b02 100644 --- a/app/util/ENSUtils.js +++ b/app/util/ENSUtils.js @@ -7,6 +7,10 @@ import { ethers } from 'ethers'; import { toLowerCaseEquals } from '../util/general'; import { getEthersNetworkTypeById } from './networks'; import Logger from './Logger'; +const ENS_NAME_NOT_DEFINED_ERROR = 'ENS name not defined'; +const INVALID_ENS_NAME_ERROR = 'invalid ENS name'; +// One hour cache threshold. +const CACHE_REFRESH_THRESHOLD = 60 * 60 * 1000; /** * Utility class with the single responsibility @@ -29,22 +33,31 @@ export function getEnsProvider(network, provider) { } export async function doENSReverseLookup(address, network) { - const cache = ENSCache.cache[network + address]; const { provider } = Engine.context.NetworkController; - if (cache) { - return Promise.resolve(cache); + const { name: cachedName, timestamp } = + ENSCache.cache[network + address] || {}; + const nowTimestamp = Date.now(); + if (timestamp && nowTimestamp - timestamp < CACHE_REFRESH_THRESHOLD) { + return Promise.resolve(cachedName); } - const ensProvider = await getEnsProvider(network, provider); - if (ensProvider) { - try { + + try { + const ensProvider = await getEnsProvider(network, provider); + if (ensProvider) { const name = await ensProvider.lookupAddress(address); const resolvedAddress = await ensProvider.resolveName(name); if (toLowerCaseEquals(address, resolvedAddress)) { - ENSCache.cache[network + address] = name; + ENSCache.cache[network + address] = { name, timestamp: Date.now() }; return name; } - // eslint-disable-next-line no-empty - } catch (e) {} + } + } catch (e) { + if ( + e.message.includes(ENS_NAME_NOT_DEFINED_ERROR) || + e.message.includes(INVALID_ENS_NAME_ERROR) + ) { + ENSCache.cache[network + address] = { timestamp: Date.now() }; + } } } diff --git a/app/util/analyticsV2.js b/app/util/analyticsV2.js index c1f4887ddcc..b20cd3a48de 100644 --- a/app/util/analyticsV2.js +++ b/app/util/analyticsV2.js @@ -186,7 +186,7 @@ export const ANALYTICS_EVENTS_V2 = { * @param {Object} eventName * @param {Object} params */ -export const trackEventV2 = (eventName, params) => { +export const trackEventV2 = (eventName, params = undefined) => { InteractionManager.runAfterInteractions(() => { let anonymousEvent = false; try { diff --git a/locales/languages/en.json b/locales/languages/en.json index c5653996282..4a27b3aeb6d 100644 --- a/locales/languages/en.json +++ b/locales/languages/en.json @@ -437,14 +437,17 @@ "receive": "RECEIVE" }, "accounts": { - "create_new_account": "Create New Account", - "import_account": "Import an Account", - "connect_hardware": "Connect Hardware Wallet", - "imported": "IMPORTED", + "create_new_account": "Create a new account", + "import_account": "Import an account", + "connect_hardware": "Connect hardware wallet", + "imported": "Imported", "remove_account_title": "Account removal", "remove_account_message": "Do you really want to remove this account?", "no": "No", - "yes_remove_it": "Yes, remove it" + "yes_remove_it": "Yes, remove it", + "account_selector": { + "title": "Accounts" + } }, "connect_qr_hardware": { "title": "Connect a QR-based hardware wallet", diff --git a/package.json b/package.json index bdbf3f6c008..733703f1462 100644 --- a/package.json +++ b/package.json @@ -135,6 +135,7 @@ "@sentry/integrations": "6.3.1", "@sentry/react-native": "2.4.2", "@tradle/react-native-http": "2.0.1", + "@types/lodash": "^4.14.184", "@walletconnect/client": "^1.7.1", "@walletconnect/utils": "^1.7.1", "asyncstorage-down": "4.2.0", @@ -169,6 +170,7 @@ "is-url": "^1.2.4", "json-rpc-engine": "^6.1.0", "json-rpc-middleware-stream": "3.0.0", + "lodash": "^4.17.21", "lottie-react-native": "git+https://github.com/MetaMask/lottie-react-native.git#7ce6a78ac4ac7b9891bc513cb3f12f8b9c9d9106", "metro-config": "^0.71.1", "multihashes": "0.4.14", diff --git a/yarn.lock b/yarn.lock index 8356a4715eb..803e5c129fc 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3967,6 +3967,11 @@ dependencies: "@types/node" "*" +"@types/lodash@^4.14.184": + version "4.14.184" + resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.184.tgz#23f96cd2a21a28e106dc24d825d4aa966de7a9fe" + integrity sha512-RoZphVtHbxPZizt4IcILciSWiC6dcn+eZ8oX9IWEYfDMcocdd42f7NPI6fQj+6zI8y4E0L7gu2pcZKLGTRaV9Q== + "@types/node@*": version "16.0.1" resolved "https://registry.yarnpkg.com/@types/node/-/node-16.0.1.tgz#70cedfda26af7a2ca073fdcc9beb2fff4aa693f8" @@ -11736,7 +11741,7 @@ lodash-es@^4.17.15: lodash.debounce@^4.0.8: version "4.0.8" resolved "https://registry.yarnpkg.com/lodash.debounce/-/lodash.debounce-4.0.8.tgz#82d79bff30a67c4005ffd5e2515300ad9ca4d7af" - integrity sha1-gteb/zCmfEAF/9XiUVMArZyk168= + integrity sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow== lodash.escape@^4.0.1: version "4.0.1" @@ -11751,7 +11756,7 @@ lodash.flattendeep@^4.4.0: lodash.isequal@^4.5.0: version "4.5.0" resolved "https://registry.yarnpkg.com/lodash.isequal/-/lodash.isequal-4.5.0.tgz#415c4478f2bcc30120c22ce10ed3226f7d3e18e0" - integrity sha1-QVxEePK8wwEgwizhDtMib30+GOA= + integrity sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ== lodash.isplainobject@^4.0.6: version "4.0.6"