BlueWallet/components/TransactionListItem.tsx
2024-07-26 19:53:12 -04:00

344 lines
13 KiB
TypeScript

import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import AsyncStorage from '@react-native-async-storage/async-storage';
import Clipboard from '@react-native-clipboard/clipboard';
import { Linking, View, ViewStyle } from 'react-native';
import Lnurl from '../class/lnurl';
import { LightningTransaction, Transaction } from '../class/wallets/types';
import TransactionExpiredIcon from '../components/icons/TransactionExpiredIcon';
import TransactionIncomingIcon from '../components/icons/TransactionIncomingIcon';
import TransactionOffchainIcon from '../components/icons/TransactionOffchainIcon';
import TransactionOffchainIncomingIcon from '../components/icons/TransactionOffchainIncomingIcon';
import TransactionOnchainIcon from '../components/icons/TransactionOnchainIcon';
import TransactionOutgoingIcon from '../components/icons/TransactionOutgoingIcon';
import TransactionPendingIcon from '../components/icons/TransactionPendingIcon';
import loc, { formatBalanceWithoutSuffix, transactionTimeToReadable } from '../loc';
import { BitcoinUnit } from '../models/bitcoinUnits';
import { useSettings } from '../hooks/context/useSettings';
import ListItem from './ListItem';
import { useTheme } from './themes';
import { Action, ToolTipMenuProps } from './types';
import { useExtendedNavigation } from '../hooks/useExtendedNavigation';
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { DetailViewStackParamList } from '../navigation/DetailViewStackParamList';
import { useStorage } from '../hooks/context/useStorage';
import ToolTipMenu from './TooltipMenu';
import { CommonToolTipActions } from '../typings/CommonToolTipActions';
import { pop } from '../NavigationService';
interface TransactionListItemProps {
itemPriceUnit: BitcoinUnit;
walletID: string;
item: Transaction & LightningTransaction; // using type intersection to have less issues with ts
searchQuery?: string;
style?: ViewStyle;
renderHighlightedText?: (text: string, query: string) => JSX.Element;
}
type NavigationProps = NativeStackNavigationProp<DetailViewStackParamList>;
export const TransactionListItem: React.FC<TransactionListItemProps> = React.memo(
({ item, itemPriceUnit = BitcoinUnit.BTC, walletID, searchQuery, style, renderHighlightedText }) => {
const [subtitleNumberOfLines, setSubtitleNumberOfLines] = useState(1);
const { colors } = useTheme();
const { navigate } = useExtendedNavigation<NavigationProps>();
const menuRef = useRef<ToolTipMenuProps>();
const { txMetadata, counterpartyMetadata, wallets } = useStorage();
const { language } = useSettings();
const containerStyle = useMemo(
() => ({
backgroundColor: 'transparent',
borderBottomColor: colors.lightBorder,
}),
[colors.lightBorder],
);
const combinedStyle = useMemo(() => [containerStyle, style], [containerStyle, style]);
const shortenContactName = (name: string): string => {
if (name.length < 16) return name;
return name.substr(0, 7) + '...' + name.substr(name.length - 7, 7);
};
const title = useMemo(() => {
if (item.confirmations === 0) {
return loc.transactions.pending;
} else {
return transactionTimeToReadable(item.received!);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [item.confirmations, item.received, language]);
let counterparty;
if (item.counterparty) {
counterparty = counterpartyMetadata?.[item.counterparty]?.label ?? item.counterparty;
}
const txMemo = (counterparty ? `[${shortenContactName(counterparty)}] ` : '') + (txMetadata[item.hash]?.memo ?? '');
const subtitle = useMemo(() => {
let sub = Number(item.confirmations) < 7 ? loc.formatString(loc.transactions.list_conf, { number: item.confirmations }) : '';
if (sub !== '') sub += ' ';
sub += txMemo;
if (item.memo) sub += item.memo;
return sub || undefined;
}, [txMemo, item.confirmations, item.memo]);
const rowTitle = useMemo(() => {
if (item.type === 'user_invoice' || item.type === 'payment_request') {
if (isNaN(Number(item.value))) {
item.value = 0;
}
const currentDate = new Date();
const now = (currentDate.getTime() / 1000) | 0; // eslint-disable-line no-bitwise
const invoiceExpiration = item.timestamp! + item.expire_time!;
if (invoiceExpiration > now) {
return formatBalanceWithoutSuffix(item.value && item.value, itemPriceUnit, true).toString();
} else {
if (item.ispaid) {
return formatBalanceWithoutSuffix(item.value && item.value, itemPriceUnit, true).toString();
} else {
return loc.lnd.expired;
}
}
} else {
return formatBalanceWithoutSuffix(item.value && item.value, itemPriceUnit, true).toString();
}
}, [item, itemPriceUnit]);
const rowTitleStyle = useMemo(() => {
let color = colors.successColor;
if (item.type === 'user_invoice' || item.type === 'payment_request') {
const currentDate = new Date();
const now = (currentDate.getTime() / 1000) | 0; // eslint-disable-line no-bitwise
const invoiceExpiration = item.timestamp! + item.expire_time!;
if (invoiceExpiration > now) {
color = colors.successColor;
} else if (invoiceExpiration < now) {
if (item.ispaid) {
color = colors.successColor;
} else {
color = '#9AA0AA';
}
}
} else if (item.value! / 100000000 < 0) {
color = colors.foregroundColor;
}
return {
color,
fontSize: 14,
fontWeight: '600',
textAlign: 'right',
};
}, [item, colors.foregroundColor, colors.successColor]);
const determineTransactionTypeAndAvatar = () => {
if (item.category === 'receive' && item.confirmations! < 3) {
return {
label: loc.transactions.pending_transaction,
icon: <TransactionPendingIcon />,
};
}
if (item.type && item.type === 'bitcoind_tx') {
return {
label: loc.transactions.onchain,
icon: <TransactionOnchainIcon />,
};
}
if (item.type === 'paid_invoice') {
return {
label: loc.transactions.offchain,
icon: <TransactionOffchainIcon />,
};
}
if (item.type === 'user_invoice' || item.type === 'payment_request') {
const currentDate = new Date();
const now = (currentDate.getTime() / 1000) | 0; // eslint-disable-line no-bitwise
const invoiceExpiration = item.timestamp! + item.expire_time!;
if (!item.ispaid && invoiceExpiration < now) {
return {
label: loc.transactions.expired_transaction,
icon: <TransactionExpiredIcon />,
};
} else {
return {
label: loc.transactions.incoming_transaction,
icon: <TransactionOffchainIncomingIcon />,
};
}
}
if (!item.confirmations) {
return {
label: loc.transactions.pending_transaction,
icon: <TransactionPendingIcon />,
};
} else if (item.value! < 0) {
return {
label: loc.transactions.outgoing_transaction,
icon: <TransactionOutgoingIcon />,
};
} else {
return {
label: loc.transactions.incoming_transaction,
icon: <TransactionIncomingIcon />,
};
}
};
const { label: transactionTypeLabel, icon: avatar } = determineTransactionTypeAndAvatar();
const amountWithUnit = useMemo(() => {
const amount = formatBalanceWithoutSuffix(item.value && item.value, itemPriceUnit, true).toString();
const unit = itemPriceUnit === BitcoinUnit.BTC || itemPriceUnit === BitcoinUnit.SATS ? ` ${itemPriceUnit}` : ' ';
return `${amount}${unit}`;
}, [item.value, itemPriceUnit]);
useEffect(() => {
setSubtitleNumberOfLines(1);
}, [subtitle]);
const onPress = useCallback(async () => {
menuRef?.current?.dismissMenu?.();
if (item.hash) {
pop();
navigate('TransactionStatus', { hash: item.hash, walletID });
} else if (item.type === 'user_invoice' || item.type === 'payment_request' || item.type === 'paid_invoice') {
const lightningWallet = wallets.filter(wallet => wallet?.getID() === item.walletID);
if (lightningWallet.length === 1) {
try {
// is it a successful lnurl-pay?
const LN = new Lnurl(false, AsyncStorage);
let paymentHash = item.payment_hash!;
if (typeof paymentHash === 'object') {
paymentHash = Buffer.from(paymentHash.data).toString('hex');
}
const loaded = await LN.loadSuccessfulPayment(paymentHash);
if (loaded) {
navigate('ScanLndInvoiceRoot', {
screen: 'LnurlPaySuccess',
params: {
paymentHash,
justPaid: false,
fromWalletID: lightningWallet[0].getID(),
},
});
return;
}
} catch (e) {
console.debug(e);
}
navigate('LNDViewInvoice', {
invoice: item,
walletID: lightningWallet[0].getID(),
});
}
}
}, [item, wallets, navigate, walletID]);
const handleOnExpandNote = useCallback(() => {
setSubtitleNumberOfLines(0);
}, []);
const subtitleProps = useMemo(() => ({ numberOfLines: subtitleNumberOfLines }), [subtitleNumberOfLines]);
const handleOnCopyAmountTap = useCallback(() => Clipboard.setString(rowTitle.replace(/[\s\\-]/g, '')), [rowTitle]);
const handleOnCopyTransactionID = useCallback(() => Clipboard.setString(item.hash), [item.hash]);
const handleOnCopyNote = useCallback(() => Clipboard.setString(subtitle ?? ''), [subtitle]);
const handleOnViewOnBlockExplorer = useCallback(() => {
const url = `https://mempool.space/tx/${item.hash}`;
Linking.canOpenURL(url).then(supported => {
if (supported) {
Linking.openURL(url);
}
});
}, [item.hash]);
const handleCopyOpenInBlockExplorerPress = useCallback(() => {
Clipboard.setString(`https://mempool.space/tx/${item.hash}`);
}, [item.hash]);
const onToolTipPress = useCallback(
(id: any) => {
if (id === CommonToolTipActions.CopyAmount.id) {
handleOnCopyAmountTap();
} else if (id === CommonToolTipActions.CopyNote.id) {
handleOnCopyNote();
} else if (id === CommonToolTipActions.OpenInBlockExplorer.id) {
handleOnViewOnBlockExplorer();
} else if (id === CommonToolTipActions.ExpandNote.id) {
handleOnExpandNote();
} else if (id === CommonToolTipActions.CopyBlockExplorerLink.id) {
handleCopyOpenInBlockExplorerPress();
} else if (id === CommonToolTipActions.CopyTXID.id) {
handleOnCopyTransactionID();
}
},
[
handleCopyOpenInBlockExplorerPress,
handleOnCopyAmountTap,
handleOnCopyNote,
handleOnCopyTransactionID,
handleOnExpandNote,
handleOnViewOnBlockExplorer,
],
);
const toolTipActions = useMemo((): Action[] | Action[][] => {
const actions: (Action | Action[])[] = [];
if (rowTitle !== loc.lnd.expired) {
actions.push(CommonToolTipActions.CopyAmount);
}
if (subtitle) {
actions.push(CommonToolTipActions.CopyNote);
}
if (item.hash) {
actions.push(CommonToolTipActions.CopyTXID, CommonToolTipActions.CopyBlockExplorerLink, [CommonToolTipActions.OpenInBlockExplorer]);
}
if (subtitle && subtitleNumberOfLines === 1) {
actions.push([CommonToolTipActions.ExpandNote]);
}
return actions as Action[] | Action[][];
}, [item.hash, subtitle, rowTitle, subtitleNumberOfLines]);
const accessibilityState = useMemo(() => {
return {
expanded: subtitleNumberOfLines === 0,
};
}, [subtitleNumberOfLines]);
return (
<ToolTipMenu
isButton
actions={toolTipActions}
onPressMenuItem={onToolTipPress}
onPress={onPress}
accessibilityLabel={`${transactionTypeLabel}, ${amountWithUnit}, ${subtitle ?? title}`}
accessibilityRole="button"
accessibilityState={accessibilityState}
>
<ListItem
leftAvatar={avatar}
title={title}
subtitleNumberOfLines={subtitleNumberOfLines}
subtitle={subtitle ? (renderHighlightedText ? renderHighlightedText(subtitle, searchQuery ?? '') : subtitle) : undefined}
Component={View}
subtitleProps={subtitleProps}
chevron={false}
rightTitle={rowTitle}
rightTitleStyle={rowTitleStyle}
containerStyle={combinedStyle}
/>
</ToolTipMenu>
);
},
);