BlueWallet/screen/lnd/ldkInfo.tsx
2024-03-31 20:59:14 +01:00

469 lines
16 KiB
TypeScript

import React, { useContext, useEffect, useRef, useState } from 'react';
import { View, StyleSheet, Text, Keyboard, TouchableOpacity, SectionList } from 'react-native';
import { RouteProp, useNavigation, useRoute } from '@react-navigation/native';
import { Psbt } from 'bitcoinjs-lib';
import { BlueSpacing20, BlueSpacing10, BlueLoading, BlueTextCentered } from '../../BlueComponents';
import navigationStyle from '../../components/navigationStyle';
import { BlueStorageContext } from '../../blue_modules/storage-context';
import { Chain } from '../../models/bitcoinUnits';
import loc, { formatBalance } from '../../loc';
import LNNodeBar from '../../components/LNNodeBar';
import BottomModal from '../../components/BottomModal';
import Button from '../../components/Button';
import { LightningLdkWallet } from '../../class';
import presentAlert from '../../components/Alert';
import { useTheme } from '../../components/themes';
import StyledButton, { StyledButtonType } from '../../components/StyledButton';
import SafeArea from '../../components/SafeArea';
import confirm from '../../helpers/confirm';
import selectWallet from '../../helpers/select-wallet';
import { TWallet } from '../../class/wallets/types';
const LdkNodeInfoChannelStatus = { ACTIVE: 'Active', INACTIVE: 'Inactive', PENDING: 'PENDING', STATUS: 'status' };
type LdkInfoRouteProps = RouteProp<
{
params: {
walletID: string;
psbt: Psbt;
};
},
'params'
>;
const LdkInfo = () => {
const { walletID } = useRoute<LdkInfoRouteProps>().params;
const { wallets } = useContext(BlueStorageContext);
const refreshDataInterval = useRef<NodeJS.Timer>();
const sectionList = useRef<SectionList | null>();
const wallet = wallets.find(w => w.getID() === walletID) as LightningLdkWallet;
const { colors } = useTheme();
const { setOptions, navigate } = useNavigation();
const name = useRoute().name;
const [isLoading, setIsLoading] = useState(true);
const [channels, setChannels] = useState<any[]>([]);
const [inactiveChannels, setInactiveChannels] = useState<any[]>([]);
const [pendingChannels, setPendingChannels] = useState<any[]>([]);
const [wBalance, setWalletBalance] = useState<{ confirmedBalance?: number }>({});
const [maturingBalance, setMaturingBalance] = useState(0);
const [maturingEta, setMaturingEta] = useState('');
const centerContent = channels.length === 0 && pendingChannels.length === 0 && inactiveChannels.length === 0;
const allChannelsAmount = useRef(0);
// Modals
const [selectedChannelIndex, setSelectedChannelIndex] = useState<any>();
const stylesHook = StyleSheet.create({
root: {
backgroundColor: colors.background,
},
listHeaderText: {
color: colors.foregroundColor,
backgroundColor: colors.background,
},
listHeaderBack: {
backgroundColor: colors.background,
},
detailsText: {
color: colors.alternativeTextColor,
},
modalContent: {
backgroundColor: colors.elevated,
},
separator: {
backgroundColor: colors.inputBorderColor,
},
});
const refetchData = async (withLoadingIndicator = true) => {
setIsLoading(withLoadingIndicator);
try {
const listChannels = await wallet.listChannels();
if (listChannels && Array.isArray(listChannels)) {
const activeChannels = listChannels.filter(channel => channel.is_usable === true);
setChannels(activeChannels);
} else {
setChannels([]);
}
if (listChannels && Array.isArray(listChannels)) {
const inactive = listChannels.filter(channel => !channel.is_usable && channel.is_funding_locked);
setInactiveChannels(inactive);
} else {
setInactiveChannels([]);
}
if (listChannels && Array.isArray(listChannels)) {
const listPendingChannels = listChannels.filter(channel => !channel.is_funding_locked);
setPendingChannels(listPendingChannels);
} else {
setPendingChannels([]);
}
const walletBalance: { confirmedBalance?: number } = await wallet.walletBalance();
setWalletBalance(walletBalance);
setMaturingBalance(await wallet.getMaturingBalance());
const maturingHeight = await wallet.getMaturingHeight();
if (maturingHeight > 0) {
const result = await fetch('https://blockstream.info/api/blocks/tip/height');
const tip = await result.text();
const hrs = Math.ceil((maturingHeight - +tip) / 6); // convert blocks to hours
setMaturingEta(`${hrs} hours`);
} else {
setMaturingEta('');
}
} catch (e) {
console.log(e);
} finally {
setIsLoading(false);
}
};
useEffect(() => {
const channelsAvailable = channels.length + pendingChannels.length + inactiveChannels.length;
if (allChannelsAmount.current === 0 && channelsAvailable >= 1) {
sectionList?.current?.scrollToLocation({ animated: false, sectionIndex: 0, itemIndex: 0 });
}
allChannelsAmount.current = channelsAvailable;
}, [channels, pendingChannels, inactiveChannels]);
// do we even need periodic sync when user stares at this screen..?
useEffect(() => {
refetchData().then(() => {
refreshDataInterval.current = setInterval(() => {
refetchData(false);
if (wallet.timeToCheckBlockchain()) {
wallet.checkBlockchain();
wallet.reconnectPeersWithPendingChannels();
}
}, 2000);
});
return () => {
clearInterval(refreshDataInterval?.current as NodeJS.Timeout);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
setOptions({
headerStyle: {
backgroundColor: colors.customHeader,
},
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [colors]);
const showModal = (index: any) => {
setSelectedChannelIndex(index);
};
const closeChannel = async (channel: any) => {
if (!(await confirm())) return;
setSelectedChannelIndex(undefined);
const wallets2use = wallets.filter(w => w.chain === Chain.ONCHAIN);
const toWallet = await selectWallet(navigate, name, null, wallets2use, 'Onchain wallet is required to withdraw funds to');
// using wallets2use instead of simple Chain.ONCHAIN argument because by default this argument only selects wallets
// that can send, which is not possible if user wants to withdraw to watch-only wallet
if (!toWallet) return;
console.warn('want to close to wallet ', toWallet.getLabel());
const address = await toWallet.getAddressAsync();
if (!address) return presentAlert({ message: 'Error: could not get address for channel withdrawal' });
await wallet.setRefundAddress(address);
let forceClose = false;
if (!channel.is_usable) {
if (!(await confirm(loc.lnd.force_close_channel))) return;
forceClose = true;
}
const rez = await wallet.closeChannel(channel.channel_id, forceClose);
if (rez) {
presentAlert({ message: loc._.success });
return refetchData();
}
};
const claimBalance = async () => {
const wallets2use = wallets.filter(w => w.chain === Chain.ONCHAIN);
const selectedWallet = await selectWallet(navigate, name, null, wallets2use, 'Onchain wallet is required to withdraw funds to');
// using wallets2use instead of simple Chain.ONCHAIN argument because by default this argument only selects wallets
// that can send, which is not possible if user wants to withdraw to watch-only wallet
if (!selectedWallet) return;
const address = await selectedWallet.getAddressAsync();
if (address && (await confirm())) {
console.warn('selected ', selectedWallet.getLabel(), address);
setIsLoading(true);
try {
const rez = await wallet.claimCoins(address);
if (rez) {
presentAlert({ message: loc._.success });
await refetchData();
}
} finally {
setIsLoading(false);
}
}
};
const closeModal = () => {
Keyboard.dismiss();
setSelectedChannelIndex(undefined);
};
const handleOnConnectPeerTapped = async (channelData: any) => {
closeModal();
const { pubkey, host, port } = await wallet.lookupNodeConnectionDetailsByPubkey(channelData.remote_node_id);
return wallet.connectPeer(pubkey, host, port);
};
const renderModal = () => {
const status = selectedChannelIndex?.status;
const channelData = selectedChannelIndex?.channel.item;
return (
<BottomModal isVisible={selectedChannelIndex !== undefined} onClose={closeModal} avoidKeyboard>
<View style={[styles.modalContent, stylesHook.modalContent]}>
<Text style={stylesHook.detailsText}>{loc.lnd.node_alias}</Text>
<BlueSpacing10 />
{channelData && (
<Text style={stylesHook.detailsText}>
{LightningLdkWallet.pubkeyToAlias(channelData.remote_node_id) +
' (' +
channelData.remote_node_id.substr(0, 10) +
'...' +
channelData.remote_node_id.substr(-6) +
')'}
</Text>
)}
<BlueSpacing20 />
<LNNodeBar
disabled={
status === LdkNodeInfoChannelStatus.ACTIVE || status === LdkNodeInfoChannelStatus.INACTIVE ? !channelData?.is_usable : true
}
canSend={Number(channelData?.outbound_capacity_msat / 1000)}
canReceive={Number(channelData?.inbound_capacity_msat / 1000)}
itemPriceUnit={wallet.getPreferredBalanceUnit()}
/>
<Text style={stylesHook.detailsText}>
{status === LdkNodeInfoChannelStatus.PENDING
? loc.transactions.pending
: channelData?.is_usable
? loc.lnd.active
: loc.lnd.inactive}
</Text>
{status === LdkNodeInfoChannelStatus.INACTIVE && (
<>
<StyledButton
onPress={() => handleOnConnectPeerTapped(channelData)}
text={loc.lnd.reconnect_peer}
buttonStyle={StyledButtonType.grey}
/>
<BlueSpacing20 />
</>
)}
<StyledButton onPress={() => closeChannel(channelData)} text={loc.lnd.close_channel} buttonStyle={StyledButtonType.destroy} />
<BlueSpacing20 />
</View>
</BottomModal>
);
};
const renderSectionItem = (item: any) => {
switch (item.section.key) {
case LdkNodeInfoChannelStatus.ACTIVE:
return renderItemChannel({ status: LdkNodeInfoChannelStatus.ACTIVE, channel: item });
case LdkNodeInfoChannelStatus.PENDING:
return renderItemChannel({ status: LdkNodeInfoChannelStatus.PENDING, channel: item });
case LdkNodeInfoChannelStatus.INACTIVE:
return renderItemChannel({ status: LdkNodeInfoChannelStatus.INACTIVE, channel: item });
default:
return null;
}
};
const renderItemChannel = (channel: any) => {
const channelData = channel.channel.item;
return (
<TouchableOpacity accessibilityRole="button" onPress={() => showModal(channel)}>
<LNNodeBar
disabled={!channelData.is_usable}
canSend={Number(channelData.outbound_capacity_msat / 1000)}
canReceive={Number(channelData.inbound_capacity_msat / 1000)}
itemPriceUnit={wallet.getPreferredBalanceUnit()}
nodeAlias={LightningLdkWallet.pubkeyToAlias(channelData.remote_node_id)}
/>
</TouchableOpacity>
);
};
const navigateToOpenPrivateChannel = async () => {
navigateToOpenChannel({ isPrivateChannel: true });
};
const navigateToOpenChannel = async ({ isPrivateChannel }: { isPrivateChannel: boolean }) => {
const availableWallets = [...wallets.filter(item => item.isSegwit() && item.allowSend())];
if (availableWallets.length === 0) {
return presentAlert({ message: loc.lnd.refill_create });
}
// @ts-ignore: Address types later
navigate('LDKOpenChannelRoot', {
screen: 'SelectWallet',
params: {
availableWallets,
chainType: Chain.ONCHAIN,
onWalletSelect: (selectedWallet: TWallet) => {
const selectedWalletID = selectedWallet.getID();
selectedWallet.getAddressAsync().then((address): void => {
if (!address) {
presentAlert({ message: 'Error: could not get address for channel withdrawal' });
return;
}
wallet.setRefundAddress(address);
});
// @ts-ignore: Address types later
navigate('LDKOpenChannelRoot', {
screen: 'LDKOpenChannelSetAmount',
params: {
isPrivateChannel,
fundingWalletID: selectedWalletID,
ldkWalletID: walletID,
},
});
},
},
});
};
const itemSeparatorComponent = () => {
return <View style={[styles.separator, stylesHook.separator]} />;
};
const renderSectionHeader = (section: any) => {
switch (section.section.key) {
case LdkNodeInfoChannelStatus.PENDING:
return <Text style={[styles.listHeaderText, stylesHook.listHeaderText]}>{loc.transactions.pending}</Text>;
case LdkNodeInfoChannelStatus.ACTIVE:
return <Text style={[styles.listHeaderText, stylesHook.listHeaderText]}>{loc.lnd.active}</Text>;
case LdkNodeInfoChannelStatus.INACTIVE:
return <Text style={[styles.listHeaderText, stylesHook.listHeaderText]}>{loc.lnd.inactive}</Text>;
default:
return null;
}
};
const sections = () => {
const sectionForList = [];
if (channels.length > 0) {
sectionForList.push({ key: LdkNodeInfoChannelStatus.ACTIVE, data: channels });
}
if (inactiveChannels.length > 0) {
sectionForList.push({ key: LdkNodeInfoChannelStatus.INACTIVE, data: inactiveChannels });
}
if (pendingChannels.length > 0) {
sectionForList.push({ key: LdkNodeInfoChannelStatus.PENDING, data: pendingChannels });
}
return sectionForList;
};
return (
<SafeArea style={[styles.root, stylesHook.root]}>
<SectionList
ref={(ref: SectionList) => {
sectionList.current = ref;
}}
renderItem={renderSectionItem}
keyExtractor={channel => channel.channel_id}
initialNumToRender={7}
ItemSeparatorComponent={itemSeparatorComponent}
renderSectionHeader={section => (
<View style={[styles.listHeaderBack, stylesHook.listHeaderBack]}>{renderSectionHeader(section)}</View>
)}
contentContainerStyle={[centerContent ? {} : styles.contentContainerStyle, stylesHook.root]}
contentInset={{ top: 0, left: 0, bottom: 8, right: 0 }}
centerContent={centerContent}
sections={sections()}
ListEmptyComponent={isLoading ? <BlueLoading /> : <BlueTextCentered>{loc.lnd.no_channels}</BlueTextCentered>}
/>
{renderModal()}
<View style={styles.marginHorizontal16}>
{wBalance && wBalance.confirmedBalance ? (
<>
<Button
onPress={claimBalance}
title={loc.formatString(loc.lnd.claim_balance, {
balance: formatBalance(wBalance.confirmedBalance, wallet.getPreferredBalanceUnit()),
})}
/>
<BlueSpacing20 />
</>
) : null}
{maturingBalance ? (
<Text style={stylesHook.detailsText}>
Balance awaiting confirmations: {formatBalance(Number(maturingBalance), wallet.getPreferredBalanceUnit(), true)}
</Text>
) : null}
{maturingEta ? <Text style={stylesHook.detailsText}>ETA: {maturingEta}</Text> : null}
<Button title={loc.lnd.new_channel} onPress={navigateToOpenPrivateChannel} disabled={isLoading} />
<BlueSpacing20 />
</View>
</SafeArea>
);
};
const styles = StyleSheet.create({
root: {
flex: 1,
justifyContent: 'space-between',
},
marginHorizontal16: {
marginHorizontal: 16,
},
contentContainerStyle: {
marginHorizontal: 16,
},
listHeaderText: {
marginTop: 8,
marginBottom: 8,
fontWeight: 'bold',
fontSize: 24,
},
listHeaderBack: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
},
modalContent: {
minHeight: 418,
borderTopLeftRadius: 16,
borderTopRightRadius: 16,
borderColor: 'rgba(0, 0, 0, 0.1)',
padding: 24,
},
separator: {
height: 1,
marginTop: 16,
},
});
LdkInfo.navigationOptions = navigationStyle(
{
title: loc.lnd.channels,
},
(options, { theme, navigation, route }) => {
return {
...options,
statusBarStyle: 'auto',
};
},
);
export default LdkInfo;