From 93edf8b7f345569a3485fc566b4c632f717e009b Mon Sep 17 00:00:00 2001 From: zfurtak Date: Tue, 23 Jul 2024 15:10:20 +0200 Subject: [PATCH 01/42] Enable name in the filter function --- src/libs/OptionsListUtils.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/libs/OptionsListUtils.ts b/src/libs/OptionsListUtils.ts index ebe4ffdbe53a..14fcdee1875c 100644 --- a/src/libs/OptionsListUtils.ts +++ b/src/libs/OptionsListUtils.ts @@ -179,6 +179,7 @@ type GetOptionsConfig = { includeInvoiceRooms?: boolean; includeDomainEmail?: boolean; action?: IOUAction; + shouldAcceptName?: boolean; }; type GetUserToInviteConfig = { @@ -189,6 +190,7 @@ type GetUserToInviteConfig = { betas: OnyxEntry; reportActions?: ReportActions; showChatPreviewLine?: boolean; + shouldAcceptName?: boolean; }; type MemberForList = { @@ -222,7 +224,7 @@ type PreviewConfig = {showChatPreviewLine?: boolean; forcePolicyNamePreview?: bo type FilterOptionsConfig = Pick< GetOptionsConfig, - 'sortByReportTypeInSearch' | 'canInviteUser' | 'betas' | 'selectedOptions' | 'excludeUnknownUsers' | 'excludeLogins' | 'maxRecentReportsToShow' + 'sortByReportTypeInSearch' | 'canInviteUser' | 'betas' | 'selectedOptions' | 'excludeUnknownUsers' | 'excludeLogins' | 'maxRecentReportsToShow' | 'shouldAcceptName' > & {preferChatroomsOverThreads?: boolean; includeChatRoomsByParticipants?: boolean}; type HasText = { @@ -1698,6 +1700,7 @@ function canCreateOptimisticPersonalDetailOption({ * We create a new user option if the following conditions are satisfied: * - There's no matching recent report and personal detail option * - The searchValue is a valid email or phone number + * - If prop shouldAcceptName = true, the searchValue can be also a normal string * - The searchValue isn't the current personal detail login * - We can use chronos or the search value is not the chronos email */ @@ -1709,6 +1712,7 @@ function getUserToInviteOption({ betas, reportActions = {}, showChatPreviewLine = false, + shouldAcceptName = false, }: GetUserToInviteConfig): ReportUtils.OptionData | null { const parsedPhoneNumber = PhoneNumber.parsePhoneNumber(LoginUtils.appendCountryCode(Str.removeSMSDomain(searchValue))); const isCurrentUserLogin = isCurrentUser({login: searchValue} as PersonalDetails); @@ -1723,7 +1727,7 @@ function getUserToInviteOption({ !searchValue || isCurrentUserLogin || isInSelectedOption || - (!isValidEmail && !isValidPhoneNumber) || + (!isValidEmail && !isValidPhoneNumber && !shouldAcceptName) || isInOptionToExclude || (isChronosEmail && !Permissions.canUseChronos(betas)) || excludeUnknownUsers @@ -1744,7 +1748,7 @@ function getUserToInviteOption({ showChatPreviewLine, }); userToInvite.isOptimisticAccount = true; - userToInvite.login = searchValue; + userToInvite.login = isValidEmail || isValidPhoneNumber ? searchValue : ''; // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing userToInvite.text = userToInvite.text || searchValue; // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing @@ -2481,6 +2485,7 @@ function filterOptions(options: Options, searchInputValue: string, config?: Filt excludeLogins = [], preferChatroomsOverThreads = false, includeChatRoomsByParticipants = false, + shouldAcceptName = false, } = config ?? {}; if (searchInputValue.trim() === '' && maxRecentReportsToShow > 0) { return {...options, recentReports: options.recentReports.slice(0, maxRecentReportsToShow)}; @@ -2586,6 +2591,7 @@ function filterOptions(options: Options, searchInputValue: string, config?: Filt betas, selectedOptions: config?.selectedOptions, optionsToExclude, + shouldAcceptName, }); } } From a53c355cc1771fbb7f9952008337a18acaf25f65 Mon Sep 17 00:00:00 2001 From: zfurtak Date: Tue, 23 Jul 2024 15:22:05 +0200 Subject: [PATCH 02/42] Blank commit From fc456f35d01dbd76614cdf98e80100bd9b62e760 Mon Sep 17 00:00:00 2001 From: zfurtak Date: Thu, 25 Jul 2024 16:02:35 +0200 Subject: [PATCH 03/42] Added shouldAcceptName prop From d2bf2d5cb6af4335b04f9d922361b3cda206be5d Mon Sep 17 00:00:00 2001 From: Robert Kozik Date: Mon, 26 Aug 2024 14:18:31 +0200 Subject: [PATCH 04/42] create new route for attendee screen --- src/ROUTES.ts | 5 +++++ src/SCREENS.ts | 1 + .../Navigation/AppNavigator/ModalStackNavigators/index.tsx | 1 + src/libs/Navigation/linkingConfig/config.ts | 1 + src/libs/Navigation/types.ts | 7 +++++++ .../iou/request/step/withWritableReportOrNotFound.tsx | 3 ++- 6 files changed, 17 insertions(+), 1 deletion(-) diff --git a/src/ROUTES.ts b/src/ROUTES.ts index dd87e5a9996f..b085572bd03c 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -396,6 +396,11 @@ const ROUTES = { getRoute: (action: IOUAction, iouType: IOUType, transactionID: string, reportID: string, backTo = '', reportActionID?: string) => getUrlWithBackToParam(`${action as string}/${iouType as string}/category/${transactionID}/${reportID}${reportActionID ? `/${reportActionID}` : ''}`, backTo), }, + MONEY_REQUEST_ATTENDEE: { + route: ':action/:iouType/attendees/:transactionID/:reportID', + getRoute: (action: IOUAction, iouType: IOUType, transactionID: string, reportID: string, backTo = '') => + getUrlWithBackToParam(`${action as string}/${iouType as string}/attendees/${transactionID}/${reportID}`, backTo), + }, SETTINGS_CATEGORIES_ROOT: { route: 'settings/:policyID/categories', getRoute: (policyID: string, backTo = '') => getUrlWithBackToParam(`settings/${policyID}/categories`, backTo), diff --git a/src/SCREENS.ts b/src/SCREENS.ts index cc4360d7695d..925988cbede5 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -203,6 +203,7 @@ const SCREENS = { EDIT_WAYPOINT: 'Money_Request_Edit_Waypoint', RECEIPT: 'Money_Request_Receipt', STATE_SELECTOR: 'Money_Request_State_Selector', + ATTENDEE: 'Money_Request_Attendee', }, TRANSACTION_DUPLICATE: { diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx index 4694a2e73d5c..4d55ed65d12e 100644 --- a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx +++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx @@ -96,6 +96,7 @@ const MoneyRequestModalStackNavigator = createModalStackNavigator require('../../../../pages/settings/Wallet/AddDebitCardPage').default, [SCREENS.IOU_SEND.ENABLE_PAYMENTS]: () => require('../../../../pages/EnablePayments/EnablePaymentsPage').default, [SCREENS.MONEY_REQUEST.STATE_SELECTOR]: () => require('../../../../pages/settings/Profile/PersonalDetails/StateSelectionPage').default, + [SCREENS.MONEY_REQUEST.ATTENDEE]: () => require('../../../../pages/iou/request/step/AttendeeSelectionPage').default, }); const TravelModalStackNavigator = createModalStackNavigator({ diff --git a/src/libs/Navigation/linkingConfig/config.ts b/src/libs/Navigation/linkingConfig/config.ts index 6b4d7eca95c1..138a37ed20ae 100644 --- a/src/libs/Navigation/linkingConfig/config.ts +++ b/src/libs/Navigation/linkingConfig/config.ts @@ -913,6 +913,7 @@ const config: LinkingOptions['config'] = { [SCREENS.MONEY_REQUEST.STEP_TAX_RATE]: ROUTES.MONEY_REQUEST_STEP_TAX_RATE.route, [SCREENS.MONEY_REQUEST.STATE_SELECTOR]: {path: ROUTES.MONEY_REQUEST_STATE_SELECTOR.route, exact: true}, [SCREENS.MONEY_REQUEST.STEP_SPLIT_PAYER]: ROUTES.MONEY_REQUEST_STEP_SPLIT_PAYER.route, + [SCREENS.MONEY_REQUEST.ATTENDEE]: ROUTES.MONEY_REQUEST_ATTENDEE.route, [SCREENS.IOU_SEND.ENABLE_PAYMENTS]: ROUTES.IOU_SEND_ENABLE_PAYMENTS, [SCREENS.IOU_SEND.ADD_BANK_ACCOUNT]: ROUTES.IOU_SEND_ADD_BANK_ACCOUNT, [SCREENS.IOU_SEND.ADD_DEBIT_CARD]: ROUTES.IOU_SEND_ADD_DEBIT_CARD, diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts index b689f36d8a35..689e86e66e3c 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -935,6 +935,13 @@ type MoneyRequestNavigatorParamList = { backTo?: Routes; currency?: string; }; + [SCREENS.MONEY_REQUEST.ATTENDEE]: { + action: IOUAction; + iouType: Exclude; + transactionID: string; + reportID: string; + backTo: Routes; + }; }; type NewTaskNavigatorParamList = { diff --git a/src/pages/iou/request/step/withWritableReportOrNotFound.tsx b/src/pages/iou/request/step/withWritableReportOrNotFound.tsx index 8df530f3c81c..faba177dd8c6 100644 --- a/src/pages/iou/request/step/withWritableReportOrNotFound.tsx +++ b/src/pages/iou/request/step/withWritableReportOrNotFound.tsx @@ -43,7 +43,8 @@ type MoneyRequestRouteName = | typeof SCREENS.MONEY_REQUEST.STEP_TAX_AMOUNT | typeof SCREENS.MONEY_REQUEST.STEP_SCAN | typeof SCREENS.MONEY_REQUEST.STEP_SEND_FROM - | typeof SCREENS.MONEY_REQUEST.STEP_COMPANY_INFO; + | typeof SCREENS.MONEY_REQUEST.STEP_COMPANY_INFO + | typeof SCREENS.MONEY_REQUEST.ATTENDEE; type Route = RouteProp; From a355c431b92e3d06bcc71b993ab5a95c3a032142 Mon Sep 17 00:00:00 2001 From: Robert Kozik Date: Mon, 26 Aug 2024 16:09:32 +0200 Subject: [PATCH 05/42] create attendee screen + selector --- src/languages/en.ts | 2 + src/languages/es.ts | 2 + src/libs/OptionsListUtils.ts | 12 +- .../request/MoneyRequestAttendeeSelector.tsx | 367 ++++++++++++++++++ .../request/step/AttendeeSelectionPage.tsx | 56 +++ src/types/onyx/IOU.ts | 8 +- 6 files changed, 444 insertions(+), 3 deletions(-) create mode 100644 src/pages/iou/request/MoneyRequestAttendeeSelector.tsx create mode 100644 src/pages/iou/request/step/AttendeeSelectionPage.tsx diff --git a/src/languages/en.ts b/src/languages/en.ts index a378df670367..1c3bd187f1a9 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -831,6 +831,7 @@ export default { atLeastTwoDifferentWaypoints: 'Please enter at least two different addresses.', splitExpenseMultipleParticipantsErrorMessage: 'An expense cannot be split between a workspace and other members. Please update your selection.', invalidMerchant: 'Please enter a correct merchant.', + atLeastOneAttendee: 'At least one attendee must be selected', }, waitingOnEnabledWallet: ({submitterDisplayName}: WaitingOnBankAccountParams) => `started settling up. Payment is on hold until ${submitterDisplayName} enables their wallet.`, enableWallet: 'Enable wallet', @@ -881,6 +882,7 @@ export default { bookingPendingDescription: "This booking is pending because it hasn't been paid yet.", bookingArchived: 'This booking is archived', bookingArchivedDescription: 'This booking is archived because the trip date has passed. Add an expense for the final amount if needed.', + attendees: 'Attendees', }, notificationPreferencesPage: { header: 'Notification preferences', diff --git a/src/languages/es.ts b/src/languages/es.ts index a106666bf2ca..91baf30bfdeb 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -827,6 +827,7 @@ export default { atLeastTwoDifferentWaypoints: 'Por favor, introduce al menos dos direcciones diferentes.', splitExpenseMultipleParticipantsErrorMessage: 'Solo puedes dividir un gasto entre un único espacio de trabajo o con miembros individuales. Por favor, actualiza tu selección.', invalidMerchant: 'Por favor, introduce un comerciante correcto.', + atLeastOneAttendee: 'At least one attendee must be selected', }, waitingOnEnabledWallet: ({submitterDisplayName}: WaitingOnBankAccountParams) => `inició el pago, pero no se procesará hasta que ${submitterDisplayName} active su billetera`, enableWallet: 'Habilitar billetera', @@ -885,6 +886,7 @@ export default { bookingPendingDescription: 'Esta reserva está pendiente porque aún no se ha pagado.', bookingArchived: 'Esta reserva está archivada', bookingArchivedDescription: 'Esta reserva está archivada porque la fecha del viaje ha pasado. Agregue un gasto por el monto final si es necesario.', + attendees: 'Attendees', }, notificationPreferencesPage: { header: 'Preferencias de avisos', diff --git a/src/libs/OptionsListUtils.ts b/src/libs/OptionsListUtils.ts index 3f64ec6131be..ea2254a311ad 100644 --- a/src/libs/OptionsListUtils.ts +++ b/src/libs/OptionsListUtils.ts @@ -219,7 +219,10 @@ type Options = { type PreviewConfig = {showChatPreviewLine?: boolean; forcePolicyNamePreview?: boolean; showPersonalDetails?: boolean}; -type FilterOptionsConfig = Pick & { +type FilterOptionsConfig = Pick< + GetOptionsConfig, + 'sortByReportTypeInSearch' | 'canInviteUser' | 'selectedOptions' | 'excludeUnknownUsers' | 'excludeLogins' | 'maxRecentReportsToShow' | 'shouldAcceptName' +> & { preferChatroomsOverThreads?: boolean; preferPolicyExpenseChat?: boolean; }; @@ -425,7 +428,7 @@ function getParticipantsOption(participant: ReportUtils.OptionData | Participant const detail = getPersonalDetailsForAccountIDs([participant.accountID ?? -1], personalDetails)[participant.accountID ?? -1]; // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing const login = detail?.login || participant.login || ''; - const displayName = PersonalDetailsUtils.getDisplayNameOrDefault(detail, LocalePhoneNumber.formatPhoneNumber(login)); + const displayName = PersonalDetailsUtils.getDisplayNameOrDefault(detail, LocalePhoneNumber.formatPhoneNumber(login) || participant.text); return { keyForList: String(detail?.accountID), @@ -2505,6 +2508,10 @@ function shouldUseBoldText(report: ReportUtils.OptionData): boolean { return report.isUnread === true && report.notificationPreference !== CONST.REPORT.NOTIFICATION_PREFERENCE.MUTE; } +function getAttendeeOptions() { + return getEmptyOptions(); +} + export { getAvatarsForAccountIDs, isCurrentUser, @@ -2550,6 +2557,7 @@ export { getCurrentUserSearchTerms, getEmptyOptions, shouldUseBoldText, + getAttendeeOptions, }; export type {MemberForList, CategorySection, CategoryTreeSection, Options, OptionList, SearchOption, PayeePersonalDetails, Category, Tax, TaxRatesOption, Option, OptionTree}; diff --git a/src/pages/iou/request/MoneyRequestAttendeeSelector.tsx b/src/pages/iou/request/MoneyRequestAttendeeSelector.tsx new file mode 100644 index 000000000000..0cec9a8eacc3 --- /dev/null +++ b/src/pages/iou/request/MoneyRequestAttendeeSelector.tsx @@ -0,0 +1,367 @@ +import lodashIsEqual from 'lodash/isEqual'; +import lodashPick from 'lodash/pick'; +import lodashReject from 'lodash/reject'; +import React, {memo, useCallback, useEffect, useMemo} from 'react'; +import type {GestureResponderEvent} from 'react-native'; +import {useOnyx} from 'react-native-onyx'; +import Button from '@components/Button'; +import EmptySelectionListContent from '@components/EmptySelectionListContent'; +import FormHelpMessage from '@components/FormHelpMessage'; +import {usePersonalDetails} from '@components/OnyxProvider'; +import {useOptionsList} from '@components/OptionListContextProvider'; +import SelectionList from '@components/SelectionList'; +import InviteMemberListItem from '@components/SelectionList/InviteMemberListItem'; +import useDebouncedState from '@hooks/useDebouncedState'; +import useDismissedReferralBanners from '@hooks/useDismissedReferralBanners'; +import useLocalize from '@hooks/useLocalize'; +import useNetwork from '@hooks/useNetwork'; +import usePermissions from '@hooks/usePermissions'; +import usePolicy from '@hooks/usePolicy'; +import useScreenWrapperTranstionStatus from '@hooks/useScreenWrapperTransitionStatus'; +import useThemeStyles from '@hooks/useThemeStyles'; +import * as DeviceCapabilities from '@libs/DeviceCapabilities'; +import Navigation from '@libs/Navigation/Navigation'; +import * as OptionsListUtils from '@libs/OptionsListUtils'; +import * as PolicyUtils from '@libs/PolicyUtils'; +import * as SubscriptionUtils from '@libs/SubscriptionUtils'; +import * as Report from '@userActions/Report'; +import type {IOUAction, IOURequestType, IOUType} from '@src/CONST'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; +import type {Attendee, Participant} from '@src/types/onyx/IOU'; + +type MoneyRequestParticipantsSelectorProps = { + /** Callback to request parent modal to go to next step, which should be split */ + onFinish: (value?: string) => void; + + /** Callback to add participants in MoneyRequestModal */ + onParticipantsAdded: (value: Participant[]) => void; + + /** Selected participants from MoneyRequestModal with login */ + participants?: Participant[] | typeof CONST.EMPTY_ARRAY; + + /** The type of IOU report, i.e. split, request, send, track */ + iouType: IOUType; + + /** The expense type, ie. manual, scan, distance */ + iouRequestType: IOURequestType; + + /** The action of the IOU, i.e. create, split, move */ + action: IOUAction; +}; + +function MoneyRequestAttendeeSelector({participants = CONST.EMPTY_ARRAY, onFinish, onParticipantsAdded, iouType, iouRequestType, action}: MoneyRequestParticipantsSelectorProps) { + const {translate} = useLocalize(); + const styles = useThemeStyles(); + const [searchTerm, debouncedSearchTerm, setSearchTerm] = useDebouncedState(''); + const referralContentType = iouType === CONST.IOU.TYPE.PAY ? CONST.REFERRAL_PROGRAM.CONTENT_TYPES.PAY_SOMEONE : CONST.REFERRAL_PROGRAM.CONTENT_TYPES.SUBMIT_EXPENSE; + const {isOffline} = useNetwork(); + const personalDetails = usePersonalDetails(); + const {isDismissed} = useDismissedReferralBanners({referralContentType}); + const {canUseP2PDistanceRequests} = usePermissions(); + const {didScreenTransitionEnd} = useScreenWrapperTranstionStatus(); + const [betas] = useOnyx(ONYXKEYS.BETAS); + const [activePolicyID] = useOnyx(ONYXKEYS.NVP_ACTIVE_POLICY_ID); + const policy = usePolicy(activePolicyID); + const [isSearchingForReports] = useOnyx(ONYXKEYS.IS_SEARCHING_FOR_REPORTS, {initWithStoredValues: false}); + const {options, areOptionsInitialized} = useOptionsList({ + shouldInitialize: didScreenTransitionEnd, + }); + const cleanSearchTerm = useMemo(() => debouncedSearchTerm.trim().toLowerCase(), [debouncedSearchTerm]); + const offlineMessage: string = isOffline ? `${translate('common.youAppearToBeOffline')} ${translate('search.resultsAreLimited')}` : ''; + + const isPaidGroupPolicy = useMemo(() => PolicyUtils.isPaidGroupPolicy(policy), [policy]); + const isIOUSplit = iouType === CONST.IOU.TYPE.SPLIT; + const isCategorizeOrShareAction = [CONST.IOU.ACTION.CATEGORIZE, CONST.IOU.ACTION.SHARE].some((option) => option === action); + + useEffect(() => { + Report.searchInServer(debouncedSearchTerm.trim()); + }, [debouncedSearchTerm]); + + const defaultOptions = useMemo(() => { + if (!areOptionsInitialized || !didScreenTransitionEnd) { + OptionsListUtils.getEmptyOptions(); + } + + const optionList = OptionsListUtils.getFilteredOptions( + options.reports, + options.personalDetails, + betas, + '', + participants as Attendee[], + CONST.EXPENSIFY_EMAILS, + + // If we are using this component in the "Submit expense" flow then we pass the includeOwnedWorkspaceChats argument so that the current user + // sees the option to submit an expense from their admin on their own Workspace Chat. + (iouType === CONST.IOU.TYPE.SUBMIT || iouType === CONST.IOU.TYPE.SPLIT) && action !== CONST.IOU.ACTION.SUBMIT, + + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + (canUseP2PDistanceRequests || iouRequestType !== CONST.IOU.REQUEST_TYPE.DISTANCE) && !isCategorizeOrShareAction, + false, + {}, + [], + false, + {}, + [], + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + (canUseP2PDistanceRequests || iouRequestType !== CONST.IOU.REQUEST_TYPE.DISTANCE) && !isCategorizeOrShareAction, + false, + false, + 0, + undefined, + undefined, + undefined, + undefined, + undefined, + iouType === CONST.IOU.TYPE.INVOICE, + action, + isPaidGroupPolicy, + ); + + return optionList; + }, [ + action, + areOptionsInitialized, + betas, + canUseP2PDistanceRequests, + didScreenTransitionEnd, + iouRequestType, + iouType, + isCategorizeOrShareAction, + options.personalDetails, + options.reports, + participants, + isPaidGroupPolicy, + ]); + + const chatOptions = useMemo(() => { + if (!areOptionsInitialized) { + return { + userToInvite: null, + recentReports: [], + personalDetails: [], + currentUserOption: null, + headerMessage: '', + categoryOptions: [], + tagOptions: [], + taxRatesOptions: [], + }; + } + + const newOptions = OptionsListUtils.filterOptions(defaultOptions, debouncedSearchTerm, { + selectedOptions: participants as Attendee[], + excludeLogins: CONST.EXPENSIFY_EMAILS, + maxRecentReportsToShow: CONST.IOU.MAX_RECENT_REPORTS_TO_SHOW, + preferPolicyExpenseChat: isPaidGroupPolicy, + shouldAcceptName: true, + }); + return newOptions; + }, [areOptionsInitialized, defaultOptions, debouncedSearchTerm, participants, isPaidGroupPolicy]); + + /** + * Returns the sections needed for the OptionsSelector + * @returns {Array} + */ + const [sections, header] = useMemo(() => { + const newSections: OptionsListUtils.CategorySection[] = []; + if (!areOptionsInitialized || !didScreenTransitionEnd) { + return [newSections, '']; + } + + const formatResults = OptionsListUtils.formatSectionsFromSearchTerm( + debouncedSearchTerm, + participants.map((participant) => ({...participant, reportID: participant.reportID ?? '-1'})), + chatOptions.recentReports, + chatOptions.personalDetails, + personalDetails, + true, + ); + console.log('formatResults', formatResults); + newSections.push(formatResults.section); + + newSections.push({ + title: translate('common.recents'), + data: chatOptions.recentReports, + shouldShow: chatOptions.recentReports.length > 0, + }); + + newSections.push({ + title: translate('common.contacts'), + data: chatOptions.personalDetails, + shouldShow: chatOptions.personalDetails.length > 0, + }); + + if ( + chatOptions.userToInvite && + !OptionsListUtils.isCurrentUser({...chatOptions.userToInvite, accountID: chatOptions.userToInvite?.accountID ?? -1, status: chatOptions.userToInvite?.status ?? undefined}) + ) { + newSections.push({ + title: undefined, + data: [chatOptions.userToInvite].map((participant) => { + const isPolicyExpenseChat = participant?.isPolicyExpenseChat ?? false; + return isPolicyExpenseChat ? OptionsListUtils.getPolicyExpenseReportOption(participant) : OptionsListUtils.getParticipantsOption(participant, personalDetails); + }), + shouldShow: true, + }); + } + + const headerMessage = OptionsListUtils.getHeaderMessage( + (chatOptions.personalDetails ?? []).length + (chatOptions.recentReports ?? []).length !== 0, + !!chatOptions?.userToInvite, + debouncedSearchTerm.trim(), + participants.some((participant) => OptionsListUtils.getPersonalDetailSearchTerms(participant).join(' ').toLowerCase().includes(cleanSearchTerm)), + ); + + return [newSections, headerMessage]; + }, [ + areOptionsInitialized, + didScreenTransitionEnd, + debouncedSearchTerm, + participants, + chatOptions.recentReports, + chatOptions.personalDetails, + chatOptions.userToInvite, + personalDetails, + translate, + cleanSearchTerm, + ]); + + /** + * Removes a selected option from list if already selected. If not already selected add this option to the list. + * @param {Object} option + */ + const addParticipantToSelection = useCallback( + (option: Participant) => { + const isOptionSelected = (selectedOption: Participant) => { + if (selectedOption.accountID && selectedOption.accountID === option?.accountID) { + return true; + } + + if (selectedOption.reportID && selectedOption.reportID === option?.reportID) { + return true; + } + + return false; + }; + const isOptionInList = participants.some(isOptionSelected); + let newSelectedOptions: Participant[]; + + if (isOptionInList) { + newSelectedOptions = lodashReject(participants, isOptionSelected); + } else { + newSelectedOptions = [ + ...participants, + { + accountID: option.accountID, + login: option.login, + isPolicyExpenseChat: option.isPolicyExpenseChat, + reportID: option.reportID, + text: option.text, + selected: true, + searchText: option.searchText, + iouType, + }, + ]; + } + + onParticipantsAdded(newSelectedOptions); + }, + // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps -- we don't want to trigger this callback when iouType changes + [participants, onParticipantsAdded], + ); + + const shouldShowErrorMessage = participants.length < 1; + + const handleConfirmSelection = useCallback( + (_keyEvent?: GestureResponderEvent | KeyboardEvent, option?: Participant) => { + if (shouldShowErrorMessage || (!participants.length && !option)) { + return; + } + + onFinish(CONST.IOU.TYPE.SPLIT); + }, + [shouldShowErrorMessage, onFinish, participants], + ); + + const showLoadingPlaceholder = useMemo(() => !areOptionsInitialized || !didScreenTransitionEnd, [areOptionsInitialized, didScreenTransitionEnd]); + + const optionLength = useMemo(() => { + if (!areOptionsInitialized) { + return 0; + } + return sections.reduce((acc, section) => acc + section.data.length, 0); + }, [areOptionsInitialized, sections]); + + const shouldShowListEmptyContent = useMemo(() => optionLength === 0 && !showLoadingPlaceholder, [optionLength, showLoadingPlaceholder]); + + const footerContent = useMemo(() => { + if (isDismissed && !shouldShowErrorMessage && !participants.length) { + return; + } + + return ( + <> + {shouldShowErrorMessage && ( + + )} + + {!isCategorizeOrShareAction && ( +