diff --git a/src/libs/OptionsListUtils/index.ts b/src/libs/OptionsListUtils/index.ts index a5268371e249d..23e2f56fb69c3 100644 --- a/src/libs/OptionsListUtils/index.ts +++ b/src/libs/OptionsListUtils/index.ts @@ -1462,6 +1462,15 @@ function createOptionList( }; } +/** + * Sort Report objects by archived status and last visible action + * Similar to recentReportComparator, but works with raw Report objects instead of SearchOptionData + */ +const reportSortComparator = (report: Report, privateIsArchivedMap: PrivateIsArchivedMap): string => { + const isArchived = !!privateIsArchivedMap[`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${report.reportID}`]; + return `${isArchived ? 0 : 1}_${report.lastVisibleActionCreated ?? ''}`; +}; + /** * Creates an optimized option list with smart pre-filtering. * @@ -1491,6 +1500,7 @@ function createFilteredOptionList( visibleReportActionsData: VisibleReportActionsDerivedValue = {}, ) { const {maxRecentReports = 500, includeP2P = true, searchTerm = ''} = options; + const isSearching = !!searchTerm?.trim(); const reportMapForAccountIDs: Record = {}; // Step 1: Pre-filter reports to avoid processing thousands @@ -1499,20 +1509,14 @@ function createFilteredOptionList( return !!report; }); - // Step 2: Sort by lastVisibleActionCreated (most recent first) - const sortedReports = reportsArray.sort((a, b) => { - const aTime = new Date(a.lastVisibleActionCreated ?? 0).getTime(); - const bTime = new Date(b.lastVisibleActionCreated ?? 0).getTime(); - return bTime - aTime; - }); - - // Step 3: Limit to top N reports - const limitedReports = sortedReports.slice(0, maxRecentReports); + // Step 2: Sort by lastVisibleActionCreated (most recent first) and limit to top N + // In search mode, skip sorting because we return all reports anyway - sorting is unnecessary + const sortedReports = isSearching ? reportsArray : optionsOrderBy(reportsArray, (report) => reportSortComparator(report, privateIsArchivedMap), maxRecentReports); - // Step 4: If search term is present, build report map with ONLY 1:1 DM reports + // Step 3: If search term is present, build report map with ONLY 1:1 DM reports // This allows personal details to have valid 1:1 DM reportIDs for proper avatar display // Users without 1:1 DMs will have no report mapped, causing getIcons to fall back to personal avatar - if (searchTerm?.trim()) { + if (isSearching) { const allReportsArray = Object.values(reports ?? {}); // Add ONLY 1:1 DM reports (never add group/policy chats to maintain personal avatars) @@ -1535,9 +1539,9 @@ function createFilteredOptionList( } } - // Step 5: Process the limited set of reports (performance optimization) + // Step 4: Process the limited set of reports (performance optimization) const reportOptions: Array> = []; - for (const report of limitedReports) { + for (const report of sortedReports) { const privateIsArchived = privateIsArchivedMap[`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${report.reportID}`]; const reportPolicyTags = policyTags?.[`${ONYXKEYS.COLLECTION.POLICY_TAGS}${getNonEmptyStringOnyxID(report?.policyID)}`]; const {reportMapEntry, reportOption} = processReport( @@ -1570,7 +1574,7 @@ function createFilteredOptionList( } } - // Step 6: Process personal details (all of them - needed for search functionality) + // Step 5: Process personal details (all of them - needed for search functionality) const personalDetailsOptions = includeP2P ? Object.values(personalDetails ?? {}).map((personalDetail) => { const accountID = personalDetail?.accountID ?? CONST.DEFAULT_NUMBER_ID; diff --git a/src/pages/Share/ShareTab.tsx b/src/pages/Share/ShareTab.tsx index 8039de13c85d3..69c6211b1d933 100644 --- a/src/pages/Share/ShareTab.tsx +++ b/src/pages/Share/ShareTab.tsx @@ -2,13 +2,13 @@ import type {Ref} from 'react'; import React, {useEffect, useImperativeHandle, useRef} from 'react'; import {View} from 'react-native'; import {usePersonalDetails} from '@components/OnyxListItemProvider'; -import {useOptionsList} from '@components/OptionListContextProvider'; import SelectionList from '@components/SelectionList'; import InviteMemberListItem from '@components/SelectionList/ListItem/InviteMemberListItem'; import type {ListItem, SelectionListHandle} from '@components/SelectionList/types'; import Text from '@components/Text'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useDebouncedState from '@hooks/useDebouncedState'; +import useFilteredOptions from '@hooks/useFilteredOptions'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; @@ -62,8 +62,13 @@ function ShareTab({ref}: ShareTabProps) { focus: selectionListRef.current?.focusTextInput, })); - const {options, areOptionsInitialized} = useOptionsList(); const {didScreenTransitionEnd} = useScreenWrapperTransitionStatus(); + const {options: listOptions, isLoading} = useFilteredOptions({ + enabled: didScreenTransitionEnd, + betas: betas ?? [], + searchTerm: debouncedTextInputValue, + }); + const areOptionsInitialized = !isLoading; const [isSearchingForReports] = useOnyx(ONYXKEYS.IS_SEARCHING_FOR_REPORTS, {initWithStoredValues: false}); const offlineMessage: string = isOffline ? `${translate('common.youAppearToBeOffline')} ${translate('search.resultsAreLimited')}` : ''; @@ -71,7 +76,7 @@ function ShareTab({ref}: ShareTabProps) { const searchOptions = areOptionsInitialized ? getSearchOptions({ - options, + options: listOptions ?? {reports: [], personalDetails: []}, draftComments, nvpDismissedProductTraining, betas: betas ?? [], diff --git a/tests/perf-test/OptionsListUtils.perf-test.ts b/tests/perf-test/OptionsListUtils.perf-test.ts index 30cffa2e704c5..19a835e70a868 100644 --- a/tests/perf-test/OptionsListUtils.perf-test.ts +++ b/tests/perf-test/OptionsListUtils.perf-test.ts @@ -3,7 +3,7 @@ import type * as NativeNavigation from '@react-navigation/native'; import Onyx from 'react-native-onyx'; import {measureFunction} from 'reassure'; import type {PrivateIsArchivedMap} from '@hooks/usePrivateIsArchivedMap'; -import {createOptionList, filterAndOrderOptions, getSearchOptions, getValidOptions} from '@libs/OptionsListUtils'; +import {createFilteredOptionList, createOptionList, filterAndOrderOptions, getSearchOptions, getValidOptions} from '@libs/OptionsListUtils'; import type {OptionData} from '@libs/ReportUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -20,7 +20,7 @@ import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; const REPORTS_COUNT = 5000; const PERSONAL_DETAILS_LIST_COUNT = 1000; -const SEARCH_VALUE = 'TestingValue'; +const SEARCH_VALUE = 'Report'; const COUNTRY_CODE = 1; const PERSONAL_DETAILS_COUNT = 1000; @@ -267,4 +267,44 @@ describe('OptionsListUtils', () => { await waitForBatchedUpdates(); await measureFunction(() => formatSectionsFromSearchTerm('', Object.values(selectedOptions), [], [], {}, MOCK_CURRENT_USER_ACCOUNT_ID, mockedPersonalDetails, true)); }); + + test('[OptionsListUtils] createFilteredOptionList', async () => { + await waitForBatchedUpdates(); + await measureFunction(() => + createFilteredOptionList(personalDetails, mockedReportsMap, MOCK_CURRENT_USER_ACCOUNT_ID, undefined, EMPTY_PRIVATE_IS_ARCHIVED_MAP, {maxRecentReports: 500, searchTerm: ''}), + ); + }); + + test('[OptionsListUtils] createFilteredOptionList with searchTerm', async () => { + await waitForBatchedUpdates(); + await measureFunction(() => + createFilteredOptionList(personalDetails, mockedReportsMap, MOCK_CURRENT_USER_ACCOUNT_ID, undefined, EMPTY_PRIVATE_IS_ARCHIVED_MAP, { + maxRecentReports: 500, + searchTerm: SEARCH_VALUE, + }), + ); + }); + + test('[OptionsListUtils] getSearchOptions with searchTerm', async () => { + await waitForBatchedUpdates(); + const optionLists = createFilteredOptionList(personalDetails, mockedReportsMap, MOCK_CURRENT_USER_ACCOUNT_ID, undefined, EMPTY_PRIVATE_IS_ARCHIVED_MAP, { + maxRecentReports: 500, + searchTerm: SEARCH_VALUE, + }); + + await measureFunction(() => + getSearchOptions({ + options: optionLists, + betas: mockedBetas, + draftComments: {}, + nvpDismissedProductTraining, + loginList, + currentUserAccountID: MOCK_CURRENT_USER_ACCOUNT_ID, + currentUserEmail: MOCK_CURRENT_USER_EMAIL, + policyCollection: allPolicies, + personalDetails, + maxResults: 20, + }), + ); + }); }); diff --git a/tests/unit/OptionsListUtilsTest.tsx b/tests/unit/OptionsListUtilsTest.tsx index 1d00125f82836..2ba7b1d614bda 100644 --- a/tests/unit/OptionsListUtilsTest.tsx +++ b/tests/unit/OptionsListUtilsTest.tsx @@ -3514,23 +3514,6 @@ describe('OptionsListUtils', () => { // Report 2 should not have private_isArchived since it's not in the map expect(report2Option?.private_isArchived).toBeUndefined(); }); - - it('should respect maxRecentReports option while preserving archived status', () => { - renderLocaleContextProvider(); - // Given a privateIsArchivedMap and a small maxRecentReports limit - const privateIsArchivedMap: PrivateIsArchivedMap = { - [`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}7`]: '2023-12-31 23:59:59', // Report 7 has largest lastVisibleActionCreated - }; - - // When we call createFilteredOptionList with maxRecentReports limit - const result = createFilteredOptionList(PERSONAL_DETAILS, REPORTS, CURRENT_USER_ACCOUNT_ID, undefined, privateIsArchivedMap, { - maxRecentReports: 5, - }); - - // Then the report 7 (most recent) should still have private_isArchived set - const report7Option = result.reports.find((r) => r.item?.reportID === '7'); - expect(report7Option?.private_isArchived).toBe('2023-12-31 23:59:59'); - }); }); describe('filterSelfDMChat()', () => { @@ -6803,10 +6786,21 @@ describe('OptionsListUtils', () => { expect(result).toBeDefined(); }); - it('should handle searchTerm filtering', () => { - const result = createFilteredOptionList(PERSONAL_DETAILS, REPORTS, CURRENT_USER_ACCOUNT_ID, undefined, {}, {searchTerm: 'Spider'}); + it('should return all reports when searchTerm is provided (isSearching is true)', () => { + const result = createFilteredOptionList( + PERSONAL_DETAILS, + REPORTS, + CURRENT_USER_ACCOUNT_ID, + undefined, + {}, + { + searchTerm: 'Report', + maxRecentReports: 2, + }, + ); expect(result).toBeDefined(); + expect(result.reports.length).toBe(Object.keys(REPORTS).length); }); it('should return both reports and personal details', () => {