diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 7bab43acd..2b96ff0fb 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -696,7 +696,7 @@ SPEC CHECKSUMS: EXSecureStore: 1b571851e6068b30b8ec097be848a04603c03bae EXSplashScreen: ab5984afcca91e0af6b3138f01a8c47dc4955c51 FBLazyVector: 49cbe4b43e445b06bf29199b6ad2057649e4c8f5 - FBReactNativeSpec: 3560256311b67bbddc2ce503625e9edde9a6ee1d + FBReactNativeSpec: ef7076047ecfe23933320b0fb2b844474516e2e8 glog: 7a8788d88d2a384c05df45e95c5e763e4d81f3ad lottie-ios: 48fac6be217c76937e36e340e2d09cf7b10b7f5f lottie-react-native: 4dff8fe8d10ddef9e7880e770080f4a56121397e diff --git a/package.json b/package.json index 8fbc12e38..858e6feec 100644 --- a/package.json +++ b/package.json @@ -122,6 +122,7 @@ "react-qr-code": "^1.0.3", "react-redux": "^7.2.2", "redux-thunk": "^2.3.0", + "rn-swipe-button": "^1.3.6", "tinycolor2": "^1.4.2", "use-debounce": "^5.2.0", "use-state-with-callback": "^2.0.3" diff --git a/src/assets/images/deployModeIcon.svg b/src/assets/images/deployModeIcon.svg new file mode 100644 index 000000000..50e7c4992 --- /dev/null +++ b/src/assets/images/deployModeIcon.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/assets/images/lockIconRed.svg b/src/assets/images/lockIconRed.svg new file mode 100644 index 000000000..697ce934a --- /dev/null +++ b/src/assets/images/lockIconRed.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/components/Bullet.tsx b/src/components/Bullet.tsx index 2ba5124f9..fecf04a3b 100644 --- a/src/components/Bullet.tsx +++ b/src/components/Bullet.tsx @@ -1,10 +1,20 @@ import React, { ReactNode } from 'react' +import { ViewStyle } from 'react-native' import Box from './Box' import Text from './Text' -type Props = { children?: ReactNode; color?: string } -const Bullet = ({ children, color = 'black' }: Props) => ( - +type Props = { + children?: ReactNode + color?: string + style?: ViewStyle +} +const Bullet = ({ children, color = 'black', style = {} }: Props) => ( + diff --git a/src/components/HeliumActionSheet.tsx b/src/components/HeliumActionSheet.tsx index ec92e9093..48f388188 100644 --- a/src/components/HeliumActionSheet.tsx +++ b/src/components/HeliumActionSheet.tsx @@ -1,17 +1,11 @@ /* eslint-disable react/jsx-props-no-spreading */ import React, { memo, useCallback, useEffect, useMemo, useState } from 'react' import { BoxProps } from '@shopify/restyle' -import Close from '@assets/images/close.svg' import CarotDown from '@assets/images/carot-down.svg' import Kabob from '@assets/images/kabob.svg' import { useTranslation } from 'react-i18next' -import { Modal, StyleSheet } from 'react-native' +import { StyleSheet } from 'react-native' import { useSafeAreaInsets } from 'react-native-safe-area-context' -import { - useAnimatedStyle, - useSharedValue, - withSpring, -} from 'react-native-reanimated' import { FlatList } from 'react-native-gesture-handler' import { Colors, Theme } from '../theme/theme' import HeliumActionSheetItem, { @@ -22,9 +16,7 @@ import { useColors } from '../theme/themeHooks' import Text, { TextProps } from './Text' import Box from './Box' import TouchableOpacityBox from './TouchableOpacityBox' -import BlurBox from './BlurBox' -import { ReAnimatedBox } from './AnimatedBox' -import useVisible from '../utils/useVisible' +import HeliumBottomSheet from './HeliumBottomSheet' type Props = BoxProps & { data: Array @@ -66,31 +58,11 @@ const HeliumActionSheet = ({ const [data, setData] = useState>([]) const { t } = useTranslation() const colors = useColors() - const offset = useSharedValue(0) useEffect(() => { setData(propsData) }, [propsData]) - const animatedStyles = useAnimatedStyle(() => { - return { - transform: [{ translateY: offset.value + sheetHeight }], - } - }) - - const animate = useCallback( - (val: number) => { - offset.value = withSpring(val, { - damping: 80, - overshootClamping: true, - restDisplacementThreshold: 0.1, - restSpeedThreshold: 0.1, - stiffness: 500, - }) - }, - [offset], - ) - useEffect(() => { let nextSheetHeight = data.length * HeliumActionSheetItemHeight + 156 + (insets?.bottom || 0) @@ -98,8 +70,7 @@ const HeliumActionSheet = ({ nextSheetHeight = maxModalHeight } setSheetHeight(nextSheetHeight) - animate(nextSheetHeight) - }, [animate, data.length, insets?.bottom, maxModalHeight]) + }, [data.length, insets?.bottom, maxModalHeight]) const handlePresentModalPress = useCallback(async () => { setModalVisible(true) @@ -109,15 +80,6 @@ const HeliumActionSheet = ({ setModalVisible(false) }, []) - useVisible({ onDisappear: handleClose }) - - useEffect(() => { - if (modalVisible) { - offset.value = 0 - animate(-sheetHeight) - } - }, [animate, modalVisible, offset, sheetHeight]) - const keyExtractor = useCallback((item) => item.value, []) const buttonTitle = useMemo(() => { @@ -247,55 +209,19 @@ const HeliumActionSheet = ({ return ( {displayText} - - - - - - - - - {title} - - - - - - - {footer} - - - + + {footer} + ) } diff --git a/src/components/HeliumBottomSheet.tsx b/src/components/HeliumBottomSheet.tsx new file mode 100644 index 000000000..c02353a04 --- /dev/null +++ b/src/components/HeliumBottomSheet.tsx @@ -0,0 +1,122 @@ +import React, { memo, useCallback, useEffect } from 'react' +import { BoxProps } from '@shopify/restyle' +import Close from '@assets/images/close.svg' +import { Modal, StyleSheet } from 'react-native' +import { + useAnimatedStyle, + useSharedValue, + withSpring, +} from 'react-native-reanimated' +import { Theme } from '../theme/theme' +import { useColors } from '../theme/themeHooks' +import Text from './Text' +import Box from './Box' +import TouchableOpacityBox from './TouchableOpacityBox' +import BlurBox from './BlurBox' +import { ReAnimatedBox } from './AnimatedBox' +import useVisible from '../utils/useVisible' + +type Props = BoxProps & { + children?: React.ReactNode + hideHeaderBorder?: boolean + isVisible: boolean + onClose: () => void + sheetHeight?: number + title?: string +} + +const HeliumBottomSheet = ({ + children, + hideHeaderBorder = false, + isVisible, + onClose, + sheetHeight = 260, + title, +}: Props) => { + const colors = useColors() + const offset = useSharedValue(0) + + const animatedStyles = useAnimatedStyle(() => { + return { + transform: [{ translateY: offset.value + sheetHeight }], + } + }) + + const animate = useCallback( + (val: number) => { + offset.value = withSpring(val, { + damping: 80, + overshootClamping: true, + restDisplacementThreshold: 0.1, + restSpeedThreshold: 0.1, + stiffness: 500, + }) + }, + [offset], + ) + + const handleClose = useCallback(async () => { + onClose() + }, [onClose]) + + useVisible({ onDisappear: handleClose }) + + useEffect(() => { + if (isVisible) { + offset.value = 0 + animate(-sheetHeight) + } + }, [animate, isVisible, offset, sheetHeight]) + + return ( + + + + + + + + {title} + + + + + + {children} + + + + ) +} + +const styles = StyleSheet.create({ + divider: { borderBottomColor: '#F0F0F5' }, +}) + +export default memo(HeliumBottomSheet) diff --git a/src/components/InputField.tsx b/src/components/InputField.tsx index 2abd3ea8a..ea79a27b4 100644 --- a/src/components/InputField.tsx +++ b/src/components/InputField.tsx @@ -12,7 +12,7 @@ import InputLock from '../assets/images/input-lock.svg' // import TextInput from '../../../components/TextInput' type Props = { - label: string + label?: string placeholder?: string extra?: ReactElement footer?: ReactElement @@ -26,6 +26,7 @@ type Props = { isLast?: boolean isFirst?: boolean testID?: string + optional?: boolean } const InputField = ({ @@ -42,6 +43,7 @@ const InputField = ({ numberOfLines, isLast = false, isFirst = false, + optional = false, testID, }: Props) => { const inputRef = useRef(null) @@ -50,61 +52,88 @@ const InputField = ({ inputRef.current?.focus() } - return ( - + let headerContent + if (label || extra) { + headerContent = ( + + {label ? label.toUpperCase() : null} + {locked && } + + {extra !== undefined && extra} + + ) + } + + let optionalLabel + if (optional) { + optionalLabel = ( + + Optional + + ) + } + + return ( + <> + {optionalLabel} + - - {label.toUpperCase()} - {locked && } - - {extra !== undefined && extra} + {headerContent} + + + + {footer !== undefined && footer} - - - - {footer !== undefined && footer} - - + + ) } diff --git a/src/components/LockedHeader.tsx b/src/components/LockedHeader.tsx index 82ba54334..450ff107a 100644 --- a/src/components/LockedHeader.tsx +++ b/src/components/LockedHeader.tsx @@ -1,21 +1,26 @@ import React from 'react' -import { useTranslation } from 'react-i18next' import Box from './Box' +import { Colors } from '../theme/theme' import Close from '../assets/images/close.svg' import TouchableOpacityBox from './TouchableOpacityBox' import Text from './Text' type Props = { + backgroundColor: Colors onClosePress: () => void allowClose?: boolean + text: string } -const SendLockedHeader = ({ onClosePress, allowClose = true }: Props) => { - const { t } = useTranslation() - +const SendLockedHeader = ({ + backgroundColor, + onClosePress, + allowClose = true, + text, +}: Props) => { return ( { fontSize={14} letterSpacing={0.85} > - {t('send.qrInfo')} + {text} {allowClose && ( void + onSwipeSuccessDelay?: number + title?: string +} + +function SwipeButton({ + disabled = false, + disabledColor = '#F59CA2', + enabledColor = '#FF4949', + onSwipeSuccess = () => {}, + onSwipeSuccessDelay, + thumbColor = '#FFFFFF', + textColor = '#FFFFFF', + title, +}: SwipeButtonProps) { + const { t } = useTranslation() + const [isLoading, setIsLoading] = useState(false) + const containerStyle = { + ...styles.container, + backgroundColor: disabled ? disabledColor : enabledColor, + } + const textStyle = { ...styles.text, color: textColor } + + const onSuccess = () => { + if (onSwipeSuccessDelay) { + setIsLoading(true) + setTimeout(onSwipeSuccess, onSwipeSuccessDelay) + } else { + onSwipeSuccess() + } + } + return ( + + + + ) +} + +const styles = StyleSheet.create({ + container: { + padding: 5, + borderRadius: 10, + }, + text: { + fontFamily: Font.main.regular, + fontWeight: '500', + fontSize: 16, + }, +}) + +export default SwipeButton diff --git a/src/features/hotspots/root/HotspotsList.tsx b/src/features/hotspots/root/HotspotsList.tsx index 42b0d6c60..3e8060dc1 100644 --- a/src/features/hotspots/root/HotspotsList.tsx +++ b/src/features/hotspots/root/HotspotsList.tsx @@ -5,6 +5,7 @@ import { useSelector } from 'react-redux' import { useTranslation } from 'react-i18next' import Search from '@assets/images/search.svg' import Add from '@assets/images/add.svg' +import LockIcon from '@assets/images/lockIconRed.svg' import { useSafeAreaInsets } from 'react-native-safe-area-context' import { orderBy, sortBy, uniq } from 'lodash' import { useAsync } from 'react-async-hook' @@ -89,6 +90,10 @@ const HotspotsList = ({ ) const prevOrder = usePrevious(gatewaySortOrder) + const isDeployModeEnabled = useSelector( + (state: RootState) => state.app.isDeployModeEnabled, + ) + const locationDeniedHandler = useCallback(() => { setGatewaySortOrder(GatewaySort.New) }, []) @@ -369,6 +374,11 @@ const HotspotsList = ({ + {isDeployModeEnabled && ( + + + + )} diff --git a/src/features/hotspots/settings/HotspotSettings.tsx b/src/features/hotspots/settings/HotspotSettings.tsx index fdb9ad15f..4074ecf58 100644 --- a/src/features/hotspots/settings/HotspotSettings.tsx +++ b/src/features/hotspots/settings/HotspotSettings.tsx @@ -93,7 +93,10 @@ const HotspotSettings = ({ hotspot }: Props) => { const { permissionResponse, locationBlocked } = useSelector( (state: RootState) => state.location, ) - const { showOKCancelAlert } = useAlert() + const isDeployModeEnabled = useSelector( + (state: RootState) => state.app.isDeployModeEnabled, + ) + const { showOKAlert, showOKCancelAlert } = useAlert() const dataOnly = useMemo(() => isDataOnly(hotspot), [hotspot]) @@ -218,15 +221,22 @@ const HotspotSettings = ({ hotspot }: Props) => { }, ], ) + } else if (isDeployModeEnabled) { + showOKAlert({ + titleKey: 'transfer.deployModeTransferDisableTitle', + messageKey: 'transfer.deployModeTransferDisabled', + }) } else { setNextState('transfer') } }, [ + isDeployModeEnabled, activeTransfer?.buyer, activeTransfer?.gateway, cancelTransfer, hasActiveTransfer, setNextState, + showOKAlert, t, ]) diff --git a/src/features/moreTab/more/DeployModeModal.tsx b/src/features/moreTab/more/DeployModeModal.tsx new file mode 100644 index 000000000..62107c9b9 --- /dev/null +++ b/src/features/moreTab/more/DeployModeModal.tsx @@ -0,0 +1,129 @@ +import React, { useCallback, useState, useEffect } from 'react' +import { BoxProps } from '@shopify/restyle' +import { Address } from '@helium/crypto-react-native' +import { KeyboardAvoidingView, Platform, StyleSheet } from 'react-native' +import { useTranslation } from 'react-i18next' +import { useSafeAreaInsets } from 'react-native-safe-area-context' +import DeployModeIcon from '@assets/images/deployModeIcon.svg' +import { Theme } from '../../../theme/theme' +import appSlice from '../../../store/user/appSlice' +import { useAppDispatch } from '../../../store/store' +import Text from '../../../components/Text' +import Box from '../../../components/Box' +import Bullet from '../../../components/Bullet' +import HeliumBottomSheet from '../../../components/HeliumBottomSheet' +import InputField from '../../../components/InputField' +import { useColors } from '../../../theme/themeHooks' +import SwipeButton from '../../../components/SwipeButton' + +type Props = BoxProps & { + isVisible: boolean + onClose?: () => void +} + +const DeployModeModal = ({ isVisible, onClose = () => {} }: Props) => { + const { t } = useTranslation() + const dispatch = useAppDispatch() + const insets = useSafeAreaInsets() + const colors = useColors() + const [sendAddress, setSendAddress] = useState('') + + const sheetHeight = 700 + (insets?.bottom || 0) + const enableDeployMode = useCallback(() => { + dispatch(appSlice.actions.enableDeployMode(true)) + if (sendAddress) { + dispatch(appSlice.actions.setPermanentPaymentAddress(sendAddress)) + } + onClose() + }, [dispatch, sendAddress, onClose]) + + useEffect(() => { + if (!isVisible) setSendAddress('') + }, [isVisible]) + + // Only disable "Submit" if an address is provided but is invalid + const isValid = sendAddress ? Address.isValid(sendAddress) : true + return ( + + + + + {t('more.sections.security.deployMode.title')} + + + {t('more.sections.security.deployMode.subtitle')} + + + {t('more.sections.security.deployMode.inDeployMode')} + + + + + {t('more.sections.security.deployMode.cantViewWords')} + + + + + {t('more.sections.security.deployMode.cantTransferHotspots')} + + + + + {t('more.sections.security.deployMode.canOnlySendFunds')}{' '} + + {t('generic.one')} + {' '} + {t('more.sections.security.deployMode.otherAccount')} + + + + + {t('more.sections.security.deployMode.disableInstructions')} + + + + + + + + ) +} + +const styles = StyleSheet.create({ + bullet: { marginBottom: 0 }, + cancelContainer: { backgroundColor: '#F0F0F5' }, + cancelText: { color: '#B3B4D6' }, + confirmText: { color: '#FFFFFF' }, +}) + +export default DeployModeModal diff --git a/src/features/moreTab/more/MoreListItem.tsx b/src/features/moreTab/more/MoreListItem.tsx index aadbe2ca0..654ee4f3f 100644 --- a/src/features/moreTab/more/MoreListItem.tsx +++ b/src/features/moreTab/more/MoreListItem.tsx @@ -7,6 +7,7 @@ import CarotRight from '../../../assets/images/carot-right.svg' import LinkImg from '../../../assets/images/link.svg' import HeliumActionSheet from '../../../components/HeliumActionSheet' import { HeliumActionSheetItemType } from '../../../components/HeliumActionSheetItem' +import { Colors } from '../../../theme/theme' export type SelectProps = { onDonePress?: () => void @@ -19,13 +20,25 @@ export type MoreListItemType = { destructive?: boolean onPress?: () => void onToggle?: (value: boolean) => void + renderModal?: () => void value?: boolean | string | number select?: SelectProps openUrl?: string + disabled?: boolean } const MoreListItem = ({ - item: { title, value, destructive, onToggle, onPress, select, openUrl }, + item: { + title, + value, + destructive, + onToggle, + onPress, + renderModal, + select, + openUrl, + disabled, + }, isTop = false, isBottom = false, }: { @@ -60,6 +73,11 @@ const MoreListItem = ({ [], ) + let textColor: Colors = 'primaryText' + if (destructive && !disabled) textColor = 'redMain' + if (destructive && disabled) textColor = 'redMedium' + if (!destructive && disabled) textColor = 'disabled' + return ( - + {renderModal && renderModal()} + {title} {!onToggle && !select && onPress && ( @@ -89,6 +108,7 @@ const MoreListItem = ({ onValueChange={onToggle} trackColor={trackColor} thumbColor={colors.white} + disabled={disabled} /> )} {select && ( diff --git a/src/features/moreTab/more/MoreScreen.tsx b/src/features/moreTab/more/MoreScreen.tsx index 7a4ca1b93..5499a52b0 100644 --- a/src/features/moreTab/more/MoreScreen.tsx +++ b/src/features/moreTab/more/MoreScreen.tsx @@ -1,4 +1,11 @@ -import React, { memo, ReactText, useCallback, useEffect, useMemo } from 'react' +import React, { + memo, + ReactText, + useCallback, + useEffect, + useMemo, + useState, +} from 'react' import { useTranslation } from 'react-i18next' import { Alert, SectionList } from 'react-native' import { useSelector } from 'react-redux' @@ -31,6 +38,7 @@ import Account from '../../../assets/images/account.svg' import Box from '../../../components/Box' import DiscordItem from './DiscordItem' import AppInfoItem from './AppInfoItem' +import DeployModeModal from './DeployModeModal' import activitySlice from '../../../store/activity/activitySlice' import hotspotsSlice from '../../../store/hotspots/hotspotsSlice' import { useLanguageContext } from '../../../providers/LanguageProvider' @@ -58,6 +66,10 @@ const MoreScreen = () => { const { changeLanguage, language } = useLanguageContext() const navigation = useNavigation() const spacing = useSpacing() + const [ + showingDeployModeConfirmation, + setShowingDeployModeConfirmation, + ] = useState(false) useEffect( () => @@ -259,6 +271,21 @@ const MoreScreen = () => { { title: t('more.sections.security.revealWords'), onPress: handleRevealWords, + disabled: app.isDeployModeEnabled, + }, + { + title: t('more.sections.security.deployMode.enableButton'), + value: app.isDeployModeEnabled, + onToggle: () => { + setShowingDeployModeConfirmation(true) + }, + renderModal: () => ( + setShowingDeployModeConfirmation(false)} + /> + ), + disabled: app.isDeployModeEnabled, }, ] return [ @@ -338,6 +365,9 @@ const MoreScreen = () => { app.isHapticDisabled, app.authInterval, app.isPinRequiredForPayment, + app.isDeployModeEnabled, + showingDeployModeConfirmation, + setShowingDeployModeConfirmation, showHiddenHotspots, handleRevealWords, language, diff --git a/src/features/wallet/root/WalletHeader.tsx b/src/features/wallet/root/WalletHeader.tsx index 9f5b73a72..c6a3d225c 100644 --- a/src/features/wallet/root/WalletHeader.tsx +++ b/src/features/wallet/root/WalletHeader.tsx @@ -1,13 +1,16 @@ /* eslint-disable react/jsx-props-no-spreading */ import React, { memo } from 'react' import { useTranslation } from 'react-i18next' +import { useSelector } from 'react-redux' import Qr from '@assets/images/qr.svg' +import LockIcon from '@assets/images/lockIconRed.svg' import { BoxProps } from '@shopify/restyle' import { Insets } from 'react-native' import Box from '../../../components/Box' import Text from '../../../components/Text' import TouchableOpacityBox from '../../../components/TouchableOpacityBox' import { Theme } from '../../../theme/theme' +import { RootState } from '../../../store/rootReducer' type Props = BoxProps & { handleScanPressed: () => void @@ -17,6 +20,14 @@ type Props = BoxProps & { const hitSlop = { top: 12, bottom: 12, left: 24, right: 24 } as Insets const WalletHeader = ({ handleScanPressed, hideTitle, ...boxProps }: Props) => { const { t } = useTranslation() + const isDeployModeEnabled = useSelector( + (state: RootState) => state.app.isDeployModeEnabled, + ) + const walletTitleIcon = isDeployModeEnabled ? ( + + + + ) : null return ( { {...boxProps} > {!hideTitle && ( - - {t('wallet.title')} - + <> + + {t('wallet.title')} + + {walletTitleIcon} + )} diff --git a/src/features/wallet/send/SendDetailsForm.tsx b/src/features/wallet/send/SendDetailsForm.tsx index 4a9901f99..4ca9717a2 100644 --- a/src/features/wallet/send/SendDetailsForm.tsx +++ b/src/features/wallet/send/SendDetailsForm.tsx @@ -37,6 +37,7 @@ type Props = { account?: Account fee: Balance isLocked: boolean + isLockedAddress: boolean isSeller?: boolean index: number lastReportedActivity?: string @@ -53,6 +54,7 @@ const SendDetailsForm = ({ account, fee, isLocked, + isLockedAddress, isSeller, lastReportedActivity, onScanPress, @@ -238,35 +240,39 @@ const SendDetailsForm = ({ const renderPaymentForm = () => ( <> - - } - footer={ - - - {isHotspotAddress && ( - - {t('send.not_valid_address')} - - )} - - } - /> + {isLockedAddress ? ( + + ) : ( + + } + footer={ + + + {isHotspotAddress && ( + + {t('send.not_valid_address')} + + )} + + } + /> + )} ( <> - - - - ) : ( - - - - ) - } - /> + {isLockedAddress ? ( + + ) : ( + + + + ) : ( + + + + ) + } + /> + )} hasSufficientBalance?: boolean hasValidActivity?: boolean + isDisabled: boolean isLocked: boolean + isLockedAddress: boolean isSeller?: boolean isValid: boolean lastReportedActivity?: string @@ -31,6 +34,7 @@ type Props = { type: AppLinkCategoryType unlockForm: () => void updateSendDetails: (detailsId: string, updates: SendDetailsUpdate) => void + warning?: string } const SendForm = ({ @@ -38,7 +42,9 @@ const SendForm = ({ fee, hasSufficientBalance, hasValidActivity, + isDisabled, isLocked, + isLockedAddress, isSeller, isValid, lastReportedActivity, @@ -50,6 +56,7 @@ const SendForm = ({ type, unlockForm, updateSendDetails, + warning, }: Props) => { const { t } = useTranslation() const [sendDisabled, setSendDisabled] = useState(false) @@ -80,8 +87,10 @@ const SendForm = ({ {isLocked && ( )} {sendDetails.map((details, index) => ( @@ -91,6 +100,7 @@ const SendForm = ({ account={account} fee={fee} isLocked={isLocked} + isLockedAddress={isLockedAddress} isSeller={isSeller} setSendDisabled={setSendDisabled} lastReportedActivity={lastReportedActivity} @@ -130,17 +140,31 @@ const SendForm = ({ disabled={!isValid || sendDisabled} /> {shouldShowFee && } + {warning && } ) } const FeeFooter = ({ fee }: { fee: Balance }) => { const { t } = useTranslation() + const feeText = `+${fee.toString(8, { + decimalSeparator, + groupSeparator, + })} ${t('generic.fee').toUpperCase()}` + return +} + +const NoticeFooter = ({ + text, + color = 'grayText', +}: { + text: string + color?: Colors +}) => { return ( - - +{fee.toString(8, { decimalSeparator, groupSeparator })}{' '} - {t('generic.fee').toUpperCase()} + + {text} ) diff --git a/src/features/wallet/send/SendScreen.tsx b/src/features/wallet/send/SendScreen.tsx index 8aef3c7a4..eb4c4364b 100644 --- a/src/features/wallet/send/SendScreen.tsx +++ b/src/features/wallet/send/SendScreen.tsx @@ -1,6 +1,7 @@ import React, { useEffect, useMemo } from 'react' import { RouteProp, useNavigation } from '@react-navigation/native' import { useSelector } from 'react-redux' +import { useTranslation } from 'react-i18next' import Box from '../../../components/Box' import { SendStackParamList } from './sendTypes' import SendView from './SendView' @@ -14,15 +15,26 @@ type Props = { } const SendScreen = ({ route }: Props) => { + const { t } = useTranslation() const rootNavigation = useNavigation() const scanResult = route?.params?.scanResult - const type = route?.params?.type const hotspotAddress = route?.params?.hotspotAddress const isSeller = route?.params?.isSeller const isPinVerified = route?.params?.pinVerified const isPinRequiredForPayment = useSelector( (state: RootState) => state.app.isPinRequiredForPayment, ) + const isDeployModeEnabled = useSelector( + (state: RootState) => state.app.isDeployModeEnabled, + ) + const permanentPaymentAddress = useSelector( + (state: RootState) => state.app.permanentPaymentAddress, + ) + // If "Deploy Mode" is enabled, only allow payment transactions + const type = isDeployModeEnabled ? 'payment' : route?.params?.type + // If "Deploy Mode" is enabled without a permanent payment address, disable all payments + const isDeployModePaymentsDisabled = + isDeployModeEnabled && !permanentPaymentAddress useEffect(() => { // Check if pin is required, show lock screen if so @@ -57,8 +69,15 @@ const SendScreen = ({ route }: Props) => { scanResult={scanResult} sendType={type} hotspotAddress={hotspotAddress} + isDisabled={isDeployModePaymentsDisabled} isSeller={isSeller} canSubmit={canSubmit} + lockedPaymentAddress={permanentPaymentAddress} + warning={ + isDeployModePaymentsDisabled + ? t('send.deployModePaymentsDisabled') + : undefined + } /> diff --git a/src/features/wallet/send/SendView.tsx b/src/features/wallet/send/SendView.tsx index 5b41c7580..b15ac4113 100644 --- a/src/features/wallet/send/SendView.tsx +++ b/src/features/wallet/send/SendView.tsx @@ -69,16 +69,22 @@ type Props = { scanResult?: AppLink sendType?: AppLinkCategoryType hotspotAddress?: string + isDisabled: boolean isSeller?: boolean canSubmit?: boolean + lockedPaymentAddress?: string + warning?: string } const SendView = ({ scanResult, sendType, hotspotAddress, + isDisabled, isSeller, canSubmit = true, + lockedPaymentAddress, + warning, }: Props) => { const tabNavigation = useNavigation() const sendNavigation = useNavigation() @@ -93,7 +99,7 @@ const SendView = ({ (state: RootState) => state.heliumData.currentOraclePrice, ) const [type, setType] = useState(sendType || 'payment') - const [isLocked, setIsLocked] = useState(false) + const [isLocked, setIsLocked] = useState(isDisabled) const [isValid, setIsValid] = useState(false) const [hasSufficientBalance, setHasSufficientBalance] = useState(false) const [transferData, setTransferData] = useState() @@ -116,7 +122,7 @@ const SendView = ({ const [sendDetails, setSendDetails] = useState>([ { id: '0', - address: '', + address: lockedPaymentAddress || '', addressAlias: '', addressLoading: false, amount: '', @@ -552,7 +558,9 @@ const SendView = ({ fee={fee} hasSufficientBalance={hasSufficientBalance} hasValidActivity={hasValidActivity} + isDisabled={isDisabled} isLocked={isLocked} + isLockedAddress={!!lockedPaymentAddress} isSeller={isSeller} isValid={isValid} lastReportedActivity={lastReportedActivity} @@ -564,6 +572,7 @@ const SendView = ({ type={type} unlockForm={unlockForm} updateSendDetails={updateSendDetails} + warning={warning} /> {isSeller && ( diff --git a/src/locales/en.ts b/src/locales/en.ts index 2d81d23c9..0630185c9 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -94,6 +94,7 @@ export default { generic: { clear: 'Clear', done: 'Done', + disabled: 'Disabled', readMore: 'Read More', witness: 'Witness', understand: 'I understand', @@ -148,6 +149,8 @@ export default { meters: '{{distance}}m', kilometers: '{{distance}}km', owner: 'Owner', + one: 'one', + swipe_to_confirm: 'Swipe to Confirm', }, hotspot_setup: { selection: { @@ -509,6 +512,7 @@ export default { }, qrInfo: 'QR INFO', error: 'There was an error submitting this transaction. Please try again.', + deployModePaymentsDisabled: 'Payments are disabled in Deploy Mode', hotspot_label: 'Hotspot', last_activity: 'LAST REPORTED ACTIVITY: {{activity}}', label_error: 'You do not have enough HNT in your account.', @@ -550,6 +554,20 @@ export default { after_4_hr: 'After 4 hours', }, revealWords: 'Reveal Words', + deployMode: { + title: 'Deploy Mode', + subtitle: + 'This mode adds extra protection to your wallet, restricting some app features.', + inDeployMode: 'In Deploy Mode:', + cantViewWords: "Can't view your 12 secure words", + cantTransferHotspots: "Can't transfer Hotspots from this account", + canOnlySendFunds: 'Can only send funds to', + otherAccount: 'other specified account', + enableButton: 'Enable Deploy Mode', + disableInstructions: + 'In order to disable this feature, you will have to log out. Remember to write down all 12 words now.', + addressLabel: 'Allowed Account Address...', + }, }, learn: { title: 'Learn', @@ -994,6 +1012,9 @@ export default { fine_print: 'Hotspot will transfer once the buyer accepts and completes the transaction.', notification_button: 'View Transaction', + deployModeTransferDisableTitle: 'Transfer Hotspot Disabled', + deployModeTransferDisabled: + 'Transfer Hotspot is disabled while in Deploy Mode.', cancel: { button_title: 'Transfer Pending. Tap to Cancel.', failed_alert_title: 'Unable to Cancel Transfer', diff --git a/src/locales/ja.ts b/src/locales/ja.ts index fde9446db..97bf1b2f5 100644 --- a/src/locales/ja.ts +++ b/src/locales/ja.ts @@ -94,6 +94,7 @@ export default { generic: { clear: 'クリア', done: '完了', + disabled: '無効', understand: '理解しました', blocks: 'ブロック', active: 'アクティブ', @@ -140,6 +141,8 @@ export default { minutes_plural: '{{count}}分', seconds: '{{count}}秒', seconds_plural: '{{count}}秒', + one: '一', + swipe_to_confirm: 'スワイプして確認', }, hotspot_setup: { selection: { @@ -479,6 +482,7 @@ export default { qrInfo: 'QR情報', error: 'このトランザクションの申請中にエラーが発生しました。もう一度実行してください。', + deployModePaymentsDisabled: 'デプロイモードでは支払いが無効になります', hotspot_label: 'Hotspot', last_activity: '最後に報告されたアクティビティ:{{activity}}', label_error: 'アカウントに十分なHNTがありません。', @@ -520,6 +524,21 @@ export default { after_4_hr: '4時間後', }, revealWords: '単語を表示', + deployMode: { + title: 'デプロイモード', + subtitle: + 'このモードはウォレットに追加の保護を追加し、一部のアプリ機能を制限します。', + inDeployMode: 'デプロイモードの場合:', + cantViewWords: 'あなたの12の安全な言葉を見ることができません', + cantTransferHotspots: + 'このアカウントからホットスポットを転送できません', + canOnlySendFunds: 'にのみ資金を送ることができます', + otherAccount: '他の指定されたアカウント', + enableButton: 'デプロイモードを有効にする', + disableInstructions: + 'この機能を無効にするには、ログアウトする必要があります。 今すぐ12語すべてを書き留めることを忘れないでください。', + addressLabel: '許可されたアカウントアドレス...', + }, }, learn: { title: '詳細', @@ -820,6 +839,9 @@ export default { fine_print: '購入者がトランザクションを承諾して完了すると、Hotspotでデータが転送されます。', notification_button: 'トランザクションを表示', + deployModeTransferDisableTitle: 'ホットスポットの転送が無効', + deployModeTransferDisabled: + '展開モードでは、転送ホットスポットは無効になります。', cancel: { button_title: '転送を保留しています。タップしてキャンセルします。', failed_alert_title: '転送をキャンセルできません', diff --git a/src/locales/ko.ts b/src/locales/ko.ts index b55bad902..5b2372b05 100644 --- a/src/locales/ko.ts +++ b/src/locales/ko.ts @@ -94,6 +94,7 @@ export default { generic: { clear: '지우기', done: '완료', + disabled: '장애가있는', understand: '내용을 이해함', blocks: '블록', active: '활성화', @@ -140,6 +141,8 @@ export default { minutes_plural: '{{count}} 분', seconds: '{{count}} 초', seconds_plural: '{{count}} 초', + one: '하나', + swipe_to_confirm: '스와이프하여 확인', }, hotspot_setup: { selection: { @@ -463,6 +466,7 @@ export default { }, qrInfo: 'QR 정보', error: '이 트랜잭션을 제출하는 중에 오류가 발생했습니다. 다시 시도하세요.', + deployModePaymentsDisabled: '배포 모드에서는 결제가 비활성화됩니다.', hotspot_label: 'Hotspot', last_activity: '마지막으로 보고된 활동: {{activity}}', label_error: '계정에 충분한 HNT가 없습니다.', @@ -504,6 +508,20 @@ export default { after_4_hr: '4시간 후', }, revealWords: '단어 공개', + deployMode: { + title: '배포 모드', + subtitle: + '이 모드는 지갑에 추가 보호 기능을 추가하여 일부 앱 기능을 제한합니다.', + inDeployMode: '배포 모드에서:', + cantViewWords: '12개의 보안 단어를 볼 수 없습니다', + cantTransferHotspots: '이 계정에서 핫스팟을 전송할 수 없습니다', + canOnlySendFunds: '송금만 가능', + otherAccount: '기타 지정된 계정', + enableButton: '배포 모드 활성화', + disableInstructions: + '이 기능을 비활성화하려면 로그아웃해야 합니다. 지금 12개의 단어를 모두 적어두는 것을 잊지 마십시오.', + addressLabel: '허용된 계정 주소...', + }, }, learn: { title: '알아보기', @@ -801,6 +819,8 @@ export default { '이 전송은 더 이상 활성화되지 않습니다. 판매자에게 자세한 내용을 문의하세요.', fine_print: '구매자가 트랜잭션을 수락하고 완료하면 Hotspot이 전송됩니다.', notification_button: '트랜잭션 보기', + deployModeTransferDisableTitle: '핫스팟 전송 비활성화됨', + deployModeTransferDisabled: '배포 모드에서는 핫스팟 전송이 비활성화됩니다.', cancel: { button_title: '전송 보류. 취소하려면 탭하세요.', failed_alert_title: '전송을 취소할 수 없음', diff --git a/src/locales/zh.ts b/src/locales/zh.ts index 87ac6f680..4e2030010 100644 --- a/src/locales/zh.ts +++ b/src/locales/zh.ts @@ -92,6 +92,7 @@ export default { generic: { clear: '清除', done: '已完成', + disabled: '已禁用', understand: '我知道了', blocks: '区块', active: '活跃', @@ -137,6 +138,8 @@ export default { minutes_plural: '{{count}} 分钟', seconds: '{{count}} 秒', seconds_plural: '{{count}} 秒', + one: '一', + swipe_to_confirm: '滑动确认', }, hotspot_setup: { selection: { @@ -435,6 +438,7 @@ export default { }, qrInfo: 'QR 信息', error: '提交此交易时出错。请重试。', + deployModePaymentsDisabled: '在部署模式下付款被禁用', hotspot_label: 'Hotspot', last_activity: '上次报告的活动: {{activity}}', label_error: '您的帐户 HNT 余额不足。', @@ -474,6 +478,19 @@ export default { after_4_hr: '4 小时后', }, revealWords: '显示助记词', + deployMode: { + title: '部署模式', + subtitle: '此模式为您的钱包增加了额外保护,限制了某些应用程序功能。', + inDeployMode: '在部署模式下:', + cantViewWords: '无法查看您的 12 个安全词', + cantTransferHotspots: '无法从此帐户转移热点', + canOnlySendFunds: '只能将资金发送至', + otherAccount: '其他指定帐户', + enableButton: '启用部署模式', + disableInstructions: + '要禁用此功能,您必须注销。 记住现在写下所有 12 个单词。', + addressLabel: '允许的帐户地址...', + }, }, learn: { title: '学习', @@ -765,6 +782,8 @@ export default { canceled_alert_body: '此转让不再处于活动状态。请联系卖家了解更多信息。', fine_print: '一旦买家接受并完成交易,Hotspot 即被转让。', notification_button: '查看交易', + deployModeTransferDisableTitle: '传输热点已禁用', + deployModeTransferDisabled: '在部署模式下禁用传输热点。', cancel: { button_title: '转让待处理。轻触以取消。', failed_alert_title: '无法取消转让', diff --git a/src/store/user/appSlice.ts b/src/store/user/appSlice.ts index edc9a45a8..81af8e325 100644 --- a/src/store/user/appSlice.ts +++ b/src/store/user/appSlice.ts @@ -12,6 +12,8 @@ import { Intervals } from '../../features/moreTab/more/useAuthIntervals' export type AppState = { isBackedUp: boolean isHapticDisabled: boolean + isDeployModeEnabled: boolean + permanentPaymentAddress: string isSettingUpHotspot: boolean isRestored: boolean isPinRequired: boolean @@ -24,6 +26,8 @@ export type AppState = { const initialState: AppState = { isBackedUp: false, isHapticDisabled: false, + isDeployModeEnabled: false, + permanentPaymentAddress: '', isSettingUpHotspot: false, isRestored: false, isPinRequired: false, @@ -38,6 +42,8 @@ type Restore = { isBackedUp: boolean isPinRequired: boolean isPinRequiredForPayment: boolean + isDeployModeEnabled: boolean + permanentPaymentAddress: string authInterval: number isLocked: boolean isHapticDisabled: boolean @@ -53,6 +59,8 @@ export const restoreAppSettings = createAsyncThunk( authInterval, isHapticDisabled, address, + isDeployModeEnabled, + permanentPaymentAddress, ] = await Promise.all([ getSecureItem('accountBackedUp'), getSecureItem('requirePin'), @@ -60,6 +68,8 @@ export const restoreAppSettings = createAsyncThunk( getSecureItem('authInterval'), getSecureItem('hapticDisabled'), getSecureItem('address'), + getSecureItem('deployModeEnabled'), + getSecureItem('permanentPaymentAddress'), ]) if (isBackedUp && address) { @@ -75,6 +85,8 @@ export const restoreAppSettings = createAsyncThunk( : Intervals.IMMEDIATELY, isLocked: isPinRequired, isHapticDisabled, + isDeployModeEnabled, + permanentPaymentAddress, } as Restore }, ) @@ -102,6 +114,14 @@ const appSlice = createSlice({ state.isPinRequiredForPayment = action.payload setSecureItem('requirePinForPayment', action.payload) }, + enableDeployMode: (state, action: PayloadAction) => { + state.isDeployModeEnabled = action.payload + setSecureItem('deployModeEnabled', action.payload) + }, + setPermanentPaymentAddress: (state, action: PayloadAction) => { + state.permanentPaymentAddress = action.payload + setSecureItem('permanentPaymentAddress', action.payload) + }, updateHapticEnabled: (state, action: PayloadAction) => { state.isHapticDisabled = action.payload setSecureItem('hapticDisabled', action.payload) diff --git a/src/utils/secureAccount.ts b/src/utils/secureAccount.ts index bdfe69b4a..d611e5a1e 100644 --- a/src/utils/secureAccount.ts +++ b/src/utils/secureAccount.ts @@ -13,6 +13,7 @@ const stringKeys = [ 'authInterval', 'walletApiToken', 'language', + 'permanentPaymentAddress', ] as const type StringKey = typeof stringKeys[number] @@ -23,6 +24,7 @@ const boolKeys = [ 'requirePinForPayment', 'hapticDisabled', 'convertHntToCurrency', + 'deployModeEnabled', 'fleetModeEnabled', 'hasFleetModeAutoEnabled', ] as const diff --git a/yarn.lock b/yarn.lock index 540afcefc..1593b38c8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -11810,6 +11810,11 @@ rn-host-detect@1.2.0: resolved "https://registry.yarnpkg.com/rn-host-detect/-/rn-host-detect-1.2.0.tgz#8b0396fc05631ec60c1cb8789e5070cdb04d0da0" integrity sha512-btNg5kzHcjZZ7t7mvvV/4wNJ9e3MPgrWivkRgWURzXL0JJ0pwWlU4zrbmdlz3HHzHOxhBhHB4D+/dbMFfu4/4A== +rn-swipe-button@^1.3.6: + version "1.3.6" + resolved "https://registry.yarnpkg.com/rn-swipe-button/-/rn-swipe-button-1.3.6.tgz#02e6ce0664fc94900851871a532fa0e2fbc7fd35" + integrity sha512-H4//cnGE7r4S3lTc1DbLR5gIZuibkk+Svy3g3LtRhyfd/vr1qfnQ70kMhCB/iIHD06yYOWgRdyqfyCb7jTosGg== + rsvp@^4.8.4: version "4.8.5" resolved "https://registry.yarnpkg.com/rsvp/-/rsvp-4.8.5.tgz#c8f155311d167f68f21e168df71ec5b083113734"