Compare commits

...

6 Commits

Author SHA1 Message Date
Marcos Rodriguez
ec86657a15 ccc 2026-05-12 23:53:21 -05:00
copilot-swe-agent[bot]
d06566f0cc
FIX: follow naming convention in swipe unit label helper
Agent-Logs-Url: https://github.com/BlueWallet/BlueWallet/sessions/1a6a366e-2ebf-4660-9e9b-855a7e96ac6e

Co-authored-by: marcosrdz <4793122+marcosrdz@users.noreply.github.com>
2026-05-13 02:01:12 +00:00
copilot-swe-agent[bot]
fd653588b7
FIX: address swipe action review feedback
Agent-Logs-Url: https://github.com/BlueWallet/BlueWallet/sessions/1a6a366e-2ebf-4660-9e9b-855a7e96ac6e

Co-authored-by: marcosrdz <4793122+marcosrdz@users.noreply.github.com>
2026-05-13 01:59:18 +00:00
Marcos Rodriguez Vélez
d663a13cbd
Potential fix for pull request finding 'Unused variable, import, function or class'
Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com>
2026-05-12 20:52:21 -05:00
Marcos Rodriguez
ad6b7de090 Update ManageWalletsListItem.tsx 2026-05-12 00:26:19 -05:00
Marcos Rodriguez
978acedc4c eme 2026-05-12 00:26:08 -05:00
5 changed files with 290 additions and 71 deletions

View File

@ -1,9 +1,7 @@
import React, { useCallback, useState, useEffect, useRef } from 'react';
import { StyleSheet, ViewStyle, ActivityIndicator, Platform, Animated, View, Text, Pressable } from 'react-native';
import { StyleSheet, ViewStyle, ActivityIndicator, Platform, Animated, View } from 'react-native';
import { useLocale } from '@react-navigation/native';
import { Swipeable } from 'react-native-gesture-handler';
import { ExtendedTransaction, LightningTransaction, Transaction, TWallet } from '../class/wallets/types';
import loc from '../loc';
import { TransactionListItem } from './TransactionListItem';
import { useTheme } from './themes';
import { BitcoinUnit } from '../models/bitcoinUnits';
@ -16,6 +14,7 @@ import { MultisigHDWallet } from '../class/wallets/multisig-hd-wallet';
import { AbstractHDElectrumWallet } from '../class/wallets/abstract-hd-electrum-wallet';
import { WatchOnlyWallet } from '../class/wallets/watch-only-wallet';
import WalletListItem from './WalletListItem';
import SwipeableWalletRow from './SwipeableWalletRow';
const getHdElectrumWallet = (wallet: TWallet): AbstractHDElectrumWallet | undefined => {
const w: unknown = wallet;
@ -60,6 +59,7 @@ interface ManageWalletsListItemProps {
item: Item;
isDraggingDisabled: boolean;
handleToggleHideBalance: (wallet: TWallet) => void;
handleChangeWalletUnit: (wallet: TWallet) => void;
state: { wallets: TWallet[]; searchQuery: string; isSearchFocused?: boolean };
navigateToWallet: (wallet: TWallet) => void;
navigateToAddress: (address: string, walletID: string) => void;
@ -81,6 +81,7 @@ const ManageWalletsListItem: React.FC<ManageWalletsListItemProps> = ({
isPlaceHolder = false,
navigateToWallet,
navigateToAddress,
handleChangeWalletUnit,
renderHighlightedText,
onPressIn,
onPressOut,
@ -94,7 +95,8 @@ const ManageWalletsListItem: React.FC<ManageWalletsListItemProps> = ({
const [isLoading, setIsLoading] = useState(false);
const prevIsActive = useRef(isActive);
const swipeableRef = useRef<Swipeable | null>(null);
const rowScale = useRef(new Animated.Value(1)).current;
const swipeableRef = useRef<import('react-native-gesture-handler').Swipeable | null>(null);
const swipeInProgressRef = useRef(false);
useEffect(() => {
@ -104,7 +106,17 @@ const ManageWalletsListItem: React.FC<ManageWalletsListItemProps> = ({
prevIsActive.current = isActive;
}, [isActive]);
useEffect(() => {
Animated.spring(rowScale, {
toValue: isActive ? 1.04 : 1,
friction: 7,
tension: 90,
useNativeDriver: true,
}).start();
}, [isActive, rowScale]);
const onPress = useCallback(() => {
if (globalDragActive) return;
if (swipeInProgressRef.current) return;
if (item.type === ItemType.WalletSection) {
setIsLoading(true);
@ -113,9 +125,10 @@ const ManageWalletsListItem: React.FC<ManageWalletsListItemProps> = ({
} else if (item.type === ItemType.AddressSection) {
navigateToAddress(item.data.address, item.data.walletID);
}
}, [item, navigateToWallet, navigateToAddress]);
}, [globalDragActive, item, navigateToWallet, navigateToAddress]);
const startDrag = useCallback(() => {
if (globalDragActive || isDraggingDisabled) return;
if (swipeInProgressRef.current) {
swipeableRef.current?.close?.();
return;
@ -124,7 +137,7 @@ const ManageWalletsListItem: React.FC<ManageWalletsListItemProps> = ({
if (drag) {
drag();
}
}, [drag]);
}, [drag, globalDragActive, isDraggingDisabled]);
if (isLoading) {
return <ActivityIndicator size="large" color={colors.brandingColor} />;
@ -140,26 +153,15 @@ const ManageWalletsListItem: React.FC<ManageWalletsListItemProps> = ({
const onToggle = () => {
handleToggleHideBalance(wallet);
swipeableRef.current?.close?.();
};
const renderRightActions = () => (
<View style={styles.rightActionsContainer}>
<Pressable
style={({ pressed }) => [
styles.rightAction,
{ backgroundColor: colors.buttonBackgroundColor },
pressed && styles.rightActionPressed,
]}
onPress={onToggle}
accessibilityRole="button"
>
<Text style={[styles.rightActionText, { color: colors.buttonTextColor }]}>
{isHidden ? loc.wallets.swipe_balance_show : loc.wallets.swipe_balance_hide}
</Text>
</Pressable>
</View>
);
const onChangeUnit = () => {
handleChangeWalletUnit(wallet);
};
const onSwipeStateChange = (isSwipeInProgress: boolean) => {
swipeInProgressRef.current = isSwipeInProgress;
};
const content = (
<WalletListItem
@ -178,29 +180,20 @@ const ManageWalletsListItem: React.FC<ManageWalletsListItemProps> = ({
/>
);
if (!canSwipe) return content;
return (
<Swipeable
ref={r => {
swipeableRef.current = r;
}}
onSwipeableWillOpen={() => {
swipeInProgressRef.current = true;
}}
onSwipeableWillClose={() => {
swipeInProgressRef.current = false;
}}
onSwipeableClose={() => {
swipeInProgressRef.current = false;
}}
renderRightActions={renderRightActions}
friction={2}
rightThreshold={40}
overshootRight={false}
>
{content}
</Swipeable>
<Animated.View style={[styles.dragAnimatedRow, { transform: [{ scale: rowScale }] }]}>
<SwipeableWalletRow
ref={swipeableRef}
enabled={canSwipe}
isHidden={isHidden}
currentUnit={wallet.getPreferredBalanceUnit()}
onToggleHideBalance={onToggle}
onChangeUnit={onChangeUnit}
onSwipeStateChange={onSwipeStateChange}
>
{content}
</SwipeableWalletRow>
</Animated.View>
);
} else if (item.type === ItemType.TransactionSection && item.data) {
try {
@ -411,27 +404,13 @@ const WalletGroupComponent: React.FC<WalletGroupProps> = ({
};
const styles = StyleSheet.create({
dragAnimatedRow: {
width: '100%',
},
itemDivider: {
height: 1,
width: '100%',
},
rightActionsContainer: {
justifyContent: 'center',
alignItems: 'flex-end',
},
rightAction: {
justifyContent: 'center',
alignItems: 'center',
paddingHorizontal: 18,
height: '100%',
},
rightActionPressed: {
opacity: 0.85,
},
rightActionText: {
fontSize: 15,
fontWeight: '600',
},
});
export { WalletGroupComponent };

View File

@ -0,0 +1,186 @@
import React, { useCallback, useRef } from 'react';
import { Animated, Pressable, StyleSheet, Text } from 'react-native';
import { Swipeable } from 'react-native-gesture-handler';
import triggerHapticFeedback, { HapticFeedbackTypes } from '../blue_modules/hapticFeedback';
import { useTheme } from './themes';
import loc from '../loc';
import { BitcoinUnit } from '../models/bitcoinUnits';
import { FiatUnit } from '../models/fiatUnit';
import { useSettings } from '../hooks/context/useSettings';
import Icon from './Icon';
/** Width of each swipe-action button — matches iOS Mail / Contacts style. */
const ACTION_WIDTH = 80;
interface SwipeableWalletRowProps {
children: React.ReactNode;
/** When false the row renders children without any swipe wrapper (during drag / active). */
enabled: boolean;
isHidden: boolean;
currentUnit: BitcoinUnit;
onToggleHideBalance: () => void;
onChangeUnit: () => void;
onSwipeStateChange?: (isSwipeInProgress: boolean) => void;
}
/**
* SwipeableWalletRow wraps a wallet list item and reveals iOS-style action
* buttons on left-swipe:
* [ Change Unit ] [ Hide / Show ]
*
* The reveal animation matches the native iOS feel:
* - The whole panel translates in sync with the user's finger via a single
* translateX derived from `dragX`.
* - No per-button stagger (that's what causes the "non-native" look).
* - Buttons have a fixed width so the panel has a predictable size.
*/
const SwipeableWalletRow = React.forwardRef<Swipeable, SwipeableWalletRowProps>(
({ children, enabled, isHidden, currentUnit, onToggleHideBalance, onChangeUnit, onSwipeStateChange }, ref) => {
const { colors } = useTheme();
const { preferredFiatCurrency } = useSettings();
const internalRef = useRef<Swipeable | null>(null);
// Forward the ref while keeping a local copy for `close()`.
const setRef = useCallback(
(r: Swipeable | null) => {
internalRef.current = r;
if (typeof ref === 'function') {
ref(r);
} else if (ref) {
(ref as React.MutableRefObject<Swipeable | null>).current = r;
}
},
[ref],
);
const close = useCallback(() => {
internalRef.current?.close();
}, []);
const handleHideBalance = useCallback(() => {
triggerHapticFeedback(HapticFeedbackTypes.Selection);
onToggleHideBalance();
close();
}, [onToggleHideBalance, close]);
const handleChangeUnit = useCallback(() => {
triggerHapticFeedback(HapticFeedbackTypes.Selection);
onChangeUnit();
close();
}, [onChangeUnit, close]);
const getUnitLabel = useCallback(() => {
if (currentUnit === BitcoinUnit.BTC) return loc.units.BTC;
if (currentUnit === BitcoinUnit.SATS) return loc.units.sats;
return preferredFiatCurrency?.endPointKey ?? FiatUnit.USD;
}, [currentUnit, preferredFiatCurrency]);
/**
* iOS-style reveal: the whole panel slides in together.
* `progress` goes from 0 1 as the row opens, so we translate
* each button from its fully-hidden offset 0.
*/
const renderRightActions = useCallback(
(progress: Animated.AnimatedInterpolation<number>) => {
const totalWidth = ACTION_WIDTH * 2;
const panelTranslate = progress.interpolate({
inputRange: [0, 1],
outputRange: [totalWidth, 0],
extrapolate: 'clamp',
});
return (
<Animated.View style={[styles.actionsContainer, { transform: [{ translateX: panelTranslate }] }]}>
{/* Change Unit */}
<Pressable
style={({ pressed }) => [
styles.actionButton,
{ backgroundColor: colors.changeBackground },
pressed && styles.actionButtonPressed,
]}
onPress={handleChangeUnit}
accessibilityRole="button"
accessibilityLabel={loc.wallets.swipe_change_unit}
>
<Icon name="arrows-rotate" type="font-awesome-6" size={14} color={colors.changeText} />
<Text style={[styles.actionText, { color: colors.changeText }]}>{getUnitLabel()}</Text>
</Pressable>
{/* Hide / Show Balance — rightmost, matches iOS "destructive" slot */}
<Pressable
style={({ pressed }) => [
styles.actionButton,
{ backgroundColor: colors.buttonBackgroundColor },
pressed && styles.actionButtonPressed,
]}
onPress={handleHideBalance}
accessibilityRole="button"
accessibilityLabel={isHidden ? loc.wallets.swipe_balance_show : loc.wallets.swipe_balance_hide}
>
<Icon name={isHidden ? 'eye' : 'eye-slash'} type="font-awesome" size={14} color={colors.buttonTextColor} />
<Text style={[styles.actionText, { color: colors.buttonTextColor }]}>
{isHidden ? loc.wallets.swipe_balance_show : loc.wallets.swipe_balance_hide}
</Text>
</Pressable>
</Animated.View>
);
},
[colors, getUnitLabel, handleChangeUnit, handleHideBalance, isHidden],
);
if (!enabled) {
return <>{children}</>;
}
return (
<Swipeable
ref={setRef}
renderRightActions={renderRightActions}
// friction=1 gives a 1:1 feel with the finger — most iOS-like.
friction={1}
rightThreshold={ACTION_WIDTH / 2}
// Allow pulling past action width with elastic resistance.
overshootRight
overshootFriction={10}
onSwipeableWillOpen={() => {
onSwipeStateChange?.(true);
triggerHapticFeedback(HapticFeedbackTypes.ImpactLight);
}}
onSwipeableWillClose={() => {
onSwipeStateChange?.(false);
}}
onSwipeableClose={() => {
onSwipeStateChange?.(false);
}}
>
{children}
</Swipeable>
);
},
);
SwipeableWalletRow.displayName = 'SwipeableWalletRow';
const styles = StyleSheet.create({
actionsContainer: {
flexDirection: 'row',
width: ACTION_WIDTH * 2,
},
actionButton: {
width: ACTION_WIDTH,
justifyContent: 'center',
alignItems: 'center',
gap: 3,
},
actionButtonPressed: {
opacity: 0.8,
},
actionText: {
fontSize: 12,
fontWeight: '600',
textAlign: 'center',
},
});
export default SwipeableWalletRow;

View File

@ -2806,7 +2806,7 @@ SPEC CHECKSUMS:
BVLinearGradient: cb006ba232a1f3e4f341bb62c42d1098c284da70
CocoaAsyncSocket: 065fd1e645c7abab64f7a6a2007a48038fdc6a99
FBLazyVector: 24e62c765683b8d89006a88a2c8f5cf019f0074d
hermes-engine: 4ed74710a31e8e31f20356c641eab1d8f7d54595
hermes-engine: 86cdbf283775c54dc008895c3eacd24a1f2a40b4
lottie-ios: 8f959969761e9c45d70353667d00af0e5b9cadb3
lottie-react-native: 615e5f4651bee144ea991ad8e900630b6b3daf5d
RCTDeprecation: a4c521821fab57cbb125b36effe84d897d0dfa12
@ -2817,7 +2817,7 @@ SPEC CHECKSUMS:
React: e2dc35338068bbd299c66f043ae0d7f25de8499e
React-callinvoker: 28b25d21b124c26cebaea713ba7d801b9351dc48
React-Core: 02ed7d2ffb70437bdf2aba074a13078a7b0b9ff0
React-Core-prebuilt: 3445f1028d9b206cd45c8bbb7e2427ee891f810e
React-Core-prebuilt: 9e875134f667c471ab68bf9edf1661fa11b86540
React-CoreModules: b3a5a42dadcde3b5d47b325bd912eb2ced89e146
React-cxxreact: fe8f88dda044e5905e99a00f41b7a874c3908716
React-debug: 92944dc4d89f56d640e75498266cbde557a48189
@ -2898,7 +2898,7 @@ SPEC CHECKSUMS:
ReactCodegen: 1bd7f2174582b0e142f8671735b5c906c08b72ea
ReactCommon: 7dfc3250793bf36cf221096ff59e1179e13eef7f
ReactNativeCameraKit: 5974256fc608631c1c812710cd98abe95dae0f88
ReactNativeDependencies: 75299c281f422106c723e79dc1f6ce7ef03241be
ReactNativeDependencies: 0a5c93845772e4b1c5ad065c59a859518b13a6b7
RealmJS: 1c37c6bdfe060f4caa0f9175aa0eedb962622ee1
RNCAsyncStorage: 2ad919e88b8bc2cd80e8697ce66d04d006743283
RNCClipboard: 715fa7c6c8366f17d00f05a439ee7488f390fa5f

View File

@ -448,6 +448,7 @@
"wallets": "Wallets",
"swipe_balance_hide": "Hide",
"swipe_balance_show": "Show",
"swipe_change_unit": "Unit",
"drag_to_reorder": "Drag to reorder",
"clear_search": "Clear search",
"details_type": "Type",

View File

@ -25,6 +25,7 @@ import { ExtendedTransaction, LightningTransaction, Transaction, TWallet } from
import useBounceAnimation from '../../hooks/useBounceAnimation';
import DraggableFlatList, { RenderItemParams, DragEndParams } from 'react-native-draggable-flatlist';
import { ItemType, AddressItemData } from '../../models/itemTypes';
import { BitcoinUnit } from '../../models/bitcoinUnits';
import ManageWalletsListItem, { WalletGroupComponent } from '../../components/ManageWalletsListItem';
import HighlightedText from '../../components/HighlightedText';
import triggerHapticFeedback, { HapticFeedbackTypes } from '../../blue_modules/hapticFeedback';
@ -144,6 +145,8 @@ const ManageWallets: React.FC = () => {
const [noResultsOpacity] = useState(new Animated.Value(0));
const [dragging, setDragging] = useState(false);
const [interactionLockActive, setInteractionLockActive] = useState(false);
const interactionLockTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const searchTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const debouncedSearch = useCallback((text: string) => {
if (searchTimerRef.current) clearTimeout(searchTimerRef.current);
@ -161,6 +164,7 @@ const ManageWallets: React.FC = () => {
useEffect(() => {
return () => {
if (searchTimerRef.current) clearTimeout(searchTimerRef.current);
if (interactionLockTimerRef.current) clearTimeout(interactionLockTimerRef.current);
};
}, []);
@ -414,7 +418,10 @@ const ManageWallets: React.FC = () => {
accessibilityRole="button"
accessibilityLabel={loc._.close}
style={styles.button}
onPress={goBack}
onPress={() => {
triggerHapticFeedback(HapticFeedbackTypes.Selection);
goBack();
}}
testID="NavigationCloseButton"
>
<Image source={closeImage} />
@ -427,8 +434,14 @@ const ManageWallets: React.FC = () => {
const searchBarOptions = {
hideWhenScrolling: false,
onChangeText: (event: { nativeEvent: { text: any } }) => debouncedSearch(event.nativeEvent.text),
onClear: () => debouncedSearch(''),
onFocus: () => dispatch({ type: SET_IS_SEARCH_FOCUSED, payload: true }),
onClear: () => {
triggerHapticFeedback(HapticFeedbackTypes.Selection);
debouncedSearch('');
},
onFocus: () => {
triggerHapticFeedback(HapticFeedbackTypes.Selection);
dispatch({ type: SET_IS_SEARCH_FOCUSED, payload: true });
},
onBlur: () => dispatch({ type: SET_IS_SEARCH_FOCUSED, payload: false }),
placeholder: loc.wallets.manage_wallets_search_placeholder,
};
@ -496,6 +509,30 @@ const ManageWallets: React.FC = () => {
[state.walletsCopy, setWalletsWithNewOrder],
);
const handleChangeWalletUnit = useCallback(
(wallet: TWallet) => {
const current = wallet.getPreferredBalanceUnit();
let next: BitcoinUnit;
if (current === BitcoinUnit.BTC) {
next = BitcoinUnit.SATS;
} else if (current === BitcoinUnit.SATS) {
next = BitcoinUnit.LOCAL_CURRENCY;
} else {
next = BitcoinUnit.BTC;
}
const walletID = wallet.getID();
const updatedWallets = deepCopyWallets(state.walletsCopy).map(w => {
if (w.getID() === walletID) {
w.setPreferredBalanceUnit(next);
}
return w;
});
setWalletsWithNewOrder(updatedWallets);
dispatch({ type: SAVE_CHANGES, payload: updatedWallets });
},
[state.walletsCopy, setWalletsWithNewOrder],
);
const renderListItem = useCallback(
(item: Item, drag: (() => void) | undefined, isActive: boolean) => {
const compatibleState = {
@ -521,20 +558,22 @@ const ManageWallets: React.FC = () => {
return (
<ManageWalletsListItem
item={item}
isDraggingDisabled={isDragDisabled}
isDraggingDisabled={isDragDisabled || interactionLockActive}
handleToggleHideBalance={handleToggleHideBalance}
handleChangeWalletUnit={handleChangeWalletUnit}
state={compatibleState}
navigateToWallet={navigateToWallet}
navigateToAddress={navigateToAddress}
renderHighlightedText={renderHighlightedText}
isActive={isActive}
drag={isDragDisabled ? undefined : drag}
globalDragActive={dragging}
globalDragActive={dragging || interactionLockActive}
/>
);
},
[
handleToggleHideBalance,
handleChangeWalletUnit,
state.walletsCopy,
state.searchQuery,
state.isSearchFocused,
@ -542,6 +581,7 @@ const ManageWallets: React.FC = () => {
navigateToAddress,
renderHighlightedText,
dragging,
interactionLockActive,
isDragDisabled,
],
);
@ -618,6 +658,7 @@ const ManageWallets: React.FC = () => {
style={({ pressed }) => [styles.clearSearchButton, stylesHook.clearSearchButton, pressed && styles.clearSearchButtonPressed]}
android_ripple={{ color: colors.buttonDisabledTextColor, borderless: false }}
onPress={() => {
triggerHapticFeedback(HapticFeedbackTypes.Selection);
dispatch({ type: SET_SEARCH_QUERY, payload: '' });
dispatch({ type: SET_IS_SEARCH_FOCUSED, payload: false });
}}
@ -660,10 +701,22 @@ const ManageWallets: React.FC = () => {
renderItem={renderDraggableItem}
containerStyle={styles.listContainer}
onDragBegin={() => {
if (interactionLockTimerRef.current) {
clearTimeout(interactionLockTimerRef.current);
interactionLockTimerRef.current = null;
}
setInteractionLockActive(false);
setDragging(true);
}}
onDragEnd={({ from, to, data }: DragEndParams<Item>) => {
setDragging(false);
setInteractionLockActive(true);
if (interactionLockTimerRef.current) {
clearTimeout(interactionLockTimerRef.current);
}
interactionLockTimerRef.current = setTimeout(() => {
setInteractionLockActive(false);
}, 180);
if (state.searchQuery.length > 0 || state.isSearchFocused) {
return;