diff --git a/src/CONST.ts b/src/CONST.ts index caaa26416e94..f4586573cfaa 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -297,6 +297,9 @@ const CONST = { // Regex to get link in href prop inside of component REGEX_LINK_IN_ANCHOR: /]*?\s+)?href="([^"]*)"/gi, + // Regex to read violation value from string given by backend + VIOLATION_LIMIT_REGEX: /[^0-9]+/g, + MERCHANT_NAME_MAX_LENGTH: 255, MASKED_PAN_PREFIX: 'XXXXXXXXXXXX', diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index d083a46d7760..427e05052ae3 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -3,6 +3,7 @@ import type CONST from './CONST'; import type {OnboardingCompanySizeType, OnboardingPurposeType} from './CONST'; import type * as FormTypes from './types/form'; import type * as OnyxTypes from './types/onyx'; +import type {Attendee} from './types/onyx/IOU'; import type Onboarding from './types/onyx/Onboarding'; import type AssertTypesEqual from './types/utils/AssertTypesEqual'; import type DeepValueOf from './types/utils/DeepValueOf'; @@ -112,6 +113,9 @@ const ONYXKEYS = { /** Boolean flag only true when first set */ NVP_IS_FIRST_TIME_NEW_EXPENSIFY_USER: 'nvp_isFirstTimeNewExpensifyUser', + /** This NVP contains list of at most 5 recent attendees */ + NVP_RECENT_ATTENDEES: 'nvp_expensify_recentAttendees', + /** This NVP contains information about whether the onboarding flow was completed or not */ NVP_ONBOARDING: 'nvp_onboarding', @@ -905,6 +909,7 @@ type OnyxValuesMapping = { // The value of this nvp is a string representation of the date when the block expires, or an empty string if the user is not blocked [ONYXKEYS.NVP_BLOCKED_FROM_CHAT]: string; [ONYXKEYS.NVP_PRIVATE_PUSH_NOTIFICATION_ID]: string; + [ONYXKEYS.NVP_RECENT_ATTENDEES]: Attendee[]; [ONYXKEYS.NVP_TRY_FOCUS_MODE]: boolean; [ONYXKEYS.NVP_DISMISSED_HOLD_USE_EXPLANATION]: boolean; [ONYXKEYS.FOCUS_MODE_NOTIFICATION]: boolean; diff --git a/src/ROUTES.ts b/src/ROUTES.ts index 23ef269d8cfb..e6984d6c757b 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -447,6 +447,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_TAGS_ROOT: { route: 'settings/:policyID/tags', getRoute: (policyID: string, backTo = '') => getUrlWithBackToParam(`settings/${policyID}/tags`, backTo), diff --git a/src/SCREENS.ts b/src/SCREENS.ts index 3a33975b2bbe..7f04b861dec5 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -219,6 +219,7 @@ const SCREENS = { EDIT_WAYPOINT: 'Money_Request_Edit_Waypoint', RECEIPT: 'Money_Request_Receipt', STATE_SELECTOR: 'Money_Request_State_Selector', + STEP_ATTENDEES: 'Money_Request_Attendee', }, TRANSACTION_DUPLICATE: { diff --git a/src/components/MoneyRequestConfirmationList.tsx b/src/components/MoneyRequestConfirmationList.tsx index db56320e98ff..0eccd9834baf 100755 --- a/src/components/MoneyRequestConfirmationList.tsx +++ b/src/components/MoneyRequestConfirmationList.tsx @@ -34,7 +34,7 @@ import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import type {Route} from '@src/ROUTES'; import type * as OnyxTypes from '@src/types/onyx'; -import type {Participant} from '@src/types/onyx/IOU'; +import type {Attendee, Participant} from '@src/types/onyx/IOU'; import type {PaymentMethodType} from '@src/types/onyx/OriginalMessage'; import type {SplitShares} from '@src/types/onyx/Transaction'; import ButtonWithDropdownMenu from './ButtonWithDropdownMenu'; @@ -85,6 +85,9 @@ type MoneyRequestConfirmationListProps = MoneyRequestConfirmationListOnyxProps & /** IOU amount */ iouAmount: number; + /** IOU attendees list */ + iouAttendees?: Attendee[]; + /** IOU comment */ iouComment?: string; @@ -194,6 +197,7 @@ function MoneyRequestConfirmationList({ policyID = '', reportID = '', receiptPath = '', + iouAttendees, iouComment, receiptFilename = '', iouCreated, @@ -288,7 +292,12 @@ function MoneyRequestConfirmationList({ const formattedAmount = isDistanceRequestWithPendingRoute ? '' : CurrencyUtils.convertToDisplayString(shouldCalculateDistanceAmount ? distanceRequestAmount : iouAmount, isDistanceRequest ? currency : iouCurrencyCode); - + const formattedAmountPerAttendee = isDistanceRequestWithPendingRoute + ? '' + : CurrencyUtils.convertToDisplayString( + (shouldCalculateDistanceAmount ? distanceRequestAmount : iouAmount) / (iouAttendees?.length && iouAttendees.length > 0 ? iouAttendees.length : 1), + isDistanceRequest ? currency : iouCurrencyCode, + ); const isFocused = useIsFocused(); const [formError, debouncedFormError, setFormError] = useDebouncedState(''); @@ -899,8 +908,10 @@ function MoneyRequestConfirmationList({ didConfirm={!!didConfirm} distance={distance} formattedAmount={formattedAmount} + formattedAmountPerAttendee={formattedAmountPerAttendee} formError={formError} hasRoute={hasRoute} + iouAttendees={iouAttendees} iouCategory={iouCategory} iouComment={iouComment} iouCreated={iouCreated} @@ -1010,6 +1021,7 @@ export default withOnyx isPolicyExpenseChat && OptionsListUtils.hasEnabledTags(policyTagLists), [isPolicyExpenseChat, policyTagLists]); const isMultilevelTags = useMemo(() => PolicyUtils.isMultiLevelTags(policyTags), [policyTags]); + const shouldShowAttendees = useMemo(() => TransactionUtils.shouldShowAttendees(iouType, policy), [iouType, policy]); const senderWorkspace = useMemo(() => { const senderWorkspaceParticipant = selectedParticipants.find((participant) => participant.isSender); @@ -514,6 +523,25 @@ function MoneyRequestConfirmationListFooter({ shouldShow: shouldShowTax, isSupplementary: true, }, + { + item: ( + item?.displayName ?? item?.login).join(', ')} + description={`${translate('iou.attendees')} ${ + iouAttendees?.length && iouAttendees.length > 1 ? `\u00B7 ${formattedAmountPerAttendee} ${translate('common.perPerson')}` : '' + }`} + style={[styles.moneyRequestMenuItem]} + titleStyle={styles.flex1} + onPress={() => Navigation.navigate(ROUTES.MONEY_REQUEST_ATTENDEE.getRoute(action, iouType, transactionID, reportID, Navigation.getActiveRouteWithoutParams()))} + interactive + shouldRenderAsHTML + /> + ), + shouldShow: shouldShowAttendees, + isSupplementary: true, + }, { item: ( TransactionUtils.shouldShowAttendees(iouType, policy), [iouType, policy]); const shouldShowTax = isTaxTrackingEnabled(isPolicyExpenseChat, policy, isDistanceRequest); const tripID = ReportUtils.getTripIDFromTransactionParentReport(parentReport); @@ -721,6 +724,25 @@ function MoneyRequestView({report, shouldShowAnimatedBackground, readonly = fals }} /> )} + {shouldShowAttendees && ( + + item?.displayName ?? item?.login).join(', ')} + description={`${translate('iou.attendees')} ${ + transactionAttendees?.length && transactionAttendees.length > 1 ? `${formattedPerAttendeeAmount} ${translate('common.perPerson')}` : '' + }`} + style={[styles.moneyRequestMenuItem]} + titleStyle={styles.flex1} + onPress={() => + Navigation.navigate(ROUTES.MONEY_REQUEST_ATTENDEE.getRoute(CONST.IOU.ACTION.EDIT, iouType, transaction?.transactionID ?? '-1', report?.reportID ?? '-1')) + } + interactive + shouldRenderAsHTML + /> + + )} {shouldShowBillable && ( diff --git a/src/components/SelectionList/types.ts b/src/components/SelectionList/types.ts index 13a90d13d465..b2e175418813 100644 --- a/src/components/SelectionList/types.ts +++ b/src/components/SelectionList/types.ts @@ -17,6 +17,7 @@ import type {BrickRoad} from '@libs/WorkspacesSettingsUtils'; // eslint-disable-next-line no-restricted-imports import type CursorStyles from '@styles/utils/cursor/types'; import type CONST from '@src/CONST'; +import type {Attendee} from '@src/types/onyx/IOU'; import type {Errors, Icon, PendingAction} from '@src/types/onyx/OnyxCommon'; import type {SearchPersonalDetails, SearchReport, SearchReportAction, SearchTransaction} from '@src/types/onyx/SearchResults'; import type {ReceiptErrors} from '@src/types/onyx/Transaction'; @@ -234,6 +235,9 @@ type TransactionListItemType = ListItem & /** Key used internally by React */ keyForList: string; + + /** Attendees in the transaction */ + attendees?: Attendee[]; }; type ReportActionListItemType = ListItem & diff --git a/src/languages/en.ts b/src/languages/en.ts index 4050993a23f2..ad5ffdd336e9 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -259,6 +259,7 @@ const translations = { firstName: 'First name', lastName: 'Last name', addCardTermsOfService: 'Expensify Terms of Service', + perPerson: 'per person', phone: 'Phone', phoneNumber: 'Phone number', phoneNumberPlaceholder: '(xxx) xxx-xxxx', @@ -974,6 +975,7 @@ const translations = { 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', @@ -1029,6 +1031,7 @@ const translations = { 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', paymentComplete: 'Payment complete', justTrackIt: 'Just track it (don’t submit it)', }, diff --git a/src/languages/es.ts b/src/languages/es.ts index 1db6616571e2..ba04d2b2977a 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -243,6 +243,7 @@ const translations = { not: 'No', privacyPolicy: 'la Política de Privacidad de Expensify', addCardTermsOfService: 'Términos de Servicio', + perPerson: 'por persona', signIn: 'Conectarse', signInWithGoogle: 'Iniciar sesión con Google', signInWithApple: 'Iniciar sesión con Apple', @@ -971,6 +972,7 @@ const translations = { 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: 'Debe seleccionarse al menos un asistente', }, waitingOnEnabledWallet: ({submitterDisplayName}: WaitingOnBankAccountParams) => `inició el pago, pero no se procesará hasta que ${submitterDisplayName} active su billetera`, enableWallet: 'Habilitar billetera', @@ -1026,6 +1028,7 @@ const translations = { 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: 'Asistentes', paymentComplete: 'Pago completo', justTrackIt: 'Solo guardarlo (no enviarlo)', }, diff --git a/src/libs/API/types.ts b/src/libs/API/types.ts index d0d37060065a..063be53a2eda 100644 --- a/src/libs/API/types.ts +++ b/src/libs/API/types.ts @@ -175,6 +175,7 @@ const WRITE_COMMANDS = { DELETE_REPORT_FIELD: 'RemoveReportField', SET_REPORT_NAME: 'RenameReport', COMPLETE_SPLIT_BILL: 'CompleteSplitBill', + UPDATE_MONEY_REQUEST_ATTENDEES: 'UpdateMoneyRequestAttendees', UPDATE_MONEY_REQUEST_DATE: 'UpdateMoneyRequestDate', UPDATE_MONEY_REQUEST_BILLABLE: 'UpdateMoneyRequestBillable', UPDATE_MONEY_REQUEST_MERCHANT: 'UpdateMoneyRequestMerchant', @@ -592,6 +593,7 @@ type WriteCommandParameters = { [WRITE_COMMANDS.SET_REPORT_NAME]: Parameters.SetReportNameParams; [WRITE_COMMANDS.DELETE_REPORT_FIELD]: Parameters.DeleteReportFieldParams; [WRITE_COMMANDS.COMPLETE_SPLIT_BILL]: Parameters.CompleteSplitBillParams; + [WRITE_COMMANDS.UPDATE_MONEY_REQUEST_ATTENDEES]: Parameters.UpdateMoneyRequestParams; [WRITE_COMMANDS.UPDATE_MONEY_REQUEST_DATE]: Parameters.UpdateMoneyRequestParams; [WRITE_COMMANDS.UPDATE_MONEY_REQUEST_MERCHANT]: Parameters.UpdateMoneyRequestParams; [WRITE_COMMANDS.UPDATE_MONEY_REQUEST_BILLABLE]: Parameters.UpdateMoneyRequestParams; diff --git a/src/libs/IOUUtils.ts b/src/libs/IOUUtils.ts index dcfdd4bbc73a..e0fd37db5b3b 100644 --- a/src/libs/IOUUtils.ts +++ b/src/libs/IOUUtils.ts @@ -3,7 +3,8 @@ import type {IOUAction, IOUType} from '@src/CONST'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; -import type {OnyxInputOrEntry, Report, Transaction} from '@src/types/onyx'; +import type {OnyxInputOrEntry, PersonalDetails, Report, Transaction} from '@src/types/onyx'; +import type {Attendee} from '@src/types/onyx/IOU'; import type {IOURequestType} from './actions/IOU'; import * as CurrencyUtils from './CurrencyUtils'; import DateUtils from './DateUtils'; @@ -162,6 +163,24 @@ function shouldUseTransactionDraft(action: IOUAction | undefined) { return action === CONST.IOU.ACTION.CREATE || isMovingTransactionFromTrackExpense(action); } +function formatCurrentUserToAttendee(currentUser?: PersonalDetails, reportID?: string) { + if (!currentUser) { + return; + } + const initialAttendee: Attendee = { + email: currentUser?.login, + login: currentUser?.login, + displayName: currentUser.displayName, + avatarUrl: currentUser.avatar?.toString(), + accountID: currentUser.accountID, + text: currentUser.login, + selected: true, + reportID, + }; + + return [initialAttendee]; +} + function shouldStartLocationPermissionFlow() { return ( !lastLocationPermissionPrompt || @@ -179,5 +198,6 @@ export { isValidMoneyRequestType, navigateToStartMoneyRequestStep, updateIOUOwnerAndTotal, + formatCurrentUserToAttendee, shouldStartLocationPermissionFlow, }; diff --git a/src/libs/ModifiedExpenseMessage.ts b/src/libs/ModifiedExpenseMessage.ts index 20c5f6d7dce9..f5109cbea74b 100644 --- a/src/libs/ModifiedExpenseMessage.ts +++ b/src/libs/ModifiedExpenseMessage.ts @@ -302,6 +302,19 @@ function getForReportAction(reportID: string | undefined, reportAction: OnyxEntr ); } + const hasModifiedAttendees = isReportActionOriginalMessageAnObject && 'oldAttendees' in reportActionOriginalMessage && 'attendees' in reportActionOriginalMessage; + if (hasModifiedAttendees) { + buildMessageFragmentForValue( + reportActionOriginalMessage.oldAttendees ?? '', + reportActionOriginalMessage.attendees ?? '', + Localize.translateLocal('iou.attendees'), + false, + setFragments, + removalFragments, + changeFragments, + ); + } + const message = getMessageLine(`\n${Localize.translateLocal('iou.changed')}`, changeFragments) + getMessageLine(`\n${Localize.translateLocal('iou.set')}`, setFragments) + diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx index c9432fcbe649..2ee1f7d19be9 100644 --- a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx +++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx @@ -99,6 +99,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.STEP_ATTENDEES]: () => require('../../../../pages/iou/request/step/IOURequestStepAttendees').default, }); const TravelModalStackNavigator = createModalStackNavigator({ diff --git a/src/libs/Navigation/linkingConfig/config.ts b/src/libs/Navigation/linkingConfig/config.ts index f34060407b20..39da189af13c 100644 --- a/src/libs/Navigation/linkingConfig/config.ts +++ b/src/libs/Navigation/linkingConfig/config.ts @@ -1186,6 +1186,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.STEP_ATTENDEES]: 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 757957f7bd1c..087df82c684f 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -1162,6 +1162,13 @@ type MoneyRequestNavigatorParamList = { backTo?: Routes; currency?: string; }; + [SCREENS.MONEY_REQUEST.STEP_ATTENDEES]: { + action: IOUAction; + iouType: Exclude; + transactionID: string; + reportID: string; + backTo: Routes; + }; }; type NewTaskNavigatorParamList = { diff --git a/src/libs/OptionsListUtils.ts b/src/libs/OptionsListUtils.ts index 06d2c8a6d56e..f414d2328ef6 100644 --- a/src/libs/OptionsListUtils.ts +++ b/src/libs/OptionsListUtils.ts @@ -35,7 +35,7 @@ import type { Transaction, TransactionViolation, } from '@src/types/onyx'; -import type {Participant} from '@src/types/onyx/IOU'; +import type {Attendee, Participant} from '@src/types/onyx/IOU'; import type * as OnyxCommon from '@src/types/onyx/OnyxCommon'; import type DeepValueOf from '@src/types/utils/DeepValueOf'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; @@ -178,6 +178,8 @@ type GetOptionsConfig = { includeInvoiceRooms?: boolean; includeDomainEmail?: boolean; action?: IOUAction; + shouldAcceptName?: boolean; + recentAttendees?: Attendee[]; shouldBoldTitleByDefault?: boolean; }; @@ -188,6 +190,7 @@ type GetUserToInviteConfig = { selectedOptions?: Array>; reportActions?: ReportActions; showChatPreviewLine?: boolean; + shouldAcceptName?: boolean; }; type MemberForList = { @@ -219,7 +222,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; preferRecentExpenseReports?: boolean; @@ -416,7 +422,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), @@ -1624,6 +1630,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 */ function getUserToInviteOption({ @@ -1633,6 +1640,7 @@ function getUserToInviteOption({ selectedOptions = [], 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); @@ -1642,7 +1650,7 @@ function getUserToInviteOption({ const isInOptionToExclude = optionsToExclude.findIndex((optionToExclude) => 'login' in optionToExclude && optionToExclude.login === PhoneNumber.addSMSDomainIfPhoneNumber(searchValue).toLowerCase()) !== -1; - if (!searchValue || isCurrentUserLogin || isInSelectedOption || (!isValidEmail && !isValidPhoneNumber) || isInOptionToExclude || excludeUnknownUsers) { + if (!searchValue || isCurrentUserLogin || isInSelectedOption || (!isValidEmail && !isValidPhoneNumber && !shouldAcceptName) || isInOptionToExclude || excludeUnknownUsers) { return null; } @@ -1659,7 +1667,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 @@ -1722,6 +1730,7 @@ function getOptions( includeInvoiceRooms = false, includeDomainEmail = false, action, + recentAttendees, shouldBoldTitleByDefault = true, }: GetOptionsConfig, ): Options { @@ -1897,7 +1906,7 @@ function getOptions( optionsToExclude.push({login}); }); - let recentReportOptions = []; + let recentReportOptions: ReportUtils.OptionData[] = []; let personalDetailsOptions: ReportUtils.OptionData[] = []; const preferRecentExpenseReports = action === CONST.IOU.ACTION.CREATE; @@ -1978,6 +1987,9 @@ function getOptions( } } } + } else if (recentAttendees && recentAttendees?.length > 0) { + recentAttendees.filter((attendee) => attendee.login ?? attendee.displayName).forEach((a) => optionsToExclude.push({login: a.login ?? a.displayName})); + recentReportOptions = recentAttendees as ReportUtils.OptionData[]; } const personalDetailsOptionsToExclude = [...optionsToExclude, {login: currentUserLogin}]; @@ -2210,6 +2222,52 @@ function getFilteredOptions(params: FilteredOptionsParamsWithDefaultSearchValue ); } +function getAttendeeOptions( + reports: Array>, + personalDetails: Array>, + betas: OnyxEntry, + attendees: Attendee[], + recentAttendees: Attendee[], + includeOwnedWorkspaceChats = false, + includeP2P = true, + canInviteUser = true, + includeInvoiceRooms = false, + action: IOUAction | undefined = undefined, + sortByReportTypeInSearch = false, +) { + return getOptions( + {reports, personalDetails}, + { + betas, + searchInputValue: '', + selectedOptions: attendees, + excludeLogins: CONST.EXPENSIFY_EMAILS, + includeOwnedWorkspaceChats, + includeRecentReports: false, + includeP2P, + includeCategories: false, + categories: {}, + recentlyUsedCategories: [], + includeTags: false, + tags: {}, + recentlyUsedTags: [], + canInviteUser, + includeSelectedOptions: false, + includeTaxRates: false, + maxRecentReportsToShow: 0, + taxRates: {} as TaxRatesWithDefault, + includeSelfDM: false, + includePolicyReportFieldOptions: false, + policyReportFieldOptions: [], + recentlyUsedPolicyReportFieldOptions: [], + includeInvoiceRooms, + action, + sortByReportTypeInSearch, + recentAttendees, + }, + ); +} + /** * Build the options for the Share Destination for a Task */ @@ -2614,6 +2672,7 @@ export { getCurrentUserSearchTerms, getEmptyOptions, shouldUseBoldText, + getAttendeeOptions, getAlternateText, pickUserToInvite, hasReportErrors, diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index 2588c1c89039..1094e379d6fc 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -44,7 +44,7 @@ import type { TransactionViolation, UserWallet, } from '@src/types/onyx'; -import type {Participant} from '@src/types/onyx/IOU'; +import type {Attendee, Participant} from '@src/types/onyx/IOU'; import type {SelectedParticipant} from '@src/types/onyx/NewGroupChatDraft'; import type {OriginalMessageExportedToIntegration} from '@src/types/onyx/OldDotAction'; import type Onboarding from '@src/types/onyx/Onboarding'; @@ -417,6 +417,7 @@ type OptimisticTaskReport = Pick< type TransactionDetails = { created: string; amount: number; + attendees: Attendee[]; taxAmount?: number; taxCode?: string; currency: string; @@ -3007,6 +3008,7 @@ function getTransactionDetails(transaction: OnyxInputOrEntry, creat return { created: TransactionUtils.getFormattedCreated(transaction, createdDateFormat), amount: TransactionUtils.getAmount(transaction, !isEmptyObject(report) && isExpenseReport(report)), + attendees: TransactionUtils.getAttendees(transaction), taxAmount: TransactionUtils.getTaxAmount(transaction, !isEmptyObject(report) && isExpenseReport(report)), taxCode: TransactionUtils.getTaxCode(transaction), currency: TransactionUtils.getCurrency(transaction), @@ -3585,6 +3587,9 @@ function getModifiedExpenseOriginalMessage( originalMessage.oldMerchant = TransactionUtils.getMerchant(oldTransaction); originalMessage.merchant = transactionChanges?.merchant; } + if ('attendees' in transactionChanges) { + [originalMessage.oldAttendees, originalMessage.attendees] = TransactionUtils.getFormattedAttendees(transactionChanges?.attendees, TransactionUtils.getAttendees(oldTransaction)); + } // The amount is always a combination of the currency and the number value so when one changes we need to store both // to match how we handle the modified expense action in oldDot diff --git a/src/libs/TransactionUtils/index.ts b/src/libs/TransactionUtils/index.ts index e2c2ee6f8096..bf5073aba27c 100644 --- a/src/libs/TransactionUtils/index.ts +++ b/src/libs/TransactionUtils/index.ts @@ -20,8 +20,10 @@ import * as ReportConnection from '@libs/ReportConnection'; import * as ReportUtils from '@libs/ReportUtils'; import type {IOURequestType} from '@userActions/IOU'; import CONST from '@src/CONST'; +import type {IOUType} from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {Beta, OnyxInputOrEntry, Policy, RecentWaypoint, Report, ReviewDuplicates, TaxRate, TaxRates, Transaction, TransactionViolation, TransactionViolations} from '@src/types/onyx'; +import type {Attendee} from '@src/types/onyx/IOU'; import type {Comment, Receipt, TransactionChanges, TransactionPendingFieldsKey, Waypoint, WaypointCollection} from '@src/types/onyx/Transaction'; import type DeepValueOf from '@src/types/utils/DeepValueOf'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; @@ -126,6 +128,7 @@ function buildOptimisticTransaction( currency: string, reportID: string, comment = '', + attendees: Attendee[] = [], created = '', source = '', originalTransactionID = '', @@ -179,6 +182,7 @@ function buildOptimisticTransaction( taxAmount, billable, reimbursable, + attendees, }; } @@ -207,6 +211,13 @@ function isMerchantMissing(transaction: OnyxEntry) { return isMerchantEmpty; } +// eslint-disable-next-line @typescript-eslint/no-unused-vars +function shouldShowAttendees(iouType: IOUType, policy: OnyxEntry): boolean { + return false; + // To be renabled once feature is complete: https://github.com/Expensify/App/issues/44725 + // return iouType === CONST.IOU.TYPE.SUBMIT && !!policy?.id && (policy?.type === CONST.POLICY.TYPE.CORPORATE || policy?.type === CONST.POLICY.TYPE.TEAM); +} + /** * Check if the merchant is partial i.e. `(none)` */ @@ -312,6 +323,10 @@ function getUpdatedTransaction(transaction: Transaction, transactionChanges: Tra updatedTransaction.tag = transactionChanges.tag; } + if (Object.hasOwn(transactionChanges, 'attendees')) { + updatedTransaction.modifiedAttendees = transactionChanges?.attendees; + } + if ( shouldUpdateReceiptState && shouldStopSmartscan && @@ -335,6 +350,7 @@ function getUpdatedTransaction(transaction: Transaction, transactionChanges: Tra ...(Object.hasOwn(transactionChanges, 'tag') && {tag: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}), ...(Object.hasOwn(transactionChanges, 'taxAmount') && {taxAmount: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}), ...(Object.hasOwn(transactionChanges, 'taxCode') && {taxCode: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}), + ...(Object.hasOwn(transactionChanges, 'attendees') && {attendees: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}), }; return updatedTransaction; @@ -440,6 +456,22 @@ function getMerchantOrDescription(transaction: OnyxEntry) { return !isMerchantMissing(transaction) ? getMerchant(transaction) : getDescription(transaction); } +/** + * Return the list of modified attendees if present otherwise list of attendees + */ +function getAttendees(transaction: OnyxInputOrEntry): Attendee[] { + return transaction?.modifiedAttendees ? transaction.modifiedAttendees : transaction?.attendees ?? []; +} + +/** + * Return the list of attendees as a string and modified list of attendees as a string if present. + */ +function getFormattedAttendees(modifiedAttendees?: Attendee[], attendees?: Attendee[]): [string, string] { + const oldAttendees = modifiedAttendees ?? []; + const newAttendees = attendees ?? []; + return [oldAttendees.map((item) => item.displayName ?? item.login).join(', '), newAttendees.map((item) => item.displayName ?? item.login).join(', ')]; +} + /** * Return the reimbursable value. Defaults to true to match BE logic. */ @@ -1140,6 +1172,7 @@ export { isManualRequest, isScanRequest, getAmount, + getAttendees, getTaxAmount, getTaxCode, getCurrency, @@ -1147,6 +1180,7 @@ export { getCardID, getOriginalCurrency, getOriginalAmount, + getFormattedAttendees, getMerchant, getMerchantOrDescription, getMCCGroup, @@ -1206,6 +1240,7 @@ export { removeSettledAndApprovedTransactions, getCardName, hasReceiptSource, + shouldShowAttendees, }; export type {TransactionChanges}; diff --git a/src/libs/Violations/ViolationsUtils.ts b/src/libs/Violations/ViolationsUtils.ts index eff3d2b704a7..b49a7be66b31 100644 --- a/src/libs/Violations/ViolationsUtils.ts +++ b/src/libs/Violations/ViolationsUtils.ts @@ -343,6 +343,11 @@ const ViolationsUtils = { return violation.name as never; } }, + + // We have to use regex, because Violation limit is given in a inconvenient form: "$2,000.00" + getViolationAmountLimit(violation: TransactionViolation): number { + return Number(violation.data?.formattedLimit?.replace(CONST.VIOLATION_LIMIT_REGEX, '')); + }, }; export default ViolationsUtils; diff --git a/src/libs/actions/IOU.ts b/src/libs/actions/IOU.ts index f76bb5bf1a4f..7ce9b9dfb272 100644 --- a/src/libs/actions/IOU.ts +++ b/src/libs/actions/IOU.ts @@ -61,7 +61,7 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import type * as OnyxTypes from '@src/types/onyx'; -import type {Participant, Split} from '@src/types/onyx/IOU'; +import type {Attendee, Participant, Split} from '@src/types/onyx/IOU'; import type {ErrorFields, Errors} from '@src/types/onyx/OnyxCommon'; import type {PaymentMethodType} from '@src/types/onyx/OriginalMessage'; import type ReportAction from '@src/types/onyx/ReportAction'; @@ -359,6 +359,7 @@ function initMoneyRequest( // Use set() here so that there is no way that data will be leaked between objects when it gets reset Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${newTransactionID}`, { amount: 0, + attendees: IOUUtils.formatCurrentUserToAttendee(currentUserPersonalDetails, reportID), comment, created, currency, @@ -426,6 +427,10 @@ function setMoneyRequestMerchant(transactionID: string, merchant: string, isDraf Onyx.merge(`${isDraft ? ONYXKEYS.COLLECTION.TRANSACTION_DRAFT : ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, {merchant}); } +function setMoneyRequestAttendees(transactionID: string, attendees: Attendee[], isDraft: boolean) { + Onyx.merge(`${isDraft ? ONYXKEYS.COLLECTION.TRANSACTION_DRAFT : ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, {attendees}); +} + function setMoneyRequestPendingFields(transactionID: string, pendingFields: OnyxTypes.Transaction['pendingFields']) { Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`, {pendingFields}); } @@ -1910,6 +1915,7 @@ function getSendInvoiceInformation( currency, optimisticInvoiceReport.reportID, trimmedComment, + [], created, '', '', @@ -2031,6 +2037,7 @@ function getMoneyRequestInformation( payeeEmail = currentUserEmail, moneyRequestReportID = '', linkedTrackedExpenseReportAction?: OnyxTypes.ReportAction, + attendees?: Attendee[], existingTransaction: OnyxEntry | undefined = undefined, ): MoneyRequestInformation { const payerEmail = PhoneNumber.addSMSDomainIfPhoneNumber(participant.login ?? ''); @@ -2090,6 +2097,7 @@ function getMoneyRequestInformation( currency, iouReport.reportID, comment, + attendees, created, '', '', @@ -2330,6 +2338,7 @@ function getTrackExpenseInformation( currency, shouldUseMoneyReport && iouReport ? iouReport.reportID : '-1', comment, + [], created, '', '', @@ -2489,6 +2498,7 @@ function getUpdateMoneyRequestParams( policyTagList: OnyxTypes.OnyxInputOrEntry, policyCategories: OnyxTypes.OnyxInputOrEntry, onlyIncludeChangedFields: boolean, + violations?: OnyxEntry, ): UpdateMoneyRequestData { const optimisticData: OnyxUpdate[] = []; const successData: OnyxUpdate[] = []; @@ -2732,6 +2742,19 @@ function getUpdateMoneyRequestParams( } } + const overLimitViolation = violations?.find((violation) => violation.name === 'overLimit'); + // Update violation limit, if we modify attendees. The given limit value is for a single attendee, if we have multiple attendees we should multpiply limit by attende count + if ('attendees' in transactionChanges && !!overLimitViolation) { + const limitForSingleAttendee = ViolationsUtils.getViolationAmountLimit(overLimitViolation); + if (limitForSingleAttendee * (transactionChanges?.attendees?.length ?? 1) > Math.abs(TransactionUtils.getAmount(transaction))) { + optimisticData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transactionID}`, + value: violations?.filter((violation) => violation.name !== 'overLimit') ?? [], + }); + } + } + const clearedPendingFields = Object.fromEntries(Object.keys(updatedTransaction?.pendingFields ?? transactionChanges).map((key) => [key, null])); // Clear out the error fields and loading states on success @@ -3051,6 +3074,24 @@ function updateMoneyRequestMerchant( API.write(WRITE_COMMANDS.UPDATE_MONEY_REQUEST_MERCHANT, params, onyxData); } +/** Updates the attendees list of an expense */ +function updateMoneyRequestAttendees( + transactionID: string, + transactionThreadReportID: string, + attendees: Attendee[], + policy: OnyxEntry, + policyTagList: OnyxEntry, + policyCategories: OnyxEntry, + violations: OnyxEntry, +) { + const transactionChanges: TransactionChanges = { + attendees, + }; + const data = getUpdateMoneyRequestParams(transactionID, transactionThreadReportID, transactionChanges, policy, policyTagList, policyCategories, true, violations); + const {params, onyxData} = data; + API.write(WRITE_COMMANDS.UPDATE_MONEY_REQUEST_ATTENDEES, params, onyxData); +} + /** Updates the tag of an expense */ function updateMoneyRequestTag( transactionID: string, @@ -3308,6 +3349,7 @@ function convertTrackedExpenseToRequest( comment: string, merchant: string, created: string, + attendees?: Attendee[], receipt?: Receipt, ) { const {optimisticData, successData, failureData} = onyxData; @@ -3332,6 +3374,7 @@ function convertTrackedExpenseToRequest( failureData?.push(...moveTransactionFailureData); const parameters = { + attendees, amount, currency, comment, @@ -3510,6 +3553,7 @@ function shareTrackedExpense( function requestMoney( report: OnyxEntry, amount: number, + attendees: Attendee[] | undefined, currency: string, created: string, merchant: string, @@ -3576,6 +3620,7 @@ function requestMoney( payeeEmail, moneyRequestReportID, linkedTrackedExpenseReportAction, + attendees, ); const activeReportID = isMoneyRequestReport ? report?.reportID : chatReport.reportID; @@ -3605,6 +3650,7 @@ function requestMoney( comment, merchant, created, + attendees, receipt, ); break; @@ -3990,6 +4036,7 @@ function createSplitsAndOnyxData( currency, CONST.REPORT.SPLIT_REPORTID, comment, + [], created, '', '', @@ -4234,6 +4281,7 @@ function createSplitsAndOnyxData( currency, oneOnOneIOUReport.reportID, comment, + [], created, CONST.IOU.TYPE.SPLIT, splitTransaction.transactionID, @@ -4581,6 +4629,7 @@ function startSplitBill({ currency, CONST.REPORT.SPLIT_REPORTID, comment, + [], '', '', '', @@ -4992,6 +5041,7 @@ function completeSplitBill(chatReportID: string, reportAction: OnyxTypes.ReportA currency ?? '', oneOnOneIOUReport?.reportID ?? '-1', updatedTransaction?.comment?.comment, + [], updatedTransaction?.modifiedCreated, CONST.IOU.TYPE.SPLIT, transactionID, @@ -5231,6 +5281,7 @@ function createDistanceRequest( currentUserEmail, moneyRequestReportID, undefined, + undefined, existingTransaction, ); onyxData = moneyRequestOnyxData; @@ -8523,6 +8574,7 @@ export { setDraftSplitTransaction, setIndividualShare, setMoneyRequestAmount, + setMoneyRequestAttendees, setMoneyRequestBillable, setMoneyRequestCategory, setMoneyRequestCreated, @@ -8547,6 +8599,7 @@ export { trackExpense, unapproveExpenseReport, unholdRequest, + updateMoneyRequestAttendees, updateMoneyRequestAmountAndCurrency, updateMoneyRequestBillable, updateMoneyRequestCategory, diff --git a/src/pages/iou/request/MoneyRequestAttendeeSelector.tsx b/src/pages/iou/request/MoneyRequestAttendeeSelector.tsx new file mode 100644 index 000000000000..8732efd2e72d --- /dev/null +++ b/src/pages/iou/request/MoneyRequestAttendeeSelector.tsx @@ -0,0 +1,311 @@ +import lodashIsEqual from 'lodash/isEqual'; +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, useSession} 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 useLocalize from '@hooks/useLocalize'; +import useNetwork from '@hooks/useNetwork'; +import usePolicy from '@hooks/usePolicy'; +import useScreenWrapperTranstionStatus from '@hooks/useScreenWrapperTransitionStatus'; +import useThemeStyles from '@hooks/useThemeStyles'; +import * as DeviceCapabilities from '@libs/DeviceCapabilities'; +import * as OptionsListUtils from '@libs/OptionsListUtils'; +import * as PolicyUtils from '@libs/PolicyUtils'; +import * as Report from '@userActions/Report'; +import type {IOUAction, IOUType} from '@src/CONST'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {Attendee} from '@src/types/onyx/IOU'; + +type MoneyRequestAttendeesSelectorProps = { + /** Callback to request parent modal to go to next step, which should be split */ + onFinish: (value?: string) => void; + + /** Callback to add participants in MoneyRequestModal */ + onAttendeesAdded: (value: Attendee[]) => void; + + /** Selected participants from MoneyRequestModal with login */ + attendees?: Attendee[]; + + /** The type of IOU report, i.e. split, request, send, track */ + iouType: IOUType; + + /** The action of the IOU, i.e. create, split, move */ + action: IOUAction; +}; + +function MoneyRequestAttendeeSelector({attendees = [], onFinish, onAttendeesAdded, iouType, action}: MoneyRequestAttendeesSelectorProps) { + const {translate} = useLocalize(); + const styles = useThemeStyles(); + const [searchTerm, debouncedSearchTerm, setSearchTerm] = useDebouncedState(''); + const {isOffline} = useNetwork(); + const personalDetails = usePersonalDetails(); + const {didScreenTransitionEnd} = useScreenWrapperTranstionStatus(); + const [betas] = useOnyx(ONYXKEYS.BETAS); + const [activePolicyID] = useOnyx(ONYXKEYS.NVP_ACTIVE_POLICY_ID); + const session = useSession(); + const isCurrentUserAttendee = attendees.some((attendee) => attendee.accountID === session.accountID); + const [recentAttendees] = useOnyx(ONYXKEYS.NVP_RECENT_ATTENDEES); + 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 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.getAttendeeOptions( + options.reports, + options.personalDetails, + betas, + attendees, + recentAttendees ?? [], + iouType === CONST.IOU.TYPE.SUBMIT && action !== CONST.IOU.ACTION.SUBMIT, + !isCategorizeOrShareAction, + !isCategorizeOrShareAction, + iouType === CONST.IOU.TYPE.INVOICE, + action, + isPaidGroupPolicy, + ); + if (optionList.currentUserOption && !isCurrentUserAttendee) { + optionList.recentReports = [optionList.currentUserOption, ...optionList.personalDetails]; + } + return optionList; + }, [ + areOptionsInitialized, + didScreenTransitionEnd, + options.reports, + options.personalDetails, + betas, + attendees, + recentAttendees, + iouType, + action, + isCategorizeOrShareAction, + isPaidGroupPolicy, + isCurrentUserAttendee, + ]); + + const chatOptions = useMemo(() => { + if (!areOptionsInitialized) { + return { + userToInvite: null, + recentReports: [], + personalDetails: [], + currentUserOption: null, + headerMessage: '', + categoryOptions: [], + tagOptions: [], + taxRatesOptions: [], + }; + } + const newOptions = OptionsListUtils.filterOptions(defaultOptions, debouncedSearchTerm, { + excludeLogins: CONST.EXPENSIFY_EMAILS, + maxRecentReportsToShow: CONST.IOU.MAX_RECENT_REPORTS_TO_SHOW, + preferPolicyExpenseChat: isPaidGroupPolicy, + shouldAcceptName: true, + }); + return newOptions; + }, [areOptionsInitialized, defaultOptions, debouncedSearchTerm, isPaidGroupPolicy]); + + /** + * Returns the sections needed for the OptionsSelector + */ + const [sections, header] = useMemo(() => { + const newSections: OptionsListUtils.CategorySection[] = []; + if (!areOptionsInitialized || !didScreenTransitionEnd) { + return [newSections, '']; + } + const fiveRecents = [...chatOptions.recentReports].slice(0, 5); + const restOfRecents = [...chatOptions.recentReports].slice(5); + const contactsWithRestOfRecents = [...restOfRecents, ...chatOptions.personalDetails]; + + const formatResults = OptionsListUtils.formatSectionsFromSearchTerm( + debouncedSearchTerm, + attendees.map((attendee) => ({...attendee, reportID: attendee.reportID ?? '-1'})), + chatOptions.recentReports, + chatOptions.personalDetails, + personalDetails, + true, + ); + newSections.push(formatResults.section); + + newSections.push({ + title: translate('common.recents'), + data: fiveRecents, + shouldShow: fiveRecents.length > 0, + }); + + newSections.push({ + title: translate('common.contacts'), + data: contactsWithRestOfRecents, + shouldShow: contactsWithRestOfRecents.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(), + attendees.some((attendee) => OptionsListUtils.getPersonalDetailSearchTerms(attendee).join(' ').toLowerCase().includes(cleanSearchTerm)), + ); + + return [newSections, headerMessage]; + }, [ + areOptionsInitialized, + didScreenTransitionEnd, + debouncedSearchTerm, + attendees, + chatOptions.recentReports, + chatOptions.personalDetails, + chatOptions.userToInvite, + personalDetails, + translate, + cleanSearchTerm, + ]); + + const addAttendeeToSelection = useCallback( + (option: Attendee) => { + const isOptionSelected = (selectedOption: Attendee) => { + if (selectedOption.accountID && selectedOption.accountID === option?.accountID) { + return true; + } + + return false; + }; + const isOptionInList = attendees.some(isOptionSelected); + let newSelectedOptions: Attendee[]; + + if (isOptionInList) { + newSelectedOptions = lodashReject(attendees, isOptionSelected); + } else { + newSelectedOptions = [ + ...attendees, + { + accountID: option.accountID ?? -1, + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + login: option.login || option.text, + displayName: option.text, + selected: true, + searchText: option.searchText, + iouType, + }, + ]; + } + + onAttendeesAdded(newSelectedOptions); + }, + [attendees, iouType, onAttendeesAdded], + ); + + const shouldShowErrorMessage = attendees.length < 1; + + const handleConfirmSelection = useCallback( + (_keyEvent?: GestureResponderEvent | KeyboardEvent, option?: Attendee) => { + if (shouldShowErrorMessage || (!attendees.length && !option)) { + return; + } + + onFinish(CONST.IOU.TYPE.SUBMIT); + }, + [shouldShowErrorMessage, onFinish, attendees], + ); + + 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 (!shouldShowErrorMessage && !attendees.length) { + return; + } + + return ( + <> + {shouldShowErrorMessage && ( + + )} + + {!isCategorizeOrShareAction && ( +