From 1cb59e1f42b0d9f8c1a718f31e14dbe06581a7e1 Mon Sep 17 00:00:00 2001 From: Will Beaumont <4557711+wjbeau@users.noreply.github.com> Date: Tue, 28 Apr 2026 09:01:23 +0100 Subject: [PATCH 1/5] feat: fix scroll behavior on asset lists [PERA-3277] --- .../SearchableList/SearchableList.tsx | 181 ++++++++++++++ .../src/components/SearchableList/index.ts | 14 ++ .../SearchableList/useSearchableList.ts | 228 ++++++++++++++++++ apps/mobile/src/constants/ui.ts | 3 + .../AccountAssetList/AccountAssetList.tsx | 181 ++++++-------- .../components/AccountAssetList/styles.ts | 8 +- .../AccountAssetList/useAccountAssetList.ts | 6 +- .../RemoveAssetsScreen/RemoveAssetsScreen.tsx | 17 +- .../__tests__/useRemoveAssetsScreen.spec.ts | 55 ++++- .../screens/RemoveAssetsScreen/styles.ts | 1 + .../useRemoveAssetsScreen.ts | 41 +++- .../screens/AddAssetScreen/AddAssetScreen.tsx | 11 +- .../AddAssetScreen/useAddAssetScreen.ts | 34 ++- 13 files changed, 635 insertions(+), 145 deletions(-) create mode 100644 apps/mobile/src/components/SearchableList/SearchableList.tsx create mode 100644 apps/mobile/src/components/SearchableList/index.ts create mode 100644 apps/mobile/src/components/SearchableList/useSearchableList.ts diff --git a/apps/mobile/src/components/SearchableList/SearchableList.tsx b/apps/mobile/src/components/SearchableList/SearchableList.tsx new file mode 100644 index 000000000..5da7647c9 --- /dev/null +++ b/apps/mobile/src/components/SearchableList/SearchableList.tsx @@ -0,0 +1,181 @@ +/* + 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, { 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' + +type RenderItem = (props: LegendListRenderItemProps) => React.ReactNode + +const renderHeaderNode = ( + component: React.ComponentType | React.ReactElement | null | undefined, +): 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, + 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 ( + + ) + } + // The sentinel only ever lives at index 0, so the rest of the + // data array is the caller's original list. + 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, + ], + ) + + const TypedFlatList = PWFlatList as React.ComponentType< + PWFlatListProps> & React.RefAttributes + > + + return ( + >)} + ref={listRef} + data={augmentedData} + renderItem={augmentedRenderItem} + keyExtractor={augmentedKeyExtractor} + ListHeaderComponent={augmentedHeader} + ListFooterComponent={augmentedFooter} + stickyIndices={[0]} + 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..035e89480 --- /dev/null +++ b/apps/mobile/src/components/SearchableList/useSearchableList.ts @@ -0,0 +1,228 @@ +/* + 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, + useMemo, + useRef, + useState, +} from 'react' +import { + type LayoutChangeEvent, + type NativeScrollEvent, + type NativeSyntheticEvent, +} from 'react-native' + +import type { PWFlatListRef } from '@components/core' + +const SEARCH_SENTINEL = Symbol('SearchableList.search') +const SEARCH_KEY = '__searchable_list_search__' + +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 + 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, + onScroll, + onScrollEndDrag, +}: UseSearchableListParams): UseSearchableListResult => { + const listRef = useRef(null) + const headerHeightRef = useRef(0) + const listLayoutHeightRef = useRef(0) + const naturalContentSizeRef = useRef(0) + const searchFooterHeightRef = useRef(0) + const [searchFooterHeight, setSearchFooterHeight] = useState(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) + + const updateFooterIfNeeded = useCallback(() => { + const viewport = listLayoutHeightRef.current + const natural = naturalContentSizeRef.current + // Wait for both measurements before computing — otherwise we'd + // briefly add a viewport-sized footer and have to undo it. + if (viewport <= 0 || natural <= 0) { + return + } + // Pad just enough that contentSize == viewport + headerHeight, so the + // max scroll offset equals headerHeight — letting the sticky search + // bar pin to the top even when items don't fill the viewport, while + // never letting the user scroll past the search bar. + const desired = Math.max( + 0, + viewport + headerHeightRef.current - natural, + ) + if (searchFooterHeightRef.current !== desired) { + searchFooterHeightRef.current = desired + setSearchFooterHeight(desired) + } + }, []) + + useImperativeHandle(forwardedRef, () => ({ + scrollToOffset: params => listRef.current?.scrollToOffset(params), + scrollToIndex: params => listRef.current?.scrollToIndex(params), + scrollToEnd: options => listRef.current?.scrollToEnd(options), + })) + + const handleHeaderLayout = useCallback( + (event: LayoutChangeEvent) => { + headerHeightRef.current = event.nativeEvent.layout.height + updateFooterIfNeeded() + }, + [updateFooterIfNeeded], + ) + + const handleListLayout = useCallback( + (event: LayoutChangeEvent) => { + listLayoutHeightRef.current = event.nativeEvent.layout.height + updateFooterIfNeeded() + }, + [updateFooterIfNeeded], + ) + + const handleContentSizeChange = useCallback( + (_width: number, height: number) => { + // Track the content size minus our spacer so updateFooterIfNeeded + // always reasons about the natural (item-driven) size, not the + // inflated size after we've added the spacer. + naturalContentSizeRef.current = + height - searchFooterHeightRef.current + updateFooterIfNeeded() + }, + [updateFooterIfNeeded], + ) + + const handleSearchFocus = useCallback(() => { + // Animate to the offset where the sticky search bar pins to the top + // — the list's native scroll animation provides the collapse motion. + isCollapsedRef.current = true + listRef.current?.scrollToOffset({ + offset: headerHeightRef.current, + animated: true, + }) + }, []) + + const handleScroll = useCallback( + (event: NativeSyntheticEvent) => { + onScroll?.(event) + const headerHeight = headerHeightRef.current + if ( + headerHeight > 0 && + event.nativeEvent.contentOffset.y >= headerHeight + ) { + isCollapsedRef.current = true + } + }, + [onScroll], + ) + + const handleScrollEndDrag = useCallback( + (event: NativeSyntheticEvent) => { + onScrollEndDrag?.(event) + if (!isCollapsedRef.current) { + return + } + const headerHeight = headerHeightRef.current + if (headerHeight <= 0) { + return + } + const offset = event.nativeEvent.contentOffset.y + if (offset <= 0 || offset >= headerHeight) { + return + } + const revealedFraction = (headerHeight - offset) / headerHeight + if (revealedFraction > snapThreshold) { + isCollapsedRef.current = false + listRef.current?.scrollToOffset({ + offset: 0, + animated: true, + }) + } else { + listRef.current?.scrollToOffset({ + offset: headerHeight, + 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, From 98c5ed41e94673184906984fb3ffcb98a93b2ee8 Mon Sep 17 00:00:00 2001 From: Will Beaumont <4557711+wjbeau@users.noreply.github.com> Date: Tue, 28 Apr 2026 12:21:42 +0100 Subject: [PATCH 2/5] fix: clean up search behaviour when typing --- .../SearchableList/SearchableList.tsx | 4 +- .../SearchableList/useSearchableList.ts | 144 +++++++++++------- 2 files changed, 90 insertions(+), 58 deletions(-) diff --git a/apps/mobile/src/components/SearchableList/SearchableList.tsx b/apps/mobile/src/components/SearchableList/SearchableList.tsx index 5da7647c9..f9b399afa 100644 --- a/apps/mobile/src/components/SearchableList/SearchableList.tsx +++ b/apps/mobile/src/components/SearchableList/SearchableList.tsx @@ -92,6 +92,7 @@ const SearchableListInner = ( data, keyExtractor, snapThreshold, + itemHeightEstimate: listProps.estimatedItemSize, onScroll, onScrollEndDrag, }) @@ -132,8 +133,6 @@ const SearchableListInner = ( /> ) } - // The sentinel only ever lives at index 0, so the rest of the - // data array is the caller's original list. return ( renderItem?.({ ...info, @@ -167,6 +166,7 @@ const SearchableListInner = ( ListHeaderComponent={augmentedHeader} ListFooterComponent={augmentedFooter} stickyIndices={[0]} + maintainVisibleContentPosition onLayout={handleListLayout} onContentSizeChange={handleContentSizeChange} onScroll={handleScroll} diff --git a/apps/mobile/src/components/SearchableList/useSearchableList.ts b/apps/mobile/src/components/SearchableList/useSearchableList.ts index 035e89480..d06c34fc2 100644 --- a/apps/mobile/src/components/SearchableList/useSearchableList.ts +++ b/apps/mobile/src/components/SearchableList/useSearchableList.ts @@ -13,6 +13,7 @@ import { useCallback, useImperativeHandle, + useLayoutEffect, useMemo, useRef, useState, @@ -27,6 +28,7 @@ import type { PWFlatListRef } from '@components/core' const SEARCH_SENTINEL = Symbol('SearchableList.search') const SEARCH_KEY = '__searchable_list_search__' +const DEFAULT_ITEM_HEIGHT_ESTIMATE = 56 export type SearchSentinel = typeof SEARCH_SENTINEL @@ -40,6 +42,7 @@ type UseSearchableListParams = { data: readonly T[] | null | undefined keyExtractor?: (item: T, index: number) => string snapThreshold: number + itemHeightEstimate?: number onScroll?: (event: NativeSyntheticEvent) => void onScrollEndDrag?: (event: NativeSyntheticEvent) => void } @@ -70,79 +73,108 @@ export const useSearchableList = ({ 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) - const naturalContentSizeRef = useRef(0) - const searchFooterHeightRef = useRef(0) - const [searchFooterHeight, setSearchFooterHeight] = useState(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) - const updateFooterIfNeeded = useCallback(() => { - const viewport = listLayoutHeightRef.current - const natural = naturalContentSizeRef.current - // Wait for both measurements before computing — otherwise we'd - // briefly add a viewport-sized footer and have to undo it. - if (viewport <= 0 || natural <= 0) { - return - } - // Pad just enough that contentSize == viewport + headerHeight, so the - // max scroll offset equals headerHeight — letting the sticky search - // bar pin to the top even when items don't fill the viewport, while - // never letting the user scroll past the search bar. - const desired = Math.max( - 0, - viewport + headerHeightRef.current - natural, - ) - if (searchFooterHeightRef.current !== desired) { - searchFooterHeightRef.current = desired - setSearchFooterHeight(desired) - } - }, []) - useImperativeHandle(forwardedRef, () => ({ scrollToOffset: params => listRef.current?.scrollToOffset(params), scrollToIndex: params => listRef.current?.scrollToIndex(params), scrollToEnd: options => listRef.current?.scrollToEnd(options), })) - const handleHeaderLayout = useCallback( - (event: LayoutChangeEvent) => { - headerHeightRef.current = event.nativeEvent.layout.height - updateFooterIfNeeded() - }, - [updateFooterIfNeeded], - ) + const itemCount = data?.length ?? 0 - const handleListLayout = useCallback( - (event: LayoutChangeEvent) => { - listLayoutHeightRef.current = event.nativeEvent.layout.height - updateFooterIfNeeded() - }, - [updateFooterIfNeeded], - ) + 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) => { - // Track the content size minus our spacer so updateFooterIfNeeded - // always reasons about the natural (item-driven) size, not the - // inflated size after we've added the spacer. - naturalContentSizeRef.current = - height - searchFooterHeightRef.current - updateFooterIfNeeded() + 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, + }) + } }, - [updateFooterIfNeeded], + [], ) const handleSearchFocus = useCallback(() => { - // Animate to the offset where the sticky search bar pins to the top - // — the list's native scroll animation provides the collapse motion. isCollapsedRef.current = true listRef.current?.scrollToOffset({ offset: headerHeightRef.current, @@ -153,10 +185,10 @@ export const useSearchableList = ({ const handleScroll = useCallback( (event: NativeSyntheticEvent) => { onScroll?.(event) - const headerHeight = headerHeightRef.current + const headerH = headerHeightRef.current if ( - headerHeight > 0 && - event.nativeEvent.contentOffset.y >= headerHeight + headerH > 0 && + event.nativeEvent.contentOffset.y >= headerH ) { isCollapsedRef.current = true } @@ -170,15 +202,15 @@ export const useSearchableList = ({ if (!isCollapsedRef.current) { return } - const headerHeight = headerHeightRef.current - if (headerHeight <= 0) { + const headerH = headerHeightRef.current + if (headerH <= 0) { return } const offset = event.nativeEvent.contentOffset.y - if (offset <= 0 || offset >= headerHeight) { + if (offset <= 0 || offset >= headerH) { return } - const revealedFraction = (headerHeight - offset) / headerHeight + const revealedFraction = (headerH - offset) / headerH if (revealedFraction > snapThreshold) { isCollapsedRef.current = false listRef.current?.scrollToOffset({ @@ -187,7 +219,7 @@ export const useSearchableList = ({ }) } else { listRef.current?.scrollToOffset({ - offset: headerHeight, + offset: headerH, animated: true, }) } From fd23f06801fbfbca0ce586521e93d7bed3e78d65 Mon Sep 17 00:00:00 2001 From: Will Beaumont <4557711+wjbeau@users.noreply.github.com> Date: Tue, 28 Apr 2026 12:29:48 +0100 Subject: [PATCH 3/5] chore: fix type issues --- .../SearchableList/SearchableList.tsx | 51 ++++++++++--------- 1 file changed, 28 insertions(+), 23 deletions(-) diff --git a/apps/mobile/src/components/SearchableList/SearchableList.tsx b/apps/mobile/src/components/SearchableList/SearchableList.tsx index f9b399afa..12bfb5a71 100644 --- a/apps/mobile/src/components/SearchableList/SearchableList.tsx +++ b/apps/mobile/src/components/SearchableList/SearchableList.tsx @@ -10,7 +10,12 @@ limitations under the License */ -import React, { forwardRef, useCallback, useMemo } from 'react' +import React, { + createElement, + forwardRef, + useCallback, + useMemo, +} from 'react' import type { LegendListRenderItemProps } from '@legendapp/list' import { PWFlatList, PWView } from '@components/core' @@ -152,28 +157,28 @@ const SearchableListInner = ( ], ) - const TypedFlatList = PWFlatList as React.ComponentType< - PWFlatListProps> & React.RefAttributes - > - - return ( - >)} - ref={listRef} - data={augmentedData} - renderItem={augmentedRenderItem} - keyExtractor={augmentedKeyExtractor} - ListHeaderComponent={augmentedHeader} - ListFooterComponent={augmentedFooter} - stickyIndices={[0]} - maintainVisibleContentPosition - onLayout={handleListLayout} - onContentSizeChange={handleContentSizeChange} - onScroll={handleScroll} - onScrollEndDrag={handleScrollEndDrag} - scrollEventThrottle={SCROLL_EVENT_THROTTLE} - /> - ) + // 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 ( From 6527f5f4b81793e1a67f8848356007b1e4446195 Mon Sep 17 00:00:00 2001 From: Will Beaumont <4557711+wjbeau@users.noreply.github.com> Date: Tue, 28 Apr 2026 12:30:31 +0100 Subject: [PATCH 4/5] chore: formatting --- .../SearchableList/SearchableList.tsx | 7 +------ .../SearchableList/useSearchableList.ts | 17 ++++------------- 2 files changed, 5 insertions(+), 19 deletions(-) diff --git a/apps/mobile/src/components/SearchableList/SearchableList.tsx b/apps/mobile/src/components/SearchableList/SearchableList.tsx index 12bfb5a71..6361cc7a8 100644 --- a/apps/mobile/src/components/SearchableList/SearchableList.tsx +++ b/apps/mobile/src/components/SearchableList/SearchableList.tsx @@ -10,12 +10,7 @@ limitations under the License */ -import React, { - createElement, - forwardRef, - useCallback, - useMemo, -} from 'react' +import React, { createElement, forwardRef, useCallback, useMemo } from 'react' import type { LegendListRenderItemProps } from '@legendapp/list' import { PWFlatList, PWView } from '@components/core' diff --git a/apps/mobile/src/components/SearchableList/useSearchableList.ts b/apps/mobile/src/components/SearchableList/useSearchableList.ts index d06c34fc2..39972eb07 100644 --- a/apps/mobile/src/components/SearchableList/useSearchableList.ts +++ b/apps/mobile/src/components/SearchableList/useSearchableList.ts @@ -116,7 +116,7 @@ export const useSearchableList = ({ naturalContentSize > 0 ? naturalContentSize : headerHeight + itemCount * itemHeightEstimate - + const previousItemCount = previousItemCountRef.current if (itemCount < previousItemCount) { const expectedLoss = @@ -153,18 +153,12 @@ export const useSearchableList = ({ const handleContentSizeChange = useCallback( (_width: number, height: number) => { - const natural = Math.max( - 0, - height - searchFooterHeightRef.current, - ) + 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 - ) { + if (isCollapsedRef.current && headerHeightRef.current > 0) { listRef.current?.scrollToOffset({ offset: headerHeightRef.current, animated: false, @@ -186,10 +180,7 @@ export const useSearchableList = ({ (event: NativeSyntheticEvent) => { onScroll?.(event) const headerH = headerHeightRef.current - if ( - headerH > 0 && - event.nativeEvent.contentOffset.y >= headerH - ) { + if (headerH > 0 && event.nativeEvent.contentOffset.y >= headerH) { isCollapsedRef.current = true } }, From ddfe93c59b9c31c083d96d15cbd99f06d5274cd5 Mon Sep 17 00:00:00 2001 From: Will Beaumont <4557711+wjbeau@users.noreply.github.com> Date: Wed, 29 Apr 2026 08:56:12 +0100 Subject: [PATCH 5/5] fix: review comments --- apps/mobile/src/components/SearchableList/SearchableList.tsx | 3 ++- apps/mobile/src/components/SearchableList/useSearchableList.ts | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/apps/mobile/src/components/SearchableList/SearchableList.tsx b/apps/mobile/src/components/SearchableList/SearchableList.tsx index 6361cc7a8..712668086 100644 --- a/apps/mobile/src/components/SearchableList/SearchableList.tsx +++ b/apps/mobile/src/components/SearchableList/SearchableList.tsx @@ -22,11 +22,12 @@ import { 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: React.ComponentType | React.ReactElement | null | undefined, + component: Maybe, ): React.ReactNode => { if (component == null) { return null diff --git a/apps/mobile/src/components/SearchableList/useSearchableList.ts b/apps/mobile/src/components/SearchableList/useSearchableList.ts index 39972eb07..a20aa0dbd 100644 --- a/apps/mobile/src/components/SearchableList/useSearchableList.ts +++ b/apps/mobile/src/components/SearchableList/useSearchableList.ts @@ -25,6 +25,7 @@ import { } 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__' @@ -48,7 +49,7 @@ type UseSearchableListParams = { } type UseSearchableListResult = { - listRef: React.RefObject + listRef: React.RefObject> augmentedData: AugmentedItem[] augmentedKeyExtractor: (item: AugmentedItem, index: number) => string toUserIndex: (index: number) => number