mirror of
https://github.com/BlueWallet/BlueWallet.git
synced 2025-01-19 05:45:15 +01:00
ADD: CoinControl multiselect
This commit is contained in:
parent
caefb28476
commit
291d625465
@ -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."
|
||||
}
|
||||
}
|
||||
|
@ -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 <Badge value={loc.cc.freeze} badgeStyle={oStyles[cs === 'dark' ? 'freezeDark' : 'freezeLight']} textStyle={oStyles.freezeText} />;
|
||||
};
|
||||
|
||||
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 <Badge value={loc.cc.change} badgeStyle={oStyles[cs === 'dark' ? 'changeDark' : 'changeLight']} textStyle={oStyles.changeText} />;
|
||||
};
|
||||
|
||||
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 (
|
||||
<ListItem
|
||||
bottomDivider
|
||||
onPress={onPress}
|
||||
containerStyle={[{ borderBottomColor: colors.lightBorder, backgroundColor: colors.elevated }, full && oStyles.containerFull]}
|
||||
>
|
||||
<Avatar rounded overlayContainerStyle={[oStyles.avatar, { backgroundColor: color }]} />
|
||||
<ListItem bottomDivider onPress={onPress} containerStyle={selected ? oStyles.containerSelected : oStyles.container}>
|
||||
<Avatar
|
||||
rounded
|
||||
overlayContainerStyle={oStyles.avatar}
|
||||
onPress={selected ? onDeSelect : onSelect}
|
||||
icon={selected ? { name: 'check' } : undefined}
|
||||
/>
|
||||
<ListItem.Content>
|
||||
<ListItem.Title style={[oStyles.amount, { color: colors.foregroundColor }]}>{amount}</ListItem.Title>
|
||||
{full ? (
|
||||
<>
|
||||
{memo ? (
|
||||
<>
|
||||
<ListItem.Subtitle style={[oStyles.memo, { color: colors.alternativeTextColor }]}>{memo}</ListItem.Subtitle>
|
||||
<BlueSpacing10 />
|
||||
</>
|
||||
) : null}
|
||||
<ListItem.Subtitle style={[oStyles.memo, { color: colors.alternativeTextColor }]}>{address}</ListItem.Subtitle>
|
||||
<BlueSpacing10 />
|
||||
<ListItem.Subtitle style={[oStyles.memo, { color: colors.alternativeTextColor }]}>{fullId}</ListItem.Subtitle>
|
||||
</>
|
||||
) : (
|
||||
<ListItem.Subtitle style={[oStyles.memo, { color: colors.alternativeTextColor }]} numberOfLines={1}>
|
||||
{memo || shortId}
|
||||
</ListItem.Subtitle>
|
||||
)}
|
||||
<ListItem.Title style={oStyles.amount}>{amount}</ListItem.Title>
|
||||
<ListItem.Subtitle style={oStyles.memo} numberOfLines={1}>
|
||||
{memo || shortId}
|
||||
</ListItem.Subtitle>
|
||||
</ListItem.Content>
|
||||
{change && (
|
||||
<Badge value={loc.cc.change} badgeStyle={oStyles[cs === 'dark' ? 'changeDark' : 'changeLight']} textStyle={oStyles.changeText} />
|
||||
)}
|
||||
{frozen && (
|
||||
<Badge value={loc.cc.freeze} badgeStyle={oStyles[cs === 'dark' ? 'freezeDark' : 'freezeLight']} textStyle={oStyles.freezeText} />
|
||||
)}
|
||||
{change && <ChangeBadge />}
|
||||
{frozen && <FrozenBadge />}
|
||||
</ListItem>
|
||||
);
|
||||
};
|
||||
|
||||
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 (
|
||||
<ListItem bottomDivider containerStyle={oStyles.container}>
|
||||
<Avatar rounded overlayContainerStyle={oStyles.avatar} />
|
||||
<ListItem.Content>
|
||||
<ListItem.Title style={oStyles.amount}>
|
||||
{amount}
|
||||
<View style={oStyles.tranContainer}>
|
||||
<Text style={oStyles.tranText}>{loc.formatString(loc.transactions.list_conf, { number: confirmations })}</Text>
|
||||
</View>
|
||||
</ListItem.Title>
|
||||
{memo ? (
|
||||
<>
|
||||
<ListItem.Subtitle style={oStyles.memo}>{memo}</ListItem.Subtitle>
|
||||
<BlueSpacing10 />
|
||||
</>
|
||||
) : null}
|
||||
<ListItem.Subtitle style={oStyles.memo}>{address}</ListItem.Subtitle>
|
||||
<BlueSpacing10 />
|
||||
<ListItem.Subtitle style={oStyles.memo}>{fullId}</ListItem.Subtitle>
|
||||
</ListItem.Content>
|
||||
</ListItem>
|
||||
);
|
||||
};
|
||||
|
||||
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 (
|
||||
<>
|
||||
<Output item={output} balanceUnit={wallet.getPreferredBalanceUnit()} full />
|
||||
<OutputModal item={output} balanceUnit={wallet.getPreferredBalanceUnit()} />
|
||||
<BlueSpacing20 />
|
||||
<TextInput
|
||||
testID="OutputMemo"
|
||||
@ -188,18 +261,48 @@ OutputModalContent.propTypes = {
|
||||
output: PropTypes.object,
|
||||
wallet: PropTypes.object,
|
||||
onUseCoin: PropTypes.func.isRequired,
|
||||
frozen: PropTypes.bool.isRequired,
|
||||
setFrozen: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
const CoinControl = () => {
|
||||
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 (
|
||||
<Output
|
||||
<OutputList
|
||||
balanceUnit={wallet.getPreferredBalanceUnit()}
|
||||
item={p.item}
|
||||
oMemo={memo}
|
||||
frozen={frozen}
|
||||
frozen={oFrozen}
|
||||
change={change}
|
||||
onPress={() => 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 <OutputModalContent output={output} wallet={wallet} onUseCoin={handleUseCoin} frozen={oFrozen} setFrozen={setOFrozen} />;
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<SafeBlueArea style={[styles.root, styles.center, { backgroundColor: colors.elevated }]}>
|
||||
@ -253,7 +391,7 @@ const CoinControl = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<SafeBlueArea style={[styles.root, { backgroundColor: colors.elevated }]}>
|
||||
<View style={[styles.root, { backgroundColor: colors.elevated }]}>
|
||||
{utxo.length === 0 && (
|
||||
<View style={styles.empty}>
|
||||
<Text style={{ color: colors.foregroundColor }}>{loc.cc.empty}</Text>
|
||||
@ -268,14 +406,37 @@ const CoinControl = () => {
|
||||
}}
|
||||
>
|
||||
<KeyboardAvoidingView behavior={Platform.OS === 'ios' ? 'position' : null}>
|
||||
<View style={[styles.modalContent, { backgroundColor: colors.elevated }]}>
|
||||
{output && <OutputModalContent output={output} wallet={wallet} onUseCoin={handleUseCoin} />}
|
||||
</View>
|
||||
<View style={[styles.modalContent, { backgroundColor: colors.elevated }]}>{output && renderOutputModalContent()}</View>
|
||||
</KeyboardAvoidingView>
|
||||
</BottomModal>
|
||||
|
||||
<FlatList ListHeaderComponent={tipCoins} data={utxo} renderItem={renderItem} keyExtractor={item => `${item.txid}:${item.vout}`} />
|
||||
</SafeBlueArea>
|
||||
<FlatList
|
||||
ListHeaderComponent={tipCoins}
|
||||
data={utxo}
|
||||
renderItem={renderItem}
|
||||
keyExtractor={item => `${item.txid}:${item.vout}`}
|
||||
contentInset={{ top: 0, left: 0, bottom: 70, right: 0 }}
|
||||
/>
|
||||
|
||||
{selectionStarted && (
|
||||
<FContainer>
|
||||
<FButton
|
||||
onPress={handleMassFreeze}
|
||||
text={allFrozen ? loc.cc.freezeLabel_un : loc.cc.freezeLabel}
|
||||
icon={<Icon name="snowflake" size={buttonFontSize} type="font-awesome-5" color={colors.buttonAlternativeTextColor} />}
|
||||
/>
|
||||
<FButton
|
||||
onPress={handleMassUse}
|
||||
text={selected.length > 1 ? loc.cc.use_coins : loc.cc.use_coin}
|
||||
icon={
|
||||
<View style={styles.sendIcon}>
|
||||
<Icon name="arrow-down" size={buttonFontSize} type="font-awesome" color={colors.buttonAlternativeTextColor} />
|
||||
</View>
|
||||
}
|
||||
/>
|
||||
</FContainer>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
@ -306,6 +467,9 @@ const styles = StyleSheet.create({
|
||||
padding: 16,
|
||||
marginVertical: 24,
|
||||
},
|
||||
sendIcon: {
|
||||
transform: [{ rotate: '225deg' }],
|
||||
},
|
||||
});
|
||||
|
||||
CoinControl.navigationOptions = () => ({
|
||||
|
Loading…
Reference in New Issue
Block a user