import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { RouteProp, useRoute } from '@react-navigation/native'; import { NativeStackNavigationProp } from '@react-navigation/native-stack'; import { Avatar, Badge, Icon, ListItem as RNElementsListItem } from '@rneui/themed'; import { ActivityIndicator, FlatList, Keyboard, LayoutAnimation, PixelRatio, Platform, StyleSheet, Text, TextInput, TouchableWithoutFeedback, useWindowDimensions, View, } from 'react-native'; import * as RNLocalize from 'react-native-localize'; import debounce from '../../blue_modules/debounce'; import { BlueSpacing10, BlueSpacing20 } from '../../BlueComponents'; import { TWallet, Utxo } from '../../class/wallets/types'; import BottomModal, { BottomModalHandle } from '../../components/BottomModal'; import Button from '../../components/Button'; import { FButton, FContainer } from '../../components/FloatButtons'; import HeaderMenuButton from '../../components/HeaderMenuButton'; import ListItem from '../../components/ListItem'; import SafeArea from '../../components/SafeArea'; import { useTheme } from '../../components/themes'; import { Action } from '../../components/types'; import { useStorage } from '../../hooks/context/useStorage'; import { useExtendedNavigation } from '../../hooks/useExtendedNavigation'; import loc, { formatBalance } from '../../loc'; import { BitcoinUnit } from '../../models/bitcoinUnits'; import { SendDetailsStackParamList } from '../../navigation/SendDetailsStackParamList'; import { CommonToolTipActions } from '../../typings/CommonToolTipActions'; type NavigationProps = NativeStackNavigationProp; type RouteProps = RouteProp; const FrozenBadge: React.FC = () => { const { colors } = useTheme(); const oStyles = StyleSheet.create({ freeze: { backgroundColor: colors.redBG, borderWidth: 0, marginLeft: 4 }, freezeText: { color: colors.redText, marginTop: -1 }, }); return ; }; const ChangeBadge: React.FC = () => { const { colors } = useTheme(); const oStyles = StyleSheet.create({ change: { backgroundColor: colors.buttonDisabledBackgroundColor, borderWidth: 0, marginLeft: 4 }, changeText: { color: colors.alternativeTextColor, marginTop: -1 }, }); return ; }; type TOutputListProps = { item: Utxo; balanceUnit: string; oMemo?: string; frozen: boolean; change: boolean; onOpen: () => void; selected: boolean; selectionStarted: boolean; onSelect: () => void; onDeSelect: () => void; }; const OutputList: React.FC = ({ item: { address, txid, value }, balanceUnit = BitcoinUnit.BTC, oMemo, frozen, change, onOpen, selected, selectionStarted, onSelect, onDeSelect, }: TOutputListProps) => { const { colors } = useTheme(); const { txMetadata } = useStorage(); const memo = oMemo || txMetadata[txid]?.memo || ''; const color = `#${txid.substring(0, 6)}`; const amount = formatBalance(value, balanceUnit, true); const oStyles = StyleSheet.create({ container: { borderBottomColor: colors.lightBorder, backgroundColor: colors.elevated }, containerSelected: { backgroundColor: colors.ballOutgoingExpired, borderBottomColor: 'rgba(0, 0, 0, 0)', }, avatar: { borderColor: 'white', borderWidth: 1, backgroundColor: color }, amount: { fontWeight: 'bold', color: colors.foregroundColor }, memo: { fontSize: 13, marginTop: 3, color: colors.alternativeTextColor }, }); let onPress = onOpen; if (selectionStarted) { onPress = selected ? onDeSelect : onSelect; } return ( {amount} {memo || address} {frozen && } {change && } ); }; type TOutputModalProps = { item: Utxo; balanceUnit: string; oMemo?: string; }; const OutputModal: React.FC = ({ item: { address, txid, value, vout, confirmations = 0 }, balanceUnit = BitcoinUnit.BTC, oMemo, }) => { const { colors } = useTheme(); const { txMetadata } = useStorage(); const memo = oMemo || txMetadata[txid]?.memo || ''; const fullId = `${txid}:${vout}`; const color = `#${txid.substring(0, 6)}`; const amount = formatBalance(value, balanceUnit, true); const oStyles = StyleSheet.create({ container: { paddingHorizontal: 0, borderBottomColor: colors.lightBorder, backgroundColor: 'transparent' }, avatar: { borderColor: 'white', borderWidth: 1, backgroundColor: color }, amount: { fontWeight: 'bold', color: colors.foregroundColor }, tranContainer: { paddingLeft: 20 }, tranText: { fontWeight: 'normal', fontSize: 13, color: colors.alternativeTextColor }, memo: { fontSize: 13, marginTop: 3, color: colors.alternativeTextColor }, }); const confirmationsFormatted = new Intl.NumberFormat(RNLocalize.getLocales()[0].languageCode, { maximumSignificantDigits: 3 }).format( confirmations, ); return ( {amount} {loc.formatString(loc.transactions.list_conf, { number: confirmationsFormatted })} {memo ? ( <> {memo} ) : null} {address} {fullId} ); }; const mStyles = StyleSheet.create({ memoTextInput: { flexDirection: 'row', borderWidth: 1, borderBottomWidth: 0.5, minHeight: 44, height: 44, alignItems: 'center', marginVertical: 8, borderRadius: 4, paddingHorizontal: 8, color: '#81868e', }, buttonContainer: { height: 45, marginBottom: 36, marginHorizontal: 24, }, }); type TOutputModalContentProps = { output: Utxo; wallet: TWallet; onUseCoin: (u: Utxo[]) => void; frozen: boolean; setFrozen: (value: boolean) => void; }; const transparentBackground = { backgroundColor: 'transparent' }; const OutputModalContent: React.FC = ({ output, wallet, onUseCoin, frozen, setFrozen }) => { const { colors } = useTheme(); const { txMetadata, saveToDisk } = useStorage(); const [memo, setMemo] = useState(wallet.getUTXOMetadata(output.txid, output.vout).memo || txMetadata[output.txid]?.memo || ''); const switchValue = useMemo(() => ({ value: frozen, onValueChange: (value: boolean) => setFrozen(value) }), [frozen, setFrozen]); const onMemoChange = (value: string) => setMemo(value); // save on form change. Because effect called on each event, debounce it. const debouncedSaveMemo = useRef( debounce(async m => { wallet.setUTXOMetadata(output.txid, output.vout, { memo: m }); await saveToDisk(); }, 500), ); useEffect(() => { debouncedSaveMemo.current(memo); }, [memo]); return ( ); }; enum ESortDirections { asc = 'asc', desc = 'desc', } enum ESortTypes { height = 'height', label = 'label', value = 'value', frozen = 'frozen', } const CoinControl: React.FC = () => { const { colors } = useTheme(); const navigation = useExtendedNavigation(); const { width } = useWindowDimensions(); const bottomModalRef = useRef(null); const { walletID } = useRoute().params; const { wallets, saveToDisk, sleep } = useStorage(); const [sortDirection, setSortDirection] = useState(ESortDirections.asc); const [sortType, setSortType] = useState(ESortTypes.height); const wallet = useMemo(() => wallets.find(w => w.getID() === walletID) as TWallet, [walletID, wallets]); const [frozen, setFrozen] = useState( wallet .getUtxo(true) .filter(out => wallet.getUTXOMetadata(out.txid, out.vout).frozen) .map(({ txid, vout }) => `${txid}:${vout}`), ); const utxos: Utxo[] = useMemo(() => { const res = wallet.getUtxo(true).sort((a, b) => { switch (sortType) { case ESortTypes.height: return a.height - b.height || a.txid.localeCompare(b.txid) || a.vout - b.vout; case ESortTypes.value: return a.value - b.value || a.txid.localeCompare(b.txid) || a.vout - b.vout; case ESortTypes.label: { const aMemo = wallet.getUTXOMetadata(a.txid, a.vout).memo || ''; const bMemo = wallet.getUTXOMetadata(b.txid, b.vout).memo || ''; return aMemo.localeCompare(bMemo) || a.txid.localeCompare(b.txid) || a.vout - b.vout; } case ESortTypes.frozen: { const aF = frozen.includes(`${a.txid}:${a.vout}`); const bF = frozen.includes(`${b.txid}:${b.vout}`); return aF !== bF ? (aF ? -1 : 1) : a.txid.localeCompare(b.txid) || a.vout - b.vout; } default: return 0; } }); // invert if descending return sortDirection === ESortDirections.desc ? res.reverse() : res; }, [sortDirection, sortType, wallet, frozen]); const [output, setOutput] = useState(); const [loading, setLoading] = useState(true); const [selected, setSelected] = useState([]); // save frozen status. Because effect called on each event, debounce it. const debouncedSaveFronen = useRef( debounce(async frzn => { utxos.forEach(({ txid, vout }) => { wallet.setUTXOMetadata(txid, vout, { frozen: frzn.includes(`${txid}:${vout}`) }); }); await saveToDisk(); }, 500), ); useEffect(() => { debouncedSaveFronen.current(frozen); }, [frozen]); useEffect(() => { (async () => { try { await Promise.race([wallet.fetchUtxo(), sleep(10000)]); } catch (e) { console.log('coincontrol wallet.fetchUtxo() failed'); // either sleep expired or fetchUtxo threw an exception } const freshUtxo = wallet.getUtxo(true); setFrozen(freshUtxo.filter(out => wallet.getUTXOMetadata(out.txid, out.vout).frozen).map(({ txid, vout }) => `${txid}:${vout}`)); setLoading(false); })(); }, [wallet, setLoading, sleep]); const stylesHook = StyleSheet.create({ tip: { backgroundColor: colors.ballOutgoingExpired, }, }); const tipCoins = () => { if (utxos.length === 0) return null; let text = loc.cc.tip; if (selected.length > 0) { // show summ of coins if any selected const summ = selected.reduce((prev, curr) => { return prev + (utxos.find(({ txid, vout }) => `${txid}:${vout}` === curr) as Utxo).value; }, 0); const value = formatBalance(summ, wallet.getPreferredBalanceUnit(), true); text = loc.formatString(loc.cc.selected_summ, { value }); } return ( {text} ); }; const handleChoose = (item: Utxo) => setOutput(item); const handleUseCoin = async (u: Utxo[]) => { setOutput(undefined); // @ts-ignore navigation WTF navigation.navigate('SendDetailsRoot', { screen: 'SendDetails', params: { utxos: u, }, merge: true, }); }; const handleMassFreeze = () => { if (allFrozen) { setFrozen(f => f.filter(i => !selected.includes(i))); // unfreeze } else { setFrozen(f => [...new Set([...f, ...selected])]); // freeze } }; const handleMassUse = () => { const fUtxo = utxos.filter(({ txid, vout }) => selected.includes(`${txid}:${vout}`)); handleUseCoin(fUtxo); }; // check if any outputs are selected const selectionStarted = selected.length > 0; // check if all selected items are frozen const allFrozen = selectionStarted && selected.reduce((prev, curr) => (prev ? frozen.includes(curr) : false), true); const buttonFontSize = PixelRatio.roundToNearestPixel(width / 26) > 22 ? 22 : PixelRatio.roundToNearestPixel(width / 26); const renderItem = (p: { item: Utxo }) => { const { memo } = wallet.getUTXOMetadata(p.item.txid, p.item.vout); const change = wallet.addressIsChange(p.item.address); const oFrozen = frozen.includes(`${p.item.txid}:${p.item.vout}`); return ( handleChoose(p.item)} selected={selected.includes(`${p.item.txid}:${p.item.vout}`)} selectionStarted={selectionStarted} onSelect={() => { setSelected(s => { if (s.length === 0) { LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut); // animate buttons show } return [...s, `${p.item.txid}:${p.item.vout}`]; }); }} onDeSelect={() => { setSelected(s => { const newValue = s.filter(i => i !== `${p.item.txid}:${p.item.vout}`); if (newValue.length === 0) { LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut); // animate buttons show } return newValue; }); }} /> ); }; const renderOutputModalContent = (o: Utxo | undefined) => { if (!o) { return null; } const oFrozen = frozen.includes(`${o.txid}:${o.vout}`); const setOFrozen = (value: boolean) => { if (value) { setFrozen(f => [...f, `${o.txid}:${o.vout}`]); } else { setFrozen(f => f.filter(i => i !== `${o.txid}:${o.vout}`)); } }; return ; }; useEffect(() => { if (output) { bottomModalRef.current?.present(); } }, [output]); const toolTipActions = useMemo((): Action[] | Action[][] => { return [ [sortDirection === ESortDirections.asc ? CommonToolTipActions.SortASC : CommonToolTipActions.SortDESC], [ { ...CommonToolTipActions.SortHeight, menuState: sortType === ESortTypes.height }, { ...CommonToolTipActions.SortValue, menuState: sortType === ESortTypes.value }, { ...CommonToolTipActions.SortLabel, menuState: sortType === ESortTypes.label }, { ...CommonToolTipActions.SortStatus, menuState: sortType === ESortTypes.frozen }, ], ]; }, [sortDirection, sortType]); const toolTipOnPressMenuItem = useCallback((menuItem: string) => { Keyboard.dismiss(); if (menuItem === CommonToolTipActions.SortASC.id) { setSortDirection(ESortDirections.desc); } else if (menuItem === CommonToolTipActions.SortDESC.id) { setSortDirection(ESortDirections.asc); } else if (menuItem === CommonToolTipActions.SortHeight.id) { setSortType(ESortTypes.height); } else if (menuItem === CommonToolTipActions.SortValue.id) { setSortType(ESortTypes.value); } else if (menuItem === CommonToolTipActions.SortLabel.id) { setSortType(ESortTypes.label); } else if (menuItem === CommonToolTipActions.SortStatus.id) { setSortType(ESortTypes.frozen); } }, []); const HeaderRight = useMemo( () => , [toolTipOnPressMenuItem, toolTipActions], ); // Adding the ToolTipMenu to the header useEffect(() => { navigation.setOptions({ headerRight: () => HeaderRight, }); }, [HeaderRight, navigation]); if (loading) { return ( ); } return ( {utxos.length === 0 && ( {loc.cc.empty} )} { Keyboard.dismiss(); setOutput(undefined); }} backgroundColor={colors.elevated} contentContainerStyle={styles.modalMinHeight} footer={