diff --git a/android/app/build.gradle b/android/app/build.gradle index 3671f189765b..4f41039c2958 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -110,8 +110,8 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion multiDexEnabled rootProject.ext.multiDexEnabled - versionCode 1009008700 - versionName "9.0.87-0" + versionCode 1009008703 + versionName "9.0.87-3" // Supported language variants must be declared here to avoid from being removed during the compilation. // This also helps us to not include unnecessary language variants in the APK. resConfigs "en", "es" diff --git a/docs/articles/new-expensify/getting-started/Create-a-company-workspace.md b/docs/articles/new-expensify/getting-started/Create-a-company-workspace.md index 664e41677cbc..d74f9108d6d9 100644 --- a/docs/articles/new-expensify/getting-started/Create-a-company-workspace.md +++ b/docs/articles/new-expensify/getting-started/Create-a-company-workspace.md @@ -21,6 +21,8 @@ You can get support any time by locating your chat with Concierge in your chat i # 2. Create a new workspace +Members can submit and manage their expenses in a workspace in Expensify. Each workspace has its own set of rules, settings, and integrations. +
  1. Click your profile photo or icon in the bottom left menu.
  2. Scroll down and click Workspaces in the left menu.
  3. diff --git a/docs/articles/new-expensify/getting-started/Join-your-company's-workspace.md b/docs/articles/new-expensify/getting-started/Join-your-company's-workspace.md index 1e8ae38b3991..3a06ef247212 100644 --- a/docs/articles/new-expensify/getting-started/Join-your-company's-workspace.md +++ b/docs/articles/new-expensify/getting-started/Join-your-company's-workspace.md @@ -16,7 +16,7 @@ Get started by downloading Expensify mobile or desktop apps and ensure you’re - **Mobile Devices:** Download the Expensify app for [Android](https://play.google.com/store/apps/details?id=com.expensify.chat) or [iOS](https://apps.apple.com/us/app/expensify-cash/id1530278510). - **Desktop Devices:** Download the Expensify app for [macOS](https://new.expensify.com/NewExpensify.dmg). -## Use Expensify on the Web: +## Use Expensify on the web: Expensify is also accessible via the web and supports the following browsers: @@ -76,9 +76,9 @@ By using a compatible device or browser, you’ll ensure the best experience wit # 3. Meet Concierge Concierge is your personal assistant that walks you through setting up your account and also provides: -- Reminders to do things like submit your expenses -- Alerts when more information is needed on an expense report -- Updates on new and improved account features +- Reminders to do things like submit your expenses. +- Alerts when more information is needed on an expense report. +- Updates on new and improved account features. You can get support any time by locating your chat with Concierge in your chat inbox. You can ask questions and receive direct support in this thread. @@ -92,7 +92,7 @@ You can create an expense by SmartScanning a receipt to automatically capture th {% include option.html value="desktop" %}
      -
    1. Click the + icon in the bottom left menu and select Submit Expense.
    2. +
    3. Click the + icon in the bottom left menu and select Create Expense.
    4. Click Scan.
    5. Drag and drop the receipt into Expensify, or click Choose File to select it from your saved files. Note: The SmartScan process will auto-populate the merchant, date, and amount.
    6. Use the search field to find the desired workspace or an individual’s name, email, or phone number.
    7. @@ -104,7 +104,7 @@ You can create an expense by SmartScanning a receipt to automatically capture th {% include option.html value="mobile" %}
        -
      1. Tap the + icon at the bottom of the screen and select Submit Expense.
      2. +
      3. Tap the + icon at the bottom of the screen and select Create Expense.
      4. Tap Scan.
      5. Tap the green button to take a photo of a receipt, or tap the Image icon to the left of it to upload a receipt from your phone. Note: The SmartScan process will auto-populate the merchant, date, and amount.
      6. Use the search field to find the desired workspace or an individual’s name, email, or phone number.
      7. diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist index a04d8eac5873..2a837891827f 100644 --- a/ios/NewExpensify/Info.plist +++ b/ios/NewExpensify/Info.plist @@ -44,7 +44,7 @@ CFBundleVersion - 9.0.87.0 + 9.0.87.3 FullStory OrgId diff --git a/ios/NewExpensifyTests/Info.plist b/ios/NewExpensifyTests/Info.plist index 3245342658cb..c21dcfae616b 100644 --- a/ios/NewExpensifyTests/Info.plist +++ b/ios/NewExpensifyTests/Info.plist @@ -19,6 +19,6 @@ CFBundleSignature ???? CFBundleVersion - 9.0.87.0 + 9.0.87.3 diff --git a/ios/NotificationServiceExtension/Info.plist b/ios/NotificationServiceExtension/Info.plist index 314309978cff..32cbdbfc1dfa 100644 --- a/ios/NotificationServiceExtension/Info.plist +++ b/ios/NotificationServiceExtension/Info.plist @@ -13,7 +13,7 @@ CFBundleShortVersionString 9.0.87 CFBundleVersion - 9.0.87.0 + 9.0.87.3 NSExtension NSExtensionPointIdentifier diff --git a/package-lock.json b/package-lock.json index 79abe870d375..64b213b76147 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "new.expensify", - "version": "9.0.87-0", + "version": "9.0.87-3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "new.expensify", - "version": "9.0.87-0", + "version": "9.0.87-3", "hasInstallScript": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index 43e6944cc236..9ab7cf7cdad2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "new.expensify", - "version": "9.0.87-0", + "version": "9.0.87-3", "author": "Expensify, Inc.", "homepage": "https://new.expensify.com", "description": "New Expensify is the next generation of Expensify: a reimagination of payments based atop a foundation of chat.", diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index 6b26ecd73700..d39213943c82 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -558,6 +558,8 @@ const ONYXKEYS = { ADD_PAYMENT_CARD_FORM_DRAFT: 'addPaymentCardFormDraft', WORKSPACE_SETTINGS_FORM: 'workspaceSettingsForm', WORKSPACE_CATEGORY_FORM: 'workspaceCategoryForm', + WORKSPACE_CONFIRMATION_FORM: 'workspaceConfirmationForm', + WORKSPACE_CONFIRMATION_FORM_DRAFT: 'workspaceConfirmationFormDraft', WORKSPACE_CATEGORY_FORM_DRAFT: 'workspaceCategoryFormDraft', WORKSPACE_CATEGORY_DESCRIPTION_HINT_FORM: 'workspaceCategoryDescriptionHintForm', WORKSPACE_CATEGORY_DESCRIPTION_HINT_FORM_DRAFT: 'workspaceCategoryDescriptionHintFormDraft', @@ -743,6 +745,7 @@ type OnyxFormValuesMapping = { [ONYXKEYS.FORMS.ADD_PAYMENT_CARD_FORM]: FormTypes.AddPaymentCardForm; [ONYXKEYS.FORMS.WORKSPACE_SETTINGS_FORM]: FormTypes.WorkspaceSettingsForm; [ONYXKEYS.FORMS.WORKSPACE_CATEGORY_FORM]: FormTypes.WorkspaceCategoryForm; + [ONYXKEYS.FORMS.WORKSPACE_CONFIRMATION_FORM]: FormTypes.WorkspaceConfirmationForm; [ONYXKEYS.FORMS.WORKSPACE_TAG_FORM]: FormTypes.WorkspaceTagForm; [ONYXKEYS.FORMS.WORKSPACE_TAX_CUSTOM_NAME]: FormTypes.WorkspaceTaxCustomName; [ONYXKEYS.FORMS.WORKSPACE_COMPANY_CARD_FEED_NAME]: FormTypes.WorkspaceCompanyCardFeedName; diff --git a/src/ROUTES.ts b/src/ROUTES.ts index 7bf1bf1c9e07..4220569388ee 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -13,8 +13,8 @@ import type AssertTypesNotEqual from './types/utils/AssertTypesNotEqual'; /** * Builds a URL with an encoded URI component for the `backTo` param which can be added to the end of URLs */ -function getUrlWithBackToParam(url: TUrl, backTo?: string): `${TUrl}` { - const backToParam = backTo ? (`${url.includes('?') ? '&' : '?'}backTo=${encodeURIComponent(backTo)}` as const) : ''; +function getUrlWithBackToParam(url: TUrl, backTo?: string, shouldEncodeURIComponent = true): `${TUrl}` { + const backToParam = backTo ? (`${url.includes('?') ? '&' : '?'}backTo=${shouldEncodeURIComponent ? encodeURIComponent(backTo) : backTo}` as const) : ''; return `${url}${backToParam}` as `${TUrl}`; } @@ -777,7 +777,7 @@ const ROUTES = { }, POLICY_ACCOUNTING_QUICKBOOKS_ONLINE_EXPORT: { route: 'settings/workspaces/:policyID/accounting/quickbooks-online/export', - getRoute: (policyID: string, backTo?: string) => `settings/workspaces/${policyID}/accounting/quickbooks-online/export?backTo=${backTo}` as const, + getRoute: (policyID: string, backTo?: string) => getUrlWithBackToParam(`settings/workspaces/${policyID}/accounting/quickbooks-online/export` as const, backTo, false), }, POLICY_ACCOUNTING_QUICKBOOKS_ONLINE_COMPANY_CARD_EXPENSE_ACCOUNT: { route: 'settings/workspaces/:policyID/accounting/quickbooks-online/export/company-card-expense-account', @@ -1270,7 +1270,7 @@ const ROUTES = { WORKSPACE_COMPANY_CARD_EXPORT: { route: 'settings/workspaces/:policyID/company-cards/:bank/:cardID/edit/export', getRoute: (policyID: string, cardID: string, bank: string, backTo?: string) => - `settings/workspaces/${policyID}/company-cards/${bank}/${cardID}/edit/export?backTo=${backTo}` as const, + getUrlWithBackToParam(`settings/workspaces/${policyID}/company-cards/${bank}/${cardID}/edit/export`, backTo, false), }, WORKSPACE_EXPENSIFY_CARD: { route: 'settings/workspaces/:policyID/expensify-card', @@ -1487,6 +1487,10 @@ const ROUTES = { }, WELCOME_VIDEO_ROOT: 'onboarding/welcome-video', EXPLANATION_MODAL_ROOT: 'onboarding/explanation', + WORKSPACE_CONFIRMATION: { + route: 'workspace/confirmation', + getRoute: (backTo?: string) => getUrlWithBackToParam(`workspace/confirmation`, backTo), + }, MIGRATED_USER_WELCOME_MODAL: 'onboarding/migrated-user-welcome', TRANSACTION_RECEIPT: { @@ -1562,7 +1566,7 @@ const ROUTES = { }, POLICY_ACCOUNTING_XERO_EXPORT: { route: 'settings/workspaces/:policyID/accounting/xero/export', - getRoute: (policyID: string, backTo?: string) => `settings/workspaces/${policyID}/accounting/xero/export?backTo=${backTo}` as const, + getRoute: (policyID: string, backTo?: string) => getUrlWithBackToParam(`settings/workspaces/${policyID}/accounting/xero/export` as const, backTo, false), }, POLICY_ACCOUNTING_XERO_PREFERRED_EXPORTER_SELECT: { route: 'settings/workspaces/:policyID/connections/xero/export/preferred-exporter/select', @@ -1687,7 +1691,7 @@ const ROUTES = { }, POLICY_ACCOUNTING_NETSUITE_EXPORT: { route: 'settings/workspaces/:policyID/connections/netsuite/export/', - getRoute: (policyID: string, backTo?: string) => `settings/workspaces/${policyID}/connections/netsuite/export?backTo=${backTo}` as const, + getRoute: (policyID: string, backTo?: string) => getUrlWithBackToParam(`settings/workspaces/${policyID}/connections/netsuite/export/` as const, backTo, false), }, POLICY_ACCOUNTING_NETSUITE_PREFERRED_EXPORTER_SELECT: { route: 'settings/workspaces/:policyID/connections/netsuite/export/preferred-exporter/select', @@ -1825,7 +1829,7 @@ const ROUTES = { }, POLICY_ACCOUNTING_SAGE_INTACCT_EXPORT: { route: 'settings/workspaces/:policyID/accounting/sage-intacct/export', - getRoute: (policyID: string, backTo?: string) => `settings/workspaces/${policyID}/accounting/sage-intacct/export?backTo=${backTo}` as const, + getRoute: (policyID: string, backTo?: string) => getUrlWithBackToParam(`settings/workspaces/${policyID}/accounting/sage-intacct/export` as const, backTo, false), }, POLICY_ACCOUNTING_SAGE_INTACCT_PREFERRED_EXPORTER: { route: 'settings/workspaces/:policyID/accounting/sage-intacct/export/preferred-exporter', diff --git a/src/SCREENS.ts b/src/SCREENS.ts index c204b05c90de..5a5635037d02 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -159,6 +159,7 @@ const SCREENS = { DETAILS: 'Details', PROFILE: 'Profile', REPORT_DETAILS: 'Report_Details', + WORKSPACE_CONFIRMATION: 'Workspace_Confirmation', REPORT_SETTINGS: 'Report_Settings', REPORT_DESCRIPTION: 'Report_Description', PARTICIPANTS: 'Participants', @@ -326,6 +327,8 @@ const SCREENS = { EXPORT: 'Report_Details_Export', }, + WORKSPACE_CONFIRMATION: {ROOT: 'Workspace_Confirmation_Root'}, + WORKSPACE: { ACCOUNTING: { ROOT: 'Policy_Accounting', diff --git a/src/components/CurrencyPicker.tsx b/src/components/CurrencyPicker.tsx new file mode 100644 index 000000000000..f382542c6538 --- /dev/null +++ b/src/components/CurrencyPicker.tsx @@ -0,0 +1,89 @@ +import React, {forwardRef, useState} from 'react'; +import type {ForwardedRef} from 'react'; +import {View} from 'react-native'; +import useLocalize from '@hooks/useLocalize'; +import useStyleUtils from '@hooks/useStyleUtils'; +import useThemeStyles from '@hooks/useThemeStyles'; +import variables from '@styles/variables'; +import CONST from '@src/CONST'; +import CurrencySelectionListWithOnyx from './CurrencySelectionList'; +import HeaderWithBackButton from './HeaderWithBackButton'; +import MenuItemWithTopDescription from './MenuItemWithTopDescription'; +import Modal from './Modal'; +import ScreenWrapper from './ScreenWrapper'; +import type {ValuePickerItem, ValuePickerProps} from './ValuePicker/types'; + +type CurrencyPickerProps = { + selectedCurrency?: string; +}; +function CurrencyPicker({selectedCurrency, label = '', errorText = '', value, onInputChange, furtherDetails}: ValuePickerProps & CurrencyPickerProps, forwardedRef: ForwardedRef) { + const StyleUtils = useStyleUtils(); + const styles = useThemeStyles(); + const [isPickerVisible, setIsPickerVisible] = useState(false); + const {translate} = useLocalize(); + + const showPickerModal = () => { + setIsPickerVisible(true); + }; + + const hidePickerModal = () => { + setIsPickerVisible(false); + }; + + const updateInput = (item: ValuePickerItem) => { + if (item.value !== selectedCurrency) { + onInputChange?.(item.value); + } + hidePickerModal(); + }; + + const descStyle = !selectedCurrency || selectedCurrency.length === 0 ? StyleUtils.getFontSizeStyle(variables.fontSizeLabel) : null; + + return ( + + + + hidePickerModal} + onModalHide={hidePickerModal} + hideModalContentWhileAnimating + useNativeDriver + onBackdropPress={hidePickerModal} + > + + + updateInput({value: item.currencyCode})} + searchInputLabel={translate('common.currency')} + initiallySelectedCurrencyCode={selectedCurrency} + /> + + + + ); +} + +CurrencyPicker.displayName = 'CurrencyPicker'; + +export default forwardRef(CurrencyPicker); diff --git a/src/languages/en.ts b/src/languages/en.ts index 31c90570abf9..fc29e0b6e63c 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -358,7 +358,6 @@ const translations = { invalidRateError: 'Please enter a valid rate.', lowRateError: 'Rate must be greater than 0.', email: 'Please enter a valid email address.', - login: 'An error occurred while logging in. Please try again.', }, comma: 'comma', semicolon: 'semicolon', @@ -2517,6 +2516,7 @@ const translations = { carType: 'Car type', cancellation: 'Cancellation policy', cancellationUntil: 'Free cancellation until', + freeCancellation: 'Free cancellation', confirmation: 'Confirmation number', }, train: 'Rail', @@ -3560,7 +3560,7 @@ const translations = { }, earnSection: { title: 'Earn', - subtitle: 'Enable optional functionality to streamline your revenue and get paid faster.', + subtitle: 'Streamline your revenue and get paid faster.', }, organizeSection: { title: 'Organize', @@ -3817,7 +3817,7 @@ const translations = { }, emptyWorkspace: { title: 'Create a workspace', - subtitle: 'Create a workspace to track receipts, reimburse expenses, send invoices, and more -- all at the speed of chat.', + subtitle: 'Create a workspace to track receipts, reimburse expenses, send invoices, and more — all at the speed of chat.', createAWorkspaceCTA: 'Get Started', features: { trackAndCollect: 'Track and collect receipts', @@ -3835,6 +3835,7 @@ const translations = { new: { newWorkspace: 'New workspace', getTheExpensifyCardAndMore: 'Get the Expensify Card and more', + confirmWorkspace: 'Confirm Workspace', }, people: { genericFailureMessage: 'An error occurred removing a member from the workspace, please try again.', @@ -4557,6 +4558,7 @@ const translations = { description: 'Choose from the support options below:', chatWithConcierge: 'Chat with Concierge', scheduleSetupCall: 'Schedule a setup call', + scheduleADemo: 'Schedule a demo', questionMarkButtonTooltip: 'Get assistance from our team', exploreHelpDocs: 'Explore help docs', }, diff --git a/src/languages/es.ts b/src/languages/es.ts index 26507be8c7a9..43db4f9f3eb5 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -349,7 +349,6 @@ const translations = { invalidRateError: 'Por favor, introduce una tarifa válida.', lowRateError: 'La tarifa debe ser mayor que 0.', email: 'Por favor, introduzca una dirección de correo electrónico válida.', - login: 'Se produjo un error al iniciar sesión. Por favor intente nuevamente.', }, comma: 'la coma', semicolon: 'el punto y coma', @@ -2541,6 +2540,7 @@ const translations = { carType: 'Tipo de coche', cancellation: 'Política de cancelación', cancellationUntil: 'Cancelación gratuita hasta el', + freeCancellation: 'Cancelación gratuita', confirmation: 'Número de confirmación', }, train: 'Tren', @@ -3602,7 +3602,7 @@ const translations = { }, earnSection: { title: 'Gane', - subtitle: 'Habilita funciones opcionales para agilizar tus ingresos y recibir pagos más rápido.', + subtitle: 'Agiliza tus ingresos y recibe pagos más rápido.', }, organizeSection: { title: 'Organizar', @@ -3879,6 +3879,7 @@ const translations = { new: { newWorkspace: 'Nuevo espacio de trabajo', getTheExpensifyCardAndMore: 'Consigue la Tarjeta Expensify y más', + confirmWorkspace: 'Confirmar espacio de trabajo', }, people: { genericFailureMessage: 'Se ha producido un error al intentar eliminar a un miembro del espacio de trabajo. Por favor, inténtalo más tarde.', @@ -4604,6 +4605,7 @@ const translations = { description: 'Elige una de las siguientes opciones:', chatWithConcierge: 'Chatear con Concierge', scheduleSetupCall: 'Concertar una llamada', + scheduleADemo: 'Programa una demostración', questionMarkButtonTooltip: 'Obtén ayuda de nuestro equipo', exploreHelpDocs: 'Explorar la documentación de ayuda', }, diff --git a/src/libs/API/parameters/CreateWorkspaceParams.ts b/src/libs/API/parameters/CreateWorkspaceParams.ts index 91c1039169aa..313ef1bd6268 100644 --- a/src/libs/API/parameters/CreateWorkspaceParams.ts +++ b/src/libs/API/parameters/CreateWorkspaceParams.ts @@ -11,6 +11,8 @@ type CreateWorkspaceParams = { customUnitID: string; customUnitRateID: string; engagementChoice?: string; + currency: string; + file?: File; }; export default CreateWorkspaceParams; diff --git a/src/libs/CurrencyUtils.ts b/src/libs/CurrencyUtils.ts index 26a5d209b0ff..cb5363225a4c 100644 --- a/src/libs/CurrencyUtils.ts +++ b/src/libs/CurrencyUtils.ts @@ -2,8 +2,9 @@ import Onyx from 'react-native-onyx'; import CONST from '@src/CONST'; import type {OnyxValues} from '@src/ONYXKEYS'; import ONYXKEYS from '@src/ONYXKEYS'; +import type {Currency} from '@src/types/onyx'; import BaseLocaleListener from './Localize/LocaleListener/BaseLocaleListener'; -import * as NumberFormatUtils from './NumberFormatUtils'; +import {format, formatToParts} from './NumberFormatUtils'; let currencyList: OnyxValues[typeof ONYXKEYS.CURRENCY_LIST] = {}; @@ -30,6 +31,11 @@ function getCurrencyDecimals(currency: string = CONST.CURRENCY.USD): number { return decimals ?? 2; } +function getCurrency(currency: string = CONST.CURRENCY.USD): Currency | null { + const currencyItem = currencyList?.[currency]; + return currencyItem; +} + /** * Returns the currency's minor unit quantity * e.g. Cent in USD @@ -44,7 +50,7 @@ function getCurrencyUnit(currency: string = CONST.CURRENCY.USD): number { * Get localized currency symbol for currency(ISO 4217) Code */ function getLocalizedCurrencySymbol(currencyCode: string): string | undefined { - const parts = NumberFormatUtils.formatToParts(BaseLocaleListener.getPreferredLocale(), 0, { + const parts = formatToParts(BaseLocaleListener.getPreferredLocale(), 0, { style: 'currency', currency: currencyCode, }); @@ -62,7 +68,7 @@ function getCurrencySymbol(currencyCode: string): string | undefined { * Whether the currency symbol is left-to-right. */ function isCurrencySymbolLTR(currencyCode: string): boolean { - const parts = NumberFormatUtils.formatToParts(BaseLocaleListener.getPreferredLocale(), 0, { + const parts = formatToParts(BaseLocaleListener.getPreferredLocale(), 0, { style: 'currency', currency: currencyCode, }); @@ -121,7 +127,7 @@ function convertToDisplayString(amountInCents = 0, currency: string = CONST.CURR if (!currency) { currencyWithFallback = CONST.CURRENCY.USD; } - return NumberFormatUtils.format(BaseLocaleListener.getPreferredLocale(), convertedAmount, { + return format(BaseLocaleListener.getPreferredLocale(), convertedAmount, { style: 'currency', currency: currencyWithFallback, @@ -143,7 +149,7 @@ function convertToDisplayString(amountInCents = 0, currency: string = CONST.CURR function convertToShortDisplayString(amountInCents = 0, currency: string = CONST.CURRENCY.USD): string { const convertedAmount = convertToFrontendAmountAsInteger(amountInCents, currency); - return NumberFormatUtils.format(BaseLocaleListener.getPreferredLocale(), convertedAmount, { + return format(BaseLocaleListener.getPreferredLocale(), convertedAmount, { style: 'currency', currency, @@ -161,7 +167,7 @@ function convertToShortDisplayString(amountInCents = 0, currency: string = CONST */ function convertAmountToDisplayString(amount = 0, currency: string = CONST.CURRENCY.USD): string { const convertedAmount = amount / 100.0; - return NumberFormatUtils.format(BaseLocaleListener.getPreferredLocale(), convertedAmount, { + return format(BaseLocaleListener.getPreferredLocale(), convertedAmount, { style: 'currency', currency, minimumFractionDigits: CONST.MIN_TAX_RATE_DECIMAL_PLACES, @@ -174,7 +180,7 @@ function convertAmountToDisplayString(amount = 0, currency: string = CONST.CURRE */ function convertToDisplayStringWithoutCurrency(amountInCents: number, currency: string = CONST.CURRENCY.USD) { const convertedAmount = convertToFrontendAmountAsInteger(amountInCents, currency); - return NumberFormatUtils.formatToParts(BaseLocaleListener.getPreferredLocale(), convertedAmount, { + return formatToParts(BaseLocaleListener.getPreferredLocale(), convertedAmount, { style: 'currency', currency, @@ -216,5 +222,6 @@ export { convertToDisplayStringWithoutCurrency, isValidCurrencyCode, convertToShortDisplayString, + getCurrency, sanitizeCurrencyCode, }; diff --git a/src/libs/LoginUtils.ts b/src/libs/LoginUtils.ts index f496c9de0e6e..191fd72db4e9 100644 --- a/src/libs/LoginUtils.ts +++ b/src/libs/LoginUtils.ts @@ -1,11 +1,7 @@ import {PUBLIC_DOMAINS, Str} from 'expensify-common'; import Onyx from 'react-native-onyx'; -import CONFIG from '@src/CONFIG'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import ROUTES from '@src/ROUTES'; -import * as Session from './actions/Session'; -import Navigation from './Navigation/Navigation'; import {parsePhoneNumber} from './PhoneNumber'; let countryCodeByIP: number; @@ -79,26 +75,4 @@ function areEmailsFromSamePrivateDomain(email1: string, email2: string): boolean return Str.extractEmailDomain(email1).toLowerCase() === Str.extractEmailDomain(email2).toLowerCase(); } -function postSAMLLogin(body: FormData): Promise { - return fetch(CONFIG.EXPENSIFY.SAML_URL, { - method: CONST.NETWORK.METHOD.POST, - body, - credentials: 'omit', - }).then((response) => { - if (!response.ok) { - throw new Error('An error occurred while logging in. Please try again'); - } - return response.json() as Promise; - }); -} - -function handleSAMLLoginError(errorMessage: string, cleanSignInData: boolean) { - if (cleanSignInData) { - Session.clearSignInData(); - } - - Session.setAccountError(errorMessage); - Navigation.goBack(ROUTES.HOME); -} - -export {getPhoneNumberWithoutSpecialChars, appendCountryCode, isEmailPublicDomain, validateNumber, getPhoneLogin, areEmailsFromSamePrivateDomain, postSAMLLogin, handleSAMLLoginError}; +export {getPhoneNumberWithoutSpecialChars, appendCountryCode, isEmailPublicDomain, validateNumber, getPhoneLogin, areEmailsFromSamePrivateDomain}; diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx index e05d316bd8ca..cf6bb5eae810 100644 --- a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx +++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx @@ -31,6 +31,7 @@ import type { TransactionDuplicateNavigatorParamList, TravelNavigatorParamList, WalletStatementNavigatorParamList, + WorkspaceConfirmationNavigatorParamList, } from '@navigation/types'; import type {Screen} from '@src/SCREENS'; import SCREENS from '@src/SCREENS'; @@ -137,6 +138,10 @@ const ReportSettingsModalStackNavigator = createModalStackNavigator require('../../../../pages/settings/Report/VisibilityPage').default, }); +const WorkspaceConfirmationModalStackNavigator = createModalStackNavigator({ + [SCREENS.WORKSPACE_CONFIRMATION.ROOT]: () => require('../../../../pages/workspace/WorkspaceConfirmationPage').default, +}); + const TaskModalStackNavigator = createModalStackNavigator({ [SCREENS.TASK.TITLE]: () => require('../../../../pages/tasks/TaskTitlePage').default, [SCREENS.TASK.ASSIGNEE]: () => require('../../../../pages/tasks/TaskAssigneeSelectorModal').default, @@ -730,4 +735,5 @@ export { SearchSavedSearchModalStackNavigator, MissingPersonalDetailsModalStackNavigator, DebugModalStackNavigator, + WorkspaceConfirmationModalStackNavigator, }; diff --git a/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx b/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx index 95f82f4a2fdf..ac53ad3b64d2 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx +++ b/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx @@ -138,6 +138,10 @@ function RightModalNavigator({navigation, route}: RightModalNavigatorProps) { name={SCREENS.RIGHT_MODAL.MONEY_REQUEST} component={ModalStackNavigators.MoneyRequestModalStackNavigator} /> + ['config'] = { }, }, }, + [SCREENS.RIGHT_MODAL.WORKSPACE_CONFIRMATION]: { + screens: { + [SCREENS.WORKSPACE_CONFIRMATION.ROOT]: ROUTES.WORKSPACE_CONFIRMATION.route, + }, + }, [SCREENS.RIGHT_MODAL.NEW_TASK]: { screens: { [SCREENS.NEW_TASK.ROOT]: ROUTES.NEW_TASK.route, diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts index 994178139675..41e8c8cc7824 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -1282,6 +1282,12 @@ type MoneyRequestNavigatorParamList = { }; }; +type WorkspaceConfirmationNavigatorParamList = { + [SCREENS.WORKSPACE_CONFIRMATION.ROOT]: { + backTo?: Routes; + }; +}; + type NewTaskNavigatorParamList = { [SCREENS.NEW_TASK.ROOT]: { backTo?: Routes; @@ -1455,6 +1461,7 @@ type RightModalNavigatorParamList = { [SCREENS.RIGHT_MODAL.PARTICIPANTS]: NavigatorScreenParams; [SCREENS.RIGHT_MODAL.ROOM_MEMBERS]: NavigatorScreenParams; [SCREENS.RIGHT_MODAL.MONEY_REQUEST]: NavigatorScreenParams; + [SCREENS.RIGHT_MODAL.WORKSPACE_CONFIRMATION]: NavigatorScreenParams; [SCREENS.RIGHT_MODAL.NEW_TASK]: NavigatorScreenParams; [SCREENS.RIGHT_MODAL.TEACHERS_UNITE]: NavigatorScreenParams; [SCREENS.RIGHT_MODAL.TASK_DETAILS]: NavigatorScreenParams; @@ -1863,4 +1870,5 @@ export type { MissingPersonalDetailsParamList, DebugParamList, MigratedUserModalNavigatorParamList, + WorkspaceConfirmationNavigatorParamList, }; diff --git a/src/libs/OptionsListUtils.ts b/src/libs/OptionsListUtils.ts index 5314391e2166..4b95fd1ed3b3 100644 --- a/src/libs/OptionsListUtils.ts +++ b/src/libs/OptionsListUtils.ts @@ -1,3 +1,5 @@ +/* eslint-disable @typescript-eslint/prefer-for-of */ + /* eslint-disable no-continue */ import {Str} from 'expensify-common'; import lodashOrderBy from 'lodash/orderBy'; @@ -29,22 +31,105 @@ import type DeepValueOf from '@src/types/utils/DeepValueOf'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; import Timing from './actions/Timing'; import filterArrayByMatch from './filterArrayByMatch'; -import * as LocalePhoneNumber from './LocalePhoneNumber'; -import * as Localize from './Localize'; -import * as LoginUtils from './LoginUtils'; +import {formatPhoneNumber} from './LocalePhoneNumber'; +import {translate, translateLocal} from './Localize'; +import {appendCountryCode, getPhoneNumberWithoutSpecialChars} from './LoginUtils'; import ModifiedExpenseMessage from './ModifiedExpenseMessage'; import Navigation from './Navigation/Navigation'; import Parser from './Parser'; import Performance from './Performance'; -import * as PersonalDetailsUtils from './PersonalDetailsUtils'; -import * as PhoneNumber from './PhoneNumber'; -import * as PolicyUtils from './PolicyUtils'; -import * as ReportActionUtils from './ReportActionsUtils'; -import * as ReportUtils from './ReportUtils'; -import * as TaskUtils from './TaskUtils'; -import * as UserUtils from './UserUtils'; - -type SearchOption = ReportUtils.OptionData & { +import {getDisplayNameOrDefault} from './PersonalDetailsUtils'; +import {addSMSDomainIfPhoneNumber, parsePhoneNumber} from './PhoneNumber'; +import {canSendInvoiceFromWorkspace} from './PolicyUtils'; +import { + getCombinedReportActions, + getExportIntegrationLastMessageText, + getIOUReportIDFromReportActionPreview, + getMessageOfOldDotReportAction, + getOneTransactionThreadReportID, + getOriginalMessage, + getReportActionMessageText, + getSortedReportActions, + isActionableAddPaymentCard, + isActionOfType, + isClosedAction, + isCreatedTaskReportAction, + isDeletedParentAction, + isModifiedExpenseAction, + isMoneyRequestAction, + isOldDotReportAction, + isPendingRemove, + isReimbursementDeQueuedAction, + isReimbursementQueuedAction, + isReportPreviewAction, + isResolvedActionTrackExpense, + isTaskAction, + isThreadParentMessage, + isUnapprovedAction, + isWhisperAction, + shouldReportActionBeVisible, +} from './ReportActionsUtils'; +import { + canUserPerformWriteAction, + formatReportLastMessageText, + getAllReportErrors, + getChatRoomSubtitle, + getDeletedParentActionMessageForChatReport, + getDisplayNameForParticipant, + getDowngradeWorkspaceMessage, + getIcons, + getIOUApprovedMessage, + getIOUForwardedMessage, + getIOUSubmittedMessage, + getIOUUnapprovedMessage, + getMoneyRequestSpendBreakdown, + getParticipantsAccountIDsForDisplay, + getPolicyName, + getReimbursementDeQueuedActionMessage, + getReimbursementQueuedActionMessage, + getRejectedReportMessage, + getReportAutomaticallyApprovedMessage, + getReportAutomaticallyForwardedMessage, + getReportAutomaticallySubmittedMessage, + getReportLastMessage, + getReportName, + getReportNotificationPreference, + getReportOrDraftReport, + getReportParticipantsTitle, + getReportPreviewMessage, + getUpgradeWorkspaceMessage, + hasIOUWaitingOnCurrentUserBankAccount, + isArchivedNonExpenseReport, + isChatThread, + isDefaultRoom, + isDraftReport, + isExpenseReport, + isHiddenForCurrentUser, + isInvoiceRoom, + isIOUOwnedByCurrentUser, + isMoneyRequest, + isPolicyAdmin, + isReportMessageAttachment, + isUnread, + isAdminRoom as reportUtilsIsAdminRoom, + isAnnounceRoom as reportUtilsIsAnnounceRoom, + isChatReport as reportUtilsIsChatReport, + isChatRoom as reportUtilsIsChatRoom, + isGroupChat as reportUtilsIsGroupChat, + isMoneyRequestReport as reportUtilsIsMoneyRequestReport, + isOneOnOneChat as reportUtilsIsOneOnOneChat, + isPolicyExpenseChat as reportUtilsIsPolicyExpenseChat, + isSelfDM as reportUtilsIsSelfDM, + isTaskReport as reportUtilsIsTaskReport, + shouldDisplayViolationsRBRInLHN, + shouldReportBeInOptionList, + shouldReportShowSubscript, +} from './ReportUtils'; +import type {OptionData} from './ReportUtils'; +import {getTaskCreatedMessage, getTaskReportActionMessage} from './TaskUtils'; +import {generateAccountID} from './UserUtils'; + +type SearchOption = OptionData & { item: T; }; @@ -53,7 +138,7 @@ type OptionList = { personalDetails: Array>; }; -type Option = Partial; +type Option = Partial; /** * A narrowed version of `Option` is used when we have a guarantee that given values exist. @@ -87,32 +172,39 @@ type Section = SectionBase & { data: Option[]; }; -type GetOptionsConfig = { - betas?: OnyxEntry; +type GetValidOptionsSharedConfig = { + includeP2P?: boolean; + transactionViolations?: OnyxCollection; + action?: IOUAction; + shouldBoldTitleByDefault?: boolean; selectedOptions?: Option[]; - excludeLogins?: string[]; +}; + +type GetValidReportsConfig = { + betas?: OnyxEntry; includeMultipleParticipantReports?: boolean; - includeRecentReports?: boolean; - includeSelfDM?: boolean; showChatPreviewLine?: boolean; forcePolicyNamePreview?: boolean; + includeSelfDM?: boolean; includeOwnedWorkspaceChats?: boolean; includeThreads?: boolean; includeTasks?: boolean; includeMoneyRequests?: boolean; - includeP2P?: boolean; - includeSelectedOptions?: boolean; - transactionViolations?: OnyxCollection; includeInvoiceRooms?: boolean; includeDomainEmail?: boolean; - action?: IOUAction; + optionsToExclude?: Array>; +} & GetValidOptionsSharedConfig; + +type GetOptionsConfig = { + excludeLogins?: string[]; + includeRecentReports?: boolean; + includeSelectedOptions?: boolean; recentAttendees?: Attendee[]; - shouldBoldTitleByDefault?: boolean; -}; +} & GetValidReportsConfig; type GetUserToInviteConfig = { searchValue: string | undefined; - optionsToExclude?: Array>; + optionsToExclude?: Array>; reportActions?: ReportActions; shouldAcceptName?: boolean; } & Pick; @@ -134,10 +226,10 @@ type SectionForSearchTerm = { section: Section; }; type Options = { - recentReports: ReportUtils.OptionData[]; - personalDetails: ReportUtils.OptionData[]; - userToInvite: ReportUtils.OptionData | null; - currentUserOption: ReportUtils.OptionData | null | undefined; + recentReports: OptionData[]; + personalDetails: OptionData[]; + userToInvite: OptionData | null; + currentUserOption: OptionData | null | undefined; }; type PreviewConfig = {showChatPreviewLine?: boolean; forcePolicyNamePreview?: boolean; showPersonalDetails?: boolean}; @@ -255,15 +347,15 @@ Onyx.connect({ } const reportActionsArray = Object.values(reportActions[1] ?? {}); - let sortedReportActions = ReportActionUtils.getSortedReportActions(reportActionsArray, true); + let sortedReportActions = getSortedReportActions(reportActionsArray, true); allSortedReportActions[reportID] = sortedReportActions; // If the report is a one-transaction report and has , we need to return the combined reportActions so that the LHN can display modifications // to the transaction thread or the report itself - const transactionThreadReportID = ReportActionUtils.getOneTransactionThreadReportID(reportID, actions[reportActions[0]]); + const transactionThreadReportID = getOneTransactionThreadReportID(reportID, actions[reportActions[0]]); if (transactionThreadReportID) { const transactionThreadReportActionsArray = Object.values(actions[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transactionThreadReportID}`] ?? {}); - sortedReportActions = ReportActionUtils.getCombinedReportActions(sortedReportActions, transactionThreadReportID, transactionThreadReportActionsArray, reportID, false); + sortedReportActions = getCombinedReportActions(sortedReportActions, transactionThreadReportID, transactionThreadReportActionsArray, reportID, false); } const firstReportAction = sortedReportActions.at(0); @@ -274,17 +366,17 @@ Onyx.connect({ } const report = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`]; - const canUserPerformWriteAction = ReportUtils.canUserPerformWriteAction(report); + const isWriteActionAllowed = canUserPerformWriteAction(report); // The report is only visible if it is the last action not deleted that // does not match a closed or created state. const reportActionsForDisplay = sortedReportActions.filter( (reportAction, actionKey) => - ReportActionUtils.shouldReportActionBeVisible(reportAction, actionKey, canUserPerformWriteAction) && - !ReportActionUtils.isWhisperAction(reportAction) && + shouldReportActionBeVisible(reportAction, actionKey, isWriteActionAllowed) && + !isWhisperAction(reportAction) && reportAction.actionName !== CONST.REPORT.ACTIONS.TYPE.CREATED && reportAction.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE && - !ReportActionUtils.isResolvedActionTrackExpense(reportAction), + !isResolvedActionTrackExpense(reportAction), ); const reportActionForDisplay = reportActionsForDisplay.at(0); if (!reportActionForDisplay) { @@ -366,11 +458,11 @@ function isPersonalDetailsReady(personalDetails: OnyxEntry) /** * Get the participant option for a report. */ -function getParticipantsOption(participant: ReportUtils.OptionData | Participant, personalDetails: OnyxEntry): Participant { +function getParticipantsOption(participant: OptionData | Participant, personalDetails: OnyxEntry): Participant { const detail = participant.accountID ? getPersonalDetailsForAccountIDs([participant.accountID], personalDetails)[participant.accountID] : undefined; // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing const login = detail?.login || participant.login || ''; - const displayName = LocalePhoneNumber.formatPhoneNumber(PersonalDetailsUtils.getDisplayNameOrDefault(detail, login || participant.text)); + const displayName = formatPhoneNumber(getDisplayNameOrDefault(detail, login || participant.text)); return { keyForList: String(detail?.accountID), @@ -379,7 +471,7 @@ function getParticipantsOption(participant: ReportUtils.OptionData | Participant text: displayName, firstName: detail?.firstName ?? '', lastName: detail?.lastName ?? '', - alternateText: LocalePhoneNumber.formatPhoneNumber(login) || displayName, + alternateText: formatPhoneNumber(login) || displayName, icons: [ { source: detail?.avatar ?? FallbackAvatar, @@ -420,27 +512,27 @@ function uniqFast(items: string[]): string[] { function getLastActorDisplayName(lastActorDetails: Partial | null, hasMultipleParticipants: boolean) { return hasMultipleParticipants && lastActorDetails && lastActorDetails.accountID !== currentUserAccountID ? // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - lastActorDetails.firstName || LocalePhoneNumber.formatPhoneNumber(PersonalDetailsUtils.getDisplayNameOrDefault(lastActorDetails)) + lastActorDetails.firstName || formatPhoneNumber(getDisplayNameOrDefault(lastActorDetails)) : ''; } /** * Update alternate text for the option when applicable */ -function getAlternateText(option: ReportUtils.OptionData, {showChatPreviewLine = false, forcePolicyNamePreview = false}: PreviewConfig) { - const report = ReportUtils.getReportOrDraftReport(option.reportID); - const isAdminRoom = ReportUtils.isAdminRoom(report); - const isAnnounceRoom = ReportUtils.isAnnounceRoom(report); - const isGroupChat = ReportUtils.isGroupChat(report); - const isExpenseThread = ReportUtils.isMoneyRequest(report); - const formattedLastMessageText = ReportUtils.formatReportLastMessageText(Parser.htmlToText(option.lastMessageText ?? '')); +function getAlternateText(option: OptionData, {showChatPreviewLine = false, forcePolicyNamePreview = false}: PreviewConfig) { + const report = getReportOrDraftReport(option.reportID); + const isAdminRoom = reportUtilsIsAdminRoom(report); + const isAnnounceRoom = reportUtilsIsAnnounceRoom(report); + const isGroupChat = reportUtilsIsGroupChat(report); + const isExpenseThread = isMoneyRequest(report); + const formattedLastMessageText = formatReportLastMessageText(Parser.htmlToText(option.lastMessageText ?? '')); if (isExpenseThread || option.isMoneyRequestReport) { - return showChatPreviewLine && formattedLastMessageText ? formattedLastMessageText : Localize.translate(preferredLocale, 'iou.expense'); + return showChatPreviewLine && formattedLastMessageText ? formattedLastMessageText : translate(preferredLocale, 'iou.expense'); } if (option.isThread) { - return showChatPreviewLine && formattedLastMessageText ? formattedLastMessageText : Localize.translate(preferredLocale, 'threads.thread'); + return showChatPreviewLine && formattedLastMessageText ? formattedLastMessageText : translate(preferredLocale, 'threads.thread'); } if (option.isChatRoom && !isAdminRoom && !isAnnounceRoom) { @@ -452,16 +544,16 @@ function getAlternateText(option: ReportUtils.OptionData, {showChatPreviewLine = } if (option.isTaskReport) { - return showChatPreviewLine && formattedLastMessageText ? formattedLastMessageText : Localize.translate(preferredLocale, 'task.task'); + return showChatPreviewLine && formattedLastMessageText ? formattedLastMessageText : translate(preferredLocale, 'task.task'); } if (isGroupChat) { - return showChatPreviewLine && formattedLastMessageText ? formattedLastMessageText : Localize.translate(preferredLocale, 'common.group'); + return showChatPreviewLine && formattedLastMessageText ? formattedLastMessageText : translate(preferredLocale, 'common.group'); } return showChatPreviewLine && formattedLastMessageText ? formattedLastMessageText - : LocalePhoneNumber.formatPhoneNumber(option.participantsList && option.participantsList.length > 0 ? option.participantsList.at(0)?.login ?? '' : ''); + : formatPhoneNumber(option.participantsList && option.participantsList.length > 0 ? option.participantsList.at(0)?.login ?? '' : ''); } function isSearchStringMatchUserDetails(personalDetail: PersonalDetails, searchValue: string) { @@ -476,7 +568,7 @@ function isSearchStringMatchUserDetails(personalDetail: PersonalDetails, searchV memberDetails += ` ${personalDetail.lastName}`; } if (personalDetail.displayName) { - memberDetails += ` ${PersonalDetailsUtils.getDisplayNameOrDefault(personalDetail)}`; + memberDetails += ` ${getDisplayNameOrDefault(personalDetail)}`; } if (personalDetail.phoneNumber) { memberDetails += ` ${personalDetail.phoneNumber}`; @@ -492,10 +584,10 @@ function getIOUReportIDOfLastAction(report: OnyxEntry): string | undefin return; } const lastAction = lastVisibleReportActions[report.reportID]; - if (!ReportActionUtils.isReportPreviewAction(lastAction)) { + if (!isReportPreviewAction(lastAction)) { return; } - return ReportUtils.getReportOrDraftReport(ReportActionUtils.getIOUReportIDFromReportActionPreview(lastAction))?.reportID; + return getReportOrDraftReport(getIOUReportIDFromReportActionPreview(lastAction))?.reportID; } /** @@ -509,117 +601,114 @@ function getLastMessageTextForReport(report: OnyxEntry, lastActorDetails const lastOriginalReportAction = reportID ? lastReportActions[reportID] : undefined; let lastMessageTextFromReport = ''; - if (ReportUtils.isArchivedNonExpenseReport(report)) { + if (isArchivedNonExpenseReport(report)) { const archiveReason = // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - (ReportActionUtils.isClosedAction(lastOriginalReportAction) && ReportActionUtils.getOriginalMessage(lastOriginalReportAction)?.reason) || CONST.REPORT.ARCHIVE_REASON.DEFAULT; + (isClosedAction(lastOriginalReportAction) && getOriginalMessage(lastOriginalReportAction)?.reason) || CONST.REPORT.ARCHIVE_REASON.DEFAULT; switch (archiveReason) { case CONST.REPORT.ARCHIVE_REASON.ACCOUNT_CLOSED: case CONST.REPORT.ARCHIVE_REASON.REMOVED_FROM_POLICY: case CONST.REPORT.ARCHIVE_REASON.POLICY_DELETED: { - lastMessageTextFromReport = Localize.translate(preferredLocale, `reportArchiveReasons.${archiveReason}`, { - displayName: LocalePhoneNumber.formatPhoneNumber(PersonalDetailsUtils.getDisplayNameOrDefault(lastActorDetails)), - policyName: ReportUtils.getPolicyName(report, false, policy), + lastMessageTextFromReport = translate(preferredLocale, `reportArchiveReasons.${archiveReason}`, { + displayName: formatPhoneNumber(getDisplayNameOrDefault(lastActorDetails)), + policyName: getPolicyName(report, false, policy), }); break; } case CONST.REPORT.ARCHIVE_REASON.BOOKING_END_DATE_HAS_PASSED: { - lastMessageTextFromReport = Localize.translate(preferredLocale, `reportArchiveReasons.${archiveReason}`); + lastMessageTextFromReport = translate(preferredLocale, `reportArchiveReasons.${archiveReason}`); break; } default: { - lastMessageTextFromReport = Localize.translate(preferredLocale, `reportArchiveReasons.default`); + lastMessageTextFromReport = translate(preferredLocale, `reportArchiveReasons.default`); } } - } else if (ReportActionUtils.isMoneyRequestAction(lastReportAction)) { - const properSchemaForMoneyRequestMessage = ReportUtils.getReportPreviewMessage(report, lastReportAction, true, false, null, true); - lastMessageTextFromReport = ReportUtils.formatReportLastMessageText(properSchemaForMoneyRequestMessage); - } else if (ReportActionUtils.isReportPreviewAction(lastReportAction)) { - const iouReport = ReportUtils.getReportOrDraftReport(ReportActionUtils.getIOUReportIDFromReportActionPreview(lastReportAction)); + } else if (isMoneyRequestAction(lastReportAction)) { + const properSchemaForMoneyRequestMessage = getReportPreviewMessage(report, lastReportAction, true, false, null, true); + lastMessageTextFromReport = formatReportLastMessageText(properSchemaForMoneyRequestMessage); + } else if (isReportPreviewAction(lastReportAction)) { + const iouReport = getReportOrDraftReport(getIOUReportIDFromReportActionPreview(lastReportAction)); const lastIOUMoneyReportAction = iouReport?.reportID ? allSortedReportActions[iouReport.reportID]?.find( (reportAction, key): reportAction is ReportAction => - ReportActionUtils.shouldReportActionBeVisible(reportAction, key, ReportUtils.canUserPerformWriteAction(report)) && + shouldReportActionBeVisible(reportAction, key, canUserPerformWriteAction(report)) && reportAction.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE && - ReportActionUtils.isMoneyRequestAction(reportAction), + isMoneyRequestAction(reportAction), ) : undefined; - const reportPreviewMessage = ReportUtils.getReportPreviewMessage( + const reportPreviewMessage = getReportPreviewMessage( !isEmptyObject(iouReport) ? iouReport : null, lastIOUMoneyReportAction, true, - ReportUtils.isChatReport(report), + reportUtilsIsChatReport(report), null, true, lastReportAction, ); - lastMessageTextFromReport = ReportUtils.formatReportLastMessageText(reportPreviewMessage); - } else if (ReportActionUtils.isReimbursementQueuedAction(lastReportAction)) { - lastMessageTextFromReport = ReportUtils.getReimbursementQueuedActionMessage(lastReportAction, report); - } else if (ReportActionUtils.isReimbursementDeQueuedAction(lastReportAction)) { - lastMessageTextFromReport = ReportUtils.getReimbursementDeQueuedActionMessage(lastReportAction, report, true); - } else if (ReportActionUtils.isDeletedParentAction(lastReportAction) && ReportUtils.isChatReport(report)) { - lastMessageTextFromReport = ReportUtils.getDeletedParentActionMessageForChatReport(lastReportAction); - } else if (ReportActionUtils.isPendingRemove(lastReportAction) && report?.reportID && ReportActionUtils.isThreadParentMessage(lastReportAction, report.reportID)) { - lastMessageTextFromReport = Localize.translateLocal('parentReportAction.hiddenMessage'); - } else if (ReportUtils.isReportMessageAttachment({text: report?.lastMessageText ?? '', html: report?.lastMessageHtml, type: ''})) { - lastMessageTextFromReport = `[${Localize.translateLocal('common.attachment')}]`; - } else if (ReportActionUtils.isModifiedExpenseAction(lastReportAction)) { + lastMessageTextFromReport = formatReportLastMessageText(reportPreviewMessage); + } else if (isReimbursementQueuedAction(lastReportAction)) { + lastMessageTextFromReport = getReimbursementQueuedActionMessage(lastReportAction, report); + } else if (isReimbursementDeQueuedAction(lastReportAction)) { + lastMessageTextFromReport = getReimbursementDeQueuedActionMessage(lastReportAction, report, true); + } else if (isDeletedParentAction(lastReportAction) && reportUtilsIsChatReport(report)) { + lastMessageTextFromReport = getDeletedParentActionMessageForChatReport(lastReportAction); + } else if (isPendingRemove(lastReportAction) && report?.reportID && isThreadParentMessage(lastReportAction, report.reportID)) { + lastMessageTextFromReport = translateLocal('parentReportAction.hiddenMessage'); + } else if (isReportMessageAttachment({text: report?.lastMessageText ?? '', html: report?.lastMessageHtml, type: ''})) { + lastMessageTextFromReport = `[${translateLocal('common.attachment')}]`; + } else if (isModifiedExpenseAction(lastReportAction)) { const properSchemaForModifiedExpenseMessage = ModifiedExpenseMessage.getForReportAction(report?.reportID, lastReportAction); - lastMessageTextFromReport = ReportUtils.formatReportLastMessageText(properSchemaForModifiedExpenseMessage, true); - } else if (ReportActionUtils.isTaskAction(lastReportAction)) { - lastMessageTextFromReport = ReportUtils.formatReportLastMessageText(TaskUtils.getTaskReportActionMessage(lastReportAction).text); - } else if (ReportActionUtils.isCreatedTaskReportAction(lastReportAction)) { - lastMessageTextFromReport = TaskUtils.getTaskCreatedMessage(lastReportAction); - } else if ( - ReportActionUtils.isActionOfType(lastReportAction, CONST.REPORT.ACTIONS.TYPE.SUBMITTED) || - ReportActionUtils.isActionOfType(lastReportAction, CONST.REPORT.ACTIONS.TYPE.SUBMITTED_AND_CLOSED) - ) { - const wasSubmittedViaHarvesting = ReportActionUtils.getOriginalMessage(lastReportAction)?.harvesting ?? false; + lastMessageTextFromReport = formatReportLastMessageText(properSchemaForModifiedExpenseMessage, true); + } else if (isTaskAction(lastReportAction)) { + lastMessageTextFromReport = formatReportLastMessageText(getTaskReportActionMessage(lastReportAction).text); + } else if (isCreatedTaskReportAction(lastReportAction)) { + lastMessageTextFromReport = getTaskCreatedMessage(lastReportAction); + } else if (isActionOfType(lastReportAction, CONST.REPORT.ACTIONS.TYPE.SUBMITTED) || isActionOfType(lastReportAction, CONST.REPORT.ACTIONS.TYPE.SUBMITTED_AND_CLOSED)) { + const wasSubmittedViaHarvesting = getOriginalMessage(lastReportAction)?.harvesting ?? false; if (wasSubmittedViaHarvesting) { - lastMessageTextFromReport = ReportUtils.getReportAutomaticallySubmittedMessage(lastReportAction); + lastMessageTextFromReport = getReportAutomaticallySubmittedMessage(lastReportAction); } else { - lastMessageTextFromReport = ReportUtils.getIOUSubmittedMessage(lastReportAction); + lastMessageTextFromReport = getIOUSubmittedMessage(lastReportAction); } - } else if (ReportActionUtils.isActionOfType(lastReportAction, CONST.REPORT.ACTIONS.TYPE.APPROVED)) { - const {automaticAction} = ReportActionUtils.getOriginalMessage(lastReportAction) ?? {}; + } else if (isActionOfType(lastReportAction, CONST.REPORT.ACTIONS.TYPE.APPROVED)) { + const {automaticAction} = getOriginalMessage(lastReportAction) ?? {}; if (automaticAction) { - lastMessageTextFromReport = ReportUtils.getReportAutomaticallyApprovedMessage(lastReportAction); + lastMessageTextFromReport = getReportAutomaticallyApprovedMessage(lastReportAction); } else { - lastMessageTextFromReport = ReportUtils.getIOUApprovedMessage(lastReportAction); + lastMessageTextFromReport = getIOUApprovedMessage(lastReportAction); } - } else if (ReportActionUtils.isUnapprovedAction(lastReportAction)) { - lastMessageTextFromReport = ReportUtils.getIOUUnapprovedMessage(lastReportAction); - } else if (ReportActionUtils.isActionOfType(lastReportAction, CONST.REPORT.ACTIONS.TYPE.FORWARDED)) { - const {automaticAction} = ReportActionUtils.getOriginalMessage(lastReportAction) ?? {}; + } else if (isUnapprovedAction(lastReportAction)) { + lastMessageTextFromReport = getIOUUnapprovedMessage(lastReportAction); + } else if (isActionOfType(lastReportAction, CONST.REPORT.ACTIONS.TYPE.FORWARDED)) { + const {automaticAction} = getOriginalMessage(lastReportAction) ?? {}; if (automaticAction) { - lastMessageTextFromReport = ReportUtils.getReportAutomaticallyForwardedMessage(lastReportAction, reportID); + lastMessageTextFromReport = getReportAutomaticallyForwardedMessage(lastReportAction, reportID); } else { - lastMessageTextFromReport = ReportUtils.getIOUForwardedMessage(lastReportAction, report); + lastMessageTextFromReport = getIOUForwardedMessage(lastReportAction, report); } } else if (lastReportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.REJECTED) { - lastMessageTextFromReport = ReportUtils.getRejectedReportMessage(); + lastMessageTextFromReport = getRejectedReportMessage(); } else if (lastReportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.CORPORATE_UPGRADE) { - lastMessageTextFromReport = ReportUtils.getUpgradeWorkspaceMessage(); + lastMessageTextFromReport = getUpgradeWorkspaceMessage(); } else if (lastReportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.TEAM_DOWNGRADE) { - lastMessageTextFromReport = ReportUtils.getDowngradeWorkspaceMessage(); - } else if (ReportActionUtils.isActionableAddPaymentCard(lastReportAction)) { - lastMessageTextFromReport = ReportActionUtils.getReportActionMessageText(lastReportAction); + lastMessageTextFromReport = getDowngradeWorkspaceMessage(); + } else if (isActionableAddPaymentCard(lastReportAction)) { + lastMessageTextFromReport = getReportActionMessageText(lastReportAction); } else if (lastReportAction?.actionName === 'EXPORTINTEGRATION') { - lastMessageTextFromReport = ReportActionUtils.getExportIntegrationLastMessageText(lastReportAction); - } else if (lastReportAction?.actionName && ReportActionUtils.isOldDotReportAction(lastReportAction)) { - lastMessageTextFromReport = ReportActionUtils.getMessageOfOldDotReportAction(lastReportAction, false); + lastMessageTextFromReport = getExportIntegrationLastMessageText(lastReportAction); + } else if (lastReportAction?.actionName && isOldDotReportAction(lastReportAction)) { + lastMessageTextFromReport = getMessageOfOldDotReportAction(lastReportAction, false); } // we do not want to show report closed in LHN for non archived report so use getReportLastMessage as fallback instead of lastMessageText from report if (reportID && !report.private_isArchived && report.lastActionType === CONST.REPORT.ACTIONS.TYPE.CLOSED) { - return lastMessageTextFromReport || (ReportUtils.getReportLastMessage(reportID).lastMessageText ?? ''); + return lastMessageTextFromReport || (getReportLastMessage(reportID).lastMessageText ?? ''); } return lastMessageTextFromReport || (report?.lastMessageText ?? ''); } function hasReportErrors(report: Report, reportActions: OnyxEntry) { - return !isEmptyObject(ReportUtils.getAllReportErrors(report, reportActions)); + return !isEmptyObject(getAllReportErrors(report, reportActions)); } /** @@ -631,9 +720,9 @@ function createOption( report: OnyxInputOrEntry, reportActions: ReportActions, config?: PreviewConfig, -): ReportUtils.OptionData { +): OptionData { const {showChatPreviewLine = false, forcePolicyNamePreview = false, showPersonalDetails = false} = config ?? {}; - const result: ReportUtils.OptionData = { + const result: OptionData = { text: undefined, alternateText: undefined, pendingAction: undefined, @@ -676,39 +765,39 @@ function createOption( result.participantsList = personalDetailList; result.isOptimisticPersonalDetail = personalDetail?.isOptimisticPersonalDetail; if (report) { - result.isChatRoom = ReportUtils.isChatRoom(report); - result.isDefaultRoom = ReportUtils.isDefaultRoom(report); + result.isChatRoom = reportUtilsIsChatRoom(report); + result.isDefaultRoom = isDefaultRoom(report); // eslint-disable-next-line @typescript-eslint/naming-convention result.private_isArchived = report.private_isArchived; - result.isExpenseReport = ReportUtils.isExpenseReport(report); - result.isInvoiceRoom = ReportUtils.isInvoiceRoom(report); - result.isMoneyRequestReport = ReportUtils.isMoneyRequestReport(report); - result.isThread = ReportUtils.isChatThread(report); - result.isTaskReport = ReportUtils.isTaskReport(report); - result.shouldShowSubscript = ReportUtils.shouldReportShowSubscript(report); - result.isPolicyExpenseChat = ReportUtils.isPolicyExpenseChat(report); + result.isExpenseReport = isExpenseReport(report); + result.isInvoiceRoom = isInvoiceRoom(report); + result.isMoneyRequestReport = reportUtilsIsMoneyRequestReport(report); + result.isThread = isChatThread(report); + result.isTaskReport = reportUtilsIsTaskReport(report); + result.shouldShowSubscript = shouldReportShowSubscript(report); + result.isPolicyExpenseChat = reportUtilsIsPolicyExpenseChat(report); result.isOwnPolicyExpenseChat = report.isOwnPolicyExpenseChat ?? false; - result.allReportErrors = ReportUtils.getAllReportErrors(report, reportActions); + result.allReportErrors = getAllReportErrors(report, reportActions); result.brickRoadIndicator = hasReportErrors(report, reportActions) ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : ''; result.pendingAction = report.pendingFields ? report.pendingFields.addWorkspaceRoom ?? report.pendingFields.createChat : undefined; result.ownerAccountID = report.ownerAccountID; result.reportID = report.reportID; - result.isUnread = ReportUtils.isUnread(report); + result.isUnread = isUnread(report); result.isPinned = report.isPinned; result.iouReportID = report.iouReportID; result.keyForList = String(report.reportID); result.isWaitingOnBankAccount = report.isWaitingOnBankAccount; result.policyID = report.policyID; - result.isSelfDM = ReportUtils.isSelfDM(report); - result.notificationPreference = ReportUtils.getReportNotificationPreference(report); + result.isSelfDM = reportUtilsIsSelfDM(report); + result.notificationPreference = getReportNotificationPreference(report); result.lastVisibleActionCreated = report.lastVisibleActionCreated; - const visibleParticipantAccountIDs = ReportUtils.getParticipantsAccountIDsForDisplay(report, true); + const visibleParticipantAccountIDs = getParticipantsAccountIDsForDisplay(report, true); - result.tooltipText = ReportUtils.getReportParticipantsTitle(visibleParticipantAccountIDs); + result.tooltipText = getReportParticipantsTitle(visibleParticipantAccountIDs); - hasMultipleParticipants = personalDetailList.length > 1 || result.isChatRoom || result.isPolicyExpenseChat || ReportUtils.isGroupChat(report); - subtitle = ReportUtils.getChatRoomSubtitle(report); + hasMultipleParticipants = personalDetailList.length > 1 || result.isChatRoom || result.isPolicyExpenseChat || reportUtilsIsGroupChat(report); + subtitle = getChatRoomSubtitle(report); const lastActorDetails = report.lastActorAccountID ? personalDetailMap[report.lastActorAccountID] : null; const lastActorDisplayName = getLastActorDisplayName(lastActorDetails, hasMultipleParticipants); @@ -727,28 +816,26 @@ function createOption( // If displaying chat preview line is needed, let's overwrite the default alternate text result.alternateText = showPersonalDetails && personalDetail?.login ? personalDetail.login : getAlternateText(result, {showChatPreviewLine, forcePolicyNamePreview}); - reportName = showPersonalDetails - ? ReportUtils.getDisplayNameForParticipant(accountIDs.at(0)) || LocalePhoneNumber.formatPhoneNumber(personalDetail?.login ?? '') - : ReportUtils.getReportName(report); + reportName = showPersonalDetails ? getDisplayNameForParticipant(accountIDs.at(0)) || formatPhoneNumber(personalDetail?.login ?? '') : getReportName(report); } else { // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - reportName = ReportUtils.getDisplayNameForParticipant(accountIDs.at(0)) || LocalePhoneNumber.formatPhoneNumber(personalDetail?.login ?? ''); + reportName = getDisplayNameForParticipant(accountIDs.at(0)) || formatPhoneNumber(personalDetail?.login ?? ''); result.keyForList = String(accountIDs.at(0)); - result.alternateText = LocalePhoneNumber.formatPhoneNumber(personalDetails?.[accountIDs[0]]?.login ?? ''); + result.alternateText = formatPhoneNumber(personalDetails?.[accountIDs[0]]?.login ?? ''); } - result.isIOUReportOwner = ReportUtils.isIOUOwnedByCurrentUser(result); - result.iouReportAmount = ReportUtils.getMoneyRequestSpendBreakdown(result).totalDisplaySpend; + result.isIOUReportOwner = isIOUOwnedByCurrentUser(result); + result.iouReportAmount = getMoneyRequestSpendBreakdown(result).totalDisplaySpend; - if (!hasMultipleParticipants && (!report || (report && !ReportUtils.isGroupChat(report) && !ReportUtils.isChatRoom(report)))) { + if (!hasMultipleParticipants && (!report || (report && !reportUtilsIsGroupChat(report) && !reportUtilsIsChatRoom(report)))) { result.login = personalDetail?.login; result.accountID = Number(personalDetail?.accountID); result.phoneNumber = personalDetail?.phoneNumber; } result.text = reportName; - result.icons = ReportUtils.getIcons(report, personalDetails, personalDetail?.avatar, personalDetail?.login, personalDetail?.accountID, null); + result.icons = getIcons(report, personalDetails, personalDetail?.avatar, personalDetail?.login, personalDetail?.accountID, null); result.subtitle = subtitle; return result; @@ -757,9 +844,9 @@ function createOption( /** * Get the option for a given report. */ -function getReportOption(participant: Participant): ReportUtils.OptionData { - const report = ReportUtils.getReportOrDraftReport(participant.reportID); - const visibleParticipantAccountIDs = ReportUtils.getParticipantsAccountIDsForDisplay(report, true); +function getReportOption(participant: Participant): OptionData { + const report = getReportOrDraftReport(participant.reportID); + const visibleParticipantAccountIDs = getParticipantsAccountIDsForDisplay(report, true); const option = createOption( visibleParticipantAccountIDs, @@ -774,15 +861,15 @@ function getReportOption(participant: Participant): ReportUtils.OptionData { // Update text & alternateText because createOption returns workspace name only if report is owned by the user if (option.isSelfDM) { - option.alternateText = Localize.translateLocal('reportActionsView.yourSpace'); + option.alternateText = translateLocal('reportActionsView.yourSpace'); } else if (option.isInvoiceRoom) { - option.text = ReportUtils.getReportName(report); - option.alternateText = Localize.translateLocal('workspace.common.invoices'); + option.text = getReportName(report); + option.alternateText = translateLocal('workspace.common.invoices'); } else { - option.text = ReportUtils.getPolicyName(report); - option.alternateText = Localize.translateLocal('workspace.common.workspace'); + option.text = getPolicyName(report); + option.alternateText = translateLocal('workspace.common.workspace'); } - option.isDisabled = ReportUtils.isDraftReport(participant.reportID); + option.isDisabled = isDraftReport(participant.reportID); option.selected = participant.selected; option.isSelected = participant.selected; return option; @@ -791,11 +878,11 @@ function getReportOption(participant: Participant): ReportUtils.OptionData { /** * Get the option for a policy expense report. */ -function getPolicyExpenseReportOption(participant: Participant | ReportUtils.OptionData): ReportUtils.OptionData { - const expenseReport = ReportUtils.isPolicyExpenseChat(participant) ? ReportUtils.getReportOrDraftReport(participant.reportID) : null; +function getPolicyExpenseReportOption(participant: Participant | OptionData): OptionData { + const expenseReport = reportUtilsIsPolicyExpenseChat(participant) ? getReportOrDraftReport(participant.reportID) : null; const visibleParticipantAccountIDs = Object.entries(expenseReport?.participants ?? {}) - .filter(([, reportParticipant]) => reportParticipant && !ReportUtils.isHiddenForCurrentUser(reportParticipant.notificationPreference)) + .filter(([, reportParticipant]) => reportParticipant && !isHiddenForCurrentUser(reportParticipant.notificationPreference)) .map(([accountID]) => Number(accountID)); const option = createOption( @@ -810,8 +897,8 @@ function getPolicyExpenseReportOption(participant: Participant | ReportUtils.Opt ); // Update text & alternateText because createOption returns workspace name only if report is owned by the user - option.text = ReportUtils.getPolicyName(expenseReport); - option.alternateText = Localize.translateLocal('workspace.common.workspace'); + option.text = getPolicyName(expenseReport); + option.alternateText = translateLocal('workspace.common.workspace'); option.selected = participant.selected; option.isSelected = participant.selected; return option; @@ -846,7 +933,7 @@ function isCurrentUser(userDetails: PersonalDetails): boolean { } // If user login is a mobile number, append sms domain if not appended already. - const userDetailsLogin = PhoneNumber.addSMSDomainIfPhoneNumber(userDetails.login ?? ''); + const userDetailsLogin = addSMSDomainIfPhoneNumber(userDetails.login ?? ''); if (currentUserLogin?.toLowerCase() === userDetailsLogin.toLowerCase()) { return true; @@ -864,7 +951,7 @@ function getEnabledCategoriesCount(options: PolicyCategories): number { } function getSearchValueForPhoneOrEmail(searchTerm: string) { - const parsedPhoneNumber = PhoneNumber.parsePhoneNumber(LoginUtils.appendCountryCode(Str.removeSMSDomain(searchTerm))); + const parsedPhoneNumber = parsePhoneNumber(appendCountryCode(Str.removeSMSDomain(searchTerm))); return parsedPhoneNumber.possible ? parsedPhoneNumber.number?.e164 ?? '' : searchTerm.toLowerCase(); } @@ -882,7 +969,7 @@ function hasEnabledOptions(options: PolicyCategories | PolicyTag[]): boolean { * @param selectedOptions - Array of selected options to compare with. * @returns true if the report option matches any of the selected options by accountID or reportID, false otherwise. */ -function isReportSelected(reportOption: ReportUtils.OptionData, selectedOptions: Array>) { +function isReportSelected(reportOption: OptionData, selectedOptions: Array>) { if (!selectedOptions || selectedOptions.length === 0) { return false; } @@ -901,10 +988,10 @@ function createOptionList(personalDetails: OnyxEntry, repor return; } - const isOneOnOneChat = ReportUtils.isOneOnOneChat(report); - const accountIDs = ReportUtils.getParticipantsAccountIDsForDisplay(report); + const isOneOnOneChat = reportUtilsIsOneOnOneChat(report); + const accountIDs = getParticipantsAccountIDsForDisplay(report); - const isChatRoom = ReportUtils.isChatRoom(report); + const isChatRoom = reportUtilsIsChatRoom(report); if ((!accountIDs || accountIDs.length === 0) && !isChatRoom) { return; } @@ -941,7 +1028,7 @@ function createOptionList(personalDetails: OnyxEntry, repor } function createOptionFromReport(report: Report, personalDetails: OnyxEntry) { - const accountIDs = ReportUtils.getParticipantsAccountIDsForDisplay(report); + const accountIDs = getParticipantsAccountIDsForDisplay(report); return { item: report, @@ -949,7 +1036,7 @@ function createOptionFromReport(report: Report, personalDetails: OnyxEntry personalDetail.text?.toLowerCase()], 'asc'); } @@ -958,7 +1045,7 @@ function orderPersonalDetailsOptions(options: ReportUtils.OptionData[]) { * Orders report options without grouping them by kind. * Usually used when there is no search value */ -function orderReportOptions(options: ReportUtils.OptionData[]) { +function orderReportOptions(options: OptionData[]) { return lodashOrderBy(options, [sortComparatorReportOptionByArchivedStatus, sortComparatorReportOptionByDate], ['asc', 'desc']); } @@ -969,7 +1056,7 @@ function orderReportOptions(options: ReportUtils.OptionData[]) { * @returns a sorted list of options */ function orderReportOptionsWithSearch( - options: ReportUtils.OptionData[], + options: OptionData[], searchValue: string, {preferChatroomsOverThreads = false, preferPolicyExpenseChat = false, preferRecentExpenseReports = false}: OrderReportOptionsConfig = {}, ) { @@ -1017,11 +1104,11 @@ function orderReportOptionsWithSearch( ); } -function sortComparatorReportOptionByArchivedStatus(option: ReportUtils.OptionData) { +function sortComparatorReportOptionByArchivedStatus(option: OptionData) { return option.private_isArchived ? 1 : 0; } -function sortComparatorReportOptionByDate(options: ReportUtils.OptionData) { +function sortComparatorReportOptionByDate(options: OptionData) { // If there is no date (ie. a personal detail option), the option will be sorted to the bottom // (comparing a dateString > '' returns true, and we are sorting descending, so the dateString will come before '') return options.lastVisibleActionCreated ?? ''; @@ -1037,7 +1124,7 @@ function orderOptions(options: ReportAndPersonalDetailOptions): ReportAndPersona */ function orderOptions(options: ReportAndPersonalDetailOptions, searchValue: string, config?: OrderReportOptionsConfig): ReportAndPersonalDetailOptions; function orderOptions(options: ReportAndPersonalDetailOptions, searchValue?: string, config?: OrderReportOptionsConfig): ReportAndPersonalDetailOptions { - let orderedReportOptions: ReportUtils.OptionData[]; + let orderedReportOptions: OptionData[]; if (searchValue) { orderedReportOptions = orderReportOptionsWithSearch(options.recentReports, searchValue, config); } else { @@ -1057,9 +1144,9 @@ function canCreateOptimisticPersonalDetailOption({ currentUserOption, searchValue, }: { - recentReportOptions: ReportUtils.OptionData[]; - personalDetailsOptions: ReportUtils.OptionData[]; - currentUserOption?: ReportUtils.OptionData | null; + recentReportOptions: OptionData[]; + personalDetailsOptions: OptionData[]; + currentUserOption?: OptionData | null; searchValue: string; }) { if (recentReportOptions.length + personalDetailsOptions.length > 0) { @@ -1068,7 +1155,7 @@ function canCreateOptimisticPersonalDetailOption({ if (!currentUserOption) { return true; } - return currentUserOption.login !== PhoneNumber.addSMSDomainIfPhoneNumber(searchValue ?? '').toLowerCase() && currentUserOption.login !== searchValue?.toLowerCase(); + return currentUserOption.login !== addSMSDomainIfPhoneNumber(searchValue ?? '').toLowerCase() && currentUserOption.login !== searchValue?.toLowerCase(); } /** @@ -1085,25 +1172,25 @@ function getUserToInviteOption({ reportActions = {}, showChatPreviewLine = false, shouldAcceptName = false, -}: GetUserToInviteConfig): ReportUtils.OptionData | null { +}: GetUserToInviteConfig): OptionData | null { if (!searchValue) { return null; } - const parsedPhoneNumber = PhoneNumber.parsePhoneNumber(LoginUtils.appendCountryCode(Str.removeSMSDomain(searchValue))); + const parsedPhoneNumber = parsePhoneNumber(appendCountryCode(Str.removeSMSDomain(searchValue))); const isCurrentUserLogin = isCurrentUser({login: searchValue} as PersonalDetails); const isInSelectedOption = selectedOptions.some((option) => 'login' in option && option.login === searchValue); const isValidEmail = Str.isValidEmail(searchValue) && !Str.isDomainEmail(searchValue) && !Str.endsWith(searchValue, CONST.SMS.DOMAIN); - const isValidPhoneNumber = parsedPhoneNumber.possible && Str.isValidE164Phone(LoginUtils.getPhoneNumberWithoutSpecialChars(parsedPhoneNumber.number?.input ?? '')); + const isValidPhoneNumber = parsedPhoneNumber.possible && Str.isValidE164Phone(getPhoneNumberWithoutSpecialChars(parsedPhoneNumber.number?.input ?? '')); const isInOptionToExclude = - optionsToExclude.findIndex((optionToExclude) => 'login' in optionToExclude && optionToExclude.login === PhoneNumber.addSMSDomainIfPhoneNumber(searchValue).toLowerCase()) !== -1; + optionsToExclude.findIndex((optionToExclude) => 'login' in optionToExclude && optionToExclude.login === addSMSDomainIfPhoneNumber(searchValue).toLowerCase()) !== -1; if (isCurrentUserLogin || isInSelectedOption || (!isValidEmail && !isValidPhoneNumber && !shouldAcceptName) || isInOptionToExclude) { return null; } // Generates an optimistic account ID for new users not yet saved in Onyx - const optimisticAccountID = UserUtils.generateAccountID(searchValue); + const optimisticAccountID = generateAccountID(searchValue); const personalDetailsExtended = { ...allPersonalDetails, [optimisticAccountID]: { @@ -1134,42 +1221,40 @@ function getUserToInviteOption({ return userToInvite; } -/** - * Options are reports and personal details. This function filters out the options that are not valid to be displayed. - */ -function getValidOptions( - options: OptionList, +function getValidReports( + reports: OptionList['reports'], { betas = [], - selectedOptions = [], - excludeLogins = [], includeMultipleParticipantReports = false, - includeRecentReports = true, showChatPreviewLine = false, forcePolicyNamePreview = false, includeOwnedWorkspaceChats = false, includeThreads = false, includeTasks = false, includeMoneyRequests = false, - includeP2P = true, - includeSelectedOptions = false, transactionViolations = {}, includeSelfDM = false, includeInvoiceRooms = false, - includeDomainEmail = false, action, - recentAttendees, + selectedOptions = [], + includeP2P = true, + includeDomainEmail = false, shouldBoldTitleByDefault = true, - }: GetOptionsConfig = {}, -): Options { + optionsToExclude = [], + }: GetValidReportsConfig, +) { const topmostReportId = Navigation.getTopmostReportId(); - // Filter out all the reports that shouldn't be displayed - const filteredReportOptions = options.reports.filter((option) => { + const validReportOptions: OptionData[] = []; + const preferRecentExpenseReports = action === CONST.IOU.ACTION.CREATE; + + for (let i = 0; i < reports.length; i++) { + // eslint-disable-next-line rulesdir/prefer-at + const option = reports[i]; const report = option.item; - const doesReportHaveViolations = ReportUtils.shouldDisplayViolationsRBRInLHN(report, transactionViolations); + const doesReportHaveViolations = shouldDisplayViolationsRBRInLHN(report, transactionViolations); - return ReportUtils.shouldReportBeInOptionList({ + const shouldBeInOptionList = shouldReportBeInOptionList({ report, currentReportId: topmostReportId, betas, @@ -1181,13 +1266,9 @@ function getValidOptions( login: option.login, includeDomainEmail, }); - }); - - const allReportOptions = filteredReportOptions.filter((option) => { - const report = option.item; - if (!report) { - return false; + if (!shouldBeInOptionList) { + continue; } const isThread = option.isThread; @@ -1196,164 +1277,174 @@ function getValidOptions( const isMoneyRequestReport = option.isMoneyRequestReport; const isSelfDM = option.isSelfDM; const isChatRoom = option.isChatRoom; - const accountIDs = ReportUtils.getParticipantsAccountIDsForDisplay(report); + const accountIDs = getParticipantsAccountIDsForDisplay(report); if (isPolicyExpenseChat && report.isOwnPolicyExpenseChat && !includeOwnedWorkspaceChats) { - return false; + continue; } // When passing includeP2P false we are trying to hide features from users that are not ready for P2P and limited to workspace chats only. if (!includeP2P && !isPolicyExpenseChat) { - return false; + continue; } if (isSelfDM && !includeSelfDM) { - return false; + continue; } if (isThread && !includeThreads) { - return false; + continue; } if (isTaskReport && !includeTasks) { - return false; + continue; } if (isMoneyRequestReport && !includeMoneyRequests) { - return false; + continue; } // In case user needs to add credit bank account, don't allow them to submit an expense from the workspace. - if (includeOwnedWorkspaceChats && ReportUtils.hasIOUWaitingOnCurrentUserBankAccount(report)) { - return false; + if (includeOwnedWorkspaceChats && hasIOUWaitingOnCurrentUserBankAccount(report)) { + continue; } if ((!accountIDs || accountIDs.length === 0) && !isChatRoom) { - return false; + continue; } - return true; - }); + if (option.login === CONST.EMAIL.NOTIFICATIONS) { + continue; + } - const allPersonalDetailsOptions = includeP2P - ? options.personalDetails.filter((detail) => !!detail?.login && !!detail.accountID && !detail?.isOptimisticPersonalDetail && (includeDomainEmail || !Str.isDomainEmail(detail.login))) - : []; + const isCurrentUserOwnedPolicyExpenseChatThatCouldShow = + option.isPolicyExpenseChat && option.ownerAccountID === currentUserAccountID && includeOwnedWorkspaceChats && !option.private_isArchived; - const optionsToExclude: Option[] = [{login: CONST.EMAIL.NOTIFICATIONS}]; + const shouldShowInvoiceRoom = + includeInvoiceRooms && isInvoiceRoom(option.item) && isPolicyAdmin(option.policyID, policies) && !option.private_isArchived && canSendInvoiceFromWorkspace(option.policyID); + + /* + Exclude the report option if it doesn't meet any of the following conditions: + - It is not an owned policy expense chat that could be shown + - Multiple participant reports are not included + - It doesn't have a login + - It is not an invoice room that should be shown + */ + if (!isCurrentUserOwnedPolicyExpenseChatThatCouldShow && !includeMultipleParticipantReports && !option.login && !shouldShowInvoiceRoom) { + continue; + } + + // If we're excluding threads, check the report to see if it has a single participant and if the participant is already selected + if (!includeThreads && (!!option.login || option.reportID) && optionsToExclude.some((x) => x.login === option.login || x.reportID === option.reportID)) { + continue; + } + + if (action === CONST.IOU.ACTION.CATEGORIZE) { + const reportPolicy = allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${option.policyID}`]; + if (!reportPolicy?.areCategoriesEnabled) { + continue; + } + } + + /** + * By default, generated options does not have the chat preview line enabled. + * If showChatPreviewLine or forcePolicyNamePreview are true, let's generate and overwrite the alternate text. + */ + const alternateText = getAlternateText(option, {showChatPreviewLine, forcePolicyNamePreview}); + const isSelected = isReportSelected(option, selectedOptions); + const isBold = shouldBoldTitleByDefault || shouldUseBoldText(option); + let lastIOUCreationDate; + + // Add a field to sort the recent reports by the time of last IOU request for create actions + if (preferRecentExpenseReports) { + const reportPreviewAction = allSortedReportActions[option.reportID]?.find((reportAction) => isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW)); + + if (reportPreviewAction) { + const iouReportID = getIOUReportIDFromReportActionPreview(reportPreviewAction); + const iouReportActions = iouReportID ? allSortedReportActions[iouReportID] ?? [] : []; + const lastIOUAction = iouReportActions.find((iouAction) => iouAction.actionName === CONST.REPORT.ACTIONS.TYPE.IOU); + if (lastIOUAction) { + lastIOUCreationDate = lastIOUAction.lastModified; + } + } + } + + const newReportOption = { + ...option, + alternateText, + isSelected, + isBold, + lastIOUCreationDate, + }; + + validReportOptions.push(newReportOption); + } + + return validReportOptions; +} +/** + * Options are reports and personal details. This function filters out the options that are not valid to be displayed. + */ +function getValidOptions( + options: OptionList, + {excludeLogins = [], includeSelectedOptions = false, includeRecentReports = true, recentAttendees, selectedOptions = [], ...config}: GetOptionsConfig = {}, +): Options { + // Gather shared configs: + const optionsToExclude: Option[] = [{login: CONST.EMAIL.NOTIFICATIONS}]; // If we're including selected options from the search results, we only want to exclude them if the search input is empty // This is because on certain pages, we show the selected options at the top when the search input is empty // This prevents the issue of seeing the selected option twice if you have them as a recent chat and select them if (!includeSelectedOptions) { optionsToExclude.push(...selectedOptions); } - excludeLogins.forEach((login) => { optionsToExclude.push({login}); }); + const {includeP2P = true, shouldBoldTitleByDefault = true, includeDomainEmail = false, ...getValidReportsConfig} = config; - let recentReportOptions: ReportUtils.OptionData[] = []; - const personalDetailsOptions: ReportUtils.OptionData[] = []; - - const preferRecentExpenseReports = action === CONST.IOU.ACTION.CREATE; - + // Get valid recent reports: + let recentReportOptions: OptionData[] = []; if (includeRecentReports) { - for (const reportOption of allReportOptions) { - // Skip notifications@expensify.com - if (reportOption.login === CONST.EMAIL.NOTIFICATIONS) { - continue; - } + recentReportOptions = getValidReports(options.reports, { + ...getValidReportsConfig, + includeP2P, + includeDomainEmail, + selectedOptions, + optionsToExclude, + shouldBoldTitleByDefault, + }); + } 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 OptionData[]; + } - const isCurrentUserOwnedPolicyExpenseChatThatCouldShow = - reportOption.isPolicyExpenseChat && reportOption.ownerAccountID === currentUserAccountID && includeOwnedWorkspaceChats && !reportOption.private_isArchived; - - const shouldShowInvoiceRoom = - includeInvoiceRooms && - ReportUtils.isInvoiceRoom(reportOption.item) && - ReportUtils.isPolicyAdmin(reportOption.policyID, policies) && - !reportOption.private_isArchived && - PolicyUtils.canSendInvoiceFromWorkspace(reportOption.policyID); - - /** - Exclude the report option if it doesn't meet any of the following conditions: - - It is not an owned policy expense chat that could be shown - - Multiple participant reports are not included - - It doesn't have a login - - It is not an invoice room that should be shown - */ - if (!isCurrentUserOwnedPolicyExpenseChatThatCouldShow && !includeMultipleParticipantReports && !reportOption.login && !shouldShowInvoiceRoom) { + // Get valid personal details and check if we can find the current user: + const personalDetailsOptions: OptionData[] = []; + let currentUserOption: OptionData | undefined; + if (includeP2P) { + const personalDetailsOptionsToExclude = [...optionsToExclude, {login: currentUserLogin}]; + for (let i = 0; i < options.personalDetails.length; i++) { + // eslint-disable-next-line rulesdir/prefer-at + const detail = options.personalDetails[i]; + if (!detail?.login || !detail.accountID || !!detail?.isOptimisticPersonalDetail || (!includeDomainEmail && Str.isDomainEmail(detail.login))) { continue; } - // If we're excluding threads, check the report to see if it has a single participant and if the participant is already selected - if ( - !includeThreads && - (!!reportOption.login || reportOption.reportID) && - optionsToExclude.some((option) => option.login === reportOption.login || option.reportID === reportOption.reportID) - ) { - continue; + if (!!currentUserLogin && detail.login === currentUserLogin) { + currentUserOption = detail; } - /** - * By default, generated options does not have the chat preview line enabled. - * If showChatPreviewLine or forcePolicyNamePreview are true, let's generate and overwrite the alternate text. - */ - const alternateText = getAlternateText(reportOption, {showChatPreviewLine, forcePolicyNamePreview}); - const isSelected = isReportSelected(reportOption, selectedOptions); - const isBold = shouldBoldTitleByDefault || shouldUseBoldText(reportOption); - let lastIOUCreationDate; - - // Add a field to sort the recent reports by the time of last IOU request for create actions - if (preferRecentExpenseReports) { - const reportPreviewAction = allSortedReportActions[reportOption.reportID]?.find((reportAction) => - ReportActionUtils.isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW), - ); - - if (reportPreviewAction) { - const iouReportID = ReportActionUtils.getIOUReportIDFromReportActionPreview(reportPreviewAction); - const iouReportActions = iouReportID ? allSortedReportActions[iouReportID] ?? [] : []; - const lastIOUAction = iouReportActions.find((iouAction) => iouAction.actionName === CONST.REPORT.ACTIONS.TYPE.IOU); - if (lastIOUAction) { - lastIOUCreationDate = lastIOUAction.lastModified; - } - } + if (personalDetailsOptionsToExclude.some((optionToExclude) => optionToExclude.login === detail.login)) { + continue; } - const newReportOption = { - ...reportOption, - alternateText, - isSelected, - isBold, - lastIOUCreationDate, - }; - - if (action === CONST.IOU.ACTION.CATEGORIZE) { - const reportPolicy = allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${newReportOption.policyID}`]; - if (reportPolicy?.areCategoriesEnabled) { - recentReportOptions.push(newReportOption); - } - } else { - recentReportOptions.push(newReportOption); - } - } - } 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[]; - } + detail.isBold = shouldBoldTitleByDefault; - const personalDetailsOptionsToExclude = [...optionsToExclude, {login: currentUserLogin}]; - // Next loop over all personal details removing any that are selectedUsers or recentChats - for (const personalDetailOption of allPersonalDetailsOptions) { - if (personalDetailsOptionsToExclude.some((optionToExclude) => optionToExclude.login === personalDetailOption.login)) { - continue; + personalDetailsOptions.push(detail); } - personalDetailOption.isBold = shouldBoldTitleByDefault; - - personalDetailsOptions.push(personalDetailOption); } - const currentUserOption = allPersonalDetailsOptions.find((personalDetailsOption) => personalDetailsOption.login === currentUserLogin); - return { personalDetails: personalDetailsOptions, recentReports: recentReportOptions, @@ -1411,8 +1502,8 @@ function getShareLogOptions(options: OptionList, betas: Beta[] = []): Options { function getIOUConfirmationOptionsFromPayeePersonalDetail(personalDetail: OnyxEntry, amountText?: string): PayeePersonalDetails { const login = personalDetail?.login ?? ''; return { - text: LocalePhoneNumber.formatPhoneNumber(PersonalDetailsUtils.getDisplayNameOrDefault(personalDetail, login)), - alternateText: LocalePhoneNumber.formatPhoneNumber(login || PersonalDetailsUtils.getDisplayNameOrDefault(personalDetail, '', false)), + text: formatPhoneNumber(getDisplayNameOrDefault(personalDetail, login)), + alternateText: formatPhoneNumber(login || getDisplayNameOrDefault(personalDetail, '', false)), icons: [ { source: personalDetail?.avatar ?? FallbackAvatar, @@ -1465,7 +1556,7 @@ function getShareDestinationOptions( reports: Array> = [], personalDetails: Array> = [], betas: OnyxEntry = [], - selectedOptions: Array> = [], + selectedOptions: Array> = [], excludeLogins: string[] = [], includeOwnedWorkspaceChats = true, ) { @@ -1493,7 +1584,7 @@ function getShareDestinationOptions( * @param member - personalDetails or userToInvite * @param config - keys to overwrite the default values */ -function formatMemberForList(member: ReportUtils.OptionData): MemberForList { +function formatMemberForList(member: OptionData): MemberForList { const accountID = member.accountID; return { @@ -1547,27 +1638,27 @@ function getMemberInviteOptions( * Helper method that returns the text to be used for the header's message and title (if any) */ function getHeaderMessage(hasSelectableOptions: boolean, hasUserToInvite: boolean, searchValue: string, hasMatchedParticipant = false): string { - const isValidPhone = PhoneNumber.parsePhoneNumber(LoginUtils.appendCountryCode(searchValue)).possible; + const isValidPhone = parsePhoneNumber(appendCountryCode(searchValue)).possible; const isValidEmail = Str.isValidEmail(searchValue); if (searchValue && CONST.REGEX.DIGITS_AND_PLUS.test(searchValue) && !isValidPhone && !hasSelectableOptions) { - return Localize.translate(preferredLocale, 'messages.errorMessageInvalidPhone'); + return translate(preferredLocale, 'messages.errorMessageInvalidPhone'); } // Without a search value, it would be very confusing to see a search validation message. // Therefore, this skips the validation when there is no search value. if (searchValue && !hasSelectableOptions && !hasUserToInvite) { if (/^\d+$/.test(searchValue) && !isValidPhone) { - return Localize.translate(preferredLocale, 'messages.errorMessageInvalidPhone'); + return translate(preferredLocale, 'messages.errorMessageInvalidPhone'); } if (/@/.test(searchValue) && !isValidEmail) { - return Localize.translate(preferredLocale, 'messages.errorMessageInvalidEmail'); + return translate(preferredLocale, 'messages.errorMessageInvalidEmail'); } if (hasMatchedParticipant && (isValidEmail || isValidPhone)) { return ''; } - return Localize.translate(preferredLocale, 'common.noResultsFound'); + return translate(preferredLocale, 'common.noResultsFound'); } return ''; @@ -1578,7 +1669,7 @@ function getHeaderMessage(hasSelectableOptions: boolean, hasUserToInvite: boolea */ function getHeaderMessageForNonUserList(hasSelectableOptions: boolean, searchValue: string): string { if (searchValue && !hasSelectableOptions) { - return Localize.translate(preferredLocale, 'common.noResultsFound'); + return translate(preferredLocale, 'common.noResultsFound'); } return ''; } @@ -1586,7 +1677,7 @@ function getHeaderMessageForNonUserList(hasSelectableOptions: boolean, searchVal /** * Helper method to check whether an option can show tooltip or not */ -function shouldOptionShowTooltip(option: ReportUtils.OptionData): boolean { +function shouldOptionShowTooltip(option: OptionData): boolean { return !option.private_isArchived; } @@ -1595,9 +1686,9 @@ function shouldOptionShowTooltip(option: ReportUtils.OptionData): boolean { */ function formatSectionsFromSearchTerm( searchTerm: string, - selectedOptions: ReportUtils.OptionData[], - filteredRecentReports: ReportUtils.OptionData[], - filteredPersonalDetails: ReportUtils.OptionData[], + selectedOptions: OptionData[], + filteredRecentReports: OptionData[], + filteredPersonalDetails: OptionData[], personalDetails: OnyxEntry = {}, shouldGetOptionDetails = false, ): SectionForSearchTerm { @@ -1657,18 +1748,18 @@ function getFirstKeyForList(data?: Option[] | null) { return firstNonEmptyDataObj?.keyForList ? firstNonEmptyDataObj?.keyForList : ''; } -function getPersonalDetailSearchTerms(item: Partial) { +function getPersonalDetailSearchTerms(item: Partial) { return [item.participantsList?.[0]?.displayName ?? '', item.login ?? '', item.login?.replace(CONST.EMAIL_SEARCH_REGEX, '') ?? '']; } -function getCurrentUserSearchTerms(item: ReportUtils.OptionData) { +function getCurrentUserSearchTerms(item: OptionData) { return [item.text ?? '', item.login ?? '', item.login?.replace(CONST.EMAIL_SEARCH_REGEX, '') ?? '']; } /** * Remove the personal details for the DMs that are already in the recent reports so that we don't show duplicates. */ -function filteredPersonalDetailsOfRecentReports(recentReports: ReportUtils.OptionData[], personalDetails: ReportUtils.OptionData[]) { +function filteredPersonalDetailsOfRecentReports(recentReports: OptionData[], personalDetails: OptionData[]) { const excludedLogins = new Set(recentReports.map((report) => report.login)); return personalDetails.filter((personalDetail) => !excludedLogins.has(personalDetail.login)); } @@ -1676,7 +1767,7 @@ function filteredPersonalDetailsOfRecentReports(recentReports: ReportUtils.Optio /** * Filters options based on the search input value */ -function filterReports(reports: ReportUtils.OptionData[], searchTerms: string[]): ReportUtils.OptionData[] { +function filterReports(reports: OptionData[], searchTerms: string[]): OptionData[] { // We search eventually for multiple whitespace separated search terms. // We start with the search term at the end, and then narrow down those filtered search results with the next search term. // We repeat (reduce) this until all search terms have been used: @@ -1712,7 +1803,7 @@ function filterReports(reports: ReportUtils.OptionData[], searchTerms: string[]) return filteredReports; } -function filterPersonalDetails(personalDetails: ReportUtils.OptionData[], searchTerms: string[]): ReportUtils.OptionData[] { +function filterPersonalDetails(personalDetails: OptionData[], searchTerms: string[]): OptionData[] { return searchTerms.reduceRight( (items, term) => filterArrayByMatch(items, term, (item) => { @@ -1723,7 +1814,7 @@ function filterPersonalDetails(personalDetails: ReportUtils.OptionData[], search ); } -function filterCurrentUserOption(currentUserOption: ReportUtils.OptionData | null | undefined, searchTerms: string[]): ReportUtils.OptionData | null | undefined { +function filterCurrentUserOption(currentUserOption: OptionData | null | undefined, searchTerms: string[]): OptionData | null | undefined { return searchTerms.reduceRight((item, term) => { if (!item) { return null; @@ -1734,7 +1825,7 @@ function filterCurrentUserOption(currentUserOption: ReportUtils.OptionData | nul }, currentUserOption); } -function filterUserToInvite(options: Omit, searchValue: string, config?: FilterUserToInviteConfig): ReportUtils.OptionData | null { +function filterUserToInvite(options: Omit, searchValue: string, config?: FilterUserToInviteConfig): OptionData | null { const {canInviteUser = true, excludeLogins = []} = config ?? {}; if (!canInviteUser) { return null; @@ -1764,7 +1855,7 @@ function filterUserToInvite(options: Omit, searchValue: } function filterOptions(options: Options, searchInputValue: string, config?: FilterUserToInviteConfig): Options { - const parsedPhoneNumber = PhoneNumber.parsePhoneNumber(LoginUtils.appendCountryCode(Str.removeSMSDomain(searchInputValue))); + const parsedPhoneNumber = parsePhoneNumber(appendCountryCode(Str.removeSMSDomain(searchInputValue))); const searchValue = parsedPhoneNumber.possible && parsedPhoneNumber.number?.e164 ? parsedPhoneNumber.number.e164 : searchInputValue.toLowerCase(); const searchTerms = searchValue ? searchValue.split(' ') : []; @@ -1857,9 +1948,9 @@ function getEmptyOptions(): Options { }; } -function shouldUseBoldText(report: ReportUtils.OptionData): boolean { - const notificationPreference = report.notificationPreference ?? ReportUtils.getReportNotificationPreference(report); - return report.isUnread === true && notificationPreference !== CONST.REPORT.NOTIFICATION_PREFERENCE.MUTE && !ReportUtils.isHiddenForCurrentUser(notificationPreference); +function shouldUseBoldText(report: OptionData): boolean { + const notificationPreference = report.notificationPreference ?? getReportNotificationPreference(report); + return report.isUnread === true && notificationPreference !== CONST.REPORT.NOTIFICATION_PREFERENCE.MUTE && !isHiddenForCurrentUser(notificationPreference); } export { diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index 674708fb4f9f..f5a1e71cea78 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -7597,7 +7597,10 @@ function getWorkspaceChats(policyID: string, accountIDs: number[], reports: Onyx * * @param policyID - the workspace ID to get all associated reports */ -function getAllWorkspaceReports(policyID: string): Array> { +function getAllWorkspaceReports(policyID?: string): Array> { + if (!policyID) { + return []; + } return Object.values(allReports ?? {}).filter((report) => report?.policyID === policyID); } diff --git a/src/libs/actions/App.ts b/src/libs/actions/App.ts index 51093e759e1a..ddf9701a6c8e 100644 --- a/src/libs/actions/App.ts +++ b/src/libs/actions/App.ts @@ -384,10 +384,22 @@ function endSignOnTransition() { * @param [transitionFromOldDot] Optional, if the user is transitioning from old dot * @param [makeMeAdmin] Optional, leave the calling account as an admin on the policy * @param [backTo] An optional return path. If provided, it will be URL-encoded and appended to the resulting URL. + * @param [policyID] Optional, Policy id. + * @param [currency] Optional, selected currency for the workspace + * @param [file], avatar file for workspace */ -function createWorkspaceWithPolicyDraftAndNavigateToIt(policyOwnerEmail = '', policyName = '', transitionFromOldDot = false, makeMeAdmin = false, backTo = '') { - const policyID = generatePolicyID(); - createDraftInitialWorkspace(policyOwnerEmail, policyName, policyID, makeMeAdmin); +function createWorkspaceWithPolicyDraftAndNavigateToIt( + policyOwnerEmail = '', + policyName = '', + transitionFromOldDot = false, + makeMeAdmin = false, + backTo = '', + policyID = '', + currency?: string, + file?: File, +) { + const policyIDWithDefault = policyID || generatePolicyID(); + createDraftInitialWorkspace(policyOwnerEmail, policyName, policyIDWithDefault, makeMeAdmin, currency, file); Navigation.isNavigationReady() .then(() => { @@ -395,8 +407,8 @@ function createWorkspaceWithPolicyDraftAndNavigateToIt(policyOwnerEmail = '', po // We must call goBack() to remove the /transition route from history Navigation.goBack(); } - savePolicyDraftByNewWorkspace(policyID, policyName, policyOwnerEmail, makeMeAdmin); - Navigation.navigate(ROUTES.WORKSPACE_INITIAL.getRoute(policyID, backTo)); + savePolicyDraftByNewWorkspace(policyIDWithDefault, policyName, policyOwnerEmail, makeMeAdmin, currency, file); + Navigation.navigate(ROUTES.WORKSPACE_INITIAL.getRoute(policyIDWithDefault, backTo)); }) .then(endSignOnTransition); } @@ -408,9 +420,11 @@ function createWorkspaceWithPolicyDraftAndNavigateToIt(policyOwnerEmail = '', po * @param [policyName] custom policy name we will use for created workspace * @param [policyOwnerEmail] Optional, the email of the account to make the owner of the policy * @param [makeMeAdmin] Optional, leave the calling account as an admin on the policy + * @param [currency] Optional, selected currency for the workspace + * @param [file] Optional, avatar file for workspace */ -function savePolicyDraftByNewWorkspace(policyID?: string, policyName?: string, policyOwnerEmail = '', makeMeAdmin = false) { - createWorkspace(policyOwnerEmail, makeMeAdmin, policyName, policyID); +function savePolicyDraftByNewWorkspace(policyID?: string, policyName?: string, policyOwnerEmail = '', makeMeAdmin = false, currency = '', file?: File) { + createWorkspace(policyOwnerEmail, makeMeAdmin, policyName, policyID, '', currency, file); } /** diff --git a/src/libs/actions/OnyxUpdates.ts b/src/libs/actions/OnyxUpdates.ts index a09159993ad8..5a57e5622c53 100644 --- a/src/libs/actions/OnyxUpdates.ts +++ b/src/libs/actions/OnyxUpdates.ts @@ -9,7 +9,7 @@ import ONYXKEYS from '@src/ONYXKEYS'; import type {OnyxUpdateEvent, OnyxUpdatesFromServer, Request} from '@src/types/onyx'; import type Response from '@src/types/onyx/Response'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; -import * as QueuedOnyxUpdates from './QueuedOnyxUpdates'; +import {queueOnyxUpdates} from './QueuedOnyxUpdates'; // This key needs to be separate from ONYXKEYS.ONYX_UPDATES_FROM_SERVER so that it can be updated without triggering the callback when the server IDs are updated. If that // callback were triggered it would lead to duplicate processing of server updates. @@ -30,7 +30,7 @@ function applyHTTPSOnyxUpdates(request: Request, response: Response) { console.debug('[OnyxUpdateManager] Applying https update'); // For most requests we can immediately update Onyx. For write requests we queue the updates and apply them after the sequential queue has flushed to prevent a replay effect in // the UI. See https://github.com/Expensify/App/issues/12775 for more info. - const updateHandler: (updates: OnyxUpdate[]) => Promise = request?.data?.apiRequestType === CONST.API_REQUEST_TYPE.WRITE ? QueuedOnyxUpdates.queueOnyxUpdates : Onyx.update; + const updateHandler: (updates: OnyxUpdate[]) => Promise = request?.data?.apiRequestType === CONST.API_REQUEST_TYPE.WRITE ? queueOnyxUpdates : Onyx.update; // First apply any onyx data updates that are being sent back from the API. We wait for this to complete and then // apply successData or failureData. This ensures that we do not update any pending, loading, or other UI states contained @@ -114,7 +114,10 @@ function apply({lastUpdateID, type, request, response, updates}: OnyxUpdatesFrom Log.info(`[OnyxUpdateManager] Applying update type: ${type} with lastUpdateID: ${lastUpdateID}`, false, {command: request?.command}); if (lastUpdateID && lastUpdateIDAppliedToClient && Number(lastUpdateID) <= lastUpdateIDAppliedToClient) { - Log.info('[OnyxUpdateManager] Update received was older than or the same as current state, returning without applying the updates other than successData and failureData'); + Log.info('[OnyxUpdateManager] Update received was older than or the same as current state, returning without applying the updates other than successData and failureData', false, { + lastUpdateID, + lastUpdateIDAppliedToClient, + }); // In this case, we're already received the OnyxUpdate included in the response, so we don't need to apply it again. // However, we do need to apply the successData and failureData from the request diff --git a/src/libs/actions/Policy/Policy.ts b/src/libs/actions/Policy/Policy.ts index f28a82bea9bb..eb302fd7fda5 100644 --- a/src/libs/actions/Policy/Policy.ts +++ b/src/libs/actions/Policy/Policy.ts @@ -64,6 +64,7 @@ import {READ_COMMANDS, WRITE_COMMANDS} from '@libs/API/types'; import * as CurrencyUtils from '@libs/CurrencyUtils'; import DateUtils from '@libs/DateUtils'; import * as ErrorUtils from '@libs/ErrorUtils'; +import {createFile} from '@libs/fileDownload/FileUtils'; import getIsNarrowLayout from '@libs/getIsNarrowLayout'; import GoogleTagManager from '@libs/GoogleTagManager'; import Log from '@libs/Log'; @@ -1596,7 +1597,8 @@ function generateCustomUnitID(): string { } function buildOptimisticDistanceRateCustomUnits(reportCurrency?: string): OptimisticCustomUnits { - const currency = reportCurrency ?? allPersonalDetails?.[sessionAccountID]?.localCurrencyCode ?? CONST.CURRENCY.USD; + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- Disabling this line for safeness as nullish coalescing works only if the value is undefined or null + const currency = reportCurrency || (allPersonalDetails?.[sessionAccountID]?.localCurrencyCode ?? CONST.CURRENCY.USD); const customUnitID = generateCustomUnitID(); const customUnitRateID = generateCustomUnitID(); @@ -1634,10 +1636,12 @@ function buildOptimisticDistanceRateCustomUnits(reportCurrency?: string): Optimi * @param [policyName] custom policy name we will use for created workspace * @param [policyID] custom policy id we will use for created workspace * @param [makeMeAdmin] leave the calling account as an admin on the policy + * @param [currency] Optional, selected currency for the workspace + * @param [file], avatar file for workspace */ -function createDraftInitialWorkspace(policyOwnerEmail = '', policyName = '', policyID = generatePolicyID(), makeMeAdmin = false) { +function createDraftInitialWorkspace(policyOwnerEmail = '', policyName = '', policyID = generatePolicyID(), makeMeAdmin = false, currency = '', file?: File) { const workspaceName = policyName || generateDefaultWorkspaceName(policyOwnerEmail); - const {customUnits, outputCurrency} = buildOptimisticDistanceRateCustomUnits(); + const {customUnits, outputCurrency} = buildOptimisticDistanceRateCustomUnits(currency); const optimisticData: OnyxUpdate[] = [ { @@ -1658,6 +1662,8 @@ function createDraftInitialWorkspace(policyOwnerEmail = '', policyName = '', pol makeMeAdmin, autoReporting: true, autoReportingFrequency: CONST.POLICY.AUTO_REPORTING_FREQUENCIES.INSTANT, + avatarURL: file?.uri ?? null, + originalFileName: file?.name, employeeList: { [sessionEmail]: { role: CONST.POLICY.ROLE.ADMIN, @@ -1687,12 +1693,24 @@ function createDraftInitialWorkspace(policyOwnerEmail = '', policyName = '', pol * @param [makeMeAdmin] leave the calling account as an admin on the policy * @param [policyName] custom policy name we will use for created workspace * @param [policyID] custom policy id we will use for created workspace - * @param [expenseReportId] the reportID of the expense report that is being used to create the workspace + * @param [expenseReportId] Optional, Purpose of using application selected by user in guided setup flow + * @param [engagementChoice] Purpose of using application selected by user in guided setup flow + * @param [currency] Optional, selected currency for the workspace + * @param [file] Optional, avatar file for workspace */ -function buildPolicyData(policyOwnerEmail = '', makeMeAdmin = false, policyName = '', policyID = generatePolicyID(), expenseReportId?: string, engagementChoice?: string) { +function buildPolicyData( + policyOwnerEmail = '', + makeMeAdmin = false, + policyName = '', + policyID = generatePolicyID(), + expenseReportId?: string, + engagementChoice?: string, + currency = '', + file?: File, +) { const workspaceName = policyName || generateDefaultWorkspaceName(policyOwnerEmail); - const {customUnits, customUnitID, customUnitRateID, outputCurrency} = buildOptimisticDistanceRateCustomUnits(); + const {customUnits, customUnitID, customUnitRateID, outputCurrency} = buildOptimisticDistanceRateCustomUnits(currency); const { adminsChatReportID, @@ -1754,6 +1772,8 @@ function buildPolicyData(policyOwnerEmail = '', makeMeAdmin = false, policyName description: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, type: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, }, + avatarURL: file?.uri, + originalFileName: file?.name, }, }, { @@ -1939,6 +1959,9 @@ function buildPolicyData(policyOwnerEmail = '', makeMeAdmin = false, policyName successData.push(...optimisticCategoriesData.successData); } + // We need to clone the file to prevent non-indexable errors. + const clonedFile = file ? (createFile(file) as File) : undefined; + const params: CreateWorkspaceParams = { policyID, adminsChatReportID, @@ -1952,6 +1975,8 @@ function buildPolicyData(policyOwnerEmail = '', makeMeAdmin = false, policyName customUnitID, customUnitRateID, engagementChoice, + currency: outputCurrency, + file: clonedFile, }; return {successData, optimisticData, failureData, params}; @@ -1965,9 +1990,19 @@ function buildPolicyData(policyOwnerEmail = '', makeMeAdmin = false, policyName * @param [policyName] custom policy name we will use for created workspace * @param [policyID] custom policy id we will use for created workspace * @param [engagementChoice] Purpose of using application selected by user in guided setup flow + * @param [currency] Optional, selected currency for the workspace + * @param [file], avatar file for workspace */ -function createWorkspace(policyOwnerEmail = '', makeMeAdmin = false, policyName = '', policyID = generatePolicyID(), engagementChoice = ''): CreateWorkspaceParams { - const {optimisticData, failureData, successData, params} = buildPolicyData(policyOwnerEmail, makeMeAdmin, policyName, policyID, undefined, engagementChoice); +function createWorkspace( + policyOwnerEmail = '', + makeMeAdmin = false, + policyName = '', + policyID = generatePolicyID(), + engagementChoice = '', + currency = '', + file?: File, +): CreateWorkspaceParams { + const {optimisticData, failureData, successData, params} = buildPolicyData(policyOwnerEmail, makeMeAdmin, policyName, policyID, undefined, engagementChoice, currency, file); API.write(WRITE_COMMANDS.CREATE_WORKSPACE, params, {optimisticData, successData, failureData}); // Publish a workspace created event if this is their first policy @@ -1986,10 +2021,10 @@ function createWorkspace(policyOwnerEmail = '', makeMeAdmin = false, policyName * @param [policyName] custom policy name we will use for created workspace * @param [policyID] custom policy id we will use for created workspace */ -function createDraftWorkspace(policyOwnerEmail = '', makeMeAdmin = false, policyName = '', policyID = generatePolicyID()): CreateWorkspaceParams { +function createDraftWorkspace(policyOwnerEmail = '', makeMeAdmin = false, policyName = '', policyID = generatePolicyID(), currency = '', file?: File): CreateWorkspaceParams { const workspaceName = policyName || generateDefaultWorkspaceName(policyOwnerEmail); - const {customUnits, customUnitID, customUnitRateID, outputCurrency} = buildOptimisticDistanceRateCustomUnits(); + const {customUnits, customUnitID, customUnitRateID, outputCurrency} = buildOptimisticDistanceRateCustomUnits(currency); const {expenseChatData, adminsChatReportID, adminsCreatedReportActionID, expenseChatReportID, expenseCreatedReportActionID} = ReportUtils.buildOptimisticWorkspaceChats( policyID, @@ -2056,6 +2091,9 @@ function createDraftWorkspace(policyOwnerEmail = '', makeMeAdmin = false, policy }, ]; + // We need to clone the file to prevent non-indexable errors. + const clonedFile = file ? (createFile(file) as File) : undefined; + const params: CreateWorkspaceParams = { policyID, adminsChatReportID, @@ -2068,6 +2106,8 @@ function createDraftWorkspace(policyOwnerEmail = '', makeMeAdmin = false, policy expenseCreatedReportActionID, customUnitID, customUnitRateID, + currency: outputCurrency, + file: clonedFile, }; Onyx.update(optimisticData); diff --git a/src/libs/actions/Session/index.ts b/src/libs/actions/Session/index.ts index 44fa06714b6d..728353365a43 100644 --- a/src/libs/actions/Session/index.ts +++ b/src/libs/actions/Session/index.ts @@ -272,6 +272,9 @@ function signOutAndRedirectToSignIn(shouldResetToHome?: boolean, shouldStashSess [ONYXKEYS.SESSION]: stashedSession, }; } + if (isSupportal && !shouldStashSession && !hasStashedSession()) { + Log.info('No stashed session found for supportal access, clearing the session'); + } redirectToSignIn().then(() => { Onyx.multiSet(onyxSetParams); }); diff --git a/src/libs/fileDownload/FileUtils.ts b/src/libs/fileDownload/FileUtils.ts index f209e46930cb..cc43850f12f3 100644 --- a/src/libs/fileDownload/FileUtils.ts +++ b/src/libs/fileDownload/FileUtils.ts @@ -3,6 +3,7 @@ import {Alert, Linking, Platform} from 'react-native'; import ImageSize from 'react-native-image-size'; import type {FileObject} from '@components/AttachmentModal'; import DateUtils from '@libs/DateUtils'; +import getPlatform from '@libs/getPlatform'; import * as Localize from '@libs/Localize'; import Log from '@libs/Log'; import saveLastRoute from '@libs/saveLastRoute'; @@ -330,6 +331,21 @@ const resizeImageIfNeeded = (file: FileObject) => { } return getImageDimensionsAfterResize(file).then(({width, height}) => getImageManipulator({fileUri: file.uri ?? '', width, height, fileName: file.name ?? '', type: file.type})); }; + +const createFile = (file: File): FileObject => { + if (getPlatform() === CONST.PLATFORM.ANDROID || getPlatform() === CONST.PLATFORM.IOS) { + return { + uri: file.uri, + name: file.name, + type: file.type, + }; + } + return new File([file], file.name, { + type: file.type, + lastModified: file.lastModified, + }); +}; + export { showGeneralErrorAlert, showSuccessAlert, @@ -350,4 +366,5 @@ export { verifyFileFormat, getImageDimensionsAfterResize, resizeImageIfNeeded, + createFile, }; diff --git a/src/pages/LogInWithShortLivedAuthTokenPage.tsx b/src/pages/LogInWithShortLivedAuthTokenPage.tsx index 5669a98fd484..845722909b2c 100644 --- a/src/pages/LogInWithShortLivedAuthTokenPage.tsx +++ b/src/pages/LogInWithShortLivedAuthTokenPage.tsx @@ -49,8 +49,6 @@ function LogInWithShortLivedAuthTokenPage({route}: LogInWithShortLivedAuthTokenP // For HybridApp we have separate logic to handle transitions. if (!NativeModules.HybridAppModule && exitTo) { Navigation.isNavigationReady().then(() => { - // We must call goBack() to remove the /transition route from history - Navigation.goBack(); Navigation.navigate(exitTo as Route); }); } diff --git a/src/pages/Travel/CarTripDetails.tsx b/src/pages/Travel/CarTripDetails.tsx index 09ffd3d2cad1..e840e1a56da7 100644 --- a/src/pages/Travel/CarTripDetails.tsx +++ b/src/pages/Travel/CarTripDetails.tsx @@ -22,9 +22,15 @@ function CarTripDetails({reservation, personalDetails}: CarTripDetailsProps) { const pickUpDate = DateUtils.getFormattedTransportDateAndHour(new Date(reservation.start.date)); const dropOffDate = DateUtils.getFormattedTransportDateAndHour(new Date(reservation.end.date)); - const cancellationText = reservation.cancellationDeadline - ? `${translate('travel.carDetails.cancellationUntil')} ${DateUtils.getFormattedTransportDateAndHour(new Date(reservation.cancellationDeadline)).date}` - : reservation.cancellationPolicy; + + let cancellationText = reservation.cancellationPolicy; + if (reservation.cancellationDeadline) { + cancellationText = `${translate('travel.carDetails.cancellationUntil')} ${DateUtils.getFormattedTransportDateAndHour(new Date(reservation.cancellationDeadline)).date}`; + } + + if (reservation.cancellationPolicy === null && reservation.cancellationDeadline === null) { + cancellationText = translate('travel.carDetails.freeCancellation'); + } const displayName = personalDetails?.displayName ?? reservation.travelerPersonalInfo?.name; diff --git a/src/pages/WorkspaceSwitcherPage/WorkspaceCardCreateAWorkspace.tsx b/src/pages/WorkspaceSwitcherPage/WorkspaceCardCreateAWorkspace.tsx index 4a6b6a473188..0fa006601097 100644 --- a/src/pages/WorkspaceSwitcherPage/WorkspaceCardCreateAWorkspace.tsx +++ b/src/pages/WorkspaceSwitcherPage/WorkspaceCardCreateAWorkspace.tsx @@ -5,7 +5,7 @@ import Section, {CARD_LAYOUT} from '@components/Section'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; import Navigation from '@libs/Navigation/Navigation'; -import * as App from '@userActions/App'; +import ROUTES from '@src/ROUTES'; function WorkspaceCardCreateAWorkspace() { const styles = useThemeStyles(); @@ -22,8 +22,7 @@ function WorkspaceCardCreateAWorkspace() { >