diff --git a/src/components/Search/SearchList.tsx b/src/components/Search/SearchList.tsx index 50187515b6ca..1d8b017f4879 100644 --- a/src/components/Search/SearchList.tsx +++ b/src/components/Search/SearchList.tsx @@ -1,11 +1,12 @@ import {useIsFocused} from '@react-navigation/native'; +import {FlashList} from '@shopify/flash-list'; +import type {FlashListProps, ViewToken} from '@shopify/flash-list'; import React, {forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState} from 'react'; import type {ForwardedRef} from 'react'; import {View} from 'react-native'; -import type {FlatList, ListRenderItemInfo, NativeSyntheticEvent, StyleProp, ViewStyle, ViewToken} from 'react-native'; +import type {NativeSyntheticEvent, StyleProp, ViewStyle} from 'react-native'; import {useOnyx} from 'react-native-onyx'; import Animated from 'react-native-reanimated'; -import type {FlatListPropsWithLayout} from 'react-native-reanimated'; import Checkbox from '@components/Checkbox'; import * as Expensicons from '@components/Icon/Expensicons'; import MenuItem from '@components/MenuItem'; @@ -28,9 +29,11 @@ import useThemeStyles from '@hooks/useThemeStyles'; import {turnOffMobileSelectionMode, turnOnMobileSelectionMode} from '@libs/actions/MobileSelectionMode'; import {isMobileChrome} from '@libs/Browser'; import {addKeyDownPressListener, removeKeyDownPressListener} from '@libs/KeyboardShortcut/KeyDownPressListener'; +import {isReportActionListItemType, isReportListItemType, isTransactionListItemType} from '@libs/SearchUIUtils'; import variables from '@styles/variables'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; +import ITEM_HEIGHTS from './itemHeights'; type SearchListItem = TransactionListItemType | ReportListItemType | ReportActionListItemType | TaskListItemType; type SearchListItemComponentType = typeof TransactionListItem | typeof ChatListItem | typeof ReportListItem | typeof TaskListItem; @@ -40,7 +43,7 @@ type SearchListHandle = { scrollToIndex: (index: number, animated?: boolean) => void; }; -type SearchListProps = Pick, 'onScroll' | 'contentContainerStyle' | 'onEndReached' | 'onEndReachedThreshold' | 'ListFooterComponent'> & { +type SearchListProps = Pick, 'onScroll' | 'contentContainerStyle' | 'onEndReached' | 'onEndReachedThreshold' | 'ListFooterComponent' | 'estimatedItemSize'> & { data: SearchListItem[]; /** Default renderer for every item in the list */ @@ -72,6 +75,9 @@ type SearchListProps = Pick, 'onScroll' /** The hash of the queryJSON */ queryJSONHash: number; + /** The type of the queryJSON */ + queryJSONType: string; + /** Whether to group the list by reports */ shouldGroupByReports?: boolean; @@ -82,7 +88,8 @@ type SearchListProps = Pick, 'onScroll' onLayout?: () => void; }; -const onScrollToIndexFailed = () => {}; +const AnimatedFlashList = Animated.createAnimatedComponent>(FlashList); +const keyExtractor = (item: SearchListItem, index: number) => item.keyForList ?? `${index}`; function SearchList( { @@ -104,7 +111,9 @@ function SearchList( queryJSONHash, shouldGroupByReports, onViewableItemsChanged, + estimatedItemSize = ITEM_HEIGHTS.NARROW_WITHOUT_DRAWER.STANDARD, onLayout, + queryJSONType, }: SearchListProps, ref: ForwardedRef, ) { @@ -115,7 +124,7 @@ function SearchList( }, 0); const {translate} = useLocalize(); const isFocused = useIsFocused(); - const listRef = useRef>(null); + const listRef = useRef>(null); const hasKeyBeenPressed = useRef(false); const [itemsToHighlight, setItemsToHighlight] = useState | null>(null); const itemFocusTimeoutRef = useRef(null); @@ -124,7 +133,7 @@ function SearchList( // We need to use isSmallScreenWidth instead of shouldUseNarrowLayout here because there is a race condition that causes shouldUseNarrowLayout to change indefinitely in this component // See https://github.com/Expensify/App/issues/48675 for more details // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth - const {isSmallScreenWidth} = useResponsiveLayout(); + const {isSmallScreenWidth, isLargeScreenWidth, shouldUseNarrowLayout} = useResponsiveLayout(); const [isModalVisible, setIsModalVisible] = useState(false); const {selectionMode} = useMobileSelectionMode(); @@ -299,8 +308,77 @@ function SearchList( useImperativeHandle(ref, () => ({scrollAndHighlightItem, scrollToIndex}), [scrollAndHighlightItem, scrollToIndex]); + const getItemHeight = useCallback( + (item: SearchListItem): number => { + try { + const reportListItem = item as ReportListItemType; + const transactionListItem = item as TransactionListItemType; + const reportActionListItem = item as ReportActionListItemType; + + const isTransaction = isTransactionListItemType(transactionListItem); + const isReportAction = isReportActionListItemType(reportActionListItem); + + if (isTransaction || isReportAction) { + if (queryJSONType === CONST.SEARCH.DATA_TYPES.CHAT) { + return reportListItem?.childReportID ? variables.searchListItemHeightChat : variables.searchListItemHeightChatCompact; + } + const itemAction = transactionListItem?.action; + // VIEW is the only action type that should be compact + const isItemActionView = isTransaction && itemAction === CONST.SEARCH.ACTION_TYPES.VIEW; + + // Determine which layout to use based on screen size and drawer state + let heightConstants; + + if (shouldUseNarrowLayout) { + // For narrow screens without drawer (mobile or collapsed desktop) + heightConstants = isItemActionView ? ITEM_HEIGHTS.NARROW_WITHOUT_DRAWER.STANDARD : ITEM_HEIGHTS.NARROW_WITHOUT_DRAWER.WITH_BUTTON; + } else if (!isLargeScreenWidth) { + // For narrow screens with drawer + heightConstants = isItemActionView ? ITEM_HEIGHTS.NARROW_WITH_DRAWER.STANDARD : ITEM_HEIGHTS.NARROW_WITH_DRAWER.WITH_BUTTON; + } else { + // For wide screens (desktop) + heightConstants = ITEM_HEIGHTS.WIDE.STANDARD; + } + + return heightConstants; + } + if (isReportListItemType(reportListItem)) { + if (!reportListItem.transactions || reportListItem.transactions.length === 0) { + return Math.max(ITEM_HEIGHTS.HEADER, 1); + } + const baseReportItemHeight = isLargeScreenWidth + ? variables.searchOptionRowMargin + variables.searchOptionRowBaseHeight + variables.searchOptionRowLargeFooterHeight + : variables.searchOptionRowMargin + variables.searchOptionRowBaseHeight + variables.searchOptionRowSmallFooterHeight; + const transactionHeight = variables.searchOptionRowTransactionHeight; + const calculatedHeight = + baseReportItemHeight + reportListItem.transactions.length * transactionHeight + variables.optionRowListItemPadding + variables.searchOptionRowMargin; + return Math.max(calculatedHeight, ITEM_HEIGHTS.HEADER, 1); + } + + return isLargeScreenWidth ? variables.searchListItemHeightLargeScreen : variables.searchListItemHeightSmallScreen; + } catch (error) { + console.error('SearchList: Error calculating item height, returning estimated size.', error, item); + return estimatedItemSize; + } + }, + [isLargeScreenWidth, estimatedItemSize, shouldUseNarrowLayout, queryJSONType], + ); + + const overrideItemLayout = useCallback( + (layout: {span?: number; size?: number}, item: SearchListItem) => { + const height = getItemHeight(item); + if (!layout) { + return; + } + // eslint-disable-next-line no-param-reassign + layout.size = height > 0 ? height : estimatedItemSize; + }, + [getItemHeight, estimatedItemSize], + ); + const renderItem = useCallback( - ({item, index}: ListRenderItemInfo) => { + // eslint-disable-next-line react/no-unused-prop-types + ({item, index}: {item: SearchListItem; index: number}) => { const isItemFocused = focusedIndex === index; const isItemHighlighted = !!itemsToHighlight?.has(item.keyForList ?? ''); @@ -386,22 +464,23 @@ function SearchList( )} )} - - item.keyForList ?? `${index}`} + keyExtractor={keyExtractor} onScroll={onScroll} contentContainerStyle={contentContainerStyle} showsVerticalScrollIndicator={false} - ref={listRef} - extraData={focusedIndex} + estimatedItemSize={estimatedItemSize} + overrideItemLayout={overrideItemLayout} onEndReached={onEndReached} onEndReachedThreshold={onEndReachedThreshold} ListFooterComponent={ListFooterComponent} + drawDistance={1000} + extraData={focusedIndex} removeClippedSubviews onViewableItemsChanged={onViewableItemsChanged} - onScrollToIndexFailed={onScrollToIndexFailed} onLayout={onLayout} /> ) => void; - contentContainerStyle?: StyleProp; + contentContainerStyle?: ContentStyle; currentSearchResults?: SearchResults; lastNonEmptySearchResults?: SearchResults; handleSearch: (value: SearchParams) => void; @@ -594,7 +595,7 @@ function Search({queryJSON, currentSearchResults, lastNonEmptySearchResults, onS /> ) } - contentContainerStyle={[contentContainerStyle, styles.pb3]} + contentContainerStyle={{...contentContainerStyle, ...styles.pb3}} containerStyle={[styles.pv0, type === CONST.SEARCH.DATA_TYPES.CHAT && !isSmallScreenWidth && styles.pt3]} shouldPreventDefaultFocusOnSelectRow={!canUseTouchScreen()} shouldGroupByReports={shouldGroupByReports} @@ -610,6 +611,7 @@ function Search({queryJSON, currentSearchResults, lastNonEmptySearchResults, onS ) : undefined } queryJSONHash={hash} + queryJSONType={type} onViewableItemsChanged={onViewableItemsChanged} onLayout={() => handleSelectionListScroll(sortedSelectedData, searchListRef.current)} /> diff --git a/src/components/Search/itemHeights.ts b/src/components/Search/itemHeights.ts new file mode 100644 index 000000000000..b7d540c46cda --- /dev/null +++ b/src/components/Search/itemHeights.ts @@ -0,0 +1,24 @@ +import variables from '@styles/variables'; + +const ITEM_HEIGHTS = { + // Constants for wide screen layout + WIDE: { + STANDARD: variables.optionRowWideItemHeight + variables.optionRowListItemPadding, + }, + + // Constants for narrow screen with drawer + NARROW_WITH_DRAWER: { + STANDARD: variables.optionRowNarrowWithDrawerItemHeight + variables.optionRowListItemPadding, + WITH_BUTTON: variables.optionRowNarrowWithDrawerItemHeightWithButton + variables.optionRowListItemPadding, + }, + + // Constants for narrow screen without drawer (mobile-like) + NARROW_WITHOUT_DRAWER: { + STANDARD: variables.optionRowNarrowWithoutDrawerItemHeight + variables.optionRowListItemPadding, + WITH_BUTTON: variables.optionRowNarrowWithoutDrawerItemHeightWithButton + variables.optionRowListItemPadding, + }, + + HEADER: variables.optionRowSearchHeaderHeight, +} as const; + +export default ITEM_HEIGHTS; diff --git a/src/components/Skeletons/SearchStatusSkeleton.tsx b/src/components/Skeletons/SearchStatusSkeleton.tsx index bf897233c584..e6e4267bbb9e 100644 --- a/src/components/Skeletons/SearchStatusSkeleton.tsx +++ b/src/components/Skeletons/SearchStatusSkeleton.tsx @@ -14,7 +14,7 @@ function SearchStatusSkeleton({shouldAnimate = true}: SearchStatusSkeletonProps) const styles = useThemeStyles(); return ( - +