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/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 64abe30d6187..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": { diff --git a/package.json b/package.json index 6f0d4d70f768..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.", 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 5c8cd1b8f038..dcd5ac1a8db7 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, 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/index.js b/src/components/Attachments/AttachmentCarousel/index.js index dde75c7caad6..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); @@ -132,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 @@ -226,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/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/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/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 9303f078e823..e0dce180043b 100644 --- a/src/components/ImageView/index.js +++ b/src/components/ImageView/index.js @@ -143,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) { @@ -227,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/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/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/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/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/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/OptionsListUtils.js b/src/libs/OptionsListUtils.js index 45bc6dffd67a..3c6e879bd423 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, }; } 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/ReportUtils.js b/src/libs/ReportUtils.js index 5e328a156a23..edf646d0266b 100644 --- a/src/libs/ReportUtils.js +++ b/src/libs/ReportUtils.js @@ -1817,7 +1817,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 @@ -1825,7 +1825,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); } /** @@ -2646,11 +2646,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, @@ -2662,6 +2663,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/__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/Report.js b/src/libs/actions/Report.js index 55b03110e925..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); } 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/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/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/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/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/MoneyRequstParticipantsPage/MoneyRequestParticipantsSelector.js b/src/pages/iou/steps/MoneyRequstParticipantsPage/MoneyRequestParticipantsSelector.js index 77ead4bf5a85..a8da1b4ec9ed 100755 --- a/src/pages/iou/steps/MoneyRequstParticipantsPage/MoneyRequestParticipantsSelector.js +++ b/src/pages/iou/steps/MoneyRequstParticipantsPage/MoneyRequestParticipantsSelector.js @@ -158,7 +158,9 @@ function MoneyRequestParticipantsSelector({ * @param {Object} option */ const addSingleParticipant = (option) => { - onAddParticipants([{accountID: option.accountID, login: option.login, isPolicyExpenseChat: option.isPolicyExpenseChat, reportID: option.reportID, selected: true}]); + onAddParticipants([ + {accountID: option.accountID, login: option.login, isPolicyExpenseChat: option.isPolicyExpenseChat, reportID: option.reportID, selected: true, searchText: option.searchText}, + ]); navigateToRequest(); }; @@ -187,7 +189,14 @@ 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, + }, ]; } @@ -223,7 +232,7 @@ 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); 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) && ( + + )}