import React, { useCallback, useEffect, useMemo, useState } from 'react'; import Clipboard from '@react-native-clipboard/clipboard'; import { RouteProp, useFocusEffect, useRoute } from '@react-navigation/native'; import { NativeStackNavigationProp } from '@react-navigation/native-stack'; import assert from 'assert'; import dayjs from 'dayjs'; import { InteractionManager, Keyboard, Linking, ScrollView, StyleSheet, Text, TextInput, View } from 'react-native'; import triggerHapticFeedback, { HapticFeedbackTypes } from '../../blue_modules/hapticFeedback'; import { BlueCard, BlueLoading, BlueSpacing20, BlueText } from '../../BlueComponents'; import { Transaction, TWallet } from '../../class/wallets/types'; import presentAlert from '../../components/Alert'; import CopyToClipboardButton from '../../components/CopyToClipboardButton'; import HandOffComponent from '../../components/HandOffComponent'; import HeaderRightButton from '../../components/HeaderRightButton'; import { useTheme } from '../../components/themes'; import ToolTipMenu from '../../components/TooltipMenu'; import loc from '../../loc'; import { useExtendedNavigation } from '../../hooks/useExtendedNavigation'; import { DetailViewStackParamList } from '../../navigation/DetailViewStackParamList'; import { useStorage } from '../../hooks/context/useStorage'; interface TransactionDetailsProps { route: RouteProp<{ params: { hash: string; walletID: string } }, 'params'>; navigation: NativeStackNavigationProp; } const actionKeys = { CopyToClipboard: 'copyToClipboard', GoToWallet: 'goToWallet', }; const actionIcons = { Clipboard: { iconType: 'SYSTEM', iconValue: 'doc.on.doc', }, GoToWallet: { iconType: 'SYSTEM', iconValue: 'wallet.pass', }, }; function onlyUnique(value: any, index: number, self: any[]) { return self.indexOf(value) === index; } function arrDiff(a1: any[], a2: any[]) { const ret = []; for (const v of a2) { if (a1.indexOf(v) === -1) { ret.push(v); } } return ret; } const toolTipMenuActions = [ { id: actionKeys.CopyToClipboard, text: loc.transactions.copy_link, icon: actionIcons.Clipboard, }, ]; type NavigationProps = NativeStackNavigationProp; const TransactionDetails = () => { const { setOptions, navigate } = useExtendedNavigation(); const { hash, walletID } = useRoute().params; const { saveToDisk, txMetadata, counterpartyMetadata, wallets, getTransactions } = useStorage(); const [from, setFrom] = useState([]); const [to, setTo] = useState([]); const [isLoading, setIsLoading] = useState(true); const [tx, setTX] = useState(); const [memo, setMemo] = useState(''); const [counterpartyLabel, setCounterpartyLabel] = useState(''); const [paymentCode, setPaymentCode] = useState(''); const [isCounterpartyLabelVisible, setIsCounterpartyLabelVisible] = useState(false); const { colors } = useTheme(); const stylesHooks = StyleSheet.create({ memoTextInput: { borderColor: colors.formBorder, borderBottomColor: colors.formBorder, backgroundColor: colors.inputBackgroundColor, }, greyButton: { backgroundColor: colors.lightButton, }, Link: { color: colors.buttonTextColor, }, }); const handleOnSaveButtonTapped = useCallback(() => { Keyboard.dismiss(); if (!tx) return; txMetadata[tx.hash] = { memo }; if (counterpartyLabel && paymentCode) { counterpartyMetadata[paymentCode] = { label: counterpartyLabel }; } saveToDisk().then(_success => { triggerHapticFeedback(HapticFeedbackTypes.NotificationSuccess); presentAlert({ message: loc.transactions.transaction_saved }); }); }, [tx, txMetadata, memo, counterpartyLabel, paymentCode, saveToDisk, counterpartyMetadata]); const HeaderRight = useMemo( () => , [handleOnSaveButtonTapped], ); useEffect(() => { // This effect only handles changes in `colors` setOptions({ headerRight: () => HeaderRight }); }, [colors, HeaderRight, setOptions]); useFocusEffect( useCallback(() => { const task = InteractionManager.runAfterInteractions(() => { let foundTx: Transaction | false = false; let newFrom: string[] = []; let newTo: string[] = []; for (const transaction of getTransactions(undefined, Infinity, true)) { if (transaction.hash === hash) { foundTx = transaction; for (const input of foundTx.inputs) { newFrom = newFrom.concat(input?.addresses ?? []); } for (const output of foundTx.outputs) { if (output?.scriptPubKey?.addresses) newTo = newTo.concat(output.scriptPubKey.addresses); } } } assert(foundTx, 'Internal error: could not find transaction'); const wallet = wallets.find(w => w.getID() === walletID); assert(wallet, 'Internal error: could not find wallet'); if (wallet.allowBIP47() && wallet.isBIP47Enabled() && 'getBip47CounterpartyByTxid' in wallet) { const foundPaymentCode = wallet.getBip47CounterpartyByTxid(hash); if (foundPaymentCode) { // okay, this txid _was_ with someone using payment codes, so we show the label edit dialog // and load user-defined alias for the pc if any setCounterpartyLabel(counterpartyMetadata ? counterpartyMetadata[foundPaymentCode]?.label ?? '' : ''); setIsCounterpartyLabelVisible(true); setPaymentCode(foundPaymentCode); } } setMemo(txMetadata[foundTx.hash]?.memo ?? ''); setTX(foundTx); setFrom(newFrom); setTo(newTo); setIsLoading(false); }); return () => { task.cancel(); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [hash, wallets]), ); const handleOnOpenTransactionOnBlockExplorerTapped = () => { const url = `https://mempool.space/tx/${tx?.hash}`; Linking.canOpenURL(url) .then(supported => { if (supported) { Linking.openURL(url).catch(e => { console.log('openURL failed in handleOnOpenTransactionOnBlockExplorerTapped'); console.log(e.message); triggerHapticFeedback(HapticFeedbackTypes.NotificationError); presentAlert({ message: e.message }); }); } else { console.log('canOpenURL supported is false in handleOnOpenTransactionOnBlockExplorerTapped'); triggerHapticFeedback(HapticFeedbackTypes.NotificationError); presentAlert({ message: loc.transactions.open_url_error }); } }) .catch(e => { console.log('canOpenURL failed in handleOnOpenTransactionOnBlockExplorerTapped'); console.log(e.message); triggerHapticFeedback(HapticFeedbackTypes.NotificationError); presentAlert({ message: e.message }); }); }; const handleCopyPress = (stringToCopy: string) => { Clipboard.setString(stringToCopy !== actionKeys.CopyToClipboard ? stringToCopy : `https://mempool.space/tx/${tx?.hash}`); }; if (isLoading || !tx) { return ; } const weOwnAddress = (address: string): TWallet | null => { for (const w of wallets) { if (w.weOwnAddress(address)) { return w; } } return null; }; const navigateToWallet = (wallet: TWallet) => { navigate('WalletTransactions', { walletID: wallet.getID(), walletType: wallet.type, }); }; const onPressMenuItem = (key: string) => { if (key === actionKeys.CopyToClipboard) { handleCopyPress(key); } else if (key === actionKeys.GoToWallet) { const wallet = weOwnAddress(key); if (wallet) { navigateToWallet(wallet); } } }; const renderSection = (array: any[]) => { const fromArray = []; for (const [index, address] of array.entries()) { const actions = []; actions.push({ id: actionKeys.CopyToClipboard, text: loc.transactions.details_copy, icon: actionIcons.Clipboard, }); const isWeOwnAddress = weOwnAddress(address); if (isWeOwnAddress) { actions.push({ id: actionKeys.GoToWallet, text: loc.formatString(loc.transactions.view_wallet, { walletLabel: isWeOwnAddress.getLabel() }), icon: actionIcons.GoToWallet, }); } fromArray.push( {address} {index === array.length - 1 ? null : ','} , ); } return fromArray; }; return ( {isCounterpartyLabelVisible ? ( ) : null} {from && ( <> {loc.transactions.details_from} {renderSection(from.filter(onlyUnique))} )} {to && ( <> {loc.transactions.details_to} {renderSection(arrDiff(from, to.filter(onlyUnique)))} )} {tx.hash && ( <> {loc.transactions.txid} {tx.hash} )} {tx.received && ( <> {loc.transactions.details_received} {dayjs(tx.received).format('LLL')} )} {tx.inputs && ( <> {loc.transactions.details_inputs} {tx.inputs.length} )} {tx.outputs?.length > 0 && ( <> {loc.transactions.details_outputs} {tx.outputs.length} )} {loc.transactions.details_show_in_block_explorer} ); }; const styles = StyleSheet.create({ scroll: { flex: 1, }, rowHeader: { flex: 1, flexDirection: 'row', marginBottom: 4, justifyContent: 'space-between', }, rowCaption: { fontSize: 16, fontWeight: '500', marginBottom: 4, }, rowValue: { color: 'grey', }, marginBottom18: { marginBottom: 18, }, txid: { fontSize: 16, fontWeight: '500', }, Link: { fontWeight: '600', fontSize: 15, }, weOwnAddress: { fontWeight: '700', }, memoTextInput: { flexDirection: 'row', borderWidth: 1, borderBottomWidth: 0.5, minHeight: 44, height: 44, alignItems: 'center', marginVertical: 8, borderRadius: 4, paddingHorizontal: 8, color: '#81868e', }, greyButton: { borderRadius: 9, minHeight: 49, paddingHorizontal: 8, justifyContent: 'center', alignItems: 'center', flexDirection: 'row', alignSelf: 'auto', flexGrow: 1, marginHorizontal: 4, }, }); export default TransactionDetails;