Merge pull request #7620 from BlueWallet/usede

REF: use debounce in wallet transactions to avoid rapid reattempts
This commit is contained in:
GLaDOS 2025-02-22 17:28:54 +00:00 committed by GitHub
commit ad71dccd72
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 105 additions and 104 deletions

View file

@ -1,23 +1,27 @@
import { useState, useEffect } from 'react';
import { useState, useEffect, useMemo } from 'react';
import debounce from '../blue_modules/debounce';
const useDebounce = <T>(value: T, delay: number): T => {
// Overload signatures
function useDebounce<T extends (...args: any[]) => any>(callback: T, delay: number): T;
function useDebounce<T>(value: T, delay: number): T;
function useDebounce<T>(value: T, delay: number): T {
const isFn = typeof value === 'function';
const debouncedFunction = useMemo(() => {
return isFn ? debounce(value as unknown as (...args: any[]) => any, delay) : null;
}, [isFn, value, delay]);
const [debouncedValue, setDebouncedValue] = useState<T>(value);
useEffect(() => {
const handler = debounce((val: T) => {
setDebouncedValue(val);
}, delay);
if (!isFn) {
const handler = setTimeout(() => setDebouncedValue(value), delay);
return () => clearTimeout(handler);
}
}, [isFn, value, delay]);
handler(value);
return () => {
handler.cancel();
};
}, [value, delay]);
return debouncedValue;
};
return isFn ? (debouncedFunction as unknown as T) : debouncedValue;
}
export default useDebounce;

View file

@ -12,18 +12,18 @@ import {
ActivityIndicator,
} from 'react-native';
import { GestureHandlerRootView } from 'react-native-gesture-handler';
import { useFocusEffect, useNavigation } from '@react-navigation/native';
import { useFocusEffect, usePreventRemove } from '@react-navigation/native';
import { useTheme } from '../../components/themes';
import { useExtendedNavigation } from '../../hooks/useExtendedNavigation';
import loc from '../../loc';
import { useStorage } from '../../hooks/context/useStorage';
import useDebounce from '../../hooks/useDebounce';
import { TTXMetadata } from '../../class';
import { ExtendedTransaction, LightningTransaction, Transaction, TWallet } from '../../class/wallets/types';
import useBounceAnimation from '../../hooks/useBounceAnimation';
import HeaderRightButton from '../../components/HeaderRightButton';
import { useSettings } from '../../hooks/context/useSettings';
import DragList, { DragListRenderItemInfo } from 'react-native-draglist';
import useDebounce from '../../hooks/useDebounce';
const ManageWalletsListItem = lazy(() => import('../../components/ManageWalletsListItem'));
@ -194,10 +194,8 @@ const ManageWallets: React.FC = () => {
const walletsRef = useRef<TWallet[]>(deepCopyWallets(storedWallets)); // Create a deep copy of wallets for the DraggableFlatList
const { navigate, setOptions, goBack } = useExtendedNavigation();
const [state, dispatch] = useReducer(reducer, initialState);
const navigation = useNavigation();
const debouncedSearchQuery = useDebounce(state.searchQuery, 300);
const bounceAnim = useBounceAnimation(state.searchQuery);
const beforeRemoveListenerRef = useRef<(() => void) | null>(null);
const stylesHook = {
root: {
backgroundColor: colors.elevated,
@ -225,6 +223,19 @@ const ManageWallets: React.FC = () => {
}
}, [debouncedSearchQuery, state.order]);
const hasUnsavedChanges = useMemo(() => {
return JSON.stringify(walletsRef.current) !== JSON.stringify(state.tempOrder.map(item => item.data));
}, [state.tempOrder]);
usePreventRemove(hasUnsavedChanges, async () => {
await new Promise<void>(resolve => {
Alert.alert(loc._.discard_changes, loc._.discard_changes_explain, [
{ text: loc._.cancel, style: 'cancel', onPress: () => resolve() },
{ text: loc._.ok, style: 'default', onPress: () => resolve() },
]);
});
});
const handleClose = useCallback(() => {
if (state.searchQuery.length === 0 && !state.isSearchFocused) {
const newWalletOrder = state.tempOrder
@ -243,20 +254,12 @@ const ManageWallets: React.FC = () => {
}
});
if (beforeRemoveListenerRef.current) {
navigation.removeListener('beforeRemove', beforeRemoveListenerRef.current);
}
goBack();
} else {
dispatch({ type: SET_SEARCH_QUERY, payload: '' });
dispatch({ type: SET_IS_SEARCH_FOCUSED, payload: false });
}
}, [goBack, setWalletsWithNewOrder, state.searchQuery, state.isSearchFocused, state.tempOrder, navigation, handleWalletDeletion]);
const hasUnsavedChanges = useMemo(() => {
return JSON.stringify(walletsRef.current) !== JSON.stringify(state.tempOrder.map(item => item.data));
}, [state.tempOrder]);
}, [goBack, setWalletsWithNewOrder, state.searchQuery, state.isSearchFocused, state.tempOrder, handleWalletDeletion]);
const HeaderLeftButton = useMemo(
() => (
@ -298,45 +301,12 @@ const ManageWallets: React.FC = () => {
useFocusEffect(
useCallback(() => {
setIsDrawerShouldHide(true);
const beforeRemoveListener = (e: { preventDefault: () => void; data: { action: any } }) => {
if (!hasUnsavedChanges) {
return;
}
e.preventDefault();
Alert.alert(loc._.discard_changes, loc._.discard_changes_explain, [
{ text: loc._.cancel, style: 'cancel', onPress: () => {} },
{
text: loc._.ok,
style: 'default',
onPress: () => navigation.dispatch(e.data.action),
},
]);
};
// @ts-ignore: fix later
beforeRemoveListenerRef.current = beforeRemoveListener;
navigation.addListener('beforeRemove', beforeRemoveListener);
return () => {
if (beforeRemoveListenerRef.current) {
navigation.removeListener('beforeRemove', beforeRemoveListenerRef.current);
}
setIsDrawerShouldHide(false);
};
}, [hasUnsavedChanges, navigation, setIsDrawerShouldHide]),
}, [setIsDrawerShouldHide]),
);
// Ensure the listener is re-added every time there are unsaved changes
useEffect(() => {
if (beforeRemoveListenerRef.current) {
navigation.removeListener('beforeRemove', beforeRemoveListenerRef.current);
navigation.addListener('beforeRemove', beforeRemoveListenerRef.current);
}
}, [hasUnsavedChanges, navigation]);
const renderHighlightedText = useCallback(
(text: string, query: string) => {
const parts = text.split(new RegExp(`(${query})`, 'gi'));

View file

@ -71,6 +71,9 @@ const WalletTransactions: React.FC<WalletTransactionsProps> = ({ route }) => {
const { colors } = useTheme();
const { isElectrumDisabled } = useSettings();
const walletActionButtonsRef = useRef<View>(null);
const [lastFetchTimestamp, setLastFetchTimestamp] = useState(() => wallet?._lastTxFetch || 0);
const [fetchFailures, setFetchFailures] = useState(0);
const MAX_FAILURES = 3;
const stylesHook = StyleSheet.create({
listHeaderText: {
@ -126,51 +129,75 @@ const WalletTransactions: React.FC<WalletTransactionsProps> = ({ route }) => {
}
}, [getTransactions, limit, pageSize]);
const refreshTransactions = useCallback(async () => {
console.debug('refreshTransactions, ', wallet?.getLabel());
if (!wallet || isElectrumDisabled || isLoading) return;
setIsLoading(true);
let smthChanged = false;
try {
await BlueElectrum.waitTillConnected();
if (wallet.allowBIP47() && wallet.isBIP47Enabled() && 'fetchBIP47SenderPaymentCodes' in wallet) {
await wallet.fetchBIP47SenderPaymentCodes();
}
const oldBalance = wallet.getBalance();
await wallet.fetchBalance();
if (oldBalance !== wallet.getBalance()) smthChanged = true;
const oldTxLen = wallet.getTransactions().length;
await wallet.fetchTransactions();
if ('fetchPendingTransactions' in wallet) {
await wallet.fetchPendingTransactions();
}
if ('fetchUserInvoices' in wallet) {
await wallet.fetchUserInvoices();
}
if (oldTxLen !== wallet.getTransactions().length) smthChanged = true;
} catch (err) {
presentAlert({ message: (err as Error).message, type: AlertType.Toast });
} finally {
if (smthChanged) {
await saveToDisk();
setLimit(prev => prev + pageSize);
}
setIsLoading(false);
}
}, [wallet, isElectrumDisabled, isLoading, saveToDisk, pageSize]);
const refreshTransactions = useCallback(
async (isManualRefresh = false) => {
console.debug('refreshTransactions, ', wallet?.getLabel());
if (!wallet || isElectrumDisabled || isLoading) return;
useFocusEffect(
useCallback(() => {
const task = InteractionManager.runAfterInteractions(() => {
if (wallet && wallet.getLastTxFetch() === 0) {
refreshTransactions();
const MIN_REFRESH_INTERVAL = 5000; // 5 seconds
if (!isManualRefresh && lastFetchTimestamp !== 0 && Date.now() - lastFetchTimestamp < MIN_REFRESH_INTERVAL) {
return; // Prevent auto-refreshing if last fetch was too recent
}
if (fetchFailures >= MAX_FAILURES && !isManualRefresh) {
return; // Silently stop auto-retrying, but allow manual refresh
}
// Only show loading indicator on manual refresh or after first successful fetch
if (isManualRefresh || lastFetchTimestamp !== 0) {
setIsLoading(true);
}
let smthChanged = false;
try {
await BlueElectrum.waitTillConnected();
if (wallet.allowBIP47() && wallet.isBIP47Enabled() && 'fetchBIP47SenderPaymentCodes' in wallet) {
await wallet.fetchBIP47SenderPaymentCodes();
}
});
const oldBalance = wallet.getBalance();
await wallet.fetchBalance();
if (oldBalance !== wallet.getBalance()) smthChanged = true;
const oldTxLen = wallet.getTransactions().length;
await wallet.fetchTransactions();
if ('fetchPendingTransactions' in wallet) {
await wallet.fetchPendingTransactions();
}
if ('fetchUserInvoices' in wallet) {
await wallet.fetchUserInvoices();
}
if (oldTxLen !== wallet.getTransactions().length) smthChanged = true;
return () => task.cancel();
}, [refreshTransactions, wallet]),
// Success - reset failure counter and update timestamps
setFetchFailures(0);
const newTimestamp = Date.now();
setLastFetchTimestamp(newTimestamp);
wallet._lastTxFetch = newTimestamp;
} catch (err) {
setFetchFailures(prev => {
const newFailures = prev + 1;
// Only show error on final attempt for automatic refresh
if ((isManualRefresh || newFailures === MAX_FAILURES) && newFailures >= MAX_FAILURES) {
presentAlert({ message: (err as Error).message, type: AlertType.Toast });
}
return newFailures;
});
} finally {
if (smthChanged) {
await saveToDisk();
setLimit(prev => prev + pageSize);
}
setIsLoading(false);
}
},
[wallet, isElectrumDisabled, isLoading, saveToDisk, pageSize, lastFetchTimestamp, fetchFailures],
);
useEffect(() => {
if (wallet && lastFetchTimestamp === 0 && !isLoading && !isElectrumDisabled) {
refreshTransactions(false).catch(console.error);
}
}, [wallet, isElectrumDisabled, isLoading, refreshTransactions, lastFetchTimestamp]);
useEffect(() => {
if (wallet) {
setSelectedWalletID(walletID);
@ -373,7 +400,7 @@ const WalletTransactions: React.FC<WalletTransactionsProps> = ({ route }) => {
task.cancel();
setReloadTransactionsMenuActionFunction(() => {});
};
}, [refreshTransactions, setReloadTransactionsMenuActionFunction]),
}, [setReloadTransactionsMenuActionFunction, refreshTransactions]),
);
const [balance, setBalance] = useState(wallet ? wallet.getBalance() : 0);
@ -525,7 +552,7 @@ const WalletTransactions: React.FC<WalletTransactionsProps> = ({ route }) => {
}
refreshControl={
!isDesktop && !isElectrumDisabled ? (
<RefreshControl refreshing={isLoading} onRefresh={refreshTransactions} tintColor={colors.msSuccessCheck} />
<RefreshControl refreshing={isLoading} onRefresh={() => refreshTransactions(true)} tintColor={colors.msSuccessCheck} />
) : undefined
}
/>