diff --git a/.github/workflows/createDeployChecklist.yml b/.github/workflows/createDeployChecklist.yml index dde65f5a1503..9a1cac41ed69 100644 --- a/.github/workflows/createDeployChecklist.yml +++ b/.github/workflows/createDeployChecklist.yml @@ -14,15 +14,7 @@ jobs: - name: Setup Node uses: ./.github/actions/composite/setupNode - - name: Set up git for OSBotify - id: setupGitForOSBotify - uses: ./.github/actions/composite/setupGitForOSBotifyApp - with: - GPG_PASSPHRASE: ${{ secrets.LARGE_SECRET_PASSPHRASE }} - OS_BOTIFY_APP_ID: ${{ secrets.OS_BOTIFY_APP_ID }} - OS_BOTIFY_PRIVATE_KEY: ${{ secrets.OS_BOTIFY_PRIVATE_KEY }} - - name: Create or update deploy checklist uses: ./.github/actions/javascript/createOrUpdateStagingDeploy with: - GITHUB_TOKEN: ${{ steps.setupGitForOSBotify.outputs.OS_BOTIFY_API_TOKEN }} + GITHUB_TOKEN: ${{ github.token }} diff --git a/.github/workflows/deployBlocker.yml b/.github/workflows/deployBlocker.yml index b55354b95571..d118b3fee252 100644 --- a/.github/workflows/deployBlocker.yml +++ b/.github/workflows/deployBlocker.yml @@ -32,7 +32,7 @@ jobs: channel: '#expensify-open-source', attachments: [{ color: "#DB4545", - text: '💥 We have found a New Expensify Deploy Blocker, if you have any idea which PR could be causing this, please comment in the issue: <${{ github.event.issue.html_url }}|${{ toJSON(github.event.issue.title) }}>', + text: '💥 We have found a New Expensify Deploy Blocker, if you have any idea which PR could be causing this, please comment in the issue: <${{ github.event.issue.html_url }}|${{ github.event.issue.title }}>'.replace(/[&<>"'|]/g, function(m) { return {'&': '&', '<': '<', '>': '>', '"': '"', "'": ''', '|': '|'}[m]; }), }] } env: diff --git a/android/app/build.gradle b/android/app/build.gradle index c16509048c04..cf8a3f74e2d0 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 1001040107 - versionName "1.4.1-7" + versionCode 1001040200 + versionName "1.4.2-0" } flavorDimensions "default" diff --git a/docs/articles/expensify-classic/account-settings/Close-Account.md b/docs/articles/expensify-classic/account-settings/Close-Account.md index 5e18490fc357..c25c22de9704 100644 --- a/docs/articles/expensify-classic/account-settings/Close-Account.md +++ b/docs/articles/expensify-classic/account-settings/Close-Account.md @@ -1,5 +1,123 @@ --- -title: Close Account +title: Close Account description: Close Account ---- -## Resource Coming Soon! +--- +# Overview + +Here is a walk through of how to close an Expensify account through the website or mobile app. + +# How to close your account +On the Web: + +1. Go to **Settings** in the left hand menu. Click **Account**. +2. Click “Close Account” +3. Follow the prompts to verify your email or phone number. +4. Your account will be closed, and all data will be deleted.* + +![Close Account via Website]({{site.url}}/assets/images/ExpensifyHelp_CloseAccount_Desktop.png){:width="100%"} + +On the Mobile App: + +Open the app and tap the three horizontal lines in the upper left corner. +Select Settings from the menu. +Look for the "Close Account" option in the "Others" section. (If you don’t see this option, you have a Domain Controlled account and will need to ask your Domain Admin to delete your account.) +Complete the verification process using your email or phone number. +Your account will be closed, and all data will be deleted.* + +![Close Account on Mobile Application]({{site.url}}/assets/images/ExpensifyHelp_CloseAccount_Mobile.png){:width="100%"} + +These instructions may vary depending on the specific device you are using, so be sure to follow the steps as they appear on your screen. + +*Note: Transactions shared with other accounts will still be visible to those accounts. (Example: A report submitted to your company and reimbursed will not be deleted from your company’s account.) Additionally, we are required to retain certain records of transactions in compliance with laws in various jurisdictions. + +# How to reopen your account + +If your Expensify account is closed and not associated with a verified domain, you can reopen it with the following steps: + +1. Visit [expensify.com](https://www.expensify.com/). +2. Attempt to sign in using your email address or phone number associated with the closed account. +3. After entering your user name, you will see a prompt to reopen your account. +4. Click on **Reopen Account**. +5. A magic link will be sent to your email address. +6. Access your email and click on the magic link. This link will take you to Expensify and reopen your account. +7. Follow the prompts to create a new password associated with your account. +8. Your account is now reopened. Any previously approved expense data will still be visible in the account. + +Note: Reports submitted and closed on an Individual workspace will not be retained since they have not been approved or shared with anyone else in Expensify. + +That's it! Your account is successfully reopened, and you can access your historical data that was shared with other accounts. Remember to recreate any workspaces and adjust settings if needed. + +# How to Reopen a Domain-controlled account +Once an account has been **Closed** by a Domain Admin, it can be reopened by any Domain Admin on that domain. + +The Domain Admin will simply need to invite the previously closed account in the same manner that new accounts are invited to the Domain. The user will receive a magic link to their email account which they can use to Reopen the account. + +# How to retain a free account to keep historical expenses +If you no longer need a group workspace or have a more advanced workspace than necessary in Expensify, and you want to downgrade while retaining your historical data, here's what you should do: + +1. If you're part of a group workspace, request the Workspace Admin to remove you, or if you own the workspace, delete it to downgrade to a free version. +2. Once you've removed or been removed from a workspace, start using your free Expensify account. Your submitted expenses will still be saved, allowing you to access the historical data. +3. Domain Admins in the company will still retain access to approved and reimbursed expenses. +4. To keep your data, avoid closing your account. Account closures are irreversible and will result in the deletion of all your unapproved data. + +# Deep Dive + +## I’m unable to close my account + +If you're encountering an error message while trying to close your Expensify account, it's important to pinpoint the specific error. Encountering an error when trying to close your account is typically only experienced if the account has been an admin on a company’s Expensify workspace. (Especially if the account was involved in responsibilities like reimbursing employees or exporting expense reports.) + +In order to avoid users accidentally creating issues for their company, Expensify prevents account closure if you still have any individual responsibilities related to a Workspace within the platform. To successfully close your account, you need to ensure that your workspace no longer relies on your account to operate. + +Here are the reasons why you might encounter an error when trying to close your Expensify account, along with the actions required to address each of these issues: + +- **Account Under a Validated Domain**: If your account is associated with a validated domain, only a Domain Admin can close it. You can find this option in your Domain Admin's settings under Settings > Domains > Domain Members. Afterward, if you have a secondary personal login, you can delete it by following the instructions mentioned above. +- **Sole Domain Admin for Your Company**: If you are the only Domain Admin for your company's domain, you must appoint another Domain Admin before you can close your account. This is to avoid accidentally prohibiting your entire company from using Expensify. You can do this by going to Settings > Domains > [Domain Name] > Domain Admins and making the necessary changes, or you can reset the entire domain. +- **Workspace Billing Owner with an Annual Subscription**: If you are the Workspace Billing Owner with an Annual Subscription, you need to downgrade from the Annual subscription before closing the account. Alternatively, you can have another user take over billing for your workspaces. +- **Ownership of a Company Workspace or Outstanding Balance**: If you own a company workspace or there is an outstanding balance owed to Expensify, you must take one of the following actions before closing your account: + + - Make the payment for the outstanding balance. + - Have another user take over billing for the workspace. + - Request a refund for your initial bill. + - Delete the workspace. + +- **Preferred Exporter for Your Workspace Integration**: If you are the "Preferred Exporter" for a workspace Integration, you must update the Preferred Exporter before closing your account. You can do this by navigating to **Settings** > **Workspaces** > **Group** > [Workspace name] > **Connections** > **Configure** and selecting any Workspace Admin from the dropdown menu as the Preferred Exporter. +- **Verified Business Account with Outstanding Balance or Locked Status**: If you have a Verified Business Account with an outstanding balance or if the account is locked, you should wait for all payments to settle or unlock the account. To settle the amount owed, go to **Settings** > **Account** > **Payments** > **Bank Accounts** and take the necessary steps. + +## Validate the account to close it + +Sometimes, you may find yourself with an Expensify account that you don't need. This could be due to various reasons like a company inviting you for reimbursement, a vendor inviting you to pay, or a mistaken sign-up. + +In such cases, you have two options: + +**Option 1**: Retain the Account for Future Use +You can keep the Expensify account just in case you need it later. + +**Option 2**: Close the Account + +Start by verifying your email or phone number + +Before closing the account, you need to verify that you have access to the email or phone number associated with it. This ensures that only you can close the account. + +Here's how to do it: + +1. Go to [www.expensify.com](http://www.expensify.com/). +2. Enter your email address or phone number (whichever is associated with the unwanted account). +3. Click the **Resend Link** button. +4. Check your Home Page for the most recent email with the subject line "Please validate your Expensify login." Click the link provided in the email to validate your email address. + - If it's an account linked to a phone number, tap the link sent to your phone. +5. After clicking the validation link, you'll be directed to an Expensify Home Page. +6. Navigate to **Settings** > **Account** > **Account Details** > **Close Account**. +7. Click the **Close My Account** button. + - Re-enter the email address or phone number of the account when prompted. + - Check the box that says, "I understand all of my unsubmitted expense data will be deleted." + - Click the **Close My Account** button. + +By following these steps, you can easily verify your email or phone number and close an unwanted Expensify account. + +# FAQ + +## What should I do if I'm not directed to my account when clicking the validate option from my phone or email? +It's possible your browser has blocked this, either because of some existing cache or extension. In this case, you should follow the Reset Password flow to reset the password and manually gain access with the new password, along with your email address. + +## Why don't I see the Close Account option? +It's possible your account is on a managed company domain. In this case, only the admins from that company can close it. diff --git a/docs/assets/images/ExpensifyHelp_CloseAccount_Desktop.png b/docs/assets/images/ExpensifyHelp_CloseAccount_Desktop.png new file mode 100644 index 000000000000..ca084324808e Binary files /dev/null and b/docs/assets/images/ExpensifyHelp_CloseAccount_Desktop.png differ diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist index 25d3f8c4a3c3..24298c4ff064 100644 --- a/ios/NewExpensify/Info.plist +++ b/ios/NewExpensify/Info.plist @@ -19,7 +19,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 1.4.1 + 1.4.2 CFBundleSignature ???? CFBundleURLTypes @@ -40,7 +40,7 @@ CFBundleVersion - 1.4.1.7 + 1.4.2.0 ITSAppUsesNonExemptEncryption LSApplicationQueriesSchemes diff --git a/ios/NewExpensifyTests/Info.plist b/ios/NewExpensifyTests/Info.plist index 3d9e32ca6d05..519c58663534 100644 --- a/ios/NewExpensifyTests/Info.plist +++ b/ios/NewExpensifyTests/Info.plist @@ -15,10 +15,10 @@ CFBundlePackageType BNDL CFBundleShortVersionString - 1.4.1 + 1.4.2 CFBundleSignature ???? CFBundleVersion - 1.4.1.7 + 1.4.2.0 diff --git a/package-lock.json b/package-lock.json index 5c5404eebd9b..d25a90a79654 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "new.expensify", - "version": "1.4.1-7", + "version": "1.4.2-0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "new.expensify", - "version": "1.4.1-7", + "version": "1.4.2-0", "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -51,7 +51,7 @@ "date-fns-tz": "^2.0.0", "dom-serializer": "^0.2.2", "domhandler": "^4.3.0", - "expensify-common": "git+ssh://git@github.com/Expensify/expensify-common.git#e9daa1c475ba047fd13ad50079cd64f730e58c29", + "expensify-common": "git+ssh://git@github.com/Expensify/expensify-common.git#ee14b3255da33d2b6924c357f43393251b6dc6d2", "expo": "^49.0.0", "expo-asset": "~8.10.1", "expo-image": "^1.8.1", @@ -31871,8 +31871,8 @@ }, "node_modules/expensify-common": { "version": "1.0.0", - "resolved": "git+ssh://git@github.com/Expensify/expensify-common.git#e9daa1c475ba047fd13ad50079cd64f730e58c29", - "integrity": "sha512-gxW5C5LHt1yYLWik1X1aipE05gcVZZJxyp9MC8Kxr5jKtuVm4OeNeYCFWhSgfIrfszfY7VAdofzALcWLAMjxqg==", + "resolved": "git+ssh://git@github.com/Expensify/expensify-common.git#ee14b3255da33d2b6924c357f43393251b6dc6d2", + "integrity": "sha512-u5Is3a/jD9KJ/LtSofeevauDlxZX5++w+VENP2cNhT1lm1GSwRM5FlTT7bIVSyrHr0cppAgl7cLiW2aPDr2hKA==", "license": "MIT", "dependencies": { "classnames": "2.3.1", @@ -79515,9 +79515,9 @@ } }, "expensify-common": { - "version": "git+ssh://git@github.com/Expensify/expensify-common.git#e9daa1c475ba047fd13ad50079cd64f730e58c29", - "integrity": "sha512-gxW5C5LHt1yYLWik1X1aipE05gcVZZJxyp9MC8Kxr5jKtuVm4OeNeYCFWhSgfIrfszfY7VAdofzALcWLAMjxqg==", - "from": "expensify-common@git+ssh://git@github.com/Expensify/expensify-common.git#e9daa1c475ba047fd13ad50079cd64f730e58c29", + "version": "git+ssh://git@github.com/Expensify/expensify-common.git#ee14b3255da33d2b6924c357f43393251b6dc6d2", + "integrity": "sha512-u5Is3a/jD9KJ/LtSofeevauDlxZX5++w+VENP2cNhT1lm1GSwRM5FlTT7bIVSyrHr0cppAgl7cLiW2aPDr2hKA==", + "from": "expensify-common@git+ssh://git@github.com/Expensify/expensify-common.git#ee14b3255da33d2b6924c357f43393251b6dc6d2", "requires": { "classnames": "2.3.1", "clipboard": "2.0.4", @@ -97057,4 +97057,4 @@ "dev": true } } -} \ No newline at end of file +} diff --git a/package.json b/package.json index 77ed19519532..6414e9caa546 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "new.expensify", - "version": "1.4.1-7", + "version": "1.4.2-0", "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.", @@ -98,7 +98,7 @@ "date-fns-tz": "^2.0.0", "dom-serializer": "^0.2.2", "domhandler": "^4.3.0", - "expensify-common": "git+ssh://git@github.com/Expensify/expensify-common.git#e9daa1c475ba047fd13ad50079cd64f730e58c29", + "expensify-common": "git+ssh://git@github.com/Expensify/expensify-common.git#ee14b3255da33d2b6924c357f43393251b6dc6d2", "expo": "^49.0.0", "expo-asset": "~8.10.1", "expo-image": "^1.8.1", diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index 475f355c6a10..75c284fb9546 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -261,6 +261,9 @@ const ONYXKEYS = { REPORT_USER_IS_LEAVING_ROOM: 'reportUserIsLeavingRoom_', SECURITY_GROUP: 'securityGroup_', TRANSACTION: 'transactions_', + + // Holds temporary transactions used during the creation and edit flow + TRANSACTION_DRAFT: 'transactionsDraft_', SPLIT_TRANSACTION_DRAFT: 'splitTransactionDraft_', PRIVATE_NOTES_DRAFT: 'privateNotesDraft_', NEXT_STEP: 'reportNextStep_', diff --git a/src/components/Alert/index.js b/src/components/Alert/index.js index dc3a722c2331..3fc90433f13e 100644 --- a/src/components/Alert/index.js +++ b/src/components/Alert/index.js @@ -5,7 +5,7 @@ import _ from 'underscore'; * * @param {String} title The title of the alert * @param {String} description The description of the alert - * @param {Object[]} options An array of objects with `style` and `onPress` properties + * @param {Object[]} [options] An array of objects with `style` and `onPress` properties */ export default (title, description, options) => { const result = _.filter(window.confirm([title, description], Boolean)).join('\n'); diff --git a/src/components/AvatarWithImagePicker.js b/src/components/AvatarWithImagePicker.js index d2d0aa248472..24965fddc1f3 100644 --- a/src/components/AvatarWithImagePicker.js +++ b/src/components/AvatarWithImagePicker.js @@ -1,14 +1,15 @@ import lodashGet from 'lodash/get'; import PropTypes from 'prop-types'; -import React from 'react'; -import {View} from 'react-native'; +import React, {useEffect, useRef, useState} from 'react'; +import {StyleSheet, View} from 'react-native'; import _ from 'underscore'; +import useLocalize from '@hooks/useLocalize'; import * as Browser from '@libs/Browser'; -import compose from '@libs/compose'; import * as FileUtils from '@libs/fileDownload/FileUtils'; import getImageResolution from '@libs/fileDownload/getImageResolution'; -import SpinningIndicatorAnimation from '@styles/animation/SpinningIndicatorAnimation'; import stylePropTypes from '@styles/stylePropTypes'; +import styles from '@styles/styles'; +import themeColors from '@styles/themes/default'; import variables from '@styles/variables'; import CONST from '@src/CONST'; import AttachmentModal from './AttachmentModal'; @@ -22,11 +23,8 @@ import sourcePropTypes from './Image/sourcePropTypes'; import OfflineWithFeedback from './OfflineWithFeedback'; import PopoverMenu from './PopoverMenu'; import PressableWithoutFeedback from './Pressable/PressableWithoutFeedback'; -import Tooltip from './Tooltip/PopoverAnchorTooltip'; -import withLocalize, {withLocalizePropTypes} from './withLocalize'; +import Tooltip from './Tooltip'; import withNavigationFocus from './withNavigationFocus'; -import withTheme, {withThemePropTypes} from './withTheme'; -import withThemeStyles, {withThemeStylesPropTypes} from './withThemeStyles'; const propTypes = { /** Avatar source to display */ @@ -55,9 +53,6 @@ const propTypes = { left: PropTypes.number, }).isRequired, - /** Flag to see if image is being uploaded */ - isUploading: PropTypes.bool, - /** Size of Indicator */ size: PropTypes.oneOf([CONST.AVATAR_SIZE.LARGE, CONST.AVATAR_SIZE.DEFAULT]), @@ -95,9 +90,11 @@ const propTypes = { /** Whether navigation is focused */ isFocused: PropTypes.bool.isRequired, - ...withLocalizePropTypes, - ...withThemeStylesPropTypes, - ...withThemePropTypes, + /** Where the popover should be positioned relative to the anchor points. */ + anchorAlignment: PropTypes.shape({ + horizontal: PropTypes.oneOf(_.values(CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL)), + vertical: PropTypes.oneOf(_.values(CONST.MODAL.ANCHOR_ORIGIN_VERTICAL)), + }), }; const defaultProps = { @@ -107,7 +104,6 @@ const defaultProps = { style: [], DefaultAvatar: () => {}, isUsingDefaultAvatar: false, - isUploading: false, size: CONST.AVATAR_SIZE.DEFAULT, fallbackIcon: Expensicons.FallbackAvatar, type: CONST.ICON_TYPE_AVATAR, @@ -119,58 +115,67 @@ const defaultProps = { headerTitle: '', previewSource: '', originalFileName: '', + anchorAlignment: { + horizontal: CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.LEFT, + vertical: CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.TOP, + }, }; -class AvatarWithImagePicker extends React.Component { - constructor(props) { - super(props); - this.animation = new SpinningIndicatorAnimation(); - this.setError = this.setError.bind(this); - this.isValidSize = this.isValidSize.bind(this); - this.showAvatarCropModal = this.showAvatarCropModal.bind(this); - this.hideAvatarCropModal = this.hideAvatarCropModal.bind(this); - this.state = { - isMenuVisible: false, - validationError: null, - phraseParam: {}, - isAvatarCropModalOpen: false, - imageName: '', - imageUri: '', - imageType: '', - }; - this.anchorRef = React.createRef(); - } - - componentDidMount() { - if (!this.props.isUploading) { - return; - } - - this.animation.start(); - } - - componentDidUpdate(prevProps) { - if (!prevProps.isFocused && this.props.isFocused) { - this.setError(null, {}); - } - if (!prevProps.isUploading && this.props.isUploading) { - this.animation.start(); - } else if (prevProps.isUploading && !this.props.isUploading) { - this.animation.stop(); - } - } - - componentWillUnmount() { - this.animation.stop(); - } +function AvatarWithImagePicker({ + isFocused, + DefaultAvatar, + style, + pendingAction, + errors, + errorRowStyles, + onErrorClose, + source, + fallbackIcon, + size, + type, + headerTitle, + previewSource, + originalFileName, + isUsingDefaultAvatar, + onImageRemoved, + anchorPosition, + anchorAlignment, + onImageSelected, + editorMaskImage, +}) { + const [isMenuVisible, setIsMenuVisible] = useState(false); + const [errorData, setErrorData] = useState({ + validationError: null, + phraseParam: {}, + }); + const [isAvatarCropModalOpen, setIsAvatarCropModalOpen] = useState(false); + const [imageData, setImageData] = useState({ + uri: '', + name: '', + type: '', + }); + const anchorRef = useRef(); + const {translate} = useLocalize(); /** * @param {String} error * @param {Object} phraseParam */ - setError(error, phraseParam) { - this.setState({validationError: error, phraseParam}); - } + const setError = (error, phraseParam) => { + setErrorData({ + validationError: error, + phraseParam, + }); + }; + + useEffect(() => { + if (isFocused) { + return; + } + + // Reset the error if the component is no longer focused. + setError(null, {}); + }, [isFocused]); /** * Check if the attachment extension is allowed. @@ -178,10 +183,10 @@ class AvatarWithImagePicker extends React.Component { * @param {Object} image * @returns {Boolean} */ - isValidExtension(image) { + const isValidExtension = (image) => { const {fileExtension} = FileUtils.splitExtensionFromFileName(lodashGet(image, 'name', '')); return _.contains(CONST.AVATAR_ALLOWED_EXTENSIONS, fileExtension.toLowerCase()); - } + }; /** * Check if the attachment size is less than allowed size. @@ -189,9 +194,7 @@ class AvatarWithImagePicker extends React.Component { * @param {Object} image * @returns {Boolean} */ - isValidSize(image) { - return image && lodashGet(image, 'size', 0) < CONST.AVATAR_MAX_ATTACHMENT_SIZE; - } + const isValidSize = (image) => image && lodashGet(image, 'size', 0) < CONST.AVATAR_MAX_ATTACHMENT_SIZE; /** * Check if the attachment resolution matches constraints. @@ -199,34 +202,29 @@ class AvatarWithImagePicker extends React.Component { * @param {Object} image * @returns {Promise} */ - isValidResolution(image) { - return getImageResolution(image).then( - (resolution) => - resolution.height >= CONST.AVATAR_MIN_HEIGHT_PX && - resolution.width >= CONST.AVATAR_MIN_WIDTH_PX && - resolution.height <= CONST.AVATAR_MAX_HEIGHT_PX && - resolution.width <= CONST.AVATAR_MAX_WIDTH_PX, + const isValidResolution = (image) => + getImageResolution(image).then( + ({height, width}) => height >= CONST.AVATAR_MIN_HEIGHT_PX && width >= CONST.AVATAR_MIN_WIDTH_PX && height <= CONST.AVATAR_MAX_HEIGHT_PX && width <= CONST.AVATAR_MAX_WIDTH_PX, ); - } /** * Validates if an image has a valid resolution and opens an avatar crop modal * * @param {Object} image */ - showAvatarCropModal(image) { - if (!this.isValidExtension(image)) { - this.setError('avatarWithImagePicker.notAllowedExtension', {allowedExtensions: CONST.AVATAR_ALLOWED_EXTENSIONS}); + const showAvatarCropModal = (image) => { + if (!isValidExtension(image)) { + setError('avatarWithImagePicker.notAllowedExtension', {allowedExtensions: CONST.AVATAR_ALLOWED_EXTENSIONS}); return; } - if (!this.isValidSize(image)) { - this.setError('avatarWithImagePicker.sizeExceeded', {maxUploadSizeInMB: CONST.AVATAR_MAX_ATTACHMENT_SIZE / (1024 * 1024)}); + if (!isValidSize(image)) { + setError('avatarWithImagePicker.sizeExceeded', {maxUploadSizeInMB: CONST.AVATAR_MAX_ATTACHMENT_SIZE / (1024 * 1024)}); return; } - this.isValidResolution(image).then((isValidResolution) => { - if (!isValidResolution) { - this.setError('avatarWithImagePicker.resolutionConstraints', { + isValidResolution(image).then((isValid) => { + if (!isValid) { + setError('avatarWithImagePicker.resolutionConstraints', { minHeightInPx: CONST.AVATAR_MIN_HEIGHT_PX, minWidthInPx: CONST.AVATAR_MIN_WIDTH_PX, maxHeightInPx: CONST.AVATAR_MAX_HEIGHT_PX, @@ -235,158 +233,168 @@ class AvatarWithImagePicker extends React.Component { return; } - this.setState({ - isAvatarCropModalOpen: true, - validationError: null, - phraseParam: {}, - isMenuVisible: false, - imageUri: image.uri, - imageName: image.name, - imageType: image.type, + setIsAvatarCropModalOpen(true); + setError(null, {}); + setIsMenuVisible(false); + setImageData({ + uri: image.uri, + name: image.name, + type: image.type, }); }); - } - - hideAvatarCropModal() { - this.setState({isAvatarCropModalOpen: false}); - } - - render() { - const DefaultAvatar = this.props.DefaultAvatar; - const additionalStyles = _.isArray(this.props.style) ? this.props.style : [this.props.style]; - - return ( - - - - - this.setState((prev) => ({isMenuVisible: !prev.isMenuVisible}))} - role={CONST.ACCESSIBILITY_ROLE.IMAGEBUTTON} - accessibilityLabel={this.props.translate('avatarWithImagePicker.editImage')} - disabled={this.state.isAvatarCropModalOpen} - ref={this.anchorRef} - > - - {this.props.source ? ( - - ) : ( - - )} - - - { + setIsAvatarCropModalOpen(false); + }; + + /** + * Create menu items list for avatar menu + * + * @param {Function} openPicker + * @returns {Array} + */ + const createMenuItems = (openPicker) => { + const menuItems = [ + { + icon: Expensicons.Upload, + text: translate('avatarWithImagePicker.uploadPhoto'), + onSelected: () => { + if (Browser.isSafari()) { + return; + } + openPicker({ + onPicked: showAvatarCropModal, + }); + }, + }, + ]; + + // If current avatar isn't a default avatar, allow Remove Photo option + if (!isUsingDefaultAvatar) { + menuItems.push({ + icon: Expensicons.Trashcan, + text: translate('avatarWithImagePicker.removePhoto'), + onSelected: () => { + setError(null, {}); + onImageRemoved(); + }, + }); + } + return menuItems; + }; + + return ( + + + + + setIsMenuVisible((prev) => !prev)} + role={CONST.ACCESSIBILITY_ROLE.IMAGEBUTTON} + accessibilityLabel={translate('avatarWithImagePicker.editImage')} + disabled={isAvatarCropModalOpen} + ref={anchorRef} + > + + {source ? ( + - - - - - - {({show}) => ( - - {({openPicker}) => { - const menuItems = [ - { - icon: Expensicons.Upload, - text: this.props.translate('avatarWithImagePicker.uploadPhoto'), - onSelected: () => { - if (Browser.isSafari()) { - return; - } + ) : ( + + )} + + + + + + + + + {({show}) => ( + + {({openPicker}) => { + const menuItems = createMenuItems(openPicker); + + // If the current avatar isn't a default avatar, allow the "View Photo" option + if (!isUsingDefaultAvatar) { + menuItems.push({ + icon: Expensicons.Eye, + text: translate('avatarWithImagePicker.viewPhoto'), + onSelected: show, + }); + } + + return ( + setIsMenuVisible(false)} + onItemSelected={(item, index) => { + setIsMenuVisible(false); + // In order for the file picker to open dynamically, the click + // function must be called from within an event handler that was initiated + // by the user on Safari. + if (index === 0 && Browser.isSafari()) { openPicker({ - onPicked: this.showAvatarCropModal, + onPicked: showAvatarCropModal, }); - }, - }, - ]; - - // If current avatar isn't a default avatar, allow Remove Photo option - if (!this.props.isUsingDefaultAvatar) { - menuItems.push({ - icon: Expensicons.Trashcan, - text: this.props.translate('avatarWithImagePicker.removePhoto'), - onSelected: () => { - this.setError(null, {}); - this.props.onImageRemoved(); - }, - }); - - menuItems.push({ - icon: Expensicons.Eye, - text: this.props.translate('avatarWithImagePicker.viewPhoto'), - onSelected: () => show(), - }); - } - return ( - this.setState({isMenuVisible: false})} - onItemSelected={(item, index) => { - this.setState({isMenuVisible: false}); - // In order for the file picker to open dynamically, the click - // function must be called from within a event handler that was initiated - // by the user on Safari. - if (index === 0 && Browser.isSafari()) { - openPicker({ - onPicked: this.showAvatarCropModal, - }); - } - }} - menuItems={menuItems} - anchorPosition={this.props.anchorPosition} - withoutOverlay - anchorRef={this.anchorRef} - anchorAlignment={this.props.anchorAlignment} - /> - ); - }} - - )} - - - {this.state.validationError && ( - - )} - + } + }} + menuItems={menuItems} + anchorPosition={anchorPosition} + withoutOverlay + anchorRef={anchorRef} + anchorAlignment={anchorAlignment} + /> + ); + }} + + )} + - ); - } + {errorData.validationError && ( + + )} + + + ); } AvatarWithImagePicker.propTypes = propTypes; AvatarWithImagePicker.defaultProps = defaultProps; +AvatarWithImagePicker.displayName = 'AvatarWithImagePicker'; -export default compose(withLocalize, withNavigationFocus, withThemeStyles, withTheme)(AvatarWithImagePicker); +export default withNavigationFocus(AvatarWithImagePicker); diff --git a/src/components/Badge.tsx b/src/components/Badge.tsx index 22c056dfdfc4..575646f7dd9c 100644 --- a/src/components/Badge.tsx +++ b/src/components/Badge.tsx @@ -29,7 +29,7 @@ type BadgeProps = { textStyles?: StyleProp; /** Callback to be called on onPress */ - onPress: (event?: GestureResponderEvent | KeyboardEvent) => void; + onPress?: (event?: GestureResponderEvent | KeyboardEvent) => void; }; function Badge({success = false, error = false, pressable = false, text, environment = CONST.ENVIRONMENT.DEV, badgeStyles, textStyles, onPress = () => {}}: BadgeProps) { diff --git a/src/components/EnvironmentBadge.js b/src/components/EnvironmentBadge.tsx similarity index 94% rename from src/components/EnvironmentBadge.js rename to src/components/EnvironmentBadge.tsx index f32946f8bc25..c31677a8f5c3 100644 --- a/src/components/EnvironmentBadge.js +++ b/src/components/EnvironmentBadge.tsx @@ -18,7 +18,7 @@ function EnvironmentBadge() { const {environment} = useEnvironment(); // If we are on production, don't show any badge - if (environment === CONST.ENVIRONMENT.PRODUCTION) { + if (environment === CONST.ENVIRONMENT.PRODUCTION || environment === undefined) { return null; } diff --git a/src/components/HeaderGap/index.desktop.js b/src/components/HeaderGap/index.desktop.js index 2d583881cab6..07ea1ea6f48d 100644 --- a/src/components/HeaderGap/index.desktop.js +++ b/src/components/HeaderGap/index.desktop.js @@ -1,7 +1,8 @@ import PropTypes from 'prop-types'; -import React, {PureComponent} from 'react'; +import React, {memo} from 'react'; import {View} from 'react-native'; import withThemeStyles, {withThemeStylesPropTypes} from '@components/withThemeStyles'; +import compose from '@libs/compose'; const propTypes = { /** Styles to apply to the HeaderGap */ @@ -10,14 +11,15 @@ const propTypes = { ...withThemeStylesPropTypes, }; -class HeaderGap extends PureComponent { - render() { - return ; - } +const defaultProps = { + styles: [], +}; + +function HeaderGap(props) { + return ; } +HeaderGap.displayName = 'HeaderGap'; HeaderGap.propTypes = propTypes; -HeaderGap.defaultProps = { - styles: [], -}; -export default withThemeStyles(HeaderGap); +HeaderGap.defaultProps = defaultProps; +export default compose(memo, withThemeStyles)(HeaderGap); diff --git a/src/components/HeaderGap/index.js b/src/components/HeaderGap/index.js index ca81056d5f7a..35e6bf92fb5d 100644 --- a/src/components/HeaderGap/index.js +++ b/src/components/HeaderGap/index.js @@ -1,7 +1,6 @@ -import {PureComponent} from 'react'; - -export default class HeaderGap extends PureComponent { - render() { - return null; - } +function HeaderGap() { + return null; } + +HeaderGap.displayName = 'HeaderGap'; +export default HeaderGap; diff --git a/src/components/LHNOptionsList/LHNOptionsList.js b/src/components/LHNOptionsList/LHNOptionsList.js index 0d300c5e2179..5e77947187e9 100644 --- a/src/components/LHNOptionsList/LHNOptionsList.js +++ b/src/components/LHNOptionsList/LHNOptionsList.js @@ -1,8 +1,7 @@ -import {FlashList} from '@shopify/flash-list'; import lodashGet from 'lodash/get'; import PropTypes from 'prop-types'; import React, {useCallback} from 'react'; -import {View} from 'react-native'; +import {FlatList, View} from 'react-native'; import {withOnyx} from 'react-native-onyx'; import _ from 'underscore'; import participantPropTypes from '@components/participantPropTypes'; @@ -12,7 +11,6 @@ import compose from '@libs/compose'; import * as OptionsListUtils from '@libs/OptionsListUtils'; import reportActionPropTypes from '@pages/home/report/reportActionPropTypes'; import reportPropTypes from '@pages/reportPropTypes'; -import stylePropTypes from '@styles/stylePropTypes'; import useThemeStyles from '@styles/useThemeStyles'; import variables from '@styles/variables'; import CONST from '@src/CONST'; @@ -21,10 +19,12 @@ import OptionRowLHNData from './OptionRowLHNData'; const propTypes = { /** Wrapper style for the section list */ - style: stylePropTypes, + // eslint-disable-next-line react/forbid-prop-types + style: PropTypes.arrayOf(PropTypes.object), /** Extra styles for the section list container */ - contentContainerStyles: stylePropTypes.isRequired, + // eslint-disable-next-line react/forbid-prop-types + contentContainerStyles: PropTypes.arrayOf(PropTypes.object).isRequired, /** Sections for the section list */ data: PropTypes.arrayOf(PropTypes.string).isRequired, @@ -80,7 +80,7 @@ const defaultProps = { ...withCurrentReportIDDefaultProps, }; -const keyExtractor = (item) => `report_${item}`; +const keyExtractor = (item) => item; function LHNOptionsList({ style, @@ -99,6 +99,28 @@ function LHNOptionsList({ currentReportID, }) { const styles = useThemeStyles(); + /** + * This function is used to compute the layout of any given item in our list. Since we know that each item will have the exact same height, this is a performance optimization + * so that the heights can be determined before the options are rendered. Otherwise, the heights are determined when each option is rendering and it causes a lot of overhead on large + * lists. + * + * @param {Array} itemData - This is the same as the data we pass into the component + * @param {Number} index the current item's index in the set of data + * + * @returns {Object} + */ + const getItemLayout = useCallback( + (itemData, index) => { + const optionHeight = optionMode === CONST.OPTION_MODE.COMPACT ? variables.optionRowHeightCompact : variables.optionRowHeight; + return { + length: optionHeight, + offset: index * optionHeight, + index, + }; + }, + [optionMode], + ); + /** * Function which renders a row in the list * @@ -142,17 +164,20 @@ function LHNOptionsList({ return ( - ); diff --git a/src/components/Modal/BaseModal.tsx b/src/components/Modal/BaseModal.tsx index d0e309d06766..54a178db1cdd 100644 --- a/src/components/Modal/BaseModal.tsx +++ b/src/components/Modal/BaseModal.tsx @@ -72,14 +72,21 @@ function BaseModal( useEffect(() => { isVisibleRef.current = isVisible; + let removeOnCloseListener: () => void; if (isVisible) { Modal.willAlertModalBecomeVisible(true); // To handle closing any modal already visible when this modal is mounted, i.e. PopoverReportActionContextMenu - Modal.setCloseModal(onClose); + removeOnCloseListener = Modal.setCloseModal(onClose); } else if (wasVisible && !isVisible) { Modal.willAlertModalBecomeVisible(false); - Modal.setCloseModal(null); } + + return () => { + if (!removeOnCloseListener) { + return; + } + removeOnCloseListener(); + }; }, [isVisible, wasVisible, onClose]); useEffect( @@ -90,8 +97,6 @@ function BaseModal( } hideModal(true); Modal.willAlertModalBecomeVisible(false); - // To prevent closing any modal already unmounted when this modal still remains as visible state - Modal.setCloseModal(null); }, // eslint-disable-next-line react-hooks/exhaustive-deps [], diff --git a/src/components/ParentNavigationSubtitle.js b/src/components/ParentNavigationSubtitle.tsx similarity index 65% rename from src/components/ParentNavigationSubtitle.js rename to src/components/ParentNavigationSubtitle.tsx index 0ce6582fe86d..e65a8617a996 100644 --- a/src/components/ParentNavigationSubtitle.js +++ b/src/components/ParentNavigationSubtitle.tsx @@ -1,49 +1,38 @@ -import PropTypes from 'prop-types'; import React from 'react'; +import {StyleProp, ViewStyle} from 'react-native'; import useLocalize from '@hooks/useLocalize'; import Navigation from '@libs/Navigation/Navigation'; import useThemeStyles from '@styles/useThemeStyles'; import CONST from '@src/CONST'; +import {ParentNavigationSummaryParams} from '@src/languages/types'; import ROUTES from '@src/ROUTES'; import PressableWithoutFeedback from './Pressable/PressableWithoutFeedback'; import Text from './Text'; -const propTypes = { - parentNavigationSubtitleData: PropTypes.shape({ - // Title of root report room - rootReportName: PropTypes.string, - - // Name of workspace, if any - workspaceName: PropTypes.string, - }).isRequired, +type ParentNavigationSubtitleProps = { + parentNavigationSubtitleData: ParentNavigationSummaryParams; /** parent Report ID */ - parentReportID: PropTypes.string, + parentReportID?: string; /** PressableWithoutFeedack additional styles */ - // eslint-disable-next-line react/forbid-prop-types - pressableStyles: PropTypes.arrayOf(PropTypes.object), -}; - -const defaultProps = { - parentReportID: '', - pressableStyles: [], + pressableStyles?: StyleProp; }; -function ParentNavigationSubtitle(props) { +function ParentNavigationSubtitle({parentNavigationSubtitleData, parentReportID = '', pressableStyles}: ParentNavigationSubtitleProps) { const styles = useThemeStyles(); - const {workspaceName, rootReportName} = props.parentNavigationSubtitleData; + const {workspaceName, rootReportName} = parentNavigationSubtitleData; const {translate} = useLocalize(); return ( { - Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(props.parentReportID)); + Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(parentReportID)); }} accessibilityLabel={translate('threads.parentNavigationSummary', {rootReportName, workspaceName})} role={CONST.ACCESSIBILITY_ROLE.LINK} - style={[...props.pressableStyles]} + style={pressableStyles} > { + let removeOnClose; if (props.isVisible) { props.onModalShow(); onOpen({ ref: props.withoutOverlayRef, close: props.onClose, anchorRef: props.anchorRef, - onCloseCallback: () => Modal.setCloseModal(null), - onOpenCallback: () => Modal.setCloseModal(() => props.onClose(props.anchorRef)), }); + removeOnClose = Modal.setCloseModal(() => props.onClose(props.anchorRef)); } else { props.onModalHide(); close(props.anchorRef); @@ -41,6 +41,12 @@ function Popover(props) { } Modal.willAlertModalBecomeVisible(props.isVisible); + return () => { + if (!removeOnClose) { + return; + } + removeOnClose(); + }; // We want this effect to run strictly ONLY when isVisible prop changes // eslint-disable-next-line react-hooks/exhaustive-deps }, [props.isVisible]); diff --git a/src/components/TaskHeaderActionButton.js b/src/components/TaskHeaderActionButton.js index 3e2a835e0722..09ca427b8e56 100644 --- a/src/components/TaskHeaderActionButton.js +++ b/src/components/TaskHeaderActionButton.js @@ -6,6 +6,7 @@ import compose from '@libs/compose'; import * as ReportUtils from '@libs/ReportUtils'; import reportPropTypes from '@pages/reportPropTypes'; import useThemeStyles from '@styles/useThemeStyles'; +import * as Session from '@userActions/Session'; import * as Task from '@userActions/Task'; import ONYXKEYS from '@src/ONYXKEYS'; import Button from './Button'; @@ -38,7 +39,7 @@ function TaskHeaderActionButton(props) { isDisabled={!Task.canModifyTask(props.report, props.session.accountID)} medium text={props.translate(ReportUtils.isCompletedTaskReport(props.report) ? 'task.markAsIncomplete' : 'task.markAsComplete')} - onPress={() => (ReportUtils.isCompletedTaskReport(props.report) ? Task.reopenTask(props.report) : Task.completeTask(props.report))} + onPress={Session.checkIfActionIsAllowed(() => (ReportUtils.isCompletedTaskReport(props.report) ? Task.reopenTask(props.report) : Task.completeTask(props.report)))} style={[styles.flex1]} /> diff --git a/src/components/ThumbnailImage.js b/src/components/ThumbnailImage.tsx similarity index 60% rename from src/components/ThumbnailImage.js rename to src/components/ThumbnailImage.tsx index 30bbbe525100..0fdd626a1517 100644 --- a/src/components/ThumbnailImage.js +++ b/src/components/ThumbnailImage.tsx @@ -1,51 +1,52 @@ import lodashClamp from 'lodash/clamp'; -import PropTypes from 'prop-types'; import React, {useCallback, useState} from 'react'; -import {Dimensions, View} from 'react-native'; +import {Dimensions, StyleProp, View, ViewStyle} from 'react-native'; import useWindowDimensions from '@hooks/useWindowDimensions'; import * as DeviceCapabilities from '@libs/DeviceCapabilities'; import * as StyleUtils from '@styles/StyleUtils'; import useThemeStyles from '@styles/useThemeStyles'; import ImageWithSizeCalculation from './ImageWithSizeCalculation'; -const propTypes = { +type ThumbnailImageProps = { /** Source URL for the preview image */ - previewSourceURL: PropTypes.string.isRequired, + previewSourceURL: string; /** Any additional styles to apply */ - // eslint-disable-next-line react/forbid-prop-types - style: PropTypes.any, + style?: StyleProp; /** Whether the image requires an authToken */ - isAuthTokenRequired: PropTypes.bool.isRequired, + isAuthTokenRequired: boolean; /** Width of the thumbnail image */ - imageWidth: PropTypes.number, + imageWidth?: number; /** Height of the thumbnail image */ - imageHeight: PropTypes.number, + imageHeight?: number; /** Should the image be resized on load or just fit container */ - shouldDynamicallyResize: PropTypes.bool, + shouldDynamicallyResize?: boolean; }; -const defaultProps = { - style: {}, - imageWidth: 200, - imageHeight: 200, - shouldDynamicallyResize: true, +type UpdateImageSizeParams = { + width: number; + height: number; +}; + +type CalculateThumbnailImageSizeResult = { + thumbnailWidth?: number; + thumbnailHeight?: number; }; /** * Compute the thumbnails width and height given original image dimensions. * - * @param {Number} width - Width of the original image. - * @param {Number} height - Height of the original image. - * @param {Number} windowHeight - Height of the device/browser window. - * @returns {Object} - Object containing thumbnails width and height. + * @param width - Width of the original image. + * @param height - Height of the original image. + * @param windowHeight - Height of the device/browser window. + * @returns - Object containing thumbnails width and height. */ -function calculateThumbnailImageSize(width, height, windowHeight) { +function calculateThumbnailImageSize(width: number, height: number, windowHeight: number): CalculateThumbnailImageSizeResult { if (!width || !height) { return {}; } @@ -69,44 +70,42 @@ function calculateThumbnailImageSize(width, height, windowHeight) { return {thumbnailWidth: Math.max(40, thumbnailScreenWidth), thumbnailHeight: Math.max(40, thumbnailScreenHeight)}; } -function ThumbnailImage(props) { +function ThumbnailImage({previewSourceURL, style, isAuthTokenRequired, imageWidth = 200, imageHeight = 200, shouldDynamicallyResize = true}: ThumbnailImageProps) { const styles = useThemeStyles(); const {windowHeight} = useWindowDimensions(); - const initialDimensions = calculateThumbnailImageSize(props.imageWidth, props.imageHeight, windowHeight); - const [imageWidth, setImageWidth] = useState(initialDimensions.thumbnailWidth); - const [imageHeight, setImageHeight] = useState(initialDimensions.thumbnailHeight); + const initialDimensions = calculateThumbnailImageSize(imageWidth, imageHeight, windowHeight); + const [currentImageWidth, setCurrentImageWidth] = useState(initialDimensions.thumbnailWidth); + const [currentImageHeight, setCurrentImageHeight] = useState(initialDimensions.thumbnailHeight); /** * Update the state with the computed thumbnail sizes. - * - * @param {{ width: number, height: number }} Params - width and height of the original image. + * @param Params - width and height of the original image. */ const updateImageSize = useCallback( - ({width, height}) => { + ({width, height}: UpdateImageSizeParams) => { const {thumbnailWidth, thumbnailHeight} = calculateThumbnailImageSize(width, height, windowHeight); - setImageWidth(thumbnailWidth); - setImageHeight(thumbnailHeight); + + setCurrentImageWidth(thumbnailWidth); + setCurrentImageHeight(thumbnailHeight); }, [windowHeight], ); - const sizeStyles = props.shouldDynamicallyResize ? [StyleUtils.getWidthAndHeightStyle(imageWidth, imageHeight)] : [styles.w100, styles.h100]; + const sizeStyles = shouldDynamicallyResize ? [StyleUtils.getWidthAndHeightStyle(currentImageWidth ?? 0, currentImageHeight)] : [styles.w100, styles.h100]; return ( - + ); } -ThumbnailImage.propTypes = propTypes; -ThumbnailImage.defaultProps = defaultProps; ThumbnailImage.displayName = 'ThumbnailImage'; export default React.memo(ThumbnailImage); diff --git a/src/libs/ComposerUtils/index.ts b/src/libs/ComposerUtils/index.ts index 5a7da7ca08cf..32ebca9afee8 100644 --- a/src/libs/ComposerUtils/index.ts +++ b/src/libs/ComposerUtils/index.ts @@ -23,19 +23,4 @@ function canSkipTriggerHotkeys(isSmallScreenWidth: boolean, isKeyboardShown: boo return (isSmallScreenWidth && DeviceCapabilities.canUseTouchScreen()) || isKeyboardShown; } -/** - * Returns the length of the common suffix between two input strings. - * The common suffix is the number of characters shared by both strings - * at the end (suffix) until a mismatch is encountered. - * - * @returns The length of the common suffix between the strings. - */ -function getCommonSuffixLength(str1: string, str2: string): number { - let i = 0; - while (str1[str1.length - 1 - i] === str2[str2.length - 1 - i]) { - i++; - } - return i; -} - -export {getNumberOfLines, updateNumberOfLines, insertText, canSkipTriggerHotkeys, getCommonSuffixLength}; +export {getNumberOfLines, updateNumberOfLines, insertText, canSkipTriggerHotkeys}; diff --git a/src/libs/EmojiUtils.ts b/src/libs/EmojiUtils.ts index 55596f9becf8..fc9e601133fe 100644 --- a/src/libs/EmojiUtils.ts +++ b/src/libs/EmojiUtils.ts @@ -14,7 +14,7 @@ import emojisTrie from './EmojiTrie'; type HeaderIndice = {code: string; index: number; icon: React.FC | ImageSourcePropType}; type EmojiSpacer = {code: string; spacer: boolean}; type EmojiPickerList = Array; -type ReplacedEmoji = {text: string; emojis: Emoji[]}; +type ReplacedEmoji = {text: string; emojis: Emoji[]; cursorPosition?: number}; type UserReactions = { id: string; skinTones: Record; @@ -334,8 +334,11 @@ function replaceEmojis(text: string, preferredSkinTone = CONST.EMOJI_DEFAULT_SKI if (!emojiData || emojiData.length === 0) { return {text: newText, emojis}; } - for (let i = 0; i < emojiData.length; i++) { - const name = emojiData[i].slice(1, -1); + + let cursorPosition; + + for (const emoji of emojiData) { + const name = emoji.slice(1, -1); let checkEmoji = trie.search(name); // If the user has selected a language other than English, and the emoji doesn't exist in that language, // we will check if the emoji exists in English. @@ -347,35 +350,46 @@ function replaceEmojis(text: string, preferredSkinTone = CONST.EMOJI_DEFAULT_SKI } } if (checkEmoji?.metaData?.code && checkEmoji?.metaData?.name) { - let emojiReplacement = getEmojiCodeWithSkinColor(checkEmoji.metaData as Emoji, preferredSkinTone); + const emojiReplacement = getEmojiCodeWithSkinColor(checkEmoji.metaData as Emoji, preferredSkinTone); emojis.push({ name, code: checkEmoji.metaData?.code, types: checkEmoji.metaData.types, }); - // If this is the last emoji in the message and it's the end of the message so far, - // add a space after it so the user can keep typing easily. - if (i === emojiData.length - 1) { - emojiReplacement += ' '; - } + // Set the cursor to the end of the last replaced Emoji. Note that we position after + // the extra space, if we added one. + cursorPosition = newText.indexOf(emoji) + emojiReplacement.length; + + newText = newText.replace(emoji, emojiReplacement); + } + } + + // cursorPosition, when not undefined, points to the end of the last emoji that was replaced. + // In that case we want to append a space at the cursor position, but only if the next character + // is not already a space (to avoid double spaces). + if (cursorPosition && cursorPosition > 0) { + const space = ' '; - newText = newText.replace(emojiData[i], emojiReplacement); + if (newText.charAt(cursorPosition) !== space) { + newText = newText.slice(0, cursorPosition) + space + newText.slice(cursorPosition); } + cursorPosition += space.length; } - return {text: newText, emojis}; + return {text: newText, emojis, cursorPosition}; } /** * Find all emojis in a text and replace them with their code. */ function replaceAndExtractEmojis(text: string, preferredSkinTone = CONST.EMOJI_DEFAULT_SKIN_TONE, lang = CONST.LOCALES.DEFAULT): ReplacedEmoji { - const {text: convertedText = '', emojis = []} = replaceEmojis(text, preferredSkinTone, lang); + const {text: convertedText = '', emojis = [], cursorPosition} = replaceEmojis(text, preferredSkinTone, lang); return { text: convertedText, emojis: emojis.concat(extractEmojis(text)), + cursorPosition, }; } diff --git a/src/libs/HttpUtils.js b/src/libs/HttpUtils.ts similarity index 65% rename from src/libs/HttpUtils.js rename to src/libs/HttpUtils.ts index 2df7421ea91c..859c8624833c 100644 --- a/src/libs/HttpUtils.js +++ b/src/libs/HttpUtils.ts @@ -1,13 +1,16 @@ import Onyx from 'react-native-onyx'; -import _ from 'underscore'; +import {ValueOf} from 'type-fest'; import alert from '@components/Alert'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; +import {RequestType} from '@src/types/onyx/Request'; +import type Response from '@src/types/onyx/Response'; import * as ApiUtils from './ApiUtils'; import HttpsError from './Errors/HttpsError'; let shouldFailAllRequests = false; let shouldForceOffline = false; + Onyx.connect({ key: ONYXKEYS.NETWORK, callback: (network) => { @@ -25,14 +28,8 @@ let cancellationController = new AbortController(); /** * Send an HTTP request, and attempt to resolve the json response. * If there is a network error, we'll set the application offline. - * - * @param {String} url - * @param {String} [method] - * @param {Object} [body] - * @param {Boolean} [canCancel] - * @returns {Promise} */ -function processHTTPRequest(url, method = 'get', body = null, canCancel = true) { +function processHTTPRequest(url: string, method: RequestType = 'get', body: FormData | null = null, canCancel = true): Promise { return fetch(url, { // We hook requests to the same Controller signal, so we can cancel them all at once signal: canCancel ? cancellationController.signal : undefined, @@ -49,40 +46,41 @@ function processHTTPRequest(url, method = 'get', body = null, canCancel = true) if (!response.ok) { // Expensify site is down or there was an internal server error, or something temporary like a Bad Gateway, or unknown error occurred - const serviceInterruptedStatuses = [ + const serviceInterruptedStatuses: Array> = [ CONST.HTTP_STATUS.INTERNAL_SERVER_ERROR, CONST.HTTP_STATUS.BAD_GATEWAY, CONST.HTTP_STATUS.GATEWAY_TIMEOUT, CONST.HTTP_STATUS.UNKNOWN_ERROR, ]; - if (_.contains(serviceInterruptedStatuses, response.status)) { + if (serviceInterruptedStatuses.indexOf(response.status as ValueOf) > -1) { throw new HttpsError({ message: CONST.ERROR.EXPENSIFY_SERVICE_INTERRUPTED, - status: response.status, + status: response.status.toString(), title: 'Issue connecting to Expensify site', }); - } else if (response.status === CONST.HTTP_STATUS.TOO_MANY_REQUESTS) { + } + if (response.status === CONST.HTTP_STATUS.TOO_MANY_REQUESTS) { throw new HttpsError({ message: CONST.ERROR.THROTTLED, - status: response.status, + status: response.status.toString(), title: 'API request throttled', }); } throw new HttpsError({ message: response.statusText, - status: response.status, + status: response.status.toString(), }); } - return response.json(); + return response.json() as Promise; }) .then((response) => { // Some retried requests will result in a "Unique Constraints Violation" error from the server, which just means the record already exists if (response.jsonCode === CONST.JSON_CODE.BAD_REQUEST && response.message === CONST.ERROR_TITLE.DUPLICATE_RECORD) { throw new HttpsError({ message: CONST.ERROR.DUPLICATE_RECORD, - status: CONST.JSON_CODE.BAD_REQUEST, + status: CONST.JSON_CODE.BAD_REQUEST.toString(), title: CONST.ERROR_TITLE.DUPLICATE_RECORD, }); } @@ -91,43 +89,42 @@ function processHTTPRequest(url, method = 'get', body = null, canCancel = true) if (response.jsonCode === CONST.JSON_CODE.EXP_ERROR && response.title === CONST.ERROR_TITLE.SOCKET && response.type === CONST.ERROR_TYPE.SOCKET) { throw new HttpsError({ message: CONST.ERROR.EXPENSIFY_SERVICE_INTERRUPTED, - status: CONST.JSON_CODE.EXP_ERROR, + status: CONST.JSON_CODE.EXP_ERROR.toString(), title: CONST.ERROR_TITLE.SOCKET, }); } if (response.jsonCode === CONST.JSON_CODE.MANY_WRITES_ERROR) { - const {phpCommandName, authWriteCommands} = response.data; - // eslint-disable-next-line max-len - const message = `The API call (${phpCommandName}) did more Auth write requests than allowed. Count ${authWriteCommands.length}, commands: ${authWriteCommands.join( - ', ', - )}. Check the APIWriteCommands class in Web-Expensify`; - alert('Too many auth writes', message); + if (response.data) { + const {phpCommandName, authWriteCommands} = response.data; + // eslint-disable-next-line max-len + const message = `The API call (${phpCommandName}) did more Auth write requests than allowed. Count ${authWriteCommands.length}, commands: ${authWriteCommands.join( + ', ', + )}. Check the APIWriteCommands class in Web-Expensify`; + alert('Too many auth writes', message); + } } - return response; + return response as Promise; }); } /** * Makes XHR request - * @param {String} command the name of the API command - * @param {Object} data parameters for the API command - * @param {String} type HTTP request type (get/post) - * @param {Boolean} shouldUseSecure should we use the secure server - * @returns {Promise} + * @param command the name of the API command + * @param data parameters for the API command + * @param type HTTP request type (get/post) + * @param shouldUseSecure should we use the secure server */ -function xhr(command, data, type = CONST.NETWORK.METHOD.POST, shouldUseSecure = false) { +function xhr(command: string, data: Record, type: RequestType = CONST.NETWORK.METHOD.POST, shouldUseSecure = false): Promise { const formData = new FormData(); - _.each(data, (val, key) => { - // Do not send undefined request parameters to our API. They will be processed as strings of 'undefined'. - if (_.isUndefined(val)) { + Object.keys(data).forEach((key) => { + if (typeof data[key] === 'undefined') { return; } - - formData.append(key, val); + formData.append(key, data[key] as string | Blob); }); const url = ApiUtils.getCommandURL({shouldUseSecure, command}); - return processHTTPRequest(url, type, formData, data.canCancel); + return processHTTPRequest(url, type, formData, Boolean(data.canCancel)); } function cancelPendingRequests() { diff --git a/src/libs/Notification/PushNotification/subscribeToReportCommentPushNotifications.js b/src/libs/Notification/PushNotification/subscribeToReportCommentPushNotifications.js index 04fd34bf6075..ede873f79c6e 100644 --- a/src/libs/Notification/PushNotification/subscribeToReportCommentPushNotifications.js +++ b/src/libs/Notification/PushNotification/subscribeToReportCommentPushNotifications.js @@ -23,18 +23,20 @@ export default function subscribeToReportCommentPushNotifications() { } Log.info('[PushNotification] onSelected() - called', false, {reportID, reportActionID}); - Navigation.isNavigationReady().then(() => { - try { - // If a chat is visible other than the one we are trying to navigate to, then we need to navigate back - if (Navigation.getActiveRoute().slice(1, 2) === ROUTES.REPORT && !Navigation.isActiveRoute(`r/${reportID}`)) { - Navigation.goBack(ROUTES.HOME); - } + Navigation.isNavigationReady() + .then(Navigation.waitForProtectedRoutes) + .then(() => { + try { + // If a chat is visible other than the one we are trying to navigate to, then we need to navigate back + if (Navigation.getActiveRoute().slice(1, 2) === ROUTES.REPORT && !Navigation.isActiveRoute(`r/${reportID}`)) { + Navigation.goBack(ROUTES.HOME); + } - Log.info('[PushNotification] onSelected() - Navigation is ready. Navigating...', false, {reportID, reportActionID}); - Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(reportID)); - } catch (error) { - Log.alert('[PushNotification] onSelected() - failed', {reportID, reportActionID, error: error.message}); - } - }); + Log.info('[PushNotification] onSelected() - Navigation is ready. Navigating...', false, {reportID, reportActionID}); + Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(reportID)); + } catch (error) { + Log.alert('[PushNotification] onSelected() - failed', {reportID, reportActionID, error: error.message}); + } + }); }); } diff --git a/src/libs/ReportActionsUtils.ts b/src/libs/ReportActionsUtils.ts index a5edd6fe549f..4af2d0c8a3c2 100644 --- a/src/libs/ReportActionsUtils.ts +++ b/src/libs/ReportActionsUtils.ts @@ -415,9 +415,12 @@ function getLastVisibleMessage(reportID: string, actionsToMerge: ReportActions = }; } - const messageText = message?.text ?? ''; + let messageText = message?.text ?? ''; + if (messageText) { + messageText = String(messageText).replace(CONST.REGEX.AFTER_FIRST_LINE_BREAK, '').substring(0, CONST.REPORT.LAST_MESSAGE_TEXT_MAX_LENGTH).trim(); + } return { - lastMessageText: String(messageText).replace(CONST.REGEX.AFTER_FIRST_LINE_BREAK, '').substring(0, CONST.REPORT.LAST_MESSAGE_TEXT_MAX_LENGTH).trim(), + lastMessageText: messageText, }; } diff --git a/src/libs/ReportUtils.js b/src/libs/ReportUtils.js index 2dedd2053719..f5f8bf879284 100644 --- a/src/libs/ReportUtils.js +++ b/src/libs/ReportUtils.js @@ -841,8 +841,15 @@ function isOneOnOneChat(report) { * @returns {Object} */ function getReport(reportID) { - // Deleted reports are set to null and lodashGet will still return null in that case, so we need to add an extra check - return lodashGet(allReports, `${ONYXKEYS.COLLECTION.REPORT}${reportID}`, {}) || {}; + /** + * Using typical string concatenation here due to performance issues + * with template literals. + */ + if (!allReports) { + return {}; + } + + return allReports[ONYXKEYS.COLLECTION.REPORT + reportID] || {}; } /** @@ -1582,14 +1589,25 @@ function getMoneyRequestSpendBreakdown(report, allReportsDict = null) { * @returns {String} */ function getPolicyExpenseChatName(report, policy = undefined) { - const reportOwnerDisplayName = getDisplayNameForParticipant(report.ownerAccountID) || lodashGet(allPersonalDetails, [report.ownerAccountID, 'login']) || report.reportName; + const ownerAccountID = report.ownerAccountID; + const personalDetails = allPersonalDetails[ownerAccountID]; + const login = personalDetails ? personalDetails.login : null; + const reportOwnerDisplayName = getDisplayNameForParticipant(report.ownerAccountID) || login || report.reportName; // If the policy expense chat is owned by this user, use the name of the policy as the report name. if (report.isOwnPolicyExpenseChat) { return getPolicyName(report, false, policy); } - const policyExpenseChatRole = lodashGet(allPolicies, [`${ONYXKEYS.COLLECTION.POLICY}${report.policyID}`, 'role']) || 'user'; + let policyExpenseChatRole = 'user'; + /** + * Using typical string concatenation here due to performance issues + * with template literals. + */ + const policyItem = allPolicies[ONYXKEYS.COLLECTION.POLICY + report.policyID]; + if (policyItem) { + policyExpenseChatRole = policyItem.role || 'user'; + } // If this user is not admin and this policy expense chat has been archived because of account merging, this must be an old workspace chat // of the account which was merged into the current user's account. Use the name of the policy as the name of the report. @@ -2276,6 +2294,19 @@ function navigateToDetailsPage(report) { Navigation.navigate(ROUTES.REPORT_WITH_ID_DETAILS.getRoute(report.reportID)); } +/** + * Go back to the details page of a given report + * + * @param {Object} report + */ +function goBackToDetailsPage(report) { + if (isOneOnOneChat(report)) { + Navigation.goBack(ROUTES.PROFILE.getRoute(report.participantAccountIDs[0])); + return; + } + Navigation.goBack(ROUTES.REPORT_SETTINGS.getRoute(report.reportID)); +} + /** * Generate a random reportID up to 53 bits aka 9,007,199,254,740,991 (Number.MAX_SAFE_INTEGER). * There were approximately 98,000,000 reports with sequential IDs generated before we started using this approach, those make up roughly one billionth of the space for these numbers, @@ -4446,6 +4477,7 @@ export { hasSingleParticipant, getReportRecipientAccountIDs, isOneOnOneChat, + goBackToDetailsPage, getTransactionReportName, getTransactionDetails, getTaskAssigneeChatOnyxData, diff --git a/src/libs/Request.ts b/src/libs/Request.ts index 335731763ec9..18fadca467ad 100644 --- a/src/libs/Request.ts +++ b/src/libs/Request.ts @@ -16,7 +16,7 @@ function makeXHR(request: Request): Promise { return new Promise((resolve) => resolve()); } - return HttpUtils.xhr(request.command, finalParameters, request.type, request.shouldUseSecure) as Promise; + return HttpUtils.xhr(request.command, finalParameters, request.type, request.shouldUseSecure); }); } diff --git a/src/libs/SidebarUtils.ts b/src/libs/SidebarUtils.ts index 58c4a124335d..763a0000ba35 100644 --- a/src/libs/SidebarUtils.ts +++ b/src/libs/SidebarUtils.ts @@ -158,18 +158,6 @@ function getOrderedReportIDs( } } - // There are a few properties that need to be calculated for the report which are used when sorting reports. - reportsToDisplay.forEach((report) => { - // Normally, the spread operator would be used here to clone the report and prevent the need to reassign the params. - // However, this code needs to be very performant to handle thousands of reports, so in the interest of speed, we're just going to disable this lint rule and add - // the reportDisplayName property to the report object directly. - // eslint-disable-next-line no-param-reassign - report.displayName = ReportUtils.getReportName(report); - - // eslint-disable-next-line no-param-reassign - report.iouReportAmount = ReportUtils.getMoneyRequestReimbursableTotal(report, allReports); - }); - // The LHN is split into four distinct groups, and each group is sorted a little differently. The groups will ALWAYS be in this order: // 1. Pinned/GBR - Always sorted by reportDisplayName // 2. Drafts - Always sorted by reportDisplayName @@ -183,7 +171,18 @@ function getOrderedReportIDs( const draftReports: Report[] = []; const nonArchivedReports: Report[] = []; const archivedReports: Report[] = []; + + // There are a few properties that need to be calculated for the report which are used when sorting reports. reportsToDisplay.forEach((report) => { + // Normally, the spread operator would be used here to clone the report and prevent the need to reassign the params. + // However, this code needs to be very performant to handle thousands of reports, so in the interest of speed, we're just going to disable this lint rule and add + // the reportDisplayName property to the report object directly. + // eslint-disable-next-line no-param-reassign + report.displayName = ReportUtils.getReportName(report); + + // eslint-disable-next-line no-param-reassign + report.iouReportAmount = ReportUtils.getMoneyRequestReimbursableTotal(report, allReports); + const isPinned = report.isPinned ?? false; if (isPinned || ReportUtils.requiresAttentionFromCurrentUser(report)) { pinnedAndGBRReports.push(report); diff --git a/src/libs/UnreadIndicatorUpdater/index.js b/src/libs/UnreadIndicatorUpdater/index.js index 9af74f8313c3..bfa0cd911177 100644 --- a/src/libs/UnreadIndicatorUpdater/index.js +++ b/src/libs/UnreadIndicatorUpdater/index.js @@ -1,3 +1,4 @@ +import {InteractionManager} from 'react-native'; import Onyx from 'react-native-onyx'; import _ from 'underscore'; import * as ReportUtils from '@libs/ReportUtils'; @@ -5,11 +6,33 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import updateUnread from './updateUnread/index'; +let previousUnreadCount = 0; + Onyx.connect({ key: ONYXKEYS.COLLECTION.REPORT, waitForCollectionCallback: true, callback: (reportsFromOnyx) => { - const unreadReports = _.filter(reportsFromOnyx, (report) => ReportUtils.isUnread(report) && report.notificationPreference !== CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN); - updateUnread(_.size(unreadReports)); + if (!reportsFromOnyx) { + return; + } + + /** + * We need to wait until after interactions have finished to update the unread count because otherwise + * the unread count will be updated while the interactions/animations are in progress and we don't want + * to put more work on the main thread. + * + * For eg. On web we are manipulating DOM and it makes it a better candidate to wait until after interactions + * have finished. + * + * More info: https://reactnative.dev/docs/interactionmanager + */ + InteractionManager.runAfterInteractions(() => { + const unreadReports = _.filter(reportsFromOnyx, (report) => ReportUtils.isUnread(report) && report.notificationPreference !== CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN); + const unreadReportsCount = _.size(unreadReports); + if (previousUnreadCount !== unreadReportsCount) { + previousUnreadCount = unreadReportsCount; + updateUnread(unreadReportsCount); + } + }); }, }); diff --git a/src/libs/actions/IOU.js b/src/libs/actions/IOU.js index 6347f45549c7..939a11dad511 100644 --- a/src/libs/actions/IOU.js +++ b/src/libs/actions/IOU.js @@ -794,10 +794,10 @@ function editDistanceMoneyRequest(transactionID, transactionThreadReportID, tran }); if (_.has(transactionChanges, 'waypoints')) { - // Delete the backup transaction when editing waypoints when the server responds successfully and there are no errors + // Delete the draft transaction when editing waypoints when the server responds successfully and there are no errors successData.push({ onyxMethod: Onyx.METHOD.SET, - key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}-backup`, + key: `${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`, value: null, }); } @@ -2445,7 +2445,7 @@ function getSendMoneyParams(report, amount, currency, comment, paymentMethodType function getPayMoneyRequestParams(chatReport, iouReport, recipient, paymentMethodType) { const optimisticIOUReportAction = ReportUtils.buildOptimisticIOUReportAction( CONST.IOU.REPORT_ACTION_TYPE.PAY, - iouReport.total, + -iouReport.total, iouReport.currency, '', [recipient], diff --git a/src/libs/actions/Modal.ts b/src/libs/actions/Modal.ts index 39016b241585..e1e73d425281 100644 --- a/src/libs/actions/Modal.ts +++ b/src/libs/actions/Modal.ts @@ -1,30 +1,38 @@ import Onyx from 'react-native-onyx'; import ONYXKEYS from '@src/ONYXKEYS'; -let closeModal: ((isNavigating: boolean) => void) | null; +const closeModals: Array<(isNavigating: boolean) => void> = []; + let onModalClose: null | (() => void); /** * Allows other parts of the app to call modal close function */ -function setCloseModal(onClose: (() => void) | null) { - closeModal = onClose; +function setCloseModal(onClose: () => void) { + if (!closeModals.includes(onClose)) { + closeModals.push(onClose); + } + return () => { + const index = closeModals.indexOf(onClose); + if (index === -1) { + return; + } + closeModals.splice(index, 1); + }; } /** * Close modal in other parts of the app */ function close(onModalCloseCallback: () => void, isNavigating = true) { - if (!closeModal) { - // If modal is already closed, no need to wait for modal close. So immediately call callback. - if (onModalCloseCallback) { - onModalCloseCallback(); - } - onModalClose = null; + if (closeModals.length === 0) { + onModalCloseCallback(); return; } onModalClose = onModalCloseCallback; - closeModal(isNavigating); + [...closeModals].reverse().forEach((onClose) => { + onClose(isNavigating); + }); } function onModalDidClose() { diff --git a/src/libs/actions/PersonalDetails.ts b/src/libs/actions/PersonalDetails.ts index 12da798177ab..e26cee71dc67 100644 --- a/src/libs/actions/PersonalDetails.ts +++ b/src/libs/actions/PersonalDetails.ts @@ -1,7 +1,7 @@ import Str from 'expensify-common/lib/str'; import Onyx, {OnyxEntry, OnyxUpdate} from 'react-native-onyx'; import * as API from '@libs/API'; -import {CustomRNImageManipulatorResult, FileWithUri} from '@libs/cropOrRotateImage/types'; +import {CustomRNImageManipulatorResult} from '@libs/cropOrRotateImage/types'; import * as LocalePhoneNumber from '@libs/LocalePhoneNumber'; import Navigation from '@libs/Navigation/Navigation'; import * as PersonalDetailsUtils from '@libs/PersonalDetailsUtils'; @@ -445,7 +445,7 @@ function openPublicProfilePage(accountID: number) { /** * Updates the user's avatar image */ -function updateAvatar(file: FileWithUri | CustomRNImageManipulatorResult) { +function updateAvatar(file: File | CustomRNImageManipulatorResult) { if (!currentUserAccountID) { return; } @@ -501,7 +501,7 @@ function updateAvatar(file: FileWithUri | CustomRNImageManipulatorResult) { ]; type UpdateUserAvatarParams = { - file: FileWithUri | CustomRNImageManipulatorResult; + file: File | CustomRNImageManipulatorResult; }; const parameters: UpdateUserAvatarParams = {file}; diff --git a/src/libs/actions/Report.js b/src/libs/actions/Report.js index ac45a1e3f3be..9bc2aa1b3f2f 100644 --- a/src/libs/actions/Report.js +++ b/src/libs/actions/Report.js @@ -1375,11 +1375,12 @@ function saveReportActionDraftNumberOfLines(reportID, reportActionID, numberOfLi * @param {boolean} navigate * @param {String} parentReportID * @param {String} parentReportActionID + * @param {Object} report */ -function updateNotificationPreference(reportID, previousValue, newValue, navigate, parentReportID = 0, parentReportActionID = 0) { +function updateNotificationPreference(reportID, previousValue, newValue, navigate, parentReportID = 0, parentReportActionID = 0, report = {}) { if (previousValue === newValue) { - if (navigate) { - Navigation.goBack(ROUTES.REPORT_SETTINGS.getRoute(reportID)); + if (navigate && report.reportID) { + ReportUtils.goBackToDetailsPage(report); } return; } @@ -1411,7 +1412,7 @@ function updateNotificationPreference(reportID, previousValue, newValue, navigat } API.write('UpdateReportNotificationPreference', {reportID, notificationPreference: newValue}, {optimisticData, failureData}); if (navigate) { - Navigation.goBack(ROUTES.REPORT_SETTINGS.getRoute(reportID)); + ReportUtils.goBackToDetailsPage(report); } } diff --git a/src/libs/actions/ReportActions.ts b/src/libs/actions/ReportActions.ts index d7ff96fc6c2e..49d2432277a0 100644 --- a/src/libs/actions/ReportActions.ts +++ b/src/libs/actions/ReportActions.ts @@ -27,7 +27,7 @@ function clearReportActionErrors(reportID: string, reportAction: ReportAction) { // Delete the failed task report too const taskReportID = reportAction.message?.[0]?.taskReportID; - if (taskReportID) { + if (taskReportID && ReportActionUtils.isCreatedTaskReportAction(reportAction)) { Report.deleteReport(taskReportID); } return; diff --git a/src/libs/actions/Task.js b/src/libs/actions/Task.js index ef998d6dac8d..c91f6d1a2eec 100644 --- a/src/libs/actions/Task.js +++ b/src/libs/actions/Task.js @@ -450,6 +450,9 @@ function editTaskAssigneeAndNavigate(report, ownerAccountID, assigneeEmail, assi pendingFields: { ...(assigneeAccountID && {managerID: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}), }, + notificationPreference: [assigneeAccountID, ownerAccountID].includes(currentUserAccountID) + ? CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS + : CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN, }; const optimisticData = [ diff --git a/src/libs/actions/TransactionEdit.js b/src/libs/actions/TransactionEdit.js index 78a271f0f8cd..2cb79ac387bd 100644 --- a/src/libs/actions/TransactionEdit.js +++ b/src/libs/actions/TransactionEdit.js @@ -11,7 +11,7 @@ function createBackupTransaction(transaction) { ...transaction, }; // Use set so that it will always fully overwrite any backup transaction that could have existed before - Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}-backup`, newTransaction); + Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transaction.transactionID}`, newTransaction); } /** @@ -19,12 +19,12 @@ function createBackupTransaction(transaction) { * @param {String} transactionID */ function removeBackupTransaction(transactionID) { - Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}-backup`, null); + Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`, null); } function restoreOriginalTransactionFromBackup(transactionID) { const connectionID = Onyx.connect({ - key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}-backup`, + key: `${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`, callback: (backupTransaction) => { Onyx.disconnect(connectionID); diff --git a/src/libs/cropOrRotateImage/index.ts b/src/libs/cropOrRotateImage/index.ts index 6b222c9759b5..a66ddbb40b00 100644 --- a/src/libs/cropOrRotateImage/index.ts +++ b/src/libs/cropOrRotateImage/index.ts @@ -1,4 +1,4 @@ -import {CropOptions, CropOrRotateImage, CropOrRotateImageOptions, FileWithUri} from './types'; +import {CropOptions, CropOrRotateImage, CropOrRotateImageOptions} from './types'; type SizeFromAngle = { width: number; @@ -71,13 +71,13 @@ function cropCanvas(canvas: HTMLCanvasElement, options: CropOptions) { return result; } -function convertCanvasToFile(canvas: HTMLCanvasElement, options: CropOrRotateImageOptions): Promise { +function convertCanvasToFile(canvas: HTMLCanvasElement, options: CropOrRotateImageOptions): Promise { return new Promise((resolve) => { canvas.toBlob((blob) => { if (!blob) { return; } - const file = new File([blob], options.name || 'fileName.jpeg', {type: options.type || 'image/jpeg'}) as FileWithUri; + const file = new File([blob], options.name || 'fileName.jpeg', {type: options.type || 'image/jpeg'}); file.uri = URL.createObjectURL(file); resolve(file); }); diff --git a/src/libs/cropOrRotateImage/types.ts b/src/libs/cropOrRotateImage/types.ts index 09f441bd9324..188d557a1258 100644 --- a/src/libs/cropOrRotateImage/types.ts +++ b/src/libs/cropOrRotateImage/types.ts @@ -18,12 +18,8 @@ type Action = { rotate?: number; }; -type FileWithUri = File & { - uri: string; -}; - type CustomRNImageManipulatorResult = RNImageManipulatorResult & {size: number; type: string; name: string}; -type CropOrRotateImage = (uri: string, actions: Action[], options: CropOrRotateImageOptions) => Promise; +type CropOrRotateImage = (uri: string, actions: Action[], options: CropOrRotateImageOptions) => Promise; -export type {CropOrRotateImage, CropOptions, Action, FileWithUri, CropOrRotateImageOptions, CustomRNImageManipulatorResult}; +export type {CropOrRotateImage, CropOptions, Action, CropOrRotateImageOptions, CustomRNImageManipulatorResult}; diff --git a/src/libs/fileDownload/FileUtils.js b/src/libs/fileDownload/FileUtils.ts similarity index 71% rename from src/libs/fileDownload/FileUtils.js rename to src/libs/fileDownload/FileUtils.ts index b838a81ea550..5bac47fb63ec 100644 --- a/src/libs/fileDownload/FileUtils.js +++ b/src/libs/fileDownload/FileUtils.ts @@ -2,6 +2,7 @@ import {Alert, Linking, Platform} from 'react-native'; import DateUtils from '@libs/DateUtils'; import * as Localize from '@libs/Localize'; import CONST from '@src/CONST'; +import type {ReadFileAsync, SplitExtensionFromFileName} from './types'; /** * Show alert on successful attachment download @@ -43,7 +44,9 @@ function showPermissionErrorAlert() { }, { text: Localize.translateLocal('common.settings'), - onPress: () => Linking.openSettings(), + onPress: () => { + Linking.openSettings(); + }, }, ]); } @@ -62,7 +65,9 @@ function showCameraPermissionsAlert() { }, { text: Localize.translateLocal('common.settings'), - onPress: () => Linking.openSettings(), + onPress: () => { + Linking.openSettings(); + }, }, ], {cancelable: false}, @@ -71,42 +76,36 @@ function showCameraPermissionsAlert() { /** * Generate a random file name with timestamp and file extension - * @param {String} url - * @returns {String} */ -function getAttachmentName(url) { +function getAttachmentName(url: string): string { if (!url) { return ''; } - return `${DateUtils.getDBTime()}.${url.split(/[#?]/)[0].split('.').pop().trim()}`; + return `${DateUtils.getDBTime()}.${url.split(/[#?]/)[0].split('.').pop()?.trim()}`; } -/** - * @param {String} fileName - * @returns {Boolean} - */ -function isImage(fileName) { +function isImage(fileName: string): boolean { return CONST.FILE_TYPE_REGEX.IMAGE.test(fileName); } -/** - * @param {String} fileName - * @returns {Boolean} - */ -function isVideo(fileName) { +function isVideo(fileName: string): boolean { return CONST.FILE_TYPE_REGEX.VIDEO.test(fileName); } /** * Returns file type based on the uri - * @param {String} fileUrl - * @returns {String} */ -function getFileType(fileUrl) { +function getFileType(fileUrl: string): string | undefined { if (!fileUrl) { return; } - const fileName = fileUrl.split('/').pop().split('?')[0].split('#')[0]; + + const fileName = fileUrl.split('/').pop()?.split('?')[0].split('#')[0]; + + if (!fileName) { + return; + } + if (isImage(fileName)) { return CONST.ATTACHMENT_FILE_TYPE.IMAGE; } @@ -118,32 +117,22 @@ function getFileType(fileUrl) { /** * Returns the filename split into fileName and fileExtension - * - * @param {String} fullFileName - * @returns {Object} */ -function splitExtensionFromFileName(fullFileName) { +const splitExtensionFromFileName: SplitExtensionFromFileName = (fullFileName) => { const fileName = fullFileName.trim(); const splitFileName = fileName.split('.'); const fileExtension = splitFileName.length > 1 ? splitFileName.pop() : ''; - return {fileName: splitFileName.join('.'), fileExtension}; -} + return {fileName: splitFileName.join('.'), fileExtension: fileExtension ?? ''}; +}; /** * Returns the filename replacing special characters with underscore - * - * @param {String} fileName - * @returns {String} */ -function cleanFileName(fileName) { +function cleanFileName(fileName: string): string { return fileName.replace(/[^a-zA-Z0-9\-._]/g, '_'); } -/** - * @param {String} fileName - * @returns {String} - */ -function appendTimeToFileName(fileName) { +function appendTimeToFileName(fileName: string): string { const file = splitExtensionFromFileName(fileName); let newFileName = `${file.fileName}-${DateUtils.getDBTime()}`; // Replace illegal characters before trying to download the attachment. @@ -156,21 +145,17 @@ function appendTimeToFileName(fileName) { /** * Reads a locally uploaded file - * - * @param {String} path - the blob url of the locally uplodaded file - * @param {String} fileName - * @param {Function} onSuccess - * @param {Function} onFailure - * - * @returns {Promise} + * @param path - the blob url of the locally uploaded file + * @param fileName - name of the file to read */ -const readFileAsync = (path, fileName, onSuccess, onFailure = () => {}) => +const readFileAsync: ReadFileAsync = (path, fileName, onSuccess, onFailure = () => {}) => new Promise((resolve) => { if (!path) { resolve(); + onFailure('[FileUtils] Path not specified'); + return; } - - return fetch(path) + fetch(path) .then((res) => { // For some reason, fetch is "Unable to read uploaded file" // on Android even though the blob is returned, so we'll ignore @@ -178,19 +163,26 @@ const readFileAsync = (path, fileName, onSuccess, onFailure = () => {}) => if (!res.ok && Platform.OS !== 'android') { throw Error(res.statusText); } - return res.blob(); - }) - .then((blob) => { - const file = new File([blob], cleanFileName(fileName), {type: blob.type}); - file.source = path; - // For some reason, the File object on iOS does not have a uri property - // so images aren't uploaded correctly to the backend - file.uri = path; - onSuccess(file); + res.blob() + .then((blob) => { + const file = new File([blob], cleanFileName(fileName)); + file.source = path; + // For some reason, the File object on iOS does not have a uri property + // so images aren't uploaded correctly to the backend + file.uri = path; + onSuccess(file); + resolve(file); + }) + .catch((e) => { + console.debug('[FileUtils] Could not read uploaded file', e); + onFailure(e); + resolve(); + }); }) .catch((e) => { console.debug('[FileUtils] Could not read uploaded file', e); onFailure(e); + resolve(); }); }); @@ -198,16 +190,16 @@ const readFileAsync = (path, fileName, onSuccess, onFailure = () => {}) => * Converts a base64 encoded image string to a File instance. * Adds a `uri` property to the File instance for accessing the blob as a URI. * - * @param {string} base64 - The base64 encoded image string. - * @param {string} filename - Desired filename for the File instance. - * @returns {File} The File instance created from the base64 string with an additional `uri` property. + * @param base64 - The base64 encoded image string. + * @param filename - Desired filename for the File instance. + * @returns The File instance created from the base64 string with an additional `uri` property. * * @example * const base64Image = "data:image/png;base64,..."; // your base64 encoded image * const imageFile = base64ToFile(base64Image, "example.png"); * console.log(imageFile.uri); // Blob URI */ -function base64ToFile(base64, filename) { +function base64ToFile(base64: string, filename: string): File { // Decode the base64 string const byteString = atob(base64.split(',')[1]); diff --git a/src/libs/fileDownload/getAttachmentDetails.js b/src/libs/fileDownload/getAttachmentDetails.ts similarity index 81% rename from src/libs/fileDownload/getAttachmentDetails.js rename to src/libs/fileDownload/getAttachmentDetails.ts index 28b678ffb651..5787979a3795 100644 --- a/src/libs/fileDownload/getAttachmentDetails.js +++ b/src/libs/fileDownload/getAttachmentDetails.ts @@ -1,12 +1,11 @@ import tryResolveUrlFromApiRoot from '@libs/tryResolveUrlFromApiRoot'; import CONST from '@src/CONST'; +import type {GetAttachmentDetails} from './types'; /** * Extract the thumbnail URL, source URL and the original filename from the HTML. - * @param {String} html - * @returns {Object} */ -export default function getAttachmentDetails(html) { +const getAttachmentDetails: GetAttachmentDetails = (html) => { // Files can be rendered either as anchor tag or as an image so based on that we have to form regex. const IS_IMAGE_TAG = //i.test(html); const PREVIEW_SOURCE_REGEX = new RegExp(`${CONST.ATTACHMENT_PREVIEW_ATTRIBUTE}*=*"(.+?)"`, 'i'); @@ -21,10 +20,10 @@ export default function getAttachmentDetails(html) { } // Files created/uploaded/hosted by App should resolve from API ROOT. Other URLs aren't modified - const sourceURL = tryResolveUrlFromApiRoot(html.match(SOURCE_REGEX)[1]); - const imageURL = IS_IMAGE_TAG && tryResolveUrlFromApiRoot(html.match(PREVIEW_SOURCE_REGEX)[1]); + const sourceURL = tryResolveUrlFromApiRoot(html.match(SOURCE_REGEX)?.[1] ?? ''); + const imageURL = IS_IMAGE_TAG ? tryResolveUrlFromApiRoot(html.match(PREVIEW_SOURCE_REGEX)?.[1] ?? '') : null; const previewSourceURL = IS_IMAGE_TAG ? imageURL : sourceURL; - const originalFileName = html.match(ORIGINAL_FILENAME_REGEX)[1]; + const originalFileName = html.match(ORIGINAL_FILENAME_REGEX)?.[1] ?? null; // Update the image URL so the images can be accessed depending on the config environment return { @@ -32,4 +31,6 @@ export default function getAttachmentDetails(html) { sourceURL, originalFileName, }; -} +}; + +export default getAttachmentDetails; diff --git a/src/libs/fileDownload/getImageResolution.native.js b/src/libs/fileDownload/getImageResolution.native.ts similarity index 61% rename from src/libs/fileDownload/getImageResolution.native.js rename to src/libs/fileDownload/getImageResolution.native.ts index f291886f4665..3bdff78a93ed 100644 --- a/src/libs/fileDownload/getImageResolution.native.js +++ b/src/libs/fileDownload/getImageResolution.native.ts @@ -1,14 +1,13 @@ +import {Asset} from 'react-native-image-picker'; +import type {GetImageResolution} from './types'; + /** * Get image resolution * Image object is returned as a result of a user selecting image using the react-native-image-picker * Image already has width and height properties coming from library so we just need to return them on native * Opposite to web where we need to create a new Image object and get dimensions from it * - * @param {*} file Picked file blob - * @returns {Promise} */ -function getImageResolution(file) { - return Promise.resolve({width: file.width, height: file.height}); -} +const getImageResolution: GetImageResolution = (file: Asset) => Promise.resolve({width: file.width ?? 0, height: file.height ?? 0}); export default getImageResolution; diff --git a/src/libs/fileDownload/getImageResolution.js b/src/libs/fileDownload/getImageResolution.ts similarity index 80% rename from src/libs/fileDownload/getImageResolution.js rename to src/libs/fileDownload/getImageResolution.ts index 2f9a6d4fbdb4..74dc7401d801 100644 --- a/src/libs/fileDownload/getImageResolution.js +++ b/src/libs/fileDownload/getImageResolution.ts @@ -1,3 +1,5 @@ +import type {GetImageResolution} from './types'; + /** * Get image resolution * File object is returned as a result of a user selecting image using the @@ -7,10 +9,8 @@ * new Image() is used specifically for performance reasons, opposed to using FileReader (5ms vs +100ms) * because FileReader is slow and causes a noticeable delay in the UI when selecting an image. * - * @param {*} file Picked file blob - * @returns {Promise} */ -function getImageResolution(file) { +const getImageResolution: GetImageResolution = (file) => { if (!(file instanceof File)) { return Promise.reject(new Error('Object is not an instance of File')); } @@ -20,14 +20,14 @@ function getImageResolution(file) { const objectUrl = URL.createObjectURL(file); image.onload = function () { resolve({ - width: this.naturalWidth, - height: this.naturalHeight, + width: (this as HTMLImageElement).naturalWidth, + height: (this as HTMLImageElement).naturalHeight, }); URL.revokeObjectURL(objectUrl); }; image.onerror = reject; image.src = objectUrl; }); -} +}; export default getImageResolution; diff --git a/src/libs/fileDownload/index.android.js b/src/libs/fileDownload/index.android.ts similarity index 82% rename from src/libs/fileDownload/index.android.js rename to src/libs/fileDownload/index.android.ts index c3528b579f67..41c7cb29550a 100644 --- a/src/libs/fileDownload/index.android.js +++ b/src/libs/fileDownload/index.android.ts @@ -1,15 +1,15 @@ import {PermissionsAndroid, Platform} from 'react-native'; -import RNFetchBlob from 'react-native-blob-util'; +import RNFetchBlob, {FetchBlobResponse} from 'react-native-blob-util'; import * as FileUtils from './FileUtils'; +import type {FileDownload} from './types'; /** * Android permission check to store images - * @returns {Promise} */ -function hasAndroidPermission() { +function hasAndroidPermission(): Promise { // On Android API Level 33 and above, these permissions do nothing and always return 'never_ask_again' // More info here: https://stackoverflow.com/a/74296799 - if (Platform.Version >= 33) { + if (Number(Platform.Version) >= 33) { return Promise.resolve(true); } @@ -31,11 +31,8 @@ function hasAndroidPermission() { /** * Handling the download - * @param {String} url - * @param {String} fileName - * @returns {Promise} */ -function handleDownload(url, fileName) { +function handleDownload(url: string, fileName: string): Promise { return new Promise((resolve) => { const dirs = RNFetchBlob.fs.dirs; @@ -46,7 +43,7 @@ function handleDownload(url, fileName) { const isLocalFile = url.startsWith('file://'); let attachmentPath = isLocalFile ? url : undefined; - let fetchedAttachment = Promise.resolve(); + let fetchedAttachment: Promise = Promise.resolve(); if (!isLocalFile) { // Fetching the attachment @@ -69,7 +66,7 @@ function handleDownload(url, fileName) { } if (!isLocalFile) { - attachmentPath = attachment.path(); + attachmentPath = (attachment as FetchBlobResponse).path(); } return RNFetchBlob.MediaCollection.copyToMediaStore( @@ -79,11 +76,13 @@ function handleDownload(url, fileName) { mimeType: null, }, 'Download', - attachmentPath, + attachmentPath ?? '', ); }) .then(() => { - RNFetchBlob.fs.unlink(attachmentPath); + if (attachmentPath) { + RNFetchBlob.fs.unlink(attachmentPath); + } FileUtils.showSuccessAlert(); }) .catch(() => { @@ -95,12 +94,9 @@ function handleDownload(url, fileName) { /** * Checks permission and downloads the file for Android - * @param {String} url - * @param {String} fileName - * @returns {Promise} */ -export default function fileDownload(url, fileName) { - return new Promise((resolve) => { +const fileDownload: FileDownload = (url, fileName) => + new Promise((resolve) => { hasAndroidPermission() .then((hasPermission) => { if (hasPermission) { @@ -113,4 +109,5 @@ export default function fileDownload(url, fileName) { }) .finally(() => resolve()); }); -} + +export default fileDownload; diff --git a/src/libs/fileDownload/index.ios.js b/src/libs/fileDownload/index.ios.ts similarity index 72% rename from src/libs/fileDownload/index.ios.js rename to src/libs/fileDownload/index.ios.ts index 1599e919d28a..fdc4a78e0b9b 100644 --- a/src/libs/fileDownload/index.ios.js +++ b/src/libs/fileDownload/index.ios.ts @@ -1,23 +1,20 @@ import {CameraRoll} from '@react-native-camera-roll/camera-roll'; -import lodashGet from 'lodash/get'; import RNFetchBlob from 'react-native-blob-util'; import CONST from '@src/CONST'; import * as FileUtils from './FileUtils'; +import type {FileDownload} from './types'; /** * Downloads the file to Documents section in iOS - * @param {String} fileUrl - * @param {String} fileName - * @returns {Promise} */ -function downloadFile(fileUrl, fileName) { +function downloadFile(fileUrl: string, fileName: string) { const dirs = RNFetchBlob.fs.dirs; // The iOS files will download to documents directory const path = dirs.DocumentDir; // Fetching the attachment - const fetchedAttachment = RNFetchBlob.config({ + return RNFetchBlob.config({ fileCache: true, path: `${path}/${fileName}`, addAndroidDownloads: { @@ -26,60 +23,61 @@ function downloadFile(fileUrl, fileName) { path: `${path}/Expensify/${fileName}`, }, }).fetch('GET', fileUrl); - return fetchedAttachment; } /** * Download the image to photo lib in iOS - * @param {String} fileUrl - * @param {String} fileName - * @returns {String} URI */ -function downloadImage(fileUrl) { +function downloadImage(fileUrl: string) { return CameraRoll.save(fileUrl); } /** * Download the video to photo lib in iOS - * @param {String} fileUrl - * @param {String} fileName - * @returns {String} URI */ -function downloadVideo(fileUrl, fileName) { +function downloadVideo(fileUrl: string, fileName: string): Promise { return new Promise((resolve, reject) => { - let documentPathUri = null; - let cameraRollUri = null; + let documentPathUri: string | null = null; + let cameraRollUri: string | null = null; // Because CameraRoll doesn't allow direct downloads of video with remote URIs, we first download as documents, then copy to photo lib and unlink the original file. downloadFile(fileUrl, fileName) .then((attachment) => { - documentPathUri = lodashGet(attachment, 'data'); + documentPathUri = attachment.data; + if (!documentPathUri) { + throw new Error('Error downloading video'); + } return CameraRoll.save(documentPathUri); }) .then((attachment) => { cameraRollUri = attachment; + if (!documentPathUri) { + throw new Error('Error downloading video'); + } return RNFetchBlob.fs.unlink(documentPathUri); }) - .then(() => resolve(cameraRollUri)) + .then(() => { + if (!cameraRollUri) { + throw new Error('Error downloading video'); + } + resolve(cameraRollUri); + }) .catch((err) => reject(err)); }); } /** * Download the file based on type(image, video, other file types)for iOS - * @param {String} fileUrl - * @param {String} fileName - * @returns {Promise} */ -export default function fileDownload(fileUrl, fileName) { - return new Promise((resolve) => { - let fileDownloadPromise = null; +const fileDownload: FileDownload = (fileUrl, fileName) => + new Promise((resolve) => { + let fileDownloadPromise; const fileType = FileUtils.getFileType(fileUrl); const attachmentName = FileUtils.appendTimeToFileName(fileName) || FileUtils.getAttachmentName(fileUrl); switch (fileType) { case CONST.ATTACHMENT_FILE_TYPE.IMAGE: - fileDownloadPromise = downloadImage(fileUrl, attachmentName); + fileDownloadPromise = downloadImage(fileUrl); break; case CONST.ATTACHMENT_FILE_TYPE.VIDEO: fileDownloadPromise = downloadVideo(fileUrl, attachmentName); @@ -108,4 +106,5 @@ export default function fileDownload(fileUrl, fileName) { }) .finally(() => resolve()); }); -} + +export default fileDownload; diff --git a/src/libs/fileDownload/index.js b/src/libs/fileDownload/index.js deleted file mode 100644 index 002594244def..000000000000 --- a/src/libs/fileDownload/index.js +++ /dev/null @@ -1,54 +0,0 @@ -import _ from 'lodash'; -import * as ApiUtils from '@libs/ApiUtils'; -import tryResolveUrlFromApiRoot from '@libs/tryResolveUrlFromApiRoot'; -import * as Link from '@userActions/Link'; -import CONST from '@src/CONST'; -import * as FileUtils from './FileUtils'; - -/** - * Downloading attachment in web, desktop - * @param {String} url - * @param {String} fileName - * @returns {Promise} - */ -export default function fileDownload(url, fileName) { - const resolvedUrl = tryResolveUrlFromApiRoot(url); - if (!resolvedUrl.startsWith(ApiUtils.getApiRoot()) && !_.some(CONST.ATTACHMENT_LOCAL_URL_PREFIX, (prefix) => resolvedUrl.startsWith(prefix))) { - // Different origin URLs might pose a CORS issue during direct downloads. - // Opening in a new tab avoids this limitation, letting the browser handle the download. - Link.openExternalLink(url); - return Promise.resolve(); - } - - return ( - fetch(url) - .then((response) => response.blob()) - .then((blob) => { - // Create blob link to download - const href = URL.createObjectURL(new Blob([blob])); - - // creating anchor tag to initiate download - const link = document.createElement('a'); - - // adding href to anchor - link.href = href; - link.style.display = 'none'; - link.setAttribute( - 'download', - FileUtils.appendTimeToFileName(fileName) || FileUtils.getAttachmentName(url), // generating the file name - ); - - // Append to html link element page - document.body.appendChild(link); - - // Start download - link.click(); - - // Clean up and remove the link - URL.revokeObjectURL(link.href); - link.parentNode.removeChild(link); - }) - // file could not be downloaded, open sourceURL in new tab - .catch(() => Link.openExternalLink(url)) - ); -} diff --git a/src/libs/fileDownload/index.ts b/src/libs/fileDownload/index.ts new file mode 100644 index 000000000000..ef36647e549d --- /dev/null +++ b/src/libs/fileDownload/index.ts @@ -0,0 +1,53 @@ +import * as ApiUtils from '@libs/ApiUtils'; +import tryResolveUrlFromApiRoot from '@libs/tryResolveUrlFromApiRoot'; +import * as Link from '@userActions/Link'; +import CONST from '@src/CONST'; +import * as FileUtils from './FileUtils'; +import type {FileDownload} from './types'; + +/** + * The function downloads an attachment on web/desktop platforms. + */ +const fileDownload: FileDownload = (url, fileName) => { + const resolvedUrl = tryResolveUrlFromApiRoot(url); + if (!resolvedUrl.startsWith(ApiUtils.getApiRoot()) && !CONST.ATTACHMENT_LOCAL_URL_PREFIX.some((prefix) => resolvedUrl.startsWith(prefix))) { + // Different origin URLs might pose a CORS issue during direct downloads. + // Opening in a new tab avoids this limitation, letting the browser handle the download. + Link.openExternalLink(url); + return Promise.resolve(); + } + + return fetch(url) + .then((response) => response.blob()) + .then((blob) => { + // Create blob link to download + const href = URL.createObjectURL(new Blob([blob])); + + // creating anchor tag to initiate download + const link = document.createElement('a'); + + // adding href to anchor + link.href = href; + link.style.display = 'none'; + link.setAttribute( + 'download', + FileUtils.appendTimeToFileName(fileName) || FileUtils.getAttachmentName(url), // generating the file name + ); + + // Append to html link element page + document.body.appendChild(link); + + // Start download + link.click(); + + // Clean up and remove the link + URL.revokeObjectURL(link.href); + link.parentNode?.removeChild(link); + }) + .catch(() => { + // file could not be downloaded, open sourceURL in new tab + Link.openExternalLink(url); + }); +}; + +export default fileDownload; diff --git a/src/libs/fileDownload/types.ts b/src/libs/fileDownload/types.ts new file mode 100644 index 000000000000..c7388f2e52a2 --- /dev/null +++ b/src/libs/fileDownload/types.ts @@ -0,0 +1,20 @@ +import {Asset} from 'react-native-image-picker'; + +type FileDownload = (url: string, fileName: string) => Promise; + +type ImageResolution = {width: number; height: number}; +type GetImageResolution = (url: File | Asset) => Promise; + +type ExtensionAndFileName = {fileName: string; fileExtension: string}; +type SplitExtensionFromFileName = (fileName: string) => ExtensionAndFileName; + +type ReadFileAsync = (path: string, fileName: string, onSuccess: (file: File) => void, onFailure: (error?: unknown) => void) => Promise; + +type AttachmentDetails = { + previewSourceURL: null | string; + sourceURL: null | string; + originalFileName: null | string; +}; +type GetAttachmentDetails = (html: string) => AttachmentDetails; + +export type {SplitExtensionFromFileName, GetAttachmentDetails, ReadFileAsync, FileDownload, GetImageResolution}; diff --git a/src/libs/migrateOnyx.js b/src/libs/migrateOnyx.js index b65670819418..5daba3686208 100644 --- a/src/libs/migrateOnyx.js +++ b/src/libs/migrateOnyx.js @@ -3,6 +3,7 @@ import Log from './Log'; import KeyReportActionsDraftByReportActionID from './migrations/KeyReportActionsDraftByReportActionID'; import PersonalDetailsByAccountID from './migrations/PersonalDetailsByAccountID'; import RenameReceiptFilename from './migrations/RenameReceiptFilename'; +import TransactionBackupsToCollection from './migrations/TransactionBackupsToCollection'; export default function () { const startTime = Date.now(); @@ -10,7 +11,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]; + const migrationPromises = [PersonalDetailsByAccountID, RenameReceiptFilename, KeyReportActionsDraftByReportActionID, TransactionBackupsToCollection]; // 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/TransactionBackupsToCollection.ts b/src/libs/migrations/TransactionBackupsToCollection.ts new file mode 100644 index 000000000000..ddaa691b8d47 --- /dev/null +++ b/src/libs/migrations/TransactionBackupsToCollection.ts @@ -0,0 +1,58 @@ +import Onyx, {OnyxCollection} from 'react-native-onyx'; +import Log from '@libs/Log'; +import ONYXKEYS from '@src/ONYXKEYS'; +import {Transaction} from '@src/types/onyx'; + +/** + * This migration moves all the transaction backups stored in the transaction collection, ONYXKEYS.COLLECTION.TRANSACTION, to a reserved collection that only + * stores draft transactions, ONYXKEYS.COLLECTION.TRANSACTION_DRAFT. The purpose of the migration is that there is a possibility that transaction backups are + * not filtered by most functions, e.g, getAllReportTransactions (src/libs/TransactionUtils.ts). One problem that arose from storing transaction backups with + * the other transactions is that for every distance request which have their waypoints updated offline, we expect the ReportPreview component to display the + * default image of a pending map. However, due to the presence of the transaction backup, the previous map image will be displayed alongside the pending map. + * The problem was further discussed in this PR. https://github.com/Expensify/App/pull/30232#issuecomment-178110172 + */ +export default function (): Promise { + return new Promise((resolve) => { + const connectionID = Onyx.connect({ + key: ONYXKEYS.COLLECTION.TRANSACTION, + waitForCollectionCallback: true, + callback: (transactions: OnyxCollection) => { + Onyx.disconnect(connectionID); + + // Determine whether any transactions were stored + if (!transactions || Object.keys(transactions).length === 0) { + Log.info('[Migrate Onyx] Skipped TransactionBackupsToCollection migration because there are no transactions'); + return resolve(); + } + + const onyxData: OnyxCollection = {}; + + // Find all the transaction backups available + Object.keys(transactions).forEach((transactionOnyxKey: string) => { + const transaction: Transaction | null = transactions[transactionOnyxKey]; + + // Determine whether or not the transaction is a backup + if (transactionOnyxKey.endsWith('-backup') && transaction) { + // Create the transaction backup in the draft transaction collection + onyxData[`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transaction.transactionID}`] = transaction; + + // Delete the transaction backup stored in the transaction collection + onyxData[transactionOnyxKey] = null; + } + }); + + // Determine whether any transaction backups are found + if (Object.keys(onyxData).length === 0) { + Log.info('[Migrate Onyx] Skipped TransactionBackupsToCollection migration because there are no transaction backups'); + return resolve(); + } + + // Move the transaction backups to the draft transaction collection + Onyx.multiSet(onyxData as Partial<{string: [Transaction | null]}>).then(() => { + Log.info('[Migrate Onyx] TransactionBackupsToCollection migration: Successfully moved all the transaction backups to the draft transaction collection'); + resolve(); + }); + }, + }); + }); +} diff --git a/src/pages/EditRequestDistancePage.js b/src/pages/EditRequestDistancePage.js index 375d56935135..48b80890dc49 100644 --- a/src/pages/EditRequestDistancePage.js +++ b/src/pages/EditRequestDistancePage.js @@ -140,6 +140,6 @@ export default withOnyx({ key: (props) => `${ONYXKEYS.COLLECTION.TRANSACTION}${props.transactionID}`, }, transactionBackup: { - key: (props) => `${ONYXKEYS.COLLECTION.TRANSACTION}${props.transactionID}-backup`, + key: (props) => `${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${props.transactionID}`, }, })(EditRequestDistancePage); diff --git a/src/pages/ReferralDetailsPage.js b/src/pages/ReferralDetailsPage.js index 282e85fe0237..36fc74836d58 100644 --- a/src/pages/ReferralDetailsPage.js +++ b/src/pages/ReferralDetailsPage.js @@ -17,6 +17,7 @@ import Navigation from '@libs/Navigation/Navigation'; import styles from '@styles/styles'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; const propTypes = { /** Navigation route context info provided by react navigation */ @@ -52,6 +53,17 @@ function ReferralDetailsPage({route, account}) { return `${CONST.REFERRAL_PROGRAM.LINK}/?thanks=${encodeURIComponent(email)}`; } + function getFallbackRoute() { + const fallbackRoutes = { + [CONST.REFERRAL_PROGRAM.CONTENT_TYPES.MONEY_REQUEST]: ROUTES.MONEY_REQUEST_PARTICIPANTS.getRoute(CONST.IOU.TYPE.REQUEST), + [CONST.REFERRAL_PROGRAM.CONTENT_TYPES.SEND_MONEY]: ROUTES.MONEY_REQUEST_PARTICIPANTS.getRoute(CONST.IOU.TYPE.SEND), + [CONST.REFERRAL_PROGRAM.CONTENT_TYPES.START_CHAT]: ROUTES.NEW_CHAT, + [CONST.REFERRAL_PROGRAM.CONTENT_TYPES.REFER_FRIEND]: ROUTES.SEARCH, + }; + + return fallbackRoutes[contentType]; + } + return ( Navigation.goBack()} + onBackButtonPress={() => Navigation.goBack(getFallbackRoute())} /> Navigation.goBack()} + onPress={() => Navigation.goBack(getFallbackRoute())} pressOnEnter enterKeyEventListenerPriority={1} /> diff --git a/src/pages/ReportParticipantsPage.js b/src/pages/ReportParticipantsPage.js index 376ef35da7cd..ceaa53a41a6b 100755 --- a/src/pages/ReportParticipantsPage.js +++ b/src/pages/ReportParticipantsPage.js @@ -12,6 +12,7 @@ import withLocalize, {withLocalizePropTypes} from '@components/withLocalize'; import compose from '@libs/compose'; import * as LocalePhoneNumber from '@libs/LocalePhoneNumber'; import Navigation from '@libs/Navigation/Navigation'; +import * as PersonalDetailsUtils from '@libs/PersonalDetailsUtils'; import * as ReportUtils from '@libs/ReportUtils'; import * as UserUtils from '@libs/UserUtils'; import useThemeStyles from '@styles/useThemeStyles'; @@ -59,10 +60,11 @@ const getAllParticipants = (report, personalDetails, translate) => .map((accountID, index) => { const userPersonalDetail = lodashGet(personalDetails, accountID, {displayName: personalDetails.displayName || translate('common.hidden'), avatar: ''}); const userLogin = LocalePhoneNumber.formatPhoneNumber(userPersonalDetail.login || '') || translate('common.hidden'); + const displayName = PersonalDetailsUtils.getDisplayNameOrDefault(userPersonalDetail, 'displayName'); return { alternateText: userLogin, - displayName: userPersonalDetail.displayName, + displayName, accountID: userPersonalDetail.accountID, icons: [ { @@ -74,9 +76,9 @@ const getAllParticipants = (report, personalDetails, translate) => ], keyForList: `${index}-${userLogin}`, login: userLogin, - text: userPersonalDetail.displayName, + text: displayName, tooltipText: userLogin, - participantsList: [{accountID, displayName: userPersonalDetail.displayName}], + participantsList: [{accountID, displayName}], }; }) .sortBy((participant) => participant.displayName.toLowerCase()) diff --git a/src/pages/home/ReportScreen.js b/src/pages/home/ReportScreen.js index 649bcee6da18..312f64ea13f3 100644 --- a/src/pages/home/ReportScreen.js +++ b/src/pages/home/ReportScreen.js @@ -126,8 +126,12 @@ const defaultProps = { * @returns {String} */ function getReportID(route) { - // // The reportID is used inside a collection key and should not be empty, as an empty reportID will result in the entire collection being returned. - return String(lodashGet(route, 'params.reportID', null)); + // The report ID is used in an onyx key. If it's an empty string, onyx will return + // a collection instead of an individual report. + // We can't use the default value functionality of `lodash.get()` because it only + // provides a default value on `undefined`, and will return an empty string. + // Placing the default value outside of `lodash.get()` is intentional. + return String(lodashGet(route, 'params.reportID') || 0); } function ReportScreen({ diff --git a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.js b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.js index e376e8481c0c..6e69f77d0649 100644 --- a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.js +++ b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.js @@ -217,7 +217,7 @@ function ComposerWithSuggestions({ const updateComment = useCallback( (commentValue, shouldDebounceSaveComment) => { raiseIsScrollLikelyLayoutTriggered(); - const {text: newComment, emojis} = EmojiUtils.replaceAndExtractEmojis(commentValue, preferredSkinTone, preferredLocale); + const {text: newComment, emojis, cursorPosition} = EmojiUtils.replaceAndExtractEmojis(commentValue, preferredSkinTone, preferredLocale); if (!_.isEmpty(emojis)) { const newEmojis = EmojiUtils.getAddedEmojis(emojis, emojisPresentBefore.current); if (!_.isEmpty(newEmojis)) { @@ -230,14 +230,20 @@ function ComposerWithSuggestions({ } } const newCommentConverted = convertToLTRForComposer(newComment); + const isNewCommentEmpty = !!newCommentConverted.match(/^(\s)*$/); + const isPrevCommentEmpty = !!commentRef.current.match(/^(\s)*$/); + + /** Only update isCommentEmpty state if it's different from previous one */ + if (isNewCommentEmpty !== isPrevCommentEmpty) { + setIsCommentEmpty(isNewCommentEmpty); + } emojisPresentBefore.current = emojis; - setIsCommentEmpty(!!newCommentConverted.match(/^(\s)*$/)); setValue(newCommentConverted); if (commentValue !== newComment) { - const remainder = ComposerUtils.getCommonSuffixLength(commentValue, newComment); + const position = Math.max(selection.end + (newComment.length - commentRef.current.length), cursorPosition || 0); setSelection({ - start: newComment.length - remainder, - end: newComment.length - remainder, + start: position, + end: position, }); } @@ -270,6 +276,7 @@ function ComposerWithSuggestions({ suggestionsRef, raiseIsScrollLikelyLayoutTriggered, debouncedSaveReportComment, + selection.end, ], ); diff --git a/src/pages/home/report/ReportActionCompose/SuggestionEmoji.js b/src/pages/home/report/ReportActionCompose/SuggestionEmoji.js index 88f0d0a68c67..6e8d8d12afaf 100644 --- a/src/pages/home/report/ReportActionCompose/SuggestionEmoji.js +++ b/src/pages/home/report/ReportActionCompose/SuggestionEmoji.js @@ -1,5 +1,5 @@ import PropTypes from 'prop-types'; -import React, {useCallback, useImperativeHandle, useRef, useState} from 'react'; +import React, {useCallback, useEffect, useImperativeHandle, useRef, useState} from 'react'; import {withOnyx} from 'react-native-onyx'; import _ from 'underscore'; import EmojiSuggestions from '@components/EmojiSuggestions'; @@ -59,6 +59,7 @@ function SuggestionEmoji({ forwardedRef, resetKeyboardInput, measureParentContainer, + isComposerFocused, }) { const [suggestionValues, setSuggestionValues] = useState(defaultSuggestionsValues); @@ -180,6 +181,13 @@ function SuggestionEmoji({ [value, preferredLocale, setHighlightedEmojiIndex, resetSuggestions], ); + useEffect(() => { + if (!isComposerFocused) { + return; + } + calculateEmojiSuggestion(selection.end); + }, [selection, calculateEmojiSuggestion, isComposerFocused]); + const onSelectionChange = useCallback( (e) => { /** diff --git a/src/pages/home/report/ReportActionItemMessageEdit.js b/src/pages/home/report/ReportActionItemMessageEdit.js index eea53b17763d..13d89eeb6d33 100644 --- a/src/pages/home/report/ReportActionItemMessageEdit.js +++ b/src/pages/home/report/ReportActionItemMessageEdit.js @@ -122,6 +122,7 @@ function ReportActionItemMessageEdit(props) { const textInputRef = useRef(null); const isFocusedRef = useRef(false); const insertedEmojis = useRef([]); + const draftRef = useRef(draft); useEffect(() => { if (ReportActionsUtils.isDeletedAction(props.action) || props.draftMessage === props.action.message[0].html) { @@ -241,7 +242,7 @@ function ReportActionItemMessageEdit(props) { */ const updateDraft = useCallback( (newDraftInput) => { - const {text: newDraft, emojis} = EmojiUtils.replaceAndExtractEmojis(newDraftInput, props.preferredSkinTone, preferredLocale); + const {text: newDraft, emojis, cursorPosition} = EmojiUtils.replaceAndExtractEmojis(newDraftInput, props.preferredSkinTone, preferredLocale); if (!_.isEmpty(emojis)) { const newEmojis = EmojiUtils.getAddedEmojis(emojis, emojisPresentBefore.current); @@ -255,13 +256,15 @@ function ReportActionItemMessageEdit(props) { setDraft(newDraft); if (newDraftInput !== newDraft) { - const remainder = ComposerUtils.getCommonSuffixLength(newDraftInput, newDraft); + const position = Math.max(selection.end + (newDraft.length - draftRef.current.length), cursorPosition || 0); setSelection({ - start: newDraft.length - remainder, - end: newDraft.length - remainder, + start: position, + end: position, }); } + 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) { @@ -271,7 +274,7 @@ function ReportActionItemMessageEdit(props) { debouncedSaveDraft(props.action.message[0].html); } }, - [props.action.message, debouncedSaveDraft, debouncedUpdateFrequentlyUsedEmojis, props.preferredSkinTone, preferredLocale], + [props.action.message, debouncedSaveDraft, debouncedUpdateFrequentlyUsedEmojis, props.preferredSkinTone, preferredLocale, selection.end], ); useEffect(() => { diff --git a/src/pages/home/report/ReportActionsList.js b/src/pages/home/report/ReportActionsList.js index dd537959c91f..e1230d7219db 100644 --- a/src/pages/home/report/ReportActionsList.js +++ b/src/pages/home/report/ReportActionsList.js @@ -213,7 +213,7 @@ function ReportActionsList({ if (!userActiveSince.current || report.reportID !== prevReportID) { return; } - if (!messageManuallyMarkedUnread && lastReadTimeRef.current && lastReadTimeRef.current < report.lastReadTime) { + if (!messageManuallyMarkedUnread && (lastReadTimeRef.current || '') < report.lastReadTime) { cacheUnreadMarkers.delete(report.reportID); } lastReadTimeRef.current = report.lastReadTime; diff --git a/src/pages/home/sidebar/SidebarLinks.js b/src/pages/home/sidebar/SidebarLinks.js index 51573cf4d7f3..b316f4d22d55 100644 --- a/src/pages/home/sidebar/SidebarLinks.js +++ b/src/pages/home/sidebar/SidebarLinks.js @@ -1,7 +1,7 @@ /* eslint-disable rulesdir/onyx-props-must-have-default */ import PropTypes from 'prop-types'; import React, {useCallback, useEffect, useRef} from 'react'; -import {InteractionManager, StyleSheet, View} from 'react-native'; +import {InteractionManager, View} from 'react-native'; import _ from 'underscore'; import LogoComponent from '@assets/images/expensify-wordmark.svg'; import Header from '@components/Header'; @@ -183,21 +183,16 @@ function SidebarLinks({onLinkClick, insets, optionListItems, isLoading, priority - - - {isLoading && ( - - - - )} - + + + {isLoading && } ); } diff --git a/src/pages/home/sidebar/SidebarLinksData.js b/src/pages/home/sidebar/SidebarLinksData.js index dc7d00566bc0..088eb5c0092a 100644 --- a/src/pages/home/sidebar/SidebarLinksData.js +++ b/src/pages/home/sidebar/SidebarLinksData.js @@ -199,23 +199,28 @@ export default compose( chatReports: { key: ONYXKEYS.COLLECTION.REPORT, selector: chatReportSelector, + initialValue: {}, }, isLoadingReportData: { key: ONYXKEYS.IS_LOADING_REPORT_DATA, }, priorityMode: { key: ONYXKEYS.NVP_PRIORITY_MODE, + initialValue: CONST.PRIORITY_MODE.DEFAULT, }, betas: { key: ONYXKEYS.BETAS, + initialValue: [], }, allReportActions: { key: ONYXKEYS.COLLECTION.REPORT_ACTIONS, selector: reportActionsSelector, + initialValue: {}, }, policies: { key: ONYXKEYS.COLLECTION.POLICY, selector: policySelector, + initialValue: {}, }, }), )(SidebarLinksData); diff --git a/src/pages/settings/Report/NotificationPreferencePage.js b/src/pages/settings/Report/NotificationPreferencePage.js index 75e9e0d5c5e8..c6044bd81efe 100644 --- a/src/pages/settings/Report/NotificationPreferencePage.js +++ b/src/pages/settings/Report/NotificationPreferencePage.js @@ -6,13 +6,11 @@ import ScreenWrapper from '@components/ScreenWrapper'; import SelectionList from '@components/SelectionList'; import withLocalize, {withLocalizePropTypes} from '@components/withLocalize'; import compose from '@libs/compose'; -import Navigation from '@libs/Navigation/Navigation'; import * as ReportUtils from '@libs/ReportUtils'; import withReportOrNotFound from '@pages/home/report/withReportOrNotFound'; import reportPropTypes from '@pages/reportPropTypes'; import * as Report from '@userActions/Report'; import CONST from '@src/CONST'; -import ROUTES from '@src/ROUTES'; const propTypes = { ...withLocalizePropTypes, @@ -41,11 +39,13 @@ function NotificationPreferencePage(props) { Navigation.goBack(ROUTES.REPORT_SETTINGS.getRoute(props.report.reportID))} + onBackButtonPress={() => ReportUtils.goBackToDetailsPage(props.report)} /> Report.updateNotificationPreference(props.report.reportID, props.report.notificationPreference, option.value, true)} + onSelectRow={(option) => + Report.updateNotificationPreference(props.report.reportID, props.report.notificationPreference, option.value, true, undefined, undefined, props.report) + } initiallyFocusedOptionKey={_.find(notificationPreferenceOptions, (locale) => locale.isSelected).keyForList} /> diff --git a/src/pages/workspace/WorkspaceSettingsPage.js b/src/pages/workspace/WorkspaceSettingsPage.js index cc9505a4378f..b51146cde7f3 100644 --- a/src/pages/workspace/WorkspaceSettingsPage.js +++ b/src/pages/workspace/WorkspaceSettingsPage.js @@ -111,7 +111,6 @@ function WorkspaceSettingsPage({policy, currencyList, windowWidth, route}) { enabledWhenOffline > ( diff --git a/src/styles/animation/SpinningIndicatorAnimation.js b/src/styles/animation/SpinningIndicatorAnimation.js deleted file mode 100644 index 1ae4b1518325..000000000000 --- a/src/styles/animation/SpinningIndicatorAnimation.js +++ /dev/null @@ -1,89 +0,0 @@ -import {Animated, Easing} from 'react-native'; -import useNativeDriver from '@libs/useNativeDriver'; - -class SpinningIndicatorAnimation { - constructor() { - this.rotate = new Animated.Value(0); - this.scale = new Animated.Value(1); - this.startRotation = this.startRotation.bind(this); - this.start = this.start.bind(this); - this.stop = this.stop.bind(this); - this.getSyncingStyles = this.getSyncingStyles.bind(this); - } - - /** - * Rotation animation for indicator in a loop - * - * @memberof AvatarWithImagePicker - */ - startRotation() { - this.rotate.setValue(0); - Animated.loop( - Animated.timing(this.rotate, { - toValue: 1, - duration: 2000, - easing: Easing.linear, - isInteraction: false, - - // Animated.loop does not work with `useNativeDriver: true` on Web - useNativeDriver, - }), - ).start(); - } - - /** - * Start Animation for Indicator - * - * @memberof AvatarWithImagePicker - */ - start() { - this.startRotation(); - Animated.spring(this.scale, { - toValue: 1.666, - tension: 1, - isInteraction: false, - useNativeDriver, - }).start(); - } - - /** - * Stop Animation for Indicator - * - * @memberof AvatarWithImagePicker - */ - stop() { - Animated.spring(this.scale, { - toValue: 1, - tension: 1, - isInteraction: false, - useNativeDriver, - }).start(() => { - this.rotate.resetAnimation(); - this.scale.resetAnimation(); - this.rotate.setValue(0); - }); - } - - /** - * Get Indicator Styles while animating - * - * @returns {Object} - */ - getSyncingStyles() { - return { - transform: [ - { - rotate: this.rotate.interpolate({ - inputRange: [0, 1], - outputRange: ['0deg', '-360deg'], - }), - }, - { - scale: this.scale, - }, - ], - }; - } -} - -export default SpinningIndicatorAnimation; diff --git a/src/styles/styles.ts b/src/styles/styles.ts index c1b78a224eb3..e597f0ec874e 100644 --- a/src/styles/styles.ts +++ b/src/styles/styles.ts @@ -1367,6 +1367,7 @@ const styles = (theme: ThemeColors) => }, sidebarListContainer: { + scrollbarWidth: 'none', paddingBottom: 4, }, diff --git a/src/types/modules/pusher.d.ts b/src/types/modules/pusher.d.ts index b54a0508c309..fb7bbaa97f79 100644 --- a/src/types/modules/pusher.d.ts +++ b/src/types/modules/pusher.d.ts @@ -5,4 +5,11 @@ declare global { interface Window { getPusherInstance: () => Pusher | null; } + + // eslint-disable-next-line @typescript-eslint/consistent-type-definitions + interface File { + source?: string; + + uri?: string; + } } diff --git a/src/types/onyx/Request.ts b/src/types/onyx/Request.ts index c97a5a21f488..746e7f75b3d5 100644 --- a/src/types/onyx/Request.ts +++ b/src/types/onyx/Request.ts @@ -7,11 +7,13 @@ type OnyxData = { optimisticData?: OnyxUpdate[]; }; +type RequestType = 'get' | 'post'; + type RequestData = { command: string; commandName?: string; data?: Record; - type?: string; + type?: RequestType; shouldUseSecure?: boolean; successData?: OnyxUpdate[]; failureData?: OnyxUpdate[]; @@ -24,4 +26,4 @@ type RequestData = { type Request = RequestData & OnyxData; export default Request; -export type {OnyxData}; +export type {OnyxData, RequestType}; diff --git a/src/types/onyx/Response.ts b/src/types/onyx/Response.ts index d36a875ea6de..66d5dcbdfd5b 100644 --- a/src/types/onyx/Response.ts +++ b/src/types/onyx/Response.ts @@ -1,5 +1,10 @@ import {OnyxUpdate} from 'react-native-onyx'; +type Data = { + phpCommandName: string; + authWriteCommands: string[]; +}; + type Response = { previousUpdateID?: number | string; lastUpdateID?: number | string; @@ -10,6 +15,9 @@ type Response = { authToken?: string; encryptedAuthToken?: string; message?: string; + title?: string; + data?: Data; + type?: string; shortLivedAuthToken?: string; auth?: string; // eslint-disable-next-line @typescript-eslint/naming-convention diff --git a/tests/actions/IOUTest.js b/tests/actions/IOUTest.js index 7d8a8a4202e7..944ec944648a 100644 --- a/tests/actions/IOUTest.js +++ b/tests/actions/IOUTest.js @@ -1757,7 +1757,7 @@ describe('actions/IOU', () => { }), ]), originalMessage: expect.objectContaining({ - amount, + amount: -amount, paymentType: CONST.IOU.PAYMENT_TYPE.VBBA, type: 'pay', }), diff --git a/tests/perf-test/SidebarLinks.perf-test.js b/tests/perf-test/SidebarLinks.perf-test.js index 5601c588bb93..f6819d40a48f 100644 --- a/tests/perf-test/SidebarLinks.perf-test.js +++ b/tests/perf-test/SidebarLinks.perf-test.js @@ -105,9 +105,9 @@ test('should scroll and click some of the items', () => { expect(lhnOptionsList).toBeDefined(); fireEvent.scroll(lhnOptionsList, eventData); - // find elements that are currently visible in the viewport - const button1 = await screen.findByTestId('7'); - const button2 = await screen.findByTestId('8'); + + const button1 = await screen.findByTestId('1'); + const button2 = await screen.findByTestId('2'); fireEvent.press(button1); fireEvent.press(button2); }; diff --git a/tests/unit/EmojiTest.js b/tests/unit/EmojiTest.js index 4da29027de86..40474d0331fe 100644 --- a/tests/unit/EmojiTest.js +++ b/tests/unit/EmojiTest.js @@ -106,11 +106,41 @@ describe('EmojiTest', () => { expect(lodashGet(EmojiUtils.replaceEmojis(text), 'text')).toBe('Hi 😄 '); }); + it('will add a space after the last emoji', () => { + const text = 'Hi :smile::wave:'; + expect(lodashGet(EmojiUtils.replaceEmojis(text), 'text')).toBe('Hi 😄👋 '); + }); + it('will add a space after the last emoji if there is text after it', () => { const text = 'Hi :smile::wave:space after last emoji'; expect(lodashGet(EmojiUtils.replaceEmojis(text), 'text')).toBe('Hi 😄👋 space after last emoji'); }); + it('will add a space after the last emoji if there is invalid emoji after it', () => { + const text = 'Hi :smile::wave:space when :invalidemoji: present'; + expect(lodashGet(EmojiUtils.replaceEmojis(text), 'text')).toBe('Hi 😄👋 space when :invalidemoji: present'); + }); + + it('will not add a space after the last emoji if there if last emoji is immediately followed by a space', () => { + const text = 'Hi :smile::wave: space after last emoji'; + expect(lodashGet(EmojiUtils.replaceEmojis(text), 'text')).toBe('Hi 😄👋 space after last emoji'); + }); + + it('will return correct cursor position', () => { + const text = 'Hi :smile: there :wave:!'; + expect(lodashGet(EmojiUtils.replaceEmojis(text), 'cursorPosition')).toBe(15); + }); + + it('will return correct cursor position when space is not added by space follows last emoji', () => { + const text = 'Hi :smile: there!'; + expect(lodashGet(EmojiUtils.replaceEmojis(text), 'cursorPosition')).toBe(6); + }); + + it('will return undefined cursor position when no emoji is replaced', () => { + const text = 'Hi there!'; + expect(lodashGet(EmojiUtils.replaceEmojis(text), 'cursorPosition')).toBe(undefined); + }); + it('suggests emojis when typing emojis prefix after colon', () => { const text = 'Hi :coffin'; expect(EmojiUtils.suggestEmojis(text, 'en')).toEqual([{code: '⚰️', name: 'coffin'}]);