Skip to content

Commit bdbd914

Browse files
authored
Merge pull request #94816 from callstack-internal/perf/search-s6-chat-view
refactor: add dedicated ChatSearchView
2 parents 225f08e + 4cf8753 commit bdbd914

3 files changed

Lines changed: 569 additions & 57 deletions

File tree

Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
import React, {useImperativeHandle} from 'react';
2+
import type {ForwardedRef} from 'react';
3+
import type {NativeScrollEvent, NativeSyntheticEvent, StyleProp, ViewStyle} from 'react-native';
4+
import type {ExtendedTargetedEvent} from '@components/SelectionList/ListItem/types';
5+
import type {TransactionPreviewData} from '@libs/actions/Search';
6+
import type {ModifiedMouseEvent} from '@libs/Navigation/helpers/openInternalRouteInNewTab';
7+
import CONST from '@src/CONST';
8+
import type {Transaction} from '@src/types/onyx';
9+
import useSearchListViewState from './hooks/useSearchListViewState';
10+
import AnimatedExitRow from './primitives/AnimatedExitRow';
11+
import SelectionTopBar from './primitives/SelectionTopBar';
12+
import BaseSearchList from './SearchList/BaseSearchList';
13+
import ChatListItem from './SearchList/ListItem/ChatListItem';
14+
import type {SearchListItem} from './SearchList/ListItem/types';
15+
import SearchListViewLayout from './SearchListViewLayout';
16+
import type {SearchColumnType, SearchQueryJSON} from './types';
17+
18+
/** Imperative handle the router uses for highlight-driven scrolling (mirrors SearchList's handle). */
19+
type SearchListHandle = {
20+
scrollToIndex: (index: number, animated?: boolean) => void;
21+
};
22+
23+
type ChatSearchViewProps = {
24+
/** The chat search query. */
25+
queryJSON: SearchQueryJSON;
26+
27+
/** The sorted rows to render (from the router's useSearchSnapshot). */
28+
data: SearchListItem[];
29+
30+
/** The columns to render in the list (drives the table min-width and header). */
31+
columns: SearchColumnType[];
32+
33+
/** Whether the list supports multi-select. */
34+
canSelectMultiple: boolean;
35+
36+
/** Whether the action column uses its wider variant. */
37+
isActionColumnWide: boolean;
38+
39+
/** Whether mobile selection mode is on. */
40+
isMobileSelectionModeEnabled: boolean;
41+
42+
/** The column header element (undefined on narrow layouts). */
43+
SearchTableHeader?: React.JSX.Element;
44+
45+
/** Whether a table header bar is shown above the list. */
46+
tableHeaderVisible: boolean;
47+
48+
/** Whether everything has been loaded (gates the fully-checked select-all state). */
49+
hasLoadedAllTransactions?: boolean;
50+
51+
/** Rows flagged for the post-create highlight animation (feeds BaseSearchList extraData). */
52+
newTransactions: Transaction[];
53+
54+
/** The navigation handler for a row tap (owned by the router). */
55+
onSelectRow: (item: SearchListItem, transactionPreviewData?: TransactionPreviewData, event?: ModifiedMouseEvent) => void;
56+
57+
/** The list footer (pagination / pending skeleton). */
58+
ListFooterComponent?: React.JSX.Element;
59+
60+
/** Fires when the list scrolls near its end (router's fetchMoreResults). */
61+
onEndReached: () => void;
62+
63+
/** Fires on the list's first layout and on layout changes. */
64+
onLayout: () => void;
65+
66+
/** Scroll handler forwarded to the list. */
67+
onScroll?: (event: NativeSyntheticEvent<NativeScrollEvent>) => void;
68+
69+
/** Content container style for the list. */
70+
contentContainerStyle: StyleProp<ViewStyle>;
71+
72+
/** Outer container style for the list wrapper. */
73+
containerStyle: StyleProp<ViewStyle>;
74+
75+
/** Imperative handle for highlight-driven scrolling, set by the router. */
76+
ref?: ForwardedRef<SearchListHandle>;
77+
};
78+
79+
const keyExtractor = (item: SearchListItem, index: number) => item.keyForList ?? `${index}`;
80+
81+
const isRowDeleted = (item: SearchListItem) => item.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE;
82+
83+
/**
84+
* The chat Search list.
85+
*
86+
* Rendered by `<Search>` as a child of the selection providers. The shared list state and interactions come
87+
* from `useSearchListViewState`, and the surrounding chrome from `SearchListViewLayout`; this view only owns
88+
* the chat specifics: the `ChatListItem` renderer and single-pass selection counts. Chat rows have no
89+
* column data, no checkbox, and no exit animation (only grouped expenses animate), and long press is
90+
* suppressed, so this is the leanest of the Search views.
91+
*/
92+
function ChatSearchView({
93+
queryJSON,
94+
data,
95+
columns,
96+
canSelectMultiple,
97+
isActionColumnWide,
98+
isMobileSelectionModeEnabled,
99+
SearchTableHeader: searchTableHeader,
100+
tableHeaderVisible,
101+
hasLoadedAllTransactions,
102+
newTransactions,
103+
onSelectRow,
104+
ListFooterComponent,
105+
onEndReached,
106+
onLayout,
107+
onScroll,
108+
contentContainerStyle,
109+
containerStyle,
110+
ref,
111+
}: ChatSearchViewProps) {
112+
const {type} = queryJSON;
113+
114+
const {isKeyboardShown, safeAreaPaddingBottomStyle, isLargeScreenWidth, toggleAll, selectedTransactions, listRef, onLongPressRow, modal, handleSelectRow, scrollToListIndex} =
115+
useSearchListViewState({
116+
data,
117+
isMobileSelectionModeEnabled,
118+
onSelectRow,
119+
shouldPreventLongPressRow: true,
120+
});
121+
122+
// Chat is a flat list: no group children, no empty groups, so selection counts are a single pass.
123+
const selectedItemsLength = data.reduce((acc, item) => acc + (item.keyForList && selectedTransactions[item.keyForList]?.isSelected ? 1 : 0), 0);
124+
const totalItems = data.filter((item) => !isRowDeleted(item)).length;
125+
126+
// Flat data maps 1:1 to the rendered list, so highlight-scroll-to-index is the same as scroll-to-data-index.
127+
useImperativeHandle(ref, () => ({scrollToIndex: scrollToListIndex}), [scrollToListIndex]);
128+
129+
const renderItem = (item: SearchListItem, index: number, isItemFocused: boolean, onFocus?: (event: NativeSyntheticEvent<ExtendedTargetedEvent>) => void) => (
130+
// Chat rows never animate their exit (only grouped expenses do), so the wrapper just preserves the overflow clip.
131+
<AnimatedExitRow
132+
shouldApplyAnimation={false}
133+
hasItemsBeingRemoved={false}
134+
>
135+
<ChatListItem
136+
showTooltip
137+
isFocused={isItemFocused}
138+
onSelectRow={handleSelectRow}
139+
onLongPressRow={onLongPressRow}
140+
canSelectMultiple={canSelectMultiple}
141+
item={item}
142+
isDisabled={isRowDeleted(item)}
143+
onFocus={onFocus}
144+
keyForList={item.keyForList}
145+
/>
146+
</AnimatedExitRow>
147+
);
148+
149+
const isSelectAllChecked = selectedItemsLength > 0 && selectedItemsLength === totalItems && hasLoadedAllTransactions;
150+
const selectAllButtonVisible = canSelectMultiple && !searchTableHeader;
151+
152+
return (
153+
<SearchListViewLayout
154+
columns={columns}
155+
type={type}
156+
isActionColumnWide={isActionColumnWide}
157+
isHeaderVisible={!!searchTableHeader}
158+
dataKey={data}
159+
isKeyboardShown={isKeyboardShown}
160+
safeAreaPaddingBottomStyle={safeAreaPaddingBottomStyle}
161+
containerStyle={containerStyle}
162+
>
163+
{tableHeaderVisible && (
164+
<SelectionTopBar
165+
isLargeScreenWidth={isLargeScreenWidth}
166+
shouldSplitGroups={false}
167+
canSelectMultiple={canSelectMultiple}
168+
isSelectAllChecked={isSelectAllChecked}
169+
selectedItemsLength={selectedItemsLength}
170+
totalItems={totalItems}
171+
hasLoadedAllTransactions={hasLoadedAllTransactions}
172+
selectAllButtonVisible={selectAllButtonVisible}
173+
onAllCheckboxPress={toggleAll}
174+
SearchTableHeader={searchTableHeader}
175+
/>
176+
)}
177+
<BaseSearchList
178+
data={data}
179+
renderItem={renderItem}
180+
onSelectRow={handleSelectRow}
181+
keyExtractor={keyExtractor}
182+
onScroll={onScroll}
183+
showsVerticalScrollIndicator={false}
184+
ref={listRef}
185+
columns={columns}
186+
scrollToIndex={scrollToListIndex}
187+
flattenedItemsLength={data.length}
188+
onEndReached={onEndReached}
189+
onEndReachedThreshold={0.75}
190+
ListFooterComponent={ListFooterComponent}
191+
onLayout={onLayout}
192+
contentContainerStyle={contentContainerStyle}
193+
newTransactions={newTransactions}
194+
/>
195+
{modal}
196+
</SearchListViewLayout>
197+
);
198+
}
199+
200+
ChatSearchView.displayName = 'ChatSearchView';
201+
202+
export default ChatSearchView;

src/components/Search/index.tsx

Lines changed: 85 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@ import type {PaymentMethodType} from '@src/types/onyx/OriginalMessage';
8181
import type SearchResults from '@src/types/onyx/SearchResults';
8282
import {isEmptyObject} from '@src/types/utils/EmptyObject';
8383
import getEmptyArray from '@src/types/utils/getEmptyArray';
84+
import ChatSearchView from './ChatSearchView';
8485
import ExpenseFlatSearchView from './ExpenseFlatSearchView';
8586
import useSearchSnapshot from './hooks/useSearchSnapshot';
8687
import SearchChartView from './SearchChartView';
@@ -1040,12 +1041,91 @@ function Search({
10401041
/>
10411042
) : undefined;
10421043

1043-
// Flat-expense (a plain transaction list) renders through the dedicated ExpenseFlatSearchView, which
1044-
// composes the reusable Search list primitives directly over BaseSearchList. Every other variant
1045-
// (chat, task, report, grouped) keeps the legacy SearchList shell. The snapshot, lifecycle and
1046-
// selection providers stay here so the data layer runs once.
10471044
const isFlatExpenseView = type === CONST.SEARCH.DATA_TYPES.EXPENSE && !validGroupBy;
10481045

1046+
// Flat-expense and chat each render through a dedicated view composed over BaseSearchList; the remaining
1047+
// types keep the legacy SearchList shell. The snapshot, lifecycle and selection providers stay here so
1048+
// the data layer runs once.
1049+
let searchListContent: React.JSX.Element;
1050+
if (isFlatExpenseView) {
1051+
searchListContent = (
1052+
<ExpenseFlatSearchView
1053+
ref={searchListRef}
1054+
queryJSON={queryJSON}
1055+
data={stableSortedData}
1056+
columns={columnsToShow}
1057+
onSelectRow={onSelectRow}
1058+
canSelectMultiple={canSelectMultiple}
1059+
SearchTableHeader={searchTableHeader}
1060+
tableHeaderVisible={tableHeaderVisible}
1061+
contentContainerStyle={[styles.pb3, contentContainerStyle]}
1062+
containerStyle={[styles.pv0]}
1063+
onScroll={onSearchListScroll}
1064+
onEndReached={fetchMoreResults}
1065+
ListFooterComponent={listFooterComponent}
1066+
onLayout={onLayout}
1067+
isMobileSelectionModeEnabled={isMobileSelectionModeEnabled}
1068+
newTransactions={newTransactions}
1069+
hasLoadedAllTransactions={hasLoadedAllTransactions}
1070+
isAttendeesEnabledForMovingPolicy={isAttendeesEnabledForMovingPolicy}
1071+
nonPersonalAndWorkspaceCards={nonPersonalAndWorkspaceCards}
1072+
isActionColumnWide={isTask || hasDeletedTransaction}
1073+
/>
1074+
);
1075+
} else if (isChat) {
1076+
searchListContent = (
1077+
<ChatSearchView
1078+
ref={searchListRef}
1079+
queryJSON={queryJSON}
1080+
data={stableSortedData}
1081+
columns={columnsToShow}
1082+
onSelectRow={onSelectRow}
1083+
canSelectMultiple={canSelectMultiple}
1084+
SearchTableHeader={searchTableHeader}
1085+
tableHeaderVisible={tableHeaderVisible}
1086+
contentContainerStyle={[styles.pb3, contentContainerStyle]}
1087+
containerStyle={[styles.pv0]}
1088+
onScroll={onSearchListScroll}
1089+
onEndReached={fetchMoreResults}
1090+
ListFooterComponent={listFooterComponent}
1091+
onLayout={onLayout}
1092+
isMobileSelectionModeEnabled={isMobileSelectionModeEnabled}
1093+
newTransactions={newTransactions}
1094+
hasLoadedAllTransactions={hasLoadedAllTransactions}
1095+
isActionColumnWide={isTask || hasDeletedTransaction}
1096+
/>
1097+
);
1098+
} else {
1099+
searchListContent = (
1100+
<SearchList
1101+
ref={searchListRef}
1102+
data={stableSortedData}
1103+
ListItem={ListItem}
1104+
onSelectRow={onSelectRow}
1105+
canSelectMultiple={canSelectMultiple}
1106+
shouldPreventLongPressRow={isChat || isTask}
1107+
SearchTableHeader={searchTableHeader}
1108+
contentContainerStyle={[styles.pb3, contentContainerStyle]}
1109+
containerStyle={[styles.pv0]}
1110+
onScroll={onSearchListScroll}
1111+
onEndReachedThreshold={0.75}
1112+
onEndReached={fetchMoreResults}
1113+
ListFooterComponent={listFooterComponent}
1114+
queryJSON={queryJSON}
1115+
columns={columnsToShow}
1116+
onLayout={onLayout}
1117+
isMobileSelectionModeEnabled={isMobileSelectionModeEnabled}
1118+
shouldAnimate={type === CONST.SEARCH.DATA_TYPES.EXPENSE}
1119+
newTransactions={newTransactions}
1120+
hasLoadedAllTransactions={hasLoadedAllTransactions}
1121+
isAttendeesEnabledForMovingPolicy={isAttendeesEnabledForMovingPolicy}
1122+
nonPersonalAndWorkspaceCards={nonPersonalAndWorkspaceCards}
1123+
policyTags={policyTags}
1124+
isActionColumnWide={isTask || hasDeletedTransaction}
1125+
/>
1126+
);
1127+
}
1128+
10491129
return (
10501130
<SearchScopeProvider>
10511131
<SearchWriteActionsProvider
@@ -1059,59 +1139,7 @@ function Search({
10591139
isExpenseReportType={isExpenseReportType}
10601140
isSearchResultsEmpty={isSearchResultsEmpty}
10611141
>
1062-
<Animated.View style={[styles.flex1, animatedStyle]}>
1063-
{isFlatExpenseView ? (
1064-
<ExpenseFlatSearchView
1065-
ref={searchListRef}
1066-
queryJSON={queryJSON}
1067-
data={stableSortedData}
1068-
columns={columnsToShow}
1069-
onSelectRow={onSelectRow}
1070-
canSelectMultiple={canSelectMultiple}
1071-
SearchTableHeader={searchTableHeader}
1072-
tableHeaderVisible={tableHeaderVisible}
1073-
contentContainerStyle={[styles.pb3, contentContainerStyle]}
1074-
containerStyle={[styles.pv0]}
1075-
onScroll={onSearchListScroll}
1076-
onEndReached={fetchMoreResults}
1077-
ListFooterComponent={listFooterComponent}
1078-
onLayout={onLayout}
1079-
isMobileSelectionModeEnabled={isMobileSelectionModeEnabled}
1080-
newTransactions={newTransactions}
1081-
hasLoadedAllTransactions={hasLoadedAllTransactions}
1082-
isAttendeesEnabledForMovingPolicy={isAttendeesEnabledForMovingPolicy}
1083-
nonPersonalAndWorkspaceCards={nonPersonalAndWorkspaceCards}
1084-
isActionColumnWide={isTask || hasDeletedTransaction}
1085-
/>
1086-
) : (
1087-
<SearchList
1088-
ref={searchListRef}
1089-
data={stableSortedData}
1090-
ListItem={ListItem}
1091-
onSelectRow={onSelectRow}
1092-
canSelectMultiple={canSelectMultiple}
1093-
shouldPreventLongPressRow={isChat || isTask}
1094-
SearchTableHeader={searchTableHeader}
1095-
contentContainerStyle={[styles.pb3, contentContainerStyle]}
1096-
containerStyle={[styles.pv0]}
1097-
onScroll={onSearchListScroll}
1098-
onEndReachedThreshold={0.75}
1099-
onEndReached={fetchMoreResults}
1100-
ListFooterComponent={listFooterComponent}
1101-
queryJSON={queryJSON}
1102-
columns={columnsToShow}
1103-
onLayout={onLayout}
1104-
isMobileSelectionModeEnabled={isMobileSelectionModeEnabled}
1105-
shouldAnimate={type === CONST.SEARCH.DATA_TYPES.EXPENSE}
1106-
newTransactions={newTransactions}
1107-
hasLoadedAllTransactions={hasLoadedAllTransactions}
1108-
isAttendeesEnabledForMovingPolicy={isAttendeesEnabledForMovingPolicy}
1109-
nonPersonalAndWorkspaceCards={nonPersonalAndWorkspaceCards}
1110-
policyTags={policyTags}
1111-
isActionColumnWide={isTask || hasDeletedTransaction}
1112-
/>
1113-
)}
1114-
</Animated.View>
1142+
<Animated.View style={[styles.flex1, animatedStyle]}>{searchListContent}</Animated.View>
11151143
</SearchWriteActionsProvider>
11161144
</SearchScopeProvider>
11171145
);

0 commit comments

Comments
 (0)