diff --git a/index.js b/index.js index 36651f41..e6b44fd8 100644 --- a/index.js +++ b/index.js @@ -56,7 +56,7 @@ notifee.registerForegroundService(async (notification) => { if(notification.data.task === SYNC_STATE_WITH_ALL_MINTS_TASK) { log.debug(`[registerForegroundService] Submitting task ${SYNC_STATE_WITH_ALL_MINTS_TASK} to the queue.`) - WalletTask.syncStateWithAllMintsQueue({isPending: false}) + await WalletTask.syncStateWithAllMintsQueueAwaitable({isPending: false}) } diff --git a/package.json b/package.json index 4e1c298d..657d3ed9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "minibits_wallet", - "version": "0.3.4-beta.4", + "version": "0.3.4-beta.5", "private": true, "scripts": { "android:clean": "cd android && ./gradlew clean", @@ -63,6 +63,7 @@ "react-native-fast-image": "^8.6.3", "react-native-flash-message": "^0.4.2", "react-native-gesture-handler": "^2.28.0", + "react-native-indicators": "^0.17.0", "react-native-json-tree": "^1.5.0", "react-native-keychain": "^10.0.0", "react-native-localize": "^3.5.2", diff --git a/patches/react-native-indicators+0.17.0.patch b/patches/react-native-indicators+0.17.0.patch new file mode 100644 index 00000000..663cb380 --- /dev/null +++ b/patches/react-native-indicators+0.17.0.patch @@ -0,0 +1,86 @@ +diff --git a/node_modules/react-native-indicators/src/components/indicator/index.js b/node_modules/react-native-indicators/src/components/indicator/index.js +index b2de0a8..feb3c21 100644 +--- a/node_modules/react-native-indicators/src/components/indicator/index.js ++++ b/node_modules/react-native-indicators/src/components/indicator/index.js +@@ -175,6 +175,7 @@ export default class Indicator extends PureComponent { + render() { + let { hideAnimation } = this.state; + let { count, hidesWhenStopped, ...props } = this.props; ++ const { key, ...rest } = props; + + if (hidesWhenStopped) { + props.style = [] +@@ -182,7 +183,7 @@ export default class Indicator extends PureComponent { + } + + return ( +- ++ + {Array.from(new Array(count), this.renderComponent, this)} + + ); +diff --git a/node_modules/react-native-indicators/src/components/material-indicator/index.js b/node_modules/react-native-indicators/src/components/material-indicator/index.js +index bf7edc4..0bde608 100644 +--- a/node_modules/react-native-indicators/src/components/material-indicator/index.js ++++ b/node_modules/react-native-indicators/src/components/material-indicator/index.js +@@ -107,7 +107,7 @@ export default class MaterialIndicator extends PureComponent { + }; + + return ( +- ++ + + + +diff --git a/node_modules/react-native-indicators/src/components/pulse-indicator/index.js b/node_modules/react-native-indicators/src/components/pulse-indicator/index.js +index 53bcf92..e452df8 100644 +--- a/node_modules/react-native-indicators/src/components/pulse-indicator/index.js ++++ b/node_modules/react-native-indicators/src/components/pulse-indicator/index.js +@@ -51,7 +51,7 @@ export default class PulseIndicator extends PureComponent { + }; + + return ( +- ++ + + + ); +diff --git a/node_modules/react-native-indicators/src/components/skype-indicator/index.js b/node_modules/react-native-indicators/src/components/skype-indicator/index.js +index 8a988df..1a0304f 100644 +--- a/node_modules/react-native-indicators/src/components/skype-indicator/index.js ++++ b/node_modules/react-native-indicators/src/components/skype-indicator/index.js +@@ -68,7 +68,7 @@ export default class SkypeIndicator extends PureComponent { + }; + + return ( +- ++ + + + ); +diff --git a/node_modules/react-native-indicators/src/components/ui-activity-indicator/index.js b/node_modules/react-native-indicators/src/components/ui-activity-indicator/index.js +index dcde1e5..468e560 100644 +--- a/node_modules/react-native-indicators/src/components/ui-activity-indicator/index.js ++++ b/node_modules/react-native-indicators/src/components/ui-activity-indicator/index.js +@@ -61,7 +61,7 @@ export default class UIActivityIndicator extends PureComponent { + }; + + return ( +- ++ + + + ); +diff --git a/node_modules/react-native-indicators/src/components/wave-indicator/index.js b/node_modules/react-native-indicators/src/components/wave-indicator/index.js +index 2de37d5..1fbe5ce 100644 +--- a/node_modules/react-native-indicators/src/components/wave-indicator/index.js ++++ b/node_modules/react-native-indicators/src/components/wave-indicator/index.js +@@ -64,7 +64,7 @@ export default class WaveIndicator extends PureComponent { + }; + + return ( +- ++ + + + ); \ No newline at end of file diff --git a/src/App.tsx b/src/App.tsx index d0a8dc12..de9c52b9 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react' +import React, { useEffect, useState } from 'react' import FlashMessage from "react-native-flash-message" import { SafeAreaProvider, @@ -20,15 +20,12 @@ import { spacing, typography } from './theme' import { displayName } from '../app.json' import { Text } from './components/Text' import useIsInternetReachable from './utils/useIsInternetReachable' -import * as Sentry from '@sentry/react-native' -import { ANDROID_VERSION_NAME, APP_ENV, JS_BUNDLE_VERSION, SENTRY_DSN } from '@env' /* Set default size ratio for styling */ setSizeMattersBaseWidth(375) setSizeMattersBaseHeight(812) function App() { - const {userSettingsStore, relaysStore, authStore, walletStore, walletProfileStore} = useStores() const [isUserAuthenticated, setIsUserAuthenticated] = useState(false) const [isDeviceAuthenticated, setIsDeviceAuthenticated] = useState(false) @@ -65,8 +62,8 @@ function App() { } // re-enroll device for JWT authentication if refresh token expired - if(authStore.isRefreshTokenExpired && isInternetReachable && userSettingsStore.isOnboarded) { - log.trace('[useInitialRootStore]', 'Re-enrolling device for JWT authentication') + if(authStore.isRefreshTokenExpired && isInternetReachable === true && userSettingsStore.isOnboarded) { + log.trace('[useInitialRootStore]', 'Re-enrolling device for JWT authentication', {isInternetReachable}) try { const walletKeys: WalletKeys = await walletStore.getCachedWalletKeys() const deviceId = walletProfileStore.device diff --git a/src/components/ErrorModal.tsx b/src/components/ErrorModal.tsx index d47ec721..9c6d5696 100644 --- a/src/components/ErrorModal.tsx +++ b/src/components/ErrorModal.tsx @@ -7,6 +7,7 @@ import { isObj } from '@cashu/cashu-ts/src/utils' import JSONTree from 'react-native-json-tree' import Clipboard from '@react-native-clipboard/clipboard' import { useStores } from '../models' +import useIsInternetReachable from '../utils/useIsInternetReachable' type ErrorModalProps = { error: AppError @@ -41,9 +42,11 @@ export const ErrorModal: FC = function ({ error }) { } } + const isInternetReachable = useIsInternetReachable() + const enrollDevice = async () => { const keys = await walletStore.getCachedWalletKeys() - if (keys.NOSTR) { + if (keys.NOSTR && isInternetReachable === true) { authStore.enrollDevice(keys.NOSTR, walletProfileStore.device) .then(() => { setIsErrorVisible(false) diff --git a/src/components/Header.tsx b/src/components/Header.tsx index 1aaa6bc1..10a4e498 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -168,7 +168,7 @@ function FocusAwareStatusBar(props: StatusBarProps) { * - [Documentation and Examples](https://github.com/infinitered/ignite/blob/master/docs/Components-Header.md) */ export function Header(props: HeaderProps) { - const isInternetReachable = useIsInternetReachable() + //const isInternetReachable = useIsInternetReachable() const { backgroundColor = useThemeColor('header'), diff --git a/src/screens/CashuPaymentRequestScreen.tsx b/src/screens/CashuPaymentRequestScreen.tsx index fcc47f7b..3a30e14f 100644 --- a/src/screens/CashuPaymentRequestScreen.tsx +++ b/src/screens/CashuPaymentRequestScreen.tsx @@ -42,7 +42,7 @@ type Props = StaticScreenProps<{ }> export const CashuPaymentRequestScreen = observer(function CashuPaymentRequestScreen({ route }: Props) { - const isInternetReachable = useIsInternetReachable() + //const isInternetReachable = useIsInternetReachable() const navigation = useNavigation() const { proofsStore, diff --git a/src/screens/DeveloperScreen.tsx b/src/screens/DeveloperScreen.tsx index 5d4b5e8e..7175ee58 100644 --- a/src/screens/DeveloperScreen.tsx +++ b/src/screens/DeveloperScreen.tsx @@ -34,7 +34,7 @@ import RNExitApp from 'react-native-exit-app' import { TransactionStatus } from '../models/Transaction' import { maxTransactionsInHistory } from '../models/TransactionsStore' import { StaticScreenProps, useNavigation } from '@react-navigation/native' -// refresh // refresh +// refresh // refresh // refresh type Props = StaticScreenProps diff --git a/src/screens/NfcPayScreen.tsx b/src/screens/NfcPayScreen.tsx index e592334c..dd06f3de 100644 --- a/src/screens/NfcPayScreen.tsx +++ b/src/screens/NfcPayScreen.tsx @@ -7,6 +7,17 @@ import { ColorValue, Platform, } from 'react-native' +import { + BallIndicator, + BarIndicator, + DotIndicator, + MaterialIndicator, + PacmanIndicator, + PulseIndicator, + SkypeIndicator, + UIActivityIndicator, + WaveIndicator, + } from 'react-native-indicators' import { PaymentRequest as CashuPaymentRequest, MeltQuoteResponse, PaymentRequestTransport, PaymentRequestTransportType, decodePaymentRequest, getDecodedToken } from '@cashu/cashu-ts' import NfcManager, { NfcTech, Ndef, NfcEvents, TagEvent } from 'react-native-nfc-manager' import { colors, spacing, typography, useThemeColor } from '../theme' @@ -14,7 +25,7 @@ import EventEmitter from '../utils/eventEmitter' import { log } from '../services/logService' import { IncomingDataType, IncomingParser } from '../services/incomingParser' import AppError, { Err } from '../utils/AppError' -import { BottomModal, Button, Card, ErrorModal, Icon, InfoModal, ListItem, ScanIcon, Screen, Text } from '../components' +import { AmountInput, BottomModal, Button, Card, ErrorModal, Icon, InfoModal, ListItem, ScanIcon, Screen, Text } from '../components' import { infoMessage } from '../utils/utils' import { SvgXml } from 'react-native-svg' import { formatCurrency, getCurrency, MintUnit, MintUnits } from '../services/wallet/currency' @@ -48,6 +59,7 @@ import Animated, { Easing, } from 'react-native-reanimated'; import { NfcService } from '../services/nfcService' +import { TranItem } from './TranDetailScreen' //import Animated from 'react-native-reanimated' const ContactlessIcon = (color: ColorValue | string) => ` @@ -110,9 +122,11 @@ type Props = StaticScreenProps<{ unit: MintUnit }> +const SYNC_STATE_WITH_MINT_TIMEOUT = 10 * 1000 + export const NfcPayScreen = observer(function NfcPayScreen({ route }: Props) { const navigation = useNavigation() - const { mintsStore, walletStore, proofsStore } = useStores() + const { mintsStore, walletStore, proofsStore, transactionsStore } = useStores() const unitRef = useRef('sat') const isInternetReachable = useIsInternetReachable() @@ -139,6 +153,7 @@ export const NfcPayScreen = observer(function NfcPayScreen({ route }: Props) { const [isNfcEnabled, setIsNfcEnabled] = useState(false) const [isNfcSupported, setIsNfcSupported] = useState(false) const [isProcessing, setIsProcessing] = useState(false) + const [isPaid, setIsPaid] = useState(false) const [nfcInfo, setNfcInfo] = useState() const [readNfcData, setReadNfcData] = useState() const [mint, setMint] = useState() @@ -164,7 +179,7 @@ export const NfcPayScreen = observer(function NfcPayScreen({ route }: Props) { return } - setNfcInfo('Tap your device to the NFC reader to pay') + setNfcInfo('Hold your device close to the NFC reader.') setIsNfcEnabled(true) // Start listening for tags immediately @@ -196,6 +211,39 @@ export const NfcPayScreen = observer(function NfcPayScreen({ route }: Props) { }, [navigation, isNfcEnabled, isProcessing]) + const showSuccessModal = function () { + const amountSentInt = Math.round( + (toNumber(amountToPay || '0') * getCurrency(unitRef.current).precision) + ); + + const currency = getCurrency(unitRef.current); + + setResultModalInfo({ + status: TransactionStatus.COMPLETED, + title: 'That was fast!', + message: `${formatCurrency(amountSentInt, currency.code)} ${currency.code} were received by the payee.`, + }); + setTransactionStatus(TransactionStatus.COMPLETED); + setIsResultModalVisible(true); + //setNfcInfo('Payment completed successfully!'); + } + + + const showPendingModal = function () { + const amountSentInt = Math.round( + (toNumber(amountToPay || '0') * getCurrency(unitRef.current).precision) + ); + + const currency = getCurrency(unitRef.current); + + setResultModalInfo({ + status: TransactionStatus.PENDING, + //title: 'Payment is pending', + message: `${formatCurrency(amountSentInt, currency.code)} ${currency.code} were sent successfully, but the payee has not yet claimed the ecash . You can wait for confirmation or revert the transaction.`, + }); + setTransactionStatus(TransactionStatus.PENDING); + setIsResultModalVisible(true) + } // Stable handler — only recreated when dependencies actually change const handleSyncStateResult = useCallback(async (result: SyncStateTaskResult) => { @@ -208,21 +256,7 @@ export const NfcPayScreen = observer(function NfcPayScreen({ route }: Props) { // ——— SUCCESS: Receiver claimed the ecash ——— if (completedTransactionIds?.includes(transactionId)) { log.debug('[NfcScreen.handleSyncStateResult] Ecash claimed successfully by the payee.', { transactionId }); - - const amountSentInt = Math.round( - (toNumber(amountToPay || '0') * getCurrency(unitRef.current).precision) - ); - - const currency = getCurrency(unitRef.current); - - setResultModalInfo({ - status: TransactionStatus.COMPLETED, - title: 'That was fast!', - message: `${formatCurrency(amountSentInt, currency.code)} ${currency.code} were received by the payee.`, - }); - setTransactionStatus(TransactionStatus.COMPLETED); - setIsResultModalVisible(true); - setNfcInfo('Payment completed successfully!'); + showSuccessModal() return; } @@ -250,8 +284,10 @@ export const NfcPayScreen = observer(function NfcPayScreen({ route }: Props) { // One-time listener with auto-cleanup using ref const syncStateListenerRef = useRef<((r: SyncStateTaskResult) => void) | null>(null) + const fallbackTimeoutRef = useRef(null) useEffect(() => { + log.trace('[useEffect] Triggered to set syncStateListenerRef', {transactionId}) // If no transactionId → nothing to listen for if (!transactionId) { // Clean up any dangling listener @@ -259,10 +295,33 @@ export const NfcPayScreen = observer(function NfcPayScreen({ route }: Props) { EventEmitter.off(`ev_${SYNC_STATE_WITH_MINT_TASK}_result`, syncStateListenerRef.current) syncStateListenerRef.current = null } + + if (fallbackTimeoutRef.current) { + clearTimeout(fallbackTimeoutRef.current) + fallbackTimeoutRef.current = null + } return } + const checkFallback = () => { + const currentTx = transactionsStore.findById(transactionId) + log.trace('[checkFallback] Check after timeout returned transaction', {status: currentTx?.status}) + if (currentTx?.status === TransactionStatus.COMPLETED) { + showSuccessModal() + } + + if(currentTx?.status === TransactionStatus.PENDING) { + showPendingModal() + } + return + // If still PENDING or ERROR → do nothing, user can close manually + } + const eventName = `ev_${SYNC_STATE_WITH_MINT_TASK}_result` + // Start fallback timer — 15 seconds after token write. Skip if offline. + if(isInternetReachable) { + fallbackTimeoutRef.current = setTimeout(checkFallback, SYNC_STATE_WITH_MINT_TIMEOUT) + } // Remove previous listener (defensive) if (syncStateListenerRef.current) { @@ -270,7 +329,13 @@ export const NfcPayScreen = observer(function NfcPayScreen({ route }: Props) { } const handler = (result: SyncStateTaskResult) => { - handleSyncStateResult(result); + handleSyncStateResult(result) + + // Clear fallback — event won + if (fallbackTimeoutRef.current) { + clearTimeout(fallbackTimeoutRef.current) + fallbackTimeoutRef.current = null + } // "keep listening" for poller if our transaction is still pending const shouldStaySubscribed = (() => { @@ -297,13 +362,15 @@ export const NfcPayScreen = observer(function NfcPayScreen({ route }: Props) { // Cleanup on unmount or when transactionId changes/becomes null return () => { if (syncStateListenerRef.current) { - EventEmitter.off(eventName, syncStateListenerRef.current) - syncStateListenerRef.current = null + EventEmitter.off(eventName, syncStateListenerRef.current) + syncStateListenerRef.current = null + } + if (fallbackTimeoutRef.current) { + clearTimeout(fallbackTimeoutRef.current) + fallbackTimeoutRef.current = null } } - }, [ - handleSyncStateResult, // ← stable thanks to useCallback - ]) + }, [transactionId, handleSyncStateResult]) // ====================== Lightning Payment (Transfer Task) Result Handler ====================== @@ -319,6 +386,8 @@ export const NfcPayScreen = observer(function NfcPayScreen({ route }: Props) { return } + setIsProcessing(true) + //log.info('NFC Tag found', {tag}); const bytes = new Uint8Array(tag.ndefMessage[0]?.payload ?? []) decoded = Ndef.text.decodePayload(bytes) @@ -337,7 +406,7 @@ export const NfcPayScreen = observer(function NfcPayScreen({ route }: Props) { return } - setNfcInfo('Processing payment request...') + setNfcInfo('Processing, keep your device still...') log.info('NFC tag decoded', { decoded }) @@ -352,7 +421,7 @@ export const NfcPayScreen = observer(function NfcPayScreen({ route }: Props) { }) handleError(e) } finally { - + setIsProcessing(false) } } @@ -408,7 +477,13 @@ export const NfcPayScreen = observer(function NfcPayScreen({ route }: Props) { ) if(!selectedBalance) { // we might not find suitable balance if offline - setInfo('Wallet do') + setResultModalInfo({ + status: (transaction?.status || TransactionStatus.ERROR) as TransactionStatus, + title: 'Offline payment failed', + message: 'Wallet is offline and does not have ecash denominations matching requested amount', + }); + toggleResultModal() + setNfcInfo('Can not pay requested amount') return } @@ -489,7 +564,19 @@ export const NfcPayScreen = observer(function NfcPayScreen({ route }: Props) { log.trace('[NfcScreen] handleSendTaskResult: Write completed.') setNfcInfo('Ecash token sent successfully.') + setIsPaid(true) + expandHeader() + // Show explanatory modal immediately when offline + if (!isInternetReachable) { + setResultModalInfo({ + status: TransactionStatus.PENDING, + title: translate('commonOfflinePretty'), + message: 'Your wallet sent this payment while offline. Consult the payee that the funds have been claimed.', + }) + + toggleResultModal() + } } catch (err: any) { log.error('Error in handleSendTaskResult', {error: err, caller: 'NfcScreen.handleSendTaskResult'}) @@ -500,6 +587,7 @@ export const NfcPayScreen = observer(function NfcPayScreen({ route }: Props) { }) toggleResultModal() + } } @@ -507,7 +595,13 @@ export const NfcPayScreen = observer(function NfcPayScreen({ route }: Props) { const handleLightningInvoice = async (encodedInvoice: string) => { if (!isInternetReachable) { - setInfo(translate('commonOfflinePretty')) + setResultModalInfo({ + status: TransactionStatus.ERROR, + title: translate('commonOfflinePretty'), + message: 'Can not use Lightning payment method while offline.', + }) + + toggleResultModal() return } @@ -524,19 +618,24 @@ export const NfcPayScreen = observer(function NfcPayScreen({ route }: Props) { const balances = proofsStore.getMintBalancesWithEnoughBalance(totalRequired, unitRef.current) if (balances.length === 0) { - setInfo( - translate('transferScreen_insufficientFunds', { + setResultModalInfo({ + status: TransactionStatus.ERROR, + title: 'Not enough funds', + message: translate('transferScreen_insufficientFunds', { currency: getCurrency(unitRef.current).code, amount: totalRequired, - }) - ) + }), + }) + + toggleResultModal() return } const selectedBalance = balances[0] setMintBalanceToSendFrom(selectedBalance) - setInfo('Creating Lightning payment quote...') + setNfcInfo('Creating Lightning payment quote...') + const quote = await walletStore.createLightningMeltQuote( selectedBalance.mintUrl, unitRef.current, @@ -552,8 +651,7 @@ export const NfcPayScreen = observer(function NfcPayScreen({ route }: Props) { if (description) setMemo(description) try { - setInfo('Paying Lightning invoice...') - setIsLoading(true) + setNfcInfo('Paying Lightning invoice...') const result = await WalletTask.transferQueueAwaitable( selectedBalance, @@ -601,12 +699,15 @@ export const NfcPayScreen = observer(function NfcPayScreen({ route }: Props) { }) } } else { - // Success or settled pending - setResultModalInfo({ - status, - message: message || 'Lightning payment successful!', - title: status === TransactionStatus.COMPLETED ? 'Payment sent!' : undefined, - }); + // Success or settled pending + setResultModalInfo({ + status, + message: message || 'Lightning payment successful!', + title: status === TransactionStatus.COMPLETED ? 'Payment sent!' : undefined, + }); + setIsPaid(true) + expandHeader() + setNfcInfo(transaction.memo || 'Lightning payment settled.') } } else { // Fallback (shouldn't happen) @@ -616,11 +717,10 @@ export const NfcPayScreen = observer(function NfcPayScreen({ route }: Props) { message: 'No transaction data received', }) } - + toggleResultModal() - } finally { - setIsLoading(false) - setInfo('') + } catch (e: any) { + handleError(e) } } @@ -720,36 +820,123 @@ export const NfcPayScreen = observer(function NfcPayScreen({ route }: Props) { setInfo('') setError(undefined) setIsLoading(false) + setIsProcessing(false) setIsResultModalVisible(false) setResultModalInfo(undefined) + setIsPaid(false) } const toggleResultModal = () => setIsResultModalVisible(previousState => !previousState) + const headerHeight = useSharedValue(spacing.screenHeight * 0.15); // Initial height + const collapseHeader = () => { + headerHeight.value = spacing.screenHeight * 0.15 + } + + const expandHeader = () => { + headerHeight.value = spacing.screenHeight * 0.20 + } + + const animatedHeader = useAnimatedStyle(() => { + return { + height: withTiming(headerHeight.value, { duration: 300 }), + // opacity: withTiming(isVisible.value ? 1 : 0, { duration: 300 }), + } + }) + // Theme const hintText = useThemeColor('textDim') const headerBg = useThemeColor('header') const headerTitle = useThemeColor('headerTitle') const scanIcon = useThemeColor('text') - const scanButtonColor = useThemeColor('card') + const indicatorStandby = useThemeColor('headerTitle') + const indicatorProcessing = useThemeColor('button') return ( - - {/**/} - - + + + {isPaid && ( + {}} + unit={unitRef.current} + editable={false} + /> + )} + + - + + {transaction?.memo && ( + + )} + + + + } + /> + ) : ( + - - + ) : isNfcEnabled ? ( + + + + ) : ( + + )} + + + } + FooterComponent={ + - + /> } + style={{height: spacing.screenHeight * 0.3}} /> + )} + -