diff --git a/.well-known/apple-app-site-association b/.well-known/apple-app-site-association index c871764117ed..a9e2b0383691 100644 --- a/.well-known/apple-app-site-association +++ b/.well-known/apple-app-site-association @@ -68,6 +68,10 @@ "/": "/workspace/*", "comment": "Workspace Details" }, + { + "/": "/get-assistance/*", + "comment": "Get Assistance Pages" + }, { "/": "/teachersunite/*", "comment": "Teachers Unite!" diff --git a/docs/404.html b/docs/404.html index 1773388c6923..4338293218cc 100644 --- a/docs/404.html +++ b/docs/404.html @@ -1,8 +1,8 @@ --- permalink: /404.html --- -
- +
+ Hmm it's not here...
That page is nowhere to be found.
diff --git a/docs/_sass/_main.scss b/docs/_sass/_main.scss index 6e4095569a6d..3ad2276713da 100644 --- a/docs/_sass/_main.scss +++ b/docs/_sass/_main.scss @@ -641,30 +641,13 @@ button { } .centered-content { - height: 240px; + width: 100%; + height: calc(100vh - 56px); + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; text-align: center; - font-size: larger; - position: absolute; - top: calc((100vh - 240px) / 2); - - width: 380px; - right: calc((100vw - 380px) / 2); - @include breakpoint($breakpoint-tablet) { - width: 500px; - right: calc((100vw - 500px) / 2); - } - - &.with-lhn { - right: calc((100vw - 380px) / 2); - - @include breakpoint($breakpoint-tablet) { - right: calc((100vw - 320px - 500px ) / 2); - } - - @include breakpoint($breakpoint-desktop) { - right: calc((100vw - 420px - 500px) / 2); - } - } div { margin-top: 8px; diff --git a/docs/articles/expensify-classic/get-paid-back/expenses/Create-Expenses.md b/docs/articles/expensify-classic/get-paid-back/expenses/Create-Expenses.md index 8323be7b8e3f..e565e59dc754 100644 --- a/docs/articles/expensify-classic/get-paid-back/expenses/Create-Expenses.md +++ b/docs/articles/expensify-classic/get-paid-back/expenses/Create-Expenses.md @@ -1,5 +1,124 @@ --- -title: Create Expenses -description: Create Expenses +title: Create-Expenses.md +description: This is an article that shows you all the ways that you can create Expenses in Expensify! --- -## Resource Coming Soon! + + +# About +Whether you're using SmartScan for automatic expense creation, or manually creating, splitting, or duplicating expenses, you can rest assured your expenses will be correctly tracked in Expensify. + +# How-to Create Expenses +## Using SmartScan +Use the big green camera button within the Expensify mobile app to snap a photo of your physical receipt to have it SmartScanned. +For digital or emailed receipts, simply forward them to receipts@expensify.com and it will be SmartScanned and added to your Expensify account. + +There’s no need to keep the app open and most SmartScans are finished within the hour. If more details are needed, Concierge will reach out to you with a friendly message. +## Using the Mobile App +Simply tap the **+** icon in the top-right corner +Choose **Expense** and then select **Manually Create**. +If you don't have a receipt handy or want to add it later, fill in your expense details and click the **Save** button. +## Using the Expensify Website +Log into the Expensify website +Click on the **Expenses** page and find the **New Expense** dropdown. +Select your expense type, hit the **Save** button and you're all set. +You can then add details like the Merchant and Category, attach a receipt image, and even add a description. +# How to Split an Expense +Splitting an expense in Expensify allows you to break down a single expense into multiple expenses. Each split expense is treated as an individual expense which can be categorized and tagged separately. The same receipt image will be attached to all of the split expenses, allowing you to divide a single expense into smaller, more manageable expenses. +To split an expense on the mobile app: + +1. Open an expense. +2. At the bottom of the screen, tap **More Options**. +3. Then, use the **Split** button to divide the expense. + +To split an expense on the Expensify website: + +1. Click on the expense you want to split. +2. Click on the **Split** button. + - On the Expenses page, this button is at the top. + - Within an individual expense, you'll find it at the bottom. +3. This will automatically be split in two, but you can decide how many expenses you want to split it into by clicking on the **Add Split** button. + - Remember, the total of all pieces must add up to the original expense amount, and no piece can have a $0.00 amount (or you won't be able to save the changes). + +# How to Create Bulk Expenses + +If you have multiple saved receipt images or PDFs to upload, you can drag and drop them onto your Expenses page in batches of ten - this will start the SmartScan process for all of them. + +You can also create a number of future 'placeholder' expenses for your recurring expenses (such as recurring bills or subscriptions) which you don't have receipts for by clicking *New Expense > Create Multiple* to quickly add multiple expenses in batches of up to ten. + +# How to Edit Bulk Expenses +Editing expenses in bulk will allow you to apply the same coding across multiple expenses and is a web-only feature. To bulk edit expenses: +Go to the Expenses page. +To narrow down your selection, use the filters (e.g. "Merchant" and "Open") to find the specific expenses you want to edit. +Select all the expenses you want to edit. +Click on the **Edit Multiple** button at the top of the page. +# How to Edit Expenses on a Report +If you’d like to edit expenses within an Open report: + +1. Click on the Report containing all the expenses. +2. Click on **Details**. +3. Click on the Pencil icon. +3. Select the **Edit Multiple** button. + +If you've already submitted your report, you'll need to Retract it or have it Unapproved first before you can edit the expenses. + + +# FAQ +## Does Expensify account for duplicates? + +Yes, Expensify will account for duplicates. Expensify works behind the scenes to identify duplicate expenses before they are submitted, warning employees when they exist. If a duplicate expense is submitted, the same warning will be shown to the approver responsible for reviewing the report. + +If two expenses are SmartScanned on the same day for the same amount, they will be flagged as duplicates unless: +The expenses were split from a single expense, +The expenses were imported from a credit card, or +Matching email receipts sent to receipts@expensify.com were received with different timestamps. +## How do I resolve a duplicate expense? + +If Concierge has let you know it's flagged a receipt as a duplicate, scanning the receipt again will trigger the same duplicate flagging.Users have the ability to resolve duplicates by either deleting the duplicated transactions, merging them, or ignoring them (if they are legitimately separate expenses of the same date and amount). + +## How do I recover a duplicate or undelete an expense? + +To recover a duplicate or undelete an expense: +Log into your Expensify account on the website and navigate to the Expenses page +Use the filters to search for deleted expenses by selecting the "Deleted" filter +Select the checkbox next to the expenses you want to restore +Click the **Undelete** button and you're all set. You’ll find the expense on your Expenses page again. + +# Deep Dive + +## What are the different Expense statuses? + +There are a number of different expense statuses in Expensify: +1. **Unreported**: Unreported expenses are not yet part of a report (and therefore unsubmitted) and are not viewable by anyone but the expense creator/owner. +2. **Open**: Open expenses are on a report that's still in progress, and are unsubmitted. Your Policy Admin will be able to view them, making it a collaborative step toward reimbursement. +3. **Processing**: Processing expenses are submitted, but waiting for approval. +4. **Approved**: If it's a non-reimbursable expense, the workflow is complete at this point. If it's a reimbursable expense, you're one step closer to getting paid. +5. **Reimbursed**: Reimbursed expenses are fully settled. You can check the Report Comments to see when you'll get paid. +6. **Closed**: Sometimes an expense accidentally ends up on your Individual Policy, falling into the Closed status. You’ll need to reopen the report and change the Policy by clicking on the **Details** tab in order to resubmit your report. +## What are Violations? + +Violations represent errors or discrepancies that Expensify has picked up and need to be corrected before a report can be successfully submitted. The one exception is when an expense comment is added, it will override the violation - as the user is providing a valid reason for submission. + +To enable or configure violations according to your policy, go to **Settings > Policies > _Policy Name_ > Expenses > Expense Violations**. Keep in mind that Expensify includes certain system mandatory violations that can't be disabled, even if your policy has violations turned off. + +You can spot violations by the exclamation marks (!) attached to expenses. Hovering over the symbol will provide a brief description and you can find more detailed information below the list of expenses. The two types of violations are: +**Red**: These indicate violations directly tied to your report's Policy settings. They are clear rule violations that must be addressed before submission. +**Yellow**: Concierge will highlight items that require attention but may not necessarily need corrective action. For example, if a receipt was SmartScanned and then the amount was modified, we’ll bring it to your attention so that it can be manually reviewed. +## How to Track Attendees + +Attendee tracking makes it easy to track shared expenses and maintain transparency in your group spending. + +Internal attendees are considered users within your policies or domain. To add internal attendees on mobile or web: +1. Click or tap the **Attendee** field within your expense. +2. Select the internal attendees you'd like to add from the list of searchable users. +3. You can continue adding more attendees or save the Expense. + +External attendees are considered users outside your group policy or domain. To add external attendees: +1. Click or tap the **Attendee** field within your expense. +2. Type in the individual's name or email address. +3. Tap **Add** to include the attendee. +You can continue adding more attendees or save the Expense. +To remove an attendee from an expense: +Open the expense. +Click or tap the **Attendees** field to display the list of attendees. +From the list, de-select the attendees you'd like to remove from the expense. + diff --git a/src/CONST.ts b/src/CONST.ts index 5be89f0c808a..528cb6ca1e9e 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -982,6 +982,10 @@ const CONST = { GOLD: 'GOLD', SILVER: 'SILVER', }, + WEB_MESSAGE_TYPE: { + STATEMENT: 'STATEMENT_NAVIGATE', + CONCIERGE: 'CONCIERGE_NAVIGATE', + }, }, PLAID: { diff --git a/src/ROUTES.ts b/src/ROUTES.ts index b1fbe21053b4..00f3a4012664 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -247,7 +247,6 @@ export default { IOU_SEND_ENABLE_PAYMENTS: 'send/new/enable-payments', NEW_TASK: 'new/task', - NEW_TASK_WITH_REPORT_ID: 'new/task/:reportID?', NEW_TASK_ASSIGNEE: 'new/task/assignee', NEW_TASK_SHARE_DESTINATION: 'new/task/share-destination', NEW_TASK_DETAILS: 'new/task/details', diff --git a/src/components/AttachmentModal.js b/src/components/AttachmentModal.js index c0fe0e2d26f8..23545de26cfd 100755 --- a/src/components/AttachmentModal.js +++ b/src/components/AttachmentModal.js @@ -30,6 +30,7 @@ import useWindowDimensions from '../hooks/useWindowDimensions'; import Navigation from '../libs/Navigation/Navigation'; import ROUTES from '../ROUTES'; import useNativeDriver from '../libs/useNativeDriver'; +import useNetwork from '../hooks/useNetwork'; /** * Modal render prop component that exposes modal launching triggers that can be used @@ -121,6 +122,7 @@ function AttachmentModal(props) { : undefined, ); const {translate} = useLocalize(); + const {isOffline} = useNetwork(); const onCarouselAttachmentChange = props.onCarouselAttachmentChange; @@ -350,7 +352,7 @@ function AttachmentModal(props) { downloadAttachment(source)} shouldShowCloseButton={!props.isSmallScreenWidth} shouldShowBackButton={props.isSmallScreenWidth} diff --git a/src/components/AutoCompleteSuggestions/BaseAutoCompleteSuggestions.js b/src/components/AutoCompleteSuggestions/BaseAutoCompleteSuggestions.js index b4710f1f343e..40e08d876907 100644 --- a/src/components/AutoCompleteSuggestions/BaseAutoCompleteSuggestions.js +++ b/src/components/AutoCompleteSuggestions/BaseAutoCompleteSuggestions.js @@ -70,7 +70,7 @@ function BaseAutoCompleteSuggestions(props) { }); const innerHeight = CONST.AUTO_COMPLETE_SUGGESTER.SUGGESTION_ROW_HEIGHT * props.suggestions.length; - const animatedStyles = useAnimatedStyle(() => StyleUtils.getAutoCompleteSuggestionContainerStyle(rowHeight.value, props.shouldIncludeReportRecipientLocalTimeHeight)); + const animatedStyles = useAnimatedStyle(() => StyleUtils.getAutoCompleteSuggestionContainerStyle(rowHeight.value)); useEffect(() => { rowHeight.value = withTiming(measureHeightOfSuggestionRows(props.suggestions.length, props.isSuggestionPickerLarge), { diff --git a/src/components/AutoCompleteSuggestions/autoCompleteSuggestionsPropTypes.js b/src/components/AutoCompleteSuggestions/autoCompleteSuggestionsPropTypes.js index 16040991a3d8..8c6dca1902c5 100644 --- a/src/components/AutoCompleteSuggestions/autoCompleteSuggestionsPropTypes.js +++ b/src/components/AutoCompleteSuggestions/autoCompleteSuggestionsPropTypes.js @@ -22,9 +22,6 @@ const propTypes = { * When this value is false, the suggester will have a height of 2.5 items. When this value is true, the height can be up to 5 items. */ isSuggestionPickerLarge: PropTypes.bool.isRequired, - /** Show that we should include ReportRecipientLocalTime view height */ - shouldIncludeReportRecipientLocalTimeHeight: PropTypes.bool.isRequired, - /** create accessibility label for each item */ accessibilityLabelExtractor: PropTypes.func.isRequired, diff --git a/src/components/AutoCompleteSuggestions/index.js b/src/components/AutoCompleteSuggestions/index.js index b37fcd7181d9..9234d04f4507 100644 --- a/src/components/AutoCompleteSuggestions/index.js +++ b/src/components/AutoCompleteSuggestions/index.js @@ -14,7 +14,7 @@ import useWindowDimensions from '../../hooks/useWindowDimensions'; * On the native platform, tapping on auto-complete suggestions will not blur the main input. */ -function AutoCompleteSuggestions({parentContainerRef, ...props}) { +function AutoCompleteSuggestions({measureParentContainer, ...props}) { const containerRef = React.useRef(null); const {windowHeight, windowWidth} = useWindowDimensions(); const [{width, left, bottom}, setContainerState] = React.useState({ @@ -37,11 +37,11 @@ function AutoCompleteSuggestions({parentContainerRef, ...props}) { }, []); React.useEffect(() => { - if (!parentContainerRef || !parentContainerRef.current) { + if (!measureParentContainer) { return; } - parentContainerRef.current.measureInWindow((x, y, w) => setContainerState({left: x, bottom: windowHeight - y, width: w})); - }, [parentContainerRef, windowHeight, windowWidth]); + measureParentContainer((x, y, w) => setContainerState({left: x, bottom: windowHeight - y, width: w})); + }, [measureParentContainer, windowHeight, windowWidth]); const componentToRender = ( ); - if (!width) { - return componentToRender; - } - - return ReactDOM.createPortal({componentToRender}, document.querySelector('body')); + return ( + Boolean(width) && + ReactDOM.createPortal({componentToRender}, document.querySelector('body')) + ); } AutoCompleteSuggestions.propTypes = propTypes; diff --git a/src/components/AutoCompleteSuggestions/index.native.js b/src/components/AutoCompleteSuggestions/index.native.js index 514cec6cd844..f5ff4636f395 100644 --- a/src/components/AutoCompleteSuggestions/index.native.js +++ b/src/components/AutoCompleteSuggestions/index.native.js @@ -1,10 +1,15 @@ import React from 'react'; +import {Portal} from '@gorhom/portal'; import BaseAutoCompleteSuggestions from './BaseAutoCompleteSuggestions'; import {propTypes} from './autoCompleteSuggestionsPropTypes'; -function AutoCompleteSuggestions({parentContainerRef, ...props}) { - // eslint-disable-next-line react/jsx-props-no-spreading - return ; +function AutoCompleteSuggestions({measureParentContainer, ...props}) { + return ( + + {/* eslint-disable-next-line react/jsx-props-no-spreading */} + + + ); } AutoCompleteSuggestions.propTypes = propTypes; diff --git a/src/components/EmojiSuggestions.js b/src/components/EmojiSuggestions.js index b06b0cc63eb8..d7f7a8d6091a 100644 --- a/src/components/EmojiSuggestions.js +++ b/src/components/EmojiSuggestions.js @@ -40,9 +40,6 @@ const propTypes = { * 2.5 items. When this value is true, the height can be up to 5 items. */ isEmojiPickerLarge: PropTypes.bool.isRequired, - /** Show that we should include ReportRecipientLocalTime view height */ - shouldIncludeReportRecipientLocalTimeHeight: PropTypes.bool.isRequired, - /** Stores user's preferred skin tone */ preferredSkinToneIndex: PropTypes.number.isRequired, @@ -102,7 +99,6 @@ function EmojiSuggestions(props) { highlightedSuggestionIndex={props.highlightedEmojiIndex} onSelect={props.onSelect} isSuggestionPickerLarge={props.isEmojiPickerLarge} - shouldIncludeReportRecipientLocalTimeHeight={props.shouldIncludeReportRecipientLocalTimeHeight} accessibilityLabelExtractor={keyExtractor} measureParentContainer={props.measureParentContainer} /> diff --git a/src/components/LHNOptionsList/OptionRowLHN.js b/src/components/LHNOptionsList/OptionRowLHN.js index 91b582221171..fddcede3a4b0 100644 --- a/src/components/LHNOptionsList/OptionRowLHN.js +++ b/src/components/LHNOptionsList/OptionRowLHN.js @@ -177,6 +177,7 @@ function OptionRowLHN(props) { ]} accessibilityRole={CONST.ACCESSIBILITY_ROLE.BUTTON} accessibilityLabel={translate('accessibilityHints.navigatesToChat')} + needsOffscreenAlphaCompositing={props.optionItem.icons.length >= 2} > diff --git a/src/components/MagicCodeInput.js b/src/components/MagicCodeInput.js index 454aacc8a03b..1db1acddc5d7 100644 --- a/src/components/MagicCodeInput.js +++ b/src/components/MagicCodeInput.js @@ -2,6 +2,7 @@ import React, {useEffect, useImperativeHandle, useRef, useState, forwardRef} fro import {StyleSheet, View} from 'react-native'; import PropTypes from 'prop-types'; import _ from 'underscore'; +import {TapGestureHandler} from 'react-native-gesture-handler'; import styles from '../styles/styles'; import * as StyleUtils from '../styles/StyleUtils'; import * as ValidationUtils from '../libs/ValidationUtils'; @@ -12,6 +13,9 @@ import FormHelpMessage from './FormHelpMessage'; import {withNetwork} from './OnyxProvider'; import networkPropTypes from './networkPropTypes'; import useNetwork from '../hooks/useNetwork'; +import * as Browser from '../libs/Browser'; + +const TEXT_INPUT_EMPTY_STATE = ''; const propTypes = { /** Information about the network */ @@ -91,22 +95,40 @@ const composeToString = (value) => _.map(value, (v) => (v === undefined || v === const getInputPlaceholderSlots = (length) => Array.from(Array(length).keys()); function MagicCodeInput(props) { - const inputRefs = useRef([]); - const [input, setInput] = useState(''); + const inputRefs = useRef(); + const [input, setInput] = useState(TEXT_INPUT_EMPTY_STATE); const [focusedIndex, setFocusedIndex] = useState(0); const [editIndex, setEditIndex] = useState(0); + const shouldFocusLast = useRef(false); + const inputWidth = useRef(0); + const lastFocusedIndex = useRef(0); const blurMagicCodeInput = () => { - inputRefs.current[editIndex].blur(); + inputRefs.current.blur(); setFocusedIndex(undefined); }; + const focusMagicCodeInput = () => { + setFocusedIndex(0); + lastFocusedIndex.current = 0; + setEditIndex(0); + inputRefs.current.focus(); + }; + useImperativeHandle(props.innerRef, () => ({ focus() { - inputRefs.current[0].focus(); + focusMagicCodeInput(); + }, + resetFocus() { + setInput(TEXT_INPUT_EMPTY_STATE); + focusMagicCodeInput(); }, clear() { - inputRefs.current[0].focus(); + setInput(TEXT_INPUT_EMPTY_STATE); + setFocusedIndex(0); + lastFocusedIndex.current = 0; + setEditIndex(0); + inputRefs.current.focus(); props.onChangeText(''); }, blur() { @@ -137,17 +159,37 @@ function MagicCodeInput(props) { }, [props.value, props.shouldSubmitOnComplete]); /** - * Callback for the onFocus event, updates the indexes - * of the currently focused input. + * Focuses on the input when it is pressed. * * @param {Object} event * @param {Number} index */ - const onFocus = (event, index) => { + const onFocus = (event) => { + if (shouldFocusLast.current) { + setInput(TEXT_INPUT_EMPTY_STATE); + setFocusedIndex(lastFocusedIndex.current); + setEditIndex(lastFocusedIndex.current); + } event.preventDefault(); - setInput(''); + }; + + /** + * Callback for the onPress event, updates the indexes + * of the currently focused input. + * + * @param {Number} index + */ + const onPress = (index) => { + shouldFocusLast.current = false; + // TapGestureHandler works differently on mobile web and native app + // On web gesture handler doesn't block interactions with textInput below so there is no need to run `focus()` manually + if (!Browser.isMobileChrome() && !Browser.isMobileSafari()) { + inputRefs.current.focus(); + } + setInput(TEXT_INPUT_EMPTY_STATE); setFocusedIndex(index); setEditIndex(index); + lastFocusedIndex.current = index; }; /** @@ -175,7 +217,9 @@ function MagicCodeInput(props) { let numbers = decomposeString(props.value, props.maxLength); numbers = [...numbers.slice(0, editIndex), ...numbersArr, ...numbers.slice(numbersArr.length + editIndex, props.maxLength)]; - inputRefs.current[updatedFocusedIndex].focus(); + setFocusedIndex(updatedFocusedIndex); + setEditIndex(updatedFocusedIndex); + setInput(TEXT_INPUT_EMPTY_STATE); const finalInput = composeToString(numbers); props.onChangeText(finalInput); @@ -196,7 +240,7 @@ function MagicCodeInput(props) { // If the currently focused index already has a value, it will delete // that value but maintain the focus on the same input. if (numbers[focusedIndex] !== CONST.MAGIC_CODE_EMPTY_CHAR) { - setInput(''); + setInput(TEXT_INPUT_EMPTY_STATE); numbers = [...numbers.slice(0, focusedIndex), CONST.MAGIC_CODE_EMPTY_CHAR, ...numbers.slice(focusedIndex + 1, props.maxLength)]; setEditIndex(focusedIndex); props.onChangeText(composeToString(numbers)); @@ -215,24 +259,37 @@ function MagicCodeInput(props) { } const newFocusedIndex = Math.max(0, focusedIndex - 1); + + // Saves the input string so that it can compare to the change text + // event that will be triggered, this is a workaround for mobile that + // triggers the change text on the event after the key press. + setInput(TEXT_INPUT_EMPTY_STATE); + setFocusedIndex(newFocusedIndex); + setEditIndex(newFocusedIndex); props.onChangeText(composeToString(numbers)); if (!_.isUndefined(newFocusedIndex)) { - inputRefs.current[newFocusedIndex].focus(); + inputRefs.current.focus(); } } if (keyValue === 'ArrowLeft' && !_.isUndefined(focusedIndex)) { const newFocusedIndex = Math.max(0, focusedIndex - 1); - inputRefs.current[newFocusedIndex].focus(); + setInput(TEXT_INPUT_EMPTY_STATE); + setFocusedIndex(newFocusedIndex); + setEditIndex(newFocusedIndex); + inputRefs.current.focus(); } else if (keyValue === 'ArrowRight' && !_.isUndefined(focusedIndex)) { const newFocusedIndex = Math.min(focusedIndex + 1, props.maxLength - 1); - inputRefs.current[newFocusedIndex].focus(); + setInput(TEXT_INPUT_EMPTY_STATE); + setFocusedIndex(newFocusedIndex); + setEditIndex(newFocusedIndex); + inputRefs.current.focus(); } else if (keyValue === 'Enter') { // We should prevent users from submitting when it's offline. if (props.network.isOffline) { return; } - setInput(''); + setInput(TEXT_INPUT_EMPTY_STATE); props.onFulfill(props.value); } }; @@ -240,6 +297,48 @@ function MagicCodeInput(props) { return ( <> + { + onPress(Math.floor(e.nativeEvent.x / (inputWidth.current / props.maxLength))); + }} + > + {/* Android does not handle touch on invisible Views so I created a wrapper around invisible TextInput just to handle taps */} + + { + inputWidth.current = e.nativeEvent.layout.width; + }} + ref={(ref) => (inputRefs.current = ref)} + autoFocus={props.autoFocus} + inputMode="numeric" + textContentType="oneTimeCode" + name={props.name} + maxLength={props.maxLength} + value={input} + hideFocusedState + autoComplete={props.autoComplete} + keyboardType={CONST.KEYBOARD_TYPE.NUMBER_PAD} + onChangeText={(value) => { + onChangeText(value); + }} + onKeyPress={onKeyPress} + onFocus={onFocus} + onBlur={() => { + shouldFocusLast.current = true; + lastFocusedIndex.current = focusedIndex; + setFocusedIndex(undefined); + }} + selectionColor="transparent" + inputStyle={[styles.inputTransparent]} + accessibilityRole={CONST.ACCESSIBILITY_ROLE.TEXT} + style={[styles.inputTransparent]} + textInputContainerStyles={[styles.borderNone]} + /> + + {_.map(getInputPlaceholderSlots(props.maxLength), (index) => ( {decomposeString(props.value, props.maxLength)[index] || ''} - {/* Hide the input above the text. Cannot set opacity to 0 as it would break pasting on iOS Safari. */} - - { - inputRefs.current[index] = ref; - // Setting attribute type to "search" to prevent Password Manager from appearing in Mobile Chrome - if (ref && ref.setAttribute) { - ref.setAttribute('type', 'search'); - } - }} - autoFocus={index === 0 && props.autoFocus} - inputMode="numeric" - textContentType="oneTimeCode" - name={props.name} - maxLength={props.maxLength} - value={input} - hideFocusedState - autoComplete={index === 0 ? props.autoComplete : 'off'} - keyboardType={CONST.KEYBOARD_TYPE.NUMBER_PAD} - onChangeText={(value) => { - // Do not run when the event comes from an input that is - // not currently being responsible for the input, this is - // necessary to avoid calls when the input changes due to - // deleted characters. Only happens in mobile. - if (index !== editIndex || _.isUndefined(focusedIndex)) { - return; - } - onChangeText(value); - }} - onKeyPress={onKeyPress} - onFocus={(event) => onFocus(event, index)} - // Manually set selectionColor to make caret transparent. - // We cannot use caretHidden as it breaks the pasting function on Android. - selectionColor="transparent" - textInputContainerStyles={[styles.borderNone]} - inputStyle={[styles.inputTransparent]} - accessibilityRole={CONST.ACCESSIBILITY_ROLE.TEXT} - /> - ))} diff --git a/src/components/MapView/MapView.web.tsx b/src/components/MapView/MapView.web.tsx index 56838df43140..52f953e0d3bd 100644 --- a/src/components/MapView/MapView.web.tsx +++ b/src/components/MapView/MapView.web.tsx @@ -12,6 +12,8 @@ import responder from './responder'; import utils from './utils'; import CONST from '../../CONST'; +import * as StyleUtils from '../../styles/StyleUtils'; +import themeColors from '../../styles/themes/default'; import Direction from './Direction'; import {MapViewHandle, MapViewProps} from './MapViewTypes'; @@ -89,6 +91,7 @@ const MapView = forwardRef( latitude: initialState.location[1], zoom: initialState.zoom, }} + style={StyleUtils.getTextColorStyle(themeColors.mapAttributionText) as React.CSSProperties} mapStyle={styleURL} > {waypoints?.map(({coordinate, markerComponent, id}) => { diff --git a/src/components/MentionSuggestions.js b/src/components/MentionSuggestions.js index b3374279f66b..6c0803ca9d64 100644 --- a/src/components/MentionSuggestions.js +++ b/src/components/MentionSuggestions.js @@ -41,9 +41,6 @@ const propTypes = { * When this value is false, the suggester will have a height of 2.5 items. When this value is true, the height can be up to 5 items. */ isMentionPickerLarge: PropTypes.bool.isRequired, - /** Show that we should include ReportRecipientLocalTime view height */ - shouldIncludeReportRecipientLocalTimeHeight: PropTypes.bool.isRequired, - /** Meaures the parent container's position and dimensions. */ measureParentContainer: PropTypes.func, }; @@ -126,7 +123,6 @@ function MentionSuggestions(props) { highlightedSuggestionIndex={props.highlightedMentionIndex} onSelect={props.onSelect} isSuggestionPickerLarge={props.isMentionPickerLarge} - shouldIncludeReportRecipientLocalTimeHeight={props.shouldIncludeReportRecipientLocalTimeHeight} accessibilityLabelExtractor={keyExtractor} measureParentContainer={props.measureParentContainer} /> diff --git a/src/components/OpacityView.js b/src/components/OpacityView.js index 2d09da744267..daef93cdc09b 100644 --- a/src/components/OpacityView.js +++ b/src/components/OpacityView.js @@ -3,6 +3,7 @@ import Animated, {useAnimatedStyle, useSharedValue, withTiming} from 'react-nati import PropTypes from 'prop-types'; import variables from '../styles/variables'; import * as StyleUtils from '../styles/StyleUtils'; +import shouldRenderOffscreen from '../libs/shouldRenderOffscreen'; const propTypes = { /** @@ -27,11 +28,15 @@ const propTypes = { * @default 0.5 */ dimmingValue: PropTypes.number, + + /** Whether the view needs to be rendered offscreen (for Android only) */ + needsOffscreenAlphaCompositing: PropTypes.bool, }; const defaultProps = { style: [], dimmingValue: variables.hoverDimValue, + needsOffscreenAlphaCompositing: false, }; function OpacityView(props) { @@ -48,7 +53,14 @@ function OpacityView(props) { } }, [props.shouldDim, props.dimmingValue, opacity]); - return {props.children}; + return ( + + {props.children} + + ); } OpacityView.displayName = 'OpacityView'; diff --git a/src/components/OptionRow.js b/src/components/OptionRow.js index 7f6eb0a490b7..8bc016faa6b5 100644 --- a/src/components/OptionRow.js +++ b/src/components/OptionRow.js @@ -212,6 +212,7 @@ class OptionRow extends Component { accessibilityRole={CONST.ACCESSIBILITY_ROLE.BUTTON} hoverDimmingValue={1} hoverStyle={this.props.hoverStyle} + needsOffscreenAlphaCompositing={this.props.option.icons.length >= 2} > diff --git a/src/components/Pressable/PressableWithFeedback.js b/src/components/Pressable/PressableWithFeedback.js index 40be99823ceb..a80e2109ebd7 100644 --- a/src/components/Pressable/PressableWithFeedback.js +++ b/src/components/Pressable/PressableWithFeedback.js @@ -7,7 +7,7 @@ import OpacityView from '../OpacityView'; import variables from '../../styles/variables'; import useSingleExecution from '../../hooks/useSingleExecution'; -const omittedProps = ['wrapperStyle']; +const omittedProps = ['wrapperStyle', 'needsOffscreenAlphaCompositing']; const PressableWithFeedbackPropTypes = { ...GenericPressablePropTypes.pressablePropTypes, @@ -27,6 +27,9 @@ const PressableWithFeedbackPropTypes = { * Used to locate this view from native classes. */ nativeID: propTypes.string, + + /** Whether the view needs to be rendered offscreen (for Android only) */ + needsOffscreenAlphaCompositing: propTypes.bool, }; const PressableWithFeedbackDefaultProps = { @@ -35,10 +38,11 @@ const PressableWithFeedbackDefaultProps = { hoverDimmingValue: variables.hoverDimValue, nativeID: '', wrapperStyle: [], + needsOffscreenAlphaCompositing: false, }; const PressableWithFeedback = forwardRef((props, ref) => { - const propsWithoutWrapperStyles = _.omit(props, omittedProps); + const propsWithoutWrapperProps = _.omit(props, omittedProps); const {isExecuting, singleExecution} = useSingleExecution(); const [isPressed, setIsPressed] = useState(false); const [isHovered, setIsHovered] = useState(false); @@ -49,11 +53,12 @@ const PressableWithFeedback = forwardRef((props, ref) => { shouldDim={Boolean(!isDisabled && (isPressed || isHovered))} dimmingValue={isPressed ? props.pressDimmingValue : props.hoverDimmingValue} style={props.wrapperStyle} + needsOffscreenAlphaCompositing={props.needsOffscreenAlphaCompositing} > { diff --git a/src/components/PressableWithSecondaryInteraction/pressableWithSecondaryInteractionPropTypes.js b/src/components/PressableWithSecondaryInteraction/pressableWithSecondaryInteractionPropTypes.js index f521a57957f3..0a4f7949643a 100644 --- a/src/components/PressableWithSecondaryInteraction/pressableWithSecondaryInteractionPropTypes.js +++ b/src/components/PressableWithSecondaryInteraction/pressableWithSecondaryInteractionPropTypes.js @@ -48,6 +48,9 @@ const propTypes = { /** Used to apply styles to the Pressable */ style: stylePropTypes, + + /** Whether the view needs to be rendered offscreen (for Android only) */ + needsOffscreenAlphaCompositing: PropTypes.bool, }; const defaultProps = { @@ -59,6 +62,7 @@ const defaultProps = { withoutFocusOnSecondaryInteraction: false, activeOpacity: 1, enableLongPressWithHover: false, + needsOffscreenAlphaCompositing: false, }; export {propTypes, defaultProps}; diff --git a/src/components/ReportActionItem/MoneyRequestView.js b/src/components/ReportActionItem/MoneyRequestView.js index 80c66b466170..8d797540fde9 100644 --- a/src/components/ReportActionItem/MoneyRequestView.js +++ b/src/components/ReportActionItem/MoneyRequestView.js @@ -7,13 +7,15 @@ import PropTypes from 'prop-types'; import reportPropTypes from '../../pages/reportPropTypes'; import ONYXKEYS from '../../ONYXKEYS'; import ROUTES from '../../ROUTES'; +import Permissions from '../../libs/Permissions'; import Navigation from '../../libs/Navigation/Navigation'; import withCurrentUserPersonalDetails, {withCurrentUserPersonalDetailsPropTypes} from '../withCurrentUserPersonalDetails'; import compose from '../../libs/compose'; -import Permissions from '../../libs/Permissions'; import MenuItemWithTopDescription from '../MenuItemWithTopDescription'; import styles from '../../styles/styles'; +import themeColors from '../../styles/themes/default'; import * as ReportUtils from '../../libs/ReportUtils'; +import * as IOU from '../../libs/actions/IOU'; import * as OptionsListUtils from '../../libs/OptionsListUtils'; import * as ReportActionsUtils from '../../libs/ReportActionsUtils'; import * as StyleUtils from '../../styles/StyleUtils'; @@ -28,6 +30,8 @@ import * as ReceiptUtils from '../../libs/ReceiptUtils'; import useWindowDimensions from '../../hooks/useWindowDimensions'; import transactionPropTypes from '../transactionPropTypes'; import Image from '../Image'; +import Text from '../Text'; +import Switch from '../Switch'; import ReportActionItemImage from './ReportActionItemImage'; import * as TransactionUtils from '../../libs/TransactionUtils'; import OfflineWithFeedback from '../OfflineWithFeedback'; @@ -73,10 +77,9 @@ const defaultProps = { policyTags: {}, }; -function MoneyRequestView({report, betas, parentReport, policyCategories, shouldShowHorizontalRule, transaction, policyTags}) { +function MoneyRequestView({report, betas, parentReport, policyCategories, shouldShowHorizontalRule, transaction, policyTags, policy}) { const {isSmallScreenWidth} = useWindowDimensions(); const {translate} = useLocalize(); - const parentReportAction = ReportActionsUtils.getParentReportAction(report); const moneyRequestReport = parentReport; const { @@ -85,6 +88,7 @@ function MoneyRequestView({report, betas, parentReport, policyCategories, should currency: transactionCurrency, comment: transactionDescription, merchant: transactionMerchant, + billable: transactionBillable, category: transactionCategory, tag: transactionTag, } = ReportUtils.getTransactionDetails(transaction); @@ -104,6 +108,7 @@ function MoneyRequestView({report, betas, parentReport, policyCategories, should // Flags for showing categories and tags const shouldShowCategory = isPolicyExpenseChat && Permissions.canUseCategories(betas) && (transactionCategory || OptionsListUtils.hasEnabledOptions(lodashValues(policyCategories))); const shouldShowTag = isPolicyExpenseChat && Permissions.canUseTags(betas) && (transactionTag || OptionsListUtils.hasEnabledOptions(lodashValues(policyTagsList))); + const shouldShowBillable = isPolicyExpenseChat && Permissions.canUseTags(betas) && (transactionBillable || !lodashGet(policy, 'disabledFields.defaultBillable', true)); let description = `${translate('iou.amount')} • ${translate('iou.cash')}`; if (isSettled) { @@ -242,6 +247,16 @@ function MoneyRequestView({report, betas, parentReport, policyCategories, should /> )} + {shouldShowBillable && ( + + {translate('common.billable')} + IOU.editMoneyRequest(transaction.transactionID, report.reportID, {billable: value})} + /> + + )} { - if (!event.data || !event.data.type || (event.data.type !== 'STATEMENT_NAVIGATE' && event.data.type !== 'CONCIERGE_NAVIGATE')) { + if (!event.data || !event.data.type || (event.data.type !== CONST.WALLET.WEB_MESSAGE_TYPE.STATEMENT && event.data.type !== CONST.WALLET.WEB_MESSAGE_TYPE.CONCIERGE)) { return; } - if (event.data.type === 'CONCIERGE_NAVIGATE') { + if (event.data.type === CONST.WALLET.WEB_MESSAGE_TYPE.CONCIERGE) { Report.navigateToConciergeChat(); } - if (event.data.type === 'STATEMENT_NAVIGATE' && event.data.url) { + if (event.data.type === CONST.WALLET.WEB_MESSAGE_TYPE.STATEMENT && event.data.url) { const iouRoutes = [ROUTES.IOU_REQUEST, ROUTES.IOU_SEND]; const navigateToIOURoute = _.find(iouRoutes, (iouRoute) => event.data.url.includes(iouRoute)); if (navigateToIOURoute) { diff --git a/src/components/WalletStatementModal/index.native.js b/src/components/WalletStatementModal/index.native.js index 590431274da5..38d1f90af00d 100644 --- a/src/components/WalletStatementModal/index.native.js +++ b/src/components/WalletStatementModal/index.native.js @@ -1,24 +1,22 @@ -import React from 'react'; +import React, {useCallback, useRef} from 'react'; import {WebView} from 'react-native-webview'; import lodashGet from 'lodash/get'; import {withOnyx} from 'react-native-onyx'; import _ from 'underscore'; -import withLocalize from '../withLocalize'; -import ONYXKEYS from '../../ONYXKEYS'; -import compose from '../../libs/compose'; import {walletStatementPropTypes, walletStatementDefaultProps} from './WalletStatementModalPropTypes'; import FullScreenLoadingIndicator from '../FullscreenLoadingIndicator'; import * as Report from '../../libs/actions/Report'; import Navigation from '../../libs/Navigation/Navigation'; import ROUTES from '../../ROUTES'; +import ONYXKEYS from '../../ONYXKEYS'; +import CONST from '../../CONST'; -class WalletStatementModal extends React.Component { - constructor(props) { - super(props); +const IOU_ROUTES = [ROUTES.IOU_REQUEST, ROUTES.IOU_SEND]; +const renderLoading = () => ; - this.authToken = lodashGet(props, 'session.authToken', null); - this.navigate = this.navigate.bind(this); - } +function WalletStatementModal({statementPageURL, session}) { + const webViewRef = useRef(); + const authToken = lodashGet(session, 'authToken', null); /** * Handles in-app navigation for webview links @@ -26,54 +24,53 @@ class WalletStatementModal extends React.Component { * @param {String} params.type * @param {String} params.url */ - navigate({type, url}) { - if (!this.webview || (type !== 'STATEMENT_NAVIGATE' && type !== 'CONCIERGE_NAVIGATE')) { - return; - } + const handleNavigationStateChange = useCallback( + ({type, url}) => { + if (!webViewRef.current || (type !== CONST.WALLET.WEB_MESSAGE_TYPE.STATEMENT && type !== CONST.WALLET.WEB_MESSAGE_TYPE.CONCIERGE)) { + return; + } - if (type === 'CONCIERGE_NAVIGATE') { - this.webview.stopLoading(); - Report.navigateToConciergeChat(); - } + if (type === CONST.WALLET.WEB_MESSAGE_TYPE.CONCIERGE) { + webViewRef.current.stopLoading(); + Report.navigateToConciergeChat(); + } - if (type === 'STATEMENT_NAVIGATE' && url) { - const iouRoutes = [ROUTES.IOU_REQUEST, ROUTES.IOU_SEND]; - const navigateToIOURoute = _.find(iouRoutes, (iouRoute) => url.includes(iouRoute)); - if (navigateToIOURoute) { - this.webview.stopLoading(); - Navigation.navigate(navigateToIOURoute); + if (type === CONST.WALLET.WEB_MESSAGE_TYPE.STATEMENT && url) { + const iouRoute = _.find(IOU_ROUTES, (item) => url.includes(item)); + + if (iouRoute) { + webViewRef.current.stopLoading(); + Navigation.navigate(iouRoute); + } } - } - } + }, + [webViewRef], + ); - render() { - return ( - (this.webview = node)} - originWhitelist={['https://*']} - source={{ - uri: this.props.statementPageURL, - headers: { - Cookie: `authToken=${this.authToken}`, - }, - }} - incognito // 'incognito' prop required for Android, issue here https://github.com/react-native-webview/react-native-webview/issues/1352 - startInLoadingState - renderLoading={() => } - onNavigationStateChange={this.navigate} - /> - ); - } + return ( + + ); } +WalletStatementModal.displayName = 'WalletStatementModal'; WalletStatementModal.propTypes = walletStatementPropTypes; WalletStatementModal.defaultProps = walletStatementDefaultProps; -export default compose( - withLocalize, - withOnyx({ - session: { - key: ONYXKEYS.SESSION, - }, - }), -)(WalletStatementModal); +export default withOnyx({ + session: { + key: ONYXKEYS.SESSION, + }, +})(WalletStatementModal); diff --git a/src/languages/en.ts b/src/languages/en.ts index 403931d542f3..43d93e093d69 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -244,6 +244,7 @@ export default { merchant: 'Merchant', category: 'Category', billable: 'Billable', + nonBillable: 'Non-billable', tag: 'Tag', receipt: 'Receipt', replace: 'Replace', @@ -1071,7 +1072,7 @@ export default { noBankAccountSelected: 'Please choose an account', taxID: 'Please enter a valid tax ID number', website: 'Please enter a valid website', - zipCode: 'Please enter a valid zip code', + zipCode: `Incorrect zip code format. Acceptable format: ${CONST.COUNTRY_ZIP_REGEX_DATA.US.samples}`, phoneNumber: 'Please enter a valid phone number', companyName: 'Please enter a valid legal business name', addressCity: 'Please enter a valid city', diff --git a/src/languages/es.ts b/src/languages/es.ts index ffe334f4a807..582134c32896 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -234,6 +234,7 @@ export default { merchant: 'Comerciante', category: 'Categoría', billable: 'Facturable', + nonBillable: 'No facturable', tag: 'Etiqueta', receipt: 'Recibo', replace: 'Sustituir', @@ -1086,7 +1087,7 @@ export default { noBankAccountSelected: 'Por favor, elige una cuenta bancaria', taxID: 'Por favor, introduce un número de identificación fiscal válido', website: 'Por favor, introduce un sitio web válido', - zipCode: 'Por favor, introduce un código postal válido', + zipCode: `Formato de código postal incorrecto. Formato aceptable: ${CONST.COUNTRY_ZIP_REGEX_DATA.US.samples}`, phoneNumber: 'Por favor, introduce un teléfono válido', companyName: 'Por favor, introduce un nombre comercial legal válido', addressCity: 'Por favor, introduce una ciudad válida', diff --git a/src/libs/DateUtils.js b/src/libs/DateUtils.js index 70c4277bdb5e..5c0171067870 100644 --- a/src/libs/DateUtils.js +++ b/src/libs/DateUtils.js @@ -4,11 +4,10 @@ import {es, enGB} from 'date-fns/locale'; import { formatDistanceToNow, subMinutes, + addDays, + subDays, isBefore, subMilliseconds, - isToday, - isTomorrow, - isYesterday, startOfWeek, endOfWeek, format, @@ -85,6 +84,47 @@ function getLocalDateFromDatetime(locale, datetime, currentSelectedTimezone = ti return utcToZonedTime(parsedDatetime, currentSelectedTimezone); } +/** + * Checks if a given date is today in the specified time zone. + * + * @param {Date} date - The date to compare. + * @param {String} timeZone - The time zone to consider. + * @returns {Boolean} True if the date is today; otherwise, false. + */ +function isToday(date, timeZone) { + const currentDate = new Date(); + const currentDateInTimeZone = utcToZonedTime(currentDate, timeZone); + return isSameDay(date, currentDateInTimeZone); +} + +/** + * Checks if a given date is tomorrow in the specified time zone. + * + * @param {Date} date - The date to compare. + * @param {String} timeZone - The time zone to consider. + * @returns {Boolean} True if the date is tomorrow; otherwise, false. + */ +function isTomorrow(date, timeZone) { + const currentDate = new Date(); + const tomorrow = addDays(currentDate, 1); // Get the date for tomorrow in the current time zone + const tomorrowInTimeZone = utcToZonedTime(tomorrow, timeZone); + return isSameDay(date, tomorrowInTimeZone); +} + +/** + * Checks if a given date is yesterday in the specified time zone. + * + * @param {Date} date - The date to compare. + * @param {String} timeZone - The time zone to consider. + * @returns {Boolean} True if the date is yesterday; otherwise, false. + */ +function isYesterday(date, timeZone) { + const currentDate = new Date(); + const yesterday = subDays(currentDate, 1); // Get the date for yesterday in the current time zone + const yesterdayInTimeZone = utcToZonedTime(yesterday, timeZone); + return isSameDay(date, yesterdayInTimeZone); +} + /** * Formats an ISO-formatted datetime string to local date and time string * @@ -117,13 +157,13 @@ function datetimeToCalendarTime(locale, datetime, includeTimeZone = false, curre yesterdayAt = yesterdayAt.toLowerCase(); } - if (isToday(date)) { + if (isToday(date, currentSelectedTimezone)) { return `${todayAt} ${format(date, CONST.DATE.LOCAL_TIME_FORMAT)}${tz}`; } - if (isTomorrow(date)) { + if (isTomorrow(date, currentSelectedTimezone)) { return `${tomorrowAt} ${format(date, CONST.DATE.LOCAL_TIME_FORMAT)}${tz}`; } - if (isYesterday(date)) { + if (isYesterday(date, currentSelectedTimezone)) { return `${yesterdayAt} ${format(date, CONST.DATE.LOCAL_TIME_FORMAT)}${tz}`; } if (date >= startOfCurrentWeek && date <= endOfCurrentWeek) { @@ -346,6 +386,9 @@ const DateUtils = { subtractMillisecondsFromDateTime, getDateStringFromISOTimestamp, getStatusUntilDate, + isToday, + isTomorrow, + isYesterday, }; export default DateUtils; diff --git a/src/libs/ErrorUtils.js b/src/libs/ErrorUtils.js deleted file mode 100644 index 95bbad5f5409..000000000000 --- a/src/libs/ErrorUtils.js +++ /dev/null @@ -1,135 +0,0 @@ -import _ from 'underscore'; -import lodashGet from 'lodash/get'; -import CONST from '../CONST'; -import DateUtils from './DateUtils'; -import * as Localize from './Localize'; - -/** - * @param {Object} response - * @param {Number} response.jsonCode - * @param {String} response.message - * @returns {String} - */ -function getAuthenticateErrorMessage(response) { - switch (response.jsonCode) { - case CONST.JSON_CODE.UNABLE_TO_RETRY: - return 'session.offlineMessageRetry'; - case 401: - return 'passwordForm.error.incorrectLoginOrPassword'; - case 402: - // If too few characters are passed as the password, the WAF will pass it to the API as an empty - // string, which results in a 402 error from Auth. - if (response.message === '402 Missing partnerUserSecret') { - return 'passwordForm.error.incorrectLoginOrPassword'; - } - return 'passwordForm.error.twoFactorAuthenticationEnabled'; - case 403: - if (response.message === 'Invalid code') { - return 'passwordForm.error.incorrect2fa'; - } - return 'passwordForm.error.invalidLoginOrPassword'; - case 404: - return 'passwordForm.error.unableToResetPassword'; - case 405: - return 'passwordForm.error.noAccess'; - case 413: - return 'passwordForm.error.accountLocked'; - default: - return 'passwordForm.error.fallback'; - } -} - -/** - * Method used to get an error object with microsecond as the key. - * @param {String} error - error key or message to be saved - * @return {Object} - * - */ -function getMicroSecondOnyxError(error) { - return {[DateUtils.getMicroseconds()]: error}; -} - -/** - * @param {Object} onyxData - * @param {Object} onyxData.errors - * @returns {String} - */ -function getLatestErrorMessage(onyxData) { - if (_.isEmpty(onyxData.errors)) { - return ''; - } - return _.chain(onyxData.errors || []) - .keys() - .sortBy() - .reverse() - .map((key) => onyxData.errors[key]) - .first() - .value(); -} - -/** - * @param {Object} onyxData - * @param {Object} onyxData.errorFields - * @param {String} fieldName - * @returns {Object} - */ -function getLatestErrorField(onyxData, fieldName) { - const errorsForField = lodashGet(onyxData, ['errorFields', fieldName], {}); - - if (_.isEmpty(errorsForField)) { - return {}; - } - return _.chain(errorsForField) - .keys() - .sortBy() - .reverse() - .map((key) => ({[key]: errorsForField[key]})) - .first() - .value(); -} - -/** - * @param {Object} onyxData - * @param {Object} onyxData.errorFields - * @param {String} fieldName - * @returns {Object} - */ -function getEarliestErrorField(onyxData, fieldName) { - const errorsForField = lodashGet(onyxData, ['errorFields', fieldName], {}); - - if (_.isEmpty(errorsForField)) { - return {}; - } - return _.chain(errorsForField) - .keys() - .sortBy() - .map((key) => ({[key]: errorsForField[key]})) - .first() - .value(); -} - -/** - * Method used to generate error message for given inputID - * @param {Object} errors - An object containing current errors in the form - * @param {String} inputID - * @param {String|Array} message - Message to assign to the inputID errors - * - */ -function addErrorMessage(errors, inputID, message) { - if (!message || !inputID) { - return; - } - - const errorList = errors; - const translatedMessage = Localize.translateIfPhraseKey(message); - - if (_.isEmpty(errorList[inputID])) { - errorList[inputID] = [translatedMessage, {isTranslated: true}]; - } else if (_.isString(errorList[inputID])) { - errorList[inputID] = [`${errorList[inputID]}\n${translatedMessage}`, {isTranslated: true}]; - } else { - errorList[inputID][0] = `${errorList[inputID][0]}\n${translatedMessage}`; - } -} - -export {getAuthenticateErrorMessage, getMicroSecondOnyxError, getLatestErrorMessage, getLatestErrorField, getEarliestErrorField, addErrorMessage}; diff --git a/src/libs/ErrorUtils.ts b/src/libs/ErrorUtils.ts new file mode 100644 index 000000000000..bf4fc0d810a4 --- /dev/null +++ b/src/libs/ErrorUtils.ts @@ -0,0 +1,114 @@ +import CONST from '../CONST'; +import DateUtils from './DateUtils'; +import * as Localize from './Localize'; +import Response from '../types/onyx/Response'; +import {ErrorFields, Errors} from '../types/onyx/OnyxCommon'; +import {TranslationFlatObject} from '../languages/types'; + +function getAuthenticateErrorMessage(response: Response): keyof TranslationFlatObject { + switch (response.jsonCode) { + case CONST.JSON_CODE.UNABLE_TO_RETRY: + return 'session.offlineMessageRetry'; + case 401: + return 'passwordForm.error.incorrectLoginOrPassword'; + case 402: + // If too few characters are passed as the password, the WAF will pass it to the API as an empty + // string, which results in a 402 error from Auth. + if (response.message === '402 Missing partnerUserSecret') { + return 'passwordForm.error.incorrectLoginOrPassword'; + } + return 'passwordForm.error.twoFactorAuthenticationEnabled'; + case 403: + if (response.message === 'Invalid code') { + return 'passwordForm.error.incorrect2fa'; + } + return 'passwordForm.error.invalidLoginOrPassword'; + case 404: + return 'passwordForm.error.unableToResetPassword'; + case 405: + return 'passwordForm.error.noAccess'; + case 413: + return 'passwordForm.error.accountLocked'; + default: + return 'passwordForm.error.fallback'; + } +} + +/** + * Method used to get an error object with microsecond as the key. + * @param error - error key or message to be saved + */ +function getMicroSecondOnyxError(error: string): Record { + return {[DateUtils.getMicroseconds()]: error}; +} + +type OnyxDataWithErrors = { + errors?: Errors; +}; + +function getLatestErrorMessage(onyxData: TOnyxData): string { + const errors = onyxData.errors ?? {}; + + if (Object.keys(errors).length === 0) { + return ''; + } + + const key = Object.keys(errors).sort().reverse()[0]; + + return errors[key]; +} + +type OnyxDataWithErrorFields = { + errorFields?: ErrorFields; +}; + +function getLatestErrorField(onyxData: TOnyxData, fieldName: string): Record { + const errorsForField = onyxData.errorFields?.[fieldName] ?? {}; + + if (Object.keys(errorsForField).length === 0) { + return {}; + } + + const key = Object.keys(errorsForField).sort().reverse()[0]; + + return {[key]: errorsForField[key]}; +} + +function getEarliestErrorField(onyxData: TOnyxData, fieldName: string): Record { + const errorsForField = onyxData.errorFields?.[fieldName] ?? {}; + + if (Object.keys(errorsForField).length === 0) { + return {}; + } + + const key = Object.keys(errorsForField).sort()[0]; + + return {[key]: errorsForField[key]}; +} + +type ErrorsList = Record; + +/** + * Method used to generate error message for given inputID + * @param errorList - An object containing current errors in the form + * @param message - Message to assign to the inputID errors + */ +function addErrorMessage(errors: ErrorsList, inputID?: string, message?: string) { + if (!message || !inputID) { + return; + } + + const errorList = errors; + const error = errorList[inputID]; + const translatedMessage = Localize.translateIfPhraseKey(message); + + if (!error) { + errorList[inputID] = [translatedMessage, {isTranslated: true}]; + } else if (typeof error === 'string') { + errorList[inputID] = [`${error}\n${translatedMessage}`, {isTranslated: true}]; + } else if (Array.isArray(error)) { + error[0] = `${error[0]}\n${translatedMessage}`; + } +} + +export {getAuthenticateErrorMessage, getMicroSecondOnyxError, getLatestErrorMessage, getLatestErrorField, getEarliestErrorField, addErrorMessage}; diff --git a/src/libs/KeyboardShortcut/index.js b/src/libs/KeyboardShortcut/index.js index 37d85c7bfbfc..f91c81a1b856 100644 --- a/src/libs/KeyboardShortcut/index.js +++ b/src/libs/KeyboardShortcut/index.js @@ -83,6 +83,9 @@ _.each(CONST.KEYBOARD_SHORTCUTS, (shortcut) => { */ function unsubscribe(displayName, callbackID) { eventHandlers[displayName] = _.reject(eventHandlers[displayName], (callback) => callback.id === callbackID); + if (_.has(documentedShortcuts, displayName) && _.size(eventHandlers[displayName]) === 0) { + delete documentedShortcuts[displayName]; + } } /** diff --git a/src/libs/Navigation/AppNavigator/AuthScreens.js b/src/libs/Navigation/AppNavigator/AuthScreens.js index 16d0e2225007..dee5b7d4b489 100644 --- a/src/libs/Navigation/AppNavigator/AuthScreens.js +++ b/src/libs/Navigation/AppNavigator/AuthScreens.js @@ -71,8 +71,10 @@ Onyx.connect({ // If the current timezone is different than the user's timezone, and their timezone is set to automatic // then update their timezone. if (_.isObject(timezone) && timezone.automatic && timezone.selected !== currentTimezone) { - timezone.selected = currentTimezone; - PersonalDetails.updateAutomaticTimezone(timezone); + PersonalDetails.updateAutomaticTimezone({ + automatic: true, + selected: currentTimezone, + }); } }, }); diff --git a/src/libs/Navigation/linkingConfig.js b/src/libs/Navigation/linkingConfig.js index 770b8774f0ca..533dbf51633a 100644 --- a/src/libs/Navigation/linkingConfig.js +++ b/src/libs/Navigation/linkingConfig.js @@ -263,7 +263,7 @@ export default { }, NewTask: { screens: { - NewTask_Root: ROUTES.NEW_TASK_WITH_REPORT_ID, + NewTask_Root: ROUTES.NEW_TASK, NewTask_TaskAssigneeSelector: ROUTES.NEW_TASK_ASSIGNEE, NewTask_TaskShareDestinationSelector: ROUTES.NEW_TASK_SHARE_DESTINATION, NewTask_Details: ROUTES.NEW_TASK_DETAILS, diff --git a/src/libs/ReportUtils.js b/src/libs/ReportUtils.js index a57ba4d847b8..47d2f9ba2217 100644 --- a/src/libs/ReportUtils.js +++ b/src/libs/ReportUtils.js @@ -1357,6 +1357,7 @@ function getTransactionDetails(transaction) { merchant: TransactionUtils.getMerchant(transaction), waypoints: TransactionUtils.getWaypoints(transaction), category: TransactionUtils.getCategory(transaction), + billable: TransactionUtils.getBillable(transaction), tag: TransactionUtils.getTag(transaction), }; } @@ -1617,6 +1618,11 @@ function getModifiedExpenseMessage(reportAction) { if (hasModifiedTag) { return getProperSchemaForModifiedExpenseMessage(reportActionOriginalMessage.tag, reportActionOriginalMessage.oldTag, Localize.translateLocal('common.tag'), true); } + + const hasModifiedBillable = _.has(reportActionOriginalMessage, 'oldBillable') && _.has(reportActionOriginalMessage, 'billable'); + if (hasModifiedBillable) { + return getProperSchemaForModifiedExpenseMessage(reportActionOriginalMessage.billable, reportActionOriginalMessage.oldBillable, Localize.translateLocal('iou.request'), true); + } } /** @@ -1667,6 +1673,12 @@ function getModifiedExpenseOriginalMessage(oldTransaction, transactionChanges, i originalMessage.tag = transactionChanges.tag; } + if (_.has(transactionChanges, 'billable')) { + const oldBillable = TransactionUtils.getBillable(oldTransaction); + originalMessage.oldBillable = oldBillable ? Localize.translateLocal('common.billable').toLowerCase() : Localize.translateLocal('common.nonBillable').toLowerCase(); + originalMessage.billable = transactionChanges.billable ? Localize.translateLocal('common.billable').toLowerCase() : Localize.translateLocal('common.nonBillable').toLowerCase(); + } + return originalMessage; } @@ -3627,6 +3639,46 @@ function getReportPreviewDisplayTransactions(reportPreviewAction) { ); } +/** + * Return iou report action display message + * + * @param {Object} reportAction report action + * @returns {String} + */ +function getIOUReportActionDisplayMessage(reportAction) { + const originalMessage = _.get(reportAction, 'originalMessage', {}); + let displayMessage; + if (originalMessage.type === CONST.IOU.REPORT_ACTION_TYPE.PAY) { + const {amount, currency, IOUReportID} = originalMessage; + const formattedAmount = CurrencyUtils.convertToDisplayString(amount, currency); + const iouReport = getReport(IOUReportID); + const payerName = isExpenseReport(iouReport) ? getPolicyName(iouReport) : getDisplayNameForParticipant(iouReport.managerID); + let translationKey; + switch (originalMessage.paymentType) { + case CONST.IOU.PAYMENT_TYPE.ELSEWHERE: + translationKey = 'iou.paidElsewhereWithAmount'; + break; + case CONST.IOU.PAYMENT_TYPE.EXPENSIFY: + case CONST.IOU.PAYMENT_TYPE.VBBA: + translationKey = 'iou.paidUsingExpensifyWithAmount'; + break; + default: + translationKey = ''; + break; + } + displayMessage = Localize.translateLocal(translationKey, {amount: formattedAmount, payer: payerName}); + } else { + const transaction = TransactionUtils.getTransaction(originalMessage.IOUTransactionID); + const {amount, currency, comment} = getTransactionDetails(transaction); + const formattedAmount = CurrencyUtils.convertToDisplayString(amount, currency); + displayMessage = Localize.translateLocal('iou.requestedAmount', { + formattedAmount, + comment, + }); + } + return displayMessage; +} + export { getReportParticipantsTitle, isReportMessageAttachment, @@ -3767,5 +3819,6 @@ export { getReportPreviewDisplayTransactions, getTransactionsWithReceipts, hasMissingSmartscanFields, + getIOUReportActionDisplayMessage, isWaitingForTaskCompleteFromAssignee, }; diff --git a/src/libs/TransactionUtils.js b/src/libs/TransactionUtils.js index 2230479cb3a2..aff1068546d1 100644 --- a/src/libs/TransactionUtils.js +++ b/src/libs/TransactionUtils.js @@ -152,6 +152,10 @@ function getUpdatedTransaction(transaction, transactionChanges, isFromExpenseRep shouldStopSmartscan = true; } + if (_.has(transactionChanges, 'billable')) { + updatedTransaction.billable = transactionChanges.billable; + } + if (_.has(transactionChanges, 'category')) { updatedTransaction.category = transactionChanges.category; } @@ -171,6 +175,7 @@ function getUpdatedTransaction(transaction, transactionChanges, isFromExpenseRep ...(_.has(transactionChanges, 'currency') && {currency: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}), ...(_.has(transactionChanges, 'merchant') && {merchant: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}), ...(_.has(transactionChanges, 'waypoints') && {waypoints: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}), + ...(_.has(transactionChanges, 'billable') && {billable: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}), ...(_.has(transactionChanges, 'category') && {category: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}), ...(_.has(transactionChanges, 'tag') && {tag: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}), }; @@ -274,6 +279,16 @@ function getCategory(transaction) { return lodashGet(transaction, 'category', ''); } +/** + * Return the billable field from the transaction. This "billable" field has no "modified" complement. + * + * @param {Object} transaction + * @return {Boolean} + */ +function getBillable(transaction) { + return lodashGet(transaction, 'billable', false); +} + /** * Return the tag from the transaction. This "tag" field has no "modified" complement. * @@ -430,6 +445,7 @@ export { getMerchant, getCreated, getCategory, + getBillable, getTag, getLinkedTransaction, getAllReportTransactions, diff --git a/src/libs/actions/Chronos.js b/src/libs/actions/Chronos.ts similarity index 82% rename from src/libs/actions/Chronos.js rename to src/libs/actions/Chronos.ts index b9c0eed7b354..1b46a68a1afe 100644 --- a/src/libs/actions/Chronos.js +++ b/src/libs/actions/Chronos.ts @@ -1,16 +1,10 @@ -import _ from 'underscore'; import Onyx from 'react-native-onyx'; import CONST from '../../CONST'; import ONYXKEYS from '../../ONYXKEYS'; import * as API from '../API'; +import {ChronosOOOEvent} from '../../types/onyx/OriginalMessage'; -/** - * @param {String} reportID - * @param {String} reportActionID - * @param {String} eventID - * @param {Object[]} events - */ -const removeEvent = (reportID, reportActionID, eventID, events) => { +const removeEvent = (reportID: string, reportActionID: string, eventID: string, events: ChronosOOOEvent[]) => { const optimisticData = [ { onyxMethod: Onyx.METHOD.MERGE, @@ -19,7 +13,7 @@ const removeEvent = (reportID, reportActionID, eventID, events) => { [reportActionID]: { pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, originalMessage: { - events: _.reject(events, (event) => event.id === eventID), + events: events.filter((event) => event.id !== eventID), }, }, }, diff --git a/src/libs/actions/Device/getDeviceInfo/getOSAndName/index.js b/src/libs/actions/Device/getDeviceInfo/getOSAndName/index.js deleted file mode 100644 index 29b004412f64..000000000000 --- a/src/libs/actions/Device/getDeviceInfo/getOSAndName/index.js +++ /dev/null @@ -1,4 +0,0 @@ -// Don't import this file with '* as Device'. It's known to make VSCode IntelliSense crash. -import {getOSAndName} from 'expensify-common/lib/Device'; - -export default getOSAndName; diff --git a/src/libs/actions/Device/getDeviceInfo/getOSAndName/index.native.js b/src/libs/actions/Device/getDeviceInfo/getOSAndName/index.native.ts similarity index 51% rename from src/libs/actions/Device/getDeviceInfo/getOSAndName/index.native.js rename to src/libs/actions/Device/getDeviceInfo/getOSAndName/index.native.ts index 11d59abea1f1..bb9eb572570e 100644 --- a/src/libs/actions/Device/getDeviceInfo/getOSAndName/index.native.js +++ b/src/libs/actions/Device/getDeviceInfo/getOSAndName/index.native.ts @@ -1,11 +1,18 @@ import Str from 'expensify-common/lib/str'; import RNDeviceInfo from 'react-native-device-info'; +import GetOSAndName from './types'; -export default function getOSAndName() { +const getOSAndName: GetOSAndName = () => { const deviceName = RNDeviceInfo.getDeviceNameSync(); const prettyName = `${Str.UCFirst(RNDeviceInfo.getManufacturerSync() || '')} ${deviceName}`; return { + // Parameter names are predefined and we don't choose it here + // eslint-disable-next-line @typescript-eslint/naming-convention device_name: RNDeviceInfo.isEmulatorSync() ? `Emulator - ${prettyName}` : prettyName, + // Parameter names are predefined and we don't choose it here + // eslint-disable-next-line @typescript-eslint/naming-convention os_version: RNDeviceInfo.getSystemVersion(), }; -} +}; + +export default getOSAndName; diff --git a/src/libs/actions/Device/getDeviceInfo/getOSAndName/index.ts b/src/libs/actions/Device/getDeviceInfo/getOSAndName/index.ts new file mode 100644 index 000000000000..d63c2fedc51d --- /dev/null +++ b/src/libs/actions/Device/getDeviceInfo/getOSAndName/index.ts @@ -0,0 +1,12 @@ +import {getOSAndName as libGetOSAndName} from 'expensify-common/lib/Device'; +import GetOSAndName from './types'; + +const getOSAndName: GetOSAndName = () => { + // Parameter names are predefined and we don't choose it here + // eslint-disable-next-line @typescript-eslint/naming-convention + const {device_name, os_version} = libGetOSAndName(); + // Parameter names are predefined and we don't choose it here + // eslint-disable-next-line @typescript-eslint/naming-convention + return {device_name, os_version}; +}; +export default getOSAndName; diff --git a/src/libs/actions/Device/getDeviceInfo/getOSAndName/types.ts b/src/libs/actions/Device/getDeviceInfo/getOSAndName/types.ts new file mode 100644 index 000000000000..2ca67c3c59c3 --- /dev/null +++ b/src/libs/actions/Device/getDeviceInfo/getOSAndName/types.ts @@ -0,0 +1,4 @@ +// Parameter names are predefined and we don't choose it here +// eslint-disable-next-line @typescript-eslint/naming-convention +type GetOSAndName = () => {device_name: string | undefined; os_version: string | undefined}; +export default GetOSAndName; diff --git a/src/libs/actions/IOU.js b/src/libs/actions/IOU.js index bbdc2b5a0fd8..198ceb2b8172 100644 --- a/src/libs/actions/IOU.js +++ b/src/libs/actions/IOU.js @@ -604,8 +604,9 @@ function getMoneyRequestInformation( * @param {Number} amount * @param {String} currency * @param {String} merchant + * @param {Boolean} [billable] */ -function createDistanceRequest(report, participant, comment, created, transactionID, category, tag, amount, currency, merchant) { +function createDistanceRequest(report, participant, comment, created, transactionID, category, tag, amount, currency, merchant, billable) { const optimisticReceipt = { source: ReceiptGeneric, state: CONST.IOU.RECEIPT_STATE.OPEN, @@ -624,6 +625,7 @@ function createDistanceRequest(report, participant, comment, created, transactio transactionID, category, tag, + billable, ); API.write( 'CreateDistanceRequest', @@ -640,6 +642,7 @@ function createDistanceRequest(report, participant, comment, created, transactio created, category, tag, + billable, }, onyxData, ); @@ -1372,6 +1375,7 @@ function editMoneyRequest(transactionID, transactionThreadReportID, transactionC created: null, currency: null, merchant: null, + billable: null, category: null, tag: null, }, @@ -1420,7 +1424,7 @@ function editMoneyRequest(transactionID, transactionThreadReportID, transactionC ]; // STEP 6: Call the API endpoint - const {created, amount, currency, comment, merchant, category, tag} = ReportUtils.getTransactionDetails(updatedTransaction); + const {created, amount, currency, comment, merchant, category, billable, tag} = ReportUtils.getTransactionDetails(updatedTransaction); API.write( 'EditMoneyRequest', { @@ -1432,6 +1436,7 @@ function editMoneyRequest(transactionID, transactionThreadReportID, transactionC comment, merchant, category, + billable, tag, }, {optimisticData, successData, failureData}, diff --git a/src/libs/actions/OnyxUpdateManager.js b/src/libs/actions/OnyxUpdateManager.js index e0f3f8fd4622..b6318b784439 100644 --- a/src/libs/actions/OnyxUpdateManager.js +++ b/src/libs/actions/OnyxUpdateManager.js @@ -88,6 +88,7 @@ export default () => { canUnpauseQueuePromise.finally(() => { OnyxUpdates.apply(updateParams).finally(() => { console.debug('[OnyxUpdateManager] Done applying all updates'); + Onyx.set(ONYXKEYS.ONYX_UPDATES_FROM_SERVER, null); SequentialQueue.unpause(); }); }); diff --git a/src/pages/home/report/ContextMenu/BaseReportActionContextMenu.js b/src/pages/home/report/ContextMenu/BaseReportActionContextMenu.js index fc56b3b1fac9..91fe38784e9c 100755 --- a/src/pages/home/report/ContextMenu/BaseReportActionContextMenu.js +++ b/src/pages/home/report/ContextMenu/BaseReportActionContextMenu.js @@ -18,6 +18,7 @@ import ONYXKEYS from '../../../../ONYXKEYS'; import CONST from '../../../../CONST'; import useArrowKeyFocusManager from '../../../../hooks/useArrowKeyFocusManager'; import useKeyboardShortcut from '../../../../hooks/useKeyboardShortcut'; +import useNetwork from '../../../../hooks/useNetwork'; const propTypes = { /** String representing the context menu type [LINK, REPORT_ACTION] which controls context menu choices */ @@ -51,6 +52,7 @@ function BaseReportActionContextMenu(props) { const menuItemRefs = useRef({}); const [shouldKeepOpen, setShouldKeepOpen] = useState(false); const wrapperStyle = getReportActionContextMenuStyles(props.isMini, props.isSmallScreenWidth); + const {isOffline} = useNetwork(); const reportAction = useMemo(() => { if (_.isEmpty(props.reportActions) || props.reportActionID === '0') { @@ -60,7 +62,18 @@ function BaseReportActionContextMenu(props) { }, [props.reportActions, props.reportActionID]); const shouldShowFilter = (contextAction) => - contextAction.shouldShow(props.type, reportAction, props.isArchivedRoom, props.betas, props.anchor, props.isChronosReport, props.reportID, props.isPinnedChat, props.isUnreadChat); + contextAction.shouldShow( + props.type, + reportAction, + props.isArchivedRoom, + props.betas, + props.anchor, + props.isChronosReport, + props.reportID, + props.isPinnedChat, + props.isUnreadChat, + isOffline, + ); const shouldEnableArrowNavigation = !props.isMini && (props.isVisible || shouldKeepOpen); const filteredContextMenuActions = _.filter(ContextMenuActions, shouldShowFilter); diff --git a/src/pages/home/report/ContextMenu/ContextMenuActions.js b/src/pages/home/report/ContextMenu/ContextMenuActions.js index 2a65bc2e67ab..0607404c6f66 100644 --- a/src/pages/home/report/ContextMenu/ContextMenuActions.js +++ b/src/pages/home/report/ContextMenu/ContextMenuActions.js @@ -22,9 +22,6 @@ import MiniQuickEmojiReactions from '../../../../components/Reactions/MiniQuickE import Navigation from '../../../../libs/Navigation/Navigation'; import ROUTES from '../../../../ROUTES'; import * as Task from '../../../../libs/actions/Task'; -import * as Localize from '../../../../libs/Localize'; -import * as TransactionUtils from '../../../../libs/TransactionUtils'; -import * as CurrencyUtils from '../../../../libs/CurrencyUtils'; /** * Gets the HTML version of the message in an action. @@ -98,10 +95,10 @@ export default [ icon: Expensicons.Download, successTextTranslateKey: 'common.download', successIcon: Expensicons.Download, - shouldShow: (type, reportAction) => { + shouldShow: (type, reportAction, isArchivedRoom, betas, anchor, isChronosReport, reportID, isPinnedChat, isUnreadChat, isOffline) => { const isAttachment = ReportActionsUtils.isReportActionAttachment(reportAction); const messageHtml = lodashGet(reportAction, ['message', 0, 'html']); - return isAttachment && messageHtml !== CONST.ATTACHMENT_UPLOADING_MESSAGE_HTML && reportAction.reportActionID && !ReportActionsUtils.isMessageDeleted(reportAction); + return isAttachment && messageHtml !== CONST.ATTACHMENT_UPLOADING_MESSAGE_HTML && reportAction.reportActionID && !ReportActionsUtils.isMessageDeleted(reportAction) && !isOffline; }, onPress: (closePopover, {reportAction}) => { const message = _.last(lodashGet(reportAction, 'message', [{}])); @@ -203,15 +200,8 @@ export default [ const modifyExpenseMessage = ReportUtils.getModifiedExpenseMessage(reportAction); Clipboard.setString(modifyExpenseMessage); } else if (ReportActionsUtils.isMoneyRequestAction(reportAction)) { - const originalMessage = _.get(reportAction, 'originalMessage', {}); - const transaction = TransactionUtils.getTransaction(originalMessage.IOUTransactionID); - const {amount, currency, comment} = ReportUtils.getTransactionDetails(transaction); - const formattedAmount = CurrencyUtils.convertToDisplayString(amount, currency); - const displaymessage = Localize.translateLocal('iou.requestedAmount', { - formattedAmount, - comment, - }); - Clipboard.setString(displaymessage); + const displayMessage = ReportUtils.getIOUReportActionDisplayMessage(reportAction); + Clipboard.setString(displayMessage); } else if (content) { const parser = new ExpensiMark(); if (!Clipboard.canSetHtml()) { diff --git a/src/pages/home/report/ContextMenu/PopoverReportActionContextMenu.js b/src/pages/home/report/ContextMenu/PopoverReportActionContextMenu.js index dd0813132a8e..4f09df7330ff 100644 --- a/src/pages/home/report/ContextMenu/PopoverReportActionContextMenu.js +++ b/src/pages/home/report/ContextMenu/PopoverReportActionContextMenu.js @@ -1,96 +1,59 @@ -import React from 'react'; +import React, {forwardRef, useEffect, useState, useRef, useImperativeHandle, useCallback} from 'react'; import {Dimensions} from 'react-native'; import _ from 'underscore'; -import lodashGet from 'lodash/get'; import * as Report from '../../../../libs/actions/Report'; -import withLocalize, {withLocalizePropTypes} from '../../../../components/withLocalize'; import PopoverWithMeasuredContent from '../../../../components/PopoverWithMeasuredContent'; import BaseReportActionContextMenu from './BaseReportActionContextMenu'; import ConfirmModal from '../../../../components/ConfirmModal'; -import CONST from '../../../../CONST'; import * as ReportActionsUtils from '../../../../libs/ReportActionsUtils'; import * as IOU from '../../../../libs/actions/IOU'; +import useLocalize from '../../../../hooks/useLocalize'; -const propTypes = { - ...withLocalizePropTypes, -}; - -class PopoverReportActionContextMenu extends React.Component { - constructor(props) { - super(props); - - this.state = { - reportID: '0', - reportActionID: '0', - originalReportID: '0', - reportAction: {}, - selection: '', - reportActionDraftMessage: '', - isPopoverVisible: false, - isDeleteCommentConfirmModalVisible: false, - shouldSetModalVisibilityForDeleteConfirmation: true, - cursorRelativePosition: { - horizontal: 0, - vertical: 0, - }, - - // The horizontal and vertical position (relative to the screen) where the popover will display. - popoverAnchorPosition: { - horizontal: 0, - vertical: 0, - }, - isArchivedRoom: false, - isChronosReport: false, - isPinnedChat: false, - isUnreadChat: false, - }; - this.onPopoverShow = () => {}; - this.onPopoverHide = () => {}; - this.onPopoverHideActionCallback = () => {}; - this.contextMenuAnchor = undefined; - this.showContextMenu = this.showContextMenu.bind(this); - this.hideContextMenu = this.hideContextMenu.bind(this); - this.measureContextMenuAnchorPosition = this.measureContextMenuAnchorPosition.bind(this); - this.confirmDeleteAndHideModal = this.confirmDeleteAndHideModal.bind(this); - this.hideDeleteModal = this.hideDeleteModal.bind(this); - this.showDeleteModal = this.showDeleteModal.bind(this); - this.runAndResetOnPopoverShow = this.runAndResetOnPopoverShow.bind(this); - this.runAndResetOnPopoverHide = this.runAndResetOnPopoverHide.bind(this); - this.getContextMenuMeasuredLocation = this.getContextMenuMeasuredLocation.bind(this); - this.isActiveReportAction = this.isActiveReportAction.bind(this); - this.clearActiveReportAction = this.clearActiveReportAction.bind(this); - - this.dimensionsEventListener = null; - - this.contentRef = React.createRef(); - this.setContentRef = (ref) => { - this.contentRef.current = ref; - }; - this.setContentRef = this.setContentRef.bind(this); - this.anchorRef = React.createRef(); - } - - componentDidMount() { - this.dimensionsEventListener = Dimensions.addEventListener('change', this.measureContextMenuAnchorPosition); - } - - shouldComponentUpdate(nextProps, nextState) { - const previousLocale = lodashGet(this.props, 'preferredLocale', CONST.LOCALES.DEFAULT); - const nextLocale = lodashGet(nextProps, 'preferredLocale', CONST.LOCALES.DEFAULT); - return ( - this.state.isPopoverVisible !== nextState.isPopoverVisible || - this.state.popoverAnchorPosition !== nextState.popoverAnchorPosition || - this.state.isDeleteCommentConfirmModalVisible !== nextState.isDeleteCommentConfirmModalVisible || - previousLocale !== nextLocale - ); - } - - componentWillUnmount() { - if (!this.dimensionsEventListener) { - return; - } - this.dimensionsEventListener.remove(); - } +function PopoverReportActionContextMenu(_props, ref) { + const {translate} = useLocalize(); + const reportIDRef = useRef('0'); + const typeRef = useRef(''); + const reportActionRef = useRef({}); + const reportActionIDRef = useRef('0'); + const originalReportIDRef = useRef('0'); + const selectionRef = useRef(''); + const reportActionDraftMessageRef = useRef(''); + + const cursorRelativePosition = useRef({ + horizontal: 0, + vertical: 0, + }); + + // The horizontal and vertical position (relative to the screen) where the popover will display. + const popoverAnchorPosition = useRef({ + horizontal: 0, + vertical: 0, + }); + + const [instanceID, setInstanceID] = useState(''); + + const [isPopoverVisible, setIsPopoverVisible] = useState(false); + const [isDeleteCommentConfirmModalVisible, setIsDeleteCommentConfirmModalVisible] = useState(false); + const [shouldSetModalVisibilityForDeleteConfirmation, setShouldSetModalVisibilityForDeleteConfirmation] = useState(true); + + const [isRoomArchived, setIsRoomArchived] = useState(false); + const [isChronosReportEnabled, setIsChronosReportEnabled] = useState(false); + const [isChatPinned, setIsChatPinned] = useState(false); + const [hasUnreadMessages, setHasUnreadMessages] = useState(false); + + const contentRef = useRef(null); + const anchorRef = useRef(null); + const dimensionsEventListener = useRef(null); + const contextMenuAnchorRef = useRef(null); + const contextMenuTargetNode = useRef(null); + + const onPopoverShow = useRef(() => {}); + const onPopoverHide = useRef(() => {}); + const onCancelDeleteModal = useRef(() => {}); + const onComfirmDeleteModal = useRef(() => {}); + + const onPopoverHideActionCallback = useRef(() => {}); + const callbackWhenDeleteModalHide = useRef(() => {}); /** * Get the Context menu anchor position @@ -98,15 +61,48 @@ class PopoverReportActionContextMenu extends React.Component { * * @returns {Promise} */ - getContextMenuMeasuredLocation() { - return new Promise((resolve) => { - if (this.contextMenuAnchor) { - (this.contextMenuAnchor.current || this.contextMenuAnchor).measureInWindow((x, y) => resolve({x, y})); - } else { - resolve({x: 0, y: 0}); + const getContextMenuMeasuredLocation = useCallback( + () => + new Promise((resolve) => { + if (contextMenuAnchorRef.current && _.isFunction(contextMenuAnchorRef.current.measureInWindow)) { + contextMenuAnchorRef.current.measureInWindow((x, y) => resolve({x, y})); + } else { + resolve({x: 0, y: 0}); + } + }), + [], + ); + + /** + * This gets called on Dimensions change to find the anchor coordinates for the action context menu. + */ + const measureContextMenuAnchorPosition = useCallback(() => { + if (!isPopoverVisible) { + return; + } + + getContextMenuMeasuredLocation().then(({x, y}) => { + if (!x || !y) { + return; } + + popoverAnchorPosition.current = { + horizontal: cursorRelativePosition.horizontal + x, + vertical: cursorRelativePosition.vertical + y, + }; }); - } + }, [isPopoverVisible, getContextMenuMeasuredLocation]); + + useEffect(() => { + dimensionsEventListener.current = Dimensions.addEventListener('change', measureContextMenuAnchorPosition); + + return () => { + if (!dimensionsEventListener.current) { + return; + } + dimensionsEventListener.current.remove(); + }; + }, [measureContextMenuAnchorPosition]); /** * Whether Context Menu is active for the Report Action. @@ -114,13 +110,12 @@ class PopoverReportActionContextMenu extends React.Component { * @param {Number|String} actionID * @return {Boolean} */ - isActiveReportAction(actionID) { - return Boolean(actionID) && (this.state.reportActionID === actionID || this.state.reportAction.reportActionID === actionID); - } + const isActiveReportAction = (actionID) => Boolean(actionID) && (reportActionIDRef.current === actionID || reportActionRef.current.reportActionID === actionID); - clearActiveReportAction() { - this.setState({reportID: '0', reportAction: {}}); - } + const clearActiveReportAction = () => { + reportActionIDRef.current = '0'; + reportActionRef.current = {}; + }; /** * Show the ReportActionContextMenu modal popover. @@ -140,7 +135,7 @@ class PopoverReportActionContextMenu extends React.Component { * @param {Boolean} isPinnedChat - Flag to check if the chat is pinned in the LHN. Used for the Pin/Unpin action * @param {Boolean} isUnreadChat - Flag to check if the chat is unread in the LHN. Used for the Mark as Read/Unread action */ - showContextMenu( + const showContextMenu = ( type, event, selection, @@ -155,130 +150,105 @@ class PopoverReportActionContextMenu extends React.Component { isChronosReport = false, isPinnedChat = false, isUnreadChat = false, - ) { + ) => { const nativeEvent = event.nativeEvent || {}; - this.contextMenuAnchor = contextMenuAnchor; - this.contextMenuTargetNode = nativeEvent.target; - - // Singleton behaviour of ContextMenu creates race conditions when user requests multiple contextMenus. - // But it is possible that every new request registers new callbacks thus instanceID is used to corelate those callbacks - this.instanceID = Math.random().toString(36).substr(2, 5); - - this.onPopoverShow = onShow; - this.onPopoverHide = onHide; - - this.getContextMenuMeasuredLocation().then(({x, y}) => { - this.setState({ - cursorRelativePosition: { - horizontal: nativeEvent.pageX - x, - vertical: nativeEvent.pageY - y, - }, - popoverAnchorPosition: { - horizontal: nativeEvent.pageX, - vertical: nativeEvent.pageY, - }, - type, - reportID, - reportActionID, - originalReportID, - selection, - isPopoverVisible: true, - reportActionDraftMessage: draftMessage, - isArchivedRoom, - isChronosReport, - isPinnedChat, - isUnreadChat, - }); - }); - } + contextMenuAnchorRef.current = contextMenuAnchor; + contextMenuTargetNode.current = nativeEvent.target; - /** - * This gets called on Dimensions change to find the anchor coordinates for the action context menu. - */ - measureContextMenuAnchorPosition() { - if (!this.state.isPopoverVisible) { - return; - } - this.getContextMenuMeasuredLocation().then(({x, y}) => { - if (!x || !y) { - return; - } - this.setState((prev) => ({ - popoverAnchorPosition: { - horizontal: prev.cursorRelativePosition.horizontal + x, - vertical: prev.cursorRelativePosition.vertical + y, - }, - })); + setInstanceID(Math.random().toString(36).substr(2, 5)); + + onPopoverShow.current = onShow; + onPopoverHide.current = onHide; + + getContextMenuMeasuredLocation().then(({x, y}) => { + popoverAnchorPosition.current = { + horizontal: nativeEvent.pageX - x, + vertical: nativeEvent.pageY - y, + }; + + popoverAnchorPosition.current = { + horizontal: nativeEvent.pageX, + vertical: nativeEvent.pageY, + }; + typeRef.current = type; + reportIDRef.current = reportID; + reportActionIDRef.current = reportActionID; + originalReportIDRef.current = originalReportID; + selectionRef.current = selection; + setIsPopoverVisible(true); + reportActionDraftMessageRef.current = draftMessage; + setIsRoomArchived(isArchivedRoom); + setIsChronosReportEnabled(isChronosReport); + setIsChatPinned(isPinnedChat); + setHasUnreadMessages(isUnreadChat); }); - } + }; /** * After Popover shows, call the registered onPopoverShow callback and reset it */ - runAndResetOnPopoverShow() { - this.onPopoverShow(); + const runAndResetOnPopoverShow = () => { + onPopoverShow.current(); // After we have called the action, reset it. - this.onPopoverShow = () => {}; - } + onPopoverShow.current = () => {}; + }; + + /** + * Run the callback and return a noop function to reset it + * @param {Function} callback + * @returns {Function} + */ + const runAndResetCallback = (callback) => { + callback(); + return () => {}; + }; /** * After Popover hides, call the registered onPopoverHide & onPopoverHideActionCallback callback and reset it */ - runAndResetOnPopoverHide() { - this.setState({reportID: '0', reportActionID: '0', originalReportID: '0'}, () => { - this.onPopoverHide = this.runAndResetCallback(this.onPopoverHide); - this.onPopoverHideActionCallback = this.runAndResetCallback(this.onPopoverHideActionCallback); - }); - } + const runAndResetOnPopoverHide = () => { + reportIDRef.current = '0'; + reportActionIDRef.current = '0'; + originalReportIDRef.current = '0'; + + onPopoverHide.current = runAndResetCallback(onPopoverHide.current); + onPopoverHideActionCallback.current = runAndResetCallback(onPopoverHideActionCallback.current); + }; /** * Hide the ReportActionContextMenu modal popover. * @param {Function} onHideActionCallback Callback to be called after popover is completely hidden */ - hideContextMenu(onHideActionCallback) { + const hideContextMenu = (onHideActionCallback) => { if (_.isFunction(onHideActionCallback)) { - this.onPopoverHideActionCallback = onHideActionCallback; + onPopoverHideActionCallback.current = onHideActionCallback; } - this.setState({ - selection: '', - reportActionDraftMessage: '', - isPopoverVisible: false, - }); - } - /** - * Run the callback and return a noop function to reset it - * @param {Function} callback - * @returns {Function} - */ - runAndResetCallback(callback) { - callback(); - return () => {}; - } - - confirmDeleteAndHideModal() { - this.callbackWhenDeleteModalHide = () => (this.onComfirmDeleteModal = this.runAndResetCallback(this.onComfirmDeleteModal)); + selectionRef.current = ''; + reportActionDraftMessageRef.current = ''; + setIsPopoverVisible(false); + }; - if (ReportActionsUtils.isMoneyRequestAction(this.state.reportAction)) { - IOU.deleteMoneyRequest(this.state.reportAction.originalMessage.IOUTransactionID, this.state.reportAction); + const confirmDeleteAndHideModal = useCallback(() => { + callbackWhenDeleteModalHide.current = () => (onComfirmDeleteModal.current = runAndResetCallback(onComfirmDeleteModal.current)); + if (ReportActionsUtils.isMoneyRequestAction(reportActionRef.current)) { + IOU.deleteMoneyRequest(reportActionRef.current.originalMessage.IOUTransactionID, reportActionRef.current); } else { - Report.deleteReportComment(this.state.reportID, this.state.reportAction); + Report.deleteReportComment(reportIDRef.current, reportActionRef.current); } - this.setState({isDeleteCommentConfirmModalVisible: false}); - } - - hideDeleteModal() { - this.callbackWhenDeleteModalHide = () => (this.onCancelDeleteModal = this.runAndResetCallback(this.onCancelDeleteModal)); - this.setState({ - isDeleteCommentConfirmModalVisible: false, - shouldSetModalVisibilityForDeleteConfirmation: true, - isArchivedRoom: false, - isChronosReport: false, - isPinnedChat: false, - isUnreadChat: false, - }); - } + setIsDeleteCommentConfirmModalVisible(false); + }, [reportActionRef]); + + const hideDeleteModal = () => { + callbackWhenDeleteModalHide.current = () => (onCancelDeleteModal.current = runAndResetCallback(onCancelDeleteModal.current)); + setIsDeleteCommentConfirmModalVisible(false); + setShouldSetModalVisibilityForDeleteConfirmation(true); + setIsRoomArchived(false); + setIsChronosReportEnabled(false); + setIsChatPinned(false); + setHasUnreadMessages(false); + }; /** * Opens the Confirm delete action modal @@ -288,67 +258,82 @@ class PopoverReportActionContextMenu extends React.Component { * @param {Function} [onConfirm] * @param {Function} [onCancel] */ - showDeleteModal(reportID, reportAction, shouldSetModalVisibility = true, onConfirm = () => {}, onCancel = () => {}) { - this.onCancelDeleteModal = onCancel; - this.onComfirmDeleteModal = onConfirm; - this.setState({ - reportID, - reportAction, - shouldSetModalVisibilityForDeleteConfirmation: shouldSetModalVisibility, - isDeleteCommentConfirmModalVisible: true, - }); - } - - render() { - return ( - <> - - - - {}, onCancel = () => {}) => { + onCancelDeleteModal.current = onCancel; + onComfirmDeleteModal.current = onConfirm; + + reportIDRef.current = reportID; + reportActionRef.current = reportAction; + + setShouldSetModalVisibilityForDeleteConfirmation(shouldSetModalVisibility); + setIsDeleteCommentConfirmModalVisible(true); + }; + + useImperativeHandle(ref, () => ({ + showContextMenu, + hideContextMenu, + showDeleteModal, + hideDeleteModal, + isActiveReportAction, + instanceID, + runAndResetOnPopoverHide, + clearActiveReportAction, + })); + + const reportAction = reportActionRef.current; + + return ( + <> + + - - ); - } + + { + reportIDRef.current = '0'; + reportActionRef.current = {}; + callbackWhenDeleteModalHide.current(); + }} + prompt={translate('reportActionContextMenu.deleteConfirmation', {action: reportAction})} + confirmText={translate('common.delete')} + cancelText={translate('common.cancel')} + danger + /> + + ); } -PopoverReportActionContextMenu.propTypes = propTypes; +PopoverReportActionContextMenu.displayName = 'PopoverReportActionContextMenu'; -export default withLocalize(PopoverReportActionContextMenu); +export default forwardRef(PopoverReportActionContextMenu); diff --git a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions.js b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions.js index 630c983cd889..95a33fe6b721 100644 --- a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions.js +++ b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions.js @@ -92,7 +92,6 @@ function ComposerWithSuggestions({ setIsFullComposerAvailable, setIsCommentEmpty, submitForm, - shouldShowReportRecipientLocalTime, shouldShowComposeInput, measureParentContainer, // Refs @@ -535,7 +534,6 @@ function ComposerWithSuggestions({ isComposerFullSize={isComposerFullSize} updateComment={updateComment} composerHeight={composerHeight} - shouldShowReportRecipientLocalTime={shouldShowReportRecipientLocalTime} onInsertedEmoji={onInsertedEmoji} measureParentContainer={measureParentContainer} // Input diff --git a/src/pages/home/report/ReportActionCompose/ReportActionCompose.js b/src/pages/home/report/ReportActionCompose/ReportActionCompose.js index 6ef0a9867ece..46153bda15e6 100644 --- a/src/pages/home/report/ReportActionCompose/ReportActionCompose.js +++ b/src/pages/home/report/ReportActionCompose/ReportActionCompose.js @@ -5,6 +5,7 @@ import _ from 'underscore'; import lodashGet from 'lodash/get'; import {withOnyx} from 'react-native-onyx'; import {useAnimatedRef} from 'react-native-reanimated'; +import {PortalHost} from '@gorhom/portal'; import styles from '../../../../styles/styles'; import ONYXKEYS from '../../../../ONYXKEYS'; import * as Report from '../../../../libs/actions/Report'; @@ -318,6 +319,7 @@ function ReportActionCompose({ ref={containerRef} style={[shouldShowReportRecipientLocalTime && !lodashGet(network, 'isOffline') && styles.chatItemComposeWithFirstRow, isComposerFullSize && styles.chatItemFullComposeRow]} > + ); diff --git a/src/pages/home/report/ReportActionCompose/SuggestionMention.js b/src/pages/home/report/ReportActionCompose/SuggestionMention.js index 62e98a697709..6c08b68cdc78 100644 --- a/src/pages/home/report/ReportActionCompose/SuggestionMention.js +++ b/src/pages/home/report/ReportActionCompose/SuggestionMention.js @@ -51,7 +51,6 @@ function SuggestionMention({ personalDetails, updateComment, composerHeight, - shouldShowReportRecipientLocalTime, forwardedRef, isAutoSuggestionPickerLarge, measureParentContainer, @@ -285,7 +284,6 @@ function SuggestionMention({ isComposerFullSize={isComposerFullSize} isMentionPickerLarge={isAutoSuggestionPickerLarge} composerHeight={composerHeight} - shouldIncludeReportRecipientLocalTimeHeight={shouldShowReportRecipientLocalTime} measureParentContainer={measureParentContainer} /> ); diff --git a/src/pages/home/report/ReportActionCompose/Suggestions.js b/src/pages/home/report/ReportActionCompose/Suggestions.js index 60cb9de4ccfb..a00bd342b17d 100644 --- a/src/pages/home/report/ReportActionCompose/Suggestions.js +++ b/src/pages/home/report/ReportActionCompose/Suggestions.js @@ -36,7 +36,6 @@ function Suggestions({ setSelection, updateComment, composerHeight, - shouldShowReportRecipientLocalTime, forwardedRef, onInsertedEmoji, resetKeyboardInput, @@ -105,7 +104,6 @@ function Suggestions({ isComposerFullSize, updateComment, composerHeight, - shouldShowReportRecipientLocalTime, isAutoSuggestionPickerLarge, measureParentContainer, }; diff --git a/src/pages/home/report/ReportActionCompose/composerWithSuggestionsProps.js b/src/pages/home/report/ReportActionCompose/composerWithSuggestionsProps.js index b8d9f0b6d816..0c8f36114c44 100644 --- a/src/pages/home/report/ReportActionCompose/composerWithSuggestionsProps.js +++ b/src/pages/home/report/ReportActionCompose/composerWithSuggestionsProps.js @@ -74,9 +74,6 @@ const propTypes = { /** A method to call when the form is submitted */ submitForm: PropTypes.func.isRequired, - /** Whether the recipient local time is shown or not */ - shouldShowReportRecipientLocalTime: PropTypes.bool.isRequired, - /** Whether the compose input is shown or not */ shouldShowComposeInput: PropTypes.bool.isRequired, diff --git a/src/pages/home/report/ReportActionCompose/suggestionProps.js b/src/pages/home/report/ReportActionCompose/suggestionProps.js index 12447929b980..815a1c5619f5 100644 --- a/src/pages/home/report/ReportActionCompose/suggestionProps.js +++ b/src/pages/home/report/ReportActionCompose/suggestionProps.js @@ -22,9 +22,6 @@ const baseProps = { /** Callback to update the comment draft */ updateComment: PropTypes.func.isRequired, - /** Flag whether we need to consider the participants */ - shouldShowReportRecipientLocalTime: PropTypes.bool.isRequired, - /** Meaures the parent container's position and dimensions. */ measureParentContainer: PropTypes.func.isRequired, }; diff --git a/src/pages/iou/MoneyRequestSelectorPage.js b/src/pages/iou/MoneyRequestSelectorPage.js index 2a948eb3b356..9e46b1d2d7a2 100644 --- a/src/pages/iou/MoneyRequestSelectorPage.js +++ b/src/pages/iou/MoneyRequestSelectorPage.js @@ -1,6 +1,6 @@ import {withOnyx} from 'react-native-onyx'; import {View} from 'react-native'; -import React, {useState} from 'react'; +import React, {useEffect, useState} from 'react'; import lodashGet from 'lodash/get'; import PropTypes from 'prop-types'; import ONYXKEYS from '../../ONYXKEYS'; @@ -22,6 +22,7 @@ import NewRequestAmountPage from './steps/NewRequestAmountPage'; import reportPropTypes from '../reportPropTypes'; import * as ReportUtils from '../../libs/ReportUtils'; import themeColors from '../../styles/themes/default'; +import usePrevious from '../../hooks/usePrevious'; const propTypes = { /** React Navigation route */ @@ -69,6 +70,18 @@ function MoneyRequestSelectorPage(props) { IOU.resetMoneyRequestInfo(moneyRequestID); }; + const prevSelectedTab = usePrevious(props.selectedTab); + + useEffect(() => { + if (prevSelectedTab === props.selectedTab) { + return; + } + + resetMoneyRequestInfo(); + // resetMoneyRequestInfo function is not added as dependencies since they don't change between renders + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [props.selectedTab, prevSelectedTab]); + return ( )} diff --git a/src/pages/iou/ReceiptSelector/index.js b/src/pages/iou/ReceiptSelector/index.js index d84da543e7b8..b4cf75801a3f 100644 --- a/src/pages/iou/ReceiptSelector/index.js +++ b/src/pages/iou/ReceiptSelector/index.js @@ -1,5 +1,5 @@ -import {View, Text, PixelRatio} from 'react-native'; -import React, {useContext, useState} from 'react'; +import {View, Text, PanResponder, PixelRatio} from 'react-native'; +import React, {useContext, useRef, useState} from 'react'; import lodashGet from 'lodash/get'; import _ from 'underscore'; import PropTypes from 'prop-types'; @@ -8,7 +8,6 @@ import * as IOU from '../../../libs/actions/IOU'; import reportPropTypes from '../../reportPropTypes'; import CONST from '../../../CONST'; import ReceiptUpload from '../../../../assets/images/receipt-upload.svg'; -import PressableWithFeedback from '../../../components/Pressable/PressableWithFeedback'; import Button from '../../../components/Button'; import styles from '../../../styles/styles'; import CopyTextToClipboard from '../../../components/CopyTextToClipboard'; @@ -129,6 +128,13 @@ function ReceiptSelector(props) { IOU.navigateToNextPage(iou, iouType, report, props.route.path); }; + const panResponder = useRef( + PanResponder.create({ + onMoveShouldSetPanResponder: () => true, + onPanResponderTerminationRequest: () => false, + }), + ).current; + return ( {!isDraggingOver ? ( @@ -143,35 +149,37 @@ function ReceiptSelector(props) { height={CONST.RECEIPT.ICON_SIZE} /> - {translate('receipt.upload')} - - {isSmallScreenWidth ? translate('receipt.chooseReceipt') : translate('receipt.dragReceiptBeforeEmail')} - - {isSmallScreenWidth ? null : translate('receipt.dragReceiptAfterEmail')} - + + {translate('receipt.upload')} + + {isSmallScreenWidth ? translate('receipt.chooseReceipt') : translate('receipt.dragReceiptBeforeEmail')} + + {isSmallScreenWidth ? null : translate('receipt.dragReceiptAfterEmail')} + + {({openPicker}) => ( - -