BlueWallet/screen/wallets/PaymentCodesList.tsx

393 lines
13 KiB
TypeScript
Raw Normal View History

2024-05-31 13:18:01 -04:00
import React, { useEffect, useMemo, useState } from 'react';
2024-05-20 10:54:13 +01:00
import Clipboard from '@react-native-clipboard/clipboard';
2024-05-27 19:03:21 -04:00
import { RouteProp, useRoute } from '@react-navigation/native';
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
2024-05-15 22:46:54 +01:00
import assert from 'assert';
2024-05-20 10:54:13 +01:00
import createHash from 'create-hash';
2024-06-07 14:50:50 -04:00
import { SectionList, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
2024-05-15 22:46:54 +01:00
import * as BlueElectrum from '../../blue_modules/BlueElectrum';
import { satoshiToLocalCurrency } from '../../blue_modules/currency';
2024-06-07 09:37:45 -04:00
import { BlueLoading } from '../../BlueComponents';
2024-05-20 10:54:13 +01:00
import { HDSegwitBech32Wallet } from '../../class';
import { ContactList } from '../../class/contact-list';
import { AbstractHDElectrumWallet } from '../../class/wallets/abstract-hd-electrum-wallet';
2024-05-16 22:54:40 +01:00
import presentAlert from '../../components/Alert';
2024-05-20 10:54:13 +01:00
import Button from '../../components/Button';
import { useTheme } from '../../components/themes';
import ToolTipMenu from '../../components/TooltipMenu';
2024-05-18 12:48:03 -04:00
import { Action } from '../../components/types';
2024-05-20 10:54:13 +01:00
import confirm from '../../helpers/confirm';
import prompt from '../../helpers/prompt';
import loc, { formatBalance } from '../../loc';
import { BitcoinUnit } from '../../models/bitcoinUnits';
2024-05-27 23:00:28 +01:00
import SafeArea from '../../components/SafeArea';
import { useExtendedNavigation } from '../../hooks/useExtendedNavigation';
2024-05-31 13:18:01 -04:00
import { useStorage } from '../../hooks/context/useStorage';
2024-06-07 14:50:50 -04:00
import { DetailViewStackParamList } from '../../navigation/DetailViewStackParamList';
2023-03-15 20:42:25 +00:00
interface DataSection {
title: string;
data: string[];
}
2024-06-07 14:50:50 -04:00
2024-05-15 22:46:54 +01:00
enum Actions {
pay,
rename,
copyToClipboard,
2024-06-11 23:18:13 +01:00
hide,
2024-05-15 22:46:54 +01:00
}
2024-05-18 12:48:03 -04:00
const actionKeys: Action[] = [
2024-05-15 22:46:54 +01:00
{
id: Actions.pay,
2024-05-16 22:54:40 +01:00
text: loc.bip47.pay_this_contact,
icon: {
2024-06-01 15:48:14 -04:00
iconValue: 'paperplane',
2024-05-16 22:54:40 +01:00
},
2024-05-15 22:46:54 +01:00
},
{
id: Actions.rename,
2024-05-16 22:54:40 +01:00
text: loc.bip47.rename_contact,
icon: {
2024-06-01 18:37:27 -04:00
iconValue: 'pencil',
2024-05-16 22:54:40 +01:00
},
2024-05-15 22:46:54 +01:00
},
{
id: Actions.copyToClipboard,
2024-05-16 22:54:40 +01:00
text: loc.bip47.copy_payment_code,
icon: {
2024-06-01 18:37:27 -04:00
iconValue: 'doc.on.doc',
2024-05-16 22:54:40 +01:00
},
2024-05-15 22:46:54 +01:00
},
2024-06-11 23:18:13 +01:00
{
id: Actions.hide,
text: loc.bip47.hide_contact,
icon: {
iconValue: 'eye.slash',
},
},
2024-05-15 22:46:54 +01:00
];
function onlyUnique(value: any, index: number, self: any[]) {
return self.indexOf(value) === index;
}
2024-06-10 20:44:05 -04:00
type PaymentCodeListRouteProp = RouteProp<DetailViewStackParamList, 'PaymentCodeList'>;
type PaymentCodesListNavigationProp = NativeStackNavigationProp<DetailViewStackParamList, 'PaymentCodeList'>;
2024-05-27 19:03:21 -04:00
2024-05-11 12:50:06 -04:00
export default function PaymentCodesList() {
2024-06-10 20:44:05 -04:00
const navigation = useExtendedNavigation<PaymentCodesListNavigationProp>();
const route = useRoute<PaymentCodeListRouteProp>();
const { walletID } = route.params;
2024-05-27 19:03:21 -04:00
const { wallets, txMetadata, counterpartyMetadata, saveToDisk } = useStorage();
2024-05-15 22:46:54 +01:00
const [reload, setReload] = useState<number>(0);
2023-03-15 20:42:25 +00:00
const [data, setData] = useState<DataSection[]>([]);
2024-05-15 22:46:54 +01:00
const { colors } = useTheme();
const [isLoading, setIsLoading] = useState<boolean>(false);
const [loadingText, setLoadingText] = useState<string>('Loading...');
2024-06-10 20:44:05 -04:00
const state = navigation.getState();
const previousRouteIndex = state.index - 1;
let previousRouteName: string | null;
if (previousRouteIndex >= 0) {
previousRouteName = state.routes[previousRouteIndex].name;
}
2023-03-15 20:42:25 +00:00
useEffect(() => {
if (!walletID) return;
2024-04-29 23:43:04 +01:00
const foundWallet = wallets.find(w => w.getID() === walletID) as unknown as AbstractHDElectrumWallet;
2023-03-15 20:42:25 +00:00
if (!foundWallet) return;
const newData: DataSection[] = [
{
2024-05-15 22:46:54 +01:00
title: '',
data: foundWallet.getBIP47SenderPaymentCodes().concat(foundWallet.getBIP47ReceiverPaymentCodes()).filter(onlyUnique),
2024-04-29 23:43:04 +01:00
},
2023-03-15 20:42:25 +00:00
];
setData(newData);
2024-05-15 22:46:54 +01:00
}, [walletID, wallets, reload]);
2024-05-18 12:48:03 -04:00
const toolTipActions = useMemo(() => actionKeys, []);
2024-05-15 22:46:54 +01:00
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;
2024-06-02 22:37:58 +01:00
}
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;
2024-06-02 22:37:58 +01:00
}
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;
2024-06-02 22:37:58 +01:00
}
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;
2024-06-11 23:18:13 +01:00
}
default:
break;
2024-06-11 23:18:13 +01:00
}
2024-05-15 22:46:54 +01:00
};
2024-06-02 22:37:58 +01:00
const _navigateToSend = (pc: string) => {
navigation.navigate('SendDetailsRoot', {
screen: 'SendDetails',
params: {
walletID,
addRecipientParams: {
address: pc,
},
2024-06-02 22:37:58 +01:00
},
2024-06-07 09:37:45 -04:00
merge: true,
2024-06-02 22:37:58 +01:00
});
};
const renderItem = (pc: string, index: number) => {
2024-06-11 23:18:13 +01:00
if (counterpartyMetadata?.[pc]?.hidden) return null; // hidden contact, do not render
2024-05-15 22:46:54 +01:00
const color = createHash('sha256').update(pc).digest().toString('hex').substring(0, 6);
const displayName = shortenContactName(counterpartyMetadata?.[pc]?.label || pc);
2024-05-15 22:46:54 +01:00
2024-06-10 20:44:05 -04:00
if (previousRouteName === 'SendDetails') {
2024-06-07 14:50:50 -04:00
return (
<TouchableOpacity onPress={() => onToolTipPress(Actions.pay, pc)}>
2024-06-07 14:50:50 -04:00
<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>
2024-06-07 14:50:50 -04:00
</View>
</View>
<View style={styles.stick} />
</TouchableOpacity>
);
}
2024-05-15 22:46:54 +01:00
return (
2024-05-17 22:38:46 +01:00
<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>
2024-05-15 22:46:54 +01:00
</View>
2024-05-17 22:38:46 +01:00
</View>
2024-05-15 22:46:54 +01:00
<View style={styles.stick} />
2024-05-17 22:38:46 +01:00
</ToolTipMenu>
2024-05-15 22:46:54 +01:00
);
};
const onAddContactPress = async () => {
try {
const newPc = await prompt(loc.bip47.add_contact, loc.bip47.provide_payment_code, true, 'plain-text');
2024-05-15 22:46:54 +01:00
if (!newPc) return;
2024-06-02 22:37:58 +01:00
await _addContact(newPc);
} catch (error: any) {
2024-06-07 14:50:50 -04:00
console.debug(error.message);
2024-06-02 22:37:58 +01:00
} finally {
setIsLoading(false);
}
};
2024-05-15 22:46:54 +01:00
2024-06-02 22:37:58 +01:00
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);
2024-05-30 14:54:29 +01:00
2024-06-11 23:18:13 +01:00
if (counterpartyMetadata[newPc]?.hidden) {
// contact already present, just need to unhide it
counterpartyMetadata[newPc].hidden = false;
await saveToDisk();
setReload(Math.random());
return;
}
2024-06-02 22:37:58 +01:00
const cl = new ContactList();
2024-05-15 22:46:54 +01:00
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;
}
2024-06-02 22:37:58 +01:00
if (!cl.isPaymentCodeValid(newPc)) {
presentAlert({ message: loc.bip47.invalid_pc });
return;
}
2024-05-15 22:46:54 +01:00
2024-06-02 22:37:58 +01:00
if (cl.isBip352PaymentCodeValid(newPc)) {
// ok its a SilentPayments code, notification tx is not needed, just add it to recipients:
foundWallet.addBIP47Receiver(newPc);
await saveToDisk();
2024-06-02 22:37:58 +01:00
setReload(Math.random());
return;
}
2024-05-15 22:46:54 +01:00
2024-06-02 22:37:58 +01:00
setIsLoading(true);
2024-05-15 22:46:54 +01:00
2024-06-02 22:37:58 +01:00
const notificationTx = foundWallet.getBIP47NotificationTransaction(newPc);
2024-05-15 22:46:54 +01:00
2024-06-02 22:37:58 +01:00
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;
}
2024-05-15 22:46:54 +01:00
2024-06-02 22:37:58 +01:00
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;
}
2024-05-15 22:46:54 +01:00
2024-06-02 22:37:58 +01:00
// 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;
}
2024-06-02 22:37:58 +01:00
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();
2024-06-11 23:18:13 +01:00
setLoadingText('');
2024-05-15 22:46:54 +01:00
}
};
if (isLoading) {
return (
<View style={styles.container}>
<BlueLoading />
<Text>{loadingText}</Text>
</View>
);
}
2023-03-15 20:42:25 +00:00
return (
2024-05-27 23:00:28 +01:00
<SafeArea style={styles.container}>
2023-03-15 20:42:25 +00:00
{!walletID ? (
<Text>Internal error</Text>
) : (
2024-05-15 22:46:54 +01:00
<View style={styles.sectionListContainer}>
<SectionList
sections={data}
keyExtractor={(item, index) => item + index}
renderItem={({ item, index }) => renderItem(item, index)}
/>
2023-03-15 20:42:25 +00:00
</View>
)}
2024-05-15 22:46:54 +01:00
2024-05-16 22:54:40 +01:00
<Button title={loc.bip47.add_contact} onPress={onAddContactPress} />
2024-05-27 23:00:28 +01:00
</SafeArea>
2023-03-15 20:42:25 +00:00
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
},
2024-05-15 22:46:54 +01:00
sectionListContainer: { flex: 1, width: '100%' },
circle: {
2024-05-18 10:25:54 +01:00
width: 35,
height: 35,
2024-05-15 22:46:54 +01:00
borderRadius: 25,
},
2024-05-17 22:38:46 +01:00
contactRowBody: { flex: 6, justifyContent: 'center', top: -3 },
2024-05-18 10:25:54 +01:00
contactRowNameText: { marginLeft: 10, fontSize: 16 },
2024-05-17 22:38:46 +01:00
contactRowContainer: { flexDirection: 'row', padding: 15 },
stick: { borderStyle: 'solid', borderWidth: 0.5, borderColor: 'gray', opacity: 0.5, top: 0, left: -10, width: '110%' },
2023-03-15 20:42:25 +00:00
});