BlueWallet/screen/send/coinControl.js

481 lines
16 KiB
JavaScript
Raw Normal View History

2020-10-28 09:10:12 +01:00
import React, { useMemo, useState, useContext, useEffect, useRef } from 'react';
2020-10-22 14:24:47 +02:00
import PropTypes from 'prop-types';
2020-12-12 19:00:12 +01:00
import { Avatar, Badge, Icon, ListItem } from 'react-native-elements';
2020-10-29 09:32:55 +01:00
import {
ActivityIndicator,
2020-10-29 09:32:55 +01:00
FlatList,
Keyboard,
KeyboardAvoidingView,
2020-12-12 19:00:12 +01:00
LayoutAnimation,
PixelRatio,
2020-10-29 09:32:55 +01:00
Platform,
StyleSheet,
Text,
TextInput,
TouchableWithoutFeedback,
2020-12-12 19:00:12 +01:00
useWindowDimensions,
View,
2020-10-29 09:32:55 +01:00
} from 'react-native';
2020-10-26 18:50:03 +01:00
import { useRoute, useTheme, useNavigation } from '@react-navigation/native';
2020-10-22 14:24:47 +02:00
import loc, { formatBalance } from '../../loc';
2020-10-22 14:24:47 +02:00
import { BitcoinUnit } from '../../models/bitcoinUnits';
2020-12-25 17:09:53 +01:00
import { SafeBlueArea, BlueSpacing10, BlueSpacing20, BlueButton, BlueListItem } from '../../BlueComponents';
import navigationStyle from '../../components/navigationStyle';
import BottomModal from '../../components/BottomModal';
2020-12-12 19:00:12 +01:00
import { FContainer, FButton } from '../../components/FloatButtons';
2020-10-25 08:35:36 +01:00
import { BlueStorageContext } from '../../blue_modules/storage-context';
2021-01-30 05:57:52 +01:00
import * as RNLocalize from 'react-native-localize';
2020-10-22 14:24:47 +02:00
// https://levelup.gitconnected.com/debounce-in-javascript-improve-your-applications-performance-5b01855e086
const debounce = (func, wait) => {
let timeout;
return function executedFunction(...args) {
const later = () => {
timeout = null;
func(...args);
};
clearTimeout(timeout);
timeout = setTimeout(later, wait);
};
};
2020-12-12 19:00:12 +01:00
const FrozenBadge = () => {
const { colors } = useTheme();
const oStyles = StyleSheet.create({
2020-12-13 08:49:07 +01:00
freeze: { backgroundColor: colors.redBG, borderWidth: 0 },
2020-12-12 19:00:12 +01:00
freezeText: { color: colors.redText },
});
2020-12-13 08:49:07 +01:00
return <Badge value={loc.cc.freeze} badgeStyle={oStyles.freeze} textStyle={oStyles.freezeText} />;
2020-12-12 19:00:12 +01:00
};
const ChangeBadge = () => {
const { colors } = useTheme();
const oStyles = StyleSheet.create({
2020-12-13 08:49:07 +01:00
change: { backgroundColor: colors.buttonDisabledBackgroundColor, borderWidth: 0 },
2020-12-12 19:00:12 +01:00
changeText: { color: colors.alternativeTextColor },
});
2020-12-13 08:49:07 +01:00
return <Badge value={loc.cc.change} badgeStyle={oStyles.change} textStyle={oStyles.changeText} />;
2020-12-12 19:00:12 +01:00
};
const OutputList = ({
item: { address, txid, value, vout, confirmations = 0 },
balanceUnit = BitcoinUnit.BTC,
oMemo,
frozen,
2020-12-12 19:00:12 +01:00
change,
onOpen,
selected,
selectionStarted,
onSelect,
onDeSelect,
}) => {
2020-10-22 14:24:47 +02:00
const { colors } = useTheme();
2020-10-25 08:35:36 +01:00
const { txMetadata } = useContext(BlueStorageContext);
2020-10-28 09:46:40 +01:00
const memo = oMemo || txMetadata[txid]?.memo || '';
2020-10-22 14:24:47 +02:00
const color = `#${txid.substring(0, 6)}`;
const amount = formatBalance(value, balanceUnit, true);
2020-10-22 14:24:47 +02:00
const oStyles = StyleSheet.create({
2020-12-12 19:00:12 +01:00
container: { borderBottomColor: colors.lightBorder, backgroundColor: colors.elevated },
containerSelected: {
backgroundColor: colors.ballOutgoingExpired,
borderBottomColor: 'rgba(0, 0, 0, 0)',
2020-12-12 19:00:12 +01:00
},
avatar: { borderColor: 'white', borderWidth: 1, backgroundColor: color },
amount: { fontWeight: 'bold', color: colors.foregroundColor },
memo: { fontSize: 13, marginTop: 3, color: colors.alternativeTextColor },
});
2020-12-12 19:00:12 +01:00
let onPress = onOpen;
if (selectionStarted) {
onPress = selected ? onDeSelect : onSelect;
}
2020-10-22 14:24:47 +02:00
return (
2020-12-12 19:00:12 +01:00
<ListItem bottomDivider onPress={onPress} containerStyle={selected ? oStyles.containerSelected : oStyles.container}>
<Avatar
rounded
overlayContainerStyle={oStyles.avatar}
onPress={selected ? onDeSelect : onSelect}
icon={selected ? { name: 'check' } : undefined}
/>
2020-10-22 14:24:47 +02:00
<ListItem.Content>
2020-12-12 19:00:12 +01:00
<ListItem.Title style={oStyles.amount}>{amount}</ListItem.Title>
<ListItem.Subtitle style={oStyles.memo} numberOfLines={1} ellipsizeMode="middle">
{memo || address}
2020-12-12 19:00:12 +01:00
</ListItem.Subtitle>
</ListItem.Content>
{change && <ChangeBadge />}
{frozen && <FrozenBadge />}
</ListItem>
);
};
OutputList.propTypes = {
item: PropTypes.shape({
address: PropTypes.string.isRequired,
txid: PropTypes.string.isRequired,
value: PropTypes.number.isRequired,
vout: PropTypes.number.isRequired,
confirmations: PropTypes.number,
2020-12-12 19:00:12 +01:00
}),
balanceUnit: PropTypes.string,
oMemo: PropTypes.string,
frozen: PropTypes.bool,
change: PropTypes.bool,
onOpen: PropTypes.func,
selected: PropTypes.bool,
selectionStarted: PropTypes.bool,
onSelect: PropTypes.func,
onDeSelect: PropTypes.func,
};
const OutputModal = ({ item: { address, txid, value, vout, confirmations = 0 }, balanceUnit = BitcoinUnit.BTC, oMemo }) => {
2020-12-12 19:00:12 +01:00
const { colors } = useTheme();
2021-01-30 05:57:52 +01:00
const { txMetadata } = useContext(BlueStorageContext);
2020-12-12 19:00:12 +01:00
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 },
});
2021-01-30 05:57:52 +01:00
const confirmationsFormatted = new Intl.NumberFormat(RNLocalize.getLocales()[0].languageCode, { maximumSignificantDigits: 3 }).format(
confirmations,
);
2020-10-22 14:24:47 +02:00
return (
2020-12-12 19:00:12 +01:00
<ListItem bottomDivider containerStyle={oStyles.container}>
<Avatar rounded overlayContainerStyle={oStyles.avatar} />
2020-10-22 14:24:47 +02:00
<ListItem.Content>
<ListItem.Title numberOfLines={1} adjustsFontSizeToFit style={oStyles.amount}>
2020-12-12 19:00:12 +01:00
{amount}
<View style={oStyles.tranContainer}>
<Text style={oStyles.tranText}>{loc.formatString(loc.transactions.list_conf, { number: confirmationsFormatted })}</Text>
2020-12-12 19:00:12 +01:00
</View>
</ListItem.Title>
{memo ? (
2020-10-23 12:27:03 +02:00
<>
2020-12-12 19:00:12 +01:00
<ListItem.Subtitle style={oStyles.memo}>{memo}</ListItem.Subtitle>
2020-10-28 11:00:26 +01:00
<BlueSpacing10 />
2020-10-23 12:27:03 +02:00
</>
2020-12-12 19:00:12 +01:00
) : null}
<ListItem.Subtitle style={oStyles.memo}>{address}</ListItem.Subtitle>
<BlueSpacing10 />
<ListItem.Subtitle style={oStyles.memo}>{fullId}</ListItem.Subtitle>
2020-10-22 14:24:47 +02:00
</ListItem.Content>
</ListItem>
);
};
2020-12-12 19:00:12 +01:00
OutputModal.propTypes = {
2020-10-22 14:24:47 +02:00
item: PropTypes.shape({
2020-10-28 09:46:40 +01:00
address: PropTypes.string.isRequired,
2020-10-22 14:24:47 +02:00
txid: PropTypes.string.isRequired,
2020-10-22 15:30:58 +02:00
value: PropTypes.number.isRequired,
2020-10-23 12:27:03 +02:00
vout: PropTypes.number.isRequired,
confirmations: PropTypes.number,
2020-10-22 14:24:47 +02:00
}),
balanceUnit: PropTypes.string,
oMemo: PropTypes.string,
2020-10-22 14:24:47 +02:00
};
2020-10-25 11:17:37 +01:00
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',
},
2020-11-06 07:29:03 +01:00
buttonContainer: {
height: 45,
},
2020-10-25 11:17:37 +01:00
});
2020-12-12 19:00:12 +01:00
const OutputModalContent = ({ output, wallet, onUseCoin, frozen, setFrozen }) => {
2020-10-25 11:17:37 +01:00
const { colors } = useTheme();
2020-10-28 09:10:12 +01:00
const { txMetadata, saveToDisk } = useContext(BlueStorageContext);
2020-10-25 11:17:37 +01:00
const [memo, setMemo] = useState(wallet.getUTXOMetadata(output.txid, output.vout).memo || txMetadata[output.txid]?.memo || '');
2020-10-26 12:00:14 +01:00
const onMemoChange = value => setMemo(value);
2020-10-29 20:48:27 +01:00
const switchValue = useMemo(() => ({ value: frozen, onValueChange: value => setFrozen(value) }), [frozen, setFrozen]);
2020-10-25 11:17:37 +01:00
2020-10-28 09:10:12 +01:00
// save on form change. Because effect called on each event, debounce it.
2020-12-12 19:00:12 +01:00
const debouncedSaveMemo = useRef(
debounce(async memo => {
wallet.setUTXOMetadata(output.txid, output.vout, { memo });
2020-10-28 09:10:12 +01:00
await saveToDisk();
}, 500),
);
2020-10-25 11:17:37 +01:00
useEffect(() => {
2020-12-12 19:00:12 +01:00
debouncedSaveMemo.current(memo);
}, [memo]);
2020-10-25 11:17:37 +01:00
return (
<>
2020-12-12 19:00:12 +01:00
<OutputModal item={output} balanceUnit={wallet.getPreferredBalanceUnit()} />
2020-10-25 11:17:37 +01:00
<BlueSpacing20 />
<TextInput
2020-11-03 14:18:46 +01:00
testID="OutputMemo"
2020-10-25 11:17:37 +01:00
placeholder={loc.send.details_note_placeholder}
value={memo}
placeholderTextColor="#81868e"
style={[
mStyles.memoTextInput,
{
borderColor: colors.formBorder,
borderBottomColor: colors.formBorder,
backgroundColor: colors.inputBackgroundColor,
},
]}
onChangeText={onMemoChange}
/>
2020-10-29 20:48:27 +01:00
<BlueListItem title={loc.cc.freezeLabel} Component={TouchableWithoutFeedback} switch={switchValue} />
2020-10-25 11:17:37 +01:00
<BlueSpacing20 />
2020-11-06 07:29:03 +01:00
<View style={mStyles.buttonContainer}>
2020-11-09 10:24:17 +01:00
<BlueButton testID="UseCoin" title={loc.cc.use_coin} onPress={() => onUseCoin([output])} />
2020-11-06 07:29:03 +01:00
</View>
<BlueSpacing20 />
2020-10-25 11:17:37 +01:00
</>
);
};
OutputModalContent.propTypes = {
output: PropTypes.object,
wallet: PropTypes.object,
2020-10-26 18:50:03 +01:00
onUseCoin: PropTypes.func.isRequired,
2020-12-12 19:00:12 +01:00
frozen: PropTypes.bool.isRequired,
setFrozen: PropTypes.func.isRequired,
2020-10-25 11:17:37 +01:00
};
2020-10-22 14:24:47 +02:00
const CoinControl = () => {
2020-10-22 15:30:58 +02:00
const { colors } = useTheme();
2020-10-26 18:50:03 +01:00
const navigation = useNavigation();
2020-12-12 19:00:12 +01:00
const { width } = useWindowDimensions();
const { walletID, onUTXOChoose } = useRoute().params;
const { wallets, saveToDisk, sleep } = 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);
2020-10-22 15:30:58 +02:00
const [output, setOutput] = useState();
const [loading, setLoading] = useState(true);
2020-12-12 19:00:12 +01:00
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(() => {
(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
}
2020-12-12 19:00:12 +01:00
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, sleep]);
const stylesHook = StyleSheet.create({
tip: {
backgroundColor: colors.ballOutgoingExpired,
},
});
2020-11-19 16:40:26 +01:00
const tipCoins = () => {
2020-11-22 08:01:54 +01:00
return (
utxo.length >= 1 && (
<View style={[styles.tip, stylesHook.tip]}>
<Text style={{ color: colors.foregroundColor }}>{loc.cc.tip}</Text>
</View>
)
);
};
2020-10-22 15:30:58 +02:00
const handleChoose = item => setOutput(item);
2020-10-26 18:50:03 +01:00
const handleUseCoin = utxo => {
setOutput(null);
navigation.pop();
onUTXOChoose(utxo);
};
2020-12-12 19:00:12 +01:00
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 => {
2020-12-12 19:00:12 +01:00
const { memo } = wallet.getUTXOMetadata(p.item.txid, p.item.vout);
2020-10-29 19:42:40 +01:00
const change = wallet.addressIsChange(p.item.address);
2020-12-12 19:00:12 +01:00
const oFrozen = frozen.includes(`${p.item.txid}:${p.item.vout}`);
return (
2020-12-12 19:00:12 +01:00
<OutputList
balanceUnit={wallet.getPreferredBalanceUnit()}
item={p.item}
oMemo={memo}
2020-12-12 19:00:12 +01:00
frozen={oFrozen}
change={change}
2020-12-12 19:00:12 +01:00
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={() => {
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut); // animate buttons show
setSelected(selected => selected.filter(i => i !== `${p.item.txid}:${p.item.vout}`));
}}
/>
);
};
2020-10-22 14:24:47 +02:00
2020-12-12 19:00:12 +01:00
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.center, { backgroundColor: colors.elevated }]}>
<ActivityIndicator testID="Loading" />
</SafeBlueArea>
);
}
2020-10-22 14:24:47 +02:00
return (
2020-12-12 19:00:12 +01:00
<View style={[styles.root, { backgroundColor: colors.elevated }]}>
2020-10-29 09:32:55 +01:00
{utxo.length === 0 && (
<View style={styles.empty}>
<Text style={{ color: colors.foregroundColor }}>{loc.cc.empty}</Text>
</View>
)}
<BottomModal
2020-10-22 15:30:58 +02:00
isVisible={Boolean(output)}
onClose={() => {
2020-11-03 14:18:46 +01:00
Keyboard.dismiss();
setOutput(false);
}}
2020-10-22 15:30:58 +02:00
>
2021-02-25 02:56:06 +01:00
<KeyboardAvoidingView enabled={!Platform.isPad} behavior={Platform.OS === 'ios' ? 'position' : null}>
2020-12-12 19:00:12 +01:00
<View style={[styles.modalContent, { backgroundColor: colors.elevated }]}>{output && renderOutputModalContent()}</View>
2020-10-22 15:30:58 +02:00
</KeyboardAvoidingView>
</BottomModal>
2020-10-22 15:30:58 +02:00
2020-12-12 19:00:12 +01:00
<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>
2020-10-22 14:24:47 +02:00
);
};
2020-10-22 15:30:58 +02:00
const styles = StyleSheet.create({
2020-10-28 11:00:26 +01:00
root: {
flex: 1,
},
center: {
justifyContent: 'center',
alignItems: 'center',
},
2020-10-22 15:30:58 +02:00
modalContent: {
padding: 22,
justifyContent: 'center',
borderTopLeftRadius: 16,
borderTopRightRadius: 16,
borderColor: 'rgba(0, 0, 0, 0.1)',
},
2020-10-29 09:32:55 +01:00
empty: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
padding: 24,
},
tip: {
marginHorizontal: 16,
borderRadius: 12,
padding: 16,
marginVertical: 24,
2020-10-29 09:32:55 +01:00
},
2020-12-12 19:00:12 +01:00
sendIcon: {
transform: [{ rotate: '225deg' }],
},
2020-10-22 15:30:58 +02:00
});
2021-02-15 09:03:54 +01:00
CoinControl.navigationOptions = navigationStyle({}, opts => ({ ...opts, title: loc.cc.header }));
2020-10-22 14:24:47 +02:00
export default CoinControl;