diff --git a/apps/mobile/__mocks__/react-native-collapsible-tab-view.tsx b/apps/mobile/__mocks__/react-native-collapsible-tab-view.tsx
new file mode 100644
index 0000000000..7ec244c49e
--- /dev/null
+++ b/apps/mobile/__mocks__/react-native-collapsible-tab-view.tsx
@@ -0,0 +1,25 @@
+import React from 'react'
+import { View, FlatList } from 'react-native'
+
+export const Tabs = {
+ Container: ({ children, renderTabBar }) => (
+
+ {renderTabBar && renderTabBar({ index: 0, routes: [] })}
+ {children}
+
+ ),
+ Tab: ({ children }: { children: React.ReactNode }) => {children},
+ FlashList: FlatList,
+ FlatList: FlatList,
+ useTabsContext: () => ({
+ focusedTab: '',
+ tabNames: [],
+ index: 0,
+ routes: [],
+ jumpTo: jest.fn(),
+ }),
+ useTabNameContext: () => ({ tabName: 'Tokens' }),
+ ScrollView: ({ children }: { children: React.ReactNode }) => {children},
+}
+
+export default Tabs
diff --git a/apps/mobile/app/_layout.tsx b/apps/mobile/app/_layout.tsx
index 027e159c24..d430524923 100644
--- a/apps/mobile/app/_layout.tsx
+++ b/apps/mobile/app/_layout.tsx
@@ -26,9 +26,9 @@ function RootLayout() {
store.dispatch(apiSliceWithChainsConfig.endpoints.getChainsConfig.initiate())
return (
-
-
-
+
+
+
@@ -64,9 +64,9 @@ function RootLayout() {
-
-
-
+
+
+
)
}
diff --git a/apps/mobile/package.json b/apps/mobile/package.json
index db6ff429e0..32c764568a 100644
--- a/apps/mobile/package.json
+++ b/apps/mobile/package.json
@@ -83,6 +83,7 @@
"react-native-collapsible-tab-view": "^8.0.0",
"react-native-device-crypto": "^0.1.7",
"react-native-device-info": "^14.0.1",
+ "react-native-draggable-flatlist": "^4.0.1",
"react-native-gesture-handler": "~2.20.2",
"react-native-keychain": "^9.2.2",
"react-native-mmkv": "^3.1.0",
diff --git a/apps/mobile/src/components/Container/Container.tsx b/apps/mobile/src/components/Container/Container.tsx
index 95b7585e00..c92e72ea94 100644
--- a/apps/mobile/src/components/Container/Container.tsx
+++ b/apps/mobile/src/components/Container/Container.tsx
@@ -21,12 +21,18 @@ const StyledYStack = styled(YStack, {
})
export const Container = (
- props: YStackProps & { bordered?: boolean; transparent?: boolean; themeName?: ThemeName },
+ props: YStackProps & { bordered?: boolean; spaced?: boolean; transparent?: boolean; themeName?: ThemeName },
) => {
- const { children, bordered, themeName = 'container', ...rest } = props
+ const { children, bordered, themeName = 'container', spaced = true, ...rest } = props
return (
-
+
{children}
diff --git a/apps/mobile/src/components/Dropdown/Dropdown.tsx b/apps/mobile/src/components/Dropdown/Dropdown.tsx
index 160673638a..9ef4ec31a9 100644
--- a/apps/mobile/src/components/Dropdown/Dropdown.tsx
+++ b/apps/mobile/src/components/Dropdown/Dropdown.tsx
@@ -1,23 +1,28 @@
-import React, { useCallback, useRef } from 'react'
+import React, { useCallback, useMemo, useRef } from 'react'
import { GetThemeValueForKey, H5, ScrollView, Text, View } from 'tamagui'
import { SafeFontIcon } from '@/src/components/SafeFontIcon/SafeFontIcon'
import { BottomSheetFooterProps, BottomSheetModal, BottomSheetModalProps, BottomSheetView } from '@gorhom/bottom-sheet'
import { StyleSheet } from 'react-native'
import { BackdropComponent, BackgroundComponent } from './sheetComponents'
+import DraggableFlatList, { DragEndParams, RenderItemParams, ScaleDecorator } from 'react-native-draggable-flatlist'
+
interface DropdownProps {
label: string
leftNode?: React.ReactNode
children?: React.ReactNode
dropdownTitle?: string
+ sortable?: boolean
+ onDragEnd?: (params: DragEndParams) => void
items?: T[]
snapPoints?: BottomSheetModalProps['snapPoints']
labelProps?: {
fontSize?: '$4' | '$5' | GetThemeValueForKey<'fontSize'>
fontWeight: 400 | 500 | 600
}
+ actions?: React.ReactNode
footerComponent?: React.FC
- renderItem?: React.FC<{ item: T; onClose: () => void }>
+ renderItem?: React.FC<{ item: T; isDragging?: boolean; drag?: () => void; onClose: () => void }>
keyExtractor?: ({ item, index }: { item: T; index: number }) => string
}
@@ -31,15 +36,17 @@ export function Dropdown({
leftNode,
children,
dropdownTitle,
+ sortable,
items,
snapPoints = [600, '90%'],
keyExtractor,
+ actions,
renderItem: Render,
labelProps = defaultLabelProps,
footerComponent,
+ onDragEnd,
}: DropdownProps) {
const bottomSheetModalRef = useRef(null)
-
const handlePresentModalPress = useCallback(() => {
bottomSheetModalRef.current?.present()
}, [])
@@ -49,6 +56,33 @@ export function Dropdown({
}, [])
const hasCustomItems = items && Render
+ const isSortable = items && sortable
+
+ const renderItem = useCallback(
+ ({ item, drag, isActive }: RenderItemParams) => {
+ return (
+
+ {Render && }
+
+ )
+ },
+ [handleModalClose, Render],
+ )
+
+ const renderDropdownHeader = useMemo(
+ () => (
+
+ {dropdownTitle}
+
+ {actions && (
+
+ {actions}
+
+ )}
+
+ ),
+ [dropdownTitle, actions],
+ )
return (
<>
@@ -80,28 +114,37 @@ export function Dropdown({
backdropComponent={BackdropComponent}
footerComponent={footerComponent}
>
-
-
-
- {dropdownTitle && (
-
- {dropdownTitle}
-
- )}
+ {!isSortable && dropdownTitle && renderDropdownHeader}
-
- {hasCustomItems
- ? items.map((item, index) => (
-
- ))
- : children}
+
+ {isSortable ? (
+
+ data={items}
+ containerStyle={{ height: '100%' }}
+ ListHeaderComponent={dropdownTitle ? renderDropdownHeader : undefined}
+ onDragEnd={onDragEnd}
+ keyExtractor={(item, index) => (keyExtractor ? keyExtractor({ item, index }) : index.toString())}
+ renderItem={renderItem}
+ />
+ ) : (
+
+
+
+ {hasCustomItems
+ ? items.map((item, index) => (
+
+ ))
+ : children}
+
-
-
+
+ )}
>
@@ -110,7 +153,6 @@ export function Dropdown({
const styles = StyleSheet.create({
contentContainer: {
- paddingHorizontal: 20,
justifyContent: 'space-around',
},
})
diff --git a/apps/mobile/src/components/SafeListItem/SafeListItem.tsx b/apps/mobile/src/components/SafeListItem/SafeListItem.tsx
index 9e71f252f8..7b90e01b2e 100644
--- a/apps/mobile/src/components/SafeListItem/SafeListItem.tsx
+++ b/apps/mobile/src/components/SafeListItem/SafeListItem.tsx
@@ -16,6 +16,7 @@ interface SafeListItemProps {
leftNode?: React.ReactNode
bordered?: boolean
transparent?: boolean
+ spaced?: boolean
inQueue?: boolean
executionInfo?: Transaction['executionInfo']
themeName?: ThemeName
@@ -26,6 +27,7 @@ export function SafeListItem({
leftNode,
icon,
bordered,
+ spaced,
label,
transparent,
rightNode,
@@ -36,6 +38,7 @@ export function SafeListItem({
}: SafeListItemProps) {
return (
({ tabName: '' })
+
+export const useTabNameContext = () => {
+ const context = useContext(TabNameContext)
+ if (!context) {
+ throw new Error('useTabNameContext must be inside a TabNameContext')
+ }
+ return context
+}
diff --git a/apps/mobile/src/components/transactions-list/Card/AccountCard/AccountCard.tsx b/apps/mobile/src/components/transactions-list/Card/AccountCard/AccountCard.tsx
index 7409501c45..533a306711 100644
--- a/apps/mobile/src/components/transactions-list/Card/AccountCard/AccountCard.tsx
+++ b/apps/mobile/src/components/transactions-list/Card/AccountCard/AccountCard.tsx
@@ -14,12 +14,25 @@ interface AccountCardProps {
owners: number
threshold: number
rightNode?: string | React.ReactNode
- chains: Chain[]
+ leftNode?: React.ReactNode
+ chains?: Chain[]
+ spaced?: boolean
}
-export function AccountCard({ name, chains, owners, balance, address, threshold, rightNode }: AccountCardProps) {
+export function AccountCard({
+ name,
+ chains,
+ spaced,
+ owners,
+ leftNode,
+ balance,
+ address,
+ threshold,
+ rightNode,
+}: AccountCardProps) {
return (
@@ -31,10 +44,12 @@ export function AccountCard({ name, chains, owners, balance, address, threshold,
}
leftNode={
-
+
+ {leftNode}
9 ? 8 : 12}
address={address}
badgeContent={`${threshold}/${owners}`}
/>
@@ -42,7 +57,7 @@ export function AccountCard({ name, chains, owners, balance, address, threshold,
}
rightNode={
-
+ {chains && }
{rightNode}
}
diff --git a/apps/mobile/src/components/transactions-list/Card/TxGroupedCard/TxGroupedCard.test.tsx b/apps/mobile/src/components/transactions-list/Card/TxGroupedCard/TxGroupedCard.test.tsx
index 2db0a34663..1feef55b69 100644
--- a/apps/mobile/src/components/transactions-list/Card/TxGroupedCard/TxGroupedCard.test.tsx
+++ b/apps/mobile/src/components/transactions-list/Card/TxGroupedCard/TxGroupedCard.test.tsx
@@ -4,6 +4,19 @@ import { mockERC20Transfer, mockListItemByType, mockNFTTransfer, mockSwapTransfe
import { TransactionListItemType, TransactionStatus } from '@safe-global/store/gateway/types'
import { TransactionItem } from '@safe-global/store/gateway/AUTO_GENERATED/transactions'
+jest.mock('@/src/store/chains', () => {
+ const actualModule = jest.requireActual('@/src/store/chains') // Import the real module
+ return {
+ ...actualModule,
+ selectChainById: jest.fn().mockImplementation(() => ({
+ decimals: 8,
+ logoUri: 'http://safe.com/logo.png',
+ name: 'mocked currency',
+ symbol: 'MCC',
+ })),
+ }
+})
+
describe('TxGroupedCard', () => {
it('should render the default markup', () => {
const { getAllByTestId } = render(
diff --git a/apps/mobile/src/components/transactions-list/Card/TxTokenCard/TxTokenCard.tsx b/apps/mobile/src/components/transactions-list/Card/TxTokenCard/TxTokenCard.tsx
index eef29051e5..c66158c3dd 100644
--- a/apps/mobile/src/components/transactions-list/Card/TxTokenCard/TxTokenCard.tsx
+++ b/apps/mobile/src/components/transactions-list/Card/TxTokenCard/TxTokenCard.tsx
@@ -9,11 +9,11 @@ import {
isTxQueued,
} from '@/src/utils/transaction-guards'
import { ellipsis, formatValue } from '@/src/utils/formatters'
-import { useSelector } from 'react-redux'
-import { selectNativeCurrency } from '@/src/store/activeChainSlice'
import { TransferDirection } from '@safe-global/store/gateway/types'
import { TransferTransactionInfo, Transaction } from '@safe-global/store/gateway/AUTO_GENERATED/transactions'
import { Logo } from '@/src/components/Logo'
+import { selectActiveChainCurrency } from '@/src/store/chains'
+import { useAppSelector } from '@/src/store/hooks'
interface TxTokenCardProps {
bordered?: boolean
@@ -34,7 +34,7 @@ interface tokenDetails {
const getTokenDetails = (txInfo: TransferTransactionInfo): tokenDetails => {
const transfer = txInfo.transferInfo
const unnamedToken = 'Unnamed token'
- const nativeCurrency = useSelector(selectNativeCurrency)
+ const nativeCurrency = useAppSelector(selectActiveChainCurrency)
if (isNativeTokenTransfer(transfer)) {
return {
diff --git a/apps/mobile/src/features/Assets/components/AccountItem/AccountItem.test.tsx b/apps/mobile/src/features/Assets/components/AccountItem/AccountItem.test.tsx
index 9f3573e921..70b970a342 100644
--- a/apps/mobile/src/features/Assets/components/AccountItem/AccountItem.test.tsx
+++ b/apps/mobile/src/features/Assets/components/AccountItem/AccountItem.test.tsx
@@ -1,59 +1,120 @@
-import { render, userEvent } from '@/src/tests/test-utils'
-import AccountItem from './AccountItem'
-import { mockedActiveSafeInfo, mockedChains } from '@/src/store/constants'
+import React from 'react'
+import { render, screen, fireEvent } from '@/src/tests/test-utils'
+import { AccountItem } from './AccountItem'
import { Chain } from '@safe-global/store/gateway/AUTO_GENERATED/chains'
-import { shortenAddress } from '@/src/utils/formatters'
-import { Address } from '@/src/types/address'
+
+const mockAccount = {
+ address: { value: '0x123' as `0x${string}`, name: 'Test Account' },
+ threshold: 1,
+ owners: [{ value: '0x456' as `0x${string}` }],
+ fiatTotal: '1000',
+ chainId: '1',
+ queued: 0,
+}
+
+const mockChains = [
+ {
+ chainId: '1',
+ chainName: 'Ethereum',
+ shortName: 'eth',
+ description: 'Ethereum',
+ l2: false,
+ isTestnet: false,
+ nativeCurrency: { symbol: 'ETH', decimals: 18, name: 'Ether' },
+ blockExplorerUriTemplate: { address: '', txHash: '', api: '' },
+ transactionService: '',
+ theme: { backgroundColor: '', textColor: '' },
+ gasPrice: [],
+ ensRegistryAddress: '',
+ features: [],
+ disabledWallets: [],
+ rpcUri: { authentication: '', value: '' },
+ beaconChainExplorerUriTemplate: { address: '', api: '' },
+ balancesProvider: '',
+ contractAddresses: {},
+ publicRpcUri: { authentication: '', value: '' },
+ safeAppsRpcUri: { authentication: '', value: '' },
+ },
+]
describe('AccountItem', () => {
- it('should render a unselected AccountItem', () => {
- const container = render(
+ const mockOnSelect = jest.fn()
+ const mockDrag = jest.fn()
+
+ beforeEach(() => {
+ jest.clearAllMocks()
+ })
+
+ it('renders account details correctly', () => {
+ render(
+ ,
+ )
+
+ expect(screen.getByText('Test Account')).toBeTruthy()
+ expect(screen.getByText('1/1')).toBeTruthy()
+ expect(screen.getByText('$1000')).toBeTruthy()
+ })
+
+ it('shows active state when account is selected', () => {
+ render(
,
)
- expect(container.getByTestId('account-item-wrapper')).toHaveStyle({ backgroundColor: 'transparent' })
- expect(container.getByText(shortenAddress(mockedActiveSafeInfo.address.value))).toBeDefined()
- expect(container.getByText(`${mockedActiveSafeInfo.threshold}/${mockedActiveSafeInfo.owners.length}`)).toBeDefined()
- expect(container.getByText(`$${mockedActiveSafeInfo.fiatTotal}`)).toBeVisible()
- expect(container.getAllByTestId('chain-display')).toHaveLength(mockedChains.length)
+ const wrapper = screen.getByTestId('account-item-wrapper')
+ expect(wrapper.props.style.backgroundColor).toBe('#DCDEE0')
})
- it('should render a selected AccountItem', () => {
- const container = render(
+ it('calls onSelect when pressed', () => {
+ render(
,
)
- expect(container.getByTestId('account-item-wrapper')).toHaveStyle({ backgroundColor: '#DCDEE0' })
- expect(container.getByText(shortenAddress(mockedActiveSafeInfo.address.value))).toBeDefined()
- expect(container.getByText(`${mockedActiveSafeInfo.threshold}/${mockedActiveSafeInfo.owners.length}`)).toBeDefined()
- expect(container.getByText(`$${mockedActiveSafeInfo.fiatTotal}`)).toBeVisible()
- expect(container.getAllByTestId('chain-display')).toHaveLength(mockedChains.length)
+ fireEvent.press(screen.getByTestId('account-item-wrapper'))
+ expect(mockOnSelect).toHaveBeenCalledWith(mockAccount.address.value)
})
- it('should trigger an event when user clicks in the account item', async () => {
- const spyFn = jest.fn()
- const user = userEvent.setup()
- const container = render(
+ it('enables drag functionality when provided', () => {
+ render(
,
)
- await user.press(container.getByTestId('account-item-wrapper'))
+ fireEvent(screen.getByTestId('account-item-wrapper'), 'longPress')
+ expect(mockDrag).toHaveBeenCalled()
+ })
+
+ it('disables press when dragging', () => {
+ render(
+ ,
+ )
- expect(spyFn).toHaveBeenNthCalledWith(1, mockedActiveSafeInfo.address.value)
+ fireEvent.press(screen.getByTestId('account-item-wrapper'))
+ expect(mockOnSelect).not.toHaveBeenCalled()
})
})
diff --git a/apps/mobile/src/features/Assets/components/AccountItem/AccountItem.tsx b/apps/mobile/src/features/Assets/components/AccountItem/AccountItem.tsx
index 2d3c2d7cec..7961fe3a5a 100644
--- a/apps/mobile/src/features/Assets/components/AccountItem/AccountItem.tsx
+++ b/apps/mobile/src/features/Assets/components/AccountItem/AccountItem.tsx
@@ -1,5 +1,5 @@
-import React from 'react'
-import { TouchableOpacity } from 'react-native'
+import React, { useMemo } from 'react'
+import { StyleSheet, TouchableOpacity } from 'react-native'
import { View } from 'tamagui'
import { SafeFontIcon } from '@/src/components/SafeFontIcon'
import { AccountCard } from '@/src/components/transactions-list/Card/AccountCard'
@@ -7,42 +7,73 @@ import { Chain } from '@safe-global/store/gateway/AUTO_GENERATED/chains'
import { Address } from '@/src/types/address'
import { SafeOverview } from '@safe-global/store/gateway/AUTO_GENERATED/safes'
import { shortenAddress } from '@/src/utils/formatters'
+import { RenderItemParams } from 'react-native-draggable-flatlist'
+import { useEditAccountItem } from './hooks/useEditAccountItem'
interface AccountItemProps {
chains: Chain[]
account: SafeOverview
+ drag?: RenderItemParams['drag']
+ isDragging?: boolean
activeAccount: Address
onSelect: (accountAddress: string) => void
}
-// TODO: These props needs to come from the AccountItem.container component
-// remove this comment once it is done
-export function AccountItem({ account, chains, activeAccount, onSelect }: AccountItemProps) {
+const getRightNodeLayout = (isEdit: boolean, isActive: boolean) => {
+ if (isEdit) {
+ return
+ }
+
+ return isActive ? : null
+}
+
+export function AccountItem({ account, drag, chains, isDragging, activeAccount, onSelect }: AccountItemProps) {
+ const { isEdit, onSafeDeleted } = useEditAccountItem()
const isActive = activeAccount === account.address.value
const handleChainSelect = () => {
onSelect(account.address.value)
}
+ const rightNode = useMemo(() => getRightNodeLayout(isEdit, isActive), [isEdit, isActive])
+
return (
-
+
+
+
+ )
+ }
threshold={account.threshold}
owners={account.owners.length}
name={account.address.name || shortenAddress(account.address.value)}
address={account.address.value as Address}
balance={account.fiatTotal}
- chains={chains}
- rightNode={isActive && }
+ chains={isEdit ? undefined : chains}
+ rightNode={rightNode}
/>
)
}
+const styles = StyleSheet.create({
+ container: {
+ width: '100%',
+ },
+})
+
export default AccountItem
diff --git a/apps/mobile/src/features/Assets/components/AccountItem/hooks/useEditAccountItem.ts b/apps/mobile/src/features/Assets/components/AccountItem/hooks/useEditAccountItem.ts
new file mode 100644
index 0000000000..759ba32a06
--- /dev/null
+++ b/apps/mobile/src/features/Assets/components/AccountItem/hooks/useEditAccountItem.ts
@@ -0,0 +1,35 @@
+import { selectActiveSafe, setActiveSafe } from '@/src/store/activeSafeSlice'
+import { useAppDispatch, useAppSelector } from '@/src/store/hooks'
+import { selectMyAccountsMode } from '@/src/store/myAccountsSlice'
+import { removeSafe, selectAllSafes } from '@/src/store/safesSlice'
+import { Address } from '@/src/types/address'
+import { useCallback } from 'react'
+
+export const useEditAccountItem = () => {
+ const isEdit = useAppSelector(selectMyAccountsMode)
+ const activeSafe = useAppSelector(selectActiveSafe)
+ const safes = useAppSelector(selectAllSafes)
+ const dispatch = useAppDispatch()
+
+ const onSafeDeleted = useCallback(
+ (address: Address) => () => {
+ if (activeSafe.address === address) {
+ const safe = Object.values(safes).find((item) => item.SafeInfo.address.value !== address)
+
+ if (safe) {
+ dispatch(
+ setActiveSafe({
+ address: safe.SafeInfo.address.value as Address,
+ chainId: safe.chains[0],
+ }),
+ )
+ }
+ }
+
+ dispatch(removeSafe(address))
+ },
+ [activeSafe],
+ )
+
+ return { isEdit, onSafeDeleted }
+}
diff --git a/apps/mobile/src/features/Assets/components/Balance/Balance.container.tsx b/apps/mobile/src/features/Assets/components/Balance/Balance.container.tsx
index 23c8b5cb9c..c84c3da24b 100644
--- a/apps/mobile/src/features/Assets/components/Balance/Balance.container.tsx
+++ b/apps/mobile/src/features/Assets/components/Balance/Balance.container.tsx
@@ -1,23 +1,21 @@
-import { selectActiveChain, switchActiveChain } from '@/src/store/activeChainSlice'
-import { useDispatch, useSelector } from 'react-redux'
+import { useDispatch } from 'react-redux'
import { useSafesGetSafeOverviewV1Query } from '@safe-global/store/gateway/AUTO_GENERATED/safes'
-import { selectActiveSafe } from '@/src/store/activeSafeSlice'
+import { selectActiveSafe, switchActiveChain } from '@/src/store/activeSafeSlice'
import { SafeOverviewResult } from '@safe-global/store/gateway/types'
import { POLLING_INTERVAL } from '@/src/config/constants'
import { getChainsByIds, selectAllChains } from '@/src/store/chains'
import { Balance } from './Balance'
import { makeSafeId } from '@/src/utils/formatters'
import { RootState } from '@/src/store'
-import { selectActiveSafeInfo } from '@/src/store/safesSlice'
+import { selectSafeInfo } from '@/src/store/safesSlice'
+import { useAppSelector } from '@/src/store/hooks'
export function BalanceContainer() {
- const activeChain = useSelector(selectActiveChain)
- const chains = useSelector(selectAllChains)
- const activeSafe = useSelector(selectActiveSafe)
+ const chains = useAppSelector(selectAllChains)
+ const activeSafe = useAppSelector(selectActiveSafe)
const dispatch = useDispatch()
- const activeSafeInfo = useSelector((state: RootState) => selectActiveSafeInfo(state, activeSafe.address))
- const activeSafeChains = useSelector((state: RootState) => getChainsByIds(state, activeSafeInfo.chains))
-
+ const activeSafeInfo = useAppSelector((state: RootState) => selectSafeInfo(state, activeSafe.address))
+ const activeSafeChains = useAppSelector((state: RootState) => getChainsByIds(state, activeSafeInfo.chains))
const { data, isLoading } = useSafesGetSafeOverviewV1Query(
{
safes: chains.map((chain) => makeSafeId(chain.chainId, activeSafe.address)).join(','),
@@ -31,8 +29,8 @@ export function BalanceContainer() {
},
)
- const handleChainChange = (id: string) => {
- dispatch(switchActiveChain({ id }))
+ const handleChainChange = (chainId: string) => {
+ dispatch(switchActiveChain({ chainId }))
}
return (
@@ -40,7 +38,7 @@ export function BalanceContainer() {
data={data}
chains={activeSafeChains}
isLoading={isLoading}
- activeChain={activeChain}
+ activeChainId={activeSafe.chainId}
onChainChange={handleChainChange}
/>
)
diff --git a/apps/mobile/src/features/Assets/components/Balance/Balance.tsx b/apps/mobile/src/features/Assets/components/Balance/Balance.tsx
index 3bf7b3c377..8896651010 100644
--- a/apps/mobile/src/features/Assets/components/Balance/Balance.tsx
+++ b/apps/mobile/src/features/Assets/components/Balance/Balance.tsx
@@ -9,26 +9,30 @@ import { Chain } from '@safe-global/store/gateway/AUTO_GENERATED/chains'
import { ChainItems } from './ChainItems'
import { ChainsDisplay } from '@/src/components/ChainsDisplay'
+import { selectChainById } from '@/src/store/chains'
+import { useSelector } from 'react-redux'
+import { RootState } from '@/src/store'
interface BalanceProps {
- activeChain: Chain
+ activeChainId: string
data: SafeOverview[]
isLoading: boolean
chains: Chain[]
onChainChange: (chainId: string) => void
}
-export function Balance({ activeChain, data, chains, isLoading, onChainChange }: BalanceProps) {
- const balance = data?.find((chain) => chain.chainId === activeChain.chainId)
+export function Balance({ activeChainId, data, chains, isLoading, onChainChange }: BalanceProps) {
+ const balance = data?.find((chain) => chain.chainId === activeChainId)
+ const activeChain = useSelector((state: RootState) => selectChainById(state, activeChainId))
return (
- {activeChain && (
+ {activeChainId && (
label={activeChain?.chainName}
dropdownTitle="Select network:"
- leftNode={}
+ leftNode={}
items={data}
keyExtractor={({ item }) => item.chainId}
renderItem={({ item, onClose }) => (
diff --git a/apps/mobile/src/features/Assets/components/MyAccounts/MyAccounts.container.test.tsx b/apps/mobile/src/features/Assets/components/MyAccounts/MyAccounts.container.test.tsx
new file mode 100644
index 0000000000..cf8084db24
--- /dev/null
+++ b/apps/mobile/src/features/Assets/components/MyAccounts/MyAccounts.container.test.tsx
@@ -0,0 +1,92 @@
+import React from 'react'
+import { render, screen, fireEvent } from '@/src/tests/test-utils'
+import { MyAccountsContainer } from './MyAccounts.container'
+import { mockedChains } from '@/src/store/constants'
+import { server } from '@/src/tests/server'
+import { http, HttpResponse } from 'msw'
+import { GATEWAY_URL } from '@/src/config/constants'
+
+// Mock the safe item data
+const mockSafeItem = {
+ SafeInfo: {
+ address: { value: '0x123' as `0x${string}`, name: 'Test Safe' },
+ threshold: 1,
+ owners: [{ value: '0x456' as `0x${string}` }],
+ fiatTotal: '1000',
+ chainId: '1',
+ queued: 0,
+ },
+ chains: ['1'],
+}
+
+// Create a constant object for the selector result
+const mockActiveSafe = { address: '0x789' as `0x${string}`, chainId: '1' }
+const mockChainIds = ['1'] as const
+
+// Mock Redux selectors
+jest.mock('@/src/store/activeSafeSlice', () => ({
+ selectActiveSafe: () => mockActiveSafe,
+ setActiveSafe: (payload: { address: `0x${string}`; chainId: string }) => ({
+ type: 'activeSafe/setActiveSafe',
+ payload,
+ }),
+}))
+
+jest.mock('@/src/store/chains', () => ({
+ getChainsByIds: () => mockedChains,
+ selectAllChainsIds: () => mockChainIds,
+}))
+
+jest.mock('@/src/store/myAccountsSlice', () => ({
+ selectMyAccountsMode: () => false,
+}))
+
+describe('MyAccountsContainer', () => {
+ const mockOnClose = jest.fn()
+
+ beforeEach(() => {
+ server.use(
+ http.get(`${GATEWAY_URL}//v1/safes`, () => {
+ return HttpResponse.json([
+ {
+ address: { value: '0x123', name: 'Test Safe' },
+ chainId: '1',
+ threshold: 1,
+ owners: [{ value: '0x456' }],
+ fiatTotal: '1000',
+ queued: 0,
+ },
+ ])
+ }),
+ )
+ })
+
+ afterEach(() => {
+ jest.clearAllMocks()
+ server.resetHandlers()
+ })
+
+ it('renders account item with correct data', () => {
+ render()
+
+ expect(screen.getByText('Test Safe')).toBeTruthy()
+ expect(screen.getByText('1/1')).toBeTruthy()
+ expect(screen.getByText('$1000')).toBeTruthy()
+ })
+
+ it('calls onClose when account is selected', () => {
+ render()
+
+ fireEvent.press(screen.getByTestId('account-item-wrapper'))
+
+ expect(mockOnClose).toHaveBeenCalled()
+ })
+
+ it('renders with drag functionality when provided', () => {
+ const mockDrag = jest.fn()
+
+ render()
+
+ expect(screen.getByTestId('account-item-wrapper')).toBeTruthy()
+ })
+})
diff --git a/apps/mobile/src/features/Assets/components/MyAccounts/MyAccounts.container.tsx b/apps/mobile/src/features/Assets/components/MyAccounts/MyAccounts.container.tsx
index aac4ed881d..ea034426b8 100644
--- a/apps/mobile/src/features/Assets/components/MyAccounts/MyAccounts.container.tsx
+++ b/apps/mobile/src/features/Assets/components/MyAccounts/MyAccounts.container.tsx
@@ -1,4 +1,5 @@
import React from 'react'
+import { RenderItemParams } from 'react-native-draggable-flatlist'
import { AccountItem } from '../AccountItem'
import { SafesSliceItem } from '@/src/store/safesSlice'
import { Address } from '@/src/types/address'
@@ -6,15 +7,16 @@ import { useDispatch, useSelector } from 'react-redux'
import { selectActiveSafe, setActiveSafe } from '@/src/store/activeSafeSlice'
import { getChainsByIds } from '@/src/store/chains'
import { RootState } from '@/src/store'
-import { switchActiveChain } from '@/src/store/activeChainSlice'
import { useMyAccountsService } from './hooks/useMyAccountsService'
interface MyAccountsContainerProps {
item: SafesSliceItem
onClose: () => void
+ isDragging?: boolean
+ drag?: RenderItemParams['drag']
}
-export function MyAccountsContainer({ item, onClose }: MyAccountsContainerProps) {
+export function MyAccountsContainer({ item, isDragging, drag, onClose }: MyAccountsContainerProps) {
useMyAccountsService(item)
const dispatch = useDispatch()
@@ -30,14 +32,15 @@ export function MyAccountsContainer({ item, onClose }: MyAccountsContainerProps)
chainId,
}),
)
- dispatch(switchActiveChain({ id: chainId }))
onClose()
}
return (
({
+ selectAllChainsIds: () => mockChainIds,
+}))
+
+// Mock Redux dispatch and selector
+const mockDispatch = jest.fn()
+
+jest.mock('@/src/store/hooks', () => ({
+ useAppDispatch: () => mockDispatch,
+ useAppSelector: (selector: (state: unknown) => unknown) => {
+ if (selector.name === 'selectAllChainsIds') {
+ return mockChainIds
+ }
+ return null
+ },
+}))
+
+describe('useMyAccountsService', () => {
+ beforeEach(() => {
+ jest.clearAllMocks()
+ server.use(
+ http.get('https://safe-client.safe.global//v1/safes', ({ request }) => {
+ const url = new URL(request.url)
+ const safes = url.searchParams.get('safes')?.split(',') || []
+
+ return HttpResponse.json(
+ safes.map((safe) => ({
+ address: { value: '0x123', name: 'Test Safe' },
+ chainId: safe.split(':')[0],
+ threshold: 1,
+ owners: [{ value: '0x456' }],
+ fiatTotal: '1000',
+ queued: 0,
+ })),
+ )
+ }),
+ )
+ })
+
+ afterEach(() => {
+ server.resetHandlers()
+ })
+
+ it('should fetch safe overview and update store', async () => {
+ renderHook(() => useMyAccountsService(mockSafeItem))
+
+ // Wait for dispatch to be called
+ await waitFor(() => {
+ expect(mockDispatch).toHaveBeenCalled()
+ })
+
+ expect(mockDispatch).toHaveBeenCalledWith(
+ expect.objectContaining({
+ type: 'safes/updateSafeInfo',
+ payload: expect.objectContaining({
+ address: '0x123',
+ item: expect.objectContaining({
+ chains: ['1', '5'],
+ SafeInfo: expect.objectContaining({
+ fiatTotal: '2000', // Sum of both chain balances
+ }),
+ }),
+ }),
+ }),
+ )
+ })
+
+ it('should not update store if no data is returned', async () => {
+ server.use(
+ http.get('https://safe-client.safe.global//v1/safes', () => {
+ return HttpResponse.json([])
+ }),
+ )
+
+ renderHook(() => useMyAccountsService(mockSafeItem))
+
+ await waitFor(() => {
+ expect(mockDispatch).not.toHaveBeenCalled()
+ })
+ })
+
+ it('should handle API errors gracefully', async () => {
+ server.use(
+ http.get('https://safe-client.safe.global//v1/safes', () => {
+ return HttpResponse.error()
+ }),
+ )
+
+ renderHook(() => useMyAccountsService(mockSafeItem))
+
+ await waitFor(() => {
+ expect(mockDispatch).not.toHaveBeenCalled()
+ })
+ })
+})
diff --git a/apps/mobile/src/features/Assets/components/MyAccounts/hooks/useMyAccountsService.ts b/apps/mobile/src/features/Assets/components/MyAccounts/hooks/useMyAccountsService.ts
index d590807f76..11cd7525c5 100644
--- a/apps/mobile/src/features/Assets/components/MyAccounts/hooks/useMyAccountsService.ts
+++ b/apps/mobile/src/features/Assets/components/MyAccounts/hooks/useMyAccountsService.ts
@@ -1,16 +1,16 @@
import { useSafesGetSafeOverviewV1Query } from '@safe-global/store/gateway/AUTO_GENERATED/safes'
import { SafeOverviewResult } from '@safe-global/store/gateway/types'
import { useEffect, useMemo } from 'react'
-import { useDispatch, useSelector } from 'react-redux'
import { selectAllChainsIds } from '@/src/store/chains'
import { SafesSliceItem, updateSafeInfo } from '@/src/store/safesSlice'
import { Address } from '@/src/types/address'
import { makeSafeId } from '@/src/utils/formatters'
+import { useAppDispatch, useAppSelector } from '@/src/store/hooks'
export const useMyAccountsService = (item: SafesSliceItem) => {
- const dispatch = useDispatch()
- const chainIds = useSelector(selectAllChainsIds)
+ const dispatch = useAppDispatch()
+ const chainIds = useAppSelector(selectAllChainsIds)
const safes = useMemo(
() => chainIds.map((chainId: string) => makeSafeId(chainId, item.SafeInfo.address.value)).join(','),
[chainIds, item.SafeInfo.address.value],
diff --git a/apps/mobile/src/features/Assets/components/MyAccounts/hooks/useMyAccountsSortable.ts b/apps/mobile/src/features/Assets/components/MyAccounts/hooks/useMyAccountsSortable.ts
new file mode 100644
index 0000000000..00cc577a90
--- /dev/null
+++ b/apps/mobile/src/features/Assets/components/MyAccounts/hooks/useMyAccountsSortable.ts
@@ -0,0 +1,39 @@
+import { selectMyAccountsMode, toggleMode } from '@/src/store/myAccountsSlice'
+import { SafesSliceItem, selectAllSafes, setSafes } from '@/src/store/safesSlice'
+import { useCallback, useEffect, useState } from 'react'
+import { DragEndParams } from 'react-native-draggable-flatlist'
+import { useDispatch, useSelector } from 'react-redux'
+
+type useMyAccountsSortableReturn = {
+ safes: SafesSliceItem[]
+ onDragEnd: (params: DragEndParams) => void
+}
+
+export const useMyAccountsSortable = (): useMyAccountsSortableReturn => {
+ const dispatch = useDispatch()
+ const safes = useSelector(selectAllSafes)
+ const [sortableSafes, setSortableSafes] = useState(() => Object.values(safes))
+ const isEdit = useSelector(selectMyAccountsMode)
+
+ useEffect(() => {
+ const newSafes = Object.values(safes)
+ const shouldGoToListMode = newSafes.length <= 1 && isEdit
+
+ setSortableSafes(newSafes)
+
+ if (shouldGoToListMode) {
+ dispatch(toggleMode())
+ }
+ }, [safes, isEdit])
+
+ const onDragEnd = useCallback(({ data }: DragEndParams) => {
+ // Defer Redux update due to incompatibility issues between
+ // react-native-draggable-flatlist and new architecture.
+ setTimeout(() => {
+ const safes = data.reduce((acc, item) => ({ ...acc, [item.SafeInfo.address.value]: item }), {})
+ dispatch(setSafes(safes))
+ }, 0) // Ensure this happens after the re-render
+ }, [])
+
+ return { safes: sortableSafes, onDragEnd }
+}
diff --git a/apps/mobile/src/features/Assets/components/NFTs/NFTs.container.test.tsx b/apps/mobile/src/features/Assets/components/NFTs/NFTs.container.test.tsx
new file mode 100644
index 0000000000..d3dedda2a2
--- /dev/null
+++ b/apps/mobile/src/features/Assets/components/NFTs/NFTs.container.test.tsx
@@ -0,0 +1,59 @@
+import React from 'react'
+import { render, screen } from '@/src/tests/test-utils'
+import { NFTsContainer } from './NFTs.container'
+import { server } from '@/src/tests/server'
+import { http, HttpResponse } from 'msw'
+import { GATEWAY_URL } from '@/src/config/constants'
+
+// Mock active safe selector with memoized object
+const mockActiveSafe = { chainId: '1', address: '0x123' }
+
+jest.mock('@/src/store/activeSafeSlice', () => ({
+ selectActiveSafe: () => mockActiveSafe,
+}))
+
+describe('NFTsContainer', () => {
+ afterAll(() => {
+ server.resetHandlers()
+ })
+ it('renders loading state initially', () => {
+ render()
+ expect(screen.getByTestId('fallback')).toBeTruthy()
+ })
+
+ it('renders error state when API fails', async () => {
+ server.use(
+ http.get(`${GATEWAY_URL}//v2/chains/:chainId/safes/:safeAddress/collectibles`, () => {
+ return HttpResponse.error()
+ }),
+ )
+
+ render()
+ expect(await screen.findByTestId('fallback')).toBeTruthy()
+ })
+
+ it('renders NFT list when data is available', async () => {
+ render()
+
+ // First verify we see the loading state
+ expect(screen.getByTestId('fallback')).toBeTruthy()
+
+ // Then check for NFT content
+ const nft1 = await screen.findByText('NFT #1')
+ const nft2 = await screen.findByText('NFT #2')
+
+ expect(nft1).toBeTruthy()
+ expect(nft2).toBeTruthy()
+ })
+
+ it('renders fallback when data is empty', async () => {
+ server.use(
+ http.get(`${GATEWAY_URL}//v2/chains/:chainId/safes/:safeAddress/collectibles`, () => {
+ return HttpResponse.json({ results: [] })
+ }),
+ )
+
+ render()
+ expect(await screen.findByTestId('fallback')).toBeTruthy()
+ })
+})
diff --git a/apps/mobile/src/features/Assets/components/NFTs/NFTs.container.tsx b/apps/mobile/src/features/Assets/components/NFTs/NFTs.container.tsx
index d201de1cfe..10c88689ab 100644
--- a/apps/mobile/src/features/Assets/components/NFTs/NFTs.container.tsx
+++ b/apps/mobile/src/features/Assets/components/NFTs/NFTs.container.tsx
@@ -13,17 +13,15 @@ import {
import { Fallback } from '../Fallback'
import { NFTItem } from './NFTItem'
-import { selectActiveChain } from '@/src/store/activeChainSlice'
import { useInfiniteScroll } from '@/src/hooks/useInfiniteScroll'
export function NFTsContainer() {
- const activeChain = useSelector(selectActiveChain)
const activeSafe = useSelector(selectActiveSafe)
const [pageUrl, setPageUrl] = useState()
const { data, isFetching, error, refetch } = useCollectiblesGetCollectiblesV2Query(
{
- chainId: activeChain.chainId,
+ chainId: activeSafe.chainId,
safeAddress: activeSafe.address,
cursor: pageUrl && safelyDecodeURIComponent(pageUrl?.split('cursor=')[1]),
},
diff --git a/apps/mobile/src/features/Assets/components/Navbar/Navbar.tsx b/apps/mobile/src/features/Assets/components/Navbar/Navbar.tsx
index f67f461720..791ffa7ef1 100644
--- a/apps/mobile/src/features/Assets/components/Navbar/Navbar.tsx
+++ b/apps/mobile/src/features/Assets/components/Navbar/Navbar.tsx
@@ -1,17 +1,19 @@
-import { useSelector } from 'react-redux'
import { selectActiveSafe } from '@/src/store/activeSafeSlice'
-import { View } from 'tamagui'
+import { View, H6 } from 'tamagui'
import { BlurredIdenticonBackground } from '@/src/components/BlurredIdenticonBackground'
import { SafeAreaView } from 'react-native-safe-area-context'
import { Identicon } from '@/src/components/Identicon'
import { shortenAddress } from '@/src/utils/formatters'
import { SafeFontIcon } from '@/src/components/SafeFontIcon'
import { StyleSheet, TouchableOpacity } from 'react-native'
-import React, { useMemo } from 'react'
+import React from 'react'
import { Address } from '@/src/types/address'
import { Dropdown } from '@/src/components/Dropdown'
+import { SafesSliceItem } from '@/src/store/safesSlice'
+import { selectMyAccountsMode, toggleMode } from '@/src/store/myAccountsSlice'
import { MyAccountsContainer, MyAccountsFooter } from '../MyAccounts'
-import { SafesSliceItem, selectAllSafes } from '@/src/store/safesSlice'
+import { useMyAccountsSortable } from '../MyAccounts/hooks/useMyAccountsSortable'
+import { useAppDispatch, useAppSelector } from '@/src/store/hooks'
const dropdownLabelProps = {
fontSize: '$5',
@@ -19,23 +21,37 @@ const dropdownLabelProps = {
} as const
export const Navbar = () => {
- const activeSafe = useSelector(selectActiveSafe)
- const safes = useSelector(selectAllSafes)
- const memoizedSafes = useMemo(() => Object.values(safes), [safes])
+ const dispatch = useAppDispatch()
+ const isEdit = useAppSelector(selectMyAccountsMode)
+ const activeSafe = useAppSelector(selectActiveSafe)
+ const { safes, onDragEnd } = useMyAccountsSortable()
+
+ const toggleEditMode = () => {
+ dispatch(toggleMode())
+ }
return (
-
+
label={shortenAddress(activeSafe.address)}
labelProps={dropdownLabelProps}
dropdownTitle="My accounts"
leftNode={}
- items={memoizedSafes}
+ items={safes}
keyExtractor={({ item }) => item.SafeInfo.address.value}
footerComponent={MyAccountsFooter}
renderItem={MyAccountsContainer}
+ sortable={isEdit}
+ onDragEnd={onDragEnd}
+ actions={
+ safes.length > 1 && (
+
+ {isEdit ? 'Done' : 'Edit'}
+
+ )
+ }
/>
diff --git a/apps/mobile/src/features/Assets/components/NoFunds/NoFunds.test.tsx b/apps/mobile/src/features/Assets/components/NoFunds/NoFunds.test.tsx
new file mode 100644
index 0000000000..628372895e
--- /dev/null
+++ b/apps/mobile/src/features/Assets/components/NoFunds/NoFunds.test.tsx
@@ -0,0 +1,22 @@
+import React from 'react'
+import { render, screen } from '@/src/tests/test-utils'
+import { NoFunds } from './NoFunds'
+
+describe('NoFunds', () => {
+ it('renders the empty token component', () => {
+ render()
+
+ // Check for the main elements
+ expect(screen.getByText('Add funds to get started')).toBeTruthy()
+ expect(
+ screen.getByText('Send funds to your Safe Account from another wallet by copying your address.'),
+ ).toBeTruthy()
+ })
+
+ it('renders the EmptyToken component', () => {
+ render()
+
+ // Check if EmptyToken is rendered by looking for its container
+ expect(screen.getByTestId('empty-token')).toBeTruthy()
+ })
+})
diff --git a/apps/mobile/src/features/Assets/components/NoFunds/NoFunds.tsx b/apps/mobile/src/features/Assets/components/NoFunds/NoFunds.tsx
index b0cf7f0e9d..1c5baad6e8 100644
--- a/apps/mobile/src/features/Assets/components/NoFunds/NoFunds.tsx
+++ b/apps/mobile/src/features/Assets/components/NoFunds/NoFunds.tsx
@@ -4,7 +4,7 @@ import EmptyToken from './EmptyToken'
export function NoFunds() {
return (
-
+
Add funds to get started
diff --git a/apps/mobile/src/features/Assets/components/Tokens/Tokens.container.test.tsx b/apps/mobile/src/features/Assets/components/Tokens/Tokens.container.test.tsx
new file mode 100644
index 0000000000..d2892830ce
--- /dev/null
+++ b/apps/mobile/src/features/Assets/components/Tokens/Tokens.container.test.tsx
@@ -0,0 +1,69 @@
+import React from 'react'
+import { render, screen } from '@/src/tests/test-utils'
+import { TokensContainer } from './Tokens.container'
+import { server } from '@/src/tests/server'
+import { http, HttpResponse } from 'msw'
+import { GATEWAY_URL } from '@/src/config/constants'
+
+// Mock active safe selector with memoized object
+const mockActiveSafe = { chainId: '1', address: '0x123' }
+
+jest.mock('@/src/store/activeSafeSlice', () => ({
+ selectActiveSafe: () => mockActiveSafe,
+}))
+
+describe('TokensContainer', () => {
+ afterEach(() => {
+ server.resetHandlers()
+ })
+
+ afterAll(() => {
+ server.close()
+ })
+
+ it('renders loading state initially', () => {
+ render()
+ expect(screen.getByTestId('fallback')).toBeTruthy()
+ })
+
+ it('renders error state when API fails', async () => {
+ server.use(
+ http.get(`${GATEWAY_URL}/api/v1/chains/:chainId/safes/:safeAddress/balances/usd`, () => {
+ return HttpResponse.error()
+ }),
+ )
+
+ render()
+
+ expect(await screen.findByTestId('fallback')).toBeTruthy()
+ })
+
+ it('renders token list when data is available', async () => {
+ // Setup response spy
+ render()
+
+ // First verify we see the loading state
+ expect(screen.getByTestId('fallback')).toBeTruthy()
+
+ // Then check for content
+ const ethText = await screen.findByText('Ethereum')
+ const ethAmount = await screen.findByText('1 ETH')
+ const ethValue = await screen.findByText('$2000')
+
+ expect(ethText).toBeTruthy()
+ expect(ethAmount).toBeTruthy()
+ expect(ethValue).toBeTruthy()
+ })
+
+ it('renders fallback when data is empty', async () => {
+ server.use(
+ http.get(`${GATEWAY_URL}/api/v1/chains/:chainId/safes/:safeAddress/balances/usd`, () => {
+ return HttpResponse.json({ items: [] })
+ }),
+ )
+
+ render()
+
+ expect(await screen.findByTestId('fallback')).toBeTruthy()
+ })
+})
diff --git a/apps/mobile/src/features/Assets/components/Tokens/Tokens.container.tsx b/apps/mobile/src/features/Assets/components/Tokens/Tokens.container.tsx
index 29b5ca90c5..2f3a671f35 100644
--- a/apps/mobile/src/features/Assets/components/Tokens/Tokens.container.tsx
+++ b/apps/mobile/src/features/Assets/components/Tokens/Tokens.container.tsx
@@ -9,17 +9,15 @@ import { POLLING_INTERVAL } from '@/src/config/constants'
import { selectActiveSafe } from '@/src/store/activeSafeSlice'
import { Balance, useBalancesGetBalancesV1Query } from '@safe-global/store/gateway/AUTO_GENERATED/balances'
import { formatValue } from '@/src/utils/formatters'
-import { selectActiveChain } from '@/src/store/activeChainSlice'
import { Fallback } from '../Fallback'
export function TokensContainer() {
const activeSafe = useSelector(selectActiveSafe)
- const activeChain = useSelector(selectActiveChain)
const { data, isFetching, error } = useBalancesGetBalancesV1Query(
{
- chainId: activeChain.chainId,
+ chainId: activeSafe.chainId,
fiatCode: 'USD',
safeAddress: activeSafe.address,
excludeSpam: false,
diff --git a/apps/mobile/src/features/Settings/components/AppSettings/AppSettings.container.tsx b/apps/mobile/src/features/Settings/components/AppSettings/AppSettings.container.tsx
index 977811381a..c0eec6e4d0 100644
--- a/apps/mobile/src/features/Settings/components/AppSettings/AppSettings.container.tsx
+++ b/apps/mobile/src/features/Settings/components/AppSettings/AppSettings.container.tsx
@@ -1,19 +1,18 @@
import React, { useState } from 'react'
import { useDispatch, useSelector } from 'react-redux'
-import { selectActiveChain } from '@/src/store/activeChainSlice'
-import { setActiveSafe } from '@/src/store/activeSafeSlice'
+import { selectActiveSafe, setActiveSafe } from '@/src/store/activeSafeSlice'
import { Address } from '@/src/types/address'
import { AppSettings } from './AppSettings'
export const AppSettingsContainer = () => {
const dispatch = useDispatch()
- const activeChain = useSelector(selectActiveChain)
+ const activeSafe = useSelector(selectActiveSafe)
const [safeAddress, setSafeAddress] = useState('')
const handleSubmit = () => {
dispatch(
setActiveSafe({
- chainId: activeChain.chainId,
+ chainId: activeSafe.chainId,
address: safeAddress as Address,
}),
)
diff --git a/apps/mobile/src/features/Settings/components/IdenticonWithBadge/IdenticonWithBadge.tsx b/apps/mobile/src/features/Settings/components/IdenticonWithBadge/IdenticonWithBadge.tsx
index 7e4ed2c009..2a1de35dfe 100644
--- a/apps/mobile/src/features/Settings/components/IdenticonWithBadge/IdenticonWithBadge.tsx
+++ b/apps/mobile/src/features/Settings/components/IdenticonWithBadge/IdenticonWithBadge.tsx
@@ -11,9 +11,16 @@ type IdenticonWithBadgeProps = {
badgeContent?: string
size?: number
testID?: string
+ fontSize?: number
}
-export const IdenticonWithBadge = ({ address, testID, badgeContent, size = 56 }: IdenticonWithBadgeProps) => {
+export const IdenticonWithBadge = ({
+ address,
+ testID,
+ badgeContent,
+ fontSize = 12,
+ size = 56,
+}: IdenticonWithBadgeProps) => {
return (
@@ -23,8 +30,8 @@ export const IdenticonWithBadge = ({ address, testID, badgeContent, size = 56 }:
{
const toast = useToastController()
- const activeChain = useSelector(selectActiveChain)
+ const activeSafe = useAppSelector(selectActiveSafe)
+ const activeChain = useAppSelector((state: RootState) => selectChainById(state, activeSafe.chainId))
const copyAndDispatchToast = useCopyAndDispatchToast()
const theme = useTheme()
const color = theme.color?.get()
diff --git a/apps/mobile/src/store/activeChainSlice.ts b/apps/mobile/src/store/activeChainSlice.ts
deleted file mode 100644
index db064e6a39..0000000000
--- a/apps/mobile/src/store/activeChainSlice.ts
+++ /dev/null
@@ -1,23 +0,0 @@
-import { createSelector, createSlice, PayloadAction } from '@reduxjs/toolkit'
-import { RootState } from '.'
-import { selectChainById } from './chains'
-import { mockedActiveAccount } from './constants'
-
-const initialState = { id: mockedActiveAccount.chainId }
-
-const activeChainSlice = createSlice({
- name: 'activeChain',
- initialState,
- reducers: {
- switchActiveChain: (state, action: PayloadAction<{ id: string }>) => {
- return action.payload
- },
- },
-})
-
-export const { switchActiveChain } = activeChainSlice.actions
-
-export const selectActiveChain = (state: RootState) => selectChainById(state, state.activeChain.id)
-export const selectNativeCurrency = createSelector([selectActiveChain], (activeChain) => activeChain?.nativeCurrency)
-
-export default activeChainSlice.reducer
diff --git a/apps/mobile/src/store/activeSafeSlice.ts b/apps/mobile/src/store/activeSafeSlice.ts
index adaf98cb14..6a1bc19cbc 100644
--- a/apps/mobile/src/store/activeSafeSlice.ts
+++ b/apps/mobile/src/store/activeSafeSlice.ts
@@ -18,10 +18,16 @@ const activeSafeSlice = createSlice({
clearActiveSafe: () => {
return initialState
},
+ switchActiveChain: (state, action: PayloadAction<{ chainId: string }>) => {
+ return {
+ ...state,
+ chainId: action.payload.chainId,
+ }
+ },
},
})
-export const { setActiveSafe, clearActiveSafe } = activeSafeSlice.actions
+export const { setActiveSafe, switchActiveChain, clearActiveSafe } = activeSafeSlice.actions
export const selectActiveSafe = (state: RootState) => state.activeSafe
diff --git a/apps/mobile/src/store/chains/index.ts b/apps/mobile/src/store/chains/index.ts
index 5872031662..f123ccc142 100644
--- a/apps/mobile/src/store/chains/index.ts
+++ b/apps/mobile/src/store/chains/index.ts
@@ -2,6 +2,7 @@ import { apiSliceWithChainsConfig, chainsAdapter, initialState } from '@safe-glo
import { createSelector } from '@reduxjs/toolkit'
import { RootState } from '..'
import { Chain } from '@safe-global/store/gateway/AUTO_GENERATED/chains'
+import { selectActiveSafe } from '../activeSafeSlice'
const selectChainsResult = apiSliceWithChainsConfig.endpoints.getChainsConfig.select()
@@ -15,6 +16,13 @@ export const selectChainById = (state: RootState, chainId: string) => selectById
export const selectAllChainsIds = createSelector([selectAllChains], (chains: Chain[]) =>
chains.map((chain) => chain.chainId),
)
+export const selectActiveChainCurrency = createSelector(
+ [selectActiveSafe, (state: RootState) => state],
+ (activeSafe, state) => {
+ const chain = selectChainById(state, activeSafe.chainId)
+ return chain?.nativeCurrency
+ },
+)
export const getChainsByIds = createSelector(
[
diff --git a/apps/mobile/src/store/constants.ts b/apps/mobile/src/store/constants.ts
index c71e0af9e3..0646a9476c 100644
--- a/apps/mobile/src/store/constants.ts
+++ b/apps/mobile/src/store/constants.ts
@@ -27,6 +27,24 @@ export const mockedAccounts = [
queued: 1,
threshold: 1,
},
+ {
+ address: { value: '0xF7a47Bf5705572B7EB9cb0F7007C66B770Ea120f', name: null, logoUri: null },
+ awaitingConfirmation: null,
+ chainId: '100',
+ fiatTotal: '0',
+ owners: [{ value: '0xF7a47Bf5705572B7EB9cb0F7007C66B770Ea120f', name: null, logoUri: null }],
+ queued: 1,
+ threshold: 1,
+ },
+ {
+ address: { value: '0x7bF7cF1D8375ad2B25B9050FeF93181ec3E15f08', name: null, logoUri: null },
+ awaitingConfirmation: null,
+ chainId: '1',
+ fiatTotal: '0',
+ owners: [{ value: '0x7bF7cF1D8375ad2B25B9050FeF93181ec3E15f08', name: null, logoUri: null }],
+ queued: 1,
+ threshold: 1,
+ },
]
export const mockedChains = [
diff --git a/apps/mobile/src/store/index.ts b/apps/mobile/src/store/index.ts
index 7c580238b2..bc60c33de1 100644
--- a/apps/mobile/src/store/index.ts
+++ b/apps/mobile/src/store/index.ts
@@ -2,8 +2,8 @@ import { combineReducers, configureStore } from '@reduxjs/toolkit'
import { persistStore, persistReducer, FLUSH, REHYDRATE, PAUSE, PERSIST, PURGE, REGISTER } from 'redux-persist'
import { reduxStorage } from './storage'
import txHistory from './txHistorySlice'
-import activeChain from './activeChainSlice'
import activeSafe from './activeSafeSlice'
+import myAccounts from './myAccountsSlice'
import safes from './safesSlice'
import { cgwClient, setBaseUrl } from '@safe-global/store/gateway/cgwClient'
import devToolsEnhancer from 'redux-devtools-expo-dev-plugin'
@@ -14,13 +14,13 @@ const persistConfig = {
key: 'root',
version: 1,
storage: reduxStorage,
- blacklist: [cgwClient.reducerPath],
+ blacklist: [cgwClient.reducerPath, 'myAccounts'],
}
export const rootReducer = combineReducers({
txHistory,
safes,
- activeChain,
activeSafe,
+ myAccounts,
[cgwClient.reducerPath]: cgwClient.reducer,
})
diff --git a/apps/mobile/src/store/myAccountsSlice.ts b/apps/mobile/src/store/myAccountsSlice.ts
new file mode 100644
index 0000000000..53de4ffa06
--- /dev/null
+++ b/apps/mobile/src/store/myAccountsSlice.ts
@@ -0,0 +1,22 @@
+import { createSlice } from '@reduxjs/toolkit'
+import { RootState } from '.'
+
+const initialState = {
+ isEdit: false,
+}
+
+const myAccountsSlice = createSlice({
+ name: 'myAccounts',
+ initialState,
+ reducers: {
+ toggleMode: (state) => {
+ state.isEdit = !state.isEdit
+ },
+ },
+})
+
+export const { toggleMode } = myAccountsSlice.actions
+
+export const selectMyAccountsMode = (state: RootState) => state.myAccounts.isEdit
+
+export default myAccountsSlice.reducer
diff --git a/apps/mobile/src/store/safesSlice.ts b/apps/mobile/src/store/safesSlice.ts
index d78641779c..4e62999aa7 100644
--- a/apps/mobile/src/store/safesSlice.ts
+++ b/apps/mobile/src/store/safesSlice.ts
@@ -20,6 +20,14 @@ const initialState: SafesSlice = {
SafeInfo: mockedAccounts[1],
chains: [mockedAccounts[1].chainId],
},
+ [mockedAccounts[2].address.value]: {
+ SafeInfo: mockedAccounts[2],
+ chains: [mockedAccounts[2].chainId],
+ },
+ [mockedAccounts[3].address.value]: {
+ SafeInfo: mockedAccounts[3],
+ chains: [mockedAccounts[3].chainId],
+ },
}
const activeSafeSlice = createSlice({
@@ -30,13 +38,22 @@ const activeSafeSlice = createSlice({
state[action.payload.address] = action.payload.item
return state
},
+ setSafes: (_state, action: PayloadAction>) => {
+ return action.payload
+ },
+ removeSafe: (state, action: PayloadAction) => {
+ const filteredSafes = Object.values(state).filter((safe) => safe.SafeInfo.address.value !== action.payload)
+ const newState = filteredSafes.reduce((acc, safe) => ({ ...acc, [safe.SafeInfo.address.value]: safe }), {})
+
+ return newState
+ },
},
})
-export const { updateSafeInfo } = activeSafeSlice.actions
+export const { updateSafeInfo, setSafes, removeSafe } = activeSafeSlice.actions
export const selectAllSafes = (state: RootState) => state.safes
-export const selectActiveSafeInfo = createSelector(
+export const selectSafeInfo = createSelector(
[selectAllSafes, (_state, activeSafeAddress: Address) => activeSafeAddress],
(safes: SafesSlice, activeSafeAddress: Address) => safes[activeSafeAddress],
)
diff --git a/apps/mobile/src/tests/handlers.ts b/apps/mobile/src/tests/handlers.ts
new file mode 100644
index 0000000000..69258153ad
--- /dev/null
+++ b/apps/mobile/src/tests/handlers.ts
@@ -0,0 +1,12 @@
+import { http, HttpResponse } from 'msw'
+import { mockBalanceData, mockNFTData } from './mocks'
+import { GATEWAY_URL } from '../config/constants'
+
+export const handlers = [
+ http.get(`${GATEWAY_URL}//v1/chains/1/safes/0x123/balances/USD`, () => {
+ return HttpResponse.json(mockBalanceData)
+ }),
+ http.get(`${GATEWAY_URL}//v2/chains/:chainId/safes/:safeAddress/collectibles`, () => {
+ return HttpResponse.json(mockNFTData)
+ }),
+]
diff --git a/apps/mobile/src/tests/jest.setup.tsx b/apps/mobile/src/tests/jest.setup.tsx
index f7eed09035..faff2509cb 100644
--- a/apps/mobile/src/tests/jest.setup.tsx
+++ b/apps/mobile/src/tests/jest.setup.tsx
@@ -3,6 +3,8 @@ import React from 'react'
import '@testing-library/react-native/extend-expect'
import mockRNDeviceInfo from 'react-native-device-info/jest/react-native-device-info-mock'
+import { server } from './server'
+
jest.useFakeTimers()
/**
@@ -127,3 +129,7 @@ jest.mock('@gorhom/bottom-sheet', () => {
}),
}
})
+
+beforeAll(() => server.listen())
+afterEach(() => server.resetHandlers())
+afterAll(() => server.close())
diff --git a/apps/mobile/src/tests/mocks.ts b/apps/mobile/src/tests/mocks.ts
index 7b1b4861ec..dcbc5e2db9 100644
--- a/apps/mobile/src/tests/mocks.ts
+++ b/apps/mobile/src/tests/mocks.ts
@@ -20,6 +20,52 @@ import {
Transaction,
} from '@safe-global/store/gateway/AUTO_GENERATED/transactions'
+export const mockBalanceData = {
+ items: [
+ {
+ tokenInfo: {
+ name: 'Ethereum',
+ symbol: 'ETH',
+ decimals: 18,
+ logoUri: 'https://safe-transaction-assets.safe.global/chains/1/chain_logo.png',
+ },
+ balance: '1000000000000000000',
+ fiatBalance: '2000',
+ },
+ ],
+}
+
+export const mockNFTData = {
+ count: 2,
+ next: null,
+ previous: null,
+ results: [
+ {
+ id: '1',
+ address: '0x123',
+ tokenName: 'Cool NFT',
+ tokenSymbol: 'CNFT',
+ logoUri: 'https://example.com/nft1.png',
+ name: 'NFT #1',
+ description: 'A cool NFT',
+ tokenId: '1',
+ uri: 'https://example.com/nft1.json',
+ imageUri: 'https://example.com/nft1.png',
+ },
+ {
+ id: '2',
+ address: '0x456',
+ tokenName: 'Another NFT',
+ tokenSymbol: 'ANFT',
+ logoUri: 'https://example.com/nft2.png',
+ name: 'NFT #2',
+ description: 'Another cool NFT',
+ tokenId: '2',
+ uri: 'https://example.com/nft2.json',
+ imageUri: 'https://example.com/nft2.png',
+ },
+ ],
+}
export const fakeToken = {
address: '0x1111111111',
decimals: 18,
diff --git a/apps/mobile/src/tests/server.ts b/apps/mobile/src/tests/server.ts
new file mode 100644
index 0000000000..86f7d6154a
--- /dev/null
+++ b/apps/mobile/src/tests/server.ts
@@ -0,0 +1,4 @@
+import { setupServer } from 'msw/node'
+import { handlers } from './handlers'
+
+export const server = setupServer(...handlers)
diff --git a/apps/mobile/src/tests/test-utils.tsx b/apps/mobile/src/tests/test-utils.tsx
index c2b1e81b2c..806c6bc1d2 100644
--- a/apps/mobile/src/tests/test-utils.tsx
+++ b/apps/mobile/src/tests/test-utils.tsx
@@ -1,13 +1,22 @@
import { render as nativeRender, renderHook } from '@testing-library/react-native'
import { SafeThemeProvider } from '@/src/theme/provider/safeTheme'
import { Provider } from 'react-redux'
-import { makeStore } from '../store'
+import { makeStore, rootReducer } from '../store'
import { PortalProvider } from 'tamagui'
import { BottomSheetModalProvider } from '@gorhom/bottom-sheet'
+import { configureStore } from '@reduxjs/toolkit'
-const getProviders: () => React.FC<{ children: React.ReactElement }> = () =>
+export type RootState = ReturnType
+type getProvidersArgs = (initialStoreState?: Partial) => React.FC<{ children: React.ReactNode }>
+
+const getProviders: getProvidersArgs = (initialStoreState) =>
function ProviderComponent({ children }: { children: React.ReactNode }) {
- const store = makeStore()
+ const store = initialStoreState
+ ? configureStore({
+ reducer: rootReducer,
+ preloadedState: initialStoreState,
+ })
+ : makeStore()
return (
@@ -20,10 +29,23 @@ const getProviders: () => React.FC<{ children: React.ReactElement }> = () =>
)
}
-const customRender = (ui: React.ReactElement) => {
- const wrapper = getProviders()
+const customRender = (
+ ui: React.ReactElement,
+ {
+ initialStore,
+ wrapper: CustomWrapper,
+ }: {
+ initialStore?: Partial
+ wrapper?: React.ComponentType<{ children: React.ReactNode }>
+ } = {},
+) => {
+ const Wrapper = getProviders(initialStore)
+
+ function WrapperWithCustom({ children }: { children: React.ReactNode }) {
+ return {CustomWrapper ? {children} : children}
+ }
- return nativeRender(ui, { wrapper })
+ return nativeRender(ui, { wrapper: WrapperWithCustom })
}
function customRenderHook(render: (initialProps: Props) => Result) {
diff --git a/apps/mobile/tsconfig.json b/apps/mobile/tsconfig.json
index a4564769f3..e9389e759a 100644
--- a/apps/mobile/tsconfig.json
+++ b/apps/mobile/tsconfig.json
@@ -8,5 +8,6 @@
},
"types": ["jest", "node"]
},
- "include": ["**/*.ts", "**/*.tsx", ".expo/types/**/*.ts", "expo-env.d.ts"]
+ "include": ["**/*.ts", "**/*.tsx", ".expo/types/**/*.ts", "expo-env.d.ts"],
+ "exclude": ["./__mocks__/**/*"]
}
diff --git a/package.json b/package.json
index 12065299ac..377a5c1a1c 100644
--- a/package.json
+++ b/package.json
@@ -24,6 +24,7 @@
"devDependencies": {
"husky": "^9.1.6",
"lint-staged": "^15.2.10",
+ "msw": "^2.7.0",
"prettier": "^3.4.2"
},
"packageManager": "yarn@4.5.3"
diff --git a/yarn.lock b/yarn.lock
index 1a11c6e9ae..a2a872935b 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -1613,7 +1613,7 @@ __metadata:
languageName: node
linkType: hard
-"@babel/preset-typescript@npm:^7.13.0, @babel/preset-typescript@npm:^7.16.7, @babel/preset-typescript@npm:^7.21.0, @babel/preset-typescript@npm:^7.23.0, @babel/preset-typescript@npm:^7.24.1, @babel/preset-typescript@npm:^7.24.7":
+"@babel/preset-typescript@npm:^7.13.0, @babel/preset-typescript@npm:^7.16.7, @babel/preset-typescript@npm:^7.17.12, @babel/preset-typescript@npm:^7.21.0, @babel/preset-typescript@npm:^7.23.0, @babel/preset-typescript@npm:^7.24.1, @babel/preset-typescript@npm:^7.24.7":
version: 7.26.0
resolution: "@babel/preset-typescript@npm:7.26.0"
dependencies:
@@ -1695,6 +1695,34 @@ __metadata:
languageName: node
linkType: hard
+"@bundled-es-modules/cookie@npm:^2.0.1":
+ version: 2.0.1
+ resolution: "@bundled-es-modules/cookie@npm:2.0.1"
+ dependencies:
+ cookie: "npm:^0.7.2"
+ checksum: 10/0038a5e82c41bfcd722afedabeb6961a5f15747b3681d7f4b61e35eb1e33130039e10ee9250dc9c9e4d3915ce1aeee717c0fb92225111574f0a030411abc0987
+ languageName: node
+ linkType: hard
+
+"@bundled-es-modules/statuses@npm:^1.0.1":
+ version: 1.0.1
+ resolution: "@bundled-es-modules/statuses@npm:1.0.1"
+ dependencies:
+ statuses: "npm:^2.0.1"
+ checksum: 10/9bf6a2bcf040a66fb805da0e1446041fd9def7468bb5da29c5ce02adf121a3f7cec123664308059a62a46fcaee666add83094b76df6dce72e5cafa8e6bebe60d
+ languageName: node
+ linkType: hard
+
+"@bundled-es-modules/tough-cookie@npm:^0.1.6":
+ version: 0.1.6
+ resolution: "@bundled-es-modules/tough-cookie@npm:0.1.6"
+ dependencies:
+ "@types/tough-cookie": "npm:^4.0.5"
+ tough-cookie: "npm:^4.1.4"
+ checksum: 10/4f24a820f02c08c3ca0ff21272317357152093f76f9c8cc182517f61fa426ae53dadc4d68a3d6da5078e8d73f0ff8c0907a9f994c0be756162ba9c7358533e57
+ languageName: node
+ linkType: hard
+
"@chromatic-com/storybook@npm:^1.3.1":
version: 1.9.0
resolution: "@chromatic-com/storybook@npm:1.9.0"
@@ -4800,6 +4828,51 @@ __metadata:
languageName: node
linkType: hard
+"@inquirer/confirm@npm:^5.0.0":
+ version: 5.1.1
+ resolution: "@inquirer/confirm@npm:5.1.1"
+ dependencies:
+ "@inquirer/core": "npm:^10.1.2"
+ "@inquirer/type": "npm:^3.0.2"
+ peerDependencies:
+ "@types/node": ">=18"
+ checksum: 10/060acc8b82835efb8950853b4cd226cac2e06c2b2c1a69bcc6e044cfaedd89b2df3d8bbf9ddf31b47cba3dafd8ca6c6e2c4be3f2ea413fad7250aafeab52f1e1
+ languageName: node
+ linkType: hard
+
+"@inquirer/core@npm:^10.1.2":
+ version: 10.1.2
+ resolution: "@inquirer/core@npm:10.1.2"
+ dependencies:
+ "@inquirer/figures": "npm:^1.0.9"
+ "@inquirer/type": "npm:^3.0.2"
+ ansi-escapes: "npm:^4.3.2"
+ cli-width: "npm:^4.1.0"
+ mute-stream: "npm:^2.0.0"
+ signal-exit: "npm:^4.1.0"
+ strip-ansi: "npm:^6.0.1"
+ wrap-ansi: "npm:^6.2.0"
+ yoctocolors-cjs: "npm:^2.1.2"
+ checksum: 10/e92ade5ba7dbcfd83629db2df7fb91877ac777a7f1e03a16b0d5c08621dafe09d321c5f14b37c2dca80a3db2d68e5a478f8eaeafcb62ed42c46e7349b7276094
+ languageName: node
+ linkType: hard
+
+"@inquirer/figures@npm:^1.0.9":
+ version: 1.0.9
+ resolution: "@inquirer/figures@npm:1.0.9"
+ checksum: 10/7ced1275a5826cdeb61797d6c068417e7d52aa87894de18cedd259f783f42d731226c3f8b92cab27b8e7b0e31ab1dd3cd77f16935b67ebe1cbb271e5972d7758
+ languageName: node
+ linkType: hard
+
+"@inquirer/type@npm:^3.0.2":
+ version: 3.0.2
+ resolution: "@inquirer/type@npm:3.0.2"
+ peerDependencies:
+ "@types/node": ">=18"
+ checksum: 10/d1a2879b1baa357421cef441fc7b43181e110243933763ae922c55c2fc9af2d459ceaca8b71ed57e3dabd5077542fa0dd1d0ff0cf362ce054e61202386b545ed
+ languageName: node
+ linkType: hard
+
"@inversifyjs/common@npm:1.4.0":
version: 1.4.0
resolution: "@inversifyjs/common@npm:1.4.0"
@@ -5659,6 +5732,20 @@ __metadata:
languageName: node
linkType: hard
+"@mswjs/interceptors@npm:^0.37.0":
+ version: 0.37.4
+ resolution: "@mswjs/interceptors@npm:0.37.4"
+ dependencies:
+ "@open-draft/deferred-promise": "npm:^2.2.0"
+ "@open-draft/logger": "npm:^0.3.0"
+ "@open-draft/until": "npm:^2.0.0"
+ is-node-process: "npm:^1.2.0"
+ outvariant: "npm:^1.4.3"
+ strict-event-emitter: "npm:^0.5.1"
+ checksum: 10/b6a4ab08a32b61034216ef434e4e97e77b8e8150ecb1d89f6bc71047aa6374e52606ff9758d8296ca4794da6158ebbeaf51a3d68b5581417d404587f210724c7
+ languageName: node
+ linkType: hard
+
"@mui/core-downloads-tracker@npm:^6.3.0":
version: 6.3.0
resolution: "@mui/core-downloads-tracker@npm:6.3.0"
@@ -6130,6 +6217,30 @@ __metadata:
languageName: node
linkType: hard
+"@open-draft/deferred-promise@npm:^2.2.0":
+ version: 2.2.0
+ resolution: "@open-draft/deferred-promise@npm:2.2.0"
+ checksum: 10/bc3bb1668a555bb87b33383cafcf207d9561e17d2ca0d9e61b7ce88e82b66e36a333d3676c1d39eb5848022c03c8145331fcdc828ba297f88cb1de9c5cef6c19
+ languageName: node
+ linkType: hard
+
+"@open-draft/logger@npm:^0.3.0":
+ version: 0.3.0
+ resolution: "@open-draft/logger@npm:0.3.0"
+ dependencies:
+ is-node-process: "npm:^1.2.0"
+ outvariant: "npm:^1.4.0"
+ checksum: 10/7a280f170bcd4e91d3eedbefe628efd10c3bd06dd2461d06a7fdbced89ef457a38785847f88cc630fb4fd7dfa176d6f77aed17e5a9b08000baff647433b5ff78
+ languageName: node
+ linkType: hard
+
+"@open-draft/until@npm:^2.0.0, @open-draft/until@npm:^2.1.0":
+ version: 2.1.0
+ resolution: "@open-draft/until@npm:2.1.0"
+ checksum: 10/622be42950afc8e89715d0fd6d56cbdcd13e36625e23b174bd3d9f06f80e25f9adf75d6698af93bca1e1bf465b9ce00ec05214a12189b671fb9da0f58215b6f4
+ languageName: node
+ linkType: hard
+
"@openzeppelin/contracts-upgradeable@npm:^5.0.0":
version: 5.1.0
resolution: "@openzeppelin/contracts-upgradeable@npm:5.1.0"
@@ -7360,6 +7471,7 @@ __metadata:
react-native-collapsible-tab-view: "npm:^8.0.0"
react-native-device-crypto: "npm:^0.1.7"
react-native-device-info: "npm:^14.0.1"
+ react-native-draggable-flatlist: "npm:^4.0.1"
react-native-gesture-handler: "npm:~2.20.2"
react-native-keychain: "npm:^9.2.2"
react-native-mmkv: "npm:^3.1.0"
@@ -7485,6 +7597,7 @@ __metadata:
dependencies:
husky: "npm:^9.1.6"
lint-staged: "npm:^15.2.10"
+ msw: "npm:^2.7.0"
prettier: "npm:^3.4.2"
languageName: unknown
linkType: soft
@@ -12302,7 +12415,14 @@ __metadata:
languageName: node
linkType: hard
-"@types/tough-cookie@npm:*":
+"@types/statuses@npm:^2.0.4":
+ version: 2.0.5
+ resolution: "@types/statuses@npm:2.0.5"
+ checksum: 10/3f2609f660b45a878c6782f2fb2cef9f08bbd4e89194bf7512e747b8a73b056839be1ad6f64b1353765528cd8a5e93adeffc471cde24d0d9f7b528264e7154e5
+ languageName: node
+ linkType: hard
+
+"@types/tough-cookie@npm:*, @types/tough-cookie@npm:^4.0.5":
version: 4.0.5
resolution: "@types/tough-cookie@npm:4.0.5"
checksum: 10/01fd82efc8202670865928629697b62fe9bf0c0dcbc5b1c115831caeb073a2c0abb871ff393d7df1ae94ea41e256cb87d2a5a91fd03cdb1b0b4384e08d4ee482
@@ -15664,6 +15784,13 @@ __metadata:
languageName: node
linkType: hard
+"cli-width@npm:^4.1.0":
+ version: 4.1.0
+ resolution: "cli-width@npm:4.1.0"
+ checksum: 10/b58876fbf0310a8a35c79b72ecfcf579b354e18ad04e6b20588724ea2b522799a758507a37dfe132fafaf93a9922cafd9514d9e1598e6b2cd46694853aed099f
+ languageName: node
+ linkType: hard
+
"client-only@npm:0.0.1, client-only@npm:^0.0.1":
version: 0.0.1
resolution: "client-only@npm:0.0.1"
@@ -16060,6 +16187,13 @@ __metadata:
languageName: node
linkType: hard
+"cookie@npm:^0.7.2":
+ version: 0.7.2
+ resolution: "cookie@npm:0.7.2"
+ checksum: 10/24b286c556420d4ba4e9bc09120c9d3db7d28ace2bd0f8ccee82422ce42322f73c8312441271e5eefafbead725980e5996cc02766dbb89a90ac7f5636ede608f
+ languageName: node
+ linkType: hard
+
"copy-anything@npm:^2.0.1":
version: 2.0.6
resolution: "copy-anything@npm:2.0.6"
@@ -20380,6 +20514,13 @@ __metadata:
languageName: node
linkType: hard
+"graphql@npm:^16.8.1":
+ version: 16.10.0
+ resolution: "graphql@npm:16.10.0"
+ checksum: 10/d42cf81ddcf3a61dfb213217576bf33c326f15b02c4cee369b373dc74100cbdcdc4479b3b797e79b654dabd8fddf50ef65ff75420e9ce5596c02e21f24c9126a
+ languageName: node
+ linkType: hard
+
"gray-matter@npm:^4.0.3":
version: 4.0.3
resolution: "gray-matter@npm:4.0.3"
@@ -20615,6 +20756,13 @@ __metadata:
languageName: node
linkType: hard
+"headers-polyfill@npm:^4.0.2":
+ version: 4.0.3
+ resolution: "headers-polyfill@npm:4.0.3"
+ checksum: 10/3a008aa2ef71591e2077706efb48db1b2729b90cf646cc217f9b69744e35cca4ba463f39debb6000904aa7de4fada2e5cc682463025d26bcc469c1d99fa5af27
+ languageName: node
+ linkType: hard
+
"hermes-estree@npm:0.23.1":
version: 0.23.1
resolution: "hermes-estree@npm:0.23.1"
@@ -21626,6 +21774,13 @@ __metadata:
languageName: node
linkType: hard
+"is-node-process@npm:^1.2.0":
+ version: 1.2.0
+ resolution: "is-node-process@npm:1.2.0"
+ checksum: 10/930765cdc6d81ab8f1bbecbea4a8d35c7c6d88a3ff61f3630e0fc7f22d624d7661c1df05c58547d0eb6a639dfa9304682c8e342c4113a6ed51472b704cee2928
+ languageName: node
+ linkType: hard
+
"is-number-object@npm:^1.1.1":
version: 1.1.1
resolution: "is-number-object@npm:1.1.1"
@@ -25255,6 +25410,39 @@ __metadata:
languageName: node
linkType: hard
+"msw@npm:^2.7.0":
+ version: 2.7.0
+ resolution: "msw@npm:2.7.0"
+ dependencies:
+ "@bundled-es-modules/cookie": "npm:^2.0.1"
+ "@bundled-es-modules/statuses": "npm:^1.0.1"
+ "@bundled-es-modules/tough-cookie": "npm:^0.1.6"
+ "@inquirer/confirm": "npm:^5.0.0"
+ "@mswjs/interceptors": "npm:^0.37.0"
+ "@open-draft/deferred-promise": "npm:^2.2.0"
+ "@open-draft/until": "npm:^2.1.0"
+ "@types/cookie": "npm:^0.6.0"
+ "@types/statuses": "npm:^2.0.4"
+ graphql: "npm:^16.8.1"
+ headers-polyfill: "npm:^4.0.2"
+ is-node-process: "npm:^1.2.0"
+ outvariant: "npm:^1.4.3"
+ path-to-regexp: "npm:^6.3.0"
+ picocolors: "npm:^1.1.1"
+ strict-event-emitter: "npm:^0.5.1"
+ type-fest: "npm:^4.26.1"
+ yargs: "npm:^17.7.2"
+ peerDependencies:
+ typescript: ">= 4.8.x"
+ peerDependenciesMeta:
+ typescript:
+ optional: true
+ bin:
+ msw: cli/index.js
+ checksum: 10/165ccf37d90da0d5271fdb8e01f89f48f7a60fb810038ff73d18c0e5e5ddfdb1266002d19cde61b9ae689ef37c39499b10d9d07e0d16662a31630ce9adce1d77
+ languageName: node
+ linkType: hard
+
"multibase@npm:^4.0.1":
version: 4.0.6
resolution: "multibase@npm:4.0.6"
@@ -25323,6 +25511,13 @@ __metadata:
languageName: node
linkType: hard
+"mute-stream@npm:^2.0.0":
+ version: 2.0.0
+ resolution: "mute-stream@npm:2.0.0"
+ checksum: 10/d2e4fd2f5aa342b89b98134a8d899d8ef9b0a6d69274c4af9df46faa2d97aeb1f2ce83d867880d6de63643c52386579b99139801e24e7526c3b9b0a6d1e18d6c
+ languageName: node
+ linkType: hard
+
"mz@npm:^2.7.0":
version: 2.7.0
resolution: "mz@npm:2.7.0"
@@ -26209,6 +26404,13 @@ __metadata:
languageName: node
linkType: hard
+"outvariant@npm:^1.4.0, outvariant@npm:^1.4.3":
+ version: 1.4.3
+ resolution: "outvariant@npm:1.4.3"
+ checksum: 10/3a7582745850cb344d49641867a4c080858c54f4091afd91b9c0765ba6e471c2bc841348f0fff344845ddd0a4db42fd5d68c6f7ebaf32d4b676a3a9987b2488a
+ languageName: node
+ linkType: hard
+
"ox@npm:0.1.2":
version: 0.1.2
resolution: "ox@npm:0.1.2"
@@ -26547,6 +26749,13 @@ __metadata:
languageName: node
linkType: hard
+"path-to-regexp@npm:^6.3.0":
+ version: 6.3.0
+ resolution: "path-to-regexp@npm:6.3.0"
+ checksum: 10/6822f686f01556d99538b350722ef761541ec0ce95ca40ce4c29e20a5b492fe8361961f57993c71b2418de12e604478dcf7c430de34b2c31a688363a7a944d9c
+ languageName: node
+ linkType: hard
+
"path-type@npm:^4.0.0":
version: 4.0.0
resolution: "path-type@npm:4.0.0"
@@ -27647,6 +27856,19 @@ __metadata:
languageName: node
linkType: hard
+"react-native-draggable-flatlist@npm:^4.0.1":
+ version: 4.0.1
+ resolution: "react-native-draggable-flatlist@npm:4.0.1"
+ dependencies:
+ "@babel/preset-typescript": "npm:^7.17.12"
+ peerDependencies:
+ react-native: ">=0.64.0"
+ react-native-gesture-handler: ">=2.0.0"
+ react-native-reanimated: ">=2.8.0"
+ checksum: 10/4d9c8bfef6185ab51ebef8d00a0a06f8fd57c96a5a9e60cb6e7cd56ec6222d37643c53fc998cc93efcb8be88d32c2e5e43788f1816d8cded4eb2b60076001749
+ languageName: node
+ linkType: hard
+
"react-native-gesture-handler@npm:~2.20.2":
version: 2.20.2
resolution: "react-native-gesture-handler@npm:2.20.2"
@@ -30184,7 +30406,7 @@ __metadata:
languageName: node
linkType: hard
-"statuses@npm:2.0.1":
+"statuses@npm:2.0.1, statuses@npm:^2.0.1":
version: 2.0.1
resolution: "statuses@npm:2.0.1"
checksum: 10/18c7623fdb8f646fb213ca4051be4df7efb3484d4ab662937ca6fbef7ced9b9e12842709872eb3020cc3504b93bde88935c9f6417489627a7786f24f8031cbcb
@@ -30280,6 +30502,13 @@ __metadata:
languageName: node
linkType: hard
+"strict-event-emitter@npm:^0.5.1":
+ version: 0.5.1
+ resolution: "strict-event-emitter@npm:0.5.1"
+ checksum: 10/25c84d88be85940d3547db665b871bfecea4ea0bedfeb22aae8db48126820cfb2b0bc2fba695392592a09b1aa36b686d6eede499e1ecd151593c03fe5a50d512
+ languageName: node
+ linkType: hard
+
"strict-uri-encode@npm:^2.0.0":
version: 2.0.0
resolution: "strict-uri-encode@npm:2.0.0"
@@ -31341,7 +31570,7 @@ __metadata:
languageName: node
linkType: hard
-"tough-cookie@npm:^4.1.2":
+"tough-cookie@npm:^4.1.2, tough-cookie@npm:^4.1.4":
version: 4.1.4
resolution: "tough-cookie@npm:4.1.4"
dependencies:
@@ -31778,6 +32007,13 @@ __metadata:
languageName: node
linkType: hard
+"type-fest@npm:^4.26.1":
+ version: 4.31.0
+ resolution: "type-fest@npm:4.31.0"
+ checksum: 10/e7e849845bf33e1237c3ff0d5ed00a251a807e3321ffe75278dd56a7d3c385badfe09180057c2d0b93cf7429432b8e7061b6ccf4cc468720d8f69073d2b1bed2
+ languageName: node
+ linkType: hard
+
"type-fest@npm:^4.30.0":
version: 4.30.0
resolution: "type-fest@npm:4.30.0"
@@ -33880,6 +34116,13 @@ __metadata:
languageName: node
linkType: hard
+"yoctocolors-cjs@npm:^2.1.2":
+ version: 2.1.2
+ resolution: "yoctocolors-cjs@npm:2.1.2"
+ checksum: 10/d731e3ba776a0ee19021d909787942933a6c2eafb2bbe85541f0c59aa5c7d475ce86fcb860d5803105e32244c3dd5ba875b87c4c6bf2d6f297da416aa54e556f
+ languageName: node
+ linkType: hard
+
"zodiac-roles-deployments@npm:^2.3.4":
version: 2.3.4
resolution: "zodiac-roles-deployments@npm:2.3.4"