diff --git a/apps/mobile/src/components/SearchableList/SearchableList.tsx b/apps/mobile/src/components/SearchableList/SearchableList.tsx new file mode 100644 index 000000000..712668086 --- /dev/null +++ b/apps/mobile/src/components/SearchableList/SearchableList.tsx @@ -0,0 +1,182 @@ +/* + Copyright 2022-2025 Pera Wallet, LDA + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License + */ + +import React, { createElement, forwardRef, useCallback, useMemo } from 'react' +import type { LegendListRenderItemProps } from '@legendapp/list' + +import { PWFlatList, PWView } from '@components/core' +import type { PWFlatListProps, PWFlatListRef } from '@components/core' +import { SearchInput } from '@components/SearchInput' +import { + isSearchSentinel, + useSearchableList, + type AugmentedItem, +} from './useSearchableList' +import { DEFAULT_SNAP_THRESHOLD, SCROLL_EVENT_THROTTLE } from '@constants/ui' +import { Maybe } from '@perawallet/wallet-core-shared' + +type RenderItem = (props: LegendListRenderItemProps) => React.ReactNode + +const renderHeaderNode = ( + component: Maybe, +): React.ReactNode => { + if (component == null) { + return null + } + if (typeof component === 'function') { + const Component = component + return + } + return component +} + +export type SearchableListProps = Omit< + PWFlatListProps, + 'stickyIndices' | 'renderItem' +> & { + renderItem?: RenderItem + searchValue?: string + searchPlaceholder?: string + onSearchChange?: (value: string) => void + /** + * Fraction of the header (`ListHeaderComponent`) revealed during a drag + * required to snap to fully expanded; otherwise snap to fully collapsed + * (search bar pinned). Defaults to 0.25. + */ + snapThreshold?: number +} + +const SearchableListInner = ( + props: SearchableListProps, + ref: React.ForwardedRef, +) => { + const { + ListHeaderComponent, + ListFooterComponent, + data, + renderItem, + keyExtractor, + searchValue, + searchPlaceholder, + onSearchChange, + snapThreshold = DEFAULT_SNAP_THRESHOLD, + onScroll, + onScrollEndDrag, + // children is part of the React props type but not used by the list. + children: _children, + ...listProps + } = props + + const { + listRef, + augmentedData, + augmentedKeyExtractor, + toUserIndex, + searchFooterHeight, + handleHeaderLayout, + handleListLayout, + handleContentSizeChange, + handleSearchFocus, + handleScroll, + handleScrollEndDrag, + } = useSearchableList({ + forwardedRef: ref, + data, + keyExtractor, + snapThreshold, + itemHeightEstimate: listProps.estimatedItemSize, + onScroll, + onScrollEndDrag, + }) + + const augmentedHeader = useMemo( + () => ( + + {renderHeaderNode(ListHeaderComponent)} + + ), + [ListHeaderComponent, handleHeaderLayout], + ) + + const augmentedFooter = useMemo(() => { + const callerFooter = renderHeaderNode(ListFooterComponent) + if (searchFooterHeight <= 0 && callerFooter == null) { + return null + } + return ( + <> + {callerFooter} + {searchFooterHeight > 0 && ( + + )} + + ) + }, [ListFooterComponent, searchFooterHeight]) + + const augmentedRenderItem = useCallback>>( + info => { + if (isSearchSentinel(info.item)) { + return ( + + ) + } + return ( + renderItem?.({ + ...info, + item: info.item, + index: toUserIndex(info.index), + data: (info.data?.slice(1) ?? []) as readonly T[], + }) ?? null + ) + }, + [ + renderItem, + searchValue, + searchPlaceholder, + onSearchChange, + handleSearchFocus, + toUserIndex, + ], + ) + + // LegendList's data-mode prop type uses `children: never`, while React's + // intrinsic component types always add `children?: ReactNode` — so any + // structural cast at this boundary fails. The single `any` here is + // strictly to bridge that mismatch; everything we *write* (data, + // renderItem, keyExtractor, etc.) is properly typed above. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return createElement(PWFlatList as any, { + ...listProps, + ref: listRef, + data: augmentedData, + renderItem: augmentedRenderItem, + keyExtractor: augmentedKeyExtractor, + ListHeaderComponent: augmentedHeader, + ListFooterComponent: augmentedFooter, + stickyIndices: [0], + maintainVisibleContentPosition: true, + onLayout: handleListLayout, + onContentSizeChange: handleContentSizeChange, + onScroll: handleScroll, + onScrollEndDrag: handleScrollEndDrag, + scrollEventThrottle: SCROLL_EVENT_THROTTLE, + }) +} + +export const SearchableList = forwardRef(SearchableListInner) as ( + props: SearchableListProps & React.RefAttributes, +) => React.ReactElement diff --git a/apps/mobile/src/components/SearchableList/index.ts b/apps/mobile/src/components/SearchableList/index.ts new file mode 100644 index 000000000..ce1366bfb --- /dev/null +++ b/apps/mobile/src/components/SearchableList/index.ts @@ -0,0 +1,14 @@ +/* + Copyright 2022-2025 Pera Wallet, LDA + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License + */ + +export { SearchableList } from './SearchableList' +export type { SearchableListProps } from './SearchableList' diff --git a/apps/mobile/src/components/SearchableList/useSearchableList.ts b/apps/mobile/src/components/SearchableList/useSearchableList.ts new file mode 100644 index 000000000..a20aa0dbd --- /dev/null +++ b/apps/mobile/src/components/SearchableList/useSearchableList.ts @@ -0,0 +1,252 @@ +/* + Copyright 2022-2025 Pera Wallet, LDA + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License + */ + +import { + useCallback, + useImperativeHandle, + useLayoutEffect, + useMemo, + useRef, + useState, +} from 'react' +import { + type LayoutChangeEvent, + type NativeScrollEvent, + type NativeSyntheticEvent, +} from 'react-native' + +import type { PWFlatListRef } from '@components/core' +import { Nullable } from '@perawallet/wallet-core-shared' + +const SEARCH_SENTINEL = Symbol('SearchableList.search') +const SEARCH_KEY = '__searchable_list_search__' +const DEFAULT_ITEM_HEIGHT_ESTIMATE = 56 + +export type SearchSentinel = typeof SEARCH_SENTINEL + +export type AugmentedItem = T | SearchSentinel + +export const isSearchSentinel = (item: unknown): item is SearchSentinel => + item === SEARCH_SENTINEL + +type UseSearchableListParams = { + forwardedRef: React.ForwardedRef + data: readonly T[] | null | undefined + keyExtractor?: (item: T, index: number) => string + snapThreshold: number + itemHeightEstimate?: number + onScroll?: (event: NativeSyntheticEvent) => void + onScrollEndDrag?: (event: NativeSyntheticEvent) => void +} + +type UseSearchableListResult = { + listRef: React.RefObject> + augmentedData: AugmentedItem[] + augmentedKeyExtractor: (item: AugmentedItem, index: number) => string + toUserIndex: (index: number) => number + /** + * Pixels of empty space the caller should append after the list items so + * the search bar can always pin to the top, even when items don't fill + * the viewport. 0 when the list already has enough scrollable content. + */ + searchFooterHeight: number + handleHeaderLayout: (event: LayoutChangeEvent) => void + handleListLayout: (event: LayoutChangeEvent) => void + handleContentSizeChange: (width: number, height: number) => void + handleSearchFocus: () => void + handleScroll: (event: NativeSyntheticEvent) => void + handleScrollEndDrag: ( + event: NativeSyntheticEvent, + ) => void +} + +export const useSearchableList = ({ + forwardedRef, + data, + keyExtractor, + snapThreshold, + itemHeightEstimate = DEFAULT_ITEM_HEIGHT_ESTIMATE, + onScroll, + onScrollEndDrag, +}: UseSearchableListParams): UseSearchableListResult => { + const listRef = useRef(null) + const [headerHeight, setHeaderHeight] = useState(0) + const [listLayoutHeight, setListLayoutHeight] = useState(0) + // Latest measured contentSize minus the spacer footer we set last — + // i.e. the natural (item-driven) content height. + const [naturalContentSize, setNaturalContentSize] = useState(0) + const searchFooterHeightRef = useRef(0) + // The sticky search bar's onFocus closure can outlive a re-render, so + // event handlers read these refs instead of state to always see the + // freshest measurement. + const headerHeightRef = useRef(0) + const listLayoutHeightRef = useRef(0) + // Tracks the itemCount from the previous render. When the new count is + // lower we know data just shrunk and can pre-emptively grow the footer + // synchronously — before the native list sees the smaller contentSize + // and clamps scroll, which would otherwise expose the header. + const previousItemCountRef = useRef(0) + // Latched once the header has been fully hidden (offset >= headerHeight). + // Snap logic only kicks in once latched — while the header is still + // expanded or partially expanded, the user scrolls freely. + const isCollapsedRef = useRef(false) + + useImperativeHandle(forwardedRef, () => ({ + scrollToOffset: params => listRef.current?.scrollToOffset(params), + scrollToIndex: params => listRef.current?.scrollToIndex(params), + scrollToEnd: options => listRef.current?.scrollToEnd(options), + })) + + const itemCount = data?.length ?? 0 + + const searchFooterHeight = useMemo(() => { + if (listLayoutHeight <= 0) { + return 0 + } + // Use the natural size is fwe can or estimate it + let natural = + naturalContentSize > 0 + ? naturalContentSize + : headerHeight + itemCount * itemHeightEstimate + + const previousItemCount = previousItemCountRef.current + if (itemCount < previousItemCount) { + const expectedLoss = + (previousItemCount - itemCount) * itemHeightEstimate + natural = Math.max(0, natural - expectedLoss) + } + return Math.max(0, listLayoutHeight + headerHeight - natural) + }, [ + listLayoutHeight, + headerHeight, + naturalContentSize, + itemCount, + itemHeightEstimate, + ]) + + // Mirror searchFooterHeight into a ref so handleContentSizeChange can + // recover the natural size without depending on render closures. + useLayoutEffect(() => { + searchFooterHeightRef.current = searchFooterHeight + previousItemCountRef.current = itemCount + }, [searchFooterHeight, itemCount]) + + const handleHeaderLayout = useCallback((event: LayoutChangeEvent) => { + const height = event.nativeEvent.layout.height + headerHeightRef.current = height + setHeaderHeight(height) + }, []) + + const handleListLayout = useCallback((event: LayoutChangeEvent) => { + const height = event.nativeEvent.layout.height + listLayoutHeightRef.current = height + setListLayoutHeight(height) + }, []) + + const handleContentSizeChange = useCallback( + (_width: number, height: number) => { + const natural = Math.max(0, height - searchFooterHeightRef.current) + setNaturalContentSize(prev => (prev === natural ? prev : natural)) + // Backstop: if a transient contentSize drop pushed scroll below + // the pin offset while collapsed, snap back synchronously so the + // user never sees the header peek. + if (isCollapsedRef.current && headerHeightRef.current > 0) { + listRef.current?.scrollToOffset({ + offset: headerHeightRef.current, + animated: false, + }) + } + }, + [], + ) + + const handleSearchFocus = useCallback(() => { + isCollapsedRef.current = true + listRef.current?.scrollToOffset({ + offset: headerHeightRef.current, + animated: true, + }) + }, []) + + const handleScroll = useCallback( + (event: NativeSyntheticEvent) => { + onScroll?.(event) + const headerH = headerHeightRef.current + if (headerH > 0 && event.nativeEvent.contentOffset.y >= headerH) { + isCollapsedRef.current = true + } + }, + [onScroll], + ) + + const handleScrollEndDrag = useCallback( + (event: NativeSyntheticEvent) => { + onScrollEndDrag?.(event) + if (!isCollapsedRef.current) { + return + } + const headerH = headerHeightRef.current + if (headerH <= 0) { + return + } + const offset = event.nativeEvent.contentOffset.y + if (offset <= 0 || offset >= headerH) { + return + } + const revealedFraction = (headerH - offset) / headerH + if (revealedFraction > snapThreshold) { + isCollapsedRef.current = false + listRef.current?.scrollToOffset({ + offset: 0, + animated: true, + }) + } else { + listRef.current?.scrollToOffset({ + offset: headerH, + animated: true, + }) + } + }, + [onScrollEndDrag, snapThreshold], + ) + + const augmentedData = useMemo[]>( + () => [SEARCH_SENTINEL, ...(data ?? [])], + [data], + ) + + const toUserIndex = useCallback((index: number) => index - 1, []) + + const augmentedKeyExtractor = useCallback( + (item: AugmentedItem, index: number): string => { + if (item === SEARCH_SENTINEL) { + return SEARCH_KEY + } + return keyExtractor?.(item as T, index - 1) ?? String(index - 1) + }, + [keyExtractor], + ) + + return { + listRef, + augmentedData, + augmentedKeyExtractor, + toUserIndex, + searchFooterHeight, + handleHeaderLayout, + handleListLayout, + handleContentSizeChange, + handleSearchFocus, + handleScroll, + handleScrollEndDrag, + } +} diff --git a/apps/mobile/src/constants/ui.ts b/apps/mobile/src/constants/ui.ts index 7c839058b..462248241 100644 --- a/apps/mobile/src/constants/ui.ts +++ b/apps/mobile/src/constants/ui.ts @@ -53,3 +53,6 @@ export const SEARCH_DEBOUNCE_TIME_SHORT = 75 export const ASSET_LIST_ITEM_MIN_HEIGHT = 64 export const NFT_NOT_OPTED_IN_OPACITY = 0.5 + +export const SCROLL_EVENT_THROTTLE = 16 +export const DEFAULT_SNAP_THRESHOLD = 0.25 diff --git a/apps/mobile/src/modules/accounts/components/AccountAssetList/AccountAssetList.tsx b/apps/mobile/src/modules/accounts/components/AccountAssetList/AccountAssetList.tsx index 92ed5846c..a99564246 100644 --- a/apps/mobile/src/modules/accounts/components/AccountAssetList/AccountAssetList.tsx +++ b/apps/mobile/src/modules/accounts/components/AccountAssetList/AccountAssetList.tsx @@ -10,18 +10,11 @@ limitations under the License */ -import { - PWButton, - PWFlatList, - PWText, - PWTouchableOpacity, - PWView, -} from '@components/core' +import { PWButton, PWText, PWView } from '@components/core' import type { PWFlatListRef } from '@components/core' import React, { useCallback, useEffect, useRef } from 'react' import { useStyles } from './styles' -import { SearchInput, type SearchInputRef } from '@components/SearchInput' import { WalletAccount, AssetWithAccountBalance, @@ -30,6 +23,7 @@ import { ALGO_ASSET_ID } from '@perawallet/wallet-core-assets' import { EmptyView } from '@components/EmptyView' import { LoadingView } from '@components/LoadingView' +import { SearchableList } from '@components/SearchableList' import { useLanguage } from '@hooks/useLanguage' import { KeyboardAvoidingView } from 'react-native' import { ManageAssetsBottomSheet } from '../ManageAssetsBottomSheet' @@ -54,7 +48,6 @@ export const AccountAssetList = ({ header, }: AccountAssetListProps) => { const listRef = useRef(null) - const searchInputRef = useRef(null) const styles = useStyles() const { t } = useLanguage() @@ -63,7 +56,6 @@ export const AccountAssetList = ({ isPending, isWatch, assetSortMode, - headerState, manageSheetState, sortSheetState, filterSheetState, @@ -83,20 +75,15 @@ export const AccountAssetList = ({ } = useAccountAssetList({ account, t }) useEffect(() => { - listRef.current?.scrollToOffset({ offset: 0, animated: false }) + // Defer scrolling so it runs after FlashList re-renders with the new + // sorted/account data; scrolling synchronously while the list is + // recycling cells preserves the previous offset. + const handle = requestAnimationFrame(() => { + listRef.current?.scrollToOffset({ offset: 0, animated: true }) + }) + return () => cancelAnimationFrame(handle) }, [account.address, assetSortMode]) - useEffect(() => { - if (headerState.isOpen) { - searchInputRef.current?.blur() - } - }, [headerState.isOpen]) - - const handleSearchFocus = useCallback(() => { - listRef.current?.scrollToOffset({ offset: 0, animated: false }) - headerState.close() - }, [headerState]) - const renderItem = useCallback( ({ item }: { item: AssetWithAccountBalance }) => { const isSwipeable = @@ -119,6 +106,38 @@ export const AccountAssetList = ({ [renderItemProps], ) + const listHeader = ( + + + {header} + + + {t('account_details.assets.title')} + + {!isWatch && ( + + + + + )} + + + ) + return ( - - item.assetId} - estimatedItemSize={72} - recycleItems - automaticallyAdjustKeyboardInsets - keyboardDismissMode='interactive' - contentContainerStyle={styles.rootContainer} - ListHeaderComponent={ - - - {headerState.isOpen && ( - <> - {header} - - - {t('account_details.assets.title')} - - {!isWatch && ( - - - - - )} - - - )} - - - } - ListEmptyComponent={ - isPending ? ( - - ) : ( - - ) - } - /> - + item.assetId} + estimatedItemSize={72} + recycleItems + automaticallyAdjustKeyboardInsets + keyboardDismissMode='interactive' + contentContainerStyle={styles.rootContainer} + ListHeaderComponent={listHeader} + searchPlaceholder={t( + 'account_details.assets.search_placeholder', + )} + onSearchChange={setSearchFilter} + ListEmptyComponent={ + isPending ? ( + + ) : ( + + ) + } + /> ({ - headerContainer: { - marginTop: theme.spacing.sm, - marginBottom: theme.spacing.md, - }, keyboardAvoidingViewContainer: { flexGrow: 1, backgroundColor: theme.colors.background, @@ -27,6 +23,10 @@ export const useStyles = makeStyles(theme => ({ flexGrow: 1, paddingHorizontal: theme.spacing.md, }, + headerContainer: { + marginTop: theme.spacing.sm, + marginBottom: theme.spacing.md, + }, loadingContainer: { gap: theme.spacing.md, }, diff --git a/apps/mobile/src/modules/accounts/components/AccountAssetList/useAccountAssetList.ts b/apps/mobile/src/modules/accounts/components/AccountAssetList/useAccountAssetList.ts index 4b210fcb4..abd6f6f83 100644 --- a/apps/mobile/src/modules/accounts/components/AccountAssetList/useAccountAssetList.ts +++ b/apps/mobile/src/modules/accounts/components/AccountAssetList/useAccountAssetList.ts @@ -45,7 +45,6 @@ type UseAccountAssetListResult = { hideZeroBalance: boolean assetSortMode: AssetSortMode searchFilter: string - headerState: ModalState manageSheetState: ModalState sortSheetState: ModalState filterSheetState: ModalState @@ -80,7 +79,6 @@ export const useAccountAssetList = ({ account, t, }: UseAccountAssetListParams): UseAccountAssetListResult => { - const headerState = useModalState(true) const manageSheetState = useModalState(false) const sortSheetState = useModalState(false) const filterSheetState = useModalState(false) @@ -163,7 +161,6 @@ export const useAccountAssetList = ({ const goToAssetScreen = useCallback( (item: AssetWithAccountBalance) => { - headerState.open() const assetInfo = assets?.get(item.assetId) if (assetInfo && isCollectible(assetInfo)) { navigation.navigate('CollectibleDetails', { @@ -175,7 +172,7 @@ export const useAccountAssetList = ({ }) } }, - [headerState, navigation, assets], + [navigation, assets], ) const handleOptOut = useCallback( @@ -282,7 +279,6 @@ export const useAccountAssetList = ({ hideZeroBalance, assetSortMode, searchFilter, - headerState, manageSheetState, sortSheetState, filterSheetState, diff --git a/apps/mobile/src/modules/accounts/screens/RemoveAssetsScreen/RemoveAssetsScreen.tsx b/apps/mobile/src/modules/accounts/screens/RemoveAssetsScreen/RemoveAssetsScreen.tsx index 19b32fd94..b7c96e80c 100644 --- a/apps/mobile/src/modules/accounts/screens/RemoveAssetsScreen/RemoveAssetsScreen.tsx +++ b/apps/mobile/src/modules/accounts/screens/RemoveAssetsScreen/RemoveAssetsScreen.tsx @@ -10,7 +10,7 @@ limitations under the License */ -import React, { useCallback } from 'react' +import React, { useCallback, useRef } from 'react' import { PWButton, PWCheckbox, @@ -19,6 +19,7 @@ import { PWTouchableOpacity, PWView, } from '@components/core' +import type { PWFlatListRef } from '@components/core' import { AccountAssetItemView } from '@modules/assets/components/AssetItem/AccountAssetItemView' import { AssetWithAccountBalance } from '@perawallet/wallet-core-accounts' import { EmptyView } from '@components/EmptyView' @@ -28,6 +29,17 @@ import { useStyles } from './styles' export const RemoveAssetsScreen = () => { const styles = useStyles() + const listRef = useRef(null) + + const handleAfterRemove = useCallback(() => { + // Defer the scroll so it runs after FlashList re-renders with the + // shrunken dataset; scrolling synchronously while cells are being + // recycled produces a jittery animation. + requestAnimationFrame(() => { + listRef.current?.scrollToOffset({ offset: 0, animated: true }) + }) + }, []) + const { removableAssets, selectedAssetIds, @@ -37,7 +49,7 @@ export const RemoveAssetsScreen = () => { handleToggleSelectAll, handleRemoveSelected, t, - } = useRemoveAssetsScreen() + } = useRemoveAssetsScreen({ onAfterRemove: handleAfterRemove }) useNavigationHeader({ right: ( @@ -78,6 +90,7 @@ export const RemoveAssetsScreen = () => { return ( item.assetId} diff --git a/apps/mobile/src/modules/accounts/screens/RemoveAssetsScreen/__tests__/useRemoveAssetsScreen.spec.ts b/apps/mobile/src/modules/accounts/screens/RemoveAssetsScreen/__tests__/useRemoveAssetsScreen.spec.ts index 4fad7189e..4f58cadef 100644 --- a/apps/mobile/src/modules/accounts/screens/RemoveAssetsScreen/__tests__/useRemoveAssetsScreen.spec.ts +++ b/apps/mobile/src/modules/accounts/screens/RemoveAssetsScreen/__tests__/useRemoveAssetsScreen.spec.ts @@ -79,6 +79,22 @@ vi.mock('@hooks/useLanguage', () => ({ useLanguage: () => ({ t: (k: string) => k }), })) +const { mockShowToast, mockGetMessage } = vi.hoisted(() => ({ + mockShowToast: vi.fn(), + mockGetMessage: vi.fn((err: unknown) => ({ + title: '', + body: err instanceof Error ? err.message : String(err), + })), +})) + +vi.mock('@hooks/useToast', () => ({ + useToast: () => ({ showToast: mockShowToast }), +})) + +vi.mock('@hooks/useAlgodErrorMessage', () => ({ + useAlgodErrorMessage: () => ({ getMessage: mockGetMessage }), +})) + describe('useRemoveAssetsScreen', () => { beforeEach(() => { vi.clearAllMocks() @@ -267,10 +283,36 @@ describe('useRemoveAssetsScreen', () => { expect(result.current.removableAssets[0].assetId).toBe('456') }) - it('sets removeError when optOut rejects', async () => { + it('shows success toast and invokes onAfterRemove after successful opt-out', async () => { + const onAfterRemove = vi.fn() + const { result } = renderHook(() => + useRemoveAssetsScreen({ onAfterRemove }), + ) + + act(() => { + result.current.handleToggleSelect('123') + }) + + await act(async () => { + await result.current.handleRemoveSelected() + }) + + expect(mockShowToast).toHaveBeenCalledWith( + expect.objectContaining({ + title: 'asset_opt_out.success', + type: 'success', + }), + ) + expect(onAfterRemove).toHaveBeenCalledTimes(1) + }) + + it('shows error toast and skips onAfterRemove when optOut rejects', async () => { mockOptOut.mockRejectedValueOnce(new Error('Rate limited')) + const onAfterRemove = vi.fn() - const { result } = renderHook(() => useRemoveAssetsScreen()) + const { result } = renderHook(() => + useRemoveAssetsScreen({ onAfterRemove }), + ) act(() => { result.current.handleToggleSelect('123') @@ -280,6 +322,13 @@ describe('useRemoveAssetsScreen', () => { await result.current.handleRemoveSelected() }) - expect(result.current.removeError?.message).toBe('Rate limited') + expect(mockShowToast).toHaveBeenCalledWith( + expect.objectContaining({ + title: 'asset_opt_out.error', + body: 'Rate limited', + type: 'error', + }), + ) + expect(onAfterRemove).not.toHaveBeenCalled() }) }) diff --git a/apps/mobile/src/modules/accounts/screens/RemoveAssetsScreen/styles.ts b/apps/mobile/src/modules/accounts/screens/RemoveAssetsScreen/styles.ts index 5c789be05..56ae427d2 100644 --- a/apps/mobile/src/modules/accounts/screens/RemoveAssetsScreen/styles.ts +++ b/apps/mobile/src/modules/accounts/screens/RemoveAssetsScreen/styles.ts @@ -18,6 +18,7 @@ export const useStyles = makeStyles(theme => ({ backgroundColor: theme.colors.background, }, listContent: { + flexGrow: 1, paddingHorizontal: theme.spacing.md, }, itemContainer: { diff --git a/apps/mobile/src/modules/accounts/screens/RemoveAssetsScreen/useRemoveAssetsScreen.ts b/apps/mobile/src/modules/accounts/screens/RemoveAssetsScreen/useRemoveAssetsScreen.ts index d4eece0f4..a8abc2ff9 100644 --- a/apps/mobile/src/modules/accounts/screens/RemoveAssetsScreen/useRemoveAssetsScreen.ts +++ b/apps/mobile/src/modules/accounts/screens/RemoveAssetsScreen/useRemoveAssetsScreen.ts @@ -22,8 +22,13 @@ import { PeraAsset, } from '@perawallet/wallet-core-assets' import { useAssetOptOutMutation } from '@perawallet/wallet-core-transactions' +import { useAlgodErrorMessage } from '@hooks/useAlgodErrorMessage' import { useLanguage } from '@hooks/useLanguage' -import type { Nullable } from '@perawallet/wallet-core-shared' +import { useToast } from '@hooks/useToast' + +type UseRemoveAssetsScreenProps = { + onAfterRemove?: () => void +} type UseRemoveAssetsScreenResult = { removableAssets: AssetWithAccountBalance[] @@ -31,19 +36,21 @@ type UseRemoveAssetsScreenResult = { selectedAssetIds: Set isAllSelected: boolean isRemoving: boolean - removeError: Nullable handleToggleSelect: (assetId: string) => void handleToggleSelectAll: () => void handleRemoveSelected: () => void t: (key: string, params?: Record) => string } -export const useRemoveAssetsScreen = (): UseRemoveAssetsScreenResult => { +export const useRemoveAssetsScreen = ({ + onAfterRemove, +}: UseRemoveAssetsScreenProps = {}): UseRemoveAssetsScreenResult => { const { t } = useLanguage() + const { showToast } = useToast() + const { getMessage } = useAlgodErrorMessage() const [selectedAssetIds, setSelectedAssetIds] = useState>( new Set(), ) - const [removeError, setRemoveError] = useState>(null) const selectedAccount = useAccountsStore(state => state.getSelectedAccount(), @@ -124,8 +131,6 @@ export const useRemoveAssetsScreen = (): UseRemoveAssetsScreenResult => { return } - setRemoveError(null) - const optOutParams = Array.from(selectedAssetIds).map(assetId => { const asset = assets.get(assetId) return { @@ -144,10 +149,29 @@ export const useRemoveAssetsScreen = (): UseRemoveAssetsScreenResult => { try { await optOut(optOutParams) setSelectedAssetIds(new Set()) + showToast({ + title: t('asset_opt_out.success'), + body: '', + type: 'success', + }) + onAfterRemove?.() } catch (err) { - setRemoveError(err instanceof Error ? err : new Error(String(err))) + showToast({ + title: t('asset_opt_out.error'), + body: getMessage(err).body, + type: 'error', + }) } - }, [selectedAccount, selectedAssetIds, assets, optOut]) + }, [ + selectedAccount, + selectedAssetIds, + assets, + optOut, + showToast, + t, + onAfterRemove, + getMessage, + ]) return { removableAssets: filteredRemovableAssets, @@ -155,7 +179,6 @@ export const useRemoveAssetsScreen = (): UseRemoveAssetsScreenResult => { selectedAssetIds, isAllSelected, isRemoving, - removeError, handleToggleSelect, handleToggleSelectAll, handleRemoveSelected, diff --git a/apps/mobile/src/modules/assets/screens/AddAssetScreen/AddAssetScreen.tsx b/apps/mobile/src/modules/assets/screens/AddAssetScreen/AddAssetScreen.tsx index ae096b72b..5b35db1ff 100644 --- a/apps/mobile/src/modules/assets/screens/AddAssetScreen/AddAssetScreen.tsx +++ b/apps/mobile/src/modules/assets/screens/AddAssetScreen/AddAssetScreen.tsx @@ -39,7 +39,7 @@ export const AddAssetScreen = ({ variant = 'asset' }: AddAssetScreenProps) => { hasNextPage, fetchNextPage, optedInAssetIds, - optingInAssetId, + optingInAssetIds, handleRequestAdd, handleConfirmAdd, handleCancelAdd, @@ -54,11 +54,11 @@ export const AddAssetScreen = ({ variant = 'asset' }: AddAssetScreenProps) => { ), - [optedInAssetIds, optingInAssetId, handleRequestAdd], + [optedInAssetIds, optingInAssetIds, handleRequestAdd], ) const handleEndReached = useCallback(() => { @@ -146,7 +146,10 @@ export const AddAssetScreen = ({ variant = 'asset' }: AddAssetScreenProps) => { assetId={pendingAssetId} accountAddress={selectedAccountAddress ?? ''} accountName={selectedAccountName} - isLoading={optingInAssetId !== null} + isLoading={ + pendingAssetId !== null && + optingInAssetIds.has(pendingAssetId) + } /> ) diff --git a/apps/mobile/src/modules/assets/screens/AddAssetScreen/useAddAssetScreen.ts b/apps/mobile/src/modules/assets/screens/AddAssetScreen/useAddAssetScreen.ts index 345aabb0b..9adbb917f 100644 --- a/apps/mobile/src/modules/assets/screens/AddAssetScreen/useAddAssetScreen.ts +++ b/apps/mobile/src/modules/assets/screens/AddAssetScreen/useAddAssetScreen.ts @@ -39,7 +39,7 @@ type UseAddAssetScreenResult = { hasNextPage: boolean fetchNextPage: Nullable<() => void> optedInAssetIds: Set - optingInAssetId: Nullable + optingInAssetIds: Set handleRequestAdd: (assetId: string) => void handleConfirmAdd: () => void handleCancelAdd: () => void @@ -54,8 +54,9 @@ export const useAddAssetScreen = ( ): UseAddAssetScreenResult => { const { t } = useLanguage() const hasCollectible = options?.variant === 'collectible' - const [optingInAssetId, setOptingInAssetId] = - useState>(null) + const [optingInAssetIds, setOptingInAssetIds] = useState>( + new Set(), + ) const [pendingAssetId, setPendingAssetId] = useState>(null) const [recentlyOptedIn, setRecentlyOptedIn] = useState>( new Set(), @@ -114,12 +115,12 @@ export const useAddAssetScreen = ( const handleRequestAdd = useCallback( (assetId: string) => { - if (!selectedAccount || optingInAssetId !== null) { + if (!selectedAccount || optingInAssetIds.has(assetId)) { return } setPendingAssetId(assetId) }, - [selectedAccount, optingInAssetId], + [selectedAccount, optingInAssetIds], ) const handleCancelAdd = useCallback(() => { @@ -127,7 +128,11 @@ export const useAddAssetScreen = ( }, []) const handleConfirmAdd = useCallback(async () => { - if (!selectedAccount || !pendingAssetId || optingInAssetId !== null) { + if ( + !selectedAccount || + !pendingAssetId || + optingInAssetIds.has(pendingAssetId) + ) { return } @@ -135,7 +140,11 @@ export const useAddAssetScreen = ( const pendingAsset = results.find(r => r.assetId === assetId) const assetDisplayName = pendingAsset?.unitName ?? pendingAsset?.name ?? null - setOptingInAssetId(assetId) + setOptingInAssetIds(prev => { + const next = new Set(prev) + next.add(assetId) + return next + }) setPendingAssetId(null) try { @@ -160,16 +169,21 @@ export const useAddAssetScreen = ( type: 'error', }) } finally { - setOptingInAssetId(null) + setOptingInAssetIds(prev => { + const next = new Set(prev) + next.delete(assetId) + return next + }) } }, [ selectedAccount, pendingAssetId, results, optIn, - optingInAssetId, + optingInAssetIds, showToast, t, + getMessage, ]) return { @@ -182,7 +196,7 @@ export const useAddAssetScreen = ( hasNextPage, fetchNextPage, optedInAssetIds, - optingInAssetId, + optingInAssetIds, handleRequestAdd, handleConfirmAdd, handleCancelAdd,