Files
minibits_wallet/src/screens/RecoveryOptionsScreen.tsx
T

664 lines
23 KiB
TypeScript

import {observer} from 'mobx-react-lite'
import React, {useState, useEffect, useRef, useLayoutEffect} from 'react'
import {Platform, TextInput, TextStyle, View, ViewStyle} from 'react-native'
import Animated, {
useSharedValue,
useAnimatedScrollHandler,
} from 'react-native-reanimated'
import notifee, { AndroidImportance } from '@notifee/react-native'
import {spacing, useThemeColor, colors} from '../theme'
import {
Button,
Card,
Screen,
ListItem,
Text,
ErrorModal,
InfoModal,
Loading,
BottomModal,
Header,
AnimatedHeader,
} from '../components'
import {log} from '../services/logService'
import AppError, { Err } from '../utils/AppError'
import { useStores } from '../models'
import { SYNC_STATE_WITH_ALL_MINTS_TASK, SyncStateTaskResult, WalletTask } from '../services/walletService'
import EventEmitter from '../utils/eventEmitter'
import { translate } from '../i18n'
import { NotificationService } from '../services/notificationService'
import { StaticScreenProps, useNavigation } from '@react-navigation/native'
import { ResultModalInfo } from './Wallet/ResultModalInfo'
import { MintBalanceSelector } from './Mints/MintBalanceSelector'
import { MintBalance } from '../models/Mint'
import { MintUnit } from '../services/wallet/currency'
import Clipboard from '@react-native-clipboard/clipboard'
type Props = StaticScreenProps<undefined>
export const RecoveryOptionsScreen = observer(function RecoveryOptionsScreen(_: Props) {
const navigation = useNavigation()
const scrollY = useSharedValue(0)
const HEADER_SCROLL_DISTANCE = spacing.screenHeight * 0.15
useLayoutEffect(() => {
navigation.setOptions({
headerShown: true,
header: () => (
<Header
titleTx="recoveryOptionsTitle"
leftIcon="faArrowLeft"
onLeftPress={() => navigation.goBack()}
scrollY={scrollY}
scrollDistance={HEADER_SCROLL_DISTANCE}
/>
),
})
}, [])
const {mintsStore, proofsStore} = useStores()
const unitRef = useRef<MintUnit>('sat')
const mintBalancesRef = useRef<MintBalance[]>(proofsStore.getMintBalancesWithUnit('sat'))
const mintQuoteInputRef = useRef<TextInput>(null)
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState<AppError | undefined>()
const [info, setInfo] = useState('')
const [mintBalanceToRecoverFrom, setMintBalanceToRecoverFrom] = useState<MintBalance>(mintBalancesRef.current[0])
const [mintQuote, setMintQuote] = useState<string>('')
const [meltQuote, setMeltQuote] = useState<string>('')
const [mintedAmount, setMintedAmount] = useState<number>(0)
const [meltChangeAmount, setMeltChangeAmount] = useState<number>(0)
const [isSyncStateSentToQueue, setIsSyncStateSentToQueue] = useState<boolean>(false)
const [isNotificationModalVisible, setIsNotificationModalVisible] = useState(false)
const [isMintQuoteRecoveryStarted, setIsMintQuoteRecoveryStarted] = useState(false)
const [isMeltQuoteRecoveryStarted, setIsMeltQuoteRecoveryStarted] = useState(false)
const [isMintSelectorModalVisible, setIsMintSelectorModalVisible] = useState(false)
const [isMintQuoteModalVisible, setIsMintQuoteModalVisible] = useState(false)
const [isMeltQuoteModalVisible, setIsMeltQuoteModalVisible] = useState(false)
const [isMintQuoteResultModalVisible, setIsMintQuoteResultModalVisible] = useState(false)
const [isMeltQuoteResultModalVisible, setIsMeltQuoteResultModalVisible] = useState(false)
useEffect(() => {
const removeSpentByMintTaskResult = async (result: SyncStateTaskResult) => {
log.trace('[removeSpentByMintTaskResult] event handler triggered')
if (!isSyncStateSentToQueue) { return false }
setIsLoading(false)
setInfo(result.message)
}
if(isSyncStateSentToQueue) {
EventEmitter.on(`ev_${SYNC_STATE_WITH_ALL_MINTS_TASK}_result`, removeSpentByMintTaskResult)
}
return () => {
EventEmitter.off(`ev_${SYNC_STATE_WITH_ALL_MINTS_TASK}_result`, removeSpentByMintTaskResult)
}
}, [isSyncStateSentToQueue])
const openNotificationSettings = async function() {
if(Platform.OS === 'android') {
await notifee.openNotificationSettings()
} else {
const settings = await notifee.requestPermission()
}
}
const toggleNotificationModal = () =>
setIsNotificationModalVisible(previousState => !previousState)
const toggleMintSelectorModal = () => {
setIsMintSelectorModalVisible(previousState => !previousState)
}
const toggleMintQuoteModal = () =>
setIsMintQuoteModalVisible(previousState => !previousState)
const toggleMeltQuoteModal = () =>
setIsMeltQuoteModalVisible(previousState => !previousState)
const toggleMintQuoteResultModal = () =>
setIsMintQuoteResultModalVisible(previousState => !previousState)
const toggleMeltQuoteResultModal = () =>
setIsMeltQuoteResultModalVisible(previousState => !previousState)
const gotoSeedRecoveryOptions = function () {
//@ts-ignore
navigation.navigate('SeedRecoveryOptions')
}
const checkSpent = async function () {
const enabled = await NotificationService.areNotificationsEnabled()
if(!enabled) {
toggleNotificationModal()
return
}
setIsLoading(true)
setIsSyncStateSentToQueue(true)
if(Platform.OS === 'android') {
await NotificationService.createTaskNotification(
'Cleaning spent ecash from spendable balance...',
{task: SYNC_STATE_WITH_ALL_MINTS_TASK}
)
} else {
// iOS does not support fg notifications with long running tasks
await WalletTask.syncStateWithAllMintsQueueAwaitable({isPending: false})
}
}
const increaseCounters = async function () {
const increaseAmount = 20
for (const mint of mintsStore.allMints) {
for(const counter of mint.proofsCounters) {
counter.increaseProofsCounter(increaseAmount)
}
}
setInfo(translate("recoveryIndexesIncSuccess", { indCount: increaseAmount }))
}
const onMintBalanceSelect = function (balance: MintBalance) {
setMintBalanceToRecoverFrom(balance)
}
const onMintBalanceConfirm = function () {
toggleMintSelectorModal()
if(mintBalanceToRecoverFrom) {
if(Platform.OS === 'ios') {
setTimeout(() => {
if(isMeltQuoteRecoveryStarted) {
toggleMeltQuoteModal()
}
if(isMintQuoteRecoveryStarted) {
toggleMintQuoteModal()
}
}, 500)
} else {
if(isMeltQuoteRecoveryStarted) {
toggleMeltQuoteModal()
}
if(isMintQuoteRecoveryStarted) {
toggleMintQuoteModal()
}
}
}
}
const onMintBalanceCancel = async function () {
toggleMintSelectorModal()
}
const onPasteMintQuote = async function () {
const quote = await Clipboard.getString()
if (!quote) {
setInfo(translate('recoveryInvalidMintQuote'))
return
}
setMintQuote(quote)
}
const onPasteMeltQuote = async function () {
const quote = await Clipboard.getString()
if (!quote) {
setInfo(translate('recoveryInvalidMeltQuote'))
return
}
setMeltQuote(quote)
}
const onMintEcashFromQuote = async function () {
setIsLoading(true)
toggleMintQuoteModal()
try {
if(!mintBalanceToRecoverFrom) {
throw new AppError(Err.VALIDATION_ERROR, 'Mint is not selected.')
}
if(mintQuote.length !== 40) {
throw new AppError(Err.VALIDATION_ERROR, 'Mint quote must have 40 characters.')
}
const result = await WalletTask.recoverMintQuote({
mintUrl: mintBalanceToRecoverFrom.mintUrl,
mintQuote
})
setIsLoading(false)
if(result.recoveredAmount > 0) {
setMintedAmount(result.recoveredAmount)
toggleMintQuoteResultModal()
resetState()
} else {
throw new AppError(Err.MINT_ERROR, 'Could not mint ecash from provided mint quote.')
}
} catch (e: any) {
handleError(e)
}
}
const onRecoverChangeFromQuote = async function () {
setIsLoading(true)
toggleMeltQuoteModal()
try {
if(!mintBalanceToRecoverFrom) {
throw new AppError(Err.VALIDATION_ERROR, 'Mint is not selected.')
}
const result = await WalletTask.recoverMeltQuoteChange({
mintUrl: mintBalanceToRecoverFrom.mintUrl,
meltQuote
})
setIsLoading(false)
if(result.recoveredAmount > 0) {
setMeltChangeAmount(result.recoveredAmount)
toggleMeltQuoteResultModal()
resetState()
} else {
throw new AppError(Err.MINT_ERROR, 'Could not recover any change from provided melt quote.')
}
} catch (e: any) {
handleError(e)
}
}
const handleError = function (e: AppError): void {
log.error(e.name, e.message)
resetState()
setError(e)
}
const startMeltQuoteRecovery = function(): void {
setIsMeltQuoteRecoveryStarted(true)
toggleMintSelectorModal()
}
const startMintQuoteRecovery = function(): void {
setIsMintQuoteRecoveryStarted(true)
toggleMintSelectorModal()
}
const resetState = function () {
setIsLoading(false)
setError(undefined)
setInfo('')
//setMintedAmount(0)
//setMeltChangeAmount(0)
setMintQuote('')
setMeltQuote('')
setIsSyncStateSentToQueue(false)
setIsMintQuoteRecoveryStarted(false)
setIsMeltQuoteRecoveryStarted(false)
setMintBalanceToRecoverFrom(mintBalancesRef.current[0])
}
const inputBg = useThemeColor('background')
const mintsModalBg = useThemeColor('background')
const inputText = useThemeColor('text')
const scrollHandler = useAnimatedScrollHandler({
onScroll: (event) => {
scrollY.value = event.contentOffset.y
},
})
return (
<Screen preset="fixed" contentContainerStyle={$screen}>
<Animated.ScrollView
style={$contentContainer}
//contentContainerStyle={{ paddingBottom: HEADER_SCROLL_DISTANCE }}
onScroll={scrollHandler}
scrollEventThrottle={16}
>
<AnimatedHeader
titleTx="recoveryOptionsTitle"
scrollY={scrollY}
/>
<Card
style={[$card, {marginTop: -spacing.extraLarge * 2}]}
HeadingComponent={
<ListItem
tx="seedRecoveryOptions"
subTx="seedRecoveryOptionsDesc"
leftIcon='faSeedling'
leftIconColor={colors.palette.orange400}
leftIconInverse={true}
style={$item}
onPress={gotoSeedRecoveryOptions}
/>
}
/>
<Card
style={$card}
HeadingComponent={
<>
<ListItem
tx="backupRemoveSpentCoins"
subTx="backupRemoveSpentCoinsDescription"
leftIcon='faRecycle'
leftIconColor={colors.palette.secondary300}
leftIconInverse={true}
RightComponent={
<View style={$rightContainer}>
<Button
onPress={checkSpent}
tx='recoveryScreen_remove'
preset='secondary'
/>
</View>
}
style={$item}
/>
<ListItem
tx="increaseRecoveryIndexes"
subTx="increaseRecoveryIndexesDesc"
leftIcon='faArrowUp'
leftIconColor={colors.palette.success300}
leftIconInverse={true}
RightComponent={
<View style={$rightContainer}>
<Button
onPress={increaseCounters}
tx='recoveryScreen_increase'
preset='secondary'
/>
</View>
}
topSeparator={true}
style={$item}
/>
</>
}
/>
<Card
labelTx='recoveryScreen_experimentalTools'
style={[$card, {marginBottom: spacing.huge * 2}]}
HeadingComponent={
<>
<ListItem
tx="recoveryScreen_recoverMintQuote"
subTx="recoveryScreen_recoverMintQuoteDesc"
leftIcon='faCoins'
leftIconColor={colors.palette.orange400}
leftIconInverse={true}
RightComponent={
<View style={$rightContainer}>
<Button
onPress={startMintQuoteRecovery}
tx='recoveryScreen_start'
preset='secondary'
/>
</View>
}
style={$item}
/>
<ListItem
tx="recoveryScreen_recoverMeltQuoteChange"
subTx="recoveryScreen_recoverMeltQuoteChangeDesc"
leftIcon='faArrowTurnDown'
leftIconColor={colors.palette.iconGreyBlue400}
leftIconInverse={true}
topSeparator={true}
RightComponent={
<View style={$rightContainer}>
<Button
onPress={startMeltQuoteRecovery}
tx='recoveryScreen_start'
preset='secondary'
/>
</View>
}
style={$item}
/>
</>
}
/>
{isLoading && <Loading />}
{error && <ErrorModal error={error} />}
{info && <InfoModal message={info} />}
</Animated.ScrollView>
<BottomModal
isVisible={isNotificationModalVisible ? true : false}
ContentComponent={
<>
<ResultModalInfo
icon="faTriangleExclamation"
iconColor={colors.palette.accent300}
title={translate("recoveryPermissionNeededTitle")}
message={translate("recoveryPermissionNeededDesc")}
/>
<View style={$buttonContainer}>
<Button
preset="secondary"
tx={'recoveryOpenSettingsButton'}
onPress={openNotificationSettings}
/>
</View>
</>
}
onBackButtonPress={toggleNotificationModal}
onBackdropPress={toggleNotificationModal}
/>
<BottomModal
isVisible={isMintSelectorModalVisible ? true : false}
style={{backgroundColor: mintsModalBg}}
ContentComponent={
<View style={$mintsContainer}>
<MintBalanceSelector
mintBalances={mintBalancesRef.current}
unit={unitRef.current}
title={translate('recoveryScreen_selectMintToRecoverFrom')}
confirmTitle={translate('recoveryScreen_confirm')}
collapsible={false}
onMintBalanceSelect={onMintBalanceSelect}
selectedMintBalance={mintBalanceToRecoverFrom}
onCancel={onMintBalanceCancel}
onMintBalanceConfirm={onMintBalanceConfirm}
/>
</View>
}
onBackButtonPress={toggleMintSelectorModal}
onBackdropPress={toggleMintSelectorModal}
/>
<BottomModal
isVisible={isMintQuoteModalVisible ? true : false}
ContentComponent={
<View style={$quoteContainer}>
<Text tx='recoveryEnterMintQuote' />
<View style={{flexDirection: 'row', alignItems: 'center', marginTop: spacing.small}}>
<TextInput
ref={mintQuoteInputRef}
onChangeText={(quote) => setMintQuote(quote)}
value={mintQuote}
autoCapitalize='none'
keyboardType='default'
maxLength={40}
selectTextOnFocus={true}
style={[$quoteInput, {backgroundColor: inputBg, color: inputText}]}
/>
<Button
tx='commonPaste'
preset='secondary'
style={$pasteButton}
onPress={onPasteMintQuote}
/>
</View>
<View style={[$buttonContainer, {marginTop: spacing.medium}]}>
<Button onPress={onMintEcashFromQuote} tx="recoveryMintEcashButton" />
</View>
</View>
}
onBackButtonPress={toggleMintQuoteModal}
onBackdropPress={toggleMintQuoteModal}
/>
<BottomModal
isVisible={isMintQuoteResultModalVisible ? true : false}
ContentComponent={
<>
<ResultModalInfo
icon="faCheckCircle"
iconColor={colors.palette.success200}
title={translate("recoverySuccessTitle")}
message={translate("recoveryMintSuccessMessage", {amount: mintedAmount})}
/>
<View style={$buttonContainer}>
<Button
preset="secondary"
tx={'commonClose'}
onPress={toggleMintQuoteResultModal}
/>
</View>
</>
}
onBackButtonPress={toggleMintQuoteResultModal}
onBackdropPress={toggleMintQuoteResultModal}
/>
<BottomModal
isVisible={isMeltQuoteModalVisible ? true : false}
ContentComponent={
<View style={$quoteContainer}>
<Text tx='recoveryEnterMeltQuote' />
<View style={{flexDirection: 'row', alignItems: 'center', marginTop: spacing.small}}>
<TextInput
ref={mintQuoteInputRef}
onChangeText={(quote) => setMeltQuote(quote)}
value={meltQuote}
autoCapitalize='none'
keyboardType='default'
maxLength={40}
selectTextOnFocus={true}
style={[$quoteInput, {backgroundColor: inputBg, color: inputText}]}
/>
<Button
tx={'commonPaste'}
preset='secondary'
style={$pasteButton}
onPress={onPasteMeltQuote}
/>
</View>
<View style={[$buttonContainer, {marginTop: spacing.medium}]}>
<Button onPress={onRecoverChangeFromQuote} tx='recoveryRecoverChangeButton' />
</View>
</View>
}
onBackButtonPress={toggleMintQuoteModal}
onBackdropPress={toggleMintQuoteModal}
/>
<BottomModal
isVisible={isMeltQuoteResultModalVisible ? true : false}
ContentComponent={
<>
<ResultModalInfo
icon="faCheckCircle"
iconColor={colors.palette.success200}
title={translate("recoverySuccessTitle")}
message={translate("recoveryMeltSuccessMessage", {amount: meltChangeAmount})}
/>
<View style={$buttonContainer}>
<Button
preset="secondary"
tx={'commonClose'}
onPress={toggleMeltQuoteResultModal}
/>
</View>
</>
}
onBackButtonPress={toggleMeltQuoteResultModal}
onBackdropPress={toggleMeltQuoteResultModal}
/>
</Screen>
)
},
)
const $screen: ViewStyle = {
// flex: 1,
}
const $contentContainer: TextStyle = {
//flex: 1,
// marginTop: -spacing.extraLarge * 2,
// padding: spacing.extraSmall,
// alignItems: 'center',
}
const $mintsContainer: TextStyle = {
flex: 0,
// alignItems: 'flex-start',
alignSelf: 'stretch',
minHeight: spacing.screenHeight * 0.4,
}
const $quoteContainer: TextStyle = {
// flex: 1,
padding: spacing.small,
// alignSelf: 'stretch',
maxHeight: spacing.screenHeight * 0.3,
}
const $pasteButton: ViewStyle = {
borderTopLeftRadius: 0,
borderBottomLeftRadius: 0,
alignSelf: 'stretch',
justifyContent: 'center',
marginLeft: 1
}
const $quoteInput: TextStyle = {
flex: 1,
borderTopLeftRadius: spacing.small,
borderBottomLeftRadius: spacing.small,
fontSize: 16,
padding: spacing.small,
alignSelf: 'stretch',
textAlignVertical: 'top',
}
const $card: ViewStyle = {
marginBottom: spacing.small,
marginHorizontal: spacing.small,
}
const $rightContainer: ViewStyle = {
// padding: spacing.extraSmall,
// alignSelf: 'center',
marginLeft: spacing.tiny,
marginRight: -10
}
const $lightningCard: ViewStyle = {
marginVertical: spacing.small,
// paddingTop: 0,
}
const $item: ViewStyle = {
paddingHorizontal: spacing.small,
paddingLeft: 0,
}
const $buttonContainer: ViewStyle = {
flexDirection: 'row',
alignSelf: 'center',
}