feat: new wallet export screen

This commit is contained in:
Ivan Vershigora 2024-11-18 19:30:16 +00:00
parent 5500856abf
commit 32e0ecf3c9
No known key found for this signature in database
GPG key ID: DCCF7FB5ED2CEBD7
5 changed files with 282 additions and 141 deletions

64
components/SeedWords.tsx Normal file
View file

@ -0,0 +1,64 @@
import React from 'react';
import { I18nManager, StyleSheet, Text, View } from 'react-native';
import { useTheme } from './themes';
const SeedWords = ({ seed }: { seed: string }) => {
const words = seed.split(/\s/);
const { colors } = useTheme();
const stylesHook = StyleSheet.create({
word: {
backgroundColor: colors.inputBackgroundColor,
},
wortText: {
color: colors.labelText,
},
});
return (
<View style={styles.secret}>
{words.map((secret, index) => {
const text = `${index + 1}. ${secret} `;
return (
<View style={[styles.word, stylesHook.word]} key={index}>
<Text style={[styles.wortText, stylesHook.wortText]} textBreakStrategy="simple">
{text}
</Text>
</View>
);
})}
<Text style={styles.hiddenText} testID="Secret">
{seed}
</Text>
</View>
);
};
const styles = StyleSheet.create({
word: {
marginRight: 8,
marginBottom: 8,
paddingTop: 6,
paddingBottom: 6,
paddingLeft: 8,
paddingRight: 8,
borderRadius: 4,
},
wortText: {
fontWeight: 'bold',
textAlign: 'left',
fontSize: 17,
},
secret: {
flexWrap: 'wrap',
justifyContent: 'center',
flexDirection: I18nManager.isRTL ? 'row-reverse' : 'row',
},
hiddenText: {
height: 0,
width: 0,
},
});
export default SeedWords;

View file

@ -481,7 +481,14 @@
"select_wallet": "Select Wallet", "select_wallet": "Select Wallet",
"xpub_copiedToClipboard": "Copied to clipboard.", "xpub_copiedToClipboard": "Copied to clipboard.",
"pull_to_refresh": "Pull to Refresh", "pull_to_refresh": "Pull to Refresh",
"warning_do_not_disclose": "Warning! Do not disclose.", "warning_do_not_disclose": "Never share the information below",
"scan_import": "Scan this QR code to import your wallet in another application.",
"write_down_header": "Create a manual backup",
"write_down": "Write down and securely store these words. Use them to restore your wallet at a later time.",
"wallet_type_this": "This wallet type is {type}.",
"share_number": "Share {number}",
"copy_ln_url": "Copy and securely store this URL to restore your wallet at a later time.",
"copy_ln_public": "Copy and securely store this information to restore your wallet at a later time.",
"add_ln_wallet_first": "You must first add a Lightning wallet.", "add_ln_wallet_first": "You must first add a Lightning wallet.",
"identity_pubkey": "Identity Pubkey", "identity_pubkey": "Identity Pubkey",
"xpub_title": "Wallet XPUB", "xpub_title": "Wallet XPUB",

View file

@ -1,5 +1,5 @@
import { createNativeStackNavigator } from '@react-navigation/native-stack';
import React from 'react'; import React from 'react';
import { createNativeStackNavigator } from '@react-navigation/native-stack';
import navigationStyle from '../components/navigationStyle'; import navigationStyle from '../components/navigationStyle';
import { useTheme } from '../components/themes'; import { useTheme } from '../components/themes';

View file

@ -11,13 +11,15 @@ import loc from '../../loc';
import { AddWalletStackParamList } from '../../navigation/AddWalletStack'; import { AddWalletStackParamList } from '../../navigation/AddWalletStack';
import { isDesktop } from '../../blue_modules/environment'; import { isDesktop } from '../../blue_modules/environment';
import SeedWords from '../../components/SeedWords';
type RouteProps = RouteProp<AddWalletStackParamList, 'PleaseBackup'>; type RouteProps = RouteProp<AddWalletStackParamList, 'PleaseBackup'>;
type NavigationProp = NativeStackNavigationProp<AddWalletStackParamList, 'PleaseBackup'>; type NavigationProp = NativeStackNavigationProp<AddWalletStackParamList, 'PleaseBackup'>;
const PleaseBackup: React.FC = () => { const PleaseBackup: React.FC = () => {
const { wallets } = useStorage(); const { wallets } = useStorage();
const { walletID } = useRoute<RouteProps>().params; const { walletID } = useRoute<RouteProps>().params;
const wallet = wallets.find(w => w.getID() === walletID); const wallet = wallets.find(w => w.getID() === walletID)!;
const navigation = useNavigation<NavigationProp>(); const navigation = useNavigation<NavigationProp>();
const { isPrivacyBlurEnabled } = useSettings(); const { isPrivacyBlurEnabled } = useSettings();
const { colors } = useTheme(); const { colors } = useTheme();
@ -26,12 +28,6 @@ const PleaseBackup: React.FC = () => {
flex: { flex: {
backgroundColor: colors.elevated, backgroundColor: colors.elevated,
}, },
word: {
backgroundColor: colors.inputBackgroundColor,
},
wortText: {
color: colors.labelText,
},
pleaseText: { pleaseText: {
color: colors.foregroundColor, color: colors.foregroundColor,
}, },
@ -53,26 +49,6 @@ const PleaseBackup: React.FC = () => {
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, []); }, []);
const renderSecret = () => {
const component: JSX.Element[] = [];
const entries = wallet?.getSecret().split(/\s/).entries();
if (entries) {
for (const [index, secret] of entries) {
if (secret) {
const text = `${index + 1}. ${secret} `;
component.push(
<View style={[styles.word, stylesHook.word]} key={index}>
<Text style={[styles.wortText, stylesHook.wortText]} textBreakStrategy="simple">
{text}
</Text>
</View>,
);
}
}
}
return component;
};
return ( return (
<ScrollView <ScrollView
style={styles.root} style={styles.root}
@ -85,7 +61,7 @@ const PleaseBackup: React.FC = () => {
<Text style={[styles.pleaseText, stylesHook.pleaseText]}>{loc.pleasebackup.text}</Text> <Text style={[styles.pleaseText, stylesHook.pleaseText]}>{loc.pleasebackup.text}</Text>
</View> </View>
<View style={styles.list}> <View style={styles.list}>
<View style={styles.secret}>{renderSecret()}</View> <SeedWords seed={wallet.getSecret() ?? ''} />
</View> </View>
<View style={styles.bottom}> <View style={styles.bottom}>
<Button testID="PleasebackupOk" onPress={handleBackButton} title={loc.pleasebackup.ok} /> <Button testID="PleasebackupOk" onPress={handleBackButton} title={loc.pleasebackup.ok} />
@ -102,26 +78,13 @@ const styles = StyleSheet.create({
flex: 1, flex: 1,
justifyContent: 'space-around', justifyContent: 'space-around',
}, },
word: {
marginRight: 8,
marginBottom: 8,
paddingTop: 6,
paddingBottom: 6,
paddingLeft: 8,
paddingRight: 8,
borderRadius: 4,
},
wortText: {
fontWeight: 'bold',
textAlign: 'left',
fontSize: 17,
},
please: { please: {
flexGrow: 1, flexGrow: 1,
paddingHorizontal: 16, paddingHorizontal: 16,
}, },
list: { list: {
flexGrow: 8, flexGrow: 8,
marginTop: 14,
paddingHorizontal: 16, paddingHorizontal: 16,
}, },
bottom: { bottom: {
@ -135,12 +98,6 @@ const styles = StyleSheet.create({
fontWeight: '500', fontWeight: '500',
writingDirection: I18nManager.isRTL ? 'rtl' : 'ltr', writingDirection: I18nManager.isRTL ? 'rtl' : 'ltr',
}, },
secret: {
flexWrap: 'wrap',
justifyContent: 'center',
marginTop: 14,
flexDirection: I18nManager.isRTL ? 'row-reverse' : 'row',
},
}); });
export default PleaseBackup; export default PleaseBackup;

View file

@ -1,60 +1,105 @@
import { useFocusEffect, useNavigation, useRoute, RouteProp } from '@react-navigation/native'; import React, { useCallback, useEffect, useMemo, useState } from 'react';
import React, { useCallback, useEffect, useState } from 'react'; import Clipboard from '@react-native-clipboard/clipboard';
import { ActivityIndicator, InteractionManager, ScrollView, StyleSheet, View, LayoutChangeEvent } from 'react-native'; import { RouteProp, useFocusEffect, useNavigation, useRoute } from '@react-navigation/native';
import { BlueCard, BlueSpacing20, BlueText } from '../../BlueComponents'; import { Icon } from '@rneui/themed';
import { LegacyWallet, LightningCustodianWallet, SegwitBech32Wallet, SegwitP2SHWallet, WatchOnlyWallet } from '../../class'; import { ActivityIndicator, InteractionManager, LayoutChangeEvent, ScrollView, StyleSheet, TouchableOpacity, View } from 'react-native';
import CopyTextToClipboard from '../../components/CopyTextToClipboard'; import { disallowScreenshot } from 'react-native-screen-capture';
import { validateMnemonic } from '../../blue_modules/bip39';
import triggerHapticFeedback, { HapticFeedbackTypes } from '../../blue_modules/hapticFeedback';
import { BlueText } from '../../BlueComponents';
import { LightningCustodianWallet, WatchOnlyWallet } from '../../class';
import HandOffComponent from '../../components/HandOffComponent'; import HandOffComponent from '../../components/HandOffComponent';
import QRCodeComponent from '../../components/QRCodeComponent'; import QRCodeComponent from '../../components/QRCodeComponent';
import SeedWords from '../../components/SeedWords';
import { useTheme } from '../../components/themes'; import { useTheme } from '../../components/themes';
import { disallowScreenshot } from 'react-native-screen-capture';
import loc from '../../loc';
import { useStorage } from '../../hooks/context/useStorage';
import { HandOffActivityType } from '../../components/types'; import { HandOffActivityType } from '../../components/types';
import { WalletExportStackParamList } from '../../navigation/WalletExportStack';
import useAppState from '../../hooks/useAppState';
import { useSettings } from '../../hooks/context/useSettings'; import { useSettings } from '../../hooks/context/useSettings';
import { isDesktop } from '../../blue_modules/environment'; import { isDesktop } from '../../blue_modules/environment';
import { useStorage } from '../../hooks/context/useStorage';
import useAppState from '../../hooks/useAppState';
import loc from '../../loc';
import { WalletExportStackParamList } from '../../navigation/WalletExportStack';
type RouteProps = RouteProp<WalletExportStackParamList, 'WalletExport'>; type RouteProps = RouteProp<WalletExportStackParamList, 'WalletExport'>;
const HORIZONTAL_PADDING = 20;
const CopyBox: React.FC<{ text: string; onPress: () => void }> = ({ text, onPress }) => {
const { colors } = useTheme();
const stylesHook = StyleSheet.create({
copyRoot: { backgroundColor: colors.lightBorder },
});
return (
<TouchableOpacity onPress={onPress} style={[styles.copyRoot, stylesHook.copyRoot]}>
<View style={styles.copyLeft}>
<BlueText textBreakStrategy="balanced" style={styles.copyText}>
{text}
</BlueText>
</View>
<View style={styles.copyRight}>
<Icon name="copy" type="font-awesome-5" color={colors.foregroundColor} />
</View>
</TouchableOpacity>
);
};
const DoNotDisclose: React.FC = () => {
const { colors } = useTheme();
return (
<View style={[styles.warningBox, { backgroundColor: colors.changeText }]}>
<Icon type="font-awesome-5" name="exclamation-circle" color="white" />
<BlueText style={styles.warning}>{loc.wallets.warning_do_not_disclose}</BlueText>
</View>
);
};
const WalletExport: React.FC = () => { const WalletExport: React.FC = () => {
const { wallets, saveToDisk } = useStorage(); const { wallets, saveToDisk } = useStorage();
const { walletID } = useRoute<RouteProps>().params; const { walletID } = useRoute<RouteProps>().params;
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const { goBack } = useNavigation(); const navigation = useNavigation();
const { isPrivacyBlurEnabled } = useSettings(); const { isPrivacyBlurEnabled } = useSettings();
const { colors } = useTheme(); const { colors } = useTheme();
const wallet = wallets.find(w => w.getID() === walletID); const wallet = wallets.find(w => w.getID() === walletID)!;
const [qrCodeSize, setQRCodeSize] = useState(90); const [qrCodeSize, setQRCodeSize] = useState(90);
const { currentAppState, previousAppState } = useAppState(); const { currentAppState, previousAppState } = useAppState();
const stylesHook = StyleSheet.create({ const stylesHook = StyleSheet.create({
root: { root: { backgroundColor: colors.elevated },
backgroundColor: colors.elevated,
},
type: { color: colors.foregroundColor },
secret: { color: colors.foregroundColor },
warning: { color: colors.failedColor },
}); });
const secrets: string[] = useMemo(() => {
try {
const secret = wallet.getSecret();
return typeof secret === 'string' ? [secret] : Array.isArray(secret) ? secret : [];
} catch (error) {
console.error('Failed to get wallet secret:', error);
return [];
}
}, [wallet]);
const secretIsMnemonic: boolean = useMemo(() => {
return validateMnemonic(wallet.getSecret());
}, [wallet]);
useEffect(() => { useEffect(() => {
if (!isLoading && previousAppState === 'active' && currentAppState !== 'active') { if (!isLoading && previousAppState === 'active' && currentAppState !== 'active') {
const timer = setTimeout(() => goBack(), 500); const timer = setTimeout(() => navigation.goBack(), 500);
return () => clearTimeout(timer); return () => clearTimeout(timer);
} }
}, [currentAppState, previousAppState, goBack, isLoading]); }, [currentAppState, previousAppState, navigation, isLoading]);
useFocusEffect( useFocusEffect(
useCallback(() => { useCallback(() => {
if (!isDesktop) disallowScreenshot(isPrivacyBlurEnabled); if (!isDesktop) disallowScreenshot(isPrivacyBlurEnabled);
const task = InteractionManager.runAfterInteractions(async () => { const task = InteractionManager.runAfterInteractions(async () => {
if (wallet) { if (!wallet.getUserHasSavedExport()) {
if (!wallet.getUserHasSavedExport()) { wallet.setUserHasSavedExport(true);
wallet.setUserHasSavedExport(true); saveToDisk();
saveToDisk();
}
setIsLoading(false);
} }
setIsLoading(false);
}); });
return () => { return () => {
if (!isDesktop) disallowScreenshot(false); if (!isDesktop) disallowScreenshot(false);
@ -63,21 +108,66 @@ const WalletExport: React.FC = () => {
}, [isPrivacyBlurEnabled, wallet, saveToDisk]), }, [isPrivacyBlurEnabled, wallet, saveToDisk]),
); );
const secrets: string[] = (() => { const onLayout = useCallback((e: LayoutChangeEvent) => {
try {
const secret = wallet?.getSecret();
if (!secret) return [];
return typeof secret === 'string' ? [secret] : Array.isArray(secret) ? secret : [];
} catch (error) {
console.error('Failed to get wallet secret:', error);
return [];
}
})();
const onLayout = (e: LayoutChangeEvent) => {
const { height, width } = e.nativeEvent.layout; const { height, width } = e.nativeEvent.layout;
setQRCodeSize(height > width ? width - 40 : width / 1.8); setQRCodeSize(height > width ? width - HORIZONTAL_PADDING * 2 : width / 1.8);
}; }, []);
const handleCopy = useCallback(() => {
Clipboard.setString(wallet.getSecret());
triggerHapticFeedback(HapticFeedbackTypes.Selection);
}, [wallet]);
const Scroll = useCallback(
// eslint-disable-next-line react/no-unused-prop-types
({ children }: { children: React.ReactNode | React.ReactNodeArray }) => (
<ScrollView
automaticallyAdjustContentInsets
contentInsetAdjustmentBehavior="automatic"
style={stylesHook.root}
contentContainerStyle={styles.scrollViewContent}
onLayout={onLayout}
testID="WalletExportScroll"
centerContent={isLoading}
>
{children}
</ScrollView>
),
[isLoading, onLayout, stylesHook.root],
);
if (isLoading) {
return (
<Scroll>
<ActivityIndicator />
</Scroll>
);
}
// for SLIP39
if (secrets.length !== 1) {
return (
<Scroll>
<DoNotDisclose />
<View>
<BlueText style={styles.manualText}>{loc.wallets.write_down_header}</BlueText>
<BlueText style={styles.writeText}>{loc.wallets.write_down}</BlueText>
</View>
{secrets.map((secret, index) => (
<React.Fragment key={secret}>
<BlueText style={styles.scanText}>{loc.formatString(loc.wallets.share_number, { number: index + 1 })}</BlueText>
<SeedWords seed={secret} />
</React.Fragment>
))}
<BlueText style={styles.typeText}>{loc.formatString(loc.wallets.wallet_type_this, { type: wallet.typeReadable })}</BlueText>
</Scroll>
);
}
const secret = secrets[0];
return ( return (
<ScrollView <ScrollView
@ -89,69 +179,92 @@ const WalletExport: React.FC = () => {
testID="WalletExportScroll" testID="WalletExportScroll"
centerContent={isLoading} centerContent={isLoading}
> >
{isLoading ? ( {wallet.type !== WatchOnlyWallet.type && <DoNotDisclose />}
<ActivityIndicator />
) : (
wallet && (
<>
<View>
<BlueText style={[styles.type, stylesHook.type]}>{wallet.typeReadable}</BlueText>
</View>
{[LegacyWallet.type, SegwitBech32Wallet.type, SegwitP2SHWallet.type].includes(wallet.type) && ( <BlueText style={styles.scanText}>{loc.wallets.scan_import}</BlueText>
<BlueCard>
<BlueText>{wallet.getAddress()}</BlueText> <QRCodeComponent isMenuAvailable={false} value={secret} size={qrCodeSize} logoSize={70} />
</BlueCard>
)} {/* Do not allow to copy mnemonic */}
<BlueSpacing20 /> {secretIsMnemonic ? (
{secrets.map(secret => ( <>
<React.Fragment key={secret}> <View>
<QRCodeComponent isMenuAvailable={false} value={secret} size={qrCodeSize} logoSize={70} /> <BlueText style={styles.manualText}>{loc.wallets.write_down_header}</BlueText>
{wallet.type !== WatchOnlyWallet.type && ( <BlueText style={styles.writeText}>{loc.wallets.write_down}</BlueText>
<> </View>
<BlueSpacing20 /> <SeedWords seed={secret} />
<BlueText style={stylesHook.warning}>{loc.wallets.warning_do_not_disclose}</BlueText> </>
</> ) : (
)} <>
<BlueSpacing20 /> <BlueText style={styles.writeText}>
{wallet.type === LightningCustodianWallet.type || wallet.type === WatchOnlyWallet.type ? ( {wallet.type === LightningCustodianWallet.type ? loc.wallets.copy_ln_url : loc.wallets.copy_ln_public}
<CopyTextToClipboard text={secret} /> </BlueText>
) : ( <CopyBox text={secret} onPress={handleCopy} />
<BlueText style={[styles.secret, styles.secretWritingDirection, stylesHook.secret]} testID="Secret"> </>
{secret}
</BlueText>
)}
{wallet.type === WatchOnlyWallet.type && (
<HandOffComponent title={loc.wallets.xpub_title} type={HandOffActivityType.Xpub} userInfo={{ xpub: secret }} />
)}
</React.Fragment>
))}
</>
)
)} )}
{wallet.type === WatchOnlyWallet.type && (
<HandOffComponent title={loc.wallets.xpub_title} type={HandOffActivityType.Xpub} userInfo={{ xpub: secret }} />
)}
<BlueText style={styles.typeText}>{loc.formatString(loc.wallets.wallet_type_this, { type: wallet.typeReadable })}</BlueText>
</ScrollView> </ScrollView>
); );
}; };
const styles = StyleSheet.create({ const styles = StyleSheet.create({
scrollViewContent: { scrollViewContent: {
alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
flexGrow: 1, flexGrow: 1,
gap: 32,
paddingHorizontal: HORIZONTAL_PADDING,
paddingTop: 10,
paddingBottom: 20,
}, },
type: { warningBox: {
fontSize: 17, alignItems: 'center',
fontWeight: '700', padding: 12,
}, borderRadius: 10,
secret: {
alignSelf: 'stretch', alignSelf: 'stretch',
textAlign: 'center', flexDirection: 'row',
paddingHorizontal: 16, gap: 8,
fontSize: 16,
lineHeight: 24,
}, },
secretWritingDirection: { warning: {
writingDirection: 'ltr', fontSize: 20,
color: 'white',
},
scanText: {
textAlign: 'center',
fontSize: 20,
},
writeText: {
textAlign: 'center',
fontSize: 17,
},
manualText: {
textAlign: 'center',
fontSize: 20,
marginBottom: 10,
},
typeText: {
textAlign: 'center',
fontSize: 17,
color: 'grey',
},
copyRoot: {
padding: 10,
borderRadius: 8,
flexDirection: 'row',
},
copyLeft: {
flexShrink: 1,
},
copyRight: {
justifyContent: 'center',
marginHorizontal: 8,
},
copyText: {
fontSize: 17,
}, },
}); });