diff --git a/.eslintrc.js b/.eslintrc.js index ac4546567833..3c144064eb62 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -46,6 +46,7 @@ module.exports = { touchables: ['PressableWithoutFeedback', 'PressableWithFeedback'], }, ], + curly: 'error', }, }, { diff --git a/android/app/build.gradle b/android/app/build.gradle index b377f6930402..1e34491b04ad 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -90,8 +90,8 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion multiDexEnabled rootProject.ext.multiDexEnabled - versionCode 1001037206 - versionName "1.3.72-6" + versionCode 1001037209 + versionName "1.3.72-9" } flavorDimensions "default" diff --git a/contributingGuides/STYLE.md b/contributingGuides/STYLE.md index ce59438a0681..b615104f6aab 100644 --- a/contributingGuides/STYLE.md +++ b/contributingGuides/STYLE.md @@ -567,6 +567,28 @@ A `useEffect()` that does not include referenced props or state in its dependenc There are pros and cons of each, but ultimately we have standardized on using the `function` keyword to align things more with modern React conventions. There are also some minor cognitive overhead benefits in that you don't need to think about adding and removing brackets when encountering an implicit return. The `function` syntax also has the benefit of being able to be hoisted where arrow functions do not. +## How do I auto-focus a TextInput using `useFocusEffect()`? + +```javascript +const focusTimeoutRef = useRef(null); + +useFocusEffect(useCallback(() => { + focusTimeoutRef.current = setTimeout(() => textInputRef.current.focus(), CONST.ANIMATED_TRANSITION); + return () => { + if (!focusTimeoutRef.current) { + return; + } + clearTimeout(focusTimeoutRef.current); + }; +}, [])); +``` + +This works better than using `onTransitionEnd` because - +1. `onTransitionEnd` is only fired for the top card in the stack, and therefore does not fire on the new top card when popping a card off the stack. For example - pressing the back button to go from the workspace invite page to the workspace members list. +2. Using `InteractionsManager.runAfterInteractions` with `useFocusEffect` will interrupt an in-progress transition animation. + +Note - This is a solution from [this PR](https://github.com/Expensify/App/pull/26415). You can find detailed discussion in comments. + # Onyx Best Practices [Onyx Documentation](https://github.com/expensify/react-native-onyx) diff --git a/docs/_sass/_search-bar.scss b/docs/_sass/_search-bar.scss index ce085878af46..a5cc8ae2ff20 100644 --- a/docs/_sass/_search-bar.scss +++ b/docs/_sass/_search-bar.scss @@ -23,14 +23,25 @@ $color-gray-label: $color-gray-label; #sidebar-search { background-color: $color-appBG; width: 375px; - height: 100vh; position: fixed; - display: block; + display: flex; + flex-direction: column; + bottom: 0; top: 0; right: 0; z-index: 2; } +#sidebar-search > div:last-child { + flex-grow: 1; + overflow-y: auto; + -ms-overflow-style: none; + scrollbar-width: none; + &::-webkit-scrollbar { + display: none; + } +} + @media only screen and (max-width: $breakpoint-tablet) { #sidebar-search { width: 100%; @@ -156,10 +167,6 @@ label.search-label { background-color: $color-appBG; border: $color-appBG; font-family: "ExpensifyNeue", "Helvetica Neue", "Helvetica", Arial, sans-serif !important; - max-height: 80vh; - overflow-y: scroll; - -ms-overflow-style: none; - scrollbar-width: none; } /* Hide the scrollbar */ diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist index 3a3aa7f765a8..441bd2feab92 100644 --- a/ios/NewExpensify/Info.plist +++ b/ios/NewExpensify/Info.plist @@ -40,7 +40,7 @@ CFBundleVersion - 1.3.72.6 + 1.3.72.9 ITSAppUsesNonExemptEncryption LSApplicationQueriesSchemes diff --git a/ios/NewExpensifyTests/Info.plist b/ios/NewExpensifyTests/Info.plist index 340d56aa975c..27273c7f3866 100644 --- a/ios/NewExpensifyTests/Info.plist +++ b/ios/NewExpensifyTests/Info.plist @@ -19,6 +19,6 @@ CFBundleSignature ???? CFBundleVersion - 1.3.72.6 + 1.3.72.9 diff --git a/package-lock.json b/package-lock.json index faf13c7d9d15..ff0500eb385b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "new.expensify", - "version": "1.3.72-6", + "version": "1.3.72-9", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "new.expensify", - "version": "1.3.72-6", + "version": "1.3.72-9", "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -90,7 +90,7 @@ "react-native-linear-gradient": "^2.8.1", "react-native-localize": "^2.2.6", "react-native-modal": "^13.0.0", - "react-native-onyx": "1.0.78", + "react-native-onyx": "1.0.84", "react-native-pager-view": "^6.2.0", "react-native-pdf": "^6.7.1", "react-native-performance": "^5.1.0", @@ -41203,9 +41203,9 @@ } }, "node_modules/react-native-onyx": { - "version": "1.0.78", - "resolved": "https://registry.npmjs.org/react-native-onyx/-/react-native-onyx-1.0.78.tgz", - "integrity": "sha512-SxXr0AvFyXiZ4HYW4wBJA5YQgQzU4bSpLZ9ZvFhJ7Usmf65wYrVrmrJvQnMSeWJnMdyfoVGO1rLhoZHDwgqDIw==", + "version": "1.0.84", + "resolved": "https://registry.npmjs.org/react-native-onyx/-/react-native-onyx-1.0.84.tgz", + "integrity": "sha512-qQ+o+qS5ucZLbKbG5kI0UsC42N4h1Pprg/1D7PqjDeVanS3iUv33rT4fbrHuar77g0DSTA1/M8bC2WmYrShS9A==", "dependencies": { "ascii-table": "0.0.9", "fast-equals": "^4.0.3", @@ -77265,9 +77265,9 @@ } }, "react-native-onyx": { - "version": "1.0.78", - "resolved": "https://registry.npmjs.org/react-native-onyx/-/react-native-onyx-1.0.78.tgz", - "integrity": "sha512-SxXr0AvFyXiZ4HYW4wBJA5YQgQzU4bSpLZ9ZvFhJ7Usmf65wYrVrmrJvQnMSeWJnMdyfoVGO1rLhoZHDwgqDIw==", + "version": "1.0.84", + "resolved": "https://registry.npmjs.org/react-native-onyx/-/react-native-onyx-1.0.84.tgz", + "integrity": "sha512-qQ+o+qS5ucZLbKbG5kI0UsC42N4h1Pprg/1D7PqjDeVanS3iUv33rT4fbrHuar77g0DSTA1/M8bC2WmYrShS9A==", "requires": { "ascii-table": "0.0.9", "fast-equals": "^4.0.3", diff --git a/package.json b/package.json index 85c4289436fa..44b936c8c588 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "new.expensify", - "version": "1.3.72-6", + "version": "1.3.72-9", "author": "Expensify, Inc.", "homepage": "https://new.expensify.com", "description": "New Expensify is the next generation of Expensify: a reimagination of payments based atop a foundation of chat.", @@ -132,7 +132,7 @@ "react-native-linear-gradient": "^2.8.1", "react-native-localize": "^2.2.6", "react-native-modal": "^13.0.0", - "react-native-onyx": "1.0.78", + "react-native-onyx": "1.0.84", "react-native-pager-view": "^6.2.0", "react-native-pdf": "^6.7.1", "react-native-performance": "^5.1.0", diff --git a/patches/react-native-image-picker+5.1.0.patch b/patches/react-native-image-picker+5.1.0.patch new file mode 100644 index 000000000000..0defc430e669 --- /dev/null +++ b/patches/react-native-image-picker+5.1.0.patch @@ -0,0 +1,133 @@ +diff --git a/node_modules/react-native-image-picker/android/src/main/java/com/imagepicker/ImagePickerModuleImpl.java b/node_modules/react-native-image-picker/android/src/main/java/com/imagepicker/ImagePickerModuleImpl.java +index 89b69a8..d86ab1e 100644 +--- a/node_modules/react-native-image-picker/android/src/main/java/com/imagepicker/ImagePickerModuleImpl.java ++++ b/node_modules/react-native-image-picker/android/src/main/java/com/imagepicker/ImagePickerModuleImpl.java +@@ -29,6 +29,120 @@ public class ImagePickerModuleImpl implements ActivityEventListener { + public static final int REQUEST_LAUNCH_VIDEO_CAPTURE = 13002; + public static final int REQUEST_LAUNCH_LIBRARY = 13003; + ++ // Prevent svg images from being selected as they are not supported (Image component does not support them) ++ // and also because iOS does not allow them to be selected (for consistency). ++ // Since, we can't exclude a mime type, we instead allow all image mime types except 'image/svg+xml'. ++ // Image mime types are generated by merging the Android image mime type support and the IANA media-types lists. ++ // https://android.googlesource.com/platform/external/mime-support/+/main/mime.types#636 ++ // https://www.iana.org/assignments/media-types/media-types.xhtml#image ++ private static final String[] ALLOWED_IMAGE_MIME_TYPES = { ++ "image/aces", ++ "image/apng", ++ "image/avci", ++ "image/avcs", ++ "image/avif", ++ "image/bmp", ++ "image/cgm", ++ "image/dicom-rle", ++ "image/dpx", ++ "image/emf", ++ "image/example", ++ "image/fits", ++ "image/g3fax", ++ "image/gif", ++ "image/heic-sequence", ++ "image/heic", ++ "image/heif-sequence", ++ "image/heif", ++ "image/hej2k", ++ "image/hsj2", ++ "image/ief", ++ "image/j2c", ++ "image/jls", ++ "image/jp2", ++ "image/jpeg", ++ "image/jph", ++ "image/jphc", ++ "image/jpm", ++ "image/jpx", ++ "image/jxr", ++ "image/jxrA", ++ "image/jxrS", ++ "image/jxs", ++ "image/jxsc", ++ "image/jxsi", ++ "image/jxss", ++ "image/ktx", ++ "image/ktx2", ++ "image/naplps", ++ "image/pcx", ++ "image/png", ++ "image/prs.btif", ++ "image/prs.pti", ++ "image/pwg-raster", ++ // "image/svg+xml", ++ "image/t38", ++ "image/tiff-fx", ++ "image/tiff", ++ "image/vnd.adobe.photoshop", ++ "image/vnd.airzip.accelerator.azv", ++ "image/vnd.cns.inf2", ++ "image/vnd.dece.graphic", ++ "image/vnd.djvu", ++ "image/vnd.dvb.subtitle", ++ "image/vnd.dwg", ++ "image/vnd.dxf", ++ "image/vnd.fastbidsheet", ++ "image/vnd.fpx", ++ "image/vnd.fst", ++ "image/vnd.fujixerox.edmics-mmr", ++ "image/vnd.fujixerox.edmics-rlc", ++ "image/vnd.globalgraphics.pgb", ++ "image/vnd.microsoft.icon", ++ "image/vnd.mix", ++ "image/vnd.mozilla.apng", ++ "image/vnd.ms-modi", ++ "image/vnd.net-fpx", ++ "image/vnd.pco.b16", ++ "image/vnd.radiance", ++ "image/vnd.sealed.png", ++ "image/vnd.sealedmedia.softseal.gif", ++ "image/vnd.sealedmedia.softseal.jpg", ++ "image/vnd.svf", ++ "image/vnd.tencent.tap", ++ "image/vnd.valve.source.texture", ++ "image/vnd.wap.wbmp", ++ "image/vnd.xiff", ++ "image/vnd.zbrush.pcx", ++ "image/webp", ++ "image/wmf", ++ "image/x-canon-cr2", ++ "image/x-canon-crw", ++ "image/x-cmu-raster", ++ "image/x-coreldraw", ++ "image/x-coreldrawpattern", ++ "image/x-coreldrawtemplate", ++ "image/x-corelphotopaint", ++ "image/x-emf", ++ "image/x-epson-erf", ++ "image/x-icon", ++ "image/x-jg", ++ "image/x-jng", ++ "image/x-ms-bmp", ++ "image/x-nikon-nef", ++ "image/x-olympus-orf", ++ "image/x-photoshop", ++ "image/x-portable-anymap", ++ "image/x-portable-bitmap", ++ "image/x-portable-graymap", ++ "image/x-portable-pixmap", ++ "image/x-rgb", ++ "image/x-wmf", ++ "image/x-xbitmap", ++ "image/x-xpixmap", ++ "image/x-xwindowdump", ++ }; ++ + private Uri fileUri; + + private ReactApplicationContext reactContext; +@@ -148,6 +262,7 @@ public class ImagePickerModuleImpl implements ActivityEventListener { + + if (isPhoto) { + libraryIntent.setType("image/*"); ++ libraryIntent.putExtra(Intent.EXTRA_MIME_TYPES, this.ALLOWED_IMAGE_MIME_TYPES); + } else if (isVideo) { + libraryIntent.setType("video/*"); + } else if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { diff --git a/src/CONST.ts b/src/CONST.ts index ceb0617182ad..eed1b98ae551 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -1163,6 +1163,7 @@ const CONST = { }, AVATAR_SIZE: { + XLARGE: 'xlarge', LARGE: 'large', MEDIUM: 'medium', DEFAULT: 'default', @@ -1311,9 +1312,9 @@ const CONST = { }, // Auth limit is 60k for the column but we store edits and other metadata along the html so let's use a lower limit to accommodate for it. - MAX_COMMENT_LENGTH: 15000, + MAX_COMMENT_LENGTH: 10000, - // Furthermore, applying markup is very resource-consuming, so let's set a slightly lower limit for that + // Use the same value as MAX_COMMENT_LENGTH to ensure the entire comment is parsed. Note that applying markup is very resource-consuming. MAX_MARKUP_LENGTH: 10000, MAX_THREAD_REPLIES_PREVIEW: 99, @@ -2639,6 +2640,7 @@ const CONST = { INDENTS: ' ', PARENT_CHILD_SEPARATOR: ': ', CATEGORY_LIST_THRESHOLD: 8, + TAG_LIST_THRESHOLD: 8, DEMO_PAGES: { SAASTR: 'SaaStrDemoSetup', SBE: 'SbeDemoSetup', diff --git a/src/Expensify.js b/src/Expensify.js index fba65e42c06c..9e6ae1ff27b4 100644 --- a/src/Expensify.js +++ b/src/Expensify.js @@ -99,7 +99,9 @@ function Expensify(props) { const [hasAttemptedToOpenPublicRoom, setAttemptedToOpenPublicRoom] = useState(false); useEffect(() => { - if (props.isCheckingPublicRoom) return; + if (props.isCheckingPublicRoom) { + return; + } setAttemptedToOpenPublicRoom(true); }, [props.isCheckingPublicRoom]); diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index ae8c2037a8e3..05256f2b806c 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -362,7 +362,6 @@ type OnyxValues = { [ONYXKEYS.LAST_OPENED_PUBLIC_ROOM_ID]: string; [ONYXKEYS.PREFERRED_THEME]: ValueOf; [ONYXKEYS.IS_USING_MEMORY_ONLY_KEYS]: boolean; - [ONYXKEYS.RECEIPT_MODAL]: OnyxTypes.ReceiptModal; [ONYXKEYS.MAPBOX_ACCESS_TOKEN]: OnyxTypes.MapboxAccessToken; [ONYXKEYS.ONYX_UPDATES_FROM_SERVER]: OnyxTypes.OnyxUpdatesFromServer; [ONYXKEYS.ONYX_UPDATES_LAST_UPDATE_ID_APPLIED_TO_CLIENT]: number; diff --git a/src/SCREENS.ts b/src/SCREENS.ts index 47935e117e99..eb125a43c239 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -12,8 +12,14 @@ export default { VALIDATE_LOGIN: 'ValidateLogin', CONCIERGE: 'Concierge', SETTINGS: { + ROOT: 'Settings_Root', PREFERENCES: 'Settings_Preferences', WORKSPACES: 'Settings_Workspaces', + SECURITY: 'Settings_Security', + STATUS: 'Settings_Status', + }, + SAVE_THE_WORLD: { + ROOT: 'SaveTheWorld_Root', }, SIGN_IN_WITH_APPLE_DESKTOP: 'AppleSignInDesktop', SIGN_IN_WITH_GOOGLE_DESKTOP: 'GoogleSignInDesktop', diff --git a/src/components/AddressSearch/index.js b/src/components/AddressSearch/index.js index bd6492b4237b..1b4200572664 100644 --- a/src/components/AddressSearch/index.js +++ b/src/components/AddressSearch/index.js @@ -34,7 +34,7 @@ const propTypes = { onBlur: PropTypes.func, /** Error text to display */ - errorText: PropTypes.string, + errorText: PropTypes.oneOfType([PropTypes.string, PropTypes.arrayOf(PropTypes.oneOfType([PropTypes.string, PropTypes.object]))]), /** Hint text to display */ hint: PropTypes.string, diff --git a/src/components/Attachments/AttachmentCarousel/AttachmentCarouselCellRenderer.js b/src/components/Attachments/AttachmentCarousel/AttachmentCarouselCellRenderer.js new file mode 100644 index 000000000000..2c698d5c8a61 --- /dev/null +++ b/src/components/Attachments/AttachmentCarousel/AttachmentCarouselCellRenderer.js @@ -0,0 +1,34 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import {View, PixelRatio} from 'react-native'; +import useWindowDimensions from '../../../hooks/useWindowDimensions'; +import styles from '../../../styles/styles'; + +const propTypes = { + /** Cell Container styles */ + style: PropTypes.oneOfType([PropTypes.arrayOf(PropTypes.object), PropTypes.object]), +}; + +const defaultProps = { + style: [], +}; + +function AttachmentCarouselCellRenderer(props) { + const {windowWidth, isSmallScreenWidth} = useWindowDimensions(); + const modalStyles = styles.centeredModalStyles(isSmallScreenWidth, true); + const style = [props.style, styles.h100, {width: PixelRatio.roundToNearestPixel(windowWidth - (modalStyles.marginHorizontal + modalStyles.borderWidth) * 2)}]; + + return ( + + ); +} + +AttachmentCarouselCellRenderer.propTypes = propTypes; +AttachmentCarouselCellRenderer.defaultProps = defaultProps; +AttachmentCarouselCellRenderer.displayName = 'AttachmentCarouselCellRenderer'; + +export default React.memo(AttachmentCarouselCellRenderer); diff --git a/src/components/Attachments/AttachmentCarousel/Pager/AttachmentCarouselPage.js b/src/components/Attachments/AttachmentCarousel/Pager/AttachmentCarouselPage.js index b1a844e4172d..adee75cb4fa9 100644 --- a/src/components/Attachments/AttachmentCarousel/Pager/AttachmentCarouselPage.js +++ b/src/components/Attachments/AttachmentCarousel/Pager/AttachmentCarouselPage.js @@ -41,8 +41,11 @@ function AttachmentCarouselPage({source, isAuthTokenRequired, isActive: initialI // to prevent the image transformer from flashing while still rendering // Instead, we show the fallback image while the image transformer is loading the image useEffect(() => { - if (initialIsActive) setTimeout(() => setIsActive(true), 1); - else setIsActive(false); + if (initialIsActive) { + setTimeout(() => setIsActive(true), 1); + } else { + setIsActive(false); + } }, [initialIsActive]); const [initialActivePageLoad, setInitialActivePageLoad] = useState(isActive); @@ -51,8 +54,11 @@ function AttachmentCarouselPage({source, isAuthTokenRequired, isActive: initialI // We delay hiding the fallback image while image transformer is still rendering useEffect(() => { - if (isImageLoading) setShowFallback(true); - else setTimeout(() => setShowFallback(false), 100); + if (isImageLoading) { + setShowFallback(true); + } else { + setTimeout(() => setShowFallback(false), 100); + } }, [isImageLoading]); return ( @@ -127,7 +133,9 @@ function AttachmentCarouselPage({source, isAuthTokenRequired, isActive: initialI const scaledImageHeight = imageHeight * minImageScale; // Don't update the dimensions if they are already set - if (dimensions?.scaledImageWidth === scaledImageWidth && dimensions?.scaledImageHeight === scaledImageHeight) return; + if (dimensions?.scaledImageWidth === scaledImageWidth && dimensions?.scaledImageHeight === scaledImageHeight) { + return; + } cachedDimensions.set(source, { ...dimensions, diff --git a/src/components/Attachments/AttachmentCarousel/Pager/ImageTransformer.js b/src/components/Attachments/AttachmentCarousel/Pager/ImageTransformer.js index 4475df168df2..b1c2864a05f6 100644 --- a/src/components/Attachments/AttachmentCarousel/Pager/ImageTransformer.js +++ b/src/components/Attachments/AttachmentCarousel/Pager/ImageTransformer.js @@ -306,7 +306,9 @@ function ImageTransformer({imageWidth, imageHeight, imageScaleX, imageScaleY, sc stopAnimation(); }) .onFinalize((evt, success) => { - if (!success || !onTap) return; + if (!success || !onTap) { + return; + } runOnJS(onTap)(); }); @@ -432,7 +434,9 @@ function ImageTransformer({imageWidth, imageHeight, imageScaleX, imageScaleY, sc const pinchGesture = Gesture.Pinch() .onTouchesDown((evt, state) => { // we don't want to activate pinch gesture when we are scrolling pager - if (!isScrolling.value) return; + if (!isScrolling.value) { + return; + } state.fail(); }) diff --git a/src/components/Attachments/AttachmentCarousel/index.js b/src/components/Attachments/AttachmentCarousel/index.js index 574cb496d02f..00b603cdd7d9 100644 --- a/src/components/Attachments/AttachmentCarousel/index.js +++ b/src/components/Attachments/AttachmentCarousel/index.js @@ -3,6 +3,7 @@ import {View, FlatList, PixelRatio, Keyboard} from 'react-native'; import {withOnyx} from 'react-native-onyx'; import _ from 'underscore'; import styles from '../../../styles/styles'; +import AttachmentCarouselCellRenderer from './AttachmentCarouselCellRenderer'; import CarouselActions from './CarouselActions'; import withWindowDimensions from '../../withWindowDimensions'; import CarouselButtons from './CarouselButtons'; @@ -12,7 +13,6 @@ import ONYXKEYS from '../../../ONYXKEYS'; import withLocalize from '../../withLocalize'; import compose from '../../../libs/compose'; import useCarouselArrows from './useCarouselArrows'; -import useWindowDimensions from '../../../hooks/useWindowDimensions'; import CarouselItem from './CarouselItem'; import Navigation from '../../../libs/Navigation/Navigation'; import BlockingView from '../../BlockingViews/BlockingView'; @@ -30,7 +30,6 @@ const viewabilityConfig = { function AttachmentCarousel({report, reportActions, source, onNavigate, setDownloadButtonVisibility, translate}) { const scrollRef = useRef(null); - const {windowWidth, isSmallScreenWidth} = useWindowDimensions(); const canUseTouchScreen = DeviceCapabilities.canUseTouchScreen(); const [containerWidth, setContainerWidth] = useState(0); @@ -67,7 +66,9 @@ function AttachmentCarousel({report, reportActions, source, onNavigate, setDownl setDownloadButtonVisibility(initialPage !== -1); // Update the parent modal's state with the source and name from the mapped attachments - if (!_.isUndefined(attachmentsFromReport[initialPage])) onNavigate(attachmentsFromReport[initialPage]); + if (!_.isUndefined(attachmentsFromReport[initialPage])) { + onNavigate(attachmentsFromReport[initialPage]); + } } // eslint-disable-next-line react-hooks/exhaustive-deps }, [reportActions, compareImage]); @@ -130,29 +131,6 @@ function AttachmentCarousel({report, reportActions, source, onNavigate, setDownl [containerWidth], ); - /** - * Defines how a container for a single attachment should be rendered - * @param {Object} cellRendererProps - * @returns {JSX.Element} - */ - const renderCell = useCallback( - (cellProps) => { - // Use window width instead of layout width to address the issue in https://github.com/Expensify/App/issues/17760 - // considering horizontal margin and border width in centered modal - const modalStyles = styles.centeredModalStyles(isSmallScreenWidth, true); - const style = [cellProps.style, styles.h100, {width: PixelRatio.roundToNearestPixel(windowWidth - (modalStyles.marginHorizontal + modalStyles.borderWidth) * 2)}]; - - return ( - - ); - }, - [isSmallScreenWidth, windowWidth], - ); - /** * Defines how a single attachment should be rendered * @param {Object} item @@ -224,7 +202,7 @@ function AttachmentCarousel({report, reportActions, source, onNavigate, setDownl windowSize={5} maxToRenderPerBatch={3} data={attachments} - CellRendererComponent={renderCell} + CellRendererComponent={AttachmentCarouselCellRenderer} renderItem={renderItem} getItemLayout={getItemLayout} keyExtractor={(item) => item.source} diff --git a/src/components/Attachments/AttachmentCarousel/index.native.js b/src/components/Attachments/AttachmentCarousel/index.native.js index a7a2f35a2ccc..bd12020341be 100644 --- a/src/components/Attachments/AttachmentCarousel/index.native.js +++ b/src/components/Attachments/AttachmentCarousel/index.native.js @@ -56,7 +56,9 @@ function AttachmentCarousel({report, reportActions, source, onNavigate, onClose, setDownloadButtonVisibility(initialPage !== -1); // Update the parent modal's state with the source and name from the mapped attachments - if (!_.isUndefined(attachmentsFromReport[initialPage])) onNavigate(attachmentsFromReport[initialPage]); + if (!_.isUndefined(attachmentsFromReport[initialPage])) { + onNavigate(attachmentsFromReport[initialPage]); + } } // eslint-disable-next-line react-hooks/exhaustive-deps }, [reportActions, compareImage]); @@ -148,7 +150,9 @@ function AttachmentCarousel({report, reportActions, source, onNavigate, onClose, onPageSelected={({nativeEvent: {position: newPage}}) => updatePage(newPage)} onPinchGestureChange={(newIsPinchGestureRunning) => { setIsPinchGestureRunning(newIsPinchGestureRunning); - if (!newIsPinchGestureRunning && !shouldShowArrows) setShouldShowArrows(true); + if (!newIsPinchGestureRunning && !shouldShowArrows) { + setShouldShowArrows(true); + } }} onSwipeDown={onClose} containerWidth={containerDimensions.width} diff --git a/src/components/Attachments/AttachmentView/AttachmentViewPdf/index.native.js b/src/components/Attachments/AttachmentView/AttachmentViewPdf/index.native.js index 0767b2b68985..fdf151c4d5d0 100644 --- a/src/components/Attachments/AttachmentView/AttachmentViewPdf/index.native.js +++ b/src/components/Attachments/AttachmentView/AttachmentViewPdf/index.native.js @@ -25,7 +25,9 @@ function AttachmentViewPdf({file, encryptedSourceUrl, isFocused, isUsedInCarouse attachmentCarouselPagerContext.onPinchGestureChange(!shouldPagerScroll); - if (attachmentCarouselPagerContext.shouldPagerScroll.value === shouldPagerScroll) return; + if (attachmentCarouselPagerContext.shouldPagerScroll.value === shouldPagerScroll) { + return; + } attachmentCarouselPagerContext.shouldPagerScroll.value = shouldPagerScroll; } diff --git a/src/components/CategoryPicker/index.js b/src/components/CategoryPicker/index.js index da7b77ca193e..90f72f183815 100644 --- a/src/components/CategoryPicker/index.js +++ b/src/components/CategoryPicker/index.js @@ -43,7 +43,7 @@ function CategoryPicker({selectedCategory, policyCategories, policyRecentlyUsedC }, [policyCategories, selectedOptions, isCategoriesCountBelowThreshold]); const sections = useMemo( - () => OptionsListUtils.getNewChatOptions({}, {}, [], searchValue, selectedOptions, [], false, false, true, policyCategories, policyRecentlyUsedCategories, false).categoryOptions, + () => OptionsListUtils.getFilteredOptions({}, {}, [], searchValue, selectedOptions, [], false, false, true, policyCategories, policyRecentlyUsedCategories, false).categoryOptions, [policyCategories, policyRecentlyUsedCategories, searchValue, selectedOptions], ); diff --git a/src/components/Composer/index.android.js b/src/components/Composer/index.android.js index d0805cbcc7c3..1132efa9e50e 100644 --- a/src/components/Composer/index.android.js +++ b/src/components/Composer/index.android.js @@ -97,7 +97,9 @@ function Composer({shouldClear, onClear, isDisabled, maxLines, forwardedRef, isC * @return {Number} */ const maxNumberOfLines = useMemo(() => { - if (isComposerFullSize) return 1000000; + if (isComposerFullSize) { + return 1000000; + } return maxLines; }, [isComposerFullSize, maxLines]); diff --git a/src/components/Composer/index.ios.js b/src/components/Composer/index.ios.js index c0a3859e6d01..0b2c93f6639e 100644 --- a/src/components/Composer/index.ios.js +++ b/src/components/Composer/index.ios.js @@ -97,7 +97,9 @@ function Composer({shouldClear, onClear, isDisabled, maxLines, forwardedRef, isC * @return {Number} */ const maxNumberOfLines = useMemo(() => { - if (isComposerFullSize) return undefined; + if (isComposerFullSize) { + return; + } return maxLines; }, [isComposerFullSize, maxLines]); diff --git a/src/components/CurrentUserPersonalDetailsSkeletonView/index.js b/src/components/CurrentUserPersonalDetailsSkeletonView/index.js index 6e6c46e971c0..cc305a628820 100644 --- a/src/components/CurrentUserPersonalDetailsSkeletonView/index.js +++ b/src/components/CurrentUserPersonalDetailsSkeletonView/index.js @@ -1,5 +1,6 @@ import React from 'react'; import PropTypes from 'prop-types'; +import _ from 'underscore'; import SkeletonViewContentLoader from 'react-content-loader/native'; import {Circle, Rect} from 'react-native-svg'; import {View} from 'react-native'; @@ -12,14 +13,26 @@ import styles from '../../styles/styles'; const propTypes = { /** Whether to animate the skeleton view */ shouldAnimate: PropTypes.bool, + + /** The size of the avatar */ + avatarSize: PropTypes.oneOf(_.values(CONST.AVATAR_SIZE)), + + /** Background color of the skeleton view */ + backgroundColor: PropTypes.string, + + /** Foreground color of the skeleton view */ + foregroundColor: PropTypes.string, }; const defaultProps = { shouldAnimate: true, + avatarSize: CONST.AVATAR_SIZE.LARGE, + backgroundColor: themeColors.highlightBG, + foregroundColor: themeColors.border, }; function CurrentUserPersonalDetailsSkeletonView(props) { - const avatarPlaceholderSize = StyleUtils.getAvatarSize(CONST.AVATAR_SIZE.LARGE); + const avatarPlaceholderSize = StyleUtils.getAvatarSize(props.avatarSize); const avatarPlaceholderRadius = avatarPlaceholderSize / 2; const spaceBetweenAvatarAndHeadline = styles.mb3.marginBottom + styles.mt1.marginTop + (variables.lineHeightXXLarge - variables.fontSizeXLarge) / 2; const headlineSize = variables.fontSizeXLarge; @@ -29,8 +42,8 @@ function CurrentUserPersonalDetailsSkeletonView(props) { Transaction.addStop(iou.transactionID)} + onPress={() => { + const newIndex = _.size(lodashGet(transaction, 'comment.waypoints', {})); + Navigation.navigate(ROUTES.getMoneyRequestWaypointRoute('request', newIndex)); + }} text={translate('distance.addStop')} isDisabled={numberOfWaypoints === MAX_WAYPOINTS} innerStyles={[styles.ph10]} @@ -294,7 +297,7 @@ function DistanceRequest({iou, iouType, report, transaction, mapboxAccessToken, success style={[styles.w100, styles.mb4, styles.ph4, styles.flexShrink0]} onPress={navigateToNextPage} - isDisabled={_.size(validatedWaypoints) < 2 || hasRouteError || isOffline} + isDisabled={_.size(validatedWaypoints) < 2 || hasRouteError} text={translate('common.next')} /> diff --git a/src/components/EmojiPicker/EmojiPickerMenu/index.js b/src/components/EmojiPicker/EmojiPickerMenu/index.js index d3268ebc54b0..27fd199a3895 100755 --- a/src/components/EmojiPicker/EmojiPickerMenu/index.js +++ b/src/components/EmojiPicker/EmojiPickerMenu/index.js @@ -104,7 +104,9 @@ class EmojiPickerMenu extends Component { } componentDidUpdate(prevProps) { - if (prevProps.frequentlyUsedEmojis === this.props.frequentlyUsedEmojis) return; + if (prevProps.frequentlyUsedEmojis === this.props.frequentlyUsedEmojis) { + return; + } const {filteredEmojis, headerEmojis, headerRowIndices} = this.getEmojisAndHeaderRowIndices(); this.emojis = filteredEmojis; diff --git a/src/components/ExceededCommentLength.js b/src/components/ExceededCommentLength.js index 2aa50779e10f..7c9ec4d2db25 100644 --- a/src/components/ExceededCommentLength.js +++ b/src/components/ExceededCommentLength.js @@ -4,6 +4,7 @@ import {debounce} from 'lodash'; import {withOnyx} from 'react-native-onyx'; import CONST from '../CONST'; import * as ReportUtils from '../libs/ReportUtils'; +import useLocalize from '../hooks/useLocalize'; import Text from './Text'; import styles from '../styles/styles'; import ONYXKEYS from '../ONYXKEYS'; @@ -25,6 +26,7 @@ const defaultProps = { }; function ExceededCommentLength(props) { + const {numberFormat, translate} = useLocalize(); const [commentLength, setCommentLength] = useState(0); const updateCommentLength = useMemo( () => @@ -44,7 +46,14 @@ function ExceededCommentLength(props) { return null; } - return {`${commentLength}/${CONST.MAX_COMMENT_LENGTH}`}; + return ( + + {translate('composer.commentExceededMaxLength', {formattedMaxLength: numberFormat(CONST.MAX_COMMENT_LENGTH)})} + + ); } ExceededCommentLength.propTypes = propTypes; diff --git a/src/components/StaticHeaderPageLayout.js b/src/components/HeaderPageLayout.js similarity index 53% rename from src/components/StaticHeaderPageLayout.js rename to src/components/HeaderPageLayout.js index f97e42329942..bec1e52b1cad 100644 --- a/src/components/StaticHeaderPageLayout.js +++ b/src/components/HeaderPageLayout.js @@ -10,6 +10,8 @@ import themeColors from '../styles/themes/default'; import * as StyleUtils from '../styles/StyleUtils'; import useWindowDimensions from '../hooks/useWindowDimensions'; import FixedFooter from './FixedFooter'; +import useNetwork from '../hooks/useNetwork'; +import * as Browser from '../libs/Browser'; const propTypes = { ...headerWithBackButtonPropTypes, @@ -22,16 +24,26 @@ const propTypes = { /** A fixed footer to display at the bottom of the page. */ footer: PropTypes.node, + + /** The image to display in the upper half of the screen. */ + header: PropTypes.node, + + /** Style to apply to the header image container */ + // eslint-disable-next-line react/forbid-prop-types + headerContainerStyles: PropTypes.arrayOf(PropTypes.object), }; const defaultProps = { backgroundColor: themeColors.appBG, + header: null, + headerContainerStyles: [], footer: null, }; -function StaticHeaderPageLayout({backgroundColor, children, image: Image, footer, imageContainerStyle, style, ...propsToPassToHeader}) { - const {windowHeight} = useWindowDimensions(); - +function HeaderPageLayout({backgroundColor, children, footer, headerContainerStyles, style, headerContent, ...propsToPassToHeader}) { + const {windowHeight, isSmallScreenWidth} = useWindowDimensions(); + const {isOffline} = useNetwork(); + const appBGColor = StyleUtils.getBackgroundColorStyle(themeColors.appBG); const {titleColor, iconFill} = useMemo(() => { const isColorfulBackground = backgroundColor !== themeColors.appBG; return { @@ -45,7 +57,7 @@ function StaticHeaderPageLayout({backgroundColor, children, image: Image, footer style={[StyleUtils.getBackgroundColorStyle(backgroundColor)]} shouldEnablePickerAvoiding={false} includeSafeAreaPaddingBottom={false} - offlineIndicatorStyle={[StyleUtils.getBackgroundColorStyle(themeColors.appBG)]} + offlineIndicatorStyle={[appBGColor]} > {({safeAreaPaddingBottomStyle}) => ( <> @@ -55,27 +67,24 @@ function StaticHeaderPageLayout({backgroundColor, children, image: Image, footer titleColor={titleColor} iconFill={iconFill} /> - + + {/** Safari on ios/mac has a bug where overscrolling the page scrollview shows green background color. This is a workaround to fix that. https://github.com/Expensify/App/issues/23422 */} + {Browser.isSafari() && ( + + + + + )} - - - + {!Browser.isSafari() && } + + {headerContent} - {children} + {children} {!_.isNull(footer) && {footer}} @@ -85,8 +94,8 @@ function StaticHeaderPageLayout({backgroundColor, children, image: Image, footer ); } -StaticHeaderPageLayout.propTypes = propTypes; -StaticHeaderPageLayout.defaultProps = defaultProps; -StaticHeaderPageLayout.displayName = 'StaticHeaderPageLayout'; +HeaderPageLayout.propTypes = propTypes; +HeaderPageLayout.defaultProps = defaultProps; +HeaderPageLayout.displayName = 'HeaderPageLayout'; -export default StaticHeaderPageLayout; +export default HeaderPageLayout; diff --git a/src/components/Hoverable/index.js b/src/components/Hoverable/index.js index 5da41f1388fb..7dd918f15cf4 100644 --- a/src/components/Hoverable/index.js +++ b/src/components/Hoverable/index.js @@ -91,7 +91,9 @@ class Hoverable extends Component { /** * If the isScrollingRef is true, then the user is scrolling and we should not update the hover state. */ - if (this.isScrollingRef && this.props.shouldHandleScroll && !this.state.isHovered) return; + if (this.isScrollingRef && this.props.shouldHandleScroll && !this.state.isHovered) { + return; + } if (isHovered !== this.state.isHovered) { this.setState({isHovered}, isHovered ? this.props.onHoverIn : this.props.onHoverOut); diff --git a/src/components/IllustratedHeaderPageLayout.js b/src/components/IllustratedHeaderPageLayout.js index 92a9c8b8552b..ac916117094b 100644 --- a/src/components/IllustratedHeaderPageLayout.js +++ b/src/components/IllustratedHeaderPageLayout.js @@ -1,18 +1,10 @@ -import _ from 'underscore'; import React from 'react'; import PropTypes from 'prop-types'; -import {ScrollView, View} from 'react-native'; import Lottie from 'lottie-react-native'; import headerWithBackButtonPropTypes from './HeaderWithBackButton/headerWithBackButtonPropTypes'; -import HeaderWithBackButton from './HeaderWithBackButton'; -import ScreenWrapper from './ScreenWrapper'; import styles from '../styles/styles'; import themeColors from '../styles/themes/default'; -import * as StyleUtils from '../styles/StyleUtils'; -import useWindowDimensions from '../hooks/useWindowDimensions'; -import FixedFooter from './FixedFooter'; -import useNetwork from '../hooks/useNetwork'; -import * as Browser from '../libs/Browser'; +import HeaderPageLayout from './HeaderPageLayout'; const propTypes = { ...headerWithBackButtonPropTypes, @@ -40,54 +32,28 @@ const defaultProps = { }; function IllustratedHeaderPageLayout({backgroundColor, children, illustration, footer, overlayContent, ...propsToPassToHeader}) { - const {isOffline} = useNetwork(); - const {isSmallScreenWidth, windowHeight} = useWindowDimensions(); - const appBGColor = StyleUtils.getBackgroundColorStyle(themeColors.appBG); - return ( - - {({safeAreaPaddingBottomStyle}) => ( + - - - {/* Safari on ios/mac has a bug where overscrolling the page scrollview shows green the background color. This is a workaround to fix that. https://github.com/Expensify/App/issues/23422 */} - {Browser.isSafari() && ( - - - - - )} - - {!Browser.isSafari() && } - - - {overlayContent && overlayContent()} - - {children} - - {!_.isNull(footer) && {footer}} - + {overlayContent && overlayContent()} - )} - + } + headerContainerStyles={[styles.justifyContentCenter, styles.w100]} + footer={footer} + // eslint-disable-next-line react/jsx-props-no-spreading + {...propsToPassToHeader} + > + {children} + ); } diff --git a/src/components/ImageView/index.js b/src/components/ImageView/index.js index c92bd7738253..e0dce180043b 100644 --- a/src/components/ImageView/index.js +++ b/src/components/ImageView/index.js @@ -87,7 +87,9 @@ function ImageView({isAuthTokenRequired, url, fileName}) { }; const imageLoadingStart = () => { - if (!isLoading) return; + if (!isLoading) { + return; + } setIsLoading(true); setZoomScale(0); setIsZoomed(false); @@ -141,11 +143,16 @@ function ImageView({isAuthTokenRequired, url, fileName}) { */ const onContainerPress = (e) => { if (!isZoomed && !isDragging) { - const {offsetX, offsetY} = e.nativeEvent; - // Dividing clicked positions by the zoom scale to get coordinates - // so that once we zoom we will scroll to the clicked location. - const delta = getScrollOffset(offsetX / zoomScale, offsetY / zoomScale); - setZoomDelta(delta); + if (e.nativeEvent) { + const {offsetX, offsetY} = e.nativeEvent; + + // Dividing clicked positions by the zoom scale to get coordinates + // so that once we zoom we will scroll to the clicked location. + const delta = getScrollOffset(offsetX / zoomScale, offsetY / zoomScale); + setZoomDelta(delta); + } else { + setZoomDelta({offsetX: 0, offsetY: 0}); + } } if (isZoomed && isDragging && isMouseDown) { @@ -225,14 +232,14 @@ function ImageView({isAuthTokenRequired, url, fileName}) { source={{uri: url}} isAuthTokenRequired={isAuthTokenRequired} // Hide image until finished loading to prevent showing preview with wrong dimensions. - style={isLoading ? undefined : [styles.w100, styles.h100]} + style={isLoading || zoomScale === 0 ? undefined : [styles.w100, styles.h100]} // When Image dimensions are lower than the container boundary(zoomscale <= 1), use `contain` to render the image with natural dimensions. // Both `center` and `contain` keeps the image centered on both x and y axis. resizeMode={zoomScale > 1 ? Image.resizeMode.center : Image.resizeMode.contain} onLoadStart={imageLoadingStart} onLoad={imageLoad} /> - {isLoading && } + {(isLoading || zoomScale === 0) && } ); } diff --git a/src/components/MoneyRequestConfirmationList.js b/src/components/MoneyRequestConfirmationList.js index 531bbdad3977..13471407914f 100755 --- a/src/components/MoneyRequestConfirmationList.js +++ b/src/components/MoneyRequestConfirmationList.js @@ -207,7 +207,7 @@ function MoneyRequestConfirmationList(props) { const tagListName = lodashGet(props.policyTags, [tagListKey, 'name'], ''); const canUseTags = Permissions.canUseTags(props.betas); // A flag for showing the tags field - const shouldShowTags = isPolicyExpenseChat && canUseTags && !_.isEmpty(tagList); + const shouldShowTags = isPolicyExpenseChat && canUseTags && _.any(tagList, (tag) => tag.enabled); // A flag for showing the billable field const shouldShowBillable = canUseTags && !lodashGet(props.policy, 'disabledFields.defaultBillable', true); diff --git a/src/components/Pressable/PressableWithFeedback.js b/src/components/Pressable/PressableWithFeedback.js index 7eb0ee7286c9..40be99823ceb 100644 --- a/src/components/Pressable/PressableWithFeedback.js +++ b/src/components/Pressable/PressableWithFeedback.js @@ -58,19 +58,27 @@ const PressableWithFeedback = forwardRef((props, ref) => { isExecuting={isExecuting} onHoverIn={() => { setIsHovered(true); - if (props.onHoverIn) props.onHoverIn(); + if (props.onHoverIn) { + props.onHoverIn(); + } }} onHoverOut={() => { setIsHovered(false); - if (props.onHoverOut) props.onHoverOut(); + if (props.onHoverOut) { + props.onHoverOut(); + } }} onPressIn={() => { setIsPressed(true); - if (props.onPressIn) props.onPressIn(); + if (props.onPressIn) { + props.onPressIn(); + } }} onPressOut={() => { setIsPressed(false); - if (props.onPressOut) props.onPressOut(); + if (props.onPressOut) { + props.onPressOut(); + } }} onPress={(e) => { singleExecution(() => props.onPress(e))(); diff --git a/src/components/QRShare/QRShareWithDownload/index.js b/src/components/QRShare/QRShareWithDownload/index.js index 310122b96d40..665115823357 100644 --- a/src/components/QRShare/QRShareWithDownload/index.js +++ b/src/components/QRShare/QRShareWithDownload/index.js @@ -17,7 +17,9 @@ class QRShareWithDownload extends Component { return new Promise((resolve, reject) => { // eslint-disable-next-line es/no-optional-chaining const svg = this.qrShareRef.current?.getSvg(); - if (svg == null) return reject(); + if (svg == null) { + return reject(); + } svg.toDataURL((dataURL) => resolve(fileDownload(dataURL, getQrCodeFileName(this.props.title)))); }); diff --git a/src/components/ReportActionItem/MoneyRequestPreview.js b/src/components/ReportActionItem/MoneyRequestPreview.js index 3a0741cf9bd2..2afc6240f85d 100644 --- a/src/components/ReportActionItem/MoneyRequestPreview.js +++ b/src/components/ReportActionItem/MoneyRequestPreview.js @@ -170,7 +170,7 @@ function MoneyRequestPreview(props) { !_.isEmpty(requestMerchant) && !props.isBillSplit && requestMerchant !== CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT && requestMerchant !== CONST.TRANSACTION.DEFAULT_MERCHANT; const shouldShowDescription = !_.isEmpty(description) && !shouldShowMerchant; - const receiptImages = hasReceipt ? [ReceiptUtils.getThumbnailAndImageURIs(props.transaction.receipt.source, props.transaction.filename || props.transaction.receiptFilename || '')] : []; + const receiptImages = hasReceipt ? [ReceiptUtils.getThumbnailAndImageURIs(props.transaction.receipt.source, props.transaction.filename || '')] : []; const getSettledMessage = () => { switch (lodashGet(props.action, 'originalMessage.paymentType', '')) { diff --git a/src/components/ReportActionItem/ReportPreview.js b/src/components/ReportActionItem/ReportPreview.js index c14e40010b54..1350c62bda88 100644 --- a/src/components/ReportActionItem/ReportPreview.js +++ b/src/components/ReportActionItem/ReportPreview.js @@ -120,9 +120,7 @@ function ReportPreview(props) { const isScanning = hasReceipts && ReportUtils.areAllRequestsBeingSmartScanned(props.iouReportID, props.action); const hasErrors = hasReceipts && ReportUtils.hasMissingSmartscanFields(props.iouReportID); const lastThreeTransactionsWithReceipts = ReportUtils.getReportPreviewDisplayTransactions(props.action); - const lastThreeReceipts = _.map(lastThreeTransactionsWithReceipts, ({receipt, filename, receiptFilename}) => - ReceiptUtils.getThumbnailAndImageURIs(receipt.source, filename || receiptFilename || ''), - ); + const lastThreeReceipts = _.map(lastThreeTransactionsWithReceipts, ({receipt, filename}) => ReceiptUtils.getThumbnailAndImageURIs(receipt.source, filename || '')); const hasOnlyOneReceiptRequest = numberOfRequests === 1 && hasReceipts; const previewSubtitle = hasOnlyOneReceiptRequest diff --git a/src/components/ReportActionItem/TaskView.js b/src/components/ReportActionItem/TaskView.js index f2a1758a050b..ae77a18b980f 100644 --- a/src/components/ReportActionItem/TaskView.js +++ b/src/components/ReportActionItem/TaskView.js @@ -51,7 +51,8 @@ function TaskView(props) { const isOpen = ReportUtils.isOpenTaskReport(props.report); const isCanceled = ReportUtils.isCanceledTaskReport(props.report); const canModifyTask = Task.canModifyTask(props.report, props.currentUserPersonalDetails.accountID); - const disableState = !canModifyTask || !isOpen; + const disableState = !canModifyTask || isCanceled; + const isDisableInteractive = !canModifyTask || !isOpen; return ( ( { + if (isDisableInteractive) { + return; + } if (e && e.type === 'click') { e.currentTarget.blur(); } Navigation.navigate(ROUTES.getTaskReportTitleRoute(props.report.reportID)); })} - style={({pressed}) => [styles.ph5, styles.pv2, StyleUtils.getButtonBackgroundColorStyle(getButtonState(hovered, pressed, false, disableState), true)]} + style={({pressed}) => [ + styles.ph5, + styles.pv2, + StyleUtils.getButtonBackgroundColorStyle(getButtonState(hovered, pressed, false, disableState, !isDisableInteractive), true), + isDisableInteractive && !disableState && styles.cursorDefault, + ]} ref={props.forwardedRef} disabled={disableState} accessibilityLabel={taskTitle || props.translate('task.task')} @@ -129,6 +138,7 @@ function TaskView(props) { wrapperStyle={[styles.pv2, styles.taskDescriptionMenuItem]} shouldGreyOutWhenDisabled={false} numberOfLinesTitle={0} + interactive={!isDisableInteractive} /> {props.report.managerID ? ( @@ -146,6 +156,7 @@ function TaskView(props) { wrapperStyle={[styles.pv2]} isSmallAvatarSubscriptMenu shouldGreyOutWhenDisabled={false} + interactive={!isDisableInteractive} /> ) : ( @@ -156,6 +167,7 @@ function TaskView(props) { disabled={disableState} wrapperStyle={[styles.pv2]} shouldGreyOutWhenDisabled={false} + interactive={!isDisableInteractive} /> )} diff --git a/src/components/SignInButtons/AppleSignIn/index.android.js b/src/components/SignInButtons/AppleSignIn/index.android.js index 83a99683b178..48d2bf3cc861 100644 --- a/src/components/SignInButtons/AppleSignIn/index.android.js +++ b/src/components/SignInButtons/AppleSignIn/index.android.js @@ -39,7 +39,9 @@ function AppleSignIn() { appleSignInRequest() .then((token) => Session.beginAppleSignIn(token)) .catch((e) => { - if (e.message === appleAuthAndroid.Error.SIGNIN_CANCELLED) return null; + if (e.message === appleAuthAndroid.Error.SIGNIN_CANCELLED) { + return null; + } Log.alert('[Apple Sign In] Apple authentication failed', e); }); }; diff --git a/src/components/SignInButtons/AppleSignIn/index.ios.js b/src/components/SignInButtons/AppleSignIn/index.ios.js index 681eebb298c5..0c9a8c9e8211 100644 --- a/src/components/SignInButtons/AppleSignIn/index.ios.js +++ b/src/components/SignInButtons/AppleSignIn/index.ios.js @@ -37,7 +37,9 @@ function AppleSignIn() { appleSignInRequest() .then((token) => Session.beginAppleSignIn(token)) .catch((e) => { - if (e.code === appleAuth.Error.CANCELED) return null; + if (e.code === appleAuth.Error.CANCELED) { + return null; + } Log.alert('[Apple Sign In] Apple authentication failed', e); }); }; diff --git a/src/components/SignInButtons/AppleSignIn/index.website.js b/src/components/SignInButtons/AppleSignIn/index.website.js index 41c8f2afd4d5..7046de5068b1 100644 --- a/src/components/SignInButtons/AppleSignIn/index.website.js +++ b/src/components/SignInButtons/AppleSignIn/index.website.js @@ -55,7 +55,9 @@ const successListener = (event) => { }; const failureListener = (event) => { - if (!event.detail || event.detail.error === 'popup_closed_by_user') return null; + if (!event.detail || event.detail.error === 'popup_closed_by_user') { + return null; + } Log.warn(`Apple sign-in failed: ${event.detail}`); }; @@ -126,7 +128,9 @@ const SingletonAppleSignInButtonWithFocus = withNavigationFocus(SingletonAppleSi function AppleSignIn({isDesktopFlow}) { const [scriptLoaded, setScriptLoaded] = useState(false); useEffect(() => { - if (window.appleAuthScriptLoaded) return; + if (window.appleAuthScriptLoaded) { + return; + } const localeCode = getUserLanguage(); const script = document.createElement('script'); diff --git a/src/components/TagPicker/index.js b/src/components/TagPicker/index.js index 25021bd817d7..c46ca1b57b22 100644 --- a/src/components/TagPicker/index.js +++ b/src/components/TagPicker/index.js @@ -1,62 +1,58 @@ -import React, {useMemo} from 'react'; +import React, {useMemo, useState} from 'react'; import _ from 'underscore'; import lodashGet from 'lodash/get'; import {withOnyx} from 'react-native-onyx'; +import CONST from '../../CONST'; import ONYXKEYS from '../../ONYXKEYS'; import styles from '../../styles/styles'; -import Navigation from '../../libs/Navigation/Navigation'; -import ROUTES from '../../ROUTES'; import useLocalize from '../../hooks/useLocalize'; import * as OptionsListUtils from '../../libs/OptionsListUtils'; import OptionsSelector from '../OptionsSelector'; import {propTypes, defaultProps} from './tagPickerPropTypes'; -function TagPicker({policyTags, reportID, tag, iouType, iou}) { +function TagPicker({selectedTag, tag, policyTags, policyRecentlyUsedTags, onSubmit}) { const {translate} = useLocalize(); + const [searchValue, setSearchValue] = useState(''); + + const policyRecentlyUsedTagsList = lodashGet(policyRecentlyUsedTags, tag, []); + const policyTagList = lodashGet(policyTags, [tag, 'tags'], {}); + const policyTagsCount = _.size(_.filter(policyTagList, (policyTag) => policyTag.enabled)); + const isTagsCountBelowThreshold = policyTagsCount < CONST.TAG_LIST_THRESHOLD; + + const shouldShowTextInput = !isTagsCountBelowThreshold; const selectedOptions = useMemo(() => { - if (!iou.tag) { + if (!selectedTag) { return []; } return [ { - name: iou.tag, + name: selectedTag, enabled: true, + accountID: null, }, ]; - }, [iou.tag]); - - // Only shows one section, which will be the default behavior if there are - // less than 8 policy tags - // TODO: support sections with search - const sections = useMemo(() => { - const tagList = _.chain(lodashGet(policyTags, [tag, 'tags'], {})) - .values() - .map((t) => ({ - text: t.name, - keyForList: t.name, - tooltipText: t.name, - })) - .value(); + }, [selectedTag]); - return [ - { - data: tagList, - }, - ]; - }, [policyTags, tag]); + const initialFocusedIndex = useMemo(() => { + if (isTagsCountBelowThreshold && selectedOptions.length > 0) { + return _.chain(policyTagList) + .values() + .findIndex((policyTag) => policyTag.name === selectedOptions[0].name, true) + .value(); + } - const headerMessage = OptionsListUtils.getHeaderMessage(lodashGet(sections, '[0].data.length', 0) > 0, false, ''); + return 0; + }, [policyTagList, selectedOptions, isTagsCountBelowThreshold]); - const navigateBack = () => { - Navigation.goBack(ROUTES.getMoneyRequestConfirmationRoute(iouType, reportID)); - }; + const sections = useMemo( + () => + OptionsListUtils.getFilteredOptions({}, {}, [], searchValue, selectedOptions, [], false, false, false, {}, [], true, policyTagList, policyRecentlyUsedTagsList, false).tagOptions, + [searchValue, selectedOptions, policyTagList, policyRecentlyUsedTagsList], + ); - const updateTag = () => { - // TODO: add logic to save the selected tag - navigateBack(); - }; + const headerMessage = OptionsListUtils.getHeaderMessage(lodashGet(sections, '[0].data.length', 0) > 0, false, ''); return ( ); } @@ -84,7 +84,4 @@ export default withOnyx({ policyRecentlyUsedTags: { key: ({policyID}) => `${ONYXKEYS.COLLECTION.POLICY_RECENTLY_USED_TAGS}${policyID}`, }, - iou: { - key: ONYXKEYS.IOU, - }, })(TagPicker); diff --git a/src/components/TagPicker/tagPickerPropTypes.js b/src/components/TagPicker/tagPickerPropTypes.js index ad57a0409f15..a5d94605a76a 100644 --- a/src/components/TagPicker/tagPickerPropTypes.js +++ b/src/components/TagPicker/tagPickerPropTypes.js @@ -1,22 +1,18 @@ import PropTypes from 'prop-types'; import tagPropTypes from '../tagPropTypes'; -import {iouPropTypes, iouDefaultProps} from '../../pages/iou/propTypes'; const propTypes = { - /** The report ID of the IOU */ - reportID: PropTypes.string.isRequired, - /** The policyID we are getting tags for */ policyID: PropTypes.string.isRequired, + /** The selected tag of the money request */ + selectedTag: PropTypes.string.isRequired, + /** The name of tag list we are getting tags for */ tag: PropTypes.string.isRequired, - /** The type of IOU report, i.e. bill, request, send */ - iouType: PropTypes.string.isRequired, - /** Callback to submit the selected tag */ - onSubmit: PropTypes.func, + onSubmit: PropTypes.func.isRequired, /* Onyx Props */ /** Collection of tags attached to a policy */ @@ -29,15 +25,11 @@ const propTypes = { /** List of recently used tags */ policyRecentlyUsedTags: PropTypes.objectOf(PropTypes.arrayOf(PropTypes.string)), - - /** Holds data related to Money Request view state, rather than the underlying Money Request data. */ - iou: iouPropTypes, }; const defaultProps = { policyTags: {}, policyRecentlyUsedTags: {}, - iou: iouDefaultProps, }; export {propTypes, defaultProps}; diff --git a/src/components/TextInput/index.js b/src/components/TextInput/index.js index 48a92f081200..6cefe04e71a1 100644 --- a/src/components/TextInput/index.js +++ b/src/components/TextInput/index.js @@ -30,7 +30,9 @@ function TextInput(props) { }); return () => { - if (!removeVisibilityListenerRef.current) return; + if (!removeVisibilityListenerRef.current) { + return; + } removeVisibilityListenerRef.current(); }; // eslint-disable-next-line react-hooks/exhaustive-deps diff --git a/src/components/withWindowDimensions/index.js b/src/components/withWindowDimensions/index.js index a3836fa99e6b..37d5c94688a2 100644 --- a/src/components/withWindowDimensions/index.js +++ b/src/components/withWindowDimensions/index.js @@ -1,5 +1,6 @@ import React, {forwardRef, createContext, useState, useEffect} from 'react'; import PropTypes from 'prop-types'; +import lodashDebounce from 'lodash/debounce'; import {Dimensions} from 'react-native'; import {SafeAreaInsetsContext} from 'react-native-safe-area-context'; import getComponentDisplayName from '../../libs/getComponentDisplayName'; @@ -44,14 +45,15 @@ function WindowDimensionsProvider(props) { useEffect(() => { const onDimensionChange = (newDimensions) => { const {window} = newDimensions; - setWindowDimension({ windowHeight: window.height, windowWidth: window.width, }); }; - const dimensionsEventListener = Dimensions.addEventListener('change', onDimensionChange); + const onDimensionChangeDebounce = lodashDebounce(onDimensionChange, 300); + + const dimensionsEventListener = Dimensions.addEventListener('change', onDimensionChangeDebounce); return () => { if (!dimensionsEventListener) { diff --git a/src/languages/en.ts b/src/languages/en.ts index 210d82b28a7d..f7c028d2a106 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -69,6 +69,7 @@ import type { SetTheRequestParams, UpdatedTheRequestParams, RemovedTheRequestParams, + FormattedMaxLengthParams, RequestedAmountMessageParams, TagSelectionParams, TranslationBase, @@ -282,6 +283,7 @@ export default { composer: { noExtensionFoundForMimeType: 'No extension found for mime type', problemGettingImageYouPasted: 'There was a problem getting the image you pasted', + commentExceededMaxLength: ({formattedMaxLength}: FormattedMaxLengthParams) => `The maximum comment length is ${formattedMaxLength} characters.`, }, baseUpdateAppModal: { updateApp: 'Update app', @@ -541,6 +543,7 @@ export default { threadSentMoneyReportName: ({formattedAmount, comment}: ThreadSentMoneyReportNameParams) => `${formattedAmount} sent${comment ? ` for ${comment}` : ''}`, tagSelection: ({tagName}: TagSelectionParams) => `Select a ${tagName} to add additional organization to your money`, error: { + invalidAmount: 'Please enter a valid amount before continuing.', invalidSplit: 'Split amounts do not equal total amount', other: 'Unexpected error, please try again later', genericCreateFailureMessage: 'Unexpected error requesting money, please try again later', @@ -729,6 +732,7 @@ export default { keepCodesSafe: 'Keep these recovery codes safe!', codesLoseAccess: 'If you lose access to your authenticator app and don’t have these codes, you will lose access to your account. \n\nNote: Setting up two-factor authentication will log you out of all other active sessions.', + errorStepCodes: 'Please copy or download codes before continuing.', stepVerify: 'Verify', scanCode: 'Scan the QR code using your', authenticatorApp: 'authenticator app', diff --git a/src/languages/es.ts b/src/languages/es.ts index 0048cfbb9e23..a68f33a33730 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -69,6 +69,7 @@ import type { SetTheRequestParams, UpdatedTheRequestParams, RemovedTheRequestParams, + FormattedMaxLengthParams, RequestedAmountMessageParams, TagSelectionParams, EnglishTranslation, @@ -272,6 +273,7 @@ export default { composer: { noExtensionFoundForMimeType: 'No se encontró una extension para este tipo de contenido', problemGettingImageYouPasted: 'Ha ocurrido un problema al obtener la imagen que has pegado', + commentExceededMaxLength: ({formattedMaxLength}: FormattedMaxLengthParams) => `El comentario debe tener máximo ${formattedMaxLength} caracteres.`, }, baseUpdateAppModal: { updateApp: 'Actualizar app', @@ -534,6 +536,7 @@ export default { threadSentMoneyReportName: ({formattedAmount, comment}: ThreadSentMoneyReportNameParams) => `${formattedAmount} enviado${comment ? ` para ${comment}` : ''}`, tagSelection: ({tagName}: TagSelectionParams) => `Seleccione una ${tagName} para organizar mejor tu dinero`, error: { + invalidAmount: 'Por favor ingresa un monto válido antes de continuar.', invalidSplit: 'La suma de las partes no equivale al monto total', other: 'Error inesperado, por favor inténtalo más tarde', genericCreateFailureMessage: 'Error inesperado solicitando dinero, Por favor, inténtalo más tarde', @@ -724,6 +727,7 @@ export default { keepCodesSafe: '¡Guarda los códigos de recuperación en un lugar seguro!', codesLoseAccess: 'Si pierdes el acceso a tu aplicación de autenticación y no tienes estos códigos, perderás el acceso a tu cuenta. \n\nNota: Configurar la autenticación de dos factores cerrará la sesión de todas las demás sesiones activas.', + errorStepCodes: 'Copia o descarga los códigos antes de continuar.', stepVerify: 'Verificar', scanCode: 'Escanea el código QR usando tu', authenticatorApp: 'aplicación de autenticación', diff --git a/src/languages/types.ts b/src/languages/types.ts index 9af00ceef8de..70bf2e4cae3d 100644 --- a/src/languages/types.ts +++ b/src/languages/types.ts @@ -190,6 +190,8 @@ type RemovedTheRequestParams = {valueName: string; oldValueToDisplay: string}; type UpdatedTheRequestParams = {valueName: string; newValueToDisplay: string; oldValueToDisplay: string}; +type FormattedMaxLengthParams = {formattedMaxLength: string}; + type TagSelectionParams = {tagName: string}; /* Translation Object types */ @@ -303,5 +305,6 @@ export type { SetTheRequestParams, UpdatedTheRequestParams, RemovedTheRequestParams, + FormattedMaxLengthParams, TagSelectionParams, }; diff --git a/src/libs/BootSplash/index.js b/src/libs/BootSplash/index.js index ff7ab5562b1f..c169f380a8eb 100644 --- a/src/libs/BootSplash/index.js +++ b/src/libs/BootSplash/index.js @@ -9,10 +9,14 @@ function hide() { return document.fonts.ready.then(() => { const splash = document.getElementById('splash'); - if (splash) splash.style.opacity = 0; + if (splash) { + splash.style.opacity = 0; + } return resolveAfter(250).then(() => { - if (!splash || !splash.parentNode) return; + if (!splash || !splash.parentNode) { + return; + } splash.parentNode.removeChild(splash); }); }); diff --git a/src/libs/ComposerUtils/getDraftComment.js b/src/libs/ComposerUtils/getDraftComment.js index ddcb966bb2a7..854df1ac65ee 100644 --- a/src/libs/ComposerUtils/getDraftComment.js +++ b/src/libs/ComposerUtils/getDraftComment.js @@ -5,7 +5,9 @@ const draftCommentMap = {}; Onyx.connect({ key: ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT, callback: (value, key) => { - if (!key) return; + if (!key) { + return; + } const reportID = key.replace(ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT, ''); draftCommentMap[reportID] = value; diff --git a/src/libs/CurrencyUtils.js b/src/libs/CurrencyUtils.js index 6cbb0db7661b..5cf0b22ef337 100644 --- a/src/libs/CurrencyUtils.js +++ b/src/libs/CurrencyUtils.js @@ -128,4 +128,25 @@ function convertToDisplayString(amountInCents, currency = CONST.CURRENCY.USD) { }); } -export {getCurrencyDecimals, getCurrencyUnit, getLocalizedCurrencySymbol, getCurrencySymbol, isCurrencySymbolLTR, convertToBackendAmount, convertToFrontendAmount, convertToDisplayString}; +/** + * Checks if passed currency code is a valid currency based on currency list + * + * @param {String} currencyCode + * @returns {Boolean} + */ +function isValidCurrencyCode(currencyCode) { + const currency = lodashGet(currencyList, currencyCode); + return Boolean(currency); +} + +export { + getCurrencyDecimals, + getCurrencyUnit, + getLocalizedCurrencySymbol, + getCurrencySymbol, + isCurrencySymbolLTR, + convertToBackendAmount, + convertToFrontendAmount, + convertToDisplayString, + isValidCurrencyCode, +}; diff --git a/src/libs/DateUtils.js b/src/libs/DateUtils.js index b33a1b1b2a73..70c4277bdb5e 100644 --- a/src/libs/DateUtils.js +++ b/src/libs/DateUtils.js @@ -298,7 +298,9 @@ function getDateStringFromISOTimestamp(isoTimestamp) { * @returns {String} */ function getStatusUntilDate(inputDate) { - if (!inputDate) return ''; + if (!inputDate) { + return ''; + } const {translateLocal} = Localize; const input = new Date(inputDate); diff --git a/src/libs/Log.js b/src/libs/Log.ts similarity index 70% rename from src/libs/Log.js rename to src/libs/Log.ts index e51fb74aedd5..cf139eec2682 100644 --- a/src/libs/Log.js +++ b/src/libs/Log.ts @@ -1,45 +1,43 @@ // Making an exception to this rule here since we don't need an "action" for Log and Log should just be used directly. Creating a Log // action would likely cause confusion about which one to use. But most other API methods should happen inside an action file. /* eslint-disable rulesdir/no-api-in-views */ +import {Merge} from 'type-fest'; import Logger from 'expensify-common/lib/Logger'; import getPlatform from './getPlatform'; import pkg from '../../package.json'; import requireParameters from './requireParameters'; import * as Network from './Network'; -let timeout = null; +let timeout: NodeJS.Timeout; -/** - * @param {Object} parameters - * @param {String} parameters.expensifyCashAppVersion - * @param {Object[]} parameters.logPacket - * @returns {Promise} - */ -function LogCommand(parameters) { +type LogCommandParameters = { + expensifyCashAppVersion: string; + logPacket: string; +}; + +function LogCommand(parameters: LogCommandParameters): Promise<{requestID: string}> { const commandName = 'Log'; requireParameters(['logPacket', 'expensifyCashAppVersion'], parameters, commandName); // Note: We are forcing Log to run since it requires no authToken and should only be queued when we are offline. // Non-cancellable request: during logout, when requests are cancelled, we don't want to cancel any remaining logs - return Network.post(commandName, {...parameters, forceNetworkRequest: true, canCancel: false}); + return Network.post(commandName, {...parameters, forceNetworkRequest: true, canCancel: false}) as Promise<{requestID: string}>; } +// eslint-disable-next-line +type ServerLoggingCallbackOptions = {api_setCookie: boolean; logPacket: string}; +type RequestParams = Merge; + /** * Network interface for logger. - * - * @param {Logger} logger - * @param {Object} params - * @param {Object} params.parameters - * @param {String} params.message - * @return {Promise} */ -function serverLoggingCallback(logger, params) { - const requestParams = params; +function serverLoggingCallback(logger: Logger, params: ServerLoggingCallbackOptions): Promise<{requestID: string}> { + const requestParams = params as RequestParams; requestParams.shouldProcessImmediately = false; requestParams.shouldRetry = false; requestParams.expensifyCashAppVersion = `expensifyCash[${getPlatform()}]${pkg.version}`; if (requestParams.parameters) { - requestParams.parameters = JSON.stringify(params.parameters); + requestParams.parameters = JSON.stringify(requestParams.parameters); } clearTimeout(timeout); timeout = setTimeout(() => logger.info('Flushing logs older than 10 minutes', true, {}, true), 10 * 60 * 1000); diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators.js b/src/libs/Navigation/AppNavigator/ModalStackNavigators.js index 392781a777db..5c110264e034 100644 --- a/src/libs/Navigation/AppNavigator/ModalStackNavigators.js +++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators.js @@ -334,7 +334,7 @@ const NewTeachersUniteNavigator = createModalStackNavigator([ const SaveTheWorldPage = require('../../../pages/TeachersUnite/SaveTheWorldPage').default; return SaveTheWorldPage; }, - name: 'SaveTheWorld_Root', + name: SCREENS.SAVE_THE_WORLD.ROOT, }, { getComponent: () => { @@ -365,7 +365,7 @@ const SettingsModalStackNavigator = createModalStackNavigator([ const SettingsInitialPage = require('../../../pages/settings/InitialSettingsPage').default; return SettingsInitialPage; }, - name: 'Settings_Root', + name: SCREENS.SETTINGS.ROOT, }, { getComponent: () => { @@ -506,7 +506,7 @@ const SettingsModalStackNavigator = createModalStackNavigator([ const SettingsSecurityPage = require('../../../pages/settings/Security/SecuritySettingsPage').default; return SettingsSecurityPage; }, - name: 'Settings_Security', + name: SCREENS.SETTINGS.SECURITY, }, { getComponent: () => { @@ -576,7 +576,7 @@ const SettingsModalStackNavigator = createModalStackNavigator([ const SettingsStatus = require('../../../pages/settings/Profile/CustomStatus/StatusPage').default; return SettingsStatus; }, - name: 'Settings_Status', + name: SCREENS.SETTINGS.STATUS, }, { getComponent: () => { diff --git a/src/libs/Navigation/NavigationRoot.js b/src/libs/Navigation/NavigationRoot.js index d8cb96e2c6b3..4d50a1cd6a68 100644 --- a/src/libs/Navigation/NavigationRoot.js +++ b/src/libs/Navigation/NavigationRoot.js @@ -103,7 +103,8 @@ function NavigationRoot(props) { prevStatusBarBackgroundColor.current = statusBarBackgroundColor.current; statusBarBackgroundColor.current = currentScreenBackgroundColor; - if (prevStatusBarBackgroundColor.current === statusBarBackgroundColor.current) { + + if (currentScreenBackgroundColor === themeColors.appBG && prevStatusBarBackgroundColor.current === themeColors.appBG) { return; } diff --git a/src/libs/Navigation/OnyxTabNavigator.js b/src/libs/Navigation/OnyxTabNavigator.js index dc68021bf515..2782054497b0 100644 --- a/src/libs/Navigation/OnyxTabNavigator.js +++ b/src/libs/Navigation/OnyxTabNavigator.js @@ -33,6 +33,7 @@ function OnyxTabNavigator({id, selectedTab, children, ...rest}) { id={id} initialRouteName={selectedTab} backBehavior="initialRoute" + keyboardDismissMode="none" screenListeners={{ state: (event) => { const state = event.data.state; diff --git a/src/libs/Navigation/linkingConfig.js b/src/libs/Navigation/linkingConfig.js index 11d21d6d005c..f4420330fbd9 100644 --- a/src/libs/Navigation/linkingConfig.js +++ b/src/libs/Navigation/linkingConfig.js @@ -38,7 +38,7 @@ export default { screens: { Settings: { screens: { - Settings_Root: { + [SCREENS.SETTINGS.ROOT]: { path: ROUTES.SETTINGS, }, [SCREENS.SETTINGS.WORKSPACES]: { @@ -65,7 +65,7 @@ export default { path: ROUTES.SETTINGS_CLOSE, exact: true, }, - Settings_Security: { + [SCREENS.SETTINGS.SECURITY]: { path: ROUTES.SETTINGS_SECURITY, exact: true, }, @@ -159,7 +159,7 @@ export default { path: ROUTES.SETTINGS_SHARE_CODE, exact: true, }, - Settings_Status: { + [SCREENS.SETTINGS.STATUS]: { path: ROUTES.SETTINGS_STATUS, exact: true, }, @@ -273,7 +273,7 @@ export default { }, TeachersUnite: { screens: { - SaveTheWorld_Root: ROUTES.TEACHERS_UNITE, + [SCREENS.SAVE_THE_WORLD.ROOT]: ROUTES.TEACHERS_UNITE, I_Know_A_Teacher: ROUTES.I_KNOW_A_TEACHER, Intro_School_Principal: ROUTES.INTRO_SCHOOL_PRINCIPAL, I_Am_A_Teacher: ROUTES.I_AM_A_TEACHER, diff --git a/src/libs/Notification/PushNotification/ForegroundNotifications/index.android.js b/src/libs/Notification/PushNotification/ForegroundNotifications/index.android.js new file mode 100644 index 000000000000..0afc8fe10490 --- /dev/null +++ b/src/libs/Notification/PushNotification/ForegroundNotifications/index.android.js @@ -0,0 +1,15 @@ +import Airship from '@ua/react-native-airship'; +import shouldShowPushNotification from '../shouldShowPushNotification'; + +function configureForegroundNotifications() { + Airship.push.android.setForegroundDisplayPredicate((pushPayload) => Promise.resolve(shouldShowPushNotification(pushPayload))); +} + +function disableForegroundNotifications() { + Airship.push.android.setForegroundDisplayPredicate(() => Promise.resolve(false)); +} + +export default { + configureForegroundNotifications, + disableForegroundNotifications, +}; diff --git a/src/libs/Notification/PushNotification/configureForegroundNotifications/index.ios.js b/src/libs/Notification/PushNotification/ForegroundNotifications/index.ios.js similarity index 78% rename from src/libs/Notification/PushNotification/configureForegroundNotifications/index.ios.js rename to src/libs/Notification/PushNotification/ForegroundNotifications/index.ios.js index 88d94b4ee805..17ad1baaebe3 100644 --- a/src/libs/Notification/PushNotification/configureForegroundNotifications/index.ios.js +++ b/src/libs/Notification/PushNotification/ForegroundNotifications/index.ios.js @@ -1,7 +1,7 @@ import Airship, {iOS} from '@ua/react-native-airship'; import shouldShowPushNotification from '../shouldShowPushNotification'; -export default function configureForegroundNotifications() { +function configureForegroundNotifications() { // Set our default iOS foreground presentation to be loud with a banner // More info here https://developer.apple.com/documentation/usernotifications/unusernotificationcenterdelegate/1649518-usernotificationcenter Airship.push.iOS.setForegroundPresentationOptions([ @@ -15,3 +15,12 @@ export default function configureForegroundNotifications() { // Returning null keeps the default presentation. Returning [] uses no presentation (hides the notification). Airship.push.iOS.setForegroundPresentationOptionsCallback((pushPayload) => Promise.resolve(shouldShowPushNotification(pushPayload) ? null : [])); } + +function disableForegroundNotifications() { + Airship.push.iOS.setForegroundPresentationOptionsCallback(() => Promise.resolve([])); +} + +export default { + configureForegroundNotifications, + disableForegroundNotifications, +}; diff --git a/src/libs/Notification/PushNotification/configureForegroundNotifications/index.js b/src/libs/Notification/PushNotification/ForegroundNotifications/index.js similarity index 52% rename from src/libs/Notification/PushNotification/configureForegroundNotifications/index.js rename to src/libs/Notification/PushNotification/ForegroundNotifications/index.js index c6cb13a0b3b9..acb116f7bc43 100644 --- a/src/libs/Notification/PushNotification/configureForegroundNotifications/index.js +++ b/src/libs/Notification/PushNotification/ForegroundNotifications/index.js @@ -1,4 +1,7 @@ /** * Configures notification handling while in the foreground on iOS and Android. This is a no-op on other platforms. */ -export default function () {} +export default { + configureForegroundNotifications: () => {}, + disableForegroundNotifications: () => {}, +}; diff --git a/src/libs/Notification/PushNotification/configureForegroundNotifications/index.android.js b/src/libs/Notification/PushNotification/configureForegroundNotifications/index.android.js deleted file mode 100644 index 393072df3d12..000000000000 --- a/src/libs/Notification/PushNotification/configureForegroundNotifications/index.android.js +++ /dev/null @@ -1,6 +0,0 @@ -import Airship from '@ua/react-native-airship'; -import shouldShowPushNotification from '../shouldShowPushNotification'; - -export default function configureForegroundNotifications() { - Airship.push.android.setForegroundDisplayPredicate((pushPayload) => Promise.resolve(shouldShowPushNotification(pushPayload))); -} diff --git a/src/libs/Notification/PushNotification/index.native.js b/src/libs/Notification/PushNotification/index.native.js index 299af69873f9..7192ee66a791 100644 --- a/src/libs/Notification/PushNotification/index.native.js +++ b/src/libs/Notification/PushNotification/index.native.js @@ -6,7 +6,7 @@ import Log from '../../Log'; import NotificationType from './NotificationType'; import * as PushNotification from '../../actions/PushNotification'; import ONYXKEYS from '../../../ONYXKEYS'; -import configureForegroundNotifications from './configureForegroundNotifications'; +import ForegroundNotifications from './ForegroundNotifications'; let isUserOptedInToPushNotifications = false; Onyx.connect({ @@ -96,7 +96,7 @@ function init() { // Keep track of which users have enabled push notifications via an NVP. Airship.addListener(EventType.NotificationOptInStatus, refreshNotificationOptInStatus); - configureForegroundNotifications(); + ForegroundNotifications.configureForegroundNotifications(); } /** @@ -136,6 +136,7 @@ function deregister() { Airship.contact.reset(); Airship.removeAllListeners(EventType.PushReceived); Airship.removeAllListeners(EventType.NotificationResponse); + ForegroundNotifications.disableForegroundNotifications(); } /** diff --git a/src/libs/OptionsListUtils.js b/src/libs/OptionsListUtils.js index e1661e0318b8..3bdf77745432 100644 --- a/src/libs/OptionsListUtils.js +++ b/src/libs/OptionsListUtils.js @@ -114,6 +114,7 @@ function getPolicyExpenseReportOption(report) { ], selected: report.selected, isPolicyExpenseChat: true, + searchText: report.searchText, }; } @@ -226,6 +227,7 @@ function getParticipantsOption(participant, personalDetails) { ], phoneNumber: lodashGet(detail, 'phoneNumber', ''), selected: participant.selected, + searchText: participant.searchText, }; } @@ -618,7 +620,7 @@ function hasEnabledOptions(options) { /** * Build the options for the category tree hierarchy via indents * - * @param {Object[]} options - an initial strings array + * @param {Object[]} options - an initial object array * @param {Boolean} options[].enabled - a flag to enable/disable option in a list * @param {String} options[].name - a name of an option * @param {Boolean} [isOneLine] - a flag to determine if text should be one line @@ -775,6 +777,124 @@ function getCategoryListSections(categories, recentlyUsedCategories, selectedOpt return categorySections; } +/** + * Transforms the provided tags into objects with a specific structure. + * + * @param {Object[]} tags - an initial tag array + * @param {Boolean} tags[].enabled - a flag to enable/disable option in a list + * @param {String} tags[].name - a name of an option + * @returns {Array} + */ +function getTagsOptions(tags) { + return _.map(tags, (tag) => ({ + text: tag.name, + keyForList: tag.name, + searchText: tag.name, + tooltipText: tag.name, + isDisabled: !tag.enabled, + })); +} + +/** + * Build the section list for tags + * + * @param {Object[]} tags + * @param {String} tags[].name + * @param {Boolean} tags[].enabled + * @param {String[]} recentlyUsedTags + * @param {Object[]} selectedOptions + * @param {String} selectedOptions[].name + * @param {String} searchInputValue + * @param {Number} maxRecentReportsToShow + * @returns {Array} + */ +function getTagListSections(tags, recentlyUsedTags, selectedOptions, searchInputValue, maxRecentReportsToShow) { + const tagSections = []; + const enabledTags = _.filter(tags, (tag) => tag.enabled); + const numberOfTags = _.size(enabledTags); + let indexOffset = 0; + + if (!_.isEmpty(searchInputValue)) { + const searchTags = _.filter(enabledTags, (tag) => tag.name.toLowerCase().includes(searchInputValue.toLowerCase())); + + tagSections.push({ + // "Search" section + title: '', + shouldShow: false, + indexOffset, + data: getTagsOptions(searchTags), + }); + + return tagSections; + } + + if (numberOfTags < CONST.TAG_LIST_THRESHOLD) { + tagSections.push({ + // "All" section when items amount less than the threshold + title: '', + shouldShow: false, + indexOffset, + data: getTagsOptions(enabledTags), + }); + + return tagSections; + } + + const selectedOptionNames = _.map(selectedOptions, (selectedOption) => selectedOption.name); + const filteredRecentlyUsedTags = _.map( + _.filter(recentlyUsedTags, (recentlyUsedTag) => { + const tagObject = _.find(tags, (tag) => tag.name === recentlyUsedTag); + return Boolean(tagObject && tagObject.enabled) && !_.includes(selectedOptionNames, recentlyUsedTag); + }), + (tag) => ({name: tag, enabled: true}), + ); + const filteredTags = _.filter(enabledTags, (tag) => !_.includes(selectedOptionNames, tag.name)); + + if (!_.isEmpty(selectedOptions)) { + const selectedTagOptions = _.map(selectedOptions, (option) => { + const tagObject = _.find(tags, (tag) => tag.name === option.name); + return { + name: option.name, + enabled: Boolean(tagObject && tagObject.enabled), + }; + }); + + tagSections.push({ + // "Selected" section + title: '', + shouldShow: false, + indexOffset, + data: getTagsOptions(selectedTagOptions), + }); + + indexOffset += selectedOptions.length; + } + + if (!_.isEmpty(filteredRecentlyUsedTags)) { + const cutRecentlyUsedTags = filteredRecentlyUsedTags.slice(0, maxRecentReportsToShow); + + tagSections.push({ + // "Recent" section + title: Localize.translateLocal('common.recent'), + shouldShow: true, + indexOffset, + data: getTagsOptions(cutRecentlyUsedTags), + }); + + indexOffset += filteredRecentlyUsedTags.length; + } + + tagSections.push({ + // "All" section when items amount more than the threshold + title: Localize.translateLocal('common.all'), + shouldShow: true, + indexOffset, + data: getTagsOptions(filteredTags), + }); + + return tagSections; +} + /** * Build the options * @@ -811,6 +931,9 @@ function getOptions( includeCategories = false, categories = {}, recentlyUsedCategories = [], + includeTags = false, + tags = {}, + recentlyUsedTags = [], canInviteUser = true, }, ) { @@ -823,6 +946,20 @@ function getOptions( userToInvite: null, currentUserOption: null, categoryOptions, + tagOptions: [], + }; + } + + if (includeTags) { + const tagOptions = getTagListSections(_.values(tags), recentlyUsedTags, selectedOptions, searchInputValue, maxRecentReportsToShow); + + return { + recentReports: [], + personalDetails: [], + userToInvite: null, + currentUserOption: null, + categoryOptions: [], + tagOptions, }; } @@ -833,6 +970,7 @@ function getOptions( userToInvite: null, currentUserOption: null, categoryOptions: [], + tagOptions: [], }; } @@ -1087,6 +1225,7 @@ function getOptions( userToInvite: canInviteUser ? userToInvite : null, currentUserOption, categoryOptions: [], + tagOptions: [], }; } @@ -1171,10 +1310,13 @@ function getIOUConfirmationOptionsFromParticipants(participants, amountText) { * @param {boolean} [includeCategories] * @param {Object} [categories] * @param {Array} [recentlyUsedCategories] + * @param {boolean} [includeTags] + * @param {Object} [tags] + * @param {Array} [recentlyUsedTags] * @param {boolean} [canInviteUser] * @returns {Object} */ -function getNewChatOptions( +function getFilteredOptions( reports, personalDetails, betas = [], @@ -1186,6 +1328,9 @@ function getNewChatOptions( includeCategories = false, categories = {}, recentlyUsedCategories = [], + includeTags = false, + tags = {}, + recentlyUsedTags = [], canInviteUser = true, ) { return getOptions(reports, personalDetails, { @@ -1201,6 +1346,9 @@ function getNewChatOptions( includeCategories, categories, recentlyUsedCategories, + includeTags, + tags, + recentlyUsedTags, canInviteUser, }); } @@ -1356,7 +1504,7 @@ export { isCurrentUser, isPersonalDetailsReady, getSearchOptions, - getNewChatOptions, + getFilteredOptions, getShareDestinationOptions, getMemberInviteOptions, getHeaderMessage, diff --git a/src/libs/Permissions.js b/src/libs/Permissions.js deleted file mode 100644 index 0294236b1cd7..000000000000 --- a/src/libs/Permissions.js +++ /dev/null @@ -1,126 +0,0 @@ -import _ from 'underscore'; -import CONST from '../CONST'; - -/** - * @private - * @param {Array} betas - * @returns {Boolean} - */ -function canUseAllBetas(betas) { - return _.contains(betas, CONST.BETAS.ALL); -} - -/** - * @param {Array} betas - * @returns {Boolean} - */ -function canUseChronos(betas) { - return _.contains(betas, CONST.BETAS.CHRONOS_IN_CASH) || canUseAllBetas(betas); -} - -/** - * @param {Array} betas - * @returns {Boolean} - */ -function canUsePayWithExpensify(betas) { - return _.contains(betas, CONST.BETAS.PAY_WITH_EXPENSIFY) || canUseAllBetas(betas); -} - -/** - * @param {Array} betas - * @returns {Boolean} - */ -function canUseDefaultRooms(betas) { - return _.contains(betas, CONST.BETAS.DEFAULT_ROOMS) || canUseAllBetas(betas); -} - -/** - * IOU Send feature is temporarily disabled. - * - * @returns {Boolean} - */ -function canUseIOUSend() { - return false; -} - -/** - * @param {Array} betas - * @returns {Boolean} - */ -function canUseWallet(betas) { - return _.contains(betas, CONST.BETAS.BETA_EXPENSIFY_WALLET) || canUseAllBetas(betas); -} - -/** - * @param {Array} betas - * @returns {Boolean} - */ -function canUseCommentLinking(betas) { - return _.contains(betas, CONST.BETAS.BETA_COMMENT_LINKING) || canUseAllBetas(betas); -} - -/** - * We're requiring you to be added to the policy rooms beta on dev, - * since contributors have been reporting a number of false issues related to the feature being under development. - * See https://expensify.slack.com/archives/C01GTK53T8Q/p1641921996319400?thread_ts=1641598356.166900&cid=C01GTK53T8Q - * @param {Array} betas - * @returns {Boolean} - */ -function canUsePolicyRooms(betas) { - return _.contains(betas, CONST.BETAS.POLICY_ROOMS) || canUseAllBetas(betas); -} - -/** - * @param {Array} betas - * @returns {Boolean} - */ -function canUseTasks(betas) { - return _.contains(betas, CONST.BETAS.TASKS) || canUseAllBetas(betas); -} - -/** - * @param {Array} betas - * @returns {Boolean} - */ -function canUseCustomStatus(betas) { - return _.contains(betas, CONST.BETAS.CUSTOM_STATUS) || canUseAllBetas(betas); -} - -/** - * @param {Array} betas - * @returns {Boolean} - */ -function canUseCategories(betas) { - return _.contains(betas, CONST.BETAS.NEW_DOT_CATEGORIES) || canUseAllBetas(betas); -} - -/** - * @param {Array} betas - * @returns {Boolean} - */ -function canUseTags(betas) { - return _.contains(betas, CONST.BETAS.NEW_DOT_TAGS) || canUseAllBetas(betas); -} - -/** - * Link previews are temporarily disabled. - * @returns {Boolean} - */ -function canUseLinkPreviews() { - return false; -} - -export default { - canUseChronos, - canUsePayWithExpensify, - canUseDefaultRooms, - canUseIOUSend, - canUseWallet, - canUseCommentLinking, - canUsePolicyRooms, - canUseTasks, - canUseCustomStatus, - canUseCategories, - canUseTags, - canUseLinkPreviews, -}; diff --git a/src/libs/Permissions.ts b/src/libs/Permissions.ts new file mode 100644 index 000000000000..05322472a407 --- /dev/null +++ b/src/libs/Permissions.ts @@ -0,0 +1,80 @@ +import CONST from '../CONST'; +import Beta from '../types/onyx/Beta'; + +function canUseAllBetas(betas: Beta[]): boolean { + return betas?.includes(CONST.BETAS.ALL); +} + +function canUseChronos(betas: Beta[]): boolean { + return betas?.includes(CONST.BETAS.CHRONOS_IN_CASH) || canUseAllBetas(betas); +} + +function canUsePayWithExpensify(betas: Beta[]): boolean { + return betas?.includes(CONST.BETAS.PAY_WITH_EXPENSIFY) || canUseAllBetas(betas); +} + +function canUseDefaultRooms(betas: Beta[]): boolean { + return betas?.includes(CONST.BETAS.DEFAULT_ROOMS) || canUseAllBetas(betas); +} + +/** + * IOU Send feature is temporarily disabled. + */ +function canUseIOUSend(): boolean { + return false; +} + +function canUseWallet(betas: Beta[]): boolean { + return betas?.includes(CONST.BETAS.BETA_EXPENSIFY_WALLET) || canUseAllBetas(betas); +} + +function canUseCommentLinking(betas: Beta[]): boolean { + return betas?.includes(CONST.BETAS.BETA_COMMENT_LINKING) || canUseAllBetas(betas); +} + +/** + * We're requiring you to be added to the policy rooms beta on dev, + * since contributors have been reporting a number of false issues related to the feature being under development. + * See https://expensify.slack.com/archives/C01GTK53T8Q/p1641921996319400?thread_ts=1641598356.166900&cid=C01GTK53T8Q + */ +function canUsePolicyRooms(betas: Beta[]): boolean { + return betas?.includes(CONST.BETAS.POLICY_ROOMS) || canUseAllBetas(betas); +} + +function canUseTasks(betas: Beta[]): boolean { + return betas?.includes(CONST.BETAS.TASKS) || canUseAllBetas(betas); +} + +function canUseCustomStatus(betas: Beta[]): boolean { + return betas?.includes(CONST.BETAS.CUSTOM_STATUS) || canUseAllBetas(betas); +} + +function canUseCategories(betas: Beta[]): boolean { + return betas?.includes(CONST.BETAS.NEW_DOT_CATEGORIES) || canUseAllBetas(betas); +} + +function canUseTags(betas: Beta[]): boolean { + return betas?.includes(CONST.BETAS.NEW_DOT_TAGS) || canUseAllBetas(betas); +} + +/** + * Link previews are temporarily disabled. + */ +function canUseLinkPreviews(): boolean { + return false; +} + +export default { + canUseChronos, + canUsePayWithExpensify, + canUseDefaultRooms, + canUseIOUSend, + canUseWallet, + canUseCommentLinking, + canUsePolicyRooms, + canUseTasks, + canUseCustomStatus, + canUseCategories, + canUseTags, + canUseLinkPreviews, +}; diff --git a/src/libs/ReceiptUtils.js b/src/libs/ReceiptUtils.js index d90a6cbf0e37..8f352c182171 100644 --- a/src/libs/ReceiptUtils.js +++ b/src/libs/ReceiptUtils.js @@ -1,34 +1,11 @@ -import lodashGet from 'lodash/get'; -import _ from 'underscore'; import Str from 'expensify-common/lib/str'; import * as FileUtils from './fileDownload/FileUtils'; import CONST from '../CONST'; -import Receipt from './actions/Receipt'; import ReceiptHTML from '../../assets/images/receipt-html.png'; import ReceiptDoc from '../../assets/images/receipt-doc.png'; import ReceiptGeneric from '../../assets/images/receipt-generic.png'; import ReceiptSVG from '../../assets/images/receipt-svg.png'; -function validateReceipt(file) { - const {fileExtension} = FileUtils.splitExtensionFromFileName(lodashGet(file, 'name', '')); - if (_.contains(CONST.API_ATTACHMENT_VALIDATIONS.UNALLOWED_EXTENSIONS, fileExtension.toLowerCase())) { - Receipt.setUploadReceiptError(true, 'attachmentPicker.wrongFileType', 'attachmentPicker.notAllowedExtension'); - return false; - } - - if (lodashGet(file, 'size', 0) > CONST.API_ATTACHMENT_VALIDATIONS.MAX_SIZE) { - Receipt.setUploadReceiptError(true, 'attachmentPicker.attachmentTooLarge', 'attachmentPicker.sizeExceeded'); - return false; - } - - if (lodashGet(file, 'size', 0) < CONST.API_ATTACHMENT_VALIDATIONS.MIN_SIZE) { - Receipt.setUploadReceiptError(true, 'attachmentPicker.attachmentTooSmall', 'attachmentPicker.sizeNotMet'); - return false; - } - - return true; -} - /** * Grab the appropriate receipt image and thumbnail URIs based on file type * @@ -64,4 +41,5 @@ function getThumbnailAndImageURIs(path, filename) { return {thumbnail: null, image}; } -export {validateReceipt, getThumbnailAndImageURIs}; +// eslint-disable-next-line import/prefer-default-export +export {getThumbnailAndImageURIs}; diff --git a/src/libs/ReportUtils.js b/src/libs/ReportUtils.js index ed2e85a4ff11..a5af66f08460 100644 --- a/src/libs/ReportUtils.js +++ b/src/libs/ReportUtils.js @@ -87,7 +87,9 @@ function getChatType(report) { * @returns {Object} */ function getPolicy(policyID) { - if (!allPolicies || !policyID) return {}; + if (!allPolicies || !policyID) { + return {}; + } return allPolicies[`${ONYXKEYS.COLLECTION.POLICY}${policyID}`] || {}; } @@ -272,7 +274,9 @@ function sortReportsByLastRead(reports) { * @returns {Boolean} */ function isSettled(reportID) { - if (!allReports) return false; + if (!allReports) { + return false; + } const report = allReports[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`] || {}; if ((typeof report === 'object' && Object.keys(report).length === 0) || report.isWaitingOnBankAccount) { return false; @@ -1836,7 +1840,7 @@ function hasReportNameError(report) { } /** - * For comments shorter than 10k chars, convert the comment from MD into HTML because that's how it is stored in the database + * For comments shorter than or equal to 10k chars, convert the comment from MD into HTML because that's how it is stored in the database * For longer comments, skip parsing, but still escape the text, and display plaintext for performance reasons. It takes over 40s to parse a 100k long string!! * * @param {String} text @@ -1844,7 +1848,7 @@ function hasReportNameError(report) { */ function getParsedComment(text) { const parser = new ExpensiMark(); - return text.length < CONST.MAX_MARKUP_LENGTH ? parser.replace(text) : _.escape(text); + return text.length <= CONST.MAX_MARKUP_LENGTH ? parser.replace(text) : _.escape(text); } /** @@ -2665,11 +2669,12 @@ function buildOptimisticWorkspaceChats(policyID, policyName) { * @param {String} parentReportID - Report ID of the chat where the Task is. * @param {String} title - Task title. * @param {String} description - Task description. + * @param {String | undefined} policyID - PolicyID of the parent report * * @returns {Object} */ -function buildOptimisticTaskReport(ownerAccountID, assigneeAccountID = 0, parentReportID, title, description) { +function buildOptimisticTaskReport(ownerAccountID, assigneeAccountID = 0, parentReportID, title, description, policyID = undefined) { return { reportID: generateReportID(), reportName: title, @@ -2681,6 +2686,7 @@ function buildOptimisticTaskReport(ownerAccountID, assigneeAccountID = 0, parent parentReportID, stateNum: CONST.REPORT.STATE_NUM.OPEN, statusNum: CONST.REPORT.STATUS.OPEN, + ...(_.isUndefined(policyID) ? {} : {policyID}), }; } diff --git a/src/libs/StatusBar/index.android.js b/src/libs/StatusBar/index.android.ts similarity index 74% rename from src/libs/StatusBar/index.android.js rename to src/libs/StatusBar/index.android.ts index 5033000d4de5..c928f0949665 100644 --- a/src/libs/StatusBar/index.android.js +++ b/src/libs/StatusBar/index.android.ts @@ -1,5 +1,4 @@ -// eslint-disable-next-line no-restricted-imports -import {StatusBar} from 'react-native'; +import StatusBar from './types'; // Only has custom web implementation StatusBar.getBackgroundColor = () => null; @@ -8,5 +7,4 @@ StatusBar.getBackgroundColor = () => null; // Also because Reanimated's interpolateColor gives Android native colors instead of hex strings, causing this to display a warning. StatusBar.setBackgroundColor = () => null; -// Just export StatusBar – no changes. export default StatusBar; diff --git a/src/libs/StatusBar/index.js b/src/libs/StatusBar/index.ts similarity index 74% rename from src/libs/StatusBar/index.js rename to src/libs/StatusBar/index.ts index ef15d597f93e..c1290bccaa77 100644 --- a/src/libs/StatusBar/index.js +++ b/src/libs/StatusBar/index.ts @@ -1,5 +1,4 @@ -// eslint-disable-next-line no-restricted-imports -import {StatusBar} from 'react-native'; +import StatusBar from './types'; // Only has custom web implementation StatusBar.getBackgroundColor = () => null; diff --git a/src/libs/StatusBar/index.web.js b/src/libs/StatusBar/index.web.js deleted file mode 100644 index dfa1226c33a8..000000000000 --- a/src/libs/StatusBar/index.web.js +++ /dev/null @@ -1,20 +0,0 @@ -// eslint-disable-next-line no-restricted-imports -import {StatusBar} from 'react-native'; - -StatusBar.getBackgroundColor = () => { - const element = document.querySelector('meta[name=theme-color]'); - if (!element || !element.content) { - return null; - } - return element.content; -}; - -StatusBar.setBackgroundColor = (backgroundColor) => { - const element = document.querySelector('meta[name=theme-color]'); - if (!element) { - return; - } - element.content = backgroundColor; -}; - -export default StatusBar; diff --git a/src/libs/StatusBar/index.web.ts b/src/libs/StatusBar/index.web.ts new file mode 100644 index 000000000000..1d46397eb4b7 --- /dev/null +++ b/src/libs/StatusBar/index.web.ts @@ -0,0 +1,23 @@ +import StatusBar from './types'; + +StatusBar.getBackgroundColor = () => { + const element = document.querySelector('meta[name=theme-color]'); + + if (!element?.content) { + return null; + } + + return element.content; +}; + +StatusBar.setBackgroundColor = (backgroundColor) => { + const element = document.querySelector('meta[name=theme-color]'); + + if (!element) { + return; + } + + element.content = backgroundColor as string; +}; + +export default StatusBar; diff --git a/src/libs/StatusBar/types.ts b/src/libs/StatusBar/types.ts new file mode 100644 index 000000000000..9098eed977ab --- /dev/null +++ b/src/libs/StatusBar/types.ts @@ -0,0 +1,10 @@ +// eslint-disable-next-line no-restricted-imports +import {StatusBar as StatusBarRN} from 'react-native'; + +type StatusBarExtended = typeof StatusBarRN & { + getBackgroundColor(): string | null; +}; + +const StatusBar = StatusBarRN as StatusBarExtended; + +export default StatusBar; diff --git a/src/libs/TransactionUtils.js b/src/libs/TransactionUtils.js index 9aeacf14bdcc..5dcfbc467c20 100644 --- a/src/libs/TransactionUtils.js +++ b/src/libs/TransactionUtils.js @@ -34,6 +34,7 @@ Onyx.connect({ * @param {String} [filename] * @param {String} [existingTransactionID] When creating a distance request, an empty transaction has already been created with a transactionID. In that case, the transaction here needs to have it's transactionID match what was already generated. * @param {String} [category] + * @param {String} [tag] * @param {Boolean} [billable] * @returns {Object} */ @@ -50,6 +51,7 @@ function buildOptimisticTransaction( filename = '', existingTransactionID = null, category = '', + tag = '', billable = false, ) { // transactionIDs are random, positive, 64-bit numeric strings. @@ -79,6 +81,7 @@ function buildOptimisticTransaction( receipt, filename, category, + tag, billable, }; } diff --git a/src/libs/__mocks__/Log.js b/src/libs/__mocks__/Log.js deleted file mode 100644 index 179a665d2bd9..000000000000 --- a/src/libs/__mocks__/Log.js +++ /dev/null @@ -1,9 +0,0 @@ -// Set up manual mocks for any Logging methods that are supposed hit the 'server', -// this is needed because before, the Logging queue would get flushed while tests were running, -// causing unexpected calls to HttpUtils.xhr() which would cause mock mismatches and flaky tests. -export default { - info: (message) => console.debug(`[info] ${message} (mocked)`), - alert: (message) => console.debug(`[alert] ${message} (mocked)`), - warn: (message) => console.debug(`[warn] ${message} (mocked)`), - hmmm: (message) => console.debug(`[hmmm] ${message} (mocked)`), -}; diff --git a/src/libs/__mocks__/Log.ts b/src/libs/__mocks__/Log.ts new file mode 100644 index 000000000000..39336db1fa51 --- /dev/null +++ b/src/libs/__mocks__/Log.ts @@ -0,0 +1,9 @@ +// Set up manual mocks for any Logging methods that are supposed hit the 'server', +// this is needed because before, the Logging queue would get flushed while tests were running, +// causing unexpected calls to HttpUtils.xhr() which would cause mock mismatches and flaky tests. +export default { + info: (message: string) => console.debug(`[info] ${message} (mocked)`), + alert: (message: string) => console.debug(`[alert] ${message} (mocked)`), + warn: (message: string) => console.debug(`[warn] ${message} (mocked)`), + hmmm: (message: string) => console.debug(`[hmmm] ${message} (mocked)`), +}; diff --git a/src/libs/__mocks__/Permissions.js b/src/libs/__mocks__/Permissions.ts similarity index 54% rename from src/libs/__mocks__/Permissions.js rename to src/libs/__mocks__/Permissions.ts index fffaea5793d4..2c062590573e 100644 --- a/src/libs/__mocks__/Permissions.js +++ b/src/libs/__mocks__/Permissions.ts @@ -1,5 +1,5 @@ -import _ from 'underscore'; import CONST from '../../CONST'; +import Beta from '../../types/onyx/Beta'; /** * This module is mocked in tests because all the permission methods call canUseAllBetas() and that will @@ -10,8 +10,8 @@ import CONST from '../../CONST'; export default { ...jest.requireActual('../Permissions'), - canUseDefaultRooms: (betas) => _.contains(betas, CONST.BETAS.DEFAULT_ROOMS), - canUsePolicyRooms: (betas) => _.contains(betas, CONST.BETAS.POLICY_ROOMS), - canUseIOUSend: (betas) => _.contains(betas, CONST.BETAS.IOU_SEND), - canUseCustomStatus: (betas) => _.contains(betas, CONST.BETAS.CUSTOM_STATUS), + canUseDefaultRooms: (betas: Beta[]) => betas.includes(CONST.BETAS.DEFAULT_ROOMS), + canUsePolicyRooms: (betas: Beta[]) => betas.includes(CONST.BETAS.POLICY_ROOMS), + canUseIOUSend: (betas: Beta[]) => betas.includes(CONST.BETAS.IOU_SEND), + canUseCustomStatus: (betas: Beta[]) => betas.includes(CONST.BETAS.CUSTOM_STATUS), }; diff --git a/src/libs/actions/App.js b/src/libs/actions/App.js index 5753804eadfe..570b25040855 100644 --- a/src/libs/actions/App.js +++ b/src/libs/actions/App.js @@ -168,7 +168,9 @@ function getOnyxDataForOpenOrReconnect(isOpenApp = false) { }, ], }; - if (!isOpenApp) return defaultData; + if (!isOpenApp) { + return defaultData; + } return { optimisticData: [ ...defaultData.optimisticData, diff --git a/src/libs/actions/IOU.js b/src/libs/actions/IOU.js index 197468f16297..36d512c8d843 100644 --- a/src/libs/actions/IOU.js +++ b/src/libs/actions/IOU.js @@ -51,6 +51,20 @@ Onyx.connect({ callback: (val) => (allRecentlyUsedCategories = val), }); +let allRecentlyUsedTags = {}; +Onyx.connect({ + key: ONYXKEYS.COLLECTION.POLICY_RECENTLY_USED_TAGS, + waitForCollectionCallback: true, + callback: (value) => { + if (!value) { + allRecentlyUsedTags = {}; + return; + } + + allRecentlyUsedTags = value; + }, +}); + let userAccountID = ''; let currentUserEmail = ''; Onyx.connect({ @@ -93,6 +107,7 @@ function resetMoneyRequestInfo(id = '') { participantAccountIDs: [], merchant: CONST.TRANSACTION.DEFAULT_MERCHANT, category: '', + tag: '', created, receiptPath: '', receiptSource: '', @@ -111,6 +126,7 @@ function buildOnyxDataForMoneyRequest( optimisticPersonalDetailListAction, reportPreviewAction, optimisticRecentlyUsedCategories, + optimisticPolicyRecentlyUsedTags, isNewChatReport, isNewIOUReport, ) { @@ -168,6 +184,14 @@ function buildOnyxDataForMoneyRequest( }); } + if (!_.isEmpty(optimisticPolicyRecentlyUsedTags)) { + optimisticData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY_RECENTLY_USED_TAGS}${iouReport.policyID}`, + value: optimisticPolicyRecentlyUsedTags, + }); + } + if (!_.isEmpty(optimisticPersonalDetailListAction)) { optimisticData.push({ onyxMethod: Onyx.METHOD.MERGE, @@ -341,6 +365,7 @@ function buildOnyxDataForMoneyRequest( * @param {Object} [receipt] * @param {String} [existingTransactionID] * @param {String} [category] + * @param {String} [tag] * @param {Boolean} [billable] * @returns {Object} data * @returns {String} data.payerEmail @@ -369,6 +394,7 @@ function getMoneyRequestInformation( receipt = undefined, existingTransactionID = undefined, category = undefined, + tag = undefined, billable = undefined, ) { const payerEmail = OptionsListUtils.addSMSDomainIfPhoneNumber(participant.login); @@ -435,6 +461,7 @@ function getMoneyRequestInformation( filename, existingTransactionID, category, + tag, billable, ); @@ -446,6 +473,16 @@ function getMoneyRequestInformation( : []; const optimisticPolicyRecentlyUsedCategories = [category, ...uniquePolicyRecentlyUsedCategories]; + const optimisticPolicyRecentlyUsedTags = {}; + const recentlyUsedPolicyTags = allRecentlyUsedTags[`${ONYXKEYS.COLLECTION.POLICY_RECENTLY_USED_TAGS}${iouReport.policyID}`]; + + if (recentlyUsedPolicyTags) { + // For now it only uses the first tag of the policy, since multi-tags are not yet supported + const recentlyUsedTagListKey = _.first(_.keys(recentlyUsedPolicyTags)); + const uniquePolicyRecentlyUsedTags = _.filter(recentlyUsedPolicyTags[recentlyUsedTagListKey], (recentlyUsedPolicyTag) => recentlyUsedPolicyTag !== tag); + optimisticPolicyRecentlyUsedTags[recentlyUsedTagListKey] = [tag, ...uniquePolicyRecentlyUsedTags]; + } + // If there is an existing transaction (which is the case for distance requests), then the data from the existing transaction // needs to be manually merged into the optimistic transaction. This is because buildOnyxDataForMoneyRequest() uses `Onyx.set()` for the transaction // data. This is a big can of worms to change it to `Onyx.merge()` as explored in https://expensify.slack.com/archives/C05DWUDHVK7/p1692139468252109. @@ -515,6 +552,7 @@ function getMoneyRequestInformation( optimisticPersonalDetailListAction, reportPreviewAction, optimisticPolicyRecentlyUsedCategories, + optimisticPolicyRecentlyUsedTags, isNewChatReport, isNewIOUReport, ); @@ -545,11 +583,12 @@ function getMoneyRequestInformation( * @param {String} created * @param {String} [transactionID] * @param {String} [category] + * @param {String} [tag] * @param {Number} amount * @param {String} currency * @param {String} merchant */ -function createDistanceRequest(report, participant, comment, created, transactionID, category, amount, currency, merchant) { +function createDistanceRequest(report, participant, comment, created, transactionID, category, tag, amount, currency, merchant) { const optimisticReceipt = { source: ReceiptGeneric, state: CONST.IOU.RECEIPT_STATE.OPEN, @@ -567,6 +606,7 @@ function createDistanceRequest(report, participant, comment, created, transactio optimisticReceipt, transactionID, category, + tag, ); API.write( 'CreateDistanceRequest', @@ -582,6 +622,7 @@ function createDistanceRequest(report, participant, comment, created, transactio waypoints: JSON.stringify(TransactionUtils.getValidWaypoints(transaction.comment.waypoints, true)), created, category, + tag, }, onyxData, ); @@ -603,9 +644,24 @@ function createDistanceRequest(report, participant, comment, created, transactio * @param {String} comment * @param {Object} [receipt] * @param {String} [category] + * @param {String} [tag] * @param {Boolean} [billable] */ -function requestMoney(report, amount, currency, created, merchant, payeeEmail, payeeAccountID, participant, comment, receipt = undefined, category = undefined, billable = undefined) { +function requestMoney( + report, + amount, + currency, + created, + merchant, + payeeEmail, + payeeAccountID, + participant, + comment, + receipt = undefined, + category = undefined, + tag = undefined, + billable = undefined, +) { // If the report is iou or expense report, we should get the linked chat report to be passed to the getMoneyRequestInformation function const isMoneyRequestReport = ReportUtils.isMoneyRequestReport(report); const currentChatReport = isMoneyRequestReport ? ReportUtils.getReport(report.chatReportID) : report; @@ -622,6 +678,7 @@ function requestMoney(report, amount, currency, created, merchant, payeeEmail, p receipt, undefined, category, + tag, billable, ); @@ -643,6 +700,7 @@ function requestMoney(report, amount, currency, created, merchant, payeeEmail, p reportPreviewReportActionID: reportPreviewAction.reportActionID, receipt, category, + tag, billable, }, onyxData, @@ -928,6 +986,7 @@ function createSplitsAndOnyxData(participants, currentUserLogin, currentUserAcco oneOnOnePersonalDetailListAction, oneOnOneReportPreviewAction, [], + {}, isNewOneOnOneChatReport, shouldCreateNewOneOnOneIOUReport, ); @@ -1148,7 +1207,9 @@ function editMoneyRequest(transactionID, transactionThreadReportID, transactionC onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transactionThread.reportID}`, value: { - [updatedReportAction.reportActionID]: updatedReportAction, + [updatedReportAction.reportActionID]: { + errors: ErrorUtils.getMicroSecondOnyxError('iou.error.genericEditFailureMessage'), + }, }, }, { @@ -1960,6 +2021,17 @@ function resetMoneyRequestCategory() { Onyx.merge(ONYXKEYS.IOU, {category: ''}); } +/* + * @param {String} tag + */ +function setMoneyRequestTag(tag) { + Onyx.merge(ONYXKEYS.IOU, {tag}); +} + +function resetMoneyRequestTag() { + Onyx.merge(ONYXKEYS.IOU, {tag: ''}); +} + /** * @param {Boolean} billable */ @@ -2050,6 +2122,8 @@ export { setMoneyRequestMerchant, setMoneyRequestCategory, resetMoneyRequestCategory, + setMoneyRequestTag, + resetMoneyRequestTag, setMoneyRequestBillable, setMoneyRequestParticipants, setMoneyRequestReceipt, diff --git a/src/libs/actions/Receipt.ts b/src/libs/actions/Receipt.ts deleted file mode 100644 index acc23f04cf67..000000000000 --- a/src/libs/actions/Receipt.ts +++ /dev/null @@ -1,39 +0,0 @@ -import Onyx from 'react-native-onyx'; -import ONYXKEYS from '../../ONYXKEYS'; - -/** - * Sets the upload receipt error modal content when an invalid receipt is uploaded - */ -function setUploadReceiptError(isAttachmentInvalid: boolean, attachmentInvalidReasonTitle: string, attachmentInvalidReason: string) { - Onyx.merge(ONYXKEYS.RECEIPT_MODAL, { - isAttachmentInvalid, - attachmentInvalidReasonTitle, - attachmentInvalidReason, - }); -} - -/** - * Clears the receipt error modal - */ -function clearUploadReceiptError() { - Onyx.merge(ONYXKEYS.RECEIPT_MODAL, { - isAttachmentInvalid: false, - attachmentInvalidReasonTitle: '', - attachmentInvalidReason: '', - }); -} - -/** - * Close the receipt modal - */ -function closeUploadReceiptModal() { - Onyx.merge(ONYXKEYS.RECEIPT_MODAL, { - isAttachmentInvalid: false, - }); -} - -export default { - setUploadReceiptError, - clearUploadReceiptError, - closeUploadReceiptModal, -}; diff --git a/src/libs/actions/Report.js b/src/libs/actions/Report.js index e73581be0c8d..aa0d4b432da4 100644 --- a/src/libs/actions/Report.js +++ b/src/libs/actions/Report.js @@ -1066,7 +1066,7 @@ const removeLinksFromHtml = (html, links) => { */ const handleUserDeletedLinksInHtml = (newCommentText, originalHtml) => { const parser = new ExpensiMark(); - if (newCommentText.length >= CONST.MAX_MARKUP_LENGTH) { + if (newCommentText.length > CONST.MAX_MARKUP_LENGTH) { return newCommentText; } const markdownOriginalComment = parser.htmlToMarkdown(originalHtml).trim(); @@ -1093,10 +1093,10 @@ function editReportComment(reportID, originalReportAction, textForNewComment) { const htmlForNewComment = handleUserDeletedLinksInHtml(textForNewComment, originalCommentHTML); const reportComment = parser.htmlToText(htmlForNewComment); - // For comments shorter than 10k chars, convert the comment from MD into HTML because that's how it is stored in the database + // For comments shorter than or equal to 10k chars, convert the comment from MD into HTML because that's how it is stored in the database // For longer comments, skip parsing and display plaintext for performance reasons. It takes over 40s to parse a 100k long string!! let parsedOriginalCommentHTML = originalCommentHTML; - if (textForNewComment.length < CONST.MAX_MARKUP_LENGTH) { + if (textForNewComment.length <= CONST.MAX_MARKUP_LENGTH) { const autolinkFilter = {filterRules: _.filter(_.pluck(parser.rules, 'name'), (name) => name !== 'autolink')}; parsedOriginalCommentHTML = parser.replace(parser.htmlToMarkdown(originalCommentHTML).trim(), autolinkFilter); } @@ -1822,6 +1822,10 @@ function leaveRoom(reportID) { if (Navigation.getTopmostReportId() === reportID) { Navigation.goBack(ROUTES.HOME); } + if (report.parentReportID) { + Navigation.navigate(ROUTES.getReportRoute(report.parentReportID), CONST.NAVIGATION.TYPE.FORCED_UP); + return; + } navigateToConciergeChat(); } diff --git a/src/libs/actions/Task.js b/src/libs/actions/Task.js index f1fb4d96f523..4e71a8793f1e 100644 --- a/src/libs/actions/Task.js +++ b/src/libs/actions/Task.js @@ -59,9 +59,10 @@ function clearOutTaskInfo() { * @param {String} assigneeEmail * @param {Number} assigneeAccountID * @param {Object} assigneeChatReport - The chat report between you and the assignee + * @param {String | undefined} policyID - the policyID of the parent report */ -function createTaskAndNavigate(parentReportID, title, description, assigneeEmail, assigneeAccountID = 0, assigneeChatReport = null) { - const optimisticTaskReport = ReportUtils.buildOptimisticTaskReport(currentUserAccountID, assigneeAccountID, parentReportID, title, description); +function createTaskAndNavigate(parentReportID, title, description, assigneeEmail, assigneeAccountID = 0, assigneeChatReport = null, policyID = undefined) { + const optimisticTaskReport = ReportUtils.buildOptimisticTaskReport(currentUserAccountID, assigneeAccountID, parentReportID, title, description, policyID); const assigneeChatReportID = assigneeChatReport ? assigneeChatReport.reportID : 0; const taskReportID = optimisticTaskReport.reportID; diff --git a/src/libs/actions/Transaction.js b/src/libs/actions/Transaction.js index 663dfd1c8564..81764a9c62be 100644 --- a/src/libs/actions/Transaction.js +++ b/src/libs/actions/Transaction.js @@ -138,6 +138,10 @@ function removeWaypoint(transactionID, currentIndex) { if (!isRemovedWaypointEmpty) { newTransaction = { ...newTransaction, + // Clear any errors that may be present, which apply to the old route + errorFields: { + route: null, + }, // Clear the existing route so that we don't show an old route routes: { route0: { diff --git a/src/libs/fileDownload/index.android.js b/src/libs/fileDownload/index.android.js index c19301cbde49..c3528b579f67 100644 --- a/src/libs/fileDownload/index.android.js +++ b/src/libs/fileDownload/index.android.js @@ -68,7 +68,9 @@ function handleDownload(url, fileName) { return Promise.reject(); } - if (!isLocalFile) attachmentPath = attachment.path(); + if (!isLocalFile) { + attachmentPath = attachment.path(); + } return RNFetchBlob.MediaCollection.copyToMediaStore( { diff --git a/src/libs/migrateOnyx.js b/src/libs/migrateOnyx.js index e691ea22ba79..e401cbd9db69 100644 --- a/src/libs/migrateOnyx.js +++ b/src/libs/migrateOnyx.js @@ -1,12 +1,12 @@ import _ from 'underscore'; import Log from './Log'; import AddEncryptedAuthToken from './migrations/AddEncryptedAuthToken'; -import RenameActiveClientsKey from './migrations/RenameActiveClientsKey'; import RenamePriorityModeKey from './migrations/RenamePriorityModeKey'; import MoveToIndexedDB from './migrations/MoveToIndexedDB'; import RenameExpensifyNewsStatus from './migrations/RenameExpensifyNewsStatus'; import AddLastVisibleActionCreated from './migrations/AddLastVisibleActionCreated'; import PersonalDetailsByAccountID from './migrations/PersonalDetailsByAccountID'; +import RenameReceiptFilename from './migrations/RenameReceiptFilename'; export default function () { const startTime = Date.now(); @@ -16,12 +16,12 @@ export default function () { // Add all migrations to an array so they are executed in order const migrationPromises = [ MoveToIndexedDB, - RenameActiveClientsKey, RenamePriorityModeKey, AddEncryptedAuthToken, RenameExpensifyNewsStatus, AddLastVisibleActionCreated, PersonalDetailsByAccountID, + RenameReceiptFilename, ]; // Reduce all promises down to a single promise. All promises run in a linear fashion, waiting for the diff --git a/src/libs/migrations/RenameActiveClientsKey.js b/src/libs/migrations/RenameActiveClientsKey.js deleted file mode 100644 index 54b36e13cff5..000000000000 --- a/src/libs/migrations/RenameActiveClientsKey.js +++ /dev/null @@ -1,33 +0,0 @@ -import Onyx from 'react-native-onyx'; -import _ from 'underscore'; -import Log from '../Log'; -import ONYXKEYS from '../../ONYXKEYS'; - -// This migration changes the name of the Onyx key ACTIVE_CLIENTS from activeClients2 to activeClients -export default function () { - return new Promise((resolve) => { - // Connect to the old key in Onyx to get the old value of activeClients2 - // then set the new key activeClients to hold the old data - // finally remove the old key by setting the value to null - const connectionID = Onyx.connect({ - key: 'activeClients2', - callback: (oldActiveClients) => { - Onyx.disconnect(connectionID); - - // Fail early here because there is nothing to migrate - if (_.isEmpty(oldActiveClients)) { - Log.info('[Migrate Onyx] Skipped migration RenameActiveClientsKey'); - return resolve(); - } - - Onyx.multiSet({ - activeClients2: null, - [ONYXKEYS.ACTIVE_CLIENTS]: oldActiveClients, - }).then(() => { - Log.info('[Migrate Onyx] Ran migration RenameActiveClientsKey'); - resolve(); - }); - }, - }); - }); -} diff --git a/src/libs/migrations/RenameReceiptFilename.js b/src/libs/migrations/RenameReceiptFilename.js new file mode 100644 index 000000000000..b8df705fd7d1 --- /dev/null +++ b/src/libs/migrations/RenameReceiptFilename.js @@ -0,0 +1,57 @@ +import Onyx from 'react-native-onyx'; +import _ from 'underscore'; +import lodashHas from 'lodash/has'; +import ONYXKEYS from '../../ONYXKEYS'; +import Log from '../Log'; + +// This migration changes the property name on a transaction from receiptFilename to filename so that it matches what is stored in the database +export default function () { + return new Promise((resolve) => { + // Connect to the TRANSACTION collection key in Onyx to get all of the stored transactions. + // Go through each transaction and change the property name + const connectionID = Onyx.connect({ + key: ONYXKEYS.COLLECTION.TRANSACTION, + waitForCollectionCallback: true, + callback: (transactions) => { + Onyx.disconnect(connectionID); + + if (!transactions || transactions.length === 0) { + Log.info('[Migrate Onyx] Skipped migration RenameReceiptFilename because there are no transactions'); + return resolve(); + } + + if (!_.compact(_.pluck(transactions, 'receiptFilename')).length) { + Log.info('[Migrate Onyx] Skipped migration RenameReceiptFilename because there were no transactions with the receiptFilename property'); + return resolve(); + } + + Log.info('[Migrate Onyx] Running RenameReceiptFilename migration'); + + const dataToSave = _.reduce( + transactions, + (result, transaction) => { + // Do nothing if there is no receiptFilename property + if (!lodashHas(transaction, 'receiptFilename')) { + return result; + } + Log.info(`[Migrate Onyx] Renaming receiptFilename ${transaction.receiptFilename} to filename`); + return { + ...result, + [`${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`]: { + filename: transaction.receiptFilename, + receiptFilename: null, + }, + }; + }, + {}, + ); + + // eslint-disable-next-line rulesdir/prefer-actions-set-data + Onyx.mergeCollection(ONYXKEYS.COLLECTION.TRANSACTION, dataToSave).then(() => { + Log.info(`[Migrate Onyx] Ran migration RenameReceiptFilename and renamed ${_.size(dataToSave)} properties`); + resolve(); + }); + }, + }); + }); +} diff --git a/src/pages/FlagCommentPage.js b/src/pages/FlagCommentPage.js index 0b850e6c3849..1a9a2d8c8767 100644 --- a/src/pages/FlagCommentPage.js +++ b/src/pages/FlagCommentPage.js @@ -2,6 +2,7 @@ import React, {useCallback} from 'react'; import _ from 'underscore'; import {View, ScrollView} from 'react-native'; import PropTypes from 'prop-types'; +import {withOnyx} from 'react-native-onyx'; import reportPropTypes from './reportPropTypes'; import reportActionPropTypes from './home/report/reportActionPropTypes'; import withLocalize, {withLocalizePropTypes} from '../components/withLocalize'; @@ -20,6 +21,7 @@ import * as ReportActionsUtils from '../libs/ReportActionsUtils'; import * as Session from '../libs/actions/Session'; import FullPageNotFoundView from '../components/BlockingViews/FullPageNotFoundView'; import withReportAndReportActionOrNotFound from './home/report/withReportAndReportActionOrNotFound'; +import ONYXKEYS from '../ONYXKEYS'; const propTypes = { /** Array of report actions for this report */ @@ -178,4 +180,13 @@ FlagCommentPage.propTypes = propTypes; FlagCommentPage.defaultProps = defaultProps; FlagCommentPage.displayName = 'FlagCommentPage'; -export default compose(withLocalize, withReportAndReportActionOrNotFound)(FlagCommentPage); +export default compose( + withLocalize, + withReportAndReportActionOrNotFound, + withOnyx({ + parentReportActions: { + key: ({report}) => `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.parentReportID || report.reportID}`, + canEvict: false, + }, + }), +)(FlagCommentPage); diff --git a/src/pages/NewChatPage.js b/src/pages/NewChatPage.js index edbae46a3207..cb54aa8e5a7b 100755 --- a/src/pages/NewChatPage.js +++ b/src/pages/NewChatPage.js @@ -124,7 +124,7 @@ function NewChatPage({betas, isGroupChat, personalDetails, reports, translate}) recentReports, personalDetails: newChatPersonalDetails, userToInvite, - } = OptionsListUtils.getNewChatOptions(reports, personalDetails, betas, searchTerm, newSelectedOptions, excludedGroupEmails); + } = OptionsListUtils.getFilteredOptions(reports, personalDetails, betas, searchTerm, newSelectedOptions, excludedGroupEmails); setSelectedOptions(newSelectedOptions); setFilteredRecentReports(recentReports); @@ -159,7 +159,7 @@ function NewChatPage({betas, isGroupChat, personalDetails, reports, translate}) recentReports, personalDetails: newChatPersonalDetails, userToInvite, - } = OptionsListUtils.getNewChatOptions(reports, personalDetails, betas, searchTerm, selectedOptions, isGroupChat ? excludedGroupEmails : []); + } = OptionsListUtils.getFilteredOptions(reports, personalDetails, betas, searchTerm, selectedOptions, isGroupChat ? excludedGroupEmails : []); setFilteredRecentReports(recentReports); setFilteredPersonalDetails(newChatPersonalDetails); setFilteredUserToInvite(userToInvite); diff --git a/src/pages/NewChatSelectorPage.js b/src/pages/NewChatSelectorPage.js index 89a3fd1adc72..ce0bbda0d239 100755 --- a/src/pages/NewChatSelectorPage.js +++ b/src/pages/NewChatSelectorPage.js @@ -1,4 +1,5 @@ import React from 'react'; +import {withOnyx} from 'react-native-onyx'; import OnyxTabNavigator, {TopTab} from '../libs/Navigation/OnyxTabNavigator'; import TabSelector from '../components/TabSelector/TabSelector'; import Navigation from '../libs/Navigation/Navigation'; @@ -6,6 +7,7 @@ import Permissions from '../libs/Permissions'; import NewChatPage from './NewChatPage'; import WorkspaceNewRoomPage from './workspace/WorkspaceNewRoomPage'; import CONST from '../CONST'; +import ONYXKEYS from '../ONYXKEYS'; import withWindowDimensions, {windowDimensionsPropTypes} from '../components/withWindowDimensions'; import HeaderWithBackButton from '../components/HeaderWithBackButton'; import ScreenWrapper from '../components/ScreenWrapper'; @@ -66,4 +68,10 @@ NewChatSelectorPage.propTypes = propTypes; NewChatSelectorPage.defaultProps = defaultProps; NewChatSelectorPage.displayName = 'NewChatPage'; -export default compose(withLocalize, withWindowDimensions)(NewChatSelectorPage); +export default compose( + withLocalize, + withWindowDimensions, + withOnyx({ + betas: {key: ONYXKEYS.BETAS}, + }), +)(NewChatSelectorPage); diff --git a/src/pages/ShareCodePage.js b/src/pages/ShareCodePage.js index a81d02c60b36..a36149a5f4fa 100644 --- a/src/pages/ShareCodePage.js +++ b/src/pages/ShareCodePage.js @@ -42,8 +42,9 @@ class ShareCodePage extends React.Component { render() { const isReport = this.props.report != null && this.props.report.reportID != null; - const subtitle = ReportUtils.getChatRoomSubtitle(this.props.report); - + const title = isReport ? ReportUtils.getReportName(this.props.report) : this.props.currentUserPersonalDetails.displayName; + const formattedEmail = this.props.formatPhoneNumber(this.props.session.email); + const subtitle = isReport ? ReportUtils.getParentNavigationSubtitle(this.props.report).workspaceName || ReportUtils.getChatRoomSubtitle(this.props.report) : formattedEmail; const urlWithTrailingSlash = Url.addTrailingForwardSlash(this.props.environmentURL); const url = isReport ? `${urlWithTrailingSlash}${ROUTES.getReportRoute(this.props.report.reportID)}` @@ -51,7 +52,6 @@ class ShareCodePage extends React.Component { const platform = getPlatform(); const isNative = platform === CONST.PLATFORM.IOS || platform === CONST.PLATFORM.ANDROID; - const formattedEmail = this.props.formatPhoneNumber(this.props.session.email); return ( @@ -65,8 +65,8 @@ class ShareCodePage extends React.Component { Navigation.goBack(ROUTES.HOME)} - backgroundColor={themeColors.PAGE_BACKGROUND_COLORS[ROUTES.I_KNOW_A_TEACHER]} illustration={LottieAnimations.SaveTheWorld} > diff --git a/src/pages/home/report/ContextMenu/BaseReportActionContextMenu.js b/src/pages/home/report/ContextMenu/BaseReportActionContextMenu.js index 3f4a5fe3ac4c..6382af6a898e 100755 --- a/src/pages/home/report/ContextMenu/BaseReportActionContextMenu.js +++ b/src/pages/home/report/ContextMenu/BaseReportActionContextMenu.js @@ -49,7 +49,9 @@ function BaseReportActionContextMenu(props) { const wrapperStyle = getReportActionContextMenuStyles(props.isMini, props.isSmallScreenWidth); const reportAction = useMemo(() => { - if (_.isEmpty(props.reportActions) || props.reportActionID === '0') return {}; + if (_.isEmpty(props.reportActions) || props.reportActionID === '0') { + return {}; + } return props.reportActions[props.reportActionID] || {}; }, [props.reportActions, props.reportActionID]); diff --git a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions.js b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions.js index 0257590908e8..ccf7a0a51518 100644 --- a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions.js +++ b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions.js @@ -246,6 +246,11 @@ function ComposerWithSuggestions({ return ''; } + // Since we're submitting the form here which should clear the composer + // We don't really care about saving the draft the user was typing + // We need to make sure an empty draft gets saved instead + debouncedSaveReportComment.cancel(); + updateComment(''); setTextInputShouldClear(true); if (isComposerFullSize) { diff --git a/src/pages/home/report/ReportActionCompose/ReportActionCompose.js b/src/pages/home/report/ReportActionCompose/ReportActionCompose.js index ddcd43cd8cd0..c5179290bf2c 100644 --- a/src/pages/home/report/ReportActionCompose/ReportActionCompose.js +++ b/src/pages/home/report/ReportActionCompose/ReportActionCompose.js @@ -30,7 +30,6 @@ import OfflineWithFeedback from '../../../../components/OfflineWithFeedback'; import SendButton from './SendButton'; import AttachmentPickerWithMenuItems from './AttachmentPickerWithMenuItems'; import ComposerWithSuggestions from './ComposerWithSuggestions'; -import debouncedSaveReportComment from '../../../../libs/ComposerUtils/debouncedSaveReportComment'; import reportActionPropTypes from '../reportActionPropTypes'; import useLocalize from '../../../../hooks/useLocalize'; import getModalState from '../../../../libs/getModalState'; @@ -220,10 +219,6 @@ function ReportActionCompose({ */ const addAttachment = useCallback( (file) => { - // Since we're submitting the form here which should clear the composer - // We don't really care about saving the draft the user was typing - // We need to make sure an empty draft gets saved instead - debouncedSaveReportComment.cancel(); const newComment = composerRef.current.prepareCommentAndResetComposer(); Report.addAttachment(reportID, file, newComment); setTextInputShouldClear(false); @@ -251,11 +246,6 @@ function ReportActionCompose({ e.preventDefault(); } - // Since we're submitting the form here which should clear the composer - // We don't really care about saving the draft the user was typing - // We need to make sure an empty draft gets saved instead - debouncedSaveReportComment.cancel(); - const newComment = composerRef.current.prepareCommentAndResetComposer(); if (!newComment) { return; diff --git a/src/pages/home/sidebar/SidebarScreen/index.js b/src/pages/home/sidebar/SidebarScreen/index.js index 705d9d1e2d08..08888352ddff 100755 --- a/src/pages/home/sidebar/SidebarScreen/index.js +++ b/src/pages/home/sidebar/SidebarScreen/index.js @@ -23,18 +23,28 @@ function SidebarScreen(props) { }, []), ); + /** + * Method to hide popover when dragover. + */ + const hidePopoverOnDragOver = useCallback(() => { + if (!popoverModal.current) { + return; + } + popoverModal.current.hideCreateMenu(); + }, []); + /** * Method create event listener */ const createDragoverListener = () => { - document.addEventListener('dragover', () => popoverModal.current.hideCreateMenu()); + document.addEventListener('dragover', hidePopoverOnDragOver); }; /** * Method remove event listener. */ const removeDragoverListener = () => { - document.removeEventListener('dragover', () => popoverModal.current.hideCreateMenu()); + document.removeEventListener('dragover', hidePopoverOnDragOver); }; return ( diff --git a/src/pages/iou/IOUCurrencySelection.js b/src/pages/iou/IOUCurrencySelection.js index 7a08a8261cbf..1238d8934f75 100644 --- a/src/pages/iou/IOUCurrencySelection.js +++ b/src/pages/iou/IOUCurrencySelection.js @@ -104,7 +104,7 @@ function IOUCurrencySelection(props) { }; }); - const searchRegex = new RegExp(Str.escapeForRegExp(searchValue), 'i'); + const searchRegex = new RegExp(Str.escapeForRegExp(searchValue.trim()), 'i'); const filteredCurrencies = _.filter(currencyOptions, (currencyOption) => searchRegex.test(currencyOption.text)); const isEmpty = searchValue.trim() && !filteredCurrencies.length; diff --git a/src/pages/iou/MoneyRequestTagPage.js b/src/pages/iou/MoneyRequestTagPage.js index a1795d50df8a..c9b5eb4f8f6f 100644 --- a/src/pages/iou/MoneyRequestTagPage.js +++ b/src/pages/iou/MoneyRequestTagPage.js @@ -5,6 +5,7 @@ import lodashGet from 'lodash/get'; import {withOnyx} from 'react-native-onyx'; import compose from '../../libs/compose'; import ROUTES from '../../ROUTES'; +import * as IOU from '../../libs/actions/IOU'; import Navigation from '../../libs/Navigation/Navigation'; import useLocalize from '../../hooks/useLocalize'; import ScreenWrapper from '../../components/ScreenWrapper'; @@ -15,6 +16,7 @@ import tagPropTypes from '../../components/tagPropTypes'; import ONYXKEYS from '../../ONYXKEYS'; import reportPropTypes from '../reportPropTypes'; import styles from '../../styles/styles'; +import {iouPropTypes, iouDefaultProps} from './propTypes'; const propTypes = { /** Navigation route context info provided by react navigation */ @@ -40,14 +42,18 @@ const propTypes = { tags: PropTypes.objectOf(tagPropTypes), }), ), + + /** Holds data related to Money Request view state, rather than the underlying Money Request data. */ + iou: iouPropTypes, }; const defaultProps = { report: {}, policyTags: {}, + iou: iouDefaultProps, }; -function MoneyRequestTagPage({route, report, policyTags}) { +function MoneyRequestTagPage({route, report, policyTags, iou}) { const {translate} = useLocalize(); const iouType = lodashGet(route, 'params.iouType', ''); @@ -55,10 +61,19 @@ function MoneyRequestTagPage({route, report, policyTags}) { // Fetches the first tag list of the policy const tagListKey = _.first(_.keys(policyTags)); const tagList = lodashGet(policyTags, tagListKey, {}); - const tagListName = lodashGet(tagList, 'name', ''); + const tagListName = lodashGet(tagList, 'name', translate('common.tag')); const navigateBack = () => { - Navigation.goBack(ROUTES.getMoneyRequestConfirmationRoute(iouType, lodashGet(report, 'reportID', ''))); + Navigation.goBack(ROUTES.getMoneyRequestConfirmationRoute(iouType, report.reportID)); + }; + + const updateTag = (selectedTag) => { + if (selectedTag.searchText === iou.tag) { + IOU.resetMoneyRequestTag(); + } else { + IOU.setMoneyRequestTag(selectedTag.searchText); + } + navigateBack(); }; return ( @@ -67,15 +82,15 @@ function MoneyRequestTagPage({route, report, policyTags}) { shouldEnableMaxHeight > - {translate('iou.tagSelection', {tagListName} || translate('common.tag'))} + {translate('iou.tagSelection', {tagName: tagListName})} ); @@ -87,16 +102,13 @@ MoneyRequestTagPage.defaultProps = defaultProps; export default compose( withOnyx({ + report: { + key: ({route}) => `${ONYXKEYS.COLLECTION.REPORT}${lodashGet(route, 'params.reportID')}`, + }, iou: { key: ONYXKEYS.IOU, }, }), - withOnyx({ - report: { - // Fetch report ID from IOU participants if no report ID is set in route - key: ({route, iou}) => `${ONYXKEYS.COLLECTION.REPORT}${lodashGet(route, 'params.reportID', '') || lodashGet(iou, 'participants.0.reportID', '')}`, - }, - }), withOnyx({ policyTags: { key: ({report}) => `${ONYXKEYS.COLLECTION.POLICY_TAGS}${report ? report.policyID : '0'}`, diff --git a/src/pages/iou/ReceiptSelector/index.js b/src/pages/iou/ReceiptSelector/index.js index 013d63dc4b98..eb6e2328afd2 100644 --- a/src/pages/iou/ReceiptSelector/index.js +++ b/src/pages/iou/ReceiptSelector/index.js @@ -1,6 +1,7 @@ import {View, Text, PixelRatio} from 'react-native'; import React, {useContext, useState} from 'react'; import lodashGet from 'lodash/get'; +import _ from 'underscore'; import PropTypes from 'prop-types'; import {withOnyx} from 'react-native-onyx'; import * as IOU from '../../../libs/actions/IOU'; @@ -15,22 +16,14 @@ import ReceiptDropUI from '../ReceiptDropUI'; import AttachmentPicker from '../../../components/AttachmentPicker'; import ConfirmModal from '../../../components/ConfirmModal'; import ONYXKEYS from '../../../ONYXKEYS'; -import Receipt from '../../../libs/actions/Receipt'; import useWindowDimensions from '../../../hooks/useWindowDimensions'; import useLocalize from '../../../hooks/useLocalize'; import {DragAndDropContext} from '../../../components/DragAndDrop/Provider'; -import * as ReceiptUtils from '../../../libs/ReceiptUtils'; import {iouPropTypes, iouDefaultProps} from '../propTypes'; +import * as FileUtils from '../../../libs/fileDownload/FileUtils'; import Navigation from '../../../libs/Navigation/Navigation'; const propTypes = { - /** Information shown to the user when a receipt is not valid */ - receiptModal: PropTypes.shape({ - isAttachmentInvalid: PropTypes.bool, - attachmentInvalidReasonTitle: PropTypes.string, - attachmentInvalidReason: PropTypes.string, - }), - /** The report on which the request is initiated on */ report: reportPropTypes, @@ -54,11 +47,6 @@ const propTypes = { }; const defaultProps = { - receiptModal: { - isAttachmentInvalid: false, - attachmentInvalidReasonTitle: '', - attachmentInvalidReason: '', - }, report: {}, iou: iouDefaultProps, transactionID: '', @@ -67,14 +55,50 @@ const defaultProps = { function ReceiptSelector(props) { const reportID = lodashGet(props.route, 'params.reportID', ''); const iouType = lodashGet(props.route, 'params.iouType', ''); - const isAttachmentInvalid = lodashGet(props.receiptModal, 'isAttachmentInvalid', false); - const attachmentInvalidReasonTitle = lodashGet(props.receiptModal, 'attachmentInvalidReasonTitle', ''); - const attachmentInvalidReason = lodashGet(props.receiptModal, 'attachmentInvalidReason', ''); + const [isAttachmentInvalid, setIsAttachmentInvalid] = useState(false); + const [attachmentInvalidReasonTitle, setAttachmentInvalidReasonTitle] = useState(''); + const [attachmentInvalidReason, setAttachmentValidReason] = useState(''); const [receiptImageTopPosition, setReceiptImageTopPosition] = useState(0); const {isSmallScreenWidth} = useWindowDimensions(); const {translate} = useLocalize(); const {isDraggingOver} = useContext(DragAndDropContext); + const hideReciptModal = () => { + setIsAttachmentInvalid(false); + }; + + /** + * Sets the upload receipt error modal content when an invalid receipt is uploaded + * @param {*} isInvalid + * @param {*} title + * @param {*} reason + */ + const setUploadReceiptError = (isInvalid, title, reason) => { + setIsAttachmentInvalid(isInvalid); + setAttachmentInvalidReasonTitle(title); + setAttachmentValidReason(reason); + }; + + function validateReceipt(file) { + const {fileExtension} = FileUtils.splitExtensionFromFileName(lodashGet(file, 'name', '')); + if (_.contains(CONST.API_ATTACHMENT_VALIDATIONS.UNALLOWED_EXTENSIONS, fileExtension.toLowerCase())) { + setUploadReceiptError(true, 'attachmentPicker.wrongFileType', 'attachmentPicker.notAllowedExtension'); + return false; + } + + if (lodashGet(file, 'size', 0) > CONST.API_ATTACHMENT_VALIDATIONS.MAX_SIZE) { + setUploadReceiptError(true, 'attachmentPicker.attachmentTooLarge', 'attachmentPicker.sizeExceeded'); + return false; + } + + if (lodashGet(file, 'size', 0) < CONST.API_ATTACHMENT_VALIDATIONS.MIN_SIZE) { + setUploadReceiptError(true, 'attachmentPicker.attachmentTooSmall', 'attachmentPicker.sizeNotMet'); + return false; + } + + return true; + } + /** * Sets the Receipt objects and navigates the user to the next page * @param {Object} file @@ -82,7 +106,7 @@ function ReceiptSelector(props) { * @param {Object} report */ const setReceiptAndNavigate = (file, iou, report) => { - if (!ReceiptUtils.validateReceipt(file)) { + if (!validateReceipt(file)) { return; } @@ -154,13 +178,12 @@ function ReceiptSelector(props) { /> ); @@ -172,7 +195,6 @@ ReceiptSelector.displayName = 'ReceiptSelector'; export default withOnyx({ iou: {key: ONYXKEYS.IOU}, - receiptModal: {key: ONYXKEYS.RECEIPT_MODAL}, report: { key: ({route}) => `${ONYXKEYS.COLLECTION.REPORT}${lodashGet(route, 'params.reportID', '')}`, }, diff --git a/src/pages/iou/WaypointEditor.js b/src/pages/iou/WaypointEditor.js index 68e85a34746d..e34730acccea 100644 --- a/src/pages/iou/WaypointEditor.js +++ b/src/pages/iou/WaypointEditor.js @@ -23,6 +23,7 @@ import * as Transaction from '../../libs/actions/Transaction'; import * as ValidationUtils from '../../libs/ValidationUtils'; import ROUTES from '../../ROUTES'; import transactionPropTypes from '../../components/transactionPropTypes'; +import * as ErrorUtils from '../../libs/ErrorUtils'; const propTypes = { /** The transactionID of the IOU */ @@ -104,18 +105,28 @@ function WaypointEditor({transactionID, route: {params: {iouType = '', waypointI const errors = {}; const waypointValue = values[`waypoint${waypointIndex}`] || ''; if (isOffline && waypointValue !== '' && !ValidationUtils.isValidAddress(waypointValue)) { - errors[`waypoint${waypointIndex}`] = 'bankAccount.error.address'; + ErrorUtils.addErrorMessage(errors, `waypoint${waypointIndex}`, 'bankAccount.error.address'); } // If the user is online and they are trying to save a value without using the autocomplete, show an error message instructing them to use a selected address instead. // That enables us to save the address with coordinates when it is selected if (!isOffline && waypointValue !== '' && waypointAddress !== waypointValue) { - errors[`waypoint${waypointIndex}`] = 'distance.errors.selectSuggestedAddress'; + ErrorUtils.addErrorMessage(errors, `waypoint${waypointIndex}`, 'distance.errors.selectSuggestedAddress'); } return errors; }; + const saveWaypoint = (waypoint) => { + if (parsedWaypointIndex < _.size(allWaypoints)) { + Transaction.saveWaypoint(transactionID, waypointIndex, waypoint); + } else { + const finishWaypoint = lodashGet(allWaypoints, `waypoint${_.size(allWaypoints) - 1}`, {}); + Transaction.saveWaypoint(transactionID, waypointIndex, finishWaypoint); + Transaction.saveWaypoint(transactionID, waypointIndex - 1, waypoint); + } + }; + const onSubmit = (values) => { const waypointValue = values[`waypoint${waypointIndex}`] || ''; @@ -132,8 +143,7 @@ function WaypointEditor({transactionID, route: {params: {iouType = '', waypointI lng: null, address: waypointValue, }; - - Transaction.saveWaypoint(transactionID, waypointIndex, waypoint); + saveWaypoint(waypoint); } // Other flows will be handled by selecting a waypoint with selectWaypoint as this is mainly for the offline flow @@ -152,8 +162,7 @@ function WaypointEditor({transactionID, route: {params: {iouType = '', waypointI lng: values.lng, address: values.address, }; - - Transaction.saveWaypoint(transactionID, waypointIndex, waypoint); + saveWaypoint(waypoint); Navigation.goBack(ROUTES.getMoneyRequestDistanceTabRoute(iouType)); }; @@ -163,7 +172,7 @@ function WaypointEditor({transactionID, route: {params: {iouType = '', waypointI onEntryTransitionEnd={() => textInput.current && textInput.current.focus()} shouldEnableMaxHeight > - waypointCount - 1) && isFocused}> + waypointCount) && isFocused}> (textInput.current = e)} - hint={!isOffline ? translate('distance.errors.selectSuggestedAddress') : ''} + hint={!isOffline ? 'distance.errors.selectSuggestedAddress' : ''} containerStyles={[styles.mt4]} label={translate('distance.address')} defaultValue={waypointAddress} diff --git a/src/pages/iou/steps/MoneyRequestAmountForm.js b/src/pages/iou/steps/MoneyRequestAmountForm.js index 1ea0b002b235..e08fd5bde881 100644 --- a/src/pages/iou/steps/MoneyRequestAmountForm.js +++ b/src/pages/iou/steps/MoneyRequestAmountForm.js @@ -12,6 +12,7 @@ import * as DeviceCapabilities from '../../../libs/DeviceCapabilities'; import TextInputWithCurrencySymbol from '../../../components/TextInputWithCurrencySymbol'; import useLocalize from '../../../hooks/useLocalize'; import CONST from '../../../CONST'; +import FormHelpMessage from '../../../components/FormHelpMessage'; import refPropTypes from '../../../components/refPropTypes'; import getOperatingSystem from '../../../libs/getOperatingSystem'; import * as Browser from '../../../libs/Browser'; @@ -57,6 +58,8 @@ const getNewSelection = (oldSelection, prevLength, newLength) => { return {start: cursorPosition, end: cursorPosition}; }; +const isAmountValid = (amount) => !amount.length || parseFloat(amount) < 0.01; + const AMOUNT_VIEW_ID = 'amountView'; const NUM_PAD_CONTAINER_VIEW_ID = 'numPadContainerView'; const NUM_PAD_VIEW_ID = 'numPadView'; @@ -70,6 +73,9 @@ function MoneyRequestAmountForm({amount, currency, isEditing, forwardedRef, onCu const selectedAmountAsString = amount ? CurrencyUtils.convertToFrontendAmount(amount).toString() : ''; const [currentAmount, setCurrentAmount] = useState(selectedAmountAsString); + const [isInvalidAmount, setIsInvalidAmount] = useState(isAmountValid(selectedAmountAsString)); + const [firstPress, setFirstPress] = useState(false); + const [formError, setFormError] = useState(''); const [shouldUpdateSelection, setShouldUpdateSelection] = useState(true); const [selection, setSelection] = useState({ @@ -127,6 +133,9 @@ function MoneyRequestAmountForm({amount, currency, isEditing, forwardedRef, onCu setSelection((prevSelection) => ({...prevSelection})); return; } + const checkInvalidAmount = isAmountValid(newAmountWithoutSpaces); + setIsInvalidAmount(checkInvalidAmount); + setFormError(checkInvalidAmount ? 'iou.error.invalidAmount' : ''); setCurrentAmount((prevAmount) => { const strippedAmount = MoneyRequestUtils.stripCommaFromAmount(newAmountWithoutSpaces); const isForwardDelete = prevAmount.length > strippedAmount.length && forwardDeletePressedRef.current; @@ -177,8 +186,13 @@ function MoneyRequestAmountForm({amount, currency, isEditing, forwardedRef, onCu * Submit amount and navigate to a proper page */ const submitAndNavigateToNextPage = useCallback(() => { + if (isInvalidAmount) { + setFirstPress(true); + setFormError('iou.error.invalidAmount'); + return; + } onSubmitButtonPress(currentAmount); - }, [onSubmitButtonPress, currentAmount]); + }, [onSubmitButtonPress, currentAmount, isInvalidAmount]); /** * Input handler to check for a forward-delete key (or keyboard shortcut) press. @@ -231,9 +245,16 @@ function MoneyRequestAmountForm({amount, currency, isEditing, forwardedRef, onCu onKeyPress={textInputKeyPress} /> + {!_.isEmpty(formError) && firstPress && ( + + )} onMouseDown(event, [NUM_PAD_CONTAINER_VIEW_ID, NUM_PAD_VIEW_ID])} - style={[styles.w100, styles.justifyContentEnd, styles.pageWrapper]} + style={[styles.w100, styles.justifyContentEnd, styles.pageWrapper, styles.pt0]} nativeID={NUM_PAD_CONTAINER_VIEW_ID} > {DeviceCapabilities.canUseTouchScreen() ? ( @@ -249,7 +270,6 @@ function MoneyRequestAmountForm({amount, currency, isEditing, forwardedRef, onCu style={[styles.w100, styles.mt5]} onPress={submitAndNavigateToNextPage} pressOnEnter - isDisabled={!currentAmount.length || parseFloat(currentAmount) < 0.01} text={buttonText} /> diff --git a/src/pages/iou/steps/MoneyRequestConfirmPage.js b/src/pages/iou/steps/MoneyRequestConfirmPage.js index 224f661915d8..93ee2c7f8aac 100644 --- a/src/pages/iou/steps/MoneyRequestConfirmPage.js +++ b/src/pages/iou/steps/MoneyRequestConfirmPage.js @@ -141,6 +141,7 @@ function MoneyRequestConfirmPage(props) { trimmedComment, receipt, props.iou.category, + props.iou.tag, props.iou.billable, ); }, @@ -153,6 +154,7 @@ function MoneyRequestConfirmPage(props) { props.currentUserPersonalDetails.login, props.currentUserPersonalDetails.accountID, props.iou.category, + props.iou.tag, props.iou.billable, ], ); @@ -170,12 +172,13 @@ function MoneyRequestConfirmPage(props) { props.iou.created, props.iou.transactionID, props.iou.category, + props.iou.tag, props.iou.amount, props.iou.currency, props.iou.merchant, ); }, - [props.report, props.iou.created, props.iou.transactionID, props.iou.category, props.iou.amount, props.iou.currency, props.iou.merchant], + [props.report, props.iou.created, props.iou.transactionID, props.iou.category, props.iou.tag, props.iou.amount, props.iou.currency, props.iou.merchant], ); const createTransaction = useCallback( diff --git a/src/pages/iou/steps/MoneyRequstParticipantsPage/MoneyRequestParticipantsPage.js b/src/pages/iou/steps/MoneyRequstParticipantsPage/MoneyRequestParticipantsPage.js index cb9f4cdd9b7f..b9ee016d4099 100644 --- a/src/pages/iou/steps/MoneyRequstParticipantsPage/MoneyRequestParticipantsPage.js +++ b/src/pages/iou/steps/MoneyRequstParticipantsPage/MoneyRequestParticipantsPage.js @@ -63,7 +63,19 @@ function MoneyRequestParticipantsPage({iou, selectedTab, route}) { setHeaderTitle(_.isEmpty(iou.participants) ? translate('tabSelector.manual') : translate('iou.split')); }, [iou.participants, isDistanceRequest, translate]); - const navigateToNextStep = (moneyRequestType) => { + const navigateToRequestStep = (moneyRequestType, option) => { + if (option.reportID) { + isNewReportIDSelectedLocally.current = true; + IOU.setMoneyRequestId(`${moneyRequestType}${option.reportID}`); + Navigation.navigate(ROUTES.getMoneyRequestConfirmationRoute(moneyRequestType, option.reportID)); + return; + } + + IOU.setMoneyRequestId(moneyRequestType); + Navigation.navigate(ROUTES.getMoneyRequestConfirmationRoute(moneyRequestType, reportID.current)); + }; + + const navigateToSplitStep = (moneyRequestType) => { IOU.setMoneyRequestId(moneyRequestType); Navigation.navigate(ROUTES.getMoneyRequestConfirmationRoute(moneyRequestType, reportID.current)); }; @@ -113,8 +125,8 @@ function MoneyRequestParticipantsPage({iou, selectedTab, route}) { ref={(el) => (optionsSelectorRef.current = el)} participants={iou.participants} onAddParticipants={IOU.setMoneyRequestParticipants} - navigateToRequest={() => navigateToNextStep(CONST.IOU.MONEY_REQUEST_TYPE.REQUEST)} - navigateToSplit={() => navigateToNextStep(CONST.IOU.MONEY_REQUEST_TYPE.SPLIT)} + navigateToRequest={(option) => navigateToRequestStep(CONST.IOU.MONEY_REQUEST_TYPE.REQUEST, option)} + navigateToSplit={() => navigateToSplitStep(CONST.IOU.MONEY_REQUEST_TYPE.SPLIT)} safeAreaPaddingBottomStyle={safeAreaPaddingBottomStyle} iouType={iouType.current} isDistanceRequest={isDistanceRequest} diff --git a/src/pages/iou/steps/MoneyRequstParticipantsPage/MoneyRequestParticipantsSelector.js b/src/pages/iou/steps/MoneyRequstParticipantsPage/MoneyRequestParticipantsSelector.js index 4f761e92eaf5..170ee042bffa 100755 --- a/src/pages/iou/steps/MoneyRequstParticipantsPage/MoneyRequestParticipantsSelector.js +++ b/src/pages/iou/steps/MoneyRequstParticipantsPage/MoneyRequestParticipantsSelector.js @@ -158,8 +158,10 @@ function MoneyRequestParticipantsSelector({ * @param {Object} option */ const addSingleParticipant = (option) => { - onAddParticipants([{accountID: option.accountID, login: option.login, isPolicyExpenseChat: option.isPolicyExpenseChat, reportID: option.reportID, selected: true}]); - navigateToRequest(); + onAddParticipants([ + {accountID: option.accountID, login: option.login, isPolicyExpenseChat: option.isPolicyExpenseChat, reportID: option.reportID, selected: true, searchText: option.searchText}, + ]); + navigateToRequest(option); }; /** @@ -187,13 +189,20 @@ function MoneyRequestParticipantsSelector({ } else { newSelectedOptions = [ ...participants, - {accountID: option.accountID, login: option.login, isPolicyExpenseChat: option.isPolicyExpenseChat, reportID: option.reportID, selected: true}, + { + accountID: option.accountID, + login: option.login, + isPolicyExpenseChat: option.isPolicyExpenseChat, + reportID: option.reportID, + selected: true, + searchText: option.searchText, + }, ]; } onAddParticipants(newSelectedOptions); - const chatOptions = OptionsListUtils.getNewChatOptions( + const chatOptions = OptionsListUtils.getFilteredOptions( reports, personalDetails, betas, @@ -223,12 +232,12 @@ function MoneyRequestParticipantsSelector({ Boolean(newChatOptions.userToInvite), searchTerm.trim(), maxParticipantsReached, - _.some(participants, (participant) => participant.login.toLowerCase().includes(searchTerm.trim().toLowerCase())), + _.some(participants, (participant) => participant.searchText.toLowerCase().includes(searchTerm.trim().toLowerCase())), ); const isOptionsDataReady = ReportUtils.isReportDataReady() && OptionsListUtils.isPersonalDetailsReady(personalDetails); useEffect(() => { - const chatOptions = OptionsListUtils.getNewChatOptions( + const chatOptions = OptionsListUtils.getFilteredOptions( reports, personalDetails, betas, diff --git a/src/pages/iou/steps/NewRequestAmountPage.js b/src/pages/iou/steps/NewRequestAmountPage.js index e703c7f2f24f..6e44d8d57e32 100644 --- a/src/pages/iou/steps/NewRequestAmountPage.js +++ b/src/pages/iou/steps/NewRequestAmountPage.js @@ -67,7 +67,7 @@ function NewRequestAmountPage({route, iou, report, selectedTab}) { const currentCurrency = lodashGet(route, 'params.currency', ''); const isDistanceRequestTab = MoneyRequestUtils.isDistanceRequest(iouType, selectedTab); - const currency = currentCurrency || iou.currency; + const currency = CurrencyUtils.isValidCurrencyCode(currentCurrency) ? currentCurrency : iou.currency; const focusTextInput = () => { // Component may not be initialized due to navigation transitions diff --git a/src/pages/settings/InitialSettingsPage.js b/src/pages/settings/InitialSettingsPage.js index a67e7cbc122e..d10779210b09 100755 --- a/src/pages/settings/InitialSettingsPage.js +++ b/src/pages/settings/InitialSettingsPage.js @@ -1,6 +1,6 @@ import lodashGet from 'lodash/get'; import React, {useState, useEffect, useRef, useMemo, useCallback} from 'react'; -import {View, ScrollView} from 'react-native'; +import {View} from 'react-native'; import PropTypes from 'prop-types'; import _ from 'underscore'; import {withOnyx} from 'react-native-onyx'; @@ -12,11 +12,11 @@ import * as Session from '../../libs/actions/Session'; import ONYXKEYS from '../../ONYXKEYS'; import Tooltip from '../../components/Tooltip'; import Avatar from '../../components/Avatar'; -import HeaderWithBackButton from '../../components/HeaderWithBackButton'; import Navigation from '../../libs/Navigation/Navigation'; import * as Expensicons from '../../components/Icon/Expensicons'; -import ScreenWrapper from '../../components/ScreenWrapper'; import MenuItem from '../../components/MenuItem'; +import themeColors from '../../styles/themes/default'; +import SCREENS from '../../SCREENS'; import ROUTES from '../../ROUTES'; import withLocalize, {withLocalizePropTypes} from '../../components/withLocalize'; import compose from '../../libs/compose'; @@ -43,6 +43,7 @@ import PressableWithoutFeedback from '../../components/Pressable/PressableWithou import useLocalize from '../../hooks/useLocalize'; import useSingleExecution from '../../hooks/useSingleExecution'; import useWaitForNavigation from '../../hooks/useWaitForNavigation'; +import HeaderPageLayout from '../../components/HeaderPageLayout'; const propTypes = { /* Onyx Props */ @@ -326,79 +327,80 @@ function InitialSettingsPage(props) { if (_.isEmpty(props.currentUserPersonalDetails)) { return null; } - - return ( - - {({safeAreaPaddingBottomStyle}) => ( + const headerContent = ( + + {_.isEmpty(props.currentUserPersonalDetails) || _.isUndefined(props.currentUserPersonalDetails.displayName) ? ( + + ) : ( <> - - + + + + + + + - - {_.isEmpty(props.currentUserPersonalDetails) || _.isUndefined(props.currentUserPersonalDetails.displayName) ? ( - - ) : ( - - - - - - - - - - - - {props.currentUserPersonalDetails.displayName ? props.currentUserPersonalDetails.displayName : props.formatPhoneNumber(props.session.email)} - - - - {Boolean(props.currentUserPersonalDetails.displayName) && ( - - {props.formatPhoneNumber(props.session.email)} - - )} - - )} - {getMenuItems} - - signOut(true)} - onCancel={() => toggleSignoutConfirmModal(false)} - /> - - + + + {props.currentUserPersonalDetails.displayName ? props.currentUserPersonalDetails.displayName : props.formatPhoneNumber(props.session.email)} + + + + {Boolean(props.currentUserPersonalDetails.displayName) && ( + + {props.formatPhoneNumber(props.session.email)} + + )} )} - + + ); + + return ( + + + {getMenuItems} + signOut(true)} + onCancel={() => toggleSignoutConfirmModal(false)} + /> + + ); } diff --git a/src/pages/settings/Profile/CustomStatus/StatusPage.js b/src/pages/settings/Profile/CustomStatus/StatusPage.js index dcd356978bd3..807bd73cecc1 100644 --- a/src/pages/settings/Profile/CustomStatus/StatusPage.js +++ b/src/pages/settings/Profile/CustomStatus/StatusPage.js @@ -4,7 +4,7 @@ import {withOnyx} from 'react-native-onyx'; import lodashGet from 'lodash/get'; import withCurrentUserPersonalDetails, {withCurrentUserPersonalDetailsPropTypes} from '../../../../components/withCurrentUserPersonalDetails'; import MenuItemWithTopDescription from '../../../../components/MenuItemWithTopDescription'; -import StaticHeaderPageLayout from '../../../../components/StaticHeaderPageLayout'; +import HeaderPageLayout from '../../../../components/HeaderPageLayout'; import * as Expensicons from '../../../../components/Icon/Expensicons'; import withLocalize from '../../../../components/withLocalize'; import MenuItem from '../../../../components/MenuItem'; @@ -19,6 +19,7 @@ import styles from '../../../../styles/styles'; import compose from '../../../../libs/compose'; import ONYXKEYS from '../../../../ONYXKEYS'; import ROUTES from '../../../../ROUTES'; +import SCREENS from '../../../../SCREENS'; const propTypes = { ...withCurrentUserPersonalDetailsPropTypes, @@ -63,11 +64,17 @@ function StatusPage({draftStatus, currentUserPersonalDetails}) { useEffect(() => () => User.clearDraftCustomStatus(), []); return ( - + } + headerContainerStyles={[styles.staticHeaderImage]} + backgroundColor={themeColors.PAGE_BACKGROUND_COLORS[SCREENS.SETTINGS.STATUS]} footer={footerComponent} > @@ -91,7 +98,7 @@ function StatusPage({draftStatus, currentUserPersonalDetails}) { wrapperStyle={[styles.cardMenuItem]} /> )} - + ); } diff --git a/src/pages/settings/Security/SecuritySettingsPage.js b/src/pages/settings/Security/SecuritySettingsPage.js index 7f08247557f4..293e488ede7a 100644 --- a/src/pages/settings/Security/SecuritySettingsPage.js +++ b/src/pages/settings/Security/SecuritySettingsPage.js @@ -5,6 +5,7 @@ import {withOnyx} from 'react-native-onyx'; import PropTypes from 'prop-types'; import Navigation from '../../../libs/Navigation/Navigation'; import ROUTES from '../../../ROUTES'; +import SCREENS from '../../../SCREENS'; import styles from '../../../styles/styles'; import * as Expensicons from '../../../components/Icon/Expensicons'; import themeColors from '../../../styles/themes/default'; @@ -54,7 +55,7 @@ function SecuritySettingsPage(props) { shouldShowBackButton shouldShowCloseButton illustration={LottieAnimations.Safe} - backgroundColor={themeColors.PAGE_BACKGROUND_COLORS[ROUTES.SETTINGS_SECURITY]} + backgroundColor={themeColors.PAGE_BACKGROUND_COLORS[SCREENS.SETTINGS.SECURITY]} > diff --git a/src/pages/settings/Security/TwoFactorAuth/Steps/CodesStep.js b/src/pages/settings/Security/TwoFactorAuth/Steps/CodesStep.js index 52d7a9806f69..7aa7a8ab64c1 100644 --- a/src/pages/settings/Security/TwoFactorAuth/Steps/CodesStep.js +++ b/src/pages/settings/Security/TwoFactorAuth/Steps/CodesStep.js @@ -1,4 +1,4 @@ -import React, {useEffect} from 'react'; +import React, {useEffect, useState} from 'react'; import {withOnyx} from 'react-native-onyx'; import {ActivityIndicator, View} from 'react-native'; import {ScrollView} from 'react-native-gesture-handler'; @@ -23,10 +23,12 @@ import useWindowDimensions from '../../../../../hooks/useWindowDimensions'; import StepWrapper from '../StepWrapper/StepWrapper'; import {defaultAccount, TwoFactorAuthPropTypes} from '../TwoFactorAuthPropTypes'; import * as TwoFactorAuthActions from '../../../../../libs/actions/TwoFactorAuthActions'; +import FormHelpMessage from '../../../../../components/FormHelpMessage'; function CodesStep({account = defaultAccount}) { const {translate} = useLocalize(); const {isExtraSmallScreenWidth, isSmallScreenWidth} = useWindowDimensions(); + const [error, setError] = useState(''); const {setStep} = useTwoFactorAuthContext(); @@ -83,6 +85,7 @@ function CodesStep({account = defaultAccount}) { inline={false} onPress={() => { Clipboard.setString(account.recoveryCodes); + setError(''); TwoFactorAuthActions.setCodesAreCopied(); }} styles={[styles.button, styles.buttonMedium, styles.twoFactorAuthCodesButton]} @@ -93,6 +96,7 @@ function CodesStep({account = defaultAccount}) { icon={Expensicons.Download} onPress={() => { localFileDownload('two-factor-auth-codes', account.recoveryCodes); + setError(''); TwoFactorAuthActions.setCodesAreCopied(); }} inline={false} @@ -106,11 +110,23 @@ function CodesStep({account = defaultAccount}) { + {!_.isEmpty(error) && ( + + )}