diff --git a/src/components/DropdownMenu.tsx b/src/components/DropdownMenu.tsx index 8d2c6572e7c..054bc2d62c5 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, }), @@ -35,6 +41,7 @@ export type MenuItemIcon = Omit & (MenuIte export type MenuItem = Omit & { actionKey: T; actionTitle: string; + destructive?: boolean; icon?: MenuItemIcon | { iconType: string; iconValue: string }; }; @@ -43,10 +50,12 @@ export type MenuConfig = Omit<_MenuConfig, 'menuItems' | 'menu menuItems: Array>; }; -type DropDownMenuProps = { +type DropdownMenuProps = { children: React.ReactElement; menuConfig: MenuConfig; onPressMenuItem: (actionKey: T) => void; + triggerAction?: 'press' | 'longPress'; + menuItemType?: 'checkbox'; } & DropdownMenuContentProps; const buildIconConfig = (icon?: MenuItemIcon) => { @@ -75,7 +84,9 @@ export function DropdownMenu({ side = 'right', alignOffset = 5, avoidCollisions = true, -}: DropDownMenuProps) { + triggerAction = 'press', + menuItemType, +}: DropdownMenuProps) { const handleSelectItem = useCallback( (actionKey: T) => { onPressMenuItem(actionKey); @@ -83,9 +94,11 @@ export function DropdownMenu({ [onPressMenuItem] ); + const MenuItemComponent = menuItemType === 'checkbox' ? DropdownMenuCheckboxItem : DropdownMenuItem; + return ( - {children} + {children} ({ > {!!menuConfig.menuTitle?.trim() && ( - + {menuConfig.menuTitle} - + )} {menuConfig.menuItems?.map(item => { const Icon = buildIconConfig(item.icon as MenuItemIcon); return ( - handleSelectItem(item.actionKey)}> + handleSelectItem(item.actionKey)} + > {item.actionTitle} {Icon} - + ); })} diff --git a/src/components/SmoothPager/ListPanel.tsx b/src/components/SmoothPager/ListPanel.tsx index b8a195964e2..53f537101d2 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/components/animations/JiggleAnimation.tsx b/src/components/animations/JiggleAnimation.tsx new file mode 100644 index 00000000000..a1ea5cb606a --- /dev/null +++ b/src/components/animations/JiggleAnimation.tsx @@ -0,0 +1,67 @@ +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); + + // 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') { + internalEnabled.value = enabled; + } + }, [enabled, internalEnabled]); + + useAnimatedReaction( + () => { + return typeof enabled === 'boolean' ? internalEnabled.value : (enabled as SharedValue).value; + }, + enabled => { + if (enabled) { + rotation.value = withSequence( + withTiming(firstRotation, { duration: instanceDuration / 2 }), + withRepeat(withTiming(-amplitude * initialDirection, { 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/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/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}> diff --git a/src/components/change-wallet/AddressRow.tsx b/src/components/change-wallet/AddressRow.tsx deleted file mode 100644 index 71f3ee08e16..00000000000 --- a/src/components/change-wallet/AddressRow.tsx +++ /dev/null @@ -1,309 +0,0 @@ -import lang from 'i18n-js'; -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 { ContextMenu } from '../context-menu'; -import { useForegroundColor } from '@/design-system'; -import { MenuActionConfig } from 'react-native-ios-context-menu'; - -const maxAccountLabelWidth = deviceUtils.dimensions.width - 88; -const NOOP = () => undefined; - -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, - }, -}); - -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 ? : 􀍡} - - - ); -}; - -const ContextMenuKeys = { - Edit: 'edit', - Notifications: 'notifications', - Remove: 'remove', -}; - -interface AddressRowProps { - contextMenuActions: EditWalletContextMenuActions; - data: any; - editMode: boolean; - onPress: () => void; -} - -export default 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 balanceText = useMemo(() => { - if (!balancesMinusHiddenBalances) { - return lang.t('wallet.change_wallet.loading_balance'); - } - - 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 linearGradientProps = useMemo( - () => ({ - ...gradientProps, - 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 }, - }), - [colors, isDarkMode] - ); - - const contextMenuItems = [ - { - actionKey: ContextMenuKeys.Edit, - actionTitle: lang.t('wallet.action.edit'), - icon: { - iconType: 'SYSTEM', - iconValue: 'pencil', - }, - }, - - ...(notificationsEnabled - ? ([ - { - actionKey: ContextMenuKeys.Notifications, - actionTitle: lang.t('wallet.action.notifications.action_title'), - icon: { - iconType: 'SYSTEM', - iconValue: 'bell.fill', - }, - }, - ] as const) - : []), - { - actionKey: ContextMenuKeys.Remove, - actionTitle: lang.t('wallet.action.remove'), - icon: { iconType: 'SYSTEM', iconValue: 'trash.fill' }, - menuAttributes: ['destructive'], - }, - ] satisfies MenuActionConfig[]; - - const menuConfig = { - menuItems: contextMenuItems, - 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 ( - - ( - - {children} - - )} - > - - - {accountImage ? ( - - ) : ( - - )} - - - {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} - > - - - - - ))} - - - - - ); -} diff --git a/src/components/change-wallet/WalletList.tsx b/src/components/change-wallet/WalletList.tsx deleted file mode 100644 index e3c858761f2..00000000000 --- a/src/components/change-wallet/WalletList.tsx +++ /dev/null @@ -1,322 +0,0 @@ -import lang from 'i18n-js'; -import { isEmpty } from 'lodash'; -import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; -import { StyleSheet, View } 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 { Text } from '@/components/text'; -import Divider from '@/components/Divider'; -import { EmptyAssetList } from '../asset-list'; -import { Centered, Column, Row } from '../layout'; -import AddressRow from './AddressRow'; -import WalletOption from './WalletOption'; -import { EthereumAddress } from '@rainbow-me/entities'; -import { useAccountSettings, useWalletsWithBalancesAndNames } from '@/hooks'; -import styled from '@/styled-thing'; -import { position } from '@/styles'; -import { EditWalletContextMenuActions } from '@/screens/ChangeWalletSheet'; -import { getExperimetalFlag, HARDWARE_WALLETS, useExperimentalFlag } from '@/config'; -import { Inset, Stack } from '@/design-system'; -import { Network } from '@/state/backendNetworks/types'; -import { SheetTitle } from '../sheet'; -import ButtonPressAnimation from '@/components/animations/ButtonPressAnimation'; -import { IS_ANDROID, IS_IOS } from '@/env'; -import { useTheme } from '@/theme'; -import { DEVICE_HEIGHT } from '@/utils/deviceUtils'; - -const listTopPadding = 7.5; -const listBottomPadding = 9.5; -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}`; - -const Container = styled(View)({ - height: ({ height }: { height: number }) => height, - marginTop: -2, -}); - -const WalletsContainer = styled(Animated.View)({ - flex: 1, -}); - -const EmptyWalletList = styled(EmptyAssetList).attrs({ - descendingOpacity: true, - pointerEvents: 'none', -})({ - ...position.coverAsObject, - backgroundColor: ({ theme: { colors } }: any) => colors.white, - paddingTop: listTopPadding, -}); - -const WalletFlatList: FlatList = styled(FlatList).attrs(({ showDividers }: { showDividers: boolean }) => ({ - contentContainerStyle: { - paddingBottom: showDividers ? listBottomPadding : 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, -}); - -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, -}); - -const HEADER_HEIGHT = 40; -const FOOTER_HEIGHT = getExperimetalFlag(HARDWARE_WALLETS) ? 100 : 60; -const LIST_PADDING_BOTTOM = 6; -export const MAX_LIST_HEIGHT = DEVICE_HEIGHT - 220; -const WALLET_ROW_HEIGHT = 59; -const WATCH_ONLY_BOTTOM_PADDING = IS_ANDROID ? 20 : 0; - -const getWalletListHeight = (numWallets: number, watchOnly: boolean) => { - const baseHeight = !watchOnly ? FOOTER_HEIGHT + LIST_PADDING_BOTTOM + HEADER_HEIGHT : WATCH_ONLY_BOTTOM_PADDING; - const paddingBetweenRows = 6 * (numWallets - 1); - const rowHeight = WALLET_ROW_HEIGHT * numWallets; - const calculatedHeight = baseHeight + rowHeight + paddingBetweenRows; - return Math.min(calculatedHeight, MAX_LIST_HEIGHT); -}; - -interface Props { - accountAddress: EthereumAddress; - allWallets: ReturnType; - contextMenuActions: EditWalletContextMenuActions; - currentWallet: any; - editMode: boolean; - onPressEditMode: () => void; - onChangeAccount: (walletId: string, address: EthereumAddress) => void; - onPressAddAnotherWallet: () => void; - onPressPairHardwareWallet: () => void; - watchOnly: boolean; -} - -export default function WalletList({ - accountAddress, - allWallets, - contextMenuActions, - currentWallet, - editMode, - onPressEditMode, - onChangeAccount, - onPressAddAnotherWallet, - onPressPairHardwareWallet, - 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); - const { colors } = useTheme(); - - const containerHeight = useMemo(() => getWalletListHeight(rows.length, watchOnly), [rows.length, watchOnly]); - - // Update the rows when allWallets changes - useEffect(() => { - const seedRows: any[] = []; - const privateKeyRows: any[] = []; - const readOnlyRows: any[] = []; - - 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: WALLET_ROW_HEIGHT, - 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; - } - }); - }); - - const newRows = [...seedRows, ...privateKeyRows, ...readOnlyRows]; - setRows(newRows); - }, [accountAddress, allWallets, currentWallet?.id, editMode, network, onChangeAccount, watchOnly]); - - // Update the data provider when rows change - useEffect(() => { - if (rows?.length && !ready) { - setTimeout(() => { - setReady(true); - emptyOpacityAnimation.value = withTiming(0, { - duration: transitionDuration, - easing: Easing.out(Easing.ease), - }); - }, 50); - } - }, [rows, ready, emptyOpacityAnimation]); - - useLayoutEffect(() => { - if (ready) { - opacityAnimation.value = withTiming(1, { - duration: transitionDuration, - easing: Easing.in(Easing.ease), - }); - } else { - opacityAnimation.value = 0; - } - }, [ready, opacityAnimation]); - - const opacityStyle = useAnimatedStyle(() => ({ - opacity: opacityAnimation.value, - })); - - const emptyOpacityStyle = useAnimatedStyle(() => ({ - opacity: emptyOpacityAnimation.value, - })); - - const renderItem = useCallback( - ({ item }: any) => { - switch (item.rowType) { - case RowTypes.ADDRESS: - return ( - - - - ); - default: - return null; - } - }, - [contextMenuActions, editMode] - ); - - return ( - - - - {lang.t('wallet.label')} - - {!watchOnly && ( - - - {editMode ? lang.t('button.done') : lang.t('button.edit')} - - - )} - - - - - = MAX_LIST_HEIGHT} - renderItem={renderItem} - ListEmptyComponent={() => ( - - - - )} - /> - - - {!watchOnly && ( - - - - - {hardwareWalletsEnabled && ( - - )} - - - )} - - ); -} 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); diff --git a/src/components/drag-and-drop/DndContext.ts b/src/components/drag-and-drop/DndContext.ts index 6e58313f1c2..d5e9a4a9787 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 = { @@ -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 5f0ad9553e6..2dab939c99b 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; @@ -57,10 +51,11 @@ 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; - waitFor?: RefObject>; + waitFor?: WaitForRef; }; export type DndProviderHandle = Pick< @@ -77,10 +72,11 @@ 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); @@ -129,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, - }); + // Handle activation changes + useAnimatedReaction( + () => draggableActiveId.value, + (next, prev) => { + if (next !== null) { + onActivationWorklet?.(next, prev); + } + }, + [] + ); const panGesture = useMemo(() => { const findActiveLayoutId = (point: Point): UniqueIdentifier | null => { @@ -186,7 +165,6 @@ export const DndProvider = forwardRef { + .enabled(!disabled) + .onStart(event => { 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 + 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: 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'; - } + const activeLayout = layouts[activeId].value; + const activeOffset = offsets[activeId]; + const restingOffset = restingOffsets[activeId]; - if (onBegin) { - onBegin(event, { activeId, activeLayout }); - } + 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'; }) - .onUpdate(event => { - // console.log(draggableStates.value); - const { state, translationX, translationY } = event; - debug && console.log('update', { state, translationX, translationY }); + .onChange(event => { + 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]; - activeOffset.x.value = draggableInitialOffset.x.value + translationX; - activeOffset.y.value = draggableInitialOffset.y.value + translationY; + + activeOffset.x.value += changeX; + activeOffset.y.value += changeY; + // Check potential droppable candidates const activeLayout = layouts[activeId].value; draggableActiveLayout.value = applyOffset(activeLayout, { @@ -322,26 +270,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]; @@ -366,26 +306,17 @@ 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'; } - ); + }); + // Reset interaction-related shared state for styling purposes + draggableActiveId.value = null; }) .withTestId('DndProvider.pan'); diff --git a/src/components/drag-and-drop/components/Draggable.tsx b/src/components/drag-and-drop/components/Draggable.tsx index 24e767646ad..41eca930bb1 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 || 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 || 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 a4080b707d9..31bba453388 100644 --- a/src/components/drag-and-drop/components/DraggableFlatList.tsx +++ b/src/components/drag-and-drop/components/DraggableFlatList.tsx @@ -1,26 +1,13 @@ -import React, { - ComponentProps, - ReactElement, - // useCallback, -} from 'react'; -import { - CellRendererProps, - // FlatListProps, -} from 'react-native'; +import React, { ComponentProps, ReactElement, useCallback, useMemo } from 'react'; +import { CellRendererProps } from 'react-native'; import { FlatList } from 'react-native-gesture-handler'; -import Animated, { - AnimatedProps, - // runOnJS, - 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 { swapByItemCenterPoint } from '../utils'; import { Draggable } from './Draggable'; +import { useDraggableScroll } from '../features/sort/hooks/useDraggableScroll'; // eslint-disable-next-line @typescript-eslint/no-explicit-any type AnimatedFlatListProps = AnimatedProps>>; @@ -30,115 +17,63 @@ export type ViewableRange = { last: number | null; }; +type DraggableProps = ComponentProps; +type DraggablePropsWithoutId = Omit; + export type DraggableFlatListProps = AnimatedFlatListProps & Pick & { debug?: boolean; gap?: number; horizontal?: boolean; - initialOrder?: UniqueIdentifier[]; + draggableProps?: DraggablePropsWithoutId; + autoScrollInsets?: { + top?: number; + bottom?: number; + }; }; export const DraggableFlatList = ({ data, - // debug, + debug, gap = 0, horizontal = false, - initialOrder, onOrderChange, onOrderUpdate, renderItem, shouldSwapWorklet = swapByItemCenterPoint, + draggableProps, + autoScrollInsets, ...otherProps }: DraggableFlatListProps): ReactElement => { - const { draggableActiveId, draggableContentOffset, draggableLayouts, draggableOffsets, draggableRestingOffsets } = useDndContext(); - const animatedFlatListRef = useAnimatedRef>(); + const { draggableContentOffset } = useDndContext(); + const animatedFlatListRef = useAnimatedRef(); + const contentHeight = useSharedValue(0); + const layoutHeight = useSharedValue(0); + const scrollOffset = useSharedValue(0); - const { - // draggablePlaceholderIndex, - draggableSortOrder, - } = useDraggableSort({ - horizontal, - initialOrder, - onOrderChange, - onOrderUpdate, - shouldSwapWorklet, - }); - - const direction = horizontal ? 'column' : 'row'; - const size = 1; - - // 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; - } - } - }, - [] - ); + // @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; }); - /** ⚠️ 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] - // ); + useDraggableScroll({ + childrenIds, + onOrderChange, + onOrderUpdate, + shouldSwapWorklet, + horizontal, + contentHeight, + layoutHeight, + autoScrollInsets, + animatedScrollViewRef: animatedFlatListRef, + scrollOffset, + gap, + }); + /* ⚠️ IMPROVEMENT: Optionally expose visible range to the parent */ // const viewableRange = useSharedValue({ // first: null, // last: null, @@ -155,30 +90,13 @@ 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 */ + const CellRenderer = useCallback( + (cellProps: CellRendererProps) => , + [draggableProps] + ); + /** 🛠️ DEBUGGING */ // useAnimatedReaction( // () => { @@ -201,14 +119,23 @@ export const DraggableFlatList = ({ return ( { + layoutHeight.value = event.nativeEvent.layout.height; + }} + onContentSizeChange={(_, height) => { + contentHeight.value = height; + }} + // IMPROVEMENT: optionally implement // 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 ( ({ /> ); }} - viewabilityConfig={{ itemVisiblePercentThreshold: 50 }} + // viewabilityConfig={{ itemVisiblePercentThreshold: 50 }} // eslint-disable-next-line react/jsx-props-no-spreading, @typescript-eslint/no-explicit-any {...(otherProps as any)} /> ); }; +type DraggableCellRendererProps = CellRendererProps & { + draggableProps?: DraggablePropsWithoutId; +}; + export const DraggableFlatListCellRenderer = function DraggableFlatListCellRenderer( - props: CellRendererProps + props: DraggableCellRendererProps ) { - const { item, children } = props; + 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..7f4380f427c --- /dev/null +++ b/src/components/drag-and-drop/components/DraggableScrollView.tsx @@ -0,0 +1,80 @@ +import React, { ComponentProps, ReactElement } from 'react'; +import Animated, { useAnimatedRef, useAnimatedScrollHandler, useSharedValue } from 'react-native-reanimated'; +import { useDndContext } from '../DndContext'; +import { UseDraggableSortOptions } from '../features'; +import { swapByItemCenterPoint } from '../utils'; +import { useChildrenIds } from '../hooks'; +import { useDraggableScroll } from '../features/sort/hooks/useDraggableScroll'; + +type AnimatedScrollViewProps = ComponentProps; + +export type DraggableScrollViewProps = AnimatedScrollViewProps & + Pick & { + children: React.ReactNode; + gap?: number; + horizontal?: boolean; + debug?: boolean; + autoScrollInsets?: { + top?: number; + bottom?: number; + }; + }; + +export const DraggableScrollView = ({ + children, + debug = false, + gap = 0, + horizontal = false, + onOrderChange, + onOrderUpdate, + onOrderUpdateWorklet, + shouldSwapWorklet = swapByItemCenterPoint, + autoScrollInsets, + ...otherProps +}: DraggableScrollViewProps): ReactElement => { + const { draggableContentOffset } = useDndContext(); + + const animatedScrollViewRef = useAnimatedRef(); + const contentHeight = useSharedValue(0); + const layoutHeight = useSharedValue(0); + const scrollOffset = useSharedValue(0); + + const childrenIds = useChildrenIds(children); + + useDraggableScroll({ + childrenIds, + onOrderChange, + onOrderUpdate, + onOrderUpdateWorklet, + shouldSwapWorklet, + horizontal, + contentHeight, + layoutHeight, + autoScrollInsets, + animatedScrollViewRef, + scrollOffset, + gap, + }); + + const scrollHandler = useAnimatedScrollHandler(event => { + scrollOffset.value = event.contentOffset.y; + draggableContentOffset.y.value = event.contentOffset.y; + }); + + return ( + { + layoutHeight.value = event.nativeEvent.layout.height; + }} + onContentSizeChange={(_, height) => { + contentHeight.value = height; + }} + // eslint-disable-next-line react/jsx-props-no-spreading + {...otherProps} + > + {children} + + ); +}; 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 c6f9282c805..9e2427a2211 100644 --- a/src/components/drag-and-drop/features/sort/components/DraggableGrid.tsx +++ b/src/components/drag-and-drop/features/sort/components/DraggableGrid.tsx @@ -1,10 +1,10 @@ -import React, { Children, useMemo, type FunctionComponent, type PropsWithChildren } from 'react'; -import { StyleProp, View, ViewStyle, type FlexStyle, type ViewProps } from 'react-native'; -import type { UniqueIdentifier } from '../../../types'; +import React, { useMemo, type FunctionComponent, type PropsWithChildren } from 'react'; +import { View, type FlexStyle, type ViewProps } from 'react-native'; +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,37 +16,34 @@ 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, + onOrderUpdateWorklet, shouldSwapWorklet, size, }); 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,89 +14,62 @@ export type UseDraggableGridOptions = Pick< }; export const useDraggableGrid = ({ - initialOrder, + childrenIds, onOrderChange, onOrderUpdate, + onOrderUpdateWorklet, 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, + onOrderUpdateWorklet, 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 ? restingOffset : offsets[itemId]; + const offset = itemId === activeId ? restingOffsets[activeId] : offsets[itemId]; + if (!restingOffsets[itemId] || !offsets[itemId]) continue; - if (!restingOffset || !offsets[itemId]) { - continue; - } + 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 new file mode 100644 index 00000000000..8e7232cfdc5 --- /dev/null +++ b/src/components/drag-and-drop/features/sort/hooks/useDraggableScroll.ts @@ -0,0 +1,248 @@ +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, getFlexLayoutPosition } from '@/components/drag-and-drop/utils'; +import { useCallback } from 'react'; + +const AUTOSCROLL_THRESHOLD = 50; +const AUTOSCROLL_MIN_SPEED = 1; +const AUTOSCROLL_MAX_SPEED = 7; +const AUTOSCROLL_THRESHOLD_MAX_DISTANCE = 100; + +function easeInOutCubicWorklet(x: number): number { + 'worklet'; + return x < 0.5 ? 4 * x * x * x : 1 - Math.pow(-2 * x + 2, 3) / 2; +} + +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, + currentOffset = 0, +}: { + newOffset: number; + contentHeight: number; + layoutHeight: number; + currentOffset: number; +}): number { + 'worklet'; + const maxOffset = contentHeight - layoutHeight; + + 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, + 'childrenIds' | 'onOrderChange' | 'onOrderUpdate' | 'onOrderUpdateWorklet' | 'shouldSwapWorklet' +> & { + contentHeight: SharedValue; + layoutHeight: SharedValue; + animatedScrollViewRef: AnimatedRef; + scrollOffset: SharedValue; + horizontal?: boolean; + autoScrollInsets?: { top?: number; bottom?: number }; + gap?: number; +}; + +export const useDraggableScroll = ({ + childrenIds, + onOrderChange, + onOrderUpdate, + onOrderUpdateWorklet, + 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, + onOrderUpdateWorklet, + shouldSwapWorklet, + }); + + const direction = horizontal ? 'column' : 'row'; + const size = 1; + + const autoscroll = useCallback( + (offset: number) => { + 'worklet'; + const { value: activeId } = draggableActiveId; + + if (activeId) { + const { value: layouts } = draggableLayouts; + const { value: offsets } = draggableOffsets; + const activeLayout = layouts[activeId].value; + const activeOffset = offsets[activeId]; + 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 ( + (offset > 0 && remainingScrollDistance < AUTOSCROLL_MIN_SPEED) || + (offset < 0 && remainingScrollDistance > -AUTOSCROLL_MIN_SPEED) + ) { + return; + } + + scrollTo(animatedScrollViewRef, 0, scrollOffset.value + remainingScrollDistance, false); + activeOffset.y.value += remainingScrollDistance; + draggableActiveLayout.value = applyOffset(activeLayout, { + x: activeOffset.x.value, + y: activeOffset.y.value, + }); + } + }, + [ + draggableActiveId, + draggableLayouts, + draggableOffsets, + scrollOffset, + contentHeight, + layoutHeight, + animatedScrollViewRef, + draggableActiveLayout, + ] + ); + + // 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) => { + 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 = getScrollSpeedWorklet(distanceFromTopThreshold); + autoscroll(-scrollSpeed); + } else if (isNearBottom) { + const distanceFromBottomThreshold = activeItemY - bottomThreshold; + const scrollSpeed = getScrollSpeedWorklet(distanceFromBottomThreshold); + 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..8b30fc76b85 100644 --- a/src/components/drag-and-drop/features/sort/hooks/useDraggableSort.ts +++ b/src/components/drag-and-drop/features/sort/hooks/useDraggableSort.ts @@ -2,30 +2,35 @@ 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; + onOrderUpdateWorklet?: (nextOrder: UniqueIdentifier[], prevOrder: UniqueIdentifier[]) => void; shouldSwapWorklet?: ShouldSwapWorklet; }; export const useDraggableSort = ({ horizontal = false, - initialOrder = [], + childrenIds, onOrderChange, onOrderUpdate, - shouldSwapWorklet, + onOrderUpdateWorklet, + 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 => { @@ -35,13 +40,12 @@ export const useDraggableSort = ({ const { value: offsets } = draggableOffsets; const { value: sortOrder } = draggableSortOrder; const activeIndex = sortOrder.findIndex(id => 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) { 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,65 @@ 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)) { return itemIndex; } } // 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 waiting for requestAnimationFrame + draggableSortOrder.value = next; + + resetOffsets(); + }, + [childrenIds] + ); + // Track active layout changes and update the placeholder index useAnimatedReaction( () => [draggableActiveId.value, draggableActiveLayout.value] as const, @@ -78,7 +122,6 @@ export const useDraggableSort = ({ if (prev === null) { return; } - // const [_prevActiveId, _prevActiveLayout] = prev; // No active layout if (nextActiveLayout === null) { return; @@ -88,11 +131,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 +150,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) { @@ -125,8 +172,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/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/hooks/useDraggable.ts b/src/components/drag-and-drop/hooks/useDraggable.ts index d27ee31da55..c9e7cd0122e 100644 --- a/src/components/drag-and-drop/hooks/useDraggable.ts +++ b/src/components/drag-and-drop/hooks/useDraggable.ts @@ -57,7 +57,6 @@ export const useDraggable = ({ draggableOptions, draggableStates, draggableActiveId, - draggablePendingId, panGestureState, } = useDndContext(); // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -169,7 +168,6 @@ export const useDraggable = ({ state, setNodeRef, activeId: draggableActiveId, - pendingId: draggablePendingId, onLayout, onLayoutWorklet, panGestureState, 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..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; @@ -15,6 +17,8 @@ export type Rectangle = { height: number; }; +export type Direction = 'horizontal' | 'vertical'; + /** * @summary Split a `Rectangle` in two * @worklet @@ -122,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 }; + } +}; 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/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 ( + + + + + + + + + ); +} diff --git a/src/components/tooltips/FeatureHintTooltip.tsx b/src/components/tooltips/FeatureHintTooltip.tsx new file mode 100644 index 00000000000..740b9834132 --- /dev/null +++ b/src/components/tooltips/FeatureHintTooltip.tsx @@ -0,0 +1,342 @@ +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 +// If need to show above / on top of navigation elements, will need to refactor to use AbsolutePortal +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: 99999999, + shadowColor: '#000000', + shadowOffset: { + width: 0, + height: 10, + }, + shadowOpacity: 0.3, + shadowRadius: 50, + elevation: 25, + }, + 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, + }, +}); 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/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/languages/en_US.json b/src/languages/en_US.json index c8ad721b68a..5a15a6f3275 100644 --- a/src/languages/en_US.json +++ b/src/languages/en_US.json @@ -2579,7 +2579,25 @@ "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": { + "prefix": "Tap the", + "action": "Edit", + "suffix": "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/navigation/Routes.android.tsx b/src/navigation/Routes.android.tsx index 1e03c2d96a3..bf271f66e88 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 201eb3aa374..5257900be2c 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'; @@ -188,17 +188,7 @@ function NativeStackNavigator() { {...externalLinkWarningSheetConfig} /> - + void; + watchOnly?: boolean; + currentAccountAddress?: string; + onChangeWallet?: (address: string | Address, wallet?: RainbowWallet) => void; }; [Routes.SPEED_UP_AND_CANCEL_BOTTOM_SHEET]: { accentColor?: string; diff --git a/src/resources/summary/summary.ts b/src/resources/summary/summary.ts index 71c68ff92b5..080f1040bac 100644 --- a/src/resources/summary/summary.ts +++ b/src/resources/summary/summary.ts @@ -17,6 +17,9 @@ interface AddysSummary { addresses: { [key: Address]: { meta: { + rainbow: { + transactions: number; + }; farcaster?: { object: string; fid: number; @@ -54,19 +57,19 @@ interface AddysSummary { claimables_value: number | null; positions_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; + claimables_value: number | null; + positions_value: number | null; }; - num_erc20s: number; - last_activity: number; - asset_value: number | null; - claimables_value: number | null; - positions_value: number | null; }; }; }; @@ -85,7 +88,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/ChangeWalletSheet.tsx b/src/screens/ChangeWalletSheet.tsx deleted file mode 100644 index e58c9dda0d1..00000000000 --- a/src/screens/ChangeWalletSheet.tsx +++ /dev/null @@ -1,316 +0,0 @@ -import { useRoute } from '@react-navigation/native'; -import lang from 'i18n-js'; -import React, { useCallback, useState } from 'react'; -import { Alert, InteractionManager } from 'react-native'; -import ReactNativeHapticFeedback from 'react-native-haptic-feedback'; -import { useDispatch } from 'react-redux'; -import WalletList from '@/components/change-wallet/WalletList'; -import { Sheet } from '../components/sheet'; -import { removeWalletData } from '../handlers/localstorage/removeWallet'; -import { cleanUpWalletKeys } from '../model/wallet'; -import { useNavigation } from '../navigation/Navigation'; -import { addressSetSelected, walletsSetSelected, walletsUpdate } from '../redux/wallets'; -import { analytics, analyticsV2 } from '@/analytics'; -import { useAccountSettings, useInitializeWallet, useWallets, useWalletsWithBalancesAndNames, useWebData } from '@/hooks'; -import Routes from '@/navigation/routesNames'; -import { doesWalletsContainAddress, showActionSheetWithOptions } from '@/utils'; -import { logger, RainbowError } from '@/logger'; -import { useTheme } from '@/theme'; -import { EthereumAddress } from '@/entities'; -import { getNotificationSettingsForWalletWithAddress } from '@/notifications/settings/storage'; -import { remotePromoSheetsStore } from '@/state/remotePromoSheets/remotePromoSheets'; - -export type EditWalletContextMenuActions = { - edit: (walletId: string, address: EthereumAddress) => void; - notifications: (walletName: string, address: EthereumAddress) => void; - remove: (walletId: string, address: EthereumAddress) => void; -}; - -export default function ChangeWalletSheet() { - const { params = {} as any } = 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 dispatch = useDispatch(); - const initializeWallet = useInitializeWallet(); - const walletsWithBalancesAndNames = useWalletsWithBalancesAndNames(); - - const [editMode, setEditMode] = useState(false); - const [currentAddress, setCurrentAddress] = useState(currentAccountAddress || accountAddress); - const [currentSelectedWallet, setCurrentSelectedWallet] = useState(selectedWallet); - - const onChangeAccount = useCallback( - async (walletId: string, address: string, fromDeletion = false) => { - if (editMode && !fromDeletion) return; - const wallet = wallets?.[walletId]; - if (!wallet) return; - if (watchOnly && onChangeWallet) { - setCurrentAddress(address); - setCurrentSelectedWallet(wallet); - onChangeWallet(address, wallet); - return; - } - if (address === currentAddress) return; - try { - setCurrentAddress(address); - setCurrentSelectedWallet(wallet); - const p1 = dispatch(walletsSetSelected(wallet)); - const p2 = dispatch(addressSetSelected(address)); - await Promise.all([p1, p2]); - remotePromoSheetsStore.setState({ isShown: false }); - // @ts-expect-error initializeWallet is not typed correctly - initializeWallet(null, null, null, false, false, null, true); - if (!fromDeletion) { - goBack(); - } - } catch (e) { - logger.error(new RainbowError('[ChangeWalletSheet]: Error while switching account'), { - error: e, - }); - } - }, - [currentAddress, dispatch, editMode, goBack, initializeWallet, onChangeWallet, wallets, watchOnly] - ); - - const deleteWallet = useCallback( - async (walletId: string, address: string) => { - const currentWallet = wallets?.[walletId]; - // There's nothing to delete if there's no wallet - if (!currentWallet) return; - - const newWallets = { - ...wallets, - [walletId]: { - ...currentWallet, - addresses: (currentWallet.addresses || []).map(account => - account.address.toLowerCase() === address.toLowerCase() ? { ...account, visible: false } : account - ), - }, - }; - // If there are no visible wallets - // then delete the wallet - const visibleAddresses = ((newWallets as any)[walletId]?.addresses || []).filter((account: any) => account.visible); - if (visibleAddresses.length === 0) { - // eslint-disable-next-line @typescript-eslint/no-dynamic-delete - delete newWallets[walletId]; - dispatch(walletsUpdate(newWallets)); - } else { - dispatch(walletsUpdate(newWallets)); - } - removeWalletData(address); - }, - [dispatch, wallets] - ); - - const renameWallet = useCallback( - (walletId: string, address: string) => { - const wallet = wallets?.[walletId]; - if (!wallet) return; - const account = wallet.addresses?.find(account => account.address === address); - - InteractionManager.runAfterInteractions(() => { - goBack(); - }); - - InteractionManager.runAfterInteractions(() => { - setTimeout(() => { - navigate(Routes.MODAL_SCREEN, { - address, - asset: [], - onCloseModal: async (args: any) => { - if (args) { - if ('name' in args) { - analytics.track('Tapped "Done" after editing wallet', { - wallet_label: args.name, - }); - - const walletAddresses = wallets[walletId].addresses; - const walletAddressIndex = walletAddresses.findIndex(account => account.address === address); - const walletAddress = walletAddresses[walletAddressIndex]; - - const updatedWalletAddress = { - ...walletAddress, - color: args.color, - label: args.name, - }; - const updatedWalletAddresses = [...walletAddresses]; - updatedWalletAddresses[walletAddressIndex] = updatedWalletAddress; - - const updatedWallet = { - ...wallets[walletId], - addresses: updatedWalletAddresses, - }; - const updatedWallets = { - ...wallets, - [walletId]: updatedWallet, - }; - - if (currentSelectedWallet.id === walletId) { - setCurrentSelectedWallet(updatedWallet); - dispatch(walletsSetSelected(updatedWallet)); - } - - updateWebProfile(address, args.name, colors.avatarBackgrounds[args.color]); - - dispatch(walletsUpdate(updatedWallets)); - } else { - analytics.track('Tapped "Cancel" after editing wallet'); - } - } - }, - profile: { - color: account?.color, - image: account?.image || ``, - name: account?.label || ``, - }, - type: 'wallet_profile', - }); - }, 50); - }); - }, - [wallets, goBack, navigate, dispatch, currentSelectedWallet.id, updateWebProfile, colors.avatarBackgrounds] - ); - - const onPressEdit = useCallback( - (walletId: string, address: string) => { - analytics.track('Tapped "Edit Wallet"'); - renameWallet(walletId, address); - }, - [renameWallet] - ); - - const onPressNotifications = useCallback( - (walletName: string, address: string) => { - analytics.track('Tapped "Notification Settings"'); - const walletNotificationSettings = getNotificationSettingsForWalletWithAddress(address); - if (walletNotificationSettings) { - navigate(Routes.SETTINGS_SHEET, { - params: { - address, - title: walletName, - notificationSettings: walletNotificationSettings, - }, - screen: Routes.WALLET_NOTIFICATIONS_SETTINGS, - }); - } else { - Alert.alert(lang.t('wallet.action.notifications.alert_title'), lang.t('wallet.action.notifications.alert_message'), [ - { text: 'OK' }, - ]); - } - }, - [navigate] - ); - - const onPressRemove = useCallback( - (walletId: string, address: string) => { - analytics.track('Tapped "Delete Wallet"'); - // If there's more than 1 account - // it's deletable - let isLastAvailableWallet = false; - // eslint-disable-next-line @typescript-eslint/prefer-for-of - for (let i = 0; i < Object.keys(wallets as any).length; i++) { - const key = Object.keys(wallets as any)[i]; - const someWallet = wallets?.[key]; - const otherAccount = someWallet?.addresses.find(account => account.visible && account.address !== address); - if (otherAccount) { - isLastAvailableWallet = true; - break; - } - } - // Delete wallet with confirmation - showActionSheetWithOptions( - { - cancelButtonIndex: 1, - destructiveButtonIndex: 0, - message: lang.t('wallet.action.remove_confirm'), - options: [lang.t('wallet.action.remove'), lang.t('button.cancel')], - }, - async (buttonIndex: number) => { - if (buttonIndex === 0) { - analytics.track('Tapped "Delete Wallet" (final confirm)'); - await deleteWallet(walletId, address); - ReactNativeHapticFeedback.trigger('notificationSuccess'); - if (!isLastAvailableWallet) { - await cleanUpWalletKeys(); - goBack(); - navigate(Routes.WELCOME_SCREEN); - } else { - // If we're deleting the selected wallet - // we need to switch to another one - if (wallets && address === currentAddress) { - const { wallet: foundWallet, key } = - doesWalletsContainAddress({ - address: address, - wallets, - }) || {}; - if (foundWallet && key) { - await onChangeAccount(key, foundWallet.address, true); - } - } - } - } - } - ); - }, - [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 onPressAddAnotherWallet = useCallback(() => { - analyticsV2.track(analyticsV2.event.pressedButton, { - buttonName: 'AddAnotherWalletButton', - action: 'Navigates from WalletList to AddWalletSheet', - }); - goBack(); - InteractionManager.runAfterInteractions(() => { - navigate(Routes.ADD_WALLET_NAVIGATOR, { - isFirstWallet: false, - }); - }); - }, [goBack, navigate]); - - const onPressEditMode = useCallback(() => { - analytics.track('Tapped "Edit"'); - setEditMode(e => !e); - }, []); - - return ( - - - - ); -} diff --git a/src/screens/change-wallet/ChangeWalletSheet.tsx b/src/screens/change-wallet/ChangeWalletSheet.tsx new file mode 100644 index 00000000000..dd8c0ebce69 --- /dev/null +++ b/src/screens/change-wallet/ChangeWalletSheet.tsx @@ -0,0 +1,746 @@ +import { RouteProp, useRoute } from '@react-navigation/native'; +import * as i18n from '@/languages'; +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +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 '@/screens/change-wallet/components/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 WalletTypes from '@/helpers/walletTypes'; +import { analytics, analyticsV2 } from '@/analytics'; +import { useAccountSettings, useInitializeWallet, useWallets, useWalletsWithBalancesAndNames, useWebData } from '@/hooks'; +import Routes from '@/navigation/routesNames'; +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 { 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 } 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 { MAX_PINNED_ADDRESSES, usePinnedWalletsStore } from '@/state/wallets/pinnedWalletsStore'; +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 = Math.min(690, DEVICE_HEIGHT - 100); +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, +}; + +export interface AddressItem { + id: EthereumAddress; + address: EthereumAddress; + color: number; + isReadOnly: boolean; + isLedger: boolean; + isSelected: boolean; + label: string; + rowType: number; + walletId: string; + balance: string; + image: string | null | undefined; +} + +export default function ChangeWalletSheet() { + const { params = {} } = useRoute>(); + + const { onChangeWallet, watchOnly = false, currentAccountAddress } = params; + const { selectedWallet, wallets } = useWallets(); + const notificationsEnabled = useExperimentalFlag(NOTIFICATIONS); + + const { colors, isDarkMode } = useTheme(); + const { updateWebProfile } = useWebData(); + const { accountAddress } = useAccountSettings(); + const { goBack, navigate } = useNavigation(); + const dispatch = useDispatch(); + const initializeWallet = useInitializeWallet(); + const walletsWithBalancesAndNames = useWalletsWithBalancesAndNames(); + + const initialHasShownEditHintTooltip = useMemo(() => usePinnedWalletsStore.getState().hasShownEditHintTooltip, []); + const initialPinnedAddressCount = useMemo(() => usePinnedWalletsStore.getState().pinnedAddresses.length, []); + const { transactionCounts, isLoading: isLoadingTransactionCounts } = useWalletTransactionCounts(wallets || {}); + const hasAutoPinnedAddresses = usePinnedWalletsStore(state => state.hasAutoPinnedAddresses); + + 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) { + usePinnedWalletsStore.setState({ hasShownEditHintTooltip: true }); + } + }, [initialHasShownEditHintTooltip]); + + const walletsByAddress = useMemo(() => { + return Object.values(wallets || {}).reduce( + (acc, wallet) => { + wallet.addresses.forEach(account => { + acc[account.address] = wallet; + }); + return acc; + }, + {} as Record + ); + }, [wallets]); + + 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, + label: account.label, + balance: balanceText, + 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); + } + }); + }); + + // sorts by order wallets were added + return [...sortedWallets, ...bluetoothWallets, ...readOnlyWallets].sort((a, b) => a.walletId.localeCompare(b.walletId)); + }, [walletsWithBalancesAndNames, currentAddress]); + + // If user has never seen pinned addresses feature, auto-pin the users most used owned addresses + useEffect(() => { + if (hasAutoPinnedAddresses || initialPinnedAddressCount > 0 || isLoadingTransactionCounts) return; + + const pinnableAddresses = allWalletItems.filter(item => !item.isReadOnly).map(item => item.address); + + // Do not auto-pin if user only has read-only wallets + if (pinnableAddresses.length === 0) return; + + const addressesToAutoPin = pinnableAddresses + .sort((a, b) => transactionCounts[b.toLowerCase()] - transactionCounts[a.toLowerCase()]) + .slice(0, MAX_PINNED_ADDRESSES); + + setPinnedAddresses(addressesToAutoPin); + }, [ + allWalletItems, + setPinnedAddresses, + hasAutoPinnedAddresses, + initialPinnedAddressCount, + transactionCounts, + isLoadingTransactionCounts, + ]); + + const ownedWalletsTotalBalance = useMemo(() => { + let isLoadingBalance = false; + let hasOwnedWallets = false; + + // 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; + const visibleAccounts = wallet.addresses.filter(account => account.visible); + let walletTotalBalance: string | null = null; + + visibleAccounts.forEach(account => { + if (!account.balancesMinusHiddenBalances) { + isLoadingBalance = true; + return; + } + if (walletTotalBalance === null) { + walletTotalBalance = account.balancesMinusHiddenBalances; + return; + } + + walletTotalBalance = addDisplay(walletTotalBalance, account.balancesMinusHiddenBalances); + }); + + if (acc === null) { + return walletTotalBalance; + } + if (walletTotalBalance === null) { + return acc; + } + + 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); + + if (totalBalance === null) return null; + + return totalBalance; + }, [walletsWithBalancesAndNames]); + + const onChangeAccount = useCallback( + async (walletId: string, address: string, fromDeletion = false) => { + if (editMode && !fromDeletion) return; + const wallet = wallets?.[walletId]; + if (!wallet) return; + if (watchOnly && onChangeWallet) { + setCurrentAddress(address); + setCurrentSelectedWallet(wallet); + onChangeWallet(address, wallet); + return; + } + if (address === currentAddress) return; + try { + setCurrentAddress(address); + setCurrentSelectedWallet(wallet); + const p1 = dispatch(walletsSetSelected(wallet)); + const p2 = dispatch(addressSetSelected(address)); + await Promise.all([p1, p2]); + remotePromoSheetsStore.setState({ isShown: false }); + // @ts-expect-error initializeWallet is not typed correctly + initializeWallet(null, null, null, false, false, null, true); + if (!fromDeletion) { + goBack(); + } + } catch (e) { + logger.error(new RainbowError('[ChangeWalletSheet]: Error while switching account'), { + error: e, + }); + } + }, + [currentAddress, dispatch, editMode, goBack, initializeWallet, onChangeWallet, wallets, watchOnly] + ); + + const deleteWallet = useCallback( + async (walletId: string, address: string) => { + const currentWallet = wallets?.[walletId]; + // There's nothing to delete if there's no wallet + if (!currentWallet) return; + + const newWallets = { + ...wallets, + [walletId]: { + ...currentWallet, + addresses: (currentWallet.addresses || []).map(account => + account.address.toLowerCase() === address.toLowerCase() ? { ...account, visible: false } : account + ), + }, + }; + // If there are no visible wallets + // then delete the wallet + const visibleAddresses = ((newWallets as any)[walletId]?.addresses || []).filter((account: any) => account.visible); + if (visibleAddresses.length === 0) { + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete + delete newWallets[walletId]; + dispatch(walletsUpdate(newWallets)); + } else { + dispatch(walletsUpdate(newWallets)); + } + removeWalletData(address); + }, + [dispatch, wallets] + ); + + const renameWallet = useCallback( + (walletId: string, address: string) => { + const wallet = wallets?.[walletId]; + if (!wallet) return; + const account = wallet.addresses?.find(account => account.address === address); + + InteractionManager.runAfterInteractions(() => { + goBack(); + }); + + InteractionManager.runAfterInteractions(() => { + setTimeout(() => { + navigate(Routes.MODAL_SCREEN, { + address, + asset: [], + onCloseModal: async (args: any) => { + if (args) { + if ('name' in args) { + analytics.track('Tapped "Done" after editing wallet', { + wallet_label: args.name, + }); + + const walletAddresses = wallets[walletId].addresses; + const walletAddressIndex = walletAddresses.findIndex(account => account.address === address); + const walletAddress = walletAddresses[walletAddressIndex]; + + const updatedWalletAddress = { + ...walletAddress, + color: args.color, + label: args.name, + }; + const updatedWalletAddresses = [...walletAddresses]; + updatedWalletAddresses[walletAddressIndex] = updatedWalletAddress; + + const updatedWallet = { + ...wallets[walletId], + addresses: updatedWalletAddresses, + }; + const updatedWallets = { + ...wallets, + [walletId]: updatedWallet, + }; + + if (currentSelectedWallet.id === walletId) { + setCurrentSelectedWallet(updatedWallet); + dispatch(walletsSetSelected(updatedWallet)); + } + + updateWebProfile(address, args.name, colors.avatarBackgrounds[args.color]); + + dispatch(walletsUpdate(updatedWallets)); + } else { + analytics.track('Tapped "Cancel" after editing wallet'); + } + } + }, + profile: { + color: account?.color, + image: account?.image || ``, + name: account?.label || ``, + }, + type: 'wallet_profile', + }); + }, 50); + }); + }, + [wallets, goBack, navigate, dispatch, currentSelectedWallet.id, updateWebProfile, colors.avatarBackgrounds] + ); + + const onPressEdit = useCallback( + (walletId: string, address: string) => { + analytics.track('Tapped "Edit Wallet"'); + renameWallet(walletId, address); + }, + [renameWallet] + ); + + const onPressNotifications = useCallback( + (walletName: string, address: string) => { + analytics.track('Tapped "Notification Settings"'); + const walletNotificationSettings = getNotificationSettingsForWalletWithAddress(address); + if (walletNotificationSettings) { + navigate(Routes.SETTINGS_SHEET, { + params: { + address, + title: walletName, + notificationSettings: walletNotificationSettings, + }, + screen: Routes.WALLET_NOTIFICATIONS_SETTINGS, + }); + } else { + Alert.alert(i18n.t(i18n.l.wallet.action.notifications.alert_title), i18n.t(i18n.l.wallet.action.notifications.alert_message), [ + { text: 'OK' }, + ]); + } + }, + [navigate] + ); + + const onPressRemove = useCallback( + (walletId: string, address: string) => { + analytics.track('Tapped "Delete Wallet"'); + // If there's more than 1 account + // it's deletable + let isLastAvailableWallet = false; + // eslint-disable-next-line @typescript-eslint/prefer-for-of + for (let i = 0; i < Object.keys(wallets as any).length; i++) { + const key = Object.keys(wallets as any)[i]; + const someWallet = wallets?.[key]; + const otherAccount = someWallet?.addresses.find(account => account.visible && account.address !== address); + if (otherAccount) { + isLastAvailableWallet = true; + break; + } + } + // Delete wallet with confirmation + showActionSheetWithOptions( + { + cancelButtonIndex: 1, + destructiveButtonIndex: 0, + 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) { + analytics.track('Tapped "Delete Wallet" (final confirm)'); + await deleteWallet(walletId, address); + ReactNativeHapticFeedback.trigger('notificationSuccess'); + if (!isLastAvailableWallet) { + await cleanUpWalletKeys(); + goBack(); + navigate(Routes.WELCOME_SCREEN); + } else { + // If we're deleting the selected wallet + // we need to switch to another one + if (wallets && address === currentAddress) { + const { wallet: foundWallet, key } = + doesWalletsContainAddress({ + address: address, + wallets, + }) || {}; + if (foundWallet && key) { + await onChangeAccount(key, foundWallet.address, true); + } + } + } + } + } + ); + }, + [currentAddress, deleteWallet, goBack, navigate, onChangeAccount, wallets] + ); + + 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, { + buttonName: 'AddAnotherWalletButton', + action: 'Navigates from WalletList to AddWalletSheet', + }); + goBack(); + InteractionManager.runAfterInteractions(() => { + navigate(Routes.ADD_WALLET_NAVIGATOR, { + isFirstWallet: false, + }); + }); + }, [goBack, navigate]); + + const onPressEditMode = useCallback(() => { + analytics.track('Tapped "Edit"'); + if (featureHintTooltipRef.current) { + featureHintTooltipRef.current.dismiss(); + } + setEditMode(e => !e); + }, [featureHintTooltipRef]); + + 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, + actionTitle: i18n.t(i18n.l.wallet.change_wallet.address_menu.edit), + icon: { + iconType: 'SYSTEM', + iconValue: 'pencil', + }, + }, + { + actionKey: AddressMenuAction.Copy, + actionTitle: i18n.t(i18n.l.wallet.change_wallet.address_menu.copy), + icon: { + iconType: 'SYSTEM', + iconValue: 'doc.fill', + }, + }, + { + actionKey: AddressMenuAction.Settings, + actionTitle: i18n.t(i18n.l.wallet.change_wallet.address_menu.settings), + icon: { + iconType: 'SYSTEM', + iconValue: 'key.fill', + }, + }, + { + actionKey: AddressMenuAction.Notifications, + actionTitle: i18n.t(i18n.l.wallet.change_wallet.address_menu.notifications), + icon: { + iconType: 'SYSTEM', + iconValue: 'bell.fill', + }, + }, + { + actionKey: AddressMenuAction.Remove, + actionTitle: i18n.t(i18n.l.wallet.change_wallet.address_menu.remove), + 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'), { + actionKey, + }); + 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: + onPressWalletSettings(address); + break; + case AddressMenuAction.Copy: + onPressCopyAddress(address); + break; + } + }, + [walletsByAddress, onPressEdit, onPressNotifications, onPressRemove, onPressCopyAddress, onPressWalletSettings] + ); + + return ( + <> + + + + + + + {i18n.t(i18n.l.wallet.change_wallet.wallets)} + + {/* +3 to account for font size difference */} + + ( + + + {i18n.t(i18n.l.wallet.change_wallet.edit_hint_tooltip.subtitle.prefix)} + + + {` ${i18n.t(i18n.l.wallet.change_wallet.edit_hint_tooltip.subtitle.action)} `} + + + {i18n.t(i18n.l.wallet.change_wallet.edit_hint_tooltip.subtitle.suffix)} + + + } + > + {children} + + )} + > + + + + {editMode ? i18n.t(i18n.l.button.done) : i18n.t(i18n.l.button.edit)} + + + + + + + + + + {/* TODO: progressive blurview on iOS */} + {/* {IS_IOS ? ( + + ) : ( + + )} */} + + + {/* TODO: enable when blurview is implemented */} + {/* {!editMode && ownedWalletsTotalBalance ? ( + + + {i18n.t(i18n.l.wallet.change_wallet.total_balance)} + + + {ownedWalletsTotalBalance} + + + ) : ( + + )} */} + + + + + {`􀅼 ${i18n.t(i18n.l.button.add)}`} + + + + + + + + + + ); +} diff --git a/src/screens/change-wallet/components/AddressAvatar.tsx b/src/screens/change-wallet/components/AddressAvatar.tsx new file mode 100644 index 00000000000..666031d5181 --- /dev/null +++ b/src/screens/change-wallet/components/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 ( + + 50 ? '44pt' : '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/change-wallet/components/AddressRow.tsx b/src/screens/change-wallet/components/AddressRow.tsx new file mode 100644 index 00000000000..ca2518af0d9 --- /dev/null +++ b/src/screens/change-wallet/components/AddressRow.tsx @@ -0,0 +1,211 @@ +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 '@/components/animations'; +import ConditionalWrap from 'conditional-wrap'; +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'; +import { opacity } from '@/__swaps__/utils/swaps'; +import { usePinnedWalletsStore } from '@/state/wallets/pinnedWalletsStore'; +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 '@/components/icons'; +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, + 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'); + + return ( + + + + {icon} + + + + ); +}; +interface AddressRowProps { + menuItems: MenuItem[]; + onPressMenuItem: (actionKey: AddressMenuAction, data: { address: string }) => void; + data: AddressItem; + editMode: boolean; + onPress: () => void; +} + +export function AddressRow({ data, editMode, onPress, menuItems, onPressMenuItem }: AddressRowProps) { + const { address, color, balance, isSelected, isReadOnly, isLedger, label, image } = data; + + const walletName = useMemo(() => { + return removeFirstEmojiFromString(label) || abbreviateAddress(address, 4, 6); + }, [label, address]); + + const addPinnedAddress = usePinnedWalletsStore(state => state.addPinnedAddress); + + const { colors, isDarkMode } = useTheme(); + + const linearGradientProps = useMemo( + () => ({ + pointerEvents: 'none' as const, + style: { + 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 }, + start: { x: 0, y: 0 }, + }), + [colors, isDarkMode] + ); + + const menuConfig = { + menuItems: menuItems.filter(item => (isReadOnly ? item.actionKey !== AddressMenuAction.Settings : true)), + menuTitle: walletName, + }; + + return ( + ( + + triggerAction="longPress" + menuConfig={menuConfig} + onPressMenuItem={action => onPressMenuItem(action, { address })} + > + + {children} + + + )} + > + + + {editMode && ( + + + + )} + + + + + {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.ledger)} + + + + ) : ( + + 􀤃 + + )} + + )} + {!editMode && isSelected && } + {editMode && ( + <> + addPinnedAddress(address)} color={colors.appleBlue} icon="􀎧" size="icon 12px" /> + 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 new file mode 100644 index 00000000000..3751354be1e --- /dev/null +++ b/src/screens/change-wallet/components/PinnedWalletsGrid.tsx @@ -0,0 +1,224 @@ +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 { AddressAvatar } from './AddressAvatar'; +import { ButtonPressAnimation } from '@/components/animations'; +import { BlurView } from '@react-native-community/blur'; +import { usePinnedWalletsStore } from '@/state/wallets/pinnedWalletsStore'; +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'; +import { PANEL_WIDTH } from '@/components/SmoothPager/ListPanel'; +import { IS_IOS } from '@/env'; +import { useTheme } from '@/theme'; +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 = 105; + +type PinnedWalletsGridProps = { + walletItems: AddressItem[]; + onPress: (address: string) => void; + menuItems: MenuItem[]; + onPressMenuItem: (actionKey: AddressMenuAction, data: AddressMenuActionData) => void; + editMode: boolean; +}; + +export function PinnedWalletsGrid({ walletItems, onPress, editMode, menuItems, onPressMenuItem }: PinnedWalletsGridProps) { + const { colors, isDarkMode } = useTheme(); + + const removePinnedAddress = usePinnedWalletsStore(state => state.removePinnedAddress); + const setPinnedAddresses = usePinnedWalletsStore(state => state.setPinnedAddresses); + + const onOrderChange: DraggableGridProps['onOrderChange'] = useCallback( + (value: UniqueIdentifier[]) => { + setPinnedAddresses(value as string[]); + }, + [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 }); + }, [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]); + + const avatarSize = useMemo( + () => + // 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)), + [] + ); + + return ( + + + {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: filteredMenuItems, + menuTitle: walletName, + }} + onPressMenuItem={action => onPressMenuItem(action, { address: account.address })} + > + onPress(account.address)}> + {children} + + + )} + > + + + + + + + {account.isSelected && ( + + + + )} + {editMode && ( + + removePinnedAddress(account.address)}> + + + + {'􀅽'} + + + + + + )} + + + + {account.isLedger && ( + + 􀤃 + + )} + {account.isReadOnly && ( + + 􀋮 + + )} + + {walletName} + + + + {account.balance} + + + + + ); + })} + {fillerItems.map((_, index) => ( + + ))} + + + ); +} diff --git a/src/screens/change-wallet/components/SelectedAddressBadge.tsx b/src/screens/change-wallet/components/SelectedAddressBadge.tsx new file mode 100644 index 00000000000..787c348aa1d --- /dev/null +++ b/src/screens/change-wallet/components/SelectedAddressBadge.tsx @@ -0,0 +1,21 @@ +import React from 'react'; +import { Box, TextIcon } from '@/design-system'; +import { Shadow } from '@/design-system/layout/shadow'; + +export function SelectedAddressBadge({ size = 22, shadow = '12px' }: { size?: number; shadow?: Shadow }) { + return ( + + + 􀆅 + + + ); +} diff --git a/src/screens/change-wallet/components/WalletList.tsx b/src/screens/change-wallet/components/WalletList.tsx new file mode 100644 index 00000000000..4148ce31041 --- /dev/null +++ b/src/screens/change-wallet/components/WalletList.tsx @@ -0,0 +1,182 @@ +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'; +import { EmptyAssetList } from '@/components/asset-list'; +import { AddressRow } from './AddressRow'; +import { EthereumAddress } from '@rainbow-me/entities'; +import styled from '@/styled-thing'; +import { position } from '@/styles'; +import { + AddressItem, + AddressMenuAction, + AddressMenuActionData, + FOOTER_HEIGHT, + MAX_PANEL_HEIGHT, + PANEL_HEADER_HEIGHT, + PANEL_INSET_HORIZONTAL, +} 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/components/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'; +import { SPRING_CONFIGS } from '@/components/animations/animationConfigs'; +import { PanGesture } from 'react-native-gesture-handler'; + +const DRAG_ACTIVATION_DELAY = 150; +const FADE_TRANSITION_DURATION = 75; +const LIST_MAX_HEIGHT = MAX_PANEL_HEIGHT - PANEL_HEADER_HEIGHT; + +const EmptyWalletList = styled(EmptyAssetList).attrs({ + descendingOpacity: true, + pointerEvents: 'none', +})({ + ...position.coverAsObject, + paddingTop: 7.5, +}); + +interface Props { + walletItems: AddressItem[]; + editMode: boolean; + menuItems: MenuItem[]; + onPressMenuItem: (actionKey: AddressMenuAction, data: AddressMenuActionData) => void; + onPressAccount: (address: EthereumAddress) => void; +} + +export function WalletList({ walletItems, menuItems, onPressMenuItem, onPressAccount, editMode }: Props) { + 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 + .filter(item => pinnedAddresses.includes(item.id)) + .sort((a, b) => pinnedAddresses.indexOf(a.id) - pinnedAddresses.indexOf(b.id)); + }, [walletItems, pinnedAddresses]); + + 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 setUnpinnedAddresses = usePinnedWalletsStore(state => state.setUnpinnedAddresses); + + const onOrderChange: DraggableScrollViewProps['onOrderChange'] = useCallback( + (value: UniqueIdentifier[]) => { + 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('impactLight'); + }, []); + + const renderPinnedWalletsSection = useCallback(() => { + const hasPinnedWallets = pinnedWalletItems.length > 0; + return ( + <> + {hasPinnedWallets && ( + + + + )} + {hasPinnedWallets && unpinnedWalletItems.length > 0 && ( + <> + + + + {i18n.t(i18n.l.wallet.change_wallet.all_wallets)} + + + + )} + {!hasPinnedWallets && } + + ); + }, [pinnedWalletItems, onPressAccount, editMode, unpinnedWalletItems.length, menuItems, onPressMenuItem, onDraggableActivationWorklet]); + + // the draggable context should only layout its children when the number of children changes + const draggableUnpinnedWalletItems = useMemo(() => { + return unpinnedWalletItems; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [unpinnedWalletItems.length]); + + return ( + <> + {walletItems.length === 0 && ( + + + + )} + {walletItems.length > 0 && ( + + + + {renderPinnedWalletsSection()} + {draggableUnpinnedWalletItems.map(item => ( + + onPressAccount(item.address)} + /> + + ))} + + + + )} + + ); +} diff --git a/src/state/wallets/pinnedWalletsStore.ts b/src/state/wallets/pinnedWalletsStore.ts new file mode 100644 index 00000000000..7bdfcac167f --- /dev/null +++ b/src/state/wallets/pinnedWalletsStore.ts @@ -0,0 +1,72 @@ +import { createRainbowStore } from '@/state/internal/createRainbowStore'; + +export const MAX_PINNED_ADDRESSES = 6; + +type Address = string; + +interface PinnedWalletsStore { + pinnedAddresses: Address[]; + unpinnedAddresses: Address[]; + hasShownEditHintTooltip: boolean; + hasAutoPinnedAddresses: boolean; + canPinAddresses: () => boolean; + addPinnedAddress: (address: Address) => void; + removePinnedAddress: (address: Address) => void; + setPinnedAddresses: (newOrder: Address[]) => void; + setUnpinnedAddresses: (newOrder: Address[]) => void; + isPinnedAddress: (address: Address) => boolean; +} + +export const usePinnedWalletsStore = createRainbowStore( + (set, get) => ({ + pinnedAddresses: [], + unpinnedAddresses: [], + hasShownEditHintTooltip: false, + hasAutoPinnedAddresses: false, + + canPinAddresses: () => { + return get().pinnedAddresses.length < MAX_PINNED_ADDRESSES; + }, + + isPinnedAddress: address => { + return get().pinnedAddresses.some(pinnedAddress => pinnedAddress === address); + }, + + addPinnedAddress: address => { + const { pinnedAddresses } = get(); + + if (pinnedAddresses.length >= MAX_PINNED_ADDRESSES) return; + + set({ pinnedAddresses: [...pinnedAddresses, address] }); + }, + + removePinnedAddress: address => { + const { pinnedAddresses, unpinnedAddresses } = get(); + + const match = pinnedAddresses.find(pinnedAddress => pinnedAddress === address); + + if (match) { + set({ + pinnedAddresses: pinnedAddresses.filter(pinnedAddress => pinnedAddress !== address), + unpinnedAddresses: [address, ...unpinnedAddresses], + }); + } + }, + + setPinnedAddresses: newPinnedAddresses => { + if (!get().hasAutoPinnedAddresses) { + set({ hasAutoPinnedAddresses: true }); + } + + set({ pinnedAddresses: newPinnedAddresses }); + }, + + setUnpinnedAddresses: newUnpinnedAddresses => { + set({ unpinnedAddresses: newUnpinnedAddresses }); + }, + }), + { + storageKey: 'pinnedWallets', + version: 1, + } +); 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(); }, });