diff --git a/loc/en.json b/loc/en.json index b6df5a7a0..e019ad0d0 100644 --- a/loc/en.json +++ b/loc/en.json @@ -491,8 +491,10 @@ "empty": "This wallet doesn't have any coins at the moment", "freeze": "Freeze", "freezeLabel": "Freeze", + "freezeLabel_un": "Unfreeze", "header": "Coin Control", "use_coin": "Use Coin", + "use_coins": "Use Coins", "tip": "Allows you to see, label, freeze or select coins for improved wallet management." } } diff --git a/screen/send/coinControl.js b/screen/send/coinControl.js index 36bdd8bb3..37f85ad0c 100644 --- a/screen/send/coinControl.js +++ b/screen/send/coinControl.js @@ -1,17 +1,20 @@ import React, { useMemo, useState, useContext, useEffect, useRef } from 'react'; import PropTypes from 'prop-types'; -import { ListItem, Avatar, Badge } from 'react-native-elements'; +import { Avatar, Badge, Icon, ListItem } from 'react-native-elements'; import { ActivityIndicator, FlatList, Keyboard, KeyboardAvoidingView, + LayoutAnimation, + PixelRatio, Platform, StyleSheet, Text, TextInput, TouchableWithoutFeedback, useColorScheme, + useWindowDimensions, View, } from 'react-native'; import { useRoute, useTheme, useNavigation } from '@react-navigation/native'; @@ -20,6 +23,7 @@ import loc, { formatBalance } from '../../loc'; import { BitcoinUnit } from '../../models/bitcoinUnits'; import { BlueNavigationStyle, SafeBlueArea, BlueSpacing10, BlueSpacing20, BlueButton, BlueListItem } from '../../BlueComponents'; import BottomModal from '../../components/BottomModal'; +import { FContainer, FButton } from '../../components/FloatButtons'; import { BlueStorageContext } from '../../blue_modules/storage-context'; // https://levelup.gitconnected.com/debounce-in-javascript-improve-your-applications-performance-5b01855e086 @@ -35,87 +39,157 @@ const debounce = (func, wait) => { }; }; -const Output = ({ - item: { address, txid, value, vout }, - balanceUnit = BitcoinUnit.BTC, - oMemo, - frozen, - change = false, - full = false, - onPress, -}) => { +const FrozenBadge = () => { const { colors } = useTheme(); - const { txMetadata } = useContext(BlueStorageContext); const cs = useColorScheme(); - const memo = oMemo || txMetadata[txid]?.memo || ''; - const fullId = `${txid}:${vout}`; - const shortId = `${address.substring(0, 9)}...${address.substr(address.length - 9)}`; - const color = `#${txid.substring(0, 6)}`; - const amount = formatBalance(value, balanceUnit, true); - const oStyles = StyleSheet.create({ - containerFull: { paddingHorizontal: 0 }, - avatar: { borderColor: 'white', borderWidth: 1 }, - amount: { fontWeight: 'bold' }, - memo: { fontSize: 13, marginTop: 3 }, - changeLight: { backgroundColor: colors.buttonDisabledBackgroundColor }, - changeDark: { backgroundColor: colors.buttonDisabledBackgroundColor, borderWidth: 0 }, - changeText: { color: colors.alternativeTextColor }, freezeLight: { backgroundColor: colors.redBG }, freezeDark: { backgroundColor: colors.redBG, borderWidth: 0 }, freezeText: { color: colors.redText }, }); + return ; +}; + +const ChangeBadge = () => { + const { colors } = useTheme(); + const cs = useColorScheme(); + const oStyles = StyleSheet.create({ + changeLight: { backgroundColor: colors.buttonDisabledBackgroundColor }, + changeDark: { backgroundColor: colors.buttonDisabledBackgroundColor, borderWidth: 0 }, + changeText: { color: colors.alternativeTextColor }, + }); + + return ; +}; + +const OutputList = ({ + item: { address, txid, value, vout, confirmations }, + balanceUnit = BitcoinUnit.BTC, + oMemo, + frozen, + change, + onOpen, + selected, + selectionStarted, + onSelect, + onDeSelect, +}) => { + const { colors } = useTheme(); + const { txMetadata } = useContext(BlueStorageContext); + const memo = oMemo || txMetadata[txid]?.memo || ''; + const shortId = `${address.substring(0, 9)}...${address.substr(address.length - 9)}`; + const color = `#${txid.substring(0, 6)}`; + const amount = formatBalance(value, balanceUnit, true); + + const oStyles = StyleSheet.create({ + container: { borderBottomColor: colors.lightBorder, backgroundColor: colors.elevated }, + containerSelected: { + borderBottomColor: 'rgba(0, 0, 0, 0)', + backgroundColor: colors.ballOutgoingExpired, + borderTopLeftRadius: 10, + borderBottomLeftRadius: 10, + }, + 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} - {full ? ( - <> - {memo ? ( - <> - {memo} - - - ) : null} - {address} - - {fullId} - - ) : ( - - {memo || shortId} - - )} + {amount} + + {memo || shortId} + - {change && ( - - )} - {frozen && ( - - )} + {change && } + {frozen && } ); }; -Output.propTypes = { +OutputList.propTypes = { item: PropTypes.shape({ address: PropTypes.string.isRequired, txid: PropTypes.string.isRequired, value: PropTypes.number.isRequired, vout: PropTypes.number.isRequired, + confirmations: PropTypes.number.isRequired, }), balanceUnit: PropTypes.string, oMemo: PropTypes.string, frozen: PropTypes.bool, change: PropTypes.bool, - full: PropTypes.bool, - onPress: PropTypes.func, + onOpen: PropTypes.func, + selected: PropTypes.bool, + selectionStarted: PropTypes.bool, + onSelect: PropTypes.func, + onDeSelect: PropTypes.func, +}; + +const OutputModal = ({ item: { address, txid, value, vout, confirmations }, balanceUnit = BitcoinUnit.BTC, oMemo }) => { + const { colors } = useTheme(); + const { txMetadata } = useContext(BlueStorageContext); + 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: colors.elevated }, + 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 }, + }); + + return ( + + + + + {amount} + + {loc.formatString(loc.transactions.list_conf, { number: confirmations })} + + + {memo ? ( + <> + {memo} + + + ) : null} + {address} + + {fullId} + + + ); +}; + +OutputModal.propTypes = { + item: PropTypes.shape({ + address: PropTypes.string.isRequired, + txid: PropTypes.string.isRequired, + value: PropTypes.number.isRequired, + vout: PropTypes.number.isRequired, + confirmations: PropTypes.number.isRequired, + }), + balanceUnit: PropTypes.string, + oMemo: PropTypes.string, }; const mStyles = StyleSheet.create({ @@ -136,28 +210,27 @@ const mStyles = StyleSheet.create({ }, }); -const OutputModalContent = ({ output, wallet, onUseCoin }) => { +const OutputModalContent = ({ output, wallet, onUseCoin, frozen, setFrozen }) => { const { colors } = useTheme(); const { txMetadata, saveToDisk } = useContext(BlueStorageContext); - const [frozen, setFrozen] = useState(wallet.getUTXOMetadata(output.txid, output.vout).frozen || false); const [memo, setMemo] = useState(wallet.getUTXOMetadata(output.txid, output.vout).memo || txMetadata[output.txid]?.memo || ''); const onMemoChange = value => setMemo(value); const switchValue = useMemo(() => ({ value: frozen, onValueChange: value => setFrozen(value) }), [frozen, setFrozen]); // save on form change. Because effect called on each event, debounce it. - const debouncedSave = useRef( - debounce(async (frozen, memo) => { - wallet.setUTXOMetadata(output.txid, output.vout, { frozen, memo }); + const debouncedSaveMemo = useRef( + debounce(async memo => { + wallet.setUTXOMetadata(output.txid, output.vout, { memo }); await saveToDisk(); }, 500), ); useEffect(() => { - debouncedSave.current(frozen, memo); - }, [frozen, memo]); + debouncedSaveMemo.current(memo); + }, [memo]); return ( <> - + { const { colors } = useTheme(); const navigation = useNavigation(); + const { width } = useWindowDimensions(); const { walletId, onUTXOChoose } = useRoute().params; - const { wallets } = useContext(BlueStorageContext); + const { wallets, saveToDisk } = useContext(BlueStorageContext); const wallet = wallets.find(w => w.getID() === walletId); // sort by height ascending, txid , vout ascending const utxo = wallet.getUtxo(true).sort((a, b) => a.height - b.height || a.txid.localeCompare(b.txid) || a.vout - b.vout); const [output, setOutput] = useState(); const [loading, setLoading] = useState(true); + const [selected, setSelected] = useState([]); + const [frozen, setFrozen] = useState( + utxo.filter(output => wallet.getUTXOMetadata(output.txid, output.vout).frozen).map(({ txid, vout }) => `${txid}:${vout}`), + ); + + // save frozen status. Because effect called on each event, debounce it. + const debouncedSaveFronen = useRef( + debounce(async frozen => { + utxo.forEach(({ txid, vout }) => { + wallet.setUTXOMetadata(txid, vout, { frozen: frozen.includes(`${txid}:${vout}`) }); + }); + await saveToDisk(); + }, 500), + ); + useEffect(() => { + debouncedSaveFronen.current(frozen); + }, [frozen]); + + useEffect(() => { + wallet.fetchUtxo().then(() => { + const freshUtxo = wallet.getUtxo(true); + setFrozen( + freshUtxo.filter(output => wallet.getUTXOMetadata(output.txid, output.vout).frozen).map(({ txid, vout }) => `${txid}:${vout}`), + ); + setLoading(false); + }); + }, [wallet, setLoading]); const stylesHook = StyleSheet.create({ tip: { @@ -217,10 +320,6 @@ const CoinControl = () => { ); }; - useEffect(() => { - wallet.fetchUtxo().then(() => setLoading(false)); - }, [wallet, setLoading]); - const handleChoose = item => setOutput(item); const handleUseCoin = utxo => { @@ -229,21 +328,60 @@ const CoinControl = () => { onUTXOChoose(utxo); }; + 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 = utxo.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 => { - const { memo, frozen } = wallet.getUTXOMetadata(p.item.txid, p.item.vout); + 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)} + onOpen={() => handleChoose(p.item)} + selected={selected.includes(`${p.item.txid}:${p.item.vout}`)} + selectionStarted={selectionStarted} + onSelect={() => { + LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut); // animate buttons show + setSelected(selected => [...selected, `${p.item.txid}:${p.item.vout}`]); + }} + onDeSelect={() => setSelected(selected => selected.filter(i => i !== `${p.item.txid}:${p.item.vout}`))} /> ); }; + const renderOutputModalContent = () => { + const oFrozen = frozen.includes(`${output.txid}:${output.vout}`); + const setOFrozen = value => { + if (value) { + setFrozen(f => [...f, `${output.txid}:${output.vout}`]); + } else { + setFrozen(f => f.filter(i => i !== `${output.txid}:${output.vout}`)); + } + }; + return ; + }; + if (loading) { return ( @@ -253,7 +391,7 @@ const CoinControl = () => { } return ( - + {utxo.length === 0 && ( {loc.cc.empty} @@ -268,14 +406,37 @@ const CoinControl = () => { }} > - - {output && } - + {output && renderOutputModalContent()} - `${item.txid}:${item.vout}`} /> - + `${item.txid}:${item.vout}`} + contentInset={{ top: 0, left: 0, bottom: 70, right: 0 }} + /> + + {selectionStarted && ( + + } + /> + 1 ? loc.cc.use_coins : loc.cc.use_coin} + icon={ + + + + } + /> + + )} + ); }; @@ -306,6 +467,9 @@ const styles = StyleSheet.create({ padding: 16, marginVertical: 24, }, + sendIcon: { + transform: [{ rotate: '225deg' }], + }, }); CoinControl.navigationOptions = () => ({