diff --git a/docs/_sass/_search-bar.scss b/docs/_sass/_search-bar.scss index e9c56835af50..c2185ef8f36a 100644 --- a/docs/_sass/_search-bar.scss +++ b/docs/_sass/_search-bar.scss @@ -210,9 +210,7 @@ label.search-label { width: auto; } -/* Change the path of the Google Search Button icon into Expensify icon */ .gsc-search-button.gsc-search-button-v2 svg path { - d: path('M8 1c3.9 0 7 3.1 7 7 0 1.4-.4 2.7-1.1 3.8l5.2 5.2c.6.6.6 1.5 0 2.1-.6.6-1.5.6-2.1 0l-5.2-5.2C10.7 14.6 9.4 15 8 15c-3.9 0-7-3.1-7-7s3.1-7 7-7zm0 3c2.2 0 4 1.8 4 4s-1.8 4-4 4-4-1.8-4-4 1.8-4 4-4z'); fill-rule: evenodd; clip-rule: evenodd; } diff --git a/docs/assets/js/main.js b/docs/assets/js/main.js index bc816a7dd2cc..dde3af22e900 100644 --- a/docs/assets/js/main.js +++ b/docs/assets/js/main.js @@ -123,25 +123,12 @@ function changeSVGViewBoxGoogle() { // Get all inline Google SVG elements on the page const svgsGoogle = document.querySelectorAll('svg'); - // Create a media query for screens wider than tablet - const mediaQuery = window.matchMedia('(min-width: 800px)'); - - // Check if the viewport is smaller than tablet - if (!mediaQuery.matches) { - Array.from(svgsGoogle).forEach((svg) => { - // Set the viewBox attribute to '0 0 13 13' to make the svg fit in the mobile view - svg.setAttribute('viewBox', '0 0 13 13'); - svg.setAttribute('height', '13'); - svg.setAttribute('width', '13'); - }); - } else { - Array.from(svgsGoogle).forEach((svg) => { - // Set the viewBox attribute to '0 0 20 20' to make the svg fit in the tablet-desktop view - svg.setAttribute('viewBox', '0 0 20 20'); - svg.setAttribute('height', '16'); - svg.setAttribute('width', '16'); - }); - } + Array.from(svgsGoogle).forEach((svg) => { + // Set the viewBox attribute to '0 0 13 13' to make the svg fit in the mobile view + svg.setAttribute('viewBox', '0 0 20 20'); + svg.setAttribute('height', '16'); + svg.setAttribute('width', '16'); + }); } // Function to insert element after another @@ -150,11 +137,23 @@ function insertElementAfter(referenceNode, newNode) { referenceNode.parentNode.insertBefore(newNode, referenceNode.nextSibling); } +// Update the ICON for search input. +/* Change the path of the Google Search Button icon into Expensify icon */ +function updateGoogleSearchIcon() { + const node = document.querySelector('.gsc-search-button.gsc-search-button-v2 svg path'); + node.setAttribute( + 'd', + 'M8 1c3.9 0 7 3.1 7 7 0 1.4-.4 2.7-1.1 3.8l5.2 5.2c.6.6.6 1.5 0 2.1-.6.6-1.5.6-2.1 0l-5.2-5.2C10.7 14.6 9.4 15 8 15c-3.9 0-7-3.1-7-7s3.1-7 7-7zm0 3c2.2 0 4 1.8 4 4s-1.8 4-4 4-4-1.8-4-4 1.8-4 4-4z', + ); +} + // Need to wait up until page is load, so the svg viewBox can be changed // And the search label can be inserted window.addEventListener('load', () => { changeSVGViewBoxGoogle(); + updateGoogleSearchIcon(); + // Add required into the search input const searchInput = document.getElementById('gsc-i-id1'); searchInput.setAttribute('required', ''); diff --git a/package-lock.json b/package-lock.json index 2b32065e96bb..b34cde433719 100644 --- a/package-lock.json +++ b/package-lock.json @@ -74,7 +74,7 @@ "react-native": "0.72.4", "react-native-android-location-enabler": "^1.2.2", "react-native-blob-util": "^0.17.3", - "react-native-collapsible": "^1.6.0", + "react-native-collapsible": "^1.6.1", "react-native-config": "^1.4.5", "react-native-dev-menu": "^4.1.1", "react-native-device-info": "^10.3.0", @@ -44080,8 +44080,9 @@ } }, "node_modules/react-native-collapsible": { - "version": "1.6.0", - "license": "MIT", + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/react-native-collapsible/-/react-native-collapsible-1.6.1.tgz", + "integrity": "sha512-orF4BeiXd2hZW7fu9YcqIJXzN6TJcFcddY807D3MAOVktLuW9oQ+RIkrTJ5DR3v9ZOFfREkOjEmS79qeUTvkBQ==", "peerDependencies": { "react": "*", "react-native": "*" @@ -84475,7 +84476,9 @@ "dev": true }, "react-native-collapsible": { - "version": "1.6.0", + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/react-native-collapsible/-/react-native-collapsible-1.6.1.tgz", + "integrity": "sha512-orF4BeiXd2hZW7fu9YcqIJXzN6TJcFcddY807D3MAOVktLuW9oQ+RIkrTJ5DR3v9ZOFfREkOjEmS79qeUTvkBQ==", "requires": {} }, "react-native-config": { diff --git a/package.json b/package.json index c72d52ab4bba..e68f10940d6a 100644 --- a/package.json +++ b/package.json @@ -121,7 +121,7 @@ "react-native": "0.72.4", "react-native-android-location-enabler": "^1.2.2", "react-native-blob-util": "^0.17.3", - "react-native-collapsible": "^1.6.0", + "react-native-collapsible": "^1.6.1", "react-native-config": "^1.4.5", "react-native-dev-menu": "^4.1.1", "react-native-device-info": "^10.3.0", diff --git a/patches/react-native-web+0.19.9+004+fix-pointer-events.patch b/patches/react-native-web+0.19.9+004+fix-pointer-events.patch new file mode 100644 index 000000000000..a457fbcfe36c --- /dev/null +++ b/patches/react-native-web+0.19.9+004+fix-pointer-events.patch @@ -0,0 +1,22 @@ +diff --git a/node_modules/react-native-web/dist/exports/StyleSheet/compiler/index.js b/node_modules/react-native-web/dist/exports/StyleSheet/compiler/index.js +index bdcecc2..63f1364 100644 +--- a/node_modules/react-native-web/dist/exports/StyleSheet/compiler/index.js ++++ b/node_modules/react-native-web/dist/exports/StyleSheet/compiler/index.js +@@ -353,7 +353,7 @@ function createAtomicRules(identifier, property, value) { + var _block2 = createDeclarationBlock({ + pointerEvents: 'none' + }); +- rules.push(selector + ">*" + _block2); ++ rules.push(selector + " *" + _block2); + } + } else if (value === 'none' || value === 'box-none') { + finalValue = 'none!important'; +@@ -361,7 +361,7 @@ function createAtomicRules(identifier, property, value) { + var _block3 = createDeclarationBlock({ + pointerEvents: 'auto' + }); +- rules.push(selector + ">*" + _block3); ++ rules.push(selector + " *" + _block3); + } + } + var _block4 = createDeclarationBlock({ diff --git a/src/components/AddressSearch/index.js b/src/components/AddressSearch/index.js index 4e9f9ddaf696..61460a93650e 100644 --- a/src/components/AddressSearch/index.js +++ b/src/components/AddressSearch/index.js @@ -229,6 +229,7 @@ function AddressSearch(props) { street2: subpremise, // Make sure country is updated first, since city and state will be reset if the country changes country: '', + state: state || stateAutoCompleteFallback, // When locality is not returned, many countries return the city as postalTown (e.g. 5 New Street // Square, London), otherwise as sublocality (e.g. 384 Court Street Brooklyn). If postalTown is // returned, the sublocality will be a city subdivision so shouldn't take precedence (e.g. @@ -236,7 +237,6 @@ function AddressSearch(props) { city: locality || postalTown || sublocality || cityAutocompleteFallback, zipCode, - state: state || stateAutoCompleteFallback, lat: lodashGet(details, 'geometry.location.lat', 0), lng: lodashGet(details, 'geometry.location.lng', 0), address: lodashGet(details, 'formatted_address', ''), diff --git a/src/components/CollapsibleSection/Collapsible/index.js b/src/components/CollapsibleSection/Collapsible/index.js deleted file mode 100644 index 51d650ed5748..000000000000 --- a/src/components/CollapsibleSection/Collapsible/index.js +++ /dev/null @@ -1,3 +0,0 @@ -import Collapsible from 'react-collapse'; - -export default Collapsible; diff --git a/src/components/CollapsibleSection/Collapsible/index.native.js b/src/components/CollapsibleSection/Collapsible/index.native.js deleted file mode 100644 index 9b800304beeb..000000000000 --- a/src/components/CollapsibleSection/Collapsible/index.native.js +++ /dev/null @@ -1,24 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import CollapsibleRN from 'react-native-collapsible'; - -const propTypes = { - /** Whether the section should start expanded. False by default */ - isOpened: PropTypes.bool, - - /** Children to display inside the Collapsible component */ - children: PropTypes.node.isRequired, -}; - -const defaultProps = { - isOpened: false, -}; - -function Collapsible(props) { - return {props.children}; -} - -Collapsible.displayName = 'Collapsible'; -Collapsible.propTypes = propTypes; -Collapsible.defaultProps = defaultProps; -export default Collapsible; diff --git a/src/components/CollapsibleSection/Collapsible/index.native.tsx b/src/components/CollapsibleSection/Collapsible/index.native.tsx new file mode 100644 index 000000000000..e8d3dc9439d0 --- /dev/null +++ b/src/components/CollapsibleSection/Collapsible/index.native.tsx @@ -0,0 +1,10 @@ +import React from 'react'; +import CollapsibleRN from 'react-native-collapsible'; +import CollapsibleProps from './types'; + +function Collapsible({isOpened = false, children}: CollapsibleProps) { + return {children}; +} + +Collapsible.displayName = 'Collapsible'; +export default Collapsible; diff --git a/src/components/CollapsibleSection/Collapsible/index.tsx b/src/components/CollapsibleSection/Collapsible/index.tsx new file mode 100644 index 000000000000..2585fd92f42b --- /dev/null +++ b/src/components/CollapsibleSection/Collapsible/index.tsx @@ -0,0 +1,8 @@ +import React from 'react'; +import {Collapse} from 'react-collapse'; +import CollapsibleProps from './types'; + +function Collapsible({isOpened = false, children}: CollapsibleProps) { + return {children}; +} +export default Collapsible; diff --git a/src/components/CollapsibleSection/Collapsible/types.ts b/src/components/CollapsibleSection/Collapsible/types.ts new file mode 100644 index 000000000000..8b8e8aba6860 --- /dev/null +++ b/src/components/CollapsibleSection/Collapsible/types.ts @@ -0,0 +1,8 @@ +import ChildrenProps from '@src/types/utils/ChildrenProps'; + +type CollapsibleProps = ChildrenProps & { + /** Whether the section should start expanded. False by default */ + isOpened?: boolean; +}; + +export default CollapsibleProps; diff --git a/src/components/CollapsibleSection/index.js b/src/components/CollapsibleSection/index.js deleted file mode 100644 index 46210e57f543..000000000000 --- a/src/components/CollapsibleSection/index.js +++ /dev/null @@ -1,70 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import {View} from 'react-native'; -import Icon from '@components/Icon'; -import * as Expensicons from '@components/Icon/Expensicons'; -import PressableWithFeedback from '@components/Pressable/PressableWithFeedback'; -import Text from '@components/Text'; -import styles from '@styles/styles'; -import CONST from '@src/CONST'; -import Collapsible from './Collapsible'; - -const propTypes = { - /** Title of the Collapsible section */ - title: PropTypes.string.isRequired, - - /** Children to display inside the Collapsible component */ - children: PropTypes.node.isRequired, -}; - -class CollapsibleSection extends React.Component { - constructor(props) { - super(props); - this.toggleSection = this.toggleSection.bind(this); - this.state = { - isExpanded: false, - }; - } - - /** - * Expands/collapses the section - */ - toggleSection() { - this.setState((prevState) => ({ - isExpanded: !prevState.isExpanded, - })); - } - - render() { - const src = this.state.isExpanded ? Expensicons.UpArrow : Expensicons.DownArrow; - - return ( - - - - {this.props.title} - - - - - - - {this.props.children} - - - ); - } -} - -CollapsibleSection.propTypes = propTypes; -export default CollapsibleSection; diff --git a/src/components/CollapsibleSection/index.tsx b/src/components/CollapsibleSection/index.tsx new file mode 100644 index 000000000000..434017f2a547 --- /dev/null +++ b/src/components/CollapsibleSection/index.tsx @@ -0,0 +1,55 @@ +import React, {useState} from 'react'; +import {View} from 'react-native'; +import Icon from '@components/Icon'; +import * as Expensicons from '@components/Icon/Expensicons'; +import PressableWithFeedback from '@components/Pressable/PressableWithFeedback'; +import Text from '@components/Text'; +import styles from '@styles/styles'; +import CONST from '@src/CONST'; +import ChildrenProps from '@src/types/utils/ChildrenProps'; +import Collapsible from './Collapsible'; + +type CollapsibleSectionProps = ChildrenProps & { + /** Title of the Collapsible section */ + title: string; +}; + +function CollapsibleSection({title, children}: CollapsibleSectionProps) { + const [isExpanded, setIsExpanded] = useState(false); + + /** + * Expands/collapses the section + */ + const toggleSection = () => { + setIsExpanded(!isExpanded); + }; + + const src = isExpanded ? Expensicons.UpArrow : Expensicons.DownArrow; + + return ( + + + + {title} + + + + + + {children} + + + ); +} + +export default CollapsibleSection; diff --git a/src/components/MentionSuggestions.js b/src/components/MentionSuggestions.tsx similarity index 64% rename from src/components/MentionSuggestions.js rename to src/components/MentionSuggestions.tsx index d18b8947e68d..2d0f3bf32b41 100644 --- a/src/components/MentionSuggestions.js +++ b/src/components/MentionSuggestions.tsx @@ -1,73 +1,61 @@ -import PropTypes from 'prop-types'; import React from 'react'; import {View} from 'react-native'; -import _ from 'underscore'; import getStyledTextArray from '@libs/GetStyledTextArray'; import styles from '@styles/styles'; import * as StyleUtils from '@styles/StyleUtils'; import themeColors from '@styles/themes/default'; import CONST from '@src/CONST'; +import {Icon} from '@src/types/onyx/OnyxCommon'; import AutoCompleteSuggestions from './AutoCompleteSuggestions'; import Avatar from './Avatar'; -import avatarPropTypes from './avatarPropTypes'; import Text from './Text'; -const propTypes = { - /** The index of the highlighted mention */ - highlightedMentionIndex: PropTypes.number, +type Mention = { + /** Display name of the user */ + text: string; - /** Array of suggested mentions */ - mentions: PropTypes.arrayOf( - PropTypes.shape({ - /** Display name of the user */ - text: PropTypes.string, + /** Email/phone number of the user */ + alternateText: string; + + /** Array of icons of the user. We use the first element of this array */ + icons: Icon[]; +}; - /** Email/phone number of the user */ - alternateText: PropTypes.string, +type MentionSuggestionsProps = { + /** The index of the highlighted mention */ + highlightedMentionIndex?: number; - /** Array of icons of the user. We use the first element of this array */ - icons: PropTypes.arrayOf(avatarPropTypes), - }), - ).isRequired, + /** Array of suggested mentions */ + mentions: Mention[]; /** Fired when the user selects an mention */ - onSelect: PropTypes.func.isRequired, + onSelect: () => void; /** Mention prefix that follows the @ sign */ - prefix: PropTypes.string.isRequired, + prefix: string; /** Show that we can use large mention picker. * Depending on available space and whether the input is expanded, we can have a small or large mention suggester. * When this value is false, the suggester will have a height of 2.5 items. When this value is true, the height can be up to 5 items. */ - isMentionPickerLarge: PropTypes.bool.isRequired, + isMentionPickerLarge: boolean; /** Meaures the parent container's position and dimensions. */ - measureParentContainer: PropTypes.func, -}; - -const defaultProps = { - highlightedMentionIndex: 0, - measureParentContainer: () => {}, + measureParentContainer: () => void; }; /** * Create unique keys for each mention item - * @param {Object} item - * @param {Number} index - * @returns {String} */ -const keyExtractor = (item) => item.alternateText; +const keyExtractor = (item: Mention) => item.alternateText; -function MentionSuggestions(props) { +function MentionSuggestions({prefix, mentions, highlightedMentionIndex = 0, onSelect, isMentionPickerLarge, measureParentContainer = () => {}}: MentionSuggestionsProps) { /** * Render a suggestion menu item component. - * @param {Object} item - * @returns {JSX.Element} */ - const renderSuggestionMenuItem = (item) => { + const renderSuggestionMenuItem = (item: Mention) => { const isIcon = item.text === CONST.AUTO_COMPLETE_SUGGESTER.HERE_TEXT; - const styledDisplayName = getStyledTextArray(item.text, props.prefix); - const styledHandle = item.text === item.alternateText ? '' : getStyledTextArray(item.alternateText, props.prefix); + const styledDisplayName = getStyledTextArray(item.text, prefix); + const styledHandle = item.text === item.alternateText ? undefined : getStyledTextArray(item.alternateText, prefix); return ( @@ -85,8 +73,9 @@ function MentionSuggestions(props) { style={[styles.mentionSuggestionsText, styles.flexShrink1]} numberOfLines={1} > - {_.map(styledDisplayName, ({text, isColored}, i) => ( + {styledDisplayName?.map(({text, isColored}, i) => ( @@ -98,13 +87,13 @@ function MentionSuggestions(props) { style={[styles.mentionSuggestionsText, styles.flex1]} numberOfLines={1} > - {_.map( - styledHandle, + {styledHandle?.map( ({text, isColored}, i) => - text !== '' && ( + Boolean(text) && ( {text} @@ -117,20 +106,18 @@ function MentionSuggestions(props) { return ( ); } -MentionSuggestions.propTypes = propTypes; -MentionSuggestions.defaultProps = defaultProps; MentionSuggestions.displayName = 'MentionSuggestions'; export default MentionSuggestions; diff --git a/src/components/Modal/BaseModal.js b/src/components/Modal/BaseModal.tsx similarity index 79% rename from src/components/Modal/BaseModal.js rename to src/components/Modal/BaseModal.tsx index bf1fdc8ee7de..e428b062798f 100644 --- a/src/components/Modal/BaseModal.js +++ b/src/components/Modal/BaseModal.tsx @@ -1,4 +1,3 @@ -import PropTypes from 'prop-types'; import React, {forwardRef, useCallback, useEffect, useMemo, useRef} from 'react'; import {View} from 'react-native'; import ReactNativeModal from 'react-native-modal'; @@ -14,44 +13,34 @@ import themeColors from '@styles/themes/default'; import variables from '@styles/variables'; import * as Modal from '@userActions/Modal'; import CONST from '@src/CONST'; -import {defaultProps as modalDefaultProps, propTypes as modalPropTypes} from './modalPropTypes'; - -const propTypes = { - ...modalPropTypes, - - /** The ref to the modal container */ - forwardedRef: PropTypes.func, -}; - -const defaultProps = { - ...modalDefaultProps, - forwardedRef: () => {}, -}; - -function BaseModal({ - isVisible, - onClose, - shouldSetModalVisibility, - onModalHide, - type, - popoverAnchorPosition, - innerContainerStyle, - outerStyle, - onModalShow, - propagateSwipe, - fullscreen, - animationIn, - animationOut, - useNativeDriver: useNativeDriverProp, - hideModalContentWhileAnimating, - animationInTiming, - animationOutTiming, - statusBarTranslucent, - onLayout, - avoidKeyboard, - forwardedRef, - children, -}) { +import BaseModalProps from './types'; + +function BaseModal( + { + isVisible, + onClose, + shouldSetModalVisibility = true, + onModalHide = () => {}, + type, + popoverAnchorPosition = {}, + innerContainerStyle = {}, + outerStyle, + onModalShow = () => {}, + propagateSwipe, + fullscreen = true, + animationIn, + animationOut, + useNativeDriver: useNativeDriverProp, + hideModalContentWhileAnimating = false, + animationInTiming, + animationOutTiming, + statusBarTranslucent = true, + onLayout, + avoidKeyboard = false, + children, + }: BaseModalProps, + ref: React.ForwardedRef, +) { const {windowWidth, windowHeight, isSmallScreenWidth} = useWindowDimensions(); const safeAreaInsets = useSafeAreaInsets(); @@ -61,7 +50,7 @@ function BaseModal({ /** * Hides modal - * @param {Boolean} [callHideCallback=true] Should we call the onModalHide callback + * @param callHideCallback - Should we call the onModalHide callback */ const hideModal = useCallback( (callHideCallback = true) => { @@ -113,10 +102,11 @@ function BaseModal({ onModalShow(); }; - const handleBackdropPress = (e) => { - if (e && e.key === CONST.KEYBOARD_SHORTCUTS.ENTER.shortcutKey) { + const handleBackdropPress = (e?: KeyboardEvent) => { + if (e?.key === CONST.KEYBOARD_SHORTCUTS.ENTER.shortcutKey) { return; } + onClose(); }; @@ -196,8 +186,8 @@ function BaseModal({ style={modalStyle} deviceHeight={windowHeight} deviceWidth={windowWidth} - animationIn={animationIn || modalStyleAnimationIn} - animationOut={animationOut || modalStyleAnimationOut} + animationIn={animationIn ?? modalStyleAnimationIn} + animationOut={animationOut ?? modalStyleAnimationOut} useNativeDriver={useNativeDriverProp && useNativeDriver} hideModalContentWhileAnimating={hideModalContentWhileAnimating} animationInTiming={animationInTiming} @@ -208,7 +198,7 @@ function BaseModal({ > {children} @@ -216,18 +206,6 @@ function BaseModal({ ); } -BaseModal.propTypes = propTypes; -BaseModal.defaultProps = defaultProps; -BaseModal.displayName = 'BaseModal'; - -const BaseModalWithRef = forwardRef((props, ref) => ( - -)); - -BaseModalWithRef.displayName = 'BaseModalWithRef'; +BaseModal.displayName = 'BaseModalWithRef'; -export default BaseModalWithRef; +export default forwardRef(BaseModal); diff --git a/src/components/Modal/index.android.js b/src/components/Modal/index.android.tsx similarity index 78% rename from src/components/Modal/index.android.js rename to src/components/Modal/index.android.tsx index 51745ae6a20f..2343cb4c70a9 100644 --- a/src/components/Modal/index.android.js +++ b/src/components/Modal/index.android.tsx @@ -3,7 +3,7 @@ import {AppState} from 'react-native'; import withWindowDimensions from '@components/withWindowDimensions'; import ComposerFocusManager from '@libs/ComposerFocusManager'; import BaseModal from './BaseModal'; -import {defaultProps, propTypes} from './modalPropTypes'; +import BaseModalProps from './types'; AppState.addEventListener('focus', () => { ComposerFocusManager.setReadyToFocus(); @@ -15,19 +15,17 @@ AppState.addEventListener('blur', () => { // Only want to use useNativeDriver on Android. It has strange flashes issue on IOS // https://github.com/react-native-modal/react-native-modal#the-modal-flashes-in-a-weird-way-when-animating -function Modal(props) { +function Modal({useNativeDriver = true, ...rest}: BaseModalProps) { return ( - {props.children} + {rest.children} ); } -Modal.propTypes = propTypes; -Modal.defaultProps = defaultProps; Modal.displayName = 'Modal'; export default withWindowDimensions(Modal); diff --git a/src/components/Modal/index.ios.js b/src/components/Modal/index.ios.tsx similarity index 63% rename from src/components/Modal/index.ios.js rename to src/components/Modal/index.ios.tsx index 38f477e2049b..f780775ec216 100644 --- a/src/components/Modal/index.ios.js +++ b/src/components/Modal/index.ios.tsx @@ -1,20 +1,18 @@ import React from 'react'; import withWindowDimensions from '@components/withWindowDimensions'; import BaseModal from './BaseModal'; -import {defaultProps, propTypes} from './modalPropTypes'; +import BaseModalProps from './types'; -function Modal(props) { +function Modal({children, ...rest}: BaseModalProps) { return ( - {props.children} + {children} ); } -Modal.propTypes = propTypes; -Modal.defaultProps = defaultProps; Modal.displayName = 'Modal'; export default withWindowDimensions(Modal); diff --git a/src/components/Modal/index.web.js b/src/components/Modal/index.tsx similarity index 54% rename from src/components/Modal/index.web.js rename to src/components/Modal/index.tsx index 3bea0eb58aa9..b4cfc1f06211 100644 --- a/src/components/Modal/index.web.js +++ b/src/components/Modal/index.tsx @@ -5,13 +5,13 @@ import * as StyleUtils from '@styles/StyleUtils'; import themeColors from '@styles/themes/default'; import CONST from '@src/CONST'; import BaseModal from './BaseModal'; -import {defaultProps, propTypes} from './modalPropTypes'; +import BaseModalProps from './types'; -function Modal(props) { - const [previousStatusBarColor, setPreviousStatusBarColor] = useState(); +function Modal({fullscreen = true, onModalHide = () => {}, type, onModalShow = () => {}, children, ...rest}: BaseModalProps) { + const [previousStatusBarColor, setPreviousStatusBarColor] = useState(); const setStatusBarColor = (color = themeColors.appBG) => { - if (!props.fullscreen) { + if (!fullscreen) { return; } @@ -20,33 +20,37 @@ function Modal(props) { const hideModal = () => { setStatusBarColor(previousStatusBarColor); - props.onModalHide(); + onModalHide(); }; const showModal = () => { const statusBarColor = StatusBar.getBackgroundColor(); - const isFullScreenModal = - props.type === CONST.MODAL.MODAL_TYPE.CENTERED || props.type === CONST.MODAL.MODAL_TYPE.CENTERED_UNSWIPEABLE || props.type === CONST.MODAL.MODAL_TYPE.RIGHT_DOCKED; - setPreviousStatusBarColor(statusBarColor); - // If it is a full screen modal then match it with appBG, otherwise we use the backdrop color - setStatusBarColor(isFullScreenModal ? themeColors.appBG : StyleUtils.getThemeBackgroundColor(statusBarColor)); - props.onModalShow(); + + const isFullScreenModal = type === CONST.MODAL.MODAL_TYPE.CENTERED || type === CONST.MODAL.MODAL_TYPE.CENTERED_UNSWIPEABLE || type === CONST.MODAL.MODAL_TYPE.RIGHT_DOCKED; + + if (statusBarColor) { + setPreviousStatusBarColor(statusBarColor); + // If it is a full screen modal then match it with appBG, otherwise we use the backdrop color + setStatusBarColor(isFullScreenModal ? themeColors.appBG : StyleUtils.getThemeBackgroundColor(statusBarColor)); + } + + onModalShow?.(); }; return ( - {props.children} + {children} ); } -Modal.propTypes = propTypes; -Modal.defaultProps = defaultProps; Modal.displayName = 'Modal'; export default withWindowDimensions(Modal); diff --git a/src/components/Modal/types.ts b/src/components/Modal/types.ts new file mode 100644 index 000000000000..3fa60e6ac765 --- /dev/null +++ b/src/components/Modal/types.ts @@ -0,0 +1,66 @@ +import {ViewStyle} from 'react-native'; +import {ModalProps} from 'react-native-modal'; +import {ValueOf} from 'type-fest'; +import {WindowDimensionsProps} from '@components/withWindowDimensions/types'; +import CONST from '@src/CONST'; + +type PopoverAnchorPosition = { + top?: number; + right?: number; + bottom?: number; + left?: number; +}; + +type BaseModalProps = WindowDimensionsProps & + ModalProps & { + /** Decides whether the modal should cover fullscreen. FullScreen modal has backdrop */ + fullscreen?: boolean; + + /** Should we close modal on outside click */ + shouldCloseOnOutsideClick?: boolean; + + /** Should we announce the Modal visibility changes? */ + shouldSetModalVisibility?: boolean; + + /** Callback method fired when the user requests to close the modal */ + onClose: () => void; + + /** State that determines whether to display the modal or not */ + isVisible: boolean; + + /** Callback method fired when the user requests to submit the modal content. */ + onSubmit?: () => void; + + /** Callback method fired when the modal is hidden */ + onModalHide?: () => void; + + /** Callback method fired when the modal is shown */ + onModalShow?: () => void; + + /** Style of modal to display */ + type?: ValueOf; + + /** The anchor position of a popover modal. Has no effect on other modal types. */ + popoverAnchorPosition?: PopoverAnchorPosition; + + outerStyle?: ViewStyle; + + /** Whether the modal should go under the system statusbar */ + statusBarTranslucent?: boolean; + + /** Whether the modal should avoid the keyboard */ + avoidKeyboard?: boolean; + + /** Modal container styles */ + innerContainerStyle?: ViewStyle; + + /** + * Whether the modal should hide its content while animating. On iOS, set to true + * if `useNativeDriver` is also true, to avoid flashes in the UI. + * + * See: https://github.com/react-native-modal/react-native-modal/pull/116 + * */ + hideModalContentWhileAnimating?: boolean; + }; + +export default BaseModalProps; diff --git a/src/components/MultipleAvatars.js b/src/components/MultipleAvatars.tsx similarity index 57% rename from src/components/MultipleAvatars.js rename to src/components/MultipleAvatars.tsx index 209540189b69..e867de7ddb97 100644 --- a/src/components/MultipleAvatars.js +++ b/src/components/MultipleAvatars.tsx @@ -1,77 +1,69 @@ -import PropTypes from 'prop-types'; import React, {memo, useMemo} from 'react'; -import {View} from 'react-native'; -import _ from 'underscore'; +import {StyleProp, View, ViewStyle} from 'react-native'; +import {ValueOf} from 'type-fest'; +import {AvatarSource} from '@libs/UserUtils'; import styles from '@styles/styles'; import * as StyleUtils from '@styles/StyleUtils'; import themeColors from '@styles/themes/default'; import variables from '@styles/variables'; import CONST from '@src/CONST'; +import type {Icon} from '@src/types/onyx/OnyxCommon'; import Avatar from './Avatar'; -import avatarPropTypes from './avatarPropTypes'; import Text from './Text'; import Tooltip from './Tooltip'; import UserDetailsTooltip from './UserDetailsTooltip'; -const propTypes = { +type MultipleAvatarsProps = { /** Array of avatar URLs or icons */ - icons: PropTypes.arrayOf(avatarPropTypes), + icons: Icon[]; /** Set the size of avatars */ - size: PropTypes.oneOf(_.values(CONST.AVATAR_SIZE)), + size?: ValueOf; /** Style for Second Avatar */ - // eslint-disable-next-line react/forbid-prop-types - secondAvatarStyle: PropTypes.arrayOf(PropTypes.object), + secondAvatarStyle?: StyleProp; /** A fallback avatar icon to display when there is an error on loading avatar from remote URL. */ - fallbackIcon: PropTypes.oneOfType([PropTypes.func, PropTypes.string]), + fallbackIcon?: AvatarSource; /** Prop to identify if we should load avatars vertically instead of diagonally */ - shouldStackHorizontally: PropTypes.bool, + shouldStackHorizontally?: boolean; /** Prop to identify if we should display avatars in rows */ - shouldDisplayAvatarsInRows: PropTypes.bool, + shouldDisplayAvatarsInRows?: boolean; /** Whether the avatars are hovered */ - isHovered: PropTypes.bool, + isHovered?: boolean; /** Whether the avatars are in an element being pressed */ - isPressed: PropTypes.bool, + isPressed?: boolean; /** Whether #focus mode is on */ - isFocusMode: PropTypes.bool, + isFocusMode?: boolean; /** Whether avatars are displayed within a reportAction */ - isInReportAction: PropTypes.bool, + isInReportAction?: boolean; /** Whether to show the toolip text */ - shouldShowTooltip: PropTypes.bool, + shouldShowTooltip?: boolean; /** Whether avatars are displayed with the highlighted background color instead of the app background color. This is primarily the case for IOU previews. */ - shouldUseCardBackground: PropTypes.bool, + shouldUseCardBackground?: boolean; /** Prop to limit the amount of avatars displayed horizontally */ - maxAvatarsInRow: PropTypes.number, + maxAvatarsInRow?: number; }; -const defaultProps = { - icons: [], - size: CONST.AVATAR_SIZE.DEFAULT, - secondAvatarStyle: [StyleUtils.getBackgroundAndBorderStyle(themeColors.componentBG)], - fallbackIcon: undefined, - shouldStackHorizontally: false, - shouldDisplayAvatarsInRows: false, - isHovered: false, - isPressed: false, - isFocusMode: false, - isInReportAction: false, - shouldShowTooltip: true, - shouldUseCardBackground: false, - maxAvatarsInRow: CONST.AVATAR_ROW_SIZE.DEFAULT, +type AvatarStyles = { + singleAvatarStyle: ViewStyle; + secondAvatarStyles: ViewStyle; }; -const avatarSizeToStylesMap = { +type AvatarSizeToStyles = typeof CONST.AVATAR_SIZE.SMALL | typeof CONST.AVATAR_SIZE.LARGE | typeof CONST.AVATAR_SIZE.DEFAULT; + +type AvatarSizeToStylesMap = Record; + +const avatarSizeToStylesMap: AvatarSizeToStylesMap = { [CONST.AVATAR_SIZE.SMALL]: { singleAvatarStyle: styles.singleAvatarSmall, secondAvatarStyles: styles.secondAvatarSmall, @@ -80,78 +72,92 @@ const avatarSizeToStylesMap = { singleAvatarStyle: styles.singleAvatarMedium, secondAvatarStyles: styles.secondAvatarMedium, }, - default: { + [CONST.AVATAR_SIZE.DEFAULT]: { singleAvatarStyle: styles.singleAvatar, secondAvatarStyles: styles.secondAvatar, }, }; -function MultipleAvatars(props) { - let avatarContainerStyles = StyleUtils.getContainerStyles(props.size, props.isInReportAction); - const {singleAvatarStyle, secondAvatarStyles} = useMemo(() => avatarSizeToStylesMap[props.size] || avatarSizeToStylesMap.default, [props.size]); - const tooltipTexts = props.shouldShowTooltip ? _.pluck(props.icons, 'name') : ['']; +function MultipleAvatars({ + fallbackIcon, + icons = [], + size = CONST.AVATAR_SIZE.DEFAULT, + secondAvatarStyle = [StyleUtils.getBackgroundAndBorderStyle(themeColors.componentBG)], + shouldStackHorizontally = false, + shouldDisplayAvatarsInRows = false, + isHovered = false, + isPressed = false, + isFocusMode = false, + isInReportAction = false, + shouldShowTooltip = true, + shouldUseCardBackground = false, + maxAvatarsInRow = CONST.AVATAR_ROW_SIZE.DEFAULT, +}: MultipleAvatarsProps) { + let avatarContainerStyles = StyleUtils.getContainerStyles(size, isInReportAction); + const {singleAvatarStyle, secondAvatarStyles} = useMemo(() => avatarSizeToStylesMap[size as AvatarSizeToStyles] ?? avatarSizeToStylesMap.default, [size]); + + const tooltipTexts = useMemo(() => (shouldShowTooltip ? icons.map((icon) => icon.name) : ['']), [shouldShowTooltip, icons]); const avatarSize = useMemo(() => { - if (props.isFocusMode) { + if (isFocusMode) { return CONST.AVATAR_SIZE.MID_SUBSCRIPT; } - if (props.size === CONST.AVATAR_SIZE.LARGE) { + if (size === CONST.AVATAR_SIZE.LARGE) { return CONST.AVATAR_SIZE.MEDIUM; } return CONST.AVATAR_SIZE.SMALLER; - }, [props.isFocusMode, props.size]); + }, [isFocusMode, size]); const avatarRows = useMemo(() => { // If we're not displaying avatars in rows or the number of icons is less than or equal to the max avatars in a row, return a single row - if (!props.shouldDisplayAvatarsInRows || props.icons.length <= props.maxAvatarsInRow) { - return [props.icons]; + if (!shouldDisplayAvatarsInRows || icons.length <= maxAvatarsInRow) { + return [icons]; } // Calculate the size of each row - const rowSize = Math.min(Math.ceil(props.icons.length / 2), props.maxAvatarsInRow); + const rowSize = Math.min(Math.ceil(icons.length / 2), maxAvatarsInRow); // Slice the icons array into two rows - const firstRow = props.icons.slice(rowSize); - const secondRow = props.icons.slice(0, rowSize); + const firstRow = icons.slice(rowSize); + const secondRow = icons.slice(0, rowSize); // Update the state with the two rows as an array return [firstRow, secondRow]; - }, [props.icons, props.maxAvatarsInRow, props.shouldDisplayAvatarsInRows]); + }, [icons, maxAvatarsInRow, shouldDisplayAvatarsInRows]); - if (!props.icons.length) { + if (!icons.length) { return null; } - if (props.icons.length === 1 && !props.shouldStackHorizontally) { + if (icons.length === 1 && !shouldStackHorizontally) { return ( ); } - const oneAvatarSize = StyleUtils.getAvatarStyle(props.size); - const oneAvatarBorderWidth = StyleUtils.getAvatarBorderWidth(props.size).borderWidth; + const oneAvatarSize = StyleUtils.getAvatarStyle(size); + const oneAvatarBorderWidth = StyleUtils.getAvatarBorderWidth(size).borderWidth ?? 0; const overlapSize = oneAvatarSize.width / 3; - if (props.shouldStackHorizontally) { + if (shouldStackHorizontally) { // Height of one avatar + border space const height = oneAvatarSize.height + 2 * oneAvatarBorderWidth; avatarContainerStyles = StyleUtils.combineStyles([styles.alignItemsCenter, styles.flexRow, StyleUtils.getHeight(height)]); @@ -159,36 +165,36 @@ function MultipleAvatars(props) { return ( <> - {props.shouldStackHorizontally ? ( - _.map(avatarRows, (avatars, rowIndex) => ( + {shouldStackHorizontally ? ( + avatarRows.map((avatars, rowIndex) => ( - {_.map([...avatars].splice(0, props.maxAvatarsInRow), (icon, index) => ( + {[...avatars].splice(0, maxAvatarsInRow).map((icon, index) => ( - + ))} - {avatars.length > props.maxAvatarsInRow && ( + {avatars.length > maxAvatarsInRow && ( {`+${avatars.length - props.maxAvatarsInRow}`} + >{`+${avatars.length - maxAvatarsInRow}`} @@ -238,53 +244,45 @@ function MultipleAvatars(props) { )) ) : ( - + {/* View is necessary for tooltip to show for multiple avatars in LHN */} - - {props.icons.length === 2 ? ( + + {icons.length === 2 ? ( @@ -292,10 +290,10 @@ function MultipleAvatars(props) { - {`+${props.icons.length - 1}`} + {`+${icons.length - 1}`} @@ -308,8 +306,6 @@ function MultipleAvatars(props) { ); } -MultipleAvatars.defaultProps = defaultProps; -MultipleAvatars.propTypes = propTypes; MultipleAvatars.displayName = 'MultipleAvatars'; export default memo(MultipleAvatars); diff --git a/src/components/withTabAnimation.js b/src/components/withTabAnimation.js deleted file mode 100644 index 2af96f0215a3..000000000000 --- a/src/components/withTabAnimation.js +++ /dev/null @@ -1,72 +0,0 @@ -import {useTabAnimation} from '@react-navigation/material-top-tabs'; -import PropTypes from 'prop-types'; -import * as React from 'react'; -import getComponentDisplayName from '@libs/getComponentDisplayName'; -import refPropTypes from './refPropTypes'; - -const propTypes = { - /** The HOC takes an optional ref as a prop and passes it as a ref to the wrapped component. - * That way, if a ref is passed to a component wrapped in the HOC, the ref is a reference to the wrapped component, not the HOC. */ - forwardedRef: refPropTypes, - - /* Whether we're in a tab navigator */ - isInTabNavigator: PropTypes.bool.isRequired, -}; - -const defaultProps = { - forwardedRef: () => {}, -}; - -export default function (WrappedComponent) { - // The component with tab animation prop - function WrappedComponentWithTabAnimation(props) { - const animation = useTabAnimation(); - - return ( - - ); - } - - WrappedComponentWithTabAnimation.displayName = `withAnimation(${getComponentDisplayName(WrappedComponent)})`; - - // Return a component with tab animation prop if this component is in tab navigator, otherwise return itself - function WithTabAnimation({forwardedRef, ...rest}) { - if (rest.isInTabNavigator) { - return ( - - ); - } - return ( - - ); - } - - WithTabAnimation.propTypes = propTypes; - WithTabAnimation.defaultProps = defaultProps; - WithTabAnimation.displayName = `withTabAnimation(${getComponentDisplayName(WrappedComponent)})`; - - // eslint-disable-next-line rulesdir/no-negated-variables - const WithTabAnimationWithRef = React.forwardRef((props, ref) => ( - - )); - - WithTabAnimationWithRef.displayName = `withTabAnimationWithRef(${getComponentDisplayName(WrappedComponent)})`; - - return WithTabAnimationWithRef; -} diff --git a/src/components/withWindowDimensions/index.native.js b/src/components/withWindowDimensions/index.native.tsx similarity index 65% rename from src/components/withWindowDimensions/index.native.js rename to src/components/withWindowDimensions/index.native.tsx index 91d81f5fb4e0..0c9f61a45c0b 100644 --- a/src/components/withWindowDimensions/index.native.js +++ b/src/components/withWindowDimensions/index.native.tsx @@ -1,12 +1,14 @@ import PropTypes from 'prop-types'; -import React, {createContext, forwardRef, useEffect, useMemo, useState} from 'react'; +import React, {ComponentType, createContext, ForwardedRef, RefAttributes, useEffect, useMemo, useState} from 'react'; import {Dimensions} from 'react-native'; import {useSafeAreaInsets} from 'react-native-safe-area-context'; import getComponentDisplayName from '@libs/getComponentDisplayName'; import getWindowHeightAdjustment from '@libs/getWindowHeightAdjustment'; import variables from '@styles/variables'; +import ChildrenProps from '@src/types/utils/ChildrenProps'; +import {NewDimensions, WindowDimensionsContextData, WindowDimensionsProps} from './types'; -const WindowDimensionsContext = createContext(null); +const WindowDimensionsContext = createContext(null); const windowDimensionsPropTypes = { // Width of the window windowWidth: PropTypes.number.isRequired, @@ -27,12 +29,7 @@ const windowDimensionsPropTypes = { isLargeScreenWidth: PropTypes.bool.isRequired, }; -const windowDimensionsProviderPropTypes = { - /* Actual content wrapped by this component */ - children: PropTypes.node.isRequired, -}; - -function WindowDimensionsProvider(props) { +function WindowDimensionsProvider(props: ChildrenProps) { const [windowDimension, setWindowDimension] = useState(() => { const initialDimensions = Dimensions.get('window'); return { @@ -42,9 +39,8 @@ function WindowDimensionsProvider(props) { }); useEffect(() => { - const onDimensionChange = (newDimensions) => { + const onDimensionChange = (newDimensions: NewDimensions) => { const {window} = newDimensions; - setWindowDimension({ windowHeight: window.height, windowWidth: window.width, @@ -76,30 +72,29 @@ function WindowDimensionsProvider(props) { return {props.children}; } -WindowDimensionsProvider.propTypes = windowDimensionsProviderPropTypes; WindowDimensionsProvider.displayName = 'WindowDimensionsProvider'; -/** - * @param {React.Component} WrappedComponent - * @returns {React.Component} - */ -export default function withWindowDimensions(WrappedComponent) { - const WithWindowDimensions = forwardRef((props, ref) => ( - - {(windowDimensionsProps) => ( - - )} - - )); +export default function withWindowDimensions( + WrappedComponent: ComponentType>, +): (props: Omit & React.RefAttributes) => React.ReactElement | null { + function WithWindowDimensions(props: Omit, ref: ForwardedRef) { + return ( + + {(windowDimensionsProps) => ( + + )} + + ); + } WithWindowDimensions.displayName = `withWindowDimensions(${getComponentDisplayName(WrappedComponent)})`; - return WithWindowDimensions; + return React.forwardRef(WithWindowDimensions); } export {WindowDimensionsProvider, windowDimensionsPropTypes}; diff --git a/src/components/withWindowDimensions/index.js b/src/components/withWindowDimensions/index.tsx similarity index 68% rename from src/components/withWindowDimensions/index.js rename to src/components/withWindowDimensions/index.tsx index f46624b2f41c..1479450deec4 100644 --- a/src/components/withWindowDimensions/index.js +++ b/src/components/withWindowDimensions/index.tsx @@ -1,13 +1,15 @@ import lodashDebounce from 'lodash/debounce'; import PropTypes from 'prop-types'; -import React, {createContext, forwardRef, useEffect, useMemo, useState} from 'react'; +import React, {ComponentType, createContext, ForwardedRef, RefAttributes, useEffect, useMemo, useState} from 'react'; import {Dimensions} from 'react-native'; import {useSafeAreaInsets} from 'react-native-safe-area-context'; import getComponentDisplayName from '@libs/getComponentDisplayName'; import getWindowHeightAdjustment from '@libs/getWindowHeightAdjustment'; import variables from '@styles/variables'; +import ChildrenProps from '@src/types/utils/ChildrenProps'; +import {NewDimensions, WindowDimensionsContextData, WindowDimensionsProps} from './types'; -const WindowDimensionsContext = createContext(null); +const WindowDimensionsContext = createContext(null); const windowDimensionsPropTypes = { // Width of the window windowWidth: PropTypes.number.isRequired, @@ -28,12 +30,7 @@ const windowDimensionsPropTypes = { isLargeScreenWidth: PropTypes.bool.isRequired, }; -const windowDimensionsProviderPropTypes = { - /* Actual content wrapped by this component */ - children: PropTypes.node.isRequired, -}; - -function WindowDimensionsProvider(props) { +function WindowDimensionsProvider(props: ChildrenProps) { const [windowDimension, setWindowDimension] = useState(() => { const initialDimensions = Dimensions.get('window'); return { @@ -43,7 +40,7 @@ function WindowDimensionsProvider(props) { }); useEffect(() => { - const onDimensionChange = (newDimensions) => { + const onDimensionChange = (newDimensions: NewDimensions) => { const {window} = newDimensions; setWindowDimension({ windowHeight: window.height, @@ -81,30 +78,29 @@ function WindowDimensionsProvider(props) { return {props.children}; } -WindowDimensionsProvider.propTypes = windowDimensionsProviderPropTypes; WindowDimensionsProvider.displayName = 'WindowDimensionsProvider'; -/** - * @param {React.Component} WrappedComponent - * @returns {React.Component} - */ -export default function withWindowDimensions(WrappedComponent) { - const WithWindowDimensions = forwardRef((props, ref) => ( - - {(windowDimensionsProps) => ( - - )} - - )); +export default function withWindowDimensions( + WrappedComponent: ComponentType>, +): (props: Omit & React.RefAttributes) => React.ReactElement | null { + function WithWindowDimensions(props: Omit, ref: ForwardedRef) { + return ( + + {(windowDimensionsProps) => ( + + )} + + ); + } WithWindowDimensions.displayName = `withWindowDimensions(${getComponentDisplayName(WrappedComponent)})`; - return WithWindowDimensions; + return React.forwardRef(WithWindowDimensions); } export {WindowDimensionsProvider, windowDimensionsPropTypes}; diff --git a/src/components/withWindowDimensions/types.ts b/src/components/withWindowDimensions/types.ts new file mode 100644 index 000000000000..514c86616b87 --- /dev/null +++ b/src/components/withWindowDimensions/types.ts @@ -0,0 +1,34 @@ +import {ScaledSize} from 'react-native'; + +type WindowDimensionsContextData = { + windowHeight: number; + windowWidth: number; + isExtraSmallScreenWidth: boolean; + isSmallScreenWidth: boolean; + isMediumScreenWidth: boolean; + isLargeScreenWidth: boolean; +}; + +type WindowDimensionsProps = WindowDimensionsContextData & { + // Width of the window + windowWidth: number; + + // Height of the window + windowHeight: number; + + // Is the window width extra narrow, like on a Fold mobile device? + isExtraSmallScreenWidth: boolean; + + // Is the window width narrow, like on a mobile device? + isSmallScreenWidth: boolean; + + // Is the window width medium sized, like on a tablet device? + isMediumScreenWidth: boolean; + + // Is the window width wide, like on a browser or desktop? + isLargeScreenWidth: boolean; +}; + +type NewDimensions = {window: ScaledSize}; + +export type {WindowDimensionsContextData, WindowDimensionsProps, NewDimensions}; diff --git a/src/hooks/useTabNavigatorFocus/index.js b/src/hooks/useTabNavigatorFocus/index.js new file mode 100644 index 000000000000..f83ec5bd9270 --- /dev/null +++ b/src/hooks/useTabNavigatorFocus/index.js @@ -0,0 +1,79 @@ +import {useTabAnimation} from '@react-navigation/material-top-tabs'; +import {useIsFocused} from '@react-navigation/native'; +import {useEffect, useState} from 'react'; +import DomUtils from '@libs/DomUtils'; + +/** + * Custom React hook to determine the focus status of a tab in a Material Top Tab Navigator. + * It evaluates whether the current tab is focused based on the tab's animation position and + * the screen's focus status within a React Navigation environment. + * + * This hook is designed for use with the Material Top Tabs provided by '@react-navigation/material-top-tabs'. + * It leverages the `useTabAnimation` hook from the same package to track the animated position of tabs + * and the `useIsFocused` hook from '@react-navigation/native' to ascertain if the current screen is in focus. + * + * Note: This hook contains a conditional invocation of another hook (`useTabAnimation`), + * which is typically an anti-pattern in React. This is done to account for scenarios where the hook + * might not be used within a Material Top Tabs Navigator context. Proper usage should ensure that + * this hook is only used where appropriate. + * + * @param {Object} params - The parameters object. + * @param {Number} params.tabIndex - The index of the tab for which focus status is being determined. + * @returns {Boolean} Returns `true` if the tab is both animation-focused and screen-focused, otherwise `false`. + * + * @example + * const isTabFocused = useTabNavigatorFocus({ tabIndex: 1 }); + */ +function useTabNavigatorFocus({tabIndex}) { + let tabPositionAnimation = null; + try { + // Retrieve the animation value from the tab navigator, which ranges from 0 to the total number of pages displayed. + // Even a minimal scroll towards the camera page (e.g., a value of 0.001 at start) should activate the camera for immediate responsiveness. + // STOP!!!!!!! This is not a pattern to be followed! We are conditionally rendering this hook becase when used in the edit flow we'll never be inside a tab navigator. + // eslint-disable-next-line react-hooks/rules-of-hooks + tabPositionAnimation = useTabAnimation(); + } catch (error) { + tabPositionAnimation = null; + } + const isPageFocused = useIsFocused(); + // set to true if the hook is not used within the MaterialTopTabs context + // the hook will then return true if the screen is focused + const [isTabFocused, setIsTabFocused] = useState(!tabPositionAnimation); + + useEffect(() => { + if (!tabPositionAnimation) { + return; + } + const index = Number(tabIndex); + + const listenerId = tabPositionAnimation.addListener(({value}) => { + // Activate camera as soon the index is animating towards the `tabIndex` + DomUtils.requestAnimationFrame(() => { + setIsTabFocused(value > index - 1 && value < index + 1); + }); + }); + + // We need to get the position animation value on component initialization to determine + // if the tab is focused or not. Since it's an Animated.Value the only synchronous way + // to retrieve the value is to use a private method. + // eslint-disable-next-line no-underscore-dangle + const initialTabPositionValue = tabPositionAnimation.__getValue(); + + if (typeof initialTabPositionValue === 'number') { + DomUtils.requestAnimationFrame(() => { + setIsTabFocused(initialTabPositionValue > index - 1 && initialTabPositionValue < index + 1); + }); + } + + return () => { + if (!tabPositionAnimation) { + return; + } + tabPositionAnimation.removeListener(listenerId); + }; + }, [tabIndex, tabPositionAnimation]); + + return isTabFocused && isPageFocused; +} + +export default useTabNavigatorFocus; diff --git a/src/libs/DomUtils/index.native.ts b/src/libs/DomUtils/index.native.ts index 9a9758228776..0864f1a16ac0 100644 --- a/src/libs/DomUtils/index.native.ts +++ b/src/libs/DomUtils/index.native.ts @@ -2,6 +2,15 @@ import GetActiveElement from './types'; const getActiveElement: GetActiveElement = () => null; +const requestAnimationFrame = (callback: () => void) => { + if (!callback) { + return; + } + + callback(); +}; + export default { getActiveElement, + requestAnimationFrame, }; diff --git a/src/libs/DomUtils/index.ts b/src/libs/DomUtils/index.ts index 94dd54547454..6a2eed57fbe6 100644 --- a/src/libs/DomUtils/index.ts +++ b/src/libs/DomUtils/index.ts @@ -4,4 +4,5 @@ const getActiveElement: GetActiveElement = () => document.activeElement; export default { getActiveElement, + requestAnimationFrame: window.requestAnimationFrame.bind(window), }; diff --git a/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.js b/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.js index de3fb9e79659..3fd8dec90d8d 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.js +++ b/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.js @@ -37,10 +37,6 @@ function RightModalNavigator(props) { void; +let closeModal: ((isNavigating: boolean) => void) | null; let onModalClose: null | (() => void); /** * Allows other parts of the app to call modal close function */ -function setCloseModal(onClose: () => void) { +function setCloseModal(onClose: (() => void) | null) { closeModal = onClose; } diff --git a/src/pages/home/report/ReportActionItemFragment.js b/src/pages/home/report/ReportActionItemFragment.js index ab813db2f2e9..bdef759d0ed6 100644 --- a/src/pages/home/report/ReportActionItemFragment.js +++ b/src/pages/home/report/ReportActionItemFragment.js @@ -1,4 +1,3 @@ -import Str from 'expensify-common/lib/str'; import PropTypes from 'prop-types'; import React, {memo} from 'react'; import avatarPropTypes from '@components/avatarPropTypes'; @@ -8,16 +7,13 @@ import Text from '@components/Text'; import UserDetailsTooltip from '@components/UserDetailsTooltip'; import withLocalize, {withLocalizePropTypes} from '@components/withLocalize'; import withWindowDimensions, {windowDimensionsPropTypes} from '@components/withWindowDimensions'; -import ZeroWidthView from '@components/ZeroWidthView'; import compose from '@libs/compose'; import convertToLTR from '@libs/convertToLTR'; -import * as DeviceCapabilities from '@libs/DeviceCapabilities'; -import * as EmojiUtils from '@libs/EmojiUtils'; -import editedLabelStyles from '@styles/editedLabelStyles'; +import * as ReportUtils from '@libs/ReportUtils'; import styles from '@styles/styles'; -import themeColors from '@styles/themes/default'; -import variables from '@styles/variables'; import CONST from '@src/CONST'; +import AttachmentCommentFragment from './comment/AttachmentCommentFragment'; +import TextCommentFragment from './comment/TextCommentFragment'; import reportActionFragmentPropTypes from './reportActionFragmentPropTypes'; const propTypes = { @@ -63,6 +59,9 @@ const propTypes = { /** Whether the comment is a thread parent message/the first message in a thread */ isThreadParentMessage: PropTypes.bool, + /** Should the comment have the appearance of being grouped with the previous comment? */ + displayAsGroup: PropTypes.bool, + /** Whether the report action type is 'APPROVED' or 'SUBMITTED'. Used to style system messages from Old Dot */ isApprovedOrSubmittedReportAction: PropTypes.bool, @@ -73,9 +72,6 @@ const propTypes = { /** localization props */ ...withLocalizePropTypes, - - /** Should the comment have the appearance of being grouped with the previous comment? */ - displayAsGroup: PropTypes.bool, }; const defaultProps = { @@ -98,70 +94,39 @@ const defaultProps = { }; function ReportActionItemFragment(props) { - switch (props.fragment.type) { + const fragment = props.fragment; + + switch (fragment.type) { case 'COMMENT': { - const {html, text} = props.fragment; - const isPendingDelete = props.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE && props.network.isOffline; + const isPendingDelete = props.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE; // Threaded messages display "[Deleted message]" instead of being hidden altogether. // While offline we display the previous message with a strikethrough style. Once online we want to // immediately display "[Deleted message]" while the delete action is pending. - if ((!props.network.isOffline && props.isThreadParentMessage && props.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) || props.fragment.isDeletedParentAction) { + if ((!props.network.isOffline && props.isThreadParentMessage && isPendingDelete) || props.fragment.isDeletedParentAction) { return ${props.translate('parentReportAction.deletedMessage')}`} />; } - // If the only difference between fragment.text and fragment.html is
tags - // we render it as text, not as html. - // This is done to render emojis with line breaks between them as text. - const differByLineBreaksOnly = Str.replaceAll(html, '
', '\n') === text; - - // Only render HTML if we have html in the fragment - if (!differByLineBreaksOnly) { - const editedTag = props.fragment.isEdited ? `` : ''; - const htmlContent = isPendingDelete ? `${html}` : html; - - const htmlWithTag = editedTag ? `${htmlContent}${editedTag}` : htmlContent; - - return ${htmlWithTag}` : `${htmlWithTag}`} />; + if (ReportUtils.isReportMessageAttachment(fragment)) { + return ( + + ); } - const containsOnlyEmojis = EmojiUtils.containsOnlyEmojis(text); return ( - - - - {convertToLTR(props.iouMessage || text)} - - {Boolean(props.fragment.isEdited) && ( - <> - - {' '} - - - {props.translate('reportActionCompose.edited')} - - - )} - + ); } case 'TEXT': { @@ -182,7 +147,7 @@ function ReportActionItemFragment(props) { numberOfLines={props.isSingleLine ? 1 : undefined} style={[styles.chatItemMessageHeaderSender, props.isSingleLine ? styles.pre : styles.preWrap]} > - {props.fragment.text} + {fragment.text} ); diff --git a/src/pages/home/report/ReportActionItemMessage.js b/src/pages/home/report/ReportActionItemMessage.js index 4c6603c052a3..75e316342165 100644 --- a/src/pages/home/report/ReportActionItemMessage.js +++ b/src/pages/home/report/ReportActionItemMessage.js @@ -37,8 +37,7 @@ const defaultProps = { }; function ReportActionItemMessage(props) { - const messages = _.compact(props.action.previousMessage || props.action.message); - const isAttachment = ReportUtils.isReportMessageAttachment(_.last(messages)); + const fragments = _.compact(props.action.previousMessage || props.action.message); const isIOUReport = ReportActionsUtils.isMoneyRequestAction(props.action); let iouMessage; if (isIOUReport) { @@ -56,7 +55,7 @@ function ReportActionItemMessage(props) { * @returns {Object} report action item fragments */ const renderReportActionItemFragments = (shouldWrapInText) => { - const reportActionItemFragments = _.map(messages, (fragment, index) => ( + const reportActionItemFragments = _.map(fragments, (fragment, index) => ( + {!props.isHidden ? ( renderReportActionItemFragments(isApprovedOrSubmittedReportAction) ) : ( diff --git a/src/pages/home/report/comment/AttachmentCommentFragment.js b/src/pages/home/report/comment/AttachmentCommentFragment.js new file mode 100644 index 000000000000..8ee161600aee --- /dev/null +++ b/src/pages/home/report/comment/AttachmentCommentFragment.js @@ -0,0 +1,33 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import {View} from 'react-native'; +import reportActionSourcePropType from '@pages/home/report/reportActionSourcePropType'; +import styles from '@styles/styles'; +import RenderCommentHTML from './RenderCommentHTML'; + +const propTypes = { + /** The reportAction's source */ + source: reportActionSourcePropType.isRequired, + + /** The message fragment's HTML */ + html: PropTypes.string.isRequired, + + /** Should extra margin be added on top of the component? */ + addExtraMargin: PropTypes.bool.isRequired, +}; + +function AttachmentCommentFragment({addExtraMargin, html, source}) { + return ( + + + + ); +} + +AttachmentCommentFragment.propTypes = propTypes; +AttachmentCommentFragment.displayName = 'AttachmentCommentFragment'; + +export default AttachmentCommentFragment; diff --git a/src/pages/home/report/comment/RenderCommentHTML.js b/src/pages/home/report/comment/RenderCommentHTML.js new file mode 100644 index 000000000000..14039af21189 --- /dev/null +++ b/src/pages/home/report/comment/RenderCommentHTML.js @@ -0,0 +1,23 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import RenderHTML from '@components/RenderHTML'; +import reportActionSourcePropType from '@pages/home/report/reportActionSourcePropType'; + +const propTypes = { + /** The reportAction's source */ + source: reportActionSourcePropType.isRequired, + + /** The comment's HTML */ + html: PropTypes.string.isRequired, +}; + +function RenderCommentHTML({html, source}) { + const commentHtml = source === 'email' ? `${html}` : `${html}`; + + return ; +} + +RenderCommentHTML.propTypes = propTypes; +RenderCommentHTML.displayName = 'RenderCommentHTML'; + +export default RenderCommentHTML; diff --git a/src/pages/home/report/comment/TextCommentFragment.js b/src/pages/home/report/comment/TextCommentFragment.js new file mode 100644 index 000000000000..9dccf8de7f9d --- /dev/null +++ b/src/pages/home/report/comment/TextCommentFragment.js @@ -0,0 +1,118 @@ +import Str from 'expensify-common/lib/str'; +import PropTypes from 'prop-types'; +import React, {memo} from 'react'; +import Text from '@components/Text'; +import withLocalize, {withLocalizePropTypes} from '@components/withLocalize'; +import withWindowDimensions, {windowDimensionsPropTypes} from '@components/withWindowDimensions'; +import ZeroWidthView from '@components/ZeroWidthView'; +import compose from '@libs/compose'; +import convertToLTR from '@libs/convertToLTR'; +import * as DeviceCapabilities from '@libs/DeviceCapabilities'; +import * as EmojiUtils from '@libs/EmojiUtils'; +import reportActionFragmentPropTypes from '@pages/home/report/reportActionFragmentPropTypes'; +import reportActionSourcePropType from '@pages/home/report/reportActionSourcePropType'; +import editedLabelStyles from '@styles/editedLabelStyles'; +import styles from '@styles/styles'; +import themeColors from '@styles/themes/default'; +import variables from '@styles/variables'; +import CONST from '@src/CONST'; +import RenderCommentHTML from './RenderCommentHTML'; + +const propTypes = { + /** The reportAction's source */ + source: reportActionSourcePropType.isRequired, + + /** The message fragment needing to be displayed */ + fragment: reportActionFragmentPropTypes.isRequired, + + /** Should this message fragment be styled as deleted? */ + styleAsDeleted: PropTypes.bool.isRequired, + + /** Text of an IOU report action */ + iouMessage: PropTypes.string, + + /** Should the comment have the appearance of being grouped with the previous comment? */ + displayAsGroup: PropTypes.bool.isRequired, + + /** Additional styles to add after local styles. */ + style: PropTypes.oneOfType([PropTypes.arrayOf(PropTypes.object), PropTypes.object]).isRequired, + + ...windowDimensionsPropTypes, + + /** localization props */ + ...withLocalizePropTypes, +}; + +const defaultProps = { + iouMessage: undefined, +}; + +function TextCommentFragment(props) { + const {fragment, styleAsDeleted} = props; + const {html, text} = fragment; + + // If the only difference between fragment.text and fragment.html is
tags + // we render it as text, not as html. + // This is done to render emojis with line breaks between them as text. + const differByLineBreaksOnly = Str.replaceAll(html, '
', '\n') === text; + + // Only render HTML if we have html in the fragment + if (!differByLineBreaksOnly) { + const editedTag = fragment.isEdited ? `` : ''; + const htmlContent = styleAsDeleted ? `${html}` : html; + + const htmlWithTag = editedTag ? `${htmlContent}${editedTag}` : htmlContent; + + return ( + + ); + } + + const containsOnlyEmojis = EmojiUtils.containsOnlyEmojis(text); + + return ( + + + + {convertToLTR(props.iouMessage || text)} + + {Boolean(fragment.isEdited) && ( + <> + + {' '} + + + {props.translate('reportActionCompose.edited')} + + + )} + + ); +} + +TextCommentFragment.propTypes = propTypes; +TextCommentFragment.defaultProps = defaultProps; +TextCommentFragment.displayName = 'TextCommentFragment'; + +export default compose(withWindowDimensions, withLocalize)(memo(TextCommentFragment)); diff --git a/src/pages/home/report/reportActionSourcePropType.js b/src/pages/home/report/reportActionSourcePropType.js new file mode 100644 index 000000000000..0ad9662eb693 --- /dev/null +++ b/src/pages/home/report/reportActionSourcePropType.js @@ -0,0 +1,3 @@ +import PropTypes from 'prop-types'; + +export default PropTypes.oneOf(['Chronos', 'email', 'ios', 'android', 'web', 'email', '']); diff --git a/src/pages/iou/ReceiptSelector/NavigationAwareCamera/index.js b/src/pages/iou/ReceiptSelector/NavigationAwareCamera/index.js index e4b24f8a0ad8..10b16da13b6e 100644 --- a/src/pages/iou/ReceiptSelector/NavigationAwareCamera/index.js +++ b/src/pages/iou/ReceiptSelector/NavigationAwareCamera/index.js @@ -1,17 +1,20 @@ -import {useIsFocused} from '@react-navigation/native'; import PropTypes from 'prop-types'; import React, {useEffect, useRef} from 'react'; import {View} from 'react-native'; import Webcam from 'react-webcam'; +import useTabNavigatorFocus from '@hooks/useTabNavigatorFocus'; const propTypes = { - /* Flag to turn on/off the torch/flashlight - if available */ + /** Flag to turn on/off the torch/flashlight - if available */ torchOn: PropTypes.bool, - /* Callback function when media stream becomes available - user granted camera permissions and camera starts to work */ + /** The index of the tab that contains this camera */ + cameraTabIndex: PropTypes.number.isRequired, + + /** Callback function when media stream becomes available - user granted camera permissions and camera starts to work */ onUserMedia: PropTypes.func, - /* Callback function passing torch/flashlight capability as bool param of the browser */ + /** Callback function passing torch/flashlight capability as bool param of the browser */ onTorchAvailability: PropTypes.func, }; @@ -22,9 +25,11 @@ const defaultProps = { }; // Wraps a camera that will only be active when the tab is focused or as soon as it starts to become focused. -const NavigationAwareCamera = React.forwardRef(({torchOn, onTorchAvailability, ...props}, ref) => { +const NavigationAwareCamera = React.forwardRef(({torchOn, onTorchAvailability, cameraTabIndex, ...props}, ref) => { const trackRef = useRef(null); - const isCameraActive = useIsFocused(); + const shouldShowCamera = useTabNavigatorFocus({ + tabIndex: cameraTabIndex, + }); const handleOnUserMedia = (stream) => { if (props.onUserMedia) { @@ -51,7 +56,7 @@ const NavigationAwareCamera = React.forwardRef(({torchOn, onTorchAvailability, . }); }, [torchOn]); - if (!isCameraActive) { + if (!shouldShowCamera) { return null; } return ( diff --git a/src/pages/iou/ReceiptSelector/NavigationAwareCamera/index.native.js b/src/pages/iou/ReceiptSelector/NavigationAwareCamera/index.native.js index eca8042a6965..65c17d3cb7ab 100644 --- a/src/pages/iou/ReceiptSelector/NavigationAwareCamera/index.native.js +++ b/src/pages/iou/ReceiptSelector/NavigationAwareCamera/index.native.js @@ -1,77 +1,16 @@ -import {useNavigation} from '@react-navigation/native'; import PropTypes from 'prop-types'; -import React, {useEffect, useState} from 'react'; +import React from 'react'; import {Camera} from 'react-native-vision-camera'; -import withTabAnimation from '@components/withTabAnimation'; -import CONST from '@src/CONST'; +import useTabNavigatorFocus from '@hooks/useTabNavigatorFocus'; const propTypes = { /* The index of the tab that contains this camera */ cameraTabIndex: PropTypes.number.isRequired, - - /* Whether we're in a tab navigator */ - isInTabNavigator: PropTypes.bool.isRequired, - - /** Name of the selected receipt tab */ - selectedTab: PropTypes.string.isRequired, - - /** The tab animation from hook */ - tabAnimation: PropTypes.shape({ - addListener: PropTypes.func, - removeListener: PropTypes.func, - }), -}; - -const defaultProps = { - tabAnimation: undefined, }; // Wraps a camera that will only be active when the tab is focused or as soon as it starts to become focused. -const NavigationAwareCamera = React.forwardRef(({cameraTabIndex, isInTabNavigator, selectedTab, tabAnimation, ...props}, ref) => { - // Get navigation to get initial isFocused value (only needed once during init!) - const navigation = useNavigation(); - const [isCameraActive, setIsCameraActive] = useState(() => navigation.isFocused()); - - // Retrieve the animation value from the tab navigator, which ranges from 0 to the total number of pages displayed. - // Even a minimal scroll towards the camera page (e.g., a value of 0.001 at start) should activate the camera for immediate responsiveness. - - useEffect(() => { - if (!isInTabNavigator) { - return; - } - - const listenerId = tabAnimation.addListener(({value}) => { - if (selectedTab !== CONST.TAB.SCAN) { - return; - } - // Activate camera as soon the index is animating towards the `cameraTabIndex` - setIsCameraActive(value > cameraTabIndex - 1 && value < cameraTabIndex + 1); - }); - - return () => { - tabAnimation.removeListener(listenerId); - }; - }, [cameraTabIndex, tabAnimation, isInTabNavigator, selectedTab]); - - // Note: The useEffect can be removed once VisionCamera V3 is used. - // Its only needed for android, because there is a native cameraX android bug. With out this flow would break the camera: - // 1. Open camera tab - // 2. Take a picture - // 3. Go back from the opened screen - // 4. The camera is not working anymore - useEffect(() => { - const removeBlurListener = navigation.addListener('blur', () => { - setIsCameraActive(false); - }); - const removeFocusListener = navigation.addListener('focus', () => { - setIsCameraActive(true); - }); - - return () => { - removeBlurListener(); - removeFocusListener(); - }; - }, [navigation]); +const NavigationAwareCamera = React.forwardRef(({cameraTabIndex, ...props}, ref) => { + const isCameraActive = useTabNavigatorFocus({tabIndex: cameraTabIndex}); return ( !state, false); - const [isTorchAvailable, setIsTorchAvailable] = useState(true); + const [isTorchAvailable, setIsTorchAvailable] = useState(false); const cameraRef = useRef(null); const hideReciptModal = () => { @@ -200,6 +196,7 @@ function ReceiptSelector({route, transactionID, iou, report}) { torchOn={isFlashLightOn} onTorchAvailability={setIsTorchAvailable} forceScreenshotSourceSize + cameraTabIndex={pageIndex} />
diff --git a/src/pages/iou/ReceiptSelector/index.native.js b/src/pages/iou/ReceiptSelector/index.native.js index 824c242cf02f..ef81109ffb90 100644 --- a/src/pages/iou/ReceiptSelector/index.native.js +++ b/src/pages/iou/ReceiptSelector/index.native.js @@ -50,23 +50,15 @@ const propTypes = { /** The id of the transaction we're editing */ transactionID: PropTypes.string, - - /** Whether or not the receipt selector is in a tab navigator for tab animations */ - isInTabNavigator: PropTypes.bool, - - /** Name of the selected receipt tab */ - selectedTab: PropTypes.string, }; const defaultProps = { report: {}, iou: iouDefaultProps, transactionID: '', - isInTabNavigator: true, - selectedTab: '', }; -function ReceiptSelector({route, report, iou, transactionID, isInTabNavigator, selectedTab}) { +function ReceiptSelector({route, report, iou, transactionID}) { const devices = useCameraDevices('wide-angle-camera'); const device = devices.back; @@ -218,8 +210,6 @@ function ReceiptSelector({route, report, iou, transactionID, isInTabNavigator, s zoom={device.neutralZoom} photo cameraTabIndex={pageIndex} - isInTabNavigator={isInTabNavigator} - selectedTab={selectedTab} /> )} diff --git a/src/pages/signin/LoginForm/BaseLoginForm.js b/src/pages/signin/LoginForm/BaseLoginForm.js index 5b55965d1539..9cd620cf8f12 100644 --- a/src/pages/signin/LoginForm/BaseLoginForm.js +++ b/src/pages/signin/LoginForm/BaseLoginForm.js @@ -26,6 +26,7 @@ import Log from '@libs/Log'; import * as LoginUtils from '@libs/LoginUtils'; import * as PolicyUtils from '@libs/PolicyUtils'; import * as ValidationUtils from '@libs/ValidationUtils'; +import Visibility from '@libs/Visibility'; import styles from '@styles/styles'; import * as CloseAccount from '@userActions/CloseAccount'; import * as MemoryOnlyKeys from '@userActions/MemoryOnlyKeys/MemoryOnlyKeys'; @@ -98,18 +99,52 @@ function LoginForm(props) { const [login, setLogin] = useState(() => Str.removeSMSDomain(props.credentials.login || '')); const [formError, setFormError] = useState(false); const prevIsVisible = usePrevious(props.isVisible); + const firstBlurred = useRef(false); const {translate} = props; /** - * Handle text input and clear formError upon text change + * Validate the input value and set the error for formError + * + * @param {String} value + */ + const validate = useCallback( + (value) => { + const loginTrim = value.trim(); + if (!loginTrim) { + setFormError('common.pleaseEnterEmailOrPhoneNumber'); + return false; + } + + const phoneLogin = LoginUtils.appendCountryCode(LoginUtils.getPhoneNumberWithoutSpecialChars(loginTrim)); + const parsedPhoneNumber = parsePhoneNumber(phoneLogin); + + if (!Str.isValidEmail(loginTrim) && !parsedPhoneNumber.possible) { + if (ValidationUtils.isNumericWithSpecialChars(loginTrim)) { + setFormError('common.error.phoneNumber'); + } else { + setFormError('loginForm.error.invalidFormatEmailLogin'); + } + return false; + } + + setFormError(null); + return true; + }, + [setFormError], + ); + + /** + * Handle text input and validate the text input if it is blurred * * @param {String} text */ const onTextInput = useCallback( (text) => { setLogin(text); - setFormError(null); + if (firstBlurred.current) { + validate(text); + } if (props.account.errors || props.account.message) { Session.clearAccountMessages(); @@ -120,7 +155,7 @@ function LoginForm(props) { CloseAccount.setDefaultData(); } }, - [props.account, props.closeAccount, input, setFormError, setLogin], + [props.account, props.closeAccount, input, setLogin, validate], ); function getSignInWithStyles() { @@ -140,35 +175,30 @@ function LoginForm(props) { CloseAccount.setDefaultData(); } - const loginTrim = login.trim(); - if (!loginTrim) { - setFormError('common.pleaseEnterEmailOrPhoneNumber'); - return; + // For native, the single input doesn't lost focus when we click outside. + // So we need to change firstBlurred here to make the validate function is called whenever the text input is changed after the first validation. + if (!firstBlurred.current) { + firstBlurred.current = true; } - const phoneLogin = LoginUtils.appendCountryCode(LoginUtils.getPhoneNumberWithoutSpecialChars(loginTrim)); - const parsedPhoneNumber = parsePhoneNumber(phoneLogin); - - if (!Str.isValidEmail(loginTrim) && !parsedPhoneNumber.possible) { - if (ValidationUtils.isNumericWithSpecialChars(loginTrim)) { - setFormError('common.error.phoneNumber'); - } else { - setFormError('loginForm.error.invalidFormatEmailLogin'); - } + if (!validate(login)) { return; } + const loginTrim = login.trim(); + // If the user has entered a guide email, then we are going to enable an experimental Onyx mode to help with performance if (PolicyUtils.isExpensifyGuideTeam(loginTrim)) { Log.info('Detected guide email in login field, setting memory only keys.'); MemoryOnlyKeys.enable(); } - setFormError(null); + const phoneLogin = LoginUtils.appendCountryCode(LoginUtils.getPhoneNumberWithoutSpecialChars(loginTrim)); + const parsedPhoneNumber = parsePhoneNumber(phoneLogin); // Check if this login has an account associated with it or not Session.beginSignIn(parsedPhoneNumber.possible ? parsedPhoneNumber.number.e164 : loginTrim); - }, [login, props.account, props.closeAccount, props.network, setFormError]); + }, [login, props.account, props.closeAccount, props.network, validate]); useEffect(() => { // Just call clearAccountMessages on the login page (home route), because when the user is in the transition route and not yet authenticated, @@ -227,6 +257,13 @@ function LoginForm(props) { textContentType="username" id="username" name="username" + onBlur={() => { + if (firstBlurred.current || !Visibility.isVisible() || !Visibility.hasFocus()) { + return; + } + firstBlurred.current = true; + validate(login); + }} onChangeText={onTextInput} onSubmitEditing={validateAndSubmitForm} autoCapitalize="none" @@ -276,8 +313,12 @@ function LoginForm(props) { - - + e.preventDefault()}> + + + e.preventDefault()}> + + ) diff --git a/src/styles/StyleUtils.ts b/src/styles/StyleUtils.ts index de936570291f..a99d292e9dc6 100644 --- a/src/styles/StyleUtils.ts +++ b/src/styles/StyleUtils.ts @@ -17,6 +17,12 @@ import variables from './variables'; type AllStyles = ViewStyle | TextStyle | ImageStyle; type ParsableStyle = StyleProp | ((state: PressableStateCallbackType) => StyleProp); +type AvatarStyle = { + width: number; + height: number; + borderRadius: number; + backgroundColor: string; +}; type ColorValue = ValueOf; type AvatarSizeName = ValueOf; @@ -56,10 +62,10 @@ type ModalPaddingStylesParams = { safeAreaPaddingBottom: number; safeAreaPaddingLeft: number; safeAreaPaddingRight: number; - modalContainerStyleMarginTop: number; - modalContainerStyleMarginBottom: number; - modalContainerStylePaddingTop: number; - modalContainerStylePaddingBottom: number; + modalContainerStyleMarginTop: DimensionValue | undefined; + modalContainerStyleMarginBottom: DimensionValue | undefined; + modalContainerStylePaddingTop: DimensionValue | undefined; + modalContainerStylePaddingBottom: DimensionValue | undefined; insets: EdgeInsets; }; @@ -210,7 +216,7 @@ function getAvatarWidthStyle(size: AvatarSizeName): ViewStyle { /** * Return the style from an avatar size constant */ -function getAvatarStyle(size: AvatarSizeName): ViewStyle { +function getAvatarStyle(size: AvatarSizeName): AvatarStyle { const avatarSize = getAvatarSize(size); return { height: avatarSize, @@ -241,7 +247,7 @@ function getAvatarBorderWidth(size: AvatarSizeName): ViewStyle { /** * Return the border radius for an avatar */ -function getAvatarBorderRadius(size: AvatarSizeName, type: string): ViewStyle { +function getAvatarBorderRadius(size: AvatarSizeName, type?: string): ViewStyle { if (type === CONST.ICON_TYPE_WORKSPACE) { return {borderRadius: avatarBorderSizes[size]}; } @@ -288,12 +294,19 @@ function getEReceiptColorStyles(colorCode: EReceiptColorName): EreceiptColorStyl return eReceiptColorStyles[colorCode]; } +type SafeAreaPadding = { + paddingTop: number; + paddingBottom: number; + paddingLeft: number; + paddingRight: number; +}; + /** * Takes safe area insets and returns padding to use for a View */ -function getSafeAreaPadding(insets?: EdgeInsets, insetsPercentage: number = variables.safeInsertPercentage): ViewStyle { +function getSafeAreaPadding(insets?: EdgeInsets, insetsPercentage: number = variables.safeInsertPercentage): SafeAreaPadding { return { - paddingTop: insets?.top, + paddingTop: insets?.top ?? 0, paddingBottom: (insets?.bottom ?? 0) * insetsPercentage, paddingLeft: (insets?.left ?? 0) * insetsPercentage, paddingRight: (insets?.right ?? 0) * insetsPercentage, @@ -569,6 +582,22 @@ function getWidthAndHeightStyle(width: number, height?: number): ViewStyle { }; } +/** + * Combine margin/padding with safe area inset + * + * @param modalContainerValue - margin or padding value + * @param safeAreaValue - safe area inset + * @param shouldAddSafeAreaValue - indicator whether safe area inset should be applied + */ +function getCombinedSpacing(modalContainerValue: DimensionValue | undefined, safeAreaValue: number, shouldAddSafeAreaValue: boolean): number | DimensionValue | undefined { + // modalContainerValue can only be added to safe area inset if it's a number, otherwise it's returned as is + if (typeof modalContainerValue === 'number' || !modalContainerValue) { + return (modalContainerValue ?? 0) + (shouldAddSafeAreaValue ? safeAreaValue : 0); + } + + return modalContainerValue; +} + function getModalPaddingStyles({ shouldAddBottomSafeAreaMargin, shouldAddTopSafeAreaMargin, @@ -586,12 +615,12 @@ function getModalPaddingStyles({ }: ModalPaddingStylesParams): ViewStyle { // use fallback value for safeAreaPaddingBottom to keep padding bottom consistent with padding top. // More info: issue #17376 - const safeAreaPaddingBottomWithFallback = insets.bottom === 0 ? modalContainerStylePaddingTop ?? 0 : safeAreaPaddingBottom; + const safeAreaPaddingBottomWithFallback = insets.bottom === 0 && typeof modalContainerStylePaddingTop === 'number' ? modalContainerStylePaddingTop ?? 0 : safeAreaPaddingBottom; return { - marginTop: (modalContainerStyleMarginTop ?? 0) + (shouldAddTopSafeAreaMargin ? safeAreaPaddingTop : 0), - marginBottom: (modalContainerStyleMarginBottom ?? 0) + (shouldAddBottomSafeAreaMargin ? safeAreaPaddingBottomWithFallback : 0), - paddingTop: shouldAddTopSafeAreaPadding ? (modalContainerStylePaddingTop ?? 0) + safeAreaPaddingTop : modalContainerStylePaddingTop ?? 0, - paddingBottom: shouldAddBottomSafeAreaPadding ? (modalContainerStylePaddingBottom ?? 0) + safeAreaPaddingBottomWithFallback : modalContainerStylePaddingBottom ?? 0, + marginTop: getCombinedSpacing(modalContainerStyleMarginTop, safeAreaPaddingTop, shouldAddTopSafeAreaMargin), + marginBottom: getCombinedSpacing(modalContainerStyleMarginBottom, safeAreaPaddingBottomWithFallback, shouldAddBottomSafeAreaMargin), + paddingTop: getCombinedSpacing(modalContainerStylePaddingTop, safeAreaPaddingTop, shouldAddTopSafeAreaPadding), + paddingBottom: getCombinedSpacing(modalContainerStylePaddingBottom, safeAreaPaddingBottomWithFallback, shouldAddBottomSafeAreaPadding), paddingLeft: safeAreaPaddingLeft ?? 0, paddingRight: safeAreaPaddingRight ?? 0, }; @@ -1018,7 +1047,7 @@ function getAutoCompleteSuggestionContainerStyle(itemsHeight: number): ViewStyle /** * Select the correct color for text. */ -function getColoredBackgroundStyle(isColored: boolean): TextStyle { +function getColoredBackgroundStyle(isColored: boolean): StyleProp { return {backgroundColor: isColored ? themeColors.link : undefined}; } diff --git a/src/styles/getModalStyles.ts b/src/styles/getModalStyles.ts index 984bf018e42d..c250bdf9498d 100644 --- a/src/styles/getModalStyles.ts +++ b/src/styles/getModalStyles.ts @@ -21,8 +21,6 @@ type WindowDimensions = { windowWidth: number; windowHeight: number; isSmallScreenWidth: boolean; - isMediumScreenWidth: boolean; - isLargeScreenWidth: boolean; }; type GetModalStyles = { @@ -39,7 +37,7 @@ type GetModalStyles = { }; export default function getModalStyles( - type: ModalType, + type: ModalType | undefined, windowDimensions: WindowDimensions, popoverAnchorPosition: ViewStyle = {}, innerContainerStyle: ViewStyle = {}, diff --git a/src/styles/styles.ts b/src/styles/styles.ts index b70b50ffbbbc..644b809ebae4 100644 --- a/src/styles/styles.ts +++ b/src/styles/styles.ts @@ -328,10 +328,6 @@ const styles = (theme: ThemeColors) => textAlign: 'left', }, - textUnderline: { - textDecorationLine: 'underline', - }, - verticalAlignMiddle: { verticalAlign: 'middle', }, @@ -392,10 +388,6 @@ const styles = (theme: ThemeColors) => fontSize: variables.fontSizeLarge, }, - textXLarge: { - fontSize: variables.fontSizeXLarge, - }, - textXXLarge: { fontSize: variables.fontSizeXXLarge, }, @@ -415,11 +407,6 @@ const styles = (theme: ThemeColors) => fontWeight: fontWeightBold, }, - textItalic: { - fontFamily: fontFamily.EXP_NEUE_ITALIC, - fontStyle: 'italic', - }, - textHeadline: { ...headlineFont, ...whiteSpace.preWrap, @@ -436,10 +423,6 @@ const styles = (theme: ThemeColors) => lineHeight: variables.lineHeightSizeh1, }, - textDecorationNoLine: { - textDecorationLine: 'none', - }, - textWhite: { color: theme.textLight, }, @@ -448,10 +431,6 @@ const styles = (theme: ThemeColors) => color: theme.link, }, - textUppercase: { - textTransform: 'uppercase', - }, - textNoWrap: { ...whiteSpace.noWrap, }, diff --git a/src/types/onyx/OnyxCommon.ts b/src/types/onyx/OnyxCommon.ts index ef2944d6af82..ac69baed3ef1 100644 --- a/src/types/onyx/OnyxCommon.ts +++ b/src/types/onyx/OnyxCommon.ts @@ -1,5 +1,5 @@ -import * as React from 'react'; import {ValueOf} from 'type-fest'; +import {AvatarSource} from '@libs/UserUtils'; import CONST from '@src/CONST'; type PendingAction = ValueOf; @@ -11,9 +11,20 @@ type ErrorFields = Record; type Icon = { - source: React.ReactNode | string; - type: 'avatar' | 'workspace'; + /** Avatar source to display */ + source: AvatarSource; + + /** Denotes whether it is an avatar or a workspace avatar */ + type: typeof CONST.ICON_TYPE_AVATAR | typeof CONST.ICON_TYPE_WORKSPACE; + + /** Owner of the avatar. If user, displayName. If workspace, policy name */ name: string; + + /** Avatar id */ + id: number | string; + + /** A fallback avatar icon to display when there is an error on loading avatar from remote URL. */ + fallbackIcon?: AvatarSource; }; export type {Icon, PendingAction, PendingFields, ErrorFields, Errors};