Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
32 changes: 18 additions & 14 deletions src/libs/OptionsListUtils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -208,7 +208,7 @@
*/

let allReports: OnyxCollection<Report>;
Onyx.connect({

Check warning on line 211 in src/libs/OptionsListUtils/index.ts

View workflow job for this annotation

GitHub Actions / Changed files ESLint check

Onyx.connect() is deprecated. Use useOnyx() hook instead and pass the data as parameters to a pure function

Check warning on line 211 in src/libs/OptionsListUtils/index.ts

View workflow job for this annotation

GitHub Actions / ESLint check

Onyx.connect() is deprecated. Use useOnyx() hook instead and pass the data as parameters to a pure function
key: ONYXKEYS.COLLECTION.REPORT,
waitForCollectionCallback: true,
callback: (value) => {
Expand All @@ -220,7 +220,7 @@
const allSortedReportActions: Record<string, ReportAction[]> = {};
const cachedOneTransactionThreadReportIDs: Record<string, string | undefined> = {};
let allReportActions: OnyxCollection<ReportActions>;
Onyx.connect({

Check warning on line 223 in src/libs/OptionsListUtils/index.ts

View workflow job for this annotation

GitHub Actions / Changed files ESLint check

Onyx.connect() is deprecated. Use useOnyx() hook instead and pass the data as parameters to a pure function

Check warning on line 223 in src/libs/OptionsListUtils/index.ts

View workflow job for this annotation

GitHub Actions / ESLint check

Onyx.connect() is deprecated. Use useOnyx() hook instead and pass the data as parameters to a pure function
key: ONYXKEYS.COLLECTION.REPORT_ACTIONS,
waitForCollectionCallback: true,
callback: (actions) => {
Expand Down Expand Up @@ -265,7 +265,7 @@
});

let activePolicyID: OnyxEntry<string>;
Onyx.connect({

Check warning on line 268 in src/libs/OptionsListUtils/index.ts

View workflow job for this annotation

GitHub Actions / Changed files ESLint check

Onyx.connect() is deprecated. Use useOnyx() hook instead and pass the data as parameters to a pure function

Check warning on line 268 in src/libs/OptionsListUtils/index.ts

View workflow job for this annotation

GitHub Actions / ESLint check

Onyx.connect() is deprecated. Use useOnyx() hook instead and pass the data as parameters to a pure function
key: ONYXKEYS.NVP_ACTIVE_POLICY_ID,
callback: (value) => (activePolicyID = value),
});
Expand Down Expand Up @@ -1462,6 +1462,15 @@
};
}

/**
* 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.
*
Expand Down Expand Up @@ -1491,6 +1500,7 @@
visibleReportActionsData: VisibleReportActionsDerivedValue = {},
) {
const {maxRecentReports = 500, includeP2P = true, searchTerm = ''} = options;
const isSearching = !!searchTerm?.trim();
const reportMapForAccountIDs: Record<number, Report> = {};

// Step 1: Pre-filter reports to avoid processing thousands
Expand All @@ -1499,20 +1509,14 @@
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)
Expand All @@ -1535,9 +1539,9 @@
}
}

// Step 5: Process the limited set of reports (performance optimization)
// Step 4: Process the limited set of reports (performance optimization)
const reportOptions: Array<SearchOption<Report>> = [];
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(
Expand Down Expand Up @@ -1570,7 +1574,7 @@
}
}

// 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;
Expand Down
11 changes: 8 additions & 3 deletions src/pages/Share/ShareTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -62,16 +62,21 @@ 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')}` : '';
const shouldShowLoadingPlaceholder = !areOptionsInitialized || !didScreenTransitionEnd;

const searchOptions = areOptionsInitialized
? getSearchOptions({
options,
options: listOptions ?? {reports: [], personalDetails: []},
draftComments,
nvpDismissedProductTraining,
betas: betas ?? [],
Expand Down
44 changes: 42 additions & 2 deletions tests/perf-test/OptionsListUtils.perf-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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;
Expand Down Expand Up @@ -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,
}),
);
});
});
32 changes: 13 additions & 19 deletions tests/unit/OptionsListUtilsTest.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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()', () => {
Expand Down Expand Up @@ -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', () => {
Expand Down
Loading