BlueWallet/screen/lnd/lndCreateInvoice.js
Marcos Rodriguez Velez 4b922d07a4
Lint
2024-03-27 00:15:28 -04:00

530 lines
16 KiB
JavaScript

import React, { useCallback, useContext, useEffect, useRef, useState } from 'react';
import {
ActivityIndicator,
Image,
Keyboard,
KeyboardAvoidingView,
StyleSheet,
Text,
TextInput,
Platform,
TouchableOpacity,
TouchableWithoutFeedback,
View,
I18nManager,
} from 'react-native';
import { Icon } from 'react-native-elements';
import { useFocusEffect, useNavigation, useRoute } from '@react-navigation/native';
import { BlueDismissKeyboardInputAccessory, BlueLoading } from '../../BlueComponents';
import navigationStyle from '../../components/navigationStyle';
import AmountInput from '../../components/AmountInput';
import * as NavigationService from '../../NavigationService';
import { BitcoinUnit, Chain } from '../../models/bitcoinUnits';
import loc, { formatBalance, formatBalanceWithoutSuffix, formatBalancePlain } from '../../loc';
import Lnurl from '../../class/lnurl';
import { BlueStorageContext } from '../../blue_modules/storage-context';
import Notifications from '../../blue_modules/notifications';
import presentAlert from '../../components/Alert';
import { parse } from 'url'; // eslint-disable-line n/no-deprecated-api
import { requestCameraAuthorization } from '../../helpers/scan-qr';
import { useTheme } from '../../components/themes';
import Button from '../../components/Button';
import triggerHapticFeedback, { HapticFeedbackTypes } from '../../blue_modules/hapticFeedback';
import { btcToSatoshi, fiatToBTC, satoshiToBTC } from '../../blue_modules/currency';
import { presentWalletExportReminder } from '../../helpers/presentWalletExportReminder';
const LNDCreateInvoice = () => {
const { wallets, saveToDisk, setSelectedWalletID } = useContext(BlueStorageContext);
const { walletID, uri } = useRoute().params;
const wallet = useRef(wallets.find(item => item.getID() === walletID) || wallets.find(item => item.chain === Chain.OFFCHAIN));
const createInvoiceRef = useRef();
const { name } = useRoute();
const { colors } = useTheme();
const { navigate, getParent, goBack, pop, setParams } = useNavigation();
const [unit, setUnit] = useState(wallet.current?.getPreferredBalanceUnit() || BitcoinUnit.BTC);
const [amount, setAmount] = useState();
const [renderWalletSelectionButtonHidden, setRenderWalletSelectionButtonHidden] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const [description, setDescription] = useState('');
const [lnurlParams, setLNURLParams] = useState();
const styleHooks = StyleSheet.create({
scanRoot: {
backgroundColor: colors.scanLabel,
},
scanClick: {
color: colors.inverseForegroundColor,
},
walletNameText: {
color: colors.buttonAlternativeTextColor,
},
walletNameBalance: {
color: colors.buttonAlternativeTextColor,
},
walletNameSats: {
color: colors.buttonAlternativeTextColor,
},
root: {
backgroundColor: colors.elevated,
},
amount: {
backgroundColor: colors.elevated,
},
fiat: {
borderColor: colors.formBorder,
borderBottomColor: colors.formBorder,
backgroundColor: colors.inputBackgroundColor,
},
});
useEffect(() => {
// console.log(params)
Keyboard.addListener('keyboardDidShow', _keyboardDidShow);
Keyboard.addListener('keyboardDidHide', _keyboardDidHide);
return () => {
Keyboard.removeAllListeners('keyboardDidShow');
Keyboard.removeAllListeners('keyboardDidHide');
};
}, []);
const renderReceiveDetails = async () => {
try {
wallet.current.setUserHasSavedExport(true);
await saveToDisk();
if (uri) {
processLnurl(uri);
}
} catch (e) {
console.log(e);
}
setIsLoading(false);
};
useEffect(() => {
if (wallet.current && wallet.current.getID() !== walletID) {
const newWallet = wallets.find(w => w.getID() === walletID);
if (newWallet) {
wallet.current = newWallet;
setSelectedWalletID(newWallet.getID());
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [walletID]);
useFocusEffect(
useCallback(() => {
if (wallet.current) {
setSelectedWalletID(walletID);
if (wallet.current.getUserHasSavedExport()) {
renderReceiveDetails();
} else {
presentWalletExportReminder()
.then(() => {
renderReceiveDetails();
})
.catch(() => {
getParent().pop();
NavigationService.navigate('WalletExportRoot', {
screen: 'WalletExport',
params: {
walletID,
},
});
});
}
} else {
triggerHapticFeedback(HapticFeedbackTypes.NotificationError);
presentAlert({ message: loc.wallets.add_ln_wallet_first });
goBack();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [wallet]),
);
const _keyboardDidShow = () => {
setRenderWalletSelectionButtonHidden(true);
};
const _keyboardDidHide = () => {
setRenderWalletSelectionButtonHidden(false);
};
const createInvoice = async () => {
setIsLoading(true);
try {
let invoiceAmount = amount;
switch (unit) {
case BitcoinUnit.SATS:
invoiceAmount = parseInt(invoiceAmount, 10); // basically nop
break;
case BitcoinUnit.BTC:
invoiceAmount = btcToSatoshi(invoiceAmount);
break;
case BitcoinUnit.LOCAL_CURRENCY:
// trying to fetch cached sat equivalent for this fiat amount
invoiceAmount = AmountInput.getCachedSatoshis(invoiceAmount) || btcToSatoshi(fiatToBTC(invoiceAmount));
break;
}
if (lnurlParams) {
const { min, max } = lnurlParams;
if (invoiceAmount < min || invoiceAmount > max) {
let text;
if (invoiceAmount < min) {
text =
unit === BitcoinUnit.SATS
? loc.formatString(loc.receive.minSats, { min })
: loc.formatString(loc.receive.minSatsFull, { min, currency: formatBalance(min, unit) });
} else {
text =
unit === BitcoinUnit.SATS
? loc.formatString(loc.receive.maxSats, { max })
: loc.formatString(loc.receive.maxSatsFull, { max, currency: formatBalance(max, unit) });
}
triggerHapticFeedback(HapticFeedbackTypes.NotificationError);
presentAlert({ message: text });
setIsLoading(false);
return;
}
}
const invoiceRequest = await wallet.current.addInvoice(invoiceAmount, description);
triggerHapticFeedback(HapticFeedbackTypes.NotificationSuccess);
// lets decode payreq and subscribe groundcontrol so we can receive push notification when our invoice is paid
/** @type LightningCustodianWallet */
const decoded = await wallet.current.decodeInvoice(invoiceRequest);
await Notifications.tryToObtainPermissions(createInvoiceRef);
Notifications.majorTomToGroundControl([], [decoded.payment_hash], []);
// send to lnurl-withdraw callback url if that exists
if (lnurlParams) {
const { callback, k1 } = lnurlParams;
const callbackUrl = callback + (callback.indexOf('?') !== -1 ? '&' : '?') + 'k1=' + k1 + '&pr=' + invoiceRequest;
const resp = await fetch(callbackUrl, { method: 'GET' });
if (resp.status >= 300) {
const text = await resp.text();
throw new Error(text);
}
const reply = await resp.json();
if (reply.status === 'ERROR') {
throw new Error('Reply from server: ' + reply.reason);
}
}
setTimeout(async () => {
// wallet object doesnt have this fresh invoice in its internals, so we refetch it and only then save
await wallet.current.fetchUserInvoices(1);
await saveToDisk();
}, 1000);
navigate('LNDViewInvoice', {
invoice: invoiceRequest,
walletID: wallet.current.getID(),
});
} catch (Err) {
triggerHapticFeedback(HapticFeedbackTypes.NotificationError);
setIsLoading(false);
presentAlert({ message: Err.message });
}
};
const processLnurl = async data => {
setIsLoading(true);
if (!wallet.current) {
triggerHapticFeedback(HapticFeedbackTypes.NotificationError);
presentAlert({ message: loc.wallets.no_ln_wallet_error });
return goBack();
}
// decoding the lnurl
const url = Lnurl.getUrlFromLnurl(data);
const { query } = parse(url, true);
if (query.tag === Lnurl.TAG_LOGIN_REQUEST) {
navigate('LnurlAuth', {
lnurl: data,
walletID: walletID ?? wallet.current.getID(),
});
return;
}
// calling the url
try {
const resp = await fetch(url, { method: 'GET' });
if (resp.status >= 300) {
throw new Error('Bad response from server');
}
const reply = await resp.json();
if (reply.status === 'ERROR') {
throw new Error('Reply from server: ' + reply.reason);
}
if (reply.tag === Lnurl.TAG_PAY_REQUEST) {
// we are here by mistake. user wants to SEND to lnurl-pay, but he is on a screen that creates
// invoices (including through lnurl-withdraw)
navigate('ScanLndInvoiceRoot', {
screen: 'LnurlPay',
params: {
lnurl: data,
walletID: walletID ?? wallet.current.getID(),
},
});
return;
}
if (reply.tag !== Lnurl.TAG_WITHDRAW_REQUEST) {
throw new Error('Unsupported lnurl');
}
// amount that comes from lnurl is always in sats
let newAmount = (reply.maxWithdrawable / 1000).toString();
const sats = newAmount;
switch (unit) {
case BitcoinUnit.SATS:
// nop
break;
case BitcoinUnit.BTC:
newAmount = satoshiToBTC(newAmount);
break;
case BitcoinUnit.LOCAL_CURRENCY:
newAmount = formatBalancePlain(newAmount, BitcoinUnit.LOCAL_CURRENCY);
AmountInput.setCachedSatoshis(newAmount, sats);
break;
}
// setting the invoice creating screen with the parameters
setLNURLParams({
k1: reply.k1,
callback: reply.callback,
fixed: reply.minWithdrawable === reply.maxWithdrawable,
min: (reply.minWithdrawable || 0) / 1000,
max: reply.maxWithdrawable / 1000,
});
setAmount(newAmount);
setDescription(reply.defaultDescription);
setIsLoading(false);
} catch (Err) {
Keyboard.dismiss();
setIsLoading(false);
triggerHapticFeedback(HapticFeedbackTypes.NotificationError);
presentAlert({ message: Err.message });
}
};
const renderCreateButton = () => {
return (
<View style={styles.createButton}>
{isLoading ? (
<ActivityIndicator />
) : (
<Button disabled={!(amount > 0)} ref={createInvoiceRef} onPress={createInvoice} title={loc.send.details_create} />
)}
</View>
);
};
const navigateToScanQRCode = () => {
requestCameraAuthorization().then(() => {
NavigationService.navigate('ScanQRCodeRoot', {
screen: 'ScanQRCode',
params: {
onBarScanned: processLnurl,
launchedBy: name,
},
});
Keyboard.dismiss();
});
};
const renderScanClickable = () => {
return (
<TouchableOpacity
disabled={isLoading}
onPress={navigateToScanQRCode}
style={[styles.scanRoot, styleHooks.scanRoot]}
accessibilityRole="button"
accessibilityLabel={loc.send.details_scan}
accessibilityHint={loc.send.details_scan_hint}
>
<Image style={{}} source={require('../../img/scan-white.png')} />
<Text style={[styles.scanClick, styleHooks.scanClick]}>{loc.send.details_scan}</Text>
</TouchableOpacity>
);
};
const navigateToSelectWallet = () => {
navigate('SelectWallet', { onWalletSelect, chainType: Chain.OFFCHAIN });
};
const renderWalletSelectionButton = () => {
if (renderWalletSelectionButtonHidden) return;
return (
<View style={styles.walletRoot}>
{!isLoading && (
<TouchableOpacity accessibilityRole="button" style={styles.walletChooseWrap} onPress={navigateToSelectWallet}>
<Text style={styles.walletChooseText}>{loc.wallets.select_wallet.toLowerCase()}</Text>
<Icon name={I18nManager.isRTL ? 'angle-left' : 'angle-right'} size={18} type="font-awesome" color="#9aa0aa" />
</TouchableOpacity>
)}
<View style={styles.walletNameWrap}>
<TouchableOpacity accessibilityRole="button" style={styles.walletNameTouch} onPress={navigateToSelectWallet}>
<Text style={[styles.walletNameText, styleHooks.walletNameText]}>{wallet.current.getLabel()}</Text>
<Text style={[styles.walletNameBalance, styleHooks.walletNameBalance]}>
{formatBalanceWithoutSuffix(wallet.current.getBalance(), BitcoinUnit.SATS, false)}
</Text>
<Text style={[styles.walletNameSats, styleHooks.walletNameSats]}>{BitcoinUnit.SATS}</Text>
</TouchableOpacity>
</View>
</View>
);
};
const onWalletSelect = selectedWallet => {
setParams({ walletID: selectedWallet.getID() });
pop();
};
if (!wallet.current) {
return (
<View style={[styles.root, styleHooks.root]}>
<BlueLoading />
</View>
);
}
return (
<TouchableWithoutFeedback onPress={Keyboard.dismiss} accessible={false}>
<View style={[styles.root, styleHooks.root]}>
<View style={[styles.amount, styleHooks.amount]}>
<KeyboardAvoidingView enabled={!Platform.isPad} behavior="position">
<AmountInput
isLoading={isLoading}
amount={amount}
onAmountUnitChange={setUnit}
onChangeText={setAmount}
disabled={isLoading || (lnurlParams && lnurlParams.fixed)}
unit={unit}
inputAccessoryViewID={BlueDismissKeyboardInputAccessory.InputAccessoryViewID}
/>
<View style={[styles.fiat, styleHooks.fiat]}>
<TextInput
onChangeText={setDescription}
placeholder={loc.receive.details_label}
value={description}
numberOfLines={1}
placeholderTextColor="#81868e"
style={styles.fiat2}
editable={!isLoading}
onSubmitEditing={Keyboard.dismiss}
inputAccessoryViewID={BlueDismissKeyboardInputAccessory.InputAccessoryViewID}
/>
{lnurlParams ? null : renderScanClickable()}
</View>
<BlueDismissKeyboardInputAccessory />
{renderCreateButton()}
</KeyboardAvoidingView>
</View>
{renderWalletSelectionButton()}
</View>
</TouchableWithoutFeedback>
);
};
const styles = StyleSheet.create({
createButton: {
marginHorizontal: 16,
marginVertical: 16,
minHeight: 45,
},
scanRoot: {
height: 36,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
borderRadius: 4,
paddingVertical: 4,
paddingHorizontal: 8,
marginHorizontal: 4,
},
scanClick: {
marginLeft: 4,
},
walletRoot: {
marginBottom: 16,
alignItems: 'center',
justifyContent: 'center',
},
walletChooseWrap: {
flexDirection: 'row',
alignItems: 'center',
},
walletChooseText: {
color: '#9aa0aa',
fontSize: 14,
marginRight: 8,
},
walletNameWrap: {
flexDirection: 'row',
alignItems: 'center',
marginVertical: 4,
},
walletNameTouch: {
flexDirection: 'row',
alignItems: 'center',
},
walletNameText: {
fontSize: 14,
},
walletNameBalance: {
fontSize: 14,
fontWeight: '600',
marginLeft: 8,
marginRight: 4,
},
walletNameSats: {
fontSize: 11,
fontWeight: '600',
textAlignVertical: 'bottom',
marginTop: 2,
},
root: {
flex: 1,
justifyContent: 'space-between',
},
amount: {
flex: 1,
},
fiat: {
flexDirection: 'row',
borderWidth: 1.0,
borderBottomWidth: 0.5,
minHeight: 44,
height: 44,
marginHorizontal: 20,
alignItems: 'center',
marginVertical: 8,
borderRadius: 4,
},
fiat2: {
flex: 1,
marginHorizontal: 8,
minHeight: 33,
color: '#81868e',
},
});
export default LNDCreateInvoice;
LNDCreateInvoice.routeName = 'LNDCreateInvoice';
LNDCreateInvoice.navigationOptions = navigationStyle(
{
closeButton: true,
headerBackVisible: false,
statusBarStyle: 'light',
},
opts => ({ ...opts, title: loc.receive.header }),
);