diff --git a/.github/workflows/e2ePerformanceTests.yml b/.github/workflows/e2ePerformanceTests.yml
index 09113bdcc548..75c0b8497bdd 100644
--- a/.github/workflows/e2ePerformanceTests.yml
+++ b/.github/workflows/e2ePerformanceTests.yml
@@ -194,7 +194,7 @@ jobs:
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
- aws-region: us-west-1
+ aws-region: us-west-2
- name: Schedule AWS Device Farm test run on main branch
uses: realm/aws-devicefarm/test-application@7b9a91236c456c97e28d384c9e476035d5ea686b
diff --git a/.github/workflows/platformDeploy.yml b/.github/workflows/platformDeploy.yml
index 4c5dfdb14627..6aeecb3b4e05 100644
--- a/.github/workflows/platformDeploy.yml
+++ b/.github/workflows/platformDeploy.yml
@@ -10,7 +10,7 @@ on:
env:
SHOULD_DEPLOY_PRODUCTION: ${{ github.event_name == 'release' }}
- DEVELOPER_DIR: /Applications/Xcode_14.1.app/Contents/Developer
+ DEVELOPER_DIR: /Applications/Xcode_15.0.1.app/Contents/Developer
concurrency:
group: ${{ github.workflow }}-${{ github.event_name }}
diff --git a/.github/workflows/testBuild.yml b/.github/workflows/testBuild.yml
index 5d860b41b786..8b18b8aa5d53 100644
--- a/.github/workflows/testBuild.yml
+++ b/.github/workflows/testBuild.yml
@@ -11,7 +11,7 @@ on:
branches: ['*ci-test/**']
env:
- DEVELOPER_DIR: /Applications/Xcode_14.2.app/Contents/Developer
+ DEVELOPER_DIR: /Applications/Xcode_15.0.1.app/Contents/Developer
jobs:
validateActor:
@@ -159,7 +159,7 @@ jobs:
uses: ./.github/actions/composite/setupNode
- name: Setup XCode
- run: sudo xcode-select -switch /Applications/Xcode_14.2.app
+ run: sudo xcode-select -switch /Applications/Xcode_15.0.1.app
- name: Setup Ruby
uses: ruby/setup-ruby@a05e47355e80e57b9a67566a813648fa67d92011
diff --git a/android/app/build.gradle b/android/app/build.gradle
index 341c9ff046a1..4dd8d1766953 100644
--- a/android/app/build.gradle
+++ b/android/app/build.gradle
@@ -96,8 +96,8 @@ android {
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
multiDexEnabled rootProject.ext.multiDexEnabled
- versionCode 1001041803
- versionName "1.4.18-3"
+ versionCode 1001042002
+ versionName "1.4.20-2"
}
flavorDimensions "default"
diff --git a/assets/images/product-illustrations/telescope.svg b/assets/images/product-illustrations/telescope.svg
new file mode 100644
index 000000000000..95617c801789
--- /dev/null
+++ b/assets/images/product-illustrations/telescope.svg
@@ -0,0 +1,79 @@
+
+
+
diff --git a/docs/_data/_routes.yml b/docs/_data/_routes.yml
index 4246b23982ec..0887e90dcc8b 100644
--- a/docs/_data/_routes.yml
+++ b/docs/_data/_routes.yml
@@ -113,8 +113,8 @@ platforms:
icon: /assets/images/hand-card.svg
description: Explore how the Expensify Card combines convenience and security to enhance everyday business transactions. Discover how to apply for, oversee, and maximize your card perks here.
- - href: get-paid-back
- title: Get Paid Back
+ - href: payments
+ title: Payments
icon: /assets/images/money-into-wallet.svg
description: Whether you submit an expense report or an invoice, find out here how to ensure a smooth and timely payback process every time.
diff --git a/docs/articles/new-expensify/get-paid-back/Distance-Requests.md b/docs/articles/new-expensify/payments/Distance-Requests.md
similarity index 100%
rename from docs/articles/new-expensify/get-paid-back/Distance-Requests.md
rename to docs/articles/new-expensify/payments/Distance-Requests.md
diff --git a/docs/articles/new-expensify/get-paid-back/Referral-Program.md b/docs/articles/new-expensify/payments/Referral-Program.md
similarity index 100%
rename from docs/articles/new-expensify/get-paid-back/Referral-Program.md
rename to docs/articles/new-expensify/payments/Referral-Program.md
diff --git a/docs/articles/new-expensify/get-paid-back/Request-Money.md b/docs/articles/new-expensify/payments/Request-Money.md
similarity index 100%
rename from docs/articles/new-expensify/get-paid-back/Request-Money.md
rename to docs/articles/new-expensify/payments/Request-Money.md
diff --git a/docs/new-expensify/hubs/get-paid-back/index.html b/docs/new-expensify/hubs/payments/index.html
similarity index 69%
rename from docs/new-expensify/hubs/get-paid-back/index.html
rename to docs/new-expensify/hubs/payments/index.html
index 1f84c1510b92..d22f7e375f2e 100644
--- a/docs/new-expensify/hubs/get-paid-back/index.html
+++ b/docs/new-expensify/hubs/payments/index.html
@@ -1,6 +1,6 @@
---
layout: default
-title: Get Paid Back
+title: Payments
---
{% include hub.html %}
\ No newline at end of file
diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist
index 51de98b49720..8d60003e1355 100644
--- a/ios/NewExpensify/Info.plist
+++ b/ios/NewExpensify/Info.plist
@@ -19,7 +19,7 @@
CFBundlePackageType
APPL
CFBundleShortVersionString
- 1.4.18
+ 1.4.20
CFBundleSignature
????
CFBundleURLTypes
@@ -40,7 +40,7 @@
CFBundleVersion
- 1.4.18.3
+ 1.4.20.2
ITSAppUsesNonExemptEncryption
LSApplicationQueriesSchemes
diff --git a/ios/NewExpensifyTests/Info.plist b/ios/NewExpensifyTests/Info.plist
index c68c221f7add..740a07ea24ac 100644
--- a/ios/NewExpensifyTests/Info.plist
+++ b/ios/NewExpensifyTests/Info.plist
@@ -15,10 +15,10 @@
CFBundlePackageType
BNDL
CFBundleShortVersionString
- 1.4.18
+ 1.4.20
CFBundleSignature
????
CFBundleVersion
- 1.4.18.3
+ 1.4.20.2
diff --git a/ios/Podfile.lock b/ios/Podfile.lock
index 318d62f0a944..77c390c46416 100644
--- a/ios/Podfile.lock
+++ b/ios/Podfile.lock
@@ -802,35 +802,10 @@ PODS:
- React-Core
- RNReactNativeHapticFeedback (1.14.0):
- React-Core
- - RNReanimated (3.5.4):
- - DoubleConversion
- - FBLazyVector
- - glog
- - hermes-engine
- - RCT-Folly
- - RCTRequired
- - RCTTypeSafety
- - React-callinvoker
+ - RNReanimated (3.6.1):
+ - RCT-Folly (= 2021.07.22.00)
- React-Core
- - React-Core/DevSupport
- - React-Core/RCTWebSocket
- - React-CoreModules
- - React-cxxreact
- - React-hermes
- - React-jsi
- - React-jsiexecutor
- - React-jsinspector
- - React-RCTActionSheet
- - React-RCTAnimation
- - React-RCTAppDelegate
- - React-RCTBlob
- - React-RCTImage
- - React-RCTLinking
- - React-RCTNetwork
- - React-RCTSettings
- - React-RCTText
- ReactCommon/turbomodule/core
- - Yoga
- RNScreens (3.21.0):
- React-Core
- React-RCTImage
@@ -1333,7 +1308,7 @@ SPEC CHECKSUMS:
rnmapbox-maps: 6f638ec002aa6e906a6f766d69cd45f968d98e64
RNPermissions: 9b086c8f05b2e2faa587fdc31f4c5ab4509728aa
RNReactNativeHapticFeedback: 1e3efeca9628ff9876ee7cdd9edec1b336913f8c
- RNReanimated: ab2e96c6d5591c3dfbb38a464f54c8d17fb34a87
+ RNReanimated: fdbaa9c964bbab7fac50c90862b6cc5f041679b9
RNScreens: d037903436160a4b039d32606668350d2a808806
RNSVG: d00c8f91c3cbf6d476451313a18f04d220d4f396
SDWebImage: 750adf017a315a280c60fde706ab1e552a3ae4e9
diff --git a/package-lock.json b/package-lock.json
index bcf1b3650227..e00ae28b8113 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "new.expensify",
- "version": "1.4.18-3",
+ "version": "1.4.20-2",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "new.expensify",
- "version": "1.4.18-3",
+ "version": "1.4.20-2",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
@@ -103,7 +103,7 @@
"react-native-plaid-link-sdk": "10.8.0",
"react-native-qrcode-svg": "^6.2.0",
"react-native-quick-sqlite": "^8.0.0-beta.2",
- "react-native-reanimated": "3.5.4",
+ "react-native-reanimated": "^3.6.1",
"react-native-render-html": "6.3.1",
"react-native-safe-area-context": "4.4.1",
"react-native-screens": "3.21.0",
@@ -47677,9 +47677,9 @@
}
},
"node_modules/react-native-reanimated": {
- "version": "3.5.4",
- "resolved": "https://registry.npmjs.org/react-native-reanimated/-/react-native-reanimated-3.5.4.tgz",
- "integrity": "sha512-8we9LLDO1o4Oj9/DICeEJ2K1tjfqkJagqQUglxeUAkol/HcEJ6PGxIrpBcNryLqCDYEcu6FZWld/FzizBIw6bg==",
+ "version": "3.6.1",
+ "resolved": "https://registry.npmjs.org/react-native-reanimated/-/react-native-reanimated-3.6.1.tgz",
+ "integrity": "sha512-F4vG9Yf9PKmE3GaWtVGUpzj3SM6YY2cx1yRHCwiMd1uY7W0gU017LfcVUorboJnj0y5QZqEriEK1Usq2Y8YZqg==",
"dependencies": {
"@babel/plugin-transform-object-assign": "^7.16.7",
"@babel/preset-typescript": "^7.16.7",
@@ -90489,9 +90489,9 @@
"requires": {}
},
"react-native-reanimated": {
- "version": "3.5.4",
- "resolved": "https://registry.npmjs.org/react-native-reanimated/-/react-native-reanimated-3.5.4.tgz",
- "integrity": "sha512-8we9LLDO1o4Oj9/DICeEJ2K1tjfqkJagqQUglxeUAkol/HcEJ6PGxIrpBcNryLqCDYEcu6FZWld/FzizBIw6bg==",
+ "version": "3.6.1",
+ "resolved": "https://registry.npmjs.org/react-native-reanimated/-/react-native-reanimated-3.6.1.tgz",
+ "integrity": "sha512-F4vG9Yf9PKmE3GaWtVGUpzj3SM6YY2cx1yRHCwiMd1uY7W0gU017LfcVUorboJnj0y5QZqEriEK1Usq2Y8YZqg==",
"requires": {
"@babel/plugin-transform-object-assign": "^7.16.7",
"@babel/preset-typescript": "^7.16.7",
diff --git a/package.json b/package.json
index 3f2029118438..28dc12938d60 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "new.expensify",
- "version": "1.4.18-3",
+ "version": "1.4.20-2",
"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.",
@@ -151,7 +151,7 @@
"react-native-plaid-link-sdk": "10.8.0",
"react-native-qrcode-svg": "^6.2.0",
"react-native-quick-sqlite": "^8.0.0-beta.2",
- "react-native-reanimated": "3.5.4",
+ "react-native-reanimated": "^3.6.1",
"react-native-render-html": "6.3.1",
"react-native-safe-area-context": "4.4.1",
"react-native-screens": "3.21.0",
diff --git a/src/CONST.ts b/src/CONST.ts
index 34720adf8a21..abba27b0c33b 100755
--- a/src/CONST.ts
+++ b/src/CONST.ts
@@ -594,6 +594,7 @@ const CONST = {
JOIN_ROOM: 'JOINROOM',
},
},
+ THREAD_DISABLED: ['CREATED'],
},
ARCHIVE_REASON: {
DEFAULT: 'default',
diff --git a/src/ROUTES.ts b/src/ROUTES.ts
index 49f0337798ee..db17378684d6 100644
--- a/src/ROUTES.ts
+++ b/src/ROUTES.ts
@@ -372,6 +372,12 @@ const ROUTES = {
getRoute: (iouType: ValueOf, transactionID: string, reportID: string, pageIndex = '', backTo = '') =>
getUrlWithBackToParam(`create/${iouType}/waypoint/${transactionID}/${reportID}/${pageIndex}`, backTo),
},
+ // This URL is used as a redirect to one of the create tabs below. This is so that we can message users with a link
+ // straight to those flows without needing to have optimistic transaction and report IDs.
+ MONEY_REQUEST_START: {
+ route: 'start/:iouType/:iouRequestType',
+ getRoute: (iouType: ValueOf, iouRequestType: ValueOf) => `start/${iouType}/${iouRequestType}` as const,
+ },
MONEY_REQUEST_CREATE_TAB_DISTANCE: {
route: 'create/:iouType/start/:transactionID/:reportID/distance',
getRoute: (iouType: ValueOf, transactionID: string, reportID: string) => `create/${iouType}/start/${transactionID}/${reportID}/distance` as const,
diff --git a/src/SCREENS.ts b/src/SCREENS.ts
index 2cd263237866..c1d2059cd3b0 100644
--- a/src/SCREENS.ts
+++ b/src/SCREENS.ts
@@ -118,6 +118,7 @@ const SCREENS = {
DISTANCE_TAB: 'distance',
CREATE: 'Money_Request_Create',
STEP_CONFIRMATION: 'Money_Request_Step_Confirmation',
+ START: 'Money_Request_Start',
STEP_AMOUNT: 'Money_Request_Step_Amount',
STEP_CATEGORY: 'Money_Request_Step_Category',
STEP_CURRENCY: 'Money_Request_Step_Currency',
diff --git a/src/components/Attachments/AttachmentView/index.js b/src/components/Attachments/AttachmentView/index.js
index c01843c7bcb3..b0060afdb813 100755
--- a/src/components/Attachments/AttachmentView/index.js
+++ b/src/components/Attachments/AttachmentView/index.js
@@ -146,6 +146,7 @@ function AttachmentView({
onLoadComplete={() => !loadComplete && setLoadComplete(true)}
errorLabelStyles={isUsedInAttachmentModal ? [styles.textLabel, styles.textLarge] : [styles.cursorAuto]}
style={isUsedInAttachmentModal ? styles.imageModalPDF : styles.flex1}
+ isUsedInCarousel={isUsedInCarousel}
/>
);
diff --git a/src/components/BlockingViews/BlockingView.js b/src/components/BlockingViews/BlockingView.tsx
similarity index 56%
rename from src/components/BlockingViews/BlockingView.js
rename to src/components/BlockingViews/BlockingView.tsx
index 5c0a8a9711e7..3a038c58d886 100644
--- a/src/components/BlockingViews/BlockingView.js
+++ b/src/components/BlockingViews/BlockingView.tsx
@@ -1,60 +1,60 @@
-import PropTypes from 'prop-types';
import React from 'react';
-import {View} from 'react-native';
+import {ImageSourcePropType, View} from 'react-native';
+import {SvgProps} from 'react-native-svg';
import AutoEmailLink from '@components/AutoEmailLink';
import Icon from '@components/Icon';
-import sourcePropTypes from '@components/Image/sourcePropTypes';
import Text from '@components/Text';
import TextLink from '@components/TextLink';
import useLocalize from '@hooks/useLocalize';
import useThemeStyles from '@hooks/useThemeStyles';
import Navigation from '@libs/Navigation/Navigation';
import variables from '@styles/variables';
+import {TranslationPaths} from '@src/languages/types';
-const propTypes = {
+type BlockingViewProps = {
/** Expensicon for the page */
- icon: sourcePropTypes.isRequired,
+ icon: React.FC | ImageSourcePropType;
/** Color for the icon (should be from theme) */
- iconColor: PropTypes.string,
+ iconColor?: string;
/** Title message below the icon */
- title: PropTypes.string.isRequired,
+ title: string;
/** Subtitle message below the title */
- subtitle: PropTypes.string,
+ subtitle?: string;
/** Link message below the subtitle */
- linkKey: PropTypes.string,
+ linkKey?: TranslationPaths;
/** Whether we should show a link to navigate elsewhere */
- shouldShowLink: PropTypes.bool,
+ shouldShowLink?: boolean;
/** The custom icon width */
- iconWidth: PropTypes.number,
+ iconWidth?: number;
/** The custom icon height */
- iconHeight: PropTypes.number,
+ iconHeight?: number;
/** Function to call when pressing the navigation link */
- onLinkPress: PropTypes.func,
+ onLinkPress?: () => void;
/** Whether we should embed the link with subtitle */
- shouldEmbedLinkWithSubtitle: PropTypes.bool,
+ shouldEmbedLinkWithSubtitle?: boolean;
};
-const defaultProps = {
- iconColor: null,
- subtitle: '',
- shouldShowLink: false,
- linkKey: 'notFound.goBackHome',
- iconWidth: variables.iconSizeSuperLarge,
- iconHeight: variables.iconSizeSuperLarge,
- onLinkPress: () => Navigation.dismissModal(),
- shouldEmbedLinkWithSubtitle: false,
-};
-
-function BlockingView(props) {
+function BlockingView({
+ icon,
+ iconColor,
+ title,
+ subtitle = '',
+ linkKey = 'notFound.goBackHome',
+ shouldShowLink = false,
+ iconWidth = variables.iconSizeSuperLarge,
+ iconHeight = variables.iconSizeSuperLarge,
+ onLinkPress = () => Navigation.dismissModal(),
+ shouldEmbedLinkWithSubtitle = false,
+}: BlockingViewProps) {
const styles = useThemeStyles();
const {translate} = useLocalize();
function renderContent() {
@@ -62,14 +62,14 @@ function BlockingView(props) {
<>
- {props.shouldShowLink ? (
+ {shouldShowLink ? (
- {translate(props.linkKey)}
+ {translate(linkKey)}
) : null}
>
@@ -79,14 +79,14 @@ function BlockingView(props) {
return (
- {props.title}
+ {title}
- {props.shouldEmbedLinkWithSubtitle ? (
+ {shouldEmbedLinkWithSubtitle ? (
{renderContent()}
) : (
{renderContent()}
@@ -95,8 +95,6 @@ function BlockingView(props) {
);
}
-BlockingView.propTypes = propTypes;
-BlockingView.defaultProps = defaultProps;
BlockingView.displayName = 'BlockingView';
export default BlockingView;
diff --git a/src/components/BlockingViews/FullPageNotFoundView.js b/src/components/BlockingViews/FullPageNotFoundView.tsx
similarity index 69%
rename from src/components/BlockingViews/FullPageNotFoundView.js
rename to src/components/BlockingViews/FullPageNotFoundView.tsx
index ce76b96c0eb0..6d7f838bf6c2 100644
--- a/src/components/BlockingViews/FullPageNotFoundView.js
+++ b/src/components/BlockingViews/FullPageNotFoundView.tsx
@@ -1,4 +1,3 @@
-import PropTypes from 'prop-types';
import React from 'react';
import {View} from 'react-native';
import HeaderWithBackButton from '@components/HeaderWithBackButton';
@@ -7,54 +6,54 @@ import useLocalize from '@hooks/useLocalize';
import useThemeStyles from '@hooks/useThemeStyles';
import Navigation from '@libs/Navigation/Navigation';
import variables from '@styles/variables';
+import {TranslationPaths} from '@src/languages/types';
import ROUTES from '@src/ROUTES';
import BlockingView from './BlockingView';
-const propTypes = {
+type FullPageNotFoundViewProps = {
/** Child elements */
- children: PropTypes.node,
+ children?: React.ReactNode;
/** If true, child components are replaced with a blocking "not found" view */
- shouldShow: PropTypes.bool,
+ shouldShow?: boolean;
/** The key in the translations file to use for the title */
- titleKey: PropTypes.string,
+ titleKey?: TranslationPaths;
/** The key in the translations file to use for the subtitle */
- subtitleKey: PropTypes.string,
+ subtitleKey?: TranslationPaths;
/** Whether we should show a link to navigate elsewhere */
- shouldShowLink: PropTypes.bool,
+ shouldShowLink?: boolean;
/** Whether we should show the back button on the header */
- shouldShowBackButton: PropTypes.bool,
+ shouldShowBackButton?: boolean;
/** The key in the translations file to use for the go back link */
- linkKey: PropTypes.string,
+ linkKey?: TranslationPaths;
/** Method to trigger when pressing the back button of the header */
- onBackButtonPress: PropTypes.func,
+ onBackButtonPress: () => void;
/** Function to call when pressing the navigation link */
- onLinkPress: PropTypes.func,
-};
-
-const defaultProps = {
- children: null,
- shouldShow: false,
- titleKey: 'notFound.notHere',
- subtitleKey: 'notFound.pageNotFound',
- linkKey: 'notFound.goBackHome',
- onBackButtonPress: () => Navigation.goBack(ROUTES.HOME),
- shouldShowLink: true,
- shouldShowBackButton: true,
- onLinkPress: () => Navigation.dismissModal(),
+ onLinkPress: () => void;
};
// eslint-disable-next-line rulesdir/no-negated-variables
-function FullPageNotFoundView({children, shouldShow, titleKey, subtitleKey, linkKey, onBackButtonPress, shouldShowLink, shouldShowBackButton, onLinkPress}) {
+function FullPageNotFoundView({
+ children = null,
+ shouldShow = false,
+ titleKey = 'notFound.notHere',
+ subtitleKey = 'notFound.pageNotFound',
+ linkKey = 'notFound.goBackHome',
+ onBackButtonPress = () => Navigation.goBack(ROUTES.HOME),
+ shouldShowLink = true,
+ shouldShowBackButton = true,
+ onLinkPress = () => Navigation.dismissModal(),
+}: FullPageNotFoundViewProps) {
const styles = useThemeStyles();
const {translate} = useLocalize();
+
if (shouldShow) {
return (
<>
@@ -81,8 +80,6 @@ function FullPageNotFoundView({children, shouldShow, titleKey, subtitleKey, link
return children;
}
-FullPageNotFoundView.propTypes = propTypes;
-FullPageNotFoundView.defaultProps = defaultProps;
FullPageNotFoundView.displayName = 'FullPageNotFoundView';
export default FullPageNotFoundView;
diff --git a/src/components/BlockingViews/FullPageOfflineBlockingView.js b/src/components/BlockingViews/FullPageOfflineBlockingView.js
deleted file mode 100644
index adbda21456dc..000000000000
--- a/src/components/BlockingViews/FullPageOfflineBlockingView.js
+++ /dev/null
@@ -1,42 +0,0 @@
-import PropTypes from 'prop-types';
-import React from 'react';
-import * as Expensicons from '@components/Icon/Expensicons';
-import networkPropTypes from '@components/networkPropTypes';
-import {withNetwork} from '@components/OnyxProvider';
-import withLocalize, {withLocalizePropTypes} from '@components/withLocalize';
-import useTheme from '@hooks/useTheme';
-import compose from '@libs/compose';
-import BlockingView from './BlockingView';
-
-const propTypes = {
- /** Child elements */
- children: PropTypes.node.isRequired,
-
- /** Props to fetch translation features */
- ...withLocalizePropTypes,
-
- /** Props to detect online status */
- network: networkPropTypes.isRequired,
-};
-
-function FullPageOfflineBlockingView(props) {
- const theme = useTheme();
-
- if (props.network.isOffline) {
- return (
-
- );
- }
-
- return props.children;
-}
-
-FullPageOfflineBlockingView.propTypes = propTypes;
-FullPageOfflineBlockingView.displayName = 'FullPageOfflineBlockingView';
-
-export default compose(withLocalize, withNetwork())(FullPageOfflineBlockingView);
diff --git a/src/components/BlockingViews/FullPageOfflineBlockingView.tsx b/src/components/BlockingViews/FullPageOfflineBlockingView.tsx
new file mode 100644
index 000000000000..a9ebcf969ae5
--- /dev/null
+++ b/src/components/BlockingViews/FullPageOfflineBlockingView.tsx
@@ -0,0 +1,31 @@
+import React from 'react';
+import * as Expensicons from '@components/Icon/Expensicons';
+import useLocalize from '@hooks/useLocalize';
+import useNetwork from '@hooks/useNetwork';
+import useTheme from '@hooks/useTheme';
+import ChildrenProps from '@src/types/utils/ChildrenProps';
+import BlockingView from './BlockingView';
+
+function FullPageOfflineBlockingView({children}: ChildrenProps) {
+ const {translate} = useLocalize();
+ const {isOffline} = useNetwork();
+
+ const theme = useTheme();
+
+ if (isOffline) {
+ return (
+
+ );
+ }
+
+ return children;
+}
+
+FullPageOfflineBlockingView.displayName = 'FullPageOfflineBlockingView';
+
+export default FullPageOfflineBlockingView;
diff --git a/src/components/Checkbox.tsx b/src/components/Checkbox.tsx
index 715603ea362e..ac18b550501d 100644
--- a/src/components/Checkbox.tsx
+++ b/src/components/Checkbox.tsx
@@ -1,4 +1,4 @@
-import React, {ForwardedRef, forwardRef, KeyboardEvent as ReactKeyboardEvent} from 'react';
+import React, {type ForwardedRef, forwardRef, type MouseEventHandler, type KeyboardEvent as ReactKeyboardEvent} from 'react';
import {GestureResponderEvent, StyleProp, View, ViewStyle} from 'react-native';
import useStyleUtils from '@hooks/useStyleUtils';
import useTheme from '@hooks/useTheme';
@@ -29,7 +29,7 @@ type CheckboxProps = Partial & {
containerStyle?: StyleProp;
/** Callback that is called when mousedown is triggered. */
- onMouseDown?: () => void;
+ onMouseDown?: MouseEventHandler;
/** The size of the checkbox container */
containerSize?: number;
diff --git a/src/components/DatePicker/CalendarPicker/index.js b/src/components/DatePicker/CalendarPicker/index.js
index bbdeda6ef84f..571ddc820d43 100644
--- a/src/components/DatePicker/CalendarPicker/index.js
+++ b/src/components/DatePicker/CalendarPicker/index.js
@@ -50,7 +50,7 @@ class CalendarPicker extends React.PureComponent {
if (props.minDate >= props.maxDate) {
throw new Error('Minimum date cannot be greater than the maximum date.');
}
- let currentDateView = new Date(props.value);
+ let currentDateView = typeof props.value === 'string' ? parseISO(props.value) : new Date(props.value);
if (props.maxDate < currentDateView) {
currentDateView = props.maxDate;
} else if (props.minDate > currentDateView) {
diff --git a/src/components/FloatingActionButton.js b/src/components/FloatingActionButton.js
index 791eb150f8c9..59e741001063 100644
--- a/src/components/FloatingActionButton.js
+++ b/src/components/FloatingActionButton.js
@@ -1,132 +1,130 @@
import PropTypes from 'prop-types';
-import React, {PureComponent} from 'react';
-import {Animated, Easing, View} from 'react-native';
-import compose from '@libs/compose';
-import Icon from './Icon';
-import * as Expensicons from './Icon/Expensicons';
+import React, {useEffect, useRef} from 'react';
+import {Platform, View} from 'react-native';
+import Animated, {createAnimatedPropAdapter, Easing, interpolateColor, processColor, useAnimatedProps, useAnimatedStyle, useSharedValue, withTiming} from 'react-native-reanimated';
+import Svg, {Path} from 'react-native-svg';
+import useLocalize from '@hooks/useLocalize';
+import useTheme from '@hooks/useTheme';
+import useThemeStyles from '@hooks/useThemeStyles';
+import variables from '@styles/variables';
import PressableWithFeedback from './Pressable/PressableWithFeedback';
import Tooltip from './Tooltip/PopoverAnchorTooltip';
-import withLocalize, {withLocalizePropTypes} from './withLocalize';
-import withStyleUtils, {withStyleUtilsPropTypes} from './withStyleUtils';
-import withTheme, {withThemePropTypes} from './withTheme';
-import withThemeStyles, {withThemeStylesPropTypes} from './withThemeStyles';
-const AnimatedIcon = Animated.createAnimatedComponent(Icon);
-AnimatedIcon.displayName = 'AnimatedIcon';
+const AnimatedPath = Animated.createAnimatedComponent(Path);
+AnimatedPath.displayName = 'AnimatedPath';
const AnimatedPressable = Animated.createAnimatedComponent(PressableWithFeedback);
AnimatedPressable.displayName = 'AnimatedPressable';
+const adapter = createAnimatedPropAdapter(
+ (props) => {
+ // eslint-disable-next-line rulesdir/prefer-underscore-method
+ if (Object.keys(props).includes('fill')) {
+ // eslint-disable-next-line no-param-reassign
+ props.fill = {type: 0, payload: processColor(props.fill)};
+ }
+ // eslint-disable-next-line rulesdir/prefer-underscore-method
+ if (Object.keys(props).includes('stroke')) {
+ // eslint-disable-next-line no-param-reassign
+ props.stroke = {type: 0, payload: processColor(props.stroke)};
+ }
+ },
+ ['fill', 'stroke'],
+);
+adapter.propTypes = {
+ fill: PropTypes.string,
+ stroke: PropTypes.string,
+};
+
const propTypes = {
- // Callback to fire on request to toggle the FloatingActionButton
+ /* Callback to fire on request to toggle the FloatingActionButton */
onPress: PropTypes.func.isRequired,
- // Current state (active or not active) of the component
+ /* Current state (active or not active) of the component */
isActive: PropTypes.bool.isRequired,
- // Ref for the button
- buttonRef: PropTypes.oneOfType([PropTypes.func, PropTypes.object]),
-
- ...withLocalizePropTypes,
- ...withThemePropTypes,
- ...withThemeStylesPropTypes,
- ...withStyleUtilsPropTypes,
-};
+ /* An accessibility label for the button */
+ accessibilityLabel: PropTypes.string.isRequired,
-const defaultProps = {
- buttonRef: () => {},
+ /* An accessibility role for the button */
+ role: PropTypes.string.isRequired,
};
-class FloatingActionButton extends PureComponent {
- constructor(props) {
- super(props);
- this.animatedValue = new Animated.Value(props.isActive ? 1 : 0);
- }
-
- componentDidUpdate(prevProps) {
- if (prevProps.isActive === this.props.isActive) {
- return;
- }
-
- this.animateFloatingActionButton();
- }
-
- /**
- * Animates the floating action button
- * Method is called when the isActive prop changes
- */
- animateFloatingActionButton() {
- const animationFinalValue = this.props.isActive ? 1 : 0;
-
- Animated.timing(this.animatedValue, {
- toValue: animationFinalValue,
+const FloatingActionButton = React.forwardRef(({onPress, isActive, accessibilityLabel, role}, ref) => {
+ const {success, buttonDefaultBG, textLight, textDark} = useTheme();
+ const styles = useThemeStyles();
+ const borderRadius = styles.floatingActionButton.borderRadius;
+ const {translate} = useLocalize();
+ const fabPressable = useRef(null);
+ const sharedValue = useSharedValue(isActive ? 1 : 0);
+ const buttonRef = ref;
+
+ useEffect(() => {
+ sharedValue.value = withTiming(isActive ? 1 : 0, {
duration: 340,
easing: Easing.inOut(Easing.ease),
- useNativeDriver: false,
- }).start();
- }
-
- render() {
- const rotate = this.animatedValue.interpolate({
- inputRange: [0, 1],
- outputRange: ['0deg', '135deg'],
- });
-
- const backgroundColor = this.animatedValue.interpolate({
- inputRange: [0, 1],
- outputRange: [this.props.theme.success, this.props.theme.buttonDefaultBG],
- });
-
- const fill = this.animatedValue.interpolate({
- inputRange: [0, 1],
- outputRange: [this.props.theme.textLight, this.props.theme.textDark],
});
-
- return (
-
-
- {
- this.fabPressable = el;
- if (this.props.buttonRef) {
- this.props.buttonRef.current = el;
- }
- }}
- accessibilityLabel={this.props.accessibilityLabel}
- role={this.props.role}
- pressDimmingValue={1}
- onPress={(e) => {
- // Drop focus to avoid blue focus ring.
- this.fabPressable.blur();
- this.props.onPress(e);
- }}
- onLongPress={() => {}}
- style={[this.props.themeStyles.floatingActionButton, this.props.StyleUtils.getAnimatedFABStyle(rotate, backgroundColor)]}
+ }, [isActive, sharedValue]);
+
+ const animatedStyle = useAnimatedStyle(() => {
+ const backgroundColor = interpolateColor(sharedValue.value, [0, 1], [success, buttonDefaultBG]);
+
+ return {
+ transform: [{rotate: `${sharedValue.value * 135}deg`}],
+ backgroundColor,
+ borderRadius,
+ };
+ });
+
+ const animatedProps = useAnimatedProps(
+ () => {
+ const fill = interpolateColor(sharedValue.value, [0, 1], [textLight, textDark]);
+
+ return {
+ fill,
+ };
+ },
+ undefined,
+ Platform.OS === 'web' ? undefined : adapter,
+ );
+
+ return (
+
+
+ {
+ fabPressable.current = el;
+ if (buttonRef) {
+ buttonRef.current = el;
+ }
+ }}
+ accessibilityLabel={accessibilityLabel}
+ role={role}
+ pressDimmingValue={1}
+ onPress={(e) => {
+ // Drop focus to avoid blue focus ring.
+ fabPressable.current.blur();
+ onPress(e);
+ }}
+ onLongPress={() => {}}
+ style={[styles.floatingActionButton, animatedStyle]}
+ >
+
-
-
- );
- }
-}
+
+
+
+
+ );
+});
FloatingActionButton.propTypes = propTypes;
-FloatingActionButton.defaultProps = defaultProps;
-
-const FloatingActionButtonWithLocalize = withLocalize(FloatingActionButton);
-
-const FloatingActionButtonWithLocalizeWithRef = React.forwardRef((props, ref) => (
-
-));
-
-FloatingActionButtonWithLocalizeWithRef.displayName = 'FloatingActionButtonWithLocalizeWithRef';
+FloatingActionButton.displayName = 'FloatingActionButton';
-export default compose(withThemeStyles, withTheme, withStyleUtils)(FloatingActionButtonWithLocalizeWithRef);
+export default FloatingActionButton;
diff --git a/src/components/Header.tsx b/src/components/Header.tsx
index c02f21d7c6f2..4eac2c7a6994 100644
--- a/src/components/Header.tsx
+++ b/src/components/Header.tsx
@@ -1,4 +1,4 @@
-import React, {ReactElement} from 'react';
+import React, {ReactNode} from 'react';
import {StyleProp, TextStyle, View} from 'react-native';
import useThemeStyles from '@hooks/useThemeStyles';
import EnvironmentBadge from './EnvironmentBadge';
@@ -6,10 +6,10 @@ import Text from './Text';
type HeaderProps = {
/** Title of the Header */
- title?: string | ReactElement;
+ title?: ReactNode;
/** Subtitle of the header */
- subtitle?: string | ReactElement;
+ subtitle?: ReactNode;
/** Should we show the environment badge (dev/stg)? */
shouldShowEnvironmentBadge?: boolean;
diff --git a/src/components/HeaderWithBackButton/index.js b/src/components/HeaderWithBackButton/index.tsx
similarity index 91%
rename from src/components/HeaderWithBackButton/index.js
rename to src/components/HeaderWithBackButton/index.tsx
index 738afbfaeeba..9ec8bca55a95 100755
--- a/src/components/HeaderWithBackButton/index.js
+++ b/src/components/HeaderWithBackButton/index.tsx
@@ -19,18 +19,18 @@ import getButtonState from '@libs/getButtonState';
import Navigation from '@libs/Navigation/Navigation';
import CONST from '@src/CONST';
import ROUTES from '@src/ROUTES';
-import headerWithBackButtonPropTypes from './headerWithBackButtonPropTypes';
+import HeaderWithBackButtonProps from './types';
function HeaderWithBackButton({
- iconFill = null,
+ iconFill,
guidesCallTaskID = '',
onBackButtonPress = () => Navigation.goBack(ROUTES.HOME),
onCloseButtonPress = () => Navigation.dismissModal(),
onDownloadButtonPress = () => {},
onThreeDotsButtonPress = () => {},
report = null,
- policy = {},
- personalDetails = {},
+ policy,
+ personalDetails = null,
shouldShowAvatarWithDisplay = false,
shouldShowBackButton = true,
shouldShowBorderBottom = false,
@@ -41,10 +41,10 @@ function HeaderWithBackButton({
shouldShowPinButton = false,
shouldShowThreeDotsButton = false,
shouldDisableThreeDotsButton = false,
- stepCounter = null,
+ stepCounter,
subtitle = '',
title = '',
- titleColor = undefined,
+ titleColor,
threeDotsAnchorPosition = {
vertical: 0,
horizontal: 0,
@@ -55,14 +55,16 @@ function HeaderWithBackButton({
shouldOverlay = false,
singleExecution = (func) => func,
shouldNavigateToTopMostReport = false,
-}) {
+}: HeaderWithBackButtonProps) {
const theme = useTheme();
const styles = useThemeStyles();
const StyleUtils = useStyleUtils();
const [isDownloadButtonActive, temporarilyDisableDownloadButton] = useThrottledButtonState();
const {translate} = useLocalize();
+ // @ts-expect-error TODO: Remove this once useKeyboardState (https://github.com/Expensify/App/issues/24941) is migrated to TypeScript.
const {isKeyboardShown} = useKeyboardState();
const waitForNavigate = useWaitForNavigation();
+
return (
@@ -116,17 +118,17 @@ function HeaderWithBackButton({
{shouldShowDownloadButton && (
{
+ onPress={(event) => {
// Blur the pressable in case this button triggers a Growl notification
// We do not want to overlap Growl with the Tooltip (#15271)
- e.currentTarget.blur();
+ (event?.currentTarget as HTMLElement)?.blur();
if (!isDownloadButtonActive) {
return;
}
onDownloadButtonPress();
- temporarilyDisableDownloadButton(true);
+ temporarilyDisableDownloadButton();
}}
style={[styles.touchableButtonImage]}
role="button"
@@ -134,7 +136,7 @@ function HeaderWithBackButton({
>
@@ -150,12 +152,12 @@ function HeaderWithBackButton({
>
)}
- {shouldShowPinButton && }
+ {shouldShowPinButton && !!report && }
{shouldShowThreeDotsButton && (
@@ -186,7 +188,6 @@ function HeaderWithBackButton({
);
}
-HeaderWithBackButton.propTypes = headerWithBackButtonPropTypes;
HeaderWithBackButton.displayName = 'HeaderWithBackButton';
export default HeaderWithBackButton;
diff --git a/src/components/HeaderWithBackButton/types.ts b/src/components/HeaderWithBackButton/types.ts
new file mode 100644
index 000000000000..99e93e8d18d2
--- /dev/null
+++ b/src/components/HeaderWithBackButton/types.ts
@@ -0,0 +1,113 @@
+import {ReactNode} from 'react';
+import {OnyxCollection, OnyxEntry} from 'react-native-onyx';
+import type {Action} from '@hooks/useSingleExecution';
+import type {StepCounterParams} from '@src/languages/types';
+import type {AnchorPosition} from '@src/styles';
+import type {PersonalDetails, Policy, Report} from '@src/types/onyx';
+import type ChildrenProps from '@src/types/utils/ChildrenProps';
+import type IconAsset from '@src/types/utils/IconAsset';
+
+type ThreeDotsMenuItems = {
+ /** An icon element displayed on the left side */
+ icon?: IconAsset;
+
+ /** Text label */
+ text: string;
+
+ /** A callback triggered when the item is selected */
+ onSelected: () => void;
+};
+
+type HeaderWithBackButtonProps = Partial & {
+ /** Title of the Header */
+ title?: string;
+
+ /** Subtitle of the header */
+ subtitle?: ReactNode;
+
+ /** Title color */
+ titleColor?: string;
+
+ /** Method to trigger when pressing download button of the header */
+ onDownloadButtonPress?: () => void;
+
+ /** Method to trigger when pressing close button of the header */
+ onCloseButtonPress?: () => void;
+
+ /** Method to trigger when pressing back button of the header */
+ onBackButtonPress?: () => void;
+
+ /** Method to trigger when pressing more options button of the header */
+ onThreeDotsButtonPress?: () => void;
+
+ /** Whether we should show a border on the bottom of the Header */
+ shouldShowBorderBottom?: boolean;
+
+ /** Whether we should show a download button */
+ shouldShowDownloadButton?: boolean;
+
+ /** Whether we should show a get assistance (question mark) button */
+ shouldShowGetAssistanceButton?: boolean;
+
+ /** Whether we should disable the get assistance button */
+ shouldDisableGetAssistanceButton?: boolean;
+
+ /** Whether we should show a pin button */
+ shouldShowPinButton?: boolean;
+
+ /** Whether we should show a more options (threedots) button */
+ shouldShowThreeDotsButton?: boolean;
+
+ /** Whether we should disable threedots button */
+ shouldDisableThreeDotsButton?: boolean;
+
+ /** List of menu items for more(three dots) menu */
+ threeDotsMenuItems?: ThreeDotsMenuItems[];
+
+ /** The anchor position of the menu */
+ threeDotsAnchorPosition?: AnchorPosition;
+
+ /** Whether we should show a close button */
+ shouldShowCloseButton?: boolean;
+
+ /** Whether we should show a back button */
+ shouldShowBackButton?: boolean;
+
+ /** The guides call taskID to associate with the get assistance button, if we show it */
+ guidesCallTaskID?: string;
+
+ /** Data to display a step counter in the header */
+ stepCounter?: StepCounterParams;
+
+ /** Whether we should show an avatar */
+ shouldShowAvatarWithDisplay?: boolean;
+
+ /** Parent report, if provided it will override props.report for AvatarWithDisplay */
+ parentReport?: OnyxEntry;
+
+ /** Report, if we're showing the details for one and using AvatarWithDisplay */
+ report?: OnyxEntry;
+
+ /** The report's policy, if we're showing the details for a report and need info about it for AvatarWithDisplay */
+ policy?: OnyxEntry;
+
+ /** Policies, if we're showing the details for a report and need participant details for AvatarWithDisplay */
+ personalDetails?: OnyxCollection;
+
+ /** Single execution function to prevent concurrent navigation actions */
+ singleExecution?: (action: Action) => Action;
+
+ /** Whether we should navigate to report page when the route have a topMostReport */
+ shouldNavigateToTopMostReport?: boolean;
+
+ /** The fill color for the icon. Can be hex, rgb, rgba, or valid react-native named color such as 'red' or 'blue'. */
+ iconFill?: string;
+
+ /** Whether the popover menu should overlay the current view */
+ shouldOverlay?: boolean;
+
+ /** Whether we should enable detail page navigation */
+ shouldEnableDetailPageNavigation?: boolean;
+};
+
+export default HeaderWithBackButtonProps;
diff --git a/src/components/Icon/Illustrations.ts b/src/components/Icon/Illustrations.ts
index b0690211d6c5..1e574504001d 100644
--- a/src/components/Icon/Illustrations.ts
+++ b/src/components/Icon/Illustrations.ts
@@ -25,6 +25,7 @@ import SafeBlue from '@assets/images/product-illustrations/safe.svg';
import SmartScan from '@assets/images/product-illustrations/simple-illustration__smartscan.svg';
import TadaBlue from '@assets/images/product-illustrations/tada--blue.svg';
import TadaYellow from '@assets/images/product-illustrations/tada--yellow.svg';
+import TeleScope from '@assets/images/product-illustrations/telescope.svg';
import ToddBehindCloud from '@assets/images/product-illustrations/todd-behind-cloud.svg';
import BankArrow from '@assets/images/simple-illustrations/simple-illustration__bank-arrow.svg';
import BigRocket from '@assets/images/simple-illustrations/simple-illustration__bigrocket.svg';
@@ -110,4 +111,5 @@ export {
Hands,
HandEarth,
SmartScan,
+ TeleScope,
};
diff --git a/src/components/Icon/index.tsx b/src/components/Icon/index.tsx
index e71d21077eda..732fe90deae2 100644
--- a/src/components/Icon/index.tsx
+++ b/src/components/Icon/index.tsx
@@ -1,15 +1,14 @@
import {ImageContentFit} from 'expo-image';
-import React, {PureComponent} from 'react';
+import React from 'react';
import {StyleProp, View, ViewStyle} from 'react-native';
import ImageSVG from '@components/ImageSVG';
-import withStyleUtils, {WithStyleUtilsProps} from '@components/withStyleUtils';
-import withTheme, {WithThemeProps} from '@components/withTheme';
-import withThemeStyles, {type WithThemeStylesProps} from '@components/withThemeStyles';
+import useStyleUtils from '@hooks/useStyleUtils';
+import useThemeStyles from '@hooks/useThemeStyles';
import variables from '@styles/variables';
import IconAsset from '@src/types/utils/IconAsset';
import IconWrapperStyles from './IconWrapperStyles';
-type IconBaseProps = {
+type IconProps = {
/** The asset to render. */
src: IconAsset;
@@ -43,68 +42,65 @@ type IconBaseProps = {
/** Determines how the image should be resized to fit its container */
contentFit?: ImageContentFit;
};
-type IconProps = IconBaseProps & WithThemeStylesProps & WithThemeProps & WithStyleUtilsProps;
-
-// We must use a class component to create an animatable component with the Animated API
-// eslint-disable-next-line react/prefer-stateless-function
-class Icon extends PureComponent {
- // eslint-disable-next-line react/static-property-placement
- public static defaultProps: Partial = {
- width: variables.iconSizeNormal,
- height: variables.iconSizeNormal,
- fill: undefined,
- small: false,
- inline: false,
- additionalStyles: [],
- hovered: false,
- pressed: false,
- testID: '',
- contentFit: 'cover',
- };
-
- render() {
- const width = this.props.small ? variables.iconSizeSmall : this.props.width;
- const height = this.props.small ? variables.iconSizeSmall : this.props.height;
- const iconStyles = [this.props.StyleUtils.getWidthAndHeightStyle(width ?? 0, height), IconWrapperStyles, this.props.themeStyles.pAbsolute, this.props.additionalStyles];
-
- if (this.props.inline) {
- return (
-
-
-
-
-
- );
- }
+function Icon({
+ src,
+ width = variables.iconSizeNormal,
+ height = variables.iconSizeNormal,
+ fill = undefined,
+ small = false,
+ inline = false,
+ additionalStyles = [],
+ hovered = false,
+ pressed = false,
+ testID = '',
+ contentFit = 'cover',
+}: IconProps) {
+ const StyleUtils = useStyleUtils();
+ const styles = useThemeStyles();
+ const iconWidth = small ? variables.iconSizeSmall : width;
+ const iconHeight = small ? variables.iconSizeSmall : height;
+ const iconStyles = [StyleUtils.getWidthAndHeightStyle(width ?? 0, height), IconWrapperStyles, styles.pAbsolute, additionalStyles];
+
+ if (inline) {
return (
-
+
+
+
);
}
+
+ return (
+
+
+
+ );
}
-export default withTheme(withThemeStyles(withStyleUtils(Icon)));
+Icon.displayName = 'Icon';
+
+export default Icon;
diff --git a/src/components/InlineCodeBlock/WrappedText.tsx b/src/components/InlineCodeBlock/WrappedText.tsx
index f0bb4e3ffd53..1c66cef234ed 100644
--- a/src/components/InlineCodeBlock/WrappedText.tsx
+++ b/src/components/InlineCodeBlock/WrappedText.tsx
@@ -31,6 +31,13 @@ function getTextMatrix(text: string): string[][] {
return text.split('\n').map((row) => row.split(CONST.REGEX.SPACE_OR_EMOJI).filter((value) => value !== ''));
}
+/**
+ * Validates if the text contains any emoji
+ */
+function containsEmoji(text: string): boolean {
+ return CONST.REGEX.EMOJI.test(text);
+}
+
function WrappedText({children, wordStyles, textStyles}: WrappedTextProps) {
const styles = useThemeStyles();
@@ -53,7 +60,7 @@ function WrappedText({children, wordStyles, textStyles}: WrappedTextProps) {
style={styles.codeWordWrapper}
>
- {colText}
+ {colText}
))}
diff --git a/src/components/InvertedFlatList/BaseInvertedFlatList.js b/src/components/InvertedFlatList/BaseInvertedFlatList.js
deleted file mode 100644
index 4206d5086a9e..000000000000
--- a/src/components/InvertedFlatList/BaseInvertedFlatList.js
+++ /dev/null
@@ -1,38 +0,0 @@
-import PropTypes from 'prop-types';
-import React, {forwardRef} from 'react';
-import FlatList from '@components/FlatList';
-
-const AUTOSCROLL_TO_TOP_THRESHOLD = 128;
-
-const propTypes = {
- /** Same as FlatList can be any array of anything */
- // eslint-disable-next-line react/forbid-prop-types
- data: PropTypes.arrayOf(PropTypes.any),
-
- /** Same as FlatList although we wrap it in a measuring helper before passing to the actual FlatList component */
- renderItem: PropTypes.func.isRequired,
-};
-
-const defaultProps = {
- data: [],
-};
-
-const BaseInvertedFlatList = forwardRef((props, ref) => (
-
-));
-
-BaseInvertedFlatList.propTypes = propTypes;
-BaseInvertedFlatList.defaultProps = defaultProps;
-BaseInvertedFlatList.displayName = 'BaseInvertedFlatList';
-
-export default BaseInvertedFlatList;
diff --git a/src/components/InvertedFlatList/BaseInvertedFlatList.tsx b/src/components/InvertedFlatList/BaseInvertedFlatList.tsx
new file mode 100644
index 000000000000..08d990583572
--- /dev/null
+++ b/src/components/InvertedFlatList/BaseInvertedFlatList.tsx
@@ -0,0 +1,26 @@
+import React, {ForwardedRef, forwardRef} from 'react';
+import {FlatListProps} from 'react-native';
+import FlatList from '@components/FlatList';
+
+const AUTOSCROLL_TO_TOP_THRESHOLD = 128;
+const WINDOW_SIZE = 15;
+
+function BaseInvertedFlatList(props: FlatListProps, ref: ForwardedRef) {
+ return (
+
+ );
+}
+
+BaseInvertedFlatList.displayName = 'BaseInvertedFlatList';
+
+export default forwardRef(BaseInvertedFlatList);
diff --git a/src/components/InvertedFlatList/CellRendererComponent.js b/src/components/InvertedFlatList/CellRendererComponent.tsx
similarity index 62%
rename from src/components/InvertedFlatList/CellRendererComponent.js
rename to src/components/InvertedFlatList/CellRendererComponent.tsx
index 2b2d214000bf..252d47989064 100644
--- a/src/components/InvertedFlatList/CellRendererComponent.js
+++ b/src/components/InvertedFlatList/CellRendererComponent.tsx
@@ -1,20 +1,12 @@
-import PropTypes from 'prop-types';
import React from 'react';
-import {View} from 'react-native';
+import {StyleProp, View, ViewProps} from 'react-native';
-const propTypes = {
- /** Position index of the list item in a list view */
- index: PropTypes.number.isRequired,
-
- /** Styles that are passed to the component */
- style: PropTypes.oneOfType([PropTypes.arrayOf(PropTypes.object), PropTypes.object]),
-};
-
-const defaultProps = {
- style: {},
+type CellRendererComponentProps = ViewProps & {
+ index: number;
+ style?: StyleProp;
};
-function CellRendererComponent(props) {
+function CellRendererComponent(props: CellRendererComponentProps) {
return (
{
+function BaseInvertedFlatListWithRef(props: FlatListProps, ref: ForwardedRef) {
const styles = useThemeStyles();
return (
{
removeClippedSubviews={false}
/>
);
-});
+}
BaseInvertedFlatListWithRef.displayName = 'BaseInvertedFlatListWithRef';
-export default BaseInvertedFlatListWithRef;
+export default forwardRef(BaseInvertedFlatListWithRef);
diff --git a/src/components/InvertedFlatList/index.js b/src/components/InvertedFlatList/index.tsx
similarity index 53%
rename from src/components/InvertedFlatList/index.js
rename to src/components/InvertedFlatList/index.tsx
index 815b58ad8308..6871b010a385 100644
--- a/src/components/InvertedFlatList/index.js
+++ b/src/components/InvertedFlatList/index.tsx
@@ -1,58 +1,37 @@
-import PropTypes from 'prop-types';
-import React, {forwardRef, useEffect, useRef} from 'react';
-import {DeviceEventEmitter, FlatList} from 'react-native';
+import React, {ForwardedRef, forwardRef, useEffect, useRef} from 'react';
+import {DeviceEventEmitter, FlatList, FlatListProps, NativeScrollEvent, NativeSyntheticEvent} from 'react-native';
import CONST from '@src/CONST';
import BaseInvertedFlatList from './BaseInvertedFlatList';
-const propTypes = {
- /** Passed via forwardRef so we can access the FlatList ref */
- innerRef: PropTypes.shape({
- current: PropTypes.instanceOf(FlatList),
- }).isRequired,
-
- /** Any additional styles to apply */
- // eslint-disable-next-line react/forbid-prop-types
- contentContainerStyle: PropTypes.any,
-
- /** Same as for FlatList */
- onScroll: PropTypes.func,
-};
-
// This is adapted from https://codesandbox.io/s/react-native-dsyse
// It's a HACK alert since FlatList has inverted scrolling on web
-function InvertedFlatList(props) {
- const {innerRef, contentContainerStyle} = props;
-
- const lastScrollEvent = useRef(null);
- const scrollEndTimeout = useRef(null);
- const updateInProgress = useRef(false);
- const eventHandler = useRef(null);
+function InvertedFlatList({onScroll: onScrollProp = () => {}, contentContainerStyle, ...props}: FlatListProps, ref: ForwardedRef) {
+ const lastScrollEvent = useRef(null);
+ const scrollEndTimeout = useRef(null);
+ const updateInProgress = useRef(false);
useEffect(
() => () => {
- if (scrollEndTimeout.current) {
- clearTimeout(scrollEndTimeout.current);
- }
-
- if (eventHandler.current) {
- eventHandler.current.remove();
+ if (!scrollEndTimeout.current) {
+ return;
}
+ clearTimeout(scrollEndTimeout.current);
},
- [innerRef],
+ [ref],
);
/**
* Emits when the scrolling is in progress. Also,
* invokes the onScroll callback function from props.
*
- * @param {Event} event - The onScroll event from the FlatList
+ * @param event - The onScroll event from the FlatList
*/
- const onScroll = (event) => {
- props.onScroll(event);
+ const onScroll = (event: NativeSyntheticEvent) => {
+ onScrollProp(event);
if (!updateInProgress.current) {
updateInProgress.current = true;
- eventHandler.current = DeviceEventEmitter.emit(CONST.EVENTS.SCROLLING, true);
+ DeviceEventEmitter.emit(CONST.EVENTS.SCROLLING, true);
}
};
@@ -60,7 +39,7 @@ function InvertedFlatList(props) {
* Emits when the scrolling has ended.
*/
const onScrollEnd = () => {
- eventHandler.current = DeviceEventEmitter.emit(CONST.EVENTS.SCROLLING, false);
+ DeviceEventEmitter.emit(CONST.EVENTS.SCROLLING, false);
updateInProgress.current = false;
};
@@ -77,9 +56,8 @@ function InvertedFlatList(props) {
* This workaround is taken from below and refactored to fit our needs:
* https://github.com/necolas/react-native-web/issues/1021#issuecomment-984151185
*
- * @param {Event} event - The onScroll event from the FlatList
*/
- const handleScroll = (event) => {
+ const handleScroll = (event: NativeSyntheticEvent) => {
onScroll(event);
const timestamp = Date.now();
@@ -105,28 +83,13 @@ function InvertedFlatList(props) {
);
}
-InvertedFlatList.propTypes = propTypes;
-InvertedFlatList.defaultProps = {
- contentContainerStyle: {},
- onScroll: () => {},
-};
InvertedFlatList.displayName = 'InvertedFlatList';
-const InvertedFlatListWithRef = forwardRef((props, ref) => (
-
-));
-
-InvertedFlatListWithRef.displayName = 'InvertedFlatListWithRef';
-
-export default InvertedFlatListWithRef;
+export default forwardRef(InvertedFlatList);
diff --git a/src/components/LHNOptionsList/OptionRowLHN.js b/src/components/LHNOptionsList/OptionRowLHN.js
index f75e3390136a..fc4f05eefd22 100644
--- a/src/components/LHNOptionsList/OptionRowLHN.js
+++ b/src/components/LHNOptionsList/OptionRowLHN.js
@@ -135,7 +135,7 @@ function OptionRowLHN(props) {
props.reportID,
'0',
props.reportID,
- '',
+ undefined,
() => {},
() => setIsContextMenuActive(false),
false,
diff --git a/src/components/MentionSuggestions.tsx b/src/components/MentionSuggestions.tsx
index a20cdcff4e10..3e235a2fc88a 100644
--- a/src/components/MentionSuggestions.tsx
+++ b/src/components/MentionSuggestions.tsx
@@ -72,7 +72,7 @@ function MentionSuggestions({prefix, mentions, highlightedMentionIndex = 0, onSe
size={isIcon ? CONST.AVATAR_SIZE.MENTION_ICON : CONST.AVATAR_SIZE.SMALLER}
name={item.icons[0].name}
type={item.icons[0].type}
- fill={theme.success}
+ fill={isIcon ? theme.success : undefined}
fallbackIcon={item.icons[0].fallbackIcon}
/>
diff --git a/src/components/MoneyRequestConfirmationList.js b/src/components/MoneyRequestConfirmationList.js
index 63181e4aea87..b75f4e2df845 100755
--- a/src/components/MoneyRequestConfirmationList.js
+++ b/src/components/MoneyRequestConfirmationList.js
@@ -267,6 +267,9 @@ function MoneyRequestConfirmationList(props) {
return (props.hasSmartScanFailed && TransactionUtils.hasMissingSmartscanFields(transaction)) || (didConfirmSplit && TransactionUtils.areRequiredFieldsEmpty(transaction));
}, [props.isEditingSplitBill, props.hasSmartScanFailed, transaction, didConfirmSplit]);
+ const isMerchantEmpty = !props.iouMerchant || props.iouMerchant === CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT;
+ const shouldDisplayMerchantError = props.isPolicyExpenseChat && !props.isScanRequest && isMerchantEmpty;
+
useEffect(() => {
if (shouldDisplayFieldError && props.hasSmartScanFailed) {
setFormError('iou.receiptScanningFailed');
@@ -500,7 +503,7 @@ function MoneyRequestConfirmationList(props) {
}
const shouldShowSettlementButton = props.iouType === CONST.IOU.TYPE.SEND;
- const shouldDisableButton = selectedParticipants.length === 0;
+ const shouldDisableButton = selectedParticipants.length === 0 || shouldDisplayMerchantError;
const button = shouldShowSettlementButton ? (
)}
{shouldShowCategories && (
diff --git a/src/components/MoneyTemporaryForRefactorRequestConfirmationList.js b/src/components/MoneyTemporaryForRefactorRequestConfirmationList.js
index 20012bc90ef0..dab34e324ffa 100755
--- a/src/components/MoneyTemporaryForRefactorRequestConfirmationList.js
+++ b/src/components/MoneyTemporaryForRefactorRequestConfirmationList.js
@@ -97,6 +97,9 @@ const propTypes = {
/** Should the list be read only, and not editable? */
isReadOnly: PropTypes.bool,
+ /** Whether the money request is a scan request */
+ isScanRequest: PropTypes.bool,
+
/** Depending on expense report or personal IOU report, respective bank account route */
bankAccountRoute: PropTypes.string,
@@ -211,6 +214,7 @@ function MoneyTemporaryForRefactorRequestConfirmationList({
isEditingSplitBill,
isPolicyExpenseChat,
isReadOnly,
+ isScanRequest,
listStyles,
mileageRate,
onConfirm,
@@ -281,6 +285,8 @@ function MoneyTemporaryForRefactorRequestConfirmationList({
const [didConfirm, setDidConfirm] = useState(false);
const [didConfirmSplit, setDidConfirmSplit] = useState(false);
+ const [merchantError, setMerchantError] = useState(false);
+
const shouldDisplayFieldError = useMemo(() => {
if (!isEditingSplitBill) {
return false;
@@ -289,6 +295,21 @@ function MoneyTemporaryForRefactorRequestConfirmationList({
return (hasSmartScanFailed && TransactionUtils.hasMissingSmartscanFields(transaction)) || (didConfirmSplit && TransactionUtils.areRequiredFieldsEmpty(transaction));
}, [isEditingSplitBill, hasSmartScanFailed, transaction, didConfirmSplit]);
+ const isMerchantEmpty = !iouMerchant || iouMerchant === CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT;
+ const isMerchantRequired = isPolicyExpenseChat && !isScanRequest && shouldShowMerchant;
+
+ useEffect(() => {
+ if ((!isMerchantRequired && isMerchantEmpty) || !merchantError) {
+ return;
+ }
+ if (!isMerchantEmpty && merchantError) {
+ setMerchantError(false);
+ if (formError === 'iou.error.invalidMerchant') {
+ setFormError('');
+ }
+ }
+ }, [formError, isMerchantEmpty, merchantError, isMerchantRequired]);
+
useEffect(() => {
if (shouldDisplayFieldError && hasSmartScanFailed) {
setFormError('iou.receiptScanningFailed');
@@ -298,9 +319,13 @@ function MoneyTemporaryForRefactorRequestConfirmationList({
setFormError('iou.error.genericSmartscanFailureMessage');
return;
}
+ if (merchantError) {
+ setFormError('iou.error.invalidMerchant');
+ return;
+ }
// reset the form error whenever the screen gains or loses focus
setFormError('');
- }, [isFocused, transaction, shouldDisplayFieldError, hasSmartScanFailed, didConfirmSplit]);
+ }, [isFocused, transaction, shouldDisplayFieldError, hasSmartScanFailed, didConfirmSplit, isMerchantRequired, merchantError]);
useEffect(() => {
if (!shouldCalculateDistanceAmount) {
@@ -470,6 +495,10 @@ function MoneyTemporaryForRefactorRequestConfirmationList({
if (_.isEmpty(selectedParticipants)) {
return;
}
+ if ((isMerchantRequired && isMerchantEmpty) || (shouldDisplayFieldError && TransactionUtils.isMerchantMissing(transaction))) {
+ setMerchantError(true);
+ return;
+ }
if (iouType === CONST.IOU.TYPE.SEND) {
if (!paymentMethod) {
@@ -498,7 +527,21 @@ function MoneyTemporaryForRefactorRequestConfirmationList({
onConfirm(selectedParticipants);
}
},
- [selectedParticipants, onSendMoney, onConfirm, isEditingSplitBill, iouType, isDistanceRequest, isDistanceRequestWithoutRoute, iouCurrencyCode, iouAmount, transaction],
+ [
+ selectedParticipants,
+ isMerchantRequired,
+ isMerchantEmpty,
+ shouldDisplayFieldError,
+ transaction,
+ iouType,
+ onSendMoney,
+ iouCurrencyCode,
+ isDistanceRequest,
+ isDistanceRequestWithoutRoute,
+ iouAmount,
+ isEditingSplitBill,
+ onConfirm,
+ ],
);
const footerContent = useMemo(() => {
@@ -551,7 +594,7 @@ function MoneyTemporaryForRefactorRequestConfirmationList({
{button}
>
);
- }, [confirm, bankAccountRoute, iouCurrencyCode, iouType, isReadOnly, policyID, selectedParticipants, splitOrRequestOptions, translate, formError, styles.ph1, styles.mb2]);
+ }, [isReadOnly, iouType, selectedParticipants.length, confirm, bankAccountRoute, iouCurrencyCode, policyID, splitOrRequestOptions, formError, styles.ph1, styles.mb2, translate]);
const {image: receiptImage, thumbnail: receiptThumbnail} = receiptPath && receiptFilename ? ReceiptUtils.getThumbnailAndImageURIs(transaction, receiptPath, receiptFilename) : {};
return (
@@ -629,6 +672,26 @@ function MoneyTemporaryForRefactorRequestConfirmationList({
interactive={!isReadOnly}
numberOfLinesTitle={2}
/>
+ {isMerchantRequired && (
+ {
+ if (isEditingSplitBill) {
+ Navigation.navigate(ROUTES.EDIT_SPLIT_BILL.getRoute(reportID, reportActionID, CONST.EDIT_REQUEST_FIELD.MERCHANT));
+ return;
+ }
+ Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_MERCHANT.getRoute(iouType, transaction.transactionID, reportID, Navigation.getActiveRouteWithoutParams()));
+ }}
+ disabled={didConfirm}
+ interactive={!isReadOnly}
+ brickRoadIndicator={merchantError ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : ''}
+ error={merchantError ? translate('common.error.fieldRequired') : ''}
+ />
+ )}
{!shouldShowAllFields && (
@@ -680,10 +743,10 @@ function MoneyTemporaryForRefactorRequestConfirmationList({
interactive={!isReadOnly}
/>
)}
- {shouldShowMerchant && (
+ {!isMerchantRequired && shouldShowMerchant && (
)}
{shouldShowCategories && (
diff --git a/src/components/OfflineWithFeedback.tsx b/src/components/OfflineWithFeedback.tsx
index 5fcf1fe7442b..29fd0c2700dc 100644
--- a/src/components/OfflineWithFeedback.tsx
+++ b/src/components/OfflineWithFeedback.tsx
@@ -18,13 +18,13 @@ import MessagesRow from './MessagesRow';
type OfflineWithFeedbackProps = ChildrenProps & {
/** The type of action that's pending */
- pendingAction: OnyxCommon.PendingAction;
+ pendingAction?: OnyxCommon.PendingAction;
/** Determine whether to hide the component's children if deletion is pending */
shouldHideOnDelete?: boolean;
/** The errors to display */
- errors?: OnyxCommon.Errors;
+ errors?: OnyxCommon.Errors | null;
/** Whether we should show the error messages */
shouldShowErrorMessages?: boolean;
@@ -56,7 +56,7 @@ type OfflineWithFeedbackProps = ChildrenProps & {
type StrikethroughProps = Partial & {style: Array};
-function omitBy(obj: Record | undefined, predicate: (value: T) => boolean) {
+function omitBy(obj: Record | undefined | null, predicate: (value: T) => boolean) {
// eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-unused-vars
return Object.fromEntries(Object.entries(obj ?? {}).filter(([_, value]) => !predicate(value)));
}
diff --git a/src/components/OptionRow.js b/src/components/OptionRow.tsx
similarity index 57%
rename from src/components/OptionRow.js
rename to src/components/OptionRow.tsx
index c31ed7af1e90..5a2f6902c4c0 100644
--- a/src/components/OptionRow.js
+++ b/src/components/OptionRow.tsx
@@ -1,13 +1,13 @@
-import lodashGet from 'lodash/get';
-import PropTypes from 'prop-types';
+import lodashIsEqual from 'lodash/isEqual';
import React, {useEffect, useRef, useState} from 'react';
-import {InteractionManager, StyleSheet, View} from 'react-native';
-import _ from 'underscore';
+import {InteractionManager, StyleProp, StyleSheet, TextStyle, View, ViewStyle} from 'react-native';
+import useLocalize from '@hooks/useLocalize';
import useStyleUtils from '@hooks/useStyleUtils';
import useTheme from '@hooks/useTheme';
import useThemeStyles from '@hooks/useThemeStyles';
import * as OptionsListUtils from '@libs/OptionsListUtils';
import * as ReportUtils from '@libs/ReportUtils';
+import type {OptionData} from '@libs/ReportUtils';
import CONST from '@src/CONST';
import Button from './Button';
import DisplayNames from './DisplayNames';
@@ -16,159 +16,156 @@ import Icon from './Icon';
import * as Expensicons from './Icon/Expensicons';
import MultipleAvatars from './MultipleAvatars';
import OfflineWithFeedback from './OfflineWithFeedback';
-import optionPropTypes from './optionPropTypes';
import PressableWithFeedback from './Pressable/PressableWithFeedback';
import SelectCircle from './SelectCircle';
import SubscriptAvatar from './SubscriptAvatar';
import Text from './Text';
-import withLocalize, {withLocalizePropTypes} from './withLocalize';
-const propTypes = {
+type OptionRowProps = {
/** Style for hovered state */
- // eslint-disable-next-line react/forbid-prop-types
- hoverStyle: PropTypes.object,
+ hoverStyle?: StyleProp;
/** Option to allow the user to choose from can be type 'report' or 'user' */
- option: optionPropTypes.isRequired,
+ option: OptionData;
/** Whether this option is currently in focus so we can modify its style */
- optionIsFocused: PropTypes.bool,
+ optionIsFocused?: boolean;
/** A function that is called when an option is selected. Selected option is passed as a param */
- onSelectRow: PropTypes.func,
+ onSelectRow?: (option: OptionData, refElement: View | HTMLDivElement | null) => void | Promise;
/** Whether we should show the selected state */
- showSelectedState: PropTypes.bool,
+ showSelectedState?: boolean;
/** Whether to show a button pill instead of a tickbox */
- shouldShowSelectedStateAsButton: PropTypes.bool,
+ shouldShowSelectedStateAsButton?: boolean;
/** Text for button pill */
- selectedStateButtonText: PropTypes.string,
+ selectedStateButtonText?: string;
/** Callback to fire when the multiple selector (tickbox or button) is clicked */
- onSelectedStatePressed: PropTypes.func,
+ onSelectedStatePressed?: (option: OptionData) => void;
/** Whether we highlight selected option */
- highlightSelected: PropTypes.bool,
+ highlightSelected?: boolean;
/** Whether this item is selected */
- isSelected: PropTypes.bool,
+ isSelected?: boolean;
/** Display the text of the option in bold font style */
- boldStyle: PropTypes.bool,
+ boldStyle?: boolean;
/** Whether to show the title tooltip */
- showTitleTooltip: PropTypes.bool,
+ showTitleTooltip?: boolean;
/** Whether this option should be disabled */
- isDisabled: PropTypes.bool,
+ isDisabled?: boolean;
/** Whether to show a line separating options in list */
- shouldHaveOptionSeparator: PropTypes.bool,
+ shouldHaveOptionSeparator?: boolean;
/** Whether to remove the lateral padding and align the content with the margins */
- shouldDisableRowInnerPadding: PropTypes.bool,
+ shouldDisableRowInnerPadding?: boolean;
/** Whether to prevent default focusing on select */
- shouldPreventDefaultFocusOnSelectRow: PropTypes.bool,
+ shouldPreventDefaultFocusOnSelectRow?: boolean;
/** Whether to wrap large text up to 2 lines */
- isMultilineSupported: PropTypes.bool,
+ isMultilineSupported?: boolean;
- /** Key used internally by React */
- keyForList: PropTypes.string,
- style: PropTypes.oneOfType([PropTypes.arrayOf(PropTypes.object), PropTypes.object]),
+ /** Display name and alternate text style */
+ style?: StyleProp;
- ...withLocalizePropTypes,
-};
+ /** Hovered background color */
+ backgroundColor?: string;
-const defaultProps = {
- hoverStyle: undefined,
- showSelectedState: false,
- shouldShowSelectedStateAsButton: false,
- selectedStateButtonText: 'Select',
- onSelectedStatePressed: () => {},
- highlightSelected: false,
- isSelected: false,
- boldStyle: false,
- showTitleTooltip: false,
- onSelectRow: undefined,
- isDisabled: false,
- optionIsFocused: false,
- isMultilineSupported: false,
- style: null,
- shouldHaveOptionSeparator: false,
- shouldDisableRowInnerPadding: false,
- shouldPreventDefaultFocusOnSelectRow: false,
- keyForList: undefined,
+ /** Key used internally by React */
+ keyForList?: string;
};
-function OptionRow(props) {
+function OptionRow({
+ option,
+ onSelectRow,
+ style,
+ hoverStyle,
+ selectedStateButtonText,
+ keyForList,
+ isDisabled: isOptionDisabled = false,
+ isMultilineSupported = false,
+ shouldShowSelectedStateAsButton = false,
+ highlightSelected = false,
+ shouldHaveOptionSeparator = false,
+ showTitleTooltip = false,
+ optionIsFocused = false,
+ boldStyle = false,
+ onSelectedStatePressed = () => {},
+ backgroundColor,
+ isSelected = false,
+ showSelectedState = false,
+ shouldDisableRowInnerPadding = false,
+ shouldPreventDefaultFocusOnSelectRow = false,
+}: OptionRowProps) {
const theme = useTheme();
const styles = useThemeStyles();
const StyleUtils = useStyleUtils();
- const pressableRef = useRef(null);
- const [isDisabled, setIsDisabled] = useState(props.isDisabled);
+ const {translate} = useLocalize();
+ const pressableRef = useRef(null);
+ const [isDisabled, setIsDisabled] = useState(isOptionDisabled);
useEffect(() => {
- setIsDisabled(props.isDisabled);
- }, [props.isDisabled]);
+ setIsDisabled(isOptionDisabled);
+ }, [isOptionDisabled]);
- const text = lodashGet(props.option, 'text', '');
- const fullTitle = props.isMultilineSupported ? text.trimStart() : text;
+ const text = option.text ?? '';
+ const fullTitle = isMultilineSupported ? text.trimStart() : text;
const indentsLength = text.length - fullTitle.length;
const paddingLeft = Math.floor(indentsLength / CONST.INDENTS.length) * styles.ml3.marginLeft;
- const textStyle = props.optionIsFocused ? styles.sidebarLinkActiveText : styles.sidebarLinkText;
- const textUnreadStyle = props.boldStyle || props.option.boldStyle ? [textStyle, styles.sidebarLinkTextBold] : [textStyle];
- const displayNameStyle = StyleUtils.combineStyles(
+ const textStyle = optionIsFocused ? styles.sidebarLinkActiveText : styles.sidebarLinkText;
+ const textUnreadStyle = boldStyle || option.boldStyle ? [textStyle, styles.sidebarLinkTextBold] : [textStyle];
+ const displayNameStyle: StyleProp = [
styles.optionDisplayName,
textUnreadStyle,
- props.style,
+ style,
styles.pre,
isDisabled ? styles.optionRowDisabled : {},
- props.isMultilineSupported ? {paddingLeft} : {},
- );
- const alternateTextStyle = StyleUtils.combineStyles(
+ isMultilineSupported ? {paddingLeft} : {},
+ ];
+ const alternateTextStyle: StyleProp = [
textStyle,
styles.optionAlternateText,
styles.textLabelSupporting,
- props.style,
- lodashGet(props.option, 'alternateTextMaxLines', 1) === 1 ? styles.pre : styles.preWrap,
- );
+ style,
+ (option.alternateTextMaxLines ?? 1) === 1 ? styles.pre : styles.preWrap,
+ ];
const contentContainerStyles = [styles.flex1];
const sidebarInnerRowStyle = StyleSheet.flatten([styles.chatLinkRowPressable, styles.flexGrow1, styles.optionItemAvatarNameWrapper, styles.optionRow, styles.justifyContentCenter]);
- const hoveredBackgroundColor =
- (props.hoverStyle || styles.sidebarLinkHover) && (props.hoverStyle || styles.sidebarLinkHover).backgroundColor
- ? (props.hoverStyle || styles.sidebarLinkHover).backgroundColor
- : props.backgroundColor;
+ const flattenHoverStyle = StyleSheet.flatten(hoverStyle);
+ const hoveredStyle = hoverStyle ? flattenHoverStyle : styles.sidebarLinkHover;
+ const hoveredBackgroundColor = hoveredStyle?.backgroundColor ? (hoveredStyle.backgroundColor as string) : backgroundColor;
const focusedBackgroundColor = styles.sidebarLinkActive.backgroundColor;
- const isMultipleParticipant = lodashGet(props.option, 'participantsList.length', 0) > 1;
+ const isMultipleParticipant = (option.participantsList?.length ?? 0) > 1;
// We only create tooltips for the first 10 users or so since some reports have hundreds of users, causing performance to degrade.
- const displayNamesWithTooltips = ReportUtils.getDisplayNamesWithTooltips(
- (props.option.participantsList || (props.option.accountID ? [props.option] : [])).slice(0, 10),
- isMultipleParticipant,
- );
+ const displayNamesWithTooltips = ReportUtils.getDisplayNamesWithTooltips((option.participantsList ?? (option.accountID ? [option] : [])).slice(0, 10), isMultipleParticipant);
let subscriptColor = theme.appBG;
- if (props.optionIsFocused) {
+ if (optionIsFocused) {
subscriptColor = focusedBackgroundColor;
}
return (
{(hovered) => (
(pressableRef.current = el)}
+ nativeID={keyForList}
+ ref={pressableRef}
onPress={(e) => {
- if (!props.onSelectRow) {
+ if (!onSelectRow) {
return;
}
@@ -176,12 +173,13 @@ function OptionRow(props) {
if (e) {
e.preventDefault();
}
- let result = props.onSelectRow(props.option, pressableRef.current);
+ let result = onSelectRow(option, pressableRef.current);
if (!(result instanceof Promise)) {
result = Promise.resolve();
}
+
InteractionManager.runAfterInteractions(() => {
- result.finally(() => setIsDisabled(props.isDisabled));
+ result?.finally(() => setIsDisabled(isOptionDisabled));
});
}}
disabled={isDisabled}
@@ -190,68 +188,64 @@ function OptionRow(props) {
styles.alignItemsCenter,
styles.justifyContentBetween,
styles.sidebarLink,
- !props.isDisabled && styles.cursorPointer,
- props.shouldDisableRowInnerPadding ? null : styles.sidebarLinkInner,
- props.optionIsFocused ? styles.sidebarLinkActive : null,
- props.shouldHaveOptionSeparator && styles.borderTop,
- !props.onSelectRow && !props.isDisabled ? styles.cursorDefault : null,
+ !isOptionDisabled && styles.cursorPointer,
+ shouldDisableRowInnerPadding ? null : styles.sidebarLinkInner,
+ optionIsFocused ? styles.sidebarLinkActive : null,
+ shouldHaveOptionSeparator && styles.borderTop,
+ !onSelectRow && !isOptionDisabled ? styles.cursorDefault : null,
]}
- accessibilityLabel={props.option.text}
+ accessibilityLabel={option.text}
role={CONST.ROLE.BUTTON}
hoverDimmingValue={1}
- hoverStyle={!props.optionIsFocused ? props.hoverStyle || styles.sidebarLinkHover : undefined}
- needsOffscreenAlphaCompositing={lodashGet(props.option, 'icons.length', 0) >= 2}
- onMouseDown={props.shouldPreventDefaultFocusOnSelectRow ? (e) => e.preventDefault() : undefined}
+ hoverStyle={!optionIsFocused ? hoverStyle ?? styles.sidebarLinkHover : undefined}
+ needsOffscreenAlphaCompositing={(option.icons?.length ?? 0) >= 2}
+ onMouseDown={shouldPreventDefaultFocusOnSelectRow ? (event) => event.preventDefault() : undefined}
>
- {!_.isEmpty(props.option.icons) &&
- (props.option.shouldShowSubscript ? (
+ {!!option.icons?.length &&
+ (option.shouldShowSubscript ? (
) : (
))}
- {props.option.alternateText ? (
+ {option.alternateText ? (
- {props.option.alternateText}
+ {option.alternateText}
) : null}
- {props.option.descriptiveText ? (
+ {option.descriptiveText ? (
- {props.option.descriptiveText}
+ {option.descriptiveText}
) : null}
- {props.option.brickRoadIndicator === CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR && (
+ {option.brickRoadIndicator === CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR && (
)}
- {props.showSelectedState && (
+ {showSelectedState && (
<>
- {props.shouldShowSelectedStateAsButton && !props.isSelected ? (
+ {shouldShowSelectedStateAsButton && !isSelected ? (
);
}
-
-PopoverWithMeasuredContent.propTypes = propTypes;
-PopoverWithMeasuredContent.defaultProps = defaultProps;
PopoverWithMeasuredContent.displayName = 'PopoverWithMeasuredContent';
export default React.memo(PopoverWithMeasuredContent, (prevProps, nextProps) => {
if (prevProps.isVisible === nextProps.isVisible && nextProps.isVisible === false) {
return true;
}
- return _.isEqual(prevProps, nextProps);
+ return isEqual(prevProps, nextProps);
});
diff --git a/src/components/Pressable/GenericPressable/types.ts b/src/components/Pressable/GenericPressable/types.ts
index bff5f651ac9f..cdb9a8624114 100644
--- a/src/components/Pressable/GenericPressable/types.ts
+++ b/src/components/Pressable/GenericPressable/types.ts
@@ -40,7 +40,7 @@ type PressableProps = RNPressableProps &
/**
* onPress callback
*/
- onPress: (event?: GestureResponderEvent | KeyboardEvent) => void;
+ onPress: (event?: GestureResponderEvent | KeyboardEvent) => void | Promise;
/**
* Specifies keyboard shortcut to trigger onPressHandler
diff --git a/src/components/RNTextInput.tsx b/src/components/RNTextInput.tsx
index ff812e7c799b..40dfc109ba6b 100644
--- a/src/components/RNTextInput.tsx
+++ b/src/components/RNTextInput.tsx
@@ -1,13 +1,13 @@
-import React, {ForwardedRef} from 'react';
+import React, {Component, ForwardedRef} from 'react';
// eslint-disable-next-line no-restricted-imports
import {TextInput, TextInputProps} from 'react-native';
import Animated, {AnimatedProps} from 'react-native-reanimated';
import useTheme from '@hooks/useTheme';
+type AnimatedTextInputRef = Component>;
// Convert the underlying TextInput into an Animated component so that we can take an animated ref and pass it to a worklet
const AnimatedTextInput = Animated.createAnimatedComponent(TextInput);
-// eslint-disable-next-line @typescript-eslint/no-explicit-any
function RNTextInputWithRef(props: TextInputProps, ref: ForwardedRef>>) {
const theme = useTheme();
@@ -31,3 +31,5 @@ function RNTextInputWithRef(props: TextInputProps, ref: ForwardedRef
{},
+ shouldDelayFocus = false,
+ submitOnEnter = false,
+ multiline = false,
+ shouldInterceptSwipe = false,
+ autoCorrect = true,
+ prefixCharacter = '',
+ inputID,
+ ...props
+ }: BaseTextInputProps,
+ ref: BaseTextInputRef,
+) {
+ const inputProps = {shouldSaveDraft: false, shouldUseDefaultValue: false, ...props};
const theme = useTheme();
const styles = useThemeStyles();
const StyleUtils = useStyleUtils();
- const initialValue = props.value || props.defaultValue || '';
- const initialActiveLabel = props.forceActiveLabel || initialValue.length > 0 || Boolean(props.prefixCharacter);
- const isMultiline = props.multiline || props.autoGrowHeight;
+ const {translate} = useLocalize();
+
+ const {hasError = false} = inputProps;
+ // Disabling this line for safeness as nullish coalescing works only if the value is undefined or null
+ // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
+ const initialValue = value || defaultValue || '';
+ const initialActiveLabel = !!forceActiveLabel || initialValue.length > 0 || !!prefixCharacter;
+ const isMultiline = multiline || autoGrowHeight;
const [isFocused, setIsFocused] = useState(false);
- const [passwordHidden, setPasswordHidden] = useState(props.secureTextEntry);
+ const [passwordHidden, setPasswordHidden] = useState(inputProps.secureTextEntry);
const [textInputWidth, setTextInputWidth] = useState(0);
const [textInputHeight, setTextInputHeight] = useState(0);
- const [height, setHeight] = useState(variables.componentSizeLarge);
- const [width, setWidth] = useState();
+ const [height, setHeight] = useState(variables.componentSizeLarge);
+ const [width, setWidth] = useState(null);
const labelScale = useRef(new Animated.Value(initialActiveLabel ? styleConst.ACTIVE_LABEL_SCALE : styleConst.INACTIVE_LABEL_SCALE)).current;
const labelTranslateY = useRef(new Animated.Value(initialActiveLabel ? styleConst.ACTIVE_LABEL_TRANSLATE_Y : styleConst.INACTIVE_LABEL_TRANSLATE_Y)).current;
-
- const input = useRef(null);
+ const input = useRef(null);
const isLabelActive = useRef(initialActiveLabel);
// AutoFocus which only works on mount:
useEffect(() => {
// We are manually managing focus to prevent this issue: https://github.com/Expensify/App/issues/4514
- if (!props.autoFocus || !input.current) {
+ if (!autoFocus || !input.current) {
return;
}
- if (props.shouldDelayFocus) {
- const focusTimeout = setTimeout(() => input.current.focus(), CONST.ANIMATED_TRANSITION);
+ if (shouldDelayFocus) {
+ const focusTimeout = setTimeout(() => input.current?.focus(), CONST.ANIMATED_TRANSITION);
return () => clearTimeout(focusTimeout);
}
input.current.focus();
@@ -60,16 +99,14 @@ function BaseTextInput(props) {
}, []);
const animateLabel = useCallback(
- (translateY, scale) => {
+ (translateY: number, scale: number) => {
Animated.parallel([
Animated.spring(labelTranslateY, {
toValue: translateY,
- duration: styleConst.LABEL_ANIMATION_DURATION,
useNativeDriver,
}),
Animated.spring(labelScale, {
toValue: scale,
- duration: styleConst.LABEL_ANIMATION_DURATION,
useNativeDriver,
}),
]).start();
@@ -78,72 +115,66 @@ function BaseTextInput(props) {
);
const activateLabel = useCallback(() => {
- const value = props.value || '';
+ const inputValue = value ?? '';
- if (value.length < 0 || isLabelActive.current) {
+ if (inputValue.length < 0 || isLabelActive.current) {
return;
}
animateLabel(styleConst.ACTIVE_LABEL_TRANSLATE_Y, styleConst.ACTIVE_LABEL_SCALE);
isLabelActive.current = true;
- }, [animateLabel, props.value]);
+ }, [animateLabel, value]);
const deactivateLabel = useCallback(() => {
- const value = props.value || '';
+ const inputValue = value ?? '';
- if (props.forceActiveLabel || value.length !== 0 || props.prefixCharacter) {
+ if (!!forceActiveLabel || inputValue.length !== 0 || prefixCharacter) {
return;
}
animateLabel(styleConst.INACTIVE_LABEL_TRANSLATE_Y, styleConst.INACTIVE_LABEL_SCALE);
isLabelActive.current = false;
- }, [animateLabel, props.forceActiveLabel, props.prefixCharacter, props.value]);
+ }, [animateLabel, forceActiveLabel, prefixCharacter, value]);
- const onFocus = (event) => {
- if (props.onFocus) {
- props.onFocus(event);
- }
+ const onFocus = (event: NativeSyntheticEvent) => {
+ inputProps.onFocus?.(event);
setIsFocused(true);
};
- const onBlur = (event) => {
- if (props.onBlur) {
- props.onBlur(event);
- }
+ const onBlur = (event: NativeSyntheticEvent) => {
+ inputProps.onBlur?.(event);
setIsFocused(false);
};
- const onPress = (event) => {
- if (props.disabled) {
+ const onPress = (event?: GestureResponderEvent | KeyboardEvent) => {
+ if (!!inputProps.disabled || !event) {
return;
}
- if (props.onPress) {
- props.onPress(event);
- }
+ inputProps.onPress?.(event);
- if (!event.isDefaultPrevented()) {
- input.current.focus();
+ if ('isDefaultPrevented' in event && !event?.isDefaultPrevented()) {
+ input.current?.focus();
}
};
const onLayout = useCallback(
- (event) => {
- if (!props.autoGrowHeight && props.multiline) {
+ (event: LayoutChangeEvent) => {
+ if (!autoGrowHeight && multiline) {
return;
}
const layout = event.nativeEvent.layout;
- setWidth((prevWidth) => (props.autoGrowHeight ? layout.width : prevWidth));
- setHeight((prevHeight) => (!props.multiline ? layout.height : prevHeight));
+ setWidth((prevWidth: number | null) => (autoGrowHeight ? layout.width : prevWidth));
+ setHeight((prevHeight: number) => (!multiline ? layout.height : prevHeight));
},
- [props.autoGrowHeight, props.multiline],
+ [autoGrowHeight, multiline],
);
// The ref is needed when the component is uncontrolled and we don't have a value prop
const hasValueRef = useRef(initialValue.length > 0);
- const inputValue = props.value || '';
+ const inputValue = value ?? '';
const hasValue = inputValue.length > 0 || hasValueRef.current;
// Activate or deactivate the label when either focus changes, or for controlled
@@ -165,31 +196,28 @@ function BaseTextInput(props) {
// When the value prop gets cleared externally, we need to keep the ref in sync:
useEffect(() => {
// Return early when component uncontrolled, or we still have a value
- if (props.value === undefined || !_.isEmpty(props.value)) {
+ if (value === undefined || value) {
return;
}
hasValueRef.current = false;
- }, [props.value]);
+ }, [value]);
/**
* Set Value & activateLabel
- *
- * @param {String} val
- * @memberof BaseTextInput
*/
- const setValue = (val) => {
- const value = isMultiline ? val : val.replace(/\n/g, ' ');
+ const setValue = (newValue: string) => {
+ const formattedValue = isMultiline ? newValue : newValue.replace(/\n/g, ' ');
- if (props.onInputChange) {
- props.onInputChange(value);
- }
+ onInputChange?.(formattedValue);
- Str.result(props.onChangeText, value);
+ if (inputProps.onChangeText) {
+ Str.result(inputProps.onChangeText, formattedValue);
+ }
- if (value && value.length > 0) {
+ if (formattedValue && formattedValue.length > 0) {
hasValueRef.current = true;
- // When the component is uncontrolled, we need to manually activate the label
- if (props.value === undefined) {
+ // When the component is uncontrolled, we need to manually activate the label:
+ if (value === undefined) {
activateLabel();
}
} else {
@@ -207,7 +235,7 @@ function BaseTextInput(props) {
// Some characters are wider than the others when rendered, e.g. '@' vs '#'. Chosen font-family and font-size
// also have an impact on the width of the character, but as long as there's only one font-family and one font-size,
// this method will produce reliable results.
- const getCharacterPadding = (prefix) => {
+ const getCharacterPadding = (prefix: string): number => {
switch (prefix) {
case CONST.POLICY.ROOM_PREFIX:
return 10;
@@ -216,20 +244,18 @@ function BaseTextInput(props) {
}
};
- // eslint-disable-next-line react/forbid-foreign-prop-types
- const inputProps = _.omit(props, _.keys(baseTextInputPropTypes.propTypes));
- const hasLabel = Boolean(props.label.length);
- const isReadOnly = _.isUndefined(props.readOnly) ? props.disabled : props.readOnly;
- const inputHelpText = props.errorText || props.hint;
- const placeholder = props.prefixCharacter || isFocused || !hasLabel || (hasLabel && props.forceActiveLabel) ? props.placeholder : null;
- const maxHeight = StyleSheet.flatten(props.containerStyles).maxHeight;
- const textInputContainerStyles = StyleSheet.flatten([
+ const hasLabel = Boolean(label?.length);
+ const isReadOnly = inputProps.readOnly ?? inputProps.disabled;
+ const inputHelpText = errorText || hint;
+ const placeholderValue = !!prefixCharacter || isFocused || !hasLabel || (hasLabel && forceActiveLabel) ? placeholder : undefined;
+ const maxHeight = StyleSheet.flatten(containerStyles)?.maxHeight;
+ const newTextInputContainerStyles: StyleProp = StyleSheet.flatten([
styles.textInputContainer,
- ...props.textInputContainerStyles,
- props.autoGrow && StyleUtils.getWidthStyle(textInputWidth),
- !props.hideFocusedState && isFocused && styles.borderColorFocus,
- (props.hasError || props.errorText) && styles.borderColorDanger,
- props.autoGrowHeight && {scrollPaddingTop: 2 * maxHeight},
+ textInputContainerStyles,
+ autoGrow && StyleUtils.getWidthStyle(textInputWidth),
+ !hideFocusedState && isFocused && styles.borderColorFocus,
+ (!!hasError || !!errorText) && styles.borderColorDanger,
+ autoGrowHeight && {scrollPaddingTop: typeof maxHeight === 'number' ? 2 * maxHeight : undefined},
]);
return (
@@ -237,16 +263,16 @@ function BaseTextInput(props) {
{hasLabel ? (
@@ -267,88 +293,93 @@ function BaseTextInput(props) {
{isMultiline && }
>
) : null}
- {Boolean(props.prefixCharacter) && (
+ {!!prefixCharacter && (
- {props.prefixCharacter}
+ {prefixCharacter}
)}
{
- if (typeof props.innerRef === 'function') {
- props.innerRef(ref);
- } else if (props.innerRef && _.has(props.innerRef, 'current')) {
+ ref={(element) => {
+ if (typeof ref === 'function') {
+ ref(element);
+ } else if (ref && 'current' in ref) {
// eslint-disable-next-line no-param-reassign
- props.innerRef.current = ref;
+ ref.current = element;
}
- input.current = ref;
+
+ (input.current as AnimatedTextInputRef | null) = element;
}}
// eslint-disable-next-line
{...inputProps}
- autoCorrect={props.secureTextEntry ? false : props.autoCorrect}
- placeholder={placeholder}
+ autoCorrect={inputProps.secureTextEntry ? false : autoCorrect}
+ placeholder={placeholderValue}
placeholderTextColor={theme.placeholderText}
underlineColorAndroid="transparent"
style={[
styles.flex1,
styles.w100,
- props.inputStyle,
+ inputStyle,
(!hasLabel || isMultiline) && styles.pv0,
- props.prefixCharacter && StyleUtils.getPaddingLeft(getCharacterPadding(props.prefixCharacter) + styles.pl1.paddingLeft),
- props.secureTextEntry && styles.secureInput,
+ !!prefixCharacter && StyleUtils.getPaddingLeft(getCharacterPadding(prefixCharacter) + styles.pl1.paddingLeft),
+ inputProps.secureTextEntry && styles.secureInput,
!isMultiline && {height, lineHeight: undefined},
// Stop scrollbar flashing when breaking lines with autoGrowHeight enabled.
- ...(props.autoGrowHeight ? [StyleUtils.getAutoGrowHeightInputStyle(textInputHeight, maxHeight), styles.verticalAlignTop] : []),
+ ...(autoGrowHeight
+ ? [StyleUtils.getAutoGrowHeightInputStyle(textInputHeight, typeof maxHeight === 'number' ? maxHeight : 0), styles.verticalAlignTop]
+ : []),
// Add disabled color theme when field is not editable.
- props.disabled && styles.textInputDisabled,
+ inputProps.disabled && styles.textInputDisabled,
styles.pointerEventsAuto,
]}
multiline={isMultiline}
- maxLength={props.maxLength}
+ maxLength={maxLength}
onFocus={onFocus}
onBlur={onBlur}
onChangeText={setValue}
secureTextEntry={passwordHidden}
- onPressOut={props.onPress}
- showSoftInputOnFocus={!props.disableKeyboard}
- keyboardType={getSecureEntryKeyboardType(props.keyboardType, props.secureTextEntry, passwordHidden)}
- inputMode={!props.disableKeyboard ? props.inputMode : CONST.INPUT_MODE.NONE}
- value={props.value}
- selection={props.selection}
+ onPressOut={inputProps.onPress}
+ showSoftInputOnFocus={!disableKeyboard}
+ keyboardType={getSecureEntryKeyboardType(inputProps.keyboardType, inputProps.secureTextEntry ?? false, passwordHidden ?? false)}
+ inputMode={!disableKeyboard ? inputProps.inputMode : CONST.INPUT_MODE.NONE}
+ value={value}
+ selection={inputProps.selection}
readOnly={isReadOnly}
- defaultValue={props.defaultValue}
+ defaultValue={defaultValue}
// FormSubmit Enter key handler does not have access to direct props.
// `dataset.submitOnEnter` is used to indicate that pressing Enter on this input should call the submit callback.
- dataSet={{submitOnEnter: isMultiline && props.submitOnEnter}}
+ dataSet={{submitOnEnter: isMultiline && submitOnEnter}}
/>
- {props.isLoading && (
+ {inputProps.isLoading && (
)}
- {Boolean(props.secureTextEntry) && (
+ {!!inputProps.secureTextEntry && (
e.preventDefault()}
- accessibilityLabel={props.translate('common.visible')}
+ onMouseDown={(event) => {
+ event.preventDefault();
+ }}
+ accessibilityLabel={translate('common.visible')}
>
)}
- {!props.secureTextEntry && Boolean(props.icon) && (
+ {!inputProps.secureTextEntry && icon && (
@@ -367,32 +398,37 @@ function BaseTextInput(props) {
- {!_.isEmpty(inputHelpText) && (
+ {!!inputHelpText && (
)}
{/*
- Text input component doesn't support auto grow by default.
- We're using a hidden text input to achieve that.
- This text view is used to calculate width or height of the input value given textStyle in this component.
- This Text component is intentionally positioned out of the screen.
- */}
- {(props.autoGrow || props.autoGrowHeight) && (
+ Text input component doesn't support auto grow by default.
+ We're using a hidden text input to achieve that.
+ This text view is used to calculate width or height of the input value given textStyle in this component.
+ This Text component is intentionally positioned out of the screen.
+ */}
+ {(!!autoGrow || autoGrowHeight) && (
// Add +2 to width on Safari browsers so that text is not cut off due to the cursor or when changing the value
// https://github.com/Expensify/App/issues/8158
// https://github.com/Expensify/App/issues/26628
{
setTextInputWidth(e.nativeEvent.layout.width);
setTextInputHeight(e.nativeEvent.layout.height);
}}
>
{/* \u200B added to solve the issue of not expanding the text input enough when the value ends with '\n' (https://github.com/Expensify/App/issues/21271) */}
- {props.value ? `${props.value}${props.value.endsWith('\n') ? '\u200B' : ''}` : props.placeholder}
+ {value ? `${value}${value.endsWith('\n') ? '\u200B' : ''}` : placeholder}
)}
>
@@ -400,7 +436,5 @@ function BaseTextInput(props) {
}
BaseTextInput.displayName = 'BaseTextInput';
-BaseTextInput.propTypes = baseTextInputPropTypes.propTypes;
-BaseTextInput.defaultProps = baseTextInputPropTypes.defaultProps;
-export default withLocalize(BaseTextInput);
+export default forwardRef(BaseTextInput);
diff --git a/src/components/TextInput/BaseTextInput/index.js b/src/components/TextInput/BaseTextInput/index.tsx
similarity index 59%
rename from src/components/TextInput/BaseTextInput/index.js
rename to src/components/TextInput/BaseTextInput/index.tsx
index 67776d6c7e91..a66df0496a1a 100644
--- a/src/components/TextInput/BaseTextInput/index.js
+++ b/src/components/TextInput/BaseTextInput/index.tsx
@@ -1,18 +1,18 @@
import Str from 'expensify-common/lib/str';
-import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react';
-import {ActivityIndicator, Animated, StyleSheet, View} from 'react-native';
-import _ from 'underscore';
+import React, {forwardRef, useCallback, useEffect, useMemo, useRef, useState} from 'react';
+import {ActivityIndicator, Animated, StyleSheet, TextInput, View} from 'react-native';
+import type {GestureResponderEvent, LayoutChangeEvent, NativeSyntheticEvent, StyleProp, TextInputFocusEventData, ViewStyle} from 'react-native';
import Checkbox from '@components/Checkbox';
import FormHelpMessage from '@components/FormHelpMessage';
import Icon from '@components/Icon';
import * as Expensicons from '@components/Icon/Expensicons';
import PressableWithoutFeedback from '@components/Pressable/PressableWithoutFeedback';
-import RNTextInput from '@components/RNTextInput';
+import RNTextInput, {AnimatedTextInputRef} from '@components/RNTextInput';
import SwipeInterceptPanResponder from '@components/SwipeInterceptPanResponder';
import Text from '@components/Text';
import * as styleConst from '@components/TextInput/styleConst';
import TextInputLabel from '@components/TextInput/TextInputLabel';
-import withLocalize from '@components/withLocalize';
+import useLocalize from '@hooks/useLocalize';
import useStyleUtils from '@hooks/useStyleUtils';
import useTheme from '@hooks/useTheme';
import useThemeStyles from '@hooks/useThemeStyles';
@@ -21,36 +21,74 @@ import isInputAutoFilled from '@libs/isInputAutoFilled';
import useNativeDriver from '@libs/useNativeDriver';
import variables from '@styles/variables';
import CONST from '@src/CONST';
-import * as baseTextInputPropTypes from './baseTextInputPropTypes';
-
-function BaseTextInput(props) {
+import type {BaseTextInputProps, BaseTextInputRef} from './types';
+
+function BaseTextInput(
+ {
+ label = '',
+ /**
+ * To be able to function as either controlled or uncontrolled component we should not
+ * assign a default prop value for `value` or `defaultValue` props
+ */
+ value = undefined,
+ defaultValue = undefined,
+ placeholder = '',
+ errorText = '',
+ icon = null,
+ textInputContainerStyles,
+ containerStyles,
+ inputStyle,
+ forceActiveLabel = false,
+ autoFocus = false,
+ disableKeyboard = false,
+ autoGrow = false,
+ autoGrowHeight = false,
+ hideFocusedState = false,
+ maxLength = undefined,
+ hint = '',
+ onInputChange = () => {},
+ shouldDelayFocus = false,
+ submitOnEnter = false,
+ multiline = false,
+ shouldInterceptSwipe = false,
+ autoCorrect = true,
+ prefixCharacter = '',
+ inputID,
+ ...inputProps
+ }: BaseTextInputProps,
+ ref: BaseTextInputRef,
+) {
const theme = useTheme();
const styles = useThemeStyles();
+ const {hasError = false} = inputProps;
const StyleUtils = useStyleUtils();
- const initialValue = props.value || props.defaultValue || '';
- const initialActiveLabel = props.forceActiveLabel || initialValue.length > 0 || Boolean(props.prefixCharacter);
+ const {translate} = useLocalize();
+
+ // Disabling this line for saftiness as nullish coalescing works only if value is undefined or null
+ // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
+ const initialValue = value || defaultValue || '';
+ const initialActiveLabel = !!forceActiveLabel || initialValue.length > 0 || !!prefixCharacter;
const [isFocused, setIsFocused] = useState(false);
- const [passwordHidden, setPasswordHidden] = useState(props.secureTextEntry);
+ const [passwordHidden, setPasswordHidden] = useState(inputProps.secureTextEntry);
const [textInputWidth, setTextInputWidth] = useState(0);
const [textInputHeight, setTextInputHeight] = useState(0);
- const [height, setHeight] = useState(variables.componentSizeLarge);
- const [width, setWidth] = useState();
+ const [height, setHeight] = useState(variables.componentSizeLarge);
+ const [width, setWidth] = useState(null);
const labelScale = useRef(new Animated.Value(initialActiveLabel ? styleConst.ACTIVE_LABEL_SCALE : styleConst.INACTIVE_LABEL_SCALE)).current;
const labelTranslateY = useRef(new Animated.Value(initialActiveLabel ? styleConst.ACTIVE_LABEL_TRANSLATE_Y : styleConst.INACTIVE_LABEL_TRANSLATE_Y)).current;
-
- const input = useRef(null);
+ const input = useRef(null);
const isLabelActive = useRef(initialActiveLabel);
// AutoFocus which only works on mount:
useEffect(() => {
// We are manually managing focus to prevent this issue: https://github.com/Expensify/App/issues/4514
- if (!props.autoFocus || !input.current) {
+ if (!autoFocus || !input.current) {
return;
}
- if (props.shouldDelayFocus) {
- const focusTimeout = setTimeout(() => input.current.focus(), CONST.ANIMATED_TRANSITION);
+ if (shouldDelayFocus) {
+ const focusTimeout = setTimeout(() => input?.current?.focus(), CONST.ANIMATED_TRANSITION);
return () => clearTimeout(focusTimeout);
}
input.current.focus();
@@ -59,16 +97,14 @@ function BaseTextInput(props) {
}, []);
const animateLabel = useCallback(
- (translateY, scale) => {
+ (translateY: number, scale: number) => {
Animated.parallel([
Animated.spring(labelTranslateY, {
toValue: translateY,
- duration: styleConst.LABEL_ANIMATION_DURATION,
useNativeDriver,
}),
Animated.spring(labelScale, {
toValue: scale,
- duration: styleConst.LABEL_ANIMATION_DURATION,
useNativeDriver,
}),
]).start();
@@ -77,72 +113,66 @@ function BaseTextInput(props) {
);
const activateLabel = useCallback(() => {
- const value = props.value || '';
+ const newValue = value ?? '';
- if (value.length < 0 || isLabelActive.current) {
+ if (newValue.length < 0 || isLabelActive.current) {
return;
}
animateLabel(styleConst.ACTIVE_LABEL_TRANSLATE_Y, styleConst.ACTIVE_LABEL_SCALE);
isLabelActive.current = true;
- }, [animateLabel, props.value]);
+ }, [animateLabel, value]);
const deactivateLabel = useCallback(() => {
- const value = props.value || '';
+ const newValue = value ?? '';
- if (props.forceActiveLabel || value.length !== 0 || props.prefixCharacter) {
+ if (!!forceActiveLabel || newValue.length !== 0 || prefixCharacter) {
return;
}
animateLabel(styleConst.INACTIVE_LABEL_TRANSLATE_Y, styleConst.INACTIVE_LABEL_SCALE);
isLabelActive.current = false;
- }, [animateLabel, props.forceActiveLabel, props.prefixCharacter, props.value]);
+ }, [animateLabel, forceActiveLabel, prefixCharacter, value]);
- const onFocus = (event) => {
- if (props.onFocus) {
- props.onFocus(event);
- }
+ const onFocus = (event: NativeSyntheticEvent) => {
+ inputProps.onFocus?.(event);
setIsFocused(true);
};
- const onBlur = (event) => {
- if (props.onBlur) {
- props.onBlur(event);
- }
+ const onBlur = (event: NativeSyntheticEvent) => {
+ inputProps.onBlur?.(event);
setIsFocused(false);
};
- const onPress = (event) => {
- if (props.disabled) {
+ const onPress = (event?: GestureResponderEvent | KeyboardEvent) => {
+ if (!!inputProps.disabled || !event) {
return;
}
- if (props.onPress) {
- props.onPress(event);
- }
+ inputProps.onPress?.(event);
- if (!event.isDefaultPrevented()) {
- input.current.focus();
+ if ('isDefaultPrevented' in event && !event?.isDefaultPrevented()) {
+ input.current?.focus();
}
};
const onLayout = useCallback(
- (event) => {
- if (!props.autoGrowHeight && props.multiline) {
+ (event: LayoutChangeEvent) => {
+ if (!autoGrowHeight && multiline) {
return;
}
const layout = event.nativeEvent.layout;
- setWidth((prevWidth) => (props.autoGrowHeight ? layout.width : prevWidth));
- setHeight((prevHeight) => (!props.multiline ? layout.height : prevHeight));
+ setWidth((prevWidth: number | null) => (autoGrowHeight ? layout.width : prevWidth));
+ setHeight((prevHeight: number) => (!multiline ? layout.height : prevHeight));
},
- [props.autoGrowHeight, props.multiline],
+ [autoGrowHeight, multiline],
);
// The ref is needed when the component is uncontrolled and we don't have a value prop
const hasValueRef = useRef(initialValue.length > 0);
- const inputValue = props.value || '';
+ const inputValue = value ?? '';
const hasValue = inputValue.length > 0 || hasValueRef.current;
// Activate or deactivate the label when either focus changes, or for controlled
@@ -164,29 +194,25 @@ function BaseTextInput(props) {
// When the value prop gets cleared externally, we need to keep the ref in sync:
useEffect(() => {
// Return early when component uncontrolled, or we still have a value
- if (props.value === undefined || !_.isEmpty(props.value)) {
+ if (value === undefined || value) {
return;
}
hasValueRef.current = false;
- }, [props.value]);
+ }, [value]);
/**
* Set Value & activateLabel
- *
- * @param {String} value
- * @memberof BaseTextInput
*/
- const setValue = (value) => {
- if (props.onInputChange) {
- props.onInputChange(value);
- }
-
- Str.result(props.onChangeText, value);
+ const setValue = (newValue: string) => {
+ onInputChange?.(newValue);
- if (value && value.length > 0) {
+ if (inputProps.onChangeText) {
+ Str.result(inputProps.onChangeText, newValue);
+ }
+ if (newValue && newValue.length > 0) {
hasValueRef.current = true;
// When the componment is uncontrolled, we need to manually activate the label:
- if (props.value === undefined) {
+ if (value === undefined) {
activateLabel();
}
} else {
@@ -195,7 +221,7 @@ function BaseTextInput(props) {
};
const togglePasswordVisibility = useCallback(() => {
- setPasswordHidden((prevPasswordHidden) => !prevPasswordHidden);
+ setPasswordHidden((prevPasswordHidden: boolean | undefined) => !prevPasswordHidden);
}, []);
// When adding a new prefix character, adjust this method to add expected character width.
@@ -204,7 +230,7 @@ function BaseTextInput(props) {
// Some characters are wider than the others when rendered, e.g. '@' vs '#'. Chosen font-family and font-size
// also have an impact on the width of the character, but as long as there's only one font-family and one font-size,
// this method will produce reliable results.
- const getCharacterPadding = (prefix) => {
+ const getCharacterPadding = (prefix: string): number => {
switch (prefix) {
case CONST.POLICY.ROOM_PREFIX:
return 10;
@@ -213,22 +239,20 @@ function BaseTextInput(props) {
}
};
- // eslint-disable-next-line react/forbid-foreign-prop-types
- const inputProps = _.omit(props, _.keys(baseTextInputPropTypes.propTypes));
- const hasLabel = Boolean(props.label.length);
- const isReadOnly = _.isUndefined(props.readOnly) ? props.disabled : props.readOnly;
- const inputHelpText = props.errorText || props.hint;
- const placeholder = props.prefixCharacter || isFocused || !hasLabel || (hasLabel && props.forceActiveLabel) ? props.placeholder : null;
- const maxHeight = StyleSheet.flatten(props.containerStyles).maxHeight;
- const textInputContainerStyles = StyleSheet.flatten([
+ const hasLabel = Boolean(label?.length);
+ const isReadOnly = inputProps.readOnly ?? inputProps.disabled;
+ const inputHelpText = errorText || hint;
+ const newPlaceholder = !!prefixCharacter || isFocused || !hasLabel || (hasLabel && forceActiveLabel) ? placeholder : undefined;
+ const maxHeight = StyleSheet.flatten(containerStyles).maxHeight;
+ const newTextInputContainerStyles: StyleProp = StyleSheet.flatten([
styles.textInputContainer,
- ...props.textInputContainerStyles,
- props.autoGrow && StyleUtils.getWidthStyle(textInputWidth),
- !props.hideFocusedState && isFocused && styles.borderColorFocus,
- (props.hasError || props.errorText) && styles.borderColorDanger,
- props.autoGrowHeight && {scrollPaddingTop: 2 * maxHeight},
+ textInputContainerStyles,
+ autoGrow && StyleUtils.getWidthStyle(textInputWidth),
+ !hideFocusedState && isFocused && styles.borderColorFocus,
+ (!!hasError || !!errorText) && styles.borderColorDanger,
+ autoGrowHeight && {scrollPaddingTop: typeof maxHeight === 'number' ? 2 * maxHeight : undefined},
]);
- const isMultiline = props.multiline || props.autoGrowHeight;
+ const isMultiline = multiline || autoGrowHeight;
/* To prevent text jumping caused by virtual DOM calculations on Safari and mobile Chrome,
make sure to include the `lineHeight`.
@@ -238,31 +262,31 @@ function BaseTextInput(props) {
See https://github.com/Expensify/App/issues/13802 */
const lineHeight = useMemo(() => {
- if ((Browser.isSafari() || Browser.isMobileChrome()) && _.isArray(props.inputStyle)) {
- const lineHeightValue = _.find(props.inputStyle, (f) => f.lineHeight !== undefined);
- if (lineHeightValue) {
- return lineHeightValue.lineHeight;
+ if (Browser.isSafari() || Browser.isMobileChrome()) {
+ const lineHeightValue = StyleSheet.flatten(inputStyle).lineHeight;
+ if (lineHeightValue !== undefined) {
+ return lineHeightValue;
}
}
return undefined;
- }, [props.inputStyle]);
+ }, [inputStyle]);
return (
<>
{hasLabel ? (
@@ -283,48 +307,49 @@ function BaseTextInput(props) {
{isMultiline && }
>
) : null}
- {Boolean(props.prefixCharacter) && (
+ {Boolean(prefixCharacter) && (
- {props.prefixCharacter}
+ {prefixCharacter}
)}
{
- if (typeof props.innerRef === 'function') {
- props.innerRef(ref);
- } else if (props.innerRef && _.has(props.innerRef, 'current')) {
+ ref={(element) => {
+ if (typeof ref === 'function') {
+ ref(element);
+ } else if (ref && 'current' in ref) {
// eslint-disable-next-line no-param-reassign
- props.innerRef.current = ref;
+ ref.current = element;
}
- input.current = ref;
+
+ (input.current as AnimatedTextInputRef | null) = element;
}}
// eslint-disable-next-line
{...inputProps}
- autoCorrect={props.secureTextEntry ? false : props.autoCorrect}
- placeholder={placeholder}
+ autoCorrect={inputProps.secureTextEntry ? false : autoCorrect}
+ placeholder={newPlaceholder}
placeholderTextColor={theme.placeholderText}
underlineColorAndroid="transparent"
style={[
styles.flex1,
styles.w100,
- props.inputStyle,
+ inputStyle,
(!hasLabel || isMultiline) && styles.pv0,
- props.prefixCharacter && StyleUtils.getPaddingLeft(getCharacterPadding(props.prefixCharacter) + styles.pl1.paddingLeft),
- props.secureTextEntry && styles.secureInput,
+ !!prefixCharacter && StyleUtils.getPaddingLeft(getCharacterPadding(prefixCharacter) + styles.pl1.paddingLeft),
+ inputProps.secureTextEntry && styles.secureInput,
// Explicitly remove `lineHeight` from single line inputs so that long text doesn't disappear
// once it exceeds the input space (See https://github.com/Expensify/App/issues/13802)
@@ -335,42 +360,46 @@ function BaseTextInput(props) {
!isMultiline && Browser.isMobileChrome() && {boxSizing: 'content-box', height: undefined},
// Stop scrollbar flashing when breaking lines with autoGrowHeight enabled.
- ...(props.autoGrowHeight ? [StyleUtils.getAutoGrowHeightInputStyle(textInputHeight, maxHeight), styles.verticalAlignTop] : []),
+ ...(autoGrowHeight
+ ? [StyleUtils.getAutoGrowHeightInputStyle(textInputHeight, typeof maxHeight === 'number' ? maxHeight : 0), styles.verticalAlignTop]
+ : []),
// Add disabled color theme when field is not editable.
- props.disabled && styles.textInputDisabled,
+ inputProps.disabled && styles.textInputDisabled,
styles.pointerEventsAuto,
]}
multiline={isMultiline}
- maxLength={props.maxLength}
+ maxLength={maxLength}
onFocus={onFocus}
onBlur={onBlur}
onChangeText={setValue}
secureTextEntry={passwordHidden}
- onPressOut={props.onPress}
- showSoftInputOnFocus={!props.disableKeyboard}
- inputMode={props.inputMode}
- value={props.value}
- selection={props.selection}
+ onPressOut={inputProps.onPress}
+ showSoftInputOnFocus={!disableKeyboard}
+ inputMode={inputProps.inputMode}
+ value={value}
+ selection={inputProps.selection}
readOnly={isReadOnly}
- defaultValue={props.defaultValue}
+ defaultValue={defaultValue}
// FormSubmit Enter key handler does not have access to direct props.
// `dataset.submitOnEnter` is used to indicate that pressing Enter on this input should call the submit callback.
- dataSet={{submitOnEnter: isMultiline && props.submitOnEnter}}
+ dataSet={{submitOnEnter: isMultiline && submitOnEnter}}
/>
- {props.isLoading && (
+ {inputProps.isLoading && (
)}
- {Boolean(props.secureTextEntry) && (
+ {Boolean(inputProps.secureTextEntry) && (
e.preventDefault()}
- accessibilityLabel={props.translate('common.visible')}
+ onMouseDown={(e) => {
+ e.preventDefault();
+ }}
+ accessibilityLabel={translate('common.visible')}
>
)}
- {!props.secureTextEntry && Boolean(props.icon) && (
+ {!inputProps.secureTextEntry && icon && (
@@ -389,25 +418,30 @@ function BaseTextInput(props) {
- {!_.isEmpty(inputHelpText) && (
+ {!!inputHelpText && (
)}
{/*
- Text input component doesn't support auto grow by default.
- We're using a hidden text input to achieve that.
- This text view is used to calculate width or height of the input value given textStyle in this component.
- This Text component is intentionally positioned out of the screen.
- */}
- {(props.autoGrow || props.autoGrowHeight) && (
+ Text input component doesn't support auto grow by default.
+ We're using a hidden text input to achieve that.
+ This text view is used to calculate width or height of the input value given textStyle in this component.
+ This Text component is intentionally positioned out of the screen.
+ */}
+ {(!!autoGrow || autoGrowHeight) && (
// Add +2 to width on Safari browsers so that text is not cut off due to the cursor or when changing the value
// https://github.com/Expensify/App/issues/8158
// https://github.com/Expensify/App/issues/26628
{
let additionalWidth = 0;
if (Browser.isMobileSafari() || Browser.isSafari()) {
@@ -418,7 +452,7 @@ function BaseTextInput(props) {
}}
>
{/* \u200B added to solve the issue of not expanding the text input enough when the value ends with '\n' (https://github.com/Expensify/App/issues/21271) */}
- {props.value ? `${props.value}${props.value.endsWith('\n') ? '\u200B' : ''}` : props.placeholder}
+ {value ? `${value}${value.endsWith('\n') ? '\u200B' : ''}` : placeholder}
)}
>
@@ -426,7 +460,5 @@ function BaseTextInput(props) {
}
BaseTextInput.displayName = 'BaseTextInput';
-BaseTextInput.propTypes = baseTextInputPropTypes.propTypes;
-BaseTextInput.defaultProps = baseTextInputPropTypes.defaultProps;
-export default withLocalize(BaseTextInput);
+export default forwardRef(BaseTextInput);
diff --git a/src/components/TextInput/BaseTextInput/types.ts b/src/components/TextInput/BaseTextInput/types.ts
new file mode 100644
index 000000000000..2b8264af815b
--- /dev/null
+++ b/src/components/TextInput/BaseTextInput/types.ts
@@ -0,0 +1,115 @@
+import type {Component, ForwardedRef} from 'react';
+import type {GestureResponderEvent, StyleProp, TextInputProps, TextStyle, ViewStyle} from 'react-native';
+import type {AnimatedProps} from 'react-native-reanimated';
+import type {MaybePhraseKey} from '@libs/Localize';
+import IconAsset from '@src/types/utils/IconAsset';
+
+type CustomBaseTextInputProps = {
+ /** Input label */
+ label?: string;
+
+ /** Name attribute for the input */
+ name?: string;
+
+ /** Input value */
+ value?: string;
+
+ /** Default value - used for non controlled inputs */
+ defaultValue?: string;
+
+ /** Input value placeholder */
+ placeholder?: string;
+
+ /** Error text to display */
+ errorText?: MaybePhraseKey;
+
+ /** Icon to display in right side of text input */
+ icon: IconAsset | null;
+
+ /** Customize the TextInput container */
+ textInputContainerStyles?: StyleProp;
+
+ /** Customize the main container */
+ containerStyles?: StyleProp;
+
+ /** input style */
+ inputStyle?: StyleProp;
+
+ /** If present, this prop forces the label to remain in a position where it will not collide with input text */
+ forceActiveLabel?: boolean;
+
+ /** Should the input auto focus? */
+ autoFocus?: boolean;
+
+ /** Disable the virtual keyboard */
+ disableKeyboard?: boolean;
+
+ /**
+ * Autogrow input container length based on the entered text.
+ * Note: If you use this prop, the text input has to be controlled
+ * by a value prop.
+ */
+ autoGrow?: boolean;
+
+ /**
+ * Autogrow input container height based on the entered text
+ * Note: If you use this prop, the text input has to be controlled
+ * by a value prop.
+ */
+ autoGrowHeight?: boolean;
+
+ /** Hide the focus styles on TextInput */
+ hideFocusedState?: boolean;
+
+ /** Hint text to display below the TextInput */
+ hint?: string;
+
+ /** Prefix character */
+ prefixCharacter?: string;
+
+ /** Whether autoCorrect functionality should enable */
+ autoCorrect?: boolean;
+
+ /** Form props */
+ /** The ID used to uniquely identify the input in a Form */
+ inputID?: string;
+
+ /** Saves a draft of the input value when used in a form */
+ shouldSaveDraft?: boolean;
+
+ /** Callback to update the value on Form when input is used in the Form component. */
+ onInputChange?: (value: string) => void;
+
+ /** Whether we should wait before focusing the TextInput, useful when using transitions */
+ shouldDelayFocus?: boolean;
+
+ /** Indicate whether pressing Enter on multiline input is allowed to submit the form. */
+ submitOnEnter?: boolean;
+
+ /** Indicate whether input is multiline */
+ multiline?: boolean;
+
+ /** Set the default value to the input if there is a valid saved value */
+ shouldUseDefaultValue?: boolean;
+
+ /** Indicate whether or not the input should prevent swipe actions in tabs */
+ shouldInterceptSwipe?: boolean;
+
+ /** Should there be an error displayed */
+ hasError?: boolean;
+
+ /** On Press handler */
+ onPress?: (event: GestureResponderEvent | KeyboardEvent) => void;
+
+ /** Should loading state should be displayed */
+ isLoading?: boolean;
+
+ /** Type of autocomplete */
+ autoCompleteType?: string;
+};
+
+type BaseTextInputRef = ForwardedRef>>;
+
+type BaseTextInputProps = CustomBaseTextInputProps & TextInputProps;
+
+export type {CustomBaseTextInputProps, BaseTextInputRef, BaseTextInputProps};
diff --git a/src/components/TextInput/TextInputLabel/TextInputLabelPropTypes.js b/src/components/TextInput/TextInputLabel/TextInputLabelPropTypes.js
deleted file mode 100644
index 82b98f17d808..000000000000
--- a/src/components/TextInput/TextInputLabel/TextInputLabelPropTypes.js
+++ /dev/null
@@ -1,25 +0,0 @@
-import PropTypes from 'prop-types';
-import {Animated} from 'react-native';
-
-const propTypes = {
- /** Label */
- label: PropTypes.string.isRequired,
-
- /** Label vertical translate */
- labelTranslateY: PropTypes.instanceOf(Animated.Value).isRequired,
-
- /** Label scale */
- labelScale: PropTypes.instanceOf(Animated.Value).isRequired,
-
- /** Whether the label is currently active or not */
- isLabelActive: PropTypes.bool.isRequired,
-
- /** For attribute for label */
- for: PropTypes.string,
-};
-
-const defaultProps = {
- for: '',
-};
-
-export {propTypes, defaultProps};
diff --git a/src/components/TextInput/TextInputLabel/index.native.js b/src/components/TextInput/TextInputLabel/index.native.tsx
similarity index 76%
rename from src/components/TextInput/TextInputLabel/index.native.js
rename to src/components/TextInput/TextInputLabel/index.native.tsx
index eb0f8b17e8b7..569d590dbb8d 100644
--- a/src/components/TextInput/TextInputLabel/index.native.js
+++ b/src/components/TextInput/TextInputLabel/index.native.tsx
@@ -2,9 +2,9 @@ import React, {useState} from 'react';
import {Animated} from 'react-native';
import * as styleConst from '@components/TextInput/styleConst';
import useThemeStyles from '@hooks/useThemeStyles';
-import * as TextInputLabelPropTypes from './TextInputLabelPropTypes';
+import type TextInputLabelProps from './types';
-function TextInputLabel(props) {
+function TextInputLabel({isLabelActive, label, labelScale, labelTranslateY}: TextInputLabelProps) {
const styles = useThemeStyles();
const [width, setWidth] = useState(0);
@@ -17,29 +17,27 @@ function TextInputLabel(props) {
style={[
styles.textInputLabel,
styles.textInputLabelTransformation(
- props.labelTranslateY,
- props.labelScale.interpolate({
+ labelTranslateY,
+ labelScale.interpolate({
inputRange: [styleConst.ACTIVE_LABEL_SCALE, styleConst.INACTIVE_LABEL_SCALE],
outputRange: [-(width - width * styleConst.ACTIVE_LABEL_SCALE) / 2, 0],
}),
- props.labelScale,
+ labelScale,
),
// If the label is active but the width is not ready yet, the above translateX value will be 0,
// making the label sits at the top center instead of the top left of the input. To solve it
// move the label by a percentage value with left style as translateX doesn't support percentage value.
width === 0 &&
- props.isLabelActive && {
+ isLabelActive && {
left: `${-((1 - styleConst.ACTIVE_LABEL_SCALE) * 100) / 2}%`,
},
]}
>
- {props.label}
+ {label}
);
}
-TextInputLabel.propTypes = TextInputLabelPropTypes.propTypes;
-TextInputLabel.defaultProps = TextInputLabelPropTypes.defaultProps;
TextInputLabel.displayName = 'TextInputLabel';
export default TextInputLabel;
diff --git a/src/components/TextInput/TextInputLabel/index.js b/src/components/TextInput/TextInputLabel/index.tsx
similarity index 73%
rename from src/components/TextInput/TextInputLabel/index.js
rename to src/components/TextInput/TextInputLabel/index.tsx
index 61af88fe317b..628de2970331 100644
--- a/src/components/TextInput/TextInputLabel/index.js
+++ b/src/components/TextInput/TextInputLabel/index.tsx
@@ -1,12 +1,12 @@
import React, {useEffect, useRef} from 'react';
-import {Animated} from 'react-native';
+import {Animated, Text} from 'react-native';
import useThemeStyles from '@hooks/useThemeStyles';
import CONST from '@src/CONST';
-import {defaultProps, propTypes} from './TextInputLabelPropTypes';
+import type TextInputLabelProps from './types';
-function TextInputLabel({for: inputId, label, labelTranslateY, labelScale}) {
+function TextInputLabel({for: inputId = '', label, labelTranslateY, labelScale}: TextInputLabelProps) {
const styles = useThemeStyles();
- const labelRef = useRef(null);
+ const labelRef = useRef(null);
useEffect(() => {
if (!inputId || !labelRef.current) {
@@ -28,7 +28,5 @@ function TextInputLabel({for: inputId, label, labelTranslateY, labelScale}) {
}
TextInputLabel.displayName = 'TextInputLabel';
-TextInputLabel.propTypes = propTypes;
-TextInputLabel.defaultProps = defaultProps;
export default React.memo(TextInputLabel);
diff --git a/src/components/TextInput/TextInputLabel/types.ts b/src/components/TextInput/TextInputLabel/types.ts
new file mode 100644
index 000000000000..6f85eef18f42
--- /dev/null
+++ b/src/components/TextInput/TextInputLabel/types.ts
@@ -0,0 +1,20 @@
+import {Animated} from 'react-native';
+
+type TextInputLabelProps = {
+ /** Label */
+ label: string;
+
+ /** Label vertical translate */
+ labelTranslateY: Animated.Value;
+
+ /** Label scale */
+ labelScale: Animated.Value;
+
+ /** Whether the label is currently active or not */
+ isLabelActive: boolean;
+
+ /** For attribute for label */
+ for?: string;
+};
+
+export default TextInputLabelProps;
diff --git a/src/components/TextInput/index.native.js b/src/components/TextInput/index.native.tsx
similarity index 75%
rename from src/components/TextInput/index.native.js
rename to src/components/TextInput/index.native.tsx
index e5aba76957ad..656f0657dd26 100644
--- a/src/components/TextInput/index.native.js
+++ b/src/components/TextInput/index.native.tsx
@@ -2,10 +2,11 @@ import React, {forwardRef, useEffect} from 'react';
import {AppState, Keyboard} from 'react-native';
import useThemeStyles from '@hooks/useThemeStyles';
import BaseTextInput from './BaseTextInput';
-import * as baseTextInputPropTypes from './BaseTextInput/baseTextInputPropTypes';
+import type {BaseTextInputProps, BaseTextInputRef} from './BaseTextInput/types';
-const TextInput = forwardRef((props, ref) => {
+function TextInput(props: BaseTextInputProps, ref: BaseTextInputRef) {
const styles = useThemeStyles();
+
useEffect(() => {
if (!props.disableKeyboard) {
return;
@@ -30,15 +31,13 @@ const TextInput = forwardRef((props, ref) => {
{...props}
// Setting autoCompleteType to new-password throws an error on Android/iOS, so fall back to password in that case
// eslint-disable-next-line react/jsx-props-no-multi-spaces
+ ref={ref}
autoCompleteType={props.autoCompleteType === 'new-password' ? 'password' : props.autoCompleteType}
- innerRef={ref}
- inputStyle={[styles.baseTextInput, ...props.inputStyle]}
+ inputStyle={[styles.baseTextInput, props.inputStyle]}
/>
);
-});
+}
-TextInput.propTypes = baseTextInputPropTypes.propTypes;
-TextInput.defaultProps = baseTextInputPropTypes.defaultProps;
TextInput.displayName = 'TextInput';
-export default TextInput;
+export default forwardRef(TextInput);
diff --git a/src/components/TextInput/index.js b/src/components/TextInput/index.tsx
similarity index 50%
rename from src/components/TextInput/index.js
rename to src/components/TextInput/index.tsx
index 87db18754ed8..3043edbd26a5 100644
--- a/src/components/TextInput/index.js
+++ b/src/components/TextInput/index.tsx
@@ -1,28 +1,31 @@
import React, {useEffect, useRef} from 'react';
-import _ from 'underscore';
+import type {StyleProp, ViewStyle} from 'react-native';
import useThemeStyles from '@hooks/useThemeStyles';
import * as Browser from '@libs/Browser';
import DomUtils from '@libs/DomUtils';
import Visibility from '@libs/Visibility';
import BaseTextInput from './BaseTextInput';
-import * as baseTextInputPropTypes from './BaseTextInput/baseTextInputPropTypes';
+import type {BaseTextInputProps, BaseTextInputRef} from './BaseTextInput/types';
import * as styleConst from './styleConst';
-function TextInput(props) {
+type RemoveVisibilityListener = () => void;
+
+function TextInput(props: BaseTextInputProps, ref: BaseTextInputRef) {
const styles = useThemeStyles();
- const textInputRef = useRef(null);
- const removeVisibilityListenerRef = useRef(null);
+ const textInputRef = useRef(null);
+ const removeVisibilityListenerRef = useRef(null);
useEffect(() => {
+ let removeVisibilityListener = removeVisibilityListenerRef.current;
if (props.disableKeyboard) {
- textInputRef.current.setAttribute('inputmode', 'none');
+ textInputRef.current?.setAttribute('inputmode', 'none');
}
if (props.name) {
- textInputRef.current.setAttribute('name', props.name);
+ textInputRef.current?.setAttribute('name', props.name);
}
- removeVisibilityListenerRef.current = Visibility.onVisibilityChange(() => {
+ removeVisibilityListener = Visibility.onVisibilityChange(() => {
if (!Browser.isMobileChrome() || !Visibility.isVisible() || !textInputRef.current || DomUtils.getActiveElement() !== textInputRef.current) {
return;
}
@@ -31,18 +34,21 @@ function TextInput(props) {
});
return () => {
- if (!removeVisibilityListenerRef.current) {
+ if (!removeVisibilityListener) {
return;
}
- removeVisibilityListenerRef.current();
+ removeVisibilityListener();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
- const isLabeledMultiline = Boolean(props.label.length) && props.multiline;
+ const isLabeledMultiline = Boolean(props.label?.length) && props.multiline;
const labelAnimationStyle = {
+ // eslint-disable-next-line @typescript-eslint/naming-convention
'--active-label-translate-y': `${styleConst.ACTIVE_LABEL_TRANSLATE_Y}px`,
+ // eslint-disable-next-line @typescript-eslint/naming-convention
'--active-label-scale': `${styleConst.ACTIVE_LABEL_SCALE}`,
+ // eslint-disable-next-line @typescript-eslint/naming-convention
'--label-transition-duration': `${styleConst.LABEL_ANIMATION_DURATION}ms`,
};
@@ -50,38 +56,27 @@ function TextInput(props) {
{
- textInputRef.current = el;
- if (!props.innerRef) {
+ ref={(element) => {
+ textInputRef.current = element as HTMLElement;
+
+ if (!ref) {
return;
}
- if (_.isFunction(props.innerRef)) {
- props.innerRef(el);
+ if (typeof ref === 'function') {
+ ref(element);
return;
}
// eslint-disable-next-line no-param-reassign
- props.innerRef.current = el;
+ ref.current = element;
}}
- inputStyle={[styles.baseTextInput, styles.textInputDesktop, isLabeledMultiline ? styles.textInputMultiline : {}, ...props.inputStyle]}
- textInputContainerStyles={[labelAnimationStyle, ...props.textInputContainerStyles]}
+ inputStyle={[styles.baseTextInput, styles.textInputDesktop, isLabeledMultiline ? styles.textInputMultiline : {}, props.inputStyle]}
+ textInputContainerStyles={[labelAnimationStyle as StyleProp, props.textInputContainerStyles]}
/>
);
}
TextInput.displayName = 'TextInput';
-TextInput.propTypes = baseTextInputPropTypes.propTypes;
-TextInput.defaultProps = baseTextInputPropTypes.defaultProps;
-
-const TextInputWithRef = React.forwardRef((props, ref) => (
-
-));
-
-TextInputWithRef.displayName = 'TextInputWithRef';
-export default TextInputWithRef;
+export default React.forwardRef(TextInput);
diff --git a/src/components/TextInput/styleConst.js b/src/components/TextInput/styleConst.ts
similarity index 100%
rename from src/components/TextInput/styleConst.js
rename to src/components/TextInput/styleConst.ts
diff --git a/src/components/withKeyboardState.js b/src/components/withKeyboardState.js
deleted file mode 100755
index d89a4a8228bf..000000000000
--- a/src/components/withKeyboardState.js
+++ /dev/null
@@ -1,68 +0,0 @@
-import PropTypes from 'prop-types';
-import React, {createContext, forwardRef, useEffect, useMemo, useState} from 'react';
-import {Keyboard} from 'react-native';
-import getComponentDisplayName from '@libs/getComponentDisplayName';
-
-const KeyboardStateContext = createContext(null);
-const keyboardStatePropTypes = {
- /** Whether or not the keyboard is open */
- isKeyboardShown: PropTypes.bool.isRequired,
-};
-
-const keyboardStateProviderPropTypes = {
- /* Actual content wrapped by this component */
- children: PropTypes.node.isRequired,
-};
-
-function KeyboardStateProvider(props) {
- const {children} = props;
- const [isKeyboardShown, setIsKeyboardShown] = useState(false);
- useEffect(() => {
- const keyboardDidShowListener = Keyboard.addListener('keyboardDidShow', () => {
- setIsKeyboardShown(true);
- });
- const keyboardDidHideListener = Keyboard.addListener('keyboardDidHide', () => {
- setIsKeyboardShown(false);
- });
-
- return () => {
- keyboardDidShowListener.remove();
- keyboardDidHideListener.remove();
- };
- }, []);
-
- const contextValue = useMemo(
- () => ({
- isKeyboardShown,
- }),
- [isKeyboardShown],
- );
- return {children};
-}
-
-KeyboardStateProvider.propTypes = keyboardStateProviderPropTypes;
-
-/**
- * @param {React.Component} WrappedComponent
- * @returns {React.Component}
- */
-export default function withKeyboardState(WrappedComponent) {
- const WithKeyboardState = forwardRef((props, ref) => (
-
- {(keyboardStateProps) => (
-
- )}
-
- ));
-
- WithKeyboardState.displayName = `withKeyboardState(${getComponentDisplayName(WrappedComponent)})`;
- return WithKeyboardState;
-}
-
-export {KeyboardStateProvider, keyboardStatePropTypes, KeyboardStateContext};
diff --git a/src/components/withKeyboardState.tsx b/src/components/withKeyboardState.tsx
new file mode 100755
index 000000000000..0c168a4537f5
--- /dev/null
+++ b/src/components/withKeyboardState.tsx
@@ -0,0 +1,68 @@
+import PropTypes from 'prop-types';
+import React, {ComponentType, createContext, ForwardedRef, forwardRef, ReactElement, RefAttributes, useEffect, useMemo, useState} from 'react';
+import {Keyboard} from 'react-native';
+import getComponentDisplayName from '@libs/getComponentDisplayName';
+import ChildrenProps from '@src/types/utils/ChildrenProps';
+
+type KeyboardStateContextValue = {
+ /** Whether the keyboard is open */
+ isKeyboardShown: boolean;
+};
+
+// TODO: Remove - left for backwards compatibility with existing components (https://github.com/Expensify/App/issues/25151)
+const keyboardStatePropTypes = {
+ /** Whether the keyboard is open */
+ isKeyboardShown: PropTypes.bool.isRequired,
+};
+
+const KeyboardStateContext = createContext(null);
+
+function KeyboardStateProvider({children}: ChildrenProps): ReactElement | null {
+ const [isKeyboardShown, setIsKeyboardShown] = useState(false);
+
+ useEffect(() => {
+ const keyboardDidShowListener = Keyboard.addListener('keyboardDidShow', () => {
+ setIsKeyboardShown(true);
+ });
+ const keyboardDidHideListener = Keyboard.addListener('keyboardDidHide', () => {
+ setIsKeyboardShown(false);
+ });
+
+ return () => {
+ keyboardDidShowListener.remove();
+ keyboardDidHideListener.remove();
+ };
+ }, []);
+
+ const contextValue = useMemo(
+ () => ({
+ isKeyboardShown,
+ }),
+ [isKeyboardShown],
+ );
+ return {children};
+}
+
+export default function withKeyboardState(
+ WrappedComponent: ComponentType>,
+): (props: Omit & React.RefAttributes) => ReactElement | null {
+ function WithKeyboardState(props: Omit, ref: ForwardedRef) {
+ return (
+
+ {(keyboardStateProps) => (
+
+ )}
+
+ );
+ }
+ WithKeyboardState.displayName = `withKeyboardState(${getComponentDisplayName(WrappedComponent)})`;
+ return forwardRef(WithKeyboardState);
+}
+
+export {KeyboardStateProvider, keyboardStatePropTypes, type KeyboardStateContextValue, KeyboardStateContext};
diff --git a/src/hooks/useKeyboardState.js b/src/hooks/useKeyboardState.js
deleted file mode 100644
index 68e9dbfc2b13..000000000000
--- a/src/hooks/useKeyboardState.js
+++ /dev/null
@@ -1,11 +0,0 @@
-import {useContext} from 'react';
-import {KeyboardStateContext} from '@components/withKeyboardState';
-
-/**
- * Hook for getting current state of keyboard
- * whether or not the keyboard is open
- * @returns {Object}
- */
-export default function useKeyboardState() {
- return useContext(KeyboardStateContext);
-}
diff --git a/src/hooks/useKeyboardState.ts b/src/hooks/useKeyboardState.ts
new file mode 100644
index 000000000000..edcbaa32cc9e
--- /dev/null
+++ b/src/hooks/useKeyboardState.ts
@@ -0,0 +1,10 @@
+import {useContext} from 'react';
+import {KeyboardStateContext, KeyboardStateContextValue} from '@components/withKeyboardState';
+
+/**
+ * Hook for getting current state of keyboard
+ * whether the keyboard is open
+ */
+export default function useKeyboardState(): KeyboardStateContextValue | null {
+ return useContext(KeyboardStateContext);
+}
diff --git a/src/hooks/useResponsiveLayout.ts b/src/hooks/useResponsiveLayout.ts
new file mode 100644
index 000000000000..b386fc985604
--- /dev/null
+++ b/src/hooks/useResponsiveLayout.ts
@@ -0,0 +1,24 @@
+import {ParamListBase, RouteProp, useRoute} from '@react-navigation/native';
+import useWindowDimensions from './useWindowDimensions';
+
+type RouteParams = ParamListBase & {
+ params: {isInRHP?: boolean};
+};
+type ResponsiveLayoutResult = {
+ shouldUseNarrowLayout: boolean;
+};
+/**
+ * Hook to determine if we are on mobile devices or in the RHP
+ */
+export default function useResponsiveLayout(): ResponsiveLayoutResult {
+ const {isSmallScreenWidth} = useWindowDimensions();
+ try {
+ // eslint-disable-next-line react-hooks/rules-of-hooks
+ const {params} = useRoute>();
+ return {shouldUseNarrowLayout: isSmallScreenWidth || (params?.isInRHP ?? false)};
+ } catch (error) {
+ return {
+ shouldUseNarrowLayout: isSmallScreenWidth,
+ };
+ }
+}
diff --git a/src/hooks/useSingleExecution/index.ts b/src/hooks/useSingleExecution/index.ts
index c37087d27c5f..f1be359f0355 100644
--- a/src/hooks/useSingleExecution/index.ts
+++ b/src/hooks/useSingleExecution/index.ts
@@ -18,3 +18,5 @@ export default function useSingleExecution() {
return {isExecuting: false, singleExecution};
}
+
+export type {Action};
diff --git a/src/languages/en.ts b/src/languages/en.ts
index 3cdb7cf2bf98..c1decfdf1c70 100755
--- a/src/languages/en.ts
+++ b/src/languages/en.ts
@@ -112,6 +112,7 @@ export default {
add: 'Add',
resend: 'Resend',
save: 'Save',
+ select: 'Select',
saveChanges: 'Save changes',
submit: 'Submit',
rotate: 'Rotate',
@@ -576,7 +577,7 @@ export default {
amountEach: ({amount}: AmountEachParams) => `${amount} each`,
payerOwesAmount: ({payer, amount}: PayerOwesAmountParams) => `${payer} owes ${amount}`,
payerOwes: ({payer}: PayerOwesParams) => `${payer} owes: `,
- payerPaidAmount: ({payer, amount}: PayerPaidAmountParams): string => `${payer} paid ${amount}`,
+ payerPaidAmount: ({payer, amount}: PayerPaidAmountParams): string => `${payer ? `${payer} ` : ''}paid ${amount}`,
payerPaid: ({payer}: PayerPaidParams) => `${payer} paid: `,
payerSpentAmount: ({payer, amount}: PayerPaidAmountParams): string => `${payer} spent ${amount}`,
payerSpent: ({payer}: PayerPaidParams) => `${payer} spent: `,
@@ -589,8 +590,8 @@ export default {
`Canceled the ${amount} payment, because ${submitterDisplayName} did not enable their Expensify Wallet within 30 days`,
settledAfterAddedBankAccount: ({submitterDisplayName, amount}: SettledAfterAddedBankAccountParams) =>
`${submitterDisplayName} added a bank account. The ${amount} payment has been made.`,
- paidElsewhereWithAmount: ({payer, amount}: PaidElsewhereWithAmountParams) => `${payer} paid ${amount} elsewhere`,
- paidWithExpensifyWithAmount: ({payer, amount}: PaidWithExpensifyWithAmountParams) => `${payer} paid ${amount} using Expensify`,
+ paidElsewhereWithAmount: ({payer, amount}: PaidElsewhereWithAmountParams) => `${payer ? `${payer} ` : ''}paid ${amount} elsewhere`,
+ paidWithExpensifyWithAmount: ({payer, amount}: PaidWithExpensifyWithAmountParams) => `${payer ? `${payer} ` : ''}paid ${amount} using Expensify`,
noReimbursableExpenses: 'This report has an invalid amount',
pendingConversionMessage: "Total will update when you're back online",
changedTheRequest: 'changed the request',
@@ -618,6 +619,7 @@ export default {
genericSmartscanFailureMessage: 'Transaction is missing fields',
atLeastTwoDifferentWaypoints: 'Please enter at least two different addresses',
splitBillMultipleParticipantsErrorMessage: 'Split bill is only allowed between a single workspace or individual users. Please update your selection.',
+ invalidMerchant: 'Please enter a correct merchant.',
},
waitingOnEnabledWallet: ({submitterDisplayName}: WaitingOnBankAccountParams) => `Started settling up, payment is held until ${submitterDisplayName} enables their Wallet`,
enableWallet: 'Enable Wallet',
diff --git a/src/languages/es.ts b/src/languages/es.ts
index d11211ca9325..42461e766b29 100644
--- a/src/languages/es.ts
+++ b/src/languages/es.ts
@@ -96,6 +96,7 @@ export default {
new: 'Nuevo',
search: 'Buscar',
searchWithThreeDots: 'Buscar...',
+ select: 'Seleccionar',
next: 'Siguiente',
previous: 'Anterior',
goBack: 'Volver',
@@ -569,7 +570,7 @@ export default {
amountEach: ({amount}: AmountEachParams) => `${amount} cada uno`,
payerOwesAmount: ({payer, amount}: PayerOwesAmountParams) => `${payer} debe ${amount}`,
payerOwes: ({payer}: PayerOwesParams) => `${payer} debe: `,
- payerPaidAmount: ({payer, amount}: PayerPaidAmountParams) => `${payer} pagó ${amount}`,
+ payerPaidAmount: ({payer, amount}: PayerPaidAmountParams) => `${payer ? `${payer} ` : ''}pagó ${amount}`,
payerPaid: ({payer}: PayerPaidParams) => `${payer} pagó: `,
payerSpentAmount: ({payer, amount}: PayerPaidAmountParams): string => `${payer} gastó ${amount}`,
payerSpent: ({payer}: PayerPaidParams) => `${payer} gastó: `,
@@ -582,8 +583,8 @@ export default {
`Canceló el pago ${amount}, porque ${submitterDisplayName} no habilitó su billetera Expensify en un plazo de 30 dÃas.`,
settledAfterAddedBankAccount: ({submitterDisplayName, amount}: SettledAfterAddedBankAccountParams) =>
`${submitterDisplayName} añadió una cuenta bancaria. El pago de ${amount} se ha realizado.`,
- paidElsewhereWithAmount: ({payer, amount}: PaidElsewhereWithAmountParams) => `${payer} pagó ${amount} de otra forma`,
- paidWithExpensifyWithAmount: ({payer, amount}: PaidWithExpensifyWithAmountParams) => `${payer} pagó ${amount} con Expensify`,
+ paidElsewhereWithAmount: ({payer, amount}: PaidElsewhereWithAmountParams) => `${payer ? `${payer} ` : ''}pagó ${amount} de otra forma`,
+ paidWithExpensifyWithAmount: ({payer, amount}: PaidWithExpensifyWithAmountParams) => `${payer ? `${payer} ` : ''}pagó ${amount} con Expensify`,
noReimbursableExpenses: 'El importe de este informe no es válido',
pendingConversionMessage: 'El total se actualizará cuando estés online',
changedTheRequest: 'cambió la solicitud',
@@ -613,6 +614,7 @@ export default {
genericSmartscanFailureMessage: 'La transacción tiene campos vacÃos',
atLeastTwoDifferentWaypoints: 'Por favor introduce al menos dos direcciones diferentes',
splitBillMultipleParticipantsErrorMessage: 'Solo puedes dividir una cuenta entre un único espacio de trabajo o con usuarios individuales. Por favor actualiza tu selección.',
+ invalidMerchant: 'Por favor ingrese un comerciante correcto.',
},
waitingOnEnabledWallet: ({submitterDisplayName}: WaitingOnBankAccountParams) => `Inició el pago, pero no se procesará hasta que ${submitterDisplayName} active su Billetera`,
enableWallet: 'Habilitar Billetera',
diff --git a/src/languages/types.ts b/src/languages/types.ts
index 8e72c700a9cc..427a5777b2ea 100644
--- a/src/languages/types.ts
+++ b/src/languages/types.ts
@@ -103,7 +103,7 @@ type SettleExpensifyCardParams = {
type RequestAmountParams = {amount: number};
-type RequestedAmountMessageParams = {formattedAmount: string; comment: string};
+type RequestedAmountMessageParams = {formattedAmount: string; comment?: string};
type SplitAmountParams = {amount: number};
@@ -115,7 +115,7 @@ type PayerOwesAmountParams = {payer: string; amount: number | string};
type PayerOwesParams = {payer: string};
-type PayerPaidAmountParams = {payer: string; amount: number | string};
+type PayerPaidAmountParams = {payer?: string; amount: number | string};
type ApprovedAmountParams = {amount: number | string};
@@ -133,9 +133,9 @@ type CanceledRequestParams = {amount: string; submitterDisplayName: string};
type SettledAfterAddedBankAccountParams = {submitterDisplayName: string; amount: string};
-type PaidElsewhereWithAmountParams = {payer: string; amount: string};
+type PaidElsewhereWithAmountParams = {payer?: string; amount: string};
-type PaidWithExpensifyWithAmountParams = {payer: string; amount: string};
+type PaidWithExpensifyWithAmountParams = {payer?: string; amount: string};
type ThreadRequestReportNameParams = {formattedAmount: string; comment: string};
diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators.tsx b/src/libs/Navigation/AppNavigator/ModalStackNavigators.tsx
index 256ea6d4eceb..51dada669131 100644
--- a/src/libs/Navigation/AppNavigator/ModalStackNavigators.tsx
+++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators.tsx
@@ -73,6 +73,7 @@ function createModalStackNavigator(screens:
}
const MoneyRequestModalStackNavigator = createModalStackNavigator({
+ [SCREENS.MONEY_REQUEST.START]: () => require('../../../pages/iou/request/IOURequestRedirectToStartPage').default as React.ComponentType,
[SCREENS.MONEY_REQUEST.CREATE]: () => require('../../../pages/iou/request/IOURequestStartPage').default as React.ComponentType,
[SCREENS.MONEY_REQUEST.STEP_CONFIRMATION]: () => require('../../../pages/iou/request/step/IOURequestStepConfirmation').default as React.ComponentType,
[SCREENS.MONEY_REQUEST.STEP_AMOUNT]: () => require('../../../pages/iou/request/step/IOURequestStepAmount').default as React.ComponentType,
diff --git a/src/libs/Navigation/AppNavigator/ReportScreenIDSetter.ts b/src/libs/Navigation/AppNavigator/ReportScreenIDSetter.ts
index 8be512962981..6ae6bb0a2516 100644
--- a/src/libs/Navigation/AppNavigator/ReportScreenIDSetter.ts
+++ b/src/libs/Navigation/AppNavigator/ReportScreenIDSetter.ts
@@ -4,7 +4,7 @@ import usePermissions from '@hooks/usePermissions';
import * as ReportUtils from '@libs/ReportUtils';
import * as App from '@userActions/App';
import ONYXKEYS from '@src/ONYXKEYS';
-import type {Policy, Report} from '@src/types/onyx';
+import type {Policy, Report, ReportMetadata} from '@src/types/onyx';
import type {ReportScreenWrapperProps} from './ReportScreenWrapper';
type ReportScreenIDSetterComponentProps = {
@@ -16,6 +16,9 @@ type ReportScreenIDSetterComponentProps = {
/** Whether user is a new user */
isFirstTimeNewExpensifyUser: OnyxEntry;
+
+ /** The report metadata */
+ reportMetadata: OnyxCollection;
};
type ReportScreenIDSetterProps = ReportScreenIDSetterComponentProps & ReportScreenWrapperProps;
@@ -29,15 +32,15 @@ const getLastAccessedReportID = (
policies: OnyxCollection,
isFirstTimeNewExpensifyUser: OnyxEntry,
openOnAdminRoom: boolean,
+ reportMetadata: OnyxCollection,
): string | undefined => {
- const lastReport = ReportUtils.findLastAccessedReport(reports, ignoreDefaultRooms, policies, !!isFirstTimeNewExpensifyUser, openOnAdminRoom);
+ const lastReport = ReportUtils.findLastAccessedReport(reports, ignoreDefaultRooms, policies, !!isFirstTimeNewExpensifyUser, openOnAdminRoom, reportMetadata);
return lastReport?.reportID;
};
// This wrapper is reponsible for opening the last accessed report if there is no reportID specified in the route params
-function ReportScreenIDSetter({route, reports, policies, navigation, isFirstTimeNewExpensifyUser = false}: ReportScreenIDSetterProps) {
+function ReportScreenIDSetter({route, reports, policies, navigation, isFirstTimeNewExpensifyUser = false, reportMetadata}: ReportScreenIDSetterProps) {
const {canUseDefaultRooms} = usePermissions();
-
useEffect(() => {
// Don't update if there is a reportID in the params already
if (route?.params?.reportID) {
@@ -46,7 +49,7 @@ function ReportScreenIDSetter({route, reports, policies, navigation, isFirstTime
}
// If there is no reportID in route, try to find last accessed and use it for setParams
- const reportID = getLastAccessedReportID(reports, !canUseDefaultRooms, policies, isFirstTimeNewExpensifyUser, !!reports?.params?.openOnAdminRoom);
+ const reportID = getLastAccessedReportID(reports, !canUseDefaultRooms, policies, isFirstTimeNewExpensifyUser, !!reports?.params?.openOnAdminRoom, reportMetadata);
// It's possible that reports aren't fully loaded yet
// in that case the reportID is undefined
@@ -55,7 +58,7 @@ function ReportScreenIDSetter({route, reports, policies, navigation, isFirstTime
} else {
App.confirmReadyToOpenApp();
}
- }, [route, navigation, reports, canUseDefaultRooms, policies, isFirstTimeNewExpensifyUser]);
+ }, [route, navigation, reports, canUseDefaultRooms, policies, isFirstTimeNewExpensifyUser, reportMetadata]);
// The ReportScreen without the reportID set will display a skeleton
// until the reportID is loaded and set in the route param
@@ -77,4 +80,8 @@ export default withOnyx = {
},
[SCREENS.RIGHT_MODAL.MONEY_REQUEST]: {
screens: {
+ [SCREENS.MONEY_REQUEST.START]: ROUTES.MONEY_REQUEST_START.route,
[SCREENS.MONEY_REQUEST.CREATE]: {
path: ROUTES.MONEY_REQUEST_CREATE.route,
exact: true,
diff --git a/src/libs/OptionsListUtils.js b/src/libs/OptionsListUtils.js
index 6e84ef4dca27..0d5162399fcb 100644
--- a/src/libs/OptionsListUtils.js
+++ b/src/libs/OptionsListUtils.js
@@ -387,7 +387,7 @@ function getLastMessageTextForReport(report) {
const lastActionName = lodashGet(lastReportAction, 'actionName', '');
if (ReportActionUtils.isMoneyRequestAction(lastReportAction)) {
- const properSchemaForMoneyRequestMessage = ReportUtils.getReportPreviewMessage(report, lastReportAction, true);
+ const properSchemaForMoneyRequestMessage = ReportUtils.getReportPreviewMessage(report, lastReportAction, true, false, null, true);
lastMessageTextFromReport = ReportUtils.formatReportLastMessageText(properSchemaForMoneyRequestMessage);
} else if (ReportActionUtils.isReportPreviewAction(lastReportAction)) {
const iouReport = ReportUtils.getReport(ReportActionUtils.getIOUReportIDFromReportActionPreview(lastReportAction));
@@ -398,7 +398,7 @@ function getLastMessageTextForReport(report) {
reportAction.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE &&
ReportActionUtils.isMoneyRequestAction(reportAction),
);
- lastMessageTextFromReport = ReportUtils.getReportPreviewMessage(iouReport, lastIOUMoneyReportAction, true, ReportUtils.isChatReport(report));
+ lastMessageTextFromReport = ReportUtils.getReportPreviewMessage(iouReport, lastIOUMoneyReportAction, true, ReportUtils.isChatReport(report), null, true);
} else if (ReportActionUtils.isReimbursementQueuedAction(lastReportAction)) {
lastMessageTextFromReport = ReportUtils.getReimbursementQueuedActionMessage(lastReportAction, report);
} else if (ReportActionUtils.isReimbursementDeQueuedAction(lastReportAction)) {
@@ -511,8 +511,9 @@ function createOption(accountIDs, personalDetails, report, reportActions = {}, {
const lastMessageTextFromReport = getLastMessageTextForReport(report);
const lastActorDetails = personalDetailMap[report.lastActorAccountID] || null;
- let lastMessageText = hasMultipleParticipants && lastActorDetails && lastActorDetails.accountID !== currentUserAccountID ? `${lastActorDetails.displayName}: ` : '';
- lastMessageText += report ? lastMessageTextFromReport : '';
+ const lastActorDisplayName =
+ hasMultipleParticipants && lastActorDetails && lastActorDetails.accountID !== currentUserAccountID ? lastActorDetails.firstName || lastActorDetails.displayName : '';
+ let lastMessageText = lastActorDisplayName ? `${lastActorDisplayName}: ${lastMessageTextFromReport}` : lastMessageTextFromReport;
if (result.isArchivedRoom) {
const archiveReason =
diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts
index 81bbf1df6273..6a4914f44121 100644
--- a/src/libs/ReportUtils.ts
+++ b/src/libs/ReportUtils.ts
@@ -13,9 +13,10 @@ import CONST from '@src/CONST';
import {ParentNavigationSummaryParams, TranslationPaths} from '@src/languages/types';
import ONYXKEYS from '@src/ONYXKEYS';
import ROUTES from '@src/ROUTES';
-import {Beta, Login, PersonalDetails, PersonalDetailsList, Policy, Report, ReportAction, Session, Transaction} from '@src/types/onyx';
+import {Beta, Login, PersonalDetails, PersonalDetailsList, Policy, Report, ReportAction, ReportMetadata, Session, Transaction} from '@src/types/onyx';
import {Errors, Icon, PendingAction} from '@src/types/onyx/OnyxCommon';
import {IOUMessage, OriginalMessageActionName, OriginalMessageCreated} from '@src/types/onyx/OriginalMessage';
+import {Status} from '@src/types/onyx/PersonalDetails';
import {NotificationPreference} from '@src/types/onyx/Report';
import {Message, ReportActionBase, ReportActions} from '@src/types/onyx/ReportAction';
import {Receipt, WaypointCollection} from '@src/types/onyx/Transaction';
@@ -310,15 +311,26 @@ type OptimisticIOUReport = Pick<
>;
type DisplayNameWithTooltips = Array>;
+type CustomIcon = {
+ src: IconAsset;
+ color?: string;
+};
+
type OptionData = {
+ text: string;
alternateText?: string | null;
allReportErrors?: Errors | null;
brickRoadIndicator?: typeof CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR | '' | null;
tooltipText?: string | null;
+ alternateTextMaxLines?: number;
+ boldStyle?: boolean;
+ customIcon?: CustomIcon;
+ descriptiveText?: string;
subtitle?: string | null;
login?: string | null;
accountID?: number | null;
- status?: string | null;
+ pronouns?: string;
+ status?: Status | null;
phoneNumber?: string | null;
isUnread?: boolean | null;
isUnreadWithMention?: boolean | null;
@@ -551,12 +563,12 @@ function isDraftExpenseReport(report: OnyxEntry): boolean {
/**
* Given a collection of reports returns them sorted by last read
*/
-function sortReportsByLastRead(reports: OnyxCollection): Array> {
+function sortReportsByLastRead(reports: OnyxCollection, reportMetadata: OnyxCollection): Array> {
return Object.values(reports ?? {})
- .filter((report) => !!report?.reportID && !!report?.lastReadTime)
+ .filter((report) => !!report?.reportID && !!(reportMetadata?.[`${ONYXKEYS.COLLECTION.REPORT_METADATA}${report.reportID}`]?.lastVisitTime ?? report?.lastReadTime))
.sort((a, b) => {
- const aTime = new Date(a?.lastReadTime ?? '');
- const bTime = new Date(b?.lastReadTime ?? '');
+ const aTime = new Date(reportMetadata?.[`${ONYXKEYS.COLLECTION.REPORT_METADATA}${a?.reportID}`]?.lastVisitTime ?? a?.lastReadTime ?? '');
+ const bTime = new Date(reportMetadata?.[`${ONYXKEYS.COLLECTION.REPORT_METADATA}${b?.reportID}`]?.lastVisitTime ?? b?.lastReadTime ?? '');
return aTime.valueOf() - bTime.valueOf();
});
@@ -820,13 +832,14 @@ function findLastAccessedReport(
policies: OnyxCollection,
isFirstTimeNewExpensifyUser: boolean,
openOnAdminRoom = false,
+ reportMetadata: OnyxCollection = {},
): OnyxEntry {
// If it's the user's first time using New Expensify, then they could either have:
// - just a Concierge report, if so we'll return that
// - their Concierge report, and a separate report that must have deeplinked them to the app before they created their account.
// If it's the latter, we'll use the deeplinked report over the Concierge report,
// since the Concierge report would be incorrectly selected over the deep-linked report in the logic below.
- let sortedReports = sortReportsByLastRead(reports);
+ let sortedReports = sortReportsByLastRead(reports, reportMetadata);
let adminReport: OnyxEntry | undefined;
if (openOnAdminRoom) {
@@ -1167,7 +1180,7 @@ function formatReportLastMessageText(lastMessageText: string, isModifiedExpenseM
if (isModifiedExpenseMessage) {
return String(lastMessageText).trim().replace(CONST.REGEX.LINE_BREAK, '').trim();
}
- return String(lastMessageText).trim().replace(CONST.REGEX.AFTER_FIRST_LINE_BREAK, '').substring(0, CONST.REPORT.LAST_MESSAGE_TEXT_MAX_LENGTH).trim();
+ return String(lastMessageText).trim().replace(CONST.REGEX.LINE_BREAK, ' ').substring(0, CONST.REPORT.LAST_MESSAGE_TEXT_MAX_LENGTH).trim();
}
/**
@@ -1448,7 +1461,7 @@ function getDisplayNameForParticipant(accountID?: number, shouldUseShortForm = f
}
function getDisplayNamesWithTooltips(
- personalDetailsList: PersonalDetails[] | PersonalDetailsList,
+ personalDetailsList: PersonalDetails[] | PersonalDetailsList | OptionData[],
isMultipleParticipantReport: boolean,
shouldFallbackToHidden = true,
): DisplayNameWithTooltips {
@@ -1961,8 +1974,10 @@ function getReportPreviewMessage(
shouldConsiderReceiptBeingScanned = false,
isPreviewMessageForParentChatReport = false,
policy: OnyxEntry = null,
+ isForListPreview = false,
): string {
const reportActionMessage = reportAction?.message?.[0].html ?? '';
+
if (isEmptyObject(report) || !report?.reportID) {
// The iouReport is not found locally after SignIn because the OpenApp API won't return iouReports if they're settled
// As a temporary solution until we know how to solve this the best, we just use the message that returned from BE
@@ -1992,7 +2007,9 @@ function getReportPreviewMessage(
}
const totalAmount = getMoneyRequestReimbursableTotal(report);
- const payerName = isExpenseReport(report) ? getPolicyName(report, false, policy) : getDisplayNameForParticipant(report.managerID, true);
+ const policyName = getPolicyName(report, false, policy);
+ const payerName = isExpenseReport(report) ? policyName : getDisplayNameForParticipant(report.managerID, !isPreviewMessageForParentChatReport);
+
const formattedAmount = CurrencyUtils.convertToDisplayString(totalAmount, report.currency);
if (isReportApproved(report) && isGroupPolicy(report)) {
@@ -2009,12 +2026,12 @@ function getReportPreviewMessage(
return Localize.translateLocal('iou.receiptScanning');
}
}
+ const originalMessage = reportAction?.originalMessage as IOUMessage | undefined;
// Show Paid preview message if it's settled or if the amount is paid & stuck at receivers end for only chat reports.
if (isSettled(report.reportID) || (report.isWaitingOnBankAccount && isPreviewMessageForParentChatReport)) {
// A settled report preview message can come in three formats "paid ... elsewhere" or "paid ... with Expensify"
let translatePhraseKey: TranslationPaths = 'iou.paidElsewhereWithAmount';
- const originalMessage = reportAction?.originalMessage as IOUMessage | undefined;
if (
[CONST.IOU.PAYMENT_TYPE.VBBA, CONST.IOU.PAYMENT_TYPE.EXPENSIFY].some((paymentType) => paymentType === originalMessage?.paymentType) ||
!!reportActionMessage.match(/ (with Expensify|using Expensify)$/) ||
@@ -2022,7 +2039,12 @@ function getReportPreviewMessage(
) {
translatePhraseKey = 'iou.paidWithExpensifyWithAmount';
}
- return Localize.translateLocal(translatePhraseKey, {amount: formattedAmount, payer: payerName ?? ''});
+
+ let actualPayerName = report.managerID === currentUserAccountID ? '' : getDisplayNameForParticipant(report.managerID, true);
+ actualPayerName = actualPayerName && isForListPreview && !isPreviewMessageForParentChatReport ? `${actualPayerName}:` : actualPayerName;
+ const payerDisplayName = isPreviewMessageForParentChatReport ? payerName : actualPayerName;
+
+ return Localize.translateLocal(translatePhraseKey, {amount: formattedAmount, payer: payerDisplayName ?? ''});
}
if (report.isWaitingOnBankAccount) {
@@ -2031,6 +2053,20 @@ function getReportPreviewMessage(
}
const containsNonReimbursable = hasNonReimbursableTransactions(report.reportID);
+
+ const lastActorID = reportAction?.actorAccountID;
+
+ // if we have the amount in the originalMessage and lastActorID, we can use that to display the preview message for the latest request
+ if (originalMessage?.amount !== undefined && lastActorID && !isPreviewMessageForParentChatReport) {
+ const amount = originalMessage?.amount;
+ const currency = originalMessage?.currency ?? report.currency ?? '';
+ const amountToDisplay = CurrencyUtils.convertToDisplayString(Math.abs(amount), currency);
+
+ // We only want to show the actor name in the preview if it's not the current user who took the action
+ const requestorName = lastActorID && lastActorID !== currentUserAccountID ? getDisplayNameForParticipant(lastActorID, !isPreviewMessageForParentChatReport) : '';
+ return `${requestorName ? `${requestorName}: ` : ''}${Localize.translateLocal('iou.requestedAmount', {formattedAmount: amountToDisplay})}`;
+ }
+
return Localize.translateLocal(containsNonReimbursable ? 'iou.payerSpentAmount' : 'iou.payerOwesAmount', {payer: payerName ?? '', amount: formattedAmount});
}
@@ -4194,6 +4230,31 @@ function navigateToPrivateNotes(report: Report, session: Session) {
Navigation.navigate(ROUTES.PRIVATE_NOTES_LIST.getRoute(report.reportID));
}
+/**
+ * Disable reply in thread action if:
+ *
+ * - The action is listed in the thread-disabled list
+ * - The action is a split bill action
+ * - The action is deleted and is not threaded
+ * - The action is a whisper action and it's neither a report preview nor IOU action
+ * - The action is the thread's first chat
+ */
+function shouldDisableThread(reportAction: ReportAction, reportID: string) {
+ const isSplitBillAction = ReportActionsUtils.isSplitBillAction(reportAction);
+ const isDeletedAction = ReportActionsUtils.isDeletedAction(reportAction);
+ const isReportPreviewAction = ReportActionsUtils.isReportPreviewAction(reportAction);
+ const isIOUAction = ReportActionsUtils.isMoneyRequestAction(reportAction);
+ const isWhisperAction = ReportActionsUtils.isWhisperAction(reportAction);
+
+ return (
+ CONST.REPORT.ACTIONS.THREAD_DISABLED.some((action: string) => action === reportAction.actionName) ||
+ isSplitBillAction ||
+ (isDeletedAction && !reportAction.childVisibleActionCount) ||
+ (isWhisperAction && !isReportPreviewAction && !isIOUAction) ||
+ isThreadFirstChat(reportAction, reportID)
+ );
+}
+
export {
getReportParticipantsTitle,
isReportMessageAttachment,
@@ -4366,6 +4427,7 @@ export {
canEditWriteCapability,
hasSmartscanError,
shouldAutoFocusOnKeyPress,
+ shouldDisableThread,
};
export type {ExpenseOriginalMessage, OptionData, OptimisticChatReport};
diff --git a/src/libs/SidebarUtils.ts b/src/libs/SidebarUtils.ts
index 4744426ecfd3..c4fad1a86906 100644
--- a/src/libs/SidebarUtils.ts
+++ b/src/libs/SidebarUtils.ts
@@ -223,6 +223,8 @@ function getOrderedReportIDs(
type ActorDetails = {
displayName?: string;
+ firstName?: string;
+ lastName?: string;
accountID?: number;
};
@@ -245,6 +247,7 @@ function getOptionData(
}
const result: ReportUtils.OptionData = {
+ text: '',
alternateText: null,
allReportErrors: null,
brickRoadIndicator: null,
@@ -307,7 +310,7 @@ function getOptionData(
result.isAllowedToComment = ReportUtils.canUserPerformWriteAction(report);
result.chatType = report.chatType;
- const hasMultipleParticipants = participantPersonalDetailList.length > 1 || result.isChatRoom || result.isPolicyExpenseChat;
+ const hasMultipleParticipants = participantPersonalDetailList.length > 1 || result.isChatRoom || result.isPolicyExpenseChat || ReportUtils.isExpenseReport(report);
const subtitle = ReportUtils.getChatRoomSubtitle(report);
const login = Str.removeSMSDomain(personalDetail?.login ?? '');
@@ -331,7 +334,11 @@ function getOptionData(
}
: null;
}
- const lastActorDisplayName = hasMultipleParticipants && lastActorDetails?.accountID && Number(lastActorDetails.accountID) !== currentUserAccountID ? lastActorDetails.displayName : '';
+
+ const shouldShowDisplayName = hasMultipleParticipants && lastActorDetails?.accountID && Number(lastActorDetails.accountID) !== currentUserAccountID;
+ const lastActorName = lastActorDetails?.firstName ?? lastActorDetails?.displayName;
+ const lastActorDisplayName = shouldShowDisplayName ? lastActorName : '';
+
let lastMessageText = lastMessageTextFromReport;
const reportAction = lastReportActions?.[report.reportID];
@@ -354,7 +361,10 @@ function getOptionData(
}
}
- if ((result.isChatRoom || result.isPolicyExpenseChat || result.isThread || result.isTaskReport) && !result.isArchivedRoom) {
+ const isThreadMessage =
+ ReportUtils.isThread(report) && reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT && reportAction?.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE;
+
+ if ((result.isChatRoom || result.isPolicyExpenseChat || result.isThread || result.isTaskReport || isThreadMessage) && !result.isArchivedRoom) {
const lastAction = visibleReportActionItems[report.reportID];
if (lastAction?.actionName === CONST.REPORT.ACTIONS.TYPE.RENAMED) {
diff --git a/src/libs/TransactionUtils.ts b/src/libs/TransactionUtils.ts
index 6905a542fa5b..75815a2448e4 100644
--- a/src/libs/TransactionUtils.ts
+++ b/src/libs/TransactionUtils.ts
@@ -107,7 +107,7 @@ function buildOptimisticTransaction(
currency,
reportID,
comment: commentJSON,
- merchant: merchant || CONST.TRANSACTION.DEFAULT_MERCHANT,
+ merchant: merchant || CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT,
created: created || DateUtils.getDBTime(),
pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD,
receipt,
diff --git a/src/libs/actions/IOU.js b/src/libs/actions/IOU.js
index d43fefca20bc..ecae885392b9 100644
--- a/src/libs/actions/IOU.js
+++ b/src/libs/actions/IOU.js
@@ -160,6 +160,7 @@ function startMoneyRequest_temporaryForRefactor(reportID, isFromGlobalCreate, io
reportID,
transactionID: newTransactionID,
isFromGlobalCreate,
+ merchant: CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT,
});
}
@@ -278,7 +279,7 @@ function resetMoneyRequestInfo(id = '') {
currency: lodashGet(currentUserPersonalDetails, 'localCurrencyCode', CONST.CURRENCY.USD),
comment: '',
participants: [],
- merchant: CONST.TRANSACTION.DEFAULT_MERCHANT,
+ merchant: CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT,
category: '',
tag: '',
created,
@@ -2202,17 +2203,28 @@ function editRegularMoneyRequest(transactionID, transactionThreadReportID, trans
},
},
{
- onyxMethod: Onyx.METHOD.SET,
+ onyxMethod: Onyx.METHOD.MERGE,
key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`,
- value: transaction,
+ value: {
+ ...transaction,
+ modifiedCreated: transaction.modifiedCreated ? transaction.modifiedCreated : null,
+ modifiedAmount: transaction.modifiedAmount ? transaction.modifiedAmount : null,
+ modifiedCurrency: transaction.modifiedCurrency ? transaction.modifiedCurrency : null,
+ modifiedMerchant: transaction.modifiedMerchant ? transaction.modifiedMerchant : null,
+ modifiedWaypoints: transaction.modifiedWaypoints ? transaction.modifiedWaypoints : null,
+ pendingFields: null,
+ },
},
{
- onyxMethod: Onyx.METHOD.SET,
+ onyxMethod: Onyx.METHOD.MERGE,
key: `${ONYXKEYS.COLLECTION.REPORT}${iouReport.reportID}`,
- value: iouReport,
+ value: {
+ ...iouReport,
+ cachedTotal: iouReport.cachedTotal ? iouReport.cachedTotal : null,
+ },
},
{
- onyxMethod: Onyx.METHOD.SET,
+ onyxMethod: Onyx.METHOD.MERGE,
key: `${ONYXKEYS.COLLECTION.REPORT}${iouReport.chatReportID}`,
value: chatReport,
},
diff --git a/src/libs/actions/PersonalDetails.ts b/src/libs/actions/PersonalDetails.ts
index 02b5f70db285..ff919f312987 100644
--- a/src/libs/actions/PersonalDetails.ts
+++ b/src/libs/actions/PersonalDetails.ts
@@ -60,31 +60,6 @@ function getDisplayName(login: string, personalDetail: Pick value?.login === userAccountIDOrLogin)?.[1];
-
- // It's possible for displayName to be empty string, so we must use "||" to fallback to userAccountIDOrLogin.
- // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
- return detailsByLogin?.displayName || userAccountIDOrLogin;
- }
-
- const detailsByAccountID = allPersonalDetails?.[accountID];
-
- // It's possible for displayName to be empty string, so we must use "||" to fallback to login or defaultDisplayName.
- // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
- return detailsByAccountID?.displayName || detailsByAccountID?.login || defaultDisplayName;
-}
-
/**
* Gets the first and last name from the user's personal details.
* If the login is the same as the displayName, then they don't exist,
@@ -585,7 +560,6 @@ export {
extractFirstAndLastNameFromAvailableDetails,
getCountryISO,
getDisplayName,
- getDisplayNameForTypingIndicator,
getPrivatePersonalDetails,
openPersonalDetailsPage,
openPublicProfilePage,
diff --git a/src/libs/actions/Report.ts b/src/libs/actions/Report.ts
index 4ac46fcf212e..ef24b64ce7c7 100644
--- a/src/libs/actions/Report.ts
+++ b/src/libs/actions/Report.ts
@@ -496,6 +496,7 @@ function openReport(
isLoadingInitialReportActions: true,
isLoadingOlderReportActions: false,
isLoadingNewerReportActions: false,
+ lastVisitTime: DateUtils.getDBTime(),
},
},
];
@@ -938,8 +939,18 @@ function readNewestAction(reportID: string) {
* Sets the last read time on a report
*/
function markCommentAsUnread(reportID: string, reportActionCreated: string) {
- // If no action created date is provided, use the last action's
- const actionCreationTime = reportActionCreated || (allReports?.[reportID]?.lastVisibleActionCreated ?? DateUtils.getDBTime(0));
+ const reportActions = allReportActions?.[reportID];
+
+ // Find the latest report actions from other users
+ const latestReportActionFromOtherUsers = Object.values(reportActions ?? {}).reduce((latest: ReportAction | null, current: ReportAction) => {
+ if (current.actorAccountID !== currentUserAccountID && (!latest || current.created > latest.created)) {
+ return current;
+ }
+ return latest;
+ }, null);
+
+ // If no action created date is provided, use the last action's from other user
+ const actionCreationTime = reportActionCreated || (latestReportActionFromOtherUsers?.created ?? DateUtils.getDBTime(0));
// We subtract 1 millisecond so that the lastReadTime is updated to just before a given reportAction's created date
// For example, if we want to mark a report action with ID 100 and created date '2014-04-01 16:07:02.999' unread, we set the lastReadTime to '2014-04-01 16:07:02.998'
@@ -1344,10 +1355,16 @@ function editReportComment(reportID: string, originalReportAction: OnyxEntry;
@@ -9,11 +10,6 @@ type AnchorOrigin = {
shiftVertical?: number;
};
-type AnchorPosition = {
- horizontal: number;
- vertical: number;
-};
-
/**
* Gets the x,y position of the passed in component for the purpose of anchoring another component to it.
*/
diff --git a/src/libs/getSecureEntryKeyboardType/types.ts b/src/libs/getSecureEntryKeyboardType/types.ts
index fe79440e3109..750c84133ea2 100644
--- a/src/libs/getSecureEntryKeyboardType/types.ts
+++ b/src/libs/getSecureEntryKeyboardType/types.ts
@@ -1,3 +1,5 @@
-type GetSecureEntryKeyboardType = (keyboardType: string, secureTextEntry: boolean, passwordHidden: boolean) => string;
+import {KeyboardTypeOptions} from 'react-native';
+
+type GetSecureEntryKeyboardType = (keyboardType: KeyboardTypeOptions | undefined, secureTextEntry: boolean, passwordHidden: boolean) => KeyboardTypeOptions | undefined;
export default GetSecureEntryKeyboardType;
diff --git a/src/libs/isInputAutoFilled.ts b/src/libs/isInputAutoFilled.ts
index e1b9942b0e78..fbe6240def47 100644
--- a/src/libs/isInputAutoFilled.ts
+++ b/src/libs/isInputAutoFilled.ts
@@ -1,10 +1,11 @@
+import {TextInput} from 'react-native';
import isSelectorSupported from './isSelectorSupported';
/**
* Check the input is auto filled or not
*/
-export default function isInputAutoFilled(input: Element): boolean {
- if (!input?.matches) {
+export default function isInputAutoFilled(input: (TextInput | HTMLElement) | null): boolean {
+ if ((!!input && !('matches' in input)) || !input?.matches) {
return false;
}
if (isSelectorSupported(':autofill')) {
diff --git a/src/libs/migrateOnyx.js b/src/libs/migrateOnyx.js
index 5daba3686208..9b8b4056e3e5 100644
--- a/src/libs/migrateOnyx.js
+++ b/src/libs/migrateOnyx.js
@@ -2,6 +2,7 @@ import _ from 'underscore';
import Log from './Log';
import KeyReportActionsDraftByReportActionID from './migrations/KeyReportActionsDraftByReportActionID';
import PersonalDetailsByAccountID from './migrations/PersonalDetailsByAccountID';
+import RemoveEmptyReportActionsDrafts from './migrations/RemoveEmptyReportActionsDrafts';
import RenameReceiptFilename from './migrations/RenameReceiptFilename';
import TransactionBackupsToCollection from './migrations/TransactionBackupsToCollection';
@@ -11,7 +12,7 @@ export default function () {
return new Promise((resolve) => {
// Add all migrations to an array so they are executed in order
- const migrationPromises = [PersonalDetailsByAccountID, RenameReceiptFilename, KeyReportActionsDraftByReportActionID, TransactionBackupsToCollection];
+ const migrationPromises = [PersonalDetailsByAccountID, RenameReceiptFilename, KeyReportActionsDraftByReportActionID, TransactionBackupsToCollection, RemoveEmptyReportActionsDrafts];
// Reduce all promises down to a single promise. All promises run in a linear fashion, waiting for the
// previous promise to finish before moving onto the next one.
diff --git a/src/libs/migrations/RemoveEmptyReportActionsDrafts.ts b/src/libs/migrations/RemoveEmptyReportActionsDrafts.ts
new file mode 100644
index 000000000000..d8816198e537
--- /dev/null
+++ b/src/libs/migrations/RemoveEmptyReportActionsDrafts.ts
@@ -0,0 +1,76 @@
+import _ from 'lodash';
+import Onyx, {OnyxEntry} from 'react-native-onyx';
+import Log from '@libs/Log';
+import ONYXKEYS from '@src/ONYXKEYS';
+import {ReportActionsDraft, ReportActionsDrafts} from '@src/types/onyx';
+import {isEmptyObject} from '@src/types/utils/EmptyObject';
+
+type ReportActionsDraftsKey = `${typeof ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS}${string}`;
+
+/**
+ * This migration removes empty drafts from reportActionsDrafts, which was previously used to mark a draft as being non-existent (e.g. upon cancel).
+ */
+export default function (): Promise {
+ return new Promise((resolve) => {
+ const connectionID = Onyx.connect({
+ key: ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS,
+ waitForCollectionCallback: true,
+ callback: (allReportActionsDrafts) => {
+ Onyx.disconnect(connectionID);
+
+ if (!allReportActionsDrafts) {
+ Log.info('[Migrate Onyx] Skipped migration RemoveEmptyReportActionsDrafts because there were no reportActionsDrafts');
+ return resolve();
+ }
+
+ const newReportActionsDrafts: Record> = {};
+ Object.entries(allReportActionsDrafts).forEach(([onyxKey, reportActionDrafts]) => {
+ const newReportActionsDraftsForReport: Record = {};
+
+ // Whether there is at least one draft in this report that has to be migrated
+ let hasUnmigratedDraft = false;
+
+ if (reportActionDrafts) {
+ Object.entries(reportActionDrafts).forEach(([reportActionID, reportActionDraft]) => {
+ // If the draft is a string, it means it hasn't been migrated yet
+ if (typeof reportActionDraft === 'string') {
+ hasUnmigratedDraft = true;
+ Log.info(`[Migrate Onyx] Migrating draft for report action ${reportActionID}`);
+
+ if (_.isEmpty(reportActionDraft)) {
+ Log.info(`[Migrate Onyx] Removing draft for report action ${reportActionID}`);
+ return;
+ }
+
+ newReportActionsDraftsForReport[reportActionID] = {message: reportActionDraft};
+ } else {
+ // We've already migrated this draft, so keep the existing value
+ newReportActionsDraftsForReport[reportActionID] = reportActionDraft;
+ }
+ });
+ }
+
+ if (isEmptyObject(newReportActionsDraftsForReport)) {
+ Log.info('[Migrate Onyx] NO REMAINING');
+ // Clear if there are no drafts remaining
+ newReportActionsDrafts[onyxKey as ReportActionsDraftsKey] = null;
+ } else if (hasUnmigratedDraft) {
+ // Only migrate if there are unmigrated drafts, there's no need to overwrite this onyx key with the same data
+ newReportActionsDrafts[onyxKey as ReportActionsDraftsKey] = newReportActionsDraftsForReport;
+ }
+ });
+
+ if (isEmptyObject(newReportActionsDrafts)) {
+ Log.info('[Migrate Onyx] Skipped migration RemoveEmptyReportActionsDrafts because there are no actions drafts to migrate');
+ return resolve();
+ }
+
+ Log.info(`[Migrate Onyx] Updating drafts for ${Object.keys(newReportActionsDrafts).length} reports`);
+ Onyx.multiSet(newReportActionsDrafts).then(() => {
+ Log.info('[Migrate Onyx] Ran migration RemoveEmptyReportActionsDrafts successfully');
+ resolve();
+ });
+ },
+ });
+ });
+}
diff --git a/src/pages/EditRequestMerchantPage.js b/src/pages/EditRequestMerchantPage.js
index 5fa14d850f45..c8766d9acc67 100644
--- a/src/pages/EditRequestMerchantPage.js
+++ b/src/pages/EditRequestMerchantPage.js
@@ -18,22 +18,27 @@ const propTypes = {
/** Callback to fire when the Save button is pressed */
onSubmit: PropTypes.func.isRequired,
+
+ /** Boolean to enable validation */
+ isPolicyExpenseChat: PropTypes.bool.isRequired,
};
-function EditRequestMerchantPage({defaultMerchant, onSubmit}) {
+function EditRequestMerchantPage({defaultMerchant, onSubmit, isPolicyExpenseChat}) {
const styles = useThemeStyles();
const {translate} = useLocalize();
const merchantInputRef = useRef(null);
+ const isEmptyMerchant = defaultMerchant === '' || defaultMerchant === CONST.TRANSACTION.UNKNOWN_MERCHANT || defaultMerchant === CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT;
- const validate = useCallback((value) => {
- const errors = {};
-
- if (_.isEmpty(value.merchant)) {
- errors.merchant = 'common.error.fieldRequired';
- }
-
- return errors;
- }, []);
+ const validate = useCallback(
+ (value) => {
+ const errors = {};
+ if (_.isEmpty(value.merchant) && value.merchant.trim() === '' && isPolicyExpenseChat) {
+ errors.merchant = 'common.error.fieldRequired';
+ }
+ return errors;
+ },
+ [isPolicyExpenseChat],
+ );
return (
{
+ const newTrimmedMerchant = transactionChanges.merchant.trim();
+
// In case the merchant hasn't been changed, do not make the API request.
- if (transactionChanges.merchant.trim() === transactionMerchant) {
+ // In case the merchant has been set to empty string while current merchant is partial, do nothing too.
+ if (newTrimmedMerchant === transactionMerchant || (newTrimmedMerchant === '' && transactionMerchant === CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT)) {
Navigation.dismissModal();
return;
}
- editMoneyRequest({merchant: transactionChanges.merchant.trim()});
+
+ // This is possible only in case of IOU requests.
+ if (newTrimmedMerchant === '') {
+ editMoneyRequest({merchant: CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT});
+ return;
+ }
+ editMoneyRequest({merchant: newTrimmedMerchant});
}}
/>
);
diff --git a/src/pages/home/ReportScreen.js b/src/pages/home/ReportScreen.js
index 7259ede3f480..8e177e0c2e64 100644
--- a/src/pages/home/ReportScreen.js
+++ b/src/pages/home/ReportScreen.js
@@ -1,3 +1,4 @@
+import {useIsFocused} from '@react-navigation/native';
import lodashGet from 'lodash/get';
import PropTypes from 'prop-types';
import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react';
@@ -256,6 +257,14 @@ function ReportScreen({
return reportIDFromPath !== '' && report.reportID && !isTransitioning;
}, [route, report]);
+ const isFocused = useIsFocused();
+ useEffect(() => {
+ if (!report.reportID || !isFocused) {
+ return;
+ }
+ Report.updateLastVisitTime(report.reportID);
+ }, [report.reportID, isFocused]);
+
const fetchReportIfNeeded = useCallback(() => {
const reportIDFromPath = getReportID(route);
diff --git a/src/pages/home/report/ContextMenu/ContextMenuActions.js b/src/pages/home/report/ContextMenu/ContextMenuActions.js
index f1a46785a59a..b2e74a2b7cbf 100644
--- a/src/pages/home/report/ContextMenu/ContextMenuActions.js
+++ b/src/pages/home/report/ContextMenu/ContextMenuActions.js
@@ -23,7 +23,7 @@ import * as Download from '@userActions/Download';
import * as Report from '@userActions/Report';
import CONST from '@src/CONST';
import ROUTES from '@src/ROUTES';
-import {clearActiveReportAction, hideContextMenu, showDeleteModal} from './ReportActionContextMenu';
+import {hideContextMenu, showDeleteModal} from './ReportActionContextMenu';
/**
* Gets the HTML version of the message in an action.
@@ -134,17 +134,7 @@ export default [
if (type !== CONST.CONTEXT_MENU_TYPES.REPORT_ACTION) {
return false;
}
- const isCommentAction = reportAction.actionName === CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT;
- const isReportPreviewAction = reportAction.actionName === CONST.REPORT.ACTIONS.TYPE.REPORTPREVIEW;
- const isIOUAction = reportAction.actionName === CONST.REPORT.ACTIONS.TYPE.IOU && !ReportActionsUtils.isSplitBillAction(reportAction);
- const isModifiedExpenseAction = ReportActionsUtils.isModifiedExpenseAction(reportAction);
- const isTaskAction = ReportActionsUtils.isTaskAction(reportAction);
- const isWhisperAction = ReportActionsUtils.isWhisperAction(reportAction);
- return (
- (!isWhisperAction || isIOUAction || isReportPreviewAction) &&
- (isCommentAction || isReportPreviewAction || isIOUAction || isModifiedExpenseAction || isTaskAction) &&
- !ReportUtils.isThreadFirstChat(reportAction, reportID)
- );
+ return !ReportUtils.shouldDisableThread(reportAction, reportID);
},
onPress: (closePopover, {reportAction, reportID}) => {
if (closePopover) {
@@ -390,7 +380,13 @@ export default [
Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(childReportID));
return;
}
- const editAction = () => Report.saveReportActionDraft(reportID, reportAction, _.isEmpty(draftMessage) ? getActionText(reportAction) : '');
+ const editAction = () => {
+ if (_.isUndefined(draftMessage)) {
+ Report.saveReportActionDraft(reportID, reportAction, getActionText(reportAction));
+ } else {
+ Report.deleteReportActionDraft(reportID, reportAction);
+ }
+ };
if (closePopover) {
// Hide popover, then call editAction
@@ -417,12 +413,12 @@ export default [
onPress: (closePopover, {reportID, reportAction}) => {
if (closePopover) {
// Hide popover, then call showDeleteConfirmModal
- hideContextMenu(false, () => showDeleteModal(reportID, reportAction, true, clearActiveReportAction, clearActiveReportAction));
+ hideContextMenu(false, () => showDeleteModal(reportID, reportAction));
return;
}
// No popover to hide, call showDeleteConfirmModal immediately
- showDeleteModal(reportID, reportAction, true, clearActiveReportAction, clearActiveReportAction);
+ showDeleteModal(reportID, reportAction);
},
getDescription: () => {},
},
diff --git a/src/pages/home/report/ContextMenu/PopoverReportActionContextMenu.js b/src/pages/home/report/ContextMenu/PopoverReportActionContextMenu.js
index 7f60b9d9b4d5..1c93c3bc90c7 100644
--- a/src/pages/home/report/ContextMenu/PopoverReportActionContextMenu.js
+++ b/src/pages/home/report/ContextMenu/PopoverReportActionContextMenu.js
@@ -17,7 +17,7 @@ function PopoverReportActionContextMenu(_props, ref) {
const reportActionIDRef = useRef('0');
const originalReportIDRef = useRef('0');
const selectionRef = useRef('');
- const reportActionDraftMessageRef = useRef('');
+ const reportActionDraftMessageRef = useRef(undefined);
const cursorRelativePosition = useRef({
horizontal: 0,
@@ -226,7 +226,7 @@ function PopoverReportActionContextMenu(_props, ref) {
}
selectionRef.current = '';
- reportActionDraftMessageRef.current = '';
+ reportActionDraftMessageRef.current = undefined;
setIsPopoverVisible(false);
};
@@ -322,6 +322,7 @@ function PopoverReportActionContextMenu(_props, ref) {
onConfirm={confirmDeleteAndHideModal}
onCancel={hideDeleteModal}
onModalHide={() => {
+ clearActiveReportAction();
callbackWhenDeleteModalHide.current();
}}
prompt={translate('reportActionContextMenu.deleteConfirmation', {action: reportAction})}
diff --git a/src/pages/home/report/ContextMenu/ReportActionContextMenu.ts b/src/pages/home/report/ContextMenu/ReportActionContextMenu.ts
index b269bc276b55..1e1fc700d8e0 100644
--- a/src/pages/home/report/ContextMenu/ReportActionContextMenu.ts
+++ b/src/pages/home/report/ContextMenu/ReportActionContextMenu.ts
@@ -98,7 +98,7 @@ function showContextMenu(
reportID = '0',
reportActionID = '0',
originalReportID = '0',
- draftMessage = '',
+ draftMessage = undefined,
onShow = () => {},
onHide = () => {},
isArchivedRoom = false,
diff --git a/src/pages/home/report/ContextMenu/genericReportActionContextMenuPropTypes.js b/src/pages/home/report/ContextMenu/genericReportActionContextMenuPropTypes.js
index 3d8667e44e62..b9f892c1b9ff 100644
--- a/src/pages/home/report/ContextMenu/genericReportActionContextMenuPropTypes.js
+++ b/src/pages/home/report/ContextMenu/genericReportActionContextMenuPropTypes.js
@@ -28,7 +28,7 @@ const defaultProps = {
isMini: false,
isVisible: false,
selection: '',
- draftMessage: '',
+ draftMessage: undefined,
};
export {propTypes, defaultProps};
diff --git a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.js b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.js
index 8def3a53ca0d..c2e8234ab23f 100644
--- a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.js
+++ b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.js
@@ -1,10 +1,11 @@
import {useIsFocused, useNavigation} from '@react-navigation/native';
import lodashGet from 'lodash/get';
import React, {useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState} from 'react';
-import {findNodeHandle, NativeModules, View} from 'react-native';
+import {findNodeHandle, InteractionManager, NativeModules, View} from 'react-native';
import {withOnyx} from 'react-native-onyx';
import _ from 'underscore';
import Composer from '@components/Composer';
+import {PopoverContext} from '@components/PopoverProvider';
import withKeyboardState from '@components/withKeyboardState';
import useDebounce from '@hooks/useDebounce';
import useLocalize from '@hooks/useLocalize';
@@ -21,6 +22,7 @@ import getDraftComment from '@libs/ComposerUtils/getDraftComment';
import convertToLTRForComposer from '@libs/convertToLTRForComposer';
import * as EmojiUtils from '@libs/EmojiUtils';
import focusComposerWithDelay from '@libs/focusComposerWithDelay';
+import getPlatform from '@libs/getPlatform';
import * as KeyDownListener from '@libs/KeyboardShortcut/KeyDownPressListener';
import ReportActionComposeFocusManager from '@libs/ReportActionComposeFocusManager';
import * as ReportActionsUtils from '@libs/ReportActionsUtils';
@@ -40,6 +42,8 @@ import {defaultProps, propTypes} from './composerWithSuggestionsProps';
const {RNTextInputReset} = NativeModules;
+const isIOSNative = getPlatform() === CONST.PLATFORM.IOS;
+
/**
* Broadcast that the user is typing. Debounced to limit how often we publish client events.
* @param {String} reportID
@@ -103,6 +107,7 @@ function ComposerWithSuggestions({
// For testing
children,
}) {
+ const {isOpen: isPopoverOpen} = React.useContext(PopoverContext);
const theme = useTheme();
const styles = useThemeStyles();
const StyleUtils = useStyleUtils();
@@ -136,6 +141,8 @@ function ComposerWithSuggestions({
const textInputRef = useRef(null);
const insertedEmojisRef = useRef([]);
+ const syncSelectionWithOnChangeTextRef = useRef(null);
+
// A flag to indicate whether the onScroll callback is likely triggered by a layout change (caused by text change) or not
const isScrollLikelyLayoutTriggered = useRef(false);
const suggestions = lodashGet(suggestionsRef, 'current.getSuggestions', () => [])();
@@ -235,6 +242,11 @@ function ComposerWithSuggestions({
setValue(newCommentConverted);
if (commentValue !== newComment) {
const position = Math.max(selection.end + (newComment.length - commentRef.current.length), cursorPosition || 0);
+
+ if (isIOSNative) {
+ syncSelectionWithOnChangeTextRef.current = {position, value: newComment};
+ }
+
setSelection({
start: position,
end: position,
@@ -367,6 +379,25 @@ function ComposerWithSuggestions({
[isKeyboardShown, isSmallScreenWidth, parentReportActions, report, reportActions, reportID, handleSendMessage, suggestionsRef, valueRef],
);
+ const onChangeText = useCallback(
+ (commentValue) => {
+ updateComment(commentValue, true);
+
+ if (isIOSNative && syncSelectionWithOnChangeTextRef.current) {
+ const positionSnapshot = syncSelectionWithOnChangeTextRef.current.position;
+ syncSelectionWithOnChangeTextRef.current = null;
+
+ // ensure that selection is set imperatively after all state changes are effective
+ InteractionManager.runAfterInteractions(() => {
+ // note: this implementation is only available on non-web RN, thus the wrapping
+ // 'if' block contains a redundant (since the ref is only used on iOS) platform check
+ textInputRef.current.setSelection(positionSnapshot, positionSnapshot);
+ });
+ }
+ },
+ [updateComment],
+ );
+
const onSelectionChange = useCallback(
(e) => {
if (textInputRef.current && textInputRef.current.isFocused() && suggestionsRef.current.onSelectionChange(e)) {
@@ -398,9 +429,15 @@ function ComposerWithSuggestions({
* @param {Boolean} [shouldDelay=false] Impose delay before focusing the composer
* @memberof ReportActionCompose
*/
- const focus = useCallback((shouldDelay = false) => {
- focusComposerWithDelay(textInputRef.current)(shouldDelay);
- }, []);
+ const focus = useCallback(
+ (shouldDelay = false) => {
+ if (isPopoverOpen) {
+ return;
+ }
+ focusComposerWithDelay(textInputRef.current)(shouldDelay);
+ },
+ [isPopoverOpen],
+ );
const setUpComposeFocusManager = useCallback(() => {
// This callback is used in the contextMenuActions to manage giving focus back to the compose input.
@@ -531,7 +568,7 @@ function ComposerWithSuggestions({
ref={setTextInputRef}
placeholder={inputPlaceholder}
placeholderTextColor={theme.placeholderText}
- onChangeText={(commentValue) => updateComment(commentValue, true)}
+ onChangeText={onChangeText}
onKeyPress={triggerHotkeyActions}
textAlignVertical="top"
style={[styles.textInputCompose, isComposerFullSize ? styles.textInputFullCompose : styles.textInputCollapseCompose]}
diff --git a/src/pages/home/report/ReportActionItem.js b/src/pages/home/report/ReportActionItem.js
index 57627d819197..a7972b97f5c1 100644
--- a/src/pages/home/report/ReportActionItem.js
+++ b/src/pages/home/report/ReportActionItem.js
@@ -120,7 +120,7 @@ const propTypes = {
};
const defaultProps = {
- draftMessage: '',
+ draftMessage: undefined,
preferredSkinTone: CONST.EMOJI_DEFAULT_SKIN_TONE,
emojiReactions: {},
shouldShowSubscriptAvatar: false,
@@ -157,12 +157,6 @@ function ReportActionItem(props) {
// IOUDetails only exists when we are sending money
const isSendingMoney = originalMessage.type === CONST.IOU.REPORT_ACTION_TYPE.PAY && _.has(originalMessage, 'IOUDetails');
- // When active action changes, we need to update the `isContextMenuActive` state
- const isActiveReportActionForMenu = ReportActionContextMenu.isActiveReportAction(props.action.reportActionID);
- useEffect(() => {
- setIsContextMenuActive(isActiveReportActionForMenu);
- }, [isActiveReportActionForMenu]);
-
const updateHiddenState = useCallback(
(isHiddenValue) => {
setIsHidden(isHiddenValue);
@@ -203,7 +197,7 @@ function ReportActionItem(props) {
}, [isDeletedParentAction, props.action.reportActionID]);
useEffect(() => {
- if (prevDraftMessage || !props.draftMessage) {
+ if (!_.isUndefined(prevDraftMessage) || _.isUndefined(props.draftMessage)) {
return;
}
@@ -225,10 +219,10 @@ function ReportActionItem(props) {
}, [props.action, props.report.reportID]);
useEffect(() => {
- if (!props.draftMessage || !ReportActionsUtils.isDeletedAction(props.action)) {
+ if (_.isUndefined(props.draftMessage) || !ReportActionsUtils.isDeletedAction(props.action)) {
return;
}
- Report.saveReportActionDraft(props.report.reportID, props.action, '');
+ Report.deleteReportActionDraft(props.report.reportID, props.action);
}, [props.draftMessage, props.action, props.report.reportID]);
// Hide the message if it is being moderated for a higher offense, or is hidden by a moderator
@@ -266,7 +260,7 @@ function ReportActionItem(props) {
const showPopover = useCallback(
(event) => {
// Block menu on the message being Edited or if the report action item has errors
- if (props.draftMessage || !_.isEmpty(props.action.errors)) {
+ if (!_.isUndefined(props.draftMessage) || !_.isEmpty(props.action.errors)) {
return;
}
@@ -432,7 +426,7 @@ function ReportActionItem(props) {
const hasBeenFlagged = !_.contains([CONST.MODERATION.MODERATOR_DECISION_APPROVED, CONST.MODERATION.MODERATOR_DECISION_PENDING], moderationDecision);
children = (
- {!props.draftMessage ? (
+ {_.isUndefined(props.draftMessage) ? (
Number(accountID));
- const draftMessageRightAlign = props.draftMessage ? styles.chatItemReactionsDraftRight : {};
+ const draftMessageRightAlign = !_.isUndefined(props.draftMessage) ? styles.chatItemReactionsDraftRight : {};
return (
<>
{children}
{Permissions.canUseLinkPreviews() && !isHidden && !_.isEmpty(props.action.linkMetadata) && (
-
+
!_.isEmpty(item))} />
)}
@@ -548,7 +542,7 @@ function ReportActionItem(props) {
const renderReportActionItem = (hovered, isWhisper, hasErrors) => {
const content = renderItemContent(hovered || isContextMenuActive, isWhisper, hasErrors);
- if (props.draftMessage) {
+ if (!_.isUndefined(props.draftMessage)) {
return {content};
}
@@ -556,7 +550,7 @@ function ReportActionItem(props) {
return (
${props.translate('parentReportAction.deletedTask')}`} />
@@ -670,13 +664,13 @@ function ReportActionItem(props) {
onPressIn={() => props.isSmallScreenWidth && DeviceCapabilities.canUseTouchScreen() && ControlSelection.block()}
onPressOut={() => ControlSelection.unblock()}
onSecondaryInteraction={showPopover}
- preventDefaultContextMenu={!props.draftMessage && !hasErrors}
+ preventDefaultContextMenu={_.isUndefined(props.draftMessage) && !hasErrors}
withoutFocusOnSecondaryInteraction
accessibilityLabel={props.translate('accessibilityHints.chatMessage')}
>
{(hovered) => (
@@ -687,14 +681,14 @@ function ReportActionItem(props) {
originalReportID={originalReportID}
isArchivedRoom={ReportUtils.isArchivedRoom(props.report)}
displayAsGroup={props.displayAsGroup}
- isVisible={hovered && !props.draftMessage && !hasErrors}
+ isVisible={hovered && _.isUndefined(props.draftMessage) && !hasErrors}
draftMessage={props.draftMessage}
isChronosReport={ReportUtils.chatIncludesChronos(originalReport)}
/>
-
+
ReportActions.clearReportActionErrors(props.report.reportID, props.action)}
- pendingAction={props.draftMessage ? null : props.action.pendingAction}
+ pendingAction={!_.isUndefined(props.draftMessage) ? null : props.action.pendingAction}
shouldHideOnDelete={!ReportActionsUtils.isThreadParentMessage(props.action, props.report.reportID)}
errors={props.action.errors}
errorRowStyles={[styles.ml10, styles.mr2]}
@@ -750,7 +744,7 @@ export default compose(
transformValue: (drafts, props) => {
const originalReportID = ReportUtils.getOriginalReportID(props.report.reportID, props.action);
const draftKey = `${ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS}${originalReportID}`;
- return lodashGet(drafts, [draftKey, props.action.reportActionID], '');
+ return lodashGet(drafts, [draftKey, props.action.reportActionID, 'message']);
},
}),
withOnyx({
diff --git a/src/pages/home/report/ReportActionItemMessageEdit.js b/src/pages/home/report/ReportActionItemMessageEdit.js
index d2b22921a15d..dbd3262f30d5 100644
--- a/src/pages/home/report/ReportActionItemMessageEdit.js
+++ b/src/pages/home/report/ReportActionItemMessageEdit.js
@@ -270,16 +270,10 @@ function ReportActionItemMessageEdit(props) {
draftRef.current = newDraft;
- // This component is rendered only when draft is set to a non-empty string. In order to prevent component
- // unmount when user deletes content of textarea, we set previous message instead of empty string.
- if (newDraft.trim().length > 0) {
- // We want to escape the draft message to differentiate the HTML from the report action and the HTML the user drafted.
- debouncedSaveDraft(_.escape(newDraft));
- } else {
- debouncedSaveDraft(props.action.message[0].html);
- }
+ // We want to escape the draft message to differentiate the HTML from the report action and the HTML the user drafted.
+ debouncedSaveDraft(_.escape(newDraft));
},
- [props.action.message, debouncedSaveDraft, debouncedUpdateFrequentlyUsedEmojis, props.preferredSkinTone, preferredLocale, selection.end],
+ [debouncedSaveDraft, debouncedUpdateFrequentlyUsedEmojis, props.preferredSkinTone, preferredLocale, selection.end],
);
useEffect(() => {
@@ -292,7 +286,7 @@ function ReportActionItemMessageEdit(props) {
*/
const deleteDraft = useCallback(() => {
debouncedSaveDraft.cancel();
- Report.saveReportActionDraft(props.reportID, props.action, '');
+ Report.deleteReportActionDraft(props.reportID, props.action);
if (isActive()) {
ReportActionComposeFocusManager.clear();
diff --git a/src/pages/home/report/ReportActionItemSingle.js b/src/pages/home/report/ReportActionItemSingle.tsx
similarity index 57%
rename from src/pages/home/report/ReportActionItemSingle.js
rename to src/pages/home/report/ReportActionItemSingle.tsx
index c47274fa5f94..69bbd924caef 100644
--- a/src/pages/home/report/ReportActionItemSingle.js
+++ b/src/pages/home/report/ReportActionItemSingle.tsx
@@ -1,8 +1,5 @@
-import lodashGet from 'lodash/get';
-import PropTypes from 'prop-types';
import React, {useCallback, useMemo} from 'react';
-import {View} from 'react-native';
-import _ from 'underscore';
+import {StyleProp, View, ViewStyle} from 'react-native';
import Avatar from '@components/Avatar';
import MultipleAvatars from '@components/MultipleAvatars';
import OfflineWithFeedback from '@components/OfflineWithFeedback';
@@ -12,7 +9,7 @@ import SubscriptAvatar from '@components/SubscriptAvatar';
import Text from '@components/Text';
import Tooltip from '@components/Tooltip';
import UserDetailsTooltip from '@components/UserDetailsTooltip';
-import withLocalize, {withLocalizePropTypes} from '@components/withLocalize';
+import useLocalize from '@hooks/useLocalize';
import useStyleUtils from '@hooks/useStyleUtils';
import useTheme from '@hooks/useTheme';
import useThemeStyles from '@hooks/useThemeStyles';
@@ -21,112 +18,136 @@ import DateUtils from '@libs/DateUtils';
import Navigation from '@libs/Navigation/Navigation';
import * as ReportUtils from '@libs/ReportUtils';
import * as UserUtils from '@libs/UserUtils';
-import reportPropTypes from '@pages/reportPropTypes';
-import stylePropTypes from '@styles/stylePropTypes';
import CONST from '@src/CONST';
import ROUTES from '@src/ROUTES';
+import type {Report, ReportAction} from '@src/types/onyx';
+import {AvatarType} from '@src/types/onyx/OnyxCommon';
import ReportActionItemDate from './ReportActionItemDate';
import ReportActionItemFragment from './ReportActionItemFragment';
-import reportActionPropTypes from './reportActionPropTypes';
-const propTypes = {
+type ReportActionItemSingleProps = {
/** All the data of the action */
- action: PropTypes.shape(reportActionPropTypes).isRequired,
+ action: ReportAction;
/** Styles for the outermost View */
- wrapperStyle: stylePropTypes,
+ wrapperStyle?: StyleProp;
/** Children view component for this action item */
- children: PropTypes.node.isRequired,
+ children: React.ReactNode;
/** Report for this action */
- report: reportPropTypes,
+ report: Report;
/** IOU Report for this action, if any */
- iouReport: reportPropTypes,
+ iouReport?: Report;
/** Show header for action */
- showHeader: PropTypes.bool,
+ showHeader?: boolean;
/** Determines if the avatar is displayed as a subscript (positioned lower than normal) */
- shouldShowSubscriptAvatar: PropTypes.bool,
+ shouldShowSubscriptAvatar?: boolean;
/** If the message has been flagged for moderation */
- hasBeenFlagged: PropTypes.bool,
+ hasBeenFlagged?: boolean;
/** If the action is being hovered */
- isHovered: PropTypes.bool,
-
- ...withLocalizePropTypes,
+ isHovered?: boolean;
};
-const defaultProps = {
- wrapperStyle: undefined,
- showHeader: true,
- shouldShowSubscriptAvatar: false,
- hasBeenFlagged: false,
- report: undefined,
- iouReport: undefined,
- isHovered: false,
+type SubAvatar = {
+ /** Avatar source to display */
+ source: UserUtils.AvatarSource;
+
+ /** Denotes whether it is a user avatar or a workspace avatar */
+ type: AvatarType;
+
+ /** Owner of the avatar. If user, displayName. If workspace, policy name */
+ name: string;
+
+ /** Avatar id */
+ id?: number | string;
+
+ /** A fallback avatar icon to display when there is an error on loading avatar from remote URL */
+ fallbackIcon?: UserUtils.AvatarSource;
};
-const showUserDetails = (accountID) => {
+const showUserDetails = (accountID: string) => {
Navigation.navigate(ROUTES.PROFILE.getRoute(accountID));
};
-const showWorkspaceDetails = (reportID) => {
+const showWorkspaceDetails = (reportID: string) => {
Navigation.navigate(ROUTES.REPORT_WITH_ID_DETAILS.getRoute(reportID));
};
-function ReportActionItemSingle(props) {
+function ReportActionItemSingle({
+ action,
+ children,
+ wrapperStyle,
+ showHeader = true,
+ shouldShowSubscriptAvatar = false,
+ hasBeenFlagged = false,
+ report,
+ iouReport,
+ isHovered = false,
+}: ReportActionItemSingleProps) {
const theme = useTheme();
const styles = useThemeStyles();
const StyleUtils = useStyleUtils();
- const personalDetails = usePersonalDetails() || CONST.EMPTY_OBJECT;
- const actorAccountID = props.action.actionName === CONST.REPORT.ACTIONS.TYPE.REPORTPREVIEW && props.iouReport ? props.iouReport.managerID : props.action.actorAccountID;
+ const {translate} = useLocalize();
+ const personalDetails = usePersonalDetails() ?? CONST.EMPTY_OBJECT;
+ const actorAccountID = action.actionName === CONST.REPORT.ACTIONS.TYPE.REPORTPREVIEW && iouReport ? iouReport.managerID : action.actorAccountID;
let displayName = ReportUtils.getDisplayNameForParticipant(actorAccountID);
- const {avatar, login, pendingFields, status, fallbackIcon} = personalDetails[actorAccountID] || {};
- let actorHint = (login || displayName || '').replace(CONST.REGEX.MERGED_ACCOUNT_PREFIX, '');
- const displayAllActors = useMemo(() => props.action.actionName === CONST.REPORT.ACTIONS.TYPE.REPORTPREVIEW && props.iouReport, [props.action.actionName, props.iouReport]);
- const isWorkspaceActor = ReportUtils.isPolicyExpenseChat(props.report) && (!actorAccountID || displayAllActors);
- let avatarSource = UserUtils.getAvatar(avatar, actorAccountID);
+ const {avatar, login, pendingFields, status, fallbackIcon} = personalDetails[actorAccountID ?? -1] ?? {};
+ // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
+ let actorHint = (login || (displayName ?? '')).replace(CONST.REGEX.MERGED_ACCOUNT_PREFIX, '');
+ const displayAllActors = useMemo(() => action.actionName === CONST.REPORT.ACTIONS.TYPE.REPORTPREVIEW && iouReport, [action.actionName, iouReport]);
+ const isWorkspaceActor = ReportUtils.isPolicyExpenseChat(report) && (!actorAccountID || displayAllActors);
+ let avatarSource = UserUtils.getAvatar(avatar ?? '', actorAccountID);
if (isWorkspaceActor) {
- displayName = ReportUtils.getPolicyName(props.report);
+ displayName = ReportUtils.getPolicyName(report);
actorHint = displayName;
- avatarSource = ReportUtils.getWorkspaceAvatar(props.report);
- } else if (props.action.delegateAccountID && personalDetails[props.action.delegateAccountID]) {
+ avatarSource = ReportUtils.getWorkspaceAvatar(report);
+ } else if (action.delegateAccountID && personalDetails[action.delegateAccountID]) {
// We replace the actor's email, name, and avatar with the Copilot manually for now. And only if we have their
// details. This will be improved upon when the Copilot feature is implemented.
- const delegateDetails = personalDetails[props.action.delegateAccountID];
- const delegateDisplayName = delegateDetails.displayName;
- actorHint = `${delegateDisplayName} (${props.translate('reportAction.asCopilot')} ${displayName})`;
+ const delegateDetails = personalDetails[action.delegateAccountID];
+ const delegateDisplayName = delegateDetails?.displayName;
+ actorHint = `${delegateDisplayName} (${translate('reportAction.asCopilot')} ${displayName})`;
displayName = actorHint;
- avatarSource = UserUtils.getAvatar(delegateDetails.avatar, props.action.delegateAccountID);
+ avatarSource = UserUtils.getAvatar(delegateDetails?.avatar ?? '', Number(action.delegateAccountID));
}
// If this is a report preview, display names and avatars of both people involved
- let secondaryAvatar = {};
+ let secondaryAvatar: SubAvatar;
const primaryDisplayName = displayName;
if (displayAllActors) {
// The ownerAccountID and actorAccountID can be the same if the a user requests money back from the IOU's original creator, in that case we need to use managerID to avoid displaying the same user twice
- const secondaryAccountId = props.iouReport.ownerAccountID === actorAccountID ? props.iouReport.managerID : props.iouReport.ownerAccountID;
- const secondaryUserDetails = personalDetails[secondaryAccountId] || {};
+ const secondaryAccountId = iouReport?.ownerAccountID === actorAccountID ? iouReport?.managerID : iouReport?.ownerAccountID;
+ const secondaryUserAvatar = personalDetails?.[secondaryAccountId ?? -1]?.avatar ?? '';
const secondaryDisplayName = ReportUtils.getDisplayNameForParticipant(secondaryAccountId);
displayName = `${primaryDisplayName} & ${secondaryDisplayName}`;
secondaryAvatar = {
- source: UserUtils.getAvatar(secondaryUserDetails.avatar, secondaryAccountId),
+ source: UserUtils.getAvatar(secondaryUserAvatar, secondaryAccountId),
type: CONST.ICON_TYPE_AVATAR,
- name: secondaryDisplayName,
+ name: secondaryDisplayName ?? '',
id: secondaryAccountId,
};
} else if (!isWorkspaceActor) {
- const avatarIconIndex = props.report.isOwnPolicyExpenseChat || ReportUtils.isPolicyExpenseChat(props.report) ? 0 : 1;
- const reportIcons = ReportUtils.getIcons(props.report, {});
+ // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
+ const avatarIconIndex = report.isOwnPolicyExpenseChat || ReportUtils.isPolicyExpenseChat(report) ? 0 : 1;
+ const reportIcons = ReportUtils.getIcons(report, {});
secondaryAvatar = reportIcons[avatarIconIndex];
+ } else {
+ secondaryAvatar = {name: '', source: '', type: 'avatar'};
}
- const icon = {source: avatarSource, type: isWorkspaceActor ? CONST.ICON_TYPE_WORKSPACE : CONST.ICON_TYPE_AVATAR, name: primaryDisplayName, id: isWorkspaceActor ? '' : actorAccountID};
+ const icon = {
+ source: avatarSource,
+ type: isWorkspaceActor ? CONST.ICON_TYPE_WORKSPACE : CONST.ICON_TYPE_AVATAR,
+ name: primaryDisplayName ?? '',
+ id: isWorkspaceActor ? '' : actorAccountID,
+ };
// Since the display name for a report action message is delivered with the report history as an array of fragments
// we'll need to take the displayName from personal details and have it be in the same format for now. Eventually,
@@ -138,29 +159,29 @@ function ReportActionItemSingle(props) {
text: displayName,
},
]
- : props.action.person;
+ : action.person;
- const reportID = props.report && props.report.reportID;
- const iouReportID = props.iouReport && props.iouReport.reportID;
+ const reportID = report?.reportID;
+ const iouReportID = iouReport?.reportID;
const showActorDetails = useCallback(() => {
if (isWorkspaceActor) {
showWorkspaceDetails(reportID);
} else {
// Show participants page IOU report preview
- if (displayAllActors) {
+ if (iouReportID && displayAllActors) {
Navigation.navigate(ROUTES.REPORT_PARTICIPANTS.getRoute(iouReportID));
return;
}
- showUserDetails(props.action.delegateAccountID ? props.action.delegateAccountID : actorAccountID);
+ showUserDetails(action.delegateAccountID ? action.delegateAccountID : String(actorAccountID));
}
- }, [isWorkspaceActor, reportID, actorAccountID, props.action.delegateAccountID, iouReportID, displayAllActors]);
+ }, [isWorkspaceActor, reportID, actorAccountID, action.delegateAccountID, iouReportID, displayAllActors]);
const shouldDisableDetailPage = useMemo(
() =>
actorAccountID === CONST.ACCOUNT_ID.NOTIFICATIONS ||
- (!isWorkspaceActor && ReportUtils.isOptimisticPersonalDetail(props.action.delegateAccountID ? props.action.delegateAccountID : actorAccountID)),
- [props.action, isWorkspaceActor, actorAccountID],
+ (!isWorkspaceActor && ReportUtils.isOptimisticPersonalDetail(action.delegateAccountID ? Number(action.delegateAccountID) : actorAccountID ?? -1)),
+ [action, isWorkspaceActor, actorAccountID],
);
const getAvatar = () => {
@@ -170,17 +191,15 @@ function ReportActionItemSingle(props) {
icons={[icon, secondaryAvatar]}
isInReportAction
shouldShowTooltip
- secondAvatarStyle={[StyleUtils.getBackgroundAndBorderStyle(theme.appBG), props.isHovered ? StyleUtils.getBackgroundAndBorderStyle(theme.hoverComponentBG) : undefined]}
+ secondAvatarStyle={[StyleUtils.getBackgroundAndBorderStyle(theme.appBG), isHovered ? StyleUtils.getBackgroundAndBorderStyle(theme.hoverComponentBG) : undefined]}
/>
);
}
- if (props.shouldShowSubscriptAvatar) {
+ if (shouldShowSubscriptAvatar) {
return (
);
@@ -188,7 +207,7 @@ function ReportActionItemSingle(props) {
return (
@@ -203,13 +222,13 @@ function ReportActionItemSingle(props) {
);
};
- const hasEmojiStatus = !displayAllActors && status && status.emojiCode;
- const formattedDate = DateUtils.getStatusUntilDate(lodashGet(status, 'clearAfter'));
- const statusText = lodashGet(status, 'text', '');
+ const hasEmojiStatus = !displayAllActors && status?.emojiCode;
+ const formattedDate = DateUtils.getStatusUntilDate(status?.clearAfter ?? '');
+ const statusText = status?.text ?? '';
const statusTooltipText = formattedDate ? `${statusText ? `${statusText} ` : ''}(${formattedDate})` : statusText;
return (
-
+
- {getAvatar()}
+ {getAvatar()}
- {props.showHeader ? (
+ {showHeader ? (
- {_.map(personArray, (fragment, index) => (
+ {personArray?.map((fragment, index) => (
@@ -249,20 +269,18 @@ function ReportActionItemSingle(props) {
{`${status.emojiCode}`}
+ >{`${status?.emojiCode}`}
)}
-
+
) : null}
- {props.children}
+ {children}
);
}
-ReportActionItemSingle.propTypes = propTypes;
-ReportActionItemSingle.defaultProps = defaultProps;
ReportActionItemSingle.displayName = 'ReportActionItemSingle';
-export default withLocalize(ReportActionItemSingle);
+export default ReportActionItemSingle;
diff --git a/src/pages/home/report/ReportTypingIndicator.js b/src/pages/home/report/ReportTypingIndicator.js
index 74778b364db1..785f1e3f6a1e 100755
--- a/src/pages/home/report/ReportTypingIndicator.js
+++ b/src/pages/home/report/ReportTypingIndicator.js
@@ -2,80 +2,70 @@ import PropTypes from 'prop-types';
import React, {useMemo} from 'react';
import {withOnyx} from 'react-native-onyx';
import _ from 'underscore';
-import networkPropTypes from '@components/networkPropTypes';
-import {withNetwork} from '@components/OnyxProvider';
import Text from '@components/Text';
import TextWithEllipsis from '@components/TextWithEllipsis';
-import withLocalize, {withLocalizePropTypes} from '@components/withLocalize';
+import useLocalize from '@hooks/useLocalize';
+import useNetwork from '@hooks/useNetwork';
import useThemeStyles from '@hooks/useThemeStyles';
-import compose from '@libs/compose';
-import * as PersonalDetails from '@userActions/PersonalDetails';
+import * as ReportUtils from '@libs/ReportUtils';
import ONYXKEYS from '@src/ONYXKEYS';
const propTypes = {
/** Key-value pairs of user accountIDs/logins and whether or not they are typing. Keys are accountIDs or logins. */
userTypingStatuses: PropTypes.objectOf(PropTypes.bool),
-
- /** Information about the network */
- network: networkPropTypes.isRequired,
-
- ...withLocalizePropTypes,
};
const defaultProps = {
userTypingStatuses: {},
};
-function ReportTypingIndicator(props) {
+function ReportTypingIndicator({userTypingStatuses}) {
+ const {translate} = useLocalize();
+ const {isOffline} = useNetwork();
+
const styles = useThemeStyles();
- const usersTyping = useMemo(() => _.filter(_.keys(props.userTypingStatuses), (loginOrAccountID) => props.userTypingStatuses[loginOrAccountID]), [props.userTypingStatuses]);
+ const usersTyping = useMemo(() => _.filter(_.keys(userTypingStatuses), (loginOrAccountID) => userTypingStatuses[loginOrAccountID]), [userTypingStatuses]);
+ const firstUserTyping = usersTyping[0];
+
+ const isUserTypingADisplayName = Number.isNaN(Number(firstUserTyping));
+
// If we are offline, the user typing statuses are not up-to-date so do not show them
- if (props.network.isOffline) {
+ if (isOffline || !firstUserTyping) {
return null;
}
- const numUsersTyping = _.size(usersTyping);
-
- // Decide on the Text element that will hold the display based on the number of users that are typing.
- switch (numUsersTyping) {
- case 0:
- return null;
-
- case 1:
- return (
-
- );
+ // If the user is typing on OldDot, firstUserTyping will be a string (the user's displayName)
+ const firstUserTypingDisplayName = isUserTypingADisplayName ? firstUserTyping : ReportUtils.getDisplayNameForParticipant(Number(firstUserTyping), false, false);
- default:
- return (
-
- {props.translate('reportTypingIndicator.multipleUsers')}
- {` ${props.translate('reportTypingIndicator.areTyping')}`}
-
- );
+ if (usersTyping.length === 1) {
+ return (
+
+ );
}
+ return (
+
+ {translate('reportTypingIndicator.multipleUsers')}
+ {` ${translate('reportTypingIndicator.areTyping')}`}
+
+ );
}
ReportTypingIndicator.propTypes = propTypes;
ReportTypingIndicator.defaultProps = defaultProps;
ReportTypingIndicator.displayName = 'ReportTypingIndicator';
-export default compose(
- withLocalize,
- withNetwork(),
- withOnyx({
- userTypingStatuses: {
- key: ({reportID}) => `${ONYXKEYS.COLLECTION.REPORT_USER_IS_TYPING}${reportID}`,
- initialValue: {},
- },
- }),
-)(ReportTypingIndicator);
+export default withOnyx({
+ userTypingStatuses: {
+ key: ({reportID}) => `${ONYXKEYS.COLLECTION.REPORT_USER_IS_TYPING}${reportID}`,
+ initialValue: {},
+ },
+})(ReportTypingIndicator);
diff --git a/src/pages/iou/MoneyRequestMerchantPage.js b/src/pages/iou/MoneyRequestMerchantPage.js
index bf799cd0957b..ce96a09446b9 100644
--- a/src/pages/iou/MoneyRequestMerchantPage.js
+++ b/src/pages/iou/MoneyRequestMerchantPage.js
@@ -53,6 +53,7 @@ function MoneyRequestMerchantPage({iou, route}) {
const {inputCallbackRef} = useAutoFocusInput();
const iouType = lodashGet(route, 'params.iouType', '');
const reportID = lodashGet(route, 'params.reportID', '');
+ const isEmptyMerchant = iou.merchant === '' || iou.merchant === CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT;
useEffect(() => {
const moneyRequestId = `${iouType}${reportID}`;
@@ -114,7 +115,7 @@ function MoneyRequestMerchantPage({iou, route}) {
InputComponent={TextInput}
inputID="moneyRequestMerchant"
name="moneyRequestMerchant"
- defaultValue={iou.merchant}
+ defaultValue={isEmptyMerchant ? '' : iou.merchant}
maxLength={CONST.MERCHANT_NAME_MAX_LENGTH}
label={translate('common.merchant')}
accessibilityLabel={translate('common.merchant')}
diff --git a/src/pages/iou/request/IOURequestRedirectToStartPage.js b/src/pages/iou/request/IOURequestRedirectToStartPage.js
new file mode 100644
index 000000000000..ee98c8006cdb
--- /dev/null
+++ b/src/pages/iou/request/IOURequestRedirectToStartPage.js
@@ -0,0 +1,69 @@
+import PropTypes from 'prop-types';
+import React, {useEffect} from 'react';
+import _ from 'underscore';
+import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView';
+import ScreenWrapper from '@components/ScreenWrapper';
+import Navigation from '@libs/Navigation/Navigation';
+import * as ReportUtils from '@libs/ReportUtils';
+import CONST from '@src/CONST';
+import ROUTES from '@src/ROUTES';
+
+const propTypes = {
+ /** Navigation route context info provided by react navigation */
+ route: PropTypes.shape({
+ /** Route specific parameters used on this screen */
+ params: PropTypes.shape({
+ /** The type of IOU report, i.e. bill, request, send */
+ iouType: PropTypes.oneOf(_.values(CONST.IOU.TYPE)).isRequired,
+
+ /** The type of IOU Request, i.e. manual, scan, distance */
+ iouRequestType: PropTypes.oneOf(_.values(CONST.IOU.REQUEST_TYPE)).isRequired,
+ }),
+ }).isRequired,
+};
+
+function IOURequestRedirectToStartPage({
+ route: {
+ params: {iouType, iouRequestType},
+ },
+}) {
+ const isIouTypeValid = _.values(CONST.IOU.TYPE).includes(iouType);
+ const isIouRequestTypeValid = _.values(CONST.IOU.REQUEST_TYPE).includes(iouRequestType);
+
+ useEffect(() => {
+ if (!isIouTypeValid || !isIouRequestTypeValid) {
+ return;
+ }
+
+ // Dismiss this modal because the redirects below will open a new modal and there shouldn't be two modals stacked on top of each other.
+ Navigation.dismissModal();
+
+ // Redirect the person to the right start page using a rendom reportID
+ const optimisticReportID = ReportUtils.generateReportID();
+ if (iouRequestType === CONST.IOU.REQUEST_TYPE.DISTANCE) {
+ Navigation.navigate(ROUTES.MONEY_REQUEST_CREATE_TAB_DISTANCE.getRoute(iouType, CONST.IOU.OPTIMISTIC_TRANSACTION_ID, optimisticReportID));
+ } else if (iouRequestType === CONST.IOU.REQUEST_TYPE.MANUAL) {
+ Navigation.navigate(ROUTES.MONEY_REQUEST_CREATE_TAB_MANUAL.getRoute(iouType, CONST.IOU.OPTIMISTIC_TRANSACTION_ID, optimisticReportID));
+ } else if (iouRequestType === CONST.IOU.REQUEST_TYPE.SCAN) {
+ Navigation.navigate(ROUTES.MONEY_REQUEST_CREATE_TAB_SCAN.getRoute(iouType, CONST.IOU.OPTIMISTIC_TRANSACTION_ID, optimisticReportID));
+ }
+
+ // This useEffect should only run on mount which is why there are no dependencies being passed in the second parameter
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
+ if (!isIouTypeValid || !isIouRequestTypeValid) {
+ return (
+
+
+
+ );
+ }
+
+ return null;
+}
+
+IOURequestRedirectToStartPage.displayName = 'IOURequestRedirectToStartPage';
+IOURequestRedirectToStartPage.propTypes = propTypes;
+
+export default IOURequestRedirectToStartPage;
diff --git a/src/pages/iou/request/step/IOURequestStepMerchant.js b/src/pages/iou/request/step/IOURequestStepMerchant.js
index 3234b6046f31..355bb76b89b0 100644
--- a/src/pages/iou/request/step/IOURequestStepMerchant.js
+++ b/src/pages/iou/request/step/IOURequestStepMerchant.js
@@ -41,6 +41,7 @@ function IOURequestStepMerchant({
const styles = useThemeStyles();
const {translate} = useLocalize();
const {inputCallbackRef} = useAutoFocusInput();
+ const isEmptyMerchant = merchant === '' || merchant === CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT;
const navigateBack = () => {
Navigation.goBack(backTo || ROUTES.HOME);
@@ -89,7 +90,7 @@ function IOURequestStepMerchant({
InputComponent={TextInput}
inputID="moneyRequestMerchant"
name="moneyRequestMerchant"
- defaultValue={merchant}
+ defaultValue={isEmptyMerchant ? '' : merchant}
maxLength={CONST.MERCHANT_NAME_MAX_LENGTH}
label={translate('common.merchant')}
accessibilityLabel={translate('common.merchant')}
diff --git a/src/pages/iou/request/step/IOURequestStepWaypoint.js b/src/pages/iou/request/step/IOURequestStepWaypoint.js
index dc5b9f7d6275..4f429460af0a 100644
--- a/src/pages/iou/request/step/IOURequestStepWaypoint.js
+++ b/src/pages/iou/request/step/IOURequestStepWaypoint.js
@@ -140,7 +140,7 @@ function IOURequestStepWaypoint({
lat: null,
lng: null,
address: waypointValue,
- name: values.name,
+ name: values.name || null,
};
saveWaypoint(waypoint);
}
@@ -166,7 +166,7 @@ function IOURequestStepWaypoint({
lat: values.lat,
lng: values.lng,
address: values.address,
- name: values.name,
+ name: values.name || null,
};
Transaction.saveWaypoint(transactionID, pageIndex, waypoint, false);
Navigation.goBack(ROUTES.MONEY_REQUEST_CREATE_TAB_DISTANCE.getRoute(iouType, transactionID, reportID));
diff --git a/src/pages/reportPropTypes.js b/src/pages/reportPropTypes.js
index c89ea761f582..d2d7bb480ed6 100644
--- a/src/pages/reportPropTypes.js
+++ b/src/pages/reportPropTypes.js
@@ -28,7 +28,7 @@ export default PropTypes.shape({
/** The time of the last message on the report */
lastVisibleActionCreated: PropTypes.string,
- /** The last time the report was visited */
+ /** The time when user read the last message */
lastReadTime: PropTypes.string,
/** The current user's notification preference for this report */
diff --git a/src/pages/settings/AboutPage/AboutPage.js b/src/pages/settings/AboutPage/AboutPage.js
index ef85bfe02c47..81186af3fcd1 100644
--- a/src/pages/settings/AboutPage/AboutPage.js
+++ b/src/pages/settings/AboutPage/AboutPage.js
@@ -65,6 +65,7 @@ function AboutPage(props) {
action: () => {
Link.openExternalLink(CONST.GITHUB_URL);
},
+ link: CONST.GITHUB_URL,
},
{
translationKey: 'initialSettingsPage.aboutPage.viewOpenJobs',
diff --git a/src/pages/tasks/TaskAssigneeSelectorModal.js b/src/pages/tasks/TaskAssigneeSelectorModal.js
index 8ef9bf1414f7..39b585438413 100644
--- a/src/pages/tasks/TaskAssigneeSelectorModal.js
+++ b/src/pages/tasks/TaskAssigneeSelectorModal.js
@@ -263,8 +263,11 @@ export default compose(
session: {
key: ONYXKEYS.SESSION,
},
+ }),
+ withOnyx({
rootParentReportPolicy: {
- key: ({report}) => {
+ key: ({reports, route}) => {
+ const report = reports[`${ONYXKEYS.COLLECTION.REPORT}${route.params?.reportID || '0'}`];
const rootParentReport = ReportUtils.getRootParentReport(report);
return `${ONYXKEYS.COLLECTION.POLICY}${rootParentReport ? rootParentReport.policyID : '0'}`;
},
diff --git a/src/pages/workspace/WorkspaceNewRoomPage.js b/src/pages/workspace/WorkspaceNewRoomPage.js
index 6f86e871e8ae..f0c3d3ada0c2 100644
--- a/src/pages/workspace/WorkspaceNewRoomPage.js
+++ b/src/pages/workspace/WorkspaceNewRoomPage.js
@@ -231,9 +231,9 @@ function WorkspaceNewRoomPage(props) {
const renderEmptyWorkspaceView = () => (
<>
optionDisplayName: {
fontFamily: fontFamily.EXP_NEUE,
minHeight: variables.alternateTextHeight,
- lineHeight: variables.lineHeightXXLarge,
+ lineHeight: variables.lineHeightXLarge,
...whiteSpace.noWrap,
},
@@ -1606,7 +1606,7 @@ const styles = (theme: ThemeColors) =>
optionAlternateText: {
minHeight: variables.alternateTextHeight,
- lineHeight: variables.lineHeightXXLarge,
+ lineHeight: variables.lineHeightXLarge,
},
optionAlternateTextCompact: {
@@ -2769,6 +2769,10 @@ const styles = (theme: ThemeColors) =>
paddingRight: 5,
},
+ codePlainTextStyle: {
+ ...codeStyles.codePlainTextStyle,
+ },
+
fullScreenLoading: {
backgroundColor: theme.componentBG,
opacity: 0.8,
@@ -4133,4 +4137,4 @@ const defaultStyles = styles(defaultTheme);
export default styles;
export {defaultStyles};
-export type {Styles, ThemeStyles, StatusBarStyle, ColorScheme};
+export type {Styles, ThemeStyles, StatusBarStyle, ColorScheme, AnchorPosition};
diff --git a/src/styles/utils/codeStyles/index.android.ts b/src/styles/utils/codeStyles/index.android.ts
index 1c2b80374fa1..1912f0ad7489 100644
--- a/src/styles/utils/codeStyles/index.android.ts
+++ b/src/styles/utils/codeStyles/index.android.ts
@@ -13,4 +13,8 @@ const codeTextStyle: CodeTextStyles = {
lineHeight: 15,
};
-export default {codeWordWrapper, codeWordStyle, codeTextStyle};
+const codePlainTextStyle: CodeTextStyles = {
+ lineHeight: 14.5,
+};
+
+export default {codeWordWrapper, codeWordStyle, codeTextStyle, codePlainTextStyle};
diff --git a/src/styles/utils/codeStyles/index.ios.ts b/src/styles/utils/codeStyles/index.ios.ts
index 07a67f0f4a20..ec87e3b6427b 100644
--- a/src/styles/utils/codeStyles/index.ios.ts
+++ b/src/styles/utils/codeStyles/index.ios.ts
@@ -14,4 +14,8 @@ const codeTextStyle: CodeTextStyles = {
lineHeight: 18,
};
-export default {codeWordWrapper, codeWordStyle, codeTextStyle};
+const codePlainTextStyle: CodeTextStyles = {
+ lineHeight: 15,
+};
+
+export default {codeWordWrapper, codeWordStyle, codeTextStyle, codePlainTextStyle};
diff --git a/src/styles/utils/codeStyles/index.ts b/src/styles/utils/codeStyles/index.ts
index 0ba8b2104867..45f669b0adaa 100644
--- a/src/styles/utils/codeStyles/index.ts
+++ b/src/styles/utils/codeStyles/index.ts
@@ -4,4 +4,5 @@ import {CodeTextStyles, CodeWordStyles, CodeWordWrapperStyles} from './types';
const codeWordWrapper: CodeWordWrapperStyles = {};
const codeWordStyle: CodeWordStyles = {};
const codeTextStyle: CodeTextStyles = {};
-export default {codeWordWrapper, codeWordStyle, codeTextStyle};
+const codePlainTextStyle: CodeTextStyles = {};
+export default {codeWordWrapper, codeWordStyle, codeTextStyle, codePlainTextStyle};
diff --git a/src/styles/utils/index.ts b/src/styles/utils/index.ts
index a7bc368983b5..4d7af731bae8 100644
--- a/src/styles/utils/index.ts
+++ b/src/styles/utils/index.ts
@@ -382,7 +382,7 @@ function getWidthStyle(width: number): ViewStyle {
/**
* Returns a style with backgroundColor and borderColor set to the same color
*/
-function getBackgroundAndBorderStyle(backgroundColor: string): ViewStyle {
+function getBackgroundAndBorderStyle(backgroundColor: string | undefined): ViewStyle {
return {
backgroundColor,
borderColor: backgroundColor,
@@ -445,13 +445,6 @@ function getBackgroundColorWithOpacityStyle(backgroundColor: string, opacity: nu
return {};
}
-function getAnimatedFABStyle(rotate: Animated.Value, backgroundColor: Animated.Value): Animated.WithAnimatedValue {
- return {
- transform: [{rotate}],
- backgroundColor,
- };
-}
-
function getWidthAndHeightStyle(width: number, height?: number): ViewStyle {
return {
width,
@@ -1013,7 +1006,6 @@ const staticStyleUtils = {
combineStyles,
displayIfTrue,
getAmountFontSizeAndLineHeight,
- getAnimatedFABStyle,
getAutoCompleteSuggestionContainerStyle,
getAvatarBorderRadius,
getAvatarBorderStyle,
diff --git a/src/styles/variables.ts b/src/styles/variables.ts
index 9bc467615706..4d717389cdb6 100644
--- a/src/styles/variables.ts
+++ b/src/styles/variables.ts
@@ -115,6 +115,8 @@ export default {
avatarChatSpacing: 12,
chatInputSpacing: 52, // 40 + avatarChatSpacing
borderTopWidth: 1,
+ emptyWorkspaceIconWidth: 84,
+ emptyWorkspaceIconHeight: 84,
modalTopIconWidth: 200,
modalTopIconHeight: 164,
modalTopBigIconHeight: 244,
diff --git a/src/types/onyx/Account.ts b/src/types/onyx/Account.ts
index c338924bae55..8093642a7111 100644
--- a/src/types/onyx/Account.ts
+++ b/src/types/onyx/Account.ts
@@ -47,6 +47,9 @@ type Account = {
/** Whether a sign is loading */
isLoading?: boolean;
+ /** The active policy ID. Initiating a SmartScan will create an expense on this policy by default. */
+ activePolicyID?: string;
+
errors?: OnyxCommon.Errors;
success?: string;
codesAreCopied?: boolean;
diff --git a/src/types/onyx/PersonalDetails.ts b/src/types/onyx/PersonalDetails.ts
index 9f613cbf4f1e..03aa7e4ec928 100644
--- a/src/types/onyx/PersonalDetails.ts
+++ b/src/types/onyx/PersonalDetails.ts
@@ -12,6 +12,17 @@ type Timezone = {
automatic?: boolean;
};
+type Status = {
+ /** The emoji code of the status */
+ emojiCode: string;
+
+ /** The text of the draft status */
+ text?: string;
+
+ /** The timestamp of when the status should be cleared */
+ clearAfter: string; // ISO 8601 format;
+};
+
type PersonalDetails = {
/** ID of the current user from their personal details */
accountID: number;
@@ -70,11 +81,11 @@ type PersonalDetails = {
fallbackIcon?: string;
/** Status of the current user from their personal details */
- status?: string;
+ status?: Status;
};
type PersonalDetailsList = Record;
export default PersonalDetails;
-export type {Timezone, SelectedTimezone, PersonalDetailsList};
+export type {Timezone, Status, SelectedTimezone, PersonalDetailsList};
diff --git a/src/types/onyx/Report.ts b/src/types/onyx/Report.ts
index 509f27f9e95d..b274025908f5 100644
--- a/src/types/onyx/Report.ts
+++ b/src/types/onyx/Report.ts
@@ -44,7 +44,7 @@ type Report = {
/** The time of the last read of the report */
lastReadCreated?: string;
- /** The last time the report was visited */
+ /** The time when user read the last message */
lastReadTime?: string;
/** The sequence number of the last report visit */
@@ -153,8 +153,7 @@ type Report = {
nonReimbursableTotal?: number;
isHidden?: boolean;
isChatRoom?: boolean;
- participantsList?: Array>;
- text?: string;
+ participantsList?: PersonalDetails[];
privateNotes?: Record;
isLoadingPrivateNotes?: boolean;
diff --git a/src/types/onyx/ReportActionsDraft.ts b/src/types/onyx/ReportActionsDraft.ts
new file mode 100644
index 000000000000..41a701f16e71
--- /dev/null
+++ b/src/types/onyx/ReportActionsDraft.ts
@@ -0,0 +1,7 @@
+type ReportActionsDraft =
+ | {
+ message: string;
+ }
+ | string;
+
+export default ReportActionsDraft;
diff --git a/src/types/onyx/ReportActionsDrafts.ts b/src/types/onyx/ReportActionsDrafts.ts
index e40007b6b47a..ad2782111144 100644
--- a/src/types/onyx/ReportActionsDrafts.ts
+++ b/src/types/onyx/ReportActionsDrafts.ts
@@ -1,3 +1,5 @@
-type ReportActionsDrafts = Record;
+import ReportActionsDraft from './ReportActionsDraft';
+
+type ReportActionsDrafts = Record;
export default ReportActionsDrafts;
diff --git a/src/types/onyx/ReportMetadata.ts b/src/types/onyx/ReportMetadata.ts
index 8c13e0f127b8..c6484705553c 100644
--- a/src/types/onyx/ReportMetadata.ts
+++ b/src/types/onyx/ReportMetadata.ts
@@ -7,6 +7,9 @@ type ReportMetadata = {
/** Flag to check if the report actions data are loading */
isLoadingInitialReportActions?: boolean;
+
+ /** The time when user last visited the report */
+ lastVisitTime?: string;
};
export default ReportMetadata;
diff --git a/src/types/onyx/index.ts b/src/types/onyx/index.ts
index 3d4eef500f1d..efa1ae1fe630 100644
--- a/src/types/onyx/index.ts
+++ b/src/types/onyx/index.ts
@@ -36,6 +36,7 @@ import ReimbursementAccountDraft from './ReimbursementAccountDraft';
import Report from './Report';
import ReportAction, {ReportActions} from './ReportAction';
import ReportActionReactions from './ReportActionReactions';
+import ReportActionsDraft from './ReportActionsDraft';
import ReportActionsDrafts from './ReportActionsDrafts';
import ReportMetadata from './ReportMetadata';
import ReportNextStep from './ReportNextStep';
@@ -105,6 +106,7 @@ export type {
ReportAction,
ReportActionReactions,
ReportActions,
+ ReportActionsDraft,
ReportActionsDrafts,
ReportMetadata,
ReportNextStep,
diff --git a/tests/actions/IOUTest.js b/tests/actions/IOUTest.js
index bb7a7c3424d2..4d9ce42a08ce 100644
--- a/tests/actions/IOUTest.js
+++ b/tests/actions/IOUTest.js
@@ -313,7 +313,7 @@ describe('actions/IOU', () => {
// The comment should be correct
expect(transaction.comment.comment).toBe(comment);
- expect(transaction.merchant).toBe(CONST.TRANSACTION.DEFAULT_MERCHANT);
+ expect(transaction.merchant).toBe(CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT);
// It should be pending
expect(transaction.pendingAction).toBe(CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD);
@@ -497,7 +497,7 @@ describe('actions/IOU', () => {
expect(newTransaction.reportID).toBe(iouReportID);
expect(newTransaction.amount).toBe(amount);
expect(newTransaction.comment.comment).toBe(comment);
- expect(newTransaction.merchant).toBe(CONST.TRANSACTION.DEFAULT_MERCHANT);
+ expect(newTransaction.merchant).toBe(CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT);
expect(newTransaction.pendingAction).toBe(CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD);
// The transactionID on the iou action should match the one from the transactions collection
@@ -642,7 +642,7 @@ describe('actions/IOU', () => {
expect(transaction.reportID).toBe(iouReportID);
expect(transaction.amount).toBe(amount);
expect(transaction.comment.comment).toBe(comment);
- expect(transaction.merchant).toBe(CONST.TRANSACTION.DEFAULT_MERCHANT);
+ expect(transaction.merchant).toBe(CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT);
expect(transaction.pendingAction).toBe(CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD);
// The transactionID on the iou action should match the one from the transactions collection
diff --git a/tests/ui/UnreadIndicatorsTest.js b/tests/ui/UnreadIndicatorsTest.js
index 0f97d36a9832..f4126ff34313 100644
--- a/tests/ui/UnreadIndicatorsTest.js
+++ b/tests/ui/UnreadIndicatorsTest.js
@@ -40,6 +40,11 @@ jest.mock('react-native/Libraries/LogBox/LogBox', () => ({
},
}));
+jest.mock('react-native-reanimated', () => ({
+ ...jest.requireActual('react-native-reanimated/mock'),
+ createAnimatedPropAdapter: jest.fn,
+}));
+
/**
* We need to keep track of the transitionEnd callback so we can trigger it in our tests
*/
diff --git a/tests/unit/CalendarPickerTest.js b/tests/unit/CalendarPickerTest.js
index 9c5ea4bc72f6..e1c11fbb8ca8 100644
--- a/tests/unit/CalendarPickerTest.js
+++ b/tests/unit/CalendarPickerTest.js
@@ -1,5 +1,5 @@
import {fireEvent, render, within} from '@testing-library/react-native';
-import {addMonths, addYears, subYears} from 'date-fns';
+import {addMonths, addYears, subMonths, subYears} from 'date-fns';
import CalendarPicker from '../../src/components/DatePicker/CalendarPicker';
import CONST from '../../src/CONST';
import DateUtils from '../../src/libs/DateUtils';
@@ -73,7 +73,7 @@ describe('CalendarPicker', () => {
fireEvent.press(getByTestId('prev-month-arrow'));
- const prevMonth = new Date().getMonth() - 1;
+ const prevMonth = subMonths(new Date(), 1).getMonth();
expect(getByText(monthNames[prevMonth])).toBeTruthy();
});