Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
5a26daa
Merge branch 'main' of https://github.com/Expensify/App
perunt Feb 28, 2025
bf10fe9
Merge branch 'main' of https://github.com/Expensify/App
perunt Mar 10, 2025
a40364e
Merge branch 'main' of https://github.com/Expensify/App
perunt Mar 12, 2025
aa0be7c
Merge branch 'main' of https://github.com/Expensify/App
perunt Apr 4, 2025
cd09ef0
Merge branch 'main' of https://github.com/Expensify/App
perunt Apr 25, 2025
186c4d8
Merge branch 'main' of https://github.com/Expensify/App
perunt Apr 29, 2025
a7a7a7a
getPlatformHeight
perunt Apr 30, 2025
abb887f
fix contentContainerStyle
perunt Apr 30, 2025
d7d5056
use FlashList and add getItemHeight
perunt Apr 30, 2025
f65d798
change handleScroll
perunt Apr 30, 2025
70b8390
variables
perunt Apr 30, 2025
8bdcbf6
Merge branch 'main' of https://github.com/Expensify/App into @perunt/…
perunt Apr 30, 2025
3a4ff6d
lint
perunt Apr 30, 2025
10c0a43
getPlatform
perunt May 8, 2025
481ec6a
animated header fix
perunt May 8, 2025
4491326
Merge branch 'main' of https://github.com/Expensify/App into @perunt/…
perunt May 8, 2025
21d9401
Merge branch 'main' of https://github.com/Expensify/App into @perunt/…
perunt May 13, 2025
c622ffe
chore: fix lint issues due to new ESLint rules
perunt May 13, 2025
084e165
chore: fix lint issues due to new ESLint rules
perunt May 13, 2025
2b2132d
clean
perunt May 13, 2025
e22fc29
prettier
perunt May 13, 2025
960f88c
fix initial render height
perunt May 14, 2025
8ab6f83
lint
perunt May 14, 2025
3444898
Merge branch 'main' of https://github.com/Expensify/App into @perunt/…
perunt May 14, 2025
4a1ee27
change margin for skeleton
perunt May 21, 2025
b0e85f6
do size calculation
perunt May 21, 2025
2f03c88
lint
perunt May 21, 2025
d9a23af
typo queryJSONType
perunt May 22, 2025
3ad3e93
Merge branch 'main' of https://github.com/Expensify/App into @perunt/…
perunt May 29, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
105 changes: 92 additions & 13 deletions src/components/Search/SearchList.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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;
Expand All @@ -40,7 +43,7 @@ type SearchListHandle = {
scrollToIndex: (index: number, animated?: boolean) => void;
};

type SearchListProps = Pick<FlatListPropsWithLayout<SearchListItem>, 'onScroll' | 'contentContainerStyle' | 'onEndReached' | 'onEndReachedThreshold' | 'ListFooterComponent'> & {
type SearchListProps = Pick<FlashListProps<SearchListItem>, 'onScroll' | 'contentContainerStyle' | 'onEndReached' | 'onEndReachedThreshold' | 'ListFooterComponent' | 'estimatedItemSize'> & {
data: SearchListItem[];

/** Default renderer for every item in the list */
Expand Down Expand Up @@ -72,6 +75,9 @@ type SearchListProps = Pick<FlatListPropsWithLayout<SearchListItem>, 'onScroll'
/** The hash of the queryJSON */
queryJSONHash: number;

/** The type of the queryJSON */
queryJSONType: string;

/** Whether to group the list by reports */
shouldGroupByReports?: boolean;

Expand All @@ -82,7 +88,8 @@ type SearchListProps = Pick<FlatListPropsWithLayout<SearchListItem>, 'onScroll'
onLayout?: () => void;
};

const onScrollToIndexFailed = () => {};
const AnimatedFlashList = Animated.createAnimatedComponent<FlashListProps<SearchListItem>>(FlashList);
const keyExtractor = (item: SearchListItem, index: number) => item.keyForList ?? `${index}`;

function SearchList(
{
Expand All @@ -104,7 +111,9 @@ function SearchList(
queryJSONHash,
shouldGroupByReports,
onViewableItemsChanged,
estimatedItemSize = ITEM_HEIGHTS.NARROW_WITHOUT_DRAWER.STANDARD,
onLayout,
queryJSONType,
}: SearchListProps,
ref: ForwardedRef<SearchListHandle>,
) {
Expand All @@ -115,7 +124,7 @@ function SearchList(
}, 0);
const {translate} = useLocalize();
const isFocused = useIsFocused();
const listRef = useRef<FlatList<SearchListItem>>(null);
const listRef = useRef<FlashList<SearchListItem>>(null);
const hasKeyBeenPressed = useRef(false);
const [itemsToHighlight, setItemsToHighlight] = useState<Set<string> | null>(null);
const itemFocusTimeoutRef = useRef<NodeJS.Timeout | null>(null);
Expand All @@ -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();
Expand Down Expand Up @@ -299,8 +308,77 @@ function SearchList(

useImperativeHandle(ref, () => ({scrollAndHighlightItem, scrollToIndex}), [scrollAndHighlightItem, scrollToIndex]);

const getItemHeight = useCallback(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So this means that if we add a new list item, we'd have to add it here too right? Is there a more foolproof way of doing this? Maybe we could use the same pattern we have for sections, e.g. here, so that it's centralized in SearchUIUtils and this component just calls getItemHeight(type, ...). I think that'd make it easier to see all the places that we need to update when adding a new list item type

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree that we can architect something neater. I have a few concerns about putting it away since this value will always recalculate on resizing or when new data comes in. This may cause some loading effects.
The second thing is that our items sometimes have three different sizes (wide screen with drawer, small screen with drawer, small screen) and sometimes it's messy and undesired. If we can unify some sizes of items, we can drastically simplify it

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yea, let's clean this up in a follow up. I think that we can make it easier to scale as we add more items to Reports

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note - @luacmartins When we switch to FlashList v2, we can remove this size estimation logic entirely.

(item: SearchListItem): number => {
try {
const reportListItem = item as ReportListItemType;
const transactionListItem = item as TransactionListItemType;
const reportActionListItem = item as ReportActionListItemType;
Comment thread
luacmartins marked this conversation as resolved.
Comment on lines +314 to +316

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This casting leads to very poor type safety. The types of the variables reportListItem, transactionListItem and reportActionListItem can be completely wrong.

The code for now works, because you have your checks and the optional chaining correctly place, but this is not enforced correctly by the type checks, another dev can easily do things wrong trusting the types of these variables when they are not what they seem.

A better approach would have been something like:

                const reportListItem = isReportListItemType(item) ? item : null;
                const transactionListItem = isTransactionListItemType(item) ? item : null;
                const reportActionListItem = isReportActionListItemType(item) ? item : null;

                if (transactionListItem !== null || reportActionListItem !== null) {
                     ...

or something like:

                if (isTransactionListItemType(item) || isReportActionListItemType(item)) {
                    if (queryJSONType === CONST.SEARCH.DATA_TYPES.CHAT) {
                        return item?.childReportID ? variables.searchListItemHeightChat : variables.searchListItemHeightChatCompact;
                    }

The later starts showing a type error which makes me question if there is a bug here or if it is really intentional that you want to return variables.searchListItemHeightChatCompact for all TransactionListItemType (because, according to types, they never have a 'childReportID`).

image

Is the behaviour correct?

@perunt Please please please avoid coding things using casting unless it is really necessary (not sure if it is ever necessary), it leads to brittle code since types are not reliable anymore.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree that's a nice improvement. I'll also keep an eye out for these casts in the future. @perunt let's update this code to ensure better type safety

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey. I agree that it was not the best decision. I changed it here: #63236. @luacmartins can you take it?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seems like that PR is merged. Thanks for the quick work!

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for improving it!


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<SearchListItem>) => {
// 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 ?? '');

Expand Down Expand Up @@ -386,22 +464,23 @@ function SearchList(
)}
</View>
)}

<Animated.FlatList
<AnimatedFlashList
ref={listRef}
data={data}
renderItem={renderItem}
keyExtractor={(item, index) => 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}
/>
<Modal
Expand Down
8 changes: 5 additions & 3 deletions src/components/Search/index.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {useIsFocused, useNavigation} from '@react-navigation/native';
import type {ContentStyle} from '@shopify/flash-list';
import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react';
import type {NativeScrollEvent, NativeSyntheticEvent, StyleProp, ViewStyle, ViewToken} from 'react-native';
import type {NativeScrollEvent, NativeSyntheticEvent, ViewToken} from 'react-native';
import {View} from 'react-native';
import {useOnyx} from 'react-native-onyx';
import FullPageErrorView from '@components/BlockingViews/FullPageErrorView';
Expand Down Expand Up @@ -55,7 +56,7 @@ import type {SearchColumnType, SearchParams, SearchQueryJSON, SelectedTransactio
type SearchProps = {
queryJSON: SearchQueryJSON;
onSearchListScroll?: (event: NativeSyntheticEvent<NativeScrollEvent>) => void;
contentContainerStyle?: StyleProp<ViewStyle>;
contentContainerStyle?: ContentStyle;
currentSearchResults?: SearchResults;
lastNonEmptySearchResults?: SearchResults;
handleSearch: (value: SearchParams) => void;
Expand Down Expand Up @@ -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}
Expand All @@ -610,6 +611,7 @@ function Search({queryJSON, currentSearchResults, lastNonEmptySearchResults, onS
) : undefined
}
queryJSONHash={hash}
queryJSONType={type}
onViewableItemsChanged={onViewableItemsChanged}
onLayout={() => handleSelectionListScroll(sortedSelectedData, searchListRef.current)}
/>
Expand Down
24 changes: 24 additions & 0 deletions src/components/Search/itemHeights.ts
Original file line number Diff line number Diff line change
@@ -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;
2 changes: 1 addition & 1 deletion src/components/Skeletons/SearchStatusSkeleton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ function SearchStatusSkeleton({shouldAnimate = true}: SearchStatusSkeletonProps)
const styles = useThemeStyles();

return (
<View style={[styles.mh5, styles.mb5]}>
<View style={[styles.mh5, styles.mb2]}>
<SkeletonViewContentLoader
animate={shouldAnimate}
height={40}
Expand Down
26 changes: 26 additions & 0 deletions src/styles/variables.ts
Original file line number Diff line number Diff line change
Expand Up @@ -318,4 +318,30 @@ export default {
w96: 96,
w184: 184,
w191: 191,

// Transaction item row heights based on layout types
// Wide screen (desktop) layout
optionRowWideItemHeight: 64,

// Narrow screen with drawer layout
optionRowNarrowWithDrawerItemHeight: 96,
optionRowNarrowWithDrawerItemHeightWithButton: 104,

// Narrow screen without drawer (mobile-like) layout
optionRowNarrowWithoutDrawerItemHeight: 92,
optionRowNarrowWithoutDrawerItemHeightWithButton: 104,

optionRowListItemPadding: 8,
optionRowSearchHeaderHeight: 54,

// SearchList item heights
searchListItemHeightLargeScreen: 72,
searchListItemHeightSmallScreen: 96,
searchListItemHeightChat: 351,
searchListItemHeightChatCompact: 105,
searchOptionRowTransactionHeight: 52,
searchOptionRowBaseHeight: 52,
searchOptionRowSmallFooterHeight: 28,
searchOptionRowLargeFooterHeight: 17,
searchOptionRowMargin: 6,
} as const;
3 changes: 3 additions & 0 deletions src/types/onyx/SearchResults.ts
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,9 @@ type SearchReport = {

/** The policy name to use for an archived report */
oldPolicyName?: string;

/** The ID of the chat report associated with this report item, if any */
childReportID?: string;
};

/** Model of report action search result */
Expand Down