mirror of
https://github.com/BlueWallet/BlueWallet.git
synced 2024-11-19 18:00:17 +01:00
393 lines
13 KiB
TypeScript
393 lines
13 KiB
TypeScript
import React, { useEffect, useMemo, useState } from 'react';
|
|
import Clipboard from '@react-native-clipboard/clipboard';
|
|
import { RouteProp, useRoute } from '@react-navigation/native';
|
|
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
|
import assert from 'assert';
|
|
import createHash from 'create-hash';
|
|
import { SectionList, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
|
import * as BlueElectrum from '../../blue_modules/BlueElectrum';
|
|
import { satoshiToLocalCurrency } from '../../blue_modules/currency';
|
|
import { BlueLoading } from '../../BlueComponents';
|
|
import { HDSegwitBech32Wallet } from '../../class';
|
|
import { ContactList } from '../../class/contact-list';
|
|
import { AbstractHDElectrumWallet } from '../../class/wallets/abstract-hd-electrum-wallet';
|
|
import presentAlert from '../../components/Alert';
|
|
import Button from '../../components/Button';
|
|
import { useTheme } from '../../components/themes';
|
|
import ToolTipMenu from '../../components/TooltipMenu';
|
|
import { Action } from '../../components/types';
|
|
import confirm from '../../helpers/confirm';
|
|
import prompt from '../../helpers/prompt';
|
|
import loc, { formatBalance } from '../../loc';
|
|
import { BitcoinUnit } from '../../models/bitcoinUnits';
|
|
import SafeArea from '../../components/SafeArea';
|
|
import { useExtendedNavigation } from '../../hooks/useExtendedNavigation';
|
|
import { useStorage } from '../../hooks/context/useStorage';
|
|
import { DetailViewStackParamList } from '../../navigation/DetailViewStackParamList';
|
|
|
|
interface DataSection {
|
|
title: string;
|
|
data: string[];
|
|
}
|
|
|
|
enum Actions {
|
|
pay,
|
|
rename,
|
|
copyToClipboard,
|
|
hide,
|
|
}
|
|
|
|
const actionKeys: Action[] = [
|
|
{
|
|
id: Actions.pay,
|
|
text: loc.bip47.pay_this_contact,
|
|
icon: {
|
|
iconValue: 'paperplane',
|
|
},
|
|
},
|
|
{
|
|
id: Actions.rename,
|
|
text: loc.bip47.rename_contact,
|
|
icon: {
|
|
iconValue: 'pencil',
|
|
},
|
|
},
|
|
{
|
|
id: Actions.copyToClipboard,
|
|
text: loc.bip47.copy_payment_code,
|
|
icon: {
|
|
iconValue: 'doc.on.doc',
|
|
},
|
|
},
|
|
{
|
|
id: Actions.hide,
|
|
text: loc.bip47.hide_contact,
|
|
icon: {
|
|
iconValue: 'eye.slash',
|
|
},
|
|
},
|
|
];
|
|
|
|
function onlyUnique(value: any, index: number, self: any[]) {
|
|
return self.indexOf(value) === index;
|
|
}
|
|
|
|
type PaymentCodeListRouteProp = RouteProp<DetailViewStackParamList, 'PaymentCodeList'>;
|
|
type PaymentCodesListNavigationProp = NativeStackNavigationProp<DetailViewStackParamList, 'PaymentCodeList'>;
|
|
|
|
export default function PaymentCodesList() {
|
|
const navigation = useExtendedNavigation<PaymentCodesListNavigationProp>();
|
|
const route = useRoute<PaymentCodeListRouteProp>();
|
|
const { walletID } = route.params;
|
|
const { wallets, txMetadata, counterpartyMetadata, saveToDisk } = useStorage();
|
|
const [reload, setReload] = useState<number>(0);
|
|
const [data, setData] = useState<DataSection[]>([]);
|
|
const { colors } = useTheme();
|
|
const [isLoading, setIsLoading] = useState<boolean>(false);
|
|
const [loadingText, setLoadingText] = useState<string>('Loading...');
|
|
const state = navigation.getState();
|
|
const previousRouteIndex = state.index - 1;
|
|
|
|
let previousRouteName: string | null;
|
|
if (previousRouteIndex >= 0) {
|
|
previousRouteName = state.routes[previousRouteIndex].name;
|
|
}
|
|
|
|
useEffect(() => {
|
|
if (!walletID) return;
|
|
|
|
const foundWallet = wallets.find(w => w.getID() === walletID) as unknown as AbstractHDElectrumWallet;
|
|
if (!foundWallet) return;
|
|
|
|
const newData: DataSection[] = [
|
|
{
|
|
title: '',
|
|
data: foundWallet.getBIP47SenderPaymentCodes().concat(foundWallet.getBIP47ReceiverPaymentCodes()).filter(onlyUnique),
|
|
},
|
|
];
|
|
setData(newData);
|
|
}, [walletID, wallets, reload]);
|
|
|
|
const toolTipActions = useMemo(() => actionKeys, []);
|
|
|
|
const shortenContactName = (name: string): string => {
|
|
if (name.length < 20) return name;
|
|
return name.substr(0, 10) + '...' + name.substr(name.length - 10, 10);
|
|
};
|
|
|
|
const onToolTipPress = async (id: any, pc: string) => {
|
|
try {
|
|
setIsLoading(true);
|
|
await _onToolTipPress(id, pc);
|
|
} catch (error: any) {
|
|
presentAlert({ message: error.message });
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
};
|
|
|
|
const _onToolTipPress = async (id: any, pc: string) => {
|
|
switch (String(id)) {
|
|
case String(Actions.copyToClipboard): {
|
|
Clipboard.setString(pc);
|
|
break;
|
|
}
|
|
case String(Actions.rename): {
|
|
const newName = await prompt(loc.bip47.rename, loc.bip47.provide_name, true, 'plain-text');
|
|
if (!newName) return;
|
|
|
|
counterpartyMetadata[pc] = { label: newName };
|
|
setReload(Math.random());
|
|
await saveToDisk();
|
|
break;
|
|
}
|
|
case String(Actions.pay): {
|
|
const cl = new ContactList();
|
|
// ok its a SilentPayments code/regular address, no need to check for notif tx, ok to just send
|
|
if (cl.isBip352PaymentCodeValid(pc) || cl.isAddressValid(pc)) {
|
|
_navigateToSend(pc);
|
|
return;
|
|
}
|
|
// check if notif tx is in place and has confirmations
|
|
const foundWallet = wallets.find(w => w.getID() === walletID) as unknown as HDSegwitBech32Wallet;
|
|
assert(foundWallet, 'Internal error: cant find walletID ' + walletID);
|
|
const notifTx = foundWallet.getBIP47NotificationTransaction(pc);
|
|
if (!notifTx) {
|
|
await _addContact(pc);
|
|
return;
|
|
}
|
|
if (!notifTx.confirmations) {
|
|
// when we just sent the confirmation tx and it havent confirmed yet
|
|
presentAlert({ message: loc.bip47.notification_tx_unconfirmed });
|
|
return;
|
|
}
|
|
_navigateToSend(pc);
|
|
break;
|
|
}
|
|
case String(Actions.hide): {
|
|
if (!(await confirm(loc.wallets.details_are_you_sure))) {
|
|
return;
|
|
}
|
|
counterpartyMetadata[pc] = { label: counterpartyMetadata[pc]?.label, hidden: true };
|
|
setReload(Math.random());
|
|
await saveToDisk();
|
|
break;
|
|
}
|
|
default:
|
|
break;
|
|
}
|
|
};
|
|
|
|
const _navigateToSend = (pc: string) => {
|
|
navigation.navigate('SendDetailsRoot', {
|
|
screen: 'SendDetails',
|
|
params: {
|
|
walletID,
|
|
addRecipientParams: {
|
|
address: pc,
|
|
},
|
|
},
|
|
merge: true,
|
|
});
|
|
};
|
|
|
|
const renderItem = (pc: string, index: number) => {
|
|
if (counterpartyMetadata?.[pc]?.hidden) return null; // hidden contact, do not render
|
|
|
|
const color = createHash('sha256').update(pc).digest().toString('hex').substring(0, 6);
|
|
|
|
const displayName = shortenContactName(counterpartyMetadata?.[pc]?.label || pc);
|
|
|
|
if (previousRouteName === 'SendDetails') {
|
|
return (
|
|
<TouchableOpacity onPress={() => onToolTipPress(Actions.pay, pc)}>
|
|
<View style={styles.contactRowContainer}>
|
|
<View style={[styles.circle, { backgroundColor: '#' + color }]} />
|
|
<View style={styles.contactRowBody}>
|
|
<Text testID={`ContactListItem${index}`} style={[styles.contactRowNameText, { color: colors.labelText }]}>
|
|
{displayName}
|
|
</Text>
|
|
</View>
|
|
</View>
|
|
<View style={styles.stick} />
|
|
</TouchableOpacity>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<ToolTipMenu
|
|
actions={toolTipActions}
|
|
onPressMenuItem={(item: any) => onToolTipPress(item, pc)}
|
|
isButton={true}
|
|
isMenuPrimaryAction={true}
|
|
>
|
|
<View style={styles.contactRowContainer}>
|
|
<View style={[styles.circle, { backgroundColor: '#' + color }]} />
|
|
<View style={styles.contactRowBody}>
|
|
<Text testID={`ContactListItem${index}`} style={[styles.contactRowNameText, { color: colors.labelText }]}>
|
|
{displayName}
|
|
</Text>
|
|
</View>
|
|
</View>
|
|
<View style={styles.stick} />
|
|
</ToolTipMenu>
|
|
);
|
|
};
|
|
|
|
const onAddContactPress = async () => {
|
|
try {
|
|
const newPc = await prompt(loc.bip47.add_contact, loc.bip47.provide_payment_code, true, 'plain-text');
|
|
if (!newPc) return;
|
|
|
|
await _addContact(newPc);
|
|
} catch (error: any) {
|
|
console.debug(error.message);
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
};
|
|
|
|
const _addContact = async (newPc: string) => {
|
|
const foundWallet = wallets.find(w => w.getID() === walletID) as unknown as HDSegwitBech32Wallet;
|
|
assert(foundWallet, 'Internal error: cant find walletID ' + walletID);
|
|
|
|
if (counterpartyMetadata[newPc]?.hidden) {
|
|
// contact already present, just need to unhide it
|
|
counterpartyMetadata[newPc].hidden = false;
|
|
await saveToDisk();
|
|
setReload(Math.random());
|
|
return;
|
|
}
|
|
|
|
const cl = new ContactList();
|
|
|
|
if (cl.isAddressValid(newPc)) {
|
|
// this is not a payment code but a regular onchain address. pretending its a payment code and adding it
|
|
foundWallet.addBIP47Receiver(newPc);
|
|
await saveToDisk();
|
|
setReload(Math.random());
|
|
return;
|
|
}
|
|
|
|
if (!cl.isPaymentCodeValid(newPc)) {
|
|
presentAlert({ message: loc.bip47.invalid_pc });
|
|
return;
|
|
}
|
|
|
|
if (cl.isBip352PaymentCodeValid(newPc)) {
|
|
// ok its a SilentPayments code, notification tx is not needed, just add it to recipients:
|
|
foundWallet.addBIP47Receiver(newPc);
|
|
await saveToDisk();
|
|
setReload(Math.random());
|
|
return;
|
|
}
|
|
|
|
setIsLoading(true);
|
|
|
|
const notificationTx = foundWallet.getBIP47NotificationTransaction(newPc);
|
|
|
|
if (notificationTx && notificationTx.confirmations > 0) {
|
|
// we previously sent notification transaction to him, so just need to add him to internals
|
|
foundWallet.addBIP47Receiver(newPc);
|
|
await foundWallet.syncBip47ReceiversAddresses(newPc); // so we can unwrap and save all his possible addresses
|
|
// (for a case if already have txs with him, we will now be able to label them on tx list)
|
|
await saveToDisk();
|
|
setReload(Math.random());
|
|
return;
|
|
}
|
|
|
|
if (notificationTx && notificationTx.confirmations === 0) {
|
|
// for a rare case when we just sent the confirmation tx and it havent confirmed yet
|
|
presentAlert({ message: loc.bip47.notification_tx_unconfirmed });
|
|
return;
|
|
}
|
|
|
|
// need to send notif tx:
|
|
|
|
setLoadingText('Fetching UTXO...');
|
|
await foundWallet.fetchUtxo();
|
|
setLoadingText('Fetching fees...');
|
|
const fees = await BlueElectrum.estimateFees();
|
|
setLoadingText('Fetching change address...');
|
|
const changeAddress = await foundWallet.getChangeAddressAsync();
|
|
setLoadingText('Crafting notification transaction...');
|
|
if (foundWallet.getUtxo().length === 0) {
|
|
// no balance..?
|
|
presentAlert({ message: loc.send.details_total_exceeds_balance });
|
|
return;
|
|
}
|
|
const { tx, fee } = foundWallet.createBip47NotificationTransaction(foundWallet.getUtxo(), newPc, fees.fast, changeAddress);
|
|
|
|
if (!tx) {
|
|
presentAlert({ message: loc.bip47.failed_create_notif_tx });
|
|
return;
|
|
}
|
|
|
|
setLoadingText('');
|
|
if (
|
|
await confirm(
|
|
loc.bip47.onchain_tx_needed,
|
|
`${loc.send.create_fee}: ${formatBalance(fee, BitcoinUnit.BTC)} (${satoshiToLocalCurrency(fee)}). `,
|
|
)
|
|
) {
|
|
setLoadingText('Broadcasting...');
|
|
try {
|
|
await foundWallet.broadcastTx(tx.toHex());
|
|
foundWallet.addBIP47Receiver(newPc);
|
|
presentAlert({ message: loc.bip47.notif_tx_sent });
|
|
txMetadata[tx.getId()] = { memo: loc.bip47.notif_tx };
|
|
setReload(Math.random());
|
|
await new Promise(resolve => setTimeout(resolve, 5000)); // tx propagate on backend so our fetch will actually get the new tx
|
|
} catch (_) {}
|
|
setLoadingText('Fetching transactions...');
|
|
await foundWallet.fetchTransactions();
|
|
setLoadingText('');
|
|
}
|
|
};
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<View style={styles.container}>
|
|
<BlueLoading />
|
|
<Text>{loadingText}</Text>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<SafeArea style={styles.container}>
|
|
{!walletID ? (
|
|
<Text>Internal error</Text>
|
|
) : (
|
|
<View style={styles.sectionListContainer}>
|
|
<SectionList
|
|
sections={data}
|
|
keyExtractor={(item, index) => item + index}
|
|
renderItem={({ item, index }) => renderItem(item, index)}
|
|
/>
|
|
</View>
|
|
)}
|
|
|
|
<Button title={loc.bip47.add_contact} onPress={onAddContactPress} />
|
|
</SafeArea>
|
|
);
|
|
}
|
|
|
|
const styles = StyleSheet.create({
|
|
container: {
|
|
flex: 1,
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
},
|
|
sectionListContainer: { flex: 1, width: '100%' },
|
|
circle: {
|
|
width: 35,
|
|
height: 35,
|
|
borderRadius: 25,
|
|
},
|
|
contactRowBody: { flex: 6, justifyContent: 'center', top: -3 },
|
|
contactRowNameText: { marginLeft: 10, fontSize: 16 },
|
|
contactRowContainer: { flexDirection: 'row', padding: 15 },
|
|
stick: { borderStyle: 'solid', borderWidth: 0.5, borderColor: 'gray', opacity: 0.5, top: 0, left: -10, width: '110%' },
|
|
});
|