import { BottomSheetFlatList, BottomSheetFlatListMethods, BottomSheetModalProvider, } from '@gorhom/bottom-sheet' import { debounce } from 'lodash' import React, { RefObject, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { StyleSheet, Text, TextStyle, View } from 'react-native' import { ScrollView } from 'react-native-gesture-handler' import { useSafeAreaInsets } from 'react-native-safe-area-context' import AppAnalytics from 'src/analytics/AppAnalytics' import { TokenBottomSheetEvents } from 'src/analytics/Events' import { BottomSheetModalRefType } from 'src/components/BottomSheet' import BottomSheetBase from 'src/components/BottomSheetBase' import FilterChipsCarousel, { FilterChip, NetworkFilterChip, isNetworkChip, } from 'src/components/FilterChipsCarousel' import KeyboardSpacer from 'src/components/KeyboardSpacer' import SearchInput from 'src/components/SearchInput' import NetworkMultiSelectBottomSheet from 'src/components/multiSelect/NetworkMultiSelectBottomSheet' import InfoIcon from 'src/icons/InfoIcon' import { NETWORK_NAMES } from 'src/shared/conts' import Colors from 'src/styles/colors' import { typeScale } from 'src/styles/fonts' import { Spacing } from 'src/styles/styles' import { TokenBalanceItem } from 'src/tokens/TokenBalanceItem' import { TokenBalance } from 'src/tokens/slice' import { NetworkId } from 'src/transactions/types' export enum TokenPickerOrigin { Send = 'Send', SwapFrom = 'Swap/From', SwapTo = 'Swap/To', CashIn = 'CashIn', CashOut = 'CashOut', Spend = 'Spend', Earn = 'Earn', } export const DEBOUNCE_WAIT_TIME = 200 export type TokenBottomSheetProps = { origin: TokenPickerOrigin onTokenSelected: (token: TokenBalance, tokenPositionInList: number) => void title: string titleStyle?: TextStyle searchEnabled?: boolean snapPoints?: (string | number)[] tokens: TokenBalance[] TokenOptionComponent?: React.ComponentType showPriceUsdUnavailableWarning?: boolean filterChips?: FilterChip[] areSwapTokensShuffled?: boolean wrapWithModalProvider?: boolean } & ( | { isScreen: true; forwardedRef?: undefined } | { forwardedRef: RefObject; isScreen?: false } ) interface TokenOptionProps { tokenInfo: TokenBalance onPress: () => void index: number showPriceUsdUnavailableWarning?: boolean } function NoResults({ testID = 'TokenBottomSheet/NoResult', searchTerm, activeFilters, }: { testID?: string searchTerm: string activeFilters: FilterChip[] }) { const { t } = useTranslation() const activeFilterNames = activeFilters.map((activeFilter) => { if (!isNetworkChip(activeFilter)) { return `"${activeFilter.name}"` } // use the network name as the filter name to give more information, // rather than the filter name itself (which is "network") return activeFilter.selectedNetworkIds .map( (selectedNetworkId) => `"${t('tokenBottomSheet.filters.network', { networkName: NETWORK_NAMES[selectedNetworkId], })}"` ) .join(', ') }) const noResultsText = activeFilterNames.length > 0 && searchTerm.length > 0 ? 'tokenBottomSheet.noFilterSearchResults' : activeFilterNames.length > 0 ? 'tokenBottomSheet.noFilterResults' : 'tokenBottomSheet.noSearchResults' return ( {t(noResultsText, { searchTerm: searchTerm, filterNames: activeFilterNames.join(', ') })} ) } function TokenBottomSheet({ forwardedRef, snapPoints, origin, onTokenSelected, tokens, searchEnabled, title, titleStyle, showPriceUsdUnavailableWarning, filterChips = [], areSwapTokensShuffled, isScreen, wrapWithModalProvider = true, }: TokenBottomSheetProps) { const insets = useSafeAreaInsets() const filterChipsCarouselRef = useRef(null) const tokenListRef = useRef(null) const [headerHeight, setHeaderHeight] = useState(0) const [searchTerm, setSearchTerm] = useState('') const [filters, setFilters] = useState(filterChips) const activeFilters = useMemo(() => filters.filter((filter) => filter.isSelected), [filters]) const { t } = useTranslation() const networkChipRef = useRef(null) const networkChip = useMemo( () => filters.find((chip): chip is NetworkFilterChip => isNetworkChip(chip)), [filters] ) // These function params mimic the params of the setSelectedNetworkIds function in // const [selectedNetworkIds, setSelectedNetworkIds] = useState([]) // This custom function is used to keep the same shared state between the network filter and the other filters // which made the rest of the code more readable and maintainable const setSelectedNetworkIds = (arg: NetworkId[] | ((networkIds: NetworkId[]) => NetworkId[])) => { setFilters((prev) => { return prev.map((chip) => { if (isNetworkChip(chip)) { const selectedNetworkIds = typeof arg === 'function' ? arg(chip.selectedNetworkIds) : arg return { ...chip, selectedNetworkIds, isSelected: selectedNetworkIds.length !== chip.allNetworkIds.length, } } return chip }) }) } const handleToggleFilterChip = (toggledChip: FilterChip) => { if (isNetworkChip(toggledChip)) { networkChipRef.current?.snapToIndex(0) } else { AppAnalytics.track(TokenBottomSheetEvents.toggle_tokens_filter, { filterId: toggledChip.id, isRemoving: filters.find((chip) => chip.id === toggledChip.id)?.isSelected ?? false, isPreSelected: filterChips.find((chip) => chip.id === toggledChip.id)?.isSelected ?? false, }) setFilters((prev) => { return prev.map((chip) => { if (chip.id === toggledChip.id) { return { ...chip, isSelected: !chip.isSelected } } return chip }) }) } } const onTokenPressed = (token: TokenBalance, index: number) => () => { AppAnalytics.track(TokenBottomSheetEvents.token_selected, { origin, tokenAddress: token.address, tokenId: token.tokenId, networkId: token.networkId, usedSearchTerm: searchTerm.length > 0, selectedFilters: activeFilters.map((filter) => filter.id), areSwapTokensShuffled, tokenPositionInList: index, }) onTokenSelected(token, index) } const sendAnalytics = useCallback( debounce((searchInput: string) => { AppAnalytics.track(TokenBottomSheetEvents.search_token, { origin, searchInput, }) }, DEBOUNCE_WAIT_TIME), [] ) const tokenList = useMemo(() => { const lowercasedSearchTerm = searchTerm.toLowerCase() return tokens.filter((token) => { // Exclude the token if it does not match the active filters if ( !activeFilters.every((filter) => { if (isNetworkChip(filter)) { return filter.filterFn(token, filter.selectedNetworkIds) } return filter.filterFn(token) }) ) { return false } // Exclude the token if it does not match the search term if ( searchTerm && !( token.symbol.toLowerCase().includes(lowercasedSearchTerm) || token.name.toLowerCase().includes(lowercasedSearchTerm) ) ) { return false } return true }) }, [searchTerm, tokens, activeFilters]) useEffect(() => { // Scroll to top when the token list changes (e.g. when there are new // filters and search terms applied) if (tokenList.length > 0) { tokenListRef.current?.scrollToOffset({ offset: 0, animated: true }) } }, [tokenList]) const handleMeasureHeader = (event: { nativeEvent: { layout: { height: number } } }) => { setHeaderHeight(event.nativeEvent.layout.height) } // This component implements a sticky header using an absolutely positioned // component on top of a blank container of the same height in the // ListHeaderComponent of the Flatlist. Unfortunately the out of the box // sticky header implementation of FlatList does not work so well with the // scroll methods inside dynamically sized bottom sheets and it was observed // that the header would be stuck to the wrong position between sheet reopens. // See https://valora-app.slack.com/archives/C04B61SJ6DS/p1707757919681089 const content = ( // Note: ideally we wouldn't need this wrapper view, // it's here for testing purposes with the testID // wrapping both the FlatList and the sticky header // TODO: check if we can remove this wrapper view now that dynamic sizing seems more robust item.tokenId} contentContainerStyle={{ paddingBottom: insets.bottom, backgroundColor: Colors.backgroundPrimary, // fill full height if there are filter chips, otherwise the bottom // sheet height changes as tokens are filtered flexGrow: filterChips.length ? 1 : undefined, }} scrollIndicatorInsets={{ top: headerHeight }} renderItem={({ item, index }) => { return ( ) }} ListHeaderComponent={} ListEmptyComponent={() => { if (searchEnabled || filterChips.length > 0) { return } return null }} /> {title} {searchEnabled && ( { setSearchTerm(text) sendAnalytics(text) }} style={styles.searchInput} returnKeyType={'search'} // disable autoCorrect and spellCheck since the search terms here // are token names which autoCorrect would get in the way of. This // combination also hides the keyboard suggestions bar from the top // of the iOS keyboard, preserving screen real estate. autoCorrect={false} spellCheck={false} /> )} {filterChips.length > 0 && ( )} ) return ( <> {isScreen ? ( // Don't wrap the content in a BottomSheetBase when used as a screen // since the bottom sheet navigator already provides the necessary wrapping content ) : wrapWithModalProvider ? ( {content} ) : ( {content} )} {networkChip && ( { AppAnalytics.track(TokenBottomSheetEvents.network_filter_updated, { selectedNetworkIds, origin, }) }} /> )} ) } TokenBottomSheet.navigationOptions = {} const styles = StyleSheet.create({ // Important: care must be taken when changing the styles of the container // It could most likely break the dynamic sizing of the bottom sheet // so avoid adding padding/margin to it, or min/max height container: { flex: 1, }, searchInput: { marginTop: Spacing.Regular16, }, iconContainer: { marginRight: Spacing.Small12, }, noResultsText: { ...typeScale.labelSmall, flex: 1, }, noResultsContainer: { flexDirection: 'row', alignItems: 'center', margin: Spacing.Thick24, }, tokenBalanceItemContainer: { marginHorizontal: Spacing.Thick24, }, filterChipsCarouselContainer: { paddingTop: Spacing.Thick24, }, headerContainer: { position: 'absolute', top: 0, padding: Spacing.Thick24, backgroundColor: Colors.backgroundPrimary, width: '100%', }, title: { ...typeScale.titleSmall, }, }) export default TokenBottomSheet