From 87737bd912d3c4714d70a22810dec99e78f9e4b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucas=20M=C3=B3rawski?= Date: Thu, 22 Jun 2023 07:47:20 +0200 Subject: [PATCH 01/19] viewport scroll block on report screen --- src/components/SwipeableView/index.js | 82 ++++++++++- .../withBlockViewportScroll/index.js | 59 ++++++++ .../withBlockViewportScroll/index.native.js | 23 +++ src/libs/NativeWebKeyboard/index.js | 133 ++++++++++++++++++ src/libs/NativeWebKeyboard/index.native.js | 4 + src/pages/home/ReportScreen.js | 34 +++-- src/pages/home/report/ReportFooter.js | 22 +-- 7 files changed, 334 insertions(+), 23 deletions(-) create mode 100644 src/components/withBlockViewportScroll/index.js create mode 100644 src/components/withBlockViewportScroll/index.native.js create mode 100644 src/libs/NativeWebKeyboard/index.js create mode 100644 src/libs/NativeWebKeyboard/index.native.js diff --git a/src/components/SwipeableView/index.js b/src/components/SwipeableView/index.js index 96640b107608..8edd3ce3f38d 100644 --- a/src/components/SwipeableView/index.js +++ b/src/components/SwipeableView/index.js @@ -1,3 +1,81 @@ -export default ({children}) => children; +import React, {useEffect, useRef} from 'react'; +import {View} from 'react-native'; +import PropTypes from 'prop-types'; -// Swipeable View is available just on Android/iOS for now. +const propTypes = { + children: PropTypes.element.isRequired, + + /** Callback to fire when the user swipes down on the child content */ + onSwipeDown: PropTypes.func, + + /** Callback to fire when the user swipes up on the child content */ + onSwipeUp: PropTypes.func, + + /** Container styles */ + style: PropTypes.oneOfType([PropTypes.arrayOf(PropTypes.object), PropTypes.object]), +}; + +const defaultProps = { + onSwipeDown: undefined, + onSwipeUp: undefined, + style: undefined, +}; + +const MIN_DELTA_Y = 25; + +const isTextSelection = () => { + const focused = document.activeElement; + if (!focused) return false; + if (typeof focused.selectionStart === 'number' && typeof focused.selectionEnd === 'number') { + return focused.selectionStart !== focused.selectionEnd; + } + return false; +}; + +function SwipeableView(props) { + const ref = useRef(); + const startY = useRef(0); + + useEffect(() => { + const element = ref.current; + + const handleTouchStart = (event) => { + startY.current = event.touches[0].clientY; + }; + + const handleTouchEnd = (event) => { + const deltaY = event.changedTouches[0].clientY - startY.current; + + if (deltaY > MIN_DELTA_Y && props.onSwipeDown && !isTextSelection()) { + props.onSwipeDown(); + } + + if (deltaY < -MIN_DELTA_Y && props.onSwipeUp && !isTextSelection()) { + props.onSwipeUp(); + } + }; + + element.addEventListener('touchstart', handleTouchStart); + + element.addEventListener('touchend', handleTouchEnd); + + return () => { + element.removeEventListener('touchstart', handleTouchStart); + element.removeEventListener('touchend', handleTouchEnd); + }; + }, [props]); + + return ( + + {props.children} + + ); +} + +SwipeableView.propTypes = propTypes; +SwipeableView.defaultProps = defaultProps; + +export default SwipeableView; diff --git a/src/components/withBlockViewportScroll/index.js b/src/components/withBlockViewportScroll/index.js new file mode 100644 index 000000000000..ff3b32c2253a --- /dev/null +++ b/src/components/withBlockViewportScroll/index.js @@ -0,0 +1,59 @@ +/* eslint-disable react/jsx-props-no-spreading */ +import React, {useEffect, useRef} from 'react'; +import PropTypes from 'prop-types'; +import Keyboard from '../../libs/NativeWebKeyboard'; +import getComponentDisplayName from '../../libs/getComponentDisplayName'; + +export default function (WrappedComponent) { + const WithBlockViewportScroll = (props) => { + const optimalScrollY = useRef(0); + const keybShowOff = useRef(() => {}); + const keybHideOff = useRef(() => {}); + + useEffect(() => { + const handleTouchEnd = () => { + window.scrollTo({top: optimalScrollY.current, behavior: 'smooth'}); + }; + + const handleKeybShow = () => { + optimalScrollY.current = window.scrollY; + window.addEventListener('touchend', handleTouchEnd); + }; + + const handleKeybHide = () => { + window.removeEventListener('touchend', handleTouchEnd); + }; + + keybShowOff.current = Keyboard.addListener('keyboardDidShow', handleKeybShow); + keybHideOff.current = Keyboard.addListener('keyboardDidHide', handleKeybHide); + + return () => { + keybShowOff.current(); + keybHideOff.current(); + window.removeEventListener('touchend', handleTouchEnd); + }; + }, []); + + return ( + + ); + }; + + WithBlockViewportScroll.displayName = `WithBlockViewportScroll(${getComponentDisplayName(WrappedComponent)})`; + WithBlockViewportScroll.propTypes = { + forwardedRef: PropTypes.oneOfType([PropTypes.func, PropTypes.shape({current: PropTypes.instanceOf(React.Component)})]), + }; + WithBlockViewportScroll.defaultProps = { + forwardedRef: undefined, + }; + + return React.forwardRef((props, ref) => ( + + )); +} diff --git a/src/components/withBlockViewportScroll/index.native.js b/src/components/withBlockViewportScroll/index.native.js new file mode 100644 index 000000000000..97d21bb15986 --- /dev/null +++ b/src/components/withBlockViewportScroll/index.native.js @@ -0,0 +1,23 @@ +/* eslint-disable react/jsx-props-no-spreading */ +import React from 'react'; +import PropTypes from 'prop-types'; +import getComponentDisplayName from '../../libs/getComponentDisplayName'; + +export default function (WrappedComponent) { + const PassThroughComponent = (props) => ; + + PassThroughComponent.displayName = `PassThroughComponent(${getComponentDisplayName(WrappedComponent)})`; + PassThroughComponent.propTypes = { + forwardedRef: PropTypes.oneOfType([PropTypes.func, PropTypes.shape({current: PropTypes.instanceOf(React.Component)})]), + }; + PassThroughComponent.defaultProps = { + forwardedRef: undefined, + }; + + return React.forwardRef((props, ref) => ( + + )); +} diff --git a/src/libs/NativeWebKeyboard/index.js b/src/libs/NativeWebKeyboard/index.js new file mode 100644 index 000000000000..c17719762df8 --- /dev/null +++ b/src/libs/NativeWebKeyboard/index.js @@ -0,0 +1,133 @@ +import _ from 'underscore'; +import {Keyboard} from 'react-native'; + +// types that will show a virtual keyboard in a mobile browser +const INPUT_TYPES_WITH_KEYBOARD = ['text', 'search', 'tel', 'url', 'email', 'password']; + +const isInputKeyboardType = (element) => { + if (!!element && ((element.tagName === 'INPUT' && INPUT_TYPES_WITH_KEYBOARD.includes(element.type)) || element.tagName === 'TEXTAREA')) { + return true; + } + return false; +}; + +const isVisible = () => { + const focused = document.activeElement; + return isInputKeyboardType(focused); +}; + +const nullFn = () => null; + +let isKeyboardListenerRunning = false; +let currentVisibleElement = null; +const showListeners = []; +const hideListeners = []; +const SHOW_EVENT_NAME = 'keyboardDidShow'; +const HIDE_EVENT_NAME = 'keyboardDidHide'; +let previousVPHeight = window.visualViewport.height; + +const handleVPResize = () => { + if (window.visualViewport.height < previousVPHeight) { + // this might mean virtual keyboard showed up + // checking if any input element is in focus + if (isInputKeyboardType(document.activeElement) && document.activeElement !== currentVisibleElement) { + // input el is focused - v keyboard is up + showListeners.forEach((fn) => fn()); + } + } + + if (window.visualViewport.height > previousVPHeight) { + if (!isVisible()) { + hideListeners.forEach((fn) => fn()); + } + } + + previousVPHeight = window.visualViewport.height; + currentVisibleElement = document.activeElement; +}; + +const startKeboardListeningService = () => { + isKeyboardListenerRunning = true; + window.visualViewport.addEventListener('resize', handleVPResize); +}; + +const addListener = (eventName, callbackFn) => { + if ((eventName !== SHOW_EVENT_NAME && eventName !== HIDE_EVENT_NAME) || !callbackFn) return; + + if (eventName === SHOW_EVENT_NAME) { + showListeners.push(callbackFn); + } + + if (eventName === HIDE_EVENT_NAME) { + hideListeners.push(callbackFn); + } + + if (!isKeyboardListenerRunning) { + startKeboardListeningService(); + } + + return () => { + if (eventName === SHOW_EVENT_NAME) { + _.filter(showListeners, (fn) => fn !== callbackFn); + } + + if (eventName === HIDE_EVENT_NAME) { + _.filter(hideListeners, (fn) => fn !== callbackFn); + } + + if (isKeyboardListenerRunning && !showListeners.length && !hideListeners.length) { + window.visualViewport.removeEventListener('resize', handleVPResize); + isKeyboardListenerRunning = false; + } + }; +}; + +export default { + /** + * Whether the keyboard is last known to be visible. + */ + isVisible, + /** + * Dismisses the active keyboard and removes focus. + */ + dismiss: Keyboard.dismiss, + /** + * The `addListener` function connects a JavaScript function to an identified native + * keyboard notification event. + * + * This function then returns the reference to the listener. + * + * {string} eventName The `nativeEvent` is the string that identifies the event you're listening for. This + * can be any of the following: + * + * - `keyboardWillShow` + * - `keyboardDidShow` + * - `keyboardWillHide` + * - `keyboardDidHide` + * - `keyboardWillChangeFrame` + * - `keyboardDidChangeFrame` + * + * Note that if you set `android:windowSoftInputMode` to `adjustResize` or `adjustNothing`, + * only `keyboardDidShow` and `keyboardDidHide` events will be available on Android. + * `keyboardWillShow` as well as `keyboardWillHide` are generally not available on Android + * since there is no native corresponding event. + * + * On Web only two events are available: + * + * - `keyboardDidShow` + * - `keyboardDidHide` + * + * {function} callback function to be called when the event fires. + */ + addListener, + /** + * Useful for syncing TextInput (or other keyboard accessory view) size of + * position changes with keyboard movements. + * Not working on web. + */ + scheduleLayoutAnimation: nullFn, + /** + * Return the metrics of the soft-keyboard if visible. Currently now working on web. + */ + metrics: nullFn, +}; diff --git a/src/libs/NativeWebKeyboard/index.native.js b/src/libs/NativeWebKeyboard/index.native.js new file mode 100644 index 000000000000..7c0431a971ac --- /dev/null +++ b/src/libs/NativeWebKeyboard/index.native.js @@ -0,0 +1,4 @@ +import {Keyboard} from 'react-native'; + +// just reexport native Keyboard module +export default Keyboard; diff --git a/src/pages/home/ReportScreen.js b/src/pages/home/ReportScreen.js index d3fb665ecb48..5dca56cc52ca 100644 --- a/src/pages/home/ReportScreen.js +++ b/src/pages/home/ReportScreen.js @@ -1,10 +1,10 @@ import React from 'react'; -import {withOnyx} from 'react-native-onyx'; +import { withOnyx } from 'react-native-onyx'; import PropTypes from 'prop-types'; -import {View} from 'react-native'; +import { View } from 'react-native'; import lodashGet from 'lodash/get'; import _ from 'underscore'; -import {PortalHost} from '@gorhom/portal'; +import { PortalHost } from '@gorhom/portal'; import styles from '../../styles/styles'; import ScreenWrapper from '../../components/ScreenWrapper'; import HeaderView from './HeaderView'; @@ -17,18 +17,18 @@ import ReportActionsView from './report/ReportActionsView'; import CONST from '../../CONST'; import ReportActionsSkeletonView from '../../components/ReportActionsSkeletonView'; import reportActionPropTypes from './report/reportActionPropTypes'; -import {withNetwork} from '../../components/OnyxProvider'; +import { withNetwork } from '../../components/OnyxProvider'; import compose from '../../libs/compose'; import Visibility from '../../libs/Visibility'; import networkPropTypes from '../../components/networkPropTypes'; -import withWindowDimensions, {windowDimensionsPropTypes} from '../../components/withWindowDimensions'; +import withWindowDimensions, { windowDimensionsPropTypes } from '../../components/withWindowDimensions'; import OfflineWithFeedback from '../../components/OfflineWithFeedback'; import ReportFooter from './report/ReportFooter'; import Banner from '../../components/Banner'; import withLocalize from '../../components/withLocalize'; import reportPropTypes from '../reportPropTypes'; import FullPageNotFoundView from '../../components/BlockingViews/FullPageNotFoundView'; -import withViewportOffsetTop, {viewportOffsetTopPropTypes} from '../../components/withViewportOffsetTop'; +import withViewportOffsetTop, { viewportOffsetTopPropTypes } from '../../components/withViewportOffsetTop'; import * as ReportActionsUtils from '../../libs/ReportActionsUtils'; import personalDetailsPropType from '../personalDetailsPropType'; import withNavigationFocus from '../../components/withNavigationFocus'; @@ -37,8 +37,9 @@ import EmojiPicker from '../../components/EmojiPicker/EmojiPicker'; import * as EmojiPickerAction from '../../libs/actions/EmojiPickerAction'; import TaskHeader from '../../components/TaskHeader'; import MoneyRequestHeader from '../../components/MoneyRequestHeader'; -import withNavigation, {withNavigationPropTypes} from '../../components/withNavigation'; +import withNavigation, { withNavigationPropTypes } from '../../components/withNavigation'; import * as ComposerActions from '../../libs/actions/Composer'; +import withHideKeyboardOnViewportScroll from '../../components/withBlockViewportScroll'; const propTypes = { /** Navigation route context info provided by react navigation */ @@ -218,7 +219,7 @@ class ReportScreen extends React.Component { } dismissBanner() { - this.setState({isBannerVisible: false}); + this.setState({ isBannerVisible: false }); } chatWithAccountManager() { @@ -231,7 +232,13 @@ class ReportScreen extends React.Component { const reportID = getReportID(this.props.route); const addWorkspaceRoomOrChatPendingAction = lodashGet(this.props.report, 'pendingFields.addWorkspaceRoom') || lodashGet(this.props.report, 'pendingFields.createChat'); const addWorkspaceRoomOrChatErrors = lodashGet(this.props.report, 'errorFields.addWorkspaceRoom') || lodashGet(this.props.report, 'errorFields.createChat'); - const screenWrapperStyle = [styles.appContent, styles.flex1, {marginTop: this.props.viewportOffsetTop}]; + const screenWrapperStyle = [ + styles.appContent, + styles.flex1, + { + marginTop: this.props.viewportOffsetTop, + }, + ]; // There are no reportActions at all to display and we are still in the process of loading the next set of actions. const isLoadingInitialReportActions = _.isEmpty(this.props.reportActions) && this.props.report.isLoadingReportActions; @@ -307,7 +314,7 @@ class ReportScreen extends React.Component { return; } reportActionsListViewHeight = skeletonViewContainerHeight; - this.setState({skeletonViewContainerHeight}); + this.setState({ skeletonViewContainerHeight }); }} > {this.isReportReadyForDisplay() && !isLoadingInitialReportActions && !isLoading && ( @@ -361,6 +368,7 @@ ReportScreen.propTypes = propTypes; ReportScreen.defaultProps = defaultProps; export default compose( + withHideKeyboardOnViewportScroll, withViewportOffsetTop, withLocalize, withWindowDimensions, @@ -372,15 +380,15 @@ export default compose( key: ONYXKEYS.IS_SIDEBAR_LOADED, }, reportActions: { - key: ({route}) => `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${getReportID(route)}`, + key: ({ route }) => `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${getReportID(route)}`, canEvict: false, selector: ReportActionsUtils.getSortedReportActionsForDisplay, }, report: { - key: ({route}) => `${ONYXKEYS.COLLECTION.REPORT}${getReportID(route)}`, + key: ({ route }) => `${ONYXKEYS.COLLECTION.REPORT}${getReportID(route)}`, }, isComposerFullSize: { - key: ({route}) => `${ONYXKEYS.COLLECTION.REPORT_IS_COMPOSER_FULL_SIZE}${getReportID(route)}`, + key: ({ route }) => `${ONYXKEYS.COLLECTION.REPORT_IS_COMPOSER_FULL_SIZE}${getReportID(route)}`, }, betas: { key: ONYXKEYS.BETAS, diff --git a/src/pages/home/report/ReportFooter.js b/src/pages/home/report/ReportFooter.js index 92838ecf9451..49679b4f7d78 100644 --- a/src/pages/home/report/ReportFooter.js +++ b/src/pages/home/report/ReportFooter.js @@ -1,8 +1,8 @@ import React from 'react'; import _ from 'underscore'; import PropTypes from 'prop-types'; -import {withOnyx} from 'react-native-onyx'; -import {View, Keyboard} from 'react-native'; +import { withOnyx } from 'react-native-onyx'; +import { Keyboard, View } from 'react-native'; import CONST from '../../../CONST'; import ReportActionCompose from './ReportActionCompose'; import AnonymousReportFooter from '../../../components/AnonymousReportFooter'; @@ -11,7 +11,7 @@ import OfflineIndicator from '../../../components/OfflineIndicator'; import ArchivedReportFooter from '../../../components/ArchivedReportFooter'; import compose from '../../../libs/compose'; import ONYXKEYS from '../../../ONYXKEYS'; -import withWindowDimensions, {windowDimensionsPropTypes} from '../../../components/withWindowDimensions'; +import withWindowDimensions, { windowDimensionsPropTypes } from '../../../components/withWindowDimensions'; import styles from '../../../styles/styles'; import reportActionPropTypes from './reportActionPropTypes'; import reportPropTypes from '../../reportPropTypes'; @@ -48,9 +48,9 @@ const propTypes = { }; const defaultProps = { - report: {reportID: '0'}, + report: { reportID: '0' }, reportActions: [], - onSubmitComment: () => {}, + onSubmitComment: () => { }, errors: {}, pendingAction: null, shouldShowComposeInput: true, @@ -58,7 +58,10 @@ const defaultProps = { }; function ReportFooter(props) { - const chatFooterStyles = {...styles.chatFooter, minHeight: !props.isOffline ? CONST.CHAT_FOOTER_MIN_HEIGHT : 0}; + const chatFooterStyles = { + ...styles.chatFooter, + minHeight: !props.isOffline ? CONST.CHAT_FOOTER_MIN_HEIGHT : 0, + }; const isArchivedRoom = ReportUtils.isArchivedRoom(props.report); const isAllowedToComment = ReportUtils.isAllowedToComment(props.report); const hideComposer = isArchivedRoom || !_.isEmpty(props.errors) || !isAllowedToComment; @@ -75,7 +78,10 @@ function ReportFooter(props) { )} {!hideComposer && (props.shouldShowComposeInput || !props.isSmallScreenWidth) && ( - + {Session.isAnonymousUser() ? ( ) : ( @@ -102,6 +108,6 @@ ReportFooter.defaultProps = defaultProps; export default compose( withWindowDimensions, withOnyx({ - shouldShowComposeInput: {key: ONYXKEYS.SHOULD_SHOW_COMPOSE_INPUT}, + shouldShowComposeInput: { key: ONYXKEYS.SHOULD_SHOW_COMPOSE_INPUT }, }), )(ReportFooter); From 9ef0f8302f6cf6ddc14660c90e22813743750d49 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucas=20M=C3=B3rawski?= Date: Thu, 22 Jun 2023 09:05:12 +0200 Subject: [PATCH 02/19] refactored function declaration --- src/components/withBlockViewportScroll/index.js | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/components/withBlockViewportScroll/index.js b/src/components/withBlockViewportScroll/index.js index ff3b32c2253a..8228f80e2461 100644 --- a/src/components/withBlockViewportScroll/index.js +++ b/src/components/withBlockViewportScroll/index.js @@ -1,18 +1,18 @@ /* eslint-disable react/jsx-props-no-spreading */ -import React, {useEffect, useRef} from 'react'; +import React, { useEffect, useRef } from 'react'; import PropTypes from 'prop-types'; import Keyboard from '../../libs/NativeWebKeyboard'; import getComponentDisplayName from '../../libs/getComponentDisplayName'; -export default function (WrappedComponent) { - const WithBlockViewportScroll = (props) => { +export default function(WrappedComponent) { + function WithBlockViewportScroll(props) { const optimalScrollY = useRef(0); - const keybShowOff = useRef(() => {}); - const keybHideOff = useRef(() => {}); + const keybShowOff = useRef(() => { }); + const keybHideOff = useRef(() => { }); useEffect(() => { const handleTouchEnd = () => { - window.scrollTo({top: optimalScrollY.current, behavior: 'smooth'}); + window.scrollTo({ top: optimalScrollY.current, behavior: 'smooth' }); }; const handleKeybShow = () => { @@ -40,11 +40,11 @@ export default function (WrappedComponent) { ref={props.forwardedRef} /> ); - }; + } WithBlockViewportScroll.displayName = `WithBlockViewportScroll(${getComponentDisplayName(WrappedComponent)})`; WithBlockViewportScroll.propTypes = { - forwardedRef: PropTypes.oneOfType([PropTypes.func, PropTypes.shape({current: PropTypes.instanceOf(React.Component)})]), + forwardedRef: PropTypes.oneOfType([PropTypes.func, PropTypes.shape({ current: PropTypes.instanceOf(React.Component) })]), }; WithBlockViewportScroll.defaultProps = { forwardedRef: undefined, From c342cec1f43dfd77e491e4f20fc62f01182cd277 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucas=20M=C3=B3rawski?= Date: Fri, 23 Jun 2023 15:33:41 +0200 Subject: [PATCH 03/19] formatting, const naming and jsdocs --- .../withBlockViewportScroll/index.js | 41 ++++++++++++++----- src/pages/home/ReportScreen.js | 24 +++++------ src/pages/home/report/ReportFooter.js | 12 +++--- 3 files changed, 49 insertions(+), 28 deletions(-) diff --git a/src/components/withBlockViewportScroll/index.js b/src/components/withBlockViewportScroll/index.js index 8228f80e2461..5573081655d8 100644 --- a/src/components/withBlockViewportScroll/index.js +++ b/src/components/withBlockViewportScroll/index.js @@ -1,18 +1,39 @@ /* eslint-disable react/jsx-props-no-spreading */ -import React, { useEffect, useRef } from 'react'; +import React, {useEffect, useRef} from 'react'; import PropTypes from 'prop-types'; import Keyboard from '../../libs/NativeWebKeyboard'; import getComponentDisplayName from '../../libs/getComponentDisplayName'; -export default function(WrappedComponent) { +/** + * A Higher Order Component (HOC) that wraps a given React component and blocks viewport scroll when the keyboard is visible. + * It does this by capturing the current scrollY position when the keyboard is shown, then scrolls back to this position smoothly on 'touchend' event. + * This scroll blocking is removed when the keyboard hides. + * This HOC is doing nothing on native platforms. + * + * The HOC also passes through a `forwardedRef` prop to the wrapped component, which can be used to assign a ref to the wrapped component. + * + * @export + * @param {React.Component} WrappedComponent - The component to be wrapped by the HOC. + * @returns {React.Component} A component that includes the scroll-blocking behaviour. + * + * @example + * export default withBlockViewportScroll(MyComponent); + * + * // Inside MyComponent definition + * // You can access the ref passed from HOC as follows: + * const MyComponent = React.forwardRef((props, ref) => ( + * // use ref here + * )); + */ +export default function (WrappedComponent) { function WithBlockViewportScroll(props) { const optimalScrollY = useRef(0); - const keybShowOff = useRef(() => { }); - const keybHideOff = useRef(() => { }); + const keyboardShowListenerRef = useRef(() => {}); + const keyboardHideListenerRef = useRef(() => {}); useEffect(() => { const handleTouchEnd = () => { - window.scrollTo({ top: optimalScrollY.current, behavior: 'smooth' }); + window.scrollTo({top: optimalScrollY.current, behavior: 'smooth'}); }; const handleKeybShow = () => { @@ -24,12 +45,12 @@ export default function(WrappedComponent) { window.removeEventListener('touchend', handleTouchEnd); }; - keybShowOff.current = Keyboard.addListener('keyboardDidShow', handleKeybShow); - keybHideOff.current = Keyboard.addListener('keyboardDidHide', handleKeybHide); + keyboardShowListenerRef.current = Keyboard.addListener('keyboardDidShow', handleKeybShow); + keyboardHideListenerRef.current = Keyboard.addListener('keyboardDidHide', handleKeybHide); return () => { - keybShowOff.current(); - keybHideOff.current(); + keyboardShowListenerRef.current(); + keyboardHideListenerRef.current(); window.removeEventListener('touchend', handleTouchEnd); }; }, []); @@ -44,7 +65,7 @@ export default function(WrappedComponent) { WithBlockViewportScroll.displayName = `WithBlockViewportScroll(${getComponentDisplayName(WrappedComponent)})`; WithBlockViewportScroll.propTypes = { - forwardedRef: PropTypes.oneOfType([PropTypes.func, PropTypes.shape({ current: PropTypes.instanceOf(React.Component) })]), + forwardedRef: PropTypes.oneOfType([PropTypes.func, PropTypes.shape({current: PropTypes.instanceOf(React.Component)})]), }; WithBlockViewportScroll.defaultProps = { forwardedRef: undefined, diff --git a/src/pages/home/ReportScreen.js b/src/pages/home/ReportScreen.js index 5dca56cc52ca..819e3b60cfac 100644 --- a/src/pages/home/ReportScreen.js +++ b/src/pages/home/ReportScreen.js @@ -1,10 +1,10 @@ import React from 'react'; -import { withOnyx } from 'react-native-onyx'; +import {withOnyx} from 'react-native-onyx'; import PropTypes from 'prop-types'; -import { View } from 'react-native'; +import {View} from 'react-native'; import lodashGet from 'lodash/get'; import _ from 'underscore'; -import { PortalHost } from '@gorhom/portal'; +import {PortalHost} from '@gorhom/portal'; import styles from '../../styles/styles'; import ScreenWrapper from '../../components/ScreenWrapper'; import HeaderView from './HeaderView'; @@ -17,18 +17,18 @@ import ReportActionsView from './report/ReportActionsView'; import CONST from '../../CONST'; import ReportActionsSkeletonView from '../../components/ReportActionsSkeletonView'; import reportActionPropTypes from './report/reportActionPropTypes'; -import { withNetwork } from '../../components/OnyxProvider'; +import {withNetwork} from '../../components/OnyxProvider'; import compose from '../../libs/compose'; import Visibility from '../../libs/Visibility'; import networkPropTypes from '../../components/networkPropTypes'; -import withWindowDimensions, { windowDimensionsPropTypes } from '../../components/withWindowDimensions'; +import withWindowDimensions, {windowDimensionsPropTypes} from '../../components/withWindowDimensions'; import OfflineWithFeedback from '../../components/OfflineWithFeedback'; import ReportFooter from './report/ReportFooter'; import Banner from '../../components/Banner'; import withLocalize from '../../components/withLocalize'; import reportPropTypes from '../reportPropTypes'; import FullPageNotFoundView from '../../components/BlockingViews/FullPageNotFoundView'; -import withViewportOffsetTop, { viewportOffsetTopPropTypes } from '../../components/withViewportOffsetTop'; +import withViewportOffsetTop, {viewportOffsetTopPropTypes} from '../../components/withViewportOffsetTop'; import * as ReportActionsUtils from '../../libs/ReportActionsUtils'; import personalDetailsPropType from '../personalDetailsPropType'; import withNavigationFocus from '../../components/withNavigationFocus'; @@ -37,7 +37,7 @@ import EmojiPicker from '../../components/EmojiPicker/EmojiPicker'; import * as EmojiPickerAction from '../../libs/actions/EmojiPickerAction'; import TaskHeader from '../../components/TaskHeader'; import MoneyRequestHeader from '../../components/MoneyRequestHeader'; -import withNavigation, { withNavigationPropTypes } from '../../components/withNavigation'; +import withNavigation, {withNavigationPropTypes} from '../../components/withNavigation'; import * as ComposerActions from '../../libs/actions/Composer'; import withHideKeyboardOnViewportScroll from '../../components/withBlockViewportScroll'; @@ -219,7 +219,7 @@ class ReportScreen extends React.Component { } dismissBanner() { - this.setState({ isBannerVisible: false }); + this.setState({isBannerVisible: false}); } chatWithAccountManager() { @@ -314,7 +314,7 @@ class ReportScreen extends React.Component { return; } reportActionsListViewHeight = skeletonViewContainerHeight; - this.setState({ skeletonViewContainerHeight }); + this.setState({skeletonViewContainerHeight}); }} > {this.isReportReadyForDisplay() && !isLoadingInitialReportActions && !isLoading && ( @@ -380,15 +380,15 @@ export default compose( key: ONYXKEYS.IS_SIDEBAR_LOADED, }, reportActions: { - key: ({ route }) => `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${getReportID(route)}`, + key: ({route}) => `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${getReportID(route)}`, canEvict: false, selector: ReportActionsUtils.getSortedReportActionsForDisplay, }, report: { - key: ({ route }) => `${ONYXKEYS.COLLECTION.REPORT}${getReportID(route)}`, + key: ({route}) => `${ONYXKEYS.COLLECTION.REPORT}${getReportID(route)}`, }, isComposerFullSize: { - key: ({ route }) => `${ONYXKEYS.COLLECTION.REPORT_IS_COMPOSER_FULL_SIZE}${getReportID(route)}`, + key: ({route}) => `${ONYXKEYS.COLLECTION.REPORT_IS_COMPOSER_FULL_SIZE}${getReportID(route)}`, }, betas: { key: ONYXKEYS.BETAS, diff --git a/src/pages/home/report/ReportFooter.js b/src/pages/home/report/ReportFooter.js index 49679b4f7d78..85e41a871357 100644 --- a/src/pages/home/report/ReportFooter.js +++ b/src/pages/home/report/ReportFooter.js @@ -1,8 +1,8 @@ import React from 'react'; import _ from 'underscore'; import PropTypes from 'prop-types'; -import { withOnyx } from 'react-native-onyx'; -import { Keyboard, View } from 'react-native'; +import {withOnyx} from 'react-native-onyx'; +import {Keyboard, View} from 'react-native'; import CONST from '../../../CONST'; import ReportActionCompose from './ReportActionCompose'; import AnonymousReportFooter from '../../../components/AnonymousReportFooter'; @@ -11,7 +11,7 @@ import OfflineIndicator from '../../../components/OfflineIndicator'; import ArchivedReportFooter from '../../../components/ArchivedReportFooter'; import compose from '../../../libs/compose'; import ONYXKEYS from '../../../ONYXKEYS'; -import withWindowDimensions, { windowDimensionsPropTypes } from '../../../components/withWindowDimensions'; +import withWindowDimensions, {windowDimensionsPropTypes} from '../../../components/withWindowDimensions'; import styles from '../../../styles/styles'; import reportActionPropTypes from './reportActionPropTypes'; import reportPropTypes from '../../reportPropTypes'; @@ -48,9 +48,9 @@ const propTypes = { }; const defaultProps = { - report: { reportID: '0' }, + report: {reportID: '0'}, reportActions: [], - onSubmitComment: () => { }, + onSubmitComment: () => {}, errors: {}, pendingAction: null, shouldShowComposeInput: true, @@ -108,6 +108,6 @@ ReportFooter.defaultProps = defaultProps; export default compose( withWindowDimensions, withOnyx({ - shouldShowComposeInput: { key: ONYXKEYS.SHOULD_SHOW_COMPOSE_INPUT }, + shouldShowComposeInput: {key: ONYXKEYS.SHOULD_SHOW_COMPOSE_INPUT}, }), )(ReportFooter); From 906f744ad9c3ff7d10dafefcc55bacb025611f92 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucas=20M=C3=B3rawski?= Date: Wed, 19 Jul 2023 11:14:05 +0200 Subject: [PATCH 04/19] linting --- src/components/withBlockViewportScroll/index.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/withBlockViewportScroll/index.js b/src/components/withBlockViewportScroll/index.js index 5573081655d8..0494306e8f2d 100644 --- a/src/components/withBlockViewportScroll/index.js +++ b/src/components/withBlockViewportScroll/index.js @@ -25,7 +25,7 @@ import getComponentDisplayName from '../../libs/getComponentDisplayName'; * // use ref here * )); */ -export default function (WrappedComponent) { +export default function WithBlockViewportScrollHOC (WrappedComponent) { function WithBlockViewportScroll(props) { const optimalScrollY = useRef(0); const keyboardShowListenerRef = useRef(() => {}); From 0d240cb61ee637410fad256b26e3caf897c67014 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucas=20M=C3=B3rawski?= Date: Thu, 20 Jul 2023 11:08:57 +0200 Subject: [PATCH 05/19] even more linting --- src/components/withBlockViewportScroll/index.native.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/withBlockViewportScroll/index.native.js b/src/components/withBlockViewportScroll/index.native.js index 97d21bb15986..994dd32f9775 100644 --- a/src/components/withBlockViewportScroll/index.native.js +++ b/src/components/withBlockViewportScroll/index.native.js @@ -3,7 +3,7 @@ import React from 'react'; import PropTypes from 'prop-types'; import getComponentDisplayName from '../../libs/getComponentDisplayName'; -export default function (WrappedComponent) { +export default function WithBlockViewportScrollHOC(WrappedComponent) { const PassThroughComponent = (props) => ; PassThroughComponent.displayName = `PassThroughComponent(${getComponentDisplayName(WrappedComponent)})`; From 75cf222160fd0908978257ac86499996a0731099 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucas=20M=C3=B3rawski?= Date: Thu, 20 Jul 2023 11:18:48 +0200 Subject: [PATCH 06/19] even more linting --- src/components/withBlockViewportScroll/index.native.js | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/components/withBlockViewportScroll/index.native.js b/src/components/withBlockViewportScroll/index.native.js index 994dd32f9775..e45951b28749 100644 --- a/src/components/withBlockViewportScroll/index.native.js +++ b/src/components/withBlockViewportScroll/index.native.js @@ -4,11 +4,13 @@ import PropTypes from 'prop-types'; import getComponentDisplayName from '../../libs/getComponentDisplayName'; export default function WithBlockViewportScrollHOC(WrappedComponent) { - const PassThroughComponent = (props) => ; + function PassThroughComponent(props) { + return ; + } PassThroughComponent.displayName = `PassThroughComponent(${getComponentDisplayName(WrappedComponent)})`; PassThroughComponent.propTypes = { - forwardedRef: PropTypes.oneOfType([PropTypes.func, PropTypes.shape({current: PropTypes.instanceOf(React.Component)})]), + forwardedRef: PropTypes.oneOfType([PropTypes.func, PropTypes.shape({ current: PropTypes.instanceOf(React.Component) })]), }; PassThroughComponent.defaultProps = { forwardedRef: undefined, From f8c624382289cff6d5729611501e254788205baf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucas=20M=C3=B3rawski?= Date: Thu, 27 Jul 2023 14:54:33 +0300 Subject: [PATCH 07/19] bug fix/firing swipe down call back in SwipeableView on scrolling --- src/components/SwipeableView/index.js | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/src/components/SwipeableView/index.js b/src/components/SwipeableView/index.js index 8edd3ce3f38d..e7534c333c70 100644 --- a/src/components/SwipeableView/index.js +++ b/src/components/SwipeableView/index.js @@ -34,6 +34,7 @@ const isTextSelection = () => { function SwipeableView(props) { const ref = useRef(); + const scrollableChildRef = useRef(); const startY = useRef(0); useEffect(() => { @@ -45,23 +46,38 @@ function SwipeableView(props) { const handleTouchEnd = (event) => { const deltaY = event.changedTouches[0].clientY - startY.current; + const isSelecting = isTextSelection(); + let canSwipeDown = true; + let canSwipeUp = true; + if (scrollableChildRef.current) { + canSwipeUp = scrollableChildRef.current.scrollHeight - scrollableChildRef.current.scrollTop === scrollableChildRef.current.clientHeight; + canSwipeDown = scrollableChildRef.current.scrollTop === 0; + } - if (deltaY > MIN_DELTA_Y && props.onSwipeDown && !isTextSelection()) { + if (deltaY > MIN_DELTA_Y && props.onSwipeDown && !isSelecting && canSwipeDown) { props.onSwipeDown(); } - if (deltaY < -MIN_DELTA_Y && props.onSwipeUp && !isTextSelection()) { + if (deltaY < -MIN_DELTA_Y && props.onSwipeUp && !isSelecting && canSwipeUp) { props.onSwipeUp(); } }; - element.addEventListener('touchstart', handleTouchStart); + const handleScroll = (event) => { + if (!event.target) return; + scrollableChildRef.current = event.target; + element.removeEventListener('scroll', handleScroll); + }; + + element.addEventListener('touchstart', handleTouchStart); element.addEventListener('touchend', handleTouchEnd); + element.addEventListener('scroll', handleScroll, true); return () => { element.removeEventListener('touchstart', handleTouchStart); element.removeEventListener('touchend', handleTouchEnd); + element.removeEventListener('scroll', handleScroll); }; }, [props]); From 14689870d34aa0ccca070ee930bc88f349896b7d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucas=20M=C3=B3rawski?= Date: Tue, 1 Aug 2023 19:53:43 +0300 Subject: [PATCH 08/19] fix/swipe down on android closes keyboard when scrolling --- src/components/SwipeableView/index.js | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/components/SwipeableView/index.js b/src/components/SwipeableView/index.js index e7534c333c70..912e7998cb37 100644 --- a/src/components/SwipeableView/index.js +++ b/src/components/SwipeableView/index.js @@ -36,6 +36,7 @@ function SwipeableView(props) { const ref = useRef(); const scrollableChildRef = useRef(); const startY = useRef(0); + const isScrolling = useRef(false); useEffect(() => { const element = ref.current; @@ -54,20 +55,20 @@ function SwipeableView(props) { canSwipeDown = scrollableChildRef.current.scrollTop === 0; } - if (deltaY > MIN_DELTA_Y && props.onSwipeDown && !isSelecting && canSwipeDown) { + if (deltaY > MIN_DELTA_Y && props.onSwipeDown && !isSelecting && canSwipeDown && !isScrolling.current) { props.onSwipeDown(); } - if (deltaY < -MIN_DELTA_Y && props.onSwipeUp && !isSelecting && canSwipeUp) { + if (deltaY < -MIN_DELTA_Y && props.onSwipeUp && !isSelecting && canSwipeUp && !isScrolling.current) { props.onSwipeUp(); } + isScrolling.current = false; }; const handleScroll = (event) => { - if (!event.target) return; + isScrolling.current = true; + if (!event.target || scrollableChildRef.current) return; scrollableChildRef.current = event.target; - - element.removeEventListener('scroll', handleScroll); }; element.addEventListener('touchstart', handleTouchStart); From 279e4c481709222f561abc76319c7a0189a8da06 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucas=20M=C3=B3rawski?= Date: Fri, 4 Aug 2023 11:07:27 +0300 Subject: [PATCH 09/19] swipeableview props desctruct --- src/components/SwipeableView/index.js | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/components/SwipeableView/index.js b/src/components/SwipeableView/index.js index 912e7998cb37..fe07cd233e56 100644 --- a/src/components/SwipeableView/index.js +++ b/src/components/SwipeableView/index.js @@ -32,7 +32,7 @@ const isTextSelection = () => { return false; }; -function SwipeableView(props) { +function SwipeableView({onSwipeUp, onSwipeDown, style, children}) { const ref = useRef(); const scrollableChildRef = useRef(); const startY = useRef(0); @@ -55,12 +55,12 @@ function SwipeableView(props) { canSwipeDown = scrollableChildRef.current.scrollTop === 0; } - if (deltaY > MIN_DELTA_Y && props.onSwipeDown && !isSelecting && canSwipeDown && !isScrolling.current) { - props.onSwipeDown(); + if (deltaY > MIN_DELTA_Y && onSwipeDown && !isSelecting && canSwipeDown && !isScrolling.current) { + onSwipeDown(); } - if (deltaY < -MIN_DELTA_Y && props.onSwipeUp && !isSelecting && canSwipeUp && !isScrolling.current) { - props.onSwipeUp(); + if (deltaY < -MIN_DELTA_Y && onSwipeUp && !isSelecting && canSwipeUp && !isScrolling.current) { + onSwipeUp(); } isScrolling.current = false; }; @@ -80,14 +80,14 @@ function SwipeableView(props) { element.removeEventListener('touchend', handleTouchEnd); element.removeEventListener('scroll', handleScroll); }; - }, [props]); + }, [onSwipeDown, onSwipeUp]); return ( - {props.children} + {children} ); } From 43627604632cbb3075abf6e8e77294eee6e58a67 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucas=20M=C3=B3rawski?= Date: Fri, 4 Aug 2023 11:14:41 +0300 Subject: [PATCH 10/19] formatting --- src/components/withBlockViewportScroll/index.js | 2 +- src/components/withBlockViewportScroll/index.native.js | 2 +- src/pages/home/ReportScreen.js | 1 - src/pages/home/report/ReportFooter.js | 5 ++++- 4 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/components/withBlockViewportScroll/index.js b/src/components/withBlockViewportScroll/index.js index 0494306e8f2d..46b45e55d20b 100644 --- a/src/components/withBlockViewportScroll/index.js +++ b/src/components/withBlockViewportScroll/index.js @@ -25,7 +25,7 @@ import getComponentDisplayName from '../../libs/getComponentDisplayName'; * // use ref here * )); */ -export default function WithBlockViewportScrollHOC (WrappedComponent) { +export default function WithBlockViewportScrollHOC(WrappedComponent) { function WithBlockViewportScroll(props) { const optimalScrollY = useRef(0); const keyboardShowListenerRef = useRef(() => {}); diff --git a/src/components/withBlockViewportScroll/index.native.js b/src/components/withBlockViewportScroll/index.native.js index e45951b28749..8d3c4d47e2a0 100644 --- a/src/components/withBlockViewportScroll/index.native.js +++ b/src/components/withBlockViewportScroll/index.native.js @@ -10,7 +10,7 @@ export default function WithBlockViewportScrollHOC(WrappedComponent) { PassThroughComponent.displayName = `PassThroughComponent(${getComponentDisplayName(WrappedComponent)})`; PassThroughComponent.propTypes = { - forwardedRef: PropTypes.oneOfType([PropTypes.func, PropTypes.shape({ current: PropTypes.instanceOf(React.Component) })]), + forwardedRef: PropTypes.oneOfType([PropTypes.func, PropTypes.shape({current: PropTypes.instanceOf(React.Component)})]), }; PassThroughComponent.defaultProps = { forwardedRef: undefined, diff --git a/src/pages/home/ReportScreen.js b/src/pages/home/ReportScreen.js index 618940533f0e..89c54a4e7a83 100644 --- a/src/pages/home/ReportScreen.js +++ b/src/pages/home/ReportScreen.js @@ -42,7 +42,6 @@ import withHideKeyboardOnViewportScroll from '../../components/withBlockViewport import ReportScreenContext from './ReportScreenContext'; import TaskHeaderActionButton from '../../components/TaskHeaderActionButton'; - const propTypes = { /** Navigation route context info provided by react navigation */ route: PropTypes.shape({ diff --git a/src/pages/home/report/ReportFooter.js b/src/pages/home/report/ReportFooter.js index 3c1a1be0c934..decb5d12ac48 100644 --- a/src/pages/home/report/ReportFooter.js +++ b/src/pages/home/report/ReportFooter.js @@ -86,7 +86,10 @@ function ReportFooter(props) { )} {!hideComposer && (props.shouldShowComposeInput || !props.isSmallScreenWidth) && ( - + Date: Tue, 17 Oct 2023 18:40:42 +0200 Subject: [PATCH 11/19] linting --- src/components/SwipeableView/index.js | 8 ++++++-- src/libs/NativeWebKeyboard/index.js | 4 +++- src/pages/home/ReportScreen.js | 1 - 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/src/components/SwipeableView/index.js b/src/components/SwipeableView/index.js index fe07cd233e56..aee4bf640e5d 100644 --- a/src/components/SwipeableView/index.js +++ b/src/components/SwipeableView/index.js @@ -25,7 +25,9 @@ const MIN_DELTA_Y = 25; const isTextSelection = () => { const focused = document.activeElement; - if (!focused) return false; + if (!focused) { + return false; + } if (typeof focused.selectionStart === 'number' && typeof focused.selectionEnd === 'number') { return focused.selectionStart !== focused.selectionEnd; } @@ -67,7 +69,9 @@ function SwipeableView({onSwipeUp, onSwipeDown, style, children}) { const handleScroll = (event) => { isScrolling.current = true; - if (!event.target || scrollableChildRef.current) return; + if (!event.target || scrollableChildRef.current) { + return; + } scrollableChildRef.current = event.target; }; diff --git a/src/libs/NativeWebKeyboard/index.js b/src/libs/NativeWebKeyboard/index.js index c17719762df8..9af8c31ce917 100644 --- a/src/libs/NativeWebKeyboard/index.js +++ b/src/libs/NativeWebKeyboard/index.js @@ -52,7 +52,9 @@ const startKeboardListeningService = () => { }; const addListener = (eventName, callbackFn) => { - if ((eventName !== SHOW_EVENT_NAME && eventName !== HIDE_EVENT_NAME) || !callbackFn) return; + if ((eventName !== SHOW_EVENT_NAME && eventName !== HIDE_EVENT_NAME) || !callbackFn) { + return; + } if (eventName === SHOW_EVENT_NAME) { showListeners.push(callbackFn); diff --git a/src/pages/home/ReportScreen.js b/src/pages/home/ReportScreen.js index 319b465c3a04..8a9d787fcee3 100644 --- a/src/pages/home/ReportScreen.js +++ b/src/pages/home/ReportScreen.js @@ -34,7 +34,6 @@ import MoneyRequestHeader from '../../components/MoneyRequestHeader'; import MoneyReportHeader from '../../components/MoneyReportHeader'; import * as ComposerActions from '../../libs/actions/Composer'; import withHideKeyboardOnViewportScroll from '../../components/withBlockViewportScroll'; -import ReportScreenContext from './ReportScreenContext'; import {ActionListContext, ReactionListContext} from './ReportScreenContext'; import TaskHeaderActionButton from '../../components/TaskHeaderActionButton'; import DragAndDropProvider from '../../components/DragAndDrop/Provider'; From 8dfc2b60a6b87f55bb85db8f7c1d8f6c123a6fe7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucas=20M=C3=B3rawski?= Date: Tue, 24 Oct 2023 17:24:42 +0200 Subject: [PATCH 12/19] refactored SwipeableView for browsers to TS --- src/components/SwipeableView/index.tsx | 86 +++++++++++++++++++++++++- 1 file changed, 84 insertions(+), 2 deletions(-) diff --git a/src/components/SwipeableView/index.tsx b/src/components/SwipeableView/index.tsx index 335c3e7dcf03..a7173dbd686f 100644 --- a/src/components/SwipeableView/index.tsx +++ b/src/components/SwipeableView/index.tsx @@ -1,4 +1,86 @@ +import React, { useEffect, useRef } from 'react'; +import { View } from 'react-native'; import SwipeableViewProps from './types'; -// Swipeable View is available just on Android/iOS for now. -export default ({children}: SwipeableViewProps) => children; +const MIN_DELTA_Y = 25; + +const isTextSelection = (): boolean => { + const focused = document.activeElement as HTMLInputElement | HTMLTextAreaElement | null; + if (!focused) { + return false; + } + if (typeof focused.selectionStart === 'number' && typeof focused.selectionEnd === 'number') { + return focused.selectionStart !== focused.selectionEnd; + } + return false; +}; + +function SwipeableView({ onSwipeUp, onSwipeDown, style, children }: SwipeableViewProps) { + const ref = useRef(null); + const scrollableChildRef = useRef(null); + const startY = useRef(0); + const isScrolling = useRef(false); + + useEffect(() => { + if (!ref.current) { + return; + } + + const element = ref.current as unknown as HTMLElement; + + const handleTouchStart = (event: TouchEvent) => { + startY.current = event.touches[0].clientY; + }; + + const handleTouchEnd = (event: TouchEvent) => { + const deltaY = event.changedTouches[0].clientY - startY.current; + const isSelecting = isTextSelection(); + let canSwipeDown = true; + let canSwipeUp = true; + if (scrollableChildRef.current) { + canSwipeUp = scrollableChildRef.current.scrollHeight - scrollableChildRef.current.scrollTop === scrollableChildRef.current.clientHeight; + canSwipeDown = scrollableChildRef.current.scrollTop === 0; + } + + if (deltaY > MIN_DELTA_Y && onSwipeDown && !isSelecting && canSwipeDown && !isScrolling.current) { + onSwipeDown(); + } + + if (deltaY < -MIN_DELTA_Y && onSwipeUp && !isSelecting && canSwipeUp && !isScrolling.current) { + onSwipeUp(); + } + isScrolling.current = false; + }; + + const handleScroll = (event: Event) => { + isScrolling.current = true; + if (!event.target || scrollableChildRef.current) { + return; + } + scrollableChildRef.current = event.target; + }; + + element.addEventListener('touchstart', handleTouchStart); + element.addEventListener('touchend', handleTouchEnd); + element.addEventListener('scroll', handleScroll, true); + + return () => { + element.removeEventListener('touchstart', handleTouchStart); + element.removeEventListener('touchend', handleTouchEnd); + element.removeEventListener('scroll', handleScroll); + }; + }, [onSwipeDown, onSwipeUp]); + + return ( + + {children} + + ); +} + +SwipeableView.displayName = 'SwipeableView'; + +export default SwipeableView; From 04af0badaf678924054cf78daca90abf4e50a51f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucas=20M=C3=B3rawski?= Date: Tue, 24 Oct 2023 18:13:03 +0200 Subject: [PATCH 13/19] native swipeable view compatibility with the browser one --- src/components/SwipeableView/index.js | 102 ------------------ src/components/SwipeableView/index.native.tsx | 30 ++++-- src/components/SwipeableView/index.tsx | 10 +- src/components/SwipeableView/types.ts | 9 +- 4 files changed, 33 insertions(+), 118 deletions(-) delete mode 100644 src/components/SwipeableView/index.js diff --git a/src/components/SwipeableView/index.js b/src/components/SwipeableView/index.js deleted file mode 100644 index aee4bf640e5d..000000000000 --- a/src/components/SwipeableView/index.js +++ /dev/null @@ -1,102 +0,0 @@ -import React, {useEffect, useRef} from 'react'; -import {View} from 'react-native'; -import PropTypes from 'prop-types'; - -const propTypes = { - children: PropTypes.element.isRequired, - - /** Callback to fire when the user swipes down on the child content */ - onSwipeDown: PropTypes.func, - - /** Callback to fire when the user swipes up on the child content */ - onSwipeUp: PropTypes.func, - - /** Container styles */ - style: PropTypes.oneOfType([PropTypes.arrayOf(PropTypes.object), PropTypes.object]), -}; - -const defaultProps = { - onSwipeDown: undefined, - onSwipeUp: undefined, - style: undefined, -}; - -const MIN_DELTA_Y = 25; - -const isTextSelection = () => { - const focused = document.activeElement; - if (!focused) { - return false; - } - if (typeof focused.selectionStart === 'number' && typeof focused.selectionEnd === 'number') { - return focused.selectionStart !== focused.selectionEnd; - } - return false; -}; - -function SwipeableView({onSwipeUp, onSwipeDown, style, children}) { - const ref = useRef(); - const scrollableChildRef = useRef(); - const startY = useRef(0); - const isScrolling = useRef(false); - - useEffect(() => { - const element = ref.current; - - const handleTouchStart = (event) => { - startY.current = event.touches[0].clientY; - }; - - const handleTouchEnd = (event) => { - const deltaY = event.changedTouches[0].clientY - startY.current; - const isSelecting = isTextSelection(); - let canSwipeDown = true; - let canSwipeUp = true; - if (scrollableChildRef.current) { - canSwipeUp = scrollableChildRef.current.scrollHeight - scrollableChildRef.current.scrollTop === scrollableChildRef.current.clientHeight; - canSwipeDown = scrollableChildRef.current.scrollTop === 0; - } - - if (deltaY > MIN_DELTA_Y && onSwipeDown && !isSelecting && canSwipeDown && !isScrolling.current) { - onSwipeDown(); - } - - if (deltaY < -MIN_DELTA_Y && onSwipeUp && !isSelecting && canSwipeUp && !isScrolling.current) { - onSwipeUp(); - } - isScrolling.current = false; - }; - - const handleScroll = (event) => { - isScrolling.current = true; - if (!event.target || scrollableChildRef.current) { - return; - } - scrollableChildRef.current = event.target; - }; - - element.addEventListener('touchstart', handleTouchStart); - element.addEventListener('touchend', handleTouchEnd); - element.addEventListener('scroll', handleScroll, true); - - return () => { - element.removeEventListener('touchstart', handleTouchStart); - element.removeEventListener('touchend', handleTouchEnd); - element.removeEventListener('scroll', handleScroll); - }; - }, [onSwipeDown, onSwipeUp]); - - return ( - - {children} - - ); -} - -SwipeableView.propTypes = propTypes; -SwipeableView.defaultProps = defaultProps; - -export default SwipeableView; diff --git a/src/components/SwipeableView/index.native.tsx b/src/components/SwipeableView/index.native.tsx index ac500f025016..9b3429a67c69 100644 --- a/src/components/SwipeableView/index.native.tsx +++ b/src/components/SwipeableView/index.native.tsx @@ -3,30 +3,40 @@ import {PanResponder, View} from 'react-native'; import CONST from '../../CONST'; import SwipeableViewProps from './types'; -function SwipeableView({children, onSwipeDown}: SwipeableViewProps) { +function SwipeableView({children, onSwipeDown, onSwipeUp}: SwipeableViewProps) { const minimumPixelDistance = CONST.COMPOSER_MAX_HEIGHT; const oldYRef = useRef(0); + const directionRef = useRef<'UP' | 'DOWN' | null>(null); + const panResponder = useRef( PanResponder.create({ - // The PanResponder gets focus only when the y-axis movement is over minimumPixelDistance & swipe direction is downwards - // eslint-disable-next-line @typescript-eslint/naming-convention - onMoveShouldSetPanResponderCapture: (_event, gestureState) => { + onMoveShouldSetPanResponderCapture: (event, gestureState) => { if (gestureState.dy - oldYRef.current > 0 && gestureState.dy > minimumPixelDistance) { + directionRef.current = 'DOWN'; + return true; + } + + if (gestureState.dy - oldYRef.current < 0 && Math.abs(gestureState.dy) > minimumPixelDistance) { + directionRef.current = 'UP'; return true; } oldYRef.current = gestureState.dy; return false; }, - // Calls the callback when the swipe down is released; after the completion of the gesture - onPanResponderRelease: onSwipeDown, + onPanResponderRelease: () => { + if (directionRef.current === 'DOWN' && onSwipeDown) { + onSwipeDown(); + } else if (directionRef.current === 'UP' && onSwipeUp) { + onSwipeUp(); + } + directionRef.current = null; // Reset the direction after the gesture completes + }, }), ).current; - return ( - // eslint-disable-next-line react/jsx-props-no-spreading - {children} - ); + // eslint-disable-next-line react/jsx-props-no-spreading + return {children}; } SwipeableView.displayName = 'SwipeableView'; diff --git a/src/components/SwipeableView/index.tsx b/src/components/SwipeableView/index.tsx index a7173dbd686f..d775e9a6be22 100644 --- a/src/components/SwipeableView/index.tsx +++ b/src/components/SwipeableView/index.tsx @@ -1,5 +1,5 @@ -import React, { useEffect, useRef } from 'react'; -import { View } from 'react-native'; +import React, {useEffect, useRef} from 'react'; +import {View} from 'react-native'; import SwipeableViewProps from './types'; const MIN_DELTA_Y = 25; @@ -15,9 +15,9 @@ const isTextSelection = (): boolean => { return false; }; -function SwipeableView({ onSwipeUp, onSwipeDown, style, children }: SwipeableViewProps) { +function SwipeableView({onSwipeUp, onSwipeDown, style, children}: SwipeableViewProps) { const ref = useRef(null); - const scrollableChildRef = useRef(null); + const scrollableChildRef = useRef(null); const startY = useRef(0); const isScrolling = useRef(false); @@ -57,7 +57,7 @@ function SwipeableView({ onSwipeUp, onSwipeDown, style, children }: SwipeableVie if (!event.target || scrollableChildRef.current) { return; } - scrollableChildRef.current = event.target; + scrollableChildRef.current = event.target as HTMLElement; }; element.addEventListener('touchstart', handleTouchStart); diff --git a/src/components/SwipeableView/types.ts b/src/components/SwipeableView/types.ts index 560df7ef5a45..66f9115faa82 100644 --- a/src/components/SwipeableView/types.ts +++ b/src/components/SwipeableView/types.ts @@ -1,11 +1,18 @@ import {ReactNode} from 'react'; +import {StyleProp, ViewStyle} from 'react-native'; type SwipeableViewProps = { /** The content to be rendered within the SwipeableView */ children: ReactNode; /** Callback to fire when the user swipes down on the child content */ - onSwipeDown: () => void; + onSwipeDown?: () => void; + + /** Callback to fire when the user swipes up on the child content */ + onSwipeUp?: () => void; + + /** Style for the wrapper View */ + style?: StyleProp; }; export default SwipeableViewProps; From fdbfb79e51bc24395e4f2ef1589b74a6cfccd728 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucas=20M=C3=B3rawski?= Date: Mon, 6 Nov 2023 07:35:09 +0100 Subject: [PATCH 14/19] linting --- .../withBlockViewportScroll/index.js | 17 +++++-------- .../withBlockViewportScroll/index.native.js | 25 +++++++------------ src/libs/NativeWebKeyboard/index.js | 2 +- src/pages/home/ReportScreen.js | 13 +--------- src/styles/fontFamily/multiFontFamily.ts | 4 +-- tests/perf-test/ReportScreen.perf-test.js | 24 +++++++++--------- 6 files changed, 31 insertions(+), 54 deletions(-) diff --git a/src/components/withBlockViewportScroll/index.js b/src/components/withBlockViewportScroll/index.js index 46b45e55d20b..c68ea19878e0 100644 --- a/src/components/withBlockViewportScroll/index.js +++ b/src/components/withBlockViewportScroll/index.js @@ -1,8 +1,8 @@ /* eslint-disable react/jsx-props-no-spreading */ -import React, {useEffect, useRef} from 'react'; import PropTypes from 'prop-types'; -import Keyboard from '../../libs/NativeWebKeyboard'; -import getComponentDisplayName from '../../libs/getComponentDisplayName'; +import React, {useEffect, useRef} from 'react'; +import getComponentDisplayName from '@libs/getComponentDisplayName'; +import Keyboard from '@libs/NativeWebKeyboard'; /** * A Higher Order Component (HOC) that wraps a given React component and blocks viewport scroll when the keyboard is visible. @@ -26,7 +26,7 @@ import getComponentDisplayName from '../../libs/getComponentDisplayName'; * )); */ export default function WithBlockViewportScrollHOC(WrappedComponent) { - function WithBlockViewportScroll(props) { + function WithBlockViewportScroll(props, ref) { const optimalScrollY = useRef(0); const keyboardShowListenerRef = useRef(() => {}); const keyboardHideListenerRef = useRef(() => {}); @@ -58,7 +58,7 @@ export default function WithBlockViewportScrollHOC(WrappedComponent) { return ( ); } @@ -71,10 +71,5 @@ export default function WithBlockViewportScrollHOC(WrappedComponent) { forwardedRef: undefined, }; - return React.forwardRef((props, ref) => ( - - )); + return React.forwardRef(WithBlockViewportScroll); } diff --git a/src/components/withBlockViewportScroll/index.native.js b/src/components/withBlockViewportScroll/index.native.js index 8d3c4d47e2a0..88b6042256b8 100644 --- a/src/components/withBlockViewportScroll/index.native.js +++ b/src/components/withBlockViewportScroll/index.native.js @@ -1,25 +1,18 @@ /* eslint-disable react/jsx-props-no-spreading */ import React from 'react'; -import PropTypes from 'prop-types'; -import getComponentDisplayName from '../../libs/getComponentDisplayName'; +import getComponentDisplayName from '@libs/getComponentDisplayName'; export default function WithBlockViewportScrollHOC(WrappedComponent) { - function PassThroughComponent(props) { - return ; + function PassThroughComponent(props, ref) { + return ( + + ); } PassThroughComponent.displayName = `PassThroughComponent(${getComponentDisplayName(WrappedComponent)})`; - PassThroughComponent.propTypes = { - forwardedRef: PropTypes.oneOfType([PropTypes.func, PropTypes.shape({current: PropTypes.instanceOf(React.Component)})]), - }; - PassThroughComponent.defaultProps = { - forwardedRef: undefined, - }; - return React.forwardRef((props, ref) => ( - - )); + return React.forwardRef(PassThroughComponent); } diff --git a/src/libs/NativeWebKeyboard/index.js b/src/libs/NativeWebKeyboard/index.js index 9af8c31ce917..c3030cfebdad 100644 --- a/src/libs/NativeWebKeyboard/index.js +++ b/src/libs/NativeWebKeyboard/index.js @@ -1,5 +1,5 @@ -import _ from 'underscore'; import {Keyboard} from 'react-native'; +import _ from 'underscore'; // types that will show a virtual keyboard in a mobile browser const INPUT_TYPES_WITH_KEYBOARD = ['text', 'search', 'tel', 'url', 'email', 'password']; diff --git a/src/pages/home/ReportScreen.js b/src/pages/home/ReportScreen.js index 3bfd0ff351d7..f6ef2cfac0e3 100644 --- a/src/pages/home/ReportScreen.js +++ b/src/pages/home/ReportScreen.js @@ -13,6 +13,7 @@ import OfflineWithFeedback from '@components/OfflineWithFeedback'; import ReportActionsSkeletonView from '@components/ReportActionsSkeletonView'; import ScreenWrapper from '@components/ScreenWrapper'; import TaskHeaderActionButton from '@components/TaskHeaderActionButton'; +import withHideKeyboardOnViewportScroll from '@components/withBlockViewportScroll'; import withCurrentReportID, {withCurrentReportIDDefaultProps, withCurrentReportIDPropTypes} from '@components/withCurrentReportID'; import withViewportOffsetTop from '@components/withViewportOffsetTop'; import useLocalize from '@hooks/useLocalize'; @@ -36,18 +37,6 @@ import HeaderView from './HeaderView'; import reportActionPropTypes from './report/reportActionPropTypes'; import ReportActionsView from './report/ReportActionsView'; import ReportFooter from './report/ReportFooter'; -import Banner from '../../components/Banner'; -import reportPropTypes from '../reportPropTypes'; -import reportMetadataPropTypes from '../reportMetadataPropTypes'; -import FullPageNotFoundView from '../../components/BlockingViews/FullPageNotFoundView'; -import withViewportOffsetTop from '../../components/withViewportOffsetTop'; -import * as ReportActionsUtils from '../../libs/ReportActionsUtils'; -import personalDetailsPropType from '../personalDetailsPropType'; -import getIsReportFullyVisible from '../../libs/getIsReportFullyVisible'; -import MoneyRequestHeader from '../../components/MoneyRequestHeader'; -import MoneyReportHeader from '../../components/MoneyReportHeader'; -import * as ComposerActions from '../../libs/actions/Composer'; -import withHideKeyboardOnViewportScroll from '../../components/withBlockViewportScroll'; import {ActionListContext, ReactionListContext} from './ReportScreenContext'; const propTypes = { diff --git a/src/styles/fontFamily/multiFontFamily.ts b/src/styles/fontFamily/multiFontFamily.ts index 14f64d406954..5bd89e0d4bcb 100644 --- a/src/styles/fontFamily/multiFontFamily.ts +++ b/src/styles/fontFamily/multiFontFamily.ts @@ -1,7 +1,7 @@ +import getOperatingSystem from '@libs/getOperatingSystem'; +import CONST from '@src/CONST'; import {multiBold} from './bold'; import FontFamilyStyles from './types'; -import CONST from '../../CONST'; -import getOperatingSystem from '../../libs/getOperatingSystem'; // In windows and ubuntu, we need some extra system fonts for emojis to work properly // otherwise few of them will appear as black and white diff --git a/tests/perf-test/ReportScreen.perf-test.js b/tests/perf-test/ReportScreen.perf-test.js index 578546cb4679..b34ab8a74e3a 100644 --- a/tests/perf-test/ReportScreen.perf-test.js +++ b/tests/perf-test/ReportScreen.perf-test.js @@ -2,18 +2,18 @@ import {fireEvent, screen} from '@testing-library/react-native'; import React from 'react'; import Onyx from 'react-native-onyx'; import {measurePerformance} from 'reassure'; -import ComposeProviders from '../../src/components/ComposeProviders'; -import DragAndDropProvider from '../../src/components/DragAndDrop/Provider'; -import {LocaleContextProvider} from '../../src/components/LocaleContextProvider'; -import OnyxProvider from '../../src/components/OnyxProvider'; -import {CurrentReportIDContextProvider} from '../../src/components/withCurrentReportID'; -import {KeyboardStateProvider} from '../../src/components/withKeyboardState'; -import {WindowDimensionsProvider} from '../../src/components/withWindowDimensions'; -import CONST from '../../src/CONST'; -import * as Localize from '../../src/libs/Localize'; -import ONYXKEYS from '../../src/ONYXKEYS'; -import {ReportAttachmentsProvider} from '../../src/pages/home/report/ReportAttachmentsContext'; -import ReportScreen from '../../src/pages/home/ReportScreen'; +import ComposeProviders from '@components/ComposeProviders'; +import DragAndDropProvider from '@components/DragAndDrop/Provider'; +import {LocaleContextProvider} from '@components/LocaleContextProvider'; +import OnyxProvider from '@components/OnyxProvider'; +import {CurrentReportIDContextProvider} from '@components/withCurrentReportID'; +import {KeyboardStateProvider} from '@components/withKeyboardState'; +import {WindowDimensionsProvider} from '@components/withWindowDimensions'; +import * as Localize from '@libs/Localize'; +import {ReportAttachmentsProvider} from '@pages/home/report/ReportAttachmentsContext'; +import ReportScreen from '@pages/home/ReportScreen'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; import * as LHNTestUtils from '../utils/LHNTestUtils'; import PusherHelper from '../utils/PusherHelper'; import * as ReportTestUtils from '../utils/ReportTestUtils'; From 11bd396e3675ad6cbac219c0365bd886a2c71b00 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucas=20M=C3=B3rawski?= Date: Thu, 9 Nov 2023 18:02:21 +0100 Subject: [PATCH 15/19] fixes --- src/components/SwipeableView/index.tsx | 15 +--- .../withBlockViewportScroll/index.js | 75 ------------------- .../withBlockViewportScroll/index.native.js | 18 ----- .../useBlockViewportScroll/index.native.ts | 15 ++++ src/hooks/useBlockViewportScroll/index.ts | 43 +++++++++++ src/libs/DomUtils/index.native.ts | 16 ++++ src/libs/DomUtils/index.ts | 23 ++++++ src/libs/NativeWebKeyboard/index.js | 12 +-- src/libs/NativeWebKeyboard/index.native.js | 1 - src/pages/home/ReportScreen.js | 4 +- 10 files changed, 108 insertions(+), 114 deletions(-) delete mode 100644 src/components/withBlockViewportScroll/index.js delete mode 100644 src/components/withBlockViewportScroll/index.native.js create mode 100644 src/hooks/useBlockViewportScroll/index.native.ts create mode 100644 src/hooks/useBlockViewportScroll/index.ts diff --git a/src/components/SwipeableView/index.tsx b/src/components/SwipeableView/index.tsx index d775e9a6be22..478935173841 100644 --- a/src/components/SwipeableView/index.tsx +++ b/src/components/SwipeableView/index.tsx @@ -1,20 +1,11 @@ import React, {useEffect, useRef} from 'react'; import {View} from 'react-native'; +import DomUtils from '@libs/DomUtils'; import SwipeableViewProps from './types'; +// Min delta y in px to trigger swipe const MIN_DELTA_Y = 25; -const isTextSelection = (): boolean => { - const focused = document.activeElement as HTMLInputElement | HTMLTextAreaElement | null; - if (!focused) { - return false; - } - if (typeof focused.selectionStart === 'number' && typeof focused.selectionEnd === 'number') { - return focused.selectionStart !== focused.selectionEnd; - } - return false; -}; - function SwipeableView({onSwipeUp, onSwipeDown, style, children}: SwipeableViewProps) { const ref = useRef(null); const scrollableChildRef = useRef(null); @@ -34,7 +25,7 @@ function SwipeableView({onSwipeUp, onSwipeDown, style, children}: SwipeableViewP const handleTouchEnd = (event: TouchEvent) => { const deltaY = event.changedTouches[0].clientY - startY.current; - const isSelecting = isTextSelection(); + const isSelecting = DomUtils.isActiveTextSelection(); let canSwipeDown = true; let canSwipeUp = true; if (scrollableChildRef.current) { diff --git a/src/components/withBlockViewportScroll/index.js b/src/components/withBlockViewportScroll/index.js deleted file mode 100644 index c68ea19878e0..000000000000 --- a/src/components/withBlockViewportScroll/index.js +++ /dev/null @@ -1,75 +0,0 @@ -/* eslint-disable react/jsx-props-no-spreading */ -import PropTypes from 'prop-types'; -import React, {useEffect, useRef} from 'react'; -import getComponentDisplayName from '@libs/getComponentDisplayName'; -import Keyboard from '@libs/NativeWebKeyboard'; - -/** - * A Higher Order Component (HOC) that wraps a given React component and blocks viewport scroll when the keyboard is visible. - * It does this by capturing the current scrollY position when the keyboard is shown, then scrolls back to this position smoothly on 'touchend' event. - * This scroll blocking is removed when the keyboard hides. - * This HOC is doing nothing on native platforms. - * - * The HOC also passes through a `forwardedRef` prop to the wrapped component, which can be used to assign a ref to the wrapped component. - * - * @export - * @param {React.Component} WrappedComponent - The component to be wrapped by the HOC. - * @returns {React.Component} A component that includes the scroll-blocking behaviour. - * - * @example - * export default withBlockViewportScroll(MyComponent); - * - * // Inside MyComponent definition - * // You can access the ref passed from HOC as follows: - * const MyComponent = React.forwardRef((props, ref) => ( - * // use ref here - * )); - */ -export default function WithBlockViewportScrollHOC(WrappedComponent) { - function WithBlockViewportScroll(props, ref) { - const optimalScrollY = useRef(0); - const keyboardShowListenerRef = useRef(() => {}); - const keyboardHideListenerRef = useRef(() => {}); - - useEffect(() => { - const handleTouchEnd = () => { - window.scrollTo({top: optimalScrollY.current, behavior: 'smooth'}); - }; - - const handleKeybShow = () => { - optimalScrollY.current = window.scrollY; - window.addEventListener('touchend', handleTouchEnd); - }; - - const handleKeybHide = () => { - window.removeEventListener('touchend', handleTouchEnd); - }; - - keyboardShowListenerRef.current = Keyboard.addListener('keyboardDidShow', handleKeybShow); - keyboardHideListenerRef.current = Keyboard.addListener('keyboardDidHide', handleKeybHide); - - return () => { - keyboardShowListenerRef.current(); - keyboardHideListenerRef.current(); - window.removeEventListener('touchend', handleTouchEnd); - }; - }, []); - - return ( - - ); - } - - WithBlockViewportScroll.displayName = `WithBlockViewportScroll(${getComponentDisplayName(WrappedComponent)})`; - WithBlockViewportScroll.propTypes = { - forwardedRef: PropTypes.oneOfType([PropTypes.func, PropTypes.shape({current: PropTypes.instanceOf(React.Component)})]), - }; - WithBlockViewportScroll.defaultProps = { - forwardedRef: undefined, - }; - - return React.forwardRef(WithBlockViewportScroll); -} diff --git a/src/components/withBlockViewportScroll/index.native.js b/src/components/withBlockViewportScroll/index.native.js deleted file mode 100644 index 88b6042256b8..000000000000 --- a/src/components/withBlockViewportScroll/index.native.js +++ /dev/null @@ -1,18 +0,0 @@ -/* eslint-disable react/jsx-props-no-spreading */ -import React from 'react'; -import getComponentDisplayName from '@libs/getComponentDisplayName'; - -export default function WithBlockViewportScrollHOC(WrappedComponent) { - function PassThroughComponent(props, ref) { - return ( - - ); - } - - PassThroughComponent.displayName = `PassThroughComponent(${getComponentDisplayName(WrappedComponent)})`; - - return React.forwardRef(PassThroughComponent); -} diff --git a/src/hooks/useBlockViewportScroll/index.native.ts b/src/hooks/useBlockViewportScroll/index.native.ts new file mode 100644 index 000000000000..59ee34b1c9f6 --- /dev/null +++ b/src/hooks/useBlockViewportScroll/index.native.ts @@ -0,0 +1,15 @@ +/** + * A hook that blocks viewport scroll when the keyboard is visible. + * It does this by capturing the current scrollY position when the keyboard is shown, then scrolls back to this position smoothly on 'touchend' event. + * This scroll blocking is removed when the keyboard hides. + * This hook is doing nothing on native platforms. + * + * @example + * useBlockViewportScroll(); + */ +function useBlockViewportScroll() { + // This hook is doing nothing on native platforms. + // Check index.ts for web implementation. +} + +export default useBlockViewportScroll; diff --git a/src/hooks/useBlockViewportScroll/index.ts b/src/hooks/useBlockViewportScroll/index.ts new file mode 100644 index 000000000000..5766d59f2bdd --- /dev/null +++ b/src/hooks/useBlockViewportScroll/index.ts @@ -0,0 +1,43 @@ +import {useEffect, useRef} from 'react'; +import Keyboard from '@libs/NativeWebKeyboard'; + +/** + * A hook that blocks viewport scroll when the keyboard is visible. + * It does this by capturing the current scrollY position when the keyboard is shown, then scrolls back to this position smoothly on 'touchend' event. + * This scroll blocking is removed when the keyboard hides. + * This hook is doing nothing on native platforms. + * + * @example + * useBlockViewportScroll(); + */ +function useBlockViewportScroll() { + const optimalScrollY = useRef(0); + const keyboardShowListenerRef = useRef(() => {}); + const keyboardHideListenerRef = useRef(() => {}); + + useEffect(() => { + const handleTouchEnd = () => { + window.scrollTo({top: optimalScrollY.current, behavior: 'smooth'}); + }; + + const handleKeybShow = () => { + optimalScrollY.current = window.scrollY; + window.addEventListener('touchend', handleTouchEnd); + }; + + const handleKeybHide = () => { + window.removeEventListener('touchend', handleTouchEnd); + }; + + keyboardShowListenerRef.current = Keyboard.addListener('keyboardDidShow', handleKeybShow); + keyboardHideListenerRef.current = Keyboard.addListener('keyboardDidHide', handleKeybHide); + + return () => { + keyboardShowListenerRef.current(); + keyboardHideListenerRef.current(); + window.removeEventListener('touchend', handleTouchEnd); + }; + }, []); +} + +export default useBlockViewportScroll; diff --git a/src/libs/DomUtils/index.native.ts b/src/libs/DomUtils/index.native.ts index 9a9758228776..1987b46eb88d 100644 --- a/src/libs/DomUtils/index.native.ts +++ b/src/libs/DomUtils/index.native.ts @@ -2,6 +2,22 @@ import GetActiveElement from './types'; const getActiveElement: GetActiveElement = () => null; +/** + * Checks if there is a text selection within the currently focused input or textarea element. + * + * This function determines whether the currently focused element is an input or textarea, + * and if so, it checks whether there is a text selection (i.e., whether the start and end + * of the selection are at different positions). It assumes that only inputs and textareas + * can have text selections. + * Works only on web. Throws an error on native. + * + * @returns True if there is a text selection within the focused element, false otherwise. + */ +const isActiveTextSelection = () => { + throw new Error('Not implemented in React Native. Use only for web.'); +}; + export default { getActiveElement, + isActiveTextSelection, }; diff --git a/src/libs/DomUtils/index.ts b/src/libs/DomUtils/index.ts index 94dd54547454..0d4698ebe500 100644 --- a/src/libs/DomUtils/index.ts +++ b/src/libs/DomUtils/index.ts @@ -2,6 +2,29 @@ import GetActiveElement from './types'; const getActiveElement: GetActiveElement = () => document.activeElement; +/** + * Checks if there is a text selection within the currently focused input or textarea element. + * + * This function determines whether the currently focused element is an input or textarea, + * and if so, it checks whether there is a text selection (i.e., whether the start and end + * of the selection are at different positions). It assumes that only inputs and textareas + * can have text selections. + * Works only on web. Throws an error on native. + * + * @returns True if there is a text selection within the focused element, false otherwise. + */ +const isActiveTextSelection = (): boolean => { + const focused = document.activeElement as HTMLInputElement | HTMLTextAreaElement | null; + if (!focused) { + return false; + } + if (typeof focused.selectionStart === 'number' && typeof focused.selectionEnd === 'number') { + return focused.selectionStart !== focused.selectionEnd; + } + return false; +}; + export default { getActiveElement, + isActiveTextSelection, }; diff --git a/src/libs/NativeWebKeyboard/index.js b/src/libs/NativeWebKeyboard/index.js index c3030cfebdad..b2ea5381abaf 100644 --- a/src/libs/NativeWebKeyboard/index.js +++ b/src/libs/NativeWebKeyboard/index.js @@ -26,9 +26,9 @@ const SHOW_EVENT_NAME = 'keyboardDidShow'; const HIDE_EVENT_NAME = 'keyboardDidHide'; let previousVPHeight = window.visualViewport.height; -const handleVPResize = () => { +const handleViewportResize = () => { if (window.visualViewport.height < previousVPHeight) { - // this might mean virtual keyboard showed up + // This might mean virtual keyboard showed up // checking if any input element is in focus if (isInputKeyboardType(document.activeElement) && document.activeElement !== currentVisibleElement) { // input el is focused - v keyboard is up @@ -48,12 +48,12 @@ const handleVPResize = () => { const startKeboardListeningService = () => { isKeyboardListenerRunning = true; - window.visualViewport.addEventListener('resize', handleVPResize); + window.visualViewport.addEventListener('resize', handleViewportResize); }; const addListener = (eventName, callbackFn) => { if ((eventName !== SHOW_EVENT_NAME && eventName !== HIDE_EVENT_NAME) || !callbackFn) { - return; + throw new Error('Invalid eventName passed to addListener()'); } if (eventName === SHOW_EVENT_NAME) { @@ -78,7 +78,7 @@ const addListener = (eventName, callbackFn) => { } if (isKeyboardListenerRunning && !showListeners.length && !hideListeners.length) { - window.visualViewport.removeEventListener('resize', handleVPResize); + window.visualViewport.removeEventListener('resize', handleViewportResize); isKeyboardListenerRunning = false; } }; @@ -129,7 +129,7 @@ export default { */ scheduleLayoutAnimation: nullFn, /** - * Return the metrics of the soft-keyboard if visible. Currently now working on web. + * Return the metrics of the soft-keyboard if visible. Currently not working on web. */ metrics: nullFn, }; diff --git a/src/libs/NativeWebKeyboard/index.native.js b/src/libs/NativeWebKeyboard/index.native.js index 7c0431a971ac..404bd58075d4 100644 --- a/src/libs/NativeWebKeyboard/index.native.js +++ b/src/libs/NativeWebKeyboard/index.native.js @@ -1,4 +1,3 @@ import {Keyboard} from 'react-native'; -// just reexport native Keyboard module export default Keyboard; diff --git a/src/pages/home/ReportScreen.js b/src/pages/home/ReportScreen.js index f6ef2cfac0e3..c59c00cdfbe5 100644 --- a/src/pages/home/ReportScreen.js +++ b/src/pages/home/ReportScreen.js @@ -13,9 +13,9 @@ import OfflineWithFeedback from '@components/OfflineWithFeedback'; import ReportActionsSkeletonView from '@components/ReportActionsSkeletonView'; import ScreenWrapper from '@components/ScreenWrapper'; import TaskHeaderActionButton from '@components/TaskHeaderActionButton'; -import withHideKeyboardOnViewportScroll from '@components/withBlockViewportScroll'; import withCurrentReportID, {withCurrentReportIDDefaultProps, withCurrentReportIDPropTypes} from '@components/withCurrentReportID'; import withViewportOffsetTop from '@components/withViewportOffsetTop'; +import useBlockViewportScroll from '@hooks/useBlockViewportScroll'; import useLocalize from '@hooks/useLocalize'; import usePrevious from '@hooks/usePrevious'; import useWindowDimensions from '@hooks/useWindowDimensions'; @@ -150,6 +150,7 @@ function ReportScreen({ }) { const {translate} = useLocalize(); const {isSmallScreenWidth} = useWindowDimensions(); + useBlockViewportScroll(); const firstRenderRef = useRef(true); const flatListRef = useRef(); @@ -460,7 +461,6 @@ ReportScreen.defaultProps = defaultProps; ReportScreen.displayName = 'ReportScreen'; export default compose( - withHideKeyboardOnViewportScroll, withViewportOffsetTop, withCurrentReportID, withOnyx( From 29d12dd90cb2100d53f447047d867ff835858d60 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucas=20M=C3=B3rawski?= Date: Wed, 15 Nov 2023 17:07:25 +0100 Subject: [PATCH 16/19] prettier --- src/libs/DomUtils/index.native.ts | 2 +- src/libs/DomUtils/index.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/libs/DomUtils/index.native.ts b/src/libs/DomUtils/index.native.ts index 503c453a6a7b..8af83968e8d1 100644 --- a/src/libs/DomUtils/index.native.ts +++ b/src/libs/DomUtils/index.native.ts @@ -15,7 +15,7 @@ const getActiveElement: GetActiveElement = () => null; */ const isActiveTextSelection = () => { throw new Error('Not implemented in React Native. Use only for web.'); -} +}; const requestAnimationFrame = (callback: () => void) => { if (!callback) { diff --git a/src/libs/DomUtils/index.ts b/src/libs/DomUtils/index.ts index ec435a2db53b..78c2cb37ccc8 100644 --- a/src/libs/DomUtils/index.ts +++ b/src/libs/DomUtils/index.ts @@ -28,4 +28,4 @@ export default { getActiveElement, isActiveTextSelection, requestAnimationFrame: window.requestAnimationFrame.bind(window), -}; \ No newline at end of file +}; From 7ed03560ae22958a0a0c71bc7a31f2bee3f74911 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucas=20M=C3=B3rawski?= Date: Fri, 17 Nov 2023 12:27:33 +0100 Subject: [PATCH 17/19] added comment to swipeableview style prop --- src/components/SwipeableView/types.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/SwipeableView/types.ts b/src/components/SwipeableView/types.ts index 66f9115faa82..1f2fbcdc752c 100644 --- a/src/components/SwipeableView/types.ts +++ b/src/components/SwipeableView/types.ts @@ -11,7 +11,7 @@ type SwipeableViewProps = { /** Callback to fire when the user swipes up on the child content */ onSwipeUp?: () => void; - /** Style for the wrapper View */ + /** Style for the wrapper View, applied only for the web version. Not used by the native version, as it brakes the layout. */ style?: StyleProp; }; From 2a5389a03d3733bd914ff3a5599b64b73496eb62 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucas=20M=C3=B3rawski?= Date: Fri, 17 Nov 2023 15:26:30 +0100 Subject: [PATCH 18/19] refactor/nativewebkeyboard to TS --- src/CONST.ts | 3 + src/libs/NativeWebKeyboard/index.js | 135 ------------------ .../{index.native.js => index.native.ts} | 0 src/libs/NativeWebKeyboard/index.ts | 94 ++++++++++++ 4 files changed, 97 insertions(+), 135 deletions(-) delete mode 100644 src/libs/NativeWebKeyboard/index.js rename src/libs/NativeWebKeyboard/{index.native.js => index.native.ts} (100%) create mode 100644 src/libs/NativeWebKeyboard/index.ts diff --git a/src/CONST.ts b/src/CONST.ts index 69ca8256cc6f..fbae37e1296e 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -2879,6 +2879,9 @@ const CONST = { * The count of characters we'll allow the user to type after reaching SEARCH_MAX_LENGTH in an input. */ ADDITIONAL_ALLOWED_CHARACTERS: 20, + + /** types that will show a virtual keyboard in a mobile browser */ + INPUT_TYPES_WITH_KEYBOARD: ['text', 'search', 'tel', 'url', 'email', 'password'], } as const; export default CONST; diff --git a/src/libs/NativeWebKeyboard/index.js b/src/libs/NativeWebKeyboard/index.js deleted file mode 100644 index b2ea5381abaf..000000000000 --- a/src/libs/NativeWebKeyboard/index.js +++ /dev/null @@ -1,135 +0,0 @@ -import {Keyboard} from 'react-native'; -import _ from 'underscore'; - -// types that will show a virtual keyboard in a mobile browser -const INPUT_TYPES_WITH_KEYBOARD = ['text', 'search', 'tel', 'url', 'email', 'password']; - -const isInputKeyboardType = (element) => { - if (!!element && ((element.tagName === 'INPUT' && INPUT_TYPES_WITH_KEYBOARD.includes(element.type)) || element.tagName === 'TEXTAREA')) { - return true; - } - return false; -}; - -const isVisible = () => { - const focused = document.activeElement; - return isInputKeyboardType(focused); -}; - -const nullFn = () => null; - -let isKeyboardListenerRunning = false; -let currentVisibleElement = null; -const showListeners = []; -const hideListeners = []; -const SHOW_EVENT_NAME = 'keyboardDidShow'; -const HIDE_EVENT_NAME = 'keyboardDidHide'; -let previousVPHeight = window.visualViewport.height; - -const handleViewportResize = () => { - if (window.visualViewport.height < previousVPHeight) { - // This might mean virtual keyboard showed up - // checking if any input element is in focus - if (isInputKeyboardType(document.activeElement) && document.activeElement !== currentVisibleElement) { - // input el is focused - v keyboard is up - showListeners.forEach((fn) => fn()); - } - } - - if (window.visualViewport.height > previousVPHeight) { - if (!isVisible()) { - hideListeners.forEach((fn) => fn()); - } - } - - previousVPHeight = window.visualViewport.height; - currentVisibleElement = document.activeElement; -}; - -const startKeboardListeningService = () => { - isKeyboardListenerRunning = true; - window.visualViewport.addEventListener('resize', handleViewportResize); -}; - -const addListener = (eventName, callbackFn) => { - if ((eventName !== SHOW_EVENT_NAME && eventName !== HIDE_EVENT_NAME) || !callbackFn) { - throw new Error('Invalid eventName passed to addListener()'); - } - - if (eventName === SHOW_EVENT_NAME) { - showListeners.push(callbackFn); - } - - if (eventName === HIDE_EVENT_NAME) { - hideListeners.push(callbackFn); - } - - if (!isKeyboardListenerRunning) { - startKeboardListeningService(); - } - - return () => { - if (eventName === SHOW_EVENT_NAME) { - _.filter(showListeners, (fn) => fn !== callbackFn); - } - - if (eventName === HIDE_EVENT_NAME) { - _.filter(hideListeners, (fn) => fn !== callbackFn); - } - - if (isKeyboardListenerRunning && !showListeners.length && !hideListeners.length) { - window.visualViewport.removeEventListener('resize', handleViewportResize); - isKeyboardListenerRunning = false; - } - }; -}; - -export default { - /** - * Whether the keyboard is last known to be visible. - */ - isVisible, - /** - * Dismisses the active keyboard and removes focus. - */ - dismiss: Keyboard.dismiss, - /** - * The `addListener` function connects a JavaScript function to an identified native - * keyboard notification event. - * - * This function then returns the reference to the listener. - * - * {string} eventName The `nativeEvent` is the string that identifies the event you're listening for. This - * can be any of the following: - * - * - `keyboardWillShow` - * - `keyboardDidShow` - * - `keyboardWillHide` - * - `keyboardDidHide` - * - `keyboardWillChangeFrame` - * - `keyboardDidChangeFrame` - * - * Note that if you set `android:windowSoftInputMode` to `adjustResize` or `adjustNothing`, - * only `keyboardDidShow` and `keyboardDidHide` events will be available on Android. - * `keyboardWillShow` as well as `keyboardWillHide` are generally not available on Android - * since there is no native corresponding event. - * - * On Web only two events are available: - * - * - `keyboardDidShow` - * - `keyboardDidHide` - * - * {function} callback function to be called when the event fires. - */ - addListener, - /** - * Useful for syncing TextInput (or other keyboard accessory view) size of - * position changes with keyboard movements. - * Not working on web. - */ - scheduleLayoutAnimation: nullFn, - /** - * Return the metrics of the soft-keyboard if visible. Currently not working on web. - */ - metrics: nullFn, -}; diff --git a/src/libs/NativeWebKeyboard/index.native.js b/src/libs/NativeWebKeyboard/index.native.ts similarity index 100% rename from src/libs/NativeWebKeyboard/index.native.js rename to src/libs/NativeWebKeyboard/index.native.ts diff --git a/src/libs/NativeWebKeyboard/index.ts b/src/libs/NativeWebKeyboard/index.ts new file mode 100644 index 000000000000..a69894d218a1 --- /dev/null +++ b/src/libs/NativeWebKeyboard/index.ts @@ -0,0 +1,94 @@ +import {Keyboard} from 'react-native'; +import CONST from '@src/CONST'; + +type InputType = (typeof CONST.INPUT_TYPES_WITH_KEYBOARD)[number]; +type TCallbackFn = () => void; + +const isInputKeyboardType = (element: Element | null): boolean => { + if (element && ((element.tagName === 'INPUT' && CONST.INPUT_TYPES_WITH_KEYBOARD.includes((element as HTMLInputElement).type as InputType)) || element.tagName === 'TEXTAREA')) { + return true; + } + return false; +}; + +const isVisible = (): boolean => { + const focused = document.activeElement; + return isInputKeyboardType(focused); +}; + +const nullFn: () => null = () => null; + +let isKeyboardListenerRunning = false; +let currentVisibleElement: Element | null = null; +const showListeners: TCallbackFn[] = []; +const hideListeners: TCallbackFn[] = []; +const visualViewport = window.visualViewport ?? { + height: window.innerHeight, + width: window.innerWidth, + addEventListener: window.addEventListener.bind(window), + removeEventListener: window.removeEventListener.bind(window), +}; +let previousVPHeight = visualViewport.height; + +const handleViewportResize = (): void => { + if (visualViewport.height < previousVPHeight) { + if (isInputKeyboardType(document.activeElement) && document.activeElement !== currentVisibleElement) { + showListeners.forEach((fn) => fn()); + } + } + + if (visualViewport.height > previousVPHeight) { + if (!isVisible()) { + hideListeners.forEach((fn) => fn()); + } + } + + previousVPHeight = visualViewport.height; + currentVisibleElement = document.activeElement; +}; + +const startKeboardListeningService = (): void => { + isKeyboardListenerRunning = true; + visualViewport.addEventListener('resize', handleViewportResize); +}; + +const addListener = (eventName: 'keyboardDidShow' | 'keyboardDidHide', callbackFn: TCallbackFn): (() => void) => { + if ((eventName !== 'keyboardDidShow' && eventName !== 'keyboardDidHide') || !callbackFn) { + throw new Error('Invalid eventName passed to addListener()'); + } + + if (eventName === 'keyboardDidShow') { + showListeners.push(callbackFn); + } + + if (eventName === 'keyboardDidHide') { + hideListeners.push(callbackFn); + } + + if (!isKeyboardListenerRunning) { + startKeboardListeningService(); + } + + return () => { + if (eventName === 'keyboardDidShow') { + showListeners.filter((fn) => fn !== callbackFn); + } + + if (eventName === 'keyboardDidHide') { + hideListeners.filter((fn) => fn !== callbackFn); + } + + if (isKeyboardListenerRunning && !showListeners.length && !hideListeners.length) { + visualViewport.removeEventListener('resize', handleViewportResize); + isKeyboardListenerRunning = false; + } + }; +}; + +export default { + isVisible, + dismiss: Keyboard.dismiss, + addListener, + scheduleLayoutAnimation: nullFn, + metrics: nullFn, +}; From b7043422635b915c8172da633ec4cb4092ff9879 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucas=20M=C3=B3rawski?= Date: Fri, 17 Nov 2023 15:55:37 +0100 Subject: [PATCH 19/19] added comments to exported functions on NativeWebKeyboard --- src/libs/NativeWebKeyboard/index.ts | 42 +++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/src/libs/NativeWebKeyboard/index.ts b/src/libs/NativeWebKeyboard/index.ts index a69894d218a1..45223d4d5b42 100644 --- a/src/libs/NativeWebKeyboard/index.ts +++ b/src/libs/NativeWebKeyboard/index.ts @@ -86,9 +86,51 @@ const addListener = (eventName: 'keyboardDidShow' | 'keyboardDidHide', callbackF }; export default { + /** + * Whether the keyboard is last known to be visible. + */ isVisible, + /** + * Dismisses the active keyboard and removes focus. + */ dismiss: Keyboard.dismiss, + /** + * The `addListener` function connects a JavaScript function to an identified native + * keyboard notification event. + * + * This function then returns the reference to the listener. + * + * {string} eventName The `nativeEvent` is the string that identifies the event you're listening for. This + * can be any of the following: + * + * - `keyboardWillShow` + * - `keyboardDidShow` + * - `keyboardWillHide` + * - `keyboardDidHide` + * - `keyboardWillChangeFrame` + * - `keyboardDidChangeFrame` + * + * Note that if you set `android:windowSoftInputMode` to `adjustResize` or `adjustNothing`, + * only `keyboardDidShow` and `keyboardDidHide` events will be available on Android. + * `keyboardWillShow` as well as `keyboardWillHide` are generally not available on Android + * since there is no native corresponding event. + * + * On Web only two events are available: + * + * - `keyboardDidShow` + * - `keyboardDidHide` + * + * {function} callback function to be called when the event fires. + */ addListener, + /** + * Useful for syncing TextInput (or other keyboard accessory view) size of + * position changes with keyboard movements. + * Not working on web. + */ scheduleLayoutAnimation: nullFn, + /** + * Return the metrics of the soft-keyboard if visible. Currently not working on web. + */ metrics: nullFn, };