diff --git a/.storybook/preview.js b/.storybook/preview.js index 2926e8f8c81..7ccfd74e0e4 100644 --- a/.storybook/preview.js +++ b/.storybook/preview.js @@ -1,6 +1,7 @@ import React from 'react'; import Onyx from 'react-native-onyx'; import {SafeAreaProvider} from 'react-native-safe-area-context'; +import {PortalProvider} from '@gorhom/portal'; import './fonts.css'; import ComposeProviders from '../src/components/ComposeProviders'; import HTMLEngineProvider from '../src/components/HTMLEngineProvider'; @@ -14,7 +15,7 @@ Onyx.init({ const decorators = [ (Story) => ( - + ), diff --git a/.storybook/webpack.config.js b/.storybook/webpack.config.js index 6c39661c76f..3c5dbb41ea2 100644 --- a/.storybook/webpack.config.js +++ b/.storybook/webpack.config.js @@ -27,6 +27,7 @@ module.exports = ({config}) => { 'react-native$': '@expensify/react-native-web', 'react-native-web': '@expensify/react-native-web', '@react-native-community/netinfo': path.resolve(__dirname, '../__mocks__/@react-native-community/netinfo.js'), + '@react-navigation/native': path.resolve(__dirname, '../__mocks__/@react-navigation/native'), }; // Necessary to overwrite the values in the existing DefinePlugin hardcoded to the Config staging values diff --git a/__mocks__/@react-navigation/native/index.js b/__mocks__/@react-navigation/native/index.js new file mode 100644 index 00000000000..09abd0d02bf --- /dev/null +++ b/__mocks__/@react-navigation/native/index.js @@ -0,0 +1,8 @@ +import {useIsFocused as realUseIsFocused} from '@react-navigation/native'; + +// We only want this mocked for storybook, not jest +const useIsFocused = process.env.NODE_ENV === 'test' ? realUseIsFocused : () => true; + +export * from '@react-navigation/core'; +export * from '@react-navigation/native'; +export {useIsFocused}; diff --git a/src/CONST.js b/src/CONST.js index 39d190788b4..06bc1873431 100755 --- a/src/CONST.js +++ b/src/CONST.js @@ -480,9 +480,6 @@ const CONST = { PERSONAL_DETAIL: 'personalDetail', }, REPORT: { - DROP_HOST_NAME: 'ReportDropZone', - DROP_NATIVE_ID: 'report-dropzone', - ACTIVE_DROP_NATIVE_ID: 'report-dropzone', MAXIMUM_PARTICIPANTS: 8, SPLIT_REPORTID: '-2', ACTIONS: { diff --git a/src/components/DragAndDrop/Consumer/dragAndDropConsumerPropTypes.js b/src/components/DragAndDrop/Consumer/dragAndDropConsumerPropTypes.js new file mode 100644 index 00000000000..66f8ed996e2 --- /dev/null +++ b/src/components/DragAndDrop/Consumer/dragAndDropConsumerPropTypes.js @@ -0,0 +1,9 @@ +import PropTypes from 'prop-types'; + +export default { + /** Children to render inside this component. */ + children: PropTypes.node.isRequired, + + /** Function to execute when an item is dropped in the drop zone. */ + onDrop: PropTypes.func.isRequired, +}; diff --git a/src/components/DragAndDrop/Consumer/index.js b/src/components/DragAndDrop/Consumer/index.js new file mode 100644 index 00000000000..cf21afe627f --- /dev/null +++ b/src/components/DragAndDrop/Consumer/index.js @@ -0,0 +1,23 @@ +import React, {useContext, useEffect} from 'react'; +import {Portal} from '@gorhom/portal'; +import dragAndDropConsumerPropTypes from './dragAndDropConsumerPropTypes'; +import {DragAndDropContext} from '../Provider'; + +function DragAndDropConsumer({children, onDrop}) { + const {isDraggingOver, setOnDropHandler, dropZoneID} = useContext(DragAndDropContext); + + useEffect(() => { + setOnDropHandler(onDrop); + }, [onDrop, setOnDropHandler]); + + if (!isDraggingOver) { + return null; + } + + return {children}; +} + +DragAndDropConsumer.propTypes = dragAndDropConsumerPropTypes; +DragAndDropConsumer.displayName = 'DragAndDropConsumer'; + +export default DragAndDropConsumer; diff --git a/src/components/DragAndDrop/Consumer/index.native.js b/src/components/DragAndDrop/Consumer/index.native.js new file mode 100644 index 00000000000..fd15e5de87e --- /dev/null +++ b/src/components/DragAndDrop/Consumer/index.native.js @@ -0,0 +1,10 @@ +import dragAndDropConsumerPropTypes from './dragAndDropConsumerPropTypes'; + +function DragAndDropConsumer({children}) { + return children; +} + +DragAndDropConsumer.propTypes = dragAndDropConsumerPropTypes; +DragAndDropConsumer.displayName = 'DragAndDropConsumer'; + +export default DragAndDropConsumer; diff --git a/src/components/DragAndDrop/DropZone/index.js b/src/components/DragAndDrop/DropZone/index.js deleted file mode 100644 index 525e5a8c148..00000000000 --- a/src/components/DragAndDrop/DropZone/index.js +++ /dev/null @@ -1,34 +0,0 @@ -import React from 'react'; -import {View} from 'react-native'; -import {Portal} from '@gorhom/portal'; -import PropTypes from 'prop-types'; -import styles from '../../../styles/styles'; - -const propTypes = { - /** Name for a drop zone view holder which gives us the flexibility to mount drop zone wherever we want. The holder view can be implemented as PortalHost */ - dropZoneViewHolderName: PropTypes.string.isRequired, - - /** Drop zone content */ - children: PropTypes.node.isRequired, - - /** Required for drag and drop to properly detect dropzone */ - dropZoneId: PropTypes.string.isRequired, -}; - -function DropZone(props) { - return ( - - {props.children} - {/* Necessary for blocking events on content which can publish unwanted dragleave even if we are inside dropzone */} - - - ); -} - -DropZone.displayName = 'DropZone'; -DropZone.propTypes = propTypes; - -export default DropZone; diff --git a/src/components/DragAndDrop/DropZone/index.native.js b/src/components/DragAndDrop/DropZone/index.native.js deleted file mode 100644 index 4e76267469b..00000000000 --- a/src/components/DragAndDrop/DropZone/index.native.js +++ /dev/null @@ -1,5 +0,0 @@ -const DropZone = (props) => props.children; - -DropZone.displayName = 'DropZone'; - -export default DropZone; diff --git a/src/components/DragAndDrop/NoDropZone/index.js b/src/components/DragAndDrop/NoDropZone/index.js index f562796ce09..5573136a3bc 100644 --- a/src/components/DragAndDrop/NoDropZone/index.js +++ b/src/components/DragAndDrop/NoDropZone/index.js @@ -1,29 +1,28 @@ -import {useEffect} from 'react'; +import React, {useRef} from 'react'; +import {View} from 'react-native'; import PropTypes from 'prop-types'; +import styles from '../../../styles/styles'; +import useDragAndDrop from '../../../hooks/useDragAndDrop'; const propTypes = { /** Content */ children: PropTypes.node.isRequired, }; -function NoDropZone(props) { - function handleDrag(event) { - event.preventDefault(); - // eslint-disable-next-line no-param-reassign - event.dataTransfer.dropEffect = 'none'; - } - - useEffect(() => { - document.addEventListener('dragenter', handleDrag); - document.addEventListener('dragover', handleDrag); - - return () => { - document.removeEventListener('dragenter', handleDrag); - document.removeEventListener('dragover', handleDrag); - }; - }, []); - - return props.children; +function NoDropZone({children}) { + const noDropZone = useRef(null); + useDragAndDrop({ + dropZone: noDropZone, + shouldAllowDrop: false, + }); + return ( + (noDropZone.current = e)} + style={[styles.fullScreen]} + > + {children} + + ); } NoDropZone.displayName = 'NoDropZone'; diff --git a/src/components/DragAndDrop/Provider/dragAndDropProviderPropTypes.js b/src/components/DragAndDrop/Provider/dragAndDropProviderPropTypes.js new file mode 100644 index 00000000000..d9cc806e901 --- /dev/null +++ b/src/components/DragAndDrop/Provider/dragAndDropProviderPropTypes.js @@ -0,0 +1,9 @@ +import PropTypes from 'prop-types'; + +export default { + /** Children to render inside this component. */ + children: PropTypes.node.isRequired, + + /** Should this dropZone be disabled? */ + isDisabled: PropTypes.bool, +}; diff --git a/src/components/DragAndDrop/Provider/index.js b/src/components/DragAndDrop/Provider/index.js new file mode 100644 index 00000000000..89b0f47a830 --- /dev/null +++ b/src/components/DragAndDrop/Provider/index.js @@ -0,0 +1,57 @@ +import _ from 'underscore'; +import React, {useRef, useCallback} from 'react'; +import {View} from 'react-native'; +import {PortalHost} from '@gorhom/portal'; +import Str from 'expensify-common/lib/str'; +import dragAndDropProviderPropTypes from './dragAndDropProviderPropTypes'; +import styles from '../../../styles/styles'; +import useDragAndDrop from '../../../hooks/useDragAndDrop'; + +const DragAndDropContext = React.createContext({}); + +/** + * @param {Event} event – drag event + * @returns {Boolean} + */ +function shouldAcceptDrop(event) { + return _.some(event.dataTransfer.types, (type) => type === 'Files'); +} + +function DragAndDropProvider({children, isDisabled = false}) { + const dropZone = useRef(null); + const dropZoneID = useRef(Str.guid('drag-n-drop')); + + const onDropHandler = useRef(() => {}); + const setOnDropHandler = useCallback((callback) => { + onDropHandler.current = callback; + }, []); + + const {isDraggingOver} = useDragAndDrop({ + dropZone, + onDrop: onDropHandler.current, + shouldAcceptDrop, + isDisabled, + }); + + return ( + + (dropZone.current = e)} + style={[styles.flex1, styles.w100, styles.h100]} + > + {isDraggingOver && ( + + + + )} + {children} + + + ); +} + +DragAndDropProvider.propTypes = dragAndDropProviderPropTypes; +DragAndDropProvider.displayName = 'DragAndDropProvider'; + +export default DragAndDropProvider; +export {DragAndDropContext}; diff --git a/src/components/DragAndDrop/Provider/index.native.js b/src/components/DragAndDrop/Provider/index.native.js new file mode 100644 index 00000000000..4dac6691253 --- /dev/null +++ b/src/components/DragAndDrop/Provider/index.native.js @@ -0,0 +1,13 @@ +import dragAndDropProviderPropTypes from './dragAndDropProviderPropTypes'; + +const DragAndDropContext = {}; + +function DragAndDropProvider({children}) { + return children; +} + +DragAndDropProvider.propTypes = dragAndDropProviderPropTypes; +DragAndDropProvider.displayName = 'DragAndDropProvider'; + +export default DragAndDropProvider; +export {DragAndDropContext}; diff --git a/src/components/DragAndDrop/dragAndDropPropTypes.js b/src/components/DragAndDrop/dragAndDropPropTypes.js deleted file mode 100644 index f0b56caeb3f..00000000000 --- a/src/components/DragAndDrop/dragAndDropPropTypes.js +++ /dev/null @@ -1,24 +0,0 @@ -import PropTypes from 'prop-types'; - -export default { - /** Callback to fire when a file has being dragged over the text input & report body */ - onDragOver: PropTypes.func, - - /** Callback to fire when a file has been dragged into the text input & report body */ - onDragEnter: PropTypes.func.isRequired, - - /** Callback to fire when the user is no longer dragging over the text input & report body */ - onDragLeave: PropTypes.func.isRequired, - - /** Callback to fire when a file is dropped on the text input & report body */ - onDrop: PropTypes.func.isRequired, - - /** Guard for accepting drops in drop zone. Drag event is passed to this function as first parameter */ - shouldAcceptDrop: PropTypes.func, - - /** Id of the element on which we want to detect drag */ - dropZoneId: PropTypes.string.isRequired, - - /** Id of the element which is shown while drag is active */ - activeDropZoneId: PropTypes.string.isRequired, -}; diff --git a/src/components/DragAndDrop/index.js b/src/components/DragAndDrop/index.js deleted file mode 100644 index 5c5d17a4a27..00000000000 --- a/src/components/DragAndDrop/index.js +++ /dev/null @@ -1,197 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import _ from 'underscore'; - -import variables from '../../styles/variables'; -import DragAndDropPropTypes from './dragAndDropPropTypes'; -import withNavigationFocus from '../withNavigationFocus'; - -const COPY_DROP_EFFECT = 'copy'; -const NONE_DROP_EFFECT = 'none'; - -const propTypes = { - ...DragAndDropPropTypes, - - /** Callback to fire when a file has being dragged over the text input & report body. This prop is necessary to be inlined to satisfy the linter */ - onDragOver: DragAndDropPropTypes.onDragOver, - - /** Guard for accepting drops in drop zone. Drag event is passed to this function as first parameter. This prop is necessary to be inlined to satisfy the linter */ - shouldAcceptDrop: PropTypes.func, - - /** Whether drag & drop should be disabled */ - disabled: PropTypes.bool, - - /** Rendered child component */ - children: PropTypes.node.isRequired, -}; - -const defaultProps = { - onDragOver: () => {}, - shouldAcceptDrop: (e) => { - if (e.dataTransfer.types) { - for (let i = 0; i < e.dataTransfer.types.length; i++) { - if (e.dataTransfer.types[i] === 'Files') { - return true; - } - } - } - return false; - }, - disabled: false, -}; - -class DragAndDrop extends React.Component { - constructor(props) { - super(props); - - this.throttledDragOverHandler = _.throttle(this.dragOverHandler.bind(this), 100); - this.throttledDragNDropWindowResizeListener = _.throttle(this.dragNDropWindowResizeListener.bind(this), 100); - this.dropZoneDragHandler = this.dropZoneDragHandler.bind(this); - this.dropZoneDragListener = this.dropZoneDragListener.bind(this); - - /* - Last detected drag state on the dropzone -> we start with dragleave since user is not dragging initially. - This state is updated when drop zone is left/entered entirely(not taking the children in the account) or entire window is left - */ - this.dropZoneDragState = 'dragleave'; - } - - componentDidMount() { - if (this.props.disabled) { - return; - } - this.addEventListeners(); - } - - componentDidUpdate(prevProps) { - const isDisabled = this.props.disabled; - if (this.props.isFocused === prevProps.isFocused && isDisabled === prevProps.disabled) { - return; - } - if (!this.props.isFocused || isDisabled) { - this.removeEventListeners(); - } else { - this.addEventListeners(); - } - } - - componentWillUnmount() { - if (this.props.disabled) { - return; - } - this.removeEventListeners(); - } - - addEventListeners() { - this.dropZone = document.getElementById(this.props.dropZoneId); - this.dropZoneRect = this.calculateDropZoneClientReact(); - document.addEventListener('dragover', this.dropZoneDragListener); - document.addEventListener('dragenter', this.dropZoneDragListener); - document.addEventListener('dragleave', this.dropZoneDragListener); - document.addEventListener('drop', this.dropZoneDragListener); - window.addEventListener('resize', this.throttledDragNDropWindowResizeListener); - } - - removeEventListeners() { - document.removeEventListener('dragover', this.dropZoneDragListener); - document.removeEventListener('dragenter', this.dropZoneDragListener); - document.removeEventListener('dragleave', this.dropZoneDragListener); - document.removeEventListener('drop', this.dropZoneDragListener); - window.removeEventListener('resize', this.throttledDragNDropWindowResizeListener); - } - - /** - * @param {Object} event native Event - */ - dragOverHandler(event) { - this.props.onDragOver(event); - } - - dragNDropWindowResizeListener() { - // Update bounding client rect on window resize - this.dropZoneRect = this.calculateDropZoneClientReact(); - } - - calculateDropZoneClientReact() { - const boundingClientRect = this.dropZone.getBoundingClientRect(); - - // Handle edge case when we are under responsive breakpoint the browser doesn't normalize rect.left to 0 and rect.right to window.innerWidth - return { - width: boundingClientRect.width, - left: window.innerWidth <= variables.mobileResponsiveWidthBreakpoint ? 0 : boundingClientRect.left, - right: window.innerWidth <= variables.mobileResponsiveWidthBreakpoint ? window.innerWidth : boundingClientRect.right, - top: boundingClientRect.top, - bottom: boundingClientRect.bottom, - }; - } - - /** - * @param {Object} event native Event - */ - dropZoneDragHandler(event) { - // Setting dropEffect for dragover is required for '+' icon on certain platforms/browsers (eg. Safari) - switch (event.type) { - case 'dragover': - // Continuous event -> can hurt performance, be careful when subscribing - // eslint-disable-next-line no-param-reassign - event.dataTransfer.dropEffect = COPY_DROP_EFFECT; - this.throttledDragOverHandler(event); - break; - case 'dragenter': - // Avoid reporting onDragEnter for children views -> not performant - if (this.dropZoneDragState === 'dragleave') { - this.dropZoneDragState = 'dragenter'; - // eslint-disable-next-line no-param-reassign - event.dataTransfer.dropEffect = COPY_DROP_EFFECT; - this.props.onDragEnter(event); - } - break; - case 'dragleave': - if (this.dropZoneDragState === 'dragenter') { - if ( - event.clientY <= this.dropZoneRect.top || - event.clientY >= this.dropZoneRect.bottom || - event.clientX <= this.dropZoneRect.left || - event.clientX >= this.dropZoneRect.right || - // Cancel drag when file manager is on top of the drop zone area - works only on chromium - (event.target.getAttribute('id') === this.props.activeDropZoneId && !event.relatedTarget) - ) { - this.dropZoneDragState = 'dragleave'; - this.props.onDragLeave(event); - } - } - break; - case 'drop': - this.dropZoneDragState = 'dragleave'; - this.props.onDrop(event); - break; - default: - break; - } - } - - /** - * Handles all types of drag-N-drop events on the drop zone associated with composer - * - * @param {Object} event native Event - */ - dropZoneDragListener(event) { - event.preventDefault(); - - if (this.dropZone.contains(event.target) && this.props.shouldAcceptDrop(event)) { - this.dropZoneDragHandler(event); - } else { - // eslint-disable-next-line no-param-reassign - event.dataTransfer.dropEffect = NONE_DROP_EFFECT; - } - } - - render() { - return this.props.children; - } -} - -DragAndDrop.propTypes = propTypes; -DragAndDrop.defaultProps = defaultProps; - -export default withNavigationFocus(DragAndDrop); diff --git a/src/components/DragAndDrop/index.native.js b/src/components/DragAndDrop/index.native.js deleted file mode 100644 index 4c00e56130d..00000000000 --- a/src/components/DragAndDrop/index.native.js +++ /dev/null @@ -1,5 +0,0 @@ -const DragAndDrop = (props) => props.children; - -DragAndDrop.displayName = 'DragAndDrop'; - -export default DragAndDrop; diff --git a/src/hooks/useDragAndDrop.js b/src/hooks/useDragAndDrop.js new file mode 100644 index 00000000000..98df70085a7 --- /dev/null +++ b/src/hooks/useDragAndDrop.js @@ -0,0 +1,120 @@ +import {useEffect, useRef, useState, useCallback} from 'react'; +import {useIsFocused} from '@react-navigation/native'; + +const COPY_DROP_EFFECT = 'copy'; +const NONE_DROP_EFFECT = 'none'; +const DRAG_ENTER_EVENT = 'dragenter'; +const DRAG_OVER_EVENT = 'dragover'; +const DRAG_LEAVE_EVENT = 'dragleave'; +const DROP_EVENT = 'drop'; + +/** + * @param {Object} dropZone – ref to the dropZone component + * @param {Function} [onDrop] + * @param {Boolean} [shouldAllowDrop] + * @param {Boolean} [isDisabled] + * @param {Function} [shouldAcceptDrop] + * @returns {{isDraggingOver: Boolean}} + */ +export default function useDragAndDrop({dropZone, onDrop = () => {}, shouldAllowDrop = true, isDisabled = false, shouldAcceptDrop = () => true}) { + const isFocused = useIsFocused(); + const [isDraggingOver, setIsDraggingOver] = useState(false); + + // This solution is borrowed from this SO: https://stackoverflow.com/questions/7110353/html5-dragleave-fired-when-hovering-a-child-element + // This is necessary because dragging over children will cause dragleave to execute on the parent. + // You can think of this counter as a stack. When a child is hovered over we push an element onto the stack. + // Then we only process the dragleave event if the count is 0, because it means that the last element (the parent) has been popped off the stack. + const dragCounter = useRef(0); + + // If this component is out of focus or disabled, reset the drag state back to the default + useEffect(() => { + if (isFocused && !isDisabled) { + return; + } + dragCounter.current = 0; + setIsDraggingOver(false); + }, [isFocused, isDisabled]); + + const setDropEffect = useCallback( + (event) => { + const effect = shouldAllowDrop && shouldAcceptDrop(event) ? COPY_DROP_EFFECT : NONE_DROP_EFFECT; + // eslint-disable-next-line no-param-reassign + event.dataTransfer.dropEffect = effect; + // eslint-disable-next-line no-param-reassign + event.dataTransfer.effectAllowed = effect; + }, + [shouldAllowDrop, shouldAcceptDrop], + ); + + /** + * Handles all types of drag-N-drop events on the drop zone associated with composer + * + * @param {Object} event native Event + */ + const dropZoneDragHandler = useCallback( + (event) => { + if (!isFocused || isDisabled) { + return; + } + + event.preventDefault(); + + switch (event.type) { + case DRAG_OVER_EVENT: + setDropEffect(event); + break; + case DRAG_ENTER_EVENT: + dragCounter.current++; + setDropEffect(event); + if (isDraggingOver) { + return; + } + setIsDraggingOver(true); + break; + case DRAG_LEAVE_EVENT: + dragCounter.current--; + if (!isDraggingOver || dragCounter.current > 0) { + return; + } + + setIsDraggingOver(false); + break; + case DROP_EVENT: + dragCounter.current = 0; + setIsDraggingOver(false); + onDrop(event); + break; + default: + break; + } + }, + [isFocused, isDisabled, setDropEffect, isDraggingOver, onDrop], + ); + + useEffect(() => { + if (!dropZone.current) { + return; + } + + const dropZoneRef = dropZone.current; + + // Note that the dragover event needs to be called with `event.preventDefault` in order for the drop event to be fired: + // https://stackoverflow.com/questions/21339924/drop-event-not-firing-in-chrome + dropZoneRef.addEventListener(DRAG_OVER_EVENT, dropZoneDragHandler); + dropZoneRef.addEventListener(DRAG_ENTER_EVENT, dropZoneDragHandler); + dropZoneRef.addEventListener(DRAG_LEAVE_EVENT, dropZoneDragHandler); + dropZoneRef.addEventListener(DROP_EVENT, dropZoneDragHandler); + return () => { + if (!dropZoneRef) { + return; + } + + dropZoneRef.removeEventListener(DRAG_OVER_EVENT, dropZoneDragHandler); + dropZoneRef.removeEventListener(DRAG_ENTER_EVENT, dropZoneDragHandler); + dropZoneRef.removeEventListener(DRAG_LEAVE_EVENT, dropZoneDragHandler); + dropZoneRef.removeEventListener(DROP_EVENT, dropZoneDragHandler); + }; + }, [dropZone, dropZoneDragHandler]); + + return {isDraggingOver}; +} diff --git a/src/pages/home/ReportScreen.js b/src/pages/home/ReportScreen.js index 3535bf8fa8e..66c46da1130 100644 --- a/src/pages/home/ReportScreen.js +++ b/src/pages/home/ReportScreen.js @@ -4,7 +4,6 @@ import PropTypes from 'prop-types'; import {View} from 'react-native'; import lodashGet from 'lodash/get'; import _ from 'underscore'; -import {PortalHost} from '@gorhom/portal'; import styles from '../../styles/styles'; import ScreenWrapper from '../../components/ScreenWrapper'; import HeaderView from './HeaderView'; @@ -38,6 +37,7 @@ import MoneyReportHeader from '../../components/MoneyReportHeader'; import * as ComposerActions from '../../libs/actions/Composer'; import ReportScreenContext from './ReportScreenContext'; import TaskHeaderActionButton from '../../components/TaskHeaderActionButton'; +import DragAndDropProvider from '../../components/DragAndDrop/Provider'; const propTypes = { /** Navigation route context info provided by react navigation */ @@ -137,9 +137,6 @@ class ReportScreen extends React.Component { this.flatListRef = React.createRef(); this.reactionListRef = React.createRef(); - - // We need unique ID for drag and drop to work properly with stack navigator. - this.dragAndDropId = CONST.REPORT.DROP_NATIVE_ID + ReportUtils.generateReportID(); } componentDidMount() { @@ -350,67 +347,64 @@ class ReportScreen extends React.Component { shouldShowCloseButton /> )} - { - // Rounding this value for comparison because they can look like this: 411.9999694824219 - const skeletonViewContainerHeight = Math.round(event.nativeEvent.layout.height); - - // Only set state when the height changes to avoid unnecessary renders - if (reportActionsListViewHeight === skeletonViewContainerHeight) return; - - // The height can be 0 if the component unmounts - we are not interested in this value and want to know how much space it - // takes up so we can set the skeleton view container height. - if (skeletonViewContainerHeight === 0) { - return; - } - reportActionsListViewHeight = skeletonViewContainerHeight; - this.setState({skeletonViewContainerHeight}); - }} - > - {this.isReportReadyForDisplay() && !isLoadingInitialReportActions && !isLoading && ( - - )} - - {/* Note: The report should be allowed to mount even if the initial report actions are not loaded. If we prevent rendering the report while they are loading then - we'll unnecessarily unmount the ReportActionsView which will clear the new marker lines initial state. */} - {(!this.isReportReadyForDisplay() || isLoadingInitialReportActions || isLoading) && ( - - )} - - {this.isReportReadyForDisplay() && ( - <> - + { + // Rounding this value for comparison because they can look like this: 411.9999694824219 + const skeletonViewContainerHeight = Math.round(event.nativeEvent.layout.height); + + // Only set state when the height changes to avoid unnecessary renders + if (reportActionsListViewHeight === skeletonViewContainerHeight) return; + + // The height can be 0 if the component unmounts - we are not interested in this value and want to know how much space it + // takes up so we can set the skeleton view container height. + if (skeletonViewContainerHeight === 0) { + return; + } + reportActionsListViewHeight = skeletonViewContainerHeight; + this.setState({skeletonViewContainerHeight}); + }} + > + {this.isReportReadyForDisplay() && !isLoadingInitialReportActions && !isLoading && ( + - - )} - - {!this.isReportReadyForDisplay() && ( - - )} + )} - - + {/* Note: The report should be allowed to mount even if the initial report actions are not loaded. If we prevent rendering the report while they are loading then + we'll unnecessarily unmount the ReportActionsView which will clear the new marker lines initial state. */} + {(!this.isReportReadyForDisplay() || isLoadingInitialReportActions || isLoading) && ( + + )} + + {this.isReportReadyForDisplay() && ( + <> + + + )} + + {!this.isReportReadyForDisplay() && ( + + )} + + diff --git a/src/pages/home/report/ReportActionCompose.js b/src/pages/home/report/ReportActionCompose.js index baaca0a91bd..644a9021609 100644 --- a/src/pages/home/report/ReportActionCompose.js +++ b/src/pages/home/report/ReportActionCompose.js @@ -41,7 +41,6 @@ import withNavigation from '../../../components/withNavigation'; import * as EmojiUtils from '../../../libs/EmojiUtils'; import * as UserUtils from '../../../libs/UserUtils'; import ReportDropUI from './ReportDropUI'; -import DragAndDrop from '../../../components/DragAndDrop'; import reportPropTypes from '../../reportPropTypes'; import EmojiSuggestions from '../../../components/EmojiSuggestions'; import MentionSuggestions from '../../../components/MentionSuggestions'; @@ -125,9 +124,6 @@ const propTypes = { /** animated ref from react-native-reanimated */ animatedRef: PropTypes.oneOfType([PropTypes.func, PropTypes.shape({current: PropTypes.instanceOf(React.Component)})]).isRequired, - /** Unique id for nativeId in DragAndDrop */ - dragAndDropId: PropTypes.string.isRequired, - ...windowDimensionsPropTypes, ...withLocalizePropTypes, ...withCurrentUserPersonalDetailsPropTypes, @@ -232,7 +228,6 @@ class ReportActionCompose extends React.Component { textInputShouldClear: false, isCommentEmpty: props.comment.length === 0, isMenuVisible: false, - isDraggingOver: false, selection: { start: isMobileSafari && !this.shouldAutoFocus ? 0 : props.comment.length, end: isMobileSafari && !this.shouldAutoFocus ? 0 : props.comment.length, @@ -989,7 +984,7 @@ class ReportActionCompose extends React.Component { // Prevents focusing and showing the keyboard while the drawer is covering the chat. const isBlockedFromConcierge = ReportUtils.chatIncludesConcierge(this.props.report) && User.isBlockedFromConcierge(this.props.blockedFromConcierge); const inputPlaceholder = this.getInputPlaceholder(); - const shouldUseFocusedColor = !isBlockedFromConcierge && !this.props.disabled && (this.state.isFocused || this.state.isDraggingOver); + const shouldUseFocusedColor = !isBlockedFromConcierge && !this.props.disabled && this.state.isFocused; const hasExceededMaxCommentLength = this.state.hasExceededMaxCommentLength; const isFullComposerAvailable = this.state.isFullComposerAvailable && !_.isEmpty(this.state.value); const hasReportRecipient = _.isObject(reportRecipient) && !_.isEmpty(reportRecipient); @@ -1144,75 +1139,59 @@ class ReportActionCompose extends React.Component { )} - { - this.setState({isDraggingOver: true}); + this.checkComposerVisibility()} + autoFocus={this.shouldAutoFocus} + multiline + ref={this.setTextInputRef} + textAlignVertical="top" + placeholder={inputPlaceholder} + placeholderTextColor={themeColors.placeholderText} + onChangeText={(comment) => this.updateComment(comment, true)} + onKeyPress={this.triggerHotkeyActions} + style={[styles.textInputCompose, this.props.isComposerFullSize ? styles.textInputFullCompose : styles.flex4]} + maxLines={maxComposerLines} + onFocus={() => this.setIsFocused(true)} + onBlur={() => { + this.setIsFocused(false); + this.resetSuggestions(); }} - onDragLeave={() => { - this.setState({isDraggingOver: false}); + onClick={() => { + this.shouldBlockEmojiCalc = false; + this.shouldBlockMentionCalc = false; }} - onDrop={(e) => { - e.preventDefault(); - if (this.state.isAttachmentPreviewActive) { - this.setState({isDraggingOver: false}); + onPasteFile={displayFileInModal} + shouldClear={this.state.textInputShouldClear} + onClear={() => this.setTextInputShouldClear(false)} + isDisabled={isBlockedFromConcierge || this.props.disabled} + selection={this.state.selection} + onSelectionChange={this.onSelectionChange} + isFullComposerAvailable={isFullComposerAvailable} + setIsFullComposerAvailable={this.setIsFullComposerAvailable} + isComposerFullSize={this.props.isComposerFullSize} + value={this.state.value} + numberOfLines={this.props.numberOfLines} + onNumberOfLinesChange={this.updateNumberOfLines} + shouldCalculateCaretPosition + onLayout={(e) => { + const composerHeight = e.nativeEvent.layout.height; + if (this.state.composerHeight === composerHeight) { return; } - - const file = lodashGet(e, ['dataTransfer', 'files', 0]); - - displayFileInModal(file); - - this.setState({isDraggingOver: false}); + this.setState({composerHeight}); }} - disabled={this.props.disabled} - > - this.checkComposerVisibility()} - autoFocus={this.shouldAutoFocus} - multiline - ref={this.setTextInputRef} - textAlignVertical="top" - placeholder={inputPlaceholder} - placeholderTextColor={themeColors.placeholderText} - onChangeText={(comment) => this.updateComment(comment, true)} - onKeyPress={this.triggerHotkeyActions} - style={[styles.textInputCompose, this.props.isComposerFullSize ? styles.textInputFullCompose : styles.flex4]} - maxLines={maxComposerLines} - onFocus={() => this.setIsFocused(true)} - onBlur={() => { - this.setIsFocused(false); - this.resetSuggestions(); - }} - onClick={() => { - this.shouldBlockEmojiCalc = false; - this.shouldBlockMentionCalc = false; - }} - onPasteFile={displayFileInModal} - shouldClear={this.state.textInputShouldClear} - onClear={() => this.setTextInputShouldClear(false)} - isDisabled={isBlockedFromConcierge || this.props.disabled} - selection={this.state.selection} - onSelectionChange={this.onSelectionChange} - isFullComposerAvailable={isFullComposerAvailable} - setIsFullComposerAvailable={this.setIsFullComposerAvailable} - isComposerFullSize={this.props.isComposerFullSize} - value={this.state.value} - numberOfLines={this.props.numberOfLines} - onNumberOfLinesChange={this.updateNumberOfLines} - shouldCalculateCaretPosition - onLayout={(e) => { - const composerHeight = e.nativeEvent.layout.height; - if (this.state.composerHeight === composerHeight) { - return; - } - this.setState({composerHeight}); - }} - onScroll={() => this.setShouldShowSuggestionMenuToFalse()} - /> - + onScroll={() => this.setShouldShowSuggestionMenuToFalse()} + /> + { + if (this.state.isAttachmentPreviewActive) { + return; + } + const file = lodashGet(e, ['dataTransfer', 'files', 0]); + displayFileInModal(file); + }} + /> )} @@ -1267,7 +1246,6 @@ class ReportActionCompose extends React.Component { /> - {this.state.isDraggingOver && } {!_.isEmpty(this.state.suggestedEmojis) && this.state.shouldShowEmojiSuggestionMenu && ( - - + + + + + + {translate('reportActionCompose.dropToUpload')} - {props.translate('reportActionCompose.dropToUpload')} - + ); } ReportDropUI.displayName = 'ReportDropUI'; ReportDropUI.propTypes = propTypes; -export default withLocalize(ReportDropUI); +export default ReportDropUI; diff --git a/src/pages/home/report/ReportFooter.js b/src/pages/home/report/ReportFooter.js index f37c0247fad..c03e47dd531 100644 --- a/src/pages/home/report/ReportFooter.js +++ b/src/pages/home/report/ReportFooter.js @@ -44,9 +44,6 @@ const propTypes = { /** Whether user interactions should be disabled */ shouldDisableCompose: PropTypes.bool, - /** Unique id for nativeId in DragAndDrop */ - dragAndDropId: PropTypes.string.isRequired, - ...windowDimensionsPropTypes, }; @@ -95,7 +92,6 @@ function ReportFooter(props) { pendingAction={props.pendingAction} isComposerFullSize={props.isComposerFullSize} disabled={props.shouldDisableCompose} - dragAndDropId={props.dragAndDropId} /> diff --git a/src/stories/DragAndDrop.stories.js b/src/stories/DragAndDrop.stories.js index 0398e5af3ac..eb9b6bbed6f 100644 --- a/src/stories/DragAndDrop.stories.js +++ b/src/stories/DragAndDrop.stories.js @@ -1,10 +1,9 @@ import React, {useState} from 'react'; import {View, Image} from 'react-native'; -import {PortalHost, PortalProvider} from '@gorhom/portal'; import lodashGet from 'lodash/get'; import Text from '../components/Text'; -import DragAndDrop from '../components/DragAndDrop'; -import DropZone from '../components/DragAndDrop/DropZone'; +import DragAndDropProvider from '../components/DragAndDrop/Provider'; +import DragAndDropConsumer from '../components/DragAndDrop/Consumer'; import styles from '../styles/styles'; /** @@ -14,52 +13,28 @@ import styles from '../styles/styles'; */ const story = { title: 'Components/DragAndDrop', - component: DragAndDrop, + component: DragAndDropConsumer, }; function Default() { - const [draggingOver, setDraggingOver] = useState(false); - - const [fileUrl, setFileUrl] = useState(''); - + const [fileURL, setFileURL] = useState(''); return ( - - {/* DragAndDrop does not need to render drop area as children since it is connected to it via id, which gives us flexibility to bring DragAndDrop where your - draggingOver state is located */} - { - setDraggingOver(true); - }} - onDragLeave={() => { - setDraggingOver(false); - }} - onDrop={(e) => { - const file = lodashGet(e, ['dataTransfer', 'files', 0]); - if (file && file.type.includes('image')) { - setFileUrl(URL.createObjectURL(file)); - } - setDraggingOver(false); - }} - > - - {fileUrl ? ( + + + + {fileURL ? ( Drop a picture here! )} - {/* Portals give us flexibility to render active drag overlay regardless of your react component structure */} - - - {draggingOver && ( - - )} - + { + const file = lodashGet(e, ['dataTransfer', 'files', 0]); + if (file && file.type.includes('image')) { + const reader = new FileReader(); + reader.addEventListener('load', () => setFileURL(reader.result)); + reader.readAsDataURL(file); + } + }} + > + + Release to upload file + + + + ); } diff --git a/src/styles/styles.js b/src/styles/styles.js index 57f20a82cdf..0fe77205e9a 100644 --- a/src/styles/styles.js +++ b/src/styles/styles.js @@ -3156,30 +3156,24 @@ const styles = { marginLeft: 6, }, - fullScreenTransparentOverlay: { + fullScreen: { position: 'absolute', - width: '100%', - height: '100%', top: 0, left: 0, right: 0, bottom: 0, - backgroundColor: themeColors.dropUIBG, - zIndex: 2, }, - dropZoneTopInvisibleOverlay: { - position: 'absolute', - width: '100%', - height: '100%', - top: 0, - left: 0, - right: 0, - bottom: 0, - backgroundColor: themeColors.dropTransparentOverlay, + invisibleOverlay: { + backgroundColor: themeColors.transparent, zIndex: 1000, }, + reportDropOverlay: { + backgroundColor: themeColors.dropUIBG, + zIndex: 2, + }, + cardSection: { backgroundColor: themeColors.cardBG, borderRadius: variables.componentBorderRadiusCard, diff --git a/src/styles/themes/default.js b/src/styles/themes/default.js index 59f04efb6af..0cd32e170a7 100644 --- a/src/styles/themes/default.js +++ b/src/styles/themes/default.js @@ -65,7 +65,6 @@ const darkTheme = { heroCard: colors.blue, uploadPreviewActivityIndicator: colors.greenHighlightBackground, dropUIBG: 'rgba(6,27,9,0.92)', - dropTransparentOverlay: 'rgba(255,255,255,0)', checkBox: colors.green, pickerOptionsTextColor: colors.white, imageCropBackgroundColor: colors.greenIcons, @@ -123,7 +122,6 @@ const oldTheme = { heroCard: colors.blue, uploadPreviewActivityIndicator: colors.gray1, dropUIBG: 'rgba(6,27,9,0.92)', - dropTransparentOverlay: 'rgba(255,255,255,0)', cardBG: colors.gray1, cardBorder: colors.gray1, checkBox: colors.blue,