From 946b202b44fea8d5cebacb01368a0829d8e26471 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucien=20Akchot=C3=A9?= Date: Tue, 13 Jan 2026 17:00:47 +0100 Subject: [PATCH 01/27] add `shouldAcceptName` option to GetOptionsConfig --- src/libs/OptionsListUtils/types.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/libs/OptionsListUtils/types.ts b/src/libs/OptionsListUtils/types.ts index 814cc47f115e8..603399bf9d840 100644 --- a/src/libs/OptionsListUtils/types.ts +++ b/src/libs/OptionsListUtils/types.ts @@ -190,6 +190,7 @@ type GetOptionsConfig = { maxElements?: number; maxRecentReportElements?: number; includeUserToInvite?: boolean; + shouldAcceptName?: boolean; } & GetValidReportsConfig; type GetUserToInviteConfig = { From f76623783c0eadbbfac19ebe0f6678dea947ce58 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucien=20Akchot=C3=A9?= Date: Tue, 13 Jan 2026 17:00:54 +0100 Subject: [PATCH 02/27] fix name-only attendee filtering and deduplication --- src/libs/OptionsListUtils/index.ts | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/src/libs/OptionsListUtils/index.ts b/src/libs/OptionsListUtils/index.ts index 4afa797312021..c46fa53cee896 100644 --- a/src/libs/OptionsListUtils/index.ts +++ b/src/libs/OptionsListUtils/index.ts @@ -2126,6 +2126,7 @@ function getValidOptions( maxElements, includeUserToInvite = false, maxRecentReportElements = undefined, + shouldAcceptName = false, ...config }: GetOptionsConfig = {}, countryCode: number = CONST.DEFAULT_COUNTRY_CODE, @@ -2312,6 +2313,7 @@ function getValidOptions( countryCode, { excludeLogins: loginsToExclude, + shouldAcceptName, }, ); } @@ -2441,11 +2443,24 @@ function getFilteredRecentAttendees(personalDetails: OnyxEntry(); + const deduplicatedRecentAttendees = recentAttendees.filter((attendee) => { + const key = attendee.email || attendee.displayName || ''; + if (seenAttendees.has(key)) { + return false; + } + seenAttendees.add(key); + return true; + }); + + const filteredRecentAttendees = deduplicatedRecentAttendees .filter((attendee) => !attendees.find(({email, displayName}) => (attendee.email ? email === attendee.email : displayName === attendee.displayName))) .map((attendee) => ({ ...attendee, - login: attendee.email ?? attendee.displayName, + // Use || instead of ?? to handle empty string email for name-only attendees + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + login: attendee.email || attendee.displayName, ...getPersonalDetailByEmail(attendee.email), })) .map((attendee) => getParticipantsOption(attendee, personalDetails)); From de7be0da567702bbda93553c2dc8a9d0a73bae69 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucien=20Akchot=C3=A9?= Date: Tue, 13 Jan 2026 17:01:01 +0100 Subject: [PATCH 03/27] enable `shouldAcceptName` for attendees context --- src/hooks/useSearchSelector.base.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/hooks/useSearchSelector.base.ts b/src/hooks/useSearchSelector.base.ts index 7196fa0a9ea55..cd87176342b25 100644 --- a/src/hooks/useSearchSelector.base.ts +++ b/src/hooks/useSearchSelector.base.ts @@ -271,6 +271,7 @@ function useSearchSelectorBase({ searchString: computedSearchTerm, includeUserToInvite, includeCurrentUser, + shouldAcceptName: true, }); default: return getEmptyOptions(); From aa16954ec16121b6835b93eec5f17609021fa663 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucien=20Akchot=C3=A9?= Date: Tue, 13 Jan 2026 17:01:08 +0100 Subject: [PATCH 04/27] fix name-only attendee search filter support --- .../SearchFiltersParticipantsSelector.tsx | 127 +++++++++++++++--- 1 file changed, 109 insertions(+), 18 deletions(-) diff --git a/src/components/Search/SearchFiltersParticipantsSelector.tsx b/src/components/Search/SearchFiltersParticipantsSelector.tsx index 9665142e3d087..9e91e8f8d9f93 100644 --- a/src/components/Search/SearchFiltersParticipantsSelector.tsx +++ b/src/components/Search/SearchFiltersParticipantsSelector.tsx @@ -10,7 +10,7 @@ import useOnyx from '@hooks/useOnyx'; import useScreenWrapperTransitionStatus from '@hooks/useScreenWrapperTransitionStatus'; import {canUseTouchScreen} from '@libs/DeviceCapabilities'; import memoize from '@libs/memoize'; -import {filterAndOrderOptions, filterSelectedOptions, formatSectionsFromSearchTerm, getValidOptions} from '@libs/OptionsListUtils'; +import {filterAndOrderOptions, formatSectionsFromSearchTerm, getFilteredRecentAttendees, getValidOptions} from '@libs/OptionsListUtils'; import type {Option, Section} from '@libs/OptionsListUtils'; import type {OptionData} from '@libs/ReportUtils'; import {getDisplayNameForParticipant} from '@libs/ReportUtils'; @@ -18,6 +18,7 @@ import Navigation from '@navigation/Navigation'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; +import type {Attendee} from '@src/types/onyx/IOU'; import SearchFilterPageFooterButtons from './SearchFilterPageFooterButtons'; const defaultListOptions = { @@ -35,6 +36,32 @@ function getSelectedOptionData(option: Option): OptionData { return {...option, selected: true, reportID: option.reportID ?? '-1'}; } +/** + * Creates an OptionData object from a name-only attendee (attendee without a real accountID in personalDetails) + */ +function getOptionDataFromAttendee(attendee: Attendee): OptionData { + return { + text: attendee.displayName, + alternateText: attendee.email || attendee.displayName, + login: attendee.email || attendee.displayName, + displayName: attendee.displayName, + accountID: attendee.accountID ?? CONST.DEFAULT_NUMBER_ID, + // eslint-disable-next-line rulesdir/no-default-id-values + reportID: '-1', + selected: true, + icons: attendee.avatarUrl + ? [ + { + source: attendee.avatarUrl, + type: CONST.ICON_TYPE_AVATAR, + name: attendee.displayName, + }, + ] + : [], + searchText: attendee.searchText ?? attendee.displayName, + }; +} + type SearchFiltersParticipantsSelectorProps = { initialAccountIDs: string[]; onFiltersUpdate: (accountIDs: string[]) => void; @@ -56,12 +83,20 @@ function SearchFiltersParticipantsSelector({initialAccountIDs, onFiltersUpdate}: const cleanSearchTerm = useMemo(() => searchTerm.trim().toLowerCase(), [searchTerm]); const [draftComments] = useOnyx(ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT, {canBeMissing: true}); const [nvpDismissedProductTraining] = useOnyx(ONYXKEYS.NVP_DISMISSED_PRODUCT_TRAINING, {canBeMissing: true}); + const [recentAttendees] = useOnyx(ONYXKEYS.NVP_RECENT_ATTENDEES, {canBeMissing: true}); + + // Transform raw recentAttendees into Option[] format for use with getValidOptions + const recentAttendeeLists = useMemo( + () => getFilteredRecentAttendees(personalDetails, [], recentAttendees ?? []), + [personalDetails, recentAttendees], + ); + const defaultOptions = useMemo(() => { if (!areOptionsInitialized) { return defaultListOptions; } - return memoizedGetValidOptions( + const result = memoizedGetValidOptions( { reports: options.reports, personalDetails: options.personalDetails, @@ -72,13 +107,38 @@ function SearchFiltersParticipantsSelector({initialAccountIDs, onFiltersUpdate}: { excludeLogins: CONST.EXPENSIFY_EMAILS_OBJECT, includeCurrentUser: true, + shouldAcceptName: true, + includeUserToInvite: true, + recentAttendees: recentAttendeeLists, + includeRecentReports: false, }, countryCode, ); - }, [areOptionsInitialized, options.reports, options.personalDetails, draftComments, nvpDismissedProductTraining, loginList, countryCode]); + return result; + }, [areOptionsInitialized, options.reports, options.personalDetails, draftComments, nvpDismissedProductTraining, loginList, countryCode, recentAttendeeLists]); const unselectedOptions = useMemo(() => { - return filterSelectedOptions(defaultOptions, new Set(selectedOptions.map((option) => option.accountID))); + // Filter by both accountID (for regular users) AND login (for name-only attendees) + const selectedAccountIDs = new Set( + selectedOptions.map((option) => option.accountID).filter((id): id is number => !!id && id !== CONST.DEFAULT_NUMBER_ID), + ); + const selectedLogins = new Set(selectedOptions.map((option) => option.login).filter((login): login is string => !!login)); + + const isSelected = (option: {accountID?: number; login?: string}) => { + if (option.accountID && option.accountID !== CONST.DEFAULT_NUMBER_ID && selectedAccountIDs.has(option.accountID)) { + return true; + } + if (option.login && selectedLogins.has(option.login)) { + return true; + } + return false; + }; + + return { + ...defaultOptions, + personalDetails: defaultOptions.personalDetails.filter((option) => !isSelected(option)), + recentReports: defaultOptions.recentReports.filter((option) => !isSelected(option)), + }; }, [defaultOptions, selectedOptions]); const chatOptions = useMemo(() => { @@ -86,7 +146,8 @@ function SearchFiltersParticipantsSelector({initialAccountIDs, onFiltersUpdate}: selectedOptions, excludeLogins: CONST.EXPENSIFY_EMAILS_OBJECT, maxRecentReportsToShow: CONST.IOU.MAX_RECENT_REPORTS_TO_SHOW, - canInviteUser: false, + canInviteUser: true, + shouldAcceptName: true, }); const {currentUserOption} = unselectedOptions; @@ -143,10 +204,15 @@ function SearchFiltersParticipantsSelector({initialAccountIDs, onFiltersUpdate}: newSections.push(formattedResults.section); + // Filter current user from recentReports to avoid duplicate with currentUserOption section + const filteredRecentReports = chatOptions.recentReports.filter( + (report) => report.accountID !== chatOptions.currentUserOption?.accountID, + ); + newSections.push({ title: '', - data: chatOptions.recentReports, - shouldShow: chatOptions.recentReports.length > 0, + data: filteredRecentReports, + shouldShow: filteredRecentReports.length > 0, }); newSections.push({ @@ -169,26 +235,45 @@ function SearchFiltersParticipantsSelector({initialAccountIDs, onFiltersUpdate}: }, []); const applyChanges = useCallback(() => { - const selectedAccountIDs = selectedOptions.map((option) => (option.accountID ? option.accountID.toString() : undefined)).filter(Boolean) as string[]; - onFiltersUpdate(selectedAccountIDs); + const selectedIdentifiers = selectedOptions + .map((option) => { + // For real users (with valid accountID in personalDetails), use accountID + if (option.accountID && option.accountID !== CONST.DEFAULT_NUMBER_ID && personalDetails?.[option.accountID]) { + return option.accountID.toString(); + } + // For name-only attendees, use displayName or login as identifier + return option.displayName || option.login; + }) + .filter(Boolean) as string[]; + onFiltersUpdate(selectedIdentifiers); Navigation.goBack(ROUTES.SEARCH_ADVANCED_FILTERS.getRoute()); - }, [onFiltersUpdate, selectedOptions]); + }, [onFiltersUpdate, selectedOptions, personalDetails]); - // This effect handles setting initial selectedOptions based on accountIDs saved in onyx form + // This effect handles setting initial selectedOptions based on accountIDs or displayNames saved in onyx form useEffect(() => { if (!initialAccountIDs || initialAccountIDs.length === 0 || !personalDetails) { return; } const preSelectedOptions = initialAccountIDs - .map((accountID) => { - const participant = personalDetails[accountID]; - if (!participant) { - return; + .map((identifier) => { + // First, try to look up as accountID in personalDetails + const participant = personalDetails[identifier]; + if (participant) { + return getSelectedOptionData(participant); } - return getSelectedOptionData(participant); + // If not found in personalDetails, this might be a name-only attendee + // Search in recentAttendees by displayName or email + const attendee = recentAttendees?.find( + (recentAttendee) => recentAttendee.displayName === identifier || recentAttendee.email === identifier, + ); + if (attendee) { + return getOptionDataFromAttendee(attendee); + } + + return undefined; }) .filter((option): option is NonNullable => { return !!option; @@ -196,12 +281,13 @@ function SearchFiltersParticipantsSelector({initialAccountIDs, onFiltersUpdate}: setSelectedOptions(preSelectedOptions); // eslint-disable-next-line react-hooks/exhaustive-deps -- this should react only to changes in form data - }, [initialAccountIDs, personalDetails]); + }, [initialAccountIDs, personalDetails, recentAttendees]); const handleParticipantSelection = useCallback( (option: Option) => { const foundOptionIndex = selectedOptions.findIndex((selectedOption: Option) => { - if (selectedOption.accountID && selectedOption.accountID === option?.accountID) { + // Match by accountID for real users (excluding DEFAULT_NUMBER_ID which is 0) + if (selectedOption.accountID && selectedOption.accountID !== CONST.DEFAULT_NUMBER_ID && selectedOption.accountID === option?.accountID) { return true; } @@ -209,6 +295,11 @@ function SearchFiltersParticipantsSelector({initialAccountIDs, onFiltersUpdate}: return true; } + // Match by login for name-only attendees + if (selectedOption.login && selectedOption.login === option?.login) { + return true; + } + return false; }); From 464c711dfbfc7000d6a6b2bdbd526322c3fe17ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucien=20Akchot=C3=A9?= Date: Tue, 13 Jan 2026 17:01:15 +0100 Subject: [PATCH 05/27] fix display name fallback for name-only attendees --- .../Search/UserSelectionListItem.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/components/SelectionListWithSections/Search/UserSelectionListItem.tsx b/src/components/SelectionListWithSections/Search/UserSelectionListItem.tsx index 52266d391e927..ea2cc0057a238 100644 --- a/src/components/SelectionListWithSections/Search/UserSelectionListItem.tsx +++ b/src/components/SelectionListWithSections/Search/UserSelectionListItem.tsx @@ -61,11 +61,12 @@ function UserSelectionListItem({ }, [currentUserPersonalDetails.login, item.login]); const userDisplayName = useMemo(() => { + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing return getDisplayNameForParticipant({ accountID: item.accountID ?? CONST.DEFAULT_NUMBER_ID, formatPhoneNumber, - }); - }, [formatPhoneNumber, item.accountID]); + }) || item.text || ''; + }, [formatPhoneNumber, item.accountID, item.text]); return ( Date: Tue, 13 Jan 2026 17:01:23 +0100 Subject: [PATCH 06/27] fix attendee filter to accept name-only values --- src/libs/SearchQueryUtils.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/libs/SearchQueryUtils.ts b/src/libs/SearchQueryUtils.ts index d9242bb1d3006..6630ade4737f9 100644 --- a/src/libs/SearchQueryUtils.ts +++ b/src/libs/SearchQueryUtils.ts @@ -809,11 +809,14 @@ function buildFilterFormValuesFromQuery( filterKey === CONST.SEARCH.SYNTAX_FILTER_KEYS.FROM || filterKey === CONST.SEARCH.SYNTAX_FILTER_KEYS.TO || filterKey === CONST.SEARCH.SYNTAX_FILTER_KEYS.ASSIGNEE || - filterKey === CONST.SEARCH.SYNTAX_FILTER_KEYS.EXPORTER || - filterKey === CONST.SEARCH.SYNTAX_FILTER_KEYS.ATTENDEE + filterKey === CONST.SEARCH.SYNTAX_FILTER_KEYS.EXPORTER ) { filtersForm[key as typeof filterKey] = filterValues.filter((id) => personalDetails?.[id]); } + if (filterKey === CONST.SEARCH.SYNTAX_FILTER_KEYS.ATTENDEE) { + // Don't filter attendee values by personalDetails - they can be accountIDs OR display names for name-only attendees + filtersForm[key as typeof filterKey] = filterValues; + } if (filterKey === CONST.SEARCH.SYNTAX_FILTER_KEYS.PAYER) { filtersForm[key as typeof filterKey] = filterValues.find((id) => personalDetails?.[id]); From 3b6896536b221db34b42652a157f6dbcbfaf7027 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucien=20Akchot=C3=A9?= Date: Tue, 13 Jan 2026 17:01:30 +0100 Subject: [PATCH 07/27] fix login fallback for name-only attendees --- src/pages/iou/request/MoneyRequestAttendeeSelector.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/pages/iou/request/MoneyRequestAttendeeSelector.tsx b/src/pages/iou/request/MoneyRequestAttendeeSelector.tsx index 36c8b99b1f8ae..baf760f464a09 100644 --- a/src/pages/iou/request/MoneyRequestAttendeeSelector.tsx +++ b/src/pages/iou/request/MoneyRequestAttendeeSelector.tsx @@ -79,7 +79,9 @@ function MoneyRequestAttendeeSelector({attendees = [], onFinish, onAttendeesAdde ...attendee, reportID: CONST.DEFAULT_NUMBER_ID.toString(), selected: true, - login: attendee.email, + // Use || to fall back to displayName for name-only attendees (empty email) + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + login: attendee.email || attendee.displayName, ...getPersonalDetailByEmail(attendee.email), })), [attendees], @@ -208,7 +210,8 @@ function MoneyRequestAttendeeSelector({attendees = [], onFinish, onAttendeesAdde ...attendee, reportID: CONST.DEFAULT_NUMBER_ID.toString(), selected: true, - login: attendee.email, + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + login: attendee.email || attendee.displayName, ...getPersonalDetailByEmail(attendee.email), })), orderedAvailableOptions.recentReports, From 598df1efe302d789866dc972927d659fe1c160b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucien=20Akchot=C3=A9?= Date: Tue, 13 Jan 2026 17:01:39 +0100 Subject: [PATCH 08/27] add tests for `shouldAcceptName` option --- tests/unit/OptionsListUtilsTest.tsx | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/tests/unit/OptionsListUtilsTest.tsx b/tests/unit/OptionsListUtilsTest.tsx index 283a8a23b5890..936eba4248d45 100644 --- a/tests/unit/OptionsListUtilsTest.tsx +++ b/tests/unit/OptionsListUtilsTest.tsx @@ -1966,6 +1966,28 @@ describe('OptionsListUtils', () => { expect(filteredOptions.userToInvite).toBe(null); }); + it('should not return userToInvite for plain text name when shouldAcceptName is false', () => { + // Given a set of options + const options = getValidOptions({reports: OPTIONS.reports, personalDetails: OPTIONS.personalDetails}, {}, nvpDismissedProductTraining, loginList, {includeUserToInvite: true}); + + // When we call filterAndOrderOptions with a plain text name (not email or phone) without shouldAcceptName + const filteredOptions = filterAndOrderOptions(options, 'Jeff Amazon', COUNTRY_CODE, loginList, {shouldAcceptName: false}); + + // Then userToInvite should be null since plain names are not accepted by default + expect(filteredOptions?.userToInvite).toBe(null); + }); + + it('should return userToInvite for plain text name when shouldAcceptName is true', () => { + // Given a set of options + const options = getValidOptions({reports: OPTIONS.reports, personalDetails: OPTIONS.personalDetails}, {}, nvpDismissedProductTraining, loginList, {includeUserToInvite: true}); + + // When we call filterAndOrderOptions with a plain text name (not email or phone) with shouldAcceptName + const filteredOptions = filterAndOrderOptions(options, 'Jeff', COUNTRY_CODE, loginList, {shouldAcceptName: true}); + + // Then userToInvite should be returned for the plain name + expect(filteredOptions?.userToInvite?.text).toBe('jeff'); + }); + it('should not return any options if search value does not match any personal details', () => { // Given a set of options const options = getValidOptions({reports: OPTIONS.reports, personalDetails: OPTIONS.personalDetails}, {}, nvpDismissedProductTraining, loginList); From acaadd1f915176bf68954064fea2554b979aaa82 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucien=20Akchot=C3=A9?= Date: Wed, 14 Jan 2026 11:34:16 +0100 Subject: [PATCH 09/27] fix prettier --- .../SearchFiltersParticipantsSelector.tsx | 17 ++++------------- .../Search/UserSelectionListItem.tsx | 12 ++++++++---- 2 files changed, 12 insertions(+), 17 deletions(-) diff --git a/src/components/Search/SearchFiltersParticipantsSelector.tsx b/src/components/Search/SearchFiltersParticipantsSelector.tsx index 9e91e8f8d9f93..5903cee5d87a8 100644 --- a/src/components/Search/SearchFiltersParticipantsSelector.tsx +++ b/src/components/Search/SearchFiltersParticipantsSelector.tsx @@ -86,10 +86,7 @@ function SearchFiltersParticipantsSelector({initialAccountIDs, onFiltersUpdate}: const [recentAttendees] = useOnyx(ONYXKEYS.NVP_RECENT_ATTENDEES, {canBeMissing: true}); // Transform raw recentAttendees into Option[] format for use with getValidOptions - const recentAttendeeLists = useMemo( - () => getFilteredRecentAttendees(personalDetails, [], recentAttendees ?? []), - [personalDetails, recentAttendees], - ); + const recentAttendeeLists = useMemo(() => getFilteredRecentAttendees(personalDetails, [], recentAttendees ?? []), [personalDetails, recentAttendees]); const defaultOptions = useMemo(() => { if (!areOptionsInitialized) { @@ -119,9 +116,7 @@ function SearchFiltersParticipantsSelector({initialAccountIDs, onFiltersUpdate}: const unselectedOptions = useMemo(() => { // Filter by both accountID (for regular users) AND login (for name-only attendees) - const selectedAccountIDs = new Set( - selectedOptions.map((option) => option.accountID).filter((id): id is number => !!id && id !== CONST.DEFAULT_NUMBER_ID), - ); + const selectedAccountIDs = new Set(selectedOptions.map((option) => option.accountID).filter((id): id is number => !!id && id !== CONST.DEFAULT_NUMBER_ID)); const selectedLogins = new Set(selectedOptions.map((option) => option.login).filter((login): login is string => !!login)); const isSelected = (option: {accountID?: number; login?: string}) => { @@ -205,9 +200,7 @@ function SearchFiltersParticipantsSelector({initialAccountIDs, onFiltersUpdate}: newSections.push(formattedResults.section); // Filter current user from recentReports to avoid duplicate with currentUserOption section - const filteredRecentReports = chatOptions.recentReports.filter( - (report) => report.accountID !== chatOptions.currentUserOption?.accountID, - ); + const filteredRecentReports = chatOptions.recentReports.filter((report) => report.accountID !== chatOptions.currentUserOption?.accountID); newSections.push({ title: '', @@ -266,9 +259,7 @@ function SearchFiltersParticipantsSelector({initialAccountIDs, onFiltersUpdate}: // If not found in personalDetails, this might be a name-only attendee // Search in recentAttendees by displayName or email - const attendee = recentAttendees?.find( - (recentAttendee) => recentAttendee.displayName === identifier || recentAttendee.email === identifier, - ); + const attendee = recentAttendees?.find((recentAttendee) => recentAttendee.displayName === identifier || recentAttendee.email === identifier); if (attendee) { return getOptionDataFromAttendee(attendee); } diff --git a/src/components/SelectionListWithSections/Search/UserSelectionListItem.tsx b/src/components/SelectionListWithSections/Search/UserSelectionListItem.tsx index ea2cc0057a238..762dc41c7c291 100644 --- a/src/components/SelectionListWithSections/Search/UserSelectionListItem.tsx +++ b/src/components/SelectionListWithSections/Search/UserSelectionListItem.tsx @@ -62,10 +62,14 @@ function UserSelectionListItem({ const userDisplayName = useMemo(() => { // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - return getDisplayNameForParticipant({ - accountID: item.accountID ?? CONST.DEFAULT_NUMBER_ID, - formatPhoneNumber, - }) || item.text || ''; + return ( + getDisplayNameForParticipant({ + accountID: item.accountID ?? CONST.DEFAULT_NUMBER_ID, + formatPhoneNumber, + }) || + item.text || + '' + ); }, [formatPhoneNumber, item.accountID, item.text]); return ( From 89ddbbb721e27f3d9cd54ddd5770e1ec9e0ffce3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucien=20Akchot=C3=A9?= Date: Wed, 14 Jan 2026 13:37:26 +0100 Subject: [PATCH 10/27] fix attendees name --- src/pages/Search/AdvancedSearchFilters.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/pages/Search/AdvancedSearchFilters.tsx b/src/pages/Search/AdvancedSearchFilters.tsx index 11f0638320dd8..abcc1b408bdbc 100644 --- a/src/pages/Search/AdvancedSearchFilters.tsx +++ b/src/pages/Search/AdvancedSearchFilters.tsx @@ -288,12 +288,12 @@ function getFilterCardDisplayTitle(filters: Partial, } function getFilterParticipantDisplayTitle(accountIDs: string[], personalDetails: PersonalDetailsList | undefined, formatPhoneNumber: LocaleContextProps['formatPhoneNumber']) { - const selectedPersonalDetails = accountIDs.map((id) => personalDetails?.[id]); - - return selectedPersonalDetails - .map((personalDetail) => { + return accountIDs + .map((id) => { + const personalDetail = personalDetails?.[id]; if (!personalDetail) { - return ''; + // Name-only attendees are stored by displayName, not accountID + return id; } return createDisplayName(personalDetail.login ?? '', personalDetail, formatPhoneNumber); From 0f9b16e4596fb6ad05d36f23c999e2887bfc0f55 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucien=20Akchot=C3=A9?= Date: Wed, 14 Jan 2026 13:58:17 +0100 Subject: [PATCH 11/27] add eslint-disable for intentional || usage --- src/components/Search/SearchFiltersParticipantsSelector.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/components/Search/SearchFiltersParticipantsSelector.tsx b/src/components/Search/SearchFiltersParticipantsSelector.tsx index 5903cee5d87a8..7776b709a5f34 100644 --- a/src/components/Search/SearchFiltersParticipantsSelector.tsx +++ b/src/components/Search/SearchFiltersParticipantsSelector.tsx @@ -42,7 +42,9 @@ function getSelectedOptionData(option: Option): OptionData { function getOptionDataFromAttendee(attendee: Attendee): OptionData { return { text: attendee.displayName, + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- need || to handle empty string email alternateText: attendee.email || attendee.displayName, + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- need || to handle empty string email login: attendee.email || attendee.displayName, displayName: attendee.displayName, accountID: attendee.accountID ?? CONST.DEFAULT_NUMBER_ID, @@ -235,6 +237,7 @@ function SearchFiltersParticipantsSelector({initialAccountIDs, onFiltersUpdate}: return option.accountID.toString(); } // For name-only attendees, use displayName or login as identifier + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- need || to handle empty string return option.displayName || option.login; }) .filter(Boolean) as string[]; From 62c7da59e31de425d54ea268cfdf523bf84e77fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucien=20Akchot=C3=A9?= Date: Wed, 14 Jan 2026 13:58:20 +0100 Subject: [PATCH 12/27] fix eslint-disable for multi-line || expression --- .../SelectionListWithSections/Search/UserSelectionListItem.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/components/SelectionListWithSections/Search/UserSelectionListItem.tsx b/src/components/SelectionListWithSections/Search/UserSelectionListItem.tsx index 762dc41c7c291..9ad6f7405b7ec 100644 --- a/src/components/SelectionListWithSections/Search/UserSelectionListItem.tsx +++ b/src/components/SelectionListWithSections/Search/UserSelectionListItem.tsx @@ -61,7 +61,7 @@ function UserSelectionListItem({ }, [currentUserPersonalDetails.login, item.login]); const userDisplayName = useMemo(() => { - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + /* eslint-disable @typescript-eslint/prefer-nullish-coalescing -- need || to handle empty string from getDisplayNameForParticipant */ return ( getDisplayNameForParticipant({ accountID: item.accountID ?? CONST.DEFAULT_NUMBER_ID, @@ -70,6 +70,7 @@ function UserSelectionListItem({ item.text || '' ); + /* eslint-enable @typescript-eslint/prefer-nullish-coalescing */ }, [formatPhoneNumber, item.accountID, item.text]); return ( From e6ea8e04a88a90f54fe79caefb1974b116fd5337 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucien=20Akchot=C3=A9?= Date: Wed, 14 Jan 2026 13:58:22 +0100 Subject: [PATCH 13/27] add tests for `getFilteredRecentAttendees` --- tests/unit/OptionsListUtilsTest.tsx | 49 +++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/tests/unit/OptionsListUtilsTest.tsx b/tests/unit/OptionsListUtilsTest.tsx index f06560a09d0fb..4ad97a5825da4 100644 --- a/tests/unit/OptionsListUtilsTest.tsx +++ b/tests/unit/OptionsListUtilsTest.tsx @@ -21,6 +21,7 @@ import { filterWorkspaceChats, formatMemberForList, getCurrentUserSearchTerms, + getFilteredRecentAttendees, getLastActorDisplayName, getLastMessageTextForReport, getMemberInviteOptions, @@ -3005,4 +3006,52 @@ describe('OptionsListUtils', () => { expect(result?.login).toBe('Jeff Amazon'); }); }); + + describe('getFilteredRecentAttendees', () => { + it('should deduplicate recent attendees by email', () => { + const personalDetails = {}; + const attendees: Array<{email: string; displayName: string; avatarUrl: string}> = []; + const recentAttendees = [ + {email: 'user1@example.com', displayName: 'User One', avatarUrl: ''}, + {email: 'user1@example.com', displayName: 'User One Duplicate', avatarUrl: ''}, // Duplicate by email + {email: 'user2@example.com', displayName: 'User Two', avatarUrl: ''}, + ]; + + const result = getFilteredRecentAttendees(personalDetails, attendees, recentAttendees); + + // Should deduplicate by email - user1@example.com should only appear once + const logins = result.map((r) => r.login); + const user1Count = logins.filter((login) => login === 'user1@example.com').length; + expect(user1Count).toBe(1); + }); + + it('should deduplicate name-only attendees by displayName', () => { + const personalDetails = {}; + const attendees: Array<{email: string; displayName: string; avatarUrl: string}> = []; + const recentAttendees = [ + {email: '', displayName: 'Name Only', avatarUrl: ''}, + {email: '', displayName: 'Name Only', avatarUrl: ''}, // Duplicate by displayName (name-only attendee) + {email: '', displayName: 'Another Name', avatarUrl: ''}, + ]; + + const result = getFilteredRecentAttendees(personalDetails, attendees, recentAttendees); + + // Should deduplicate by displayName - Name Only should only appear once + const logins = result.map((r) => r.login); + const nameOnlyCount = logins.filter((login) => login === 'Name Only').length; + expect(nameOnlyCount).toBe(1); + }); + + it('should use displayName as login for name-only attendees', () => { + const personalDetails = {}; + const attendees: Array<{email: string; displayName: string; avatarUrl: string}> = []; + const recentAttendees = [{email: '', displayName: 'John Smith', avatarUrl: ''}]; + + const result = getFilteredRecentAttendees(personalDetails, attendees, recentAttendees); + + // Name-only attendee should have displayName as login + const johnSmith = result.find((r) => r.login === 'John Smith'); + expect(johnSmith).toBeDefined(); + }); + }); }); From 5bedf95a8d4bbaeb9a8dc56794db65338bdb34b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucien=20Akchot=C3=A9?= Date: Wed, 14 Jan 2026 13:58:26 +0100 Subject: [PATCH 14/27] add test for attendee filter name preservation --- tests/unit/Search/SearchQueryUtilsTest.ts | 29 +++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/tests/unit/Search/SearchQueryUtilsTest.ts b/tests/unit/Search/SearchQueryUtilsTest.ts index 2e3b935d15f63..0155b91c174bb 100644 --- a/tests/unit/Search/SearchQueryUtilsTest.ts +++ b/tests/unit/Search/SearchQueryUtilsTest.ts @@ -465,6 +465,35 @@ describe('SearchQueryUtils', () => { amountEqualTo: '-54321', }); }); + + test('attendee filter preserves name-only attendees without filtering by personalDetails', () => { + const policyCategories = {}; + const policyTags = {}; + const currencyList = {}; + const personalDetails = { + 12345: {accountID: 12345, login: 'user@example.com'}, + }; + const cardList = {}; + const reports = {}; + const taxRates = {}; + + // Test with mix of accountID and name-only attendee + const queryString = 'sortBy:date sortOrder:desc type:expense attendee:12345,ZZ'; + const queryJSON = buildSearchQueryJSON(queryString); + + if (!queryJSON) { + throw new Error('Failed to parse query string'); + } + + const result = buildFilterFormValuesFromQuery(queryJSON, policyCategories, policyTags, currencyList, personalDetails, cardList, reports, taxRates); + + // Both values should be preserved - name-only attendees should not be filtered out + expect(result).toEqual({ + type: 'expense', + status: CONST.SEARCH.STATUS.EXPENSE.ALL, + attendee: ['12345', 'ZZ'], + }); + }); }); describe('shouldHighlight', () => { From 2f036de523c8cdc8cfb6857c47709a5459b86337 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucien=20Akchot=C3=A9?= Date: Fri, 16 Jan 2026 11:43:10 +0100 Subject: [PATCH 15/27] preserve name-only attendee filters across sessions --- .../Search/SearchFiltersParticipantsSelector.tsx | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/src/components/Search/SearchFiltersParticipantsSelector.tsx b/src/components/Search/SearchFiltersParticipantsSelector.tsx index 7776b709a5f34..b2719869d62cc 100644 --- a/src/components/Search/SearchFiltersParticipantsSelector.tsx +++ b/src/components/Search/SearchFiltersParticipantsSelector.tsx @@ -267,7 +267,20 @@ function SearchFiltersParticipantsSelector({initialAccountIDs, onFiltersUpdate}: return getOptionDataFromAttendee(attendee); } - return undefined; + // Fallback: construct a minimal option from the identifier string to preserve + // name-only filters across sessions (e.g., after cache clear or on another device) + return { + text: identifier, + alternateText: identifier, + login: identifier, + displayName: identifier, + accountID: CONST.DEFAULT_NUMBER_ID, + // eslint-disable-next-line rulesdir/no-default-id-values + reportID: '-1', + selected: true, + icons: [], + searchText: identifier, + }; }) .filter((option): option is NonNullable => { return !!option; From 90e7127aa1197963ceb0922e503bde89f00fc7ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucien=20Akchot=C3=A9?= Date: Fri, 16 Jan 2026 15:51:59 +0100 Subject: [PATCH 16/27] skip reportID match for default value in selection --- src/components/Search/SearchFiltersParticipantsSelector.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/components/Search/SearchFiltersParticipantsSelector.tsx b/src/components/Search/SearchFiltersParticipantsSelector.tsx index b2719869d62cc..1c36d56d8cc3c 100644 --- a/src/components/Search/SearchFiltersParticipantsSelector.tsx +++ b/src/components/Search/SearchFiltersParticipantsSelector.tsx @@ -298,7 +298,8 @@ function SearchFiltersParticipantsSelector({initialAccountIDs, onFiltersUpdate}: return true; } - if (selectedOption.reportID && selectedOption.reportID === option?.reportID) { + // Skip reportID match for default '-1' value (used by name-only attendees) + if (selectedOption.reportID && selectedOption.reportID !== '-1' && selectedOption.reportID === option?.reportID) { return true; } From f7bb7191ce7101c15a337125a4b004c8cca2ae68 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucien=20Akchot=C3=A9?= Date: Fri, 16 Jan 2026 16:30:56 +0100 Subject: [PATCH 17/27] add `shouldAllowNameOnlyOptions` prop to gate name-only behavior --- .../SearchFiltersParticipantsSelector.tsx | 174 +++++++++++------- 1 file changed, 107 insertions(+), 67 deletions(-) diff --git a/src/components/Search/SearchFiltersParticipantsSelector.tsx b/src/components/Search/SearchFiltersParticipantsSelector.tsx index 1c36d56d8cc3c..5117f0fba066f 100644 --- a/src/components/Search/SearchFiltersParticipantsSelector.tsx +++ b/src/components/Search/SearchFiltersParticipantsSelector.tsx @@ -10,7 +10,7 @@ import useOnyx from '@hooks/useOnyx'; import useScreenWrapperTransitionStatus from '@hooks/useScreenWrapperTransitionStatus'; import {canUseTouchScreen} from '@libs/DeviceCapabilities'; import memoize from '@libs/memoize'; -import {filterAndOrderOptions, formatSectionsFromSearchTerm, getFilteredRecentAttendees, getValidOptions} from '@libs/OptionsListUtils'; +import {filterAndOrderOptions, filterSelectedOptions, formatSectionsFromSearchTerm, getFilteredRecentAttendees, getValidOptions} from '@libs/OptionsListUtils'; import type {Option, Section} from '@libs/OptionsListUtils'; import type {OptionData} from '@libs/ReportUtils'; import {getDisplayNameForParticipant} from '@libs/ReportUtils'; @@ -67,9 +67,11 @@ function getOptionDataFromAttendee(attendee: Attendee): OptionData { type SearchFiltersParticipantsSelectorProps = { initialAccountIDs: string[]; onFiltersUpdate: (accountIDs: string[]) => void; + /** Whether to allow name-only options (for attendee filter only) */ + shouldAllowNameOnlyOptions?: boolean; }; -function SearchFiltersParticipantsSelector({initialAccountIDs, onFiltersUpdate}: SearchFiltersParticipantsSelectorProps) { +function SearchFiltersParticipantsSelector({initialAccountIDs, onFiltersUpdate, shouldAllowNameOnlyOptions = false}: SearchFiltersParticipantsSelectorProps) { const {translate, formatPhoneNumber} = useLocalize(); const personalDetails = usePersonalDetails(); const {didScreenTransitionEnd} = useScreenWrapperTransitionStatus(); @@ -87,8 +89,11 @@ function SearchFiltersParticipantsSelector({initialAccountIDs, onFiltersUpdate}: const [nvpDismissedProductTraining] = useOnyx(ONYXKEYS.NVP_DISMISSED_PRODUCT_TRAINING, {canBeMissing: true}); const [recentAttendees] = useOnyx(ONYXKEYS.NVP_RECENT_ATTENDEES, {canBeMissing: true}); - // Transform raw recentAttendees into Option[] format for use with getValidOptions - const recentAttendeeLists = useMemo(() => getFilteredRecentAttendees(personalDetails, [], recentAttendees ?? []), [personalDetails, recentAttendees]); + // Transform raw recentAttendees into Option[] format for use with getValidOptions (only for attendee filter) + const recentAttendeeLists = useMemo( + () => (shouldAllowNameOnlyOptions ? getFilteredRecentAttendees(personalDetails, [], recentAttendees ?? []) : []), + [personalDetails, recentAttendees, shouldAllowNameOnlyOptions], + ); const defaultOptions = useMemo(() => { if (!areOptionsInitialized) { @@ -106,18 +111,22 @@ function SearchFiltersParticipantsSelector({initialAccountIDs, onFiltersUpdate}: { excludeLogins: CONST.EXPENSIFY_EMAILS_OBJECT, includeCurrentUser: true, - shouldAcceptName: true, - includeUserToInvite: true, + shouldAcceptName: shouldAllowNameOnlyOptions, + includeUserToInvite: shouldAllowNameOnlyOptions, recentAttendees: recentAttendeeLists, includeRecentReports: false, }, countryCode, ); return result; - }, [areOptionsInitialized, options.reports, options.personalDetails, draftComments, nvpDismissedProductTraining, loginList, countryCode, recentAttendeeLists]); + }, [areOptionsInitialized, options.reports, options.personalDetails, draftComments, nvpDismissedProductTraining, loginList, countryCode, recentAttendeeLists, shouldAllowNameOnlyOptions]); const unselectedOptions = useMemo(() => { - // Filter by both accountID (for regular users) AND login (for name-only attendees) + if (!shouldAllowNameOnlyOptions) { + return filterSelectedOptions(defaultOptions, new Set(selectedOptions.map((option) => option.accountID))); + } + + // For name-only options, filter by both accountID (for regular users) AND login (for name-only attendees) const selectedAccountIDs = new Set(selectedOptions.map((option) => option.accountID).filter((id): id is number => !!id && id !== CONST.DEFAULT_NUMBER_ID)); const selectedLogins = new Set(selectedOptions.map((option) => option.login).filter((login): login is string => !!login)); @@ -136,15 +145,15 @@ function SearchFiltersParticipantsSelector({initialAccountIDs, onFiltersUpdate}: personalDetails: defaultOptions.personalDetails.filter((option) => !isSelected(option)), recentReports: defaultOptions.recentReports.filter((option) => !isSelected(option)), }; - }, [defaultOptions, selectedOptions]); + }, [defaultOptions, selectedOptions, shouldAllowNameOnlyOptions]); const chatOptions = useMemo(() => { const filteredOptions = filterAndOrderOptions(unselectedOptions, cleanSearchTerm, countryCode, loginList, { selectedOptions, excludeLogins: CONST.EXPENSIFY_EMAILS_OBJECT, maxRecentReportsToShow: CONST.IOU.MAX_RECENT_REPORTS_TO_SHOW, - canInviteUser: true, - shouldAcceptName: true, + canInviteUser: shouldAllowNameOnlyOptions, + shouldAcceptName: shouldAllowNameOnlyOptions, }); const {currentUserOption} = unselectedOptions; @@ -155,7 +164,7 @@ function SearchFiltersParticipantsSelector({initialAccountIDs, onFiltersUpdate}: } return filteredOptions; - }, [unselectedOptions, cleanSearchTerm, countryCode, loginList, selectedOptions]); + }, [unselectedOptions, cleanSearchTerm, countryCode, loginList, selectedOptions, shouldAllowNameOnlyOptions]); const {sections, headerMessage} = useMemo(() => { const newSections: Section[] = []; @@ -230,81 +239,112 @@ function SearchFiltersParticipantsSelector({initialAccountIDs, onFiltersUpdate}: }, []); const applyChanges = useCallback(() => { - const selectedIdentifiers = selectedOptions - .map((option) => { - // For real users (with valid accountID in personalDetails), use accountID - if (option.accountID && option.accountID !== CONST.DEFAULT_NUMBER_ID && personalDetails?.[option.accountID]) { - return option.accountID.toString(); - } - // For name-only attendees, use displayName or login as identifier - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- need || to handle empty string - return option.displayName || option.login; - }) - .filter(Boolean) as string[]; - onFiltersUpdate(selectedIdentifiers); + let selectedIdentifiers: string[]; + + if (shouldAllowNameOnlyOptions) { + selectedIdentifiers = selectedOptions + .map((option) => { + // For real users (with valid accountID in personalDetails), use accountID + if (option.accountID && option.accountID !== CONST.DEFAULT_NUMBER_ID && personalDetails?.[option.accountID]) { + return option.accountID.toString(); + } + // For name-only attendees, use displayName or login as identifier + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- need || to handle empty string + return option.displayName || option.login; + }) + .filter(Boolean) as string[]; + } else { + selectedIdentifiers = selectedOptions.map((option) => (option.accountID ? option.accountID.toString() : undefined)).filter(Boolean) as string[]; + } + onFiltersUpdate(selectedIdentifiers); Navigation.goBack(ROUTES.SEARCH_ADVANCED_FILTERS.getRoute()); - }, [onFiltersUpdate, selectedOptions, personalDetails]); + }, [onFiltersUpdate, selectedOptions, personalDetails, shouldAllowNameOnlyOptions]); - // This effect handles setting initial selectedOptions based on accountIDs or displayNames saved in onyx form + // This effect handles setting initial selectedOptions based on accountIDs (or displayNames for attendee filter) useEffect(() => { if (!initialAccountIDs || initialAccountIDs.length === 0 || !personalDetails) { return; } - const preSelectedOptions = initialAccountIDs - .map((identifier) => { - // First, try to look up as accountID in personalDetails - const participant = personalDetails[identifier]; - if (participant) { + let preSelectedOptions: OptionData[]; + + if (shouldAllowNameOnlyOptions) { + preSelectedOptions = initialAccountIDs + .map((identifier) => { + // First, try to look up as accountID in personalDetails + const participant = personalDetails[identifier]; + if (participant) { + return getSelectedOptionData(participant); + } + + // If not found in personalDetails, this might be a name-only attendee + // Search in recentAttendees by displayName or email + const attendee = recentAttendees?.find((recentAttendee) => recentAttendee.displayName === identifier || recentAttendee.email === identifier); + if (attendee) { + return getOptionDataFromAttendee(attendee); + } + + // Fallback: construct a minimal option from the identifier string to preserve + // name-only filters across sessions (e.g., after cache clear or on another device) + return { + text: identifier, + alternateText: identifier, + login: identifier, + displayName: identifier, + accountID: CONST.DEFAULT_NUMBER_ID, + // eslint-disable-next-line rulesdir/no-default-id-values + reportID: '-1', + selected: true, + icons: [], + searchText: identifier, + }; + }) + .filter((option): option is NonNullable => !!option); + } else { + preSelectedOptions = initialAccountIDs + .map((accountID) => { + const participant = personalDetails[accountID]; + if (!participant) { + return undefined; + } return getSelectedOptionData(participant); - } - - // If not found in personalDetails, this might be a name-only attendee - // Search in recentAttendees by displayName or email - const attendee = recentAttendees?.find((recentAttendee) => recentAttendee.displayName === identifier || recentAttendee.email === identifier); - if (attendee) { - return getOptionDataFromAttendee(attendee); - } - - // Fallback: construct a minimal option from the identifier string to preserve - // name-only filters across sessions (e.g., after cache clear or on another device) - return { - text: identifier, - alternateText: identifier, - login: identifier, - displayName: identifier, - accountID: CONST.DEFAULT_NUMBER_ID, - // eslint-disable-next-line rulesdir/no-default-id-values - reportID: '-1', - selected: true, - icons: [], - searchText: identifier, - }; - }) - .filter((option): option is NonNullable => { - return !!option; - }); + }) + .filter((option): option is NonNullable => !!option); + } setSelectedOptions(preSelectedOptions); // eslint-disable-next-line react-hooks/exhaustive-deps -- this should react only to changes in form data - }, [initialAccountIDs, personalDetails, recentAttendees]); + }, [initialAccountIDs, personalDetails, recentAttendees, shouldAllowNameOnlyOptions]); const handleParticipantSelection = useCallback( (option: Option) => { const foundOptionIndex = selectedOptions.findIndex((selectedOption: Option) => { - // Match by accountID for real users (excluding DEFAULT_NUMBER_ID which is 0) - if (selectedOption.accountID && selectedOption.accountID !== CONST.DEFAULT_NUMBER_ID && selectedOption.accountID === option?.accountID) { - return true; + if (shouldAllowNameOnlyOptions) { + // Match by accountID for real users (excluding DEFAULT_NUMBER_ID which is 0) + if (selectedOption.accountID && selectedOption.accountID !== CONST.DEFAULT_NUMBER_ID && selectedOption.accountID === option?.accountID) { + return true; + } + + // Skip reportID match for default '-1' value (used by name-only attendees) + if (selectedOption.reportID && selectedOption.reportID !== '-1' && selectedOption.reportID === option?.reportID) { + return true; + } + + // Match by login for name-only attendees + if (selectedOption.login && selectedOption.login === option?.login) { + return true; + } + + return false; } - // Skip reportID match for default '-1' value (used by name-only attendees) - if (selectedOption.reportID && selectedOption.reportID !== '-1' && selectedOption.reportID === option?.reportID) { + // For non-name-only filters, use simple accountID and reportID matching + if (selectedOption.accountID && selectedOption.accountID === option?.accountID) { return true; } - // Match by login for name-only attendees - if (selectedOption.login && selectedOption.login === option?.login) { + if (selectedOption.reportID && selectedOption.reportID === option?.reportID) { return true; } @@ -318,7 +358,7 @@ function SearchFiltersParticipantsSelector({initialAccountIDs, onFiltersUpdate}: setSelectedOptions(newSelectedOptions); } }, - [selectedOptions], + [selectedOptions, shouldAllowNameOnlyOptions], ); const footerContent = useMemo( From 0af64d4dc48b040cee8428515de42ebac34ef5b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucien=20Akchot=C3=A9?= Date: Fri, 16 Jan 2026 16:31:02 +0100 Subject: [PATCH 18/27] enable name-only options for attendee filter --- .../SearchAdvancedFiltersPage/SearchFiltersAttendeePage.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersAttendeePage.tsx b/src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersAttendeePage.tsx index d2f67a5d3c3b4..e22dd30627ee0 100644 --- a/src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersAttendeePage.tsx +++ b/src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersAttendeePage.tsx @@ -39,6 +39,7 @@ function SearchFiltersAttendeePage() { attendee: selectedAccountIDs, }); }} + shouldAllowNameOnlyOptions /> From 9795d5deb2629bfcdb770dffd0eff649e8c6bf06 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucien=20Akchot=C3=A9?= Date: Fri, 16 Jan 2026 16:44:22 +0100 Subject: [PATCH 19/27] add blank lines --- src/components/Search/SearchFiltersParticipantsSelector.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/components/Search/SearchFiltersParticipantsSelector.tsx b/src/components/Search/SearchFiltersParticipantsSelector.tsx index 5117f0fba066f..78507a170ce1d 100644 --- a/src/components/Search/SearchFiltersParticipantsSelector.tsx +++ b/src/components/Search/SearchFiltersParticipantsSelector.tsx @@ -67,6 +67,7 @@ function getOptionDataFromAttendee(attendee: Attendee): OptionData { type SearchFiltersParticipantsSelectorProps = { initialAccountIDs: string[]; onFiltersUpdate: (accountIDs: string[]) => void; + /** Whether to allow name-only options (for attendee filter only) */ shouldAllowNameOnlyOptions?: boolean; }; @@ -248,6 +249,7 @@ function SearchFiltersParticipantsSelector({initialAccountIDs, onFiltersUpdate, if (option.accountID && option.accountID !== CONST.DEFAULT_NUMBER_ID && personalDetails?.[option.accountID]) { return option.accountID.toString(); } + // For name-only attendees, use displayName or login as identifier // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- need || to handle empty string return option.displayName || option.login; From d67de863d960fcb489e6f742b4822d8aa531d8f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucien=20Akchot=C3=A9?= Date: Fri, 16 Jan 2026 16:50:46 +0100 Subject: [PATCH 20/27] fix style --- .../Search/SearchFiltersParticipantsSelector.tsx | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/components/Search/SearchFiltersParticipantsSelector.tsx b/src/components/Search/SearchFiltersParticipantsSelector.tsx index 78507a170ce1d..c7462ade39460 100644 --- a/src/components/Search/SearchFiltersParticipantsSelector.tsx +++ b/src/components/Search/SearchFiltersParticipantsSelector.tsx @@ -120,7 +120,17 @@ function SearchFiltersParticipantsSelector({initialAccountIDs, onFiltersUpdate, countryCode, ); return result; - }, [areOptionsInitialized, options.reports, options.personalDetails, draftComments, nvpDismissedProductTraining, loginList, countryCode, recentAttendeeLists, shouldAllowNameOnlyOptions]); + }, [ + areOptionsInitialized, + options.reports, + options.personalDetails, + draftComments, + nvpDismissedProductTraining, + loginList, + countryCode, + recentAttendeeLists, + shouldAllowNameOnlyOptions, + ]); const unselectedOptions = useMemo(() => { if (!shouldAllowNameOnlyOptions) { @@ -249,7 +259,7 @@ function SearchFiltersParticipantsSelector({initialAccountIDs, onFiltersUpdate, if (option.accountID && option.accountID !== CONST.DEFAULT_NUMBER_ID && personalDetails?.[option.accountID]) { return option.accountID.toString(); } - + // For name-only attendees, use displayName or login as identifier // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- need || to handle empty string return option.displayName || option.login; From 60cb539e3d3d328f57b113ebeeafc380dc93fb61 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucien=20Akchot=C3=A9?= Date: Mon, 19 Jan 2026 08:08:48 +0100 Subject: [PATCH 21/27] pass raw search input to options filtering --- src/hooks/useSearchSelector.base.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/hooks/useSearchSelector.base.ts b/src/hooks/useSearchSelector.base.ts index cd87176342b25..766094567746b 100644 --- a/src/hooks/useSearchSelector.base.ts +++ b/src/hooks/useSearchSelector.base.ts @@ -175,6 +175,7 @@ function useSearchSelectorBase({ const computedSearchTerm = useMemo(() => { return getSearchValueForPhoneOrEmail(debouncedSearchTerm, countryCode); }, [debouncedSearchTerm, countryCode]); + const trimmedSearchInput = debouncedSearchTerm.trim(); const baseOptions = useMemo(() => { if (!areOptionsInitialized) { @@ -206,6 +207,7 @@ function useSearchSelectorBase({ maxElements: maxResults, maxRecentReportElements: maxRecentReportsToShow, searchString: computedSearchTerm, + searchInputValue: trimmedSearchInput, includeUserToInvite, }); case CONST.SEARCH_SELECTOR.SEARCH_CONTEXT_GENERAL: @@ -213,6 +215,7 @@ function useSearchSelectorBase({ ...getValidOptionsConfig, betas: betas ?? [], searchString: computedSearchTerm, + searchInputValue: trimmedSearchInput, maxElements: maxResults, maxRecentReportElements: maxRecentReportsToShow, includeUserToInvite, @@ -234,6 +237,7 @@ function useSearchSelectorBase({ includeThreads: true, includeReadOnly: false, searchString: computedSearchTerm, + searchInputValue: trimmedSearchInput, maxElements: maxResults, includeUserToInvite, }, @@ -254,6 +258,7 @@ function useSearchSelectorBase({ includeOwnedWorkspaceChats: true, includeSelfDM: true, searchString: computedSearchTerm, + searchInputValue: trimmedSearchInput, maxElements: maxResults, includeUserToInvite, }); @@ -269,6 +274,7 @@ function useSearchSelectorBase({ maxElements: maxResults, maxRecentReportElements: maxRecentReportsToShow, searchString: computedSearchTerm, + searchInputValue: trimmedSearchInput, includeUserToInvite, includeCurrentUser, shouldAcceptName: true, @@ -294,6 +300,7 @@ function useSearchSelectorBase({ getValidOptionsConfig, selectedOptions, includeCurrentUser, + trimmedSearchInput, ]); const isOptionSelected = useMemo(() => { From 6423a0fd23b3d3a2d40b042dbe1afcb5fab0ce04 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucien=20Akchot=C3=A9?= Date: Mon, 19 Jan 2026 08:08:59 +0100 Subject: [PATCH 22/27] preserve name casing in invite options --- src/libs/OptionsListUtils/index.ts | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/src/libs/OptionsListUtils/index.ts b/src/libs/OptionsListUtils/index.ts index 1b58aee15335d..2dfe93e81cf38 100644 --- a/src/libs/OptionsListUtils/index.ts +++ b/src/libs/OptionsListUtils/index.ts @@ -1686,6 +1686,7 @@ function canCreateOptimisticPersonalDetailOption({ */ function getUserToInviteOption({ searchValue, + searchInputValue, loginsToExclude = {}, selectedOptions = [], showChatPreviewLine = false, @@ -1703,6 +1704,9 @@ function getUserToInviteOption({ const isValidEmail = Str.isValidEmail(searchValue) && !Str.isDomainEmail(searchValue) && !Str.endsWith(searchValue, CONST.SMS.DOMAIN); const isValidPhoneNumber = parsedPhoneNumber.possible && Str.isValidE164Phone(getPhoneNumberWithoutSpecialChars(parsedPhoneNumber.number?.input ?? '')); const isInOptionToExclude = loginsToExclude[addSMSDomainIfPhoneNumber(searchValue).toLowerCase()]; + const trimmedSearchInputValue = searchInputValue?.trim(); + const shouldUseSearchInputValue = shouldAcceptName && !!trimmedSearchInputValue && !isValidEmail && !isValidPhoneNumber; + const displayValue = shouldUseSearchInputValue ? trimmedSearchInputValue : searchValue; // Angle brackets are not valid characters for user names const hasInvalidCharacters = shouldAcceptName && (searchValue.includes('<') || searchValue.includes('>')); @@ -1726,9 +1730,11 @@ function getUserToInviteOption({ userToInvite.isOptimisticAccount = true; userToInvite.login = searchValue; // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - userToInvite.text = userToInvite.text || searchValue; + userToInvite.text = userToInvite.text || displayValue; // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - userToInvite.alternateText = userToInvite.alternateText || searchValue; + userToInvite.displayName = userToInvite.displayName || displayValue; + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + userToInvite.alternateText = userToInvite.alternateText || displayValue; // If user doesn't exist, use a fallback avatar userToInvite.icons = [ @@ -2143,6 +2149,7 @@ function getValidOptions( excludeHiddenThreads = false, canShowManagerMcTest = false, searchString, + searchInputValue, maxElements, includeUserToInvite = false, maxRecentReportElements = undefined, @@ -2334,6 +2341,7 @@ function getValidOptions( { excludeLogins: loginsToExclude, shouldAcceptName, + searchInputValue, }, ); } @@ -2835,6 +2843,7 @@ function filterSelfDMChat(report: SearchOptionData, searchTerms: string[]): Sear function filterOptions(options: Options, searchInputValue: string, countryCode: number, loginList: OnyxEntry, config?: FilterUserToInviteConfig): Options { const trimmedSearchInput = searchInputValue.trim(); + const searchInputValueForInvite = config?.searchInputValue ?? trimmedSearchInput; // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing const parsedPhoneNumber = parsePhoneNumber(appendCountryCode(Str.removeSMSDomain(trimmedSearchInput), countryCode || CONST.DEFAULT_COUNTRY_CODE)); @@ -2853,7 +2862,10 @@ function filterOptions(options: Options, searchInputValue: string, countryCode: searchValue, loginList, countryCode, - config, + { + ...config, + searchInputValue: searchInputValueForInvite, + }, ); const workspaceChats = filterWorkspaceChats(options.workspaceChats ?? [], searchTerms); From ee55089f4f6312378995366a43f4c8fcf0a9a5e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucien=20Akchot=C3=A9?= Date: Mon, 19 Jan 2026 08:09:05 +0100 Subject: [PATCH 23/27] add searchinputvalue to options config types --- src/libs/OptionsListUtils/types.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/libs/OptionsListUtils/types.ts b/src/libs/OptionsListUtils/types.ts index fb2b73e3f30ec..955d112594bdb 100644 --- a/src/libs/OptionsListUtils/types.ts +++ b/src/libs/OptionsListUtils/types.ts @@ -190,6 +190,7 @@ type GetOptionsConfig = { excludeHiddenThreads?: boolean; canShowManagerMcTest?: boolean; searchString?: string; + searchInputValue?: string; maxElements?: number; maxRecentReportElements?: number; includeUserToInvite?: boolean; @@ -198,6 +199,7 @@ type GetOptionsConfig = { type GetUserToInviteConfig = { searchValue: string | undefined; + searchInputValue?: string; loginsToExclude?: Record; reportActions?: ReportActions; firstName?: string; @@ -246,7 +248,7 @@ type PreviewConfig = { isSelected?: boolean; }; -type FilterUserToInviteConfig = Pick & { +type FilterUserToInviteConfig = Pick & { canInviteUser?: boolean; excludeLogins?: Record; }; From 4f0e5fdfbb400c9ed8f0d4d7bc27c7401c8491c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucien=20Akchot=C3=A9?= Date: Mon, 19 Jan 2026 15:27:59 +0100 Subject: [PATCH 24/27] dedupe recent attendees by email or display name --- src/libs/actions/IOU/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/libs/actions/IOU/index.ts b/src/libs/actions/IOU/index.ts index ba5da0b670014..589fc9f30e4eb 100644 --- a/src/libs/actions/IOU/index.ts +++ b/src/libs/actions/IOU/index.ts @@ -4394,7 +4394,7 @@ function getUpdateMoneyRequestParams(params: GetUpdateMoneyRequestParamsType): U value: lodashUnionBy( transactionChanges.attendees?.map(({avatarUrl, displayName, email}) => ({avatarUrl, displayName, email})), recentAttendees, - 'email', + (attendee) => attendee.email || attendee.displayName, ).slice(0, CONST.IOU.MAX_RECENT_REPORTS_TO_SHOW), }); } From ed1489318572fe495539d03d1ffdd0f10b803190 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucien=20Akchot=C3=A9?= Date: Mon, 19 Jan 2026 17:11:47 +0100 Subject: [PATCH 25/27] fix name-only attendees filter in sections --- .../Search/SearchFiltersParticipantsSelector.tsx | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/components/Search/SearchFiltersParticipantsSelector.tsx b/src/components/Search/SearchFiltersParticipantsSelector.tsx index c7462ade39460..c85fdfaeb9524 100644 --- a/src/components/Search/SearchFiltersParticipantsSelector.tsx +++ b/src/components/Search/SearchFiltersParticipantsSelector.tsx @@ -101,7 +101,7 @@ function SearchFiltersParticipantsSelector({initialAccountIDs, onFiltersUpdate, return defaultListOptions; } - const result = memoizedGetValidOptions( + return memoizedGetValidOptions( { reports: options.reports, personalDetails: options.personalDetails, @@ -119,7 +119,6 @@ function SearchFiltersParticipantsSelector({initialAccountIDs, onFiltersUpdate, }, countryCode, ); - return result; }, [ areOptionsInitialized, options.reports, @@ -222,7 +221,11 @@ function SearchFiltersParticipantsSelector({initialAccountIDs, onFiltersUpdate, newSections.push(formattedResults.section); // Filter current user from recentReports to avoid duplicate with currentUserOption section - const filteredRecentReports = chatOptions.recentReports.filter((report) => report.accountID !== chatOptions.currentUserOption?.accountID); + // Only filter if both the report and currentUserOption have valid accountIDs to avoid + // accidentally filtering out name-only attendees (which have accountID: undefined) + const filteredRecentReports = chatOptions.recentReports.filter( + (report) => !report.accountID || !chatOptions.currentUserOption?.accountID || report.accountID !== chatOptions.currentUserOption.accountID, + ); newSections.push({ title: '', From de6865f695bad0fe244a1ce20a98a57824793d07 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucien=20Akchot=C3=A9?= Date: Mon, 19 Jan 2026 20:10:09 +0100 Subject: [PATCH 26/27] fix typescript check --- tests/unit/OptionsListUtilsTest.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/unit/OptionsListUtilsTest.tsx b/tests/unit/OptionsListUtilsTest.tsx index 3a2c2375f60c3..dcc1abd4de04b 100644 --- a/tests/unit/OptionsListUtilsTest.tsx +++ b/tests/unit/OptionsListUtilsTest.tsx @@ -2016,7 +2016,7 @@ describe('OptionsListUtils', () => { it('should not return userToInvite for plain text name when shouldAcceptName is false', () => { // Given a set of options - const options = getValidOptions({reports: OPTIONS.reports, personalDetails: OPTIONS.personalDetails}, {}, nvpDismissedProductTraining, loginList, {includeUserToInvite: true}); + const options = getValidOptions({reports: OPTIONS.reports, personalDetails: OPTIONS.personalDetails}, {}, {}, nvpDismissedProductTraining, loginList, {includeUserToInvite: true}); // When we call filterAndOrderOptions with a plain text name (not email or phone) without shouldAcceptName const filteredOptions = filterAndOrderOptions(options, 'Jeff Amazon', COUNTRY_CODE, loginList, {shouldAcceptName: false}); @@ -2027,7 +2027,7 @@ describe('OptionsListUtils', () => { it('should return userToInvite for plain text name when shouldAcceptName is true', () => { // Given a set of options - const options = getValidOptions({reports: OPTIONS.reports, personalDetails: OPTIONS.personalDetails}, {}, nvpDismissedProductTraining, loginList, {includeUserToInvite: true}); + const options = getValidOptions({reports: OPTIONS.reports, personalDetails: OPTIONS.personalDetails}, {}, {}, nvpDismissedProductTraining, loginList, {includeUserToInvite: true}); // When we call filterAndOrderOptions with a plain text name (not email or phone) with shouldAcceptName const filteredOptions = filterAndOrderOptions(options, 'Jeff', COUNTRY_CODE, loginList, {shouldAcceptName: true}); From bffe931145acca4eed3b7dcaf22156902a3552a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucien=20Akchot=C3=A9?= Date: Tue, 20 Jan 2026 07:18:42 +0100 Subject: [PATCH 27/27] fix style --- tests/unit/OptionsListUtilsTest.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/tests/unit/OptionsListUtilsTest.tsx b/tests/unit/OptionsListUtilsTest.tsx index dcc1abd4de04b..00d53261d3e87 100644 --- a/tests/unit/OptionsListUtilsTest.tsx +++ b/tests/unit/OptionsListUtilsTest.tsx @@ -2016,7 +2016,9 @@ describe('OptionsListUtils', () => { it('should not return userToInvite for plain text name when shouldAcceptName is false', () => { // Given a set of options - const options = getValidOptions({reports: OPTIONS.reports, personalDetails: OPTIONS.personalDetails}, {}, {}, nvpDismissedProductTraining, loginList, {includeUserToInvite: true}); + const options = getValidOptions({reports: OPTIONS.reports, personalDetails: OPTIONS.personalDetails}, {}, {}, nvpDismissedProductTraining, loginList, { + includeUserToInvite: true, + }); // When we call filterAndOrderOptions with a plain text name (not email or phone) without shouldAcceptName const filteredOptions = filterAndOrderOptions(options, 'Jeff Amazon', COUNTRY_CODE, loginList, {shouldAcceptName: false}); @@ -2027,7 +2029,9 @@ describe('OptionsListUtils', () => { it('should return userToInvite for plain text name when shouldAcceptName is true', () => { // Given a set of options - const options = getValidOptions({reports: OPTIONS.reports, personalDetails: OPTIONS.personalDetails}, {}, {}, nvpDismissedProductTraining, loginList, {includeUserToInvite: true}); + const options = getValidOptions({reports: OPTIONS.reports, personalDetails: OPTIONS.personalDetails}, {}, {}, nvpDismissedProductTraining, loginList, { + includeUserToInvite: true, + }); // When we call filterAndOrderOptions with a plain text name (not email or phone) with shouldAcceptName const filteredOptions = filterAndOrderOptions(options, 'Jeff', COUNTRY_CODE, loginList, {shouldAcceptName: true});