FX: ManageFlatlist fixes

This commit is contained in:
Marcos Rodriguez 2025-04-29 20:04:30 -04:00
parent 92a0d10ad8
commit 487a2828cd
11 changed files with 496 additions and 563 deletions

View File

@ -1,6 +1,7 @@
import React, { useCallback, useRef, useEffect } from 'react';
import { Text, Animated, StyleSheet, Platform, TextStyle } from 'react-native';
import React, { useCallback, useMemo, useEffect } from 'react';
import { Text, Animated, StyleSheet, Platform, TextStyle, View } from 'react-native';
import { useTheme } from './themes';
import useBounceAnimation from '../hooks/useBounceAnimation';
interface HighlightedTextProps {
text: string;
@ -13,6 +14,11 @@ interface HighlightedTextProps {
highlightOnlyFirstMatch?: boolean;
}
interface TextPart {
text: string;
isMatch: boolean;
}
const HighlightedText: React.FC<HighlightedTextProps> = ({
text,
query,
@ -24,244 +30,171 @@ const HighlightedText: React.FC<HighlightedTextProps> = ({
highlightOnlyFirstMatch = false,
}) => {
const { colors } = useTheme();
const internalBounceAnim = useRef(new Animated.Value(1)).current;
const internalBounceAnim = useBounceAnimation(query);
const bounceAnim = externalBounceAnim || internalBounceAnim;
useEffect(() => {
if (!externalBounceAnim && Platform.OS === 'ios' && query) {
Animated.sequence([
Animated.timing(bounceAnim, {
toValue: 1.07,
duration: 100,
useNativeDriver: true,
}),
Animated.spring(bounceAnim, {
toValue: 1,
friction: 4,
tension: 20,
useNativeDriver: true,
}),
]).start();
}
}, [query, bounceAnim, externalBounceAnim]);
// Create the highlighted style object
const highlightedStyle = useMemo(() => [
styles.highlight,
{
backgroundColor: '#FFF5C0',
color: colors.foregroundColor,
borderColor: 'rgba(255, 255, 255, 0.5)',
transform: Platform.OS === 'ios' ? [{ scale: bounceAnim }] : undefined,
shadowColor: '#000000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.2,
shadowRadius: 1,
elevation: 2,
},
highlightStyle
], [colors.foregroundColor, bounceAnim, highlightStyle]);
const renderHighlightedText = useCallback(() => {
if (!query) {
// Render an individual text part (highlighted or plain)
const renderTextPart = useCallback((part: TextPart, index: number) => {
if (part.isMatch) {
return (
<Text style={[styles.defaultText, style]} numberOfLines={numberOfLines}>
{text}
<Animated.View
key={`highlight-container-${index}-${query}`} // Add query to key to force re-render when query changes
style={[
styles.highlightContainer,
{
transform: [{ scale: bounceAnim }],
}
]}
>
<Animated.Text
key={`highlight-${index}-${query}`} // Add query to key to force re-render when query changes
style={highlightedStyle}
>
{part.text}
</Animated.Text>
</Animated.View>
);
} else {
return (
<Text
key={`text-${index}-${query}`} // Add query to key to force re-render when query changes
style={query ? styles.nonHighlightedText : undefined}
>
{part.text}
</Text>
);
}
}, [query, highlightedStyle, bounceAnim]);
const escapedQuery = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').trim();
if (escapedQuery === '') {
return (
<Text style={[styles.defaultText, style]} numberOfLines={numberOfLines}>
{text}
</Text>
);
// Process the text to find parts that should be highlighted
const textParts = useMemo((): TextPart[] => {
// If no query, return the full text as a single non-matched part
if (!query || query.trim() === '') {
return [{ text, isMatch: false }];
}
try {
if (escapedQuery.length === 1 && highlightOnlyFirstMatch) {
const searchChar = caseSensitive ? escapedQuery : escapedQuery.toLowerCase();
const processedText = caseSensitive ? text : text.toLowerCase();
const index = processedText.indexOf(searchChar);
if (index === -1) {
return (
<Text style={[styles.defaultText, style]} numberOfLines={numberOfLines}>
{text}
</Text>
);
}
const before = text.substring(0, index);
const match = text.substring(index, index + 1);
const after = text.substring(index + 1);
return (
<Text numberOfLines={numberOfLines} style={style || styles.defaultText}>
{before}
<Animated.View style={[styles.highlightedContainer, styles.singleCharacterContainer, { transform: [{ scale: bounceAnim }] }]}>
<Text
style={[
styles.highlighted,
Platform.OS === 'ios' ? styles.iOSHighlight : styles.androidHighlight,
styles.singleCharacterText,
highlightStyle,
]}
>
{match}
</Text>
</Animated.View>
{after}
</Text>
);
const trimmedQuery = query.trim();
if (trimmedQuery === '') {
return [{ text, isMatch: false }];
}
const queryWithFlexibleWhitespace = escapedQuery.replace(/\s+/g, '\\s+');
let regex: RegExp;
try {
regex = new RegExp(`(${queryWithFlexibleWhitespace})`, caseSensitive ? 'g' : 'gi');
} catch (error) {
return (
<Text style={[styles.defaultText, style]} numberOfLines={numberOfLines}>
{text}
</Text>
);
}
const parts = text.split(regex).filter(Boolean);
const isExactMatch = (part: string): boolean => {
if (caseSensitive) {
regex.lastIndex = 0;
return regex.test(part);
} else {
return part.toLowerCase().includes(escapedQuery.toLowerCase());
const searchQuery = caseSensitive ? trimmedQuery : trimmedQuery.toLowerCase();
const processedText = caseSensitive ? text : text.toLowerCase();
const parts: TextPart[] = [];
let lastIndex = 0;
let searchStartIndex = 0;
// Find all occurrences of the query
while (true) {
const matchIndex = processedText.indexOf(searchQuery, searchStartIndex);
// If no more matches, break out of the loop
if (matchIndex === -1) {
break;
}
};
return (
<Text numberOfLines={numberOfLines} style={style}>
{parts.map((part, index) => {
const isMatch = isExactMatch(part);
if (isMatch) {
const isFirstHighlight = index === 0 || !isExactMatch(parts[index - 1]);
const isLastHighlight = index === parts.length - 1 || !isExactMatch(parts[index + 1]);
const highlightContainerStyle = [
styles.highlightedContainer,
isFirstHighlight && styles.firstHighlightContainer,
isLastHighlight && styles.lastHighlightContainer,
!isFirstHighlight && !isLastHighlight && styles.middleHighlightContainer,
Platform.OS === 'ios' ? { transform: [{ scale: bounceAnim }] } : { backgroundColor: colors.brandingColor + '30' },
];
const highlightTextStyle = [
styles.highlighted,
Platform.OS === 'ios' ? styles.iOSHighlight : styles.androidHighlight,
isFirstHighlight && styles.firstHighlightText,
isLastHighlight && styles.lastHighlightText,
!isFirstHighlight && !isLastHighlight && styles.middleHighlightText,
highlightStyle,
];
return (
<Animated.View key={`${index}-${part}`} style={highlightContainerStyle}>
<Text style={highlightTextStyle}>{part}</Text>
</Animated.View>
);
} else {
return (
<Text key={`${index}-${part}`} style={[query ? styles.dimmedText : styles.defaultText, style]}>
{part}
</Text>
);
}
})}
</Text>
);
// Add the text before the match
if (matchIndex > lastIndex) {
parts.push({
text: text.substring(lastIndex, matchIndex),
isMatch: false
});
}
// Add the exact matching text portion (using the original casing)
parts.push({
text: text.substring(matchIndex, matchIndex + searchQuery.length),
isMatch: true
});
// Update indices
lastIndex = matchIndex + searchQuery.length;
searchStartIndex = lastIndex;
// If we only want the first match, break
if (highlightOnlyFirstMatch) {
break;
}
}
// Add any remaining text
if (lastIndex < text.length) {
parts.push({
text: text.substring(lastIndex),
isMatch: false
});
}
return parts.length > 0 ? parts : [{ text, isMatch: false }];
} catch (e) {
console.warn('Error in HighlightedText:', e);
return (
<Text style={[styles.defaultText, style]} numberOfLines={numberOfLines}>
{text}
</Text>
);
console.warn('Error processing text for highlighting:', e);
return [{ text, isMatch: false }];
}
}, [query, text, bounceAnim, colors.brandingColor, numberOfLines, style, highlightStyle, caseSensitive, highlightOnlyFirstMatch]);
}, [text, query, caseSensitive, highlightOnlyFirstMatch]);
return renderHighlightedText();
// If only one part and it's not a match, render a simple Text component
if (textParts.length === 1 && !textParts[0].isMatch) {
return (
<Text style={[styles.text, style]} numberOfLines={numberOfLines}>
{text}
</Text>
);
}
// Render the text with highlighted parts
return (
<Text
numberOfLines={numberOfLines}
style={[styles.text, style]}
key={`highlighted-wrapper-${query}`} // Add query to key to force re-render when query changes
>
{textParts.map(renderTextPart)}
</Text>
);
};
const styles = StyleSheet.create({
dimmedText: {
text: {
fontSize: 16,
},
nonHighlightedText: {
opacity: 0.8,
},
defaultText: {
fontSize: 19,
highlightContainer: {
display: 'inline',
overflow: 'hidden',
margin: 0,
padding: 0,
},
highlight: {
fontWeight: '600',
},
highlighted: {
fontSize: 19,
fontWeight: '600',
color: 'black',
textShadowRadius: 1,
textShadowOffset: { width: 1, height: 1 },
textShadowColor: '#000',
textDecorationStyle: 'double',
textDecorationLine: 'underline',
alignSelf: 'flex-start',
padding: 2,
borderRadius: 4,
borderWidth: 1,
borderColor: 'black',
backgroundColor: 'white',
},
highlightedContainer: {
alignSelf: 'flex-start',
},
iOSHighlight: {
backgroundColor: 'white',
borderWidth: 1,
borderColor: 'black',
textShadowRadius: 1,
textShadowOffset: { width: 1, height: 1 },
textShadowColor: '#000',
},
androidHighlight: {
backgroundColor: 'transparent',
borderWidth: 0,
color: 'black',
fontWeight: '700',
textDecorationLine: 'underline',
textShadowRadius: 0,
},
singleCharacterContainer: {
borderRadius: 5,
},
singleCharacterText: {
borderRadius: 5,
},
firstHighlightContainer: {
borderTopLeftRadius: 5,
borderBottomLeftRadius: 5,
},
lastHighlightContainer: {
borderTopRightRadius: 5,
borderBottomRightRadius: 5,
},
middleHighlightContainer: {
borderRadius: 0,
},
firstHighlightText: {
borderTopLeftRadius: 5,
borderBottomLeftRadius: 5,
borderLeftWidth: 1,
marginLeft: 0,
borderRadius: 0,
},
lastHighlightText: {
borderTopRightRadius: 5,
borderBottomRightRadius: 5,
borderRightWidth: 1,
marginRight: 0,
borderRadius: 0,
},
middleHighlightText: {
borderRadius: 0,
borderLeftWidth: 0,
borderRightWidth: 0,
marginLeft: -1,
marginRight: -1,
},
paddingHorizontal: 3,
paddingVertical: 1,
marginHorizontal: 1,
overflow: 'hidden',
textDecorationLine: Platform.OS === 'android' ? 'underline' : 'none',
}
});
export default HighlightedText;

View File

@ -291,7 +291,9 @@ const ManageWalletsListItem: React.FC<ManageWalletsListItemProps> = ({
balanceUnit: wallet.getPreferredBalanceUnit() || BitcoinUnit.BTC,
walletID: item.data.walletID,
allowSignVerifyMessage: wallet.allowSignVerifyMessage ? wallet.allowSignVerifyMessage() : false,
onPress: () => navigateToAddress && navigateToAddress(item.data.address, item.data.walletID),
onPress: navigateToAddress ? () => navigateToAddress(item.data.address, item.data.walletID) : undefined,
searchQuery: state.searchQuery,
renderHighlightedText: renderHighlightedText,
};
return (
@ -478,7 +480,10 @@ const WalletGroupComponent: React.FC<WalletGroupProps> = ({
balanceUnit: wallet.getPreferredBalanceUnit() || BitcoinUnit.BTC,
walletID: address.data.walletID,
allowSignVerifyMessage: wallet.allowSignVerifyMessage ? wallet.allowSignVerifyMessage() : false,
onPress: () => navigateToAddress && navigateToAddress(address.data.address, address.data.walletID),
// Use the onPress function returned by navigateToAddress instead of calling it directly
onPress: navigateToAddress ? () => navigateToAddress(address.data.address, address.data.walletID) : undefined,
searchQuery: state.searchQuery,
renderHighlightedText: renderHighlightedText,
};
return (

View File

@ -34,12 +34,13 @@ interface TransactionListItemProps {
searchQuery?: string;
style?: ViewStyle;
renderHighlightedText?: (text: string, query: string) => JSX.Element;
onPress?: () => void;
}
type NavigationProps = NativeStackNavigationProp<DetailViewStackParamList>;
export const TransactionListItem: React.FC<TransactionListItemProps> = memo(
({ item, itemPriceUnit = BitcoinUnit.BTC, walletID, searchQuery, style, renderHighlightedText }) => {
({ item, itemPriceUnit = BitcoinUnit.BTC, walletID, searchQuery, style, renderHighlightedText, onPress: customOnPress }) => {
const [subtitleNumberOfLines, setSubtitleNumberOfLines] = useState(1);
const { colors } = useTheme();
const { navigate } = useExtendedNavigation<NavigationProps>();
@ -221,6 +222,12 @@ export const TransactionListItem: React.FC<TransactionListItemProps> = memo(
const onPress = useCallback(async () => {
menuRef?.current?.dismissMenu?.();
// If a custom onPress handler was provided, use it and return
if (customOnPress) {
customOnPress();
return;
}
if (item.hash) {
if (renderHighlightedText) {
pop();
@ -258,7 +265,7 @@ export const TransactionListItem: React.FC<TransactionListItemProps> = memo(
});
}
}
}, [item, renderHighlightedText, navigate, walletID, wallets]);
}, [item, renderHighlightedText, navigate, walletID, wallets, customOnPress]);
const handleOnExpandNote = useCallback(() => {
setSubtitleNumberOfLines(0);
@ -403,7 +410,8 @@ export const TransactionListItem: React.FC<TransactionListItemProps> = memo(
prevProps.item.hash === nextProps.item.hash &&
prevProps.item.received === nextProps.item.received &&
prevProps.itemPriceUnit === nextProps.itemPriceUnit &&
prevProps.walletID === nextProps.walletID
prevProps.walletID === nextProps.walletID &&
prevProps.searchQuery === nextProps.searchQuery
);
},
);

View File

@ -18,6 +18,7 @@ import { useStorage } from '../../hooks/context/useStorage';
import ToolTipMenu from '../TooltipMenu';
import { CommonToolTipActions } from '../../typings/CommonToolTipActions';
import { useExtendedNavigation } from '../../hooks/useExtendedNavigation';
import HighlightedText from '../HighlightedText';
interface AddressItemProps {
item: any;
@ -25,11 +26,21 @@ interface AddressItemProps {
walletID: string;
allowSignVerifyMessage: boolean;
onPress?: () => void; // example: ManageWallets uses this
searchQuery?: string;
renderHighlightedText?: (text: string, query: string) => JSX.Element;
}
type NavigationProps = NativeStackNavigationProp<DetailViewStackParamList>;
const AddressItem = ({ item, balanceUnit, walletID, allowSignVerifyMessage, onPress }: AddressItemProps) => {
const AddressItem = ({
item,
balanceUnit,
walletID,
allowSignVerifyMessage,
onPress,
searchQuery = '',
renderHighlightedText
}: AddressItemProps) => {
const { wallets } = useStorage();
const { colors } = useTheme();
const { isBiometricUseCapableAndEnabled } = useBiometrics();
@ -59,12 +70,9 @@ const AddressItem = ({ item, balanceUnit, walletID, allowSignVerifyMessage, onPr
if (onPress) {
onPress();
} else {
navigate('ReceiveDetailsRoot', {
screen: 'ReceiveDetails',
params: {
walletID,
address: item.address,
},
navigate('ReceiveDetails', {
walletID,
address: item.address,
});
}
}, [navigate, walletID, item.address, onPress]);
@ -149,6 +157,30 @@ const AddressItem = ({ item, balanceUnit, walletID, allowSignVerifyMessage, onPr
const renderPreview = useCallback(() => <QRCodeComponent value={item.address} isMenuAvailable={false} />, [item.address]);
// Render address with highlighting if a search query is provided
const renderAddressContent = () => {
if (searchQuery && searchQuery.length > 0) {
if (renderHighlightedText) {
return renderHighlightedText(item.address, searchQuery);
}
return (
<HighlightedText
text={item.address}
query={searchQuery}
caseSensitive={false}
highlightOnlyFirstMatch={searchQuery.length === 1}
style={[stylesHook.address, styles.address]}
/>
);
}
return (
<Text style={[stylesHook.address, styles.address]} numberOfLines={1} ellipsizeMode="middle">
{item.address}
</Text>
);
};
return (
<ToolTipMenu
title={item.address}
@ -166,9 +198,7 @@ const AddressItem = ({ item, balanceUnit, walletID, allowSignVerifyMessage, onPr
<Text style={[styles.index, stylesHook.index]}>{item.index}</Text>
</View>
<View style={styles.middleSection}>
<Text style={[stylesHook.address, styles.address]} numberOfLines={1} ellipsizeMode="middle">
{item.address}
</Text>
{renderAddressContent()}
<Text style={[stylesHook.balance, styles.balance]}>{balance}</Text>
</View>
</View>

View File

@ -7,7 +7,7 @@ const useBounceAnimation = (query: string) => {
useEffect(() => {
if (query) {
Animated.timing(bounceAnim, {
toValue: 1.2,
toValue: 1.08, // Reduced from 1.2 to 1.08 for more subtle animation
duration: 150,
useNativeDriver: true,
}).start(() => {

View File

@ -27,23 +27,18 @@ export const useExtendedNavigation = <T extends NavigationProp<ParamListBase>>()
| [string, object | undefined, { merge?: boolean }]
| [{ name: string; params?: object; path?: string; merge?: boolean }]
) => {
let screenOrOptions: any;
let screenName: string;
let params: any;
let options: { merge?: boolean } | undefined;
if (typeof args[0] === 'string') {
screenOrOptions = args[0];
screenName = args[0];
params = args[1];
options = args[2];
} else {
screenOrOptions = args[0];
}
let screenName: string;
if (typeof screenOrOptions === 'string') {
screenName = screenOrOptions;
} else if (typeof screenOrOptions === 'object' && 'name' in screenOrOptions) {
screenName = screenOrOptions.name;
params = screenOrOptions.params; // Assign params from object if present
} else if (typeof args[0] === 'object' && 'name' in args[0]) {
screenName = args[0].name;
params = args[0].params;
options = { merge: args[0].merge };
} else {
throw new Error('Invalid navigation options');
}
@ -54,30 +49,15 @@ export const useExtendedNavigation = <T extends NavigationProp<ParamListBase>>()
const proceedWithNavigation = () => {
console.log('Proceeding with navigation to', screenName);
// Navigation logic based on current route and target screen
if (navigationRef.current?.isReady()) {
// Get the current route - we need to know which navigator we're in
const currentRoute = navigationRef.current.getCurrentRoute();
const currentRouteName = currentRoute?.name;
// Handle specific cases for nested navigation
if (currentRouteName === 'DrawerRoot') {
// If we're in DrawerRoot and trying to navigate to a screen that exists in DetailViewStackScreensStack
originalNavigation.navigate('DrawerRoot', {
screen: 'DetailViewStackScreensStack',
params: {
screen: screenName,
params,
},
});
} else {
// Normal navigation
if (typeof screenOrOptions === 'string') {
originalNavigation.navigate({ name: screenOrOptions, params, merge: options?.merge });
} else {
originalNavigation.navigate({ ...screenOrOptions, params, merge: options?.merge });
}
}
// Normal navigation handling
if (typeof args[0] === 'string') {
originalNavigation.navigate(screenName, params, options);
} else {
originalNavigation.navigate({
name: screenName,
params,
merge: options?.merge,
});
}
};
@ -102,6 +82,7 @@ export const useExtendedNavigation = <T extends NavigationProp<ParamListBase>>()
}
}
}
if (isRequiresWalletExportIsSaved) {
console.log('Checking if wallet export is saved');
let walletID: string | undefined;

View File

@ -54,6 +54,7 @@ import { useSizeClass, SizeClass } from '../blue_modules/sizeClass';
import getWalletTransactionsOptions from './helpers/getWalletTransactionsOptions';
import { isDesktop } from '../blue_modules/environment';
import ManageWallets from '../screen/wallets/ManageWallets';
import ReceiveDetails from '../screen/receive/details';
const DetailViewStackScreensStack = () => {
const theme = useTheme();
@ -318,6 +319,17 @@ const DetailViewStackScreensStack = () => {
headerShown: true,
}}
/>
<DetailViewStack.Screen
name="ReceiveDetails"
component={ReceiveDetails}
options={navigationStyle({
title: loc.receive.header,
closeButtonPosition: CloseButtonPosition.Left,
statusBarStyle: 'light',
headerShown: true,
presentation: 'modal',
})(theme)}
/>
</DetailViewStack.Navigator>
);
};

View File

@ -90,13 +90,6 @@ export type DetailViewStackParamList = {
address: string;
};
};
ReceiveDetailsRoot: {
screen: 'ReceiveDetails';
params: {
walletID?: string;
address: string;
};
};
ReceiveDetails: {
walletID?: string;
address: string;

View File

@ -19,7 +19,6 @@ const WalletExportStack = lazy(() => import('./WalletExportStack'));
const ExportMultisigCoordinationSetupStack = lazy(() => import('./ExportMultisigCoordinationSetupStack'));
const WalletXpubStackRoot = lazy(() => import('./WalletXpubStack'));
const SignVerifyStackRoot = lazy(() => import('./SignVerifyStack'));
const ReceiveDetailsStackRoot = lazy(() => import('./ReceiveDetailsStack'));
const ScanQRCode = lazy(() => import('../screen/send/ScanQRCode'));
const ViewEditMultisigCosigners = lazy(() => import('../screen/wallets/ViewEditMultisigCosigners'));
@ -110,12 +109,6 @@ const LazySignVerifyStackRoot = () => (
</Suspense>
);
const LazyReceiveDetailsStackRoot = () => (
<Suspense fallback={<LazyLoadingIndicator />}>
<ReceiveDetailsStackRoot />
</Suspense>
);
const LazyScanQRCodeComponent = () => (
<Suspense fallback={<LazyLoadingIndicator />}>
<ScanQRCode />
@ -171,7 +164,6 @@ const MainRoot = () => {
component={LazySignVerifyStackRoot}
options={{ ...NavigationDefaultOptions, ...StatusBarLightOptions }}
/>
<DetailViewStack.Screen name="ReceiveDetailsRoot" component={LazyReceiveDetailsStackRoot} options={NavigationDefaultOptions} />
<DetailViewStack.Screen
name="ScanQRCode"

View File

@ -43,7 +43,7 @@ interface TransactionItem {
interface AddressItem {
type: ItemType.AddressSection;
data: AddressItemData;
data: Omit<AddressItemData, 'label'>;
}
interface WalletGroupItem {
@ -57,9 +57,8 @@ type Item = WalletItem | TransactionItem | AddressItem | WalletGroupItem;
const SET_SEARCH_QUERY = 'SET_SEARCH_QUERY';
const SET_IS_SEARCH_FOCUSED = 'SET_IS_SEARCH_FOCUSED';
const SET_INITIAL_ORDER = 'SET_INITIAL_ORDER';
const SET_FILTERED_ORDER = 'SET_FILTERED_ORDER';
const SET_TEMP_ORDER = 'SET_TEMP_ORDER';
const SET_INITIAL_DATA = 'SET_INITIAL_DATA';
const SET_MANAGED_DATA = 'SET_MANAGED_DATA';
const REMOVE_WALLET = 'REMOVE_WALLET';
const SAVE_CHANGES = 'SAVE_CHANGES';
@ -78,18 +77,13 @@ interface SetIsSearchFocusedAction {
payload: boolean;
}
interface SetInitialOrderAction {
type: typeof SET_INITIAL_ORDER;
interface SetInitialDataAction {
type: typeof SET_INITIAL_DATA;
payload: { wallets: TWallet[]; txMetadata: TTXMetadata };
}
interface SetFilteredOrderAction {
type: typeof SET_FILTERED_ORDER;
payload: string;
}
interface SetTempOrderAction {
type: typeof SET_TEMP_ORDER;
interface SetManagedDataAction {
type: typeof SET_MANAGED_DATA;
payload: Item[];
}
@ -101,30 +95,25 @@ interface RemoveWalletAction {
type Action =
| SetSearchQueryAction
| SetIsSearchFocusedAction
| SetInitialOrderAction
| SetFilteredOrderAction
| SetTempOrderAction
| SetInitialDataAction
| SetManagedDataAction
| SaveChangesAction
| RemoveWalletAction;
interface State {
searchQuery: string;
isSearchFocused: boolean;
originalWalletsOrder: Item[];
currentWalletsOrder: Item[];
availableWallets: TWallet[];
originalWallets: TWallet[];
managedWalletsData: Item[];
txMetadata: TTXMetadata;
initialWalletsBackup: TWallet[];
}
const initialState: State = {
searchQuery: '',
isSearchFocused: false,
originalWalletsOrder: [],
currentWalletsOrder: [],
availableWallets: [],
originalWallets: [],
managedWalletsData: [],
txMetadata: {},
initialWalletsBackup: [],
};
const deepCopyWallets = (wallets: TWallet[]): TWallet[] => {
@ -137,187 +126,37 @@ const reducer = (state: State, action: Action): State => {
return { ...state, searchQuery: action.payload };
case SET_IS_SEARCH_FOCUSED:
return { ...state, isSearchFocused: action.payload };
case SET_INITIAL_ORDER: {
const initialWalletsOrder: WalletItem[] = deepCopyWallets(action.payload.wallets).map(wallet => ({
case SET_INITIAL_DATA: {
const managedWalletsData: WalletItem[] = deepCopyWallets(action.payload.wallets).map(wallet => ({
type: ItemType.WalletSection,
data: wallet,
}));
return {
...state,
availableWallets: action.payload.wallets,
originalWallets: deepCopyWallets(action.payload.wallets),
txMetadata: action.payload.txMetadata,
originalWalletsOrder: initialWalletsOrder,
currentWalletsOrder: initialWalletsOrder,
initialWalletsBackup: deepCopyWallets(action.payload.wallets),
managedWalletsData,
};
}
case SET_FILTERED_ORDER: {
const query = action.payload.toLowerCase();
// For tracking unique results across all categories
const finalOrder: Item[] = [];
const walletGroups: Record<string, WalletGroupItem> = {};
const processedWalletIds = new Set<string>();
// First pass: Find direct wallet matches
state.availableWallets.forEach(wallet => {
const walletLabel = wallet.getLabel()?.toLowerCase() || '';
const walletId = wallet.getID();
// Direct wallet match - will be shown as standalone wallet
if (walletLabel.includes(query)) {
processedWalletIds.add(walletId);
finalOrder.push({
type: ItemType.WalletSection,
data: wallet,
} as WalletItem);
}
});
// Second pass: Find matching transactions and addresses
state.availableWallets.forEach(wallet => {
const walletId = wallet.getID();
const matchingTransactions: TransactionItem[] = [];
const matchingAddresses: AddressItem[] = [];
// Process transactions
try {
const walletTransactions = wallet.getTransactions() || [];
walletTransactions
.filter((tx: Transaction) => {
try {
if (!tx) return false;
const hasMemoMatch =
tx.hash &&
Object.entries(state.txMetadata).some(([txid, meta]) => tx.hash === txid && meta?.memo?.toLowerCase().includes(query));
const hashMatch = tx.hash?.toLowerCase().includes(query);
const valueMatch = tx.value !== undefined && tx.value.toString().includes(query);
return hasMemoMatch || hashMatch || valueMatch;
} catch (e) {
console.warn('Error filtering transaction:', e);
return false;
}
})
.forEach((tx: Transaction) => {
try {
matchingTransactions.push({
type: ItemType.TransactionSection,
data: tx as ExtendedTransaction & LightningTransaction,
} as TransactionItem);
} catch (e) {
console.warn('Error mapping transaction:', e);
}
});
} catch (e) {
console.warn('Error processing wallet transactions:', e);
}
// Process addresses
try {
if ('_getExternalAddressByIndex' in wallet && '_getInternalAddressByIndex' in wallet) {
const externalLimit = wallet.getNextFreeAddressIndex ? wallet.getNextFreeAddressIndex() + 20 : 20;
for (let i = 0; i < externalLimit; i++) {
try {
const address = wallet._getExternalAddressByIndex(i);
if (address?.toLowerCase().includes(query)) {
matchingAddresses.push({
type: ItemType.AddressSection,
data: {
address,
walletID: wallet.getID(),
index: i,
isInternal: false,
},
});
}
} catch (e) {}
}
const internalLimit = wallet.getNextFreeChangeAddressIndex ? wallet.getNextFreeChangeAddressIndex() + 20 : 20;
for (let i = 0; i < internalLimit; i++) {
try {
const address = wallet._getInternalAddressByIndex(i);
if (address?.toLowerCase().includes(query)) {
matchingAddresses.push({
type: ItemType.AddressSection,
data: {
address,
walletID: wallet.getID(),
index: i,
isInternal: true,
},
});
}
} catch (e) {}
}
} else if ('getAddress' in wallet) {
const address = wallet.getAddress();
if (address && typeof address === 'string' && address.toLowerCase().includes(query)) {
matchingAddresses.push({
type: ItemType.AddressSection,
data: {
address,
walletID: wallet.getID(),
index: 0,
isInternal: false,
},
});
}
}
} catch (e) {
console.warn('Error processing wallet addresses:', e);
}
// If we have matching transactions or addresses, create a wallet group
if (matchingTransactions.length > 0 || matchingAddresses.length > 0) {
// Only add a wallet group if the wallet isn't already shown as a direct match
if (!processedWalletIds.has(walletId)) {
walletGroups[walletId] = {
type: ItemType.WalletGroupSection,
wallet,
transactions: matchingTransactions,
addresses: matchingAddresses,
};
}
}
});
// Add all wallet groups to the final results
Object.values(walletGroups).forEach(group => {
finalOrder.push(group);
});
case SET_MANAGED_DATA: {
return {
...state,
currentWalletsOrder: finalOrder,
managedWalletsData: action.payload,
};
}
case SAVE_CHANGES: {
const savedWallets = deepCopyWallets(action.payload);
return {
...state,
availableWallets: savedWallets,
initialWalletsBackup: savedWallets,
currentWalletsOrder: state.currentWalletsOrder.map(item =>
item.type === ItemType.WalletSection
? { ...item, data: action.payload.find(wallet => wallet.getID() === item.data.getID())! }
: item,
),
originalWallets: deepCopyWallets(action.payload),
};
}
case SET_TEMP_ORDER: {
return { ...state, currentWalletsOrder: action.payload };
}
case REMOVE_WALLET: {
const updatedOrder = state.currentWalletsOrder.filter(
const updatedOrder = state.managedWalletsData.filter(
item => item.type !== ItemType.WalletSection || item.data.getID() !== action.payload,
);
return {
...state,
currentWalletsOrder: updatedOrder,
managedWalletsData: updatedOrder,
};
}
default:
@ -345,83 +184,214 @@ const ManageWallets: React.FC = () => {
color: colors.foregroundColor,
},
};
const [uiData, setUiData] = useState(state.currentWalletsOrder);
const [uiData, setUiData] = useState(state.managedWalletsData);
const [noResultsOpacity] = useState(new Animated.Value(0));
const listRef = useRef<FlatList<Item> | null>(null);
const [saveInProgress, setSaveInProgress] = useState(false);
useEffect(() => {
setUiData(state.currentWalletsOrder);
}, [state.currentWalletsOrder]);
const getFilteredWalletsData = useCallback(() => {
if (debouncedSearchQuery) {
const lowerQuery = debouncedSearchQuery.toLowerCase();
// Track which wallets have matching items (transactions, addresses, or memos)
const walletsWithMatches = new Map<string, {
wallet: TWallet,
transactions: TransactionItem[],
addresses: AddressItem[]
}>();
// First check for transaction memos in txMetadata
Object.entries(state.txMetadata).forEach(([txid, metadata]) => {
if (metadata.memo && metadata.memo.toLowerCase().includes(lowerQuery)) {
// Find which wallet has this transaction
state.originalWallets.forEach(wallet => {
try {
// Check if the wallet contains this transaction
const tx = wallet.getTransactions().find((t: Transaction) => t.hash === txid || t.txid === txid);
if (tx) {
const walletID = wallet.getID();
if (!walletsWithMatches.has(walletID)) {
walletsWithMatches.set(walletID, {
wallet,
transactions: [],
addresses: []
});
}
// Add the transaction to the matching group
const group = walletsWithMatches.get(walletID)!;
// Only add if it's not already in the array
const alreadyAdded = group.transactions.some(
item => (item.data.hash === txid || item.data.txid === txid)
);
if (!alreadyAdded) {
group.transactions.push({
type: ItemType.TransactionSection,
data: tx as ExtendedTransaction & LightningTransaction
});
}
}
} catch (e) {
// Skip if the wallet doesn't have getTransactions method or other errors
}
});
}
});
// Search through all wallets for addresses and additional transactions
state.originalWallets.forEach(wallet => {
const walletID = wallet.getID();
const walletLabel = wallet.getLabel() || '';
// Check if wallet label matches
const walletLabelMatches = walletLabel.toLowerCase().includes(lowerQuery);
// If wallet label matches, make sure it's in our map
if (walletLabelMatches && !walletsWithMatches.has(walletID)) {
walletsWithMatches.set(walletID, {
wallet,
transactions: [],
addresses: []
});
}
// Search through transactions
try {
const transactions = wallet.getTransactions() || [];
transactions.forEach((tx: Transaction) => {
const txid = tx.hash || tx.txid;
// Using value instead of amount since that's what the Transaction type has
const txAmount = tx.value?.toString() || '';
const txDate = tx.received?.toString() || '';
// Check if any transaction data matches the search query
const txMemoMatches = txid &&
state.txMetadata[txid] &&
state.txMetadata[txid].memo &&
state.txMetadata[txid].memo.toLowerCase().includes(lowerQuery);
// Check if the transaction ID matches the search query
const txIdMatches = txid && txid.toLowerCase().includes(lowerQuery);
const txDataMatches = txAmount.includes(lowerQuery) || txDate.includes(lowerQuery);
if (txMemoMatches || txDataMatches || txIdMatches) {
if (!walletsWithMatches.has(walletID)) {
walletsWithMatches.set(walletID, {
wallet,
transactions: [],
addresses: []
});
}
const group = walletsWithMatches.get(walletID)!;
// Only add if it's not already in the array
const alreadyAdded = group.transactions.some(
item => (item.data.hash === txid || item.data.txid === txid)
);
if (!alreadyAdded) {
group.transactions.push({
type: ItemType.TransactionSection,
data: tx as ExtendedTransaction & LightningTransaction
});
}
}
});
} catch (e) {
// Skip if wallet doesn't support getTransactions
}
// Search through addresses
try {
const addresses = wallet.getAllExternalAddresses() || [];
// Check if any address matches
addresses.forEach((address: any, index: number) => {
const addressValue = typeof address === 'string' ? address : address.address;
const addressLabel = typeof address === 'string' ? '' : (address.label || '');
if (addressValue.toLowerCase().includes(lowerQuery) ||
addressLabel.toLowerCase().includes(lowerQuery)) {
if (!walletsWithMatches.has(walletID)) {
walletsWithMatches.set(walletID, {
wallet,
transactions: [],
addresses: []
});
}
const group = walletsWithMatches.get(walletID)!;
const addressItem: AddressItem = {
type: ItemType.AddressSection,
data: {
address: addressValue,
walletID,
index,
isInternal: false,
}
};
// Check if this address is already in the array
const alreadyAdded = group.addresses.some(item => item.data.address === addressValue);
if (!alreadyAdded) {
group.addresses.push(addressItem);
}
}
});
} catch (e) {
// Skip if wallet doesn't support getAllExternalAddresses
}
});
// Now convert the map to the data format needed for the list
const result: Item[] = [];
// Process matches by wallet
walletsWithMatches.forEach((matchData, walletID) => {
const { wallet, transactions, addresses } = matchData;
// If this wallet has transactions or addresses that match, create a group
if (transactions.length > 0 || addresses.length > 0) {
// Add a wallet group item
result.push({
type: ItemType.WalletGroupSection,
wallet,
transactions,
addresses
});
} else {
// If it's just the wallet label that matched, add a regular wallet item
result.push({
type: ItemType.WalletSection,
data: wallet
});
}
});
return result;
}
// When not searching, return the original managed data
return state.managedWalletsData;
}, [debouncedSearchQuery, state.managedWalletsData, state.originalWallets, state.txMetadata]);
useEffect(() => {
dispatch({ type: SET_INITIAL_ORDER, payload: { wallets: initialWalletsRef.current, txMetadata } });
dispatch({ type: SET_INITIAL_DATA, payload: { wallets: initialWalletsRef.current, txMetadata } });
}, [txMetadata]);
// Handle no results animation
useEffect(() => {
if (debouncedSearchQuery) {
if (Platform.OS === 'ios') {
LayoutAnimation.configureNext({
duration: 300,
create: {
type: LayoutAnimation.Types.spring,
property: LayoutAnimation.Properties.opacity,
springDamping: 0.85,
},
update: {
type: LayoutAnimation.Types.spring,
springDamping: 0.85,
},
delete: {
type: LayoutAnimation.Types.spring,
property: LayoutAnimation.Properties.opacity,
springDamping: 0.85,
},
});
} else {
LayoutAnimation.configureNext({
duration: 250,
create: {
type: LayoutAnimation.Types.easeInEaseOut,
property: LayoutAnimation.Properties.opacity,
},
update: {
type: LayoutAnimation.Types.easeInEaseOut,
},
delete: {
duration: 200,
type: LayoutAnimation.Types.easeInEaseOut,
property: LayoutAnimation.Properties.opacity,
},
});
}
dispatch({ type: SET_FILTERED_ORDER, payload: debouncedSearchQuery });
} else {
if (Platform.OS === 'ios') {
LayoutAnimation.configureNext({
duration: 250,
create: {
type: LayoutAnimation.Types.easeInEaseOut,
property: LayoutAnimation.Properties.opacity,
},
update: {
type: LayoutAnimation.Types.easeInEaseOut,
},
delete: {
type: LayoutAnimation.Types.easeInEaseOut,
property: LayoutAnimation.Properties.opacity,
},
});
} else {
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut);
}
dispatch({ type: SET_TEMP_ORDER, payload: state.originalWalletsOrder });
}
}, [debouncedSearchQuery, state.originalWalletsOrder]);
useEffect(() => {
if (state.searchQuery && state.currentWalletsOrder.length === 0) {
if (state.searchQuery && getFilteredWalletsData().length === 0) {
Animated.timing(noResultsOpacity, {
toValue: 1,
duration: 300,
@ -430,18 +400,22 @@ const ManageWallets: React.FC = () => {
} else {
noResultsOpacity.setValue(0);
}
}, [state.searchQuery, state.currentWalletsOrder.length, noResultsOpacity]);
}, [getFilteredWalletsData, state.searchQuery, state.managedWalletsData.length, noResultsOpacity]);
useEffect(() => {
setUiData(getFilteredWalletsData());
}, [state.managedWalletsData, debouncedSearchQuery]);
const hasUnsavedChanges = useMemo(() => {
if (state.searchQuery.length > 0 || state.isSearchFocused) {
return false;
}
const currentWalletIds = state.currentWalletsOrder
const currentWalletIds = state.managedWalletsData
.filter((item): item is WalletItem => item.type === ItemType.WalletSection)
.map(item => item.data.getID());
const originalWalletIds = state.initialWalletsBackup.map(wallet => wallet.getID());
const originalWalletIds = state.originalWallets.map(wallet => wallet.getID());
if (currentWalletIds.length !== originalWalletIds.length) {
return true;
@ -453,19 +427,19 @@ const ManageWallets: React.FC = () => {
}
}
const modifiedWallets = state.currentWalletsOrder
const modifiedWallets = state.managedWalletsData
.filter((item): item is WalletItem => item.type === ItemType.WalletSection)
.map(item => item.data);
for (const modifiedWallet of modifiedWallets) {
const originalWallet = state.initialWalletsBackup.find(w => w.getID() === modifiedWallet.getID());
const originalWallet = state.originalWallets.find(w => w.getID() === modifiedWallet.getID());
if (originalWallet && originalWallet.hideBalance !== modifiedWallet.hideBalance) {
return true;
}
}
return false;
}, [state.currentWalletsOrder, state.initialWalletsBackup, state.searchQuery, state.isSearchFocused]);
}, [state.managedWalletsData, state.originalWallets, state.searchQuery, state.isSearchFocused]);
usePreventRemove(hasUnsavedChanges && !saveInProgress, ({ data: preventRemoveData }) => {
Alert.alert(loc._.discard_changes, loc._.discard_changes_explain, [
@ -487,11 +461,11 @@ const ManageWallets: React.FC = () => {
const handleClose = useCallback(() => {
if (state.searchQuery.length === 0 && !state.isSearchFocused) {
const reorderedWallets = state.currentWalletsOrder
const reorderedWallets = state.managedWalletsData
.filter((item): item is WalletItem => item.type === ItemType.WalletSection)
.map(item => item.data);
const walletsToDelete = state.initialWalletsBackup.filter(
const walletsToDelete = state.originalWallets.filter(
originalWallet => !reorderedWallets.some(wallet => wallet.getID() === originalWallet.getID()),
);
@ -512,8 +486,8 @@ const ManageWallets: React.FC = () => {
setWalletsWithNewOrder,
state.searchQuery,
state.isSearchFocused,
state.currentWalletsOrder,
state.initialWalletsBackup,
state.managedWalletsData,
state.originalWallets,
handleWalletDeletion,
]);
@ -604,7 +578,7 @@ const ManageWallets: React.FC = () => {
const handleToggleHideBalance = useCallback(
(wallet: TWallet) => {
const updatedOrder = state.currentWalletsOrder.map(item => {
const updatedOrder = state.managedWalletsData.map(item => {
if (item.type === ItemType.WalletSection && item.data.getID() === wallet.getID()) {
item.data.hideBalance = !item.data.hideBalance;
return {
@ -615,9 +589,9 @@ const ManageWallets: React.FC = () => {
return item;
});
dispatch({ type: SET_TEMP_ORDER, payload: updatedOrder });
dispatch({ type: SET_MANAGED_DATA, payload: updatedOrder });
},
[state.currentWalletsOrder],
[state.managedWalletsData],
);
const navigateToWallet = useCallback(
@ -635,12 +609,17 @@ const ManageWallets: React.FC = () => {
const navigateToAddress = useCallback(
(address: string, walletID: string) => {
// First dismiss the modal and then navigate with a slight delay
Keyboard.dismiss();
goBack();
navigate('ReceiveDetails', {
walletID,
address,
});
// Use setTimeout to ensure the modal is fully dismissed before navigation
setTimeout(() => {
navigate('ReceiveDetails', {
walletID,
address,
});
}, 300);
},
[goBack, navigate],
);
@ -650,7 +629,7 @@ const ManageWallets: React.FC = () => {
const { item, onDragStart, isActive } = info;
const compatibleState = {
wallets: state.availableWallets,
wallets: state.originalWallets,
searchQuery: state.searchQuery,
isSearchFocused: state.isSearchFocused,
};
@ -688,7 +667,7 @@ const ManageWallets: React.FC = () => {
);
},
[
state.availableWallets,
state.originalWallets,
state.searchQuery,
state.isSearchFocused,
navigateToWallet,
@ -706,13 +685,13 @@ const ManageWallets: React.FC = () => {
return;
}
const updatedOrder = [...state.currentWalletsOrder];
const updatedOrder = [...state.managedWalletsData];
const removed = updatedOrder.splice(fromIndex, 1);
updatedOrder.splice(toIndex, 0, removed[0]);
dispatch({ type: SET_TEMP_ORDER, payload: updatedOrder });
dispatch({ type: SET_MANAGED_DATA, payload: updatedOrder });
},
[state.currentWalletsOrder, state.searchQuery, state.isSearchFocused],
[state.managedWalletsData, state.searchQuery, state.isSearchFocused],
);
const keyExtractor = useCallback((item: Item, index: number) => index.toString(), []);
@ -720,7 +699,7 @@ const ManageWallets: React.FC = () => {
return (
<Suspense fallback={<ActivityIndicator size="large" color={colors.brandingColor} />}>
<GestureHandlerRootView style={[{ backgroundColor: colors.background }, styles.root]}>
{state.searchQuery && state.currentWalletsOrder.length === 0 ? (
{state.searchQuery && getFilteredWalletsData().length === 0 ? (
<Animated.View style={[styles.noResultsContainer, { opacity: noResultsOpacity }]}>
<Animated.Text style={[styles.noResultsText, stylesHook.noResultsText]}>{loc.wallets.no_results_found}</Animated.Text>
</Animated.View>
@ -730,12 +709,13 @@ const ManageWallets: React.FC = () => {
automaticallyAdjustKeyboardInsets
automaticallyAdjustsScrollIndicatorInsets
contentInsetAdjustmentBehavior="automatic"
data={uiData}
data={getFilteredWalletsData()}
containerStyle={[{ backgroundColor: colors.background }, styles.root]}
keyExtractor={keyExtractor}
onReordered={onReordered}
renderItem={renderItem}
ref={listRef}
extraData={debouncedSearchQuery} // Use extraData instead of key for re-renders
/>
)}
</GestureHandlerRootView>

View File

@ -290,11 +290,8 @@ const WalletTransactions: React.FC<WalletTransactionsProps> = ({ route }) => {
selectWallet(navigate, name, Chain.ONCHAIN).then(onWalletSelect);
}
} else if (id === actionKeys.RefillWithExternalWallet) {
navigate('ReceiveDetailsRoot', {
screen: 'ReceiveDetails',
params: {
walletID,
},
navigate('ReceiveDetails', {
walletID,
});
}
},
@ -617,7 +614,9 @@ const WalletTransactions: React.FC<WalletTransactionsProps> = ({ route }) => {
if (wallet.chain === Chain.OFFCHAIN) {
navigate('LNDCreateInvoiceRoot', { screen: 'LNDCreateInvoice', params: { walletID } });
} else {
navigate('ReceiveDetailsRoot', { screen: 'ReceiveDetails', params: { walletID } });
navigate('ReceiveDetails', {
walletID,
});
}
}}
icon={