diff --git a/components/DetailRow.tsx b/components/DetailRow.tsx new file mode 100644 index 000000000..0c6fc1f33 --- /dev/null +++ b/components/DetailRow.tsx @@ -0,0 +1,77 @@ +import React from 'react'; +import { Pressable, StyleSheet, Text, View } from 'react-native'; +import { useTheme } from './themes'; +import CopyIcon from './icons/CopyIcon'; +import { ClashFont } from '../constants/fonts'; +import loc from '../loc'; + +interface DetailRowProps { + label: string; + value: string; + mono?: boolean; + onCopy?: () => void; + copied?: boolean; + accessibilityLabel?: string; +} + +const DetailRow: React.FC = ({ label, value, mono, onCopy, copied, accessibilityLabel }) => { + const { colors } = useTheme(); + const stylesHook = StyleSheet.create({ + label: { color: colors.textPrimary }, + value: { color: colors.textPrimary }, + }); + return ( + + + {label} + {onCopy && ( + + + + )} + + {value} + + ); +}; + +const styles = StyleSheet.create({ + row: { + gap: 4, + paddingVertical: 8, + }, + head: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + }, + label: { + fontFamily: ClashFont.medium, + fontSize: 14, + lineHeight: 26, + }, + value: { + fontFamily: ClashFont.regular, + fontSize: 14, + lineHeight: 20, + }, + valueMono: { + fontFamily: ClashFont.regular, + fontSize: 14, + lineHeight: 26, + }, + copyBtn: { + width: 24, + height: 24, + alignItems: 'center', + justifyContent: 'center', + }, +}); + +export default DetailRow; diff --git a/components/TransactionListItem.tsx b/components/TransactionListItem.tsx index d0c4890b7..46e814816 100644 --- a/components/TransactionListItem.tsx +++ b/components/TransactionListItem.tsx @@ -196,7 +196,7 @@ export const TransactionListItem: React.FC = memo( if (renderHighlightedText) { pop(); } - navigate('TransactionStatus', { hash: item.hash, walletID }); + navigate('TransactionDetails', { tx: item, hash: item.hash, walletID }); } }, [item, renderHighlightedText, navigate, walletID, customOnPress]); diff --git a/components/icons/ExternalLinkIcon.tsx b/components/icons/ExternalLinkIcon.tsx new file mode 100644 index 000000000..439553f63 --- /dev/null +++ b/components/icons/ExternalLinkIcon.tsx @@ -0,0 +1,14 @@ +import React from 'react'; +import Svg, { Path } from 'react-native-svg'; +import { IconProps } from './types'; + +const ExternalLinkIcon: React.FC = ({ color = 'white', size = 24 }) => ( + + + +); + +export default ExternalLinkIcon; diff --git a/components/themes.ts b/components/themes.ts index 58c8ecebc..7809c44d0 100644 --- a/components/themes.ts +++ b/components/themes.ts @@ -105,7 +105,7 @@ const tokens = { copyHint: same(palette.gray450), // "tap to copy" icon + label progressTrack: same('#EAECF0'), buttonBorder: same('#EBEBEB'), - textPrimary: same('#1A1A1A'), // titles, primary copy + textPrimary: pair('#1A1A1A', palette.white), // titles, primary copy textSecondary: same('#8E8E93'), // subtitles, privacy copy textMeta: same('#92929B'), // ETA / "%" meta text textMuted: same('#7B7A7E'), // card row labels @@ -137,8 +137,8 @@ const tokens = { cardBackground: pair('#FDFCFE', '#1A1A1A'), // --- Send redesign tokens. Ones using lightOnly() still need real dark values (see todoDark, #3). --- - fieldBackground: lightOnly(palette.gray50), // Address / Note field background - amountMeta: lightOnly('#9B9BA5'), // BTC unit, fiat estimate, slow/medium ETA + fieldBackground: pair(palette.gray50, '#1E1E1E'), // Address / Note field background + amountMeta: pair('#9B9BA5', '#AEAEB2'), // BTC unit, fiat estimate, slow/medium ETA amountPlaceholder: lightOnly('rgba(0,0,0,0.32)'), // AmountHero empty/placeholder digits scrim: lightOnly('rgba(10, 13, 19, 0.8)'), // Success bottom-sheet backdrop ctaDisabled: lightOnly(palette.gray450), // disabled primary button background @@ -146,9 +146,9 @@ const tokens = { feeCardSelectedBorder: lightOnly('#B9BAF9'), // selected fee card border useMaxBorder: lightOnly('#E8E4FA'), // "Use Max" pill border useMaxText: lightOnly('#6E55E0'), // "Use Max" pill text - copyButtonBorder: lightOnly(palette.gray200), // Confirm copy-button border - divider: lightOnly(palette.gray200), // Confirm section dividers - summaryBorder: lightOnly(palette.gray200), // SendDetails fee summary card border + copyButtonBorder: pair(palette.gray200, palette.gray800), // Confirm copy-button border + divider: pair(palette.gray200, palette.gray850), // Confirm section dividers + summaryBorder: pair(palette.gray200, palette.gray850), // SendDetails fee summary card border transactionCardBorder: pair(palette.gray200, palette.gray850), txIconHaloBorder: pair(palette.violet100, palette.violet850), incomingIconBackground: pair('#E7E6F5', '#322361'), diff --git a/loc/en.json b/loc/en.json index 73f400056..27c8d115f 100644 --- a/loc/en.json +++ b/loc/en.json @@ -231,9 +231,6 @@ "cancel_explain": "We will replace this transaction with one that pays you and has higher fees. This effectively cancels the current transaction. This is called RBF—Replace by Fee.", "cancel_no": "This transaction is not replaceable.", "cancel_title": "Cancel this transaction (RBF)", - "transaction_loading_error": "There was an issue loading the transaction. Please try again later.", - "transaction_not_available": "Transaction not available", - "confirmations_lowercase": "{confirmations} confirmations", "expand_note": "Expand Note", "cpfp_create": "Create", "cpfp_exp": "We will create another transaction that spends your unconfirmed transaction. The total fee will be higher than the original transaction fee, so it should be mined faster. This is called CPFP—Child Pays for Parent.", @@ -243,12 +240,7 @@ "details_copy": "Copy", "details_copy_note": "Copy Note", "details_copy_txid": "Copy Transaction ID", - "details_from": "Input", - "details_inputs": "Inputs", - "details_outputs": "Outputs", - "details_received": "Received", "details_title": "Transaction", - "details_to": "Output", "list_conf": "Conf: {number}", "pending": "Pending", "pending_with_amount": "Pending {amt1} ({amt2})", @@ -256,14 +248,9 @@ "eta_10m": "ETA: In ~10 minutes", "eta_3h": "ETA: In ~3 hours", "eta_1d": "ETA: In ~1 day", - "view_wallet": "View {walletLabel}", "list_title": "Transactions", - "transaction": "Transaction", "rbf_explain": "We will replace this transaction with one with a higher fee so that it will be mined faster. This is called RBF—Replace by Fee.", "rbf_title": "Bump Fee (RBF)", - "status_bump": "Bump Fee", - "status_cancel": "Cancel Transaction", - "txid": "Transaction ID", "updating": "Updating..." }, "wallets": { diff --git a/navigation/DetailViewScreensStack.tsx b/navigation/DetailViewScreensStack.tsx index ddbbbbeee..c0a64e6c2 100644 --- a/navigation/DetailViewScreensStack.tsx +++ b/navigation/DetailViewScreensStack.tsx @@ -1,6 +1,5 @@ import React, { useMemo } from 'react'; import { NativeStackNavigationOptions } from '@react-navigation/native-stack'; -import HeaderRightButton from '../components/HeaderRightButton'; import navigationStyle, { CloseButtonPosition } from '../components/navigationStyle'; import { useTheme } from '../components/themes'; import loc from '../loc'; @@ -10,7 +9,6 @@ import CPFP from '../screen/transactions/CPFP'; import TransactionDetails from '../screen/transactions/TransactionDetails'; import RBFBumpFee from '../screen/transactions/RBFBumpFee'; import RBFCancel from '../screen/transactions/RBFCancel'; -import TransactionStatus from '../screen/transactions/TransactionStatus'; import WalletsList from '../screen/wallets/WalletsList'; import { DetailViewStack } from './index'; import SettingsButton from '../components/icons/SettingsButton'; @@ -36,7 +34,6 @@ import OnboardingStack from './OnboardingStack'; const DetailViewStackScreensStack = () => { const theme = useTheme(); const { sizeClass } = useSizeClass(); - const DetailButton = useMemo(() => , []); const RightBarButtons = useMemo(() => , []); const walletListScreenOptions = useMemo(() => { @@ -80,23 +77,6 @@ const DetailViewStackScreensStack = () => { headerTitle: loc.transactions.details_title, })(theme)} /> - DetailButton, - headerBackButtonDisplayMode: 'default', - })(theme)} - /> { )(theme)} initialParams={{ isEditable: true, feeUnit: BitcoinUnit.BTC, amountUnit: BitcoinUnit.BTC }} // Correctly typed now /> - - + + { type ConfirmRouteProp = RouteProp; type ConfirmNavigationProp = NativeStackNavigationProp; -const ConfirmDetailRow: React.FC<{ label: string; value: string; mono?: boolean; onCopy?: () => void; copied?: boolean }> = ({ - label, - value, - mono, - onCopy, - copied, -}) => { - const { colors } = useTheme(); - const stylesHook = StyleSheet.create({ - label: { color: colors.textPrimary }, - value: { color: colors.textPrimary }, - copyBtn: { backgroundColor: colors.white, borderColor: colors.copyButtonBorder }, - }); - return ( - - - {label} - {onCopy && ( - - - - )} - - {value} - - ); -}; - const Confirm: React.FC = () => { const { wallets, fetchAndSaveWalletTransactions } = useStorage(); const { isElectrumDisabled } = useSettings(); @@ -208,21 +180,23 @@ const Confirm: React.FC = () => { - copy(recipient?.address ?? '', setCopiedAddr)} + accessibilityLabel={loc.transactions.details_copy} /> - copy(txid, setCopiedTxid)} + accessibilityLabel={loc.transactions.details_copy_txid} /> @@ -296,36 +270,7 @@ const styles = StyleSheet.create({ detailsGroup: { marginTop: -10, }, - detailRow: { - gap: 4, - paddingVertical: 8, - }, - detailHead: { - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'center', - }, - detailLabel: { - fontFamily: ClashFont.medium, - fontSize: 14, - lineHeight: 26, - }, - detailValue: { - fontFamily: ClashFont.regular, - fontSize: 14, - lineHeight: 20, - }, - detailMono: { - fontFamily: ClashFont.regular, - fontSize: 14, - lineHeight: 26, - }, - copyBtn: { - width: 24, - height: 24, - alignItems: 'center', - justifyContent: 'center', - }, + summaryRow: { flexDirection: 'row', justifyContent: 'space-between', diff --git a/screen/transactions/TransactionDetails.tsx b/screen/transactions/TransactionDetails.tsx index e5cb9fcc1..606bdacb9 100644 --- a/screen/transactions/TransactionDetails.tsx +++ b/screen/transactions/TransactionDetails.tsx @@ -1,190 +1,118 @@ import React, { useCallback, useState } from 'react'; import Clipboard from '@react-native-clipboard/clipboard'; -import { RouteProp, useFocusEffect, usePreventRemove, useRoute } from '@react-navigation/native'; -import { NativeStackNavigationProp } from '@react-navigation/native-stack'; -import assert from 'assert'; +import { RouteProp, usePreventRemove, useRoute } from '@react-navigation/native'; import dayjs from 'dayjs'; -import { InteractionManager, StyleSheet, TextInput, View } from 'react-native'; -import { ShroudCard, ShroudText } from '../../ShroudComponents'; -import { Transaction, TWallet } from '../../class/wallets/types'; -import CopyToClipboardButton from '../../components/CopyToClipboardButton'; +import { Pressable, StyleSheet, TextInput, View, Text, Linking } from 'react-native'; import { useTheme } from '../../components/themes'; -import ToolTipMenu from '../../components/TooltipMenu'; -import loc from '../../loc'; -import { useExtendedNavigation } from '../../hooks/useExtendedNavigation'; +import loc, { formatBalanceWithoutSuffix } from '../../loc'; import { DetailViewStackParamList } from '../../navigation/DetailViewStackParamList'; import { useStorage } from '../../hooks/context/useStorage'; import SafeAreaScrollView from '../../components/SafeAreaScrollView'; -import { Loading } from '../../components/Loading'; +import TransactionDirectionIcon from '../../components/icons/TransactionDirectionIcon'; +import { getTransactionIconColors } from '../../components/icons/getTransactionIconColors'; +import { isIncomingTransaction, getRelevantAddress } from '../../utils/transactionHelpers'; +import { BitcoinUnit } from '../../models/bitcoinUnits'; +import { ClashFont } from '../../constants/fonts'; +import triggerHapticFeedback, { HapticFeedbackTypes } from '../../modules/hapticFeedback'; +import ExternalLinkIcon from '../../components/icons/ExternalLinkIcon'; +import DetailRow from '../../components/DetailRow'; -const actionKeys = { - CopyToClipboard: 'copyToClipboard', - GoToWallet: 'goToWallet', -}; - -const actionIcons = { - Clipboard: { - iconValue: 'doc.on.doc', - }, - GoToWallet: { - 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; -} - -type NavigationProps = NativeStackNavigationProp; type RouteProps = RouteProp; const TransactionDetails = () => { - const { navigate } = useExtendedNavigation(); - const { hash, walletID } = useRoute().params; - const { saveToDisk, txMetadata, 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 { tx, hash, walletID } = useRoute().params; + const { saveToDisk, txMetadata, wallets } = useStorage(); + const [memo, setMemo] = useState(txMetadata[hash]?.memo ?? ''); const { colors } = useTheme(); - const stylesHooks = StyleSheet.create({ - memoTextInput: { - borderColor: colors.formBorder, - borderBottomColor: colors.formBorder, - backgroundColor: colors.inputBackgroundColor, - }, - }); + const wallet = wallets.find(w => w.getID() === walletID); + const [copiedAddr, setCopiedAddr] = useState(false); + const [copiedTxid, setCopiedTxid] = useState(false); const saveTransactionDetails = useCallback(() => { - if (tx) { - txMetadata[tx.hash] = { memo }; - saveToDisk(); - } - }, [tx, txMetadata, memo, saveToDisk]); + txMetadata[hash] = { memo }; + saveToDisk(); + }, [hash, txMetadata, memo, saveToDisk]); usePreventRemove(false, () => { saveTransactionDetails(); }); - 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'); - - 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 handleMemoBlur = useCallback(() => { saveTransactionDetails(); }, [saveTransactionDetails]); - const handleCopyPress = (stringToCopy: string) => { - Clipboard.setString(stringToCopy); - }; - - if (isLoading || !tx) { - return ; - } - - const weOwnAddress = (address: string): TWallet | null => { - for (const w of wallets) { - if (w.weOwnAddress(address)) { - return w; - } - } - return null; + const handleCopy = (text: string, setFlag: (b: boolean) => void) => { + Clipboard.setString(text); + triggerHapticFeedback(HapticFeedbackTypes.Selection); + setFlag(true); + setTimeout(() => setFlag(false), 1000); }; - const onPressMenuItem = (key: string) => { - if (key === actionKeys.CopyToClipboard) { - handleCopyPress(key); - } else if (key === actionKeys.GoToWallet) { - const wallet = weOwnAddress(key); - if (wallet) { - navigate('WalletsList', {}); - } + const viewInBlockExplorer = () => { + if (tx?.hash) { + Linking.openURL(`https://mempool.space/tx/${tx.hash}`); } }; - const renderSection = (array: any[]) => { - const fromArray = []; + const isIncoming = isIncomingTransaction(tx.value); + const amount = formatBalanceWithoutSuffix(tx.value ?? 0, BitcoinUnit.BTC, true); + const address = getRelevantAddress(tx, wallet); + const confirmations = tx.confirmations; - 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, - }); - } + // TODO: Add actual split transaction check logic when available in the wallet + const isSplit = false; - fromArray.push( - - - {address} - {index === array.length - 1 ? null : ','} - - , - ); - } - - return fromArray; - }; + const stylesHooks = StyleSheet.create({ + headerStatus: { color: colors.brandPrimary }, + amountValue: { color: colors.foregroundColor }, + amountUnit: { color: colors.amountMeta }, + dateText: { color: colors.chevron }, + noteLabel: { color: colors.textSecondary }, + memoTextInput: { + backgroundColor: colors.fieldBackground, + color: colors.textPrimary, + }, + divider: { backgroundColor: colors.divider }, + summaryTitle: { color: colors.textPrimary }, + summaryTitleSecondary: { color: colors.textSecondary }, + summaryValueConfirmations: { color: colors.brandPrimary }, + explorerButton: { + backgroundColor: colors.background, + borderColor: colors.copyButtonBorder, + }, + splitTag: { + backgroundColor: colors.incomingIconBackground, + }, + }); return ( - - - + + + {/* Header: Icon + Status + Amount + Date */} + + + + {isIncoming ? 'RECEIVED' : 'SENT'} + {isSplit && ( + + SPLIT + + )} + + + {amount} + BTC + + {dayjs(tx.timestamp * 1000).format('MMMM D, YYYY [·] h:mm A')} + + + {/* Note Section */} + + Note { /> - {from && ( - <> - - {loc.transactions.details_from} - - - {renderSection(from.filter(onlyUnique))} - - - )} + {/* Info Rows: Address + Transaction ID */} + + handleCopy(address, setCopiedAddr) : undefined} + accessibilityLabel={loc.transactions.details_copy} + /> - {to && ( - <> - - {loc.transactions.details_to} - - - {renderSection(arrDiff(from, to.filter(onlyUnique)))} - - - )} + - {tx.hash && ( - <> - - {loc.transactions.txid} - - - {tx.hash} - - - )} + handleCopy(tx.hash, setCopiedTxid)} + accessibilityLabel={loc.transactions.details_copy_txid} + /> + - {tx.timestamp && ( - <> - {loc.transactions.details_received} - {dayjs(tx.timestamp * 1000).format('LLL')} - - - )} + - {tx.inputs && ( - <> - {loc.transactions.details_inputs} - {tx.inputs.length} - - - )} + {/* Summary: Confirmations, Outputs, Fee */} + + + Confirmations + {confirmations > 6 ? '6+' : confirmations} + + + Outputs + {tx.outputs?.length || 0} + + {/* TODO: plugin actual fee value (we don't store the fee rn, in txn) */} + + Fee + - + + - {tx.outputs?.length > 0 && ( - <> - {loc.transactions.details_outputs} - {tx.outputs.length} - - - )} - + {/* Block Explorer Button */} + + + + View In Block Explorer + + + ); }; const styles = StyleSheet.create({ - rowHeader: { + scrollContent: { + flexGrow: 1, + }, + content: { + paddingHorizontal: 24, + paddingTop: 32, + alignItems: 'center', + paddingBottom: 24, flex: 1, + }, + headerContainer: { + alignItems: 'center', + gap: 4, + }, + statusContainer: { flexDirection: 'row', - marginBottom: 4, - justifyContent: 'space-between', + alignItems: 'center', }, - rowCaption: { - fontSize: 16, - fontWeight: '500', - marginBottom: 4, + headerStatus: { + fontFamily: ClashFont.medium, + fontSize: 14, + lineHeight: 24, + textTransform: 'uppercase', + textAlign: 'center', }, - rowValue: { - color: 'grey', + splitTag: { + paddingHorizontal: 6, + paddingVertical: 2, + borderRadius: 4, + marginLeft: 8, }, - marginBottom18: { - marginBottom: 18, + splitTagText: { + fontFamily: ClashFont.semibold, + fontSize: 11, + textTransform: 'uppercase', }, - txid: { - fontSize: 16, - fontWeight: '500', + amountContainer: { + flexDirection: 'row', + alignItems: 'flex-end', + justifyContent: 'center', + gap: 8, + }, + amountValue: { + fontFamily: ClashFont.medium, + fontSize: 32, + lineHeight: 39, + letterSpacing: -1.2, + textAlign: 'center', + }, + amountUnit: { + fontFamily: ClashFont.regular, + fontSize: 15, + lineHeight: 18, + marginBottom: 5, + textAlign: 'center', + }, + dateText: { + fontFamily: ClashFont.regular, + fontSize: 14, + lineHeight: 22, + textAlign: 'center', + }, + noteContainer: { + width: '100%', + marginTop: 14, + gap: 8, }, - weOwnAddress: { - fontWeight: '700', + noteLabel: { + fontFamily: ClashFont.regular, + fontSize: 14, + lineHeight: 20, }, memoTextInput: { + borderRadius: 12, + paddingHorizontal: 12, + paddingVertical: 14, + fontFamily: ClashFont.regular, + fontSize: 13, + lineHeight: 20, + minHeight: 48, + }, + infoSection: { + width: '100%', + marginTop: 14, + }, + divider: { + width: '100%', + height: StyleSheet.hairlineWidth, + marginVertical: 4, + }, + summarySection: { + width: '100%', flexDirection: 'row', - borderWidth: 1, - borderBottomWidth: 0.5, - minHeight: 44, - height: 44, + justifyContent: 'space-between', alignItems: 'center', - marginVertical: 8, - borderRadius: 4, - paddingHorizontal: 8, - color: '#81868e', + paddingVertical: 14, + paddingHorizontal: 6, + }, + summaryItem: { + alignItems: 'flex-start', + gap: 8, + }, + summaryTitle: { + fontFamily: ClashFont.medium, + fontSize: 14, + lineHeight: 18, + letterSpacing: -0.1, + }, + summaryTitleLight: { + fontFamily: ClashFont.regular, + fontSize: 14, + lineHeight: 18, + letterSpacing: -0.1, + }, + summaryValue: { + fontFamily: ClashFont.medium, + fontSize: 14, + lineHeight: 20, + }, + actionContainer: { + width: '100%', + marginTop: 'auto', + }, + explorerButton: { + flexDirection: 'row', + justifyContent: 'center', + alignItems: 'center', + borderWidth: 1, + borderRadius: 16, + height: 56, + gap: 9, + }, + explorerButtonText: { + fontFamily: ClashFont.medium, + fontSize: 16, }, }); diff --git a/screen/transactions/TransactionStatus.tsx b/screen/transactions/TransactionStatus.tsx deleted file mode 100644 index e20b88986..000000000 --- a/screen/transactions/TransactionStatus.tsx +++ /dev/null @@ -1,696 +0,0 @@ -import React, { useCallback, useEffect, useMemo, useReducer, useRef } from 'react'; -import { RouteProp, useRoute } from '@react-navigation/native'; -import { NativeStackNavigationProp } from '@react-navigation/native-stack'; -import { ActivityIndicator, BackHandler, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; -import { Icon } from '@rneui/themed'; -import * as Electrum from '../../modules/Electrum'; -import triggerHapticFeedback, { HapticFeedbackTypes } from '../../modules/hapticFeedback'; -import { ShroudCard, ShroudText } from '../../ShroudComponents'; -import { HDSegwitBech32Transaction } from '../../class'; -import { HDSilentPaymentsWallet } from '../../class/wallets/hd-bip352-wallet'; -import { Transaction, TWallet } from '../../class/wallets/types'; -import Button from '../../components/Button'; -import TransactionDirectionIcon from '../../components/icons/TransactionDirectionIcon'; -import TransactionPendingIcon from '../../components/icons/TransactionPendingIcon'; -import { getTransactionIconColors } from '../../components/icons/getTransactionIconColors'; -import SafeArea from '../../components/SafeArea'; -import { useTheme } from '../../components/themes'; -import loc, { formatBalanceWithoutSuffix } from '../../loc'; -import { BitcoinUnit } from '../../models/bitcoinUnits'; -import { useStorage } from '../../hooks/context/useStorage'; -import HeaderRightButton from '../../components/HeaderRightButton'; -import { DetailViewStackParamList } from '../../navigation/DetailViewStackParamList'; -import { useExtendedNavigation } from '../../hooks/useExtendedNavigation'; -import { Spacing10, Spacing20 } from '../../components/Spacing'; -import { Loading } from '../../components/Loading'; - -enum ButtonStatus { - Possible, - Unknown, - NotPossible, -} - -type RouteProps = RouteProp; -type NavigationProps = NativeStackNavigationProp; - -enum ActionType { - SetCPFPPossible, - SetRBFBumpFeePossible, - SetRBFCancelPossible, - SetTransaction, - SetLoading, - SetEta, - SetIntervalMs, - SetAllButtonStatus, - SetWallet, - SetLoadingError, -} - -interface State { - isCPFPPossible: ButtonStatus; - isRBFBumpFeePossible: ButtonStatus; - isRBFCancelPossible: ButtonStatus; - tx: any; - isLoading: boolean; - eta: string; - intervalMs: number; - wallet: TWallet | null; - loadingError: boolean; -} - -const initialState: State = { - isCPFPPossible: ButtonStatus.Unknown, - isRBFBumpFeePossible: ButtonStatus.Unknown, - isRBFCancelPossible: ButtonStatus.Unknown, - tx: undefined, - isLoading: true, - eta: '', - intervalMs: 1000, - wallet: null, - loadingError: false, -}; - -const reducer = (state: State, action: { type: ActionType; payload?: any }): State => { - switch (action.type) { - case ActionType.SetCPFPPossible: - return { ...state, isCPFPPossible: action.payload }; - case ActionType.SetRBFBumpFeePossible: - return { ...state, isRBFBumpFeePossible: action.payload }; - case ActionType.SetRBFCancelPossible: - return { ...state, isRBFCancelPossible: action.payload }; - case ActionType.SetTransaction: - return { ...state, tx: action.payload }; - case ActionType.SetLoading: - return { ...state, isLoading: action.payload }; - case ActionType.SetEta: - return { ...state, eta: action.payload }; - case ActionType.SetIntervalMs: - return { ...state, intervalMs: action.payload }; - case ActionType.SetAllButtonStatus: - return { ...state, isCPFPPossible: action.payload, isRBFBumpFeePossible: action.payload, isRBFCancelPossible: action.payload }; - case ActionType.SetWallet: - return { ...state, wallet: action.payload }; - case ActionType.SetLoadingError: - return { ...state, loadingError: action.payload }; - default: - return state; - } -}; - -type TransactionStatusProps = { - transaction?: { - amount?: number; - value?: number; - confirmations?: number; - }; - txid?: string; -}; - -const TransactionStatus: React.FC = ({ transaction, txid }) => { - const [state, dispatch] = useReducer(reducer, initialState); - const { isCPFPPossible, isRBFBumpFeePossible, isRBFCancelPossible, tx, isLoading, eta, intervalMs, wallet, loadingError } = state; - const { wallets, txMetadata, fetchAndSaveWalletTransactions } = useStorage(); - const { hash, walletID } = useRoute().params; - const { navigate, setOptions, goBack } = useExtendedNavigation(); - const { colors } = useTheme(); - const fetchTxInterval = useRef(); - const stylesHook = StyleSheet.create({ - value: { - color: colors.alternativeTextColor2, - }, - valueUnit: { - color: colors.alternativeTextColor2, - }, - iconRoot: { - backgroundColor: colors.success, - }, - }); - - const setTX = (value: any) => { - dispatch({ type: ActionType.SetTransaction, payload: value }); - }; - - const setIntervalMs = (ms: number) => { - dispatch({ type: ActionType.SetIntervalMs, payload: ms }); - }; - - const setEta = (value: string) => { - dispatch({ type: ActionType.SetEta, payload: value }); - }; - - const setAllButtonStatus = (status: ButtonStatus) => { - dispatch({ type: ActionType.SetAllButtonStatus, payload: status }); - }; - - const setIsLoading = (value: boolean) => { - dispatch({ type: ActionType.SetLoading, payload: value }); - }; - - const setIsCPFPPossible = (status: ButtonStatus) => { - dispatch({ type: ActionType.SetCPFPPossible, payload: status }); - }; - - const setIsRBFBumpFeePossible = (status: ButtonStatus) => { - dispatch({ type: ActionType.SetRBFBumpFeePossible, payload: status }); - }; - - const setIsRBFCancelPossible = (status: ButtonStatus) => { - dispatch({ type: ActionType.SetRBFCancelPossible, payload: status }); - }; - - const navigateToTransactionDetails = useCallback(() => { - if (walletID && tx && tx.hash) { - navigate('TransactionDetails', { tx, hash, walletID }); - } else { - console.error('Cannot navigate to TransactionDetails: Missing tx or hash.'); - } - }, [hash, navigate, tx, walletID]); - - const DetailsButton = useMemo( - () => ( - - ), - [navigateToTransactionDetails, loadingError, isLoading, tx, wallet], - ); - - useEffect(() => { - setOptions({ - headerRight: () => DetailsButton, - }); - }, [DetailsButton, colors, hash, setOptions]); - - useEffect(() => { - if (wallet) { - const transactions = wallet.getTransactions(); - const newTx = transactions.find((t: Transaction) => t.hash === hash); - if (newTx) { - setTX(newTx); - } - } - }, [hash, wallet]); - - useEffect(() => { - const foundWallet = wallets.find(w => w.getID() === walletID) || null; - dispatch({ type: ActionType.SetWallet, payload: foundWallet }); - }, [walletID, wallets]); - - // re-fetching tx status periodically - useEffect(() => { - console.debug('transactionStatus - useEffect'); - - if (!tx || tx?.confirmations) return; - if (!hash) return; - - if (fetchTxInterval.current) { - // interval already exists, lets cleanup it and recreate, so theres no duplicate intervals - clearInterval(fetchTxInterval.current); - fetchTxInterval.current = undefined; - } - - console.debug('setting up interval to check tx...'); - fetchTxInterval.current = setInterval(async () => { - try { - setIntervalMs(31000); // upon first execution we increase poll interval; - - console.debug('checking tx', hash, 'for confirmations...'); - const transactions = await Electrum.multiGetTransactionByTxid([hash], true, 10); - const txFromElectrum = transactions[hash]; - if (!txFromElectrum) { - console.error(`Transaction from Electrum with hash ${hash} not found.`); - return; - } - - console.debug('got txFromElectrum=', txFromElectrum); - - const address = txFromElectrum.vout?.[0]?.scriptPubKey?.addresses?.pop(); - if (!address) { - console.error('Address not found in txFromElectrum.'); - return; - } - - if (!txFromElectrum.confirmations && txFromElectrum.vsize) { - const txsM = await Electrum.getMempoolTransactionsByAddress(address); - let txFromMempool; - // searching for a correct tx in case this address has several pending txs: - for (const tempTxM of txsM) { - if (tempTxM?.tx_hash === hash) { - txFromMempool = tempTxM; - break; - } - } - if (!txFromMempool) { - console.error(`Transaction from mempool with hash ${hash} not found.`); - return; - } - - console.debug('txFromMempool=', txFromMempool); - - const satPerVbyte = txFromMempool.fee && txFromElectrum.vsize ? Math.round(txFromMempool.fee / txFromElectrum.vsize) : 0; - const fees = await Electrum.estimateFees(); - console.debug('fees=', fees, 'satPerVbyte=', satPerVbyte); - if (satPerVbyte >= fees.fast) { - setEta(loc.formatString(loc.transactions.eta_10m)); - } - if (satPerVbyte >= fees.medium && satPerVbyte < fees.fast) { - setEta(loc.formatString(loc.transactions.eta_3h)); - } - if (satPerVbyte < fees.medium) { - setEta(loc.formatString(loc.transactions.eta_1d)); - } - } else if (txFromElectrum.confirmations && txFromElectrum.confirmations > 0) { - // now, handling a case when tx became confirmed! - triggerHapticFeedback(HapticFeedbackTypes.NotificationSuccess); - setEta(''); - if (tx) { - setTX((prevState: any) => { - return Object.assign({}, prevState, { confirmations: txFromElectrum.confirmations }); - }); - } else { - console.error('Cannot set confirmations: tx is undefined.'); - } - clearInterval(fetchTxInterval.current); - fetchTxInterval.current = undefined; - if (wallet?.getID()) { - fetchAndSaveWalletTransactions(wallet.getID()); - } else { - console.error('Cannot fetch and save wallet transactions: wallet ID is undefined.'); - } - } - } catch (error) { - console.error('Error in fetchTxInterval:', error); - } - }, intervalMs); - - return () => { - clearInterval(fetchTxInterval.current); - fetchTxInterval.current = undefined; - }; - }, [hash, intervalMs, tx, fetchAndSaveWalletTransactions, wallet]); - - useEffect(() => { - const subscription = BackHandler.addEventListener('hardwareBackPress', () => { - goBack(); - return true; - }); - - return () => { - subscription.remove(); - clearInterval(fetchTxInterval.current); - fetchTxInterval.current = undefined; - }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - const initialButtonsState = async () => { - try { - await checkPossibilityOfCPFP(); - await checkPossibilityOfRBFBumpFee(); - await checkPossibilityOfRBFCancel(); - } catch (e) { - console.error('Error in initialButtonsState:', e); - setAllButtonStatus(ButtonStatus.NotPossible); - } - setIsLoading(false); - }; - - useEffect(() => { - initialButtonsState().catch(error => console.error('Unhandled error in initialButtonsState:', error)); - - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [tx, wallets]); - - useEffect(() => {}, [tx, wallets]); - - useEffect(() => { - console.debug('transactionStatus - useEffect'); - }, []); - - const checkPossibilityOfCPFP = async () => { - if (!wallet?.allowRBF()) { - return setIsCPFPPossible(ButtonStatus.NotPossible); - } - - if (wallet) { - const cpfbTx = new HDSegwitBech32Transaction(null, tx.hash, wallet as HDSilentPaymentsWallet); - if ((await cpfbTx.isToUsTransaction()) && (await cpfbTx.getRemoteConfirmationsNum()) === 0) { - return setIsCPFPPossible(ButtonStatus.Possible); - } else { - return setIsCPFPPossible(ButtonStatus.NotPossible); - } - } - return setIsCPFPPossible(ButtonStatus.NotPossible); - }; - - const checkPossibilityOfRBFBumpFee = async () => { - if (!wallet?.allowRBF()) { - return setIsRBFBumpFeePossible(ButtonStatus.NotPossible); - } - - const rbfTx = new HDSegwitBech32Transaction(null, tx.hash, wallet as HDSilentPaymentsWallet); - if ( - (await rbfTx.isOurTransaction()) && - (await rbfTx.getRemoteConfirmationsNum()) === 0 && - (await rbfTx.isSequenceReplaceable()) && - (await rbfTx.canBumpTx()) - ) { - return setIsRBFBumpFeePossible(ButtonStatus.Possible); - } else { - return setIsRBFBumpFeePossible(ButtonStatus.NotPossible); - } - }; - - const checkPossibilityOfRBFCancel = async () => { - if (!wallet?.allowRBF()) { - return setIsRBFCancelPossible(ButtonStatus.NotPossible); - } - - const rbfTx = new HDSegwitBech32Transaction(null, tx.hash, wallet as HDSilentPaymentsWallet); - if ( - (await rbfTx.isOurTransaction()) && - (await rbfTx.getRemoteConfirmationsNum()) === 0 && - (await rbfTx.isSequenceReplaceable()) && - (await rbfTx.canCancelTx()) - ) { - return setIsRBFCancelPossible(ButtonStatus.Possible); - } else { - return setIsRBFCancelPossible(ButtonStatus.NotPossible); - } - }; - - const navigateToRBFBumpFee = () => { - navigate('RBFBumpFee', { - txid: tx.hash, - wallet, - }); - }; - - const navigateToRBFCancel = () => { - navigate('RBFCancel', { - txid: tx.hash, - wallet, - }); - }; - - const navigateToCPFP = () => { - navigate('CPFP', { - txid: tx.hash, - wallet, - }); - }; - - const renderCPFP = () => { - if (isCPFPPossible === ButtonStatus.Unknown) { - return ( - <> - - - - ); - } else if (isCPFPPossible === ButtonStatus.Possible) { - return ( - <> -