import { useFocusEffect, useRoute } from '@react-navigation/native'; import PropTypes from 'prop-types'; import React, { useCallback, useEffect, useRef, useState } from 'react'; import { ActivityIndicator, Alert, Dimensions, findNodeHandle, FlatList, I18nManager, InteractionManager, LayoutAnimation, PixelRatio, ScrollView, StyleSheet, Text, TouchableOpacity, View, } from 'react-native'; import { Icon } from 'react-native-elements'; import * as BlueElectrum from '../../blue_modules/BlueElectrum'; import BlueClipboard from '../../blue_modules/clipboard'; import { isDesktop } from '../../blue_modules/environment'; import * as fs from '../../blue_modules/fs'; import triggerHapticFeedback, { HapticFeedbackTypes } from '../../blue_modules/hapticFeedback'; import { LightningCustodianWallet, LightningLdkWallet, MultisigHDWallet, WatchOnlyWallet } from '../../class'; import WalletGradient from '../../class/wallet-gradient'; import presentAlert from '../../components/Alert'; import { FButton, FContainer } from '../../components/FloatButtons'; import LNNodeBar from '../../components/LNNodeBar'; import navigationStyle from '../../components/navigationStyle'; import { useTheme } from '../../components/themes'; import { TransactionListItem } from '../../components/TransactionListItem'; import TransactionsNavigationHeader, { actionKeys } from '../../components/TransactionsNavigationHeader'; import { presentWalletExportReminder } from '../../helpers/presentWalletExportReminder'; import { scanQrHelper } from '../../helpers/scan-qr'; import { unlockWithBiometrics, useBiometrics } from '../../hooks/useBiometrics'; import { useExtendedNavigation } from '../../hooks/useExtendedNavigation'; import loc from '../../loc'; import { Chain } from '../../models/bitcoinUnits'; import ActionSheet from '../ActionSheet'; import { useStorage } from '../../hooks/context/useStorage'; import { WalletTransactionsStatus } from '../../components/Context/StorageProvider'; const buttonFontSize = PixelRatio.roundToNearestPixel(Dimensions.get('window').width / 26) > 22 ? 22 : PixelRatio.roundToNearestPixel(Dimensions.get('window').width / 26); const WalletTransactions = ({ navigation }) => { const { wallets, saveToDisk, setSelectedWalletID, walletTransactionUpdateStatus, isElectrumDisabled, setReloadTransactionsMenuActionFunction, } = useStorage(); const { isBiometricUseCapableAndEnabled } = useBiometrics(); const [isLoading, setIsLoading] = useState(false); const { walletID } = useRoute().params; const { name } = useRoute(); const wallet = wallets.find(w => w.getID() === walletID); const [itemPriceUnit, setItemPriceUnit] = useState(wallet.getPreferredBalanceUnit()); const [dataSource, setDataSource] = useState(wallet.getTransactions(15)); const [isRefreshing, setIsRefreshing] = useState(false); // a simple flag to know that wallet was being updated once const [timeElapsed, setTimeElapsed] = useState(0); const [limit, setLimit] = useState(15); const [pageSize, setPageSize] = useState(20); const { setParams, setOptions, navigate } = useExtendedNavigation(); const { colors } = useTheme(); const [lnNodeInfo, setLnNodeInfo] = useState({ canReceive: 0, canSend: 0 }); const walletActionButtonsRef = useRef(); const stylesHook = StyleSheet.create({ listHeaderText: { color: colors.foregroundColor, }, list: { backgroundColor: colors.background, }, }); /** * Simple wrapper for `wallet.getTransactions()`, where `wallet` is current wallet. * Sorts. Provides limiting. * * @param lmt {Integer} How many txs return, starting from the earliest. Default: all of them. * @returns {Array} */ const getTransactionsSliced = (lmt = Infinity) => { let txs = wallet.getTransactions(); for (const tx of txs) { tx.sort_ts = +new Date(tx.received); } txs = txs.sort(function (a, b) { return b.sort_ts - a.sort_ts; }); return txs.slice(0, lmt); }; useEffect(() => { const interval = setInterval(() => setTimeElapsed(prev => prev + 1), 60000); return () => { clearInterval(interval); }; }, []); useEffect(() => { if (walletTransactionUpdateStatus === walletID) { // wallet is being refreshed, drawing the 'Updating...' header: setOptions({ headerTitle: loc.transactions.updating }); setIsRefreshing(true); } else { setOptions({ headerTitle: '' }); } if (isRefreshing && walletTransactionUpdateStatus === WalletTransactionsStatus.NONE) { // if we are here this means that wallet was being updated (`walletTransactionUpdateStatus` was set, and // `isRefreshing` flag was set) and we displayed "Updating..." message, // and when it ended `walletTransactionUpdateStatus` became false (flag `isRefreshing` stayed). // chances are that txs list changed for the wallet, so we need to re-render: console.log('re-rendering transactions'); setDataSource([...getTransactionsSliced(limit)]); setIsRefreshing(false); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [walletTransactionUpdateStatus]); useEffect(() => { setIsLoading(true); setLimit(15); setPageSize(20); setTimeElapsed(0); setItemPriceUnit(wallet.getPreferredBalanceUnit()); setIsLoading(false); setSelectedWalletID(wallet.getID()); setDataSource([...getTransactionsSliced(limit)]); setOptions({ headerStyle: { backgroundColor: WalletGradient.headerColorFor(wallet.type), borderBottomWidth: 0, elevation: 0, // shadowRadius: 0, shadowOffset: { height: 0, width: 0 }, }, }); // eslint-disable-next-line react-hooks/exhaustive-deps }, [wallet]); useEffect(() => { const newWallet = wallets.find(w => w.getID() === walletID); if (newWallet) { setParams({ walletID, isLoading: false }); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [walletID]); // refresh transactions if it never hasn't been done. It could be a fresh imported wallet useEffect(() => { if (wallet.getLastTxFetch() === 0) { refreshTransactions(); } refreshLnNodeInfo(); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); // if description of transaction has been changed we want to show new one useFocusEffect( useCallback(() => { setTimeElapsed(prev => prev + 1); }, []), ); const isLightning = () => { const w = wallet; if (w && w.chain === Chain.OFFCHAIN) { return true; } return false; }; const refreshLnNodeInfo = () => { if (wallet.type === LightningLdkWallet.type) { setLnNodeInfo({ canReceive: wallet.getReceivableBalance(), canSend: wallet.getBalance() }); } }; /** * Forcefully fetches TXs and balance for wallet */ const refreshTransactions = async () => { if (isElectrumDisabled) return setIsLoading(false); if (isLoading) return; setIsLoading(true); let noErr = true; let smthChanged = false; try { refreshLnNodeInfo(); // await BlueElectrum.ping(); await BlueElectrum.waitTillConnected(); if (wallet.allowBIP47() && wallet.isBIP47Enabled()) { const pcStart = +new Date(); await wallet.fetchBIP47SenderPaymentCodes(); const pcEnd = +new Date(); console.log(wallet.getLabel(), 'fetch payment codes took', (pcEnd - pcStart) / 1000, 'sec'); } const balanceStart = +new Date(); const oldBalance = wallet.getBalance(); await wallet.fetchBalance(); if (oldBalance !== wallet.getBalance()) smthChanged = true; const balanceEnd = +new Date(); console.log(wallet.getLabel(), 'fetch balance took', (balanceEnd - balanceStart) / 1000, 'sec'); const start = +new Date(); const oldTxLen = wallet.getTransactions().length; let immatureTxsConfs = ''; // a silly way to keep track if anything changed in immature transactions for (const tx of wallet.getTransactions()) { if (tx.confirmations < 7) immatureTxsConfs += tx.txid + ':' + tx.confirmations + ';'; } await wallet.fetchTransactions(); if (wallet.fetchPendingTransactions) { await wallet.fetchPendingTransactions(); } if (wallet.fetchUserInvoices) { await wallet.fetchUserInvoices(); } if (oldTxLen !== wallet.getTransactions().length) smthChanged = true; let unconfirmedTxsConfs2 = ''; // a silly way to keep track if anything changed in immature transactions for (const tx of wallet.getTransactions()) { if (tx.confirmations < 7) unconfirmedTxsConfs2 += tx.txid + ':' + tx.confirmations + ';'; } if (unconfirmedTxsConfs2 !== immatureTxsConfs) { smthChanged = true; } const end = +new Date(); console.log(wallet.getLabel(), 'fetch tx took', (end - start) / 1000, 'sec'); } catch (err) { noErr = false; presentAlert({ message: err.message }); setIsLoading(false); setTimeElapsed(prev => prev + 1); } if (noErr && smthChanged) { console.log('saving to disk'); await saveToDisk(); // caching setDataSource([...getTransactionsSliced(limit)]); } setIsLoading(false); setTimeElapsed(prev => prev + 1); }; const _keyExtractor = (_item, index) => index.toString(); const renderListFooterComponent = () => { // if not all txs rendered - display indicator return (wallet.getTransactions().length > limit && ) || ; }; const renderListHeaderComponent = () => { const style = {}; if (!isDesktop) { // we need this button for testing style.opacity = 0; style.height = 1; style.width = 1; } else if (isLoading) { style.opacity = 0.5; } else { style.opacity = 1.0; } return ( {wallet.type === LightningLdkWallet.type && (lnNodeInfo.canSend > 0 || lnNodeInfo.canReceive > 0) && ( )} {loc.transactions.list_title} ); }; const onWalletSelect = async selectedWallet => { if (selectedWallet) { navigate('WalletTransactions', { walletType: wallet.type, walletID: wallet.getID(), key: `WalletTransactions-${wallet.getID()}`, }); /** @type {LightningCustodianWallet} */ let toAddress = false; if (wallet.refill_addressess.length > 0) { toAddress = wallet.refill_addressess[0]; } else { try { await wallet.fetchBtcAddress(); toAddress = wallet.refill_addressess[0]; } catch (Err) { return presentAlert({ message: Err.message }); } } navigate('SendDetailsRoot', { screen: 'SendDetails', params: { memo: loc.lnd.refill_lnd_balance, address: toAddress, walletID: selectedWallet.getID(), }, }); } }; const navigateToSendScreen = () => { navigate('SendDetailsRoot', { screen: 'SendDetails', params: { walletID: wallet.getID(), }, }); }; const renderItem = item => ( ); const onBarCodeRead = ret => { if (!isLoading) { setIsLoading(true); const params = { walletID: wallet.getID(), uri: ret.data ? ret.data : ret, }; if (wallet.chain === Chain.ONCHAIN) { navigate('SendDetailsRoot', { screen: 'SendDetails', params }); } else { navigate('ScanLndInvoiceRoot', { screen: 'ScanLndInvoice', params }); } } setIsLoading(false); }; const choosePhoto = () => { fs.showImagePickerAndReadImage() .then(onBarCodeRead) .catch(error => { console.log(error); triggerHapticFeedback(HapticFeedbackTypes.NotificationError); presentAlert({ title: loc.errors.error, message: error.message }); }); }; const copyFromClipboard = async () => { onBarCodeRead({ data: await BlueClipboard().getClipboardContent() }); }; const sendButtonPress = () => { if (wallet.chain === Chain.OFFCHAIN) { return navigate('ScanLndInvoiceRoot', { screen: 'ScanLndInvoice', params: { walletID: wallet.getID() } }); } if (wallet.type === WatchOnlyWallet.type && wallet.isHd() && !wallet.useWithHardwareWalletEnabled()) { return Alert.alert( loc.wallets.details_title, loc.transactions.enable_offline_signing, [ { text: loc._.ok, onPress: async () => { wallet.setUseWithHardwareWalletEnabled(true); await saveToDisk(); navigateToSendScreen(); }, style: 'default', }, { text: loc._.cancel, onPress: () => {}, style: 'cancel' }, ], { cancelable: false }, ); } navigateToSendScreen(); }; const sendButtonLongPress = async () => { const isClipboardEmpty = (await BlueClipboard().getClipboardContent()).trim().length === 0; const options = [loc._.cancel, loc.wallets.list_long_choose, loc.wallets.list_long_scan]; const cancelButtonIndex = 0; if (!isClipboardEmpty) { options.push(loc.wallets.list_long_clipboard); } ActionSheet.showActionSheetWithOptions( { title: loc.send.header, options, cancelButtonIndex, anchor: findNodeHandle(walletActionButtonsRef.current), }, async buttonIndex => { switch (buttonIndex) { case 0: break; case 1: choosePhoto(); break; case 2: scanQrHelper(name, true).then(data => onBarCodeRead(data)); break; case 3: if (!isClipboardEmpty) { copyFromClipboard(); } break; } }, ); }; const navigateToViewEditCosigners = () => { navigate('ViewEditMultisigCosignersRoot', { screen: 'ViewEditMultisigCosigners', params: { walletID, }, }); }; const onManageFundsPressed = ({ id }) => { if (id === actionKeys.Refill) { const availableWallets = [...wallets.filter(item => item.chain === Chain.ONCHAIN && item.allowSend())]; if (availableWallets.length === 0) { presentAlert({ message: loc.lnd.refill_create }); } else { navigate('SelectWallet', { onWalletSelect, chainType: Chain.ONCHAIN }); } } else if (id === actionKeys.RefillWithExternalWallet) { navigate('ReceiveDetailsRoot', { screen: 'ReceiveDetails', params: { walletID, }, }); } }; useEffect(() => { setOptions({ statusBarStyle: 'light', barTintColor: WalletGradient.headerColorFor(wallet.type) }); }, [setOptions, wallet.type]); const getItemLayout = (_, index) => ({ length: 64, offset: 64 * index, index, }); useFocusEffect( useCallback(() => { const task = InteractionManager.runAfterInteractions(() => { setReloadTransactionsMenuActionFunction(() => refreshTransactions); }); return () => { task.cancel(); setReloadTransactionsMenuActionFunction(() => {}); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []), ); // Optimized for Mac option doesn't like RN Refresh component. Menu Elements now handles it for macOS const refreshProps = isDesktop || isElectrumDisabled ? {} : { refreshing: isLoading, onRefresh: refreshTransactions }; return ( InteractionManager.runAfterInteractions(async () => { setItemPriceUnit(passedWallet.getPreferredBalanceUnit()); saveToDisk(); }) } onWalletBalanceVisibilityChange={async isShouldBeVisible => { const isBiometricsEnabled = await isBiometricUseCapableAndEnabled(); if (wallet.hideBalance && isBiometricsEnabled) { const unlocked = await unlockWithBiometrics(); if (!unlocked) { throw new Error('Biometrics failed'); } } wallet.hideBalance = isShouldBeVisible; LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut); await saveToDisk(); }} onManageFundsPressed={id => { if (wallet.type === MultisigHDWallet.type) { navigateToViewEditCosigners(); } else if (wallet.type === LightningLdkWallet.type) { navigate('LdkInfo', { walletID: wallet.getID() }); } else if (wallet.type === LightningCustodianWallet.type) { if (wallet.getUserHasSavedExport()) { onManageFundsPressed({ id }); } else { presentWalletExportReminder() .then(async () => { wallet.setUserHasSavedExport(true); await saveToDisk(); onManageFundsPressed({ id }); }) .catch(() => { navigate('WalletExportRoot', { screen: 'WalletExport', params: { walletID: wallet.getID(), }, }); }); } } }} /> { // pagination in works. in this block we will add more txs to FlatList // so as user scrolls closer to bottom it will render mode transactions if (getTransactionsSliced(Infinity).length < limit) { // all list rendered. nop return; } setDataSource(getTransactionsSliced(limit + pageSize)); setLimit(prev => prev + pageSize); setPageSize(prev => prev * 2); }} ListFooterComponent={renderListFooterComponent} ListEmptyComponent={ {(isLightning() && loc.wallets.list_empty_txs1_lightning) || loc.wallets.list_empty_txs1} {isLightning() && {loc.wallets.list_empty_txs2_lightning}} } {...refreshProps} data={dataSource} extraData={[timeElapsed, dataSource, wallets]} keyExtractor={_keyExtractor} renderItem={renderItem} initialNumToRender={10} removeClippedSubviews contentInset={{ top: 0, left: 0, bottom: 90, right: 0 }} maxToRenderPerBatch={15} windowSize={25} /> {wallet.allowReceive() && ( { if (wallet.chain === Chain.OFFCHAIN) { navigate('LNDCreateInvoiceRoot', { screen: 'LNDCreateInvoice', params: { walletID: wallet.getID() } }); } else { navigate('ReceiveDetailsRoot', { screen: 'ReceiveDetails', params: { walletID: wallet.getID() } }); } }} icon={ } /> )} {(wallet.allowSend() || (wallet.type === WatchOnlyWallet.type && wallet.isHd())) && ( } /> )} ); }; export default WalletTransactions; WalletTransactions.navigationOptions = navigationStyle({}, (options, { theme, navigation, route }) => { return { headerRight: () => ( navigation.navigate('WalletDetails', { walletID: route.params.walletID, }) } > ), title: '', headerStyle: { backgroundColor: WalletGradient.headerColorFor(route.params.walletType), borderBottomWidth: 0, elevation: 0, // shadowRadius: 0, shadowOffset: { height: 0, width: 0 }, }, headerTintColor: '#FFFFFF', headerBackTitleVisible: false, }; }); WalletTransactions.propTypes = { navigation: PropTypes.shape(), }; const styles = StyleSheet.create({ flex: { flex: 1, }, scrollViewContent: { flex: 1, justifyContent: 'center', paddingHorizontal: 16, paddingBottom: 40, }, marginHorizontal18: { marginHorizontal: 18, }, marginBottom18: { marginBottom: 18, }, walletDetails: { justifyContent: 'center', alignItems: 'flex-end', }, activityIndicator: { marginVertical: 20, }, listHeaderTextRow: { flex: 1, marginHorizontal: 16, flexDirection: 'row', justifyContent: 'space-between', }, listHeaderText: { marginTop: 8, marginBottom: 8, fontWeight: 'bold', fontSize: 24, }, list: { flex: 1, }, emptyTxs: { fontSize: 18, color: '#9aa0aa', textAlign: 'center', marginVertical: 16, }, emptyTxsLightning: { fontSize: 18, color: '#9aa0aa', textAlign: 'center', fontWeight: '600', }, sendIcon: { transform: [{ rotate: I18nManager.isRTL ? '-225deg' : '225deg' }], }, receiveIcon: { transform: [{ rotate: I18nManager.isRTL ? '45deg' : '-45deg' }], }, });