diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index cbeff2cf3b78..8161315b134a 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -41,6 +41,9 @@ const ONYXKEYS = { /** Boolean flag set whenever we are searching for reports in the server */ RAM_ONLY_IS_SEARCHING_FOR_REPORTS: 'isSearchingForReports', + /** Ordered reportIDs from the latest SearchForReports response, used to display server search results in the tier order Auth returned. */ + RAM_ONLY_SEARCH_RESULT_REPORT_IDS: 'searchResultReportIDs', + /** Boolean flag indicating a SignInWithShortLivedAuthToken request is in flight. RAM-only so an interrupted request never persists a stuck `true` to IndexedDB and blocks future reauth attempts. */ RAM_ONLY_IS_AUTHENTICATING_WITH_SHORT_LIVED_TOKEN: 'isAuthenticatingWithShortLivedToken', @@ -1568,6 +1571,7 @@ type OnyxValuesMapping = { [ONYXKEYS.ONBOARDING_ADMINS_CHAT_REPORT_ID]: string; [ONYXKEYS.ONBOARDING_LAST_VISITED_PATH]: string; [ONYXKEYS.RAM_ONLY_IS_SEARCHING_FOR_REPORTS]: boolean; + [ONYXKEYS.RAM_ONLY_SEARCH_RESULT_REPORT_IDS]: string[]; [ONYXKEYS.RAM_ONLY_IS_AUTHENTICATING_WITH_SHORT_LIVED_TOKEN]: boolean; [ONYXKEYS.LAST_VISITED_PATH]: string | undefined; [ONYXKEYS.REPORT_LAST_VISIT_TIMES]: OnyxTypes.ReportLastVisitTimes; diff --git a/src/components/Search/SearchAutocompleteList.tsx b/src/components/Search/SearchAutocompleteList.tsx index 270127671392..8bd51df51185 100644 --- a/src/components/Search/SearchAutocompleteList.tsx +++ b/src/components/Search/SearchAutocompleteList.tsx @@ -172,6 +172,7 @@ function SearchAutocompleteList({ const [allFeeds] = useOnyx(ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_DOMAIN_MEMBER); const allCards = personalAndWorkspaceCards ?? CONST.EMPTY_OBJECT; const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); + const [searchResultReportIDs] = useOnyx(ONYXKEYS.RAM_ONLY_SEARCH_RESULT_REPORT_IDS); const currentUserPersonalDetails = useCurrentUserPersonalDetails(); const currentUserEmail = currentUserPersonalDetails.email ?? ''; const currentUserAccountID = currentUserPersonalDetails.accountID; @@ -212,7 +213,8 @@ function SearchAutocompleteList({ isUsedInChatFinder: true, includeReadOnly: true, searchQuery: autocompleteQueryValue, - maxResults: CONST.AUTO_COMPLETE_SUGGESTER.MAX_AMOUNT_OF_SUGGESTIONS, + // With an Auth order, keep every matching report instead of just the 20 most recent, so the Auth-order sort below can't drop a top-ranked but old one. + maxResults: searchResultReportIDs && searchResultReportIDs.length > 0 ? listOptions.reports.length : CONST.AUTO_COMPLETE_SUGGESTER.MAX_AMOUNT_OF_SUGGESTIONS, includeUserToInvite: true, includeRecentReports: true, includeCurrentUser: true, @@ -244,6 +246,7 @@ function SearchAutocompleteList({ sortedActions, conciergeReportID, isTrackIntentUser, + searchResultReportIDs, ]); const [isInitialRender, setIsInitialRender] = useState(true); @@ -385,8 +388,23 @@ function SearchAutocompleteList({ reportOptions.push(searchOptions.userToInvite); } + // When the server has returned a tier-ranked order for this search, display results in that order + // instead of the client-side kind/recency order. Reports absent from the list sort to the end. + if (searchResultReportIDs && searchResultReportIDs.length > 0) { + const rankByReportID = new Map(searchResultReportIDs.map((reportID, index) => [reportID, index])); + const rankOf = (option: OptionData) => { + // The selfDM always sorts first when it matches — it's always in Onyx, so it ranks ahead of the server order + // (and need not be included in it). + if (option.isSelfDM) { + return -1; + } + return option.reportID === undefined ? Number.MAX_SAFE_INTEGER : (rankByReportID.get(option.reportID) ?? Number.MAX_SAFE_INTEGER); + }; + reportOptions.sort((a, b) => rankOf(a) - rankOf(b)); + } + return reportOptions.slice(0, 20); - }, [autocompleteQueryValue, searchOptions]); + }, [autocompleteQueryValue, searchOptions, searchResultReportIDs]); // Locked rank map (keyForList -> originalIndex) capturing the order of locally-known // results at the moment the query changes. Recomputed only when the query changes, so server @@ -504,8 +522,17 @@ function SearchAutocompleteList({ customHeader: skeletonHeader, }); } + } else if (searchResultReportIDs && searchResultReportIDs.length > 0) { + // The server returned a tier-ranked order for this query (already applied to recentReportsOptions), + // so render a single list in that order rather than splitting into local/server sections — splitting + // would group local matches separately and break the global tier ordering. + if (nextStyledRecentReports.length > 0 || !isLoadingOptions) { + pushSection({title: translate('search.serverResults'), data: nextStyledRecentReports, sectionIndex: sectionIndex++}); + } else { + pushSection({title: undefined, data: [], sectionIndex: sectionIndex++, customHeader: skeletonHeader}); + } } else { - // Active search: split rows into local (frozen order) and server sections. + // Active search without a server order yet: split rows into local (frozen order) and server sections. const localRows: AutocompleteListItem[] = []; const serverRows: AutocompleteListItem[] = []; for (const item of nextStyledRecentReports) { @@ -568,6 +595,7 @@ function SearchAutocompleteList({ recentSearchesData, searchOptions, searchQueryItems, + searchResultReportIDs, styles, translate, isLoadingOptions, diff --git a/src/libs/actions/Report/index.ts b/src/libs/actions/Report/index.ts index 593da3386411..2dcc2441b686 100644 --- a/src/libs/actions/Report/index.ts +++ b/src/libs/actions/Report/index.ts @@ -5585,6 +5585,10 @@ function searchForReports(isOffline: boolean, searchInput: string, policyID?: st function performServerSearch(searchInput: string, policyID?: string, isUserSearch = false) { // We are not getting isOffline from components as useEffect change will re-trigger the search on network change const isOffline = isOfflineNetwork(); + + // Clear the previous search's server-provided result order so it isn't applied to this query before a fresh response arrives. + Onyx.set(ONYXKEYS.RAM_ONLY_SEARCH_RESULT_REPORT_IDS, []); + if (isOffline || !searchInput.trim().length) { Onyx.set(ONYXKEYS.RAM_ONLY_IS_SEARCHING_FOR_REPORTS, false); return; diff --git a/src/setup/index.ts b/src/setup/index.ts index ce1aa4999eb7..348e26807b34 100644 --- a/src/setup/index.ts +++ b/src/setup/index.ts @@ -65,6 +65,7 @@ export default function () { ONYXKEYS.RAM_ONLY_UPDATE_AVAILABLE, ONYXKEYS.RAM_ONLY_UPDATE_REQUIRED, ONYXKEYS.RAM_ONLY_IS_SEARCHING_FOR_REPORTS, + ONYXKEYS.RAM_ONLY_SEARCH_RESULT_REPORT_IDS, ONYXKEYS.RAM_ONLY_IS_AUTHENTICATING_WITH_SHORT_LIVED_TOKEN, ONYXKEYS.RAM_ONLY_WALLET_ONFIDO, ONYXKEYS.COLLECTION.RAM_ONLY_REPORT_LOADING_STATE, diff --git a/tests/actions/ReportTest.ts b/tests/actions/ReportTest.ts index 16e7cfd9c40b..c6dbb0ec85a1 100644 --- a/tests/actions/ReportTest.ts +++ b/tests/actions/ReportTest.ts @@ -4642,6 +4642,17 @@ describe('actions/Report', () => { const lowerCaseRequest = PersistedRequests.getAll().at(1); expect(upperCaseRequest?.data?.searchInput).toBe(lowerCaseRequest?.data?.searchInput); }); + + it("clears the previous search's result order so a new query does not reuse it", async () => { + await Onyx.set(ONYXKEYS.RAM_ONLY_SEARCH_RESULT_REPORT_IDS, ['1', '2', '3']); + await waitForBatchedUpdates(); + + Report.searchInServer('new query'); + await waitForBatchedUpdates(); + + const orderedReportIDs = await getOnyxValue(ONYXKEYS.RAM_ONLY_SEARCH_RESULT_REPORT_IDS); + expect(orderedReportIDs).toEqual([]); + }); }); describe('searchUserInServer', () => { diff --git a/tests/unit/SearchAutocompleteListTest.tsx b/tests/unit/SearchAutocompleteListTest.tsx index 5f4cfc2e4973..25f6da0b43a5 100644 --- a/tests/unit/SearchAutocompleteListTest.tsx +++ b/tests/unit/SearchAutocompleteListTest.tsx @@ -422,6 +422,150 @@ describe('SearchAutocompleteList', () => { expect(relevantOrder.indexOf('Charlie Report')).toBeLessThan(relevantOrder.indexOf('NewServer Report')); }); + it('should order all search results by the order Auth returned', async () => { + await waitForBatchedUpdates(); + await Onyx.multiSet({ + ...mockedReports, + [ONYXKEYS.PERSONAL_DETAILS_LIST]: mockedPersonalDetails, + [ONYXKEYS.BETAS]: mockedBetas, + }); + + render(); + await flushAllUpdates(); + + // Type a search query to freeze the local rank for the locally-known reports. + const textInput = screen.getByTestId('search-autocomplete-text-input'); + fireEvent.changeText(textInput, 'test'); + await flushAllUpdates(); + + // Simulate server results arriving: three server-only reports the client did not know about, + // returned by getSearchOptions in ascending reportID order (201, 202, 203). + getSearchOptionsSpy.mockReturnValue({ + options: { + recentReports: [ + {reportID: '201', keyForList: '201', text: 'ServerOne Report', alternateText: 'one alt', lastMessageText: 'one'}, + {reportID: '202', keyForList: '202', text: 'ServerTwo Report', alternateText: 'two alt', lastMessageText: 'two'}, + {reportID: '203', keyForList: '203', text: 'ServerThree Report', alternateText: 'three alt', lastMessageText: 'three'}, + ], + personalDetails: [], + currentUserOption: null, + userToInvite: null, + }, + }); + mockUseFilteredOptions.mockReturnValue({ + options: {...mockedOptions}, + isLoading: false, + loadMore: jest.fn(), + hasMore: false, + isLoadingMore: false, + }); + + // Auth returned a DIFFERENT (tier) order: 203, 201, 202. + await act(async () => { + await Onyx.set(ONYXKEYS.RAM_ONLY_SEARCH_RESULT_REPORT_IDS, ['203', '201', '202']); + await Onyx.set(ONYXKEYS.RAM_ONLY_IS_SEARCHING_FOR_REPORTS, false); + }); + await flushAllUpdates(); + + await waitFor(() => { + expect(screen.getByText('Search results')).toBeTruthy(); + }); + + const names = screen + .queryAllByText(/Report$/) + .map((el) => (typeof el.props.children === 'string' ? el.props.children : '')) + .filter((name) => ['ServerOne Report', 'ServerTwo Report', 'ServerThree Report'].includes(name)); + + // The server section must follow Auth's order (203, 201, 202), not the order getSearchOptions returned. + expect(names.indexOf('ServerThree Report')).toBeLessThan(names.indexOf('ServerOne Report')); + expect(names.indexOf('ServerOne Report')).toBeLessThan(names.indexOf('ServerTwo Report')); + }); + + it('should rank the selfDM first even when it is absent from the server order', async () => { + await waitForBatchedUpdates(); + await Onyx.multiSet({ + ...mockedReports, + [ONYXKEYS.PERSONAL_DETAILS_LIST]: mockedPersonalDetails, + [ONYXKEYS.BETAS]: mockedBetas, + }); + + render(); + await flushAllUpdates(); + + const textInput = screen.getByTestId('search-autocomplete-text-input'); + fireEvent.changeText(textInput, 'test'); + await flushAllUpdates(); + + // The selfDM plus two server reports; the server order (below) does NOT include the selfDM. + getSearchOptionsSpy.mockReturnValue({ + options: { + recentReports: [ + {reportID: '301', keyForList: '301', text: 'ServerA Report', alternateText: 'a', lastMessageText: 'a'}, + {reportID: '999', keyForList: '999', text: 'MySelf Report', alternateText: 'me', lastMessageText: 'me', isSelfDM: true}, + {reportID: '302', keyForList: '302', text: 'ServerB Report', alternateText: 'b', lastMessageText: 'b'}, + ], + personalDetails: [], + currentUserOption: null, + userToInvite: null, + }, + }); + mockUseFilteredOptions.mockReturnValue({ + options: {...mockedOptions}, + isLoading: false, + loadMore: jest.fn(), + hasMore: false, + isLoadingMore: false, + }); + + // Server order lists only the two non-selfDM reports. + await act(async () => { + await Onyx.set(ONYXKEYS.RAM_ONLY_SEARCH_RESULT_REPORT_IDS, ['301', '302']); + await Onyx.set(ONYXKEYS.RAM_ONLY_IS_SEARCHING_FOR_REPORTS, false); + }); + await flushAllUpdates(); + + await waitFor(() => { + expect(screen.getByText('Search results')).toBeTruthy(); + }); + + const names = screen + .queryAllByText(/Report$/) + .map((el) => (typeof el.props.children === 'string' ? el.props.children : '')) + .filter((name) => ['MySelf Report', 'ServerA Report', 'ServerB Report'].includes(name)); + + // The selfDM leads, even though it isn't in the server order. + expect(names.indexOf('MySelf Report')).toBe(0); + expect(names.indexOf('MySelf Report')).toBeLessThan(names.indexOf('ServerA Report')); + }); + + it('widens the candidate pool to the full pre-filtered set once the server returns an order', async () => { + await waitForBatchedUpdates(); + await Onyx.multiSet({ + ...mockedReports, + [ONYXKEYS.PERSONAL_DETAILS_LIST]: mockedPersonalDetails, + [ONYXKEYS.BETAS]: mockedBetas, + }); + + render(); + await flushAllUpdates(); + + const textInput = screen.getByTestId('search-autocomplete-text-input'); + fireEvent.changeText(textInput, 'test'); + await flushAllUpdates(); + + // Before a server order arrives, results are capped to the default suggestion limit (by recency). + expect(getSearchOptionsSpy).toHaveBeenLastCalledWith(expect.objectContaining({maxResults: CONST.AUTO_COMPLETE_SUGGESTER.MAX_AMOUNT_OF_SUGGESTIONS})); + + await act(async () => { + await Onyx.set(ONYXKEYS.RAM_ONLY_SEARCH_RESULT_REPORT_IDS, ['101']); + await Onyx.set(ONYXKEYS.RAM_ONLY_IS_SEARCHING_FOR_REPORTS, false); + }); + await flushAllUpdates(); + + // With a server order present, the cap is lifted to the full pre-filtered pool so a low-recency top-tier report isn't culled before the Auth-order sort. + expect(getSearchOptionsSpy).toHaveBeenLastCalledWith(expect.objectContaining({maxResults: mockedOptions.reports.length})); + }); + // Regression test for https://github.com/Expensify/App/issues/93009: after the two-section switcher was // introduced, the first matched chat was no longer highlighted because the highlight focused a fixed flat // index that now lands on the "Recent chats" section header row instead of the first result. As a result