From 778117b66b8dbb2b3b945f73725ef0fd0f5c24f6 Mon Sep 17 00:00:00 2001 From: Kane Thomas Date: Tue, 10 Dec 2024 15:05:16 -0500 Subject: [PATCH 01/54] new wallet switcher UI --- src/components/change-wallet/AddressRow.tsx | 310 +++++++---------- src/components/change-wallet/WalletList.tsx | 287 +++++----------- src/navigation/Routes.android.tsx | 2 +- src/navigation/Routes.ios.tsx | 14 +- src/navigation/types.ts | 7 +- src/screens/change-wallet/AddressAvatar.tsx | 70 ++++ .../{ => change-wallet}/ChangeWalletSheet.tsx | 324 ++++++++++++------ .../change-wallet/PinnedWalletsGrid.tsx | 169 +++++++++ .../change-wallet/SelectedAddressBadge.tsx | 21 ++ src/state/wallets/pinnedWalletsStore.ts | 64 ++++ 10 files changed, 777 insertions(+), 491 deletions(-) create mode 100644 src/screens/change-wallet/AddressAvatar.tsx rename src/screens/{ => change-wallet}/ChangeWalletSheet.tsx (55%) create mode 100644 src/screens/change-wallet/PinnedWalletsGrid.tsx create mode 100644 src/screens/change-wallet/SelectedAddressBadge.tsx create mode 100644 src/state/wallets/pinnedWalletsStore.ts diff --git a/src/components/change-wallet/AddressRow.tsx b/src/components/change-wallet/AddressRow.tsx index 71f3ee08e16..cb6debcbcda 100644 --- a/src/components/change-wallet/AddressRow.tsx +++ b/src/components/change-wallet/AddressRow.tsx @@ -1,106 +1,66 @@ -import lang from 'i18n-js'; +import * as i18n from '@/languages'; import React, { useCallback, useMemo } from 'react'; -import { StyleSheet, View } from 'react-native'; import LinearGradient from 'react-native-linear-gradient'; import { useTheme } from '../../theme/ThemeContext'; import { ButtonPressAnimation } from '../animations'; -import { BottomRowText } from '../coin-row'; import ConditionalWrap from 'conditional-wrap'; -import CoinCheckButton from '../coin-row/CoinCheckButton'; -import { ContactAvatar } from '../contacts'; -import ImageAvatar from '../contacts/ImageAvatar'; -import { Icon } from '../icons'; -import { Centered, Column, ColumnWithMargins, Row } from '../layout'; -import { Text, TruncatedText } from '../text'; import ContextMenuButton from '@/components/native-context-menu/contextMenu'; import useExperimentalFlag, { NOTIFICATIONS } from '@/config/experimentalHooks'; -import { removeFirstEmojiFromString, returnStringFirstEmoji } from '@/helpers/emojiHandler'; -import styled from '@/styled-thing'; -import { fonts, fontWithWidth, getFontSize } from '@/styles'; -import { abbreviations, deviceUtils, profileUtils } from '@/utils'; -import { EditWalletContextMenuActions } from '@/screens/ChangeWalletSheet'; -import { toChecksumAddress } from '@/handlers/web3'; -import { IS_IOS, IS_ANDROID } from '@/env'; +import { IS_IOS } from '@/env'; import { ContextMenu } from '../context-menu'; -import { useForegroundColor } from '@/design-system'; +import { Box, Column, Columns, Inline, Stack, Text, Inset, useForegroundColor, useColorMode, TextIcon } from '@/design-system'; import { MenuActionConfig } from 'react-native-ios-context-menu'; +import { AddressItem, EditWalletContextMenuActions } from '@/screens/change-wallet/ChangeWalletSheet'; +import { TextSize } from '@/design-system/typography/typeHierarchy'; +import { TextWeight } from '@/design-system/components/Text/Text'; +import { opacity } from '@/__swaps__/utils/swaps'; +import { usePinnedWalletsStore } from '@/state/wallets/pinnedWalletsStore'; +import { AddressAvatar } from '@/screens/change-wallet/AddressAvatar'; +import { SelectedAddressBadge } from '@/screens/change-wallet/SelectedAddressBadge'; -const maxAccountLabelWidth = deviceUtils.dimensions.width - 88; -const NOOP = () => undefined; +const ROW_HEIGHT_WITH_PADDING = 64; -const sx = StyleSheet.create({ - accountLabel: { - fontFamily: fonts.family.SFProRounded, - fontSize: getFontSize(fonts.size.lmedium), - fontWeight: fonts.weight.medium as '500', - letterSpacing: fonts.letterSpacing.roundedMedium, - maxWidth: maxAccountLabelWidth, - }, - accountRow: { - flex: 1, - justifyContent: 'center', - marginLeft: 19, - }, - bottomRowText: { - fontWeight: fonts.weight.medium as '500', - letterSpacing: fonts.letterSpacing.roundedMedium, - }, - coinCheckIcon: { - width: 60, - }, - editIcon: { - color: '#0E76FD', - fontFamily: fonts.family.SFProRounded, - fontSize: getFontSize(fonts.size.large), - fontWeight: fonts.weight.heavy as '800', - textAlign: 'center', - }, - gradient: { - alignSelf: 'center', - borderRadius: 24, - height: 26, - justifyContent: 'center', - marginLeft: 19, - textAlign: 'center', - }, - rightContent: { - flex: 0, - flexDirection: 'row', - marginLeft: 48, - }, -}); +export const AddressRowButton = ({ + color, + icon, + onPress, + size, + weight, + disabled, +}: { + color?: string; + icon: string; + onPress?: () => void; + size?: TextSize; + weight?: TextWeight; + disabled?: boolean; +}) => { + const { isDarkMode } = useColorMode(); + const fillTertiary = useForegroundColor('fillTertiary'); + const fillQuaternary = useForegroundColor('fillQuaternary'); -const gradientProps = { - pointerEvents: 'none', - style: sx.gradient, -}; - -const StyledTruncatedText = styled(TruncatedText)({ - ...sx.accountLabel, - ...fontWithWidth(sx.accountLabel.fontWeight), -}); - -const StyledBottomRowText = styled(BottomRowText)({ - ...sx.bottomRowText, - ...fontWithWidth(sx.bottomRowText.fontWeight), -}); - -const ReadOnlyText = styled(Text).attrs({ - align: 'center', - letterSpacing: 'roundedMedium', - size: 'smedium', - weight: 'semibold', -})({ - paddingHorizontal: 8, -}); - -const OptionsIcon = ({ onPress }: { onPress: () => void }) => { - const { colors } = useTheme(); return ( - - - {IS_ANDROID ? : 􀍡} - + + + + {icon} + + ); }; @@ -113,50 +73,29 @@ const ContextMenuKeys = { interface AddressRowProps { contextMenuActions: EditWalletContextMenuActions; - data: any; + data: AddressItem; editMode: boolean; onPress: () => void; } -export default function AddressRow({ contextMenuActions, data, editMode, onPress }: AddressRowProps) { +export function AddressRow({ contextMenuActions, data, editMode, onPress }: AddressRowProps) { const notificationsEnabled = useExperimentalFlag(NOTIFICATIONS); - const { - address, - balancesMinusHiddenBalances, - color: accountColor, - ens, - image: accountImage, - isSelected, - isReadOnly, - isLedger, - label, - walletId, - } = data; - - const { colors, isDarkMode } = useTheme(); - - const labelQuaternary = useForegroundColor('labelQuaternary'); + const { address, color, secondaryLabel, isSelected, isReadOnly, isLedger, label: walletName, walletId, image } = data; - const balanceText = useMemo(() => { - if (!balancesMinusHiddenBalances) { - return lang.t('wallet.change_wallet.loading_balance'); - } + const addPinnedAddress = usePinnedWalletsStore(state => state.addPinnedAddress); - return balancesMinusHiddenBalances; - }, [balancesMinusHiddenBalances]); - - const cleanedUpLabel = useMemo(() => removeFirstEmojiFromString(label), [label]); - - const emoji = useMemo(() => returnStringFirstEmoji(label) || profileUtils.addressHashedEmoji(address), [address, label]); - - const displayAddress = useMemo(() => abbreviations.address(toChecksumAddress(address) || address, 4, 6), [address]); - - const walletName = cleanedUpLabel || ens || displayAddress; + const { colors, isDarkMode } = useTheme(); const linearGradientProps = useMemo( () => ({ - ...gradientProps, + pointerEvents: 'none' as const, + style: { + borderRadius: 12, + height: 22, + justifyContent: 'center', + paddingHorizontal: 8, + } as const, colors: [colors.alpha(colors.blueGreyDark, 0.03), colors.alpha(colors.blueGreyDark, isDarkMode ? 0.02 : 0.06)], end: { x: 1, y: 1 }, start: { x: 0, y: 0 }, @@ -167,7 +106,7 @@ export default function AddressRow({ contextMenuActions, data, editMode, onPress const contextMenuItems = [ { actionKey: ContextMenuKeys.Edit, - actionTitle: lang.t('wallet.action.edit'), + actionTitle: i18n.t(i18n.l.wallet.action.edit), icon: { iconType: 'SYSTEM', iconValue: 'pencil', @@ -178,7 +117,7 @@ export default function AddressRow({ contextMenuActions, data, editMode, onPress ? ([ { actionKey: ContextMenuKeys.Notifications, - actionTitle: lang.t('wallet.action.notifications.action_title'), + actionTitle: i18n.t(i18n.l.wallet.action.notifications.action_title), icon: { iconType: 'SYSTEM', iconValue: 'bell.fill', @@ -188,7 +127,7 @@ export default function AddressRow({ contextMenuActions, data, editMode, onPress : []), { actionKey: ContextMenuKeys.Remove, - actionTitle: lang.t('wallet.action.remove'), + actionTitle: i18n.t(i18n.l.wallet.action.remove), icon: { iconType: 'SYSTEM', iconValue: 'trash.fill' }, menuAttributes: ['destructive'], }, @@ -239,7 +178,7 @@ export default function AddressRow({ contextMenuActions, data, editMode, onPress ); return ( - + ( @@ -248,62 +187,71 @@ export default function AddressRow({ contextMenuActions, data, editMode, onPress )} > - - - {accountImage ? ( - - ) : ( - + + + {editMode && ( + + + 􀍠 + + )} - - + + + + + {walletName} - - {balanceText} - - - - {isReadOnly && ( - - {lang.t('wallet.change_wallet.watching')} - - )} - {isLedger && ( - - {lang.t('wallet.change_wallet.ledger')} - - )} - {!editMode && isSelected && ( - // @ts-expect-error JavaScript component - - )} - {editMode && - (IS_IOS ? ( - - - - ) : ( - item.actionTitle)} - isAnchoredToRight - onPressActionSheet={handleSelectActionMenuItem} - > - - - - - ))} - - + + + {secondaryLabel} + + + + + {isReadOnly && ( + + + {i18n.t(i18n.l.wallet.change_wallet.watching)} + + + )} + {isLedger && ( + + + {i18n.t(i18n.l.wallet.change_wallet.ledger)} + + + )} + {!editMode && isSelected && } + {editMode && ( + <> + addPinnedAddress(address)} color={colors.appleBlue} icon="􀎧" size="icon 12px" /> + {IS_IOS ? ( + + + + ) : ( + item.actionTitle)} + isAnchoredToRight + onPressActionSheet={handleSelectActionMenuItem} + > + + + )} + + )} + + + + - + ); } diff --git a/src/components/change-wallet/WalletList.tsx b/src/components/change-wallet/WalletList.tsx index 5d08e917f60..c6654341b43 100644 --- a/src/components/change-wallet/WalletList.tsx +++ b/src/components/change-wallet/WalletList.tsx @@ -1,54 +1,26 @@ -import lang from 'i18n-js'; -import { isEmpty } from 'lodash'; -import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'; +import React, { useCallback, useEffect, useLayoutEffect, useMemo, useState } from 'react'; import { StyleSheet } from 'react-native'; -import { FlatList } from 'react-native-gesture-handler'; import Animated, { Easing, useAnimatedStyle, useSharedValue, withTiming } from 'react-native-reanimated'; -import WalletTypes from '../../helpers/walletTypes'; -import { address } from '../../utils/abbreviations'; -import Divider from '@/components/Divider'; import { EmptyAssetList } from '../asset-list'; -import { Column } from '../layout'; -import AddressRow from './AddressRow'; -import WalletOption from './WalletOption'; +import { AddressRow } from './AddressRow'; import { EthereumAddress } from '@rainbow-me/entities'; -import { useAccountSettings } from '@/hooks'; import styled from '@/styled-thing'; import { position } from '@/styles'; -import { EditWalletContextMenuActions } from '@/screens/ChangeWalletSheet'; -import { HARDWARE_WALLETS, useExperimentalFlag } from '@/config'; -import { Inset, Stack } from '@/design-system'; -import { Network } from '@/chains/types'; - -const listTopPadding = 7.5; -const rowHeight = 59; -const transitionDuration = 75; - -const RowTypes = { - ADDRESS: 1, - EMPTY: 2, -}; - -const getItemLayout = (data: any, index: number) => { - const { height } = data[index]; - return { - index, - length: height, - offset: height * index, - }; -}; - -const keyExtractor = (item: any) => `${item.walletId}-${item?.id}`; - -// @ts-ignore -const Container = styled.View({ - height: ({ height }: { height: number }) => height, - marginTop: -2, -}); - -const WalletsContainer = styled(Animated.View)({ - flex: 1, -}); +import { + AddressItem, + EditWalletContextMenuActions, + FOOTER_HEIGHT, + MAX_PANEL_HEIGHT, + PANEL_HEADER_HEIGHT, +} from '@/screens/change-wallet/ChangeWalletSheet'; +import { Box, Inset, Separator, Text } from '@/design-system'; +import { DndProvider, DraggableFlatList, DraggableFlatListProps, UniqueIdentifier } from '../drag-and-drop'; +import { PinnedWalletsGrid } from '@/screens/change-wallet/PinnedWalletsGrid'; +import { usePinnedWalletsStore } from '@/state/wallets/pinnedWalletsStore'; + +const LIST_TOP_PADDING = 7.5; +const TRANSITION_DURATION = 75; +const LIST_MAX_HEIGHT = MAX_PANEL_HEIGHT - PANEL_HEADER_HEIGHT; const EmptyWalletList = styled(EmptyAssetList).attrs({ descendingOpacity: true, @@ -56,138 +28,60 @@ const EmptyWalletList = styled(EmptyAssetList).attrs({ })({ ...position.coverAsObject, backgroundColor: ({ theme: { colors } }: any) => colors.white, - paddingTop: listTopPadding, -}); - -const WalletFlatList = styled(FlatList).attrs(({ showDividers }: { showDividers: boolean }) => ({ - contentContainerStyle: { - paddingBottom: showDividers ? 9.5 : 0, - paddingTop: listTopPadding, - }, - getItemLayout, - keyExtractor, - removeClippedSubviews: true, -}))({ - flex: 1, - minHeight: 1, -}); - -const WalletListDivider = styled(Divider).attrs(({ theme: { colors } }: any) => ({ - color: colors.rowDividerExtraLight, - inset: [0, 15], -}))({ - marginBottom: 1, - marginTop: -1, + paddingTop: LIST_TOP_PADDING, }); interface Props { - accountAddress: EthereumAddress; - allWallets: any; + walletItems: AddressItem[]; contextMenuActions: EditWalletContextMenuActions; - currentWallet: any; editMode: boolean; - height: number; onChangeAccount: (walletId: string, address: EthereumAddress) => void; - onPressAddAnotherWallet: () => void; - onPressPairHardwareWallet: () => void; - scrollEnabled: boolean; - showDividers: boolean; - watchOnly: boolean; } -export default function WalletList({ - accountAddress, - allWallets, - contextMenuActions, - currentWallet, - editMode, - height, - onChangeAccount, - onPressAddAnotherWallet, - onPressPairHardwareWallet, - scrollEnabled, - showDividers, - watchOnly, -}: Props) { - const [rows, setRows] = useState([]); - const [ready, setReady] = useState(false); - const scrollView = useRef(null); - const { network } = useAccountSettings(); - const opacityAnimation = useSharedValue(0); - const emptyOpacityAnimation = useSharedValue(1); - const hardwareWalletsEnabled = useExperimentalFlag(HARDWARE_WALLETS); +export function WalletList({ walletItems, contextMenuActions, editMode, onChangeAccount }: Props) { + const pinnedAddresses = usePinnedWalletsStore(state => state.pinnedAddresses); + const unpinnedAddresses = usePinnedWalletsStore(state => state.unpinnedAddresses); - // Update the rows when allWallets changes - useEffect(() => { - const seedRows: any[] = []; - const privateKeyRows: any[] = []; - const readOnlyRows: any[] = []; + const pinnedWalletItems = useMemo(() => { + return walletItems + .filter(item => pinnedAddresses.includes(item.id)) + .sort((a, b) => pinnedAddresses.indexOf(a.id) - pinnedAddresses.indexOf(b.id)); + }, [walletItems, pinnedAddresses]); - if (isEmpty(allWallets)) return; - const sortedKeys = Object.keys(allWallets).sort(); - sortedKeys.forEach(key => { - const wallet = allWallets[key]; - const filteredAccounts = (wallet.addresses || []).filter((account: any) => account.visible); - filteredAccounts.forEach((account: any) => { - const row = { - ...account, - editMode, - height: rowHeight, - id: account.address, - isOnlyAddress: filteredAccounts.length === 1, - isReadOnly: wallet.type === WalletTypes.readOnly, - isLedger: wallet.type === WalletTypes.bluetooth, - isSelected: accountAddress === account.address && (watchOnly || wallet?.id === currentWallet?.id), - label: network !== Network.mainnet && account.ens === account.label ? address(account.address, 6, 4) : account.label, - onPress: () => onChangeAccount(wallet?.id, account.address), - rowType: RowTypes.ADDRESS, - walletId: wallet?.id, - }; - switch (wallet.type) { - case WalletTypes.mnemonic: - case WalletTypes.seed: - case WalletTypes.bluetooth: - seedRows.push(row); - break; - case WalletTypes.privateKey: - privateKeyRows.push(row); - break; - case WalletTypes.readOnly: - readOnlyRows.push(row); - break; - default: - break; - } - }); - }); + // it would be more efficient to map the addresses to the wallet items, but the wallet items should be the source of truth + const unpinnedWalletItems = useMemo(() => { + return walletItems + .filter(item => !pinnedAddresses.includes(item.id)) + .sort((a, b) => unpinnedAddresses.indexOf(a.id) - unpinnedAddresses.indexOf(b.id)); + }, [walletItems, pinnedAddresses, unpinnedAddresses]); - const newRows = [...seedRows, ...privateKeyRows, ...readOnlyRows]; - setRows(newRows); - }, [accountAddress, allWallets, currentWallet?.id, editMode, network, onChangeAccount, watchOnly]); + const [ready, setReady] = useState(false); + const opacityAnimation = useSharedValue(walletItems.length ? 1 : 0); + const emptyOpacityAnimation = useSharedValue(walletItems.length ? 0 : 1); + + const reorderUnpinnedAddresses = usePinnedWalletsStore(state => state.reorderUnpinnedAddresses); - // Update the data provider when rows change + // TODO: convert the effect below into an animated reaction useEffect(() => { - if (rows?.length && !ready) { + if (walletItems.length && !ready) { setTimeout(() => { setReady(true); emptyOpacityAnimation.value = withTiming(0, { - duration: transitionDuration, + duration: TRANSITION_DURATION, easing: Easing.out(Easing.ease), }); }, 50); } - }, [rows, ready, emptyOpacityAnimation]); + }, [walletItems, ready, emptyOpacityAnimation]); useLayoutEffect(() => { - if (ready) { + if (walletItems.length) { opacityAnimation.value = withTiming(1, { - duration: transitionDuration, + duration: TRANSITION_DURATION, easing: Easing.in(Easing.ease), }); - } else { - opacityAnimation.value = 0; } - }, [ready, opacityAnimation]); + }, [walletItems, opacityAnimation]); const opacityStyle = useAnimatedStyle(() => ({ opacity: opacityAnimation.value, @@ -197,59 +91,60 @@ export default function WalletList({ opacity: emptyOpacityAnimation.value, })); - const renderItem = useCallback( - ({ item }: any) => { - switch (item.rowType) { - case RowTypes.ADDRESS: - return ( - - - - ); - default: - return null; - } + const onOrderChange: DraggableFlatListProps['onOrderChange'] = useCallback( + (value: UniqueIdentifier[]) => { + // TODO: once upstream dnd fixes integrated + // reorderUnpinnedAddresses(value as string[]); }, - [contextMenuActions, editMode] + [reorderUnpinnedAddresses] ); + const renderHeader = useCallback(() => { + return ( + <> + {pinnedWalletItems.length > 0 && ( + + )} + {pinnedWalletItems.length > 0 && unpinnedWalletItems.length > 0 && ( + <> + + + + + + {'All Wallets'} + + + + )} + + ); + }, [pinnedWalletItems, onChangeAccount, editMode, unpinnedWalletItems.length]); + return ( - + - - - {showDividers && } - {!watchOnly && ( - - - + + ( + onChangeAccount(item.walletId, item.id)} /> - - {hardwareWalletsEnabled && ( - - )} - - - )} - - + )} + /> + + + ); } diff --git a/src/navigation/Routes.android.tsx b/src/navigation/Routes.android.tsx index 79bfbd90c29..ea45874c30a 100644 --- a/src/navigation/Routes.android.tsx +++ b/src/navigation/Routes.android.tsx @@ -5,7 +5,7 @@ import React, { useContext } from 'react'; import { AddCashSheet } from '../screens/AddCash'; import AvatarBuilder from '../screens/AvatarBuilder'; import BackupSheet from '../components/backup/BackupSheet'; -import ChangeWalletSheet from '../screens/ChangeWalletSheet'; +import ChangeWalletSheet from '../screens/change-wallet/ChangeWalletSheet'; import ConnectedDappsSheet from '../screens/ConnectedDappsSheet'; import ENSAdditionalRecordsSheet from '../screens/ENSAdditionalRecordsSheet'; import ENSConfirmRegisterSheet from '../screens/ENSConfirmRegisterSheet'; diff --git a/src/navigation/Routes.ios.tsx b/src/navigation/Routes.ios.tsx index 459561484d3..1153cd84c75 100644 --- a/src/navigation/Routes.ios.tsx +++ b/src/navigation/Routes.ios.tsx @@ -5,7 +5,7 @@ import React, { useContext } from 'react'; import { AddCashSheet } from '../screens/AddCash'; import AvatarBuilder from '../screens/AvatarBuilder'; import BackupSheet from '../components/backup/BackupSheet'; -import ChangeWalletSheet from '../screens/ChangeWalletSheet'; +import ChangeWalletSheet from '../screens/change-wallet/ChangeWalletSheet'; import ConnectedDappsSheet from '../screens/ConnectedDappsSheet'; import ENSAdditionalRecordsSheet from '../screens/ENSAdditionalRecordsSheet'; import ENSConfirmRegisterSheet from '../screens/ENSConfirmRegisterSheet'; @@ -184,17 +184,7 @@ function NativeStackNavigator() { {...externalLinkWarningSheetConfig} /> - + ['Screen']>[0]>, 'options'>; @@ -28,9 +29,9 @@ export type RootStackParamList = { fromProfile?: boolean; }; [Routes.CHANGE_WALLET_SHEET]: { - watchOnly: boolean; - currentAccountAddress: string; - onChangeWallet: (address: string) => void; + watchOnly?: boolean; + currentAccountAddress?: string; + onChangeWallet?: (address: string, wallet?: RainbowWallet) => void; }; [Routes.SPEED_UP_AND_CANCEL_BOTTOM_SHEET]: { accentColor?: string; diff --git a/src/screens/change-wallet/AddressAvatar.tsx b/src/screens/change-wallet/AddressAvatar.tsx new file mode 100644 index 00000000000..15fa0076e4b --- /dev/null +++ b/src/screens/change-wallet/AddressAvatar.tsx @@ -0,0 +1,70 @@ +import React from 'react'; +import { Box, Text, useForegroundColor } from '@/design-system'; +import { ImgixImage } from '@/components/images'; +import { addressHashedEmoji } from '@/utils/profileUtils'; +import { returnStringFirstEmoji } from '@/helpers/emojiHandler'; +import { colors } from '@/styles'; + +const DEFAULT_SIZE = 36; + +function AddressImageAvatar({ url, size = DEFAULT_SIZE }: { url: string; size?: number }) { + return ; +} + +function AddressEmojiAvatar({ + address, + color, + label, + size = DEFAULT_SIZE, +}: { + address: string; + color: string | number; + label: string; + size?: number; +}) { + const fillTertiary = useForegroundColor('fillTertiary'); + const emojiAvatar = returnStringFirstEmoji(label); + const accountSymbol = returnStringFirstEmoji(emojiAvatar || addressHashedEmoji(address)) || ''; + + const backgroundColor = + typeof color === 'number' + ? // sometimes the color is gonna be missing so we fallback to white + // otherwise there will be only shadows without the the placeholder "circle" + colors.avatarBackgrounds[color] ?? fillTertiary + : color; + + return ( + + 40 ? '30pt' : 'icon 18px'} weight="heavy"> + {accountSymbol} + + + ); +} + +export const AddressAvatar = React.memo(function AddressAvatar({ + address, + color, + label, + size = DEFAULT_SIZE, + url, +}: { + address: string; + color: string | number; + label: string; + size?: number; + url?: string | null; +}) { + return url ? ( + + ) : ( + + ); +}); diff --git a/src/screens/ChangeWalletSheet.tsx b/src/screens/change-wallet/ChangeWalletSheet.tsx similarity index 55% rename from src/screens/ChangeWalletSheet.tsx rename to src/screens/change-wallet/ChangeWalletSheet.tsx index 2c287f32d1f..88f94df78f4 100644 --- a/src/screens/ChangeWalletSheet.tsx +++ b/src/screens/change-wallet/ChangeWalletSheet.tsx @@ -1,71 +1,53 @@ -import { useRoute } from '@react-navigation/native'; -import lang from 'i18n-js'; -import React, { useCallback, useMemo, useState } from 'react'; -import { Alert, InteractionManager, View } from 'react-native'; +import { RouteProp, useRoute } from '@react-navigation/native'; +import * as i18n from '@/languages'; +import React, { useCallback, useMemo, useRef, useState } from 'react'; +import { Alert, InteractionManager, View, StyleSheet, StatusBar, NativeSyntheticEvent, NativeScrollEvent } from 'react-native'; import ReactNativeHapticFeedback from 'react-native-haptic-feedback'; import { useDispatch } from 'react-redux'; -import Divider from '@/components/Divider'; import { ButtonPressAnimation } from '@/components/animations'; -import WalletList from '@/components/change-wallet/WalletList'; -import { Centered, Column, Row } from '../components/layout'; -import { Sheet, SheetTitle } from '../components/sheet'; -import { Text } from '../components/text'; -import { removeWalletData } from '../handlers/localstorage/removeWallet'; -import { cleanUpWalletKeys } from '../model/wallet'; -import { useNavigation } from '../navigation/Navigation'; -import { addressSetSelected, walletsSetSelected, walletsUpdate } from '../redux/wallets'; +import { WalletList } from '@/components/change-wallet/WalletList'; +import { removeWalletData } from '../../handlers/localstorage/removeWallet'; +import { cleanUpWalletKeys } from '../../model/wallet'; +import { useNavigation } from '../../navigation/Navigation'; +import { addressSetSelected, walletsSetSelected, walletsUpdate } from '../../redux/wallets'; +import WalletTypes from '@/helpers/walletTypes'; import { analytics, analyticsV2 } from '@/analytics'; -import { getExperimetalFlag, HARDWARE_WALLETS } from '@/config'; -import { useAccountSettings, useInitializeWallet, useWallets, useWalletsWithBalancesAndNames, useWebData } from '@/hooks'; +import { useAccountSettings, useDimensions, useInitializeWallet, useWallets, useWalletsWithBalancesAndNames, useWebData } from '@/hooks'; import Routes from '@/navigation/routesNames'; import styled from '@/styled-thing'; -import { doesWalletsContainAddress, showActionSheetWithOptions } from '@/utils'; +import { doesWalletsContainAddress, safeAreaInsetValues, showActionSheetWithOptions } from '@/utils'; import { logger, RainbowError } from '@/logger'; import { useTheme } from '@/theme'; import { EthereumAddress } from '@/entities'; import { getNotificationSettingsForWalletWithAddress } from '@/notifications/settings/storage'; import { DEVICE_HEIGHT } from '@/utils/deviceUtils'; -import { IS_ANDROID, IS_IOS } from '@/env'; +import { IS_ANDROID } from '@/env'; import { remotePromoSheetsStore } from '@/state/remotePromoSheets/remotePromoSheets'; +import { RootStackParamList } from '@/navigation/types'; +import { address } from '@/utils/abbreviations'; +import { removeFirstEmojiFromString } from '@/helpers/emojiHandler'; +import { Box, Stack, Text } from '@/design-system'; +import { addDisplay } from '@/helpers/utilities'; +import { useSharedValue } from 'react-native-reanimated'; +import { Panel, TapToDismiss } from '@/components/SmoothPager/ListPanel'; +import { SheetHandle } from '@/components/sheet'; -const FOOTER_HEIGHT = getExperimetalFlag(HARDWARE_WALLETS) ? 100 : 60; const LIST_PADDING_BOTTOM = 6; const MAX_LIST_HEIGHT = DEVICE_HEIGHT - 220; const WALLET_ROW_HEIGHT = 59; const WATCH_ONLY_BOTTOM_PADDING = IS_ANDROID ? 20 : 0; -const EditButton = styled(ButtonPressAnimation).attrs(({ editMode }: { editMode: boolean }) => ({ - scaleTo: 0.96, - wrapperStyle: { - width: editMode ? 70 : 58, - }, - width: editMode ? 100 : 100, -}))( - IS_IOS - ? { - position: 'absolute', - right: 20, - top: -11, - } - : { - elevation: 10, - position: 'relative', - right: 20, - top: 6, - } -); - -const EditButtonLabel = styled(Text).attrs(({ theme: { colors }, editMode }: { theme: any; editMode: boolean }) => ({ - align: 'right', - color: colors.appleBlue, - letterSpacing: 'roundedMedium', - size: 'large', - weight: editMode ? 'bold' : 'semibold', - numberOfLines: 1, - ellipsizeMode: 'tail', -}))({ - height: 40, -}); +// TODO: calc +const PANEL_BOTTOM_OFFSET = 41; + +export const MAX_PANEL_HEIGHT = 640; +export const PANEL_HEADER_HEIGHT = 64; +export const FOOTER_HEIGHT = 91; + +const RowTypes = { + ADDRESS: 1, + EMPTY: 2, +}; const Whitespace = styled(View)({ backgroundColor: ({ theme: { colors } }: any) => colors.white, @@ -75,6 +57,7 @@ const Whitespace = styled(View)({ width: '100%', }); +// TODO: const getWalletListHeight = (wallets: any, watchOnly: boolean) => { let listHeight = !watchOnly ? FOOTER_HEIGHT + LIST_PADDING_BOTTOM : WATCH_ONLY_BOTTOM_PADDING; @@ -97,15 +80,33 @@ export type EditWalletContextMenuActions = { remove: (walletId: string, address: EthereumAddress) => void; }; +export interface AddressItem { + address: EthereumAddress; + color: number; + editMode: boolean; + height: number; + id: EthereumAddress; + isOnlyAddress: boolean; + isReadOnly: boolean; + isLedger: boolean; + isSelected: boolean; + label: string; + secondaryLabel: string; + rowType: number; + walletId: string; + image: string | null | undefined; +} + export default function ChangeWalletSheet() { - const { params = {} as any } = useRoute(); + const { params = {} } = useRoute>(); + const { onChangeWallet, watchOnly = false, currentAccountAddress } = params; const { selectedWallet, wallets } = useWallets(); const { colors } = useTheme(); const { updateWebProfile } = useWebData(); - const { accountAddress } = useAccountSettings(); - const { goBack, navigate } = useNavigation(); + const { accountAddress, network } = useAccountSettings(); + const { goBack, navigate, setParams } = useNavigation(); const dispatch = useDispatch(); const initializeWallet = useInitializeWallet(); const walletsWithBalancesAndNames = useWalletsWithBalancesAndNames(); @@ -113,15 +114,84 @@ export default function ChangeWalletSheet() { const [editMode, setEditMode] = useState(false); const [currentAddress, setCurrentAddress] = useState(currentAccountAddress || accountAddress); const [currentSelectedWallet, setCurrentSelectedWallet] = useState(selectedWallet); + const scrollContentOffsetY = useSharedValue(0); + + // TODO: maybe wallet accounts is a better name + const allWalletItems = useMemo(() => { + const sortedWallets: AddressItem[] = []; + const bluetoothWallets: AddressItem[] = []; + const readOnlyWallets: AddressItem[] = []; + + Object.values(walletsWithBalancesAndNames).forEach(wallet => { + const visibleAccounts = (wallet.addresses || []).filter(account => account.visible); + visibleAccounts.forEach(account => { + const balanceText = account.balancesMinusHiddenBalances + ? account.balancesMinusHiddenBalances + : i18n.t(i18n.l.wallet.change_wallet.loading_balance); + + const item: AddressItem = { + id: account.address, + address: account.address, + image: account.image, + color: account.color, + editMode, + height: WALLET_ROW_HEIGHT, + label: removeFirstEmojiFromString(account.label) || address(account.address, 6, 4), + // TODO: what does this do? + // label: + // network !== Network.mainnet && account.ens === account.label + // ? address(account.address, 6, 4) + // : removeFirstEmojiFromString(account.label), + secondaryLabel: balanceText, + isOnlyAddress: visibleAccounts.length === 1, + isLedger: wallet.type === WalletTypes.bluetooth, + isReadOnly: wallet.type === WalletTypes.readOnly, + isSelected: account.address === currentAddress, + rowType: RowTypes.ADDRESS, + walletId: wallet?.id, + }; + + if ([WalletTypes.mnemonic, WalletTypes.seed, WalletTypes.privateKey].includes(wallet.type)) { + sortedWallets.push(item); + } else if (wallet.type === WalletTypes.bluetooth) { + bluetoothWallets.push(item); + } else if (wallet.type === WalletTypes.readOnly) { + readOnlyWallets.push(item); + } + }); + }); - const [headerHeight, listHeight, scrollEnabled, showDividers] = useMemo(() => { - const { listHeight, scrollEnabled } = getWalletListHeight(wallets, watchOnly); + // sorts by order wallets were added + return [...sortedWallets, ...bluetoothWallets, ...readOnlyWallets].sort((a, b) => a.walletId.localeCompare(b.walletId)); + }, [walletsWithBalancesAndNames, currentAddress, editMode, network]); - const headerHeight = scrollEnabled ? 40 : 30; - const showDividers = scrollEnabled; + // TODO: maybe move this to its own hook + const ownedWalletsTotalBalance = useMemo(() => { + let isLoadingBalance = false; - return [headerHeight, listHeight, scrollEnabled, showDividers]; - }, [wallets, watchOnly]); + const totalBalance = Object.values(walletsWithBalancesAndNames).reduce((acc, wallet) => { + // only include owned wallet balances + if (wallet.type === WalletTypes.readOnly) return acc; + + const visibleAccounts = wallet.addresses.filter(account => account.visible); + + // TODO: if these are not in the native currency 0 format the end number will also not have the format + let walletTotalBalance = '0'; + + visibleAccounts.forEach(account => { + if (!account.balancesMinusHiddenBalances) { + isLoadingBalance = true; + } + walletTotalBalance = addDisplay(walletTotalBalance, account.balancesMinusHiddenBalances || '0'); + }); + + return addDisplay(acc, walletTotalBalance); + }, '0'); + + if (isLoadingBalance) return i18n.t(i18n.l.wallet.change_wallet.loading_balance); + + return totalBalance; + }, [walletsWithBalancesAndNames]); const onChangeAccount = useCallback( async (walletId: string, address: string, fromDeletion = false) => { @@ -277,7 +347,7 @@ export default function ChangeWalletSheet() { screen: Routes.WALLET_NOTIFICATIONS_SETTINGS, }); } else { - Alert.alert(lang.t('wallet.action.notifications.alert_title'), lang.t('wallet.action.notifications.alert_message'), [ + Alert.alert(i18n.t(i18n.l.wallet.action.notifications.alert_title), i18n.t(i18n.l.wallet.action.notifications.alert_message), [ { text: 'OK' }, ]); } @@ -306,8 +376,8 @@ export default function ChangeWalletSheet() { { cancelButtonIndex: 1, destructiveButtonIndex: 0, - message: lang.t('wallet.action.remove_confirm'), - options: [lang.t('wallet.action.remove'), lang.t('button.cancel')], + message: i18n.t(i18n.l.wallet.action.remove_confirm), + options: [i18n.t(i18n.l.wallet.action.remove), i18n.t(i18n.l.button.cancel)], }, async (buttonIndex: number) => { if (buttonIndex === 0) { @@ -372,42 +442,100 @@ export default function ChangeWalletSheet() { }, []); return ( - - {IS_ANDROID && } - - - {lang.t('wallet.label')} - - {!watchOnly && ( - - - {editMode ? lang.t('button.done') : lang.t('button.edit')} - - - )} - - {showDividers && } - - + - + bottom: PANEL_BOTTOM_OFFSET, + alignItems: 'center', + width: '100%', + pointerEvents: 'box-none', + position: 'absolute', + zIndex: 30000, + }, + ]} + > + + + + {/* TODO: align with design spec */} + + + + + + {'Wallets'} + + + + + {editMode ? i18n.t(i18n.l.button.done) : i18n.t(i18n.l.button.edit)} + + + + + {/* TODO: why is this here? */} + {IS_ANDROID && } + + + + {/* TODO: progressive blurview */} + + + + + {'Total Balance'} + + + {ownedWalletsTotalBalance} + + + + + + {'􀅼 Add'} + + + + + + + + + ); } diff --git a/src/screens/change-wallet/PinnedWalletsGrid.tsx b/src/screens/change-wallet/PinnedWalletsGrid.tsx new file mode 100644 index 00000000000..709e51ba09e --- /dev/null +++ b/src/screens/change-wallet/PinnedWalletsGrid.tsx @@ -0,0 +1,169 @@ +import { Draggable, DraggableGrid, DraggableGridProps, UniqueIdentifier } from '@/components/drag-and-drop'; +import { DndProvider } from '@/components/drag-and-drop/DndProvider'; +import { Box, Inline, Stack, Text } from '@/design-system'; +import { DEVICE_WIDTH } from '@/utils/deviceUtils'; +import React, { useCallback, useMemo } from 'react'; +import { AddressItem } from './ChangeWalletSheet'; +import { GenericCard } from '@/components/cards/GenericCard'; +import { useTheme } from '@/theme'; +import { IS_ANDROID } from '@/env'; +import { AddressAvatar } from './AddressAvatar'; +import { ButtonPressAnimation } from '@/components/animations'; +import { BlurView } from '@react-native-community/blur'; +import { usePinnedWalletsStore } from '@/state/wallets/pinnedWalletsStore'; +import { View } from 'react-native'; +import { SelectedAddressBadge } from './SelectedAddressBadge'; + +const UNPIN_BADGE_SIZE = 28; +const PINS_PER_ROW = 3; +const MAX_AVATAR_SIZE = 91; +const HORIZONTAL_PAGE_INSET = 24; + +function EmptyPinnedWallets() { + const { colors, isDarkMode } = useTheme(); + + return ( + + + + No pinned wallets yet + + + Pin your favorite wallets here for quick access at the top of your list + + + + ); +} + +type PinnedWalletsGridProps = { + walletItems: AddressItem[]; + onPress: (walletId: string, address: string) => void; + editMode: boolean; +}; + +export function PinnedWalletsGrid({ walletItems, onPress, editMode }: PinnedWalletsGridProps) { + const removePinnedAddress = usePinnedWalletsStore(state => state.removePinnedAddress); + const reorderPinnedAddresses = usePinnedWalletsStore(state => state.reorderPinnedAddresses); + + const onGridOrderChange: DraggableGridProps['onOrderChange'] = useCallback( + (value: UniqueIdentifier[]) => { + reorderPinnedAddresses(value as string[]); + }, + [reorderPinnedAddresses] + ); + + const gridKey = useMemo(() => walletItems.map(item => item.address).join('-'), [walletItems]); + + const fillerItems = useMemo(() => { + const itemsInLastRow = walletItems.length % PINS_PER_ROW; + return Array.from({ length: itemsInLastRow === 0 ? 0 : PINS_PER_ROW - itemsInLastRow }); + }, [walletItems.length]); + + // TODO: scale down if cannot fit three items in row + const avatarSize = MAX_AVATAR_SIZE; + + return ( + + {walletItems.length > 0 ? ( + + + {walletItems.map(account => ( + + + + + onPress(account.walletId, account.address)} scaleTo={0.8}> + + + + {account.isSelected && ( + + + + )} + + {editMode && ( + removePinnedAddress(account.address)}> + + + {'􀅽'} + + + + )} + + + {account.isLedger && ( + + 􀤃 + + )} + {account.isReadOnly && ( + + 􀋮 + + )} + + {account.label} + + + + {account.secondaryLabel} + + + + + ))} + {fillerItems.map((_, index) => ( + + ))} + + + ) : null} + + ); +} diff --git a/src/screens/change-wallet/SelectedAddressBadge.tsx b/src/screens/change-wallet/SelectedAddressBadge.tsx new file mode 100644 index 00000000000..19366df3484 --- /dev/null +++ b/src/screens/change-wallet/SelectedAddressBadge.tsx @@ -0,0 +1,21 @@ +import React from 'react'; +import { Bleed, Box } from '@/design-system'; +import { Icon } from '@/components/icons'; + +export function SelectedAddressBadge({ size = 22 }: { size?: number }) { + return ( + + + + ); +} diff --git a/src/state/wallets/pinnedWalletsStore.ts b/src/state/wallets/pinnedWalletsStore.ts new file mode 100644 index 00000000000..73cc8a8ffa9 --- /dev/null +++ b/src/state/wallets/pinnedWalletsStore.ts @@ -0,0 +1,64 @@ +import { createRainbowStore } from '@/state/internal/createRainbowStore'; + +const MIN_WALLETS_TO_SHOW_PINNING = 6; +const MAX_PINNED_WALLETS = 12; + +// TODO: fix +type Address = string; + +interface PinnedWalletsStore { + pinnedAddresses: Address[]; + unpinnedAddresses: Address[]; + canPinAddresses: () => boolean; + addPinnedAddress: (address: Address) => void; + removePinnedAddress: (address: Address) => void; + reorderPinnedAddresses: (newOrder: Address[]) => void; + reorderUnpinnedAddresses: (newOrder: Address[]) => void; + isPinnedAddress: (address: Address) => boolean; +} + +export const usePinnedWalletsStore = createRainbowStore( + (set, get) => ({ + pinnedAddresses: [], + unpinnedAddresses: [], + + canPinAddresses: () => { + const { pinnedAddresses } = get(); + return pinnedAddresses.length >= MIN_WALLETS_TO_SHOW_PINNING && pinnedAddresses.length < MAX_PINNED_WALLETS; + }, + + isPinnedAddress: address => { + return get().pinnedAddresses.some(pinnedAddress => pinnedAddress === address); + }, + + addPinnedAddress: address => { + const { pinnedAddresses } = get(); + + if (pinnedAddresses.length >= MAX_PINNED_WALLETS) return; + + set({ pinnedAddresses: [...pinnedAddresses, address] }); + }, + + removePinnedAddress: address => { + const { pinnedAddresses } = get(); + + const match = pinnedAddresses.find(pinnedAddress => pinnedAddress === address); + + if (match) { + set({ pinnedAddresses: pinnedAddresses.filter(pinnedAddress => pinnedAddress !== address) }); + } + }, + + reorderPinnedAddresses: newPinnedAddresses => { + set({ pinnedAddresses: newPinnedAddresses }); + }, + + reorderUnpinnedAddresses: newUnpinnedAddresses => { + set({ unpinnedAddresses: newUnpinnedAddresses }); + }, + }), + { + storageKey: 'pinnedWallets', + version: 1, + } +); From 74b4019d4382760111488f4439e65ff60d332112 Mon Sep 17 00:00:00 2001 From: Kane Thomas Date: Tue, 10 Dec 2024 15:06:24 -0500 Subject: [PATCH 02/54] dnd flatlist autoscrolling --- src/components/drag-and-drop/DndProvider.tsx | 12 +- .../components/DraggableFlatList.tsx | 175 ++++++++++++------ 2 files changed, 121 insertions(+), 66 deletions(-) diff --git a/src/components/drag-and-drop/DndProvider.tsx b/src/components/drag-and-drop/DndProvider.tsx index 5f0ad9553e6..15837e499f8 100644 --- a/src/components/drag-and-drop/DndProvider.tsx +++ b/src/components/drag-and-drop/DndProvider.tsx @@ -280,10 +280,10 @@ export const DndProvider = forwardRef { + .onChange(event => { // console.log(draggableStates.value); - const { state, translationX, translationY } = event; - debug && console.log('update', { state, translationX, translationY }); + const { state, translationX, translationY, changeX, changeY } = event; + debug && console.log('update', { state, changeX, changeY }); // Track current state for cancellation purposes panGestureState.value = state; const { value: activeId } = draggableActiveId; @@ -307,8 +307,10 @@ export const DndProvider = forwardRef = AnimatedProps>>; @@ -36,28 +33,36 @@ export type DraggableFlatListProps = Animate debug?: boolean; gap?: number; horizontal?: boolean; - initialOrder?: UniqueIdentifier[]; }; +function normalizeWorklet(value: number, fromMin: number, fromMax: number, toMin: number, toMax: number) { + 'worklet'; + return ((value - fromMin) * (toMax - toMin)) / (fromMax - fromMin) + toMin; +} + export const DraggableFlatList = ({ data, - // debug, + debug, gap = 0, horizontal = false, - initialOrder, + // initialOrder, onOrderChange, onOrderUpdate, renderItem, shouldSwapWorklet = swapByItemCenterPoint, ...otherProps }: DraggableFlatListProps): ReactElement => { - const { draggableActiveId, draggableContentOffset, draggableLayouts, draggableOffsets, draggableRestingOffsets } = useDndContext(); + const { draggableActiveId, draggableContentOffset, draggableLayouts, draggableOffsets, draggableRestingOffsets, draggableActiveLayout } = + useDndContext(); const animatedFlatListRef = useAnimatedRef>(); + const contentHeight = useSharedValue(0); + const visibleHeight = useSharedValue(0); + const scrollOffset = useSharedValue(0); + + // @ts-expect-error TODO: fix + const initialOrder = useMemo(() => data?.map((item: T) => item.id) ?? [], [data]); - const { - // draggablePlaceholderIndex, - draggableSortOrder, - } = useDraggableSort({ + const { draggableSortOrder } = useDraggableSort({ horizontal, initialOrder, onOrderChange, @@ -68,6 +73,51 @@ export const DraggableFlatList = ({ const direction = horizontal ? 'column' : 'row'; const size = 1; + const scrollHandler = useAnimatedScrollHandler(event => { + scrollOffset.value = event.contentOffset.y; + draggableContentOffset.y.value = event.contentOffset.y; + }); + + const autoscroll = useCallback( + (offset: number) => { + 'worklet'; + + // round to the nearest 0.5 to make scrolling smoother + const smoothedOffset = Math.round(offset * 2) / 2; + + const { value: activeId } = draggableActiveId; + + // this is similar logic to how the pan gesture onUpdate works in the DndProvider + if (activeId) { + const { value: layouts } = draggableLayouts; + const { value: offsets } = draggableOffsets; + + const activeLayout = layouts[activeId].value; + const activeOffset = offsets[activeId]; + + // if we have reached the bottom of the list, stop scrolling + if (draggableActiveLayout.value && draggableActiveLayout.value.y + smoothedOffset > contentHeight.value - AUTOSCROLL_THRESHOLD) { + return; + } + + // if we have reached the top of the list, stop scrolling + if (scrollOffset.value + smoothedOffset < 0) { + return; + } + + scrollTo(animatedFlatListRef, 0, scrollOffset.value + smoothedOffset, false); + + activeOffset.y.value += smoothedOffset; + + draggableActiveLayout.value = applyOffset(activeLayout, { + x: activeOffset.x.value, + y: activeOffset.y.value, + }); + } + }, + [draggableActiveId, draggableLayouts, draggableOffsets, draggableActiveLayout, contentHeight, animatedFlatListRef, scrollOffset] + ); + // Track sort order changes and update the offsets useAnimatedReaction( () => draggableSortOrder.value, @@ -124,22 +174,7 @@ export const DraggableFlatList = ({ [] ); - const scrollHandler = useAnimatedScrollHandler(event => { - draggableContentOffset.y.value = event.contentOffset.y; - }); - - /** ⚠️ TODO: Implement auto scrolling when dragging above or below the visible range */ - // const scrollToIndex = useCallback( - // (index: number) => { - // animatedFlatListRef.current?.scrollToIndex({ - // index, - // viewPosition: 0, - // animated: true, - // }); - // }, - // [animatedFlatListRef] - // ); - + /* ⚠️ TODO: Expose visible range to the parent */ // const viewableRange = useSharedValue({ // first: null, // last: null, @@ -156,30 +191,40 @@ export const DraggableFlatList = ({ // }, // [debug, viewableRange] // ); - - // useAnimatedReaction( - // () => draggablePlaceholderIndex.value, - // (next, prev) => { - // if (!Array.isArray(data)) { - // return; - // } - // if (debug) console.log(`placeholderIndex: ${prev} -> ${next}}, last visible= ${viewableRange.value.last}`); - // const { - // value: { first, last }, - // } = viewableRange; - // if (last !== null && next >= last && last < data.length - 1) { - // if (next < data.length) { - // runOnJS(scrollToIndex)(next + 1); - // } - // } else if (first !== null && first > 0 && next <= first) { - // if (next > 0) { - // runOnJS(scrollToIndex)(next - 1); - // } - // } - // } - // ); /** END */ + // On each frame that the draggable item's position (offsetY) changes, + // if it's within the threshold, we scroll relative to how far over the threshold it is. + // This runs every frame while the user is dragging and the item remains in the scroll trigger zone, + useAnimatedReaction( + () => draggableActiveLayout.value?.y, + activeItemY => { + if (activeItemY === undefined) return; + + const bottomThreshold = scrollOffset.value + visibleHeight.value - AUTOSCROLL_THRESHOLD; + const isNearBottom = activeItemY >= bottomThreshold; + + const topThreshold = scrollOffset.value + AUTOSCROLL_THRESHOLD; + const isNearTop = activeItemY <= topThreshold; + + if (isNearTop) { + const distanceFromTopThreshold = topThreshold - activeItemY; + const scrollSpeed = normalizeWorklet(distanceFromTopThreshold, 0, AUTOSCROLL_THRESHOLD, AUTOSCROLL_MIN_SPEED, AUTOSCROLL_MAX_SPEED); + autoscroll(-scrollSpeed); + } else if (isNearBottom) { + const distanceFromBottomThreshold = activeItemY - bottomThreshold; + const scrollSpeed = normalizeWorklet( + distanceFromBottomThreshold, + 0, + AUTOSCROLL_THRESHOLD, + AUTOSCROLL_MIN_SPEED, + AUTOSCROLL_MAX_SPEED + ); + autoscroll(scrollSpeed); + } + } + ); + /** 🛠️ DEBUGGING */ // useAnimatedReaction( // () => { @@ -205,11 +250,19 @@ export const DraggableFlatList = ({ CellRendererComponent={DraggableFlatListCellRenderer} data={data} onScroll={scrollHandler} + onLayout={event => { + visibleHeight.value = event.nativeEvent.layout.height; + }} + onContentSizeChange={(_, height) => { + contentHeight.value = height; + }} // onViewableItemsChanged={onViewableItemsChanged} ref={animatedFlatListRef} removeClippedSubviews={false} renderItem={renderItem} - renderScrollComponent={props => { + keyExtractor={(item: T) => item.id.toString()} + // eslint-disable-next-line @typescript-eslint/no-explicit-any + renderScrollComponent={(props: any) => { return ( ({ export const DraggableFlatListCellRenderer = function DraggableFlatListCellRenderer( props: CellRendererProps ) { - const { item, children } = props; + const { item, children, ...otherProps } = props; return ( - + {children} ); From 71d6b46f3fafa6376f4f29647b53964206346ac1 Mon Sep 17 00:00:00 2001 From: Kane Thomas Date: Wed, 11 Dec 2024 12:03:49 -0500 Subject: [PATCH 03/54] fix layout/spacing issues --- src/components/change-wallet/WalletList.tsx | 8 +- .../components/DraggableFlatList.tsx | 5 +- .../change-wallet/ChangeWalletSheet.tsx | 107 +++++++++--------- .../change-wallet/PinnedWalletsGrid.tsx | 39 +------ .../change-wallet/SelectedAddressBadge.tsx | 9 +- src/state/wallets/pinnedWalletsStore.ts | 2 +- 6 files changed, 72 insertions(+), 98 deletions(-) diff --git a/src/components/change-wallet/WalletList.tsx b/src/components/change-wallet/WalletList.tsx index c6654341b43..e8fb003da47 100644 --- a/src/components/change-wallet/WalletList.tsx +++ b/src/components/change-wallet/WalletList.tsx @@ -107,10 +107,10 @@ export function WalletList({ walletItems, contextMenuActions, editMode, onChange )} {pinnedWalletItems.length > 0 && unpinnedWalletItems.length > 0 && ( <> - + - + {'All Wallets'} @@ -131,9 +131,11 @@ export function WalletList({ walletItems, contextMenuActions, editMode, onChange ( ({ (offset: number) => { 'worklet'; - // round to the nearest 0.5 to make scrolling smoother - const smoothedOffset = Math.round(offset * 2) / 2; + // round to the nearest integer to make scrolling smoother + const smoothedOffset = Math.round(offset); const { value: activeId } = draggableActiveId; @@ -256,6 +256,7 @@ export const DraggableFlatList = ({ onContentSizeChange={(_, height) => { contentHeight.value = height; }} + // TODO: implement // onViewableItemsChanged={onViewableItemsChanged} ref={animatedFlatListRef} removeClippedSubviews={false} diff --git a/src/screens/change-wallet/ChangeWalletSheet.tsx b/src/screens/change-wallet/ChangeWalletSheet.tsx index 88f94df78f4..85b99eb1c12 100644 --- a/src/screens/change-wallet/ChangeWalletSheet.tsx +++ b/src/screens/change-wallet/ChangeWalletSheet.tsx @@ -26,11 +26,12 @@ import { remotePromoSheetsStore } from '@/state/remotePromoSheets/remotePromoShe import { RootStackParamList } from '@/navigation/types'; import { address } from '@/utils/abbreviations'; import { removeFirstEmojiFromString } from '@/helpers/emojiHandler'; -import { Box, Stack, Text } from '@/design-system'; +import { Box, globalColors, Stack, Text } from '@/design-system'; import { addDisplay } from '@/helpers/utilities'; import { useSharedValue } from 'react-native-reanimated'; import { Panel, TapToDismiss } from '@/components/SmoothPager/ListPanel'; -import { SheetHandle } from '@/components/sheet'; +import { SheetHandle, SheetHandleFixedToTop } from '@/components/sheet'; +import { EasingGradient } from '@/components/easing-gradient/EasingGradient'; const LIST_PADDING_BOTTOM = 6; const MAX_LIST_HEIGHT = DEVICE_HEIGHT - 220; @@ -41,7 +42,7 @@ const WATCH_ONLY_BOTTOM_PADDING = IS_ANDROID ? 20 : 0; const PANEL_BOTTOM_OFFSET = 41; export const MAX_PANEL_HEIGHT = 640; -export const PANEL_HEADER_HEIGHT = 64; +export const PANEL_HEADER_HEIGHT = 58; export const FOOTER_HEIGHT = 91; const RowTypes = { @@ -456,65 +457,63 @@ export default function ChangeWalletSheet() { ]} > - - - {/* TODO: align with design spec */} - - - - - - {'Wallets'} - - - - - {editMode ? i18n.t(i18n.l.button.done) : i18n.t(i18n.l.button.edit)} - - - + + + + + {'Wallets'} + + {/* TODO: this positioning is jank */} + + + + {editMode ? i18n.t(i18n.l.button.done) : i18n.t(i18n.l.button.edit)} + + - {/* TODO: why is this here? */} - {IS_ANDROID && } - - - - {/* TODO: progressive blurview */} - + + {/* TODO: why is this here? */} + {IS_ANDROID && } + + + + {/* TODO: progressive blurview on iOS */} + - - - {'Total Balance'} - - - {ownedWalletsTotalBalance} - - + {!editMode ? ( + + + {'Total Balance'} + + + {ownedWalletsTotalBalance} + + + ) : null} - - - No pinned wallets yet - - - Pin your favorite wallets here for quick access at the top of your list - - - - ); -} - type PinnedWalletsGridProps = { walletItems: AddressItem[]; onPress: (walletId: string, address: string) => void; @@ -53,13 +28,12 @@ export function PinnedWalletsGrid({ walletItems, onPress, editMode }: PinnedWall const onGridOrderChange: DraggableGridProps['onOrderChange'] = useCallback( (value: UniqueIdentifier[]) => { - reorderPinnedAddresses(value as string[]); + // TODO: once upstream dnd is integrated + // reorderPinnedAddresses(value as string[]); }, [reorderPinnedAddresses] ); - const gridKey = useMemo(() => walletItems.map(item => item.address).join('-'), [walletItems]); - const fillerItems = useMemo(() => { const itemsInLastRow = walletItems.length % PINS_PER_ROW; return Array.from({ length: itemsInLastRow === 0 ? 0 : PINS_PER_ROW - itemsInLastRow }); @@ -69,18 +43,17 @@ export function PinnedWalletsGrid({ walletItems, onPress, editMode }: PinnedWall const avatarSize = MAX_AVATAR_SIZE; return ( - + {walletItems.length > 0 ? ( {walletItems.map(account => ( diff --git a/src/screens/change-wallet/SelectedAddressBadge.tsx b/src/screens/change-wallet/SelectedAddressBadge.tsx index 19366df3484..e2de797f55b 100644 --- a/src/screens/change-wallet/SelectedAddressBadge.tsx +++ b/src/screens/change-wallet/SelectedAddressBadge.tsx @@ -1,6 +1,5 @@ import React from 'react'; -import { Bleed, Box } from '@/design-system'; -import { Icon } from '@/components/icons'; +import { Box, TextIcon } from '@/design-system'; export function SelectedAddressBadge({ size = 22 }: { size?: number }) { return ( @@ -12,10 +11,10 @@ export function SelectedAddressBadge({ size = 22 }: { size?: number }) { alignItems="center" justifyContent="center" shadow="12px blue" - left="0px" - padding="4px" > - + + 􀆅 + ); } diff --git a/src/state/wallets/pinnedWalletsStore.ts b/src/state/wallets/pinnedWalletsStore.ts index 73cc8a8ffa9..d131281cf3b 100644 --- a/src/state/wallets/pinnedWalletsStore.ts +++ b/src/state/wallets/pinnedWalletsStore.ts @@ -1,7 +1,7 @@ import { createRainbowStore } from '@/state/internal/createRainbowStore'; const MIN_WALLETS_TO_SHOW_PINNING = 6; -const MAX_PINNED_WALLETS = 12; +const MAX_PINNED_WALLETS = 6; // TODO: fix type Address = string; From e8fc4f8852646862bd60e8f7be31a7f449f40729 Mon Sep 17 00:00:00 2001 From: Kane Thomas Date: Wed, 11 Dec 2024 14:16:06 -0500 Subject: [PATCH 04/54] pinned wallet grid edit mode jiggle animation --- src/components/animations/JiggleAnimation.tsx | 59 +++++++++++++ .../change-wallet/PinnedWalletsGrid.tsx | 84 ++++++++++--------- 2 files changed, 104 insertions(+), 39 deletions(-) create mode 100644 src/components/animations/JiggleAnimation.tsx diff --git a/src/components/animations/JiggleAnimation.tsx b/src/components/animations/JiggleAnimation.tsx new file mode 100644 index 00000000000..5c34048e1ff --- /dev/null +++ b/src/components/animations/JiggleAnimation.tsx @@ -0,0 +1,59 @@ +import React, { useEffect } from 'react'; +import Animated, { + useSharedValue, + useAnimatedStyle, + withTiming, + withRepeat, + cancelAnimation, + useAnimatedReaction, + SharedValue, + withSequence, +} from 'react-native-reanimated'; + +type JiggleAnimationProps = { + amplitude?: number; + duration?: number; + children: React.ReactNode; + enabled: boolean | SharedValue; +}; + +export function JiggleAnimation({ children, amplitude = 2, duration = 125, enabled }: JiggleAnimationProps) { + const rotation = useSharedValue(0); + const internalEnabled = useSharedValue(typeof enabled === 'boolean' ? enabled : false); + + // slightly randomize duration to avoid sync with other jiggles + const instanceDuration = duration * (1 + (Math.random() - 0.5) * 0.2); // 10% variance + // randomize initial rotation direction to avoid sync with other jiggles + const initialRotation = Math.random() * amplitude; + + useEffect(() => { + if (typeof enabled === 'boolean') { + internalEnabled.value = enabled; + } + }, [enabled, internalEnabled]); + + useAnimatedReaction( + () => { + return typeof enabled === 'boolean' ? internalEnabled.value : (enabled as SharedValue).value; + }, + enabled => { + if (enabled) { + rotation.value = withSequence( + withTiming(initialRotation, { duration: instanceDuration / 2 }), + withRepeat(withTiming(-amplitude, { duration: instanceDuration }), -1, true) + ); + } else { + cancelAnimation(rotation); + rotation.value = withTiming(0, { duration: instanceDuration / 2 }); + } + } + ); + + const animatedStyle = useAnimatedStyle(() => { + return { + transform: [{ rotate: `${rotation.value}deg` }], + }; + }); + + return {children}; +} diff --git a/src/screens/change-wallet/PinnedWalletsGrid.tsx b/src/screens/change-wallet/PinnedWalletsGrid.tsx index 3734235b2a8..4379bff1c86 100644 --- a/src/screens/change-wallet/PinnedWalletsGrid.tsx +++ b/src/screens/change-wallet/PinnedWalletsGrid.tsx @@ -10,6 +10,7 @@ import { BlurView } from '@react-native-community/blur'; import { usePinnedWalletsStore } from '@/state/wallets/pinnedWalletsStore'; import { View } from 'react-native'; import { SelectedAddressBadge } from './SelectedAddressBadge'; +import { JiggleAnimation } from '@/components/animations/JiggleAnimation'; const UNPIN_BADGE_SIZE = 28; const PINS_PER_ROW = 3; @@ -60,49 +61,54 @@ export function PinnedWalletsGrid({ walletItems, onPress, editMode }: PinnedWall - - onPress(account.walletId, account.address)} scaleTo={0.8}> - - - - {account.isSelected && ( - - - - )} - - {editMode && ( - removePinnedAddress(account.address)}> + + + onPress(account.walletId, account.address)} scaleTo={0.8}> - - {'􀅽'} - + + {account.isSelected && ( + + + + )} - )} - + {editMode && ( + removePinnedAddress(account.address)}> + + + {'􀅽'} + + + + )} + + {account.isLedger && ( From 2fa3f663a0fb50f3703d481ce6827a43734e0ce3 Mon Sep 17 00:00:00 2001 From: Kane Thomas Date: Thu, 12 Dec 2024 12:56:15 -0500 Subject: [PATCH 05/54] add long press option and optional callback data to dropdown menu component --- src/components/DropdownMenu.tsx | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/src/components/DropdownMenu.tsx b/src/components/DropdownMenu.tsx index 8d2c6572e7c..98ef68e4c5d 100644 --- a/src/components/DropdownMenu.tsx +++ b/src/components/DropdownMenu.tsx @@ -43,10 +43,12 @@ export type MenuConfig = Omit<_MenuConfig, 'menuItems' | 'menu menuItems: Array>; }; -type DropDownMenuProps = { +type DropDownMenuProps = never> = { children: React.ReactElement; menuConfig: MenuConfig; onPressMenuItem: (actionKey: T) => void; + triggerAction?: 'press' | 'longPress'; + data?: U; } & DropdownMenuContentProps; const buildIconConfig = (icon?: MenuItemIcon) => { @@ -65,27 +67,33 @@ const buildIconConfig = (icon?: MenuItemIcon) => { return null; }; -export function DropdownMenu({ +export function DropdownMenu = never>({ children, menuConfig, onPressMenuItem, + data, loop = true, align = 'end', sideOffset = 8, side = 'right', alignOffset = 5, avoidCollisions = true, -}: DropDownMenuProps) { + triggerAction = 'press', +}: DropDownMenuProps) { const handleSelectItem = useCallback( (actionKey: T) => { - onPressMenuItem(actionKey); + if (data !== undefined) { + (onPressMenuItem as (actionKey: T, data: U) => void)(actionKey, data); + } else { + (onPressMenuItem as (actionKey: T) => void)(actionKey); + } }, - [onPressMenuItem] + [onPressMenuItem, data] ); return ( - {children} + {children} Date: Thu, 12 Dec 2024 17:39:44 -0500 Subject: [PATCH 06/54] address context menu on long press, refactor context menu to use new dropdown component --- src/components/DropdownMenu.tsx | 8 +- src/components/change-wallet/AddressRow.tsx | 154 ++++---------- src/components/change-wallet/WalletList.tsx | 27 ++- .../change-wallet/ChangeWalletSheet.tsx | 188 ++++++++++++++---- .../change-wallet/PinnedWalletsGrid.tsx | 131 +++++++----- 5 files changed, 290 insertions(+), 218 deletions(-) diff --git a/src/components/DropdownMenu.tsx b/src/components/DropdownMenu.tsx index 98ef68e4c5d..3c54359561e 100644 --- a/src/components/DropdownMenu.tsx +++ b/src/components/DropdownMenu.tsx @@ -35,6 +35,7 @@ export type MenuItemIcon = Omit & (MenuIte export type MenuItem = Omit & { actionKey: T; actionTitle: string; + destructive?: boolean; icon?: MenuItemIcon | { iconType: string; iconValue: string }; }; @@ -114,7 +115,12 @@ export function DropdownMenu const Icon = buildIconConfig(item.icon as MenuItemIcon); return ( - handleSelectItem(item.actionKey)}> + handleSelectItem(item.actionKey)} + > {item.actionTitle} {Icon} diff --git a/src/components/change-wallet/AddressRow.tsx b/src/components/change-wallet/AddressRow.tsx index cb6debcbcda..6fe595e9d84 100644 --- a/src/components/change-wallet/AddressRow.tsx +++ b/src/components/change-wallet/AddressRow.tsx @@ -1,22 +1,20 @@ import * as i18n from '@/languages'; -import React, { useCallback, useMemo } from 'react'; +import React, { useMemo } from 'react'; import LinearGradient from 'react-native-linear-gradient'; import { useTheme } from '../../theme/ThemeContext'; import { ButtonPressAnimation } from '../animations'; import ConditionalWrap from 'conditional-wrap'; -import ContextMenuButton from '@/components/native-context-menu/contextMenu'; -import useExperimentalFlag, { NOTIFICATIONS } from '@/config/experimentalHooks'; -import { IS_IOS } from '@/env'; -import { ContextMenu } from '../context-menu'; import { Box, Column, Columns, Inline, Stack, Text, Inset, useForegroundColor, useColorMode, TextIcon } from '@/design-system'; -import { MenuActionConfig } from 'react-native-ios-context-menu'; -import { AddressItem, EditWalletContextMenuActions } from '@/screens/change-wallet/ChangeWalletSheet'; +import { AddressItem, AddressMenuAction, AddressMenuActionData } from '@/screens/change-wallet/ChangeWalletSheet'; import { TextSize } from '@/design-system/typography/typeHierarchy'; import { TextWeight } from '@/design-system/components/Text/Text'; import { opacity } from '@/__swaps__/utils/swaps'; import { usePinnedWalletsStore } from '@/state/wallets/pinnedWalletsStore'; import { AddressAvatar } from '@/screens/change-wallet/AddressAvatar'; import { SelectedAddressBadge } from '@/screens/change-wallet/SelectedAddressBadge'; +import { DropdownMenu, MenuItem } from '@/components/DropdownMenu'; +import { haptics } from '@/utils'; +import { GestureHandlerButton } from '@/__swaps__/screens/Swap/components/GestureHandlerButton'; const ROW_HEIGHT_WITH_PADDING = 64; @@ -64,24 +62,16 @@ export const AddressRowButton = ({ ); }; - -const ContextMenuKeys = { - Edit: 'edit', - Notifications: 'notifications', - Remove: 'remove', -}; - interface AddressRowProps { - contextMenuActions: EditWalletContextMenuActions; + menuItems: MenuItem[]; + onPressMenuItem: (actionKey: AddressMenuAction, data: { address: string }) => void; data: AddressItem; editMode: boolean; onPress: () => void; } -export function AddressRow({ contextMenuActions, data, editMode, onPress }: AddressRowProps) { - const notificationsEnabled = useExperimentalFlag(NOTIFICATIONS); - - const { address, color, secondaryLabel, isSelected, isReadOnly, isLedger, label: walletName, walletId, image } = data; +export function AddressRow({ data, editMode, onPress, menuItems, onPressMenuItem }: AddressRowProps) { + const { address, color, secondaryLabel, isSelected, isReadOnly, isLedger, label: walletName, image } = data; const addPinnedAddress = usePinnedWalletsStore(state => state.addPinnedAddress); @@ -103,90 +93,28 @@ export function AddressRow({ contextMenuActions, data, editMode, onPress }: Addr [colors, isDarkMode] ); - const contextMenuItems = [ - { - actionKey: ContextMenuKeys.Edit, - actionTitle: i18n.t(i18n.l.wallet.action.edit), - icon: { - iconType: 'SYSTEM', - iconValue: 'pencil', - }, - }, - - ...(notificationsEnabled - ? ([ - { - actionKey: ContextMenuKeys.Notifications, - actionTitle: i18n.t(i18n.l.wallet.action.notifications.action_title), - icon: { - iconType: 'SYSTEM', - iconValue: 'bell.fill', - }, - }, - ] as const) - : []), - { - actionKey: ContextMenuKeys.Remove, - actionTitle: i18n.t(i18n.l.wallet.action.remove), - icon: { iconType: 'SYSTEM', iconValue: 'trash.fill' }, - menuAttributes: ['destructive'], - }, - ] satisfies MenuActionConfig[]; - const menuConfig = { - menuItems: contextMenuItems, + menuItems: menuItems, menuTitle: walletName, }; - const handleSelectActionMenuItem = useCallback( - (buttonIndex: number) => { - switch (buttonIndex) { - case 0: - contextMenuActions?.edit(walletId, address); - break; - case 1: - contextMenuActions?.notifications(walletName, address); - break; - case 2: - contextMenuActions?.remove(walletId, address); - break; - default: - break; - } - }, - [contextMenuActions, walletName, walletId, address] - ); - - const handleSelectMenuItem = useCallback( - // @ts-expect-error ContextMenu is an untyped JS component and can't type its onPress handler properly - ({ nativeEvent: { actionKey } }) => { - switch (actionKey) { - case ContextMenuKeys.Remove: - contextMenuActions?.remove(walletId, address); - break; - case ContextMenuKeys.Notifications: - contextMenuActions?.notifications(walletName, address); - break; - case ContextMenuKeys.Edit: - contextMenuActions?.edit(walletId, address); - break; - default: - break; - } - }, - [address, contextMenuActions, walletName, walletId] - ); - return ( - - ( - + ( + + triggerAction="longPress" + menuConfig={menuConfig} + onPressMenuItem={action => onPressMenuItem(action, { address })} + > + {/* TODO: there is some issue with how the dropdown long press interacts with the button long press. Inconsistent behavior. */} + {children} - )} - > + + )} + > + {editMode && ( @@ -225,33 +153,21 @@ export function AddressRow({ contextMenuActions, data, editMode, onPress }: Addr )} {!editMode && isSelected && } {editMode && ( - <> - addPinnedAddress(address)} color={colors.appleBlue} icon="􀎧" size="icon 12px" /> - {IS_IOS ? ( - - - - ) : ( - item.actionTitle)} - isAnchoredToRight - onPressActionSheet={handleSelectActionMenuItem} - > - - - )} - + addPinnedAddress(address)} color={colors.appleBlue} icon="􀎧" size="icon 12px" /> + )} + {editMode && ( + + menuConfig={menuConfig} + onPressMenuItem={action => onPressMenuItem(action, { address })} + > + + )} - - + + ); } diff --git a/src/components/change-wallet/WalletList.tsx b/src/components/change-wallet/WalletList.tsx index e8fb003da47..3864fe1bc16 100644 --- a/src/components/change-wallet/WalletList.tsx +++ b/src/components/change-wallet/WalletList.tsx @@ -8,7 +8,8 @@ import styled from '@/styled-thing'; import { position } from '@/styles'; import { AddressItem, - EditWalletContextMenuActions, + AddressMenuAction, + AddressMenuActionData, FOOTER_HEIGHT, MAX_PANEL_HEIGHT, PANEL_HEADER_HEIGHT, @@ -17,6 +18,7 @@ import { Box, Inset, Separator, Text } from '@/design-system'; import { DndProvider, DraggableFlatList, DraggableFlatListProps, UniqueIdentifier } from '../drag-and-drop'; import { PinnedWalletsGrid } from '@/screens/change-wallet/PinnedWalletsGrid'; import { usePinnedWalletsStore } from '@/state/wallets/pinnedWalletsStore'; +import { MenuItem } from '@/components/DropdownMenu'; const LIST_TOP_PADDING = 7.5; const TRANSITION_DURATION = 75; @@ -33,12 +35,13 @@ const EmptyWalletList = styled(EmptyAssetList).attrs({ interface Props { walletItems: AddressItem[]; - contextMenuActions: EditWalletContextMenuActions; editMode: boolean; - onChangeAccount: (walletId: string, address: EthereumAddress) => void; + menuItems: MenuItem[]; + onPressMenuItem: (actionKey: AddressMenuAction, data: AddressMenuActionData) => void; + onPressAccount: (address: EthereumAddress) => void; } -export function WalletList({ walletItems, contextMenuActions, editMode, onChangeAccount }: Props) { +export function WalletList({ walletItems, menuItems, onPressMenuItem, onPressAccount, editMode }: Props) { const pinnedAddresses = usePinnedWalletsStore(state => state.pinnedAddresses); const unpinnedAddresses = usePinnedWalletsStore(state => state.unpinnedAddresses); @@ -103,7 +106,13 @@ export function WalletList({ walletItems, contextMenuActions, editMode, onChange return ( <> {pinnedWalletItems.length > 0 && ( - + )} {pinnedWalletItems.length > 0 && unpinnedWalletItems.length > 0 && ( <> @@ -119,7 +128,7 @@ export function WalletList({ walletItems, contextMenuActions, editMode, onChange )} ); - }, [pinnedWalletItems, onChangeAccount, editMode, unpinnedWalletItems.length]); + }, [pinnedWalletItems, onPressAccount, editMode, unpinnedWalletItems.length, menuItems, onPressMenuItem]); return ( @@ -135,13 +144,13 @@ export function WalletList({ walletItems, contextMenuActions, editMode, onChange contentContainerStyle={{ paddingBottom: FOOTER_HEIGHT - 24 }} data={unpinnedWalletItems} ListHeaderComponent={renderHeader} - automaticallyAdjustContentInsets={false} renderItem={({ item }) => ( onChangeAccount(item.walletId, item.id)} + onPress={() => onPressAccount(item.address)} /> )} /> diff --git a/src/screens/change-wallet/ChangeWalletSheet.tsx b/src/screens/change-wallet/ChangeWalletSheet.tsx index 85b99eb1c12..356955bc003 100644 --- a/src/screens/change-wallet/ChangeWalletSheet.tsx +++ b/src/screens/change-wallet/ChangeWalletSheet.tsx @@ -1,21 +1,21 @@ import { RouteProp, useRoute } from '@react-navigation/native'; import * as i18n from '@/languages'; -import React, { useCallback, useMemo, useRef, useState } from 'react'; -import { Alert, InteractionManager, View, StyleSheet, StatusBar, NativeSyntheticEvent, NativeScrollEvent } from 'react-native'; +import React, { useCallback, useMemo, useState } from 'react'; +import { Alert, InteractionManager, View } from 'react-native'; import ReactNativeHapticFeedback from 'react-native-haptic-feedback'; import { useDispatch } from 'react-redux'; import { ButtonPressAnimation } from '@/components/animations'; import { WalletList } from '@/components/change-wallet/WalletList'; import { removeWalletData } from '../../handlers/localstorage/removeWallet'; -import { cleanUpWalletKeys } from '../../model/wallet'; +import { cleanUpWalletKeys, RainbowWallet } from '../../model/wallet'; import { useNavigation } from '../../navigation/Navigation'; import { addressSetSelected, walletsSetSelected, walletsUpdate } from '../../redux/wallets'; import WalletTypes from '@/helpers/walletTypes'; import { analytics, analyticsV2 } from '@/analytics'; -import { useAccountSettings, useDimensions, useInitializeWallet, useWallets, useWalletsWithBalancesAndNames, useWebData } from '@/hooks'; +import { useAccountSettings, useInitializeWallet, useWallets, useWalletsWithBalancesAndNames, useWebData } from '@/hooks'; import Routes from '@/navigation/routesNames'; import styled from '@/styled-thing'; -import { doesWalletsContainAddress, safeAreaInsetValues, showActionSheetWithOptions } from '@/utils'; +import { doesWalletsContainAddress, showActionSheetWithOptions } from '@/utils'; import { logger, RainbowError } from '@/logger'; import { useTheme } from '@/theme'; import { EthereumAddress } from '@/entities'; @@ -26,12 +26,13 @@ import { remotePromoSheetsStore } from '@/state/remotePromoSheets/remotePromoShe import { RootStackParamList } from '@/navigation/types'; import { address } from '@/utils/abbreviations'; import { removeFirstEmojiFromString } from '@/helpers/emojiHandler'; -import { Box, globalColors, Stack, Text } from '@/design-system'; +import { Box, Stack, Text } from '@/design-system'; import { addDisplay } from '@/helpers/utilities'; -import { useSharedValue } from 'react-native-reanimated'; import { Panel, TapToDismiss } from '@/components/SmoothPager/ListPanel'; -import { SheetHandle, SheetHandleFixedToTop } from '@/components/sheet'; +import { SheetHandleFixedToTop } from '@/components/sheet'; import { EasingGradient } from '@/components/easing-gradient/EasingGradient'; +import { MenuConfig, MenuItem } from '@/components/DropdownMenu'; +import { NOTIFICATIONS, useExperimentalFlag } from '@/config'; const LIST_PADDING_BOTTOM = 6; const MAX_LIST_HEIGHT = DEVICE_HEIGHT - 220; @@ -45,6 +46,18 @@ export const MAX_PANEL_HEIGHT = 640; export const PANEL_HEADER_HEIGHT = 58; export const FOOTER_HEIGHT = 91; +export enum AddressMenuAction { + Edit = 'edit', + Notifications = 'notifications', + Remove = 'remove', + Copy = 'copy', + Settings = 'settings', +} + +export type AddressMenuActionData = { + address: string; +}; + const RowTypes = { ADDRESS: 1, EMPTY: 2, @@ -75,12 +88,6 @@ const getWalletListHeight = (wallets: any, watchOnly: boolean) => { return { listHeight, scrollEnabled: false }; }; -export type EditWalletContextMenuActions = { - edit: (walletId: string, address: EthereumAddress) => void; - notifications: (walletName: string, address: EthereumAddress) => void; - remove: (walletId: string, address: EthereumAddress) => void; -}; - export interface AddressItem { address: EthereumAddress; color: number; @@ -103,6 +110,7 @@ export default function ChangeWalletSheet() { const { onChangeWallet, watchOnly = false, currentAccountAddress } = params; const { selectedWallet, wallets } = useWallets(); + const notificationsEnabled = useExperimentalFlag(NOTIFICATIONS); const { colors } = useTheme(); const { updateWebProfile } = useWebData(); @@ -115,7 +123,18 @@ export default function ChangeWalletSheet() { const [editMode, setEditMode] = useState(false); const [currentAddress, setCurrentAddress] = useState(currentAccountAddress || accountAddress); const [currentSelectedWallet, setCurrentSelectedWallet] = useState(selectedWallet); - const scrollContentOffsetY = useSharedValue(0); + + const walletsByAddress = useMemo(() => { + return Object.values(wallets || {}).reduce( + (acc, wallet) => { + wallet.addresses.forEach(account => { + acc[account.address] = wallet; + }); + return acc; + }, + {} as Record + ); + }, [wallets]); // TODO: maybe wallet accounts is a better name const allWalletItems = useMemo(() => { @@ -410,19 +429,19 @@ export default function ChangeWalletSheet() { [currentAddress, deleteWallet, goBack, navigate, onChangeAccount, wallets] ); - const onPressPairHardwareWallet = useCallback(() => { - analyticsV2.track(analyticsV2.event.addWalletFlowStarted, { - isFirstWallet: false, - type: 'ledger_nano_x', - }); - goBack(); - InteractionManager.runAfterInteractions(() => { - navigate(Routes.PAIR_HARDWARE_WALLET_NAVIGATOR, { - entryPoint: Routes.CHANGE_WALLET_SHEET, - isFirstWallet: false, - }); - }); - }, [goBack, navigate]); + // const onPressPairHardwareWallet = useCallback(() => { + // analyticsV2.track(analyticsV2.event.addWalletFlowStarted, { + // isFirstWallet: false, + // type: 'ledger_nano_x', + // }); + // goBack(); + // InteractionManager.runAfterInteractions(() => { + // navigate(Routes.PAIR_HARDWARE_WALLET_NAVIGATOR, { + // entryPoint: Routes.CHANGE_WALLET_SHEET, + // isFirstWallet: false, + // }); + // }); + // }, [goBack, navigate]); const onPressAddAnotherWallet = useCallback(() => { analyticsV2.track(analyticsV2.event.pressedButton, { @@ -442,6 +461,108 @@ export default function ChangeWalletSheet() { setEditMode(e => !e); }, []); + const onPressAccount = useCallback( + (address: string) => { + const wallet = walletsByAddress[address]; + if (!wallet) { + logger.error(new RainbowError('[ChangeWalletSheet]: No wallet for address found when pressing account'), { + address, + }); + return; + } + onChangeAccount(wallet.id, address); + }, + [onChangeAccount, walletsByAddress] + ); + + const addressMenuConfig = useMemo>(() => { + let menuItems = [ + { + actionKey: AddressMenuAction.Edit, + // TODO: localize + actionTitle: 'Edit Wallet', + icon: { + iconType: 'SYSTEM', + iconValue: 'pencil', + }, + }, + { + actionKey: AddressMenuAction.Copy, + actionTitle: 'Copy Address', + icon: { + iconType: 'SYSTEM', + iconValue: 'doc.fill', + }, + }, + { + actionKey: AddressMenuAction.Settings, + actionTitle: 'Wallet Settings', + icon: { + iconType: 'SYSTEM', + iconValue: 'key.fill', + }, + }, + { + actionKey: AddressMenuAction.Notifications, + actionTitle: 'Notification Settings', + icon: { + iconType: 'SYSTEM', + iconValue: 'bell.fill', + }, + }, + { + actionKey: AddressMenuAction.Remove, + actionTitle: 'Remove Wallet', + destructive: true, + icon: { + iconType: 'SYSTEM', + iconValue: 'trash.fill', + }, + }, + ] satisfies MenuItem[]; + + if (!notificationsEnabled) { + menuItems = menuItems.filter(item => item.actionKey !== AddressMenuAction.Notifications); + } + + return { + menuItems, + }; + }, [notificationsEnabled]); + + const onPressMenuItem = useCallback( + (actionKey: AddressMenuAction, { address }: AddressMenuActionData) => { + const wallet = walletsByAddress[address]; + if (!wallet) { + logger.error(new RainbowError('[ChangeWalletSheet]: No wallet for address found when pressing menu item'), { + // TODO: make sure this is okay to log + address, + actionKey, + }); + // TODO: show user facing error + return; + } + switch (actionKey) { + case AddressMenuAction.Edit: + onPressEdit(wallet.id, address); + break; + case AddressMenuAction.Notifications: + onPressNotifications(wallet.name, address); + break; + case AddressMenuAction.Remove: + onPressRemove(wallet.id, address); + break; + case AddressMenuAction.Settings: + // onPressSettings(address); + break; + case AddressMenuAction.Copy: + // onPressCopy(address); + break; + } + }, + [walletsByAddress, onPressEdit, onPressNotifications, onPressRemove] + ); + return ( <> } diff --git a/src/screens/change-wallet/PinnedWalletsGrid.tsx b/src/screens/change-wallet/PinnedWalletsGrid.tsx index 4379bff1c86..eca4626f7b3 100644 --- a/src/screens/change-wallet/PinnedWalletsGrid.tsx +++ b/src/screens/change-wallet/PinnedWalletsGrid.tsx @@ -3,7 +3,7 @@ import { DndProvider } from '@/components/drag-and-drop/DndProvider'; import { Box, Inline, Stack, Text } from '@/design-system'; import { DEVICE_WIDTH } from '@/utils/deviceUtils'; import React, { useCallback, useMemo } from 'react'; -import { AddressItem } from './ChangeWalletSheet'; +import { AddressItem, AddressMenuAction, AddressMenuActionData } from './ChangeWalletSheet'; import { AddressAvatar } from './AddressAvatar'; import { ButtonPressAnimation } from '@/components/animations'; import { BlurView } from '@react-native-community/blur'; @@ -11,6 +11,8 @@ import { usePinnedWalletsStore } from '@/state/wallets/pinnedWalletsStore'; import { View } from 'react-native'; import { SelectedAddressBadge } from './SelectedAddressBadge'; import { JiggleAnimation } from '@/components/animations/JiggleAnimation'; +import { DropdownMenu, MenuItem } from '@/components/DropdownMenu'; +import ConditionalWrap from 'conditional-wrap'; const UNPIN_BADGE_SIZE = 28; const PINS_PER_ROW = 3; @@ -19,11 +21,13 @@ const HORIZONTAL_PAGE_INSET = 24; type PinnedWalletsGridProps = { walletItems: AddressItem[]; - onPress: (walletId: string, address: string) => void; + onPress: (address: string) => void; + menuItems: MenuItem[]; + onPressMenuItem: (actionKey: AddressMenuAction, data: AddressMenuActionData) => void; editMode: boolean; }; -export function PinnedWalletsGrid({ walletItems, onPress, editMode }: PinnedWalletsGridProps) { +export function PinnedWalletsGrid({ walletItems, onPress, editMode, menuItems, onPressMenuItem }: PinnedWalletsGridProps) { const removePinnedAddress = usePinnedWalletsStore(state => state.removePinnedAddress); const reorderPinnedAddresses = usePinnedWalletsStore(state => state.reorderPinnedAddresses); @@ -46,7 +50,7 @@ export function PinnedWalletsGrid({ walletItems, onPress, editMode }: PinnedWall return ( {walletItems.length > 0 ? ( - + {walletItems.map(account => ( - - - - - - onPress(account.walletId, account.address)} scaleTo={0.8}> + + ( + + triggerAction="longPress" + menuConfig={{ menuItems, menuTitle: account.label }} + onPressMenuItem={action => onPressMenuItem(action, { address: account.address })} + > + {/* TODO: there is some issue with how the dropdown long press interacts with the button long press. Inconsistent behavior. */} + onPress(account.address)} minLongPressDuration={150}> + {children} + + + )} + > + + + + )} - - {editMode && ( - removePinnedAddress(account.address)}> - - - {'􀅽'} - - - + {/* */} + {editMode && ( + removePinnedAddress(account.address)}> + + + {'􀅽'} + + + + )} + + + + {account.isLedger && ( + + 􀤃 + )} - - - - {account.isLedger && ( - - 􀤃 - - )} - {account.isReadOnly && ( - - 􀋮 + {account.isReadOnly && ( + + 􀋮 + + )} + + {account.label} - )} - - {account.label} + + + {account.secondaryLabel} - - - {account.secondaryLabel} - - - + + + ))} {fillerItems.map((_, index) => ( From 4b8b9895dde5d8641c4381a1a814c48fe075a454 Mon Sep 17 00:00:00 2001 From: Kane Thomas Date: Fri, 13 Dec 2024 10:13:02 -0500 Subject: [PATCH 07/54] add drag handler icon --- src/components/icons/Icon.js | 2 ++ src/components/icons/svg/DragHandlerIcon.tsx | 16 ++++++++++++++++ 2 files changed, 18 insertions(+) create mode 100644 src/components/icons/svg/DragHandlerIcon.tsx diff --git a/src/components/icons/Icon.js b/src/components/icons/Icon.js index 0afb3610555..369ecd9cff7 100644 --- a/src/components/icons/Icon.js +++ b/src/components/icons/Icon.js @@ -91,6 +91,7 @@ import WalletSwitcherCaret from './svg/WalletSwitcherCaret'; import WarningCircledIcon from './svg/WarningCircledIcon'; import WarningIcon from './svg/WarningIcon'; import BridgeIcon from './svg/BridgeIcon'; +import { DragHandlerIcon } from './svg/DragHandlerIcon'; const IconTypes = { applePay: ApplePayIcon, @@ -118,6 +119,7 @@ const IconTypes = { dogeCoin: DOGEIcon, dot: DotIcon, doubleCaret: DoubleCaretIcon, + dragHandler: DragHandlerIcon, emojiActivities: EmojiActivitiesIcon, emojiAnimals: EmojiAnimalsIcon, emojiFlags: EmojiFlagsIcon, diff --git a/src/components/icons/svg/DragHandlerIcon.tsx b/src/components/icons/svg/DragHandlerIcon.tsx new file mode 100644 index 00000000000..23a83ea9d13 --- /dev/null +++ b/src/components/icons/svg/DragHandlerIcon.tsx @@ -0,0 +1,16 @@ +import React from 'react'; +import { Circle, SvgProps } from 'react-native-svg'; +import Svg from '../Svg'; + +export function DragHandlerIcon({ color, ...props }: SvgProps) { + return ( + + + + + + + + + ); +} From 63d9683f8ede8499c3430b15d95511fab56c1ca2 Mon Sep 17 00:00:00 2001 From: Kane Thomas Date: Fri, 13 Dec 2024 10:15:38 -0500 Subject: [PATCH 08/54] selected wallet shadow, watching & hw wallet badge fixes --- src/components/change-wallet/AddressRow.tsx | 63 ++++++++++++------- .../change-wallet/ChangeWalletSheet.tsx | 4 +- .../change-wallet/PinnedWalletsGrid.tsx | 49 ++++++++++++--- .../change-wallet/SelectedAddressBadge.tsx | 5 +- 4 files changed, 90 insertions(+), 31 deletions(-) diff --git a/src/components/change-wallet/AddressRow.tsx b/src/components/change-wallet/AddressRow.tsx index 6fe595e9d84..fcbbf32635a 100644 --- a/src/components/change-wallet/AddressRow.tsx +++ b/src/components/change-wallet/AddressRow.tsx @@ -13,8 +13,7 @@ import { usePinnedWalletsStore } from '@/state/wallets/pinnedWalletsStore'; import { AddressAvatar } from '@/screens/change-wallet/AddressAvatar'; import { SelectedAddressBadge } from '@/screens/change-wallet/SelectedAddressBadge'; import { DropdownMenu, MenuItem } from '@/components/DropdownMenu'; -import { haptics } from '@/utils'; -import { GestureHandlerButton } from '@/__swaps__/screens/Swap/components/GestureHandlerButton'; +import { Icon } from '../icons'; const ROW_HEIGHT_WITH_PADDING = 64; @@ -81,10 +80,12 @@ export function AddressRow({ data, editMode, onPress, menuItems, onPressMenuItem () => ({ pointerEvents: 'none' as const, style: { - borderRadius: 12, - height: 22, + borderRadius: 22, justifyContent: 'center', paddingHorizontal: 8, + paddingVertical: 6, + borderWidth: 1, + borderColor: colors.alpha('#F5F8FF', 0.03), } as const, colors: [colors.alpha(colors.blueGreyDark, 0.03), colors.alpha(colors.blueGreyDark, isDarkMode ? 0.02 : 0.06)], end: { x: 1, y: 1 }, @@ -119,16 +120,21 @@ export function AddressRow({ data, editMode, onPress, menuItems, onPressMenuItem {editMode && ( - - 􀍠 - + {/* Fix on light mode */} + )} - + {walletName} @@ -137,21 +143,36 @@ export function AddressRow({ data, editMode, onPress, menuItems, onPressMenuItem - {isReadOnly && ( - - - {i18n.t(i18n.l.wallet.change_wallet.watching)} + {isReadOnly && + (!editMode ? ( + + + {i18n.t(i18n.l.wallet.change_wallet.watching)} + + + ) : ( + + 􀋮 - - )} - {isLedger && ( - - - {i18n.t(i18n.l.wallet.change_wallet.ledger)} + ))} + {isLedger && + (!editMode ? ( + + + + 􀤃 + + + {i18n.t(i18n.l.wallet.change_wallet.ledger)} + + + + ) : ( + + 􀤃 - - )} - {!editMode && isSelected && } + ))} + {!editMode && isSelected && } {editMode && ( addPinnedAddress(address)} color={colors.appleBlue} icon="􀎧" size="icon 12px" /> )} diff --git a/src/screens/change-wallet/ChangeWalletSheet.tsx b/src/screens/change-wallet/ChangeWalletSheet.tsx index 356955bc003..aeafd4bd175 100644 --- a/src/screens/change-wallet/ChangeWalletSheet.tsx +++ b/src/screens/change-wallet/ChangeWalletSheet.tsx @@ -629,7 +629,9 @@ export default function ChangeWalletSheet() { {ownedWalletsTotalBalance} - ) : null} + ) : ( + + )} {walletItems.length > 0 ? ( - + onPressMenuItem(action, { address: account.address })} > {/* TODO: there is some issue with how the dropdown long press interacts with the button long press. Inconsistent behavior. */} - onPress(account.address)} minLongPressDuration={150}> + onPress(account.address)} + minLongPressDuration={150} + > {children} @@ -92,10 +97,41 @@ export function PinnedWalletsGrid({ walletItems, onPress, editMode, menuItems, o shouldRasterizeIOS > )} - {/* */} {editMode && ( removePinnedAddress(account.address)}> {account.isLedger && ( - + 􀤃 )} {account.isReadOnly && ( - + 􀋮 )} - + {account.label} diff --git a/src/screens/change-wallet/SelectedAddressBadge.tsx b/src/screens/change-wallet/SelectedAddressBadge.tsx index e2de797f55b..787c348aa1d 100644 --- a/src/screens/change-wallet/SelectedAddressBadge.tsx +++ b/src/screens/change-wallet/SelectedAddressBadge.tsx @@ -1,7 +1,8 @@ import React from 'react'; import { Box, TextIcon } from '@/design-system'; +import { Shadow } from '@/design-system/layout/shadow'; -export function SelectedAddressBadge({ size = 22 }: { size?: number }) { +export function SelectedAddressBadge({ size = 22, shadow = '12px' }: { size?: number; shadow?: Shadow }) { return ( 􀆅 From 54e291159b65ede53b5160ab9c376a7ce6618faf Mon Sep 17 00:00:00 2001 From: Kane Thomas Date: Fri, 13 Dec 2024 10:40:36 -0500 Subject: [PATCH 09/54] fix address label abbreviation & truncation --- src/components/change-wallet/AddressRow.tsx | 8 +- .../change-wallet/ChangeWalletSheet.tsx | 5 +- .../change-wallet/PinnedWalletsGrid.tsx | 179 +++++++++--------- 3 files changed, 100 insertions(+), 92 deletions(-) diff --git a/src/components/change-wallet/AddressRow.tsx b/src/components/change-wallet/AddressRow.tsx index fcbbf32635a..976bc16f567 100644 --- a/src/components/change-wallet/AddressRow.tsx +++ b/src/components/change-wallet/AddressRow.tsx @@ -14,6 +14,8 @@ import { AddressAvatar } from '@/screens/change-wallet/AddressAvatar'; import { SelectedAddressBadge } from '@/screens/change-wallet/SelectedAddressBadge'; import { DropdownMenu, MenuItem } from '@/components/DropdownMenu'; import { Icon } from '../icons'; +import { removeFirstEmojiFromString } from '@/helpers/emojiHandler'; +import { address as abbreviateAddress } from '@/utils/abbreviations'; const ROW_HEIGHT_WITH_PADDING = 64; @@ -70,7 +72,11 @@ interface AddressRowProps { } export function AddressRow({ data, editMode, onPress, menuItems, onPressMenuItem }: AddressRowProps) { - const { address, color, secondaryLabel, isSelected, isReadOnly, isLedger, label: walletName, image } = data; + const { address, color, secondaryLabel, isSelected, isReadOnly, isLedger, label, image } = data; + + const walletName = useMemo(() => { + return removeFirstEmojiFromString(label) || abbreviateAddress(address, 4, 6); + }, [label, address]); const addPinnedAddress = usePinnedWalletsStore(state => state.addPinnedAddress); diff --git a/src/screens/change-wallet/ChangeWalletSheet.tsx b/src/screens/change-wallet/ChangeWalletSheet.tsx index aeafd4bd175..92d58094ebc 100644 --- a/src/screens/change-wallet/ChangeWalletSheet.tsx +++ b/src/screens/change-wallet/ChangeWalletSheet.tsx @@ -156,7 +156,8 @@ export default function ChangeWalletSheet() { color: account.color, editMode, height: WALLET_ROW_HEIGHT, - label: removeFirstEmojiFromString(account.label) || address(account.address, 6, 4), + label: account.label, + // label: removeFirstEmojiFromString(account.label) || address(account.address, 6, 4), // TODO: what does this do? // label: // network !== Network.mainnet && account.ens === account.label @@ -644,7 +645,7 @@ export default function ChangeWalletSheet() { borderColor={{ custom: colors.alpha(colors.appleBlue, 0.06) }} > - {'􀅼 Add'} + {`􀅼 ${i18n.t(i18n.l.button.add)}`} diff --git a/src/screens/change-wallet/PinnedWalletsGrid.tsx b/src/screens/change-wallet/PinnedWalletsGrid.tsx index 718ea8a7ba8..113d60543a6 100644 --- a/src/screens/change-wallet/PinnedWalletsGrid.tsx +++ b/src/screens/change-wallet/PinnedWalletsGrid.tsx @@ -13,6 +13,8 @@ import { SelectedAddressBadge } from './SelectedAddressBadge'; import { JiggleAnimation } from '@/components/animations/JiggleAnimation'; import { DropdownMenu, MenuItem } from '@/components/DropdownMenu'; import ConditionalWrap from 'conditional-wrap'; +import { address } from '@/utils/abbreviations'; +import { removeFirstEmojiFromString } from '@/helpers/emojiHandler'; const UNPIN_BADGE_SIZE = 28; const PINS_PER_ROW = 3; @@ -89,105 +91,104 @@ export function PinnedWalletsGrid({ walletItems, onPress, editMode, menuItems, o )} > - - - + + + - - - - {account.isSelected && ( - - - - )} - {editMode && ( - removePinnedAddress(account.address)}> - - - {'􀅽'} - - - - )} + }, + } + : undefined + } + borderRadius={avatarSize / 2} + borderWidth={account.isSelected ? 4 : undefined} + borderColor={account.isSelected ? { custom: '#268FFF' } : undefined} + > + - - - {account.isLedger && ( - - 􀤃 - + {account.isSelected && ( + + + )} - {account.isReadOnly && ( - - 􀋮 - + {editMode && ( + removePinnedAddress(account.address)}> + + + {'􀅽'} + + + )} - - {account.label} + + + + {account.isLedger && ( + + 􀤃 + + )} + {account.isReadOnly && ( + + 􀋮 - - - {account.secondaryLabel} + )} + + {/* TODO: can ens names have emojis? If so this logic is wrong */} + {removeFirstEmojiFromString(account.label) || address(account.address, 4, 4)} - - + + + {account.secondaryLabel} + + ))} From 2339bd09d0a0cd7961d8faac1163308d2cc3ca9b Mon Sep 17 00:00:00 2001 From: Kane Thomas Date: Fri, 13 Dec 2024 10:52:23 -0500 Subject: [PATCH 10/54] fix total owned wallet balance formatting --- src/screens/change-wallet/ChangeWalletSheet.tsx | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/src/screens/change-wallet/ChangeWalletSheet.tsx b/src/screens/change-wallet/ChangeWalletSheet.tsx index 92d58094ebc..f558732360f 100644 --- a/src/screens/change-wallet/ChangeWalletSheet.tsx +++ b/src/screens/change-wallet/ChangeWalletSheet.tsx @@ -27,7 +27,7 @@ import { RootStackParamList } from '@/navigation/types'; import { address } from '@/utils/abbreviations'; import { removeFirstEmojiFromString } from '@/helpers/emojiHandler'; import { Box, Stack, Text } from '@/design-system'; -import { addDisplay } from '@/helpers/utilities'; +import { addDisplay, convertAmountToNativeDisplay } from '@/helpers/utilities'; import { Panel, TapToDismiss } from '@/components/SmoothPager/ListPanel'; import { SheetHandleFixedToTop } from '@/components/sheet'; import { EasingGradient } from '@/components/easing-gradient/EasingGradient'; @@ -114,7 +114,7 @@ export default function ChangeWalletSheet() { const { colors } = useTheme(); const { updateWebProfile } = useWebData(); - const { accountAddress, network } = useAccountSettings(); + const { accountAddress, network, nativeCurrency, nativeCurrencySymbol } = useAccountSettings(); const { goBack, navigate, setParams } = useNavigation(); const dispatch = useDispatch(); const initializeWallet = useInitializeWallet(); @@ -157,12 +157,6 @@ export default function ChangeWalletSheet() { editMode, height: WALLET_ROW_HEIGHT, label: account.label, - // label: removeFirstEmojiFromString(account.label) || address(account.address, 6, 4), - // TODO: what does this do? - // label: - // network !== Network.mainnet && account.ens === account.label - // ? address(account.address, 6, 4) - // : removeFirstEmojiFromString(account.label), secondaryLabel: balanceText, isOnlyAddress: visibleAccounts.length === 1, isLedger: wallet.type === WalletTypes.bluetooth, @@ -211,8 +205,8 @@ export default function ChangeWalletSheet() { if (isLoadingBalance) return i18n.t(i18n.l.wallet.change_wallet.loading_balance); - return totalBalance; - }, [walletsWithBalancesAndNames]); + return convertAmountToNativeDisplay(totalBalance, nativeCurrency); + }, [walletsWithBalancesAndNames, nativeCurrency]); const onChangeAccount = useCallback( async (walletId: string, address: string, fromDeletion = false) => { From b85e0c72768f958bad7228b31e5f0ad448092566 Mon Sep 17 00:00:00 2001 From: Kane Thomas Date: Fri, 13 Dec 2024 12:32:04 -0500 Subject: [PATCH 11/54] fix autoscrolling can scroll logic & add inset behavior --- src/components/change-wallet/WalletList.tsx | 6 +- .../components/DraggableFlatList.tsx | 114 +++++++++++++++--- 2 files changed, 99 insertions(+), 21 deletions(-) diff --git a/src/components/change-wallet/WalletList.tsx b/src/components/change-wallet/WalletList.tsx index 3864fe1bc16..c404678b50d 100644 --- a/src/components/change-wallet/WalletList.tsx +++ b/src/components/change-wallet/WalletList.tsx @@ -140,10 +140,14 @@ export function WalletList({ walletItems, menuItems, onPressMenuItem, onPressAcc ( = AnimatedProps>>; @@ -28,11 +32,19 @@ export type ViewableRange = { last: number | null; }; +type DraggableProps = ComponentProps; +type DraggablePropsWithoutId = Omit; + export type DraggableFlatListProps = AnimatedFlatListProps & Pick & { debug?: boolean; gap?: number; horizontal?: boolean; + draggableProps?: DraggablePropsWithoutId; + autoScrollInsets?: { + top?: number; + bottom?: number; + }; }; function normalizeWorklet(value: number, fromMin: number, fromMax: number, toMin: number, toMax: number) { @@ -40,6 +52,36 @@ function normalizeWorklet(value: number, fromMin: number, fromMax: number, toMin return ((value - fromMin) * (toMax - toMin)) / (fromMax - fromMin) + toMin; } +const canScrollToWorklet = ({ + newOffset, + contentHeight, + layoutHeight, + currentOffset = 0, +}: { + newOffset: number; + contentHeight: number; + layoutHeight: number; + currentOffset: number; +}) => { + 'worklet'; + + const maxOffset = contentHeight - layoutHeight; + + if (newOffset < 0) { + return false; + } + + if (newOffset > maxOffset) { + return false; + } + + if (newOffset === currentOffset) { + return false; + } + + return true; +}; + export const DraggableFlatList = ({ data, debug, @@ -50,13 +92,15 @@ export const DraggableFlatList = ({ onOrderUpdate, renderItem, shouldSwapWorklet = swapByItemCenterPoint, + draggableProps, + autoScrollInsets, ...otherProps }: DraggableFlatListProps): ReactElement => { const { draggableActiveId, draggableContentOffset, draggableLayouts, draggableOffsets, draggableRestingOffsets, draggableActiveLayout } = useDndContext(); const animatedFlatListRef = useAnimatedRef>(); const contentHeight = useSharedValue(0); - const visibleHeight = useSharedValue(0); + const layoutHeight = useSharedValue(0); const scrollOffset = useSharedValue(0); // @ts-expect-error TODO: fix @@ -95,17 +139,20 @@ export const DraggableFlatList = ({ const activeLayout = layouts[activeId].value; const activeOffset = offsets[activeId]; - // if we have reached the bottom of the list, stop scrolling - if (draggableActiveLayout.value && draggableActiveLayout.value.y + smoothedOffset > contentHeight.value - AUTOSCROLL_THRESHOLD) { - return; - } + const newOffset = scrollOffset.value + smoothedOffset; - // if we have reached the top of the list, stop scrolling - if (scrollOffset.value + smoothedOffset < 0) { + if ( + !canScrollToWorklet({ + newOffset: newOffset, + contentHeight: contentHeight.value, + layoutHeight: layoutHeight.value, + currentOffset: scrollOffset.value, + }) + ) { return; } - scrollTo(animatedFlatListRef, 0, scrollOffset.value + smoothedOffset, false); + scrollTo(animatedFlatListRef, 0, newOffset, false); activeOffset.y.value += smoothedOffset; @@ -115,7 +162,16 @@ export const DraggableFlatList = ({ }); } }, - [draggableActiveId, draggableLayouts, draggableOffsets, draggableActiveLayout, contentHeight, animatedFlatListRef, scrollOffset] + [ + draggableActiveId, + draggableLayouts, + draggableOffsets, + scrollOffset, + contentHeight, + layoutHeight, + animatedFlatListRef, + draggableActiveLayout, + ] ); // Track sort order changes and update the offsets @@ -174,7 +230,7 @@ export const DraggableFlatList = ({ [] ); - /* ⚠️ TODO: Expose visible range to the parent */ + /* ⚠️ IMPROVEMENT: Optionally expose visible range to the parent */ // const viewableRange = useSharedValue({ // first: null, // last: null, @@ -201,30 +257,44 @@ export const DraggableFlatList = ({ activeItemY => { if (activeItemY === undefined) return; - const bottomThreshold = scrollOffset.value + visibleHeight.value - AUTOSCROLL_THRESHOLD; + const bottomThreshold = scrollOffset.value + layoutHeight.value - AUTOSCROLL_THRESHOLD - (autoScrollInsets?.bottom ?? 0); const isNearBottom = activeItemY >= bottomThreshold; - const topThreshold = scrollOffset.value + AUTOSCROLL_THRESHOLD; + const topThreshold = scrollOffset.value + AUTOSCROLL_THRESHOLD + (autoScrollInsets?.top ?? 0); const isNearTop = activeItemY <= topThreshold; if (isNearTop) { const distanceFromTopThreshold = topThreshold - activeItemY; - const scrollSpeed = normalizeWorklet(distanceFromTopThreshold, 0, AUTOSCROLL_THRESHOLD, AUTOSCROLL_MIN_SPEED, AUTOSCROLL_MAX_SPEED); + const scrollSpeed = normalizeWorklet( + distanceFromTopThreshold, + 0, + AUTOSCROLL_THRESHOLD_MAX_DISTANCE, + AUTOSCROLL_MIN_SPEED, + AUTOSCROLL_MAX_SPEED + ); autoscroll(-scrollSpeed); } else if (isNearBottom) { const distanceFromBottomThreshold = activeItemY - bottomThreshold; + const scrollSpeed = normalizeWorklet( distanceFromBottomThreshold, 0, - AUTOSCROLL_THRESHOLD, + AUTOSCROLL_THRESHOLD_MAX_DISTANCE, AUTOSCROLL_MIN_SPEED, AUTOSCROLL_MAX_SPEED ); + + console.log('scrollSpeed', scrollSpeed, distanceFromBottomThreshold); autoscroll(scrollSpeed); } } ); + const CellRenderer = useCallback( + (cellProps: CellRendererProps) => , + [draggableProps] + ); + /** 🛠️ DEBUGGING */ // useAnimatedReaction( // () => { @@ -247,16 +317,16 @@ export const DraggableFlatList = ({ return ( { - visibleHeight.value = event.nativeEvent.layout.height; + layoutHeight.value = event.nativeEvent.layout.height; }} onContentSizeChange={(_, height) => { contentHeight.value = height; }} - // TODO: implement + // IMPROVEMENT: optionally implement // onViewableItemsChanged={onViewableItemsChanged} ref={animatedFlatListRef} removeClippedSubviews={false} @@ -278,12 +348,16 @@ export const DraggableFlatList = ({ ); }; +type DraggableCellRendererProps = CellRendererProps & { + draggableProps?: DraggablePropsWithoutId; +}; + export const DraggableFlatListCellRenderer = function DraggableFlatListCellRenderer( - props: CellRendererProps + props: DraggableCellRendererProps ) { - const { item, children, ...otherProps } = props; + const { item, children, draggableProps, ...otherProps } = props; return ( - + {children} ); From 5134fcc97b90eb0faff7899de9c460b5771f512a Mon Sep 17 00:00:00 2001 From: Kane Thomas Date: Sat, 14 Dec 2024 13:46:26 -0500 Subject: [PATCH 12/54] general purpose feature hint tooltip component --- .../tooltips/FeatureHintTooltip.tsx | 327 ++++++++++++++++++ 1 file changed, 327 insertions(+) create mode 100644 src/components/tooltips/FeatureHintTooltip.tsx diff --git a/src/components/tooltips/FeatureHintTooltip.tsx b/src/components/tooltips/FeatureHintTooltip.tsx new file mode 100644 index 00000000000..81f15b73643 --- /dev/null +++ b/src/components/tooltips/FeatureHintTooltip.tsx @@ -0,0 +1,327 @@ +import React, { forwardRef, useCallback, useImperativeHandle, useMemo, useRef } from 'react'; +import { StyleSheet, View, LayoutChangeEvent, useWindowDimensions } from 'react-native'; +import Animated, { useSharedValue, useAnimatedStyle, withTiming, runOnJS } from 'react-native-reanimated'; +import { Box, globalColors, HitSlop, Text } from '@/design-system'; +import LinearGradient from 'react-native-linear-gradient'; +import MaskedView from '@react-native-masked-view/masked-view'; +import Svg, { Path } from 'react-native-svg'; +import { ButtonPressAnimation } from '@/components/animations'; +import { BlurView } from '@react-native-community/blur'; +import { TIMING_CONFIGS } from '@/components/animations/animationConfigs'; + +// which side of the child the tooltip is on +type Side = 'top' | 'bottom'; + +// which side of the tooltip is aligned to the child +type Align = 'start' | 'center' | 'end'; +interface Position { + x: number; + y: number; + width: number; + height: number; +} + +const TOOLTIP_HEIGHT = 68; +const TOOLTIP_MAX_WIDTH = 350; +const TOOLTIP_PADDING = 16; +const ARROW_SIZE = 10; +const BORDER_RADIUS = 24; +const ICON_SIZE = 36; + +/* + This draws the entire tooltip, including the arrow, in one path + This allows the arrow to blend into the tooltip background nicely +*/ +const calculateTooltipPath = (side: Side, align: Align, width: number): string => { + 'worklet'; + + const height = TOOLTIP_HEIGHT + ARROW_SIZE; + const arrowWidth = ARROW_SIZE * 2; + const arrowHeight = ARROW_SIZE; + const arrowCornerRadius = 4; + + // Calculate arrow center position based on alignment + let arrowCenter; + switch (align) { + case 'start': + arrowCenter = BORDER_RADIUS * 1.5; + break; + case 'center': + arrowCenter = width * 0.5; + break; + case 'end': + arrowCenter = width - BORDER_RADIUS * 1.5; + break; + default: + arrowCenter = width * 0.5; + } + + const arrowLeft = arrowCenter - arrowWidth / 2; + const arrowRight = arrowCenter + arrowWidth / 2; + + switch (side) { + case 'bottom': // Tooltip box below child, arrow at top + return ` + M ${BORDER_RADIUS},${arrowHeight} + H ${arrowLeft} + Q ${arrowLeft},${arrowHeight} ${arrowLeft + arrowCornerRadius},${arrowHeight - arrowCornerRadius} + L ${arrowCenter - arrowCornerRadius},${arrowCornerRadius} + Q ${arrowCenter},0 ${arrowCenter + arrowCornerRadius},${arrowCornerRadius} + L ${arrowRight - arrowCornerRadius},${arrowHeight - arrowCornerRadius} + Q ${arrowRight},${arrowHeight} ${arrowRight},${arrowHeight} + H ${width - BORDER_RADIUS} + Q ${width},${arrowHeight} ${width},${arrowHeight + BORDER_RADIUS} + V ${height - BORDER_RADIUS} + Q ${width},${height} ${width - BORDER_RADIUS},${height} + H ${BORDER_RADIUS} + Q 0,${height} 0,${height - BORDER_RADIUS} + V ${arrowHeight + BORDER_RADIUS} + Q 0,${arrowHeight} ${BORDER_RADIUS},${arrowHeight} + `; + case 'top': // Tooltip box above child, arrow at bottom + return ` + M ${BORDER_RADIUS},0 + H ${width - BORDER_RADIUS} + Q ${width},0 ${width},${BORDER_RADIUS} + V ${height - arrowHeight - BORDER_RADIUS} + Q ${width},${height - arrowHeight} ${width - BORDER_RADIUS},${height - arrowHeight} + H ${arrowRight} + Q ${arrowRight},${height - arrowHeight} ${arrowRight - arrowCornerRadius},${height - arrowHeight + arrowCornerRadius} + L ${arrowCenter + arrowCornerRadius},${height - arrowCornerRadius} + Q ${arrowCenter},${height} ${arrowCenter - arrowCornerRadius},${height - arrowCornerRadius} + L ${arrowLeft + arrowCornerRadius},${height - arrowHeight + arrowCornerRadius} + Q ${arrowLeft},${height - arrowHeight} ${arrowLeft},${height - arrowHeight} + H ${BORDER_RADIUS} + Q 0,${height - arrowHeight} 0,${height - arrowHeight - BORDER_RADIUS} + V ${BORDER_RADIUS} + Q 0,0 ${BORDER_RADIUS},0 + `; + default: + return ''; + } +}; + +export interface TooltipRef { + dismiss: () => void; + open: () => void; +} + +interface FeatureHintTooltipProps { + children: React.ReactNode; + title?: string; + TitleComponent?: React.ReactNode; + subtitle?: string; + SubtitleComponent?: React.ReactNode; + side?: Side; + sideOffset?: number; + align?: Align; + alignOffset?: number; + backgroundColor?: string; + onDismissed?: () => void; +} + +// currently only used for first time feature hints, but if needed can be better abstracted for general tooltips +export const FeatureHintTooltip = forwardRef( + ( + { + children, + title, + TitleComponent, + subtitle, + SubtitleComponent, + side = 'top', + sideOffset = 5, + align = 'center', + alignOffset = 0, + backgroundColor = 'rgba(255, 255, 255, 0.95)', + onDismissed, + }, + ref + ) => { + const opacity = useSharedValue(0); + const isVisible = useSharedValue(false); + const childLayout = useSharedValue(null); + const { width: deviceWidth } = useWindowDimensions(); + const hasOpened = useRef(false); + const tooltipWidth = Math.min(deviceWidth * 0.9, TOOLTIP_MAX_WIDTH); + + const tooltipPath = useMemo(() => calculateTooltipPath(side, align, tooltipWidth), [side, align, tooltipWidth]); + + useImperativeHandle(ref, () => ({ + dismiss: () => { + hideTooltip(); + }, + open: () => { + showTooltip(); + }, + })); + + const showTooltip = useCallback(() => { + 'worklet'; + hasOpened.current = true; + isVisible.value = true; + opacity.value = withTiming(1, TIMING_CONFIGS.slowestFadeConfig); + }, [isVisible, opacity]); + + const hideTooltip = useCallback(() => { + 'worklet'; + opacity.value = withTiming(0, TIMING_CONFIGS.slowFadeConfig, finished => { + if (finished) { + isVisible.value = false; + if (onDismissed) { + runOnJS(onDismissed)(); + } + } + }); + }, [isVisible, onDismissed, opacity]); + + const measureChildLayout = useCallback( + (event: LayoutChangeEvent): void => { + const { x, y, width, height } = event.nativeEvent.layout; + childLayout.value = { x, y, width, height }; + // tooltip defaults to openning automatically, but only if it has not been opened yet so that re-renders don't open it again + if (!hasOpened.current) { + showTooltip(); + } + }, + [childLayout, showTooltip, hasOpened] + ); + + const tooltipStyle = useAnimatedStyle(() => { + // always returning same style object shape optimizes hook + if (!childLayout.value || !isVisible.value) { + return { + opacity: 0, + transform: [{ translateX: 0 }, { translateY: 0 }], + pointerEvents: 'none', + }; + } + + let translateY = 0; + if (side === 'bottom') { + translateY = childLayout.value.y + childLayout.value.height + sideOffset; + } else if (side === 'top') { + translateY = childLayout.value.y - TOOLTIP_HEIGHT - ARROW_SIZE - sideOffset; + } + + let translateX = 0; + switch (align) { + case 'start': + translateX = childLayout.value.x + alignOffset; + break; + case 'center': + translateX = childLayout.value.x + (childLayout.value.width - tooltipWidth) / 2 + alignOffset; + break; + case 'end': + translateX = childLayout.value.x + childLayout.value.width - tooltipWidth + alignOffset; + break; + } + + return { + opacity: opacity.value, + transform: [{ translateX }, { translateY }], + pointerEvents: opacity.value === 0 ? 'none' : ('auto' as const), + }; + }); + + return ( + + {children} + + + + + } + > + + + + + + 􀍱 + + + + {TitleComponent || ( + + {title} + + )} + {SubtitleComponent || ( + + {subtitle} + + )} + + + + + + 􀆄 + + + + + + + + + + ); + } +); + +FeatureHintTooltip.displayName = 'FeatureHintTooltip'; + +const styles = StyleSheet.create({ + tooltipContainer: { + position: 'absolute', + height: TOOLTIP_HEIGHT + ARROW_SIZE, + zIndex: 1000, + }, + maskedContainer: { + flex: 1, + }, + background: { + flex: 1, + justifyContent: 'center', + }, + contentContainer: { + padding: TOOLTIP_PADDING, + flexDirection: 'row', + alignItems: 'flex-start', + }, + iconContainer: { + height: ICON_SIZE, + width: ICON_SIZE, + borderRadius: 10, + borderWidth: 1, + borderColor: '#268FFF0D', + backgroundColor: '#268FFF14', + alignItems: 'center', + justifyContent: 'center', + }, + textContainer: { + flex: 1, + height: '100%', + flexDirection: 'column', + justifyContent: 'space-between', + paddingVertical: 4, + paddingHorizontal: 12, + }, +}); From a8ede2bafc6b2a1a73cd7654f131bc66ec7ecbd8 Mon Sep 17 00:00:00 2001 From: Kane Thomas Date: Sat, 14 Dec 2024 13:48:52 -0500 Subject: [PATCH 13/54] edit wallets hint tooltip --- .../change-wallet/ChangeWalletSheet.tsx | 99 +++++++++++++------ src/state/wallets/pinnedWalletsStore.ts | 2 + 2 files changed, 73 insertions(+), 28 deletions(-) diff --git a/src/screens/change-wallet/ChangeWalletSheet.tsx b/src/screens/change-wallet/ChangeWalletSheet.tsx index f558732360f..a298f9fafca 100644 --- a/src/screens/change-wallet/ChangeWalletSheet.tsx +++ b/src/screens/change-wallet/ChangeWalletSheet.tsx @@ -1,6 +1,6 @@ import { RouteProp, useRoute } from '@react-navigation/native'; import * as i18n from '@/languages'; -import React, { useCallback, useMemo, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Alert, InteractionManager, View } from 'react-native'; import ReactNativeHapticFeedback from 'react-native-haptic-feedback'; import { useDispatch } from 'react-redux'; @@ -24,15 +24,16 @@ import { DEVICE_HEIGHT } from '@/utils/deviceUtils'; import { IS_ANDROID } from '@/env'; import { remotePromoSheetsStore } from '@/state/remotePromoSheets/remotePromoSheets'; import { RootStackParamList } from '@/navigation/types'; -import { address } from '@/utils/abbreviations'; -import { removeFirstEmojiFromString } from '@/helpers/emojiHandler'; -import { Box, Stack, Text } from '@/design-system'; +import { Box, globalColors, Inline, Stack, Text } from '@/design-system'; import { addDisplay, convertAmountToNativeDisplay } from '@/helpers/utilities'; import { Panel, TapToDismiss } from '@/components/SmoothPager/ListPanel'; import { SheetHandleFixedToTop } from '@/components/sheet'; import { EasingGradient } from '@/components/easing-gradient/EasingGradient'; import { MenuConfig, MenuItem } from '@/components/DropdownMenu'; import { NOTIFICATIONS, useExperimentalFlag } from '@/config'; +import { FeatureHintTooltip, TooltipRef } from '@/components/tooltips/FeatureHintTooltip'; +import { usePinnedWalletsStore } from '@/state/wallets/pinnedWalletsStore'; +import ConditionalWrap from 'conditional-wrap'; const LIST_PADDING_BOTTOM = 6; const MAX_LIST_HEIGHT = DEVICE_HEIGHT - 220; @@ -72,21 +73,21 @@ const Whitespace = styled(View)({ }); // TODO: -const getWalletListHeight = (wallets: any, watchOnly: boolean) => { - let listHeight = !watchOnly ? FOOTER_HEIGHT + LIST_PADDING_BOTTOM : WATCH_ONLY_BOTTOM_PADDING; - - if (wallets) { - for (const key of Object.keys(wallets)) { - const visibleAccounts = wallets[key].addresses.filter((account: any) => account.visible); - listHeight += visibleAccounts.length * WALLET_ROW_HEIGHT; - - if (listHeight > MAX_LIST_HEIGHT) { - return { listHeight: MAX_LIST_HEIGHT, scrollEnabled: true }; - } - } - } - return { listHeight, scrollEnabled: false }; -}; +// const getWalletListHeight = (wallets: any, watchOnly: boolean) => { +// let listHeight = !watchOnly ? FOOTER_HEIGHT + LIST_PADDING_BOTTOM : WATCH_ONLY_BOTTOM_PADDING; + +// if (wallets) { +// for (const key of Object.keys(wallets)) { +// const visibleAccounts = wallets[key].addresses.filter((account: any) => account.visible); +// listHeight += visibleAccounts.length * WALLET_ROW_HEIGHT; + +// if (listHeight > MAX_LIST_HEIGHT) { +// return { listHeight: MAX_LIST_HEIGHT, scrollEnabled: true }; +// } +// } +// } +// return { listHeight, scrollEnabled: false }; +// }; export interface AddressItem { address: EthereumAddress; @@ -114,16 +115,25 @@ export default function ChangeWalletSheet() { const { colors } = useTheme(); const { updateWebProfile } = useWebData(); - const { accountAddress, network, nativeCurrency, nativeCurrencySymbol } = useAccountSettings(); + const { accountAddress, nativeCurrency } = useAccountSettings(); const { goBack, navigate, setParams } = useNavigation(); const dispatch = useDispatch(); const initializeWallet = useInitializeWallet(); const walletsWithBalancesAndNames = useWalletsWithBalancesAndNames(); + const initialHasShownEditHintTooltip = useMemo(() => usePinnedWalletsStore.getState().hasShownEditHintTooltip, []); + const featureHintTooltipRef = useRef(null); const [editMode, setEditMode] = useState(false); const [currentAddress, setCurrentAddress] = useState(currentAccountAddress || accountAddress); const [currentSelectedWallet, setCurrentSelectedWallet] = useState(selectedWallet); + // Feature hint tooltip should only ever been shown once. + useEffect(() => { + if (!initialHasShownEditHintTooltip) { + usePinnedWalletsStore.setState({ hasShownEditHintTooltip: true }); + } + }, [initialHasShownEditHintTooltip]); + const walletsByAddress = useMemo(() => { return Object.values(wallets || {}).reduce( (acc, wallet) => { @@ -178,7 +188,7 @@ export default function ChangeWalletSheet() { // sorts by order wallets were added return [...sortedWallets, ...bluetoothWallets, ...readOnlyWallets].sort((a, b) => a.walletId.localeCompare(b.walletId)); - }, [walletsWithBalancesAndNames, currentAddress, editMode, network]); + }, [walletsWithBalancesAndNames, currentAddress, editMode]); // TODO: maybe move this to its own hook const ownedWalletsTotalBalance = useMemo(() => { @@ -453,8 +463,11 @@ export default function ChangeWalletSheet() { const onPressEditMode = useCallback(() => { analytics.track('Tapped "Edit"'); + if (featureHintTooltipRef.current) { + featureHintTooltipRef.current.dismiss(); + } setEditMode(e => !e); - }, []); + }, [featureHintTooltipRef]); const onPressAccount = useCallback( (address: string) => { @@ -575,17 +588,46 @@ export default function ChangeWalletSheet() { - + {'Wallets'} {/* TODO: this positioning is jank */} - - - {editMode ? i18n.t(i18n.l.button.done) : i18n.t(i18n.l.button.edit)} - - + ( + + + {'Tap the'} + + + {' Edit '} + + + {'button above to set up'} + + + } + > + {children} + + )} + > + + + {editMode ? i18n.t(i18n.l.button.done) : i18n.t(i18n.l.button.edit)} + + + {/* TODO: why is this here? */} @@ -648,6 +690,7 @@ export default function ChangeWalletSheet() { + {/* */} ); } diff --git a/src/state/wallets/pinnedWalletsStore.ts b/src/state/wallets/pinnedWalletsStore.ts index d131281cf3b..8e50bee3c0e 100644 --- a/src/state/wallets/pinnedWalletsStore.ts +++ b/src/state/wallets/pinnedWalletsStore.ts @@ -9,6 +9,7 @@ type Address = string; interface PinnedWalletsStore { pinnedAddresses: Address[]; unpinnedAddresses: Address[]; + hasShownEditHintTooltip: boolean; canPinAddresses: () => boolean; addPinnedAddress: (address: Address) => void; removePinnedAddress: (address: Address) => void; @@ -21,6 +22,7 @@ export const usePinnedWalletsStore = createRainbowStore( (set, get) => ({ pinnedAddresses: [], unpinnedAddresses: [], + hasShownEditHintTooltip: false, canPinAddresses: () => { const { pinnedAddresses } = get(); From 10faef352617c5107f783336d938c955515326fa Mon Sep 17 00:00:00 2001 From: Kane Thomas Date: Mon, 16 Dec 2024 11:16:34 -0500 Subject: [PATCH 14/54] fix dnd provider gesture disabled toggle logic --- src/components/drag-and-drop/DndProvider.tsx | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/components/drag-and-drop/DndProvider.tsx b/src/components/drag-and-drop/DndProvider.tsx index 15837e499f8..9440ec3d2b4 100644 --- a/src/components/drag-and-drop/DndProvider.tsx +++ b/src/components/drag-and-drop/DndProvider.tsx @@ -218,13 +218,10 @@ export const DndProvider = forwardRef { const { state, x, y } = event; debug && console.log('begin', { state, x, y }); - // Gesture is globally disabled - if (disabled) { - return; - } // console.log("begin", { state, x, y }); // Track current state for cancellation purposes panGestureState.value = state; From f69dcdfb02b7de60ac0ebbe402ed9d7125c3ff24 Mon Sep 17 00:00:00 2001 From: Kane Thomas Date: Mon, 16 Dec 2024 11:58:03 -0500 Subject: [PATCH 15/54] add DraggableScrollView based on DraggableFlatList --- .../components/DraggableFlatList.tsx | 5 +- .../components/DraggableScrollView.tsx | 223 ++++++++++++++++++ 2 files changed, 225 insertions(+), 3 deletions(-) create mode 100644 src/components/drag-and-drop/components/DraggableScrollView.tsx diff --git a/src/components/drag-and-drop/components/DraggableFlatList.tsx b/src/components/drag-and-drop/components/DraggableFlatList.tsx index 1f58f42cdec..0d7e316e8b2 100644 --- a/src/components/drag-and-drop/components/DraggableFlatList.tsx +++ b/src/components/drag-and-drop/components/DraggableFlatList.tsx @@ -284,7 +284,6 @@ export const DraggableFlatList = ({ AUTOSCROLL_MAX_SPEED ); - console.log('scrollSpeed', scrollSpeed, distanceFromBottomThreshold); autoscroll(scrollSpeed); } } @@ -341,7 +340,7 @@ export const DraggableFlatList = ({ /> ); }} - viewabilityConfig={{ itemVisiblePercentThreshold: 50 }} + // viewabilityConfig={{ itemVisiblePercentThreshold: 50 }} // eslint-disable-next-line react/jsx-props-no-spreading, @typescript-eslint/no-explicit-any {...(otherProps as any)} /> @@ -357,7 +356,7 @@ export const DraggableFlatListCellRenderer = function DraggableFlatListCellRende ) { const { item, children, draggableProps, ...otherProps } = props; return ( - + {children} ); diff --git a/src/components/drag-and-drop/components/DraggableScrollView.tsx b/src/components/drag-and-drop/components/DraggableScrollView.tsx new file mode 100644 index 00000000000..4ad5774b81d --- /dev/null +++ b/src/components/drag-and-drop/components/DraggableScrollView.tsx @@ -0,0 +1,223 @@ +import React, { ComponentProps, ReactElement, useCallback, useMemo } from 'react'; +import Animated, { scrollTo, useAnimatedReaction, useAnimatedRef, useAnimatedScrollHandler, useSharedValue } from 'react-native-reanimated'; +import { useDndContext } from '../DndContext'; +import { useDraggableSort, UseDraggableStackOptions } from '../features'; +import type { UniqueIdentifier } from '../types'; +import { applyOffset, swapByItemCenterPoint } from '../utils'; + +const AUTOSCROLL_THRESHOLD = 50; +const AUTOSCROLL_MIN_SPEED = 1; +const AUTOSCROLL_MAX_SPEED = 3; +const AUTOSCROLL_THRESHOLD_MAX_DISTANCE = 100; + +type AnimatedScrollViewProps = ComponentProps; + +export type DraggableScrollViewProps = AnimatedScrollViewProps & + Pick & { + data: T[]; + gap?: number; + horizontal?: boolean; + autoScrollInsets?: { + top?: number; + bottom?: number; + }; + }; + +function normalizeWorklet(value: number, fromMin: number, fromMax: number, toMin: number, toMax: number) { + 'worklet'; + return ((value - fromMin) * (toMax - toMin)) / (fromMax - fromMin) + toMin; +} + +const canScrollToWorklet = ({ + newOffset, + contentHeight, + layoutHeight, + currentOffset = 0, +}: { + newOffset: number; + contentHeight: number; + layoutHeight: number; + currentOffset: number; +}) => { + 'worklet'; + const maxOffset = contentHeight - layoutHeight; + return newOffset >= 0 && newOffset <= maxOffset && newOffset !== currentOffset; +}; + +export const DraggableScrollView = ({ + children, + data, + gap = 0, + horizontal = false, + onOrderChange, + onOrderUpdate, + shouldSwapWorklet = swapByItemCenterPoint, + autoScrollInsets, + ...otherProps +}: DraggableScrollViewProps): ReactElement => { + const { draggableActiveId, draggableContentOffset, draggableLayouts, draggableOffsets, draggableRestingOffsets, draggableActiveLayout } = + useDndContext(); + + const animatedScrollViewRef = useAnimatedRef(); + const contentHeight = useSharedValue(0); + const layoutHeight = useSharedValue(0); + const scrollOffset = useSharedValue(0); + + const initialOrder = useMemo(() => data?.map((item: T) => item.id) ?? [], [data]); + + const { draggableSortOrder } = useDraggableSort({ + horizontal, + initialOrder, + onOrderChange, + onOrderUpdate, + shouldSwapWorklet, + }); + + const direction = horizontal ? 'column' : 'row'; + const size = 1; + + const scrollHandler = useAnimatedScrollHandler(event => { + scrollOffset.value = event.contentOffset.y; + draggableContentOffset.y.value = event.contentOffset.y; + }); + + const autoscroll = useCallback( + (offset: number) => { + 'worklet'; + const smoothedOffset = Math.round(offset); + const { value: activeId } = draggableActiveId; + + if (activeId) { + const { value: layouts } = draggableLayouts; + const { value: offsets } = draggableOffsets; + const activeLayout = layouts[activeId].value; + const activeOffset = offsets[activeId]; + const newOffset = scrollOffset.value + smoothedOffset; + + if ( + !canScrollToWorklet({ + newOffset, + contentHeight: contentHeight.value, + layoutHeight: layoutHeight.value, + currentOffset: scrollOffset.value, + }) + ) { + return; + } + + scrollTo(animatedScrollViewRef, 0, newOffset, false); + activeOffset.y.value += smoothedOffset; + draggableActiveLayout.value = applyOffset(activeLayout, { + x: activeOffset.x.value, + y: activeOffset.y.value, + }); + } + }, + [ + draggableActiveId, + draggableLayouts, + draggableOffsets, + scrollOffset, + contentHeight, + layoutHeight, + animatedScrollViewRef, + draggableActiveLayout, + ] + ); + + useAnimatedReaction( + () => draggableSortOrder.value, + (nextOrder, prevOrder) => { + if (prevOrder === null) return; + + const { value: activeId } = draggableActiveId; + const { value: layouts } = draggableLayouts; + const { value: offsets } = draggableOffsets; + const { value: restingOffsets } = draggableRestingOffsets; + + if (!activeId) return; + + const activeLayout = layouts[activeId].value; + const { width, height } = activeLayout; + const restingOffset = restingOffsets[activeId]; + + for (let nextIndex = 0; nextIndex < nextOrder.length; nextIndex++) { + const itemId = nextOrder[nextIndex]; + const prevIndex = prevOrder.findIndex(id => id === itemId); + if (nextIndex === prevIndex) continue; + + const prevRow = Math.floor(prevIndex / size); + const prevCol = prevIndex % size; + const nextRow = Math.floor(nextIndex / size); + const nextCol = nextIndex % size; + const moveCol = nextCol - prevCol; + const moveRow = nextRow - prevRow; + + const offset = itemId === activeId ? restingOffset : offsets[itemId]; + if (!restingOffset || !offsets[itemId]) continue; + + switch (direction) { + case 'row': + offset.y.value += moveRow * (height + gap); + break; + case 'column': + offset.x.value += moveCol * (width + gap); + break; + } + } + }, + [] + ); + + useAnimatedReaction( + () => draggableActiveLayout.value?.y, + activeItemY => { + if (activeItemY === undefined) return; + + const bottomThreshold = scrollOffset.value + layoutHeight.value - AUTOSCROLL_THRESHOLD - (autoScrollInsets?.bottom ?? 0); + const isNearBottom = activeItemY >= bottomThreshold; + + const topThreshold = scrollOffset.value + AUTOSCROLL_THRESHOLD + (autoScrollInsets?.top ?? 0); + const isNearTop = activeItemY <= topThreshold; + + if (isNearTop) { + const distanceFromTopThreshold = topThreshold - activeItemY; + const scrollSpeed = normalizeWorklet( + distanceFromTopThreshold, + 0, + AUTOSCROLL_THRESHOLD_MAX_DISTANCE, + AUTOSCROLL_MIN_SPEED, + AUTOSCROLL_MAX_SPEED + ); + autoscroll(-scrollSpeed); + } else if (isNearBottom) { + const distanceFromBottomThreshold = activeItemY - bottomThreshold; + const scrollSpeed = normalizeWorklet( + distanceFromBottomThreshold, + 0, + AUTOSCROLL_THRESHOLD_MAX_DISTANCE, + AUTOSCROLL_MIN_SPEED, + AUTOSCROLL_MAX_SPEED + ); + autoscroll(scrollSpeed); + } + } + ); + + return ( + { + layoutHeight.value = event.nativeEvent.layout.height; + }} + onContentSizeChange={(_, height) => { + contentHeight.value = height; + }} + // eslint-disable-next-line react/jsx-props-no-spreading + {...otherProps} + > + {children} + + ); +}; From 6455f0ba3cb0e7946a9146f4d5a78df29b0d4c6b Mon Sep 17 00:00:00 2001 From: Kane Thomas Date: Mon, 16 Dec 2024 12:00:59 -0500 Subject: [PATCH 16/54] fix gesture problems by switching to draggable scrollview --- src/components/change-wallet/AddressRow.tsx | 3 +- src/components/change-wallet/WalletList.tsx | 68 +++-- .../change-wallet/ChangeWalletSheet.tsx | 1 - .../change-wallet/PinnedWalletsGrid.tsx | 271 +++++++++--------- 4 files changed, 168 insertions(+), 175 deletions(-) diff --git a/src/components/change-wallet/AddressRow.tsx b/src/components/change-wallet/AddressRow.tsx index 976bc16f567..c30bab57177 100644 --- a/src/components/change-wallet/AddressRow.tsx +++ b/src/components/change-wallet/AddressRow.tsx @@ -114,8 +114,7 @@ export function AddressRow({ data, editMode, onPress, menuItems, onPressMenuItem menuConfig={menuConfig} onPressMenuItem={action => onPressMenuItem(action, { address })} > - {/* TODO: there is some issue with how the dropdown long press interacts with the button long press. Inconsistent behavior. */} - + {children} diff --git a/src/components/change-wallet/WalletList.tsx b/src/components/change-wallet/WalletList.tsx index c404678b50d..0807be755a0 100644 --- a/src/components/change-wallet/WalletList.tsx +++ b/src/components/change-wallet/WalletList.tsx @@ -1,5 +1,5 @@ import React, { useCallback, useEffect, useLayoutEffect, useMemo, useState } from 'react'; -import { StyleSheet } from 'react-native'; +import { StyleSheet, View } from 'react-native'; import Animated, { Easing, useAnimatedStyle, useSharedValue, withTiming } from 'react-native-reanimated'; import { EmptyAssetList } from '../asset-list'; import { AddressRow } from './AddressRow'; @@ -15,11 +15,13 @@ import { PANEL_HEADER_HEIGHT, } from '@/screens/change-wallet/ChangeWalletSheet'; import { Box, Inset, Separator, Text } from '@/design-system'; -import { DndProvider, DraggableFlatList, DraggableFlatListProps, UniqueIdentifier } from '../drag-and-drop'; +import { DndProvider, Draggable, DraggableFlatListProps, UniqueIdentifier } from '../drag-and-drop'; import { PinnedWalletsGrid } from '@/screens/change-wallet/PinnedWalletsGrid'; import { usePinnedWalletsStore } from '@/state/wallets/pinnedWalletsStore'; import { MenuItem } from '@/components/DropdownMenu'; +import { DraggableScrollView } from '@/components/drag-and-drop/components/DraggableScrollView'; +const DRAG_ACTIVATION_DELAY = 150; const LIST_TOP_PADDING = 7.5; const TRANSITION_DURATION = 75; const LIST_MAX_HEIGHT = MAX_PANEL_HEIGHT - PANEL_HEADER_HEIGHT; @@ -103,18 +105,21 @@ export function WalletList({ walletItems, menuItems, onPressMenuItem, onPressAcc ); const renderHeader = useCallback(() => { + const hasPinnedWallets = pinnedWalletItems.length > 0; return ( <> - {pinnedWalletItems.length > 0 && ( - + {hasPinnedWallets && ( + + + )} - {pinnedWalletItems.length > 0 && unpinnedWalletItems.length > 0 && ( + {hasPinnedWallets && unpinnedWalletItems.length > 0 && ( <> @@ -126,38 +131,43 @@ export function WalletList({ walletItems, menuItems, onPressMenuItem, onPressAcc )} + {!hasPinnedWallets && } ); }, [pinnedWalletItems, onPressAccount, editMode, unpinnedWalletItems.length, menuItems, onPressMenuItem]); + const renderItem = useCallback( + (item: AddressItem) => ( + + onPressAccount(item.address)} + /> + + ), + [menuItems, onPressMenuItem, onPressAccount, editMode] + ); + return ( - - + ( - onPressAccount(item.address)} - /> - )} - /> + > + {renderHeader()} + {unpinnedWalletItems.map(item => renderItem(item))} + diff --git a/src/screens/change-wallet/ChangeWalletSheet.tsx b/src/screens/change-wallet/ChangeWalletSheet.tsx index a298f9fafca..10f0721f3c2 100644 --- a/src/screens/change-wallet/ChangeWalletSheet.tsx +++ b/src/screens/change-wallet/ChangeWalletSheet.tsx @@ -690,7 +690,6 @@ export default function ChangeWalletSheet() { - {/* */} ); } diff --git a/src/screens/change-wallet/PinnedWalletsGrid.tsx b/src/screens/change-wallet/PinnedWalletsGrid.tsx index 113d60543a6..91332241af5 100644 --- a/src/screens/change-wallet/PinnedWalletsGrid.tsx +++ b/src/screens/change-wallet/PinnedWalletsGrid.tsx @@ -1,5 +1,4 @@ import { Draggable, DraggableGrid, DraggableGridProps, UniqueIdentifier } from '@/components/drag-and-drop'; -import { DndProvider } from '@/components/drag-and-drop/DndProvider'; import { Box, Inline, Stack, Text } from '@/design-system'; import { DEVICE_WIDTH } from '@/utils/deviceUtils'; import React, { useCallback, useMemo } from 'react'; @@ -8,7 +7,6 @@ import { AddressAvatar } from './AddressAvatar'; import { ButtonPressAnimation } from '@/components/animations'; import { BlurView } from '@react-native-community/blur'; import { usePinnedWalletsStore } from '@/state/wallets/pinnedWalletsStore'; -import { View } from 'react-native'; import { SelectedAddressBadge } from './SelectedAddressBadge'; import { JiggleAnimation } from '@/components/animations/JiggleAnimation'; import { DropdownMenu, MenuItem } from '@/components/DropdownMenu'; @@ -51,159 +49,146 @@ export function PinnedWalletsGrid({ walletItems, onPress, editMode, menuItems, o return ( - {walletItems.length > 0 ? ( - - - {walletItems.map(account => ( - + {walletItems.map(account => { + // TODO: can ens names have emojis? If so this logic is wrong + const walletName = removeFirstEmojiFromString(account.label) || address(account.address, 4, 4); + return ( + + ( + + triggerAction="longPress" + menuConfig={{ menuItems, menuTitle: walletName }} + onPressMenuItem={action => onPressMenuItem(action, { address: account.address })} + > + onPress(account.address)}> + {children} + + + )} > - ( - - triggerAction="longPress" - menuConfig={{ menuItems, menuTitle: account.label }} - onPressMenuItem={action => onPressMenuItem(action, { address: account.address })} + + + - {/* TODO: there is some issue with how the dropdown long press interacts with the button long press. Inconsistent behavior. */} - onPress(account.address)} - minLongPressDuration={150} - > - {children} - - - )} - > - - - - - - {account.isSelected && ( - - - - )} - {editMode && ( - removePinnedAddress(account.address)}> - - - {'􀅽'} - - - - )} + }, + } + : undefined + } + borderRadius={avatarSize / 2} + borderWidth={account.isSelected ? 4 : undefined} + borderColor={account.isSelected ? { custom: '#268FFF' } : undefined} + > + - - - {account.isLedger && ( - - 􀤃 - + {account.isSelected && ( + + + )} - {account.isReadOnly && ( - - 􀋮 - + {editMode && ( + removePinnedAddress(account.address)}> + + + {'􀅽'} + + + )} - - {/* TODO: can ens names have emojis? If so this logic is wrong */} - {removeFirstEmojiFromString(account.label) || address(account.address, 4, 4)} + + + + {account.isLedger && ( + + 􀤃 + + )} + {account.isReadOnly && ( + + 􀋮 - - - {account.secondaryLabel} + )} + + {walletName} - - - - ))} - {fillerItems.map((_, index) => ( - - ))} - - - ) : null} + + + {account.secondaryLabel} + + + + + ); + })} + {fillerItems.map((_, index) => ( + + ))} + ); } From 6cb4663b2a8c48309f1280b1c38632f7e2ccad4c Mon Sep 17 00:00:00 2001 From: Kane Thomas Date: Tue, 17 Dec 2024 11:02:25 -0500 Subject: [PATCH 17/54] fixes for DnD children layout changes & draggable scrollview refactor --- src/components/change-wallet/WalletList.tsx | 14 +- src/components/drag-and-drop/DndContext.ts | 2 +- .../drag-and-drop/components/Draggable.tsx | 13 +- .../components/DraggableFlatList.tsx | 2 +- .../components/DraggableScrollView.tsx | 191 +++-------------- .../sort/components/DraggableGrid.tsx | 37 ++-- .../sort/components/DraggableStack.tsx | 18 +- .../features/sort/hooks/useDraggableGrid.ts | 14 +- .../features/sort/hooks/useDraggableScroll.ts | 195 ++++++++++++++++++ .../features/sort/hooks/useDraggableSort.ts | 96 ++++++--- .../features/sort/hooks/useDraggableStack.ts | 6 +- src/components/drag-and-drop/hooks/index.ts | 1 + .../drag-and-drop/hooks/useChildrenIds.ts | 15 ++ .../drag-and-drop/utils/collision.ts | 23 +++ .../drag-and-drop/utils/geometry.ts | 2 + src/components/drag-and-drop/utils/index.ts | 1 + .../change-wallet/PinnedWalletsGrid.tsx | 15 +- 17 files changed, 393 insertions(+), 252 deletions(-) create mode 100644 src/components/drag-and-drop/features/sort/hooks/useDraggableScroll.ts create mode 100644 src/components/drag-and-drop/hooks/useChildrenIds.ts create mode 100644 src/components/drag-and-drop/utils/collision.ts diff --git a/src/components/change-wallet/WalletList.tsx b/src/components/change-wallet/WalletList.tsx index 0807be755a0..6f79b79619b 100644 --- a/src/components/change-wallet/WalletList.tsx +++ b/src/components/change-wallet/WalletList.tsx @@ -98,8 +98,7 @@ export function WalletList({ walletItems, menuItems, onPressMenuItem, onPressAcc const onOrderChange: DraggableFlatListProps['onOrderChange'] = useCallback( (value: UniqueIdentifier[]) => { - // TODO: once upstream dnd fixes integrated - // reorderUnpinnedAddresses(value as string[]); + reorderUnpinnedAddresses(value as string[]); }, [reorderUnpinnedAddresses] ); @@ -136,7 +135,7 @@ export function WalletList({ walletItems, menuItems, onPressMenuItem, onPressAcc ); }, [pinnedWalletItems, onPressAccount, editMode, unpinnedWalletItems.length, menuItems, onPressMenuItem]); - const renderItem = useCallback( + const renderScrollItem = useCallback( (item: AddressItem) => ( { + return unpinnedWalletItems; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [unpinnedWalletItems.length]); + return ( @@ -163,10 +168,9 @@ export function WalletList({ walletItems, menuItems, onPressMenuItem, onPressAcc style={{ maxHeight: LIST_MAX_HEIGHT }} autoScrollInsets={{ bottom: FOOTER_HEIGHT - 24 }} contentContainerStyle={{ paddingBottom: FOOTER_HEIGHT - 24 }} - data={unpinnedWalletItems} > {renderHeader()} - {unpinnedWalletItems.map(item => renderItem(item))} + {draggableUnpinnedWalletItems.map(item => renderScrollItem(item))} diff --git a/src/components/drag-and-drop/DndContext.ts b/src/components/drag-and-drop/DndContext.ts index 6e58313f1c2..c09ae4f64c4 100644 --- a/src/components/drag-and-drop/DndContext.ts +++ b/src/components/drag-and-drop/DndContext.ts @@ -11,7 +11,7 @@ export type DraggableOptions = Record; export type DroppableOptions = Record; export type Layouts = Record>; export type Offsets = Record; -export type DraggableState = 'resting' | 'pending' | 'dragging' | 'dropping' | 'acting'; +export type DraggableState = 'resting' | 'pending' | 'dragging' | 'dropping' | 'acting' | 'sleeping'; export type DraggableStates = Record>; export type DndContextValue = { diff --git a/src/components/drag-and-drop/components/Draggable.tsx b/src/components/drag-and-drop/components/Draggable.tsx index 24e767646ad..f19636b8a52 100644 --- a/src/components/drag-and-drop/components/Draggable.tsx +++ b/src/components/drag-and-drop/components/Draggable.tsx @@ -60,6 +60,7 @@ export const Draggable: FunctionComponent> = ( }); const animatedStyle = useAnimatedStyle(() => { + const isSleeping = state.value === 'sleeping'; const isActive = state.value === 'dragging'; const isActing = state.value === 'acting'; // eslint-disable-next-line no-nested-ternary @@ -71,12 +72,20 @@ export const Draggable: FunctionComponent> = ( { translateX: // eslint-disable-next-line no-nested-ternary - dragDirection !== 'y' ? (isActive ? offset.x.value : withTiming(offset.x.value, TIMING_CONFIGS.slowestFadeConfig)) : 0, + dragDirection !== 'y' + ? isActive || isActing || isSleeping + ? offset.x.value + : withTiming(offset.x.value, TIMING_CONFIGS.slowestFadeConfig) + : 0, }, { translateY: // eslint-disable-next-line no-nested-ternary - dragDirection !== 'x' ? (isActive ? offset.y.value : withTiming(offset.y.value, TIMING_CONFIGS.slowestFadeConfig)) : 0, + dragDirection !== 'x' + ? isActive || isActing || isSleeping + ? offset.y.value + : withTiming(offset.y.value, TIMING_CONFIGS.slowestFadeConfig) + : 0, }, { scale: activeScale === undefined ? 1 : withTiming(isActive ? activeScale : 1, TIMING_CONFIGS.tabPressConfig) }, ], diff --git a/src/components/drag-and-drop/components/DraggableFlatList.tsx b/src/components/drag-and-drop/components/DraggableFlatList.tsx index 0d7e316e8b2..73280b40953 100644 --- a/src/components/drag-and-drop/components/DraggableFlatList.tsx +++ b/src/components/drag-and-drop/components/DraggableFlatList.tsx @@ -108,7 +108,7 @@ export const DraggableFlatList = ({ const { draggableSortOrder } = useDraggableSort({ horizontal, - initialOrder, + childrenIds: initialOrder, onOrderChange, onOrderUpdate, shouldSwapWorklet, diff --git a/src/components/drag-and-drop/components/DraggableScrollView.tsx b/src/components/drag-and-drop/components/DraggableScrollView.tsx index 4ad5774b81d..bc721c2daf2 100644 --- a/src/components/drag-and-drop/components/DraggableScrollView.tsx +++ b/src/components/drag-and-drop/components/DraggableScrollView.tsx @@ -1,52 +1,28 @@ -import React, { ComponentProps, ReactElement, useCallback, useMemo } from 'react'; -import Animated, { scrollTo, useAnimatedReaction, useAnimatedRef, useAnimatedScrollHandler, useSharedValue } from 'react-native-reanimated'; +import React, { ComponentProps, ReactElement } from 'react'; +import Animated, { useAnimatedRef, useAnimatedScrollHandler, useSharedValue } from 'react-native-reanimated'; import { useDndContext } from '../DndContext'; -import { useDraggableSort, UseDraggableStackOptions } from '../features'; -import type { UniqueIdentifier } from '../types'; -import { applyOffset, swapByItemCenterPoint } from '../utils'; - -const AUTOSCROLL_THRESHOLD = 50; -const AUTOSCROLL_MIN_SPEED = 1; -const AUTOSCROLL_MAX_SPEED = 3; -const AUTOSCROLL_THRESHOLD_MAX_DISTANCE = 100; +import { UseDraggableStackOptions } from '../features'; +import { swapByItemCenterPoint } from '../utils'; +import { useChildrenIds } from '../hooks'; +import { useDraggableScroll } from '../features/sort/hooks/useDraggableScroll'; type AnimatedScrollViewProps = ComponentProps; -export type DraggableScrollViewProps = AnimatedScrollViewProps & +export type DraggableScrollViewProps = AnimatedScrollViewProps & Pick & { - data: T[]; + children: React.ReactNode; gap?: number; horizontal?: boolean; + debug?: boolean; autoScrollInsets?: { top?: number; bottom?: number; }; }; -function normalizeWorklet(value: number, fromMin: number, fromMax: number, toMin: number, toMax: number) { - 'worklet'; - return ((value - fromMin) * (toMax - toMin)) / (fromMax - fromMin) + toMin; -} - -const canScrollToWorklet = ({ - newOffset, - contentHeight, - layoutHeight, - currentOffset = 0, -}: { - newOffset: number; - contentHeight: number; - layoutHeight: number; - currentOffset: number; -}) => { - 'worklet'; - const maxOffset = contentHeight - layoutHeight; - return newOffset >= 0 && newOffset <= maxOffset && newOffset !== currentOffset; -}; - -export const DraggableScrollView = ({ +export const DraggableScrollView = ({ children, - data, + debug = false, gap = 0, horizontal = false, onOrderChange, @@ -54,156 +30,35 @@ export const DraggableScrollView = ({ shouldSwapWorklet = swapByItemCenterPoint, autoScrollInsets, ...otherProps -}: DraggableScrollViewProps): ReactElement => { - const { draggableActiveId, draggableContentOffset, draggableLayouts, draggableOffsets, draggableRestingOffsets, draggableActiveLayout } = - useDndContext(); +}: DraggableScrollViewProps): ReactElement => { + const { draggableContentOffset } = useDndContext(); const animatedScrollViewRef = useAnimatedRef(); const contentHeight = useSharedValue(0); const layoutHeight = useSharedValue(0); const scrollOffset = useSharedValue(0); - const initialOrder = useMemo(() => data?.map((item: T) => item.id) ?? [], [data]); + const childrenIds = useChildrenIds(children); - const { draggableSortOrder } = useDraggableSort({ - horizontal, - initialOrder, + useDraggableScroll({ + childrenIds, onOrderChange, onOrderUpdate, shouldSwapWorklet, + horizontal, + contentHeight, + layoutHeight, + autoScrollInsets, + animatedScrollViewRef, + scrollOffset, + gap, }); - const direction = horizontal ? 'column' : 'row'; - const size = 1; - const scrollHandler = useAnimatedScrollHandler(event => { scrollOffset.value = event.contentOffset.y; draggableContentOffset.y.value = event.contentOffset.y; }); - const autoscroll = useCallback( - (offset: number) => { - 'worklet'; - const smoothedOffset = Math.round(offset); - const { value: activeId } = draggableActiveId; - - if (activeId) { - const { value: layouts } = draggableLayouts; - const { value: offsets } = draggableOffsets; - const activeLayout = layouts[activeId].value; - const activeOffset = offsets[activeId]; - const newOffset = scrollOffset.value + smoothedOffset; - - if ( - !canScrollToWorklet({ - newOffset, - contentHeight: contentHeight.value, - layoutHeight: layoutHeight.value, - currentOffset: scrollOffset.value, - }) - ) { - return; - } - - scrollTo(animatedScrollViewRef, 0, newOffset, false); - activeOffset.y.value += smoothedOffset; - draggableActiveLayout.value = applyOffset(activeLayout, { - x: activeOffset.x.value, - y: activeOffset.y.value, - }); - } - }, - [ - draggableActiveId, - draggableLayouts, - draggableOffsets, - scrollOffset, - contentHeight, - layoutHeight, - animatedScrollViewRef, - draggableActiveLayout, - ] - ); - - useAnimatedReaction( - () => draggableSortOrder.value, - (nextOrder, prevOrder) => { - if (prevOrder === null) return; - - const { value: activeId } = draggableActiveId; - const { value: layouts } = draggableLayouts; - const { value: offsets } = draggableOffsets; - const { value: restingOffsets } = draggableRestingOffsets; - - if (!activeId) return; - - const activeLayout = layouts[activeId].value; - const { width, height } = activeLayout; - const restingOffset = restingOffsets[activeId]; - - for (let nextIndex = 0; nextIndex < nextOrder.length; nextIndex++) { - const itemId = nextOrder[nextIndex]; - const prevIndex = prevOrder.findIndex(id => id === itemId); - if (nextIndex === prevIndex) continue; - - const prevRow = Math.floor(prevIndex / size); - const prevCol = prevIndex % size; - const nextRow = Math.floor(nextIndex / size); - const nextCol = nextIndex % size; - const moveCol = nextCol - prevCol; - const moveRow = nextRow - prevRow; - - const offset = itemId === activeId ? restingOffset : offsets[itemId]; - if (!restingOffset || !offsets[itemId]) continue; - - switch (direction) { - case 'row': - offset.y.value += moveRow * (height + gap); - break; - case 'column': - offset.x.value += moveCol * (width + gap); - break; - } - } - }, - [] - ); - - useAnimatedReaction( - () => draggableActiveLayout.value?.y, - activeItemY => { - if (activeItemY === undefined) return; - - const bottomThreshold = scrollOffset.value + layoutHeight.value - AUTOSCROLL_THRESHOLD - (autoScrollInsets?.bottom ?? 0); - const isNearBottom = activeItemY >= bottomThreshold; - - const topThreshold = scrollOffset.value + AUTOSCROLL_THRESHOLD + (autoScrollInsets?.top ?? 0); - const isNearTop = activeItemY <= topThreshold; - - if (isNearTop) { - const distanceFromTopThreshold = topThreshold - activeItemY; - const scrollSpeed = normalizeWorklet( - distanceFromTopThreshold, - 0, - AUTOSCROLL_THRESHOLD_MAX_DISTANCE, - AUTOSCROLL_MIN_SPEED, - AUTOSCROLL_MAX_SPEED - ); - autoscroll(-scrollSpeed); - } else if (isNearBottom) { - const distanceFromBottomThreshold = activeItemY - bottomThreshold; - const scrollSpeed = normalizeWorklet( - distanceFromBottomThreshold, - 0, - AUTOSCROLL_THRESHOLD_MAX_DISTANCE, - AUTOSCROLL_MIN_SPEED, - AUTOSCROLL_MAX_SPEED - ); - autoscroll(scrollSpeed); - } - } - ); - return ( & @@ -20,31 +20,26 @@ export const DraggableGrid: FunctionComponent { - const initialOrder = useMemo( - () => - Children.map(children, child => { - if (React.isValidElement(child)) { - return child.props.id; - } - return null; - })?.filter(Boolean) as UniqueIdentifier[], - [children] - ); + const childrenIds = useChildrenIds(children); - const style: StyleProp = useMemo( - () => ({ - flexDirection: direction, - gap, - flexWrap: 'wrap', - ...(styleProp as object), - }), + const style = useMemo( + () => + // eslint-disable-next-line prefer-object-spread + Object.assign( + { + flexDirection: direction, + gap, + flexWrap: 'wrap', + }, + styleProp + ), [gap, direction, styleProp] ); useDraggableGrid({ direction: style.flexDirection, gap: style.gap, - initialOrder, + childrenIds, onOrderChange, onOrderUpdate, shouldSwapWorklet, diff --git a/src/components/drag-and-drop/features/sort/components/DraggableStack.tsx b/src/components/drag-and-drop/features/sort/components/DraggableStack.tsx index 34dddffd0d2..e92214237e9 100644 --- a/src/components/drag-and-drop/features/sort/components/DraggableStack.tsx +++ b/src/components/drag-and-drop/features/sort/components/DraggableStack.tsx @@ -1,6 +1,6 @@ -import React, { Children, useMemo, type FunctionComponent, type PropsWithChildren } from 'react'; +import React, { useMemo, type FunctionComponent, type PropsWithChildren } from 'react'; import { View, type FlexStyle, type ViewProps } from 'react-native'; -import type { UniqueIdentifier } from '../../../types'; +import { useChildrenIds } from '../../../hooks'; import { useDraggableStack, type UseDraggableStackOptions } from '../hooks/useDraggableStack'; export type DraggableStackProps = Pick & @@ -18,17 +18,7 @@ export const DraggableStack: FunctionComponent { - const initialOrder = useMemo( - () => - Children.map(children, child => { - // console.log("in"); - if (React.isValidElement(child)) { - return child.props.id; - } - return null; - })?.filter(Boolean) as UniqueIdentifier[], - [children] - ); + const childrenIds = useChildrenIds(children); const style = useMemo( () => ({ @@ -44,7 +34,7 @@ export const DraggableStack: FunctionComponent & { gap?: number; size: number; @@ -14,20 +14,20 @@ export type UseDraggableGridOptions = Pick< }; export const useDraggableGrid = ({ - initialOrder, + childrenIds, onOrderChange, onOrderUpdate, gap = 0, size, direction = 'row', - shouldSwapWorklet = swapByItemCenterPoint, + shouldSwapWorklet = doesCenterPointOverlap, }: UseDraggableGridOptions) => { const { draggableActiveId, draggableOffsets, draggableRestingOffsets, draggableLayouts } = useDndContext(); const horizontal = ['row', 'row-reverse'].includes(direction); const { draggablePlaceholderIndex, draggableSortOrder } = useDraggableSort({ horizontal, - initialOrder, + childrenIds, onOrderChange, onOrderUpdate, shouldSwapWorklet, @@ -70,10 +70,6 @@ export const useDraggableGrid = ({ const offset = itemId === activeId ? restingOffset : offsets[itemId]; - if (!restingOffset || !offsets[itemId]) { - continue; - } - switch (direction) { case 'row': offset.x.value += moveCol * (width + gap); diff --git a/src/components/drag-and-drop/features/sort/hooks/useDraggableScroll.ts b/src/components/drag-and-drop/features/sort/hooks/useDraggableScroll.ts new file mode 100644 index 00000000000..7e30bfed5d2 --- /dev/null +++ b/src/components/drag-and-drop/features/sort/hooks/useDraggableScroll.ts @@ -0,0 +1,195 @@ +import { useDndContext } from '@/components/drag-and-drop/DndContext'; +import { useDraggableSort, type UseDraggableSortOptions } from './useDraggableSort'; +import Animated, { AnimatedRef, SharedValue, scrollTo, useAnimatedReaction } from 'react-native-reanimated'; +import { applyOffset, doesOverlapOnAxis } from '@/components/drag-and-drop/utils'; +import { useCallback } from 'react'; + +const AUTOSCROLL_THRESHOLD = 50; +const AUTOSCROLL_MIN_SPEED = 1; +const AUTOSCROLL_MAX_SPEED = 3; +const AUTOSCROLL_THRESHOLD_MAX_DISTANCE = 100; + +function normalizeWorklet(value: number, fromMin: number, fromMax: number, toMin: number, toMax: number) { + 'worklet'; + return ((value - fromMin) * (toMax - toMin)) / (fromMax - fromMin) + toMin; +} + +const canScrollToWorklet = ({ + newOffset, + contentHeight, + layoutHeight, + currentOffset = 0, +}: { + newOffset: number; + contentHeight: number; + layoutHeight: number; + currentOffset: number; +}) => { + 'worklet'; + const maxOffset = contentHeight - layoutHeight; + return newOffset >= 0 && newOffset <= maxOffset && newOffset !== currentOffset; +}; + +export type UseDraggableScrollOptions = Pick< + UseDraggableSortOptions, + 'childrenIds' | 'onOrderChange' | 'onOrderUpdate' | 'shouldSwapWorklet' +> & { + contentHeight: SharedValue; + layoutHeight: SharedValue; + animatedScrollViewRef: AnimatedRef; + scrollOffset: SharedValue; + horizontal?: boolean; + autoScrollInsets?: { top?: number; bottom?: number }; + gap?: number; +}; + +export const useDraggableScroll = ({ + childrenIds, + onOrderChange, + onOrderUpdate, + shouldSwapWorklet = doesOverlapOnAxis, + horizontal = false, + contentHeight, + layoutHeight, + autoScrollInsets, + scrollOffset, + animatedScrollViewRef, + gap = 0, +}: UseDraggableScrollOptions) => { + const { draggableActiveId, draggableLayouts, draggableOffsets, draggableRestingOffsets, draggableActiveLayout } = useDndContext(); + + const { draggableSortOrder } = useDraggableSort({ + horizontal, + childrenIds, + onOrderChange, + onOrderUpdate, + shouldSwapWorklet, + }); + + const direction = horizontal ? 'column' : 'row'; + const size = 1; + + const autoscroll = useCallback( + (offset: number) => { + 'worklet'; + const smoothedOffset = Math.round(offset); + const { value: activeId } = draggableActiveId; + + if (activeId) { + const { value: layouts } = draggableLayouts; + const { value: offsets } = draggableOffsets; + const activeLayout = layouts[activeId].value; + const activeOffset = offsets[activeId]; + const newOffset = scrollOffset.value + smoothedOffset; + + if ( + !canScrollToWorklet({ + newOffset, + contentHeight: contentHeight.value, + layoutHeight: layoutHeight.value, + currentOffset: scrollOffset.value, + }) + ) { + return; + } + + scrollTo(animatedScrollViewRef, 0, newOffset, false); + activeOffset.y.value += smoothedOffset; + draggableActiveLayout.value = applyOffset(activeLayout, { + x: activeOffset.x.value, + y: activeOffset.y.value, + }); + } + }, + [ + draggableActiveId, + draggableLayouts, + draggableOffsets, + scrollOffset, + contentHeight, + layoutHeight, + animatedScrollViewRef, + draggableActiveLayout, + ] + ); + + useAnimatedReaction( + () => draggableSortOrder.value, + (nextOrder, prevOrder) => { + if (prevOrder === null) return; + + const { value: activeId } = draggableActiveId; + const { value: layouts } = draggableLayouts; + const { value: offsets } = draggableOffsets; + const { value: restingOffsets } = draggableRestingOffsets; + + if (!activeId) return; + + const activeLayout = layouts[activeId].value; + const { width, height } = activeLayout; + const restingOffset = restingOffsets[activeId]; + + for (let nextIndex = 0; nextIndex < nextOrder.length; nextIndex++) { + const itemId = nextOrder[nextIndex]; + const prevIndex = prevOrder.findIndex(id => id === itemId); + if (nextIndex === prevIndex) continue; + + const prevRow = Math.floor(prevIndex / size); + const prevCol = prevIndex % size; + const nextRow = Math.floor(nextIndex / size); + const nextCol = nextIndex % size; + const moveCol = nextCol - prevCol; + const moveRow = nextRow - prevRow; + + const offset = itemId === activeId ? restingOffset : offsets[itemId]; + if (!restingOffset || !offsets[itemId]) continue; + + switch (direction) { + case 'row': + offset.y.value += moveRow * (height + gap); + break; + case 'column': + offset.x.value += moveCol * (width + gap); + break; + } + } + }, + [] + ); + + // React to active item position and autoscroll if necessary + useAnimatedReaction( + () => draggableActiveLayout.value?.y, + activeItemY => { + if (activeItemY === undefined) return; + + const bottomThreshold = scrollOffset.value + layoutHeight.value - AUTOSCROLL_THRESHOLD - (autoScrollInsets?.bottom ?? 0); + const isNearBottom = activeItemY >= bottomThreshold; + + const topThreshold = scrollOffset.value + AUTOSCROLL_THRESHOLD + (autoScrollInsets?.top ?? 0); + const isNearTop = activeItemY <= topThreshold; + + if (isNearTop) { + const distanceFromTopThreshold = topThreshold - activeItemY; + const scrollSpeed = normalizeWorklet( + distanceFromTopThreshold, + 0, + AUTOSCROLL_THRESHOLD_MAX_DISTANCE, + AUTOSCROLL_MIN_SPEED, + AUTOSCROLL_MAX_SPEED + ); + autoscroll(-scrollSpeed); + } else if (isNearBottom) { + const distanceFromBottomThreshold = activeItemY - bottomThreshold; + const scrollSpeed = normalizeWorklet( + distanceFromBottomThreshold, + 0, + AUTOSCROLL_THRESHOLD_MAX_DISTANCE, + AUTOSCROLL_MIN_SPEED, + AUTOSCROLL_MAX_SPEED + ); + autoscroll(scrollSpeed); + } + } + ); +}; diff --git a/src/components/drag-and-drop/features/sort/hooks/useDraggableSort.ts b/src/components/drag-and-drop/features/sort/hooks/useDraggableSort.ts index 7bdf88469f8..6a82a6aa685 100644 --- a/src/components/drag-and-drop/features/sort/hooks/useDraggableSort.ts +++ b/src/components/drag-and-drop/features/sort/hooks/useDraggableSort.ts @@ -2,12 +2,13 @@ import { LayoutRectangle } from 'react-native'; import { runOnJS, useAnimatedReaction, useSharedValue } from 'react-native-reanimated'; import { useDndContext } from '../../../DndContext'; import type { UniqueIdentifier } from '../../../types'; -import { applyOffset, arraysEqual, centerAxis, moveArrayIndex, overlapsAxis, type Rectangle } from '../../../utils'; +import { applyOffset, arraysEqual, type Direction, doesOverlapOnAxis, moveArrayIndex, type Rectangle } from '../../../utils'; +import { useCallback } from 'react'; -export type ShouldSwapWorklet = (activeLayout: Rectangle, itemLayout: Rectangle) => boolean; +export type ShouldSwapWorklet = (activeLayout: Rectangle, itemLayout: Rectangle, direction: Direction) => boolean; export type UseDraggableSortOptions = { - initialOrder?: UniqueIdentifier[]; + childrenIds: UniqueIdentifier[]; horizontal?: boolean; onOrderChange?: (order: UniqueIdentifier[]) => void; onOrderUpdate?: (nextOrder: UniqueIdentifier[], prevOrder: UniqueIdentifier[]) => void; @@ -16,16 +17,18 @@ export type UseDraggableSortOptions = { export const useDraggableSort = ({ horizontal = false, - initialOrder = [], + childrenIds, onOrderChange, onOrderUpdate, - shouldSwapWorklet, + shouldSwapWorklet = doesOverlapOnAxis, }: UseDraggableSortOptions) => { - const { draggableActiveId, draggableActiveLayout, draggableOffsets, draggableLayouts } = useDndContext(); + const { draggableActiveId, draggableStates, draggableRestingOffsets, draggableActiveLayout, draggableOffsets, draggableLayouts } = + useDndContext(); + const direction = horizontal ? 'horizontal' : 'vertical'; const draggablePlaceholderIndex = useSharedValue(-1); - const draggableLastOrder = useSharedValue(initialOrder); - const draggableSortOrder = useSharedValue(initialOrder); + const draggableLastOrder = useSharedValue(childrenIds); + const draggableSortOrder = useSharedValue(childrenIds); // Core placeholder index logic const findPlaceholderIndex = (activeLayout: LayoutRectangle): number => { @@ -42,6 +45,7 @@ export const useDraggableSort = ({ if (itemId === activeId) { continue; } + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition if (!layouts[itemId]) { console.warn(`Unexpected missing layout ${itemId} in layouts!`); continue; @@ -51,25 +55,67 @@ export const useDraggableSort = ({ y: offsets[itemId].y.value, }); - if (shouldSwapWorklet) { - if (shouldSwapWorklet(activeLayout, itemLayout)) { - // console.log(`Found placeholder index ${itemIndex} using custom shouldSwapWorklet!`); - return itemIndex; - } - continue; - } - - // Default to center axis - const itemCenterAxis = centerAxis(itemLayout, horizontal); - if (overlapsAxis(activeLayout, itemCenterAxis, horizontal)) { + if (shouldSwapWorklet(activeLayout, itemLayout, direction)) { + // console.log(`Found placeholder index ${itemIndex} using custom shouldSwapWorklet!`); return itemIndex; } + continue; } // Fallback to current index - // console.log(`Fallback to current index ${activeIndex}`); return activeIndex; }; + const resetOffsets = useCallback(() => { + 'worklet'; + requestAnimationFrame(() => { + const axis = horizontal ? 'x' : 'y'; + const { value: states } = draggableStates; + const { value: offsets } = draggableOffsets; + const { value: restingOffsets } = draggableRestingOffsets; + const { value: sortOrder } = draggableSortOrder; + + for (const itemId of sortOrder) { + // Can happen if we are asked to refresh the offsets before the layouts are available + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (!offsets[itemId]) { + continue; + } + + // required to prevent item from animating to its new position + states[itemId].value = 'sleeping'; + restingOffsets[itemId][axis].value = 0; + offsets[itemId][axis].value = 0; + } + requestAnimationFrame(() => { + for (const itemId of sortOrder) { + // Can happen if we are asked to refresh the offsets before the layouts are available + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (!offsets[itemId]) { + continue; + } + states[itemId].value = 'resting'; + } + }); + }); + }, [draggableOffsets, draggableRestingOffsets, draggableSortOrder, draggableStates, horizontal]); + + // Track added/removed draggable items and update the sort order + useAnimatedReaction( + () => childrenIds, + (next, prev) => { + if (prev === null || prev.length === 0) return; + + if (prev.length === next.length) return; + + // this assumes the order is sorted in the layout, which might not be the case + // if it's not, would need to sort based on the layout but requires waitign for requestAnimationFrame + draggableSortOrder.value = next; + + resetOffsets(); + }, + [childrenIds, onOrderChange] + ); + // Track active layout changes and update the placeholder index useAnimatedReaction( () => [draggableActiveId.value, draggableActiveLayout.value] as const, @@ -78,7 +124,7 @@ export const useDraggableSort = ({ if (prev === null) { return; } - // const [_prevActiveId, _prevActiveLayout] = prev; + const [_prevActiveId, _prevActiveLayout] = prev; // No active layout if (nextActiveLayout === null) { return; @@ -88,11 +134,15 @@ export const useDraggableSort = ({ draggablePlaceholderIndex.value = -1; return; } + // Only track our own children + if (!childrenIds.includes(nextActiveId)) { + return; + } // const axis = direction === "row" ? "x" : "y"; // const delta = prevActiveLayout !== null ? nextActiveLayout[axis] - prevActiveLayout[axis] : 0; draggablePlaceholderIndex.value = findPlaceholderIndex(nextActiveLayout); }, - [] + [childrenIds] ); // Track placeholder index changes and update the sort order @@ -103,7 +153,7 @@ export const useDraggableSort = ({ if (prev === null) { return; } - const [, prevPlaceholderIndex] = prev; + const [_prevActiveId, prevPlaceholderIndex] = prev; const [nextActiveId, nextPlaceholderIndex] = next; const { value: prevOrder } = draggableSortOrder; // if (nextPlaceholderIndex !== prevPlaceholderIndex) { diff --git a/src/components/drag-and-drop/features/sort/hooks/useDraggableStack.ts b/src/components/drag-and-drop/features/sort/hooks/useDraggableStack.ts index cb6626aab85..4413476cb53 100644 --- a/src/components/drag-and-drop/features/sort/hooks/useDraggableStack.ts +++ b/src/components/drag-and-drop/features/sort/hooks/useDraggableStack.ts @@ -6,13 +6,13 @@ import { useDraggableSort, type UseDraggableSortOptions } from './useDraggableSo export type UseDraggableStackOptions = Pick< UseDraggableSortOptions, - 'initialOrder' | 'onOrderChange' | 'onOrderUpdate' | 'shouldSwapWorklet' + 'childrenIds' | 'onOrderChange' | 'onOrderUpdate' | 'shouldSwapWorklet' > & { gap?: number; horizontal?: boolean; }; export const useDraggableStack = ({ - initialOrder, + childrenIds, onOrderChange, onOrderUpdate, gap = 0, @@ -31,7 +31,7 @@ export const useDraggableStack = ({ const { draggablePlaceholderIndex, draggableSortOrder } = useDraggableSort({ horizontal, - initialOrder, + childrenIds, onOrderChange, onOrderUpdate, shouldSwapWorklet: worklet, diff --git a/src/components/drag-and-drop/hooks/index.ts b/src/components/drag-and-drop/hooks/index.ts index 26087b68cc4..4104f774a19 100644 --- a/src/components/drag-and-drop/hooks/index.ts +++ b/src/components/drag-and-drop/hooks/index.ts @@ -11,3 +11,4 @@ export * from './useLatestValue'; export * from './useNodeRef'; export * from './useSharedPoint'; export * from './useSharedValuePair'; +export * from './useChildrenIds'; diff --git a/src/components/drag-and-drop/hooks/useChildrenIds.ts b/src/components/drag-and-drop/hooks/useChildrenIds.ts new file mode 100644 index 00000000000..9271d40bd53 --- /dev/null +++ b/src/components/drag-and-drop/hooks/useChildrenIds.ts @@ -0,0 +1,15 @@ +import React, { Children, ReactNode, useMemo } from 'react'; +import type { UniqueIdentifier } from '../types'; + +export const useChildrenIds = (children: ReactNode): UniqueIdentifier[] => { + return useMemo(() => { + const ids = Children.map(children, child => { + if (React.isValidElement(child)) { + return (child.props as { id?: UniqueIdentifier }).id; + } + return null; + }); + + return ids ? ids.filter(Boolean) : []; + }, [children]); +}; diff --git a/src/components/drag-and-drop/utils/collision.ts b/src/components/drag-and-drop/utils/collision.ts new file mode 100644 index 00000000000..45dbb17a73e --- /dev/null +++ b/src/components/drag-and-drop/utils/collision.ts @@ -0,0 +1,23 @@ +import { centerAxis, centerPoint, Direction, includesPoint, overlapsAxis, type Rectangle } from './geometry'; + +export const doesCenterPointOverlap = (activeLayout: Rectangle, itemLayout: Rectangle) => { + 'worklet'; + const itemCenterPoint = centerPoint(itemLayout); + return includesPoint(activeLayout, itemCenterPoint); +}; + +export const doesOverlapOnAxis = (activeLayout: Rectangle, itemLayout: Rectangle, direction: Direction) => { + 'worklet'; + const itemCenterAxis = centerAxis(itemLayout, direction === 'horizontal'); + return overlapsAxis(activeLayout, itemCenterAxis, direction === 'horizontal'); +}; + +export const doesOverlapHorizontally = (activeLayout: Rectangle, itemLayout: Rectangle) => { + 'worklet'; + return doesOverlapOnAxis(activeLayout, itemLayout, 'vertical'); +}; + +export const doesOverlapVertically = (activeLayout: Rectangle, itemLayout: Rectangle) => { + 'worklet'; + return doesOverlapOnAxis(activeLayout, itemLayout, 'horizontal'); +}; diff --git a/src/components/drag-and-drop/utils/geometry.ts b/src/components/drag-and-drop/utils/geometry.ts index 6f017685a10..043b428a52c 100644 --- a/src/components/drag-and-drop/utils/geometry.ts +++ b/src/components/drag-and-drop/utils/geometry.ts @@ -15,6 +15,8 @@ export type Rectangle = { height: number; }; +export type Direction = 'horizontal' | 'vertical'; + /** * @summary Split a `Rectangle` in two * @worklet diff --git a/src/components/drag-and-drop/utils/index.ts b/src/components/drag-and-drop/utils/index.ts index 513e2860213..9cdb3a33474 100644 --- a/src/components/drag-and-drop/utils/index.ts +++ b/src/components/drag-and-drop/utils/index.ts @@ -4,3 +4,4 @@ export * from './geometry'; export * from './random'; export * from './reanimated'; export * from './swap'; +export * from './collision'; diff --git a/src/screens/change-wallet/PinnedWalletsGrid.tsx b/src/screens/change-wallet/PinnedWalletsGrid.tsx index 91332241af5..bfd44316818 100644 --- a/src/screens/change-wallet/PinnedWalletsGrid.tsx +++ b/src/screens/change-wallet/PinnedWalletsGrid.tsx @@ -31,10 +31,9 @@ export function PinnedWalletsGrid({ walletItems, onPress, editMode, menuItems, o const removePinnedAddress = usePinnedWalletsStore(state => state.removePinnedAddress); const reorderPinnedAddresses = usePinnedWalletsStore(state => state.reorderPinnedAddresses); - const onGridOrderChange: DraggableGridProps['onOrderChange'] = useCallback( + const onOrderChange: DraggableGridProps['onOrderChange'] = useCallback( (value: UniqueIdentifier[]) => { - // TODO: once upstream dnd is integrated - // reorderPinnedAddresses(value as string[]); + reorderPinnedAddresses(value as string[]); }, [reorderPinnedAddresses] ); @@ -44,6 +43,12 @@ export function PinnedWalletsGrid({ walletItems, onPress, editMode, menuItems, o return Array.from({ length: itemsInLastRow === 0 ? 0 : PINS_PER_ROW - itemsInLastRow }); }, [walletItems.length]); + // the draggable context should only layout its children when the number of children changes + const draggableItems = useMemo(() => { + return walletItems; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [walletItems.length]); + // TODO: scale down if cannot fit three items in row const avatarSize = MAX_AVATAR_SIZE; @@ -53,13 +58,13 @@ export function PinnedWalletsGrid({ walletItems, onPress, editMode, menuItems, o direction="row" // TODO: design spec is 28px, but is too large gap={24} - onOrderChange={onGridOrderChange} + onOrderChange={onOrderChange} size={PINS_PER_ROW} style={{ width: '100%', }} > - {walletItems.map(account => { + {draggableItems.map(account => { // TODO: can ens names have emojis? If so this logic is wrong const walletName = removeFirstEmojiFromString(account.label) || address(account.address, 4, 4); return ( From e1bfadac27fabb89ed4c2c1c2a36cee9820085c9 Mon Sep 17 00:00:00 2001 From: Kane Thomas Date: Tue, 17 Dec 2024 11:31:39 -0500 Subject: [PATCH 18/54] fix different account emoji in list vs pinned grid --- src/components/change-wallet/AddressRow.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/components/change-wallet/AddressRow.tsx b/src/components/change-wallet/AddressRow.tsx index c30bab57177..2c3d5118e11 100644 --- a/src/components/change-wallet/AddressRow.tsx +++ b/src/components/change-wallet/AddressRow.tsx @@ -39,7 +39,7 @@ export const AddressRowButton = ({ const fillQuaternary = useForegroundColor('fillQuaternary'); return ( - + onPressMenuItem(action, { address })} > - + {children} @@ -130,7 +130,7 @@ export function AddressRow({ data, editMode, onPress, menuItems, onPressMenuItem )} - + Date: Tue, 17 Dec 2024 11:44:48 -0500 Subject: [PATCH 19/54] dynamically size pinned account avatars --- src/components/SmoothPager/ListPanel.tsx | 2 +- .../change-wallet/PinnedWalletsGrid.tsx | 22 +++++++++++++------ 2 files changed, 16 insertions(+), 8 deletions(-) diff --git a/src/components/SmoothPager/ListPanel.tsx b/src/components/SmoothPager/ListPanel.tsx index 4918473042c..6c07758f790 100644 --- a/src/components/SmoothPager/ListPanel.tsx +++ b/src/components/SmoothPager/ListPanel.tsx @@ -38,7 +38,7 @@ export const TapToDismiss = memo(function TapToDismiss() { }); const PANEL_INSET = 8; -const PANEL_WIDTH = DEVICE_WIDTH - PANEL_INSET * 2; +export const PANEL_WIDTH = DEVICE_WIDTH - PANEL_INSET * 2; const PANEL_BORDER_RADIUS = 42; const LIST_SCROLL_INDICATOR_BOTTOM_INSET = { bottom: PANEL_BORDER_RADIUS }; diff --git a/src/screens/change-wallet/PinnedWalletsGrid.tsx b/src/screens/change-wallet/PinnedWalletsGrid.tsx index bfd44316818..41d1c6600d8 100644 --- a/src/screens/change-wallet/PinnedWalletsGrid.tsx +++ b/src/screens/change-wallet/PinnedWalletsGrid.tsx @@ -1,6 +1,5 @@ import { Draggable, DraggableGrid, DraggableGridProps, UniqueIdentifier } from '@/components/drag-and-drop'; import { Box, Inline, Stack, Text } from '@/design-system'; -import { DEVICE_WIDTH } from '@/utils/deviceUtils'; import React, { useCallback, useMemo } from 'react'; import { AddressItem, AddressMenuAction, AddressMenuActionData } from './ChangeWalletSheet'; import { AddressAvatar } from './AddressAvatar'; @@ -13,11 +12,13 @@ import { DropdownMenu, MenuItem } from '@/components/DropdownMenu'; import ConditionalWrap from 'conditional-wrap'; import { address } from '@/utils/abbreviations'; import { removeFirstEmojiFromString } from '@/helpers/emojiHandler'; +import { PANEL_WIDTH } from '@/components/SmoothPager/ListPanel'; const UNPIN_BADGE_SIZE = 28; const PINS_PER_ROW = 3; +const GRID_GAP = 28; const MAX_AVATAR_SIZE = 91; -const HORIZONTAL_PAGE_INSET = 24; +const PAGE_INSET_HORIZONTAL = 24; type PinnedWalletsGridProps = { walletItems: AddressItem[]; @@ -49,15 +50,22 @@ export function PinnedWalletsGrid({ walletItems, onPress, editMode, menuItems, o // eslint-disable-next-line react-hooks/exhaustive-deps }, [walletItems.length]); - // TODO: scale down if cannot fit three items in row - const avatarSize = MAX_AVATAR_SIZE; + const avatarSize = useMemo( + () => Math.min((PANEL_WIDTH - PAGE_INSET_HORIZONTAL * 2 - GRID_GAP * (PINS_PER_ROW - 1)) / PINS_PER_ROW, MAX_AVATAR_SIZE), + [] + ); return ( - + Date: Tue, 17 Dec 2024 12:24:11 -0500 Subject: [PATCH 20/54] copy & settings dropdown menu options, fix account emoji size --- src/components/change-wallet/AddressRow.tsx | 4 +- src/screens/change-wallet/AddressAvatar.tsx | 2 +- .../change-wallet/ChangeWalletSheet.tsx | 70 +++++++++---------- .../change-wallet/PinnedWalletsGrid.tsx | 5 +- 4 files changed, 40 insertions(+), 41 deletions(-) diff --git a/src/components/change-wallet/AddressRow.tsx b/src/components/change-wallet/AddressRow.tsx index 2c3d5118e11..17bea7f14ca 100644 --- a/src/components/change-wallet/AddressRow.tsx +++ b/src/components/change-wallet/AddressRow.tsx @@ -39,7 +39,7 @@ export const AddressRowButton = ({ const fillQuaternary = useForegroundColor('fillQuaternary'); return ( - + (isReadOnly ? item.actionKey !== AddressMenuAction.Settings : true)), menuTitle: walletName, }; diff --git a/src/screens/change-wallet/AddressAvatar.tsx b/src/screens/change-wallet/AddressAvatar.tsx index 15fa0076e4b..666031d5181 100644 --- a/src/screens/change-wallet/AddressAvatar.tsx +++ b/src/screens/change-wallet/AddressAvatar.tsx @@ -42,7 +42,7 @@ function AddressEmojiAvatar({ style={{ backgroundColor }} width={{ custom: size }} > - 40 ? '30pt' : 'icon 18px'} weight="heavy"> + 50 ? '44pt' : 'icon 18px'} weight="heavy"> {accountSymbol} diff --git a/src/screens/change-wallet/ChangeWalletSheet.tsx b/src/screens/change-wallet/ChangeWalletSheet.tsx index 10f0721f3c2..4a1ea2d1aa2 100644 --- a/src/screens/change-wallet/ChangeWalletSheet.tsx +++ b/src/screens/change-wallet/ChangeWalletSheet.tsx @@ -34,6 +34,8 @@ import { NOTIFICATIONS, useExperimentalFlag } from '@/config'; import { FeatureHintTooltip, TooltipRef } from '@/components/tooltips/FeatureHintTooltip'; import { usePinnedWalletsStore } from '@/state/wallets/pinnedWalletsStore'; import ConditionalWrap from 'conditional-wrap'; +import Clipboard from '@react-native-clipboard/clipboard'; +import { SettingsPages } from '../SettingsSheet/SettingsPages'; const LIST_PADDING_BOTTOM = 6; const MAX_LIST_HEIGHT = DEVICE_HEIGHT - 220; @@ -72,23 +74,6 @@ const Whitespace = styled(View)({ width: '100%', }); -// TODO: -// const getWalletListHeight = (wallets: any, watchOnly: boolean) => { -// let listHeight = !watchOnly ? FOOTER_HEIGHT + LIST_PADDING_BOTTOM : WATCH_ONLY_BOTTOM_PADDING; - -// if (wallets) { -// for (const key of Object.keys(wallets)) { -// const visibleAccounts = wallets[key].addresses.filter((account: any) => account.visible); -// listHeight += visibleAccounts.length * WALLET_ROW_HEIGHT; - -// if (listHeight > MAX_LIST_HEIGHT) { -// return { listHeight: MAX_LIST_HEIGHT, scrollEnabled: true }; -// } -// } -// } -// return { listHeight, scrollEnabled: false }; -// }; - export interface AddressItem { address: EthereumAddress; color: number; @@ -146,7 +131,6 @@ export default function ChangeWalletSheet() { ); }, [wallets]); - // TODO: maybe wallet accounts is a better name const allWalletItems = useMemo(() => { const sortedWallets: AddressItem[] = []; const bluetoothWallets: AddressItem[] = []; @@ -190,7 +174,6 @@ export default function ChangeWalletSheet() { return [...sortedWallets, ...bluetoothWallets, ...readOnlyWallets].sort((a, b) => a.walletId.localeCompare(b.walletId)); }, [walletsWithBalancesAndNames, currentAddress, editMode]); - // TODO: maybe move this to its own hook const ownedWalletsTotalBalance = useMemo(() => { let isLoadingBalance = false; @@ -200,7 +183,6 @@ export default function ChangeWalletSheet() { const visibleAccounts = wallet.addresses.filter(account => account.visible); - // TODO: if these are not in the native currency 0 format the end number will also not have the format let walletTotalBalance = '0'; visibleAccounts.forEach(account => { @@ -434,19 +416,33 @@ export default function ChangeWalletSheet() { [currentAddress, deleteWallet, goBack, navigate, onChangeAccount, wallets] ); - // const onPressPairHardwareWallet = useCallback(() => { - // analyticsV2.track(analyticsV2.event.addWalletFlowStarted, { - // isFirstWallet: false, - // type: 'ledger_nano_x', - // }); - // goBack(); - // InteractionManager.runAfterInteractions(() => { - // navigate(Routes.PAIR_HARDWARE_WALLET_NAVIGATOR, { - // entryPoint: Routes.CHANGE_WALLET_SHEET, - // isFirstWallet: false, - // }); - // }); - // }, [goBack, navigate]); + const onPressCopyAddress = useCallback((address: string) => { + Clipboard.setString(address); + }, []); + + const onPressWalletSettings = useCallback( + (address: string) => { + const wallet = walletsByAddress[address]; + + if (!wallet) { + logger.error(new RainbowError('[ChangeWalletSheet]: No wallet for address found when pressing wallet settings'), { + address, + }); + return; + } + + InteractionManager.runAfterInteractions(() => { + navigate(Routes.SETTINGS_SHEET, { + params: { + walletId: wallet.id, + initialRoute: SettingsPages.backup, + }, + screen: SettingsPages.backup.key, + }); + }); + }, + [navigate, walletsByAddress] + ); const onPressAddAnotherWallet = useCallback(() => { analyticsV2.track(analyticsV2.event.pressedButton, { @@ -547,7 +543,7 @@ export default function ChangeWalletSheet() { address, actionKey, }); - // TODO: show user facing error + // TODO: should show user facing error? return; } switch (actionKey) { @@ -561,14 +557,14 @@ export default function ChangeWalletSheet() { onPressRemove(wallet.id, address); break; case AddressMenuAction.Settings: - // onPressSettings(address); + onPressWalletSettings(address); break; case AddressMenuAction.Copy: - // onPressCopy(address); + onPressCopyAddress(address); break; } }, - [walletsByAddress, onPressEdit, onPressNotifications, onPressRemove] + [walletsByAddress, onPressEdit, onPressNotifications, onPressRemove, onPressCopyAddress, onPressWalletSettings] ); return ( diff --git a/src/screens/change-wallet/PinnedWalletsGrid.tsx b/src/screens/change-wallet/PinnedWalletsGrid.tsx index 41d1c6600d8..8af85e2ccc3 100644 --- a/src/screens/change-wallet/PinnedWalletsGrid.tsx +++ b/src/screens/change-wallet/PinnedWalletsGrid.tsx @@ -82,7 +82,10 @@ export function PinnedWalletsGrid({ walletItems, onPress, editMode, menuItems, o wrap={(children: React.ReactElement) => ( triggerAction="longPress" - menuConfig={{ menuItems, menuTitle: walletName }} + menuConfig={{ + menuItems: menuItems.filter(item => (account.isReadOnly ? item.actionKey !== AddressMenuAction.Settings : true)), + menuTitle: walletName, + }} onPressMenuItem={action => onPressMenuItem(action, { address: account.address })} > onPress(account.address)}> From 8ed6aecee38db798f28f9a73d6018f06a174e64d Mon Sep 17 00:00:00 2001 From: Kane Thomas Date: Tue, 17 Dec 2024 13:03:12 -0500 Subject: [PATCH 21/54] localization --- src/components/change-wallet/WalletList.tsx | 3 ++- src/languages/en_US.json | 16 +++++++++++++++- src/screens/change-wallet/ChangeWalletSheet.tsx | 17 ++++++++--------- 3 files changed, 25 insertions(+), 11 deletions(-) diff --git a/src/components/change-wallet/WalletList.tsx b/src/components/change-wallet/WalletList.tsx index 6f79b79619b..a8e926d65eb 100644 --- a/src/components/change-wallet/WalletList.tsx +++ b/src/components/change-wallet/WalletList.tsx @@ -1,6 +1,7 @@ import React, { useCallback, useEffect, useLayoutEffect, useMemo, useState } from 'react'; import { StyleSheet, View } from 'react-native'; import Animated, { Easing, useAnimatedStyle, useSharedValue, withTiming } from 'react-native-reanimated'; +import * as i18n from '@/languages'; import { EmptyAssetList } from '../asset-list'; import { AddressRow } from './AddressRow'; import { EthereumAddress } from '@rainbow-me/entities'; @@ -125,7 +126,7 @@ export function WalletList({ walletItems, menuItems, onPressMenuItem, onPressAcc - {'All Wallets'} + {i18n.t(i18n.l.wallet.change_wallet.all_wallets)} diff --git a/src/languages/en_US.json b/src/languages/en_US.json index e49299f792b..71d4bfd5b4d 100644 --- a/src/languages/en_US.json +++ b/src/languages/en_US.json @@ -2528,7 +2528,21 @@ "loading_balance": "Loading Balance...", "balance_eth": "%{balanceEth} ETH", "watching": "Watching", - "ledger": "Ledger" + "ledger": "Ledger", + "wallets": "Wallets", + "all_wallets": "All Wallets", + "total_balance": "Total Balance", + "edit_hint_tooltip": { + "title": "Customize Your Wallets", + "subtitle": "Tap the Edit button above to set up" + }, + "address_menu": { + "edit": "Edit Wallet", + "copy": "Copy Address", + "settings": "Wallet Settings", + "notifications": "Notification Settings", + "remove": "Remove Wallet" + } }, "connected_apps": "Connected Apps", "copy": "Copy", diff --git a/src/screens/change-wallet/ChangeWalletSheet.tsx b/src/screens/change-wallet/ChangeWalletSheet.tsx index 4a1ea2d1aa2..5edf8eb3b8b 100644 --- a/src/screens/change-wallet/ChangeWalletSheet.tsx +++ b/src/screens/change-wallet/ChangeWalletSheet.tsx @@ -483,8 +483,7 @@ export default function ChangeWalletSheet() { let menuItems = [ { actionKey: AddressMenuAction.Edit, - // TODO: localize - actionTitle: 'Edit Wallet', + actionTitle: i18n.t(i18n.l.wallet.change_wallet.address_menu.edit), icon: { iconType: 'SYSTEM', iconValue: 'pencil', @@ -492,7 +491,7 @@ export default function ChangeWalletSheet() { }, { actionKey: AddressMenuAction.Copy, - actionTitle: 'Copy Address', + actionTitle: i18n.t(i18n.l.wallet.change_wallet.address_menu.copy), icon: { iconType: 'SYSTEM', iconValue: 'doc.fill', @@ -500,7 +499,7 @@ export default function ChangeWalletSheet() { }, { actionKey: AddressMenuAction.Settings, - actionTitle: 'Wallet Settings', + actionTitle: i18n.t(i18n.l.wallet.change_wallet.address_menu.settings), icon: { iconType: 'SYSTEM', iconValue: 'key.fill', @@ -508,7 +507,7 @@ export default function ChangeWalletSheet() { }, { actionKey: AddressMenuAction.Notifications, - actionTitle: 'Notification Settings', + actionTitle: i18n.t(i18n.l.wallet.change_wallet.address_menu.notifications), icon: { iconType: 'SYSTEM', iconValue: 'bell.fill', @@ -516,7 +515,7 @@ export default function ChangeWalletSheet() { }, { actionKey: AddressMenuAction.Remove, - actionTitle: 'Remove Wallet', + actionTitle: i18n.t(i18n.l.wallet.change_wallet.address_menu.remove), destructive: true, icon: { iconType: 'SYSTEM', @@ -586,7 +585,7 @@ export default function ChangeWalletSheet() { - {'Wallets'} + {i18n.t(i18n.l.wallet.change_wallet.wallets)} {/* TODO: this positioning is jank */} @@ -599,7 +598,7 @@ export default function ChangeWalletSheet() { align="end" alignOffset={18} sideOffset={12} - title="Customize Your Wallets" + title={i18n.t(i18n.l.wallet.change_wallet.edit_hint_tooltip.title)} SubtitleComponent={ @@ -656,7 +655,7 @@ export default function ChangeWalletSheet() { {!editMode ? ( - {'Total Balance'} + {i18n.t(i18n.l.wallet.change_wallet.total_balance)} {ownedWalletsTotalBalance} From 120483c3cc7ab2522a67eb045eb3b8d552cec9e8 Mon Sep 17 00:00:00 2001 From: Kane Thomas Date: Tue, 17 Dec 2024 16:56:12 -0500 Subject: [PATCH 22/54] misc. styling fixes for light mode, android, design spec matching --- src/components/change-wallet/AddressRow.tsx | 137 +++++++++--------- src/components/change-wallet/WalletList.tsx | 11 +- .../change-wallet/ChangeWalletSheet.tsx | 21 ++- .../change-wallet/PinnedWalletsGrid.tsx | 42 +++--- 4 files changed, 103 insertions(+), 108 deletions(-) diff --git a/src/components/change-wallet/AddressRow.tsx b/src/components/change-wallet/AddressRow.tsx index 17bea7f14ca..3892777410b 100644 --- a/src/components/change-wallet/AddressRow.tsx +++ b/src/components/change-wallet/AddressRow.tsx @@ -114,85 +114,82 @@ export function AddressRow({ data, editMode, onPress, menuItems, onPressMenuItem menuConfig={menuConfig} onPressMenuItem={action => onPressMenuItem(action, { address })} > - + {children} )} > - - - - {editMode && ( - - {/* Fix on light mode */} - - - )} + + + {editMode && ( - + - - - {walletName} - - - {secondaryLabel} - - - - - {isReadOnly && - (!editMode ? ( - + )} + + + + + + {walletName} + + + {secondaryLabel} + + + + + {isReadOnly && + (!editMode ? ( + + + {i18n.t(i18n.l.wallet.change_wallet.watching)} + + + ) : ( + + 􀋮 + + ))} + {isLedger && + (!editMode ? ( + + + + 􀤃 + - {i18n.t(i18n.l.wallet.change_wallet.watching)} + {i18n.t(i18n.l.wallet.change_wallet.ledger)} - - ) : ( - - 􀋮 - - ))} - {isLedger && - (!editMode ? ( - - - - 􀤃 - - - {i18n.t(i18n.l.wallet.change_wallet.ledger)} - - - - ) : ( - - 􀤃 - - ))} - {!editMode && isSelected && } - {editMode && ( - addPinnedAddress(address)} color={colors.appleBlue} icon="􀎧" size="icon 12px" /> - )} - {editMode && ( - - menuConfig={menuConfig} - onPressMenuItem={action => onPressMenuItem(action, { address })} - > - - - )} - - - - + + + ) : ( + + 􀤃 + + ))} + {!editMode && isSelected && } + {editMode && ( + addPinnedAddress(address)} color={colors.appleBlue} icon="􀎧" size="icon 12px" /> + )} + {editMode && ( + + menuConfig={menuConfig} + onPressMenuItem={action => onPressMenuItem(action, { address })} + > + + + )} + + + ); diff --git a/src/components/change-wallet/WalletList.tsx b/src/components/change-wallet/WalletList.tsx index a8e926d65eb..c85fe951d41 100644 --- a/src/components/change-wallet/WalletList.tsx +++ b/src/components/change-wallet/WalletList.tsx @@ -14,8 +14,9 @@ import { FOOTER_HEIGHT, MAX_PANEL_HEIGHT, PANEL_HEADER_HEIGHT, + PANEL_INSET_HORIZONTAL, } from '@/screens/change-wallet/ChangeWalletSheet'; -import { Box, Inset, Separator, Text } from '@/design-system'; +import { Box, Separator, Text } from '@/design-system'; import { DndProvider, Draggable, DraggableFlatListProps, UniqueIdentifier } from '../drag-and-drop'; import { PinnedWalletsGrid } from '@/screens/change-wallet/PinnedWalletsGrid'; import { usePinnedWalletsStore } from '@/state/wallets/pinnedWalletsStore'; @@ -121,10 +122,8 @@ export function WalletList({ walletItems, menuItems, onPressMenuItem, onPressAcc )} {hasPinnedWallets && unpinnedWalletItems.length > 0 && ( <> - - - - + + {i18n.t(i18n.l.wallet.change_wallet.all_wallets)} @@ -166,7 +165,7 @@ export function WalletList({ walletItems, menuItems, onPressMenuItem, onPressAcc diff --git a/src/screens/change-wallet/ChangeWalletSheet.tsx b/src/screens/change-wallet/ChangeWalletSheet.tsx index 5edf8eb3b8b..37e394d4645 100644 --- a/src/screens/change-wallet/ChangeWalletSheet.tsx +++ b/src/screens/change-wallet/ChangeWalletSheet.tsx @@ -15,13 +15,13 @@ import { analytics, analyticsV2 } from '@/analytics'; import { useAccountSettings, useInitializeWallet, useWallets, useWalletsWithBalancesAndNames, useWebData } from '@/hooks'; import Routes from '@/navigation/routesNames'; import styled from '@/styled-thing'; -import { doesWalletsContainAddress, showActionSheetWithOptions } from '@/utils'; +import { doesWalletsContainAddress, safeAreaInsetValues, showActionSheetWithOptions } from '@/utils'; import { logger, RainbowError } from '@/logger'; import { useTheme } from '@/theme'; import { EthereumAddress } from '@/entities'; import { getNotificationSettingsForWalletWithAddress } from '@/notifications/settings/storage'; import { DEVICE_HEIGHT } from '@/utils/deviceUtils'; -import { IS_ANDROID } from '@/env'; +import { IS_ANDROID, IS_IOS } from '@/env'; import { remotePromoSheetsStore } from '@/state/remotePromoSheets/remotePromoSheets'; import { RootStackParamList } from '@/navigation/types'; import { Box, globalColors, Inline, Stack, Text } from '@/design-system'; @@ -41,10 +41,9 @@ const LIST_PADDING_BOTTOM = 6; const MAX_LIST_HEIGHT = DEVICE_HEIGHT - 220; const WALLET_ROW_HEIGHT = 59; const WATCH_ONLY_BOTTOM_PADDING = IS_ANDROID ? 20 : 0; +const PANEL_BOTTOM_OFFSET = Math.max(safeAreaInsetValues.bottom + 5, IS_IOS ? 8 : 30); -// TODO: calc -const PANEL_BOTTOM_OFFSET = 41; - +export const PANEL_INSET_HORIZONTAL = 20; export const MAX_PANEL_HEIGHT = 640; export const PANEL_HEADER_HEIGHT = 58; export const FOOTER_HEIGHT = 91; @@ -98,7 +97,7 @@ export default function ChangeWalletSheet() { const { selectedWallet, wallets } = useWallets(); const notificationsEnabled = useExperimentalFlag(NOTIFICATIONS); - const { colors } = useTheme(); + const { colors, isDarkMode } = useTheme(); const { updateWebProfile } = useWebData(); const { accountAddress, nativeCurrency } = useAccountSettings(); const { goBack, navigate, setParams } = useNavigation(); @@ -581,14 +580,14 @@ export default function ChangeWalletSheet() { ]} > - + {i18n.t(i18n.l.wallet.change_wallet.wallets)} {/* TODO: this positioning is jank */} - + ( @@ -626,7 +625,7 @@ export default function ChangeWalletSheet() { {/* TODO: why is this here? */} - {IS_ANDROID && } + {/* {IS_ANDROID && } */} {/* TODO: progressive blurview on iOS */} diff --git a/src/screens/change-wallet/PinnedWalletsGrid.tsx b/src/screens/change-wallet/PinnedWalletsGrid.tsx index 8af85e2ccc3..d2e3e59792b 100644 --- a/src/screens/change-wallet/PinnedWalletsGrid.tsx +++ b/src/screens/change-wallet/PinnedWalletsGrid.tsx @@ -1,7 +1,7 @@ import { Draggable, DraggableGrid, DraggableGridProps, UniqueIdentifier } from '@/components/drag-and-drop'; import { Box, Inline, Stack, Text } from '@/design-system'; import React, { useCallback, useMemo } from 'react'; -import { AddressItem, AddressMenuAction, AddressMenuActionData } from './ChangeWalletSheet'; +import { AddressItem, AddressMenuAction, AddressMenuActionData, PANEL_INSET_HORIZONTAL } from './ChangeWalletSheet'; import { AddressAvatar } from './AddressAvatar'; import { ButtonPressAnimation } from '@/components/animations'; import { BlurView } from '@react-native-community/blur'; @@ -13,12 +13,13 @@ import ConditionalWrap from 'conditional-wrap'; import { address } from '@/utils/abbreviations'; import { removeFirstEmojiFromString } from '@/helpers/emojiHandler'; import { PANEL_WIDTH } from '@/components/SmoothPager/ListPanel'; +import { IS_IOS } from '@/env'; +import { useTheme } from '@/theme'; const UNPIN_BADGE_SIZE = 28; const PINS_PER_ROW = 3; -const GRID_GAP = 28; +const GRID_GAP = 26; const MAX_AVATAR_SIZE = 91; -const PAGE_INSET_HORIZONTAL = 24; type PinnedWalletsGridProps = { walletItems: AddressItem[]; @@ -29,6 +30,8 @@ type PinnedWalletsGridProps = { }; export function PinnedWalletsGrid({ walletItems, onPress, editMode, menuItems, onPressMenuItem }: PinnedWalletsGridProps) { + const { colors, isDarkMode } = useTheme(); + const removePinnedAddress = usePinnedWalletsStore(state => state.removePinnedAddress); const reorderPinnedAddresses = usePinnedWalletsStore(state => state.reorderPinnedAddresses); @@ -51,18 +54,12 @@ export function PinnedWalletsGrid({ walletItems, onPress, editMode, menuItems, o }, [walletItems.length]); const avatarSize = useMemo( - () => Math.min((PANEL_WIDTH - PAGE_INSET_HORIZONTAL * 2 - GRID_GAP * (PINS_PER_ROW - 1)) / PINS_PER_ROW, MAX_AVATAR_SIZE), + () => Math.min((PANEL_WIDTH - PANEL_INSET_HORIZONTAL * 2 - GRID_GAP * (PINS_PER_ROW - 1)) / PINS_PER_ROW, MAX_AVATAR_SIZE), [] ); return ( - + {draggableItems.map(account => { @@ -103,19 +101,12 @@ export function PinnedWalletsGrid({ walletItems, onPress, editMode, menuItems, o removePinnedAddress(account.address)}> {'􀅽'} From ccbf0961aa8024d3ff2a0034832a8783802304f1 Mon Sep 17 00:00:00 2001 From: Kane Thomas Date: Tue, 17 Dec 2024 19:21:05 -0500 Subject: [PATCH 23/54] update dropdown menu component to work for checkbox & regular items --- src/components/DropdownMenu.tsx | 18 ++++++++++++++---- src/components/cards/MintsCard/Menu.tsx | 2 +- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/src/components/DropdownMenu.tsx b/src/components/DropdownMenu.tsx index 3c54359561e..a426e3cb286 100644 --- a/src/components/DropdownMenu.tsx +++ b/src/components/DropdownMenu.tsx @@ -11,6 +11,12 @@ export const DropdownMenuRoot = DropdownMenuPrimitive.Root; export const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger; export const DropdownMenuContent = DropdownMenuPrimitive.Content; export const DropdownMenuItem = DropdownMenuPrimitive.create( + styled(DropdownMenuPrimitive.Item)({ + height: 34, + }), + 'Item' +); +export const DropdownMenuCheckboxItem = DropdownMenuPrimitive.create( styled(DropdownMenuPrimitive.CheckboxItem)({ height: 34, }), @@ -50,6 +56,7 @@ type DropDownMenuProps = nev onPressMenuItem: (actionKey: T) => void; triggerAction?: 'press' | 'longPress'; data?: U; + menuItemType?: 'checkbox'; } & DropdownMenuContentProps; const buildIconConfig = (icon?: MenuItemIcon) => { @@ -80,6 +87,7 @@ export function DropdownMenu alignOffset = 5, avoidCollisions = true, triggerAction = 'press', + menuItemType, }: DropDownMenuProps) { const handleSelectItem = useCallback( (actionKey: T) => { @@ -92,6 +100,8 @@ export function DropdownMenu [onPressMenuItem, data] ); + const MenuItemComponent = menuItemType === 'checkbox' ? DropdownMenuCheckboxItem : DropdownMenuItem; + return ( {children} @@ -106,16 +116,16 @@ export function DropdownMenu > {!!menuConfig.menuTitle?.trim() && ( - + {menuConfig.menuTitle} - + )} {menuConfig.menuItems?.map(item => { const Icon = buildIconConfig(item.icon as MenuItemIcon); return ( - > {item.actionTitle} {Icon} - + ); })} diff --git a/src/components/cards/MintsCard/Menu.tsx b/src/components/cards/MintsCard/Menu.tsx index de1defea2f2..d7b48a975e4 100644 --- a/src/components/cards/MintsCard/Menu.tsx +++ b/src/components/cards/MintsCard/Menu.tsx @@ -39,7 +39,7 @@ export function Menu() { ); return ( - menuConfig={menuConfig} onPressMenuItem={onPressMenuItem}> + menuItemType="checkbox" menuConfig={menuConfig} onPressMenuItem={onPressMenuItem}> From 8b63d337eda3a6c92a65290658a11cb3e97f7fcb Mon Sep 17 00:00:00 2001 From: Kane Thomas Date: Tue, 17 Dec 2024 20:18:13 -0500 Subject: [PATCH 24/54] tooltip localization --- src/languages/en_US.json | 6 +++++- src/screens/change-wallet/ChangeWalletSheet.tsx | 7 ++++--- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/src/languages/en_US.json b/src/languages/en_US.json index 71d4bfd5b4d..67cb2e72686 100644 --- a/src/languages/en_US.json +++ b/src/languages/en_US.json @@ -2534,7 +2534,11 @@ "total_balance": "Total Balance", "edit_hint_tooltip": { "title": "Customize Your Wallets", - "subtitle": "Tap the Edit button above to set up" + "subtitle": { + "prefix": "Tap the", + "action": "Edit", + "suffix": "button above to set up" + } }, "address_menu": { "edit": "Edit Wallet", diff --git a/src/screens/change-wallet/ChangeWalletSheet.tsx b/src/screens/change-wallet/ChangeWalletSheet.tsx index 37e394d4645..d8816034bd9 100644 --- a/src/screens/change-wallet/ChangeWalletSheet.tsx +++ b/src/screens/change-wallet/ChangeWalletSheet.tsx @@ -36,6 +36,7 @@ import { usePinnedWalletsStore } from '@/state/wallets/pinnedWalletsStore'; import ConditionalWrap from 'conditional-wrap'; import Clipboard from '@react-native-clipboard/clipboard'; import { SettingsPages } from '../SettingsSheet/SettingsPages'; +import Animated, { LinearTransition } from 'react-native-reanimated'; const LIST_PADDING_BOTTOM = 6; const MAX_LIST_HEIGHT = DEVICE_HEIGHT - 220; @@ -601,13 +602,13 @@ export default function ChangeWalletSheet() { SubtitleComponent={ - {'Tap the'} + {i18n.t(i18n.l.wallet.change_wallet.edit_hint_tooltip.subtitle.prefix)} - {' Edit '} + {` ${i18n.t(i18n.l.wallet.change_wallet.edit_hint_tooltip.subtitle.action)} `} - {'button above to set up'} + {i18n.t(i18n.l.wallet.change_wallet.edit_hint_tooltip.subtitle.suffix)} } From 45c4b1a0e5b290085bb6ec8676241f9d538cce46 Mon Sep 17 00:00:00 2001 From: Kane Thomas Date: Tue, 17 Dec 2024 20:25:48 -0500 Subject: [PATCH 25/54] remove unused wallet item fields --- src/components/change-wallet/AddressRow.tsx | 69 +++++++++++-------- .../change-wallet/ChangeWalletSheet.tsx | 14 ++-- .../change-wallet/PinnedWalletsGrid.tsx | 2 +- 3 files changed, 44 insertions(+), 41 deletions(-) diff --git a/src/components/change-wallet/AddressRow.tsx b/src/components/change-wallet/AddressRow.tsx index 3892777410b..1b19b2bf788 100644 --- a/src/components/change-wallet/AddressRow.tsx +++ b/src/components/change-wallet/AddressRow.tsx @@ -4,7 +4,7 @@ import LinearGradient from 'react-native-linear-gradient'; import { useTheme } from '../../theme/ThemeContext'; import { ButtonPressAnimation } from '../animations'; import ConditionalWrap from 'conditional-wrap'; -import { Box, Column, Columns, Inline, Stack, Text, Inset, useForegroundColor, useColorMode, TextIcon } from '@/design-system'; +import { Box, Column, Columns, Inline, Stack, Text, useForegroundColor, useColorMode, TextIcon } from '@/design-system'; import { AddressItem, AddressMenuAction, AddressMenuActionData } from '@/screens/change-wallet/ChangeWalletSheet'; import { TextSize } from '@/design-system/typography/typeHierarchy'; import { TextWeight } from '@/design-system/components/Text/Text'; @@ -46,6 +46,7 @@ export const AddressRowButton = ({ height={{ custom: 28 }} justifyContent="center" style={{ + // eslint-disable-next-line no-nested-ternary backgroundColor: color ? opacity(color, isDarkMode ? 0.16 : 0.25) : isDarkMode ? fillQuaternary : opacity(fillTertiary, 0.04), }} width={{ custom: 28 }} @@ -72,7 +73,7 @@ interface AddressRowProps { } export function AddressRow({ data, editMode, onPress, menuItems, onPressMenuItem }: AddressRowProps) { - const { address, color, secondaryLabel, isSelected, isReadOnly, isLedger, label, image } = data; + const { address, color, balance, isSelected, isReadOnly, isLedger, label, image } = data; const walletName = useMemo(() => { return removeFirstEmojiFromString(label) || abbreviateAddress(address, 4, 6); @@ -141,40 +142,48 @@ export function AddressRow({ data, editMode, onPress, menuItems, onPressMenuItem {walletName} - {secondaryLabel} + {balance} - {isReadOnly && - (!editMode ? ( - - - {i18n.t(i18n.l.wallet.change_wallet.watching)} - - - ) : ( - - 􀋮 - - ))} - {isLedger && - (!editMode ? ( - - - - 􀤃 - + {isReadOnly && ( + <> + {!editMode ? ( + // eslint-disable-next-line react/jsx-props-no-spreading + - {i18n.t(i18n.l.wallet.change_wallet.ledger)} + {i18n.t(i18n.l.wallet.change_wallet.watching)} - - - ) : ( - - 􀤃 - - ))} + + ) : ( + + 􀋮 + + )} + + )} + {isLedger && ( + <> + {!editMode ? ( + // eslint-disable-next-line react/jsx-props-no-spreading + + + + 􀤃 + + + {i18n.t(i18n.l.wallet.change_wallet.ledger)} + + + + ) : ( + + 􀤃 + + )} + + )} {!editMode && isSelected && } {editMode && ( addPinnedAddress(address)} color={colors.appleBlue} icon="􀎧" size="icon 12px" /> diff --git a/src/screens/change-wallet/ChangeWalletSheet.tsx b/src/screens/change-wallet/ChangeWalletSheet.tsx index d8816034bd9..962ac230fe7 100644 --- a/src/screens/change-wallet/ChangeWalletSheet.tsx +++ b/src/screens/change-wallet/ChangeWalletSheet.tsx @@ -75,19 +75,16 @@ const Whitespace = styled(View)({ }); export interface AddressItem { + id: EthereumAddress; address: EthereumAddress; color: number; - editMode: boolean; - height: number; - id: EthereumAddress; - isOnlyAddress: boolean; isReadOnly: boolean; isLedger: boolean; isSelected: boolean; label: string; - secondaryLabel: string; rowType: number; walletId: string; + balance: string; image: string | null | undefined; } @@ -148,11 +145,8 @@ export default function ChangeWalletSheet() { address: account.address, image: account.image, color: account.color, - editMode, - height: WALLET_ROW_HEIGHT, label: account.label, - secondaryLabel: balanceText, - isOnlyAddress: visibleAccounts.length === 1, + balance: balanceText, isLedger: wallet.type === WalletTypes.bluetooth, isReadOnly: wallet.type === WalletTypes.readOnly, isSelected: account.address === currentAddress, @@ -172,7 +166,7 @@ export default function ChangeWalletSheet() { // sorts by order wallets were added return [...sortedWallets, ...bluetoothWallets, ...readOnlyWallets].sort((a, b) => a.walletId.localeCompare(b.walletId)); - }, [walletsWithBalancesAndNames, currentAddress, editMode]); + }, [walletsWithBalancesAndNames, currentAddress]); const ownedWalletsTotalBalance = useMemo(() => { let isLoadingBalance = false; diff --git a/src/screens/change-wallet/PinnedWalletsGrid.tsx b/src/screens/change-wallet/PinnedWalletsGrid.tsx index d2e3e59792b..7b2ab764809 100644 --- a/src/screens/change-wallet/PinnedWalletsGrid.tsx +++ b/src/screens/change-wallet/PinnedWalletsGrid.tsx @@ -188,7 +188,7 @@ export function PinnedWalletsGrid({ walletItems, onPress, editMode, menuItems, o - {account.secondaryLabel} + {account.balance} From 959b774792eb5cc705d2fb9ad149755d3705afed Mon Sep 17 00:00:00 2001 From: Kane Thomas Date: Tue, 17 Dec 2024 21:50:43 -0500 Subject: [PATCH 26/54] auto pin addresses --- src/components/change-wallet/WalletList.tsx | 4 +-- .../change-wallet/ChangeWalletSheet.tsx | 26 +++++++++++++++++-- .../change-wallet/PinnedWalletsGrid.tsx | 2 +- src/state/wallets/pinnedWalletsStore.ts | 23 +++++++++------- 4 files changed, 41 insertions(+), 14 deletions(-) diff --git a/src/components/change-wallet/WalletList.tsx b/src/components/change-wallet/WalletList.tsx index c85fe951d41..1e796ac8977 100644 --- a/src/components/change-wallet/WalletList.tsx +++ b/src/components/change-wallet/WalletList.tsx @@ -66,7 +66,7 @@ export function WalletList({ walletItems, menuItems, onPressMenuItem, onPressAcc const opacityAnimation = useSharedValue(walletItems.length ? 1 : 0); const emptyOpacityAnimation = useSharedValue(walletItems.length ? 0 : 1); - const reorderUnpinnedAddresses = usePinnedWalletsStore(state => state.reorderUnpinnedAddresses); + const reorderUnpinnedAddresses = usePinnedWalletsStore(state => state.setUnpinnedAddresses); // TODO: convert the effect below into an animated reaction useEffect(() => { @@ -137,7 +137,7 @@ export function WalletList({ walletItems, menuItems, onPressMenuItem, onPressAcc const renderScrollItem = useCallback( (item: AddressItem) => ( - + >(); const { onChangeWallet, watchOnly = false, currentAccountAddress } = params; @@ -98,17 +99,23 @@ export default function ChangeWalletSheet() { const { colors, isDarkMode } = useTheme(); const { updateWebProfile } = useWebData(); const { accountAddress, nativeCurrency } = useAccountSettings(); - const { goBack, navigate, setParams } = useNavigation(); + const { goBack, navigate } = useNavigation(); const dispatch = useDispatch(); const initializeWallet = useInitializeWallet(); const walletsWithBalancesAndNames = useWalletsWithBalancesAndNames(); + const initialHasShownEditHintTooltip = useMemo(() => usePinnedWalletsStore.getState().hasShownEditHintTooltip, []); + const initialHasAutoPinnedAddresses = useMemo(() => usePinnedWalletsStore.getState().hasAutoPinnedAddresses, []); + const initialPinnedAddressCount = useMemo(() => usePinnedWalletsStore.getState().pinnedAddresses.length, []); + const featureHintTooltipRef = useRef(null); const [editMode, setEditMode] = useState(false); const [currentAddress, setCurrentAddress] = useState(currentAccountAddress || accountAddress); const [currentSelectedWallet, setCurrentSelectedWallet] = useState(selectedWallet); + const setPinnedAddresses = usePinnedWalletsStore(state => state.setPinnedAddresses); + // Feature hint tooltip should only ever been shown once. useEffect(() => { if (!initialHasShownEditHintTooltip) { @@ -168,6 +175,21 @@ export default function ChangeWalletSheet() { return [...sortedWallets, ...bluetoothWallets, ...readOnlyWallets].sort((a, b) => a.walletId.localeCompare(b.walletId)); }, [walletsWithBalancesAndNames, currentAddress]); + // On first use of this feature, auto-pin the users most used owned addresses + useEffect(() => { + if (initialHasAutoPinnedAddresses || initialPinnedAddressCount > 0) return; + + // TODO: this is a placeholder until backend adds the info needed in the return of the summary endpoint + const pinnableAddresses = allWalletItems.filter(item => !item.isReadOnly).map(item => item.address); + + // sanity check, there should always be at least one pinnable address + if (pinnableAddresses.length === 0) return; + + const addressesToAutoPin = pinnableAddresses.slice(0, MAX_PINNED_ADDRESSES); + + setPinnedAddresses(addressesToAutoPin); + }, [allWalletItems, setPinnedAddresses, initialHasAutoPinnedAddresses, initialPinnedAddressCount]); + const ownedWalletsTotalBalance = useMemo(() => { let isLoadingBalance = false; diff --git a/src/screens/change-wallet/PinnedWalletsGrid.tsx b/src/screens/change-wallet/PinnedWalletsGrid.tsx index 7b2ab764809..471ec9c1295 100644 --- a/src/screens/change-wallet/PinnedWalletsGrid.tsx +++ b/src/screens/change-wallet/PinnedWalletsGrid.tsx @@ -33,7 +33,7 @@ export function PinnedWalletsGrid({ walletItems, onPress, editMode, menuItems, o const { colors, isDarkMode } = useTheme(); const removePinnedAddress = usePinnedWalletsStore(state => state.removePinnedAddress); - const reorderPinnedAddresses = usePinnedWalletsStore(state => state.reorderPinnedAddresses); + const reorderPinnedAddresses = usePinnedWalletsStore(state => state.setPinnedAddresses); const onOrderChange: DraggableGridProps['onOrderChange'] = useCallback( (value: UniqueIdentifier[]) => { diff --git a/src/state/wallets/pinnedWalletsStore.ts b/src/state/wallets/pinnedWalletsStore.ts index 8e50bee3c0e..0bac7db3f0c 100644 --- a/src/state/wallets/pinnedWalletsStore.ts +++ b/src/state/wallets/pinnedWalletsStore.ts @@ -1,7 +1,6 @@ import { createRainbowStore } from '@/state/internal/createRainbowStore'; -const MIN_WALLETS_TO_SHOW_PINNING = 6; -const MAX_PINNED_WALLETS = 6; +export const MAX_PINNED_ADDRESSES = 6; // TODO: fix type Address = string; @@ -10,11 +9,12 @@ interface PinnedWalletsStore { pinnedAddresses: Address[]; unpinnedAddresses: Address[]; hasShownEditHintTooltip: boolean; + hasAutoPinnedAddresses: boolean; canPinAddresses: () => boolean; addPinnedAddress: (address: Address) => void; removePinnedAddress: (address: Address) => void; - reorderPinnedAddresses: (newOrder: Address[]) => void; - reorderUnpinnedAddresses: (newOrder: Address[]) => void; + setPinnedAddresses: (newOrder: Address[]) => void; + setUnpinnedAddresses: (newOrder: Address[]) => void; isPinnedAddress: (address: Address) => boolean; } @@ -23,10 +23,10 @@ export const usePinnedWalletsStore = createRainbowStore( pinnedAddresses: [], unpinnedAddresses: [], hasShownEditHintTooltip: false, + hasAutoPinnedAddresses: false, canPinAddresses: () => { - const { pinnedAddresses } = get(); - return pinnedAddresses.length >= MIN_WALLETS_TO_SHOW_PINNING && pinnedAddresses.length < MAX_PINNED_WALLETS; + return get().pinnedAddresses.length < MAX_PINNED_ADDRESSES; }, isPinnedAddress: address => { @@ -36,7 +36,7 @@ export const usePinnedWalletsStore = createRainbowStore( addPinnedAddress: address => { const { pinnedAddresses } = get(); - if (pinnedAddresses.length >= MAX_PINNED_WALLETS) return; + if (pinnedAddresses.length >= MAX_PINNED_ADDRESSES) return; set({ pinnedAddresses: [...pinnedAddresses, address] }); }, @@ -51,11 +51,16 @@ export const usePinnedWalletsStore = createRainbowStore( } }, - reorderPinnedAddresses: newPinnedAddresses => { + setPinnedAddresses: newPinnedAddresses => { + // TODO: this batches the state update right? + if (!get().hasAutoPinnedAddresses) { + set({ hasAutoPinnedAddresses: true }); + } + set({ pinnedAddresses: newPinnedAddresses }); }, - reorderUnpinnedAddresses: newUnpinnedAddresses => { + setUnpinnedAddresses: newUnpinnedAddresses => { set({ unpinnedAddresses: newUnpinnedAddresses }); }, }), From 1ec444605a7469e01cbbb6b5a5071ea86058e9f3 Mon Sep 17 00:00:00 2001 From: Kane Thomas Date: Tue, 17 Dec 2024 22:38:09 -0500 Subject: [PATCH 27/54] refactor wallet list loading animation transition --- src/components/change-wallet/WalletList.tsx | 84 +++++++-------------- 1 file changed, 26 insertions(+), 58 deletions(-) diff --git a/src/components/change-wallet/WalletList.tsx b/src/components/change-wallet/WalletList.tsx index 1e796ac8977..2a7cc8b5be4 100644 --- a/src/components/change-wallet/WalletList.tsx +++ b/src/components/change-wallet/WalletList.tsx @@ -1,8 +1,8 @@ -import React, { useCallback, useEffect, useLayoutEffect, useMemo, useState } from 'react'; +import React, { useCallback, useMemo } from 'react'; import { StyleSheet, View } from 'react-native'; -import Animated, { Easing, useAnimatedStyle, useSharedValue, withTiming } from 'react-native-reanimated'; +import Animated, { FadeIn, FadeOut } from 'react-native-reanimated'; import * as i18n from '@/languages'; -import { EmptyAssetList } from '../asset-list'; +import { EmptyAssetList } from '@/components/asset-list'; import { AddressRow } from './AddressRow'; import { EthereumAddress } from '@rainbow-me/entities'; import styled from '@/styled-thing'; @@ -17,15 +17,14 @@ import { PANEL_INSET_HORIZONTAL, } from '@/screens/change-wallet/ChangeWalletSheet'; import { Box, Separator, Text } from '@/design-system'; -import { DndProvider, Draggable, DraggableFlatListProps, UniqueIdentifier } from '../drag-and-drop'; +import { DndProvider, Draggable, DraggableFlatListProps, UniqueIdentifier } from '@/components/drag-and-drop'; import { PinnedWalletsGrid } from '@/screens/change-wallet/PinnedWalletsGrid'; import { usePinnedWalletsStore } from '@/state/wallets/pinnedWalletsStore'; import { MenuItem } from '@/components/DropdownMenu'; import { DraggableScrollView } from '@/components/drag-and-drop/components/DraggableScrollView'; const DRAG_ACTIVATION_DELAY = 150; -const LIST_TOP_PADDING = 7.5; -const TRANSITION_DURATION = 75; +const FADE_TRANSITION_DURATION = 75; const LIST_MAX_HEIGHT = MAX_PANEL_HEIGHT - PANEL_HEADER_HEIGHT; const EmptyWalletList = styled(EmptyAssetList).attrs({ @@ -33,8 +32,7 @@ const EmptyWalletList = styled(EmptyAssetList).attrs({ pointerEvents: 'none', })({ ...position.coverAsObject, - backgroundColor: ({ theme: { colors } }: any) => colors.white, - paddingTop: LIST_TOP_PADDING, + paddingTop: 7.5, }); interface Props { @@ -62,42 +60,8 @@ export function WalletList({ walletItems, menuItems, onPressMenuItem, onPressAcc .sort((a, b) => unpinnedAddresses.indexOf(a.id) - unpinnedAddresses.indexOf(b.id)); }, [walletItems, pinnedAddresses, unpinnedAddresses]); - const [ready, setReady] = useState(false); - const opacityAnimation = useSharedValue(walletItems.length ? 1 : 0); - const emptyOpacityAnimation = useSharedValue(walletItems.length ? 0 : 1); - const reorderUnpinnedAddresses = usePinnedWalletsStore(state => state.setUnpinnedAddresses); - // TODO: convert the effect below into an animated reaction - useEffect(() => { - if (walletItems.length && !ready) { - setTimeout(() => { - setReady(true); - emptyOpacityAnimation.value = withTiming(0, { - duration: TRANSITION_DURATION, - easing: Easing.out(Easing.ease), - }); - }, 50); - } - }, [walletItems, ready, emptyOpacityAnimation]); - - useLayoutEffect(() => { - if (walletItems.length) { - opacityAnimation.value = withTiming(1, { - duration: TRANSITION_DURATION, - easing: Easing.in(Easing.ease), - }); - } - }, [walletItems, opacityAnimation]); - - const opacityStyle = useAnimatedStyle(() => ({ - opacity: opacityAnimation.value, - })); - - const emptyOpacityStyle = useAnimatedStyle(() => ({ - opacity: emptyOpacityAnimation.value, - })); - const onOrderChange: DraggableFlatListProps['onOrderChange'] = useCallback( (value: UniqueIdentifier[]) => { reorderUnpinnedAddresses(value as string[]); @@ -158,22 +122,26 @@ export function WalletList({ walletItems, menuItems, onPressMenuItem, onPressAcc return ( - - - - - - - {renderHeader()} - {draggableUnpinnedWalletItems.map(item => renderScrollItem(item))} - - - + {walletItems.length === 0 && ( + + + + )} + {walletItems.length > 0 && ( + + + + {renderHeader()} + {draggableUnpinnedWalletItems.map(item => renderScrollItem(item))} + + + + )} ); } From d137c4095896c90d8f22518ee83e1c9c9671e5a0 Mon Sep 17 00:00:00 2001 From: Kane Thomas Date: Wed, 18 Dec 2024 10:39:07 -0500 Subject: [PATCH 28/54] autoscroll easing & scroll to end logic fix --- .../features/sort/hooks/useDraggableScroll.ts | 73 +++++++++++-------- 1 file changed, 42 insertions(+), 31 deletions(-) diff --git a/src/components/drag-and-drop/features/sort/hooks/useDraggableScroll.ts b/src/components/drag-and-drop/features/sort/hooks/useDraggableScroll.ts index 7e30bfed5d2..4f5e4c24f82 100644 --- a/src/components/drag-and-drop/features/sort/hooks/useDraggableScroll.ts +++ b/src/components/drag-and-drop/features/sort/hooks/useDraggableScroll.ts @@ -6,15 +6,24 @@ import { useCallback } from 'react'; const AUTOSCROLL_THRESHOLD = 50; const AUTOSCROLL_MIN_SPEED = 1; -const AUTOSCROLL_MAX_SPEED = 3; +const AUTOSCROLL_MAX_SPEED = 5; const AUTOSCROLL_THRESHOLD_MAX_DISTANCE = 100; -function normalizeWorklet(value: number, fromMin: number, fromMax: number, toMin: number, toMax: number) { +function easeInOutCubicWorklet(x: number): number { 'worklet'; - return ((value - fromMin) * (toMax - toMin)) / (fromMax - fromMin) + toMin; + return x < 0.5 ? 4 * x * x * x : 1 - Math.pow(-2 * x + 2, 3) / 2; } -const canScrollToWorklet = ({ +function getScrollSpeedWorklet(distanceFromThreshold: number): number { + 'worklet'; + const normalizedDistance = Math.min(distanceFromThreshold / AUTOSCROLL_THRESHOLD_MAX_DISTANCE, 1); + + const eased = easeInOutCubicWorklet(normalizedDistance); + + return AUTOSCROLL_MIN_SPEED + (AUTOSCROLL_MAX_SPEED - AUTOSCROLL_MIN_SPEED) * eased; +} + +function getRemainingScrollDistanceWorklet({ newOffset, contentHeight, layoutHeight, @@ -24,11 +33,22 @@ const canScrollToWorklet = ({ contentHeight: number; layoutHeight: number; currentOffset: number; -}) => { +}): number { 'worklet'; const maxOffset = contentHeight - layoutHeight; - return newOffset >= 0 && newOffset <= maxOffset && newOffset !== currentOffset; -}; + + if (newOffset < 0) { + // Distance to scroll back to top + return -currentOffset; + } + + if (newOffset > maxOffset) { + // Distance to scroll to bottom + return maxOffset - currentOffset; + } + + return newOffset - currentOffset; +} export type UseDraggableScrollOptions = Pick< UseDraggableSortOptions, @@ -72,7 +92,6 @@ export const useDraggableScroll = ({ const autoscroll = useCallback( (offset: number) => { 'worklet'; - const smoothedOffset = Math.round(offset); const { value: activeId } = draggableActiveId; if (activeId) { @@ -80,21 +99,25 @@ export const useDraggableScroll = ({ const { value: offsets } = draggableOffsets; const activeLayout = layouts[activeId].value; const activeOffset = offsets[activeId]; - const newOffset = scrollOffset.value + smoothedOffset; + const requestedOffset = scrollOffset.value + offset; + + // ensures we always scroll to the end even if the requested offset would exceed it + const remainingScrollDistance = getRemainingScrollDistanceWorklet({ + newOffset: requestedOffset, + contentHeight: contentHeight.value, + layoutHeight: layoutHeight.value, + currentOffset: scrollOffset.value, + }); if ( - !canScrollToWorklet({ - newOffset, - contentHeight: contentHeight.value, - layoutHeight: layoutHeight.value, - currentOffset: scrollOffset.value, - }) + (offset > 0 && remainingScrollDistance < AUTOSCROLL_MIN_SPEED) || + (offset < 0 && remainingScrollDistance > -AUTOSCROLL_MIN_SPEED) ) { return; } - scrollTo(animatedScrollViewRef, 0, newOffset, false); - activeOffset.y.value += smoothedOffset; + scrollTo(animatedScrollViewRef, 0, scrollOffset.value + remainingScrollDistance, false); + activeOffset.y.value += remainingScrollDistance; draggableActiveLayout.value = applyOffset(activeLayout, { x: activeOffset.x.value, y: activeOffset.y.value, @@ -171,23 +194,11 @@ export const useDraggableScroll = ({ if (isNearTop) { const distanceFromTopThreshold = topThreshold - activeItemY; - const scrollSpeed = normalizeWorklet( - distanceFromTopThreshold, - 0, - AUTOSCROLL_THRESHOLD_MAX_DISTANCE, - AUTOSCROLL_MIN_SPEED, - AUTOSCROLL_MAX_SPEED - ); + const scrollSpeed = getScrollSpeedWorklet(distanceFromTopThreshold); autoscroll(-scrollSpeed); } else if (isNearBottom) { const distanceFromBottomThreshold = activeItemY - bottomThreshold; - const scrollSpeed = normalizeWorklet( - distanceFromBottomThreshold, - 0, - AUTOSCROLL_THRESHOLD_MAX_DISTANCE, - AUTOSCROLL_MIN_SPEED, - AUTOSCROLL_MAX_SPEED - ); + const scrollSpeed = getScrollSpeedWorklet(distanceFromBottomThreshold); autoscroll(scrollSpeed); } } From 746b79b384912d0176f47ec07aa1317edaef3daf Mon Sep 17 00:00:00 2001 From: Kane Thomas Date: Wed, 18 Dec 2024 11:29:16 -0500 Subject: [PATCH 29/54] fix remove pinned address logic --- src/state/wallets/pinnedWalletsStore.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/state/wallets/pinnedWalletsStore.ts b/src/state/wallets/pinnedWalletsStore.ts index 0bac7db3f0c..6d92148a7d3 100644 --- a/src/state/wallets/pinnedWalletsStore.ts +++ b/src/state/wallets/pinnedWalletsStore.ts @@ -42,12 +42,15 @@ export const usePinnedWalletsStore = createRainbowStore( }, removePinnedAddress: address => { - const { pinnedAddresses } = get(); + const { pinnedAddresses, unpinnedAddresses } = get(); const match = pinnedAddresses.find(pinnedAddress => pinnedAddress === address); if (match) { - set({ pinnedAddresses: pinnedAddresses.filter(pinnedAddress => pinnedAddress !== address) }); + set({ + pinnedAddresses: pinnedAddresses.filter(pinnedAddress => pinnedAddress !== address), + unpinnedAddresses: [address, ...unpinnedAddresses], + }); } }, From b4c1691101e328db787d768a15f41f3381e4e62c Mon Sep 17 00:00:00 2001 From: Kane Thomas Date: Wed, 18 Dec 2024 12:43:58 -0500 Subject: [PATCH 30/54] integrate auto-pin wallet feature with backend implementation --- src/hooks/useWalletTransactionCounts.ts | 56 +++++++++++++++++++ src/resources/summary/summary.ts | 27 +++++---- .../change-wallet/ChangeWalletSheet.tsx | 51 +++++++++++------ 3 files changed, 107 insertions(+), 27 deletions(-) create mode 100644 src/hooks/useWalletTransactionCounts.ts diff --git a/src/hooks/useWalletTransactionCounts.ts b/src/hooks/useWalletTransactionCounts.ts new file mode 100644 index 00000000000..1dfe7d9e845 --- /dev/null +++ b/src/hooks/useWalletTransactionCounts.ts @@ -0,0 +1,56 @@ +import { AllRainbowWallets } from '@/model/wallet'; +import { useMemo } from 'react'; +import { Address } from 'viem'; +import useAccountSettings from './useAccountSettings'; +import { useAddysSummary } from '@/resources/summary/summary'; + +const QUERY_CONFIG = { + staleTime: 60_000, // 1 minute + cacheTime: 1000 * 60 * 60 * 24, // 24 hours + refetchInterval: 120_000, // 2 minutes +}; + +export type WalletTransactionCountsResult = { + transactionCounts: Record; + isLoading: boolean; +}; + +/** + * @param wallets - All Rainbow wallets + * @returns Number of transactions originating from Rainbow for each wallet + */ +export const useWalletTransactionCounts = (wallets: AllRainbowWallets): WalletTransactionCountsResult => { + const { nativeCurrency } = useAccountSettings(); + + const allAddresses = useMemo( + () => Object.values(wallets).flatMap(wallet => (wallet.addresses || []).map(account => account.address as Address)), + [wallets] + ); + + const { data: summaryData, isLoading } = useAddysSummary( + { + addresses: allAddresses, + currency: nativeCurrency, + }, + QUERY_CONFIG + ); + + const transactionCounts = useMemo(() => { + const result: Record = {}; + + if (isLoading) return result; + + for (const address of allAddresses) { + const lowerCaseAddress = address.toLowerCase() as Address; + const transactionCount = summaryData?.data?.addresses?.[lowerCaseAddress]?.meta.rainbow?.transactions || 0; + result[lowerCaseAddress] = transactionCount; + } + + return result; + }, [isLoading, allAddresses, summaryData?.data?.addresses]); + + return { + transactionCounts, + isLoading, + }; +}; diff --git a/src/resources/summary/summary.ts b/src/resources/summary/summary.ts index 6f902a391f0..1e264d9d555 100644 --- a/src/resources/summary/summary.ts +++ b/src/resources/summary/summary.ts @@ -16,6 +16,11 @@ interface AddysSummary { data: { addresses: { [key: Address]: { + meta: { + rainbow: { + transactions: number; + }; + }; summary: { native_balance_by_symbol: { [key in 'ETH' | 'MATIC' | 'BNB' | 'AVAX']: { @@ -28,17 +33,17 @@ interface AddysSummary { last_activity: number; asset_value: number | null; }; - }; - summary_by_chain: { - [key: number]: { - native_balance: { - symbol: string; - quantity: string; - decimals: number; + summary_by_chain: { + [key: number]: { + native_balance: { + symbol: string; + quantity: string; + decimals: number; + }; + num_erc20s: number; + last_activity: number; + asset_value: number | null; }; - num_erc20s: number; - last_activity: number; - asset_value: number | null; }; }; }; @@ -57,7 +62,7 @@ export type AddysSummaryArgs = { // Query Key export const addysSummaryQueryKey = ({ addresses, currency }: AddysSummaryArgs) => - createQueryKey('addysSummary', { addresses, currency }, { persisterVersion: 1 }); + createQueryKey('addysSummary', { addresses, currency }, { persisterVersion: 2 }); type AddysSummaryQueryKey = ReturnType; diff --git a/src/screens/change-wallet/ChangeWalletSheet.tsx b/src/screens/change-wallet/ChangeWalletSheet.tsx index a1a8e334585..d6deae76eae 100644 --- a/src/screens/change-wallet/ChangeWalletSheet.tsx +++ b/src/screens/change-wallet/ChangeWalletSheet.tsx @@ -20,8 +20,7 @@ import { logger, RainbowError } from '@/logger'; import { useTheme } from '@/theme'; import { EthereumAddress } from '@/entities'; import { getNotificationSettingsForWalletWithAddress } from '@/notifications/settings/storage'; -import { DEVICE_HEIGHT } from '@/utils/deviceUtils'; -import { IS_ANDROID, IS_IOS } from '@/env'; +import { IS_IOS } from '@/env'; import { remotePromoSheetsStore } from '@/state/remotePromoSheets/remotePromoSheets'; import { RootStackParamList } from '@/navigation/types'; import { Box, globalColors, Inline, Stack, Text } from '@/design-system'; @@ -36,12 +35,8 @@ import { MAX_PINNED_ADDRESSES, usePinnedWalletsStore } from '@/state/wallets/pin import ConditionalWrap from 'conditional-wrap'; import Clipboard from '@react-native-clipboard/clipboard'; import { SettingsPages } from '../SettingsSheet/SettingsPages'; -import Animated, { LinearTransition } from 'react-native-reanimated'; +import { useWalletTransactionCounts } from '@/hooks/useWalletTransactionCounts'; -const LIST_PADDING_BOTTOM = 6; -const MAX_LIST_HEIGHT = DEVICE_HEIGHT - 220; -const WALLET_ROW_HEIGHT = 59; -const WATCH_ONLY_BOTTOM_PADDING = IS_ANDROID ? 20 : 0; const PANEL_BOTTOM_OFFSET = Math.max(safeAreaInsetValues.bottom + 5, IS_IOS ? 8 : 30); export const PANEL_INSET_HORIZONTAL = 20; @@ -89,7 +84,6 @@ export interface AddressItem { } export default function ChangeWalletSheet() { - console.log('CHANGE WALLET SHEET RENDER'); const { params = {} } = useRoute>(); const { onChangeWallet, watchOnly = false, currentAccountAddress } = params; @@ -107,6 +101,7 @@ export default function ChangeWalletSheet() { const initialHasShownEditHintTooltip = useMemo(() => usePinnedWalletsStore.getState().hasShownEditHintTooltip, []); const initialHasAutoPinnedAddresses = useMemo(() => usePinnedWalletsStore.getState().hasAutoPinnedAddresses, []); const initialPinnedAddressCount = useMemo(() => usePinnedWalletsStore.getState().pinnedAddresses.length, []); + const { transactionCounts, isLoading: isLoadingTransactionCounts } = useWalletTransactionCounts(wallets || {}); const featureHintTooltipRef = useRef(null); @@ -175,20 +170,28 @@ export default function ChangeWalletSheet() { return [...sortedWallets, ...bluetoothWallets, ...readOnlyWallets].sort((a, b) => a.walletId.localeCompare(b.walletId)); }, [walletsWithBalancesAndNames, currentAddress]); - // On first use of this feature, auto-pin the users most used owned addresses + // If user has never seen pinned addresses feature, auto-pin the users most used owned addresses useEffect(() => { - if (initialHasAutoPinnedAddresses || initialPinnedAddressCount > 0) return; + if (initialHasAutoPinnedAddresses || initialPinnedAddressCount > 0 || isLoadingTransactionCounts) return; - // TODO: this is a placeholder until backend adds the info needed in the return of the summary endpoint const pinnableAddresses = allWalletItems.filter(item => !item.isReadOnly).map(item => item.address); - // sanity check, there should always be at least one pinnable address + // Do not auto-pin if user only has read-only wallets if (pinnableAddresses.length === 0) return; - const addressesToAutoPin = pinnableAddresses.slice(0, MAX_PINNED_ADDRESSES); + const addressesToAutoPin = pinnableAddresses + .sort((a, b) => transactionCounts[b.toLowerCase()] - transactionCounts[a.toLowerCase()]) + .slice(0, MAX_PINNED_ADDRESSES); setPinnedAddresses(addressesToAutoPin); - }, [allWalletItems, setPinnedAddresses, initialHasAutoPinnedAddresses, initialPinnedAddressCount]); + }, [ + allWalletItems, + setPinnedAddresses, + initialHasAutoPinnedAddresses, + initialPinnedAddressCount, + transactionCounts, + isLoadingTransactionCounts, + ]); const ownedWalletsTotalBalance = useMemo(() => { let isLoadingBalance = false; @@ -653,6 +656,22 @@ export default function ChangeWalletSheet() { {/* TODO: progressive blurview on iOS */} + {/* {IS_IOS ? ( + + ) : ( + + )} */} {!editMode ? ( - + {i18n.t(i18n.l.wallet.change_wallet.total_balance)} - + {ownedWalletsTotalBalance} From 41925eda4371cc03568ba447d28d9ae5e8f68a40 Mon Sep 17 00:00:00 2001 From: Kane Thomas Date: Wed, 18 Dec 2024 12:45:51 -0500 Subject: [PATCH 31/54] misc. DnD bug fixes, worklet haptic activation, and styling cleanup --- src/components/change-wallet/AddressRow.tsx | 137 +++++++++--------- src/components/change-wallet/WalletList.tsx | 25 +++- src/components/drag-and-drop/DndProvider.tsx | 13 ++ .../drag-and-drop/components/Draggable.tsx | 4 +- .../features/sort/hooks/useDraggableSort.ts | 2 +- .../change-wallet/PinnedWalletsGrid.tsx | 9 +- src/state/wallets/pinnedWalletsStore.ts | 1 - 7 files changed, 106 insertions(+), 85 deletions(-) diff --git a/src/components/change-wallet/AddressRow.tsx b/src/components/change-wallet/AddressRow.tsx index 1b19b2bf788..415b4c1e3c2 100644 --- a/src/components/change-wallet/AddressRow.tsx +++ b/src/components/change-wallet/AddressRow.tsx @@ -4,7 +4,7 @@ import LinearGradient from 'react-native-linear-gradient'; import { useTheme } from '../../theme/ThemeContext'; import { ButtonPressAnimation } from '../animations'; import ConditionalWrap from 'conditional-wrap'; -import { Box, Column, Columns, Inline, Stack, Text, useForegroundColor, useColorMode, TextIcon } from '@/design-system'; +import { Box, Inline, Stack, Text, useForegroundColor, useColorMode, TextIcon } from '@/design-system'; import { AddressItem, AddressMenuAction, AddressMenuActionData } from '@/screens/change-wallet/ChangeWalletSheet'; import { TextSize } from '@/design-system/typography/typeHierarchy'; import { TextWeight } from '@/design-system/components/Text/Text'; @@ -18,6 +18,7 @@ import { removeFirstEmojiFromString } from '@/helpers/emojiHandler'; import { address as abbreviateAddress } from '@/utils/abbreviations'; const ROW_HEIGHT_WITH_PADDING = 64; +const BUTTON_SIZE = 28; export const AddressRowButton = ({ color, @@ -43,17 +44,17 @@ export const AddressRowButton = ({ - + {editMode && ( - + - + )} - - - - - - {walletName} - - - {balance} - - - - - {isReadOnly && ( - <> - {!editMode ? ( - // eslint-disable-next-line react/jsx-props-no-spreading - + + + + + {walletName} + + + {balance} + + + + + {isReadOnly && ( + <> + {!editMode ? ( + // eslint-disable-next-line react/jsx-props-no-spreading + + + {i18n.t(i18n.l.wallet.change_wallet.watching)} + + + ) : ( + + 􀋮 + + )} + + )} + {isLedger && ( + <> + {!editMode ? ( + // eslint-disable-next-line react/jsx-props-no-spreading + + + + 􀤃 + - {i18n.t(i18n.l.wallet.change_wallet.watching)} + {i18n.t(i18n.l.wallet.change_wallet.ledger)} - - ) : ( - - 􀋮 - - )} - - )} - {isLedger && ( - <> - {!editMode ? ( - // eslint-disable-next-line react/jsx-props-no-spreading - - - - 􀤃 - - - {i18n.t(i18n.l.wallet.change_wallet.ledger)} - - - - ) : ( - - 􀤃 - - )} - - )} - {!editMode && isSelected && } - {editMode && ( + + + ) : ( + + 􀤃 + + )} + + )} + {!editMode && isSelected && } + {editMode && ( + <> addPinnedAddress(address)} color={colors.appleBlue} icon="􀎧" size="icon 12px" /> - )} - {editMode && ( menuConfig={menuConfig} onPressMenuItem={action => onPressMenuItem(action, { address })} > - )} - - - + + )} + + ); diff --git a/src/components/change-wallet/WalletList.tsx b/src/components/change-wallet/WalletList.tsx index 2a7cc8b5be4..dafe4d1742c 100644 --- a/src/components/change-wallet/WalletList.tsx +++ b/src/components/change-wallet/WalletList.tsx @@ -22,7 +22,10 @@ import { PinnedWalletsGrid } from '@/screens/change-wallet/PinnedWalletsGrid'; import { usePinnedWalletsStore } from '@/state/wallets/pinnedWalletsStore'; import { MenuItem } from '@/components/DropdownMenu'; import { DraggableScrollView } from '@/components/drag-and-drop/components/DraggableScrollView'; +import { triggerHaptics } from 'react-native-turbo-haptics'; +export const DRAGGABLE_ACTIVATION_DELAY = 150; +// how long after the touch gesture begins before the draggable registers const DRAG_ACTIVATION_DELAY = 150; const FADE_TRANSITION_DURATION = 75; const LIST_MAX_HEIGHT = MAX_PANEL_HEIGHT - PANEL_HEADER_HEIGHT; @@ -47,34 +50,39 @@ export function WalletList({ walletItems, menuItems, onPressMenuItem, onPressAcc const pinnedAddresses = usePinnedWalletsStore(state => state.pinnedAddresses); const unpinnedAddresses = usePinnedWalletsStore(state => state.unpinnedAddresses); + // it would be more efficient to map the addresses to the wallet items, but the wallet items should be the source of truth const pinnedWalletItems = useMemo(() => { return walletItems .filter(item => pinnedAddresses.includes(item.id)) .sort((a, b) => pinnedAddresses.indexOf(a.id) - pinnedAddresses.indexOf(b.id)); }, [walletItems, pinnedAddresses]); - // it would be more efficient to map the addresses to the wallet items, but the wallet items should be the source of truth const unpinnedWalletItems = useMemo(() => { return walletItems .filter(item => !pinnedAddresses.includes(item.id)) .sort((a, b) => unpinnedAddresses.indexOf(a.id) - unpinnedAddresses.indexOf(b.id)); }, [walletItems, pinnedAddresses, unpinnedAddresses]); - const reorderUnpinnedAddresses = usePinnedWalletsStore(state => state.setUnpinnedAddresses); + const setUnpinnedAddresses = usePinnedWalletsStore(state => state.setUnpinnedAddresses); const onOrderChange: DraggableFlatListProps['onOrderChange'] = useCallback( (value: UniqueIdentifier[]) => { - reorderUnpinnedAddresses(value as string[]); + setUnpinnedAddresses(value as string[]); }, - [reorderUnpinnedAddresses] + [setUnpinnedAddresses] ); + const onDraggableActivationWorklet = useCallback(() => { + 'worklet'; + triggerHaptics('selection'); + }, []); + const renderHeader = useCallback(() => { const hasPinnedWallets = pinnedWalletItems.length > 0; return ( <> {hasPinnedWallets && ( - + } ); - }, [pinnedWalletItems, onPressAccount, editMode, unpinnedWalletItems.length, menuItems, onPressMenuItem]); + }, [pinnedWalletItems, onPressAccount, editMode, unpinnedWalletItems.length, menuItems, onPressMenuItem, onDraggableActivationWorklet]); const renderScrollItem = useCallback( (item: AddressItem) => ( - + 0 && ( - + diff --git a/src/components/drag-and-drop/DndProvider.tsx b/src/components/drag-and-drop/DndProvider.tsx index 9440ec3d2b4..a73ca271117 100644 --- a/src/components/drag-and-drop/DndProvider.tsx +++ b/src/components/drag-and-drop/DndProvider.tsx @@ -57,6 +57,7 @@ export type DndProviderProps = { event: GestureUpdateEvent, meta: { activeId: UniqueIdentifier; activeLayout: LayoutRectangle } ) => void; + onActivationWorklet?: (next: UniqueIdentifier | null, prev: UniqueIdentifier | null) => void; simultaneousHandlers?: RefObject>; springConfig?: WithSpringConfig; style?: StyleProp; @@ -81,6 +82,7 @@ export const DndProvider = forwardRef draggableActiveId.value, + (next, prev) => { + if (next !== null) { + onActivationWorklet?.(next, prev); + } + }, + [] + ); + const setActiveId = useCallback(() => { 'worklet'; const id = draggablePendingId.value; diff --git a/src/components/drag-and-drop/components/Draggable.tsx b/src/components/drag-and-drop/components/Draggable.tsx index f19636b8a52..41eca930bb1 100644 --- a/src/components/drag-and-drop/components/Draggable.tsx +++ b/src/components/drag-and-drop/components/Draggable.tsx @@ -73,7 +73,7 @@ export const Draggable: FunctionComponent> = ( translateX: // eslint-disable-next-line no-nested-ternary dragDirection !== 'y' - ? isActive || isActing || isSleeping + ? isActive || isSleeping ? offset.x.value : withTiming(offset.x.value, TIMING_CONFIGS.slowestFadeConfig) : 0, @@ -82,7 +82,7 @@ export const Draggable: FunctionComponent> = ( translateY: // eslint-disable-next-line no-nested-ternary dragDirection !== 'x' - ? isActive || isActing || isSleeping + ? isActive || isSleeping ? offset.y.value : withTiming(offset.y.value, TIMING_CONFIGS.slowestFadeConfig) : 0, diff --git a/src/components/drag-and-drop/features/sort/hooks/useDraggableSort.ts b/src/components/drag-and-drop/features/sort/hooks/useDraggableSort.ts index 6a82a6aa685..ef59c3e83c5 100644 --- a/src/components/drag-and-drop/features/sort/hooks/useDraggableSort.ts +++ b/src/components/drag-and-drop/features/sort/hooks/useDraggableSort.ts @@ -113,7 +113,7 @@ export const useDraggableSort = ({ resetOffsets(); }, - [childrenIds, onOrderChange] + [childrenIds] ); // Track active layout changes and update the placeholder index diff --git a/src/screens/change-wallet/PinnedWalletsGrid.tsx b/src/screens/change-wallet/PinnedWalletsGrid.tsx index 471ec9c1295..d5edb62fe54 100644 --- a/src/screens/change-wallet/PinnedWalletsGrid.tsx +++ b/src/screens/change-wallet/PinnedWalletsGrid.tsx @@ -15,6 +15,7 @@ import { removeFirstEmojiFromString } from '@/helpers/emojiHandler'; import { PANEL_WIDTH } from '@/components/SmoothPager/ListPanel'; import { IS_IOS } from '@/env'; import { useTheme } from '@/theme'; +import { DRAGGABLE_ACTIVATION_DELAY } from '@/components/change-wallet/WalletList'; const UNPIN_BADGE_SIZE = 28; const PINS_PER_ROW = 3; @@ -33,13 +34,13 @@ export function PinnedWalletsGrid({ walletItems, onPress, editMode, menuItems, o const { colors, isDarkMode } = useTheme(); const removePinnedAddress = usePinnedWalletsStore(state => state.removePinnedAddress); - const reorderPinnedAddresses = usePinnedWalletsStore(state => state.setPinnedAddresses); + const setPinnedAddresses = usePinnedWalletsStore(state => state.setPinnedAddresses); const onOrderChange: DraggableGridProps['onOrderChange'] = useCallback( (value: UniqueIdentifier[]) => { - reorderPinnedAddresses(value as string[]); + setPinnedAddresses(value as string[]); }, - [reorderPinnedAddresses] + [setPinnedAddresses] ); const fillerItems = useMemo(() => { @@ -74,7 +75,7 @@ export function PinnedWalletsGrid({ walletItems, onPress, editMode, menuItems, o // TODO: can ens names have emojis? If so this logic is wrong const walletName = removeFirstEmojiFromString(account.label) || address(account.address, 4, 4); return ( - + ( diff --git a/src/state/wallets/pinnedWalletsStore.ts b/src/state/wallets/pinnedWalletsStore.ts index 6d92148a7d3..a8ef343af44 100644 --- a/src/state/wallets/pinnedWalletsStore.ts +++ b/src/state/wallets/pinnedWalletsStore.ts @@ -2,7 +2,6 @@ import { createRainbowStore } from '@/state/internal/createRainbowStore'; export const MAX_PINNED_ADDRESSES = 6; -// TODO: fix type Address = string; interface PinnedWalletsStore { From 50b5e04b4bf56356cc2f9a4a1132259539229434 Mon Sep 17 00:00:00 2001 From: Kane Thomas Date: Wed, 18 Dec 2024 16:05:23 -0500 Subject: [PATCH 32/54] fix dnd onUpdate logic, add onUpdateWorklet for haptic trigger on reorder --- src/components/change-wallet/WalletList.tsx | 23 +++++++++++++------ .../components/DraggableScrollView.tsx | 6 +++-- .../drag-and-drop/components/index.ts | 1 + .../sort/components/DraggableGrid.tsx | 4 +++- .../features/sort/hooks/useDraggableGrid.ts | 4 +++- .../features/sort/hooks/useDraggableScroll.ts | 4 +++- .../features/sort/hooks/useDraggableSort.ts | 9 ++++++-- .../change-wallet/PinnedWalletsGrid.tsx | 7 ++++++ 8 files changed, 44 insertions(+), 14 deletions(-) diff --git a/src/components/change-wallet/WalletList.tsx b/src/components/change-wallet/WalletList.tsx index dafe4d1742c..0ab2b316f21 100644 --- a/src/components/change-wallet/WalletList.tsx +++ b/src/components/change-wallet/WalletList.tsx @@ -17,7 +17,7 @@ import { PANEL_INSET_HORIZONTAL, } from '@/screens/change-wallet/ChangeWalletSheet'; import { Box, Separator, Text } from '@/design-system'; -import { DndProvider, Draggable, DraggableFlatListProps, UniqueIdentifier } from '@/components/drag-and-drop'; +import { DndProvider, Draggable, DraggableScrollViewProps, UniqueIdentifier } from '@/components/drag-and-drop'; import { PinnedWalletsGrid } from '@/screens/change-wallet/PinnedWalletsGrid'; import { usePinnedWalletsStore } from '@/state/wallets/pinnedWalletsStore'; import { MenuItem } from '@/components/DropdownMenu'; @@ -65,19 +65,26 @@ export function WalletList({ walletItems, menuItems, onPressMenuItem, onPressAcc const setUnpinnedAddresses = usePinnedWalletsStore(state => state.setUnpinnedAddresses); - const onOrderChange: DraggableFlatListProps['onOrderChange'] = useCallback( + const onOrderChange: DraggableScrollViewProps['onOrderChange'] = useCallback( (value: UniqueIdentifier[]) => { + console.log('ON ORDER CHANGE'); setUnpinnedAddresses(value as string[]); }, [setUnpinnedAddresses] ); + // Fires when order updates but drag is still active + const onOrderUpdateWorklet: DraggableScrollViewProps['onOrderUpdateWorklet'] = useCallback(() => { + 'worklet'; + triggerHaptics('impactLight'); + }, []); + const onDraggableActivationWorklet = useCallback(() => { 'worklet'; - triggerHaptics('selection'); + triggerHaptics('impactLight'); }, []); - const renderHeader = useCallback(() => { + const renderPinnedWalletsSection = useCallback(() => { const hasPinnedWallets = pinnedWalletItems.length > 0; return ( <> @@ -129,7 +136,7 @@ export function WalletList({ walletItems, menuItems, onPressMenuItem, onPressAcc }, [unpinnedWalletItems.length]); return ( - + <> {walletItems.length === 0 && ( @@ -140,17 +147,19 @@ export function WalletList({ walletItems, menuItems, onPressMenuItem, onPressAcc - {renderHeader()} + {renderPinnedWalletsSection()} {draggableUnpinnedWalletItems.map(item => renderScrollItem(item))} )} - + ); } diff --git a/src/components/drag-and-drop/components/DraggableScrollView.tsx b/src/components/drag-and-drop/components/DraggableScrollView.tsx index bc721c2daf2..7f4380f427c 100644 --- a/src/components/drag-and-drop/components/DraggableScrollView.tsx +++ b/src/components/drag-and-drop/components/DraggableScrollView.tsx @@ -1,7 +1,7 @@ import React, { ComponentProps, ReactElement } from 'react'; import Animated, { useAnimatedRef, useAnimatedScrollHandler, useSharedValue } from 'react-native-reanimated'; import { useDndContext } from '../DndContext'; -import { UseDraggableStackOptions } from '../features'; +import { UseDraggableSortOptions } from '../features'; import { swapByItemCenterPoint } from '../utils'; import { useChildrenIds } from '../hooks'; import { useDraggableScroll } from '../features/sort/hooks/useDraggableScroll'; @@ -9,7 +9,7 @@ import { useDraggableScroll } from '../features/sort/hooks/useDraggableScroll'; type AnimatedScrollViewProps = ComponentProps; export type DraggableScrollViewProps = AnimatedScrollViewProps & - Pick & { + Pick & { children: React.ReactNode; gap?: number; horizontal?: boolean; @@ -27,6 +27,7 @@ export const DraggableScrollView = ({ horizontal = false, onOrderChange, onOrderUpdate, + onOrderUpdateWorklet, shouldSwapWorklet = swapByItemCenterPoint, autoScrollInsets, ...otherProps @@ -44,6 +45,7 @@ export const DraggableScrollView = ({ childrenIds, onOrderChange, onOrderUpdate, + onOrderUpdateWorklet, shouldSwapWorklet, horizontal, contentHeight, diff --git a/src/components/drag-and-drop/components/index.ts b/src/components/drag-and-drop/components/index.ts index 88f40fe1543..1e66437d9f8 100644 --- a/src/components/drag-and-drop/components/index.ts +++ b/src/components/drag-and-drop/components/index.ts @@ -1,3 +1,4 @@ export * from './Draggable'; export * from './DraggableFlatList'; +export * from './DraggableScrollView'; export * from './Droppable'; diff --git a/src/components/drag-and-drop/features/sort/components/DraggableGrid.tsx b/src/components/drag-and-drop/features/sort/components/DraggableGrid.tsx index 2f666e48426..9e2427a2211 100644 --- a/src/components/drag-and-drop/features/sort/components/DraggableGrid.tsx +++ b/src/components/drag-and-drop/features/sort/components/DraggableGrid.tsx @@ -4,7 +4,7 @@ import { useChildrenIds } from '../../../hooks'; import { useDraggableGrid, type UseDraggableGridOptions } from '../hooks/useDraggableGrid'; export type DraggableGridProps = Pick & - Pick & { + Pick & { direction?: FlexStyle['flexDirection']; size: number; gap?: number; @@ -16,6 +16,7 @@ export const DraggableGrid: FunctionComponent & { gap?: number; size: number; @@ -17,6 +17,7 @@ export const useDraggableGrid = ({ childrenIds, onOrderChange, onOrderUpdate, + onOrderUpdateWorklet, gap = 0, size, direction = 'row', @@ -30,6 +31,7 @@ export const useDraggableGrid = ({ childrenIds, onOrderChange, onOrderUpdate, + onOrderUpdateWorklet, shouldSwapWorklet, }); diff --git a/src/components/drag-and-drop/features/sort/hooks/useDraggableScroll.ts b/src/components/drag-and-drop/features/sort/hooks/useDraggableScroll.ts index 4f5e4c24f82..637be339768 100644 --- a/src/components/drag-and-drop/features/sort/hooks/useDraggableScroll.ts +++ b/src/components/drag-and-drop/features/sort/hooks/useDraggableScroll.ts @@ -52,7 +52,7 @@ function getRemainingScrollDistanceWorklet({ export type UseDraggableScrollOptions = Pick< UseDraggableSortOptions, - 'childrenIds' | 'onOrderChange' | 'onOrderUpdate' | 'shouldSwapWorklet' + 'childrenIds' | 'onOrderChange' | 'onOrderUpdate' | 'onOrderUpdateWorklet' | 'shouldSwapWorklet' > & { contentHeight: SharedValue; layoutHeight: SharedValue; @@ -67,6 +67,7 @@ export const useDraggableScroll = ({ childrenIds, onOrderChange, onOrderUpdate, + onOrderUpdateWorklet, shouldSwapWorklet = doesOverlapOnAxis, horizontal = false, contentHeight, @@ -83,6 +84,7 @@ export const useDraggableScroll = ({ childrenIds, onOrderChange, onOrderUpdate, + onOrderUpdateWorklet, shouldSwapWorklet, }); diff --git a/src/components/drag-and-drop/features/sort/hooks/useDraggableSort.ts b/src/components/drag-and-drop/features/sort/hooks/useDraggableSort.ts index ef59c3e83c5..1bb6a458823 100644 --- a/src/components/drag-and-drop/features/sort/hooks/useDraggableSort.ts +++ b/src/components/drag-and-drop/features/sort/hooks/useDraggableSort.ts @@ -12,6 +12,7 @@ export type UseDraggableSortOptions = { horizontal?: boolean; onOrderChange?: (order: UniqueIdentifier[]) => void; onOrderUpdate?: (nextOrder: UniqueIdentifier[], prevOrder: UniqueIdentifier[]) => void; + onOrderUpdateWorklet?: (nextOrder: UniqueIdentifier[], prevOrder: UniqueIdentifier[]) => void; shouldSwapWorklet?: ShouldSwapWorklet; }; @@ -20,6 +21,7 @@ export const useDraggableSort = ({ childrenIds, onOrderChange, onOrderUpdate, + onOrderUpdateWorklet, shouldSwapWorklet = doesOverlapOnAxis, }: UseDraggableSortOptions) => { const { draggableActiveId, draggableStates, draggableRestingOffsets, draggableActiveLayout, draggableOffsets, draggableLayouts } = @@ -175,8 +177,11 @@ export const useDraggableSort = ({ // Finally update the sort order const nextOrder = moveArrayIndex(prevOrder, prevPlaceholderIndex, nextPlaceholderIndex); // Notify the parent component of the order update - if (onOrderUpdate) { - runOnJS(onOrderUpdate)(nextOrder, prevOrder); + if (prevPlaceholderIndex !== nextPlaceholderIndex && nextActiveId !== null) { + if (onOrderUpdate) { + runOnJS(onOrderUpdate)(nextOrder, prevOrder); + } + onOrderUpdateWorklet?.(nextOrder, prevOrder); } draggableSortOrder.value = nextOrder; diff --git a/src/screens/change-wallet/PinnedWalletsGrid.tsx b/src/screens/change-wallet/PinnedWalletsGrid.tsx index d5edb62fe54..2acb3463c24 100644 --- a/src/screens/change-wallet/PinnedWalletsGrid.tsx +++ b/src/screens/change-wallet/PinnedWalletsGrid.tsx @@ -16,6 +16,7 @@ import { PANEL_WIDTH } from '@/components/SmoothPager/ListPanel'; import { IS_IOS } from '@/env'; import { useTheme } from '@/theme'; import { DRAGGABLE_ACTIVATION_DELAY } from '@/components/change-wallet/WalletList'; +import { triggerHaptics } from 'react-native-turbo-haptics'; const UNPIN_BADGE_SIZE = 28; const PINS_PER_ROW = 3; @@ -43,6 +44,11 @@ export function PinnedWalletsGrid({ walletItems, onPress, editMode, menuItems, o [setPinnedAddresses] ); + const onOrderUpdateWorklet: DraggableGridProps['onOrderUpdateWorklet'] = useCallback(() => { + 'worklet'; + triggerHaptics('impactLight'); + }, []); + const fillerItems = useMemo(() => { const itemsInLastRow = walletItems.length % PINS_PER_ROW; return Array.from({ length: itemsInLastRow === 0 ? 0 : PINS_PER_ROW - itemsInLastRow }); @@ -65,6 +71,7 @@ export function PinnedWalletsGrid({ walletItems, onPress, editMode, menuItems, o direction="row" gap={GRID_GAP} onOrderChange={onOrderChange} + onOrderUpdateWorklet={onOrderUpdateWorklet} size={PINS_PER_ROW} style={{ width: '100%', From dd774e2dc88d2b57302b98f869e1705191b88e64 Mon Sep 17 00:00:00 2001 From: Kane Thomas Date: Wed, 18 Dec 2024 17:54:56 -0500 Subject: [PATCH 33/54] android button & padding fixes, grid layout jank fix --- src/components/change-wallet/AddressRow.tsx | 2 +- src/components/change-wallet/WalletList.tsx | 17 ++++-- .../change-wallet/ChangeWalletSheet.tsx | 28 +++------- .../change-wallet/PinnedWalletsGrid.tsx | 53 +++++++++++-------- 4 files changed, 52 insertions(+), 48 deletions(-) diff --git a/src/components/change-wallet/AddressRow.tsx b/src/components/change-wallet/AddressRow.tsx index 415b4c1e3c2..058c1f15d58 100644 --- a/src/components/change-wallet/AddressRow.tsx +++ b/src/components/change-wallet/AddressRow.tsx @@ -40,7 +40,7 @@ export const AddressRowButton = ({ const fillQuaternary = useForegroundColor('fillQuaternary'); return ( - + { - console.log('ON ORDER CHANGE'); setUnpinnedAddresses(value as string[]); }, [setUnpinnedAddresses] @@ -149,10 +147,19 @@ export function WalletList({ walletItems, menuItems, onPressMenuItem, onPressAcc onOrderChange={onOrderChange} onOrderUpdateWorklet={onOrderUpdateWorklet} scrollIndicatorInsets={{ bottom: FOOTER_HEIGHT - 24 }} - style={{ maxHeight: LIST_MAX_HEIGHT, marginHorizontal: -PANEL_INSET_HORIZONTAL, paddingHorizontal: PANEL_INSET_HORIZONTAL }} - // subtract 24px to account for the footers tappering gradient + style={{ + maxHeight: LIST_MAX_HEIGHT, + marginHorizontal: -PANEL_INSET_HORIZONTAL, + paddingHorizontal: PANEL_INSET_HORIZONTAL, + }} + // subtract 24px to account for the footers tapering gradient autoScrollInsets={{ bottom: FOOTER_HEIGHT - 24 }} - contentContainerStyle={{ paddingBottom: FOOTER_HEIGHT - 24 }} + contentContainerStyle={{ + paddingBottom: FOOTER_HEIGHT - 24, + // required here also for Android + marginHorizontal: -PANEL_INSET_HORIZONTAL, + paddingHorizontal: PANEL_INSET_HORIZONTAL, + }} > {renderPinnedWalletsSection()} {draggableUnpinnedWalletItems.map(item => renderScrollItem(item))} diff --git a/src/screens/change-wallet/ChangeWalletSheet.tsx b/src/screens/change-wallet/ChangeWalletSheet.tsx index d6deae76eae..9f0167a11f1 100644 --- a/src/screens/change-wallet/ChangeWalletSheet.tsx +++ b/src/screens/change-wallet/ChangeWalletSheet.tsx @@ -14,7 +14,6 @@ import WalletTypes from '@/helpers/walletTypes'; import { analytics, analyticsV2 } from '@/analytics'; import { useAccountSettings, useInitializeWallet, useWallets, useWalletsWithBalancesAndNames, useWebData } from '@/hooks'; import Routes from '@/navigation/routesNames'; -import styled from '@/styled-thing'; import { doesWalletsContainAddress, safeAreaInsetValues, showActionSheetWithOptions } from '@/utils'; import { logger, RainbowError } from '@/logger'; import { useTheme } from '@/theme'; @@ -23,7 +22,7 @@ import { getNotificationSettingsForWalletWithAddress } from '@/notifications/set import { IS_IOS } from '@/env'; import { remotePromoSheetsStore } from '@/state/remotePromoSheets/remotePromoSheets'; import { RootStackParamList } from '@/navigation/types'; -import { Box, globalColors, Inline, Stack, Text } from '@/design-system'; +import { Box, globalColors, HitSlop, Inline, Stack, Text } from '@/design-system'; import { addDisplay, convertAmountToNativeDisplay } from '@/helpers/utilities'; import { Panel, TapToDismiss } from '@/components/SmoothPager/ListPanel'; import { SheetHandleFixedToTop } from '@/components/sheet'; @@ -61,14 +60,6 @@ const RowTypes = { EMPTY: 2, }; -const Whitespace = styled(View)({ - backgroundColor: ({ theme: { colors } }: any) => colors.white, - bottom: -398, - height: 400, - position: 'absolute', - width: '100%', -}); - export interface AddressItem { id: EthereumAddress; address: EthereumAddress; @@ -557,11 +548,8 @@ export default function ChangeWalletSheet() { const wallet = walletsByAddress[address]; if (!wallet) { logger.error(new RainbowError('[ChangeWalletSheet]: No wallet for address found when pressing menu item'), { - // TODO: make sure this is okay to log - address, actionKey, }); - // TODO: should show user facing error? return; } switch (actionKey) { @@ -602,11 +590,11 @@ export default function ChangeWalletSheet() { - + {i18n.t(i18n.l.wallet.change_wallet.wallets)} - {/* TODO: this positioning is jank */} + {/* +3 to account for font size difference */} - - {editMode ? i18n.t(i18n.l.button.done) : i18n.t(i18n.l.button.edit)} - + + + {editMode ? i18n.t(i18n.l.button.done) : i18n.t(i18n.l.button.edit)} + + - {/* TODO: why is this here? */} - {/* {IS_ANDROID && } */} Math.min((PANEL_WIDTH - PANEL_INSET_HORIZONTAL * 2 - GRID_GAP * (PINS_PER_ROW - 1)) / PINS_PER_ROW, MAX_AVATAR_SIZE), + () => + PixelRatio.roundToNearestPixel( + Math.min((PANEL_WIDTH - PANEL_INSET_HORIZONTAL * 2 - GRID_GAP * (PINS_PER_ROW - 1)) / PINS_PER_ROW, MAX_AVATAR_SIZE) + ), [] ); @@ -70,6 +74,10 @@ export function PinnedWalletsGrid({ walletItems, onPress, editMode, menuItems, o )} {editMode && ( - removePinnedAddress(account.address)}> - - - {'􀅽'} - - - + + removePinnedAddress(account.address)}> + + + + {'􀅽'} + + + + + )} From 0b778efce378bfae67241cf8f70c59e517c2ebcf Mon Sep 17 00:00:00 2001 From: Kane Thomas Date: Wed, 18 Dec 2024 19:23:20 -0500 Subject: [PATCH 34/54] misc. cleanup --- src/screens/change-wallet/ChangeWalletSheet.tsx | 12 ++++++------ src/screens/change-wallet/PinnedWalletsGrid.tsx | 1 - src/state/wallets/pinnedWalletsStore.ts | 1 - 3 files changed, 6 insertions(+), 8 deletions(-) diff --git a/src/screens/change-wallet/ChangeWalletSheet.tsx b/src/screens/change-wallet/ChangeWalletSheet.tsx index 9f0167a11f1..a7b7b55603e 100644 --- a/src/screens/change-wallet/ChangeWalletSheet.tsx +++ b/src/screens/change-wallet/ChangeWalletSheet.tsx @@ -1,15 +1,15 @@ import { RouteProp, useRoute } from '@react-navigation/native'; import * as i18n from '@/languages'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { Alert, InteractionManager, View } from 'react-native'; +import { Alert, InteractionManager } from 'react-native'; import ReactNativeHapticFeedback from 'react-native-haptic-feedback'; import { useDispatch } from 'react-redux'; import { ButtonPressAnimation } from '@/components/animations'; import { WalletList } from '@/components/change-wallet/WalletList'; -import { removeWalletData } from '../../handlers/localstorage/removeWallet'; -import { cleanUpWalletKeys, RainbowWallet } from '../../model/wallet'; -import { useNavigation } from '../../navigation/Navigation'; -import { addressSetSelected, walletsSetSelected, walletsUpdate } from '../../redux/wallets'; +import { removeWalletData } from '@/handlers/localstorage/removeWallet'; +import { cleanUpWalletKeys, RainbowWallet } from '@/model/wallet'; +import { useNavigation } from '@/navigation/Navigation'; +import { addressSetSelected, walletsSetSelected, walletsUpdate } from '@/redux/wallets'; import WalletTypes from '@/helpers/walletTypes'; import { analytics, analyticsV2 } from '@/analytics'; import { useAccountSettings, useInitializeWallet, useWallets, useWalletsWithBalancesAndNames, useWebData } from '@/hooks'; @@ -33,7 +33,7 @@ import { FeatureHintTooltip, TooltipRef } from '@/components/tooltips/FeatureHin import { MAX_PINNED_ADDRESSES, usePinnedWalletsStore } from '@/state/wallets/pinnedWalletsStore'; import ConditionalWrap from 'conditional-wrap'; import Clipboard from '@react-native-clipboard/clipboard'; -import { SettingsPages } from '../SettingsSheet/SettingsPages'; +import { SettingsPages } from '@/screens/SettingsSheet/SettingsPages'; import { useWalletTransactionCounts } from '@/hooks/useWalletTransactionCounts'; const PANEL_BOTTOM_OFFSET = Math.max(safeAreaInsetValues.bottom + 5, IS_IOS ? 8 : 30); diff --git a/src/screens/change-wallet/PinnedWalletsGrid.tsx b/src/screens/change-wallet/PinnedWalletsGrid.tsx index dc33851972b..57ce5e82068 100644 --- a/src/screens/change-wallet/PinnedWalletsGrid.tsx +++ b/src/screens/change-wallet/PinnedWalletsGrid.tsx @@ -87,7 +87,6 @@ export function PinnedWalletsGrid({ walletItems, onPress, editMode, menuItems, o }} > {draggableItems.map(account => { - // TODO: can ens names have emojis? If so this logic is wrong const walletName = removeFirstEmojiFromString(account.label) || address(account.address, 4, 4); return ( diff --git a/src/state/wallets/pinnedWalletsStore.ts b/src/state/wallets/pinnedWalletsStore.ts index a8ef343af44..7bdfcac167f 100644 --- a/src/state/wallets/pinnedWalletsStore.ts +++ b/src/state/wallets/pinnedWalletsStore.ts @@ -54,7 +54,6 @@ export const usePinnedWalletsStore = createRainbowStore( }, setPinnedAddresses: newPinnedAddresses => { - // TODO: this batches the state update right? if (!get().hasAutoPinnedAddresses) { set({ hasAutoPinnedAddresses: true }); } From b116d1ad14483d0dec2bfdce27653106fc8e3f9d Mon Sep 17 00:00:00 2001 From: Kane Thomas Date: Thu, 19 Dec 2024 10:38:29 -0500 Subject: [PATCH 35/54] dnd: refactor offset update logic --- .../features/sort/hooks/useDraggableGrid.ts | 59 ++++++------------- .../features/sort/hooks/useDraggableScroll.ts | 34 +++++------ .../features/sort/hooks/useDraggableSort.ts | 4 +- .../drag-and-drop/utils/geometry.ts | 35 +++++++++++ 4 files changed, 67 insertions(+), 65 deletions(-) diff --git a/src/components/drag-and-drop/features/sort/hooks/useDraggableGrid.ts b/src/components/drag-and-drop/features/sort/hooks/useDraggableGrid.ts index 477d275e24a..b216bdf0f21 100644 --- a/src/components/drag-and-drop/features/sort/hooks/useDraggableGrid.ts +++ b/src/components/drag-and-drop/features/sort/hooks/useDraggableGrid.ts @@ -1,6 +1,6 @@ import { type FlexStyle } from 'react-native'; import { useAnimatedReaction } from 'react-native-reanimated'; -import { doesCenterPointOverlap } from '../../../utils'; +import { doesCenterPointOverlap, getFlexLayoutPosition } from '../../../utils'; import { useDndContext } from './../../../DndContext'; import { useDraggableSort, type UseDraggableSortOptions } from './useDraggableSort'; @@ -35,66 +35,41 @@ export const useDraggableGrid = ({ shouldSwapWorklet, }); - // Track sort order changes and update the offsets + // Track sort order changes and update the offsets based on base positions useAnimatedReaction( () => draggableSortOrder.value, (nextOrder, prevOrder) => { - // Ignore initial reaction - if (prevOrder === null) { - return; - } + if (prevOrder === null) return; + const { value: activeId } = draggableActiveId; const { value: layouts } = draggableLayouts; const { value: offsets } = draggableOffsets; const { value: restingOffsets } = draggableRestingOffsets; - if (!activeId) { - return; - } + if (!activeId) return; const activeLayout = layouts[activeId].value; const { width, height } = activeLayout; - const restingOffset = restingOffsets[activeId]; for (let nextIndex = 0; nextIndex < nextOrder.length; nextIndex++) { const itemId = nextOrder[nextIndex]; + + const originalIndex = childrenIds.indexOf(itemId); const prevIndex = prevOrder.findIndex(id => id === itemId); - // Skip items that haven't changed position - if (nextIndex === prevIndex) { - continue; - } - const prevRow = Math.floor(prevIndex / size); - const prevCol = prevIndex % size; - const nextRow = Math.floor(nextIndex / size); - const nextCol = nextIndex % size; - const moveCol = nextCol - prevCol; - const moveRow = nextRow - prevRow; + if (nextIndex === prevIndex) continue; + + const offset = itemId === activeId ? restingOffsets[activeId] : offsets[itemId]; + if (!restingOffsets[itemId] || !offsets[itemId]) continue; - const offset = itemId === activeId ? restingOffset : offsets[itemId]; + const originalPosition = getFlexLayoutPosition({ index: originalIndex, width, height, gap, direction, size }); + const newPosition = getFlexLayoutPosition({ index: nextIndex, width, height, gap, direction, size }); - switch (direction) { - case 'row': - offset.x.value += moveCol * (width + gap); - offset.y.value += moveRow * (height + gap); - break; - case 'row-reverse': - offset.x.value += -1 * moveCol * (width + gap); - offset.y.value += moveRow * (height + gap); - break; - case 'column': - offset.y.value += moveCol * (width + gap); - offset.x.value += moveRow * (height + gap); - break; - case 'column-reverse': - offset.y.value += -1 * moveCol * (width + gap); - offset.x.value += moveRow * (height + gap); - break; - default: - break; - } + // Set offset as the difference between new and original position + offset.x.value = newPosition.x - originalPosition.x; + offset.y.value = newPosition.y - originalPosition.y; } }, - [direction, gap, size] + [direction, gap, size, childrenIds] ); return { draggablePlaceholderIndex, draggableSortOrder }; diff --git a/src/components/drag-and-drop/features/sort/hooks/useDraggableScroll.ts b/src/components/drag-and-drop/features/sort/hooks/useDraggableScroll.ts index 637be339768..b0423f5fc8b 100644 --- a/src/components/drag-and-drop/features/sort/hooks/useDraggableScroll.ts +++ b/src/components/drag-and-drop/features/sort/hooks/useDraggableScroll.ts @@ -1,7 +1,7 @@ import { useDndContext } from '@/components/drag-and-drop/DndContext'; import { useDraggableSort, type UseDraggableSortOptions } from './useDraggableSort'; import Animated, { AnimatedRef, SharedValue, scrollTo, useAnimatedReaction } from 'react-native-reanimated'; -import { applyOffset, doesOverlapOnAxis } from '@/components/drag-and-drop/utils'; +import { applyOffset, doesOverlapOnAxis, getFlexLayoutPosition } from '@/components/drag-and-drop/utils'; import { useCallback } from 'react'; const AUTOSCROLL_THRESHOLD = 50; @@ -152,34 +152,28 @@ export const useDraggableScroll = ({ const activeLayout = layouts[activeId].value; const { width, height } = activeLayout; - const restingOffset = restingOffsets[activeId]; for (let nextIndex = 0; nextIndex < nextOrder.length; nextIndex++) { const itemId = nextOrder[nextIndex]; + const originalIndex = childrenIds.indexOf(itemId); const prevIndex = prevOrder.findIndex(id => id === itemId); + if (nextIndex === prevIndex) continue; - const prevRow = Math.floor(prevIndex / size); - const prevCol = prevIndex % size; - const nextRow = Math.floor(nextIndex / size); - const nextCol = nextIndex % size; - const moveCol = nextCol - prevCol; - const moveRow = nextRow - prevRow; - - const offset = itemId === activeId ? restingOffset : offsets[itemId]; - if (!restingOffset || !offsets[itemId]) continue; - - switch (direction) { - case 'row': - offset.y.value += moveRow * (height + gap); - break; - case 'column': - offset.x.value += moveCol * (width + gap); - break; + const offset = itemId === activeId ? restingOffsets[activeId] : offsets[itemId]; + if (!restingOffsets[itemId] || !offsets[itemId]) continue; + + const originalPosition = getFlexLayoutPosition({ index: originalIndex, width, height, gap, direction, size }); + const newPosition = getFlexLayoutPosition({ index: nextIndex, width, height, gap, direction, size }); + + if (direction === 'row') { + offset.y.value = newPosition.y - originalPosition.y; + } else if (direction === 'column') { + offset.x.value = newPosition.x - originalPosition.x; } } }, - [] + [direction, gap, size, childrenIds] ); // React to active item position and autoscroll if necessary diff --git a/src/components/drag-and-drop/features/sort/hooks/useDraggableSort.ts b/src/components/drag-and-drop/features/sort/hooks/useDraggableSort.ts index 1bb6a458823..ef1df3689a5 100644 --- a/src/components/drag-and-drop/features/sort/hooks/useDraggableSort.ts +++ b/src/components/drag-and-drop/features/sort/hooks/useDraggableSort.ts @@ -61,7 +61,6 @@ export const useDraggableSort = ({ // console.log(`Found placeholder index ${itemIndex} using custom shouldSwapWorklet!`); return itemIndex; } - continue; } // Fallback to current index return activeIndex; @@ -110,7 +109,7 @@ export const useDraggableSort = ({ if (prev.length === next.length) return; // this assumes the order is sorted in the layout, which might not be the case - // if it's not, would need to sort based on the layout but requires waitign for requestAnimationFrame + // if it's not, would need to sort based on the layout but requires waiting for requestAnimationFrame draggableSortOrder.value = next; resetOffsets(); @@ -126,7 +125,6 @@ export const useDraggableSort = ({ if (prev === null) { return; } - const [_prevActiveId, _prevActiveLayout] = prev; // No active layout if (nextActiveLayout === null) { return; diff --git a/src/components/drag-and-drop/utils/geometry.ts b/src/components/drag-and-drop/utils/geometry.ts index 043b428a52c..2915757f05b 100644 --- a/src/components/drag-and-drop/utils/geometry.ts +++ b/src/components/drag-and-drop/utils/geometry.ts @@ -1,3 +1,5 @@ +import { FlexStyle } from 'react-native'; + export type Point = { x: T; y: T; @@ -124,3 +126,36 @@ export const getDistance = (x: number, y: number): number => { 'worklet'; return Math.sqrt(Math.abs(x) ** 2 + Math.abs(y) ** 2); }; + +export const getFlexLayoutPosition = ({ + index, + width, + height, + gap, + direction, + size, +}: { + index: number; + width: number; + height: number; + gap: number; + direction: FlexStyle['flexDirection']; + size: number; +}) => { + 'worklet'; + const row = Math.floor(index / size); + const col = index % size; + + switch (direction) { + case 'row': + return { x: col * (width + gap), y: row * (height + gap) }; + case 'row-reverse': + return { x: -1 * col * (width + gap), y: row * (height + gap) }; + case 'column': + return { x: row * (height + gap), y: col * (width + gap) }; + case 'column-reverse': + return { x: row * (height + gap), y: -1 * col * (width + gap) }; + default: + return { x: 0, y: 0 }; + } +}; From 1ffd763d9387ab0588c136a98f01e1e9955ab007 Mon Sep 17 00:00:00 2001 From: Kane Thomas Date: Thu, 19 Dec 2024 10:44:05 -0500 Subject: [PATCH 36/54] delete unused component --- src/components/change-wallet/WalletOption.tsx | 17 ----------------- 1 file changed, 17 deletions(-) delete mode 100644 src/components/change-wallet/WalletOption.tsx diff --git a/src/components/change-wallet/WalletOption.tsx b/src/components/change-wallet/WalletOption.tsx deleted file mode 100644 index 217d33f6b6e..00000000000 --- a/src/components/change-wallet/WalletOption.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import React from 'react'; -import { useTheme } from '../../theme/ThemeContext'; -import { ButtonPressAnimation } from '../animations'; -import { Text } from '@/design-system'; - -const WalletOption = ({ editMode, label, onPress, testID }: { editMode: boolean; label: string; onPress: () => void; testID?: string }) => { - const { colors } = useTheme(); - return ( - - - {label} - - - ); -}; - -export default React.memo(WalletOption); From 2d582fa7cd5f2a2769941fe4ff8e75cc05c81e70 Mon Sep 17 00:00:00 2001 From: Kane Thomas Date: Thu, 19 Dec 2024 10:57:47 -0500 Subject: [PATCH 37/54] move components to screen/components directory --- .../change-wallet/{ => components}/AddressAvatar.tsx | 0 .../change-wallet/components}/AddressRow.tsx | 10 +++++----- .../{ => components}/PinnedWalletsGrid.tsx | 4 ++-- .../{ => components}/SelectedAddressBadge.tsx | 0 .../change-wallet/components}/WalletList.tsx | 2 +- 5 files changed, 8 insertions(+), 8 deletions(-) rename src/screens/change-wallet/{ => components}/AddressAvatar.tsx (100%) rename src/{components/change-wallet => screens/change-wallet/components}/AddressRow.tsx (95%) rename src/screens/change-wallet/{ => components}/PinnedWalletsGrid.tsx (98%) rename src/screens/change-wallet/{ => components}/SelectedAddressBadge.tsx (100%) rename src/{components/change-wallet => screens/change-wallet/components}/WalletList.tsx (98%) diff --git a/src/screens/change-wallet/AddressAvatar.tsx b/src/screens/change-wallet/components/AddressAvatar.tsx similarity index 100% rename from src/screens/change-wallet/AddressAvatar.tsx rename to src/screens/change-wallet/components/AddressAvatar.tsx diff --git a/src/components/change-wallet/AddressRow.tsx b/src/screens/change-wallet/components/AddressRow.tsx similarity index 95% rename from src/components/change-wallet/AddressRow.tsx rename to src/screens/change-wallet/components/AddressRow.tsx index 058c1f15d58..008c324c111 100644 --- a/src/components/change-wallet/AddressRow.tsx +++ b/src/screens/change-wallet/components/AddressRow.tsx @@ -1,8 +1,8 @@ import * as i18n from '@/languages'; import React, { useMemo } from 'react'; import LinearGradient from 'react-native-linear-gradient'; -import { useTheme } from '../../theme/ThemeContext'; -import { ButtonPressAnimation } from '../animations'; +import { useTheme } from '@/theme/ThemeContext'; +import { ButtonPressAnimation } from '@/components/animations'; import ConditionalWrap from 'conditional-wrap'; import { Box, Inline, Stack, Text, useForegroundColor, useColorMode, TextIcon } from '@/design-system'; import { AddressItem, AddressMenuAction, AddressMenuActionData } from '@/screens/change-wallet/ChangeWalletSheet'; @@ -10,10 +10,10 @@ import { TextSize } from '@/design-system/typography/typeHierarchy'; import { TextWeight } from '@/design-system/components/Text/Text'; import { opacity } from '@/__swaps__/utils/swaps'; import { usePinnedWalletsStore } from '@/state/wallets/pinnedWalletsStore'; -import { AddressAvatar } from '@/screens/change-wallet/AddressAvatar'; -import { SelectedAddressBadge } from '@/screens/change-wallet/SelectedAddressBadge'; +import { AddressAvatar } from '@/screens/change-wallet/components/AddressAvatar'; +import { SelectedAddressBadge } from '@/screens/change-wallet/components/SelectedAddressBadge'; import { DropdownMenu, MenuItem } from '@/components/DropdownMenu'; -import { Icon } from '../icons'; +import { Icon } from '@/components/icons'; import { removeFirstEmojiFromString } from '@/helpers/emojiHandler'; import { address as abbreviateAddress } from '@/utils/abbreviations'; diff --git a/src/screens/change-wallet/PinnedWalletsGrid.tsx b/src/screens/change-wallet/components/PinnedWalletsGrid.tsx similarity index 98% rename from src/screens/change-wallet/PinnedWalletsGrid.tsx rename to src/screens/change-wallet/components/PinnedWalletsGrid.tsx index 57ce5e82068..d2b567d19e2 100644 --- a/src/screens/change-wallet/PinnedWalletsGrid.tsx +++ b/src/screens/change-wallet/components/PinnedWalletsGrid.tsx @@ -1,7 +1,7 @@ import { Draggable, DraggableGrid, DraggableGridProps, UniqueIdentifier } from '@/components/drag-and-drop'; import { Box, HitSlop, Inline, Stack, Text } from '@/design-system'; import React, { useCallback, useMemo } from 'react'; -import { AddressItem, AddressMenuAction, AddressMenuActionData, PANEL_INSET_HORIZONTAL } from './ChangeWalletSheet'; +import { AddressItem, AddressMenuAction, AddressMenuActionData, PANEL_INSET_HORIZONTAL } from '../ChangeWalletSheet'; import { AddressAvatar } from './AddressAvatar'; import { ButtonPressAnimation } from '@/components/animations'; import { BlurView } from '@react-native-community/blur'; @@ -15,7 +15,7 @@ import { removeFirstEmojiFromString } from '@/helpers/emojiHandler'; import { PANEL_WIDTH } from '@/components/SmoothPager/ListPanel'; import { IS_IOS } from '@/env'; import { useTheme } from '@/theme'; -import { DRAGGABLE_ACTIVATION_DELAY } from '@/components/change-wallet/WalletList'; +import { DRAGGABLE_ACTIVATION_DELAY } from '@/screens/change-wallet/components/WalletList'; import { triggerHaptics } from 'react-native-turbo-haptics'; import { PixelRatio } from 'react-native'; diff --git a/src/screens/change-wallet/SelectedAddressBadge.tsx b/src/screens/change-wallet/components/SelectedAddressBadge.tsx similarity index 100% rename from src/screens/change-wallet/SelectedAddressBadge.tsx rename to src/screens/change-wallet/components/SelectedAddressBadge.tsx diff --git a/src/components/change-wallet/WalletList.tsx b/src/screens/change-wallet/components/WalletList.tsx similarity index 98% rename from src/components/change-wallet/WalletList.tsx rename to src/screens/change-wallet/components/WalletList.tsx index 326bff01d5b..698bc80eb6f 100644 --- a/src/components/change-wallet/WalletList.tsx +++ b/src/screens/change-wallet/components/WalletList.tsx @@ -18,7 +18,7 @@ import { } from '@/screens/change-wallet/ChangeWalletSheet'; import { Box, Separator, Text } from '@/design-system'; import { DndProvider, Draggable, DraggableScrollViewProps, UniqueIdentifier } from '@/components/drag-and-drop'; -import { PinnedWalletsGrid } from '@/screens/change-wallet/PinnedWalletsGrid'; +import { PinnedWalletsGrid } from '@/screens/change-wallet/components/PinnedWalletsGrid'; import { usePinnedWalletsStore } from '@/state/wallets/pinnedWalletsStore'; import { MenuItem } from '@/components/DropdownMenu'; import { DraggableScrollView } from '@/components/drag-and-drop/components/DraggableScrollView'; From af1e1b6d69007fa7dfcf3c795173a5a250399d46 Mon Sep 17 00:00:00 2001 From: Kane Thomas Date: Thu, 19 Dec 2024 11:20:57 -0500 Subject: [PATCH 38/54] migrate DraggableFlatList to use useDraggableScroll --- .../components/DraggableFlatList.tsx | 238 ++---------------- 1 file changed, 21 insertions(+), 217 deletions(-) diff --git a/src/components/drag-and-drop/components/DraggableFlatList.tsx b/src/components/drag-and-drop/components/DraggableFlatList.tsx index e13cebf8a5d..31bba453388 100644 --- a/src/components/drag-and-drop/components/DraggableFlatList.tsx +++ b/src/components/drag-and-drop/components/DraggableFlatList.tsx @@ -1,27 +1,13 @@ import React, { ComponentProps, ReactElement, useCallback, useMemo } from 'react'; import { CellRendererProps } from 'react-native'; import { FlatList } from 'react-native-gesture-handler'; -import Animated, { - AnimatedProps, - scrollTo, - useAnimatedReaction, - useAnimatedRef, - useAnimatedScrollHandler, - useSharedValue, -} from 'react-native-reanimated'; +import Animated, { AnimatedProps, useAnimatedRef, useAnimatedScrollHandler, useSharedValue } from 'react-native-reanimated'; import { useDndContext } from '../DndContext'; -import { useDraggableSort, UseDraggableStackOptions } from '../features'; +import { UseDraggableStackOptions } from '../features'; import type { UniqueIdentifier } from '../types'; -import { applyOffset, swapByItemCenterPoint } from '../utils'; +import { swapByItemCenterPoint } from '../utils'; import { Draggable } from './Draggable'; - -// IMPROVEMENT: expose these as props -const AUTOSCROLL_THRESHOLD = 50; -const AUTOSCROLL_MIN_SPEED = 1; -const AUTOSCROLL_MAX_SPEED = 3; - -// this is an arbitrary distance from the start of the autoscroll threshold in which the max speed is applied -const AUTOSCROLL_THRESHOLD_MAX_DISTANCE = 100; +import { useDraggableScroll } from '../features/sort/hooks/useDraggableScroll'; // eslint-disable-next-line @typescript-eslint/no-explicit-any type AnimatedFlatListProps = AnimatedProps>>; @@ -46,47 +32,11 @@ export type DraggableFlatListProps = Animate }; }; -function normalizeWorklet(value: number, fromMin: number, fromMax: number, toMin: number, toMax: number) { - 'worklet'; - return ((value - fromMin) * (toMax - toMin)) / (fromMax - fromMin) + toMin; -} - -const canScrollToWorklet = ({ - newOffset, - contentHeight, - layoutHeight, - currentOffset = 0, -}: { - newOffset: number; - contentHeight: number; - layoutHeight: number; - currentOffset: number; -}) => { - 'worklet'; - - const maxOffset = contentHeight - layoutHeight; - - if (newOffset < 0) { - return false; - } - - if (newOffset > maxOffset) { - return false; - } - - if (newOffset === currentOffset) { - return false; - } - - return true; -}; - export const DraggableFlatList = ({ data, debug, gap = 0, horizontal = false, - // initialOrder, onOrderChange, onOrderUpdate, renderItem, @@ -95,139 +45,33 @@ export const DraggableFlatList = ({ autoScrollInsets, ...otherProps }: DraggableFlatListProps): ReactElement => { - const { draggableActiveId, draggableContentOffset, draggableLayouts, draggableOffsets, draggableRestingOffsets, draggableActiveLayout } = - useDndContext(); - const animatedFlatListRef = useAnimatedRef>(); + const { draggableContentOffset } = useDndContext(); + const animatedFlatListRef = useAnimatedRef(); const contentHeight = useSharedValue(0); const layoutHeight = useSharedValue(0); const scrollOffset = useSharedValue(0); - // @ts-expect-error TODO: fix - const initialOrder = useMemo(() => data?.map((item: T) => item.id) ?? [], [data]); - - const { draggableSortOrder } = useDraggableSort({ - horizontal, - childrenIds: initialOrder, - onOrderChange, - onOrderUpdate, - shouldSwapWorklet, - }); - - const direction = horizontal ? 'column' : 'row'; - const size = 1; + // @ts-expect-error reanimated type issue + const childrenIds = useMemo(() => data?.map((item: T) => item.id) ?? [], [data]); const scrollHandler = useAnimatedScrollHandler(event => { scrollOffset.value = event.contentOffset.y; draggableContentOffset.y.value = event.contentOffset.y; }); - const autoscroll = useCallback( - (offset: number) => { - 'worklet'; - - // round to the nearest integer to make scrolling smoother - const smoothedOffset = Math.round(offset); - - const { value: activeId } = draggableActiveId; - - // this is similar logic to how the pan gesture onUpdate works in the DndProvider - if (activeId) { - const { value: layouts } = draggableLayouts; - const { value: offsets } = draggableOffsets; - - const activeLayout = layouts[activeId].value; - const activeOffset = offsets[activeId]; - - const newOffset = scrollOffset.value + smoothedOffset; - - if ( - !canScrollToWorklet({ - newOffset: newOffset, - contentHeight: contentHeight.value, - layoutHeight: layoutHeight.value, - currentOffset: scrollOffset.value, - }) - ) { - return; - } - - scrollTo(animatedFlatListRef, 0, newOffset, false); - - activeOffset.y.value += smoothedOffset; - - draggableActiveLayout.value = applyOffset(activeLayout, { - x: activeOffset.x.value, - y: activeOffset.y.value, - }); - } - }, - [ - draggableActiveId, - draggableLayouts, - draggableOffsets, - scrollOffset, - contentHeight, - layoutHeight, - animatedFlatListRef, - draggableActiveLayout, - ] - ); - - // Track sort order changes and update the offsets - useAnimatedReaction( - () => draggableSortOrder.value, - (nextOrder, prevOrder) => { - // Ignore initial reaction - if (prevOrder === null) { - return; - } - const { value: activeId } = draggableActiveId; - const { value: layouts } = draggableLayouts; - const { value: offsets } = draggableOffsets; - const { value: restingOffsets } = draggableRestingOffsets; - if (!activeId) { - return; - } - - const activeLayout = layouts[activeId].value; - const { width, height } = activeLayout; - const restingOffset = restingOffsets[activeId]; - - for (let nextIndex = 0; nextIndex < nextOrder.length; nextIndex++) { - const itemId = nextOrder[nextIndex]; - const prevIndex = prevOrder.findIndex(id => id === itemId); - // Skip items that haven't changed position - if (nextIndex === prevIndex) { - continue; - } - - const prevRow = Math.floor(prevIndex / size); - const prevCol = prevIndex % size; - const nextRow = Math.floor(nextIndex / size); - const nextCol = nextIndex % size; - const moveCol = nextCol - prevCol; - const moveRow = nextRow - prevRow; - - const offset = itemId === activeId ? restingOffset : offsets[itemId]; - - if (!restingOffset || !offsets[itemId]) { - continue; - } - - switch (direction) { - case 'row': - offset.y.value += moveRow * (height + gap); - break; - case 'column': - offset.x.value += moveCol * (width + gap); - break; - default: - break; - } - } - }, - [] - ); + useDraggableScroll({ + childrenIds, + onOrderChange, + onOrderUpdate, + shouldSwapWorklet, + horizontal, + contentHeight, + layoutHeight, + autoScrollInsets, + animatedScrollViewRef: animatedFlatListRef, + scrollOffset, + gap, + }); /* ⚠️ IMPROVEMENT: Optionally expose visible range to the parent */ // const viewableRange = useSharedValue({ @@ -248,46 +92,6 @@ export const DraggableFlatList = ({ // ); /** END */ - // On each frame that the draggable item's position (offsetY) changes, - // if it's within the threshold, we scroll relative to how far over the threshold it is. - // This runs every frame while the user is dragging and the item remains in the scroll trigger zone, - useAnimatedReaction( - () => draggableActiveLayout.value?.y, - activeItemY => { - if (activeItemY === undefined) return; - - const bottomThreshold = scrollOffset.value + layoutHeight.value - AUTOSCROLL_THRESHOLD - (autoScrollInsets?.bottom ?? 0); - const isNearBottom = activeItemY >= bottomThreshold; - - const topThreshold = scrollOffset.value + AUTOSCROLL_THRESHOLD + (autoScrollInsets?.top ?? 0); - const isNearTop = activeItemY <= topThreshold; - - if (isNearTop) { - const distanceFromTopThreshold = topThreshold - activeItemY; - const scrollSpeed = normalizeWorklet( - distanceFromTopThreshold, - 0, - AUTOSCROLL_THRESHOLD_MAX_DISTANCE, - AUTOSCROLL_MIN_SPEED, - AUTOSCROLL_MAX_SPEED - ); - autoscroll(-scrollSpeed); - } else if (isNearBottom) { - const distanceFromBottomThreshold = activeItemY - bottomThreshold; - - const scrollSpeed = normalizeWorklet( - distanceFromBottomThreshold, - 0, - AUTOSCROLL_THRESHOLD_MAX_DISTANCE, - AUTOSCROLL_MIN_SPEED, - AUTOSCROLL_MAX_SPEED - ); - - autoscroll(scrollSpeed); - } - } - ); - const CellRenderer = useCallback( (cellProps: CellRendererProps) => , [draggableProps] From ff44d3e464810bc81535cb96397e336f342d9fb9 Mon Sep 17 00:00:00 2001 From: Kane Thomas Date: Thu, 19 Dec 2024 11:27:11 -0500 Subject: [PATCH 39/54] update import, change max panel height --- src/screens/change-wallet/ChangeWalletSheet.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/screens/change-wallet/ChangeWalletSheet.tsx b/src/screens/change-wallet/ChangeWalletSheet.tsx index a7b7b55603e..7b0f346a6f9 100644 --- a/src/screens/change-wallet/ChangeWalletSheet.tsx +++ b/src/screens/change-wallet/ChangeWalletSheet.tsx @@ -5,7 +5,7 @@ import { Alert, InteractionManager } from 'react-native'; import ReactNativeHapticFeedback from 'react-native-haptic-feedback'; import { useDispatch } from 'react-redux'; import { ButtonPressAnimation } from '@/components/animations'; -import { WalletList } from '@/components/change-wallet/WalletList'; +import { WalletList } from '@/screens/change-wallet/components/WalletList'; import { removeWalletData } from '@/handlers/localstorage/removeWallet'; import { cleanUpWalletKeys, RainbowWallet } from '@/model/wallet'; import { useNavigation } from '@/navigation/Navigation'; @@ -35,11 +35,12 @@ import ConditionalWrap from 'conditional-wrap'; import Clipboard from '@react-native-clipboard/clipboard'; import { SettingsPages } from '@/screens/SettingsSheet/SettingsPages'; import { useWalletTransactionCounts } from '@/hooks/useWalletTransactionCounts'; +import { DEVICE_HEIGHT } from '@/utils/deviceUtils'; const PANEL_BOTTOM_OFFSET = Math.max(safeAreaInsetValues.bottom + 5, IS_IOS ? 8 : 30); export const PANEL_INSET_HORIZONTAL = 20; -export const MAX_PANEL_HEIGHT = 640; +export const MAX_PANEL_HEIGHT = Math.min(690, DEVICE_HEIGHT - 100); export const PANEL_HEADER_HEIGHT = 58; export const FOOTER_HEIGHT = 91; From 174f57d77eb2f7ec46f0200c2cacdfdabdd4c0e9 Mon Sep 17 00:00:00 2001 From: Kane Thomas Date: Thu, 19 Dec 2024 12:09:09 -0500 Subject: [PATCH 40/54] fix various shadows --- src/screens/change-wallet/ChangeWalletSheet.tsx | 3 +-- .../change-wallet/components/AddressRow.tsx | 14 ++++++++++++-- .../change-wallet/components/PinnedWalletsGrid.tsx | 9 ++++----- 3 files changed, 17 insertions(+), 9 deletions(-) diff --git a/src/screens/change-wallet/ChangeWalletSheet.tsx b/src/screens/change-wallet/ChangeWalletSheet.tsx index 7b0f346a6f9..af7bb02044f 100644 --- a/src/screens/change-wallet/ChangeWalletSheet.tsx +++ b/src/screens/change-wallet/ChangeWalletSheet.tsx @@ -672,8 +672,7 @@ export default function ChangeWalletSheet() { flexDirection="row" justifyContent="space-between" alignItems="center" - paddingHorizontal="20px" - paddingBottom="20px" + paddingHorizontal={{ custom: PANEL_INSET_HORIZONTAL }} paddingTop="24px" > {!editMode ? ( diff --git a/src/screens/change-wallet/components/AddressRow.tsx b/src/screens/change-wallet/components/AddressRow.tsx index 008c324c111..59e1c6278a6 100644 --- a/src/screens/change-wallet/components/AddressRow.tsx +++ b/src/screens/change-wallet/components/AddressRow.tsx @@ -4,7 +4,7 @@ import LinearGradient from 'react-native-linear-gradient'; import { useTheme } from '@/theme/ThemeContext'; import { ButtonPressAnimation } from '@/components/animations'; import ConditionalWrap from 'conditional-wrap'; -import { Box, Inline, Stack, Text, useForegroundColor, useColorMode, TextIcon } from '@/design-system'; +import { Box, Inline, Stack, Text, useForegroundColor, useColorMode, TextIcon, globalColors } from '@/design-system'; import { AddressItem, AddressMenuAction, AddressMenuActionData } from '@/screens/change-wallet/ChangeWalletSheet'; import { TextSize } from '@/design-system/typography/typeHierarchy'; import { TextWeight } from '@/design-system/components/Text/Text'; @@ -129,7 +129,17 @@ export function AddressRow({ data, editMode, onPress, menuItems, onPressMenuItem )} - + - PixelRatio.roundToNearestPixel( - Math.min((PANEL_WIDTH - PANEL_INSET_HORIZONTAL * 2 - GRID_GAP * (PINS_PER_ROW - 1)) / PINS_PER_ROW, MAX_AVATAR_SIZE) - ), + // math.floor to prevent pixel rounding causing premature grid wrapping + Math.floor(Math.min((PANEL_WIDTH - PANEL_INSET_HORIZONTAL * 2 - GRID_GAP * (PINS_PER_ROW - 1)) / PINS_PER_ROW, MAX_AVATAR_SIZE)), [] ); @@ -116,7 +115,7 @@ export function PinnedWalletsGrid({ walletItems, onPress, editMode, menuItems, o Date: Thu, 19 Dec 2024 12:45:22 -0500 Subject: [PATCH 41/54] fix shadow opacity, revert dnd offset drift fix for useDraggableScroll --- .../features/sort/hooks/useDraggableScroll.ts | 72 +++++++++++++++---- .../components/PinnedWalletsGrid.tsx | 4 +- 2 files changed, 61 insertions(+), 15 deletions(-) diff --git a/src/components/drag-and-drop/features/sort/hooks/useDraggableScroll.ts b/src/components/drag-and-drop/features/sort/hooks/useDraggableScroll.ts index b0423f5fc8b..2943979cdb9 100644 --- a/src/components/drag-and-drop/features/sort/hooks/useDraggableScroll.ts +++ b/src/components/drag-and-drop/features/sort/hooks/useDraggableScroll.ts @@ -138,6 +138,46 @@ export const useDraggableScroll = ({ ] ); + // TODO: This is a fix to offsets drifting when interacting too quickly that works for useDraggableGrid, but autoscrolling here breaks it + // useAnimatedReaction( + // () => draggableSortOrder.value, + // (nextOrder, prevOrder) => { + // if (prevOrder === null) return; + + // const { value: activeId } = draggableActiveId; + // const { value: layouts } = draggableLayouts; + // const { value: offsets } = draggableOffsets; + // const { value: restingOffsets } = draggableRestingOffsets; + + // if (!activeId) return; + + // const activeLayout = layouts[activeId].value; + // const { width, height } = activeLayout; + + // for (let nextIndex = 0; nextIndex < nextOrder.length; nextIndex++) { + // const itemId = nextOrder[nextIndex]; + // const originalIndex = childrenIds.indexOf(itemId); + // const prevIndex = prevOrder.findIndex(id => id === itemId); + + // if (nextIndex === prevIndex) continue; + + // const offset = itemId === activeId ? restingOffsets[activeId] : offsets[itemId]; + + // if (!restingOffsets[itemId] || !offsets[itemId]) continue; + + // const originalPosition = getFlexLayoutPosition({ index: originalIndex, width, height, gap, direction, size }); + // const newPosition = getFlexLayoutPosition({ index: nextIndex, width, height, gap, direction, size }); + + // if (direction === 'row') { + // offset.y.value = newPosition.y - originalPosition.y; + // } else if (direction === 'column') { + // offset.x.value = newPosition.x - originalPosition.x; + // } + // } + // }, + // [direction, gap, size, childrenIds] + // ); + useAnimatedReaction( () => draggableSortOrder.value, (nextOrder, prevOrder) => { @@ -152,28 +192,34 @@ export const useDraggableScroll = ({ const activeLayout = layouts[activeId].value; const { width, height } = activeLayout; + const restingOffset = restingOffsets[activeId]; for (let nextIndex = 0; nextIndex < nextOrder.length; nextIndex++) { const itemId = nextOrder[nextIndex]; - const originalIndex = childrenIds.indexOf(itemId); const prevIndex = prevOrder.findIndex(id => id === itemId); - if (nextIndex === prevIndex) continue; - const offset = itemId === activeId ? restingOffsets[activeId] : offsets[itemId]; - if (!restingOffsets[itemId] || !offsets[itemId]) continue; - - const originalPosition = getFlexLayoutPosition({ index: originalIndex, width, height, gap, direction, size }); - const newPosition = getFlexLayoutPosition({ index: nextIndex, width, height, gap, direction, size }); - - if (direction === 'row') { - offset.y.value = newPosition.y - originalPosition.y; - } else if (direction === 'column') { - offset.x.value = newPosition.x - originalPosition.x; + const prevRow = Math.floor(prevIndex / size); + const prevCol = prevIndex % size; + const nextRow = Math.floor(nextIndex / size); + const nextCol = nextIndex % size; + const moveCol = nextCol - prevCol; + const moveRow = nextRow - prevRow; + + const offset = itemId === activeId ? restingOffset : offsets[itemId]; + if (!restingOffset || !offsets[itemId]) continue; + + switch (direction) { + case 'row': + offset.y.value += moveRow * (height + gap); + break; + case 'column': + offset.x.value += moveCol * (width + gap); + break; } } }, - [direction, gap, size, childrenIds] + [] ); // React to active item position and autoscroll if necessary diff --git a/src/screens/change-wallet/components/PinnedWalletsGrid.tsx b/src/screens/change-wallet/components/PinnedWalletsGrid.tsx index b1f26836605..1295602fc32 100644 --- a/src/screens/change-wallet/components/PinnedWalletsGrid.tsx +++ b/src/screens/change-wallet/components/PinnedWalletsGrid.tsx @@ -132,7 +132,7 @@ export function PinnedWalletsGrid({ walletItems, onPress, editMode, menuItems, o x: 0, y: 10, blur: 30, - opacity: 1, + opacity: 0.3, color: 'blue', }, ], @@ -202,7 +202,7 @@ export function PinnedWalletsGrid({ walletItems, onPress, editMode, menuItems, o {walletName} - + {account.balance} From f4017fedc933e53aba3602d85ca8a340e4ff299f Mon Sep 17 00:00:00 2001 From: Kane Thomas Date: Thu, 19 Dec 2024 16:06:40 -0500 Subject: [PATCH 42/54] fix tooltip zindex, add shadow --- .../tooltips/FeatureHintTooltip.tsx | 117 ++++++++++-------- .../change-wallet/ChangeWalletSheet.tsx | 10 +- .../components/PinnedWalletsGrid.tsx | 4 +- .../change-wallet/components/WalletList.tsx | 4 +- 4 files changed, 79 insertions(+), 56 deletions(-) diff --git a/src/components/tooltips/FeatureHintTooltip.tsx b/src/components/tooltips/FeatureHintTooltip.tsx index 81f15b73643..740b9834132 100644 --- a/src/components/tooltips/FeatureHintTooltip.tsx +++ b/src/components/tooltips/FeatureHintTooltip.tsx @@ -120,7 +120,8 @@ interface FeatureHintTooltipProps { onDismissed?: () => void; } -// currently only used for first time feature hints, but if needed can be better abstracted for general tooltips +// Currently only used for first time feature hints, but if needed can be better abstracted for general tooltips +// If need to show above / on top of navigation elements, will need to refactor to use AbsolutePortal export const FeatureHintTooltip = forwardRef( ( { @@ -225,63 +226,69 @@ export const FeatureHintTooltip = forwardRef + <> {children} - - - - - } - > - + + + + + } > - - - - - 􀍱 - - - - {TitleComponent || ( - - {title} + + + + + 􀍱 - )} - {SubtitleComponent || ( - - {subtitle} - - )} - - - - - - 􀆄 + + + {TitleComponent || ( + + {title} - - + )} + {SubtitleComponent || ( + + {subtitle} + + )} + + + + + + 􀆄 + + + + - - - - - + + + + + ); } ); @@ -292,7 +299,15 @@ const styles = StyleSheet.create({ tooltipContainer: { position: 'absolute', height: TOOLTIP_HEIGHT + ARROW_SIZE, - zIndex: 1000, + zIndex: 99999999, + shadowColor: '#000000', + shadowOffset: { + width: 0, + height: 10, + }, + shadowOpacity: 0.3, + shadowRadius: 50, + elevation: 25, }, maskedContainer: { flex: 1, diff --git a/src/screens/change-wallet/ChangeWalletSheet.tsx b/src/screens/change-wallet/ChangeWalletSheet.tsx index af7bb02044f..0b997e9307c 100644 --- a/src/screens/change-wallet/ChangeWalletSheet.tsx +++ b/src/screens/change-wallet/ChangeWalletSheet.tsx @@ -591,7 +591,15 @@ export default function ChangeWalletSheet() { - + {i18n.t(i18n.l.wallet.change_wallet.wallets)} diff --git a/src/screens/change-wallet/components/PinnedWalletsGrid.tsx b/src/screens/change-wallet/components/PinnedWalletsGrid.tsx index 1295602fc32..a00236884db 100644 --- a/src/screens/change-wallet/components/PinnedWalletsGrid.tsx +++ b/src/screens/change-wallet/components/PinnedWalletsGrid.tsx @@ -17,7 +17,6 @@ import { IS_IOS } from '@/env'; import { useTheme } from '@/theme'; import { DRAGGABLE_ACTIVATION_DELAY } from '@/screens/change-wallet/components/WalletList'; import { triggerHaptics } from 'react-native-turbo-haptics'; -import { PixelRatio } from 'react-native'; const UNPIN_BADGE_SIZE = 28; const PINS_PER_ROW = 3; @@ -87,6 +86,7 @@ export function PinnedWalletsGrid({ walletItems, onPress, editMode, menuItems, o > {draggableItems.map(account => { const walletName = removeFirstEmojiFromString(account.label) || address(account.address, 4, 4); + const filteredMenuItems = menuItems.filter(item => (account.isReadOnly ? item.actionKey !== AddressMenuAction.Settings : true)); return ( triggerAction="longPress" menuConfig={{ - menuItems: menuItems.filter(item => (account.isReadOnly ? item.actionKey !== AddressMenuAction.Settings : true)), + menuItems: filteredMenuItems, menuTitle: walletName, }} onPressMenuItem={action => onPressMenuItem(action, { address: account.address })} diff --git a/src/screens/change-wallet/components/WalletList.tsx b/src/screens/change-wallet/components/WalletList.tsx index 698bc80eb6f..a30866f02b0 100644 --- a/src/screens/change-wallet/components/WalletList.tsx +++ b/src/screens/change-wallet/components/WalletList.tsx @@ -112,7 +112,7 @@ export function WalletList({ walletItems, menuItems, onPressMenuItem, onPressAcc ); }, [pinnedWalletItems, onPressAccount, editMode, unpinnedWalletItems.length, menuItems, onPressMenuItem, onDraggableActivationWorklet]); - const renderScrollItem = useCallback( + const renderAddressRow = useCallback( (item: AddressItem) => ( {renderPinnedWalletsSection()} - {draggableUnpinnedWalletItems.map(item => renderScrollItem(item))} + {draggableUnpinnedWalletItems.map(item => renderAddressRow(item))} From 76470ffb92253d7e8b995e6c7f04392eb57594dc Mon Sep 17 00:00:00 2001 From: Kane Thomas Date: Thu, 19 Dec 2024 16:06:58 -0500 Subject: [PATCH 43/54] improve jiggle animation --- src/components/animations/JiggleAnimation.tsx | 20 +++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/src/components/animations/JiggleAnimation.tsx b/src/components/animations/JiggleAnimation.tsx index 5c34048e1ff..a1ea5cb606a 100644 --- a/src/components/animations/JiggleAnimation.tsx +++ b/src/components/animations/JiggleAnimation.tsx @@ -21,10 +21,18 @@ export function JiggleAnimation({ children, amplitude = 2, duration = 125, enabl const rotation = useSharedValue(0); const internalEnabled = useSharedValue(typeof enabled === 'boolean' ? enabled : false); - // slightly randomize duration to avoid sync with other jiggles - const instanceDuration = duration * (1 + (Math.random() - 0.5) * 0.2); // 10% variance - // randomize initial rotation direction to avoid sync with other jiggles - const initialRotation = Math.random() * amplitude; + // Randomize some initial values to avoid sync with other jiggles + // Randomize duration (5% variance) + const instanceDuration = duration * (1 + (Math.random() - 0.5) * 0.1); + + // Randomize initial rotation that's at least 50% of the amplitude + const minInitialRotation = amplitude * 0.5; + const rotationRange = amplitude - minInitialRotation; + const initialRotation = minInitialRotation + Math.random() * rotationRange; + + // Randomize initial direction + const initialDirection = Math.random() < 0.5 ? -1 : 1; + const firstRotation = initialRotation * initialDirection; useEffect(() => { if (typeof enabled === 'boolean') { @@ -39,8 +47,8 @@ export function JiggleAnimation({ children, amplitude = 2, duration = 125, enabl enabled => { if (enabled) { rotation.value = withSequence( - withTiming(initialRotation, { duration: instanceDuration / 2 }), - withRepeat(withTiming(-amplitude, { duration: instanceDuration }), -1, true) + withTiming(firstRotation, { duration: instanceDuration / 2 }), + withRepeat(withTiming(-amplitude * initialDirection, { duration: instanceDuration }), -1, true) ); } else { cancelAnimation(rotation); From 69ca2b4e242d41d4da6e6a70041e12712141aa03 Mon Sep 17 00:00:00 2001 From: Kane Thomas Date: Thu, 19 Dec 2024 17:11:50 -0500 Subject: [PATCH 44/54] minor adjustments --- .../drag-and-drop/features/sort/hooks/useDraggableScroll.ts | 2 +- src/screens/change-wallet/components/PinnedWalletsGrid.tsx | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/components/drag-and-drop/features/sort/hooks/useDraggableScroll.ts b/src/components/drag-and-drop/features/sort/hooks/useDraggableScroll.ts index 2943979cdb9..8e7232cfdc5 100644 --- a/src/components/drag-and-drop/features/sort/hooks/useDraggableScroll.ts +++ b/src/components/drag-and-drop/features/sort/hooks/useDraggableScroll.ts @@ -6,7 +6,7 @@ import { useCallback } from 'react'; const AUTOSCROLL_THRESHOLD = 50; const AUTOSCROLL_MIN_SPEED = 1; -const AUTOSCROLL_MAX_SPEED = 5; +const AUTOSCROLL_MAX_SPEED = 7; const AUTOSCROLL_THRESHOLD_MAX_DISTANCE = 100; function easeInOutCubicWorklet(x: number): number { diff --git a/src/screens/change-wallet/components/PinnedWalletsGrid.tsx b/src/screens/change-wallet/components/PinnedWalletsGrid.tsx index a00236884db..47d2c42b485 100644 --- a/src/screens/change-wallet/components/PinnedWalletsGrid.tsx +++ b/src/screens/change-wallet/components/PinnedWalletsGrid.tsx @@ -21,7 +21,7 @@ import { triggerHaptics } from 'react-native-turbo-haptics'; const UNPIN_BADGE_SIZE = 28; const PINS_PER_ROW = 3; const GRID_GAP = 26; -const MAX_AVATAR_SIZE = 91; +const MAX_AVATAR_SIZE = 105; type PinnedWalletsGridProps = { walletItems: AddressItem[]; @@ -113,8 +113,8 @@ export function PinnedWalletsGrid({ walletItems, onPress, editMode, menuItems, o shouldRasterizeIOS > Date: Fri, 20 Dec 2024 09:23:50 -0500 Subject: [PATCH 45/54] hide total balance display if user only has watched wallets --- src/screens/change-wallet/ChangeWalletSheet.tsx | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/screens/change-wallet/ChangeWalletSheet.tsx b/src/screens/change-wallet/ChangeWalletSheet.tsx index 0b997e9307c..bda72fc7ad6 100644 --- a/src/screens/change-wallet/ChangeWalletSheet.tsx +++ b/src/screens/change-wallet/ChangeWalletSheet.tsx @@ -185,13 +185,17 @@ export default function ChangeWalletSheet() { isLoadingTransactionCounts, ]); + // TODO: bug where this can display as NaN const ownedWalletsTotalBalance = useMemo(() => { let isLoadingBalance = false; + let hasOwnedWallets = false; const totalBalance = Object.values(walletsWithBalancesAndNames).reduce((acc, wallet) => { // only include owned wallet balances if (wallet.type === WalletTypes.readOnly) return acc; + hasOwnedWallets = true; + const visibleAccounts = wallet.addresses.filter(account => account.visible); let walletTotalBalance = '0'; @@ -206,6 +210,9 @@ export default function ChangeWalletSheet() { return addDisplay(acc, walletTotalBalance); }, '0'); + // If user has no owned wallets, return null so we can conditionally hide the balance + if (!hasOwnedWallets) return null; + if (isLoadingBalance) return i18n.t(i18n.l.wallet.change_wallet.loading_balance); return convertAmountToNativeDisplay(totalBalance, nativeCurrency); @@ -683,7 +690,7 @@ export default function ChangeWalletSheet() { paddingHorizontal={{ custom: PANEL_INSET_HORIZONTAL }} paddingTop="24px" > - {!editMode ? ( + {!editMode && ownedWalletsTotalBalance ? ( {i18n.t(i18n.l.wallet.change_wallet.total_balance)} From f445acdbfa554f3ee16a657f8b34d60532185e61 Mon Sep 17 00:00:00 2001 From: Kane Thomas Date: Fri, 20 Dec 2024 11:16:37 -0500 Subject: [PATCH 46/54] fix total balance NaN by fixing addDisplay utility, add new test for utility --- src/helpers/__tests__/utilities.test.ts | 5 ++ src/helpers/utilities.ts | 18 +++++-- .../change-wallet/ChangeWalletSheet.tsx | 54 ++++++++++++------- 3 files changed, 54 insertions(+), 23 deletions(-) diff --git a/src/helpers/__tests__/utilities.test.ts b/src/helpers/__tests__/utilities.test.ts index 16a69f4e29c..28915989620 100644 --- a/src/helpers/__tests__/utilities.test.ts +++ b/src/helpers/__tests__/utilities.test.ts @@ -131,6 +131,11 @@ it('addDisplay', () => { expect(result).toBe('$1,062.71'); }); +it('addDisplay with large numbers', () => { + const result = addDisplay('$1,002,000.50', '$13,912.21'); + expect(result).toBe('$1,015,912.71'); +}); + it('addDisplay with left-aligned currency', () => { const result = addDisplay('A$150.50', 'A$912.21'); expect(result).toBe('A$1,062.71'); diff --git a/src/helpers/utilities.ts b/src/helpers/utilities.ts index ea543581048..1a2d32a1d5e 100644 --- a/src/helpers/utilities.ts +++ b/src/helpers/utilities.ts @@ -115,11 +115,21 @@ export const convertStringToHex = (stringToConvert: string): string => new BigNu export const add = (numberOne: BigNumberish, numberTwo: BigNumberish): string => new BigNumber(numberOne).plus(numberTwo).toFixed(); export const addDisplay = (numberOne: string, numberTwo: string): string => { - const unit = numberOne.replace(/[\d.-]/g, ''); + const unit = numberOne.replace(/[\d,.]/g, ''); const leftAlignedUnit = numberOne.indexOf(unit) === 0; - return currency(0, { symbol: unit, pattern: leftAlignedUnit ? '!#' : '#!' }) - .add(numberOne) - .add(numberTwo) + + const cleanNumber = (str: string): string => { + const numericPart = str.replace(/[^\d,.]/g, ''); + return numericPart.replace(/,/g, ''); + }; + + return currency(0, { + symbol: unit, + pattern: leftAlignedUnit ? '!#' : '#!', + errorOnInvalid: true, + }) + .add(cleanNumber(numberOne)) + .add(cleanNumber(numberTwo)) .format(); }; diff --git a/src/screens/change-wallet/ChangeWalletSheet.tsx b/src/screens/change-wallet/ChangeWalletSheet.tsx index bda72fc7ad6..0672a1c4703 100644 --- a/src/screens/change-wallet/ChangeWalletSheet.tsx +++ b/src/screens/change-wallet/ChangeWalletSheet.tsx @@ -23,7 +23,7 @@ import { IS_IOS } from '@/env'; import { remotePromoSheetsStore } from '@/state/remotePromoSheets/remotePromoSheets'; import { RootStackParamList } from '@/navigation/types'; import { Box, globalColors, HitSlop, Inline, Stack, Text } from '@/design-system'; -import { addDisplay, convertAmountToNativeDisplay } from '@/helpers/utilities'; +import { addDisplay } from '@/helpers/utilities'; import { Panel, TapToDismiss } from '@/components/SmoothPager/ListPanel'; import { SheetHandleFixedToTop } from '@/components/sheet'; import { EasingGradient } from '@/components/easing-gradient/EasingGradient'; @@ -84,7 +84,7 @@ export default function ChangeWalletSheet() { const { colors, isDarkMode } = useTheme(); const { updateWebProfile } = useWebData(); - const { accountAddress, nativeCurrency } = useAccountSettings(); + const { accountAddress } = useAccountSettings(); const { goBack, navigate } = useNavigation(); const dispatch = useDispatch(); const initializeWallet = useInitializeWallet(); @@ -145,7 +145,7 @@ export default function ChangeWalletSheet() { isReadOnly: wallet.type === WalletTypes.readOnly, isSelected: account.address === currentAddress, rowType: RowTypes.ADDRESS, - walletId: wallet?.id, + walletId: wallet.id, }; if ([WalletTypes.mnemonic, WalletTypes.seed, WalletTypes.privateKey].includes(wallet.type)) { @@ -185,38 +185,54 @@ export default function ChangeWalletSheet() { isLoadingTransactionCounts, ]); - // TODO: bug where this can display as NaN const ownedWalletsTotalBalance = useMemo(() => { let isLoadingBalance = false; let hasOwnedWallets = false; - const totalBalance = Object.values(walletsWithBalancesAndNames).reduce((acc, wallet) => { - // only include owned wallet balances - if (wallet.type === WalletTypes.readOnly) return acc; + // We have to explicitly handle the null case because the addDisplay function expects the currency symbol, and we cannot assume the position of the currency symbol + const totalBalance: string | null = Object.values(walletsWithBalancesAndNames).reduce( + (acc, wallet) => { + // only include owned wallet balances + if (wallet.type === WalletTypes.readOnly) return acc; - hasOwnedWallets = true; + hasOwnedWallets = true; + const visibleAccounts = wallet.addresses.filter(account => account.visible); + let walletTotalBalance: string | null = null; - const visibleAccounts = wallet.addresses.filter(account => account.visible); + visibleAccounts.forEach(account => { + if (!account.balancesMinusHiddenBalances) { + isLoadingBalance = true; + return; + } + if (walletTotalBalance === null) { + walletTotalBalance = account.balancesMinusHiddenBalances; + return; + } - let walletTotalBalance = '0'; + walletTotalBalance = addDisplay(walletTotalBalance, account.balancesMinusHiddenBalances); + }); - visibleAccounts.forEach(account => { - if (!account.balancesMinusHiddenBalances) { - isLoadingBalance = true; + if (acc === null) { + return walletTotalBalance; + } + if (walletTotalBalance === null) { + return acc; } - walletTotalBalance = addDisplay(walletTotalBalance, account.balancesMinusHiddenBalances || '0'); - }); - return addDisplay(acc, walletTotalBalance); - }, '0'); + return addDisplay(acc, walletTotalBalance); + }, + null as string | null + ); // If user has no owned wallets, return null so we can conditionally hide the balance if (!hasOwnedWallets) return null; if (isLoadingBalance) return i18n.t(i18n.l.wallet.change_wallet.loading_balance); - return convertAmountToNativeDisplay(totalBalance, nativeCurrency); - }, [walletsWithBalancesAndNames, nativeCurrency]); + if (totalBalance === null) return null; + + return totalBalance; + }, [walletsWithBalancesAndNames]); const onChangeAccount = useCallback( async (walletId: string, address: string, fromDeletion = false) => { From 3e9572e1157a39b7f2467d7384fbde76378e0b40 Mon Sep 17 00:00:00 2001 From: Kane Thomas Date: Fri, 20 Dec 2024 11:48:41 -0500 Subject: [PATCH 47/54] custom spring config for wallet draggable return to origin --- src/components/animations/animationConfigs.ts | 1 + src/components/drag-and-drop/DndProvider.tsx | 30 +++++++------------ .../change-wallet/components/WalletList.tsx | 15 ++++++++-- 3 files changed, 25 insertions(+), 21 deletions(-) diff --git a/src/components/animations/animationConfigs.ts b/src/components/animations/animationConfigs.ts index bd86931d4c4..93cb64561c1 100644 --- a/src/components/animations/animationConfigs.ts +++ b/src/components/animations/animationConfigs.ts @@ -25,6 +25,7 @@ const springAnimations = createSpringConfigs({ keyboardConfig: disableForTestingEnvironment({ damping: 500, mass: 3, stiffness: 1000 }), sliderConfig: disableForTestingEnvironment({ damping: 40, mass: 1.25, stiffness: 450 }), slowSpring: disableForTestingEnvironment({ damping: 500, mass: 3, stiffness: 800 }), + walletDraggableConfig: disableForTestingEnvironment({ damping: 36, mass: 0.8, stiffness: 800 }), snappierSpringConfig: disableForTestingEnvironment({ damping: 42, mass: 0.8, stiffness: 800 }), snappySpringConfig: disableForTestingEnvironment({ damping: 100, mass: 0.8, stiffness: 275 }), springConfig: disableForTestingEnvironment({ damping: 100, mass: 1.2, stiffness: 750 }), diff --git a/src/components/drag-and-drop/DndProvider.tsx b/src/components/drag-and-drop/DndProvider.tsx index a73ca271117..fcd3908311b 100644 --- a/src/components/drag-and-drop/DndProvider.tsx +++ b/src/components/drag-and-drop/DndProvider.tsx @@ -378,26 +378,18 @@ export const DndProvider = forwardRef { - // Cancel if we are interacting again with this item - if (panGestureState.value !== State.END && panGestureState.value !== State.FAILED && states[activeId].value !== 'acting') { - return; - } - if (states[activeId]) { - states[activeId].value = 'resting'; - } - // for (const [id, offset] of Object.entries(offsets)) { - // console.log({ [id]: [offset.x.value.toFixed(2), offset.y.value.toFixed(2)] }); - // } + animatePointWithSpring(activeOffset, [targetX, targetY], [springConfig, springConfig], () => { + // Cancel if we are interacting again with this item + if (panGestureState.value !== State.END && panGestureState.value !== State.FAILED && states[activeId].value !== 'acting') { + return; } - ); + if (states[activeId]) { + states[activeId].value = 'resting'; + } + // for (const [id, offset] of Object.entries(offsets)) { + // console.log({ [id]: [offset.x.value.toFixed(2), offset.y.value.toFixed(2)] }); + // } + }); }) .withTestId('DndProvider.pan'); diff --git a/src/screens/change-wallet/components/WalletList.tsx b/src/screens/change-wallet/components/WalletList.tsx index a30866f02b0..a786d0f56d6 100644 --- a/src/screens/change-wallet/components/WalletList.tsx +++ b/src/screens/change-wallet/components/WalletList.tsx @@ -23,6 +23,7 @@ import { usePinnedWalletsStore } from '@/state/wallets/pinnedWalletsStore'; import { MenuItem } from '@/components/DropdownMenu'; import { DraggableScrollView } from '@/components/drag-and-drop/components/DraggableScrollView'; import { triggerHaptics } from 'react-native-turbo-haptics'; +import { SPRING_CONFIGS } from '@/components/animations/animationConfigs'; export const DRAGGABLE_ACTIVATION_DELAY = 150; const DRAG_ACTIVATION_DELAY = 150; @@ -87,7 +88,12 @@ export function WalletList({ walletItems, menuItems, onPressMenuItem, onPressAcc return ( <> {hasPinnedWallets && ( - + 0 && ( - + Date: Fri, 20 Dec 2024 12:04:46 -0500 Subject: [PATCH 48/54] fix navigation param address type error --- src/navigation/types.ts | 2 +- src/walletConnect/sheets/AuthRequest.tsx | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/navigation/types.ts b/src/navigation/types.ts index 2002748c092..940d107a634 100644 --- a/src/navigation/types.ts +++ b/src/navigation/types.ts @@ -35,7 +35,7 @@ export type RootStackParamList = { [Routes.CHANGE_WALLET_SHEET]: { watchOnly?: boolean; currentAccountAddress?: string; - onChangeWallet?: (address: string, wallet?: RainbowWallet) => void; + onChangeWallet?: (address: string | Address, wallet?: RainbowWallet) => void; }; [Routes.SPEED_UP_AND_CANCEL_BOTTOM_SHEET]: { accentColor?: string; diff --git a/src/walletConnect/sheets/AuthRequest.tsx b/src/walletConnect/sheets/AuthRequest.tsx index 724cae00de2..75d0b727927 100644 --- a/src/walletConnect/sheets/AuthRequest.tsx +++ b/src/walletConnect/sheets/AuthRequest.tsx @@ -19,6 +19,7 @@ import { useDappMetadata } from '@/resources/metadata/dapp'; import { DAppStatus } from '@/graphql/__generated__/metadata'; import { InfoAlert } from '@/components/info-alert/info-alert'; import { WalletKitTypes } from '@reown/walletkit'; +import { Address } from 'viem'; export function AuthRequest({ requesterMeta, @@ -150,7 +151,7 @@ export function AuthRequest({ watchOnly: true, currentAccountAddress: address, onChangeWallet(address) { - setAddress(address); + setAddress(address as Address); goBack(); }, }); From c534b4aac5ad2873da79c275b3b46d01a77015eb Mon Sep 17 00:00:00 2001 From: Kane Thomas Date: Fri, 20 Dec 2024 15:37:54 -0500 Subject: [PATCH 49/54] prevent auto pin from running multiple times, remove total balance text --- src/screens/change-wallet/ChangeWalletSheet.tsx | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/screens/change-wallet/ChangeWalletSheet.tsx b/src/screens/change-wallet/ChangeWalletSheet.tsx index 0672a1c4703..5d4f0aa0da4 100644 --- a/src/screens/change-wallet/ChangeWalletSheet.tsx +++ b/src/screens/change-wallet/ChangeWalletSheet.tsx @@ -91,9 +91,9 @@ export default function ChangeWalletSheet() { const walletsWithBalancesAndNames = useWalletsWithBalancesAndNames(); const initialHasShownEditHintTooltip = useMemo(() => usePinnedWalletsStore.getState().hasShownEditHintTooltip, []); - const initialHasAutoPinnedAddresses = useMemo(() => usePinnedWalletsStore.getState().hasAutoPinnedAddresses, []); const initialPinnedAddressCount = useMemo(() => usePinnedWalletsStore.getState().pinnedAddresses.length, []); const { transactionCounts, isLoading: isLoadingTransactionCounts } = useWalletTransactionCounts(wallets || {}); + const hasAutoPinnedAddresses = usePinnedWalletsStore(state => state.hasAutoPinnedAddresses); const featureHintTooltipRef = useRef(null); @@ -164,7 +164,7 @@ export default function ChangeWalletSheet() { // If user has never seen pinned addresses feature, auto-pin the users most used owned addresses useEffect(() => { - if (initialHasAutoPinnedAddresses || initialPinnedAddressCount > 0 || isLoadingTransactionCounts) return; + if (hasAutoPinnedAddresses || initialPinnedAddressCount > 0 || isLoadingTransactionCounts) return; const pinnableAddresses = allWalletItems.filter(item => !item.isReadOnly).map(item => item.address); @@ -179,7 +179,7 @@ export default function ChangeWalletSheet() { }, [ allWalletItems, setPinnedAddresses, - initialHasAutoPinnedAddresses, + hasAutoPinnedAddresses, initialPinnedAddressCount, transactionCounts, isLoadingTransactionCounts, @@ -706,7 +706,8 @@ export default function ChangeWalletSheet() { paddingHorizontal={{ custom: PANEL_INSET_HORIZONTAL }} paddingTop="24px" > - {!editMode && ownedWalletsTotalBalance ? ( + {/* TODO: enable when blurview is implemented */} + {/* {!editMode && ownedWalletsTotalBalance ? ( {i18n.t(i18n.l.wallet.change_wallet.total_balance)} @@ -717,7 +718,8 @@ export default function ChangeWalletSheet() { ) : ( - )} + )} */} + Date: Sat, 4 Jan 2025 13:47:14 -0500 Subject: [PATCH 50/54] add back testID to add wallet button to fix e2e --- src/screens/change-wallet/ChangeWalletSheet.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/screens/change-wallet/ChangeWalletSheet.tsx b/src/screens/change-wallet/ChangeWalletSheet.tsx index 5d4f0aa0da4..dd8c0ebce69 100644 --- a/src/screens/change-wallet/ChangeWalletSheet.tsx +++ b/src/screens/change-wallet/ChangeWalletSheet.tsx @@ -720,7 +720,7 @@ export default function ChangeWalletSheet() { )} */} - + Date: Mon, 6 Jan 2025 15:12:18 -0500 Subject: [PATCH 51/54] remove concept of per item activation delays in dnd, move logic to gesture onStart from onBegin --- src/components/drag-and-drop/DndContext.ts | 1 - src/components/drag-and-drop/DndProvider.tsx | 194 ++++++------------ .../drag-and-drop/hooks/useDraggable.ts | 2 - 3 files changed, 62 insertions(+), 135 deletions(-) diff --git a/src/components/drag-and-drop/DndContext.ts b/src/components/drag-and-drop/DndContext.ts index c09ae4f64c4..d5e9a4a9787 100644 --- a/src/components/drag-and-drop/DndContext.ts +++ b/src/components/drag-and-drop/DndContext.ts @@ -23,7 +23,6 @@ export type DndContextValue = { draggableOffsets: SharedValue; draggableRestingOffsets: SharedValue; draggableStates: SharedValue; - draggablePendingId: SharedValue; draggableActiveId: SharedValue; droppableActiveId: SharedValue; draggableActiveLayout: SharedValue; diff --git a/src/components/drag-and-drop/DndProvider.tsx b/src/components/drag-and-drop/DndProvider.tsx index fcd3908311b..0c524209220 100644 --- a/src/components/drag-and-drop/DndProvider.tsx +++ b/src/components/drag-and-drop/DndProvider.tsx @@ -1,28 +1,17 @@ -import React, { - ComponentType, - forwardRef, - MutableRefObject, - PropsWithChildren, - RefObject, - useCallback, - useImperativeHandle, - useMemo, - useRef, -} from 'react'; +import React, { ComponentType, forwardRef, PropsWithChildren, RefObject, useImperativeHandle, useMemo, useRef } from 'react'; import { LayoutRectangle, StyleProp, View, ViewStyle } from 'react-native'; import { Gesture, GestureDetector, GestureEventPayload, GestureStateChangeEvent, + GestureType, GestureUpdateEvent, - PanGesture, PanGestureHandlerEventPayload, State, } from 'react-native-gesture-handler'; import ReactNativeHapticFeedback, { HapticFeedbackTypes } from 'react-native-haptic-feedback'; import { cancelAnimation, runOnJS, useAnimatedReaction, useSharedValue, type WithSpringConfig } from 'react-native-reanimated'; -import { useAnimatedTimeout } from '@/hooks/reanimated/useAnimatedTimeout'; import { DndContext, DraggableStates, @@ -35,16 +24,21 @@ import { } from './DndContext'; import { useSharedPoint } from './hooks'; import type { UniqueIdentifier } from './types'; -import { animatePointWithSpring, applyOffset, getDistance, includesPoint, overlapsRectangle, Point, Rectangle } from './utils'; +import { animatePointWithSpring, applyOffset, includesPoint, overlapsRectangle, Point, Rectangle } from './utils'; + +type WaitForRef = + | React.RefObject + | React.RefObject + | React.MutableRefObject; export type DndProviderProps = { activationDelay?: number; debug?: boolean; disabled?: boolean; - gestureRef?: MutableRefObject; + gestureRef?: React.MutableRefObject; hapticFeedback?: HapticFeedbackTypes; minDistance?: number; - onBegin?: ( + onStart?: ( event: GestureStateChangeEvent, meta: { activeId: UniqueIdentifier; activeLayout: LayoutRectangle } ) => void; @@ -61,7 +55,7 @@ export type DndProviderProps = { simultaneousHandlers?: RefObject>; springConfig?: WithSpringConfig; style?: StyleProp; - waitFor?: RefObject>; + waitFor?: WaitForRef; }; export type DndProviderHandle = Pick< @@ -78,7 +72,7 @@ export const DndProvider = forwardRef({}); const draggableRestingOffsets = useSharedValue({}); const draggableStates = useSharedValue({}); - const draggablePendingId = useSharedValue(null); const draggableActiveId = useSharedValue(null); const droppableActiveId = useSharedValue(null); const draggableActiveLayout = useSharedValue(null); @@ -131,7 +124,6 @@ export const DndProvider = forwardRef { - 'worklet'; - const id = draggablePendingId.value; - - if (id !== null) { - debug && console.log(`draggableActiveId.value = ${id}`); - draggableActiveId.value = id; - - const { value: layouts } = draggableLayouts; - const { value: offsets } = draggableOffsets; - const { value: activeLayout } = layouts[id]; - const activeOffset = offsets[id]; - - draggableActiveLayout.value = applyOffset(activeLayout, { - x: activeOffset.x.value, - y: activeOffset.y.value, - }); - draggableStates.value[id].value = 'dragging'; - } - }, [debug, draggableActiveId, draggableActiveLayout, draggableLayouts, draggableOffsets, draggablePendingId, draggableStates]); - - const { clearTimeout: clearActiveIdTimeout, start: setActiveIdWithDelay } = useAnimatedTimeout({ - delayMs: activationDelay, - onTimeoutWorklet: setActiveId, - }); - const panGesture = useMemo(() => { const findActiveLayoutId = (point: Point): UniqueIdentifier | null => { 'worklet'; @@ -199,7 +165,6 @@ export const DndProvider = forwardRef { + .onStart(event => { const { state, x, y } = event; - debug && console.log('begin', { state, x, y }); - // console.log("begin", { state, x, y }); - // Track current state for cancellation purposes + + debug && console.log('onStart', { state, x, y }); + + const activeId = findActiveLayoutId({ x, y }); + + // No item found, ignore gesture. + if (activeId === null) return; + panGestureState.value = state; + const { value: layouts } = draggableLayouts; const { value: offsets } = draggableOffsets; const { value: restingOffsets } = draggableRestingOffsets; - const { value: options } = draggableOptions; + // const { value: options } = draggableOptions; const { value: states } = draggableStates; - // for (const [id, offset] of Object.entries(offsets)) { - // console.log({ [id]: [offset.x.value, offset.y.value] }); - // } - // Find the active layout key under {x, y} - const activeId = findActiveLayoutId({ x, y }); - // Check if an item was actually selected - if (activeId !== null) { - // Record any ongoing current offset as our initial offset for the gesture - const activeLayout = layouts[activeId].value; - const activeOffset = offsets[activeId]; - const restingOffset = restingOffsets[activeId]; - const { value: activeState } = states[activeId]; - draggableInitialOffset.x.value = activeOffset.x.value; - draggableInitialOffset.y.value = activeOffset.y.value; - // Cancel the ongoing animation if we just reactivated an acting/dragging item - if (['dragging', 'acting'].includes(activeState)) { - cancelAnimation(activeOffset.x); - cancelAnimation(activeOffset.y); - // If not we should reset the resting offset to the current offset value - // But only if the item is not currently still animating - } else { - // active or pending - // Record current offset as our natural resting offset for the gesture - restingOffset.x.value = activeOffset.x.value; - restingOffset.y.value = activeOffset.y.value; - } - // Update activeId directly or with an optional delay - const { activationDelay } = options[activeId]; - - if (activationDelay > 0) { - draggablePendingId.value = activeId; - draggableStates.value[activeId].value = 'pending'; - setActiveIdWithDelay(); - } else { - draggableActiveId.value = activeId; - draggableActiveLayout.value = applyOffset(activeLayout, { - x: activeOffset.x.value, - y: activeOffset.y.value, - }); - draggableStates.value[activeId].value = 'dragging'; - } - if (onBegin) { - onBegin(event, { activeId, activeLayout }); - } + const activeLayout = layouts[activeId].value; + const activeOffset = offsets[activeId]; + const restingOffset = restingOffsets[activeId]; + + const { value: activeState } = states[activeId]; + + onStart?.(event, { activeId, activeLayout: activeLayout }); + + draggableInitialOffset.x.value = activeOffset.x.value; + draggableInitialOffset.y.value = activeOffset.y.value; + // Cancel the ongoing animation if we just reactivated an acting/dragging item + if (['dragging', 'acting'].includes(activeState)) { + cancelAnimation(activeOffset.x); + cancelAnimation(activeOffset.y); + // If not we should reset the resting offset to the current offset value + // But only if the item is not currently still animating + } else { + // active or pending + // Record current offset as our natural resting offset for the gesture + restingOffset.x.value = activeOffset.x.value; + restingOffset.y.value = activeOffset.y.value; } + draggableActiveId.value = activeId; + draggableActiveLayout.value = applyOffset(activeLayout, { + x: activeOffset.x.value, + y: activeOffset.y.value, + }); + draggableStates.value[activeId].value = 'dragging'; }) .onChange(event => { - // console.log(draggableStates.value); - const { state, translationX, translationY, changeX, changeY } = event; - debug && console.log('update', { state, changeX, changeY }); + const { state, changeX, changeY } = event; + debug && console.log('onChange:', { state, changeX, changeY }); // Track current state for cancellation purposes panGestureState.value = state; const { value: activeId } = draggableActiveId; - const { value: pendingId } = draggablePendingId; - const { value: options } = draggableOptions; const { value: layouts } = draggableLayouts; const { value: offsets } = draggableOffsets; - if (activeId === null) { - // Check if we are currently waiting for activation delay - if (pendingId !== null) { - const { activationTolerance } = options[pendingId]; - // Check if we've moved beyond the activation tolerance - const distance = getDistance(translationX, translationY); - if (distance > activationTolerance) { - draggablePendingId.value = null; - clearActiveIdTimeout(); - } - } - // Ignore item-free interactions - return; - } + + // Ignore item-free interactions + if (activeId === null) return; + // Update our active offset to pan the active item const activeOffset = offsets[activeId]; @@ -334,26 +273,18 @@ export const DndProvider = forwardRef { const { state, velocityX, velocityY } = event; - debug && console.log('finalize', { state, velocityX, velocityY }); + debug && console.log('onFinalize:', { state, velocityX, velocityY }); // Track current state for cancellation purposes panGestureState.value = state; // can be `FAILED` or `ENDED` const { value: activeId } = draggableActiveId; - const { value: pendingId } = draggablePendingId; const { value: layouts } = draggableLayouts; const { value: offsets } = draggableOffsets; const { value: restingOffsets } = draggableRestingOffsets; const { value: states } = draggableStates; + // Ignore item-free interactions - if (activeId === null) { - // Check if we were currently waiting for activation delay - if (pendingId !== null) { - draggablePendingId.value = null; - clearActiveIdTimeout(); - } - return; - } - // Reset interaction-related shared state for styling purposes - draggableActiveId.value = null; + if (activeId === null) return; + if (onFinalize) { const activeLayout = layouts[activeId].value; const activeOffset = offsets[activeId]; @@ -386,10 +317,9 @@ export const DndProvider = forwardRef Date: Mon, 6 Jan 2025 15:16:34 -0500 Subject: [PATCH 52/54] make grid wallets & other wallets gesture wait for each other to prevent ghost activations --- .../components/PinnedWalletsGrid.tsx | 3 +- .../change-wallet/components/WalletList.tsx | 37 +++++++++---------- 2 files changed, 19 insertions(+), 21 deletions(-) diff --git a/src/screens/change-wallet/components/PinnedWalletsGrid.tsx b/src/screens/change-wallet/components/PinnedWalletsGrid.tsx index 47d2c42b485..4c181dc0d55 100644 --- a/src/screens/change-wallet/components/PinnedWalletsGrid.tsx +++ b/src/screens/change-wallet/components/PinnedWalletsGrid.tsx @@ -15,7 +15,6 @@ import { removeFirstEmojiFromString } from '@/helpers/emojiHandler'; import { PANEL_WIDTH } from '@/components/SmoothPager/ListPanel'; import { IS_IOS } from '@/env'; import { useTheme } from '@/theme'; -import { DRAGGABLE_ACTIVATION_DELAY } from '@/screens/change-wallet/components/WalletList'; import { triggerHaptics } from 'react-native-turbo-haptics'; const UNPIN_BADGE_SIZE = 28; @@ -88,7 +87,7 @@ export function PinnedWalletsGrid({ walletItems, onPress, editMode, menuItems, o const walletName = removeFirstEmojiFromString(account.label) || address(account.address, 4, 4); const filteredMenuItems = menuItems.filter(item => (account.isReadOnly ? item.actionKey !== AddressMenuAction.Settings : true)); return ( - + ( diff --git a/src/screens/change-wallet/components/WalletList.tsx b/src/screens/change-wallet/components/WalletList.tsx index a786d0f56d6..4148ce31041 100644 --- a/src/screens/change-wallet/components/WalletList.tsx +++ b/src/screens/change-wallet/components/WalletList.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useMemo } from 'react'; +import React, { useCallback, useMemo, useRef } from 'react'; import { StyleSheet, View } from 'react-native'; import Animated, { FadeIn, FadeOut } from 'react-native-reanimated'; import * as i18n from '@/languages'; @@ -24,8 +24,8 @@ import { MenuItem } from '@/components/DropdownMenu'; import { DraggableScrollView } from '@/components/drag-and-drop/components/DraggableScrollView'; import { triggerHaptics } from 'react-native-turbo-haptics'; import { SPRING_CONFIGS } from '@/components/animations/animationConfigs'; +import { PanGesture } from 'react-native-gesture-handler'; -export const DRAGGABLE_ACTIVATION_DELAY = 150; const DRAG_ACTIVATION_DELAY = 150; const FADE_TRANSITION_DURATION = 75; const LIST_MAX_HEIGHT = MAX_PANEL_HEIGHT - PANEL_HEADER_HEIGHT; @@ -50,6 +50,8 @@ export function WalletList({ walletItems, menuItems, onPressMenuItem, onPressAcc const pinnedAddresses = usePinnedWalletsStore(state => state.pinnedAddresses); const unpinnedAddresses = usePinnedWalletsStore(state => state.unpinnedAddresses); + const pinnedWalletsGridGestureRef = useRef(); + // it would be more efficient to map the addresses to the wallet items, but the wallet items should be the source of truth const pinnedWalletItems = useMemo(() => { return walletItems @@ -93,6 +95,7 @@ export function WalletList({ walletItems, menuItems, onPressMenuItem, onPressAcc onActivationWorklet={onDraggableActivationWorklet} activationDelay={DRAG_ACTIVATION_DELAY} disabled={!editMode} + gestureRef={pinnedWalletsGridGestureRef} > ( - - onPressAccount(item.address)} - /> - - ), - [menuItems, onPressMenuItem, onPressAccount, editMode] - ); - // the draggable context should only layout its children when the number of children changes const draggableUnpinnedWalletItems = useMemo(() => { return unpinnedWalletItems; @@ -152,7 +140,8 @@ export function WalletList({ walletItems, menuItems, onPressMenuItem, onPressAcc springConfig={SPRING_CONFIGS.walletDraggableConfig} onActivationWorklet={onDraggableActivationWorklet} activationDelay={DRAG_ACTIVATION_DELAY} - disabled={!editMode} + disabled={!editMode || draggableUnpinnedWalletItems.length === 0} + waitFor={pinnedWalletsGridGestureRef} > {renderPinnedWalletsSection()} - {draggableUnpinnedWalletItems.map(item => renderAddressRow(item))} + {draggableUnpinnedWalletItems.map(item => ( + + onPressAccount(item.address)} + /> + + ))} From 3fe3ed8c6a3518f758c3bc191dcb487fad96ad5c Mon Sep 17 00:00:00 2001 From: Kane Thomas Date: Mon, 6 Jan 2025 15:24:25 -0500 Subject: [PATCH 53/54] remove unused code --- src/components/drag-and-drop/DndProvider.tsx | 3 --- .../drag-and-drop/features/sort/hooks/useDraggableSort.ts | 3 --- 2 files changed, 6 deletions(-) diff --git a/src/components/drag-and-drop/DndProvider.tsx b/src/components/drag-and-drop/DndProvider.tsx index 0c524209220..2dab939c99b 100644 --- a/src/components/drag-and-drop/DndProvider.tsx +++ b/src/components/drag-and-drop/DndProvider.tsx @@ -197,9 +197,7 @@ export const DndProvider = forwardRef { const { state, x, y } = event; - debug && console.log('onStart', { state, x, y }); - const activeId = findActiveLayoutId({ x, y }); // No item found, ignore gesture. @@ -210,7 +208,6 @@ export const DndProvider = forwardRef id === activeId); - // const activeCenterPoint = centerPoint(activeLayout); - // console.log(`activeLayout: ${JSON.stringify(activeLayout)}`); for (let itemIndex = 0; itemIndex < sortOrder.length; itemIndex++) { const itemId = sortOrder[itemIndex]; if (itemId === activeId) { @@ -58,7 +56,6 @@ export const useDraggableSort = ({ }); if (shouldSwapWorklet(activeLayout, itemLayout, direction)) { - // console.log(`Found placeholder index ${itemIndex} using custom shouldSwapWorklet!`); return itemIndex; } } From 0bbdd5caaf7b3f96229b237a3378f93103c7597d Mon Sep 17 00:00:00 2001 From: Kane Thomas Date: Mon, 6 Jan 2025 16:48:31 -0500 Subject: [PATCH 54/54] undo changes to dropdown component for data type --- src/components/DropdownMenu.tsx | 16 +++++----------- .../change-wallet/components/AddressRow.tsx | 7 ++----- .../components/PinnedWalletsGrid.tsx | 2 +- 3 files changed, 8 insertions(+), 17 deletions(-) diff --git a/src/components/DropdownMenu.tsx b/src/components/DropdownMenu.tsx index a426e3cb286..054bc2d62c5 100644 --- a/src/components/DropdownMenu.tsx +++ b/src/components/DropdownMenu.tsx @@ -50,12 +50,11 @@ export type MenuConfig = Omit<_MenuConfig, 'menuItems' | 'menu menuItems: Array>; }; -type DropDownMenuProps = never> = { +type DropdownMenuProps = { children: React.ReactElement; menuConfig: MenuConfig; onPressMenuItem: (actionKey: T) => void; triggerAction?: 'press' | 'longPress'; - data?: U; menuItemType?: 'checkbox'; } & DropdownMenuContentProps; @@ -75,11 +74,10 @@ const buildIconConfig = (icon?: MenuItemIcon) => { return null; }; -export function DropdownMenu = never>({ +export function DropdownMenu({ children, menuConfig, onPressMenuItem, - data, loop = true, align = 'end', sideOffset = 8, @@ -88,16 +86,12 @@ export function DropdownMenu avoidCollisions = true, triggerAction = 'press', menuItemType, -}: DropDownMenuProps) { +}: DropdownMenuProps) { const handleSelectItem = useCallback( (actionKey: T) => { - if (data !== undefined) { - (onPressMenuItem as (actionKey: T, data: U) => void)(actionKey, data); - } else { - (onPressMenuItem as (actionKey: T) => void)(actionKey); - } + onPressMenuItem(actionKey); }, - [onPressMenuItem, data] + [onPressMenuItem] ); const MenuItemComponent = menuItemType === 'checkbox' ? DropdownMenuCheckboxItem : DropdownMenuItem; diff --git a/src/screens/change-wallet/components/AddressRow.tsx b/src/screens/change-wallet/components/AddressRow.tsx index 59e1c6278a6..ca2518af0d9 100644 --- a/src/screens/change-wallet/components/AddressRow.tsx +++ b/src/screens/change-wallet/components/AddressRow.tsx @@ -111,7 +111,7 @@ export function AddressRow({ data, editMode, onPress, menuItems, onPressMenuItem ( - + triggerAction="longPress" menuConfig={menuConfig} onPressMenuItem={action => onPressMenuItem(action, { address })} @@ -198,10 +198,7 @@ export function AddressRow({ data, editMode, onPress, menuItems, onPressMenuItem {editMode && ( <> addPinnedAddress(address)} color={colors.appleBlue} icon="􀎧" size="icon 12px" /> - - menuConfig={menuConfig} - onPressMenuItem={action => onPressMenuItem(action, { address })} - > + menuConfig={menuConfig} onPressMenuItem={action => onPressMenuItem(action, { address })}> diff --git a/src/screens/change-wallet/components/PinnedWalletsGrid.tsx b/src/screens/change-wallet/components/PinnedWalletsGrid.tsx index 4c181dc0d55..3751354be1e 100644 --- a/src/screens/change-wallet/components/PinnedWalletsGrid.tsx +++ b/src/screens/change-wallet/components/PinnedWalletsGrid.tsx @@ -91,7 +91,7 @@ export function PinnedWalletsGrid({ walletItems, onPress, editMode, menuItems, o ( - + triggerAction="longPress" menuConfig={{ menuItems: filteredMenuItems,